<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>yesung</title>
        <link>https://velog.io/</link>
        <description>Frontend Engineer</description>
        <lastBuildDate>Tue, 19 Aug 2025 12:20:48 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>yesung</title>
            <url>https://velog.velcdn.com/images/hisung-ah/profile/ae818375-edbc-469b-b4aa-29c7a125ab8d/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. yesung. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/hisung-ah" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[프론트엔드 자동 배포 파이프라인 구축기 (React · Docker · Nginx · GitHub Actions · AWS EC2)]]></title>
            <link>https://velog.io/@hisung-ah/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%9E%90%EB%8F%99-%EB%B0%B0%ED%8F%AC-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B5%AC%EC%B6%95%EA%B8%B0-React-Docker-Nginx-GitHub-Actions-AWS-EC2</link>
            <guid>https://velog.io/@hisung-ah/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%9E%90%EB%8F%99-%EB%B0%B0%ED%8F%AC-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B5%AC%EC%B6%95%EA%B8%B0-React-Docker-Nginx-GitHub-Actions-AWS-EC2</guid>
            <pubDate>Tue, 19 Aug 2025 12:20:48 GMT</pubDate>
            <description><![CDATA[<p>프론트엔드 개발을 하면서 가장 번거로운 작업 중 하나가 <strong>빌드와 배포</strong>이죠
수동으로 서버에 접속해 코드를 옮기고 컨테이너를 띄우는 과정은 시간이 많이 들고 사람마다 절차가 달라지면 안정성도 떨어져요.</p>
<p>그러기 때문에 <strong>React 프로젝트를 AWS EC2 서버에 Docker와 Nginx, GitHub Actions를 이용해 CI/CD 자동 배포 파이프라인</strong>으로 구축한 과정을 기록합니다.</p>
<hr>
<h2 id="⚙️-사용한-스펙">⚙️ 사용한 스펙</h2>
<ul>
<li><strong>Frontend</strong>: React (Vite)</li>
<li><strong>CI/CD</strong>: GitHub Actions</li>
<li><strong>Container</strong>: Docker, Docker Buildx</li>
<li><strong>Registry</strong>: GitHub Container Registry (GHCR)</li>
<li><strong>Infra</strong>: AWS EC2 (Ubuntu 22.04)</li>
<li><strong>Reverse Proxy</strong>: Nginx</li>
<li><strong>배포 포트</strong>: 컨테이너 내부 80 → 호스트 3000 → Nginx(80) 프록시</li>
</ul>
<hr>
<h2 id="📦-전체-아키텍처">📦 전체 아키텍처</h2>
<ol>
<li><strong>GitHub Actions</strong>  <ul>
<li>main 브랜치 push → 빌드 &amp; 도커 이미지 생성 → GHCR 푸시 → EC2 배포 스크립트 실행</li>
</ul>
</li>
<li><strong>Docker</strong>  <ul>
<li>프론트 빌드 결과(<code>dist/</code>)를 Nginx 컨테이너 이미지로 패키징</li>
</ul>
</li>
<li><strong>EC2</strong>  <ul>
<li>CI/CD로 전달된 이미지를 pull → 컨테이너 run</li>
</ul>
</li>
<li><strong>Nginx</strong>  <ul>
<li>80 포트에서 외부 요청 수신 → 내부 3000 포트 컨테이너로 프록시 전달</li>
</ul>
</li>
</ol>
<hr>
<h2 id="☁️-aws-ec2-인스턴스-생성하기">☁️ AWS EC2 인스턴스 생성하기</h2>
<h3 id="1-aws-콘솔-접속-후-ec2-선택">1. AWS 콘솔 접속 후 EC2 선택</h3>
<ul>
<li><a href="https://aws.amazon.com/ko/ec2/">https://aws.amazon.com/ko/ec2/</a><h3 id="2-인스턴스-시작-클릭">2. 인스턴스 시작 클릭</h3>
<h3 id="3-기본설정">3. 기본설정</h3>
</li>
<li>AMI(운영체제): ubuntu Server 22.04 LTS</li>
<li>인스턴스 유형 <code>t2.small</code></li>
<li>스토리지: 기본 8GB SSD</li>
</ul>
<hr>
<h2 id="🔑-ec2-ssh-key-발급-및-접속-방법">🔑 EC2 SSH Key 발급 및 접속 방법</h2>
<p>AWS EC2에 접속하기 위해서는 <strong>SSH Key Pair</strong>를 생성하고 <code>.pem</code> 파일을 다운로드해야 해요.
왜냐하면 이 키는 EC2 서버에 원격으로 접속하거나 GitHub Actions에서 배포할 때 사용되기 때문이죠.</p>
<h3 id="1-key-pair-생성">1. Key Pair 생성</h3>
<ol>
<li>AWS Management Console → <strong>EC2 → Key Pairs</strong> 이동</li>
<li><strong>Create key pair</strong> 버튼 클릭</li>
<li>Key name 입력</li>
<li>Key pair type: <strong>RSA</strong></li>
<li>Private key file format: <strong>.pem</strong></li>
<li><strong>Create key pair</strong> 클릭 → 로컬 PC로 <code>.pem</code> 파일 자동 다운로드</li>
</ol>
<blockquote>
<p>⚠️ <code>.pem</code> 파일은 <strong>한 번만 다운로드 가능</strong>하므로 잘 보관해야 합니다...</p>
</blockquote>
<hr>
<h3 id="2-파일-권한-설정">2. 파일 권한 설정</h3>
<p><code>.pem</code> 파일은 다른 사용자에게 공유되면 안 되기 때문에 권한을 제한해야 해요.</p>
<pre><code class="language-bash">chmod 400 my-ec2-key.pem</code></pre>
<h3 id="3-ssh로-ec2-접속">3. SSH로 EC2 접속</h3>
<pre><code class="language-bash">ssh -i &lt;발급받은 key 이름.pem&gt; ubuntu@&lt;EC2_IP&gt;</code></pre>
<h3 id="4-github-actions-등록">4. GitHub Actions 등록</h3>
<p><strong>한 번 등록하면 이후에는 수정이 가능하지만 이전에 등록했던 값을 볼 수는 없습니다.</strong></p>
<ol>
<li>GitHub → Settings → Secrets and variables → Actions</li>
<li>Secret 이름: EC2_SSH_KEY</li>
<li><code>.pem</code> 파일 내용 전체 붙여넣기</li>
</ol>
<hr>
<h2 id="🛠️-설정-과정">🛠️ 설정 과정</h2>
<h3 id="1-dockerfile-작성">1. Dockerfile 작성</h3>
<pre><code class="language-dockerfile">FROM nginx:1.27-alpine

# Nginx 설정 복사
COPY nginx/default.conf /etc/nginx/conf.d/default.conf

# 빌드 산출물 복사
COPY dist/ /usr/share/nginx/html

HEALTHCHECK CMD wget -qO- http://127.0.0.1/ || exit 1

EXPOSE 80
CMD [&quot;nginx&quot;, &quot;-g&quot;, &quot;daemon off;&quot;]</code></pre>
<h3 id="2-nginx-리버스-프록시">2. Nginx 리버스 프록시</h3>
<p>프론트엔드 컨테이너는 3000 포트에서 동작하지만 http://&lt;탄력적IP&gt; (기본 80 포트)로 접속되도록 Nginx 리버스 프록시 설정</p>
<ol>
<li>EC2 접속<pre><code class="language-bash">ssh -i &lt;발급받은 key 이름.pem&gt; ubuntu@&lt;EC2_IP&gt;</code></pre>
</li>
<li>Nginx 설정 파일 만들기<pre><code class="language-bash">sudo nano /etc/nginx/conf.d/app.conf</code></pre>
</li>
<li>nano 에디터에 아래 코드 넣기<pre><code class="language-nginx">server {
listen 80 default_server;
server_name _;
location / {
 proxy_pass http://127.0.0.1:3000;
 proxy_set_header Host $host;
 proxy_set_header X-Real-IP $remote_addr;
 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 proxy_set_header X-Forwarded-Proto $scheme;
}
}</code></pre>
</li>
<li>설정 저장 후 나가기</li>
</ol>
<ul>
<li><code>Ctrl + O</code> → 저장</li>
<li><code>Enter</code> → 확인</li>
<li><code>Ctrl + X</code> → 종료</li>
</ul>
<ol start="5">
<li>Nginx 설정 문법 확인 후 reload<pre><code class="language-bash">sudo nginx -t &amp;&amp; sudo systemctl reload nginx</code></pre>
</li>
<li>사이트 접속<blockquote>
<p>이렇게 하면 Nginx가 80 → 3000 포트로 요청을 넘겨주니까 사용자는 단순히 <code>http://&lt;탄력적IP&gt;</code> 만 입력하면 접근 가능</p>
</blockquote>
</li>
</ol>
<h3 id="3-github-actions-workflow-작성">3. GitHub Actions Workflow 작성</h3>
<p><strong>핵심은 두 개의 Job이다</strong></p>
<ul>
<li><p><code>build-and-push</code>: 빌드 후 GHCR에 이미지 푸시</p>
<pre><code class="language-yaml">jobs:
build-and-push:
  runs-on: ubuntu-latest
  steps:
    - name: Checkout
      uses: actions/checkout@v4.2.0

    - name: Build (Vite)
      run: yarn build

    - name: Build &amp; Push Docker Image
      uses: docker/build-push-action@v6
      with:
        context: .
        file: ./Dockerfile
        push: true
        tags: |
          ghcr.io/&lt;유저명&gt;/&lt;프로젝트명&gt;:latest
          ghcr.io/&lt;유저명&gt;/&lt;프로젝트명&gt;:${{ github.sha }}</code></pre>
</li>
<li><p><code>deploy</code>: EC2에 접속해 새 이미지 실행</p>
<pre><code class="language-yaml">deploy:
  needs: build-and-push
  runs-on: ubuntu-latest
  steps:
    - name: Deploy on EC2
      uses: appleboy/ssh-action@v1.2.0
      env:
        IMAGE_NAME: ${{ secrets.IMAGE_NAME }}
        HOST_PORT: ${{ secrets.HOST_PORT }}
      with:
        host: ${{ secrets.EC2_HOST }}
        username: ${{ secrets.EC2_USER }}
        key: ${{ secrets.EC2_SSH_KEY }}
        script: |
          APP_NAME=&quot;프로젝트명&quot;
          docker pull &quot;${IMAGE_NAME}:latest&quot;
          docker rm -f $APP_NAME 2&gt;/dev/null || true
          docker run -d --name $APP_NAME --restart unless-stopped \
            -p ${HOST_PORT}:80 &quot;${IMAGE_NAME}:latest&quot;</code></pre>
<blockquote>
<p>배포 script는 별도로 분리해서 EC2 서버에서 분리한 파일 경로를 찾고 script를 실행시키면 돼요.</p>
</blockquote>
</li>
<li><p>Secrets 관리</p>
<pre><code>EC2_HOST      = &lt;EC2 탄력적 IP&gt;
EC2_USER      = ubuntu
EC2_SSH_KEY   = &lt;.pem 개인키 내용&gt;
IMAGE_NAME    = ghcr.io/&lt;유저명&gt;/&lt;프로젝트명&gt;
HOST_PORT     = 사용할 포트 번호</code></pre></li>
</ul>
<h2 id="🧩-배포-시-유의해야-할-점">🧩 배포 시 유의해야 할 점</h2>
<h3 id="port-is-already-allocated">port is already allocated</h3>
<p>호스트의 해당 포트(80/443/3000 등)를 <strong>이미 어떤 프로세스가 점유 중</strong>이라 Docker가 -p로 바인딩을 못 했다는 의미예요.
쉽게 말해서 같은 포트를 다른 컨테이너에서 사용하면 컨테이너 이름과 상관 없이 <strong>포트가 동일하므로 충돌이 발생해요.</strong></p>
<p>우선 컨테이너가 포트를 잡고 있는지 목록을 확인하면 좋아요.</p>
<pre><code class="language-bash"># EC2 서버 접속 후 확인
docker ps --format &#39;table {{.ID}}\t{{.Image}}\t{{.Names}}\t{{.Ports}}&#39;</code></pre>
<p><strong>케이스는 보통 4가지인데요.</strong></p>
<ol>
<li>nginx가 80/443을 잡고 있고 나의 앱 컨테이너도 80/443을 직접 열려 해서 충돌</li>
<li>같은 포트를 다른 컨테이너가 이미 사용 중</li>
<li>충돌 후 남은 docker-proxy가 점유</li>
<li>docker compose 프로젝트 중복 실행</li>
</ol>
<blockquote>
<p>보통 이런 케이스들은 컨테이너 포트 확인 후 충돌없는 포트로 변경하는 게 확실하고 빠르게 해결할 수 있어요.
또는 컨테이너를 정리하는 것이죠!
<code>docker ps --filter &quot;publish=8080&quot; -q | xargs -r docker rm -f</code></p>
</blockquote>
<h3 id="permission-denied-publickey">Permission denied (publickey)</h3>
<p>EC2_SSH_KEY 형식 오류로 GitHub Actions Secrets에 .pem 전체 내용(BEGIN/END 포함)을 그대로 붙여넣고 재배포하면 돼요.</p>
<h3 id="배포는-성공했는데-외부에서-접속이-불가해요">배포는 성공했는데 외부에서 접속이 불가해요</h3>
<p>컨테이너를 <code>127.0.0.1:&lt;port&gt;:80</code> 로 띄워 내부 전용 바인딩 했거나 AWS 보안그룹/방화벽에서 포트를 미허용 했을 때 발생해요.</p>
<p>이럴 경우에는 직접 노출하거나 프록시 운영으로 해결해야 해요.</p>
<ul>
<li><strong>직접 노출</strong>: <code>-p ${HOST_PORT}:80</code> + 보안그룹에서 그 포트 허용.</li>
<li><strong>프록시 운영(권장)</strong>: 컨테이너는 내부(127.0.0.1:3000), 호스트 Nginx가 80/443 → 127.0.0.1:3000 프록시.</li>
</ul>
<h3 id="unauthorized">unauthorized</h3>
<p>구분    Repo Visibility    Package Visibility    Pull 가능 여부
Public    Public    ✅ Public    ✅ 로그인 불필요
Private    Public    ✅ Public    ✅ 로그인 불필요
Public    Private    ❌ Private    ❌ 로그인 필요
Private    Private    ❌ Private    ❌ 로그인 필요</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React에서 WebSocket 통신으로 다양한 데이터 요청해보기]]></title>
            <link>https://velog.io/@hisung-ah/React%EC%97%90%EC%84%9C-WebSocket-%ED%86%B5%EC%8B%A0%EC%9C%BC%EB%A1%9C-%EB%8B%A4%EC%96%91%ED%95%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%9A%94%EC%B2%AD%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@hisung-ah/React%EC%97%90%EC%84%9C-WebSocket-%ED%86%B5%EC%8B%A0%EC%9C%BC%EB%A1%9C-%EB%8B%A4%EC%96%91%ED%95%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%9A%94%EC%B2%AD%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Sun, 16 Feb 2025 06:48:32 GMT</pubDate>
            <description><![CDATA[<p>회사에서 맡고 있던 프로젝트에서 WebSocket으로 통신해야 하는 Part를 할당받았다. 처음 접하는 작업이기에 다소 미숙한 점이 많았지만 나만의 방식대로 정리를 해보고자 한다.</p>
<p>WebSocket은 주로 실시간으로 실시간 양방향 통신이 필요한 기능에서 사용되는데 그 중에서도 나는 무수히 많은 데이터를 주고받는 주식 시장을 대상으로 하는 기능을 구현해야 했다.</p>
<h2 id="전역-상태-관리">전역 상태 관리</h2>
<blockquote>
<p>지속적인 통신이 필요했기에 전역으로 WebSocket 상태를 관리했고 현 시점에서 가장 간편하고 편리한 Zustand를 사용했다.</p>
</blockquote>
<p>Zustand로 관리하는 상태와 함수 아래와 같다.</p>
<ul>
<li><p>State</p>
<ul>
<li><code>worker</code>: WebSocket 인스턴스</li>
<li><code>isConnectd</code>: 연결 상태 관리</li>
<li><code>messageQueue</code>: 미전송 메세지 큐</li>
</ul>
</li>
<li><p>Funtions</p>
<ul>
<li><code>connect</code>: 연결 관리</li>
<li><code>disconnect</code>: 연결 해제</li>
<li><code>sendMessage</code>: 서버로부터 요청받을 데이터 메세지 전송 (클라이언트 --&gt; 서버)</li>
<li><code>setOnMessage</code>: 응답받은 데이터 처리 (서버 --&gt; 클라이언트)</li>
</ul>
</li>
</ul>
<h3 id="websocket-store">WebSocket Store</h3>
<pre><code class="language-javascript">// useWebSocketStore.ts
export const useWebSocketStore = create&lt;WebSocketStore&gt;((set, get) =&gt; ({
    worker: null,
    isConnected: false,
    messageQueue: [],

    connect: (url: string) =&gt; {
        const { worker, messageQueue } = get();
        if (worker) return;

        const newWorker = new WebSocketWorker();

        newWorker.onmessage = (event) =&gt; {
            const { type, message, error } = event.data;
            const onMessageCallback = get().onMessage;

            switch (type) {
                case &quot;CONNECTED&quot;:
                    console.log(&quot;WebSocket connected&quot;);
                    set({ isConnected: true });
                    messageQueue.forEach((msg) =&gt; {
                        newWorker.postMessage({
                            type: &quot;SEND&quot;,
                            payload: { message: JSON.stringify(msg) },
                        });
                    });
                    set({ messageQueue: [] });
                    break;
                case &quot;DISCONNECTED&quot;:
                    console.log(&quot;WebSocket disconnected&quot;);
                    set({ isConnected: false });
                    break;
                case &quot;MESSAGE&quot;:
                    if (onMessageCallback) {
                        onMessageCallback(message);
                    }
                    break;
                case &quot;ERROR&quot;:
                    console.error(&quot;WebSocket error:&quot;, error);
                    break;
                default:
                    console.error(&quot;Unknown message type:&quot;, type);
            }
        };

        newWorker.postMessage({ type: &quot;CONNECT&quot;, payload: { url } });
        set({ worker: newWorker });
    },

    disconnect: () =&gt; {
        const { worker } = get();

        if (worker) {
            worker.postMessage({ type: &quot;DISCONNECT&quot; });
            worker.terminate();
            set({ worker: null, isConnected: false });
        }
    },

    sendMessage: (message: any) =&gt; {
        const { worker, isConnected } = get();

        if (!isConnected) {
            set((state) =&gt; ({
                messageQueue: [...state.messageQueue, message],
            }));
            return;
        }

        if (worker) {
            worker.postMessage({
                type: &quot;SEND&quot;,
                payload: { message: JSON.stringify(message) },
            });
        }
    },

    setOnMessage: (callback) =&gt; {
        set({ onMessage: callback });
    },
}));</code></pre>
<h2 id="webworker">WebWorker</h2>
<p>재연결 해주는 reconnect 함수를 왜 Store에서 정의하지 않았냐면 WebWorker에서 동작하도록 구현했기 때문이다.</p>
<p><em>WebWorker를 import 할 때 경로 뒤에 <code>?worker</code>를 붙여줘야 제대로 기능이 활성화된다.</em></p>
<blockquote>
<p>WebWorker란?
메인 스레드와 분리된 별도의 스레드에서 WebSocket 통신 처리하기 위한 방법이다.
쉽게 설명하면 다른 브라우저 탭을 보고 있어도 백그라운드에서 동작하고 있다는 뜻이다.</p>
</blockquote>
<p>WebSocket을 연결하고 해제하고 메세지를 요청하는 로직은 대부분 WebWorker에서 구현했다.
Store에서는 onmessage를 통해서 type만 전달하고 있다.</p>
<pre><code class="language-javascript">// websocket-worker.js
const connect = (url) =&gt; {
    if (
        socket &amp;&amp;
        (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)
    ) {
        return;
    }

    socket = new WebSocket(url);

    socket.onopen = () =&gt; {
        isReconnecting = false;
        self.postMessage({ type: &quot;CONNECTED&quot; });
    };

    socket.onclose = () =&gt; {
        self.postMessage({ type: &quot;DISCONNECTED&quot; });
        if (navigator.onLine) return;
        reconnect(url);
    };

    socket.onerror = (error) =&gt; {
        self.postMessage({ type: &quot;ERROR&quot;, error });
        reconnect(url);
    };

    socket.onmessage = (event) =&gt; {
        const data = JSON.parse(event.data);
         self.postMessage({ type: &quot;MESSAGE&quot;, message: data });
    };
};

self.onmessage = function (event) {
    const { type, payload } = event.data;

    switch (type) {
        case &quot;CONNECT&quot;:
            connect(payload.url);
            break;
        case &quot;DISCONNECT&quot;:
            disconnect();
            break;
        case &quot;SEND&quot;:
            sendMessage(payload.message);
            break;
        default:
            console.error(&quot;Unknown message type:&quot;, type);
    }
};</code></pre>
<p>보다시피 굉장히 간단한 코드이다. 연결 여부를 확인하는 방어 코드를 넣고 안 되어 있으면 연결해주는 게 끝이다.</p>
<p><code>reconnect</code>의 경우 setTimeout으로 1초의 간격을 두고 connect 함수를 재호출 하도록 구현했지만 너무 과도한 재연결이 이뤄지면 과부하 되기 때문에 지수 백오프를 적용할 예정이다.</p>
<blockquote>
<p><code>self</code>는 Web Worker 내부에서 Worker의 전역 스코프를 가리키는 객체이고 일반 브라우저 환경의 window 객체와 유사한 역할을한다.</p>
</blockquote>
<h3 id="ping-pong-메커니즘">Ping-Pong 메커니즘</h3>
<p>가장 중요한 서버 연결 상태를 확인하기 위해 5초 마다 ping을 요청하면 바로 서버로부터 pong 응답을 받는다.
pong 응답받을 때는 pingCount 초기화</p>
<p>만약 ping 요청을 3회 이상했는데 서버로부터 pong 응답이 오지 않을 시, 연결을 바로 해제하고 1초 마다 재연결을 시도 하도록 했다.</p>
<p>위 connect 함수에 적용해보면?</p>
<pre><code class="language-javascript">const startPing = () =&gt; {
    if (pingInterval) return;

    pingInterval = setInterval(() =&gt; {
        if (socket?.readyState === WebSocket.OPEN) {
            socket.send(JSON.stringify({ cmd: &quot;ping&quot; }));
            pingCount++;

            if (pingCount &gt;= 3) disconnect();
        } else if (socket?.readyState === WebSocket.CLOSING) {
            disconnect();
        }
    }, 5000);
};

const connet = () =&gt; {
  socket.onopen = () =&gt; {
    isReconnecting = false;
    self.postMessage({ type: &quot;CONNECTED&quot; });
    startPing();
  };
}</code></pre>
<h2 id="websocket-custom-hook">WebSocket Custom Hook</h2>
<p>먼저 모든 페이지에서 통신이 이루어져야 해서 custom hook을 이용하여 useEffect 형태로 구현했고 Route 별로 가장 최상위 컴포넌트에서 호출을 진행했다. 왜냐하면 각 페이지별로 요청해야 하는 데이터가 달랐기 때문이다.</p>
<pre><code class="language-javascript">// useWebSocket.ts
const useWebSocketWorker = (
  options: WebSocketParams,
  callback?: (socketData: SocketDataType) =&gt; void,
      ) =&gt; {
        const { connect, disconnect, sendMessage, setOnMessage, isConnected } = useWebSocketStore();
        const wsUrl = import.meta.env.VITE_WEBSOCKET_URL;
        const sendTimer = useRef&lt;number | null&gt;(null);
        const prevParams = useRef&lt;string | null&gt;(null);

        const defaultCodes = [...];
        const defaultTypes = [...];

        const extendsOptions = useMemo(() =&gt; {
        return {
            ...options,
            iscdList: [...(options.iscdList ?? []), ...defaultCodes],
            typeList: [...(options.typeList ?? []), ...defaultTypes],
        };
    }, [options]);

        const sendSocketMessage = (oneMoreSend = false) =&gt; {
        const currentParams = JSON.stringify(extendsOptions.iscdList);

        if (!oneMoreSend &amp;&amp; prevParams.current === currentParams) return;
        prevParams.current = currentParams;

        if (sendTimer.current) clearTimeout(sendTimer.current);

        sendTimer.current = setTimeout(() =&gt; {
            sendMessage(extendsOptions);
            sendTimer.current = null;
        }, 400);
    };

    useEffect(() =&gt; {
        if (hasActualStockData) {
            connect(wsUrl);
        }
    }, [hasActualStockData]);

        useEffect(() =&gt; {
        if (isEmpty(extendsOptions.iscdList) || !isConnected) return;
        sendSocketMessage();
    }, [isConnected, extendsOptions.iscdList]);

    useEffect(() =&gt; {
        if (isConnected &amp;&amp; callback) setOnMessage(callback);

        if (isConnected &amp;&amp; !isEmpty(extendsOptions.iscdList)) {
            sendSocketMessage(true);
        }

        const handleVisibilityChange = () =&gt; {
            if (document.visibilityState === &quot;visible&quot;) {
                if (!isConnected) {
                    disconnect();
                    connect(wsUrl);
                    sendMessage(extendsOptions);
                }
            }
        };

        document.addEventListener(&quot;visibilitychange&quot;, handleVisibilityChange);

        return () =&gt; {
            document.removeEventListener(&quot;visibilitychange&quot;, handleVisibilityChange);
        };
    }, [isConnected]);
}</code></pre>
<p>그리고 데이터 요청과 요청했던 이전 값을 추적할 sendTimer와 prevParams가 필요했다.</p>
<p>왜냐하면 주식 종목을 4000개 이상을 리스트에서 보여줘야 했고 스크롤을 할 때 마다 서버에 요청을 하기에는 서버 과부화 문제로 인해 성능이 낮아질 우려가 있기 때문이었다. (그래서 sendTimer로 일정 간격을 둠)</p>
<p>그러기에 리스트에는 Virtual Scroll을 이미 적용한 상태여서 보여지는 영역에 종목 코드만 요청하도록 설계해 놓았고 이전에 보냈던 요청 파라미터와 현재 보낸 파라미터와 비교하는 방어 코드를 구현했다.</p>
<p>그리고 가장 중요한 가시성을 잃게되면 브라우저에서 메모리 최적화를 하기 위해 백그라운드 동작을 줄이게 되는데 이 때 소켓 연결이 해제되는 현상이 발생되었다. 그래서 visibilitychange 이벤트를 등록해서 다시 화면으로 돌아올 때 연결 상태 여부를 확인 후 재연결 하도록 설계했다. 이러면 유연하게 소켓을 지속적으로 유지할 수 있게된다.</p>
<h2 id="컴포넌트에서-사용해보기">컴포넌트에서 사용해보기</h2>
<pre><code class="language-javascript">useWebSocket({ cmd: &quot;요청 값&quot;, iscdList, typeList: [&quot;코드 값&quot;] }, (socketData) =&gt; {
  if (!socketData) return;
  if (socketData.type === &quot;1&quot;) {
    ...setState(...);
  } else if (socketData.type === &quot;2&quot;) {
    ...setState(...);
  } else {
    ...setState(...);
  }
});</code></pre>
<p>사용방법은 간단하다.
useEffect 형태로 호출하고 콜백으로 응답 값을 전달받아 분기처리 하면 된다.</p>
<h2 id="추후-개선해야-할-점">추후 개선해야 할 점</h2>
<p>첫 번째, 현재는 수 많은 종목들이 실시간으로 체결된 내역(현재가, 등락률, 등락폭)을 같은 컴포넌트 사용해서 화면에 업데이트 하다 보니까 하나의 종목이 체결돼도 다 같이 컴포넌트가 리렌더링 되고 있는데 체결된 종목만 리렌더링이 이루어지도록 최적화 작업이 필요하다.</p>
<p>두 번째, 가장 고려해야 될 부분은 가장 많은 메모리를 차지하고 있는 호가창에서 체결량이 제일 많은 종목을 선택한 후 다른 사이트에 있다가 다시 원래 화면으로 전환하게 되면 일시적으로 멈추는 현상이 발생한다.</p>
<p>그래서 useRerender hooks를 별도로 만들어서 가시성 여부를 판단하여 컴포넌트에 key값으로 리렌더링 되도록 처리를 하였으나 부드럽게 처리되지는 않았다. 이 점도 유의해서 후처리를 진행해야 할 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[자바스크립트는 싱글 스레드인데 어떻게 비동기 작업을 수행할까?]]></title>
            <link>https://velog.io/@hisung-ah/%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%EB%8A%94-%EC%8B%B1%EA%B8%80-%EC%8A%A4%EB%A0%88%EB%93%9C%EC%9D%B8%EB%8D%B0-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%9E%91%EC%97%85%EC%9D%84-%EC%88%98%ED%96%89%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@hisung-ah/%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%EB%8A%94-%EC%8B%B1%EA%B8%80-%EC%8A%A4%EB%A0%88%EB%93%9C%EC%9D%B8%EB%8D%B0-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%9E%91%EC%97%85%EC%9D%84-%EC%88%98%ED%96%89%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Wed, 01 May 2024 07:31:26 GMT</pubDate>
            <description><![CDATA[<p>자바스크립트 언어를 사용하는 개발자라면 기본적인 동작원리를 알고 있어야지 더 효율적으로 프로그래밍을 할 수 있다.</p>
<h1 id="왜-비동기-작업이-필요할까">왜 비동기 작업이 필요할까?</h1>
<p>기본적으로 싱글 스레드 언어이기 때문에 한 번의 하나의 작업만 처리할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/9e98e0f6-a6d7-402d-8f86-2418e5c54595/image.png" alt=""></p>
<p>예를 들면, 여러 집에서 짜장면을 주문했고 배달원은 먼저 주문한 첫 번째 집에 짜장면을 배달하고 주문 고객이 다 먹을 때까지 대기 후, 그릇을 수거할 때까지를 하나의 작업이라고 가정한다면 이 모든 과정이 끝나야지만 다음 집에 배달을 할 수가 있는 상황이 동기적인 작업이다.</p>
<p>그리고 배달을 하다가 신호가 막히거나 (네트워크 지연) 또는 사고 (에러 발생)가 발생하면 그 상황을 해결할 때까지 다음 작업이 막히게 된다.</p>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/2a895e9b-2921-4765-88ef-1a2a297c26d2/image.jpeg" alt=""></p>
<p>이러면 시간적인 측면에서도 오래 걸리고 리소스 활용도가 낮아져서 굉장히 비효율적이다.</p>
<blockquote>
<p>하지만 비동기 작업은 어떨까?</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/70fe2af0-5774-43c2-bd2f-be5fe02269ba/image.png" alt=""></p>
<p>작업 완료 여부와 상관 없이 기다리지 않고 다음 작업을 수행할 수 있다. 앞서 설명한 동기 작업과 달리 첫 번째 집에 짜장면을 배달하고 먹을 때까지 기다리지 않고 바로 다음 집으로 배달을 할 수가 있다. 중간에 그릇을 수거하는 작업은 별도로 분리해서 처리를 해도 된다.</p>
<p>주로 네트워크 요청이나 파일 I/O 작업 등이 있고 자바스크립트에서는 <code>콜백 함수</code> <code>async/awiat</code> 에 의해서 처리 한다.</p>
<p>단, 작업의 순서 처리를 명시적으로 해야 하고 순서가 꼬일 경우에는 에러 확인이 까다로울 우려가 있어서 에러 처리를 명확하게 해주어야 한다.</p>
<p>그래서 비동기 프로그래밍을 하게되면 사용자 경험 (주문 고객 만족도)도 동기적인 작업보다 훨씬 향상되는 장점이 있다.</p>
<h1 id="그러면-비동기-작업은-어떻게-이루어질까">그러면 비동기 작업은 어떻게 이루어질까?</h1>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/071177a6-65c2-4522-aa5f-0c39d23314cb/image.png" alt=""></p>
<p>먼저 프로그램이 실행될 때, 자바스크립트 엔진은 콜 스택을 사용해서 함수 호출을 관리한다.</p>
<blockquote>
<p><strong>자바스크립트 엔진</strong>은 메모리 힙과 콜 스택으로 구성되어 있고 싱글 스레드이기 때문에 하나의 콜 스택만 가지고 있다.
<strong>메모리 힙</strong>은 참조 타입 데이터가 저장되고 변수 할당이 일어나는 공간
<strong>콜 스택</strong>은 가장 마지막에 들어온 함수가 가장 먼저 처리되는 방식 (후입선출)</p>
</blockquote>
<p>만약 콜 스택에 비동기 함수가 들어 왔을 경우에는 <code>Web API</code> 에 의해서 처리되고 완료될 경우 콜백 큐에 추가된다.</p>
<p>그리고 이벤트 루프가 콜 스택이 비었는지 확인하고 비어 있을 때 콜백 큐에 대기 중이었던 함수를 순차적으로(선입선출) 콜 스택으로 이동되어 실행시킨다.</p>
<blockquote>
<p>실제 코드를 통해 살펴보면</p>
</blockquote>
<pre><code class="language-javascript">const first = () =&gt; {
  console.log(&#39;첫 번째 함수&#39;);
};

const second = () =&gt; {
  console.log(&#39;두 번째 함수&#39;);
};

first();

setTimeout(() =&gt; {
  console.log(&#39;세 번째 함수&#39;);
}, 3000);

second();</code></pre>
<ol>
<li>제일 먼저 <code>first()</code> 함수 콜 스택에 추가</li>
<li>그 다음 <code>first()</code> 함수 안에 있는 <code>console.log()</code> 가 콜 스택에 쌓이고</li>
<li>콘솔 창에 <code>첫 번째 함수</code> 출력</li>
<li>이제 <code>first()</code> 함수는 종료돼서 콜 스택에서 제거되고 <code>setTimeout()</code> 이 콜 스택에 추가</li>
<li><code>setTimeout()</code> 은 비동기 함수이기 때문에 <code>Web API</code> 에 의해 처리되고 <code>second()</code> 함수와 그 위에 <code>console.log()</code> 콜 스택에 추가</li>
<li>콘솔 창에 <code>두 번째 함수</code> 출력</li>
<li><code>second()</code> 함수 종료돼서 콜 스택에서 제거되고 이제 들어올 함수가 없으므로 <code>Web API</code> 에 의해서 3초 동안 처리되고 있었던 <code>setTimeout()</code> 에 콜백 함수를 콜백 큐로 이동</li>
<li>이벤트 루프는 콜백 큐에 있는 콜백 함수를 콜 스택으로 이동 보내기 위해 콜 스택이 비어 있는지 확인 후 비어 있다면 콜 스택으로 이동 후 실행</li>
<li><code>console.log()</code> 추가 후 <code>세 번째 함수</code> 출력 후 프로그램 종료</li>
</ol>
<blockquote>
<p><code>setTimeout()</code> 은 <code>second()</code> 함수가 종료되고 나서 처리되는 게 아니라 이미 <code>Web API</code> 로 넘어 갔을 시점부터 처리되고 있다.<br>
※ 기다리고 처리되는 거면 그거는 비동기가 아니라 동기적인 것.</p>
</blockquote>
<h2 id="정리">정리</h2>
<p>자바스크립트는 싱글 스레드 방식으로 동작한다.</p>
<p>하지만 싱글 스레드 방식으로 동작하는 것은 자바스크립트 엔진이지 브라우저가 아니다.</p>
<p>브라우저는 멀티 스레드로 동작하고, 이는 <strong>Web API, 콜백 큐, 이벤트 루프에 의해서 가능하다.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Custom Hook을 이용한 React Query 적용]]></title>
            <link>https://velog.io/@hisung-ah/Custom-Hook%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-React-Query-%EC%A0%81%EC%9A%A9</link>
            <guid>https://velog.io/@hisung-ah/Custom-Hook%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-React-Query-%EC%A0%81%EC%9A%A9</guid>
            <pubDate>Wed, 20 Mar 2024 00:41:06 GMT</pubDate>
            <description><![CDATA[<h2 id="custom-hook을-이용한-react-query-적용">Custom Hook을 이용한 React Query 적용</h2>
<p>코드의 간소화와 모듈화를 함으로써 보여지는 가독성을 극대화하기 위해 Custom Hook에 React Query를 적용시켜봤다.</p>
<p>아울러 현재 v3 를 적용시켰으나, 두 단계를 점프업해서 v5로 마이그레이션을 해봤다.</p>
<p>v3 에서는 useQuery를 <code>useQuery(key, fn, options)</code> 형식으로 불러왔다면
v5에서는 useQuery를 <code>useQuery({ queryKey, queryFn, ...options })</code> 형식으로 <span style="color: #00DFA2"><strong><code>단일 객체</code></strong></span> 를 전달받아 실행한다.</p>
<blockquote>
<p><code>useMutation</code> 도 그렇고 모든 내장 메서드들은 <span style="color: #00DFA2"><strong><code>단일 객체</code></strong></span> 로 받는다고 보면 된다.</p>
</blockquote>
<h3 id="usecomments">useComments</h3>
<blockquote>
<p>📌 파일명 앞에 <code>use</code> 를 붙이는 이유는 에러가 발생하면 React에서 적절한 에러를 나타내기 위함이다.</p>
</blockquote>
<pre><code class="language-jsx">// App.jsx
const queryClient = new QueryClient();

const App = () =&gt; {
  return (
    &lt;QueryClientProvider client={queryClient}&gt;
      &lt;GlobalStyle /&gt;
      &lt;Router /&gt;
    &lt;/QueryClientProvider&gt;
  );
};</code></pre>
<blockquote>
<p>최상위 컴포넌트에 <code>QueryClient</code> 를 주입해주는 것 전과 동일하다.</p>
</blockquote>
<pre><code class="language-jsx">import { useMutation, useQuery, useQueryClient } from &#39;@tanstack/react-query&#39;;
import { addComment, deleteComment, getComments, updateComment } from &#39;api/firebase&#39;;

const QUERY_KEY = &#39;comments&#39;;

export const useComments = () =&gt; {
  const queryClient = useQueryClient();

  // 조회
  const { data: comments, isLoading } = useQuery({
    queryKey: [QUERY_KEY],
    queryFn: getComments
  });
  // 추가
  const addCommentMutation = useMutation({
    mutationFn: addComment,
    onSuccess: () =&gt; {
      queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
    }
  });
  // 수정
  const updateCommentMutation = useMutation({
    mutationFn: updateComment,
    onSuccess: () =&gt; {
      queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
    }
  });
  // 삭제
  const deleteCommentMutation = useMutation({
    mutationFn: deleteComment,
    onSuccess: () =&gt; {
      queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
    }
  });

  return {
    comments,
    isLoading,
    addComment: addCommentMutation.mutate,
    updateComment: updateCommentMutation.mutate,
    deleteComment: deleteCommentMutation.mutate
  };
};</code></pre>
<p>useComments Hook에서 전부 firebase SDK를 끌어다가 firestore와 비동기 통신을 진행 중이고, 위 v5 문법을 토대로 단일 객체로 받아줬다.</p>
<p>실상은 댓글 컴포넌트에 있는 코드만 옮겨서 마이그레이션 살짝한 게 전부이다.</p>
<h3 id="comment-component">Comment Component</h3>
<pre><code class="language-jsx">const { comments, isLoading, addComment, updateComment, deleteComment } = useComments();</code></pre>
<h2 id="💡-근데-왜-v5에서는-단일-객체로-값을-받을까">💡 근데 왜 v5에서는 단일 객체로 값을 받을까?</h2>
<ul>
<li><p>단일 객체로 받으면 구조적으로 일관성을 유지할 수 있다.
예를 들어, 쿼리가 성공해도(통신 성공) 데이터가 없을 경우에는 <code>undefined</code> 대신 빈 객체를 반환해줘서 컴포넌트에서 데이터를 처리하는데 일관성을 유지할 수 있다. (불상사 방지 가능)</p>
</li>
<li><p>데이터를 캐시해서 재사용하고 성능을 향상시켜준다. 그리고 단일 객체로 받는 경우 해당 객체의 식별자를 통해 데이터를 캐싱하고 관리하는데 더 효율적이다.</p>
</li>
</ul>
<ul>
<li><p>데이터 객체가 <code>null</code> 또는 <code>undefined</code> 인 경우가 아닌 에러 객체를 반환하면 에러 처리를 통일되게 처리할 수 있습니다. 쿼리 실패 시에도 일관된 에러 객체를 반환하여 컴포넌트에서 일관된 방식으로 에러 처리를 할 수 있다.</p>
</li>
<li><p>일부 API 또는 라이브러리가 단일 객체를 반환하는 패턴을 사용할 수 있으며, <code>React Query</code> 가 이러한 패턴을 준수하여 호환성을 유지할 수 있다.</p>
</li>
</ul>
<blockquote>
<p>결국, 일관성 있는 데이터 구조를 유지하고, 캐싱이 뛰어나며 통일성 있는 에러처리에 데이터를 반환하는 패턴까지 준수해서 효율성을 최대치로 뽑기위해 단일 객체로 값을 받는다고 생각하면 되겠다.</p>
</blockquote>
<h2 id="💡-데이터-캐싱은-언제하나">💡 데이터 캐싱은 언제하나?</h2>
<p>사실 프로젝트를 하면서 솔직히 너무 궁금했다. 계속 데이터를 확인하려고 <code>console.log</code> 를 여러개 찍었는데 갑자기 콘솔창이 확 올라가는 것이다.</p>
<p>사실 무한루프에 빠졌나라고 잠깐 생각했지만 중간에 멈췄다.</p>
<blockquote>
<p>바로 그게 데이터 갱신이었던 것이다.</p>
</blockquote>
<p>정의 상, 캐싱은 특정 데이터를 복사본을 저장하고 이후에 데이터의 재접근 속도를 높이는 것을 말한다.</p>
<blockquote>
<p>그러면 상황에 맞게 적절하게 데이터 갱신을 해줘야 하는데 언제할까?</p>
</blockquote>
<ol>
<li>브라우저에 포커스가 들어온 경우</li>
<li>새로운 컴포넌트 마운트가 발생한 경우</li>
<li>네트워크 재연결이 발생한 경우</li>
</ol>
<p>위 3가지 시점에서 데이터를 Refetching해준다.</p>
<hr>
<h1 id="✏️-회고">✏️ 회고</h1>
<p>Client와 Server 전역 상태 라이브러리를 온전히 분리해서 사용하니까 되게 효율적이고 Server 데이터를 React Query로 관리함으로써 데이터 처리에 용이하다 보니 Error 횟수도 줄어들었다. 특히 <code>구조적 일관성</code> 을 갖는다는 것이 너무 장점이었다.</p>
<blockquote>
<p>Client (Redux Toolkit) : 모달 관련 , 페이지 관련 데이터 상태 등
Server (React Query) : 사용자 정보, 비지니스 로직 관련(비동기 API 호출 데이터) 상태 등</p>
</blockquote>
<hr>
<h1 id="ref">Ref</h1>
<ul>
<li><a href="https://tanstack.com/query/v5/docs/react/guides/migrating-to-v5">TanStack Query v5</a></li>
<li><a href="https://www.moonkorea.dev/React-TanStack-Query-v5-%EC%82%B4%ED%8E%B4%EB%B3%B4%EA%B8%B0-(%EB%A6%AC%EC%95%A1%ED%8A%B8%EC%BF%BC%EB%A6%AC)#suspense%EB%A5%BC-%EC%A7%80%EC%9B%90%ED%95%98%EB%8A%94-usesuspensequery,-usesuspenseinfinitequery,-usesuspensequeries%5D">MoonKorea TechBlog</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[로그인, 회원가입 React-Hook-Form으로 변경하기]]></title>
            <link>https://velog.io/@hisung-ah/%EB%A1%9C%EA%B7%B8%EC%9D%B8-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-react-hook-form%EC%9C%BC%EB%A1%9C-%EB%B3%80%EA%B2%BD%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hisung-ah/%EB%A1%9C%EA%B7%B8%EC%9D%B8-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-react-hook-form%EC%9C%BC%EB%A1%9C-%EB%B3%80%EA%B2%BD%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 03 Feb 2024 11:31:05 GMT</pubDate>
            <description><![CDATA[<h1 id="변경-이유">변경 이유</h1>
<p>기존에는 별도의 form 라이브러리를 사용하지 않고 직접 <span style="color: #EB5757"><strong><code>onChange를 관리하는 훅</code></strong></span> 과 <span style="color: #EB5757"><strong><code>유효성 검사를 하는 훅</code></strong></span> 을 별도로 만들어서 관리했는데 <span style="color: #EB5757"><strong><code>React Hook Form</code></strong></span> 은 이 둘 모두를 관리해 줄 뿐더러 불필요한 코드의 양을 줄일 수 있었고 더 간결해질 수 있었다. 그리고 이전에는 하나의 컴포넌트에서 4개의 폼을 관리하다 보니까  조건부 렌더링으로 보여지는 부분만 렌더링을 했지만 React Dev Tools로 확인해 본 결과  불필요한 부분까지 렌더링이되고 있었고 유지보수가 취약해서 따로 관리하고자 도입하기로 판단했다.</p>
<blockquote>
<p>이전 구현 기록: <a href="https://velog.io/@hisung-ah/20240118">로그인/회원가입 폼 구현</a></p>
</blockquote>
<p>해당 라이브러리를 적용 시키면서 <a href="https://react-hook-form.com/get-started#Integratinganexistingform">공식 문서 참고</a>를 굉장히 많이 했는데 상당히 잘 되어있다.</p>
<h1 id="적용">적용</h1>
<pre><code class="language-tsx">const {
    getValues,
    register,
    handleSubmit,
    watch,
    formState: { errors },
  } = useForm&lt;Inputs&gt;({ mode: &#39;onChange&#39; });</code></pre>
<p>먼저 useForm 훅을 기본적으로 제공해줘서 다양한 함수들을 사용할 수 있도록 해준다.</p>
<p>사용했었던 코드들 위주로 작성해보면 </p>
<p><code>getValues</code> 현재 input 값에 담겨 있는 값을 들어오게 해준다.
<code>register</code> input값에 검증 로직을 적용할 수 있게 헤주는 메서드이다.
<code>handleSubmit</code> form 양식 submit
<code>watch</code> input 값에 담겨 있는 값을 실시간으로 확인할 수 있다.</p>
<p>그리고 mode 라는 옵션도 있다.</p>
<p>onChange : input 값이 바뀔 때마다 검증 로직 동작</p>
<p>onBlur : 포커스 상태를 잃어 버릴떄 동작</p>
<p>onSubmit : submit 함수가 실행될 때 동작</p>
<p>onTouched : 첫 번째 blur 이벤트에서 동작하고 그 후에는 모든 change 이벤트에서 동작</p>
<p>all : blur 및 change 이벤트에서 동작</p>
<hr>
<p>나는 input을 별도로 반복되는 코드이다 보니 컴포넌트로 분리했고</p>
<pre><code class="language-tsx">&lt;input
  className={clsx(inputStyle, { [styles.inputError]: error }, { [styles.inputSuccess]: isSuccessful })}
  type={type}
  minLength={minLength}
  maxLength={maxLength}
  placeholder={placeholder}
  disabled={isSuccessful}
  onKeyDown={isKeyDown ? keyDownLoginHandler : undefined}
  {...(register &amp;&amp; register(name, validation))}</code></pre>
<p>안에 들어갈 요소들은 따로 객체 형태로 보관해서 사용될 컴포넌트에서 map으로 그려줬다.</p>
<pre><code class="language-tsx">export const signupInput = [
  {
    id: 1,
    label: &#39;사용하실 이메일과 비밀번호를 입력해 주세요.&#39;,
    type: &#39;text&#39;,
    placeholder: &#39;이메일&#39;,
    name: &#39;email&#39;,
    validation: {
      required: &#39;이메일은 필수 항목입니다.&#39;,
      pattern: {
        value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
        message: &#39;이메일 형식에 맞지 않습니다.&#39;,
      },
    },
  },
  {
    id: 2,
    type: &#39;password&#39;,
    placeholder: &#39;비밀번호&#39;,
    name: &#39;password&#39;,
    minLenght: 8,
    maxLength: 16,
    validation: {
      required: &#39;비밀번호는 필수 항목입니다.&#39;,
      minLength: {
        value: 6,
        message: &#39;비밀번호는 최소 8자리 이상이어야 합니다.&#39;,
      },
      pattern: {
        value: /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,16}$/g,
        message: &#39;비밀번호는 영문, 숫자, 특수문자를 포함해야 합니다.&#39;,
      },
    },
  },

  ... 등등</code></pre>
<p>위 Input 컴포넌트에서 <code>...(register &amp;&amp; register(name, validation))</code> 를 보면 validation이 들어가는데 위 pattren 들로 처리한다. (해당 값 자체를 정규표현식으로 검사)</p>
<p>errors의 경우 validation 중 input 속성에서 지정한 pattern 객체 내에 있는 message 를 유효성 실패 시, 보여준다. (상당히 유용함)</p>
<p>원래 처음에는 하드 코딩으로 계속 사용했지만 반복되고 많아지다 보니 가독성이 좋지도 않았고 따로 분리해서 사용하면 다른 곳에서도 쓰일 수 있는 장점이 있기에 모듈화했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js 이미지 최적화에 대하여]]></title>
            <link>https://velog.io/@hisung-ah/Next.js-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%B5%9C%EC%A0%81%ED%99%94%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC</link>
            <guid>https://velog.io/@hisung-ah/Next.js-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%B5%9C%EC%A0%81%ED%99%94%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC</guid>
            <pubDate>Wed, 31 Jan 2024 10:43:29 GMT</pubDate>
            <description><![CDATA[<p><code>Next.js</code> 는 Image 컴포넌트를 제공하여 이미지 최적화를 기본으로 지원하고 있다. 그러기에 더 편하게 개발할 수 있도록 부담을 덜어준다.</p>
<p>이미지 최적화는 <code>빌드 타임이 아닌 런타임에 요청이 들어왔을 때 최적화를 진행한다.</code> 그렇기 때문에 최초 1회 요청은 응답이 느릴 수 있다.</p>
<blockquote>
<p>Next.js는 서버가 실행되고 첫 요청이 들어온 경우에 이미지를 최적화하는 로직이 있기 때문 그래서 이미지가 캐시되고 재요청하면 엄청 빨리 로드된다.</p>
</blockquote>
<p>기본 이미지 최적화 모듈로 <code>Squoosh</code> 를 기본 이미지 최적화 모듈로 사용하고 있다. 다만, 운영 환경에서는 Next.js에서 <code>sharp</code> 라이브러리를 사용하라고 강력하게 권장하고 있다.</p>
<blockquote>
<p><code>sharp</code> 는 다양한 크기에 JPEG, PNG, WebP, GIF, AVIF와 같은 이미지들을 더 작은 크기로 만들어 준다는 라이브러리이다.</p>
</blockquote>
<p><code>sharp</code> 는 별도로 내장되어 있지 않아서 설치를 해줘야 한다.</p>
<pre><code>npm install sharp
yarn add sharp</code></pre><p>비교 사진이다. 참고로 <code>admin-prod.svg</code> 기본 이미지 크기는 <code>1.4MB</code> 이다.</p>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/6a10d8fe-0c33-4654-b97c-021fc7d2f69d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/a30d4759-a811-497b-8264-f015eb60a452/image.png" alt=""></p>
<p>1번째 사진은 Squoosh이 적용된 버전이고,
2번째 사진이 sharp가 적용된 모습이다.</p>
<p>확실히 sharp를 사용하면 Squoosh를 사용할 때와 최적화 측면에서 월등한 모습을 보여준다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Framer-motion으로 애니메이션 다루기]]></title>
            <link>https://velog.io/@hisung-ah/Framer-motion%EC%9C%BC%EB%A1%9C-%EC%95%A0%EB%8B%88%EB%A9%94%EC%9D%B4%EC%85%98-%EB%8B%A4%EB%A3%A8%EA%B8%B0</link>
            <guid>https://velog.io/@hisung-ah/Framer-motion%EC%9C%BC%EB%A1%9C-%EC%95%A0%EB%8B%88%EB%A9%94%EC%9D%B4%EC%85%98-%EB%8B%A4%EB%A3%A8%EA%B8%B0</guid>
            <pubDate>Mon, 29 Jan 2024 15:28:31 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><a href="https://velog.io/@hisung-ah/20240116">&lt;&lt; 이전 탑 스크롤 구현 작업</a></p>
</blockquote>
<p>소개 페이지 최종 시안이 나와서 작업을 바로 들어갔다. 중간에 와이어 프레임대로 짜 봤지만 역시 전체를 다 바꿔야 했고 css keyframe으로 하기엔 리소스가 많이 들어서 <code>framer-motion</code> 라이브러리를 사용하기로 했다.</p>
<pre><code>npm install framer-motion
yarn add framer-motion</code></pre><hr>
<h1 id="sticky를-이용한-motion">sticky를 이용한 motion</h1>
<pre><code class="language-tsx">/**
 * 상수 데이터
 * @description 키오스크 스크롤에 따른 애니메이션 효과를 위한 상수
 */
export const SCROLL_THRESHOLDS = [1200, 1900, 2600];
...

// 사용된 컴포넌트
const KioskVideo = () =&gt; {
  const [index, setIndex] = useState(0);
  const [ref] = useInView({ triggerOnce: true, threshold: 0.8 });

  useEffect(() =&gt; {
    const handleScroll = throttle(() =&gt; {
      for (let i = 0; i &lt; SCROLL_THRESHOLDS.length; i++) {
        if (window.scrollY &gt; SCROLL_THRESHOLDS[i]) setIndex(i);
      }
    }, 200);

    window.addEventListener(&#39;scroll&#39;, handleScroll);

    return () =&gt; window.removeEventListener(&#39;scroll&#39;, handleScroll);
  }, []);

  return (
    &lt;section className={styles.kioskBox}&gt;
      &lt;div className={styles.stickyBox}&gt;
        &lt;TransitionBox index={index} /&gt;
        &lt;div className={styles.titleBox} ref={ref}&gt;
          &lt;TransitionText className={styles.t1} text={MAIN_TITLES[index]} index={index} /&gt;
          &lt;div className={styles.subTitle}&gt;
            &lt;TransitionText text={FIRST_CAPTIONS[index]} index={index} /&gt;
            &lt;TransitionText text={SECOND_CAPTIONS[index]} index={index} /&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/section&gt;
  );
};</code></pre>
<p>마우스 스크롤 y축은 콘솔에 찍어보며 계산했고 동적으로 텍스트를 변경해주기 위해 for loop으로 스크롤을 순회하면서 인덱싱 처리를 했다. (텍스트와 이미지 배열 순서를 맞춤)</p>
<p>그리고 기존에 사용하던 <code>react-intersection-observer</code> 의 <code>useInView</code> 를 활용하여 화면에 보여질 때, 이벤트가 발동되게 걸어놨다. (메모리 누수 방지를 위해 이벤트 제거는 필수)</p>
<blockquote>
<p>throttle은 스크롤을 할 때 마다, 이벤트가 일어나서 스크롤 함수에 적용 시켜서 일정 시간 간격으로 제한을뒀다. <br>
예를 들면, 이벤트가 일어날 때 마다 실행이 되면 일정 시간 동안 이전에 실행한 함수가 있으면 그 함수의 결과를 반환하고 그렇지 않으면 새로운 함수를 실행하는 기법이다.
(2초 마다 한 번씩만 실행되고 그 사이에는 이전에 실행한 함수 결과를 반환. 결국 스크롤이 빈번하게 일어나도 실행 횟수를 제한할 수 있음)</p>
</blockquote>
<pre><code class="language-tsx">const TransitionBox = (props: Props) =&gt; {
  const { index } = props;

  return (
    &lt;motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 1 }}&gt;
      {IPHONES.map((iphone, i) =&gt; (
        &lt;motion.div
          className={styles.iphoneBox}
          key={iphone.id}
          initial={{ opacity: i === index ? 1 : 0 }}
          animate={{ opacity: i === index ? 1 : 0 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 1 }}
        &gt;
          {i === index &amp;&amp; &lt;Image src={iphone.src} width={370} height={1000} alt={iphone.alt} priority /&gt;}
        &lt;/motion.div&gt;
      ))}
    &lt;/motion.div&gt;
  );
};
const TransitionText = (props: Props) =&gt; {
  const { text, index, className } = props;

  return (
    &lt;motion.p
      className={className}
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
      key={index}
      transition={{ duration: 0.5 }}
    &gt;
      {text}
    &lt;/motion.p&gt;
  );
};
</code></pre>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/5eac3bf1-30df-463b-bc6a-4e83d1078cd6/image.gif" alt=""></p>
<p>실직적으로 애니메이션은 <code>TransitionBox</code> <code>TransitionText</code> 컴포넌트에서 이루어진다.</p>
<ul>
<li>inital로 시작 상태를 알려주고 인덱싱에 맞게 opacity 값을 부여</li>
<li>animate 전환 효과 시 부여할 옵션</li>
<li>exitd은 종료될 시점</li>
<li>transition 전환 효과 / duration 지속 시간</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js에서 PWA 기반 FCM 푸시 알림 보내기]]></title>
            <link>https://velog.io/@hisung-ah/Next.js%EC%97%90%EC%84%9C-PWA-%EA%B8%B0%EB%B0%98-FCM-%ED%91%B8%EC%8B%9C-%EC%95%8C%EB%A6%BC-%EB%B3%B4%EB%82%B4%EA%B8%B0</link>
            <guid>https://velog.io/@hisung-ah/Next.js%EC%97%90%EC%84%9C-PWA-%EA%B8%B0%EB%B0%98-FCM-%ED%91%B8%EC%8B%9C-%EC%95%8C%EB%A6%BC-%EB%B3%B4%EB%82%B4%EA%B8%B0</guid>
            <pubDate>Fri, 26 Jan 2024 10:11:51 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><a href="https://velog.io/@hisung-ah/Next.js%EB%A1%9C-PWA-%EA%B8%B0%EC%88%A0-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0">PWA 구현하기</a></p>
</blockquote>
<p>PWA를 적용 시키면 모든 OS에 푸시 알림이 가는 줄 알았다. 이는 틀린 사실은 아니지만 IOS에서는 알림이 가지 않는 이슈가 있었다. 그래서 해결 방법을 서치하다가 결과적으로 현재는 React Native 개발 또는 PWA를 적용 시키고 FCM을 연동하지 않으면 앱 푸시 알림을 보낼 수 없다는 결론을 맞이했다.</p>
<p>AOS에서는 푸시 알림이 왔지만 IOS에서 푸시 알림을 받으려면 Safari에서 웹 사이트에 접속하고 가운데에 있는 공유 버튼 클릭 후, 홈 화면에 추가하기를 누른 뒤 추가한 아이콘을 클릭해서 실행하고 푸시 허용을 해야지만 알림이 가진다.</p>
<blockquote>
<p>웹 푸시 기능을 iOS와 iPadOS의 16.4 beta 1 버전부터 지원하기 시작</p>
</blockquote>
<p>아울러 사용자 경험 측면에서는 <u>주문 전 알림 허용 유무 확인을 파악하는 UI</u>를 가져가야할 거 같고 현재 IOS 푸시 알림이 이게 한계점인지 아니면 해결 방법이 있는 건지는 더 찾아봐야겠다.</p>
<blockquote>
<p>✅ 결론 FCM을 구현해보자</p>
</blockquote>
<hr>
<h1 id="fcm-구현해보기">FCM 구현해보기</h1>
<p>설치를 해주고</p>
<pre><code>npm install firebase
yarn add firebase</code></pre><h2 id="sdk-초기화">SDK 초기화</h2>
<pre><code class="language-tsx">import { initializeApp } from &#39;firebase/app&#39;;
import { getMessaging, getToken } from &#39;firebase/messaging&#39;;

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_MESSAGING_ID,
  appId: process.env.NEXT_PUBLIC_APP_ID,
};

const app = initializeApp(firebaseConfig);

/**
 * FCM 토큰 발급
 */
export const setTokenHandler = async () =&gt; {
  const messaging = getMessaging(app);
  await getToken(messaging, {
    vapidKey: process.env.NEXT_PUBLIC_VAPID_KEY,
  })
    .then(async currentToken =&gt; {
      if (!currentToken) {
        // 토큰 생성 불가시 처리할 내용, 주로 브라우저 푸시 허용이 안된 경우에 해당한다.
      } else {
        // 토큰을 받았다면 여기서 DB에 저장하면 됩니다.
      }
    })
    .catch(error =&gt; {
      console.error(error);
    });
};</code></pre>
<p>예전 프로젝트에서도 했듯이 파이어베이스 SDK를 초기화 해주고 env에 추가한 키 값을 꺼내오면 된다.</p>
<h2 id="푸시-알림-허용">푸시 알림 허용</h2>
<pre><code class="language-tsx">const clickPushHandler = () =&gt; {
  Notification.requestPermission().then(permission =&gt; {
    if (permission !== &#39;granted&#39;) {
      // 푸시 거부됐을 때 처리할 내용
      console.log(&#39;푸시 거부됨&#39;);
    } else {
      // 푸시 승인됐을 때 처리할 내용
      console.log(&#39;푸시 승인됨&#39;);
    }
  });
};</code></pre>
<p><code>Notification.requestPermission()</code> 을 사용해서 푸시 권한을 요청할 수 있다.</p>
<blockquote>
<p>다만 해당 함수를 useEffect로 사용해서 페이지에 접근 하자마자 푸시 권한을 확인할 수도 있지만 <strong><code>클릭 이벤트로 처리</code></strong> 해야 하는 권장하는(?) 이유가 있다. 아무런 이벤트 없이 함수를 호출할 경우 브라우저마다 작동하지 않거나 요청 알림이 숨겨지기 때문이다. 사용자를 귀찮게하는 알림창이 사방팔방 뜨게 하는 것을 방지하기 위함인 느낌이다.</p>
</blockquote>
<h2 id="토큰-발급">토큰 발급</h2>
<p>토큰을 발급 받을 때는 vapid key를 통해 웹 푸시 서비스에 대한 보내기 요청을 승인 받아야 한다.</p>
<blockquote>
<p>vapid key 발급 절차</p>
</blockquote>
<ul>
<li>파이어베이스 콘솔 &gt; 프로젝트 설정(톱니바퀴) &gt; 클라우드 메세징 &gt; 웹 푸시 인증서
에서 발급 받으면 된다.</li>
</ul>
<pre><code class="language-tsx">const VAPID_KEY = process.env.NEXT_PUBLIC_VAPID_KEY;

export const getTokenHandler = async () =&gt; {
  const messaging = getMessaging(app);
  return await getToken(messaging, {
    vapidKey: VAPID_KEY,
  })
    .then(async currentToken =&gt; {
    if (!currentToken) {
      // 토큰 생성 불가시 처리할 내용, 주로 브라우저 푸시 허용이 안된 경우에 해당한다.
      console.error(&#39;토큰 생성 불가&#39;);
    } else {
      // 토큰을 받았다면 여기서 supabase 테이블의 저장하면 됩니다.
      console.log(&#39;currentToken&#39;, currentToken);
      return currentToken;
    }
  })
    .catch(error =&gt; {
    console.error(&#39;token error&#39;, error);
  });
};</code></pre>
<h2 id="서비스워커">서비스워커</h2>
<p>이제부터 신기함을 경험해 보기 위한 백그라운드에서 푸시를 받고 처리해 줄 서비스워커를 등록해야 한다. (모든 앱 끄고 폰도 꺼놔도 알림이 오도록)</p>
<blockquote>
<p>public 디렉토리에서는 process.env를 통해 환경변수를 불러올 수가 없기 때문에 파이어베이스 키를 어쩔 수 없이 공개를 해야 한다.<br>
보통 private한 키들은 숨겨두는 게 좋지만 파이어베이스의 경우 데이터베이스 보안 규칙이나 앱 체크 등을 활용해서 서버를 보호하는 것이 권장되고 더 효과적이다.<br>
하지만 이러한 설정을 하지 않았더라도 키의 공개로 인해서 보안에 치명적인 위협을 끼치지는 않도록 설계되어 있다. <a href="https://firebase.google.com/docs/projects/api-keys?hl=ko">공식 문서 참고</a></p>
</blockquote>
<h3 id="firebase-messaging-swjs">firebase-messaging-sw.js</h3>
<pre><code class="language-tsx">importScripts(&#39;https://www.gstatic.com/firebasejs/9.0.2/firebase-app-compat.js&#39;);
importScripts(&#39;https://www.gstatic.com/firebasejs/9.0.2/firebase-messaging-compat.js&#39;);

firebase.initializeApp({
  apiKey: &#39;---&#39;,
  authDomain: &#39;---&#39;,
  projectId: &#39;---&#39;,
  storageBucket: &#39;---&#39;,
  messagingSenderId: &#39;---&#39;,
  appId: &#39;---&#39;,
});

const messaging = firebase.messaging();

// 푸시 내용을 처리해서 알림으로 띄운다.
self.addEventListener(&#39;push&#39;, function (event) {

});

// 클릭 이벤트 처리 - 알림을 클릭하면 사이트로 이동한다.
self.addEventListener(&#39;notificationclick&#39;, function (event) {

});</code></pre>
<p>서버에서 처리를 하고 그 후에 이벤트로 처리를 해야 한다.</p>
<h2 id="푸시-발송">푸시 발송</h2>
<p>공식 문서를 보면 FCM 공푸시 발송 방법은 크게 3가지가 있다.</p>
<ul>
<li>Firebase Admin SDK</li>
<li>Firebase Cloud Messaging API(V1)(HTTP 프로토콜)</li>
<li>기존 Cloud Messaging API(구버전 HTTP 프로토콜)</li>
</ul>
<p>가장 권장되는 방법은 Firebase Admin SDK를 활용하는 방법이고 가장 적용하기 수월해서 해당 방법을 사용했다.</p>
<p>구현은 되게 간단하다. 서버 측 API에서 프로젝트 서비스 계정 인증을 거치고 서비스 계정 권한으로 푸시를 발송하는 방법이다.</p>
<blockquote>
<p>Next.js를 사용하고 있어서 API 라우터를 사용할 예정이다.</p>
</blockquote>
<h3 id="설정">설정</h3>
<p>설치를 하자</p>
<pre><code>npm install firebase-admin --save
yarn add firebase-admin --save 또는 yarn add firebase-admin</code></pre><p>우리 프로젝트는 패키지 매니저가 yarn berry 여서 그런건지 --save 약어가 먹히지가 않았다... <em><strong>(문제점 저장..)</strong></em></p>
<p>서비스 계정 키도 적용을 해야 한다.</p>
<blockquote>
<p>파이어베이스 콘솔 &gt; 프로젝트 설정 &gt; 서비스 계정 &gt; 새 비공개 키 생성
이 순서대로 가면 파일 하나가 다운로드 되는데 확장자를 json 파일로 변경하고 확인해보면 <code>private_key</code> 와 <code>client_email</code> 이 있을 거다 그 2개의 값만 확인해서 환경변수에 저장해주면 된다.</p>
</blockquote>
<h3 id="푸시-전송-함수">푸시 전송 함수</h3>
<pre><code class="language-tsx">import admin, { ServiceAccount } from &#39;firebase-admin&#39;;

// API 호출 시 전달할 데이터 타입
interface NotificationData {
  data: {
    title: string; // 제목
    body: string; // 내용
    image: string; // 이미지(아이콘)
    click_action: string; // url
    token: string // 토큰
  };
}
export const sendFCMNotification = async (data: NotificationData) =&gt; {
  const serviceAccount: ServiceAccount = {
    projectId: process.env.NEXT_PUBLIC_PROJECT_ID,
    privateKey: process.env.NEXT_PUBLIC_FIREBASE_PRIVATE_KEY?.replace(/\\n/g, &#39;\n&#39;),
    clientEmail: process.env.NEXT_PUBLIC_FIREBASE_CLIENT_EMAIL,
  };

  if (!admin.apps.length) {
    admin.initializeApp({
      credential: admin.credential.cert(serviceAccount),
    });
  }

  // 푸시 알림 전송 대상 토큰
  const notificationData = { data };

  // 푸시 알림 전송
  const res = await admin.messaging().send(notificationData);

  return res;
};</code></pre>
<blockquote>
</blockquote>
<p>여기서 나만 랜덤 형태인지는 몰라도 <code>\n</code>과 별 이상한 문자열이 껴 있어서 빌드 타임 때, 계속 파싱 에러가 났었다. <br>
다른 오류인 줄 알았으나 결과적으로는 서버 콘솔을 확인해 보니 private_key가 아니다라는 에러를 뿜어냈고, <br>
혹시나 해봐서 <code>.replace</code> 로 접근해보니 자동완성이 되서 정규식으로 불필요한 문자들을 치환했더니 빌드가 잘 됐다.</p>
<h3 id="api-라우터-핸들러">API 라우터 핸들러</h3>
<pre><code class="language-tsx">const sendFCMHandler = async (req: NextApiRequest, res: NextApiResponse) =&gt; {
  if (req.method === &#39;POST&#39;) {
    const { message } = req.body;
    await sendFCMNotification(message)
      .then(result =&gt; res.status(200).json({ result }))
      .catch(error =&gt; console.log(error));
  } else {
    res.status(405).end();
  }
};</code></pre>
<p>API 핸들러 파일을 별도로 pages에서 만들어서 앞서 받아온 데이터를 푸시 전송 함수에 전달하고 결과를 처리하는 역할을 한다.</p>
<h3 id="api-호출-hook">API 호출 Hook</h3>
<pre><code class="language-tsx">const useSendPush = () =&gt; {
  const sendPush = async ({ title, body, click_action, token }: { title: string; body: string; click_action: string, token: string }) =&gt; {
    const message = {
      data: {
        title,
        body,
        image: &#39;/public/icons/manifest/icon-192x192.png&#39;,
        click_action,
      },
    };

    axios.request({
      method: &#39;POST&#39;,
      url: window?.location?.origin + &#39;/api/fcm&#39;,
      data: { message },
    });
  };

  return sendPush;
};</code></pre>
<p>클라이언트 측에 원하는 부분에서 API를 호출하면 되고 커스텀 훅을 이용해서 주문 완료 버튼을 눌렀을 때, 호출해 줄 것이다.</p>
<h3 id="서비스워커에서-푸시-처리">서비스워커에서 푸시 처리</h3>
<pre><code class="language-tsx">self.addEventListener(&#39;push&#39;, function (event) {
  if (event.data) {
    // 알림 메세지일 경우엔 event.data.json().notification;
    const data = event.data.json().data;
    const options = {
      body: data.body,
      icon: data.image,
      image: data.image,
      data: {
        click_action: data.click_action, // 이 필드는 밑의 클릭 이벤트 처리에 사용됨
      },
    };

    event.waitUntil(self.registration.showNotification(data.title, options));
  } else {
    console.log(&#39;This push event has no data.&#39;);
  }
});

// 클릭 이벤트 처리
// 알림을 클릭하면 사이트로 이동한다.
self.addEventListener(&#39;notificationclick&#39;, function (event) {
  event.preventDefault();
  // 알림창 닫기
  event.notification.close();

  // 이동할 url
  // 아래의 event.notification.data는 위의 푸시 이벤트를 한 번 거쳐서 전달 받은 options.data에 해당한다.
  // api에 직접 전달한 데이터와 혼동 주의
  const urlToOpen = event.notification.data.click_action;

  // 클라이언트에 해당 사이트가 열려있는지 체크
  const promiseChain = clients
    .matchAll({
      type: &#39;window&#39;,
      includeUncontrolled: true,
    })
    .then(function (windowClients) {
      let matchingClient = null;

      for (let i = 0; i &lt; windowClients.length; i++) {
        const windowClient = windowClients[i];
        if (windowClient.url.includes(urlToOpen)) {
          matchingClient = windowClient;
          break;
        }
      }

      // 열려있다면 focus, 아니면 새로 open
      if (matchingClient) {
        return matchingClient.focus();
      } else {
        return clients.openWindow(urlToOpen);
      }
    });

  event.waitUntil(promiseChain);
});</code></pre>
<p>위 서비스워커에서 정의했던 함수 내용부를 채워줬다.</p>
<hr>
<h1 id="결과물">결과물</h1>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/a227389e-1d9c-4cba-a1d6-cd980dab440a/image.jpg" alt=""></p>
<p>크.. 너무 신기했다. 테스트는 HTTPS 프로토콜에서만 가능해서 직접 vercel에 배포하고 테스트를 했었고 저 알림 하나를 받으려고 많은 코드를 작성했지만 오는 순간 너무 짜릿했다 ㅋㅋㅋㅋ 그저 뿌듯.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js로 PWA 기술 적용하기]]></title>
            <link>https://velog.io/@hisung-ah/Next.js%EB%A1%9C-PWA-%EA%B8%B0%EC%88%A0-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hisung-ah/Next.js%EB%A1%9C-PWA-%EA%B8%B0%EC%88%A0-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 25 Jan 2024 13:09:07 GMT</pubDate>
            <description><![CDATA[<p>고객이 메뉴 주문을 완료하면 완료됐다는 푸시 알림을 주문 고객에게 보내고자 PWA 기술을 사용했다.</p>
<h1 id="pwa">PWA</h1>
<p>프로그레시브 웹 어플리케이션으로 앱처럼 보이고, 직접 홈 화면에 저장도 가능하고, 스플래시 화면도 있고, 오프라인 지원 등등 모든 작업을 수행할 수 있는 강력한 기술이다.</p>
<h1 id="구현">구현</h1>
<p>설치를 해주고</p>
<pre><code>npm install next-pwa
yarn add next-pwa</code></pre><pre><code class="language-javascript">/** @type {import(&#39;next&#39;).NextConfig} */

const withPWA = require(&#39;next-pwa&#39;)({
  dest: &#39;public&#39;,
  register: true,
  skipWaiting: true,
});

const nextConfig = withPWA({
  reactStrictMode: true,
});

module.exports = nextConfig;</code></pre>
<blockquote>
<p>next에서 진행할 거니 빌드 타임 때, pwa 쓸 거라는 명령을 내려야 하니까 <code>next.config.js</code> 에서 설정을 해줘야 한다.</p>
</blockquote>
<p>그리고 서비스 워커에 등록을 하고 푸시 알림을 처리할 수 있도록 하자.</p>
<pre><code class="language-jsx">export const registerServiceWorker = () =&gt; {
  if (&#39;serviceWorker&#39; in navigator) {
    window.addEventListener(&#39;load&#39;, () =&gt; {
      navigator.serviceWorker
        .register(&#39;/sw.js&#39;)
        .then(registration =&gt; {
          console.log(&#39;Service Worker 등록 성공:&#39;, registration);
        })
        .catch(error =&gt; {
          console.log(&#39;Service Worker 등록 실패:&#39;, error);
        });
    });
  }
};

export const requestNotificationPermission = () =&gt; {
  if (&#39;Notification&#39; in window) {
    Notification.requestPermission().then(permission =&gt; {
      if (permission === &#39;granted&#39;) {
        console.log(&#39;푸시 알림 권한이 허용됨&#39;);
      } else {
        console.log(&#39;푸시 알림 권한이 거부됨&#39;);
      }
    });
  }
};

export const sendPushNotification = (title: string, body: string) =&gt; {
  if (&#39;serviceWorker&#39; in navigator &amp;&amp; &#39;PushManager&#39; in window) {
    navigator.serviceWorker.ready.then(registration =&gt; {
      registration.showNotification(title, {
        body,
        icon: &#39;/icons/favicon/favicon-16x16.png&#39;,
      });
    });
  }
};</code></pre>
<ul>
<li><code>registerServiceWorker</code> 함수를 호출해서 웹 페이지가 로드될 때, sw.js 경로에 있는 서비스 워커를 등록해준다.</li>
<li><code>requestNotificationPermission</code> 브라우저가 Notification API를 지원하는 경우, 사용자에게 푸시 알림 권한을 요청한다. (예외 처리)</li>
<li><code>sendPushNotification</code> 서비스워커가 준비되면 등록을 사용해서 푸시 알림을 생성 후 보내준다.</li>
</ul>
<h2 id="컴포넌트-사용">컴포넌트 사용</h2>
<pre><code class="language-tsx">const Test = () =&gt; {
  // 푸시 알림 테스트
  const clickPushHandler = () =&gt; {
    sendPushNotification(&#39;매직포스 알림&#39;, &#39;알림 가나요?&#39;);
  };
  useEffect(() =&gt; {
    registerServiceWorker();
    requestNotificationPermission();
    // 직접 푸시 알림 테스트
    sendPushNotification(&#39;테스트 알림&#39;, &#39;테스트 알림입니다.&#39;);
  }, []);

  return (
    &lt;Fragment&gt;
      &lt;button onClick={clickPushHandler}&gt;알림 보내기&lt;/button&gt;
    &lt;/Fragment&gt;
  );
};</code></pre>
<p>서비스 워커만 잘 등록해주면 컴포넌트에서 사용하는 것은 어렵지 않다.</p>
<hr>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/efea11cf-4cff-44d4-8fde-bbc7c1020f42/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/9dfbf3ff-d0ec-4702-8167-ccaa45d44e38/image.png" alt=""></p>
<p>나는 모바일에서도 가능하게 하고 싶어서 추후 앱에서 푸시가 가능한 파이어베이스의 fcm 적용기를 포스팅 할 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[문의하기 기능 - 이메일 전송하기 (emailJS)]]></title>
            <link>https://velog.io/@hisung-ah/20240124</link>
            <guid>https://velog.io/@hisung-ah/20240124</guid>
            <pubDate>Wed, 24 Jan 2024 12:16:05 GMT</pubDate>
            <description><![CDATA[<p>유저가 사이트를 이용 하다가 불편한 사항이 생기면 해결 방법을 제시해야 하는데 이메일 전송으로 고객의 소리를 받기로했고 이를 간편하게 하고자 emailJS 라이브러리를 사용했다.</p>
<h1 id="설치">설치</h1>
<pre><code>yarn add @emailjs/browser --save
npm install @emailjs/browser --save</code></pre><h1 id="기본-설정">기본 설정</h1>
<blockquote>
</blockquote>
<ol>
<li><a href="https://www.emailjs.com/">https://www.emailjs.com/</a> 접속</li>
<li>회원가입 후 관리 페이지로 들어감</li>
<li>Email Service에서 사용할 메일 연동</li>
<li>Account에서 Public Key 생성</li>
<li>Template에서 사용할 메일 템플릿 생성</li>
</ol>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/fefaa394-e108-4ea9-a9fe-c2f68b346eab/image.png" alt=""></p>
<ul>
<li>to_name, user_email은 변수이고 마음대로 정해도 상관 없다. 단, 작성하게될 input name에는 똑같이 기재해줘야 한다.</li>
</ul>
<h1 id="코드-작성">코드 작성</h1>
<p>EmailJS Docs: <a href="https://www.emailjs.com/docs/sdk/send-form/">https://www.emailjs.com/docs/sdk/send-form/</a></p>
<pre><code class="language-tsx">const SERVICE_ID = `${process.env.NEXT_PUBLIC_SERVICE_ID}`;
const TEMPLATE_ID = `${process.env.NEXT_PUBLIC_TEMPLATE_ID}`;
const PUBLIC_KEY = `${process.env.NEXT_PUBLIC_PUBLIC_KEY}`;

export const useSendMail = ({ ref }: SendMailProps) =&gt; {
  const { toast } = useToast();

  const sendEmail = (e: React.ChangeEvent&lt;HTMLFormElement&gt;) =&gt; {
    e.preventDefault();
    if (!ref.current) return;

    emailjs.sendForm(SERVICE_ID, TEMPLATE_ID, ref.current, PUBLIC_KEY).then(
      result =&gt; {
        if (result.text === &#39;OK&#39;) {
          toast(&#39;메일이 전송되었습니다.&#39;, {
            type: &#39;success&#39;,
            position: &#39;top-right&#39;,
            showCloseButton: false,
            autoClose: 2000,
          });
        }
      },
      error =&gt; {
        if (error) {
          toast(&#39;메일 전송에 실패했습니다.&#39;, {
            type: &#39;danger&#39;,
            position: &#39;top-right&#39;,
            showCloseButton: false,
            autoClose: 2000,
          });
        }
      },
    );
  };

  return { sendEmail };
};

// SendMail 컴포넌트
.
.
.

return (
  &lt;div className={styles.wrapper}&gt;
    &lt;h1&gt;문의사항&lt;/h1&gt;
    &lt;form className={styles.formBox} ref={form} onSubmit={sendEmail}&gt;
      &lt;div className={styles.inputBox}&gt;
        &lt;label htmlFor=&quot;to_name&quot;&gt;상호명&lt;/label&gt;
        &lt;input type=&quot;text&quot; id=&quot;to_name&quot; name=&quot;to_name&quot; value={storeName ?? &#39;&#39;} disabled /&gt;
      &lt;/div&gt;
      &lt;div className={styles.inputBox}&gt;
        &lt;label htmlFor=&quot;user_email&quot;&gt;답변 받으실 이메일을 작성해 주세요.&lt;/label&gt;
        &lt;input type=&quot;email&quot; id=&quot;user_email&quot; name=&quot;user_email&quot; value={value.user_email} onChange={changeHandler} /&gt;
      &lt;/div&gt;
      &lt;div className={styles.inputBox}&gt;
        &lt;label&gt;문의하실 내용을 작성해 주세요.&lt;/label&gt;
        &lt;textarea id=&quot;message&quot; name=&quot;message&quot; /&gt;
      &lt;/div&gt;
      &lt;Button type=&quot;submit&quot;&gt;보내기&lt;/Button&gt;
    &lt;/form&gt;
  &lt;/div&gt;
);</code></pre>
<p>공식 문서와 별 다를 바 없이 작성했지만 메일 전송을 메인 페이지 헤더에서도 쓰일 우려를 대비해서 커스텀 훅으로 분리했고, 정말 별도의 큰 세팅 없이 간단하고 간편하게 메일 전송이 가능하다.</p>
<p>그리고 위 기본 설정에서 생성한 <code>SERVICE_ID</code>, <code>TEMPLATE_ID</code>, <code>PUBLIC_KEY</code> 를 sendForm 인자에다 각각 넣어주면 끝.</p>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/8b1057ab-88d9-4a71-bb60-02ae06371db0/image.png" alt=""></p>
<p>그러면 이렇게 성공적으로 메일이 잘 오는 거를 볼수가 있다!</p>
<p>⎣ Next.js로 nodemailer 라이브러리를 사용해서도 API 라우터를 구축하고 보낼 수도 있다.
⎣ 단, 현 시점에서 무엇을 써도 프로젝트에는 전혀 상관이 없어서 더 편리한 emailJS를 선택</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[셀렉트 박스 커스텀 하기 (feat. react-select)]]></title>
            <link>https://velog.io/@hisung-ah/20240122</link>
            <guid>https://velog.io/@hisung-ah/20240122</guid>
            <pubDate>Mon, 22 Jan 2024 08:30:26 GMT</pubDate>
            <description><![CDATA[<p>UI/UX 디자이너 님과 회의 중 일부 페이지 시안이 수정되어 셀렉트 박스도 기본 스타일이 아닌 직접 디자인을 해야하는 시안으로 변경되어 직접 커스텀 아니면 내부 option 디자인을 건드릴 수가 없기에 셀렉트 박스를 커스텀 하기로했다.</p>
<p>POS 관리자 페이지를 제작하다 보니 영업 시간을 설정하는 셀렉트이다.
→ 오픈부터 마감까지</p>
<h1 id="설치">설치</h1>
<pre><code>npm install react-select
yarn add react-select</code></pre><h1 id="select">Select</h1>
<h2 id="시간-포맷">시간 포맷</h2>
<pre><code class="language-tsx">// 시간 포맷 변환
export const convertTimeFormat = (time: string) =&gt; {
  const [hour, minute] = time.split(&#39;:&#39;);
  const hourNumber = Number(hour);
  const prefix = hourNumber &gt;= 12 ? &#39;오후&#39; : &#39;오전&#39;;
  const convertedHour = hourNumber &gt; 12 ? hourNumber - 12 : hourNumber === 0 ? 12 : hourNumber;

  return {
    originalTime: time,
    convertedTime: `${prefix} ${convertedHour}:${minute}`,
  };
};
// 셀렉트 옵션 생성
export const createTimeOptions = (timeArray: string[]) =&gt;
timeArray.map(t =&gt; ({
  value: t,
  label: convertTimeFormat(t).convertedTime,
}));

// 셀렉트 옵션 - 24시간을 30분 단위로 배열 생성
export const timeOption = Array.from(
  { length: TIME_SIZE },
  (_, i) =&gt; `${String(Math.floor(i / 2)).padStart(2, &#39;0&#39;)}:${String((i % 2) * 30).padStart(2, &#39;0&#39;)}:00`,
);</code></pre>
<p>가장 먼저 select option에 들어가게될 시간을 오전 00:00 으로 변환하는 유틸 함수를 먼저 만들었다.</p>
<p>24시간제 기준으로 <code>00:00</code> 또는 <code>00:00:00</code> 으로 보여지게 되면 사용자에게 친화적으로 다가가지 않다는 판단하에 포맷팅을 진행했다.</p>
<h2 id="구현">구현</h2>
<pre><code class="language-tsx">const StoreSelectBox = (props: StoreSelectBoxProps) =&gt; {
  const { times, item, setTimes } = props;
  const updateTimeState = (name: string, value: string) =&gt; setTimes(prevTimes =&gt; ({ ...prevTimes, [name]: value }));
  const timeOptions = createTimeOptions(timeOption);

  return (
    &lt;Select
      styles={customStyles}
      name={item.name}
      id={item.name}
      onChange={option =&gt; updateTimeState(item.name, option?.value ?? &#39;&#39;)}
      value={timeOptions.find(option =&gt; option.value === times[item.name])}
      options={timeOptions.filter(option =&gt; (item.name === &#39;endTime&#39; ? option.value &gt; times.startTime : true))}
      placeholder={item.defaultValue}
    /&gt;
  );
};</code></pre>
<p>그리고 react-select에서 기본적으로 제공하는 <code>options</code> 에 시간을 포맷팅한 값들은 객체 형태로 넣어줬다.</p>
<p><code>{ value: string, label: string }</code> 이런 형태로 들어가게 되고 내가 출력한 <code>tiemOptions</code> 변수에는 <code>{ value: 00:30:00, label: 오전 12:00 }</code> 으로 들어가게 된다.</p>
<p><code>onChange</code> 옵션도 기본적으로 제공되어서 콜백함수로 상태 변경을 진행</p>
<pre><code class="language-tsx">// item
{ id: 1, name: &#39;startTime&#39;, defaultValue: &#39;오픈&#39;, label: &#39;부터&#39; },
{ id: 2, name: &#39;endTime&#39;, defaultValue: &#39;마감&#39;, label: &#39;까지&#39; },</code></pre>
<p>그리고 오픈 시간 셀렉트와 마감 셀렉트 2개를 구현해야 해서 props로 받아온 item 즉, 2개의 객체를 기준으로 필터를 걸어줬다.</p>
<pre><code class="language-tsx">const customStyles: StylesConfig&lt;OptionType, false&gt; = {
  control: provided =&gt; ({
    ...provided,
    width: &#39;16rem&#39;,
    height: &#39;5rem&#39;,
    ... 등등,
  }),
  menu: provided =&gt; ({
    ...provided,
    width: &#39;16rem&#39;,
    fontWeight: &#39;500&#39;,
    ... 등등,
  }),
};</code></pre>
<p>가장 원했던 커스텀도 할 수 있어서 인라인 스타일에 맞게끔 작업해주면 된다.</p>
<p>control은 선택된 아이템을 보여주는 영역이고 menu는 옵션들이 보여지는 드롭다운 메뉴를 나타낸다.
provided는 현재 선택된 영역(?) control, menu 이라고 이해하면 된다.</p>
<p>다음엔 직접 컴포넌트 코딩 보단 다른 영역으로 분리하는 방법을 고안해 봐야겠다.</p>
<h2 id="컴포넌트-사용">컴포넌트 사용</h2>
<pre><code class="language-tsx">// item은 오픈, 마감을 구분하기 위한 객체
// times는 오픈, 마감 시간 상태
// setTimes는 오픈, 마감 시간 상태 변경
&lt;StoreSelectBox item={item} times={times} setTimes={setTimes} /&gt;</code></pre>
<p>실제로는 item 배열 길이 만큼 순회해서 아래와 같이 나타났다.</p>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/c8351818-90c6-4bce-8f00-cf0aeafb1814/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[회원 인증 폼 관련 리팩토링 (로그인, 회원가입 등)]]></title>
            <link>https://velog.io/@hisung-ah/20240118</link>
            <guid>https://velog.io/@hisung-ah/20240118</guid>
            <pubDate>Thu, 18 Jan 2024 14:05:52 GMT</pubDate>
            <description><![CDATA[<p>현재 진행 중인 프로젝트에서 맡은 파트 중 일부 로그인 &amp; 회원가입, 비밀번호 찾기 등 인증 관련 파트가 있고 기능 개발은 끝난 뒤 다른 기능을 개발하던 도중 필요한 코드를 참고 하려고 들어가봤더니 너무 맘에 들지 않길래 당장 <code>리팩토링</code> 에 들어갔다.</p>
<blockquote>
<p>기존 개발 일지: <a href="https://velog.io/@hisung-ah/20240109">회원 인증 폼 만들기</a></p>
</blockquote>
<h1 id="회원-인증-폼">회원 인증 폼</h1>
<h2 id="input">Input</h2>
<p>먼저 폼에서 자주 쓰이는 input을 재사용하기 위해 하나의 컴포넌트로 분리했다.</p>
<h3 id="❗️-기존-코드">❗️ 기존 코드</h3>
<pre><code class="language-tsx">const emailInput = {
    id: 1,
    name: &#39;email&#39;,
    type: &#39;text&#39;,
    placeholder: &#39;이메일&#39;,
  };

const passwordInput = {
  id: 2,
  name: &#39;password&#39;,
  type: &#39;password&#39;,
  placeholder: &#39;비밀번호: 최소 8자리 이상 25자리 이하 (알파벳, 특수문자 포함)&#39;,
};

const passwordConfirmInput = {
  id: 3,
  name: &#39;passwordConfirm&#39;,
  type: &#39;password&#39;,
  placeholder: &#39;비밀번호 확인&#39;,
};
const businessNumberInput = {
  id: 4,
  name: &#39;businessNumber&#39;,
  type: &#39;text&#39;,
  placeholder: &#39;사업자등록번호 (11자리)&#39;,
  minLength: 11,
  maxLength: 11,
  onKeyDown: onKeyDownHandler,
};
... 생략</code></pre>
<h2 id="✅-변경-코드">✅ 변경 코드</h2>
<pre><code class="language-tsx">interface InputProps {
  value: Record&lt;string, string&gt;;
  onChangeHandler?: (e: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; void;
}

interface InputType {
  id: number;
  name: string;
  type: string;
  label?: string;
  disabled?: boolean;
  placeholder?: string;
}

const Input = ({ value, onChangeHandler }: InputProps) =&gt; {
  const path = useRouter().pathname;
  const { isPasswordValid, passwordValidationMessage } = useErrorMessage(value);

  const inputOptions: Record&lt;string, InputType[]&gt; = {
    &#39;/auth/login&#39;: [emailInput, passwordInput],
    &#39;/auth/signup&#39;: [emailInput, passwordSignUpInput, passwordConfirmInput, businessNameInput],
    &#39;/auth/findPassword&#39;: [emailInput],
    &#39;/auth/reset&#39;: [passwordInput, passwordConfirmInput],
    &#39;/admin/store&#39;: [storeEmailInput, bnoNumberInput, storeBusineesNameInput],
  };

  const inputs = inputOptions[path];

  return (
    &lt;&gt;
      {inputs.map((input: InputType) =&gt; {
        const key = input.name;
        const isPasswordConfirm = input.name === &#39;passwordConfirm&#39; &amp;&amp; path === &#39;/auth/signup&#39;;

        if (input) {
          return (
            &lt;div key={input.id}&gt;
              {path === &#39;/admin/store&#39; &amp;&amp; &lt;label htmlFor={input.name}&gt;{input.label}&lt;/label&gt;}
              {path === &#39;/auth/signup&#39; &amp;&amp; &lt;label htmlFor={input.name}&gt;{input.label}&lt;/label&gt;}
              &lt;input
                id={input.name}
                className={clsx(styles.input, {
                  [styles.inputError]: isPasswordConfirm &amp;&amp; !isPasswordValid,
                })}
                name={input.name}
                value={value[key]}
                onChange={onChangeHandler}
                type={input.type}
                placeholder={input.placeholder}
                disabled={input.disabled}
                required
              /&gt;
              {isPasswordConfirm &amp;&amp; (
                &lt;span className={isPasswordValid ? styles.match : styles.error}&gt;{passwordValidationMessage}&lt;/span&gt;
              )}
            &lt;/div&gt;
          );
        }
      })}
    &lt;/&gt;
  );</code></pre>
<p>기존에 Input 컴포넌트 안에 있던 정적 데이터 들은 별도로 data 폴더 안에 모듈화 시켜서 import 하는 방식으로 가져갔다.</p>
<p>props를 받아오기 위해 기본적으로 타입을 지정 해줬는데 value와 onChangeHandler는 useInput 커스텀 훅을 별도로 만들어서 value 초기값과 onChange 함수를 내려받았고 외부 컴포넌트에서 사용될 Input 컴포넌트에 속성을 부여해 줄 타입들도 지정을 미리해 놓았다.</p>
<p>나는 Form에서 쓰일 해당 컴포넌트를 단 한 번만 호출하고 싶었고, 그래서 진입 경로 별로 어떠한 input이 쓰일지 미리 객체화 시켜놨었던 것이다. (data 폴더로 분리한 input 객체들)</p>
<p><code>inputOptions</code> 를 보면 경로를 key로 지정해둠.</p>
<pre><code class="language-tsx">const { value, changeHandler } = useInput({
  email: &#39;&#39;,
  password: &#39;&#39;,
  passwordConfirm: &#39;&#39;,
  businessName: &#39;&#39;,
  businessNumber: &#39;&#39;,
});

&lt;form className={styles.form}&gt;
  ... 
  &lt;Input value={value} onChangeHandler={changeHandler} /&gt;
  ...
&lt;/form&gt;</code></pre>
<p>input 정보가 담긴 배열을 순회하면서 한 번에 호출만으로도 모든 input이 출력됐다.</p>
<h2 id="form">Form</h2>
<p>별도로 Form도 Input과 동일한 방식으로 페르소나처럼 객체화 시켜서 정보만 전달해주고 Form 컴포넌트를 4개의 페이지에 모두 재사용을 했다.</p>
<pre><code class="language-tsx">// auth.ts
export const LOGIN_DATA = {
  url: &#39;/auth/signup&#39;,
  subUrl: &#39;/auth/findPassword&#39;,
  title: &#39;편리함의 시작&#39;,
  subTitle: &#39;Magic Pos&#39;,
  subName: &#39;비밀번호를 잊으셨나요?&#39;,
  caption: &#39;아직 회원이 아니신가요? 회원가입하러 가기&#39;,
  buttonName: &#39;로그인&#39;,
  buttonSubName: &#39;회원가입&#39;,
};
export const SIGNUP_DATA = {
  url: &#39;/auth/login&#39;,
  title: &#39;편리함의 시작&#39;,
  subTitle: &#39;Magic Pos&#39;,
  buttonName: &#39;회원가입&#39;,
  subButtonName: &#39;인증하기&#39;,
  comment: &#39;사업자등록번호를 인증해 주세요.&#39;,
};
// ... 나머지 생략

// User.tsx
type AuthObjectType = Record&lt;string, string&gt;;

const User = () =&gt; {
  const auth = useAuthStore(state =&gt; state.auth);
  const path = useRouter().pathname;
  const router = useRouter();

  if (auth) router.push(&#39;/&#39;);

  const AuthData: Record&lt;string, AuthObjectType&gt; = {
    &#39;/auth/login&#39;: LOGIN_DATA,
    &#39;/auth/signup&#39;: SIGNUP_DATA,
    &#39;/auth/findPassword&#39;: FIND_PASSWORD_DATA,
    &#39;/auth/reset&#39;: UPDATE_PASSWORD_DATA,
    &#39;/auth/success&#39;: SIGNUP_SUCCESS_DATA,
  };

  return &lt;AuthForm data={AuthData[path]} /&gt;;
};

// AuthForm.tsx
const AuthForm = ({ data }: FormProps) =&gt; {
  const router = useRouter();
  const path = router.pathname;
  const {
    url,
    subUrl,
    title,
    subTitle,
    subName,
    buttonName,
    subButtonName,
    description,
    buttonSubName,
    subDescription,
    comment,
  } = data;

}</code></pre>
<hr>
<p>사실 리팩토링과 모듈화의 정답은 없지만 마냥 만족하지는 않는다. 개발 속도가 더디어 질 수 있기에 이쯤에서 마무리를 지어야 했지만 더 좋은 방법이 생각나면 추후 개선시킬 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[관리자 카테고리 사이드 바 만들기 (feat. zustand)]]></title>
            <link>https://velog.io/@hisung-ah/20240117</link>
            <guid>https://velog.io/@hisung-ah/20240117</guid>
            <pubDate>Wed, 17 Jan 2024 10:39:53 GMT</pubDate>
            <description><![CDATA[<p>평소에는 포스로 운영하고 관리 토글을 눌렀을 때, 관리자 모드로 전환되어 관리자 페이지가되는 형태의 웹사이트에서는 빼 놓을 수 없는 관리자 카테고리, 그러기 위해서는 카테고리 사이드 바가 필요했다.</p>
<blockquote>
<p><strong>※ 구현 시 고려해야할 조건</strong></p>
</blockquote>
<ul>
<li>사이드 바 안에도 토글이 들어가서 기존 토글 상태도 고려</li>
<li>관리자 페이지 첫 진입 시 디폴트 모드는 운영 모드</li>
<li>관리자 모드에서 운영 모드로 전환 시 <strong>테이블 관리 페이지</strong>로 전환</li>
</ul>
<hr>
<h1 id="사용자-경험">사용자 경험</h1>
<p>사이드 바를 구현하기 앞 서 가장 먼저 사용자 경험 측면에서 크게 총 3가지로 분류해봤다.
⎣ <em>테스트 후 보완해야할 점이 생긴다면 디벨롭 해야겠다.</em></p>
<ul>
<li><p>운영 모드일 때, 햄버거 메뉴 클릭 가능</p>
</li>
<li><p>관리자 모드일 때, 사이드 바에서 카테고리 클릭 시 Close 처리</p>
</li>
<li><p>사이드 바 Open 상태 일 때</p>
<ul>
<li>현재 어떤 모드인지 다른 모드로 전환하려면 어떻게 해야하는지 안내 문구 추가 </li>
<li>외부 영역 클릭 시 Close 처리</li>
<li>모드 전환 토글 추가</li>
<li>닫기 버튼 추가</li>
</ul>
</li>
</ul>
<h1 id="상태-관리">상태 관리</h1>
<p>사용자 경험을 고려해서 구현하려면 독립적인 2개의 상태 관리가 필요했다.</p>
<ol>
<li>토글 버튼 상태</li>
<li>사이드 바 상태</li>
</ol>
<p>컴포넌트를 분리하고 영향이 가는 곳에 조건부 렌더링을 하려면 전역에서 관리를 해야 했고 현 프로젝트의 전역 상태 관리 스택인 <code>zustand</code> 로 상태를 관리했다.</p>
<h2 id="토글">토글</h2>
<pre><code class="language-tsx">interface ToggleState {
  isChecked: boolean;
  changeToggle: () =&gt; void;
}

const useToggleState = create&lt;ToggleState&gt;(set =&gt; ({
  isChecked: true,
  changeToggle: () =&gt; set(state =&gt; ({ isChecked: !state.isChecked })),
}));</code></pre>
<h2 id="사이드-바">사이드 바</h2>
<pre><code class="language-tsx">interface SideBarState {
  isSideBarOpen: boolean;
  setIsSideBarOpen: (value: boolean) =&gt; void;
  toggleIsSideBarOpen: () =&gt; void;
}

const useSideBarState = create&lt;SideBarState&gt;(set =&gt; ({
  isSideBarOpen: false,
  setIsSideBarOpen: value =&gt; set(() =&gt; ({ isSideBarOpen: value })),
  toggleIsSideBarOpen: () =&gt; set(state =&gt; ({ isSideBarOpen: !state.isSideBarOpen })),
}));</code></pre>
<p>모드 전환 토글의 경우, 첫 진입 시 운영 모드이기 때문에 true</p>
<p>사이드 바의 경우, 외부 클릭 &amp; 카테고리 클릭 &amp; 닫기 버튼 등 이벤트를 위해서 직접 true/false 를 받기로 했다.
⎣ 이 부분을 토글 형태의 로직으로 간다면 마음 어려워진다. (해봤다...)</p>
<h1 id="로직-구현">로직 구현</h1>
<h2 id="토글-1">토글</h2>
<pre><code class="language-tsx">// HeaderToggleButton.tsx
const { isChecked, changeToggle } = useToggleState();

useEffect(() =&gt; {
  const currentPath = router.asPath;
  const managementPath = &#39;/admin/management&#39;;

  // 운영 모드 전환 시 가야할 위치
  if (isChecked &amp;&amp; currentPath !== managementPath) {
    router.push(managementPath);
  // 운영 모드 해제 시 가야할 위치
  } else if (!isChecked &amp;&amp; currentPath === managementPath) {
    router.push(&#39;/admin/table&#39;);
  }
}, [isChecked, router]);</code></pre>
<p>Store로 꺼내와서 현 상태 값으로 useEffect에 조건부를 걸어주면 끝이다.</p>
<h2 id="사이드-바-1">사이드 바</h2>
<pre><code class="language-tsx">const { isSideBarOpen, setIsSideBarOpen } = useSideBar();</code></pre>
<p>먼저 상태 관리 스토어에서 만든 로직을 가져오자</p>
<h2 id="외부-영역-클릭-시-닫힘">외부 영역 클릭 시 닫힘</h2>
<pre><code class="language-tsx">// Sidebar.tsx
const targetRef = useRef&lt;HTMLDivElement&gt;(null);

useEffect(() =&gt; {
  const closeSideBar = (e: MouseEvent) =&gt; {
    if (targetRef.current &amp;&amp; !targetRef.current.contains(e.target as Node)) {
      setIsSideBarOpen(false);
    }
  };

  window.addEventListener(&#39;mousedown&#39;, closeSideBar);

  return () =&gt; {
    window.removeEventListener(&#39;mousedown&#39;, closeSideBar);
  };
}, [setIsSideBarOpen]);

&lt;aside className={sidebarClass} ref={targetRef}&gt; 생략... &lt;/aside&gt;</code></pre>
<p>사용자가 클릭한 곳이 aside(targetRef.current)가 아니면 닫히는 로직이고 이벤트 리스너로는 mousedown을 등록해서 closeSideBar로 이벤트를 처리했다.
useEffect 안에서는 컴포넌트가 언마운트되는 경우, 또는 의존성 배열([setIsSideBarOpen])에 있는 값이 변경되는 경우에 실행되도록 했다.</p>
<h2 id="카테고리-클릭-시-닫힘">카테고리 클릭 시 닫힘</h2>
<pre><code class="language-tsx">const clickNavListHandler = useCallback(
  (id: number, url: string) =&gt; {

    생략...

    setIsSideBarOpen(false);
  },
  [setIsSideBarOpen],
);</code></pre>
<p>카테고리 클릭 시 페이지 전환과 동시에 isSideBarOpen 상태 값을 변경해줌으로써 닫히도록 했다.</p>
<h2 id="내부-토글-버튼-추가--닫기-버튼">내부 토글 버튼 추가 &amp; 닫기 버튼</h2>
<pre><code class="language-tsx">return (
  &lt;aside className={sidebarClass} ref={targetRef}&gt;
    &lt;div className={styles.closeButton}&gt;
      &lt;CloseButton width={40} height={40} onClick={() =&gt; setIsSideBarOpen(false)} /&gt;
    &lt;/div&gt;
    &lt;div className={styles.toggleButton}&gt;
      &lt;HeaderToggleButton /&gt;
    &lt;/div&gt;
   생략...
  &lt;/aside&gt;
);</code></pre>
<p>닫기 버튼은 SVG를 컴포넌트화 시켜서 사용했고 토글 버튼은 위에서 사용한 HeaderToggleButton 컴포넌트를 재사용했다.</p>
<h1 id="📹-결과">📹 결과</h1>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/b3ce1e4a-7409-4a3b-8eaf-2040b680a2c8/image.gif" alt=""></p>
<hr>
<p>디자이너님과 같이 협업 하면서 시안이 변경되면 로직도 같이 변경되는 부분에 있어서 많은 시행 착오가 있었다.
물론 컴포넌트를 잘 분리하고 상태 관리를 처음부터 잘 해놓으면 시안이 크게 변경되지 않는 이상 로직 자체가 크게 바뀌진 않는다고 생각한다.</p>
<p>단, 시간적인 이슈로 인해 초기에 와이어 프레임만 보고 시안이 나오지 않은 상태에서 작업을 하다 보니 최종 시안이 나온 후 재작업 도중 대변경이 일어났었던 거였다.</p>
<p>그리고 이번에 사이드 바를 작업하면서 가장 크게 느낀 거는 <code>변수명</code> 을 지정하는 부분이었다.</p>
<p>초기에는 사이드 바 상태 관리 변수명을 <code>isSideBarOpen</code> 가 아닌 <code>isOutSide</code> 로 지어서 boolean 타입만으로 생각을 하려고 하니 너무 헷갈렸었지만 변수명을 재정의 하고 나서 작업을 하니까 이해도 빠르고 명확해져서 훨씬 개발 속도가 빨라졌다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[탑 스크롤 만들기 (feat. 이벤트 최적화 적용 - debounce, useCallback)]]></title>
            <link>https://velog.io/@hisung-ah/20240116</link>
            <guid>https://velog.io/@hisung-ah/20240116</guid>
            <pubDate>Tue, 16 Jan 2024 10:33:39 GMT</pubDate>
            <description><![CDATA[<p>홈 페이지 하단 영역이 길어지는 만큼 사용자 경험 측면을 고려해서 <code>Top 버튼</code> 이 필요했다.</p>
<h1 id="scrolltop">ScrollTop</h1>
<pre><code class="language-tsx">const ScrollTop = () =&gt; {
  const [showButton, setShowButton] = useState(false);

  const clickScrollTopHandler = () =&gt; {
    window.scrollTo({ top: 0, behavior: &#39;smooth&#39; });
  };

  useEffect(() =&gt; {
    const clickShowButtonHandler = () =&gt; {
      if (window.scrollY &gt; 700) {
        setShowButton(true);
      } else {
        setShowButton(false);
      }
    };

    window.addEventListener(&#39;scroll&#39;, clickShowButtonHandler);

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

  return (
    &lt;&gt;
      {showButton &amp;&amp; (
        &lt;div className={styles.scrollTopBox}&gt;
          &lt;button onClick={clickScrollTopHandler}&gt;
            &lt;p&gt;Top&lt;/p&gt;
          &lt;/button&gt;
        &lt;/div&gt;
      )}
    &lt;/&gt;
  );
};
</code></pre>
<p>먼저 show/hide 처리를 하기 위해 useState로 버튼 상태 관리를 진행했고 window 내장 API인 scrollTo로 클릭 핸들러를 생성해서 페이지 상단으로 부드럽게 스크롤 되도록 옵션을 부여했다.</p>
<p>그리고 useEffect를 사용해서 마운트 될 때 scroll 이벤트 리스너로 clickShowButtonHandler 함수를 콜백했고 언마운트 시, scroll 이벤트를 정리했다.</p>
<blockquote>
<p>window.scrollY &gt; 700 조건은 스크롤 위치가 700px 이상일 때를 의미한다.</p>
</blockquote>
<p>단, 리렌더링 여부를 파악하기 위해 console.log를 출력한 결과...</p>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/89458002-6234-452c-8fb2-821a42889f76/image.gif" alt=""></p>
<p>상당히 마음이 불편해진 결과를 볼 수 있었다.</p>
<h2 id="usecallback-적용">useCallback 적용</h2>
<p>clickShowButtonHandler 함수가 스크롤 이벤트가 발생할 때 마다 호출되고 있어서 성능적인 측면에서 굉장히 떨어졌고 이벤트 핸들러 내에서 사용되는 조건문을 최적화 하기 위해서 <code>useCallback</code> 훅을 적용해서 clickScrollTopHandler와 clickShowButtonHandler 함수가 필요할 때만 재생성되도록 했다.</p>
<pre><code class="language-tsx">const ScrollTop = () =&gt; {
  const [showButton, setShowButton] = useState(false);

  const clickScrollTopHandler = useCallback(() =&gt; {
    window.scrollTo({ top: 0, behavior: &#39;smooth&#39; });
  }, []);

  const clickShowButtonHandler = useCallback(() =&gt; {
    setShowButton(window.scrollY &gt; 700);
  }, []);

  useEffect(() =&gt; {
    window.addEventListener(&#39;scroll&#39;, clickShowButtonHandler);

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

  return (
    showButton &amp;&amp; (
      &lt;div className={styles.scrollTopBox}&gt;
        &lt;button onClick={clickScrollTopHandler}&gt;Top&lt;/button&gt;
      &lt;/div&gt;
    )
  );
};</code></pre>
<p>적용 시킨 코드 결과이다. </p>
<p>clickShowButtonHandler을 이벤트 핸들러 내부에서 setShowButton을 직접 호출하도록 해서 불필요한 조건문을 줄였다.</p>
<p>더불어 useCallback을 적용시킴으로써 위 작업한 컴포넌트가 리렌더링될 때마다 불필요한 함수 생성을 방지해서 메모리 사용량을 줄이고 성능을 향상 시킨 결과를 얻었다.</p>
<blockquote>
<p>더 이상 console.log로 인한 마음이 어려워지는 현상은 가셨다.</p>
</blockquote>
<h2 id="debounce-적용">debounce 적용</h2>
<p>패키지의 맞게 설치하면 된다.</p>
<pre><code>npm install @types/lodash
yarn add @types/lodash</code></pre><blockquote>
<p>debounce 함수는 연속된 호출을 그룹화하여 단 하나의 함수 호출만 실행되도록 지연시키는 기법</p>
</blockquote>
<pre><code class="language-tsx">import { debounce } from &#39;lodash&#39;;

const clickShowButtonHandler = debounce(() =&gt; {
    setShowButton(window.scrollY &gt; 700);
  }, 100);

useEffect(() =&gt; {
    ...

    return () =&gt; {
      window.removeEventListener(&#39;scroll&#39;, clickShowButtonHandler);
      clickShowButtonHandler.cancel();
    };
  }, [clickShowButtonHandler]);</code></pre>
<p>굉장히 간단하게도 debounce를 import 후 호출하고 setShowButton을 감싸주면 된다.</p>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/56085c29-92da-401b-a468-edcf24d8aa96/image.gif" alt=""></p>
<p>이렇게되면 빈번하게 일어나는 이벤트의 발생을 줄여주는 효과를 볼 수 있다. 난 100밀리초로 해놔서 1초마다 지연시켜 호출시켰다.</p>
<blockquote>
<p>쉽게 이해를 돕자면 1초 마다 지연 시키면서 setShowButton 함수가 호출되면서 showButton 상태를 업데이트한다. (스크롤이 멈춘 후에 함수를 실행하도록 하는 것이다.) <br>
그리고 컴포넌트가 언마운트될 때 마다 debounce에 의해 예약된 호출을 취소하는 cancel 메서드를 호출하는 것도 중요하다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[스크롤 애니메이션 구현 (Intersection Observer)]]></title>
            <link>https://velog.io/@hisung-ah/20240115</link>
            <guid>https://velog.io/@hisung-ah/20240115</guid>
            <pubDate>Sun, 14 Jan 2024 11:16:07 GMT</pubDate>
            <description><![CDATA[<p>메인 페이지의 심심함에서 벗어나고자 스크롤 애니메이션을 덧붙였다.</p>
<p>원래는 react-scroll-motion 라이브러리를 사용해 봤지만 css <code>@keyframe</code> 을 사용하는 게 더 익숙해서 별도의 라이브러리는 사용하지 않았고 스크롤 시 화면 감지를 위해 Intersection Observer를 사용했다.</p>
<h1 id="intersection-observer-설정">Intersection Observer 설정</h1>
<h2 id="설치">설치</h2>
<pre><code>yarn add react-intersectiono-bserver</code></pre><h2 id="구현-내용">구현 내용</h2>
<pre><code class="language-tsx">interface ObserverOptions {
  root?: Element | null;
  rootMargin?: string;
  threshold?: number | number[];
}

interface UseObserverParams {
  target: MutableRefObject&lt;HTMLElement | null&gt;;
  option: ObserverOptions;
}

export const useObserver = ({ target, option }: UseObserverParams) =&gt; {
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() =&gt; {
    const currentTarget = target.current;
    const observer = new IntersectionObserver(entries =&gt; {
      entries.forEach(entry =&gt; {
        if (entry.isIntersecting) {
          setIsVisible(entry.isIntersecting);
        } else {
          setIsVisible(entry.isIntersecting);
        }
      });
    }, option);

    if (currentTarget) {
      observer.observe(currentTarget);
    }

    return () =&gt; {
      if (currentTarget) {
        observer.unobserve(currentTarget);
      }
    };
  }, [option, target]);

  return { isVisible };
};</code></pre>
<p>Intersection Observer 에서 쓰이는 옵션 타입과 매개변수로 스크롤 시 감지할 대상(Ref)과 옵션을 받아와야 하는 타입도 지정했다.</p>
<p>IntersectionObserver 생성자를 만든 후 해당 참조한 영역 entries 유무를 파악해서 isVisble 상태를 변경했고 다른 페이지에서도 쓰일 것을 대비해서 커스텀 훅으로 분리했다.
→ isVisible은 상태에 따라 css를 조건부 클래스로 주기 위함이다.</p>
<h2 id="컴포넌트-호출">컴포넌트 호출</h2>
<pre><code class="language-tsx">const target = useRef(null);
const { isVisible } = useObserver({ target, option: { threshold: 0.3 } });

&lt;section className={`${styles.fadeWrapper} ${isVisible ? styles.fadeIn : &#39;&#39;}`} ref={target}&gt;
  &lt;div className={styles.fadaContainer}&gt;
    &lt;h1&gt;꼼꼼한 매출 분석까지 도와드려요.&lt;/h1&gt;
  &lt;/div&gt;
&lt;/section&gt;</code></pre>
<p>useObserver 라는 커스텀 훅을 컴포넌트에서 호출하고 훅에서 반환한 isVisible 상태 값으로 스타일 클래스를 조건부로 부여했다.</p>
<pre><code class="language-css">.fade-wrapper {
  display: flex;
  height: 140rem;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  position: sticky;
}

.fade-in {
  animation: fade-in 2s ease-in-out;
}
@keyframes fade-in {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}</code></pre>
<p>부모 클래스의 position: sticky 라는 속성을 줬는데 position값을 sticky로 설정할 경우 relative처럼 동작하면서 relative 요소가 없는 경우에는 fixed처럼 동작하는 두 가지 효과를 모두 가질 수 있다.</p>
<blockquote>
<p><strong>sticky를 사용하면서 주의해야할 점</strong></p>
</blockquote>
<ul>
<li>top, left, bottom, right의 값 여부</li>
<li>부모 요소에 해당하는 모든 엘리먼트의 overflow 값 hidden 여부 확인
이 중에서 방향을 설정하지 않고 overflow를 hidden으로 값을 부여하면 정상적으로 동작하지 않는다.
난 여기서 좀 삽질을 했었다...</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[개발 일지] 이슈 사항 - 서버 & 클라이언트 렌더링 구성 요소 비교 에러 (feat. Text content does not match server-rendered HTML)]]></title>
            <link>https://velog.io/@hisung-ah/20240112</link>
            <guid>https://velog.io/@hisung-ah/20240112</guid>
            <pubDate>Fri, 12 Jan 2024 05:16:49 GMT</pubDate>
            <description><![CDATA[<p>클라이언트 전역 상태 라이브러리 zustand를 사용하다가 에러가 발생했다.</p>
<h1 id="원인">원인</h1>
<p>supabase Authentication session API를 사용해서 조건부 렌더링을 하던 도중 해당 에러를 마주하게 됐다.</p>
<h2 id="zustand-persist">zustand persist</h2>
<pre><code class="language-tsx">const useAuthStore = create&lt;AuthState&gt;()(
  persist(
    set =&gt; ({
      session: null,
      setSession: session =&gt; set({ session }),
    }),
    { name: &#39;session-status&#39; },
  ),
);</code></pre>
<p>스토리지의 저장하기 위해 <code>persist</code> 를 사용했고 처음엔 로직 상 문제는 없었보였지만 에러에서 착하게도 던져준 링크와 zustand 공식 문서에 나와있는 내용을 확인해보니 프레임워크 상 Next.js는 SSR을 사용하니까 서버에서 렌더링된 요소와 클라이언트에서 렌더링된 구성 요소를 비교하게 되는데 구성 요소를 변경하기 위해 브라우저의 데이터를 사용하고 있어서 두 렌더링이 다르게 되니까 Next에서 경고가 표시됐었다.</p>
<p>zustand에서 말하는 에러 메세지는 총 3개이고, 우리 팀원 분도 2번 에러가 발생했었다.</p>
<ol>
<li><p><span style="color: #EB5757"><strong><code>Text content does not match server-rendered HTML</code></strong></span></p>
</li>
<li><p><span style="color: #EB5757"><strong><code>Hydration failed because the initial UI does not match what was rendered on the server</code></strong></span></p>
</li>
<li><p><span style="color: #EB5757"><strong><code>There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering</code></strong></span></p>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/6688a478-20d0-4df2-8a2d-fd8bfe0feb35/image.png" alt=""></p>
<h1 id="해결">해결</h1>
<p>에러를 해결하기 위해서 Next에서 제시한 방법은 useEffect를 클라이언트에서만 사용하는 방법과 
<code>사전 렌더링 비활성화</code> 를 하는 동적 import 방법을 제시했다.</p>
<p>근데 사전 렌더링 비활성화를 하는 방법을 사용하면 서버 사이드와 클라이언트 사이드에 상태가 다를 수도 있을 거 같아서 사용하지 않았고 에러 발생 지점 컴포넌트에서 useEffect를 사용하여 로딩을 걸어줬다.</p>
<pre><code class="language-tsx">const StickBar = () =&gt; {
  const { logout } = useAuth();
  const { session } = useAuthStore();
  const [isLoaded, setIsLoaded] = useState(false);


  useEffect(() =&gt; {
    setIsLoaded(true);
  }, []);

  return (
    &lt;&gt;
      {isLoaded &amp;&amp; (
        &lt;div className={styles.wrapper}&gt;
          // ...

          &lt;div className={styles.tabArea}&gt;
            {session === null ? (
              &lt;&gt;
                &lt;Link href=&quot;/auth/signup&quot;&gt;회원가입&lt;/Link&gt;
                &lt;Link href=&quot;/auth/login&quot;&gt;로그인&lt;/Link&gt;
                &lt;/&gt;
            ) : (
              &lt;&gt;
                &lt;Link href=&quot;/&quot; onClick={() =&gt; logout()}&gt;
                  로그아웃
                &lt;/Link&gt;
                &lt;/&gt;
            )}
          &lt;/div&gt;
        &lt;/div&gt;
      )}
      &lt;/&gt;
  );
};

export default StickBar;</code></pre>
<p>zustand 에서도 요소를 비교하고 변경하기 전에 잠시 기다리라는 커스텀 훅을 만들라고 한다. 그런데 특정 한 컴포넌트에서만 그러니 별도로 만들지는 않았다.</p>
<hr>
<blockquote>
<p>Next.js 공식 문서 참고: <a href="https://nextjs.org/docs/messages/react-hydration-error">https://nextjs.org/docs/messages/react-hydration-error</a>
zustand: 공식 문서 참고: <a href="https://docs.pmnd.rs/zustand/integrations/persisting-store-data#hydration-and-asynchronous-storages">https://docs.pmnd.rs/zustand/integrations/persisting-store-data#hydration-and-asynchronous-storages</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[개발 일지] 메인 페이지 레이아웃 잡기 (크로스 슬라이더)]]></title>
            <link>https://velog.io/@hisung-ah/20240111</link>
            <guid>https://velog.io/@hisung-ah/20240111</guid>
            <pubDate>Thu, 11 Jan 2024 13:42:56 GMT</pubDate>
            <description><![CDATA[<p>첫 진입 시 보여지는 마케팅(소개) 페이지 레이아웃을 디자이너분과 같이 잡았고
페이지 내에서 분리해야할 컴포넌트를 나열 후 이를 토대로 섹션을 잡았다. 현재는 추가 구현 중인 상태이다.</p>
<h1 id="헤더">헤더</h1>
<pre><code class="language-tsx">const Header = () =&gt; {
  return (
    &lt;header className={styles.headerWrapper}&gt;
      &lt;Link href=&quot;/&quot;&gt;Magic Pos&lt;/Link&gt;
    &lt;/header&gt;
  );
};</code></pre>
<p>헤더의 역할은 로고 뿐이었다..</p>
<h1 id="푸터">푸터</h1>
<pre><code class="language-tsx">const Footer = () =&gt; {
  return (
    &lt;footer className={styles.wrapper}&gt;
      &lt;Link href=&quot;/&quot;&gt;Magic Pos&lt;/Link&gt;
      &lt;div className={styles.info}&gt;
        &lt;p&gt;사업자등록번호: 123-45-678910 서울시 강남구 역삼동 대표: OOO&lt;/p&gt;
        &lt;p&gt;E-MAIL: ...&lt;/p&gt;
        &lt;div className={styles.textArea}&gt;
          &lt;p&gt;COPYRIGHT © 2024 MAGICPOS. ALL RIGHTS RESERVED.&lt;/p&gt;
          &lt;p className={styles.bold}&gt;DESIGN BY ...&lt;/p&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/footer&gt;
  );
};</code></pre>
<h1 id="콘텐츠-슬라이더">콘텐츠 슬라이더</h1>
<pre><code class="language-tsx">const SliderArea = () =&gt; {
  const dataChunks = [data.slice(0, 10), data.slice(10, 20)];

  return (
    &lt;section&gt;
      {dataChunks.map((chunk, chunkIndex) =&gt; (
        &lt;div key={chunkIndex} className={styles.cardWrapper}&gt;
          &lt;ul className={chunkIndex === 0 ? styles.cardList : styles.cardListDynamic}&gt;
            {chunk.map(list =&gt; (
              &lt;li key={list.id + chunkIndex * 10}&gt;
                &lt;p&gt;{list.icon}&lt;/p&gt;
                &lt;p&gt;{list.title}&lt;/p&gt;
                &lt;p&gt;{list.description}&lt;/p&gt;
              &lt;/li&gt;
            ))}
          &lt;/ul&gt;
        &lt;/div&gt;
      ))}
    &lt;/section&gt;
  );
};</code></pre>
<h2 id="css">css</h2>
<pre><code class="language-css">.card-list,
.card-list-dynamic {
  animation-duration: 20s;
  animation-fill-mode: forwards;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
}
.card-list {
  animation-name: slidedown;
}
.card-list-dynamic {
  animation-name: slideup;
}

@keyframes slidedown {
  0% {
    transform: translateX(50%);
  }
  100% {
    transform: translateX(0);
  }
}

@keyframes slideup {
  0% {
    transform: translateX(0);
  }
  100% {
    transform: translateX(50%);
  }
}</code></pre>
<p>임시 더미 데이터를 생성해서 가운데 영역의 들어갈 빈 내용물들은 채워줬다.</p>
<p>데이터는 위 이미지처럼 크로스 형식의 애니메이션으로 구현해서 총 20개의 데이터들을 10개 단위로 분리 후 이중 map으로 처리했다.</p>
<p>더미는 추후 DB에 있는 사업장 로고 및 이모지 등으로 채워질 예정이다.</p>
<h1 id="고정-서브-바">고정 서브 바</h1>
<pre><code class="language-tsx">const StickBar = () =&gt; {
  return (
    &lt;div className={styles.wrapper}&gt;
      &lt;Link className={styles.logo} href=&quot;/&quot;&gt;
        Magic pos
      &lt;/Link&gt;

      &lt;div className={styles.tabArea}&gt;
        &lt;Link href=&quot;/auth/login&quot;&gt;로그인&lt;/Link&gt;
        &lt;Link href=&quot;/auth/signup&quot;&gt;회원가입&lt;/Link&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
};</code></pre>
<p>헤더에만 로그인 &amp; 회원가입이 있다는 편견에서 벗어나 하단 서브 바로 레이아웃을 가져가봤다.</p>
<h1 id="✅-todo">✅ TODO</h1>
<ul>
<li>시작하기 버튼 클릭 시 로그인 세션에 따른 경로 분기처리</li>
<li>로그인 후 페이지 접속 시 서브 바 텍스트 상태 조건부 처리</li>
<li>슬라이더 크기 조정 및 애니메이션 끊김 여부 테스트</li>
<li>하단 콘텐츠 및 스크롤 애니메이션 추가</li>
</ul>
<p>위 사항들 반영 후 다음 포스팅을 작성해 보겠다 🙌</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[개발 일지] 매직 포스 - 유저 form 컴포넌트 모듈화 & 코드 리팩토링]]></title>
            <link>https://velog.io/@hisung-ah/20240109</link>
            <guid>https://velog.io/@hisung-ah/20240109</guid>
            <pubDate>Tue, 09 Jan 2024 14:37:24 GMT</pubDate>
            <description><![CDATA[<h1 id="today">Today</h1>
<ul>
<li>Auth input, form 컴포넌트 모듈화 &amp; 코드 리팩토링</li>
</ul>
<hr>
<h2 id="유저-기능-코드-리팩토링">유저 기능 코드 리팩토링</h2>
<p>기존 Form 양식에 중복되는 input이 많다 보니 Form 전용 input을 재사용 하기 위해 별도의 컴포넌트로 분리하면 어떨까라는 생각을 했었다.</p>
<h2 id="기존-코드">기존 코드</h2>
<pre><code class="language-tsx">&lt;input className=&quot;...&quot; type=&quot;text&quot; name=&quot;email&quot; onChange={...} placeholder=&quot;이메일&quot; /&gt;
&lt;input className=&quot;...&quot; type=&quot;password&quot; name=&quot;password&quot; onChange={...} placeholder=&quot;비밀번호&quot; /&gt;

// 그 외에 input들...</code></pre>
<h2 id="개선-코드">개선 코드</h2>
<pre><code class="language-tsx">const emailInput = {
    id: 1,
    name: &#39;email&#39;,
    type: &#39;text&#39;,
    placeholder: &#39;이메일&#39;,
  };

const passwordInput = {
  id: 2,
  name: &#39;password&#39;,
  type: &#39;password&#39;,
  placeholder: &#39;비밀번호: 최소 8자리 이상 25자리 이하 (알파벳, 특수문자 포함)&#39;,
};

const passwordConfirmInput = {
  id: 3,
  name: &#39;passwordConfirm&#39;,
  type: &#39;password&#39;,
  placeholder: &#39;비밀번호 확인&#39;,
};
const businessNumberInput = {
  id: 4,
  name: &#39;businessNumber&#39;,
  type: &#39;text&#39;,
  placeholder: &#39;사업자등록번호 (11자리)&#39;,
  minLength: 11,
  maxLength: 11,
  onKeyDown: onKeyDownHandler,
};

const inputOptions: Record&lt;string, InputType[]&gt; = {
  &#39;/auth/login&#39;: [emailInput, passwordInput],
  &#39;/auth/signup&#39;: [emailInput, passwordInput, passwordConfirmInput, businessNumberInput],
  &#39;/auth/findPassword&#39;: [emailInput],
  &#39;/auth/reset&#39;: [passwordInput, passwordConfirmInput],
};

const inputs = inputOptions[path];

return (
  &lt;&gt;
    {inputs.map((input: InputType) =&gt; {
      const key = input.name as keyof typeof value;
      if (input) {
        return (
          &lt;input
            key={input.id}
            className={styles[&#39;input&#39;]}
            name={input.name}
            value={value[key]}
            onChange={onChangeHandler}
            type={input.type}
            placeholder={input.placeholder}
            minLength={input.minLength}
            maxLength={input.maxLength}
            onKeyDown={input.onKeyDown}
            required
            /&gt;
        );
      }
    })}
    &lt;/&gt;
);</code></pre>
<p>각 input 속성으로 들어 갈 내용들을 객체로 정리 하고 path 기준으로 배열로 분리했다. 훨씬 보기 간결하고 이해하기 쉬워졌으나 길어지는 건 마찬가지였다.</p>
<p>하지만, 해당 배열의 각 들어간 요소들을 map으로 출력해주니 input 요소는 한 개만 작성해도 되다 보니 마음이 편해졌다 😀</p>
<p>결론은 Form 컴포넌트 안에는 아래 한 줄만 추가하면 Form이 있는 경로 마다 input이 동적으로 변경된다.</p>
<pre><code class="language-tsx">&lt;Input ... /&gt;</code></pre>
<h2 id="회원가입--로그인-form-리팩토링">회원가입 &amp; 로그인 Form 리팩토링</h2>
<p>결국엔 Form도 계속 재사용을 해야 하기 때문에 각각의 불러올 데이터로 세분화 해서 Form으로 데이터로 보내줬다.</p>
<h3 id="보낼-데이터들">보낼 데이터들</h3>
<pre><code class="language-tsx">import { useRouter } from &#39;next/router&#39;;
import AuthForm from &#39;./AuthForm&#39;;

type AuthObjectType = Record&lt;string, string&gt;;

const User = () =&gt; {
  const path = useRouter().pathname;

  const LOGIN_DATA = {
    url: &#39;/auth/signup&#39;,
    subUrl: &#39;/auth/findPassword&#39;,
    title: &#39;편리함의 시작&#39;,
    subTitle: &#39;Magic Pos&#39;,
    subName: &#39;비밀번호를 잊으셨나요?&#39;,
    caption: &#39;아직 회원이 아니신가요? 회원가입하러 가기&#39;,
    buttonName: &#39;로그인&#39;,
  };
  const SIGNUP_DATA = {
    url: &#39;/auth/login&#39;,
    title: &#39;편리함의 시작&#39;,
    subTitle: &#39;Magic Pos&#39;,
    caption: &#39;이미 가입을 하셨나요? 로그인하러 가기&#39;,
    buttonName: &#39;회원가입&#39;,
    subButtonName: &#39;사업자등록번호 인증&#39;,
  };
  const FIND_PASSWORD_DATA = {
    title: &#39;편리함의 시작&#39;,
    subTitle: &#39;Magic Pos&#39;,
    description: &#39;가입하신 이메일을 입력해 주세요.&#39;,
    buttonName: &#39;링크 전송&#39;,
  };
  const UPDATE_PASSWORD_DATA = {
    title: &#39;편리함의 시작&#39;,
    subTitle: &#39;Magic Pos&#39;,
    buttonName: &#39;비밀번호 변경&#39;,
    description: &#39;새로운 비밀번호를 입력해 주세요.&#39;,
  };

  const AuthData: Record&lt;string, AuthObjectType&gt; = {
    &#39;/auth/login&#39;: LOGIN_DATA,
    &#39;/auth/signup&#39;: SIGNUP_DATA,
    &#39;/auth/findPassword&#39;: FIND_PASSWORD_DATA,
    &#39;/auth/reset&#39;: UPDATE_PASSWORD_DATA,
  };

  return &lt;AuthForm data={AuthData[path]} /&gt;;
};

export default User;</code></pre>
<p>Form으로 보낼 데이터들 또한, 객체 형태로 만들어서 집합 객체로 감싸주었고 Key를 path로 지정한 다음에 path가 들어오면 <code>AuthData[path]</code> 로 path의 key를 매칭해서 데이터를 보내준 형태로 작성했다.</p>
<p>만약 보여줘야할 Form이 늘어난다면 한 객체씩 추가를 해줘야하는 수고가 들긴 한다..</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[개발 일지] 매직 포스 - 사업자등록번호 조회 API]]></title>
            <link>https://velog.io/@hisung-ah/20240108</link>
            <guid>https://velog.io/@hisung-ah/20240108</guid>
            <pubDate>Mon, 08 Jan 2024 16:45:57 GMT</pubDate>
            <description><![CDATA[<h1 id="today">Today</h1>
<ul>
<li>Auth 기능 개발 (회원가입, 로그인, 로그아웃 등)</li>
<li>공공데이터포털 사업자등록번호 조회 API 연동</li>
</ul>
<hr>
<h1 id="사업자등록번호-조회-api-연동">사업자등록번호 조회 API 연동</h1>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/fe9e1575-0429-4364-89bc-276b9d5daa23/image.png" alt=""></p>
<p>회원가입을 하려면 이메일, 비밀번호 외로 사업자등록번호 11자리를 입력 후 인증이 되어야 회원가입을 완료할 수 있다.</p>
<p>그러기에 구현하는 사이트에 적합한 API를 제공해주기 때문에 처음으로 공공데이터포털 사이트에서 API를 활용해봤다.</p>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/958dc3f0-01ea-455a-bdf6-76a6dd2df187/image.png" alt=""></p>
<blockquote>
<p>해당 사이트에 접속해서 <a href="https://www.data.go.kr/data/15081808/openapi.do">https://www.data.go.kr/data/15081808/openapi.do</a> &quot;활용신청&quot; 버튼을 누른 후 마이페이지로 이동하면 인증키가 발급되고 해당 인증키로 API 요청을 진행하면 된다.</p>
</blockquote>
<p>마이페이지 가보면 활용 명세서가 잘 되어 있어서 절차대로 진행하면 원하는 데이터를 받아올 수 있고 데이터 포맷은 JSON + XML이고 기본 값은 JSON 형태이다.</p>
<h2 id="요청-코드">요청 코드</h2>
<pre><code class="language-tsx">// Axios
export const businessNumberCheckHandler = async (bno: string) =&gt; {
  const data = { b_no: [bno] };
  const response = await axios.post(
    `https://api.odcloud.kr/api/nts-businessman/v1/status?serviceKey=${process.env.NEXT_PUBLIC_PUBLIC_DATA_API_KEY}`,
    data,
  );
  console.log(response);

  return response.status === 200 &amp;&amp; response.data.data[0].tax_type_cd === &#39;01&#39;
    ? &#39;인증되었습니다.&#39;
    : response.data.data[0].tax_type;
};

// React-Query
const businessNumberCheckMutation = useMutation({
    mutationFn: businessNumberCheckHandler,
    onSuccess: data =&gt; {
      queryClient.invalidateQueries({ queryKey: [QUERY_KEY.BUSINESS] });
      alert(data);
    },
    onError: error =&gt; {
      console.error(error);
    },
  });</code></pre>
<p>명세서에 따라 POST 방식만 가능하다 하여 axios로 요청을 했다.</p>
<p><img src="https://velog.velcdn.com/images/hisung-ah/post/9a04ae61-3e68-4cfb-a7cc-6c4f8a747a9f/image.png" alt=""></p>
<p>응답은 위와 같고 받은 데이터를 토대로 메세지를 출력했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js - API 라우터]]></title>
            <link>https://velog.io/@hisung-ah/20231221</link>
            <guid>https://velog.io/@hisung-ah/20231221</guid>
            <pubDate>Thu, 21 Dec 2023 08:02:15 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>API 라우터는 API를 호출할 수 있는 엔드포인트를 생성해준다. 파일 기반 라우팅과 동일하게 파일 기반으로 엔드포인트가 생성된다. <code>pages/api</code> 폴더 내부에 파일을 생성하고 함수를 작성하면 함수를 기반으로 API를 내려줄 수가 있다.</p>
</blockquote>
<h1 id="페이지-라우터">페이지 라우터</h1>
<h2 id="코드-예시">코드 예시</h2>
<pre><code class="language-tsx">import type { NextApiRequest, NextApiResponse } from &#39;next&#39;

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === &#39;POST&#39;) {
    const result = await 함수();
    res.status(200).json({ result });
  } else {
    res.status(500).json({ error: &#39;에러 발생&#39; })
  }
}</code></pre>
<p><a href="https://nextjs.org/docs/pages/building-your-application/routing/api-routes">공식 문서 코드</a>의 예제를 보면 위와 같이 <code>handler</code> 함수만 있다.</p>
<p>2개의 매개변수는 next에서 제공하는 타입으로 지정해주고 요청 메서드로 분기처리 해서 로직을 설계하면 끝이다.</p>
<p>단, 위와 같은 상황에서 로직이 길어지면 가독성이 낮아지는 경향이 있기 때문에 별도의 유틸리티 파일로 분리해서 만드는 게 좋을 듯 하다.</p>
<blockquote>
<p>페이지 라우터 기준이다.</p>
</blockquote>
<h1 id="앱-라우터">앱 라우터</h1>
<p>앱 라우터의 경우 <code>app/api</code>    폴더의 route.ts 파일로 작성할 수 있지만 기존 API 라우터와 같이 하나의 함수만 존재하는 것은 아니다.</p>
<h2 id="코드-예시-1">코드 예시</h2>
<pre><code class="language-tsx">export async function GET(req: NextApiRequest, res: NextApiResponse) {
  const result = await 함수();
  res.status(200).json({ result });
}

export async function HEAD(request: Request) {}

export async function POST(request: Request) {}

export async function PUT(request: Request) {}

export async function DELETE(request: Request) {}

export async function PATCH(request: Request) {}

export async function OPTIONS(request: Request) {}</code></pre>
<p>위와 같이 여러 개를 정의할 수 있고 HTTP methods 이름을 함수명으로 사용하면 된다.</p>
<p>그래서 직관적으로 파일을 관리하고 사용하려면 가령 <code>httpMethods/GET.ts or POST.ts ...</code> 이런 방식으로 폴더 트리를 가져가도 될 듯 하다.</p>
<hr>
<h1 id="✏️-회고">✏️ 회고</h1>
<p>API 라우터를 간단하게 알아봤는데 코드를 어떻게 분리시킬지 고민해보고 추후 작성을 해봐야할 것 같다.
요청 시 인터셉터를 하나 만들어서 요청 전 처리해야할 부분도 필요시 만들면 좋을 듯 싶다.</p>
]]></description>
        </item>
    </channel>
</rss>