<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>byunghun.log</title>
        <link>https://velog.io/</link>
        <description>재밌는 걸 만드는 것을 좋아하는 메이커</description>
        <lastBuildDate>Sun, 24 Nov 2024 02:54:20 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>byunghun.log</title>
            <url>https://velog.velcdn.com/images/byunghun-jake/profile/cacda937-7346-4733-831c-39ff5117dac3/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. byunghun.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/byunghun-jake" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[찾아보고, 고민한 것들] 자동 저장]]></title>
            <link>https://velog.io/@byunghun-jake/%EC%B0%BE%EC%95%84%EB%B3%B4%EA%B3%A0-%EA%B3%A0%EB%AF%BC%ED%95%9C-%EA%B2%83%EB%93%A4-%EC%9E%90%EB%8F%99-%EC%A0%80%EC%9E%A5</link>
            <guid>https://velog.io/@byunghun-jake/%EC%B0%BE%EC%95%84%EB%B3%B4%EA%B3%A0-%EA%B3%A0%EB%AF%BC%ED%95%9C-%EA%B2%83%EB%93%A4-%EC%9E%90%EB%8F%99-%EC%A0%80%EC%9E%A5</guid>
            <pubDate>Sun, 24 Nov 2024 02:54:20 GMT</pubDate>
            <description><![CDATA[<h3 id="찾아본-것들">찾아본 것들</h3>
<h4 id="to-save-or-to-autosave-autosaving-patterns-in-modern-web-applications"><a href="https://medium.com/@brooklyndippo/to-save-or-to-autosave-autosaving-patterns-in-modern-web-applications-39c26061aa6b">To save or to autosave: Autosaving patterns in modern web applications</a></h4>
<p>자동 저장에 대한 패턴을 소개합니다.</p>
<ol>
<li>Autosave on a time interval.</li>
<li>Autosave on an action interval.</li>
<li>Autosave when action is followed by inaction.</li>
<li>Autosave when focus is shifted.</li>
</ol>
<p>+) Autosave with web sockets</p>
<h3 id="고민한-것들">고민한 것들</h3>
<h4 id="자동-저장-요청을-보낸-후-새롭게-변경된-서버-상태를-기반으로-다시-동기화해야-하는가">자동 저장 요청을 보낸 후, 새롭게 변경된 서버 상태를 기반으로 다시 동기화해야 하는가?</h4>
<p>최신화된 서버 상태를 기반으로 클라이언트에 동기화한다는 것은 최신 상태의 허브를 &quot;서버&quot;로 둔다는 것.
<strong>여러 클라이언트가 함께 작업</strong>한다면, &quot;서버&quot;가 최신 상태를 관리하고 이를 동기화함으로써 충돌을 방지할 수 있을 것이다.</p>
<p>이는 곧, 동시 작업을 수행하지 않는 경우에는 불필요한 작업일 수 있다는 것이기도 함.</p>
<blockquote>
</blockquote>
<p>현재 진행하는 프로젝트에서 &quot;동시 작업&quot;은 제공하고자 하는 기능이 아니기 때문에, 서버 상태를 기반으로 동기화하는 것은 진행하지 않기로 결정했음.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[error] vscode에서 eslint가 안돼요 (next 15)]]></title>
            <link>https://velog.io/@byunghun-jake/error-vscode%EC%97%90%EC%84%9C-eslint%EA%B0%80-%EC%95%88%EB%8F%BC%EC%9A%94-next-15</link>
            <guid>https://velog.io/@byunghun-jake/error-vscode%EC%97%90%EC%84%9C-eslint%EA%B0%80-%EC%95%88%EB%8F%BC%EC%9A%94-next-15</guid>
            <pubDate>Sun, 24 Nov 2024 02:37:42 GMT</pubDate>
            <description><![CDATA[<h2 id="문제">문제</h2>
<h3 id="내용">내용</h3>
<ul>
<li>vscode(또는 cursor)에서 lint가 제대로 되지 않는다.<ul>
<li>ex) useEffect에 전달하는 deps에 값을 제대로 전달하지 않았을 때</li>
<li>ex) 전달받은 매개변수를 사용하지 않았을 때</li>
</ul>
</li>
</ul>
<blockquote>
</blockquote>
<p>새로운 프로젝트를 진행하는데, lint 설정은 이전 프로젝트와 그대로지만 에디터에서 lint 경고나 에러를 제대로 나타내지 못했다.</p>
<h3 id="환경">환경</h3>
<p>package.json</p>
<pre><code>&quot;next&quot;: &quot;15.0.1&quot;,
&quot;eslint&quot;: &quot;^9.14.0&quot;,</code></pre><p>.eslintrc.json</p>
<pre><code>{
  &quot;extends&quot;: [&quot;next/core-web-vitals&quot;, &quot;next/typescript&quot;]
}</code></pre><h2 id="해결">해결</h2>
<h3 id="디버그-debug">디버그 (debug)</h3>
<blockquote>
</blockquote>
<p>eslint debugger를 통해 설정에 어떤 문제가 있는지 파악할 수 있다.
<a href="https://eslint.org/docs/latest/use/configure/debug">https://eslint.org/docs/latest/use/configure/debug</a></p>
<pre><code>$ npx eslint --debug

Oops! Something went wrong! :(

ESLint: 9.14.0

ESLint couldn&#39;t find an eslint.config.(js|mjs|cjs) file.

From ESLint v9.0.0, the default configuration file is now eslint.config.js.
If you are using a .eslintrc.* file, please follow the migration guide
to update your configuration file to the new format:

https://eslint.org/docs/latest/use/configure/migration-guide

If you still have problems after following the migration guide, please stop by
https://eslint.org/chat/help to chat with the team.</code></pre><p>eslint 버전이 9로 업데이트됨에 따라, eslint의 설정을 관리하는 파일의 형태가 변경되었고 기존에 사용하고 있던 형태인 <code>.eslintrc.*</code>는 새로운 파일 형태로 변경해주어야 한다는 것.</p>
<h3 id="마이그레이션-migration">마이그레이션 (migration)</h3>
<p><code>.eslintrc.json</code></p>
<pre><code class="language-json">{
  &quot;extends&quot;: [&quot;next/core-web-vitals&quot;, &quot;next/typescript&quot;]
}</code></pre>
<p><code>eslint.config.mjs</code></p>
<pre><code class="language-js">import path from &quot;node:path&quot;;
import { fileURLToPath } from &quot;node:url&quot;;
import { FlatCompat } from &quot;@eslint/eslintrc&quot;;
import js from &quot;@eslint/js&quot;;

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
    baseDirectory: __dirname,
    recommendedConfig: js.configs.recommended,
    allConfig: js.configs.all
});

const config = [
  ...compat.extends(&quot;next/core-web-vitals&quot;, &quot;next/typescript&quot;),
];

export default config;</code></pre>
<h2 id="결론">결론</h2>
<p>eslint 9로 버전이 변경됨에 따라, 기존 설정 파일 형태 중 <code>.eslintrc.*</code>를 사용할 수 없게 되었음.
이로 인해, 에디터에서 해당 설정을 읽을 수 없게 되면서 lint가 동작하지 않았던 것.</p>
<p>eslint debugger를 통해 문제에 대한 원인을 찾을 수 있었고, 마이그레이션 가이드를 따라 해결할 수 있었다.</p>
<h2 id="참고">참고</h2>
<ul>
<li><p>Eslint 마이그레이션 가이드
<a href="https://eslint.org/docs/latest/use/configure/migration-guide">https://eslint.org/docs/latest/use/configure/migration-guide</a></p>
</li>
<li><p>유사한 문제를 겪은 개발자의 질문
<a href="https://stackoverflow.com/questions/79128326/eslint-9-is-not-working-properly-with-next-15">https://stackoverflow.com/questions/79128326/eslint-9-is-not-working-properly-with-next-15</a></p>
</li>
<li><p>nextjs 15 github issue
<a href="https://github.com/vercel/next.js/issues/71763#issuecomment-2436288395">https://github.com/vercel/next.js/issues/71763#issuecomment-2436288395</a></p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/byunghun-jake/post/e21fadec-5f17-440b-a216-51755cae7dcc/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[why - storybook이 toss 팀의 오픈소스인 es-toolkit을 도입했다]]></title>
            <link>https://velog.io/@byunghun-jake/why-storybook%EC%9D%B4-toss-%ED%8C%80%EC%9D%98-%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4%EC%9D%B8-es-toolkit%EC%9D%84-%EB%8F%84%EC%9E%85%ED%96%88%EB%8B%A4</link>
            <guid>https://velog.io/@byunghun-jake/why-storybook%EC%9D%B4-toss-%ED%8C%80%EC%9D%98-%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4%EC%9D%B8-es-toolkit%EC%9D%84-%EB%8F%84%EC%9E%85%ED%96%88%EB%8B%A4</guid>
            <pubDate>Thu, 10 Oct 2024 02:11:29 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/byunghun-jake/post/c6945cb8-0e0d-4801-9010-c7a47fbe193a/image.png" alt=""></p>
<p>LinkedIn에서 제목 그대로 <code>storybook 팀이 lodash 대신 toss 팀의 오픈소스인 es-toolkit을 도입했다</code>라는 <a href="https://www.linkedin.com/posts/evan-moon_core-replace-lodash-with-es-toolkit-activity-7246326056763211776-TLrW?utm_source=share&amp;utm_medium=member_desktop">문동욱님의 글</a>을 보게 되었다.</p>
<p>storybook에 es-toolkit을 도입하게 된 <a href="https://github.com/storybookjs/storybook/pull/28981">설명</a>은 다음과 같았다.</p>
<blockquote>
</blockquote>
<p>lodash is hard to tree-shake, it&#39;s CJS, and we want to move to less heavy, ESM dependencies.</p>
<blockquote>
</blockquote>
<p>This replaces lodash for es-toolkit which is mostly a drop-in replacement ♥️</p>
<blockquote>
</blockquote>
<p>I&#39;ve only changed the core for now.</p>
<h2 id="왜">왜?</h2>
<p>눈에 들어온 키워드는 <code>tree-shake</code>, <code>CJS</code>, <code>ESM</code></p>
<ol>
<li><p>tree-shake는 빌드 과정에서 사용하지 않는 모듈을 포함시키지 않는 것이라고 알고 있는데, 왜 lodash는 tree-shake가 어려운 걸까?</p>
</li>
<li><p>CJS와 ESM의 어떤 부분이 다르기 때문에 tree-shake에 영향을 주는 걸까?</p>
</li>
</ol>
<hr>
<blockquote>
</blockquote>
<p>Lodash가 tree-shake가 어려운 이유는 CommonJS(CJS)와 ES Modules(ESM)의 차이와 관련되어 있음</p>
<h3 id="cjs-commonjs">CJS (CommonJS):</h3>
<ul>
<li>노드 환경에서 주로 사용되며, module.exports와 require()로 모듈을 불러온다.</li>
<li>런타임에서 모듈을 불러오는 <strong>동적 구조</strong>이기에 빌드 도구 입장에서 어떤 코드가 사용되는지 분석하기 어렵다.
따라서, tree-shaking이 잘 동작하지 않는다.</li>
</ul>
<h3 id="esm-es-modules">ESM (ES Modules):</h3>
<ul>
<li>브라우저와 모던 자바스크립트 환경에서 사용되며, import와 export로 <strong>정적 구조</strong>를 가진다.</li>
<li>정적 구조라서 빌드 타임에 어떤 모듈을 사용하는지 명확하게 분석할 수 있다.
빌드 도구가 사용되지 않는 코드를 안전하게 제거(tree-shake)할 수 있다.</li>
</ul>
<h2 id="결론">결론</h2>
<ul>
<li><p>Lodash는 주로 CJS로 작성되었음</p>
</li>
<li><p>CJS(CommonJS)는 런타임에서 모듈을 불러오는 &quot;동적 구조&quot;이기에 tree-shake가 어려움</p>
</li>
<li><p>ESM(ES Modules)는 &quot;정적 구조&quot;이기에, 어떤 모듈을 사용하는 지 명확히 분석할 수 있어 사용하지 않는 코드를 제거(tree-shake)할 수 있음</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[내가 보고 있는 영역에 대한 데이터만 가져오기 (postgis)]]></title>
            <link>https://velog.io/@byunghun-jake/datainviews</link>
            <guid>https://velog.io/@byunghun-jake/datainviews</guid>
            <pubDate>Wed, 25 Sep 2024 05:34:45 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<ul>
<li>공간 정보를 다루기 위해 PostGIS를 활용하기</li>
<li>postgreSQL의 function을 활용하여 데이터를 가져오기</li>
<li>성능 측정 (단순 비교 vs PostGIS)</li>
</ul>
<h2 id="배경">배경</h2>
<p>지도에 마커를 표시하기 위해 데이터를 불러와야 했는데, 모든 데이터를 가져온 후에 지도에 표시하는 것은 비효율적인 방법이라고 생각했다.
(클라이언트 지도 상에 보이지 않는 영역에 대한 데이터를 불러오기 때문)</p>
<p>클라이언트에서 보고 있는 영역에 대한 데이터만 불러오려면 어떻게 해야 할까?</p>
<ol>
<li>클라이언트에서 보고 있는 영역에 대한 정보를 가져온다.</li>
<li>영역 정보를 기반으로 데이터를 요청한다.</li>
</ol>
<hr>
<h2 id="1-클라이언트에서-보고-있는-영역에-대한-정보를-가져온다">1) 클라이언트에서 보고 있는 영역에 대한 정보를 가져온다.</h2>
<p>보고 있는 영역의 좌표는 남서쪽 모서리(min)와 북동쪽 모서리(max) 좌표를 통해 표현할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/byunghun-jake/post/b48346fd-8996-4922-afaa-7aba55993bc3/image.png" alt="지도 화면"></p>
<hr>
<h2 id="2-영역-정보를-기반으로-데이터를-요청">2) 영역 정보를 기반으로 데이터를 요청</h2>
<h3 id="따릉이-정류소-데이터">따릉이 정류소 데이터</h3>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
<th>latitude</th>
<th>longitude</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>경복궁역 7번출구 앞</td>
<td>37.57579422</td>
<td>126.9714508</td>
</tr>
<tr>
<td>2</td>
<td>종로구청 옆</td>
<td>37.57255936</td>
<td>126.9783325</td>
</tr>
</tbody></table>
<blockquote>
<p>영역에 해당하는 정류소 데이터를 가져오려면?</p>
</blockquote>
<h3 id="1-단순-비교">1. 단순 비교</h3>
<p>지도의 경계 좌표가 남서(37.571, 126.976), 북동(37.573, 126.979) 라면,
아래 조건을 통해 데이터를 필터링하여 가져올 수 있다.</p>
<p><code>37.571 &lt;= latitude &lt;= 37.573</code>
<code>126.976 &lt;= longitude &lt;= 126.979</code></p>
<pre><code class="language-ts">// supabase 예시 코드
const get_data_within_bounds = async (
  lat_min,
  lat_max,
  lon_min,
  lon_max
) =&gt; {
    const { data, error } = await supabase
        .from(&#39;my_marker_data&#39;)
        .select(&#39;*&#39;)
        .gte(&#39;latitude&#39;, lat_min)
        .lte(&#39;latitude&#39;, lat_max)
        .gte(&#39;longitude&#39;, lon_min)
        .lte(&#39;longitude&#39;, lon_max);
    // ...
}</code></pre>
<h3 id="2-postgis-확장">2. PostGIS 확장</h3>
<p>PostgreSQL의 PostGIS 확장을 사용하면, 지리 공간 데이터를 더욱 효율적으로 관리하고 쿼리할 수 있다.</p>
<ol>
<li><p>PostGIS 확장 활성화
데이터베이스에서 PostGIS 확장을 활성화한다.</p>
<pre><code class="language-sql">CREATE EXTENSION IF NOT EXISTS postgis;</code></pre>
</li>
<li><p>지리 공간 데이터 타입의 컬럼 추가</p>
<pre><code class="language-sql">ALTER TABLE my_marker_data
ADD COLUMN geom geometry(Point, 4326);</code></pre>
</li>
</ol>
<ul>
<li>4326은 WGS 84 좌표계를 나타냅니다.</li>
</ul>
<ol start="3">
<li>기존 데이터 업데이트</li>
</ol>
<pre><code class="language-sql">UPDATE my_marker_data
SET geom = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326);</code></pre>
<ul>
<li>ST_MakePoint(longitude, latitude): 포인트 생성</li>
<li>ST_SetSRID: 좌표계(SRID) 설정</li>
</ul>
<ol start="4">
<li>데이터베이스 함수 생성</li>
</ol>
<pre><code class="language-sql">CREATE
OR REPLACE FUNCTION public.stations_in_view (
  min_latitude DOUBLE PRECISION,
  max_latitude DOUBLE PRECISION,
  min_longitude DOUBLE PRECISION,
  max_longitude DOUBLE PRECISION
) RETURNS TABLE (
  id BIGINT,
  station_id TEXT,
  station_name TEXT,
  LOCATION TEXT,
  latitude DOUBLE PRECISION,
  longitude DOUBLE PRECISION
) AS $$
BEGIN
    RETURN QUERY
    SELECT 
        s.id,
        s.station_id,
        s.station_name,
        s.location,
        s.latitude,
        s.longitude
    FROM 
        public.stations s
    where
      ST_Intersects(
          s.geom,
        ST_MakeEnvelope(
            min_longitude, min_latitude,
            max_longitude, max_latitude,
            4326
        )
      );
END;
$$ LANGUAGE plpgsql;
</code></pre>
<ol start="5">
<li>클라이언트에서 쿼리 수행</li>
</ol>
<pre><code class="language-ts">const get_data_within_bounds = async (max_latitude, max_longitude, min_latitude, min_longitude) =&gt; {
    let { data, error } = await supabase
        .rpc(&#39;stations_in_view&#39;, {
            max_latitude, 
            max_longitude, 
            min_latitude, 
            min_longitude
        });
    // ...
}</code></pre>
<h2 id="추가">추가</h2>
<h3 id="성능-비교">성능 비교</h3>
<h4 id="클라이언트에서-성능-측정">클라이언트에서 성능 측정</h4>
<table>
<thead>
<tr>
<th>index</th>
<th>rows [개]</th>
<th>단순 비교 [ms]</th>
<th>공간 정보(PostGIS) [ms]</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>43</td>
<td>57.4</td>
<td>60.0</td>
</tr>
<tr>
<td>1</td>
<td>2763</td>
<td>267.8</td>
<td>309.3</td>
</tr>
<tr>
<td>2</td>
<td>2763</td>
<td>339.9</td>
<td>269.0</td>
</tr>
</tbody></table>
<blockquote>
<p>지리 정보를 활용하는 방식이 일반적인 필터에 비해 당연히 빠를 것이라고 생각했는데, 예상과 다른 결과.
쿼리 성능보다는 네트워크의 영향이 더 큰 건 아닐까?</p>
</blockquote>
<h4 id="쿼리-성능-측정">쿼리 성능 측정</h4>
<p><code>EXPLAIN ANALYZE</code> 를 활용해서, 쿼리 성능을 측정했다.</p>
<blockquote>
<p>ex)
Seq Scan on stations  (cost=0.00..83.63 rows=2763 width=131) (actual time=0.013..0.277 rows=2763 loops=1)
Planning Time: 0.390 ms
Execution Time: 0.486 ms</p>
</blockquote>
<blockquote>
<p><code>cost</code>: 예측한 쿼리 실행 비용
<code>actual time</code>: 실제 실행 시간 (첫 번째 행을 가져오는 데 걸린 시간, 마지막 행을 가져오는 데까지 걸린 총 시간)
<code>planning time</code>: 쿼리 실행 계획을 수립하는 데 걸린 시간
<code>execution time</code>: 쿼리를 실제로 실행하는 데 걸린 전체 시간 (모든 노드의 실행 시간 + 추가적인 오버헤드)</p>
</blockquote>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT
  *
FROM
  public.stations
WHERE latitude &gt;= 36
  AND latitude &lt;= 38
  AND longitude &gt;= 126
  AND longitude &lt;= 128;</code></pre>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT *
FROM public.stations
WHERE ST_Intersects(
  geom,
  ST_MakeEnvelope(126, 36, 128, 38, 4326)
);</code></pre>
<table>
<thead>
<tr>
<th>*</th>
<th>scan type</th>
<th>rows</th>
<th>cost</th>
<th>actual time</th>
<th>planning time [ms]</th>
<th>execution time [ms]</th>
</tr>
</thead>
<tbody><tr>
<td>단순 비교</td>
<td>Seq Scan</td>
<td>2763</td>
<td>111.26</td>
<td>0.552</td>
<td>0.461</td>
<td>0.713</td>
</tr>
<tr>
<td>PostGIS</td>
<td>Seq Scan</td>
<td>2763</td>
<td>69158.63</td>
<td>2.264</td>
<td>27.269</td>
<td>2.483</td>
</tr>
</tbody></table>
<blockquote>
</blockquote>
<p>성능: 단순 비교 &gt; PostGIS</p>
<p>예상과 달리 공간 인덱스를 생성했음에도 단순 필터를 이용해 비교를 하는 것 보다 느리게 나왔다.
데이터 수가 적어서 순차적 스캔이 활성화될 수 있다고 해서, 순차 스캔을 비활성화도 해봤지만 속도가 빠른 결과가 나오지는 않았다.</p>
<blockquote>
</blockquote>
<p>성능 차이가 발생한 이유 (by gpt)</p>
<ul>
<li>데이터량이 적음</li>
<li>공간 함수의 오버헤드</li>
<li>쿼리 계획 수립 시간 증가</li>
</ul>
<p>위 성능 차이가 유의미한 차이일까?</p>
<p>실행 시간 비교</p>
<ul>
<li>단순 비교 쿼리: 0.7ms</li>
<li>공간 쿼리: 1.2ms</li>
</ul>
<p>절대 시간 차이는 약 0.5ms
이는, 실제 애플리케이션에서 사용자 체감 성능에 거의 영향을 미치지 않음.</p>
<blockquote>
</blockquote>
<p>성능 차이의 유의미성을 판단하는 기준 (사용자 경험에 미치는 영향) (by gpt)</p>
<ul>
<li>응답 시간이 1초를 넘을 경우: 성능 개선 필요</li>
<li>응답 시간이 100ms 이내: 사용자들이 느리다고 느낄 수 있지만, 대부분의 어플리케이션에서 충분히 빠름</li>
</ul>
<hr>
<h2 id="next">Next</h2>
<ul>
<li>정류소 데이터를 효과적으로 캐싱하려면 어떻게 해야 할까?</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[크롬 확장 프로그램 (웹뷰)]]></title>
            <link>https://velog.io/@byunghun-jake/portfolio-chrome-extension</link>
            <guid>https://velog.io/@byunghun-jake/portfolio-chrome-extension</guid>
            <pubDate>Thu, 18 Jul 2024 09:44:05 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>💡 아웃바운드를 위하여 후보자를 외부 사이트(ex. 링크드인)를 통해 소싱하려고 할 때, 위하이어 사이트와 소싱을 위한 외부 사이트를 번갈아가며 작업하는 것이 번거롭기에 이를 해결하기 위해 확장 프로그램을 개발하였습니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/byunghun-jake/post/0b0e5fff-5f9a-4452-9dad-3a29d56c8913/image.png" alt=""></p>
<h2 id="요약">요약</h2>
<ul>
<li><p>iframe을 통해, 확장 프로그램을 웹뷰로써 동작하도록 합니다.</p>
<ul>
<li>window의 message 이벤트를 통해 상호작용합니다.</li>
</ul>
</li>
<li><p>확장 프로그램의 유지보수를 위해, 타입스크립트로 전환하였습니다.</p>
</li>
</ul>
<hr>
<h2 id="세부-내용">세부 내용</h2>
<ul>
<li><p>iframe을 통해, 확장 프로그램을 웹뷰로써 동작하도록 합니다.
확장 프로그램의 재배포 과정을 거치지 않고, 웹 서비스의 업데이트를 통해 유저에게 최신 버전의 서비스를 제공할 수 있습니다.</p>
<pre><code class="language-ts">function createIframe(
  id: string,
  url: string,
  styles: Record&lt;string, string&gt;
): HTMLIframeElement {
  const iframe = document.createElement(&#39;iframe&#39;);
  iframe.id = id;
  iframe.setAttribute(&#39;src&#39;, url);
  Object.assign(iframe.style, styles);
  return iframe;
}

function insertIframe() {
  const wehireIframe = createIframe(
    IFRAME_ID,
    WEHIRE_URL,
    IFRAME_STYLES
  );
  wehireIframe.className = &#39;wehire-iframe&#39;;
  document.body.prepend(wehireIframe);
  // ...
}</code></pre>
</li>
<li><p>window의 message 이벤트를 통해 상호작용합니다.</p>
<ul>
<li><p>웹뷰의 picker 버튼을 클릭했을 때, 클라이언트 브라우저에 마우스 이벤트를 등록합니다.</p>
<pre><code class="language-tsx">// 웹뷰
&lt;button onClick={() =&gt; {
  window.parent.window.postMessage({
    type: &#39;activatePicker&#39;,
    pickerType: &#39;name&#39;,
  }, &quot;*&quot;);
}}&gt;
  Name Picker
&lt;/button&gt;</code></pre>
<pre><code class="language-tsx">// 확장 프로그램
let pickerType = null;

const onMessage = (
  event: MessageEvent&lt;IframeEventMessage&gt;
) =&gt; {
  const eventType = event.data.type;

  switch (eventType) {
    case &#39;activatePicker&#39;: {
      pickerType = event.data.pickerType;
      addMouseEvent();
    }

    case &#39;autoScraping&#39;: {
      // ...
    }
  }
};

window.addEventListener(&#39;message&#39;, onMessage);</code></pre>
</li>
</ul>
</li>
</ul>
<ul>
<li><p>유지보수를 위해 스택을 자바스크립트에서 타입스크립트로 전환하고, 빌드를 위해 웹팩을 사용했습니다. (웹팩 설정)</p>
<pre><code class="language-js">const path = require(&#39;path&#39;);
const { CleanWebpackPlugin } =
      require(&#39;clean-webpack-plugin&#39;);
const TerserPlugin =
      require(&#39;terser-webpack-plugin&#39;);

module.exports = {
  entry: {
    background: &#39;./src/background.ts&#39;,
    script: &#39;./src/script.ts&#39;,
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: &#39;ts-loader&#39;,
        exclude: /node_modules/,
      },
    ],
  },
  resolve: {
    extensions: [&#39;.tsx&#39;, &#39;.ts&#39;, &#39;.js&#39;],
  },
  output: {
    filename: &#39;[name].js&#39;,
    path: path.resolve(__dirname, &#39;dist&#39;),
    publicPath: &#39;/&#39;,
  },
  plugins: [
    // 빌드할 때마다 dist 폴더를 정리
    new CleanWebpackPlugin(),
  ],
  // 디버깅을 위하여, 소스맵 추가
  devtool: &#39;source-map&#39;,
  optimization: {
    minimize: true,
    minimizer: [
      // 코드 압축을 위해 TerserPlugin 추가
      new TerserPlugin({
        terserOptions: {
          compress: {
            drop_console: true,
          },
        },
      }),
    ],
  },
};  </code></pre>
</li>
</ul>
<hr>
<h2 id="추가-내용">추가 내용</h2>
<h3 id="windowpostmessage">Window.postMessage()</h3>
<pre><code class="language-js">  targetWindow.postMessage(message, targetOrigin, [transfer]);</code></pre>
<h4 id="주의사항">주의사항</h4>
<ul>
<li><p>메시지 출처 검증 및 <code>targetOrigin</code> 명시</p>
<p>메시지를 수신할 때, 반드시 메시지의 출처(origin)를 검증해야 합니다. 이를 통해 신뢰할 수 없는 도메인에서 전송된 메시지를 처리하지 않도록 할 수 있습니다.</p>
<p>메시지를 전송할 때는 <code>postMessage</code>의 두 번째 인자로 <code>targetOrigin</code>을 명시하여, 메시지가 전송될 대상의 출처를 지정합니다.
<code>targetOrigin</code>의 정보와 <code>targetWindow</code>의 정보가 맞지 않다면 이벤트는 전송되지 않습니다.</p>
<pre><code class="language-js">// 확장 프로그램
const TARGET_ORIGIN = &#39;https://wehire.kr&#39;;

// 메시지 수신 (메시지 출처 검증)
window.addEventListener(&#39;message&#39;, (event) =&gt; {
  // 보안 검증: 메시지의 출처(origin) 확인하기
  if (event.origin !== TARGET_ORIGIN) {
    // 신뢰할 수 없는 출처에서 온 메시지를 무시합니다.
    return;
  }
});

// 메시지 발송 (targetOrigin 명시)
function sendMessageToIframe(message) {
  iframe.contentWindow.postMessage(message, TARGET_ORIGIN);
}</code></pre>
<pre><code class="language-js">// 웹뷰
const TARGET_ORIGIN = &#39;chrome-extension://&lt;EXTENSION_ID&gt;&#39;
// 메시지 수신
window.addEventListener(&#39;message&#39;, (event) =&gt; {
  if (event.origin !== TARGET_ORIGIN) {
    return;
  }
});

// 메시지 발송
function sendMessageToExtension(message) {
  window.parent.window.postMessage(message, TARGET_ORIGIN);
};</code></pre>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[채용 페이지 빌더]]></title>
            <link>https://velog.io/@byunghun-jake/portfolio-page-builder</link>
            <guid>https://velog.io/@byunghun-jake/portfolio-page-builder</guid>
            <pubDate>Thu, 18 Jul 2024 08:55:01 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>💡 채용 페이지를 쉽게 구성하고, 관리할 수 있도록 어드민 페이지를 제공하고, 채용 페이지의 SEO 최적화를 통해 더 많은 구직자들에게 접근할 수 있도록 한다.</p>
</blockquote>
<h2 id="기술">기술</h2>
<ul>
<li>Context API</li>
<li>IntersectionObserver API</li>
<li>ServerSide Rendering</li>
</ul>
<h2 id="세부-사항">세부 사항</h2>
<h3 id="채용-페이지-구성을-위한-어드민-페이지-구성">채용 페이지 구성을 위한 어드민 페이지 구성</h3>
<ul>
<li><p>Context API를 사용하여 복잡한 폼을 관리하고, 컴포넌트 구조화를 통해 효율적인 상태 관리를 구현했습니다.</p>
<pre><code>- /settings
  - /sites
    - SettingSiteFormProvider.tsx (Context Provider)
    - useSettingSiteFormContext.ts (useContext 커스텀 훅)
    - index.tsx (페이지 컴포넌트)</code></pre><pre><code class="language-tsx">// SettingSiteFormProvider.tsx
const SettingSiteFormProvider = ({
  children,
  defaultValue,
}) =&gt; {
  const form = useForm({
    defaultValue,
  });

  // ...
  return (
    &lt;FormProvider {...form}&gt;
      {children}
    &lt;/FormProvider {...form}&gt;
  )
};

// useSettingSiteFormContext.ts
const useSettingSiteFormContext = () =&gt; {
  const form = useFormContext();

  return form;
};</code></pre>
<pre><code class="language-tsx">// 활용
// index.tsx
const SettingsSitePage = () =&gt; {
  // ...

  return (
    &lt;SettingSiteFormProvider&gt;
      &lt;SEOFieldset /&gt;
      &lt;SocialChannelsFieldset /&gt;
    &lt;/SettingSiteFormProvider&gt;
  ); 
};

// SEOFieldset.tsx
const SEOFieldset = () =&gt; {
  const form = useSettingSiteFormContext();

  // ...
};</code></pre>
</li>
<li><p>IntersectionObserver API를 활용하여 scrollSpy 기능을 구현해, 사용자가 스크롤할 때 현재 위치를 쉽게 파악할 수 있도록 했습니다.</p>
<pre><code class="language-tsx">// useScrollSpy.ts
const useScrollSpy = (
  targetElements,
  options,
) =&gt; {
  const [activeSection, setActiveSection] = useState(&#39;&#39;);

useEffect(() =&gt; {
  const callback = (entries) =&gt; {
    // ...
  };

  const observer = new IntersectionObserver(callback, options);

  targetElements.forEach((element) =&gt; observer.observe(element));

  return () =&gt; observer.disconnect();
}, [targetElemtns, options]);

return activeSection;
};</code></pre>
</li>
</ul>
<h3 id="채용-페이지-seo-동적-사이트맵-생성">채용 페이지 SEO (동적 사이트맵 생성)</h3>
<ul>
<li><p>서버사이드 렌더링을 통해 동적으로 sitemap.xml을 생성하였습니다.</p>
<pre><code class="language-tsx">// sitemap.xml.tsx

const SitemapPage = () =&gt; {
  return null;
}

export const getServerSideProps = async (context) =&gt; {
  // ...
  const sitemap = generateSiteMap({
    siteUrl,
    changeFreq,
    priority,
  });

  context.res.setHeader(&#39;Content-Type&#39;, &#39;text/xml&#39;);
  context.res.write(sitemap);
  context.res.end();

  return { props: {} };
}</code></pre>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[사이드 프로젝트 - 클라이밍 방문일 체크]]></title>
            <link>https://velog.io/@byunghun-jake/side-project-climb-cool-time</link>
            <guid>https://velog.io/@byunghun-jake/side-project-climb-cool-time</guid>
            <pubDate>Thu, 18 Jul 2024 08:44:20 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>💡 여러 클라이밍장의 세팅 일정을 한눈에 확인할 수 있도록 정보를 제공합니다. 또한, 사용자가 특정 클라이밍장을 방문했을 때, 이후 언제 다시 방문해야 해당 클라이밍장의 모든 섹터가 새롭게 바뀌는지 확인할 수 있습니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/byunghun-jake/post/b7dd82b8-8540-44dd-98b1-f3ccc89c9428/image.png" alt="썸네일"></p>
<h3 id="기술-스택"><strong>기술 스택</strong></h3>
<p><strong>프론트엔드:</strong></p>
<ul>
<li><strong>Next.js:</strong><ul>
<li>정적 페이지 구성</li>
<li>서버 컴포넌트</li>
</ul>
</li>
<li><strong>Tailwind CSS</strong></li>
</ul>
<p><strong>데이터베이스 및 인증:</strong></p>
<ul>
<li><strong>Supabase:</strong> 데이터베이스와 인증 관리를 위해 사용되었습니다.</li>
</ul>
<p><strong>기타 도구 및 서비스:</strong></p>
<ul>
<li><strong>Git:</strong> 버전 관리를 위해 사용되었습니다.</li>
<li><strong>Vercel:</strong> 배포 플랫폼으로 사용되었습니다. Vercel을 통해 프로젝트를 배포하고 관리하였습니다.</li>
</ul>
<hr>
<h3 id="개발-과정"><strong>개발 과정</strong></h3>
<ol>
<li><p>프로젝트 기획 단계</p>
<ul>
<li>요구사항 분석: 클라이밍 세팅 정보를 필요로 하는 사용자들의 니즈를 파악하기 위해 인스타그램 부계정을 개설하여 세팅 정보를 정리해 올리는 작업을 했습니다. 이를 통해 세팅 정보에 대한 수요가 있다는 것을 확인했습니다.</li>
<li>최소 기능 제품(MVP) 제작: 핵심 기능만 빠르게 제작하여 사용자 피드백을 받아 제품을 개선해 나가기로 했습니다.</li>
</ul>
</li>
<li><p>프론트엔드 개발 과정</p>
<ul>
<li>컴포넌트: 새로운 개념인 서버 컴포넌트 및 액션에 대한 학습을 진행하며, 컴포넌트를 개발했습니다.</li>
<li>퍼블리싱: 모바일 디바이스를 기준으로 사용이 편리하도록 했습니다.</li>
<li>API: Supabase와 통합하여 데이터베이스와 통신하는 API를 개발했습니다. 서버 컴포넌트에서 주로 요청을 보내고, 클라이언트 컴포넌트에서는 route handler를 통해 데이터 요청을 처리했습니다.</li>
</ul>
</li>
<li><p>데이터베이스 및 인증 설정 과정</p>
<ul>
<li>데이터베이스 설계: 클라이밍장 브랜드, 클라이밍장, 클라이밍장 섹터, 클라이밍장 섹터 세팅 히스토리로 테이블을 나누어 정의했습니다. 데이터 모델링 과정에서 테이블 간 관계 설정을 고려했습니다.</li>
<li>인증 설정: 카카오톡 로그인 작업은 처음이었지만, 공식 문서를 참고하여 어렵지 않게 구현했습니다. 인증 흐름을 설계하고 보안을 고려한 설정을 적용했습니다.</li>
</ul>
</li>
<li><p>배포 과정</p>
<ul>
<li>배포: Vercel을 통해 배포를 진행했습니다. Vercel 환경에서의 프로덕션 빌드 최적화와 환경 변수 설정을 고려했습니다.</li>
<li>성능 모니터링: Vercel에서 제공하는 Analytics와 SpeedInsight를 통해 서비스의 성능 및 지표를 확인했습니다. 페이지 로딩 시간, 사용자 행동 분석 등의 메트릭을 모니터링하고, 이를 통해 성능 개선을 위한 인사이트를 얻었습니다.</li>
</ul>
</li>
</ol>
<hr>
<h3 id="결과-및-성과"><strong>결과 및 성과</strong></h3>
<p><strong>사용자 피드백:</strong></p>
<ul>
<li>실제 사용자 유입: 인스타그램 계정을 통해 프로젝트를 공개하였고, 실제 사용자들이 유입되었습니다. 사용자들은 이 서비스가 필요했던 것임을 피드백을 통해 확인했습니다.</li>
</ul>
<p><strong>기술적 성과:</strong></p>
<ul>
<li>Next.js 14(app router) 및 서버 컴포넌트 이해: 최신 버전의 Next.js에 대한 깊이 있는 이해를 통해 서버 컴포넌트를 활용하여 데이터를 효율적으로 처리하고, 성능을 최적화하는 방법을 익혔습니다.</li>
<li>성능 최적화: FCP, LCP 시간을 개선하기 위해 다양한 성능 최적화 작업을 수행하였고, 이를 통해 사용자 경험을 향상시켰습니다.</li>
<li>Supabase 이해: Supabase를 활용한 데이터베이스 관리와 인증 설정을 통해 데이터베이스 작업의 효율성을 높였습니다.</li>
</ul>
<p><strong>배운점 및 개선할 점:</strong></p>
<ul>
<li>성능 최적화: 실 사용자들을 위한 성능 측정 및 개선 작업의 중요성을 깨달았습니다. 특히, 서버 컴포넌트 렌더링 시 데이터 페칭 과정에서의 최적화 필요성을 인식하고 이를 개선했습니다.</li>
<li>사용자 경험(UX): 방문일 입력의 허들을 낮추기 위한 방법을 고민하며, 사용자 친화적인 인터페이스를 설계할 필요성을 배웠습니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[사이드 프로젝트 - 모바일 청첩장 빌더]]></title>
            <link>https://velog.io/@byunghun-jake/side-project-invitation-card-builder</link>
            <guid>https://velog.io/@byunghun-jake/side-project-invitation-card-builder</guid>
            <pubDate>Thu, 18 Jul 2024 08:36:01 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>💡 예비 신혼부부의 이야기를 담은 풍성한 모바일 청첩장을 만들어, 초대 받는 사람들이 결혼식 주인공들에 대해 깊게 이해하고 마음 깊이 축하할 수 있도록 돕는 서비스입니다.</p>
</blockquote>
<blockquote>
<p>2023.12 ~ (진행 중)
<a href="https://bora-n-maria.vercel.app/">https://bora-n-maria.vercel.app/</a></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/byunghun-jake/post/853cc1b6-775a-4b4a-9854-7e9ef91358ac/image.png" alt="썸네일"></p>
<h3 id="기술-스택"><strong>기술 스택</strong></h3>
<p><strong>프론트엔드:</strong></p>
<ul>
<li><strong>Next.js:</strong><ul>
<li><strong>동적 메타 데이터 구성:</strong> 청첩장 별 메타 데이터를 동적으로 구성하여 각 청첩장이 적절한 메타 정보를 갖추도록 했습니다.</li>
<li><strong>정적 페이지 구성:</strong> 제작된 청첩장 페이지들을 정적 페이지로 구성하여 성능을 최적화하였습니다.</li>
<li><strong>서버 컴포넌트:</strong> 서버 컴포넌트를 활용하여 성능을 최적화하였습니다.</li>
<li><strong>서버 액션:</strong> 캐싱 관리를 통해 성능을 최적화하였습니다.</li>
</ul>
</li>
<li><strong>Tailwind CSS:</strong> 스타일링을 위해 사용되었습니다.</li>
<li><strong>react-hook-form:</strong> 복잡한 폼을 효율적으로 관리하기 위해 사용되었습니다.</li>
<li><strong>Kakao API:</strong> 예식장 검색 및 지도 노출을 위해 사용되었습니다.</li>
<li><strong>이미지 최적화:</strong> 클라이언트에서 업로드한 이미지를 서버에 보내기 전에 이미지 사이즈 및 화질, 확장자 변경 등을 통해 최적화하였습니다.</li>
</ul>
<p><strong>데이터베이스 및 인증:</strong></p>
<ul>
<li><strong>Supabase:</strong> 데이터베이스와 인증 관리를 위해 사용되었습니다.</li>
</ul>
<p><strong>기타 도구 및 서비스:</strong></p>
<ul>
<li><strong>Git:</strong> 버전 관리를 위해 사용되었습니다.</li>
<li><strong>Vercel:</strong> 배포 플랫폼으로 사용되었습니다. Vercel을 통해 프로젝트를 배포하고 관리하였습니다.</li>
</ul>
<hr>
<h3 id="주요-기능"><strong>주요 기능</strong></h3>
<ol>
<li><strong>모바일 청첩장 관리</strong><ul>
<li>스토리 및 게시물 관리</li>
<li>결혼식장 장소 검색 및 약도 제공</li>
</ul>
</li>
<li><strong>메타 정보 입력</strong><ul>
<li>예비 신혼부부의 정보 등을 통해 청첩장이 공유되었을 때, 메타데이터로 활용할 수 있도록 하였습니다. 이를 통해 청첩장이 더 많은 사람들에게 효과적으로 전달될 수 있습니다.</li>
</ul>
</li>
</ol>
<hr>
<h3 id="개발-과정"><strong>개발 과정</strong></h3>
<ol>
<li><strong>프로젝트 기획 단계:</strong><ul>
<li><strong>서비스 정의:</strong> 어떤 서비스를 왜 만들고 싶은지 정의하는 과정으로 시작하였습니다. 시장에 있는 다양한 모바일 청첩장 서비스들을 살펴보고 구조를 분석하였습니다.</li>
<li><strong>노션 정리:</strong> 노션을 통해 분석한 내용을 정리하면서 프로젝트를 구체화해 나갔습니다.</li>
<li><strong>사용자 인터뷰:</strong> 결혼한 지인들에게 인터뷰를 진행하여 청첩장 서비스에 대한 피드백을 받았고, 이를 기획에 반영하여 보강하였습니다.</li>
<li><strong>UI/UX 디자인 설계:</strong> Figma를 사용하여 UI/UX 디자인을 설계했습니다. 주로 PC에서 모바일 청첩장을 제작하는 경우가 많기 때문에, PC를 기준으로 디자인을 설계했습니다. 완성된 디자인은 다시 피드백을 받아 수정하였습니다.</li>
</ul>
</li>
<li><strong>프론트엔드 개발 과정:</strong><ul>
<li><strong>컴포넌트 개발:</strong> 사용자가 입력한 내용이 결과물에 바로 적용되도록 중점을 두어 컴포넌트를 설계했습니다. 다양한 콘텐츠를 업로드해야 하기 때문에 폼 관리에도 신경을 많이 썼습니다.</li>
<li><strong>퍼블리싱:</strong> Tailwind CSS를 사용하여 반응형 디자인을 적용하고 스타일링을 진행했습니다.</li>
<li><strong>API 통합:</strong> Supabase와 통합하여 데이터베이스와 통신하는 API를 개발했습니다. 서버 컴포넌트를 활용하여 성능을 최적화하고, 캐싱 관리 기능도 구현했습니다.</li>
</ul>
</li>
<li><strong>데이터베이스 및 인증 설정 과정:</strong><ul>
<li><strong>스토리지:</strong> 업로드한 이미지의 저장을 위해 Supabase의 스토리지를 사용했습니다.</li>
<li><strong>데이터베이스:</strong> 스토리, 게시물, 결혼식장 콘텐츠 모두 이미지를 기반으로 이루어지기 때문에, 이미지를 위한 테이블을 공용으로 구성하고, 각 테이블과의 연결을 위한 링크 테이블을 통해 관계를 설정했습니다. 이로써 데이터의 무결성을 유지하고 효율적인 데이터 관리를 구현했습니다.</li>
</ul>
</li>
<li><strong>배포 과정:</strong><ul>
<li><strong>배포:</strong> Vercel을 통해 프로젝트를 배포했습니다. Git을 이용하여 버전 관리를 하였습니다.</li>
</ul>
</li>
</ol>
<h3 id="결과-및-성과"><strong>결과 및 성과</strong></h3>
<p><strong>성과 및 배운 점:</strong></p>
<ol>
<li><p><strong>데이터베이스 설계:</strong></p>
<ul>
<li>청첩장을 구성하는 요소인 기본 정보, 스토리, 게시물, 예식장 등을 효율적으로 관리하기 위해 데이터베이스 설계에 많은 고민을 하였습니다. 데이터베이스의 구조가 서비스에 큰 영향을 미친다는 교훈을 얻었으며, 이를 통해 작업을 시작하기 전에 아키텍처에 대한 충분한 고민이 반드시 필요함을 느꼈습니다.</li>
</ul>
</li>
<li><p><strong>복잡한 폼 관리:</strong></p>
<ul>
<li>복잡한 폼을 효율적으로 관리하기 위해 컴포넌트를 설계하는 것이 중요함을 배웠습니다. 여러 필드를 포함한 폼을 처리하면서, 상태 관리와 유효성 검사를 효과적으로 구현하는 방법을 익혔습니다. react-hook-form을 활용하여 폼 상태를 관리하고, 유연하게 확장할 수 있는 컴포넌트를 작성하는 기술을 향상시켰습니다.</li>
</ul>
</li>
<li><p><strong>이미지 최적화:</strong></p>
<ul>
<li>Next.js의 Image 컴포넌트를 통해 이미지를 최적화하는 방법을 이전에 사용했으나, 이번 프로젝트에서는 클라이언트에서 업로드한 이미지를 서버에 보내기 전에 최적화하여 네트워크 통신 간 성능을 개선할 수 있음을 알게 되었습니다. 이를 통해 이미지 사이즈, 화질, 확장자 등을 최적화하는 방법을 이해하고 적용할 수 있었습니다.</li>
</ul>
</li>
</ol>
<p><strong>개선할 점:</strong></p>
<ol>
<li><strong>모바일 기기 대응:</strong><ul>
<li>처음에는 PC를 기준으로 레이아웃을 구성하였으나, 반응형 레이아웃을 활용하여 모바일 기기에도 대응할 수 있도록 구조를 변경할 필요성을 느꼈습니다. 이를 통해 더 많은 사용자들에게 최적의 사용자 경험을 제공할 수 있도록 개선하려고 합니다.</li>
</ul>
</li>
<li><strong>성능 개선:</strong><ul>
<li>완성된 모바일 청첩장을 방문했을 때, 더 좋은 사용자 경험을 제공하기 위해 최적화 작업을 통해 성능을 개선할 계획입니다. 로딩 속도와 인터랙티브 성능을 향상시켜 사용자 만족도를 높이고자 합니다.</li>
</ul>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[프론트엔드 과제 회고 - 복잡한 폼 만들기]]></title>
            <link>https://velog.io/@byunghun-jake/assignment-form</link>
            <guid>https://velog.io/@byunghun-jake/assignment-form</guid>
            <pubDate>Wed, 10 Jul 2024 07:16:54 GMT</pubDate>
            <description><![CDATA[<h2 id="과제-내용">과제 내용</h2>
<h3 id="평가-기준">평가 기준</h3>
<ul>
<li>기능 구현</li>
</ul>
<h3 id="개요">개요</h3>
<ul>
<li>메시지 발송 플랫폼 구현</li>
</ul>
<h3 id="요구사항">요구사항</h3>
<h4 id="필수-사용-언어-및-라이브러리">필수 사용 언어 및 라이브러리</h4>
<ul>
<li>TypeScript, React</li>
<li>yarn</li>
<li>Emotion</li>
</ul>
<h4 id="디자인">디자인</h4>
<ul>
<li>제공한 피그마 파일을 참고하여 레이아웃을 구성합니다.</li>
</ul>
<h4 id="기능-구현">기능 구현</h4>
<ul>
<li><p>페이지별 요구사항</p>
<ol>
<li>로그인<ul>
<li>사용자의 입력 값에 대해 유효성 검증을 진행합니다.</li>
<li>입력 값이 유효하지 않은 경우, 아래 작업을 수행하도록 구현합니다.<ul>
<li>유효하지 않은 Input 테두리가 빨간색으로 변합니다.</li>
<li>유효하지 않은 Input으로 Focus 됩니다.</li>
</ul>
</li>
<li>로그인에 성공한 경우, 서비스 페이지로 이동합니다.</li>
</ul>
</li>
<li>회원가입<ul>
<li>사용자의 입력 값에 대해 유효성 검증을 진행합니다.</li>
<li>입력 값이 유효하지 않은 경우, 아래 작업을 수행하도록 구현합니다.<ul>
<li>유효하지 않은 Input 테두리가 빨간색으로 변합니다.</li>
<li>유효하지 않은 Input으로 Focus 됩니다.</li>
</ul>
</li>
<li>회원가입에 성공한 경우, 로그인 페이지로 이동합니다.</li>
</ul>
</li>
<li>서비스(메시지 발송)<ul>
<li>메시지 발송 폼을 구현합니다.</li>
</ul>
</li>
</ol>
</li>
<li><p>별도의 요구사항이 없는 것은 지원자가 판단하여 개발합니다.</p>
</li>
</ul>
<hr>
<h2 id="구현-내용">구현 내용</h2>
<h3 id="프로젝트-구조">프로젝트 구조</h3>
<pre><code>/src
    /api (backend API 관리)
    /components (공용 컴포넌트)
    /contexts (공용 컨텍스트)
    /hooks (공용 훅)
    /icons
    /pages (라우트 기본 단위를 페이지로 설정)
    /schemas (zod 스키마)
    /styles
    /types
    /utils
    main.tsx</code></pre><h3 id="라우팅-w-react-router">라우팅 (w. react-router)</h3>
<p>구현해야 하는 페이지는 총 3개</p>
<ul>
<li>회원가입 <code>/auth/signup</code></li>
<li>로그인 <code>/auth/login</code></li>
<li>서비스 <code>/</code></li>
</ul>
<p>최근에는 주로 Nextjs를 사용했었기 때문에, 페이지 라우팅을 위해 <code>react-router</code>를 사용하는 것은 익숙하진 않았었어요.</p>
<p>공식 문서를 살펴보면서, 라우팅을 위한 작업을 진행했습니다.</p>
<ul>
<li>프로젝트 구조
NextJS의 페이지 기반 라우팅 방식이 익숙했기에, 이 방식을 살려서 구조를 설계하고자 했습니다.</li>
</ul>
<pre><code>    디렉토리
    /src
        /pages
            page.tsx
            /messages
                page.tsx
            /auth
                /login
                    page.tsx
                /register
                    page.tsx</code></pre><ul>
<li><p>createBrowserRouter
공식 문서 상, 웹 서비스의 라우팅을 위해 권장하는 방식인 createBrowserRouter를 사용했습니다.</p>
<pre><code class="language-tsx">// main.ts

const router = createBrowserRouter([
  {
    path: &quot;/&quot;,
    element: &lt;RootLayout /&gt;,
    children: [
      {
        path: &quot;/&quot;,
        element: &lt;RootPage /&gt;,
      },
      {
        path: &quot;/messages&quot;,
        element: &lt;MessagesPage /&gt;,
      },
    ],
  },
  {
    path: &quot;/auth/login&quot;,
    element: &lt;LoginPage /&gt;,
  },
  {
    path: &quot;/auth/register&quot;,
    element: &lt;RegisterPage /&gt;,
  },
]);</code></pre>
</li>
</ul>
<ul>
<li><p>layout
RootPage와 MessagesPage에서 공통으로 사용하는 레이아웃 및 로직이 존재했기에, 이를 위해 RootLayout을 구성했습니다.</p>
<ul>
<li><p>공통 로직
로그인 여부를 체크하고, 로그인이 되어있지 않다면 로그인 페이지로 리다이렉트합니다.</p>
<pre><code class="language-tsx">// layout.tsx

const RootLayout = () =&gt; {
    // 로그인 체크
  return (
    &lt;LayoutWrapper&gt;
      &lt;Sidebar /&gt;
      &lt;Outlet /&gt;
    &lt;/LayoutWrapper&gt;
  );
};</code></pre>
</li>
</ul>
</li>
</ul>
<h3 id="공용-컴포넌트-formfield">공용 컴포넌트 (FormField)</h3>
<p>로그인, 회원가입, 메시지 발송 등 여러 폼을 구현해야 했기 때문에, 폼의 구성요소를 컴포넌트화하여, 재사용할 수 있도록 했습니다.</p>
<p>공용 컴포넌트는 shadcn/ui 를 참고하여 구조화했습니다.</p>
<ul>
<li><p>사용 예제</p>
<pre><code class="language-tsx">  &lt;form&gt;
      &lt;FormField&gt;
          &lt;FormFieldLabel&gt;라벨&lt;/FormFieldLabel&gt;
          &lt;Input
              ref={inputRef}
              value={value}
              onChange={onChange}
              onBlur={onBlur}
              disabled={disabled}
              name={name}
          /&gt;
          &lt;FormFieldError&gt;{error.message}&lt;/FormFieldError&gt;
      &lt;/FormField&gt;
  &lt;/form&gt;</code></pre>
</li>
<li><p>구현 상세 내용</p>
<ul>
<li><p>FormField
label과 input을 연결할 수 있는 <code>htmlfor</code> 값을 Context API를 통해 관리합니다.</p>
</li>
<li><p>Input
<code>react-hook-form</code> 을 활용했을 때, 해당 컴포넌트에 ref를 전달하기 때문에 이를 위해 forwardRef를 사용하여 컴포넌트를 정의했습니다.</p>
</li>
<li><p>FormFieldError
에러 메시지를 렌더링합니다.</p>
</li>
</ul>
</li>
</ul>
<h3 id="복잡한-폼-관리">복잡한 폼 관리</h3>
<p>폼을 관리하기 위해 <code>react-hook-form</code> 라이브러리를 사용했습니다.</p>
<p>각 필드의 유효성을 검증하기 위해서 <code>zod</code> 와 <code>@hookform/resolver</code> 라이브러리를 활용했습니다.</p>
<ul>
<li><p>구현 예시</p>
<pre><code class="language-tsx">  const formSchema = z.object({
      header: z.string().max(100, &quot;헤더는 최대 100자까지 가능합니다&quot;),
      recipents: z.array(z.object({ phoneNumber: z.string() })).min(1, &quot;수신자를 입력해주세요&quot;),
  });

  type FormValues = z.infer&lt;typeof formSchema&gt;;

  const MessageForm = () =&gt; {
      const form = useForm&lt;FormValues&gt;({
          resolver: zodResolver(formSchema), // 유효성을 검증합니다.
          defaultValues: {
              header: &quot;&quot;,
              recipents: [{ phoneNumber: &quot;&quot; }],
          },
      });

      // ...

      return (
          &lt;form&gt;
              &lt;Controller
                  control={form.control}
                  name=&quot;header&quot;
                  render={({ field, fieldState }) =&gt; (
                      &lt;FormField&gt;
                          &lt;FormFieldLabel&gt;헤더&lt;/FormFieldLabel&gt;
                          &lt;Input {...field} hasError={!!fieldState.error} /&gt;
                          &lt;FormFieldError&gt;{fieldState.error?.message}&lt;/FormFieldError&gt;
                      &lt;/FormField&gt;
                  )}
              /&gt;
          &lt;/form&gt;
      );
  };</code></pre>
</li>
</ul>
<h3 id="api-에러-핸들링-로그인-회원가입">API 에러 핸들링 (로그인, 회원가입)</h3>
<p>백엔드로의 요청에 대한 응답을 성공과 실패 두가지 케이스로 나누어, 구조화하였습니다.
에러가 발생했을 때, 어떤 데이터와 관련이 있는지도 전달하여 해당 필드에 에러를 표현하였습니다.</p>
<pre><code class="language-ts">// auth.ts

type AuthAPIResponse&lt;D = unknown, T = unknown&gt; = 
  | {
      success: true;
      data: D;
    }
  | {
      success: false;
      target: T;
      message: string;
    }

const login = async (values: {
  email: string;
  password: string;
}): Promise&lt;AuthAPIResponse&lt;unknown, Path&lt;LoginInput&gt;&gt;&gt; =&gt; {
  const url = `${API_DOMAIN}/api/auth/signin`;
  const res = await fetch(url, {
    method: &quot;POST&quot;,
    body: JSON.stringify(values),
    headers: {
      &quot;Content-Type&quot;: &quot;application/json&quot;,
    },
  });

  const isSuccess = res.ok;

  if (isSuccess) {
    const data = await res.json();
    return {
      success: true,
      data,
    };
  }

  switch (res.status) {
    case 400: {
      const data = await res.json();
      // 백엔드 응답에 따른 분기처리
      const isInValidEmail = data.detail === &quot;Invalid email&quot;;
      const isInValidPassword = data.detail === &quot;Invalid password&quot;;

      if (isInvalidEmail) {
        return {
          success: false,
          target: &quot;email&quot;,
          message: &quot;가입된 이메일이 아닙니다.&quot;,
        };
      }

      if (isInvalidPassword) {
        return {
          success: false,
          target: &quot;password&quot;,
          message: &quot;비밀번호가 일치하지 않습니다.&quot;,
        };
      }

      throw new Error(&quot;알 수 없는 오류가 발생했습니다.&quot;);
    }

    default: {
      throw new Error(&quot;알 수 없는 오류가 발생했습니다.&quot;);
    }
  }
}</code></pre>
<p>해당 API 메서드를 호출하는 컴포넌트에서는 다음과 같이 에러를 처리할 수 있었습니다.</p>
<pre><code class="language-tsx">const form = useForm(...);

const onSubmit = async (values) =&gt; {
  try {
      const res = await login(values);

    const isSuccess = res.success;

    if (!isSuccess) {
      form.setError(
        res.target,
        {
          type: &quot;&quot;,
          message: res.message,
        },
        { shouldFocus: true },
      );
    }

    // ...
  } catch (e) {
    if (e instanceof Error) {
      alert(error.message);
    } else {
      console.error(error);
    }
  }
};</code></pre>
<hr>
<h2 id="추가한-라이브러리">추가한 라이브러리</h2>
<ul>
<li><p>react-router
페이지 라우팅</p>
</li>
<li><p>zod
유효성 검증(form)</p>
</li>
<li><p>react-hook-form
form 관리</p>
</li>
<li><p>@hook-form/resolver
zod를 활용하여, react-hook-form의 유효성 검증을 수행하기 위함</p>
</li>
<li><p>react-hot-toast
토스트</p>
</li>
</ul>
<hr>
<h2 id="어려웠던-부분">어려웠던 부분</h2>
<h3 id="emotion">emotion</h3>
<p>기존에는 주로 tailwind를 사용하여, 스타일링을 진행했었다 보니 emotion을 통해 스타일링을 하는 것이 익숙치 않아, 고민되는 부분이 있었습니다.</p>
<ul>
<li><code>css</code>, <code>styled</code>, <code>Global</code> 등 스타일링을 할 수 있는 방법이 다양하게 있어, 어떤 방식으로 스타일링을 진행할 것인가?</li>
</ul>
<ul>
<li>코드 가독성을 저해하지 않는 방법은 무엇일까?</li>
</ul>
<ul>
<li>스타일링을 위한 코드가 컴포넌트 구조 혹은 로직에 대한 이해를 위한 가독성을 떨어뜨리지 않도록 하려면?</li>
</ul>
<ul>
<li>스타일이 선언된 위치와 해당 스타일이 적용되는 요소 간, 간격이 멀지 않도록 하고자 함.</li>
</ul>
<ul>
<li>재사용 하지 않는 스타일은 어디에 정의할 것인가?</li>
</ul>
<ul>
<li>재사용 가능한 스타일 관련 코드는 어떻게 관리할 것인가?</li>
</ul>
<ul>
<li><code>Global</code> 을 사용하여, 해당 컴포넌트의 하위 컴포넌트에 대해 일관된 스타일을 적용하도록 시도해 봤지만, 이 방법은 스타일링 코드와 적용되는 부분 간 거리가 떨어져 있기에 스타일을 수정하기 위해서 찾기 힘들 수 있으며, 해당 코드가 어디에 영향을 미치는지 이해하기 어려울 수 있기에 사용하지 않는 것이 좋겠다고 판단.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[프론트엔드 과제 회고 - 대시보드 만들기]]></title>
            <link>https://velog.io/@byunghun-jake/assignment-dashboard</link>
            <guid>https://velog.io/@byunghun-jake/assignment-dashboard</guid>
            <pubDate>Tue, 09 Jul 2024 12:09:10 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>회고 목적: 시간을 들인 과제를 회고하면서, 개선점을 찾아보기
과제에 대한 세부 내용은 생략되었습니다.</p>
</blockquote>
<h2 id="평가-기준">평가 기준</h2>
<ul>
<li>기능 구현</li>
<li>관심사 분리</li>
<li>정적 타입</li>
<li>hook 함수</li>
</ul>
<hr>
<h2 id="요구사항">요구사항</h2>
<h3 id="필수-사용-언어-및-라이브러리">필수 사용 언어 및 라이브러리</h3>
<ul>
<li>Typescript</li>
<li>Nextjs</li>
<li>tailwind</li>
</ul>
<h3 id="기능">기능</h3>
<ul>
<li>유저 row 무한 스크롤</li>
<li>유저 id 복사</li>
</ul>
<hr>
<h2 id="구현-내용">구현 내용</h2>
<h3 id="무한-스크롤을-위한-상태-관리">무한 스크롤을 위한 상태 관리</h3>
<h4 id="방식-고민">방식 고민</h4>
<ol>
<li><p>서버 상태 관리를 위한 라이브러리(tanstack query)를 사용하는 게 좋을까? <strong>사용하지 않는 것으로 함</strong></p>
<p> 이전 회사에서 사용했던 방법은 react-query의 infiniteQuery를 사용하는 것이었다.</p>
<p> 클라이언트 사이드에서 데이터를 가져오는 방식은 첫 페이지를 렌더링할 때 값이 없는 상태가 되었다. 이 문제는 페이지 컴포넌트를 서버에서 렌더링할 때, 서버사이드에서 prefetch를 수행하고, hydrate 시 그 데이터를 기본 값으로 사용하는 방식을 통해 해결할 수 있었다.</p>
<p> 이전 회사는 NextJS 12 버전을 사용하고 있었고, 이번 과제는 NextJS 14 (app router)였기에 동일한 방식을 적용할 수는 없었다.</p>
<p> 우선, react-query를 사용해야 하는지를 생각해보았다. 현재는 tanstack-query 라이브러리인 해당 라이브러리는 서버 상태를 관리하는 데 유용한 라이브러리이다.</p>
<p> 유저의 데이터만 다루면 되었기에, 서버 상태를 관리하기 위한 라이브러리까지 추가하진 않아도 될 것이라고 생각했다. 또한, 과제를 진행하는 입장에서는 라이브러리를 사용하면, 안좋게 보진 않을까? 싶기도 했기 때문에…</p>
<p> 서버 상태 관리는 라이브러리를 사용하지 않고, 직접 구현하는 것으로 했다.</p>
</li>
<li><p>로컬 상태 vs <strong>전역 상태</strong></p>
<pre><code class="language-tsx"> &lt;Page&gt;
     &lt;Filter /&gt;
     &lt;DataSection /&gt;
 &lt;/Page&gt;</code></pre>
<p> 컴포넌트 구조를 위와 같이 설계했기에, Filter와 DataSection 모두 사용하는 데이터는 각 컴포넌트 상위에서 관리해야 했다.</p>
<p> Page 컴포넌트는 서버 컴포넌트로 구현하려고 했기 때문에(클라이언트 컴포넌트로 변경하면, app router의 서버 컴포넌트 이점을 아무것도 살리지 못할 것이라고 생각했음) 로컬 상태가 아닌 전역 상태로 관리하고자 했다.</p>
</li>
<li><p>전역 상태를 관리하기 위한 방법</p>
<p> 서버 상태를 관리하기 위해 라이브러리를 설치하지 않았는데, 전역 상태를 관리하기 위해 라이브러리를 추가하는 것은 이상했기에 전역 상태도 직접 구현하고자 했다.</p>
<p> nextjs에서는 서버 컴포넌트와 클라이언트 컴포넌트를 조합하여, 효율적으로 렌더링하기 위해 composition pattern을 소개하고 있고, ContextAPI의 Provider를 활용하는 방식 역시 동일한 패턴이기에 ContextAPI를 활용하여 데이터를 관리하는 것으로 결정했다.</p>
</li>
</ol>
<h4 id="구현">구현</h4>
<p>무한 스크롤 구현을 위해 필요한 로직은 Context 내부에 담아, 관심사를 분리하고자 했다.</p>
<pre><code class="language-tsx">// DataContext.tsx
&#39;use client&#39;;

type DataContextType = {
    data: Data[];
    setData: (data: Data[]) =&gt; void;
    loadMoreData: () =&gt; void; // 다음 데이터를 가져오기
    isFetching: boolean;      // 데이터 로딩 중
    hasMore: boolean;         // 추가로 가져올 데이터가 있는지 여부
}

const DataContext = createContext&lt;DataContextType | null&gt;(null);

const DataContextProvider = ({
    initialData, // 렌더링시 필요한 초기 값
    children,    // compositionPattern
}) =&gt; {
    const [data, setData] = useState([initialData]);
    const [offset, setOffset] = useState(10);
    const [isFetching, setIsFetching] = useState(false);
    const [hasMore, setHasMore] = useState(true);

    const loadMoreData = async () =&gt; {
        if (isFetching || !hasMore) return;

        setIsFetching(true);
        const newData = await getData(offset, 10);

        setData(prev =&gt; [...prev, ...newData]);
        setOffset(prev =&gt; prev + 10);
        setIsFetching(false);
        setHasMore(newData.length === 10);
    };

    return (
        &lt;DataContext.Provider
            value={{
                data,
                setData,
                loadMoreData,
                isFetching,
                hasMore,
            }}
        &gt;
            {children}
        &lt;/DataContext.Provider&gt;
    )
};</code></pre>
<pre><code class="language-ts">// useDataContext.ts

const useDataContext = () =&gt; {
    const contextValue = useContext(DataContext);

    if (!contextValue) {
        throw new Error(&#39;useDataContext는 DataContextProvider 내부에서 사용되어야 합니다.&#39;);
    }

    return contextValue;
}</code></pre>
<p>초기 데이터 문제는 서버 컴포넌트가 서버에서 렌더링될 때 가져온 후, Provider로 값을 넘겨주는 것으로 해결할 수 있었다.</p>
<pre><code class="language-tsx">// page.tsx

export default async function Home() {
    const data = await getData(0, 10);

    return (
        &lt;DataContextProvider initialData={data}&gt;
            &lt;Filter /&gt;
            &lt;DataSection /&gt;
        &lt;/DataContextProvider&gt;
    )
}</code></pre>
<p>데이터를 사용하여, 대시보드에 렌더링</p>
<pre><code class="language-tsx">// DataSection.tsx

const DataSection = () =&gt; {
    const { data, loadMoreData, isFetching, hasMore } = useDataContext();

    // ...
    return (
        &lt;&gt;
            {data.map...}
            {isFetching &amp;&amp; &lt;LoadingSkeleton /&gt;}
            {hasMore &amp;&amp; &lt;div ref={loader}&gt;...&lt;/div&gt;}
        &lt;/&gt;
    )
}</code></pre>
<h3 id="무한-스크롤을-위한-추가-데이터-요청-트리깅">무한 스크롤을 위한 추가 데이터 요청 트리깅</h3>
<p>스크롤을 내려, 대시보드 최하단까지 스크롤이 되었을 때 추가로 데이터를 가져오는 로직을 구현하기</p>
<h4 id="방식">방식</h4>
<p>Intersection Observer API를 활용하여, 최하단까지 스크롤이 되었음을 확인하고 이때 다음 데이터를 불러오도록 함</p>
<h4 id="구현-과정">구현 과정</h4>
<ol>
<li>리스트 최하단에 “더보기” 버튼을 만들어, 데이터 추가 요청과 업데이트가 제대로 이루어지는지 확인</li>
<li>더보기 버튼 위치에 Intersection Observer를 적용한 요소를 만들어, 해당 요소가 화면에 노출되었을 때 데이터를 추가로 불러오도록 함</li>
</ol>
<h4 id="구현-1">구현</h4>
<pre><code class="language-tsx">// DataSection.tsx

const DataSection = () =&gt; {
    // ...

    const loader = useRef&lt;HTMLLIElement&gt;(null);

    useEffect(() =&gt; {
        const intersectionObserverOptions: IntersectionObserverInit = {
            root: null,
            rootMargin: &quot;0px&quot;,
            threshold: 1.0,
        };

        const observer = new IntersectionObserver((entries) =&gt; {
            entries.forEach((entry) =&gt; {
                if (entry.isIntersecting) {
                    loadMoreData();
                }
            });
        }, intersectionObserverOptions);

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

        return () =&gt; {
            observer.disconnect();
        };
    }, [loadMoreData]);

    return (
        &lt;&gt;
            {data.map...}
            {isFetching &amp;&amp; &lt;LoadingSkeleton /&gt;}
            {hasMore &amp;&amp; &lt;div ref={loader}&gt;...&lt;/div&gt;}
        &lt;/&gt;
    )
};</code></pre>
<h3 id="id-복사">id 복사</h3>
<p>버튼을 눌렀을 때, 해당 유저의 아이디를 클립보드에 복사하기 위해 <code>Navigator.clipboard.writeText</code> 메서드를 활용했다.</p>
<pre><code class="language-tsx">const copyToClipboard = async (text: string) =&gt; {
    try {
        await navigator.clipboard.writeText(text);
        alert(&#39;복사되었습니다.&#39;);
    } catch (e) {
        alert(&#39;복사에 실패했습니다.&#39;);
    }
}</code></pre>
<h2 id="추가한-라이브러리">추가한 라이브러리</h2>
<h3 id="zod">zod</h3>
<h4 id="추가한-이유">추가한 이유</h4>
<p>json-server로 API 요청을 보내, 데이터를 가져올 때 해당 데이터에 대한 검증을 위한 데이터 검증 레이어를 추가하기 위해서.</p>
<h4 id="사용-예시">사용 예시</h4>
<pre><code class="language-tsx">// getData.ts

const dataSchema = z.object({
    id: z.number(),
    name: z.string(),
    // ...
});

const getData = async (offset: number, limit: number) =&gt; {
    const res = await fetch(...);

    // ...

    const data = await res.json();

    try {
        return dataSchema.array().parse(data);
    } catch (e) {
        console.error(&#39;screening data 구조가 올바르지 않습니다.&#39;, e);
        return [];
    }
}</code></pre>
<h3 id="dayjs">dayjs</h3>
<h4 id="추가한-이유-1">추가한 이유</h4>
<p>날짜 및 시간 포맷팅</p>
<p>대체 가능한 라이브러리로는 date-fn, moment 등이 있음. 그 중 dayjs를 선택한 이유는 사이즈가 가장 작고, 높은 다운로드 수 &amp; 스타 수.</p>
<hr>
<h2 id="개선할-점">개선할 점</h2>
<h3 id="관심사-분리">관심사 분리</h3>
<h4 id="intersection-observer-api">Intersection Observer API</h4>
<p>Intersection Observer API 관련 로직을 <code>DataSection.tsx</code>에 두었는데, 이는 훅이나 컴포넌트로 분리시키는 것이 더 좋았을 것 같다.</p>
<pre><code class="language-tsx">// useIntersectionObserver.ts

type Props = {
  callback: IntersectionObserverCallback;
  options?: IntersectionObserverInit;
};

const DEFAULT_OPTIONS: IntersectionObserverInit = {
  root: null,
  rootMargin: &quot;0px&quot;,
  threshold: 0,
};

export const useIntersectionObserver = ({
  options = DEFAULT_OPTIONS,
  callback,
}: Props) =&gt; {
  const observer = useRef&lt;IntersectionObserver | null&gt;(null);

  useEffect(() =&gt; {
    const handleIntersect = (
      entries: IntersectionObserverEntry[],
      observer: IntersectionObserver
    ) =&gt; {
      if (callback) {
        callback(entries, observer);
      }
    };

    observer.current = new IntersectionObserver(handleIntersect, options);

    return () =&gt; {
      if (observer.current) {
        observer.current.disconnect();
      }
    };
  }, [callback, options]);

  const observe = (element: Element) =&gt; {
    if (observer.current &amp;&amp; element) {
      observer.current.observe(element);
    }
  };

  const unobserve = (element: Element) =&gt; {
    if (observer.current &amp;&amp; element) {
      observer.current.unobserve(element);
    }
  };

  return {
    observe,
    unobserve,
  };
};
</code></pre>
<h3 id="상태-관리">상태 관리</h3>
<h4 id="라이브러리-활용하기">라이브러리 활용하기</h4>
<p>Context API가 아닌 다른 상태관리 라이브러리를 사용하는 것이 더 나을 수 있을 것 같다.</p>
<p>해당 회사의 채용 공고를 보면, &#39;Zustand, Recoil, Redux 등 상태 관리 개발 경험이 있으신 분&#39;이 명시되어 있는데 단순한 상태를 관리한다는 이유로 Context API를 사용하는 것은 채용 공고에 명시되어 있는 상태 관리 방식을 어필할 수 없게 되는 부분도 있기 때문.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[클(라이밍쿨)타임 - 서비스로만 해결할 수 있는 문제란?]]></title>
            <link>https://velog.io/@byunghun-jake/cool-time-2</link>
            <guid>https://velog.io/@byunghun-jake/cool-time-2</guid>
            <pubDate>Mon, 08 Jul 2024 10:59:59 GMT</pubDate>
            <description><![CDATA[<p>&quot;클라이밍&quot;을 가지고 서비스를 만들고 싶다는 생각은 꾸준히 하고 있었는데, 구체적인 아이템이 떠오르지는 않았었다.
클라이밍을 위한 운동 기록 앱은 충분히 있었고, 많은 클라이머들은 인스타 부계정을 만들어서 잘 기록하고 있는 것 같았기에 &quot;기록&quot;을 위한 앱은 제외하기로 했다.</p>
<p>여러 암장들을 다니면서 운동을 하는데, 방문한 날 그 암장이 세팅 중이라면 뭔가 손해보는 기분이 들었었다. 보통 4~5개의 섹터로 구분되는데, 1개 섹터가 탈거되어 있으면... ㅠ</p>
<p>세팅 중인 암장을 피하기 위해서 나는 사람들과 함께 클라이밍 약속을 잡을 때, 그 암장이 세팅 중인지 인스타를 통해 확인하는 버릇이 생겼다.
대부분 암장은 각각 인스타 계정을 운영하고 있어서, 그곳에서 세팅 정보도 확인할 수 있었는데, 여러 암장을 후보지로 두고 갈 곳을 찾는 내 입장에서는 하나하나 들어가야 하는 게 귀찮고 불편했었다.</p>
<p>불편한 이 지점이, 내가 뭔가를 해볼 수 있는 포인트라고 생각했다.
<strong>&quot;사람들이 많이 방문하는 암장의 이번주 세팅 일정을 알려주는 것&quot;</strong></p>
<p>이 문제를 해결하기 위해, 나는 서비스를 만들지 않았다.
나는 세팅 일정을 올리는 인스타 부계정을 만들었다.</p>
<p>서비스를 만들지 않고, 인스타그램 부계정을 만든 이유는</p>
<ol>
<li>클라이밍을 활발히 하고 있는 사람들은 주로 인스타그램을 이용한다. 따라서, 해당 플랫폼 내에서 정보를 얻는 것이, 인스타에서 내가 만든 서비스로 사람들을 오게 만드는 것보다 훨씬 쉬울 것</li>
<li><strong>이번주 세팅 일정</strong>은 인스타그램 게시물로 정보 전달이 충분히 가능함</li>
</ol>
<p>나는 인스타그램 부계정을 먼저 운영해보고, &quot;세팅 일정&quot;을 확인하고자 하는 수요가 있는지 파악하면서 서비스가 필요한 지점을 찾아보는 중이다.</p>
<p>--</p>
<p>혹시 클라이밍을 좋아하는 velog 분들이 있다면, 한번 구경오세요 :)
<a href="https://www.instagram.com/de.gu.r.r/">@de.gu.r.r</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[클타임 - 나는 클라이밍을 가지고, 서비스를 만들지 않았다.(1)]]></title>
            <link>https://velog.io/@byunghun-jake/%ED%81%B4%ED%83%80%EC%9E%84-%EB%82%98%EB%8A%94-%ED%81%B4%EB%9D%BC%EC%9D%B4%EB%B0%8D%EC%9D%84-%EA%B0%80%EC%A7%80%EA%B3%A0-%EC%84%9C%EB%B9%84%EC%8A%A4%EB%A5%BC-%EB%A7%8C%EB%93%A4%EC%A7%80-%EC%95%8A%EC%95%98%EB%8B%A4.1</link>
            <guid>https://velog.io/@byunghun-jake/%ED%81%B4%ED%83%80%EC%9E%84-%EB%82%98%EB%8A%94-%ED%81%B4%EB%9D%BC%EC%9D%B4%EB%B0%8D%EC%9D%84-%EA%B0%80%EC%A7%80%EA%B3%A0-%EC%84%9C%EB%B9%84%EC%8A%A4%EB%A5%BC-%EB%A7%8C%EB%93%A4%EC%A7%80-%EC%95%8A%EC%95%98%EB%8B%A4.1</guid>
            <pubDate>Fri, 28 Jun 2024 00:45:35 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/byunghun-jake/post/ffe3736c-16be-4e4a-bf8e-ae2c96642dea/image.png" alt=""></p>
<p>이전부터 클라이밍을 주제로 서비스를 만들고 싶었고, 실제로 잠깐 진행했던 적도 있었다. 그때는 회사에 다니고 있기도 했어서, 회사 사람들을 모아서 진행했었는데 방향이 모호했다보니 완성하기 전에 중단되었었다.</p>
<p>단순히, 만들고 싶다라는 동기 만으로는 서비스를 만들고 싶지 않았기에 당시에 중단하기로 한 판단은 잘했다고 생각한다.</p>
<p>서비스는 모름지기 쓰임이 있어야 하고, 쓰임이 곧 그 서비스의 가치를 증명한다고 생각하기에 만들고 싶은 주제를 가지고 제대로 쓰일 수 있을 것 같은 서비스를 만들고 싶었다.</p>
<p>운동을 기록하고 싶은 사람이 많기 때문에, 클라이밍 역시 운동을 기록하는 서비스가 많이 등장하는데 나는 그 부분은 충분히 인스타그램으로 대체가 될 수 있다고 생각했었다.(초창기에 만들고 싶었던 서비스도 운동 기록 기능을 포함하고자 했었다.)</p>
<p>그렇게 시간이 흐르고, 백수가 되고 평일 낮에 클라이밍을 갈 수 있게 되면서 개인적인 니즈가 생겨나게 된다.
&#39;암장의 세팅 날짜가 언제지? 그 날은 피해서 가고 싶은데&#39;였다. 클라이밍장은 여러개의 벽으로 구성되어 있고, 보통 매주 혹은 격주로 일부 벽을 새롭게 세팅을 하는데 하루에서 이틀정도 소요가 된다.
클라이밍장에 방문했는데, 해당 지점이 세팅을 하고 있다면 그 벽은 사용하지 못하기 때문에 뭔가 아쉬웠다.</p>
<p>그래서 나는 &#39;서비스를 만들지 않았다&#39;.</p>
<p>새롭게 서비스를 만드는 것 만이 능사는 아니었기에, 더 쉽고 접근성이 높은 방안을 생각해 보았다.</p>
<p>이전부터 만들고 싶었던 인스타그램 부계정. 하지만, 아주 멋들어진 무브를 뽑내긴 아직 클린이라고 생각했기에 본계에만 게시물을 올리며 망설였던 부계정.</p>
<p>나는 클라이밍장 세팅 정보를 모으는 부계정을 만들기로 했다. 그리고 어느덧 세번째 게시물을 올리고 있다.
전혀 모르는 사람들이 게시물에 좋아요를 눌러주는 것을 행복해하며, 그게 곧 그 게시물의 가치라고 생각하며 하나씩 꾸준히 올려보려고 한다.</p>
<p>게시물을 준비하며, 정보를 모으고 있던 중 &#39;서비스&#39;로 만들고 싶은 주제가 생각났고 그렇게 서비스 제작에 착수했다. (2편으로...)</p>
<p>--</p>
<p>혹시 클라이밍을 좋아하는 velog 분들이 있다면, 한번 구경오세요 :)
<a href="https://www.instagram.com/de.gu.r.r/">@de.gu.r.r</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JavaScript로 이미지 리사이징(압축) 하기]]></title>
            <link>https://velog.io/@byunghun-jake/JavaScript%EB%A1%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%A6%AC%EC%82%AC%EC%9D%B4%EC%A7%95%EC%95%95%EC%B6%95-%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@byunghun-jake/JavaScript%EB%A1%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%A6%AC%EC%82%AC%EC%9D%B4%EC%A7%95%EC%95%95%EC%B6%95-%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 14 Apr 2024 10:29:29 GMT</pubDate>
            <description><![CDATA[<h2 id="맥락">맥락</h2>
<p>인스타그램 레이아웃의 모바일 청첩장에는 스토리/게시물에 이미지가 필수였다.
이미지의 용량이 크면 청첩장을 만들 때에도 사용자 경험이 좋지 않고, 청첩장을 볼 때에도 경험이 좋지 않을 것이기에 이미지의 용량을 줄이는 과정이 필요했다.</p>
<h2 id="이미지-압축을-어디에서-해야-할까">이미지 압축을 어디에서 해야 할까?</h2>
<p>프로젝트는 NextJS와 supabase로만 구성되어 있기에 이미지 압축을 어디에서 진행할 것인지에 대한 선택지는 두개 였다.</p>
<ol>
<li>클라이언트</li>
<li>서버(NextJS)</li>
</ol>
<p>용량이 큰 이미지가 네트워크 통신을 통해 전달될 때의 시간을 줄이고 싶었던 것이기 때문에 클라이언트에서 이미지 업로드 직후 &#39;이미지 압축&#39; 작업을 하고, 이후에 압축한 이미지를 서버로 전달하도록 하고자 했다.</p>
<h2 id="이미지-압축은-어떻게-할까">이미지 압축은 어떻게 할까?</h2>
<p>어떻게 이미지의 용량을 줄일 수 있을까?</p>
<ol>
<li>확장자를 변경한다.</li>
<li>이미지 크기를 줄인다.</li>
</ol>
<h3 id="이미지-확장자에-따른-차이">이미지 확장자에 따른 차이</h3>
<p>ChatGPT에게 이미지 확장자에 따른 차이를 물어봤고, WebP 방식으로 결정했다.</p>
<hr>
<p>이미지 포맷마다 고유의 특성과 압축 방식을 가지고 있으며, 이는 파일 크기와 이미지 품질에 직접적인 영향을 미칩니다.
주로 고려할 수 있는 확장자는 JPEG, PNG, WebP, AVIF입니다.</p>
<ol>
<li><p>JPEG
손실 압축 방식
투명도 미지원</p>
</li>
<li><p>PNG
비손실 압축 방식
투명도 지원</p>
</li>
<li><p>WebP
손실 압축 방식 (JPEG에 비해 높은 압축 효율)
투명도 지원
브라우저 호환성 이슈 (대부분의 현대 브라우저는 가능)</p>
</li>
<li><p>AVIF
손실, 비손실 압축 방식 (WebP에 비해 높은 압축 효율)
투명도 지원
브라우저 호환성 이슈 (WebP에 비해 지원하는 브라우저가 적음)</p>
</li>
</ol>
<ul>
<li>선택 가이드
웹 호환성 중요: JPEG 또는 PNG
호환성과 압축률의 균형: WebP
최고의 압축률: AVIF</li>
</ul>
<hr>
<h2 id="해보자">해보자</h2>
<h3 id="압축-로직-플로우">압축 로직 플로우</h3>
<p><code>input=file</code> 에 파일을 올렸을 때, 이미지 압축을 수행한다.
이미지 압축은 canvas를 이용한다.
이미지 압축이 완료되면, 압축한 이미지를 supbase에 업로드한다.</p>
<p>압축 과정</p>
<ol>
<li>file을 접근 가능한 dataUrl로 변환한다.</li>
<li>dataUrl을 통해 Image 객체를 생성한다.</li>
<li>canvas에 Image를 그린다. (이미지 크기 조정)</li>
<li>canvas를 blob으로 변환한다. (이미지 확장자 변경 및 품질 조정)</li>
<li>blob을 supabase에 업로드한다.</li>
</ol>
<h3 id="코드를-짜보자">코드를 짜보자</h3>
<pre><code class="language-ts">const compressImageAndUploadFile = (file: File) =&gt; {
  // 1. file을 접근 가능한 dataUrl로 변환한다.
  const fileReader = new FileReader();
  fileReader.onload = () =&gt; {
    const dataUrl = reader.result;

    if (!dataUrl || typeof dataUrl !== &#39;string&#39;) {
      return;
    }

    // 2. dataUrl을 이용해, image 객체를 생성한다.
    const image = new Image();
    image.onload = () =&gt; {
      // 3. canvas를 이용해, 이미지를 압축한다.
      const canvas = document.createElement(&quot;canvas&quot;);

      // 3-1. 이미지의 최대 크기를 제한한다.
      const MAX_HEIGHT = 1024;
      const MAX_WIDTH = 1024;
      // 3-2. 이미지 크기를 줄일 때, 비율을 계산한다.
      const ratio = Math.min(MAX_WIDTH/image.width, MAX_HEIGHT/image.height, 1);

      const newWidth = image.width * ratio;
      const newHeight = image.height * ratio;
      canvas.width = newWidth;
      canvas.height = newHeight;

      const canvasContext = canvas.getContext(&quot;2d&quot;);
      if (!canvasContext) {
         throw new Error(&quot;Cannot get canvas context&quot;);
      }

      canvasContext.drawImage(image, 0, 0, newWidth, newHeight);

      // 4. canvas를 blob으로 변환한다.
      // 확장자: webp
      // 품질: 0.7
      canvas.toBlob(blob =&gt; {
        // 5. 변환한 blob을 file로 변환한 후 supabase에 업로드한다.
        if (!blob) {
          return;
        }
        const compressedFile = new File([blob], &#39;fileName&#39;, {
          type: blob.type,
        });
        uploadFile(compressedFile);
      }, &#39;image/webp&#39;, 0.7)
    };
    image.src = dataUrl;
  };

  // 1-1. fileReader로 file을 읽는다.
  fileReader.readAsDataURL(file);
};

const handleImageChange = (e: ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
  const file = e.target.files?.[0];

  if (!file) {
    return;
  }

  compressImageAndUploadFile(file);
}</code></pre>
<h3 id="코드-리팩토링하기">코드 리팩토링하기</h3>
<p>리팩토링 기준</p>
<ol>
<li>이미지 압축 로직과 파일 업로드 관련 로직을 분리하기</li>
<li>콜백 지옥에 빠지지 않도록 만들기</li>
</ol>
<pre><code class="language-ts">const handleImageChange = async (e: ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
  const file = e.target.files?.[0];

  if (!file) {
    return;
  }

  const dataUrl = await readFileAsDataURL(file);
  const image = await loadImage(dataUrl);
  const compressedBlob = await convertImageToCompressedBlob(image);
  const compressedFile = new File([blob], &#39;fileName&#39;, { type: blob.type });

  await uploadFile(compressedFile);
}</code></pre>
<pre><code class="language-ts">const readFileAsDataURL = (file: File): Promise&lt;string&gt; =&gt; {
  return new Promise&lt;string&gt;((resolve, reject) =&gt; {
    const fileReader = new FileReader();

    reader.onload = () =&gt; {
      const dataUrl = reader.result;

      if (!dataUrl || typeof dataUrl !== &#39;string&#39;) {
        reject(new Error(&#39;Invalid data URL&#39;));
        return;
      }

      resolve(dataUrl);
    };
    reader.onerror = reject;
    reader.readAsDataURL(file);
  })
};

const loadImage = (url: string): Promise&lt;HTMLImageElement&gt; =&gt; {
  return new Promise&lt;HTMLImageElement&gt;((resolve, reject) =&gt; {
    const image = new Image();

    image.onload = () =&gt; reslove(image);
    image.onerror = reject;
    image.src = url;
  })
};

const convertImageToCompressedBlob = (image: HTMLImageElement): Promise&lt;Blob&gt; =&gt; {
  const MAX_WIDTH = 1024;
  const MAX_HEIGHT = 1024;
  const BLOB_TYPE = &quot;image/webp&quot;;
  const BLOB_QUALITY = 0.7;

  return new Promise((resolve, reject) =&gt; {
    const canvas = document.createElement(&quot;canvas&quot;);

    const ratio = Math.min(MAX_WIDTH/image.width, MAX_HEIGHT/image.height, 1);

    const newWidth = image.width * ratio;
    const newHeight = image.height * ratio;
    canvas.width = newWidth;
    canvas.height = newHeight;

    const canvasContext = canvas.getContext(&quot;2d&quot;);
    if (!canvasContext) {
      throw new Error(&quot;Cannot get canvas context&quot;);
    }

    canvasContext.drawImage(image, 0, 0, newWidth, newHeight);
    canvas.toBlob(blob =&gt; {
      if (!blob) {
        reject(new Error(&quot;Cannot convert canvas to Blob&quot;));
        return;
      }

      resolve(blob);
    }, BLOB_TYPE, BLOB_QUALITY);
  })
};</code></pre>
<h2 id="problem">Problem</h2>
<h3 id="1-확장자만-바꾼다고-해서-용량이-줄어드는-건-아니다">1. 확장자만 바꾼다고 해서, 용량이 줄어드는 건 아니다.</h3>
<p>압축률이 좋은 확장자라고 했으니, 확장자만 바꿨을 때에도 용량이 줄어들 것이라고 생각했다.</p>
<p>하.지.만
canvas에 이미지를 로드한 후, blob으로 변환할 때 quality를 1로 설정했더니 용량이 오히려 커지는 경우가 생겼다.</p>
<p>canvas에서 이미지를 변환할 때, quality 매개변수는 0부터 1까지 입력이 가능하고 1은 최고 품질을 의미한다. 따라서, 최고 품질로 설정했을 때에는 파일 크기가 오히려 커질 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/byunghun-jake/post/003a399d-d09e-4c13-a64d-5e1d42159f8e/image.png" alt=""></p>
<blockquote>
<p><code>quality</code>를 1.0으로 설정했을 때,
원본 파일 크기: 16,387,072
변환한 파일 크기: 29,319,412</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/byunghun-jake/post/c9b6d46d-d5af-489a-9d0b-069fb4fc7efa/image.png" alt=""></p>
<blockquote>
<p><code>quality</code>를 0.7로 설정했을 때,
변환한 파일 크기: 917,310</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[#7 Route Handler는 매번 익숙해지지 않네]]></title>
            <link>https://velog.io/@byunghun-jake/7-Route-Handler%EB%8A%94-%EB%A7%A4%EB%B2%88-%EC%9D%B5%EC%88%99%ED%95%B4%EC%A7%80%EC%A7%80-%EC%95%8A%EB%84%A4</link>
            <guid>https://velog.io/@byunghun-jake/7-Route-Handler%EB%8A%94-%EB%A7%A4%EB%B2%88-%EC%9D%B5%EC%88%99%ED%95%B4%EC%A7%80%EC%A7%80-%EC%95%8A%EB%84%A4</guid>
            <pubDate>Sat, 30 Mar 2024 15:01:47 GMT</pubDate>
            <description><![CDATA[<h1 id="겪은-문제">겪은 문제</h1>
<h2 id="1-file을-보내려면">1. file을 보내려면?</h2>
<h3 id="문제">문제</h3>
<p>fetch를 통해 Route Handler로 요청을 보낼 때, data에 파일을 그대로 넣었더니 <code>json()</code> 메서드로 파싱시, 에러가 발생했다.</p>
<pre><code class="language-typescript">// StoryForm.tsx
const uploadImageFile = async (file: File) =&gt; {
  const res = await fetch(url, {
    method: &#39;POST&#39;,
    body: compressedImageFile,
  });

  //...
}</code></pre>
<pre><code class="language-typescript">// route.ts
export const POST = async (req) =&gt; {
  const requestBody = await req.json();
  // SyntaxError: Unexpected token R in JSON at position 0
}</code></pre>
<h3 id="문제-원인">문제 원인</h3>
<p>파일을 API Route로 보내고 처리하는 과정에서 JSON으로 데이터를 파싱하려고 시도했을 때 발생하는 문제는 파일 데이터를 JSON 형식으로 직접 보내려고 했기 때문이다.
파일은 바이너리 데이터이므로 JSON 형식으로 직접 변환되지 않는다. 따라서 파일을 전송할 때는 FormData 객체를 사용하여 멀티파트 형식으로 데이터를 인코딩해야 한다.</p>
<h3 id="문제-해결">문제 해결</h3>
<pre><code class="language-typescript">// StoryForm.tsx
const uploadImageFile = async (file: File) =&gt; {
  const formData = new FormData();
  formData.append(&#39;file&#39;, file);

  const res = await fetch(url, {
    method: &#39;POST&#39;,
    body: formData,
  });

  //...
}</code></pre>
<pre><code class="language-typescript">// route.ts
export const POST = async (req) =&gt; {
  const requestBody = await req.formData();
  // ...
}</code></pre>
<h2 id="2-file-타입을-확인할-수-없다">2. File 타입을 확인할 수 없다?</h2>
<h3 id="문제-1">문제</h3>
<p>Route Handler에서 요청 formData에 있는 내용이 File 타입인지 확인하기 위해, <code>instanceof</code>를 통해 체크하려고 했다.
하.지.만
에러가 발생했다.</p>
<pre><code class="language-typescript">const checkIsFile = (value: unknown): value is File =&gt; {
  return value instanceof File;
}
// ReferenceError: File is not defined</code></pre>
<h3 id="문제-원인-1">문제 원인</h3>
<p><code>File</code> 객체가 서버 측(Node.js) 환경에서 정의되어 있지 않기 때문이다. <code>File</code> 객체는 브라우저의 Web API의 일부로, 브라우저 환경에서만 사용할 수 있다. 따라서, Next.js의 서버 측 로직(ex: API Route Handler)에서는 접근할 수 없다.</p>
<h3 id="문제-우회">문제 우회</h3>
<p>formData에 담긴 file 객체의 타입을 체크하진 않고, 존재하는지만 확인하도록 함.</p>
<pre><code class="language-typescript">export const POST = async (req: NextRequest) =&gt; {
  const formData = await req.formData();
  const file = formData.get(&#39;file&#39;);

  if (!file) {
    return NextResponse.json({ message: &quot;BadRequest&quot; }, { status: 400 });
  }

  // ...
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[서버사이드 렌더링(React)]]></title>
            <link>https://velog.io/@byunghun-jake/%EC%84%9C%EB%B2%84%EC%82%AC%EC%9D%B4%EB%93%9C-%EB%A0%8C%EB%8D%94%EB%A7%81React</link>
            <guid>https://velog.io/@byunghun-jake/%EC%84%9C%EB%B2%84%EC%82%AC%EC%9D%B4%EB%93%9C-%EB%A0%8C%EB%8D%94%EB%A7%81React</guid>
            <pubDate>Tue, 26 Mar 2024 06:10:42 GMT</pubDate>
            <description><![CDATA[<p>알게된 것</p>
<ul>
<li>&#39;렌더링&#39;이라는 단어로 머리속에 혼재되어 있던, 브라우저 렌더링과 리액트 렌더링을 구분하여 생각할 수 있게 되었다.</li>
<li>리액트 렌더링을 수행하는 쪽에 따라 CSR과 SSR로 구분하여 생각할 수 있게 되었다.</li>
<li>hydrate 시에 브라우저 콘솔에 발생한 경고의 이유를 알게 되었다.</li>
</ul>
<hr>
<p>리액트를 이용해서 프론트엔드 개발을 할 때, 리액트 렌더링이 어느쪽에서 수행되는지를 기준으로 나눠볼 수 있다.</p>
<ol>
<li><p>클라이언트사이드 렌더링(CSR)</p>
</li>
<li><p>서버사이드 렌더링(SSR)</p>
</li>
</ol>
<hr>
<h3 id="단일-페이지-어플리케이션spa의-등장">단일 페이지 어플리케이션(SPA)의 등장</h3>
<p>SPA 등장 이전의 멀티 페이지 어플리케이션은 서비스 내에서 페이지 전환(라우팅) 시, 이동하려는 페이지에 대한 HTML 파일을 새로 가져와 브라우저에서 렌더링을 해야 했다.
이로 인해, 이동할 때 사용자 경험이 좋지 않았다. (다음 페이지에 대한 HTML을 받아 렌더링을 하기 전까지, 빈 화면이 노출)</p>
<blockquote>
<p>액자에 그림을 바꿔서 걸 때, 이전 그림을 빼고 다음 그림을 넣는 동안 액자 뒷면이 그대로 노출 되는 것</p>
</blockquote>
<p>모바일 앱과 유사한 사용자 경험을 제공하기 위해 자바스크립트 코드를 사용하여, 유저 인터렉션에 따라 DOM 구조를 수정하는 방식을 도입하고자 함.</p>
<h4 id="동작-방식">동작 방식</h4>
<p>SPA는 단일 페이지(HTML)로 구성된 앱으로, 초기 페이지 요청 시 함께 받은 자바스크립트 코드를 이용하여 렌더링 및 리렌더링을 수행한다.</p>
<blockquote>
<p>액자에 그림을 바꾸기 위해, 그려져있던 그림을 지우개로 지우고 새로 그리는 방식</p>
</blockquote>
<h4 id="단점">단점</h4>
<p>첫 페이지 요청 시, 많은 양의 자바스크립트 코드를 다운로드해야 하므로 초기 로딩 시간이 길어진다.</p>
<blockquote>
<p>액자를 두고, 첫 그림을 가져올 때 그림을 그릴 빈 도화지를 가져오고 그림을 그릴 도구들을 가져온 후에 직접 그려야 함.</p>
</blockquote>
<h4 id="단점-해결-방안">단점 해결 방안</h4>
<ol>
<li><p>코드 스플리팅과 지연 로딩
필요한 코드를 필요한 시간에 받도록 하여, 로딩 시간을 줄일 수 있음</p>
<blockquote>
<p>그릴 수 있는 그림을 모두 그릴 수 있도록 여러 도구(연필, 물감, 크레파스 등)를 처음부터 준비하지 않고, 지금 보려는 그림을 그릴 도구만 준비하는 것.</p>
</blockquote>
</li>
<li><p>서버사이드 렌더링
페이지 요청 시, 서버에서 해당 페이지에 대한 HTML을 렌더링한 후 클라이언트로 전달</p>
<blockquote>
<p>빈 도화지를 받지 않고, 그림이 그려진 도화지를 받는 것.</p>
</blockquote>
</li>
</ol>
<h3 id="서버사이드-렌더링">서버사이드 렌더링</h3>
<h4 id="과정">과정</h4>
<p>서버에서 요청받은 페이지에 대해 HTML을 렌더링한 후, 클라이언트에 전달한다.
클라이언트에서는 전달 받은 HTML에 대해 Hydration이 수행된다.</p>
<p>리액트가 클라이언트 측에서 실행되어, 정적인 HTML에 동적인 기능을 추가하는 것을 Hydration이라고 한다.
이 과정에는 이벤트 리스너의 등록, 리액트 컴포넌트의 상태 관리 등이 포함되어, 사용자와의 인터렉션이 가능한 동적인 페이지로 전환된다.</p>
<h4 id="추가-설명">추가 설명</h4>
<ul>
<li><p>서버사이드에서 리액트 컴포넌트를 렌더링할 때, <code>ReactDomServer.renderToString()</code> 이라는 메서드를 사용한다.
정적 HTML 마크업을 생성한다.</p>
</li>
<li><p>클라이언트 사이드에서 Hydration을 수행할 때는 <code>ReactDOM.hydrate()</code> 메서드를 사용한다.
정적 HTML 마크업에 리액트 이벤트 핸들러를 연결하고, 리액트 상태 관리 및 생명주기 메서드 등을 활성화한다.</p>
</li>
<li><p>Hydration시, 서버에서 생성한 마크업과 연결하려고 하는 컴포넌트를 렌더링했을 때의 마크업이 다르면 &#39;경고&#39;가 발생하고, 클라이언트사이드 렌더링으로 전환된다.
서버사이드 렌더링에 이어 추가 렌더링이 발생하게 될 수 있음.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[프론트엔드 면접 질문]]></title>
            <link>https://velog.io/@byunghun-jake/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EB%A9%B4%EC%A0%91-%EC%A7%88%EB%AC%B8</link>
            <guid>https://velog.io/@byunghun-jake/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EB%A9%B4%EC%A0%91-%EC%A7%88%EB%AC%B8</guid>
            <pubDate>Tue, 26 Mar 2024 05:31:46 GMT</pubDate>
            <description><![CDATA[<h2 id="쿠키">쿠키</h2>
<h3 id="쿠키란">쿠키란?</h3>
<p>웹 서버가 생성하여, 웹 브라우저로 전송하는 &quot;작은 정보 파일&quot;
웹 브라우저는 수신한 쿠키를 일정 기간동안 저장하고, 향후 사용자가 웹 서버에 요청을 보낼 때, 첨부한다.</p>
<h4 id="언제-쓸까">언제 쓸까?</h4>
<ul>
<li><p>사용자 세션
웹 서버에 요청을 보내는 사용자가 누구인지 파악하기 위해.
ex) 웹 사이트에 로그인했을 때, 웹 서버에서는 세션 쿠키를 생성하여 브라우저로 전송한다. 브라우저에 저장된 쿠키는 이후 웹 서버로 보내는 요청에 포함된다. 요청을 받은 웹 서버는 어떤 유저에 대한 요청인지 파악할 수 있게 된다.</p>
</li>
<li><p>트래킹
사용자가 방문한 웹 사이트가 기록되어, 다음에 브라우저가 해당 서버에서 콘텐츠를 로드할 때 기록을 쿠키에 담아 서버로 전송한다.
이를 통해, 어떤 곳들을 방문했었는지를 알 수 있게 된다.</p>
</li>
</ul>
<h4 id="옵션">옵션</h4>
<ul>
<li>유효 기간 설정 (Expires/Max-age)</li>
<li>전송 가능한 요청 도메인 설정 (Domain)</li>
<li>전송 가능한 요청 경로 설정 (Path)</li>
<li>보안 측면<ul>
<li>HTTPS 프로토콜 요청에 대해서만 쿠키를 전송 (Secure)</li>
<li>JavaScript를 통해 접근할 수 없도록 함 (HttpOnly)</li>
<li>같은 사이트 요청에서만 제공 설정 (SameSite)</li>
</ul>
</li>
</ul>
<h4 id="">?!</h4>
<ol>
<li><code>HttpOnly</code> 옵션이 활성화되지 않은 쿠키는 어떻게 가져올 수 있을까?
<code>document.cookie</code> 를 통해, 쿠키 정보를 확인할 수 있다.
<img src="https://velog.velcdn.com/images/byunghun-jake/post/e9abff39-72c2-46dc-b54e-f38e0960f5cc/image.png" alt="문서의 쿠키 가져오기"></li>
</ol>
<h2 id="packagejson과-package-lockjson">Package.json과 Package-lock.json</h2>
<h3 id="packagejson">Package.json</h3>
<p>Node.js 프로젝트에서 사용되는 &quot;프로젝트에 대한 설명서&quot; 파일이다.
프로젝트의 이름, 버전, 작성자, 라이센스, 의존성(서드파티 패키지) 정보가 포함되어 있다.</p>
<h3 id="package-lockjson">Package-lock.json</h3>
<p>프로젝트 기본 정보 및 의존성 트리에 대한 정보가 포함되어 있다.</p>
<h4 id="package-lockjson이-필요한-이유는">Package-lock.json이 필요한 이유는?</h4>
<p><code>package.json</code> 파일 안에 있는 &quot;의존성&quot; 정보에는 해당 패키지에 대한 &#39;대략적인&#39; 버전이 명시된다. 즉, 정확한 패키지 버전에 대한 정보는 없음.
다른 서버에서 프로젝트를 실행할 때, 대략적인 버전으로 설치가 되는 경우에 문제가 생길 &quot;수도&quot; 있기 때문에 특정한 시점을 기준으로 명확한 버전을 명시할 필요가 있기 때문에 <code>package-lock.json</code> 이 필요하다.</p>
<h2 id="브라우저-렌더링">브라우저 렌더링</h2>
<h3 id="브라우저-렌더링-1">브라우저 렌더링</h3>
<p>브라우저 렌더링은 웹 페이지가 사용자의 화면에 표시되는 과정을 말한다.
브라우저 렌더링은 다음 과정을 거쳐 진행된다.</p>
<ol>
<li><p>문서(HTML) 파싱(분석)
브라우저는 HTML Document를 받아서 DOM 트리를 구축한다.
이 트리는 페이지의 구조를 나타내며, 각각의 HTML 태그는 트리의 노드가 된다.</p>
</li>
<li><p>CSS 파싱
CSS 파일과 <code>style</code> 태그 내부의 스타일 정보를 파싱하여, CSSOM 트리를 생성한다. 이 트리는 페이지의 시각적 규칙을 나타낸다.</p>
</li>
<li><p>렌더 트리 구축
DOM 트리와, CSSOM 트리를 결합하여, 렌더 트리를 형성한다.
렌더 트리는 페이지에 실제로 표시되는 요소들만 포함한다.</p>
</li>
<li><p>레이아웃 (리플로우)
렌더 트리의 각 노드에 대해 화면 상의 정확한 위치와 크기를 계산한다.
브라우저 창의 크기 변경이나 요소의 추가 및 삭제 등에 의해 다시 실행될 수 있다.</p>
</li>
<li><p>페인트
렌더 트리의 각 노드를 화면에 그린다. 텍스트 색상, 배경 이미지 등의 시각적 스타일이 적용된다.
레이아웃 단계 이후에 실행된다.</p>
</li>
<li><p>합성
여러 레이어를 합쳐 최종적인 화면을 생성한다.
CSS의 <code>transform</code> 속성을 활용한 애니메이션은 별도의 레이어에서 처리될 수 있는데, 이처럼 복잡한 페이지는 여러 레이어로 구성될 수 있음.</p>
</li>
</ol>
<h4 id="-1">?!</h4>
<ul>
<li>실제로 표시되는 요소만을 포함한다?
<code>display: none</code> 스타일이 적용된 요소는 렌더 트리에 포함하지 않는다.</li>
</ul>
<h3 id="업데이트-유형">업데이트 유형</h3>
<p>Reflow와 Repaint는 브라우저 렌더링 과정에서 발생하는 업데이트 유형이다. 웹 페이지 성능과 직접적인 관련이 있다.</p>
<h4 id="reflow">Reflow</h4>
<p>(정의)
레이아웃을 다시 계산하고, 변경이 필요한 경우 업데이트하는 과정</p>
<p>(원인)
DOM 요소의 추가(제거), 요소의 크기나 위치 변경, 창 크기 조절, 숨겨진 요소의 표시 등의 원인으로 일어난다.</p>
<p>(영향 범위)
한 요소의 변경은 해당 요소의 자식, 부모 요소등 페이지의 다른 부분에도 영향을 줄 수 있다. (페이지 전체 레이아웃을 다시 계산해야 할 수도 있다는 것)</p>
<p>(성능 영향)
비용이 많이 드는 연산이기에, 웹 페이지 성능에 큰 영향을 미친다. 특히, 복잡한 레이아웃을 가진 페이지에서 영향이 크다.</p>
<h4 id="repaint">Repaint</h4>
<p>(정의)
요소의 시각적 변경이 발생했을 때, 변경된 요소를 다시 그리는 과정</p>
<p>(원인)
텍스트 색상 변경, 배경 이미지 변경, 테두리 스타일 변경 등 레이아웃 변경을 수반하지 않는 스타일 변경</p>
<p>(영향 범위)
reflow에 비해 영향 범위가 작다. 해당 요소에 대한 스타일 변경으로 인해 다른 요소에 영향을 주지 않기에 발생한 요소에 대해서만 업데이트가 수행됨.</p>
<p>(성능 영향)
reflow에 비해 성능에 미치는 영향이 적다. 단, 빈번하게 발생하면 렌더링 성능에 부정적인 영향을 줄 수 있다.</p>
<h4 id="불필요한-reflow를-줄이는-방법">불필요한 Reflow를 줄이는 방법</h4>
<blockquote>
<p>일괄적으로 처리하는 방향</p>
</blockquote>
<ol>
<li><p>스타일 변경 일괄 처리 (클래스를 통한 스타일 변경)</p>
<p> <strong>적용 전</strong></p>
<blockquote>
<p>DOM 요소의 스타일을 개별적으로 변경</p>
</blockquote>
<pre><code class="language-jsx"> const element = document.getElementById(&#39;myElement&#39;);
 element.style.padding = &#39;10px&#39;;
 element.style.margin = &#39;20px&#39;;
 element.style.color = &#39;blue&#39;;</code></pre>
<p> <strong>적용 후</strong></p>
<blockquote>
<p>단일 DOM 변경 사이클에서 여러 스타일 변경을 수행하기 위해 수정</p>
</blockquote>
<pre><code class="language-jsx"> const element = document.getElementById(&#39;myElement&#39;);
 element.style.cssText = &#39;padding: 10px; margin: 20px; color: blue;&#39;;
 // 또는
 element.className = &#39;newStyles&#39;;</code></pre>
</li>
<li><p>레이아웃 정보 읽기와 쓰기 분리</p>
<p> <strong>적용 전</strong></p>
<blockquote>
<p>레이아웃을 읽고 쓰는 작업이 혼재되어 있어, 불필요한 Reflow가 발생할 수 있음</p>
</blockquote>
<pre><code class="language-jsx"> const elements = document.querySelectorAll(&#39;.item&#39;);
 for (let element of elements) {
   element.style.height = (element.offsetHeight + 10) + &#39;px&#39;;
 }</code></pre>
<p> <strong>적용 후</strong></p>
<blockquote>
<p>모든 레이아웃 정보를 읽은 후, 일괄적으로 변경을 적용</p>
</blockquote>
<pre><code class="language-jsx"> const elements = document.querySelectorAll(&#39;.item&#39;);
 const heights = Array.from(elements).map(el =&gt; el.offsetHeight + 10);

 elements.forEach((element, index) =&gt; {
   element.style.height = heights[index] + &#39;px&#39;;
 });</code></pre>
</li>
<li><p>오프스크린 요소 사용</p>
<p> 적용 전</p>
<blockquote>
<p>화면에 바로 요소를 추가하고, 스타일을 변경합니다.</p>
</blockquote>
<pre><code class="language-jsx"> const element = document.createElement(&#39;div&#39;);
 document.body.appendChild(element);
 element.style.width = &#39;100px&#39;;
 element.style.height = &#39;100px&#39;;
 // 이러한 방식은 요소가 DOM에 추가될 때마다 Reflow를 유발할 수 있습니다.</code></pre>
<p> 적용 후</p>
<blockquote>
<p>요소를 브라우저의 화면에 표시되지 않는 영역에서 조작한 후, 최종적으로 DOM에 추가합니다.</p>
</blockquote>
<pre><code class="language-jsx"> const element = document.createElement(&#39;div&#39;);
 element.style.width = &#39;100px&#39;;
 element.style.height = &#39;100px&#39;;
 // 요소에 대한 모든 변경을 적용한 후에 DOM에 추가합니다.
 document.body.appendChild(element);
 // 이 방법은 DOM에 요소를 추가하기 전에 모든 변경을 완료하여 Reflow를 최소화합니다.</code></pre>
</li>
</ol>
<h4 id="정리">정리</h4>
<ul>
<li><p>공통점
웹 페이지의 시각적 업데이트 과정의 유형이다.</p>
</li>
<li><p>차이점
Reflow는 레이아웃의 변경을 포함하여, 연산 비용이 더 많이 든다.
Repaint는 시각적 스타일 변경에 국한되어, 레이아웃 변경을 수반하지는 않는다.
따라서, Reflow는 Repaint를 수반할 수 있지만 Repaint가 반드시 Reflow를 수반하는 것은 아니다.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[#6 모바일 청첩장 빌더 - serverState와 clientState 동기화]]></title>
            <link>https://velog.io/@byunghun-jake/6-%EB%AA%A8%EB%B0%94%EC%9D%BC-%EC%B2%AD%EC%B2%A9%EC%9E%A5-%EB%B9%8C%EB%8D%94-serverState%EC%99%80-clientState-%EB%8F%99%EA%B8%B0%ED%99%94</link>
            <guid>https://velog.io/@byunghun-jake/6-%EB%AA%A8%EB%B0%94%EC%9D%BC-%EC%B2%AD%EC%B2%A9%EC%9E%A5-%EB%B9%8C%EB%8D%94-serverState%EC%99%80-clientState-%EB%8F%99%EA%B8%B0%ED%99%94</guid>
            <pubDate>Sun, 03 Mar 2024 01:04:34 GMT</pubDate>
            <description><![CDATA[<h2 id="todo">TODO</h2>
<h3 id="1-update-로직-수정">1. update 로직 수정</h3>
<blockquote>
<p>client에서 직접 supabase로 업데이트 요청을 하지 않고, route로 이양하기
update할 때, 권한 체크하기</p>
</blockquote>
<h4 id="왜">왜?</h4>
<ol>
<li>client 사이드에 업데이트 로직까지 몰려있어, 코드 분리 차원</li>
<li>supabase를 사용하지 않게 되었을 때, 수정을 용이하게 하기 위함</li>
<li>update 과정에서 권한을 체크해야 하는 경우 등의 상황이 생길 때, 어떤 통신을 주고받았는지 클라이언트 단에 노출시키지 않기 위함</li>
</ol>
<h4 id="구조">구조</h4>
<p><code>action.tsx</code></p>
<pre><code class="language-ts">export const updateTemplate = async (data) =&gt; {
  &#39;use server&#39;;
  const url = &#39;~~&#39;;
  await fetch(url, {
    method: &#39;PATCH&#39;,
    body: data
  }); // route로 요청보내기

  revalidateTag(&#39;template&#39;); // 데이터 refetch
};</code></pre>
<p><code>SubmitButton.tsx</code></p>
<pre><code class="language-tsx">const SubmitButton = () =&gt; {
  // ...
  const submit = await () =&gt; {
    // ...
    const data = {};
    await updateTemplate(data);
  };
};</code></pre>
<p><code>route.ts</code></p>
<pre><code class="language-ts">const PATCH = (req) =&gt; {
  // ...
  await checkUserUpdatePermission();
  await updateTemplateMeta();
  await updateTemplatePosts();
};</code></pre>
<h3 id="2-serverstate와-clientstate-동기화">2. serverState와 clientState 동기화</h3>
<blockquote>
<p>업데이트 후, refetch한 serverState와 useState로 관리하던 clientState간의 동기화가 되지 않는 문제</p>
</blockquote>
<h4 id="동기화가-필요한-이유">동기화가 필요한 이유</h4>
<p>현재 구조에서는 다음 플로우로 업데이트가 2번 일어났을 때, 예상했던 동작과 다르게 수행 됨.</p>
<p>(post를 추가) -&gt; (첫 번째 업데이트) -&gt; (refetch) -&gt; (두 번째 업데이트)</p>
<p>post를 추가했을 때, clientState는 추가된 post의 id를 임시로 만들어서 관리를 한다.
업데이트를 수행했을 때, 임시 id는 제외하고 insert를 하게 되는데 이 post에 대해 새로운 id를 부여함.
그렇기에 refetch를 했을 때 가져오는 template의 post id와 clientState에서의 post id가 달라짐.</p>
<p>업데이트를 1번만 했으면, 크게 문제될 것은 아니지만 다시 업데이트를 했을 때 문제가 발생</p>
<p>아무것도 변경하지 않고, 두 번째 업데이트를 수행한다면
<strong>(예상 로직)</strong>
post에 대해서는 insert 없이, 기존 post에 대해서 update만 수행</p>
<p><strong>(실제 로직)</strong>
이전 업데이트에서 추가되었던 post는 delete &amp; insert 수행.
추가되었던 post에 대한 id를 서로 다르게 관리하고 있기 때문)</p>
<h4 id="동기화를-어떻게-할-것인가">동기화를 어떻게 할 것인가?</h4>
<ol>
<li>refetch가 일어나서, server에서 전달받은 template이 변경되었을 때 clientState를 변경된 데이터에 맞춰준다.</li>
<li>clientState를 관리하는 컴포넌트의 key를 template 업데이트했을 때 변경하여, 리렌더링을 트리깅한다.</li>
<li>업데이트를 할 때, 동기화가 필요한 데이터(ex. insert)는 따로 clientState 동기화를 해준다.</li>
</ol>
<p>나는 더 간단한 방식인 1번 방식으로 진행했음.
2번 방식은 동기화 때문에 하기에는 너무 과하다고 생각함.
3번 방식은 로직이 복잡해질 것</p>
<h4 id="적용">적용</h4>
<pre><code class="language-tsx">// serverComponent
const Page = async() =&gt; {
  // ...
  return &lt;InnerPage template={template} /&gt; 
};

// clientComponent
const InnerPage = (template) =&gt; {
  const [posts, setPosts] = useState(template.posts);
  useEffect(() =&gt; {
    setPosts(template.posts);
  }, [template.posts]);

  // ...
};</code></pre>
<h2 id="til">TIL</h2>
<p>없음.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[#TIL 5 모바일 청첩장 빌더 - supabase storage]]></title>
            <link>https://velog.io/@byunghun-jake/TIL-5-%EB%AA%A8%EB%B0%94%EC%9D%BC-%EC%B2%AD%EC%B2%A9%EC%9E%A5-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@byunghun-jake/TIL-5-%EB%AA%A8%EB%B0%94%EC%9D%BC-%EC%B2%AD%EC%B2%A9%EC%9E%A5-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Tue, 27 Feb 2024 12:52:37 GMT</pubDate>
            <description><![CDATA[<h1 id="todo">TODO</h1>
<h2 id="1-이미지-추가-시-storage에-업로드하기">1. 이미지 추가 시, storage에 업로드하기</h2>
<p>file input을 통해 이미지를 추가했을 때, 이미지를 storage에 업로드한다.</p>
<h3 id="기존-로직">기존 로직</h3>
<ol>
<li>파일을 리사이징하여 용량을 줄인다.</li>
<li>objectUrl을 이용하여, 로컬에서 이미지를 확인한다.</li>
</ol>
<pre><code class="language-ts">const handleChangeFileInput = async (event: ChangeEvent&lt;HtmlInputElement&gt;) =&gt; {
  const file = e.target.files[0];

  const blob = await resizeFile(file);
  const url = URL.createObjectURL(blob);
  // ...
}</code></pre>
<h3 id="변경한-로직">변경한 로직</h3>
<ol>
<li>파일을 리사이징하여 용량을 줄인다.</li>
<li>blob을 파일로 변환하여, storage에 업로드한다.</li>
<li>업로드한 이미지 경로를 images 테이블에 저장한다.</li>
</ol>
<pre><code class="language-ts">const handleChangeFileInput = async (event: ChangeEvent&lt;HtmlInputElement&gt;) =&gt; {
  const file = e.target.files[0];

  const blob = await resizeFile(file);

  const fileName = `Date.now()`;
  await supabase
    .storage
    .from(&#39;images&#39;)
    .upload(fileName, blob)
    .then(({ data, error }) =&gt; {
      if (error) {
        throw new Error(error.message);
      }
    });

  const imageUrl = `${BASE_URL}/${fileName}`;
  const newImage = await supabase
    .from(&#39;images&#39;)
    .insert({ url })
    .select(&#39;*&#39;)
    .single()
    .then(({ data, error }) =&gt; {
      if (error) {
        throw new Error(error.message);
      }

      return data;
    })
  // ...
}</code></pre>
<h2 id="2-청첩장-수정-기능-만들기">2. 청첩장 수정 기능 만들기</h2>
<h3 id="1-story-업데이트">1. Story 업데이트</h3>
<p>스토리는 배열 형태로 이미지 배열도 가지고 있기 때문에, wedding_hall 보다 업데이트 로직이 복잡함.</p>
<ol>
<li><p>스토리 분리
a. 신규 스토리
b. 기존 스토리
c. 삭제할 스토리</p>
</li>
<li><p>신규 스토리 추가</p>
<ul>
<li><p><code>insta_template.stories.insert</code> → 신규 스토리</p>
<pre><code class="language-jsx">  const newStoryId = await supabase
      .schema(&#39;insta_template&#39;)
      .from(&#39;stories&#39;)
      .insert({
          title: story.title,
          template_id: templateId,
      })
      .select(&#39;*&#39;)
      .then(({ data, error }) =&gt; {
          if error throw new Error(error.message);

          return data.id;
      })</code></pre>
</li>
<li><p><code>insta_template.story_image_link.insert</code> → 신규 이미지</p>
<pre><code class="language-jsx">  await supabase
      .schema(&#39;insta_template&#39;)
      .from(&#39;story_image_link&#39;)
      .insert({
          story_id: newStoryId,
          image_id: story.id,
      });
  // then 생략</code></pre>
</li>
<li><p><code>insta_template.images.update</code> → display_order 업데이트</p>
<pre><code class="language-jsx">  await Promise.all(
      story.images.map(async ({ id }, index) =&gt; {
          return supabase
              .schema(&#39;insta_template&#39;)
              .from(&#39;images&#39;)
              .update({ display_order: index })
              .eq(&#39;id&#39;, id);
      })
  )</code></pre>
</li>
</ul>
</li>
<li><p>스토리 업데이트</p>
<ul>
<li><p><code>insta_template.stories.update</code></p>
<p>  → title 업데이트</p>
</li>
<li><p><code>insta_template.story_image_link.insert</code></p>
<p>  → 신규 이미지</p>
</li>
<li><p><code>insta_template.story_image_link.delete</code></p>
<p>  → 삭제된 이미지</p>
</li>
<li><p><code>insta_template.images.update</code></p>
<p>  → display_order 업데이트</p>
</li>
</ul>
</li>
<li><p>스토리 제거</p>
<ul>
<li><p><code>insta_template.stories.delete</code></p>
<p>  → 삭제된 스토리</p>
</li>
</ul>
</li>
</ol>
<pre><code>→ 스토리가 삭제되면 링크 테이블의 데이터도 삭제된다.</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[#TIL 4 모바일 청첩장 빌더 - pl/pgsql]]></title>
            <link>https://velog.io/@byunghun-jake/TIL-4-%EB%AA%A8%EB%B0%94%EC%9D%BC-%EC%B2%AD%EC%B2%A9%EC%9E%A5-%EB%B9%8C%EB%8D%94-plpgsql</link>
            <guid>https://velog.io/@byunghun-jake/TIL-4-%EB%AA%A8%EB%B0%94%EC%9D%BC-%EC%B2%AD%EC%B2%A9%EC%9E%A5-%EB%B9%8C%EB%8D%94-plpgsql</guid>
            <pubDate>Mon, 26 Feb 2024 06:12:13 GMT</pubDate>
            <description><![CDATA[<h1 id="작업-내용">작업 내용</h1>
<ol>
<li>유저가 회원가입했을 때, template를 생성하기</li>
<li>template가 추가되었을 때, metadata를 생성하기</li>
</ol>
<h3 id="supabasedatabase의-function과-trigger">supabase.database의 function과 trigger</h3>
<p>supabase는 postgreSQL DB를 제공하고 있어, 테이블 외에 <a href="https://supabase.com/docs/guides/database/functions">function</a>과 <a href="https://supabase.com/docs/guides/database/postgres/triggers">trigger</a>를 제공한다.
이번 작업은 function/trigger를 이용하여, 테이블에 row가 insert 되었을 때 기본으로 있어야 하는 데이터를 자동으로 추가하도록 했다.</p>
<h3 id="1-유저가-회원가입했을-때-template를-생성하기">1. 유저가 회원가입했을 때, template를 생성하기</h3>
<p>처음에는 회원가입을 할 때, 회원가입 요청을 보낸 후 template를 생성하는 요청도 포함하도록 하려고 했었다.</p>
<pre><code class="language-ts">// /api/auth/signup/route.ts
export const POST = (req: NextRequest) =&gt; {
  // ...
  await supabase.auth.signup(user);
  await supabase.from(&#39;template&#39;).insert(template);
  // ...
};</code></pre>
<p>그런데, signup 함수에 template를 생성하는 로직도 같이 있는 것이 맞을까? 하는 생각이 들면서 다른 방식을 찾아보게 되었다.
supabase의 function/trigger를 알게 되었고, 이를 적용해보기로 했다.
내가 원하는 동작은</p>
<ol>
<li>auth.users 테이블에 insert가 되었을 때</li>
<li>생성된 새로운 row(users)에서 userId를 가지고,</li>
<li>public.templates 테이블에 insert를 하는 것</li>
</ol>
<p>1번은 trigger의 역할이 되고, 2/3번은 function의 역할이 된다.
function을 먼저 정의해보자
function은 postgreSQL의 프로시저 언어인 <code>PL/pgSQL</code>을 사용하여 작성했다.</p>
<pre><code class="language-sql">create or replace function create_template_on_user_insert()
returns trigger as $$
begin
  insert into templates(user_id, code)
  values (new.id, new.raw_user_meta_data-&gt;&gt;&#39;code&#39;);
  return new;
end;
$$ language plpgsql security definer;</code></pre>
<ul>
<li>create_template_on_user_insert 라는 함수를 생성/대체한다.</li>
<li>trigger를 반환한다.</li>
<li>실행할 함수 본문을 $$ 사인으로 감싼다.</li>
<li>plpgsql 언어로 작성했으며, 함수 생성자가 호출할 수 있도록 한다.</li>
</ul>
<p>이 함수를 호출할 trigger를 정의해보자</p>
<pre><code class="language-sql">create trigger create_template_trigger
after insert on auth.users for each row
execute procedure create_template_on_user_insert();</code></pre>
<ul>
<li>create_template_trigger 라는 trigger를 생성한다.</li>
<li>auth.user 테이블의 각각의 row가 insert 이후에 수행되며, </li>
<li>create_template_on_user_insert 라는 procedure를 실행한다.</li>
</ul>
<p>이젠, 회원가입이 이루어졌을 때 template 템플릿을 생성할 수 있다!</p>
<h3 id="2-template가-추가되었을-때-metadata를-생성하기">2. template가 추가되었을 때, metadata를 생성하기</h3>
<p>회원가입 후 template를 생성하는 것과 동일한 방법(function/trigger)으로 metadata를 생성해보자.</p>
<pre><code class="language-sql">// 함수 정의
create or replace function create_metadata_on_template_insert()
returns trigger as $$
  begin
    insert into metadata(template_id)
    values (new.id)
    return new;
  end;
$$ language plpgsql security definer;

// trigger 정의
create trigger create_metadata_trigger
after insert on templates for each row
execute procedure create_metadata_on_template_insert();</code></pre>
<p>이젠, template가 추가되었을 때, metadata가 추가된다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[#TIL 3 모바일 청첩장 빌더 - Comment]]></title>
            <link>https://velog.io/@byunghun-jake/TIL-3-%EB%AA%A8%EB%B0%94%EC%9D%BC-%EC%B2%AD%EC%B2%A9%EC%9E%A5-%EB%B9%8C%EB%8D%94-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@byunghun-jake/TIL-3-%EB%AA%A8%EB%B0%94%EC%9D%BC-%EC%B2%AD%EC%B2%A9%EC%9E%A5-%EB%B9%8C%EB%8D%94-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Fri, 23 Feb 2024 01:46:44 GMT</pubDate>
            <description><![CDATA[<h1 id="작업-내용">작업 내용</h1>
<ol>
<li>Comment 삭제하기
a. submit 후, post 데이터 업데이트하기
b. 삭제 시, 비밀번호 체크하기
c. 비밀번호 확인 가능하도록 하기
d. 비밀번호 입력 후, submit 했을 때 저장하겠냐는 confirm 안뜨게 하기</li>
<li>Comment 생성 로직 수정하기
a. 비밀번호 입력 input 수정
b. 값을 입력하지 않았을 때, submit이 실행되지 않도록 하기</li>
</ol>
<h2 id="1-comment-삭제하기">1. Comment 삭제하기</h2>
<h3 id="submit-후-post-데이터-업데이트하기">Submit 후, post 데이터 업데이트하기</h3>
<p>post 데이터는 서버 컴포넌트에서 fetch를 통해 가져왔기 때문에 revalidateTag를 이용해서 데이터를 업데이트시켜야 했다.
그런데, 클라이언트 함수에서 revalidateTag를 호출했을 때, 에러가 발생함.</p>
<pre><code class="language-tsx">const handleSubmit = async () =&gt; {
  // ...
  await fetch(url, { method: &quot;DELETE&quot; });
  revalidateTag(&quot;comments&quot;);
  // Invariant: static generation store missing in revalidateTag comments
}</code></pre>
<p>클라이언트 컴포넌트에서 revalidate를 하는 방법을 알아봤다.
<a href="https://github.com/vercel/next.js/discussions/58600">&quot;How to revalidate data from the client Component&quot;</a></p>
<p>이 질문의 답변 내용 중 &quot;revalidate하는 함수를 serverAction으로 만들어라&quot;라는 걸 보고 적용해봤다.</p>
<pre><code class="language-tsx">// actions.ts
const deleteComment = async (commentId: string, password: string) =&gt; {
  &quot;use server&quot;;
  // host, protocol, url을 구하기
  await fetch(url, { method: &quot;DELETE&quot; });
  revalidateTag(&quot;comments&quot;);
}

// DeleteCommentDialog.tsx 중
const handleAction = async (formData: FormData) =&gt; {
  // ...
  await deleteComment(commentId, password);
}</code></pre>
<blockquote>
<p><strong>알게된 내용</strong>
서버 액션은 서버 컴포넌트에서만 사용이 가능한 줄 알았지만, 아니었다.
하긴, 서버 컴포넌트도 어떻게 보면 서버 함수일텐데, 클라이언트 컴포넌트의 자식 컴포넌트로 렌더링을 할 수 있으니까.</p>
</blockquote>
<h3 id="삭제-시-비밀번호-체크하기">삭제 시, 비밀번호 체크하기</h3>
<ol>
<li>비밀번호를 어떻게 전달할 것인가?
DELETE 메서드는 body를 포함할 수는 있지만, 실제로 body를 사용하는 것을 권장하진 않는다고 한다. (경우에 따라서 body를 사용할 수 없는 케이스도 있기 때문)
그렇다면, 어떻게 비밀번호를 포함해서 전달할까?</li>
</ol>
<p>-&gt; QueryString에 비밀번호를 포함시켜서 전달하도록 함</p>
<ol start="2">
<li><p>supabase로 삭제 요청을 보냈을 때, 실제로 삭제되었는지 판단하려면?
eq 메서드로 id, password를 전달해서, 삭제요청을 보냈는데 id와 password가 일치하지 않는 케이스를 판단하려면 count를 활용할 수 있다.
delete의 옵션에 count 로직을 어떻게 수행할 지 결정할 수 있다.
count 옵션이 없으면 count가 null로 반환되고, 있으면 실제 삭제된 row의 수가 반환된다.</p>
<pre><code class="language-tsx">// comments/:id/route.ts
const DELETE = async () =&gt; {
// ...
const { count } = await supabase
 .from(&quot;comments&quot;)
 .delete({ count: &quot;exact&quot; })
 .eq(&quot;id&quot;, id)
 .eq(&quot;password&quot;, password);
}</code></pre>
</li>
<li><p>한글 등이 포함되었을 때, 주소가 유효하도록 하려면?
쿼리스트링을 <code>encodeURIComponent</code> 함수를 통해 인코딩시키기</p>
<pre><code class="language-tsx">const encodedPassword = encodeURIComponent(password)
const url = `${protocol}://${host}/api/comments/${commentId}?password=${encodedPassword}`</code></pre>
</li>
<li><p>sumit 시, 비밀번호 저장 여부를 체크하는 브라우저 dialog가 뜨지 않도록 하기
비밀번호 input의 autoComplete 속성의 값을 <code>one-time-code</code>로 하면, confirm 창이 뜨지 않음.</p>
<pre><code class="language-tsx">&lt;input
type=&quot;password&quot;
autoComplete=&quot;one-time-code&quot;
/&gt;</code></pre>
</li>
</ol>
<h2 id="comment-생성-로직-수정하기">Comment 생성 로직 수정하기</h2>
<h3 id="비밀번호-인풋-수정">비밀번호 인풋 수정</h3>
<ol>
<li>autoComplete 속성을 <code>one-time-code</code>로 설정하기</li>
</ol>
<h3 id="form-submit-로직">form submit 로직</h3>
<ol>
<li>input 값을 입력하지 않았을 때, submit할 수 없도록 하기
두 가지의 방식을 고민했었지만, b안으로 결정했음
이유: a안으로 하려면, submit 버튼의 disabled를 막고 input에서의 Enter 키 입력도 막아줘야 했기 때문에 b안에 비해 로직이 복잡해질 것 같았음.</li>
</ol>
<p>-_
a. <del>submit 이벤트를 실행할 수 없도록 막기</del>
b. <strong>submit 이벤트가 수행되었을 때 확인하여, 중단하기</strong>
-_
입력한 값이 유효한지를 확인하는 것이니까, zod를 활용하면 좋겠다는 생각을 했음.
지금은 입력했는지 여부만 판단하면 되는 것이기에 <code>min</code> 메서드를 활용했고, 추후에 다른 조건이 생기면 schema만 수정해주면 될 것 같다.</p>
<pre><code class="language-tsx">const createCommentSchema = z.object({
  name: z.string().min(1),
  content: z.string().min(1),
  password: z.string().min(1),
});

const handleFormAction = async (formData: FormData) =&gt; {
  // ...
  const data = {
    name,
    content,
    password,
  }
  const parsedData = createComentSchema.safeParse(data);

  if (!parsedData.success) {
    alert(&quot;입력값을 확인해주세요&quot;);
    return;
  }
};</code></pre>
]]></description>
        </item>
    </channel>
</rss>