<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>ShipFriend TechBlog</title>
        <link>https://shipfriend.dev</link>
        <description>개인 개발 블로그로, Nextjs로 개발되었습니다. 개발 관련 글을 작성합니다.</description>
        <lastBuildDate>Sat, 28 Mar 2026 05:48:30 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>Feed for Next.js</generator>
        <image>
            <title>ShipFriend TechBlog</title>
            <url>https://shipfriend.dev/favicon.png</url>
            <link>https://shipfriend.dev</link>
        </image>
        <copyright>All rights reserved 2026</copyright>
        <item>
            <title><![CDATA[FastAPI는 왜 사용하는 걸까?]]></title>
            <link>https://shipfriend.dev/posts/fastapi는-왜-사용하는-걸까</link>
            <guid>https://shipfriend.dev/posts/fastapi는-왜-사용하는-걸까</guid>
            <pubDate>Sat, 28 Mar 2026 04:45:57 GMT</pubDate>
            <description><![CDATA[FastAPI의 강점과 약점 알아보면서 공부하기]]></description>
            <content:encoded><![CDATA[FastAPI는 왜 사용하는 것일까?

현재 프로젝트에서는 FastAPI를 백엔드 기술스택으로 채택하고 있다. 전체 프로젝트 기술스택 선정  이후에 프로젝트에 참여했기 때문에 당시 의사결정에는 참여하지 않았지만, 프로젝트가 AI를 다루는 프로젝트이다보니 자연스럽게 파이썬 프레임워크를 사용했다고 들었다.

## FastAPI 프레임워크
FastAPI는 파이썬 언어 위에서 돌아가는 프레임워크이다. Node 진영에는 Express, Nestjs가 있고, Java 진영에는 Spring이 있듯이 파이썬에는 FastAPI, Django 등의 프레임워크가 존재한다. 공식문서도 한국어로 지원하는 웹페이지가 있다.

[https://fastapi.tiangolo.com/ko/](https://fastapi.tiangolo.com/ko/)

## FastAPI의 장점

FastAPI의 장점은 무엇일까. 이름에 당당하게 Fast를 붙였는데 과연 빠를지도 알아보면 좋을 것 같다.

원래 기본적으로 파이썬은 타입을 추론하는 프로그래밍 언어이기 때문에 추론에 들어가는 오버헤드로 인해 느린 언어라는 것은 유명하게 알려져있다. 그런 파이썬에서 FastAPI 같은 프레임워크를 만들었다는 것이 처음에는 이해하기 어려웠다.

### 파이썬의 약점을 해결한 Pydantic
개인적으로는 FastAPI에서 가장 핵심적인 역할을 하는 부분이라고 생각이 된다. 

Pydantic은 파이썬의 타입 힌트를 기반으로 데이터 유효성 검사 + 파싱 + 직렬화 등을 자동으로 처리해주는 라이브러리이다. 

```python
from pydantic import BaseModel

class User(BaseModel):
    username: str
    email: str
    age: int = 25  # 기본값 지정 가능

# 유효한 데이터
user = User(username="john", email="john@example.com")
print(user.age)  # 25 (기본값)

# 잘못된 데이터 → ValidationError 자동 발생
User(username="jane", email=123)  # email은 str이어야 함
```
FastAPI는 이런 Pydantic을 핵심 엔진으로 사용하고 있다. 최근에는 v2로 업데이트 되면서 내부적으로 Rust를 사용하여 성능이 더 좋아졌다고 한다. 

> 일반적인 모델 검증	~17x 빨라졌다고 한다.

현대 프로그래밍에서는 타입 일관성을 지키는게 가장 중요하다고 느꼈다. 타입이 일관적이지 못하면 중간에 생기는 사이드이펙트 디버깅에 들어가는 시간과 공수가 너무 많이 들어간다고 생각했다. 그런 점에서 FastAPI를 사용할 때에는 Pydantic 모델을 미리 정의하고 하는 것을 컨벤션으로 지정해두는 것이 좋겠다.

### 다른 프레임워크에 비해 가지는 장점
사실 이 섹션이 가장 중요할 것 같다. 기술스택을 선택할 때 많은 대체제가 존재하는데 그 중에서 파이썬의 FastAPI를 선택해야하는 이유에 대해서 알아보자.

#### Python 언어는 고정일 경우라면?
파이썬 언어를 사용해야만 하는 프로젝트라면 FastAPI를 선택해야할 이유는 명확하다.

##### 1️⃣ 압도적인 성능
- FastAPI는 ASGI + Uvicorn 조합으로 동작해서 nodejs 같은 이벤트루프 기반으로 비동기 방식으로 요청을 처리한다. 다른 프레임워크들(Flask, Django)의 블로킹 WSGI 방식과는 다르다.

<callout emoji="💡">

**ASGI란 무엇인가?**

Asynchronous Server Gateway Interface 라는 뜻으로, 비동기 서버를 위한 표준 인터페이스 명세를 의미한다. 다른 인터페이스로는 동기적인 Web Server Gateway Interface, WSGI가 있다. 

</callout>

##### 2️⃣ 자동 문서화 및 검증, 타입 힌트 기반 개발 생산성
  - Pydantic 타입 엔진의 강력함이 하나의 무기이다. Pydantic 모델을 한 번만 정의하면 요청 검증, 응답 직렬화, Swagger API Docs 같은 문서를 자동으로 생성해준다.
  - 별도의 라이브러리가 해주는 일들을 알아서 해주는 부분이 많아 개발 생산성에서 유리하다. 

##### 3️⃣ async / await 지원
  - 다른 프레임워크 Flask 같은 경우는 별도 설정 후에 비동기 `async/await`을 사용할 수 있지만, FastAPI는 비동기를 네이티브하게 지원한다.

##### 4️⃣ 높은 AI 프로젝트 통합도
- 우리 프로젝트에서는 이 경우에 해당했다. AI기반 프로젝트는 파이썬의 높은 AI 패키지 활용도를 활용하는 것이 좋기 때문이다.
- 머신러닝 모델 대부분이 Python으로 개발되기 때문에, 별도 변환 없이 모델을 바로 API 엔드포인트에 연결할 수 있는 점이 강력한 점이다.
- 나의 경우에도 AI 패키지를 모노레포로 관리 중인데 이 패키지의 함수들 별도의 어댑터나 브릿지 없이 바로 사용할 수 있기 때문에 편리한 것 같다.


#### 다른 생태계를 사용해도 된다면?
다른 생태계 그러니까 Python을 고정적으로 사용해야하는 환경이 아니라면 Nest.js나 Spring Boot 같은 다른 프레임워크 옵션들을 선택해볼 수 있다.

그렇다면 이들과의 비교는 어떨까?

| 항목        | Spring Boot    | NestJS       | FastAPI        |
| --------- | -------------- | ------------ | -------------- |
| 처리량(RPS)  | ~11,000–13,000 | ~8,000–9,000 | ~15,000–20,000 |
| 평균 응답시간   | ~98ms          | ~118ms       | ~60–80ms       |
| 초기 메모리    | ~512MB (JVM)   | ~245MB       | ~80–120MB      |
| 빌드/기동 시간  | ~42초 / ~8.7초   | ~15초 / ~2.3초 | ~5초 / ~0.5초    |
| I/O 집약 성능 | 멀티스레드 강점       | 비동기 강점       | 비동기 강점         |

실제 프레임워크 비교 표이다. 이 부분은 항상 절대적인 것은 아니다. 하지만 참고만 해본다면, 강점과 약점이 명확하다. 

평균 응답시간에서는 다른 프레임워크 보다 우위에 있다는 점에 놀랐고, 초기 메모리도 압도적으로 가볍다는 점도 신기했다. FastAPI라고 자신 할만 한 성능이라고 생각된다.

#### 약점

하지만 약점도 명확한데, 바로 **생태계와 그 성숙도의 차이**이다. java와 node 진영은 라이브러리 배포와 사용 생태계가 잘 갖춰져있고 성숙되었다. 하지만 파이썬은 약간은 열받는 버전 별 큰 차이점들과 비동긱를 적극적으로 사용하는 프레임워크지만 생태계 전반에 비동기를 지원하는 라이브러리의 부족과 성숙도 낮음 문제가 명확한 약점이다. 
<callout emoji="💡">Python이 버전 충돌이 많은 근본 이유는 **"격리가 기본값이 아님"** 때문이다. Node.js는 설계부터 프로젝트 단위 격리가 기본이지만, Python은 글로벌 설치가 기본이고 격리는 개발자가 직접 챙겨야 한다. </callout>
이렇게 적어보니 명확하게 나오는 것 같다.

## 결론
### 선택해야하는 경우
- AI / ML 프로젝트이다.
- Python을 써야하는데 빠른 성능을 원한다.

### 선택하지 않아도 되는 경우
AI / ML 프로젝트가 아니고, 팀의 주력 언어가 TypeScript나 Java라면 NestJS나 Spring Boot가 생태계 성숙도 면에서 유리하다. 

이게 생태계 성숙도를 무시할 수 없는 이유가 있다. FastAPI는 분명 빠르지만, 비동기를 지원하지 않는 라이브러리를 함께 사용하는 순간 비동기의 이점이 사라진다. 예를 들어 동기 ORM이나 동기 DB 드라이버를 그대로 쓰면, 이벤트 루프가 블로킹되어 오히려 성능이 떨어질 수 있다.

<callout emoji="🚀">

최근에 AI 코딩 도구를 활용하면서 개발을 하다보면서 코드레벨에 대한 지식은 점점 활용도를 잃어가고 있다고 느낀다. 그 대신 중요해진 것은 **프레임워크 레벨의 설계 철학과 선택에 대한 이유** 등이 중요해진 것 같다. 설계 철학을 알게되면 전체 애플리케이션의 아키텍쳐를 설계하는데 이유를 뒷받침 할 수 있기 때문이라고 생각했다.

> **이번에 새롭게 추가한 콜아웃 UI이다. 🤓 ㅎㅎ**

</callout>
]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[개인 블로그에 Opengraph 카드 UI 만들기]]></title>
            <link>https://shipfriend.dev/posts/개인-블로그에-opengraph-카드-ui-만들기</link>
            <guid>https://shipfriend.dev/posts/개인-블로그에-opengraph-카드-ui-만들기</guid>
            <pubDate>Sun, 15 Mar 2026 06:50:40 GMT</pubDate>
            <description><![CDATA[서버 사이드 GET, rehypeRewrite]]></description>
            <content:encoded><![CDATA[# 개인 블로그에 오픈그래프 기능 도입하기

![썸네일](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1773557035451-Gemini_Generated_Image_x0kuexx0kuexx0ku_clean-O9uwKiMPKq1HIcdbY8DsfB4hL3NVFX.webp)

티스토리 블로그나 디스코드, 카카오톡 등 플랫폼 서비스들은 대부분 외부 링크를 입력하면 하단에  해당 링크의 오픈그래프 정보를 알려주는 카드 UI를 렌더링해준다. 이런 UI가 블로그에 있어서는 무조건 있어야 한다고 생각했기 때문에 직접 만들어보기로 했다.

이전에도 [블로그에 유튜브 임베딩하기](https://shipfriend.dev/posts/블로그에-유튜브-임베딩하기) 글에서 블로그에 유튜브 링크를 걸 경우에 자동으로 하단에 유튜브 영상 임베딩 iframe을 삽입하는 기능을 구현한 적이 있었다.

원래는 저 기능을 구현할 때 오픈그래프 카드도 같이 구현하려고 했었는데 그 때에는 현재 쓰고 있는 에디터가 비동기적인 데이터 리렌더링을 지원하지 않는 줄 알고 있었기 때문에 구현을 포기했었다.

최근에 다시 찾아보니 비동기로 데이터 fetch를 해와서 업데이트를 해도 UI가 리렌더링이 된다는 것을 알게 되고 나서 이 기능을 다시 구현해보려고 했다.

## 오픈그래프란?
오픈그래프의 사전적 개념에 대해서 알아보자. 

> 오픈 그래프(Open Graph)는 웹 페이지가 소셜 미디어에 공유될 때 어떻게 보이는지를 제어하는 메타데이터 프로토콜입니다. Facebook이 2010년에 만들었고, 지금은 카카오톡, 트위터, Slack, Discord 등 거의 모든 플랫폼에서 사용합니다.

해당 사이트의 이름, 설명, 파비콘 (혹은 다른 대표이미지) 등을 미리 보여주는 기능이라 UX적으로 매우 좋다고 생각한다.

![Discord의 Opengraph 렌더링](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1773553576679-image-voWZsrLN3XSY9b5xKTtaXQMi75GGZv.webp)

오픈그래프 태그에는 여러가지가 있는데, 아래 태그들이 있다. 특정 플랫폼 별로 지정할 수도 있다.

```html
<head>
  <!-- 필수 태그 -->
  <meta property="og:title"       content="페이지 제목" />
  <meta property="og:description" content="페이지 설명 (2~3문장)" />
  <meta property="og:image"       content="https://example.com/thumbnail.jpg" />
  <meta property="og:url"         content="https://example.com/page" />

  <!-- 권장 태그 -->
  <meta property="og:type"        content="website" />  <!-- article, video.movie 등 -->
  <meta property="og:site_name"   content="사이트 이름" />
  <meta property="og:locale"      content="ko_KR" />

  <!-- 트위터 카드 (별도 네임스페이스) -->
  <meta name="twitter:card"       content="summary_large_image" />
  <meta name="twitter:title"      content="페이지 제목" />
  <meta name="twitter:image"      content="https://example.com/thumbnail.jpg" />
</head>
```


### 오픈그래프의 UX 관점
오픈그래프(Opengraph)가 UX적 관점으로 훌륭하다고 생각하는 이유는 간단하다. 개인적으로 원래 옛날부터 잘 모르는 링크는 함부로 클릭하는 것이 아니라고 들어와서 그런 탓일까. 링크만 있으면 잘 클릭하지 않는 편이다. 

그렇기 때문에 UX 관점에서 오픈그래프 카드 or 배너가 가지는 의미가 중요하다고 생각한다. 원래라면 링크만 있을 때는 볼 수 없는 해당 사이트의 타이틀, 설명, 썸네일 등을 한눈에 볼 수 있기 때문에 사용자로 하여금 무슨 사이트일지 알 수 있게 해준다.

![와!](https://media1.tenor.com/m/_TySLMd48JIAAAAd/lizard-lizard-lizard-lizard.gif)

## 원리

직접 구현하기 전에는 사실 크롤러가 필요한 줄 몰랐다. `og:` 접두사로 시작하는 태그가 있기 때문에 외부 혹은 web api로 지원을 해주지 않을까라는 막연한 생각을 가지고 있었다.

알아보니 생각보다는 단순한 방법으로 가져오고 있었다. OG 데이터를 가져오는 것은 아래와 같다.
- 사용자가 링크를 공유
- 플랫폼의 크롤러가 해당 URL을 직접 방문
- head 태그 내의 og: 태그를 파싱
- UI 렌더링

의 단순한 방법이었다. 


### 구현 코드
구현에 필요한 코드는 서버사이드의 외부 사이트 조회 API와 렌더링에 필요한 코드 2가지다.

<details>
<summary>클릭해서 API 코드보기</summary>

```ts
// GET /api/opengraph

// HTML 파일로 부터 메타데이터 파싱
const getMeta = (html: string, prop: string): string | null => {
  const patterns = [
    new RegExp(
      `<meta[^>]+(?:property|name)=["']${prop}["'][^>]+content=["']([^"']*)["']`,
      'i'
    ),
    new RegExp(
      `<meta[^>]+content=["']([^"']*)["'][^>]+(?:property|name)=["']${prop}["']`,
      'i'
    ),
  ];
  for (const pattern of patterns) {
    const match = html.match(pattern);
    if (match?.[1]?.trim()) return match[1].trim();
  }
  return null;
};

// Open Graph API 엔드포인트
export const GET = async (request: Request) => {
  const { searchParams } = new URL(request.url);
  const url = searchParams.get('url');

  if (!url) return Response.json({ error: 'url required' }, { status: 400 });

  try {
    new URL(url);
  } catch {
    return Response.json({ error: 'invalid url' }, { status: 400 });
  }

  try {
    const res = await fetch(url, {
      headers: {
        'User-Agent':
          'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
        Accept: 'text/html,application/xhtml+xml',
      },
      signal: AbortSignal.timeout(5000),
    });

    if (!res.ok)
      return Response.json({ error: 'fetch failed' }, { status: 502 });

    const html = await res.text();
    const urlObj = new URL(url);

    const title =
      getMeta(html, 'og:title') ||
      getMeta(html, 'twitter:title') ||
      html.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim() ||
      null;

    const description =
      getMeta(html, 'og:description') ||
      getMeta(html, 'twitter:description') ||
      getMeta(html, 'description') ||
      null;

    const image =
      getMeta(html, 'og:image') ||
      getMeta(html, 'twitter:image:src') ||
      getMeta(html, 'twitter:image') ||
      null;

    const siteName = getMeta(html, 'og:site_name') || null;

    const favicon = `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=64`;

    return Response.json(
      {
        url,
        title,
        description,
        image,
        siteName,
        favicon,
        hostname: urlObj.hostname,
      },
      {
        headers: {
          'Cache-Control': 'public, max-age=86400, stale-while-revalidate=3600',
        },
      }
    );
  } catch {
    return Response.json({ error: 'fetch failed' }, { status: 502 });
  }
};

```

</details>

<br/>

위  캐시 컨트롤에서 캐시 타임을 길게 잡았는데 이는 외부 사이트이기 때문에 해당 사이트의 대략적인 정보만 알려주는 목적이라 새로운 데이터 업데이트 중요도가 낮기 때문이다.

---

그리고 새롭게 알게된 사실이 있다. 원래는 rehypeRewrite 기능을 사용해서 마크다운 -> HTML 변환 과정이 있다. 변환 과정 사이에 rehypeRewrite 플러그인을 추가하여 노드를 순회하면서 원하는 특정 태그에 조작을 하는 방식으로 글 내부 조작을 했었다.

그런데 이렇게 하는 경우 rehypeRewrite 플러그인이 점점 비대해지는 단점이 있었다. 왜냐하면 JSX 방식이 아니라 dom 엘리먼트를 생성하고 하위에 children을 추가하는 식으로 UI를 만들어야했기 때문이다..

이 방법이 너무 번거롭고 특히 OgCard 같은 어느정도 복잡도를 가진 컴포넌트를 만드려고 하니 코드가 너무 길어지는 문제가 생겼다.

컴포넌트 방식으로 할 수 없나 방법을 찾아보니 MDEditor.Markdown 컴포넌트의 props로 components 로 미리 만들어둔 컴포넌트와 그 컴포넌트를 지칭하는 태그 이름 (여기서는 ogcard)을 매핑하여 전달해주면 rehype 과정에서 간단하게 컴포넌트로 불러올 수 있었다.

```tsx
<MDEditor.Markdown
     ...
    components={{
      ogcard: ({ href }: { href?: string }) =>
        href ? <OgLinkCard href={href} /> : null,
    } as any} <-- ogcard를 tagName으로 OgLinkCard 컴포넌트 치환
    rehypeRewrite={(node, index?, parent?) => {
      asideStyleRewrite(node);
      renderOpenGraph(node, index, parent as Element | undefined);
      renderYoutubeEmbed(
        node,
        index || 0,
        parent as Element | undefined
      );
      addImageClickHandler(node);
      addDescriptionUnderImage(
        node,
        index,
        parent as Element | undefined
      );
    }}
  />
```

그런 다음 rehype 함수를 만들어서 p > a 태그가 있다면 ogcard 태그 요소를 추가하는 동작을 작성한다. 여기서 ogcard 태그는 실제 렌더링 때 OgLinkCard로 치환되어 렌더링된다.

```ts
/**
 * 링크를 감지하고 <ogcard> marker 노드로 변환
 * 실제 렌더링은 PostBody의 components prop에서 OgLinkCard 컴포넌트가 담당
 */
export const renderOpenGraph = (
  node: any,
  index?: number,
  parent?: Element
) => {
  if (node.type === 'element' && node.tagName === 'p' && node.children) {
    const aTag = node.children.find(
      (node: any) => node.type === 'element' && node.tagName === 'a'
    );
    if (!aTag) return;

    const href = aTag.properties?.href;
    if (!href || !(href.startsWith('/') || href.startsWith('http'))) return;

    const ogNode = {
      type: 'element',
      tagName: 'ogcard',
      properties: { href },
      children: [],
    };

    if (
      index !== undefined &&
      parent?.children &&
      Array.isArray(parent.children)
    ) {
      // 링크 텍스트가 URL 자체인 경우(bare URL, [url](url)) → <p> 대체
      // 커스텀 텍스트인 경우([text](url)) → <p> 유지 후 카드 삽입
      const linkText =
        aTag.children?.find((c: any) => c.type === 'text')?.value ?? '';
      const isUrlOnlyLink = linkText === href;
      if (isUrlOnlyLink) {
        parent.children.splice(index, 1, ogNode);
      } else {
        parent.children.splice(index + 1, 0, ogNode);
      }
    }
  }
};
```

## 결과
결과적으로 원래라면 StoryHelper 다운로드라는 대체 텍스트 링크만 보이던 부분의 아래에 오픈그래프 카드 UI가 렌더링 되는 것을 볼 수 있다.

![결과](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1773556624717-image-BfOaaFmhf2NZS7HNU9g3KHcSwYiXwT.webp)

티스토리 같은 블로그 플랫폼에서는 당연하게 지원되는 기능이지만, 자유도 100%인 개인 블로그에서는 이런 기능을 직접 추가해야 한다는 점이 어떻게 보면 단점이지만 재미있는 포인트가 되는 것 같다.

]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[JavaScript의 Map 자료구조에 대해서 자세하게 알아보자]]></title>
            <link>https://shipfriend.dev/posts/javascript의-map-자료구조에-대해서-자세하게-알아보자</link>
            <guid>https://shipfriend.dev/posts/javascript의-map-자료구조에-대해서-자세하게-알아보자</guid>
            <pubDate>Sat, 14 Feb 2026 13:14:02 GMT</pubDate>
            <description><![CDATA[Map 내장 메서드 가지고 삽질했던 경험]]></description>
            <content:encoded><![CDATA[# JavaScript의 new Map() 구조에 대해서 알아보자

![썸네일](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1771074065409-Gemini_Generated_Image_owm5arowm5arowm5_clean-3eCBY9JhSxKTy7ji6e7Bx3eaIdyjqJ.webp)

자바스크립트에는 객체라는 자료형이 존재한다. Object는 키-값을 쌍으로 데이터를 저장하는 자료형으로, 원시 타입과는 다르게 여러 값을 하나의 단위로 묶어서 관리할 수 있는 참조 타입 자료형이다.

기본적인 사용법은 아래와 같다.
```js
const obj = {
    location: "Seoul"
};

obj.name = "jeongwoo"

console.log(obj);

/**
> console.log(obj);
{ 
    name: "jeongwoo",
    location: "Seoul"
} 
*/
```

자바스크립트 문법에서 가장 기초가 되는 객체 구조이다. 하지만 이 글에서는 이 객체에 대해서 다루고자 하는 것이 아닌 new Map() 구조와의 차이점을 중점적으로 비교해보고자 한다.


## 기본적인 차이점
Object, 객체 자료형은 데이터 저장도 하지만 원래 Object 자료형은 데이터 저장보다는 객체 지향 프로그래밍을 위해 설계되었다. 그렇지만 실제로는 키-값 저장소로 많이 사용되고 있다.

Map 구조는 똑같이 키-값 구조이지만, 이 자료구조는 정말 **데이터 저장**만을 목적으로 하는 점이 다르다.

## Map() 에 대해서
> The Map object holds key-value pairs and remembers the original insertion order of the keys. Any value (both objects and primitive values) may be used as either a key or a value.

기본적으로 new Map()으로 생성하는 map 자료구조는 객체와 유사하다. 

 ![MDN](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1771064392643-image-MTPPAqOFXBWTlHYeiUS6aLK9FF6Nrk.webp)

둘 다 값을 지정하는 키를 설정하고, 그에 대한 값을 저장한다. 

## Map과 Object의 차이점
차이점은 몇 가지가 있는데 그 중에서 내가 중요하다고 생각하는 성능과 반복에 대해서 더 자세하게 다뤄보겠다.

### 일반적인 차이점, 키의 타입과  순서 보장, 크기 확인, 프로토타입 오염 방지
Object와 Map 구조의 차이점 중 첫째는 **키의 타입이**다. Object의 경우에는 키 값에 string, symobl만 가능하다. 하지만 Map은 다른 타입들도 가능하다는 점이 다르다.

일반적으로 Object에 1이라는 키 값을 사용하면 "1" 같이 string 타입으로 자동 변환되는데, Map의 경우에는 number 타입의 1이 키 값이 될 수 있다.

```js
const map = new Map();
map.set(1, 'number key'); 
```

또 **순서 보장**, 이건 공부하면서 새롭게 알게 된 사실이다. map 에 추가하는 데이터의 순서를 보장한다. 정확히 말하면 추가된 키 값의 순서를 보장한다고 한다. 
```js
map.set('b', 2);
map.set('a', 1);
console.log(map.keys()); // ['b', 'a']
```

**크기 계산**, Object에 들어있는 데이터의 키-값의 수를 알려면 `Object.keys(obj).length` 처럼 2번 연산을 해야한다. 하지만 Map의 경우에는 기본 내장 프로퍼티인 `map.size`로 쉽게 값을 받을 수 있다.


### 반복, Iteration
이 글을 쓰려고 했던 핵심 이유이다. Object와 Map 구조는 반복하는 방법에서 차이가 있다.
```js
// Object: 메서드를 통해 간접적으로
const obj = { a: 1, b: 2 };
for (const key in obj) { }           // for...in
Object.keys(obj).forEach(key => {}); // 배열로 변환 후 순회

// Map: iterable이라 직접 가능
const map = new Map([['a', 1], ['b', 2]]);
for (const [key, value] of map) { }  // 바로 순회 가능
map.forEach((value, key) => {});     // forEach도 지원
```


### 성능 최적화
Object를 사용하는 것에 비해 Map 구조를 사용하면 얻을 수 있는 성능적인 이점이 있다. 

일반적으로 값을 추가하는 것은 Map이 더 빠르다고 생각하면 된다. 아래에서 10만개의 데이터를 추가하는 테스트를 해봤을 때 생각보다 유의미한 차이가 있었다.

> **데이터 삽입 테스트**

```js
const iterations = 100000;

// Object 삽입 테스트
const obj = {};
console.time("Object insert");
for (let i = 0; i < iterations; i++) {
  obj[`key${i}`] = i;
}
console.timeEnd("Object insert");

// Map 삽입 테스트
const map = new Map();
console.time("Map insert");
for (let i = 0; i < iterations; i++) {
  map.set(`key${i}`, i);
}
console.timeEnd("Map insert");

console.log(`Object size: ${Object.keys(obj).length}`);
console.log(`Map size: ${map.size}`);
```

```
// 데이터가 100개일 경우
Object insert: 0.305ms
Map insert: 0.021ms
Object size: 100
Map size: 100
```

```
// 데이터가 10만 개일 경우
Object insert: 64.969ms
Map insert: 15.67ms
Object size: 100000
Map size: 100000
```

> **이번에는 삭제 테스트를 해보자.**

아래는 Object에서 delete 키워드를 사용하는 것과 Map의 delete 내장 메서드를 사용하는 것의 속도 비교이다. 브라우저 콘솔을 켜서 코드를 복붙해서 테스트해 볼 수 있다.
```js
const iterations = 100000;

// Object 삭제 테스트
const obj = {};
for (let i = 0; i < iterations; i++) {
  obj[`key${i}`] = i;
}

console.time('Object delete');
for (let i = 0; i < iterations; i++) {
  delete obj[`key${i}`];
}
console.timeEnd('Object delete');

// Map 삭제 테스트
const map = new Map();
for (let i = 0; i < iterations; i++) {
  map.set(`key${i}`, i);
}

console.time('Map delete');
for (let i = 0; i < iterations; i++) {
  map.delete(`key${i}`);
}
console.timeEnd('Map delete');

console.log(`Object size: ${Object.keys(obj).length}`);
console.log(`Map size: ${map.size}`);
```

```
Object delete: 30.201ms
Map delete: 24.388ms
Object size: 0
Map size: 0
```

추가도 Map이 더 빨랐지만, 삭제는 차이가 큰 걸 알 수 있었다. 

#### 왜 더 빠를까?
삭제나 추가에서 Map이 더 빠른 이유는 Object 구조만의 독특한 설계 때문이다. 이는 JavaScript의 V8엔진과 연관되어 있다. 
V8엔진은 내부적으로 히든 클래스를 만들어서 저장한다. 
```js
const obj = { a: 1, b: 2, c: 3 };

// V8 엔진이 내부적으로 이런 "shape"을 만듦
// HiddenClass1: { a: offset 0, b: offset 1, c: offset 2 }
```
히든 클래스를 사용해서 객체의 전체 데이터가 아닌 **DB의 인덱스 느낌**으로 전체적인 모양을 저장한다. 이런 식으로 데이터 추가가 되거나 삭제가 되면 다시 히든클래스를 생성하는 오버헤드가 존재하기 때문에 삽입, 삭제 연산에서 성능차이가 발생하는 것이다.

Map 구조는 단순히 해쉬맵 자료구조의 역할을 위해서 존재한다. 따라서 별도의 히든클래스를 만들어서 저장하지 않고 삭제시 단순히 연결을 끊어버리는 동작을 하기 때문에 더 빠르다.


 
## Map의 내장 메서드
최근에 기업 코딩테스트를 보는데, MDN 레퍼런스가 제공되지 않는 환경에서 시험을 치뤘다. 해시맵 자료구조를 사용해야 하는데 Object 대신 Map 자료구조를 선택했다. 

get, set 정도는 익숙해서 기억을 했지만, 전체 Map을 배열로 변환하는 방법이 기억이 나지 않아 메서드 찾기로 삽질을 오래했던 기억이 난다.

그래서 글을 작성하는 겸 메서드들을 숙지해보려고 한다. 물론 다 외울 필요는 없겠지만 핵심적인 메서드는 알아두는게 좋을 것 같아서.

| 메서드 | 설명 | 반환값 | 예시 |
|--------|------|--------|------|
| `set(key, value)` | 키-값 쌍 추가/수정 | Map 객체 (체이닝 가능) | `map.set('name', '정우')` |
| `get(key)` | 키로 값 조회 | 값 또는 undefined | `map.get('name')` → `'정우'` |
| `has(key)` | 키 존재 여부 확인 | boolean | `map.has('name')` → `true` |
| `delete(key)` | 키-값 쌍 삭제 | boolean (삭제 성공 여부) | `map.delete('name')` → `true` |
| `clear()` | 모든 항목 삭제 | undefined | `map.clear()` |
| `keys()` | 모든 키를 가진 이터레이터 | Iterator | `[...map.keys()]` → `['a', 'b']` |
| `values()` | 모든 값을 가진 이터레이터 | Iterator | `[...map.values()]` → `[1, 2]` |
| `entries()` | 모든 [키, 값] 쌍 이터레이터 | Iterator | `[...map.entries()]` → `[['a',1], ['b',2]]` |
| `forEach(callback)` | 각 항목마다 콜백 실행 | undefined | `map.forEach((v, k) => {})` |

이렇게 보면 어렵지 않은데 시험에서는 Object.entries(map) , Object.keys(map) 별걸 다 시도해봤었다.. ㅋㅋ 

이렇게 시도한 이유는 일반적인 Object를 배열로 바꿀 때는 이런 식으로 사용했었기 때문에 당연히 지원이 될 줄 알았다.

```js
const map = new Map();
const obj = {
  A: 3,
  B: 2,
  C: 1,
};
map.set("A", 3);
map.set("B", 2);
map.set("C", 1);

console.log(map);
console.log(Object.entries(map));
console.log(Object.entries(obj));

/**
Map(3) { 'A' => 3, 'B' => 2, 'C' => 1 }
-> Object.entries(map)은 빈배열 반환 []
[ [ 'A', 3 ], [ 'B', 2 ], [ 'C', 1 ] ]
*/
```
결국에는 메서드를 여러개 시도해보다가 되는 걸 찾아서 시험을 잘 마쳤지만.. 시험을 보고 다음 날 인턴 동료에게 들었던 충격적인 사실은 스프레드 연산자를 활용한 `...map`으로 배열(`[ 'A', 3 ] [ 'B', 2 ] [ 'C', 1 ]`)로 변환된다는 사실이었다. 💀💀

> `console.log(Object.entries(Object.fromEntries(map)));` 이런 방법도 있다. JS는 레퍼런스를 줘야한다고 생각..


## 그렇다면 무조건 Map이 더 좋은가?
성능을 보면 무조건 Map을 써야할 것처럼 보인다. 하지만 실제로 서비스를 만들거나 프로젝트를 하는 경우에서 Map을 잘 쓰지 않는다는 것을 알 수 있다. 

Object는 **JSON 직렬화**를 쉽게 할 수 있고, **구현이 간단**하다는 점, **구조 분해 할당**이라는 강력한 문법 지원 등으로 인해 실질적으로는 Map 보다 더 많이 사용된다.
```js
// Object - 편리함
const { name, age, email } = user;

// Map - 불가능
const name = userMap.get('name');
const age = userMap.get('age');
const email = userMap.get('email');
```

> **그럼 언제 써요?**

Map 구조는 빈번한 데이터 추가와 삭제가 일어날 때 사용한다. 혹은 키 값으로 string이 아닌 다른 타입을 사용할 때 사용한다.

Object 구조는 API 응답 데이터, React의 Props나 State, 구조 분해 할당이 필요할 때 사용할 수 있다.

## 결론
생각보다 Map의 장점이 명확히지만, Object의 장점이 더 큰 것 같다. 많은 데이터가 들어가서 성능을 고려해서 Map을 쓰기에는 Object가 너무 편리하다는 점이 크다.

그럼에도 Map이 좋은 점은 조금 더 명시적이라는 점인 것 같다. get과 has를 사용해서 휴먼에러를 방지한다는 점이 장점이라고 생각한다.]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[FastAPI 동기/비동기 블로킹 이슈 해결하기]]></title>
            <link>https://shipfriend.dev/posts/fastapi-동기-비동기-블로킹-이슈-해결하기</link>
            <guid>https://shipfriend.dev/posts/fastapi-동기-비동기-블로킹-이슈-해결하기</guid>
            <pubDate>Sat, 07 Feb 2026 07:16:28 GMT</pubDate>
            <description><![CDATA[ArgoCD Readiness HealthCheck Timeout 문제 해결]]></description>
            <content:encoded><![CDATA[# ArgoCD Readiness HealthCheck Timeout 문제 해결: FastAPI 동기/비동기 블로킹 이슈

![썸네일](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1770828461970-argocd_clean-rTIwUR8nmblEeGQOEWozxcPo9iEtYK.webp)

## ArgoCD Readiness HealthCheck Timeout이란?

ArgoCD는 Kubernetes 클러스터에서 GitOps 방식으로 애플리케이션을 배포하고 관리하는 도구다. 배포된 파드(Pod)가 정상적으로 트래픽을 받을 준비가 되었는지 확인하기 위해 **Readiness Probe**를 사용한다.

Readiness Probe는 지정된 엔드포인트(일반적으로 `/health` 또는 `/`)에 주기적으로 HTTP 요청을 보내며, 다음과 같은 기본 설정을 가진다:

- **periodSeconds**: 10초 (체크 주기)
- **timeoutSeconds**: 1초 (응답 대기 시간)
- **failureThreshold**: 3회 (연속 실패 허용 횟수)

만약 헬스체크 API가 1초 내에 응답하지 못하면 실패로 간주되고, 3회 연속 실패 시 해당 파드는 `NotReady` 상태로 전환되어 트래픽을 받지 못한다. 심한 경우 서버가 재시작되거나 종료될 수 있다.

## 배경

최근 진행 중인 사내 프로젝트를 개발하면서, 로컬 환경에서는 완벽하게 동작하던 FastAPI 백엔드 서버가 프로덕션 환경(ArgoCD + Kubernetes)에 배포하는 순간 문제가 발생했다.

**증상**
- ArgoCD에서 `Readiness probe failed: Get http://...:8000/: context deadline exceeded` 에러 반복 발생
- 서버로 가는 트래픽이 무시됨
- 가끔 파드가 재시작되거나 완전히 종료됨
- 특정 작업 중에는 다른 GET API 요청조차 응답하지 않음

![배경](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1770448516841-Gemini_Generated_Image_pevqjypevqjypevq_clean-3oYDuZ0RfWTQ1P01WyfF5pRYmVQrJN.webp)

처음에는 "로컬에서는 잘 되는데 왜 배포에서만 안 되지?"라는 의문을 가졌고, 핵심적인 차이점은 **로컬 환경에서는 Readiness Probe를 지속적으로 수행하지 않는다**는 점이라는 걸 알게 되었다.

## 핵심 문제 원인

이 문제를 해결하기 위해 많은 시도와 가설을 세웠었는데, 처음에는 
1. 초기 데이터 로드시에 오래 걸려서 - 로드 시 문제가 생기는 건 아니었음
2. 리소스가 부족해서 - 리소스가 부족하면 OOMKilled라고 출력되어서 아니었음
3. 태스크 큐 길이가 부족해서 - 실제로 많은 양의 태스크 요청을 했지만 아니었음

이런 시도를 거쳐서 찾아낸 핵심 문제는 아래와 같다.

### 1. 헬스체크도 결국 API 요청

Readiness Probe는 별도의 특수한 메커니즘이 아니라, 일반적인 HTTP GET 요청을 `/health` 엔드포인트로 보내는 것에 불과하다. 즉, **다른 API 요청과 동일한 큐에서 처리**된다.

```python
# packages/backend/app/api/v1/endpoints/health.py
@router.get("/health")
async def health_check():
    return {"status": "ok"}
```

### 2. 블로킹 작업이 서버 전체를 멈추게 한다

우리 서비스는 다음과 같은 복잡한 작업 플로우를 가지고 있었다:

1. 이미지 업로드 및 복잡한 파이프라인 처리 (최대 10분 소요 가능)
2. 처리 결과를 S3에 업로드
3. S3 URL을 DB에 저장

문제는 **비동기 함수(`async def`) 내에서 동기적 DB 작업**을 수행하고 있었다는 점이다.

```python
# ❌ 문제가 있는 코드
async def process_and_save(image_data):
    # 파이프라인 처리 (비동기)
    result = await pipeline.process(image_data)
    
    # S3 업로드 (비동기)
    s3_url = await s3_service.upload(result)
    
    # DB 저장 (동기!) - 이 부분이 문제
    db_service.save_result(s3_url)  # sync function
    
    return s3_url
```

### 3. 동기/비동기 혼용의 함정

FastAPI는 비동기 프레임워크이지만, 동기 함수도 호출할 수 있다. 문제는 **`async def` 함수 내에서 동기 I/O 작업을 호출하면 이벤트 루프가 블로킹**된다는 점이다.

![비동기 함수 내 동기 작업](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1770446415295-Gemini_Generated_Image_on367hon367hon36_clean-20Tp7EJuSrz55CaLboYeEVYLUyQ0kc.webp)

| 함수 타입 | DB 작업 타입 | 결과 | 이유 |
|---|---|---|---|
| 동기 (`def`) | 동기 (sync SQLAlchemy) | ✅ 정상 동작 | FastAPI가 자동으로 스레드 풀에서 처리해 블로킹 없음 |
| 비동기 (`async def`) | 동기 (sync SQLAlchemy) | ❌ **블로킹 발생** | 이벤트 루프가 sync I/O 대기하며 다른 요청 차단 |
| 비동기 (`async def`) | 비동기 (AsyncSession) | ✅ **최적** | 완전 비동기 처리로 고성능 동시성 달성 |

우리의 경우 2번째 케이스였다. S3 업로드와 DB 저장이 수 초에서 수십 초 소요되는 동안, **이벤트 루프가 완전히 멈춰서** 다른 요청(헬스체크 포함)이 대기하게 되었다.

### 4. 실제 발생한 시나리오

```
[시간 0초] 파이프라인 시작 (task A)
[시간 590초] 처리 완료, S3 업로드 시작 (비동기 OK)
[시간 595초] S3 업로드 완료, DB 저장 시작 (동기 - 블로킹 시작!)
[시간 596초] ArgoCD readiness probe 요청 → 대기 중...
[시간 597초] 1초 timeout → probe failed (1/3)
[시간 607초] 2번째 probe 요청 → 여전히 대기 중...
[시간 608초] timeout → probe failed (2/3)
[시간 610초] DB 저장 완료 (블로킹 해제)
[시간 617초] 3번째 probe 성공
```

3회 연속 실패가 발생하지 않으면 다행이지만, DB 작업이 더 길어지거나 동시에 여러 작업이 진행되면 파드가 `NotReady` 상태로 전환된다. 병렬처리 기능을 도입하고 나서는 헬스체크가 더 실패할 확률이 높아지면서 작업을 처리하다가 서버가 unhealthy 상태로 들어가는 모습을 자주 봤었다.

## 해결 방법

처음에는 헬스체크 설정을 바꿔서 조금 기다리더라도 서버가 죽지는 않게 하려고 했으나, 이는 근본적인 해결 방법이 아니라고 생각을 했다. 이후 찾은 해결방법으로는 아래와 같다.

### 1. 모든 DB 작업을 비동기로 전환

가장 근본적인 해결책은 **동기 DB 작업을 비동기로 전환**하는 것이다.

```python
# ✅ 수정된 코드
from sqlalchemy.ext.asyncio import AsyncSession

async def process_and_save(image_data, db: AsyncSession):
    # 파이프라인 처리 (비동기)
    result = await pipeline.process(image_data)
    
    # S3 업로드 (비동기)
    s3_url = await s3_service.upload(result)
    
    # DB 저장 (비동기!)
    await db_service.save_result_async(db, s3_url)
    
    return s3_url
```

### 2. SQLAlchemy 비동기 세션 구성

PostgreSQL/Aurora DB를 사용하는 경우 asyncpg 드라이버를 통해 비동기 세션을 구성할 수 있다.
```python
# packages/backend/app/core/database.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker

# PostgreSQL/Aurora 연결 (asyncpg 사용)
engine = create_async_engine(
    "postgresql+asyncpg://user:pass@host:5432/dbname",
    echo=False,
    pool_pre_ping=True,
)

AsyncSessionLocal = async_sessionmaker(
    engine,
    class_=AsyncSession,
    expire_on_commit=False,
)

async def get_db():
    async with AsyncSessionLocal() as session:
        yield session
```

### 3. 서비스 레이어 비동기 변환

기존의 동기 DB 작업을 비동기로 변환했다. 특히 트랜잭션 처리가 필요한 경우 `async with` 구문을 활용했다.

```python
# Before (동기)
def save_task_result(task_id: str, result_url: str):
    with SessionLocal() as db:
    ...

# After (비동기)
async def save_task_result_async(db: AsyncSession, task_id: str, result_url: str):
    async with db.begin():  # 트랜잭션 보장
    ...
```

### 4. 순서 보장이 필요한 경우 트랜잭션 활용

비동기 작업은 기본적으로 **순서가 보장되지 않는다**. 예를 들어, "상태를 'processing'으로 변경 → 결과 저장 → 상태를 'completed'로 변경" 같은 작업은 순서가 중요하다.

```python
async def process_with_state_management(db: AsyncSession, task_id: str):
    async with db.begin():  # 트랜잭션 시작
        # 1. 상태 변경
        await update_task_status(db, task_id, "processing")
        
        # 2. 파이프라인 작업 (트랜잭션 밖에서 수행)
        # 트랜잭션 내에서 오래 걸리는 작업을 하면 안 됨
        
    # 작업 수행
    result = await pipeline.process()
    
    async with db.begin():  # 새로운 트랜잭션
        # 3. 결과 저장
        await save_result(db, task_id, result)
        
        # 4. 상태 완료로 변경
        await update_task_status(db, task_id, "completed")
```

![트랜잭션 순서보장](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1770449176172-Gemini_Generated_Image_keccj1keccj1kecc%20%281%29_clean-kJpcLW31GPeZfNwHDKq79Y6YHn2jFw.webp)

## 배운 점

### FastAPI의 동기/비동기 혼용은 치명적이다

최근 프로젝트에서 백엔드를 많이하고 있다. 프론트엔드에서의 비동기랑은 약간 차이가 있어서 배우면서 하고 있다.. ㅎㅎ 

FastAPI는 유연하게 동기와 비동기 함수를 모두 지원하지만, **비동기 함수 내에서 동기 I/O를 호출하면 이벤트 루프가 블로킹**된다고 한다. 특히 다음과 같은 작업은 반드시 비동기로 처리해야 한다.

- 데이터베이스 쿼리 (SQLAlchemy AsyncSession)
- 외부 API 호출 (httpx.AsyncClient)
- 파일 I/O (aiofiles)
- S3/클라우드 스토리지 작업 (aioboto3)


### 로컬 환경과 프로덕션 환경의 차이

로컬에서는 Readiness Probe가 없기 때문에 블로킹 문제가 드러나지 않는다. 반면 Kubernetes 환경에서는 **주기적인 헬스체크가 서버의 건강 상태를 감시**하므로, 짧은 블로킹도 치명적일 수 있다.

I/O 작업은 비동기로 설계하는 것이 좋겠다고 생각했다.

### 처음부터 올바른 아키텍처를 설계하자

이미 작성된 동기 코드를 비동기로 전환하는 작업은 매우 시간 소모적이다. 우리 프로젝트에서는 다음 파일들을 모두 수정해야 했다. 

- `app/services/db_service.py` (150+ 줄)
- `app/core/event_handlers.py` (100+ 줄)
- 각종 API 엔드포인트

**프로젝트 초기에 동기/비동기 방침을 명확히 정하고, 일관되게 적용하자**

---

## 결론

"로컬에서는 되는데 배포하면 안 된다"는 문제의 근본 원인은 **FastAPI 비동기 함수 내 동기 I/O 블로킹**이었다. ArgoCD의 Readiness Probe가 이를 드러내는 계기가 되었고, 모든 DB 작업을 비동기로 전환하여 해결했다.

이 과정에서 얻은 가장 큰 학습은 **처음부터 비동기 아키텍처를 일관되게 설계하는 것의 중요성**이다. 백엔드 개발, 특히 고성능 비동기 서버를 구축할 때는 동기/비동기 경계를 명확히 하고, I/O 작업은 무조건 비동기로 처리해야 한다. 

> **요즘에는 AI 보조 도구가 너무 잘 되어있어서 가끔은 내가 만들고 있는 프로덕트에 대한 이해가 낮아질 수 있겠다는 생각이 들었다.**
>
>  **이런 문제 해결도 결국에는 비동기와 동기, 이벤트루프와 블로킹, 트랜잭션 등의 개념을 알아야하는거구나 하는 생각도 했다. AI가 발전해도 살아남으려면 기본 개념 파악이 우선시 되어야 하는 것 같다.**]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[장시간 AI 처리 작업의 실시간 진행률 구현 (SSE)]]></title>
            <link>https://shipfriend.dev/posts/장시간-ai-처리-작업의-실시간-진행률-구현-sse</link>
            <guid>https://shipfriend.dev/posts/장시간-ai-처리-작업의-실시간-진행률-구현-sse</guid>
            <pubDate>Fri, 06 Feb 2026 15:25:59 GMT</pubDate>
            <description><![CDATA[SSE에 대해서]]></description>
            <content:encoded><![CDATA[# SSE로 실시간 진행률 받아오기

최근 진행 중인 프로젝트에서 **사용자가 요청한 작업이 완료되기까지 3분에서 5분 이상 걸리는 상황**을 마주하게 되었다.

문제는 단순히 시간이 오래 걸린다는 것만이 아니었다.

- 사용자는 작업이 진행 중인지, 멈춘 건지 알 수 없음 
    - 작업이 진행하다가 배포 서버가 죽거나 하면 더더욱   
- 백그라운드에서 복잡한 파이프라인이 돌아가지만 결과만 보여줄 뿐
- "처리 중" 메시지만 3분 동안 보여주는 건 좋은 UX가 아님

특히 우리 서비스의 경우 하나의 요청이 **여러 단계의 처리 파이프라인**을 거쳐야 했기 때문에, 각 단계별 진행 상황을 보여주는 것이 중요했다.

![이미지](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1770388198230-Gemini_Generated_Image_nsek0tnsek0tnsek_clean-fN2xRDRXXc0JvdQOabgxAGBBJJF3ko.webp)

예시를 들어보자면, 
1. 입력 데이터 전처리 (10%)
2. 외부 API 호출 및 처리 (40%)
3. 결과물 생성 (30%)
4. 후처리 및 저장 (20%)

이런 상황에서 "실시간으로 진행률을 보여주려면 어떻게 해야 할까?"라는 고민에서 출발했고, 폴링과 SSE를 비교하게 되었습니다

실시간 데이터 전달을 알아보다보면 자연스럽게 폴링과 SSE를 비교하게 된다. 이번 글에서는 처리 진행률을 받아올 때 왜 SSE를 선택했는지에 대해서 작성해보려고 한다.

## SSE란?
SSE는 Server Sent Events의 약자로 서버에서 클라이언트로 단방향으로 실시간 데이터 스트림을 전송하는 기술이다.

HTTP 프로토콜 기반으로 동작하며, 서버에서 지속적으로 데이터를 내려주는 구조다.

### 언제 필요한가?
보통 폴링, 롱폴링과 같이 지속적으로 새로운 데이터가 필요할 때 (작업 현황 실시간 진행률 같이) 사용된다.

간단하게 폴링, 롱폴링과 비교를 해본다면 아래와 같다.

- 폴링: 양방향으로 유저가 직접 서버에 요청해서 데이터를 받아옴
- 롱폴링: 양방향으로 유저가 직접 서버에 요청하고, 연결을 잠시 유지하면서 데이터를 받아옴
- SSE: 단방향(서버->클라이언트)으로 유저의 최초 연결 후 서버에서 지속적인 데이터 스트림을 받아옴


![이미지](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1770442883923-Gemini_Generated_Image_bwi41ubwi41ubwi4_clean-hMHDmx240I8RpnUZrdW6O16jN7iDaa.webp)

| 구분 | 폴링 (Polling) | SSE (Server-Sent Events) |
|------|---------------|--------------------------|
| **통신 방향** | 양방향 (클라이언트 → 서버) | 단방향 (서버 → 클라이언트) |
| **연결 방식** | 매번 새로운 HTTP 요청 | 단일 HTTP 연결 유지 |
| **요청 주체** | 클라이언트가 주기적으로 요청 | 서버가 변경사항 발생 시 푸시 |
| **네트워크 오버헤드** | 높음 (매 요청마다 헤더 전송) | 낮음 (초기 연결 후 데이터만 전송) |
| **실시간성** | 폴링 간격만큼 지연 | 즉시 전송 (지연 거의 없음) |
| **서버 부하** | 높음 (불필요한 요청 다수) | 낮음 (변경 시에만 전송) |
| **재연결** | 매번 연결/해제 | 자동 재연결 지원 |
| **브라우저 지원** | 모든 브라우저 | IE 제외 모든 모던 브라우저 |
| **구현 복잡도** | 낮음 | 중간 |
| **적합한 상황** | 간단한 상태 확인 | 실시간 업데이트, 긴 작업 진행률 |

### 예시: 3분 작업, 1초 간격 폴링 기준

| 항목 | 폴링 | SSE |
|------|------|-----|
| HTTP 요청 수 | 180회 | 1회 (초기 연결) |
| 전송 데이터 | ~90KB (헤더 포함) | ~5KB (데이터만) |
| 업데이트 지연 | 최대 1초 | 즉시 (<50ms) |


## 왜 SSE인가?
실시간 처리 진행 상황을 보여주는 용도로 폴링 대신 SSE를 사용한 이유는 실시간 업데이트에 더 적합하다고 생각했기 때문이다. 

폴링은 클라이언트에서 요청을 해야 응답을 받을 수 있고, 폴링 주기를 짧게 하면 할 수록 성능 부하가 불필요하게 많아지고, 서버에서 처리하고 있는 진행률 자체는 간단한 상태이기 때문에 폴링보다는 SSE가 적합하다고 생각했다.

SSE의 강점으로 생각했던 것은 실제로 데이터의 변경사항이 있을 때만 서버가 선택적으로 데이터를 내려줄 수 있다는 점이었다. 그리고 한번의 연결 후 데이터 스트림을 내려주는 방식이어서 폴링에 비해서 오버헤드가 적다는 장점으로 선택하게 되었다.


## SSE 구현

SSE 구현을 위해서는 클라이언트와 백엔드 모두에서 작업이 필요하다.

```tsx
import { useEffect, useState } from 'react';

interface ProgressData {
  progress: number;
  message: string;
  status: 'pending' | 'in_progress' | 'completed' | 'failed';
}

export const useTaskProgress = (taskId: string | null) => {
  const [progress, setProgress] = useState<ProgressData>({
    progress: 0,
    message: '',
    status: 'pending'
  });
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!taskId) return;

    const eventSource = new EventSource(
      `${import.meta.env.VITE_API_URL}/api/tasks/${taskId}/progress`
    );

    eventSource.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        setProgress(data);
      } catch (err) {
        console.error('Failed to parse SSE data:', err);
      }
    };

    eventSource.onerror = (err) => {
      console.error('SSE error:', err);
      setError('연결이 끊어졌습니다');
      eventSource.close();
    };

    // Cleanup
    return () => {
      eventSource.close();
    };
  }, [taskId]);

  return { progress, error };
};
```


]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[StoryHelper v1.6.2 패치노트]]></title>
            <link>https://shipfriend.dev/posts/storyhelper-v1-6-2-패치노트</link>
            <guid>https://shipfriend.dev/posts/storyhelper-v1-6-2-패치노트</guid>
            <pubDate>Sat, 03 Jan 2026 14:03:43 GMT</pubDate>
            <description><![CDATA[접근성, UI/UX 개선]]></description>
            <content:encoded><![CDATA[# StoryHelper 패치 노트 - v1.6.2

## 주요 변경사항

### 팝업 UI 개선
- 팝업 헤더에 StoryHelper 로고 추가
- "더 편리하게" 텍스트 강조 처리
- 전체적인 폰트 크기를 조정하여 더 깔끔한 화면 구성
- 새로운 기능에 NEW 배지 추가 (애니메이션 효과 포함)

![메인 기능리스트 화면](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1767449081074-%EC%8A%A4%ED%81%AC%EB%A6%B0%EC%83%B7%202026-01-03%20230426-7FzmGH0RWmTsuR8NCOI8mNaFU2hRNK.webp)

### 기능 토글 UI 변경
- 기존 체크박스를 모던한 토글 스위치로 변경
- 브랜드 색상 적용
- 슬라이드 애니메이션 효과 추가

### 단축키 설정 화면 개선
- 테이블을 카드 스타일로 재디자인
- 버튼 호버 시 색상 변경 및 부드러운 애니메이션 효과
- 단축키 입력 영역 배경에 그라디언트 적용
- 저장/취소 버튼 디자인 개선

![단축키 설정화면](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1767449112476-%EC%8A%A4%ED%81%AC%EB%A6%B0%EC%83%B7%202026-01-03%20230519-j6r4IngbjFnU7p0gBzRKLur59PgmRe.webp)

### 크레딧 화면 개선
- 섹션 간 간격 조정
- 아이콘 크기 확대 및 호버 효과 개선
- Velog 링크를 StoryHelper 공식 랜딩페이지로 변경
- 문의 버튼의 불필요한 호버 확대 효과 제거

![크레딧 화면](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1767449112895-%EC%8A%A4%ED%81%AC%EB%A6%B0%EC%83%B7%202026-01-03%20230522-zP4N8iqHA2NdLxwMglZjy3NzOsJVRO.webp)

### 접근성 향상
- 화살표 키로 탭 간 이동 가능 (Left/Right)
- Home/End 키로 첫/마지막 탭으로 이동
- 키보드 포커스 시각화 개선
- 스크린 리더를 위한 적절한 레이블 및 역할 속성 추가
- 에러 메시지 및 상태 변경 시 실시간 안내
- 한글/영어 다국어 지원

### 검색엔진 최적화 검증 기능 개선
- 아이콘을 통한 명확한 상태 표시 (체크마크, 경고, 에러)
- 검증 속도가 빠르므로 로딩 화면 제거

![변경된 UI](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1767449407297-%EC%8A%A4%ED%81%AC%EB%A6%B0%EC%83%B7%202026-01-03%20231015-m1RgmyKbK8IQ6p3iLmn4yd2LrOFGox.webp)

### 툴팁 디자인 개선
- 부드러운 그림자 및 페이드인 효과
- 툴팁에 StoryHelper 로고 추가

### 상태 인디케이터 기능 개선
- **최소화/확대 상태**를 storage 저장으로 페이지 새로고침 후에도 유지
- 최소화 시 로고만 표시되며, 로고 클릭으로 다시 확대 가능
- 호버 효과 및 안내 툴팁 추가

![기능 토글 상태 인디케이터](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1767449493802-%EC%8A%A4%ED%81%AC%EB%A6%B0%EC%83%B7%202026-01-03%20231146-74xSD0Ag15dQ19cH0u43VYgDd7znyv.webp)

### 성능 최적화
- 검증 로직을 더 효율적인 방식으로 개선
- 임시저장 글 복구 감지 방식 최적화로 CPU 사용량 감소
- 불필요한 함수 호출 최소화 및 코드 품질 향상

---

**배포일**: 2026-01-03
**개발자**: ShipFriend
**버전**: v1.6.2]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[StoryHelper에 리뷰 요청하기와 삭제시 피드백 수집 기능 만들기]]></title>
            <link>https://shipfriend.dev/posts/storyhelper에-리뷰-요청하기와-삭제시-피드백-수집-기능-만들기</link>
            <guid>https://shipfriend.dev/posts/storyhelper에-리뷰-요청하기와-삭제시-피드백-수집-기능-만들기</guid>
            <pubDate>Mon, 29 Dec 2025 07:40:12 GMT</pubDate>
            <description><![CDATA[Notion SDK 연동으로 데이터베이스에 피드백 저장]]></description>
            <content:encoded><![CDATA[# 확장프로그램에 리뷰 받는 기능을 만들어보자

![썸네일](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1766993835560-Gemini_Generated_Image_q0zqz1q0zqz1q0zq_clean-nLspTXDcrDsfrls44lgg9yZbEgTHob.webp)

사실 더 추가 할만 한 기능 아이디어는 더 없지만 가끔 들어가서 사용자 늘어있는지 보는 재미가 있는 내 확장프로그램 [Storyhelper..](https://chromewebstore.google.com/detail/storyhelper-%EC%B5%9C%EA%B3%A0%EC%9D%98-%ED%8B%B0%EC%8A%A4%ED%86%A0%EB%A6%AC-%EC%83%9D%EC%82%B0%EC%84%B1/inmbdknioncgblpeiiohmdihhidnjpfp?authuser=0&hl=ko)

유저 수가 꾸준하게 우상향하고 있는데 리뷰를 받고 피드백을 기반으로 개선해보는 경험이 하고 싶어서 리뷰를 어떻게하면 장려해볼 수 있을까 고민했다.


## 리뷰 장려 팝업 구현하기

우리가 여러 프로그램이나 서비스를  사용하다보면 가끔 리뷰를 요청하는 문구와 함께 팝업창이 뜨는 것을 자주 볼 수 있다. 개인적으로는 바로 꺼버리는 스타일이긴 하지만 그런 팝업이 자주 보이는데에는 모두 이유가 있을 것이라고 생각하고 구현해보기로 결정했다.

![리뷰를 남겨주세요](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1766993924827-%E1%84%85%E1%85%B5%E1%84%87%E1%85%B2%E1%84%85%E1%85%B3%E1%86%AF%20%E1%84%82%E1%85%A1%E1%86%B7%E1%84%80%E1%85%A7%E1%84%8C%E1%85%AE%E1%84%89%E1%85%A6%E1%84%8B%E1%85%AD-vI8WPjn9pVXiKAucobrCJkrpKL1IT3.webp)

### 고려했던 점
무턱대고 리뷰를 요청하는 것은 오히려 프로그램 삭제의 이유가 될 수 있을 것이라고 생각해서 아래의 사항을 지키자고 먼저 정해두고 시작했다.

- 사용자가 **긍정적인 경험**을 한 직후
- 생산성 프로그램이기 때문에 블로그 글 작성을 **방해하지 않는** 경험

사용자가 긍정적인 경험을 한 시점이 언제일까 생각을 해봤을 때 SEO 검증 기능 사용이나, 새 글 발행 성공 등의 시점이 있을 것이라고 생각했다.

그래서 첫 번째 리뷰 장려 기능은 SEO 검증이 5번 성공했을 시점에 팝업 형식으로 띄워주는 것으로 결정했다.

구현은 간단하게 이루어졌다. Extension Storage에 seo 검증 횟수와 리뷰 여부, 나중에 버튼 눌렀을 때 7일 이후 다시 뜨게 하기 위한 timestamp 이렇게 3가지 데이터를 저장했다. 사용자가 처음으로 SEO 검증 5번을 마치면 그때 팝업 창으로 오른쪽 아래에 띄워주는 방식이다.

![리뷰 요청 팝업](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1766991907961-%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-12-29%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%203.13.48-t7pJ0NRTMqIkDVYTIxK5eTlrbqAxWZ.webp)

이 경로로 웹스토어 리뷰가 하나로 늘어난다면 아주 좋을 것 같다.. 아주..

---
## 삭제 피드백 수집 경로 구현하기
웹스토어에서 통계를 보면 확장프로그램 설치와 삭제 비율이 6:1 정도로 보인다. 

![설치 수](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1766992932431-%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-12-29%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%204.21.43-gn7Fvqa7sELDtUuGSWgzYgM2fP4MPg.webp)

![제거 수](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1766992933128-%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-12-29%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%204.21.44-nUXPJ3IjzLOGDpT2Mx1IuSarogjSHn.webp)

삭제하는 유저들의 피드백을 들어서 개선해보면 좋겠다고 생각했고, 앞서 이야기했던 리뷰처럼 확장프로그램을 삭제했을 때 특정 URL로 이동해서 삭제 근거에 대한 피드백을 수집하도록 만들고 싶었다.


가장 쉬운 방법은 Google Form을 사용해서 수집하는 것이었지만, 개발자로서 내키지 않았기 때문에 고려하지 않았다.

그 다음으로 생각한 것은 랜딩페이지 개발이었다. 확장프로그램을 소개하는 페이지를 만들면서 거기에 피드백 수집하는 기능을 추가하는 것이었다. 확장프로그램 소개 자체는 웹스토어에서 볼 수 있지만, Claude Code를 활용해서 쉽게 만들 수 있었기 때문에 그냥 만들기로 결정했다ㅋㅋ.. 

만드는 김에 이 블로그 도메인의 서브도메인을 만들어서 도메인도 지정해두는 것도 재밌을 것이라고 생각했다.

`chrome.runtime.setUninstallURL('https://storyhelper.shipfriend.dev/feedback')` 이 한 줄만 추가해서 확장프로그램을 삭제할 때 특정 URL로 이동시키는 동작을 추가할 수 있었다. 

### 만든 화면

![랜딩페이지 메인](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1766992167823-%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-12-29%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%203.13.37-FTtYZjZAhB9jMXnRX5vExur09OeZBk.webp)

[랜딩페이지 링크](https://storyhelper.shipfriend.dev)

Claude Code를 몇 번 써보고 나서 느낀 점은 기획문서를 대충이라도 잡아두고 가면 결과물이 확연하게 달라진다는 점이다. 이번에도 마찬가지로 프로그램 설명과 로고를 기반으로 하는 디자인 시스템을 문서화해두고 시작했다.

> 💬: ~~링크(확장프로그램 웹스토어, 개인블로그에 작성된 가이드문서) 등을 읽고 이 확장프로그램에 대한 랜딩페이지 기획 문서를 루트 디렉토리에 생성

> 💬: 이 로고를 기반으로 디자인 시스템 문서 생성 -> Sage Green 기반으로 잘 만들어줌

### 피드백 받는 화면
피드백을 받는 폼도 다른 route에 만들어두었다. 여기서 피드백을 수집할 수 있게 구현했다.

![피드백 폼](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1766992166003-%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-12-29%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%203.13.15-N9jJHFrxGdugwUQLDd6eg8G0OOnpvC.webp)


## 피드백 데이터 저장하는 곳 결정하기

![피드백 수집 완료](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1766992166705-%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-12-29%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%203.13.21-a456XYpLGvCScyJVWWSaF67lmTGTK2.webp)

피드백 데이터를 수집까지는 완성했다. 그렇다면 수집된 데이터를 쌓는 곳을 결정해야한다. 데이터를 저장하는 방법에는 여러가지가 있다. DB, 구글시트, 파일 등등 많은 소스들이 있지만 이 중에 결정한 것은 Notion 이었다.


### Notion에 데이터를 어떻게 쌓아?
Notion의 개발자 센터 페이지에 들어가면 API 키를 발급 받을 수 있다. 다행히 무료였다.. 🙈

![노션 API Integration](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1766992402934-%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-12-29%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%204.13.13-E0fxW4K7oRuK7ATql7N4YZ368RTikE.webp) 

여기서 잘 설정해주면 노션의 특정 워크스페이스의 특정 페이지의 특정 데이터베이스에 데이터를 쌓거나 편집하거나 읽기가 가능하다!!

notion에서 제공하는 sdk를 사용하면 쉽게 사용이 가능하다. [공식문서](https://developers.notion.com/docs/getting-started)에서 확인 가능하다.

이렇게 수집된 피드백 데이터는 노션 데이터베이스에 저장하도록 했다. 유저 수가 천 단위가 아니기 때문에 DB를 쓰는 건 오버엔지니어링이었기 때문에 이렇게 결정했는데 실시간으로 데이터가 쌓이는게 보이니까 재미있다.

![데이터베이스에 쌓이는 피드백들](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1766992167227-%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-12-29%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%203.13.33-0sx8XwtpxENmnMIXHiVvAW2B38b0TY.webp)

## 마지막으로 
요즘 블로그 생태계가 AI로 도배 되어가는 분위기라 이런 생산성 확장프로그램에 관심을 갖는 사용자가 있을지는 모르겠으나 도움이 되면 좋겠다. 본인은 아주 유용하게 썼기 때문에..]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[zod로 유효성 검사를 선언적으로 관리하기]]></title>
            <link>https://shipfriend.dev/posts/zod로-유효성-검사를-선언적으로-관리하기</link>
            <guid>https://shipfriend.dev/posts/zod로-유효성-검사를-선언적으로-관리하기</guid>
            <pubDate>Sat, 27 Dec 2025 10:04:53 GMT</pubDate>
            <description><![CDATA[복잡한 Form을 잘 작성하려면]]></description>
            <content:encoded><![CDATA[# Zod 를 잘 작성하는 방법

![zod로 유효성 검사를 선언적으로 관리하기](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1766829791551-Gemini_Generated_Image_u8cb5su8cb5su8cb_clean-bUFayoSAXfd21oPiTHX9rBiCsx17I6.webp)

최근 10개 이상의 필드를 가진 Form 코드를 작성하게 되면서 zod를 활용할 수 있는 기회가 있었다. 복잡한 Form 코드를 작성할 기회가 많지 않았는데 최근 Form 코드를 구현하면서 복잡한 유효성 검사를 zod로 개선한 경험에 대해서 이야기해보고, 어떻게 유효성 검사를 분리하는 것이 중요할지 생각해보기로 했다.

## Zod란?

zod는 일단 Form 라이브러리는 아니고 Validation 라이브러리이다. Form 자체의 입력과 상태 관리는 React Hook Form, Formik, Tanstack Form 등이 있다. zod는 이와는 달리 유효성검사를 선언적으로 할 수 있게 도와주는 라이브러리이다.

선언적으로? 유효성 검사를? 말로 들으면 이해가 잘 되지 않았다. 아래 코드를 보고 쉽게 이해해보자.

```tsx
const schema = z.object({
  email: z.email("유효한 이메일이 아닙니다."),
  firstName: z.string().trim().min(1, "올바르지 않은 이름입니다."),
  lastName: z.string().trim().min(1, "올바르지 않은 이름입니다."),
  password: z.string().optional(),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "비밀번호가 일치하지 않습니다",
  path: ["confirmPassword"],
});

```

위 코드는 schema를 정의하는 코드이다. 각 필드명에 대해서 z.email(), z.string() 등 zod 메서드를 사용해서 타입이나 제약조건을 선언적으로 지정할 수 있다. 그리고 또 z.email().min() 형식으로 메서드 체이닝을 활용하면 제약조건을 추가하는 것도 가능하다.

zod에는 다양한 메서드가 있는데 자주 쓰이는 건 길이 검증하는 mim, max 그리고 email() 함수로 쉽게 이메일 형식 검증이 가능하다.

zod의 장점은 선언적이기 때문에 필드의 제약조건을 파악하기 쉽다.

![선언적 비교](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1766829794231-Gemini_Generated_Image_r63pc1r63pc1r63p_clean-iCI71Osq8hPkzx0bJtuSMMSZhXwB8g.webp)

원래라면 이메일 형식을 검증하기 위해 `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$`  이런 복잡한 정규표현식을 사용하거나 했다. 하지만 이럴 경우 주석처리를 하거나 함수이름을 명확하게 정의하지 않으면 이메일을 검증하는 것인지 바로 인지하기 쉽지 않다고 생각한다. zod에서는 이런 정규표현식을 추상화한 메서드로 형식을 검증할 수 있다.

그리고 zod에 없는 형식을 검증하거나 각 필드 간의 관계를 검증하기 위해서는 .refine 함수를 사용하면 보다 복잡한 유효성 검사를 적용할 수 있다.

[다양한 메서드 zod](https://zod.dev/api)

## zod 사용 예시

zod는 아래처럼 사용할 수 있다.

```tsx
// 1. 스키마 정의
const userSchema = z.object({
  nickname: z.string()
    .trim()
    .min(2, "닉네임은 최소 2자 이상이어야 합니다")
    .max(10, "닉네임은 최대 10자까지 가능합니다"),
  email: z.string().email("올바른 이메일 형식이 아닙니다"),
});

// 2. TypeScript 타입 자동 추론
type UserFormData = z.infer<typeof userSchema>;

// 3. React 컴포넌트에서 사용
const UserRegistrationForm = () => {
  const [formData, setFormData] = useState<Partial<UserFormData>>({});
  const [errors, setErrors] = useState<Record<string, string>>({});

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    
    const result = userSchema.safeParse(formData);
    
    if (!result.success) {
      const formattedErrors: Record<string, string> = {};
      result.error.errors.forEach((err) => {
        formattedErrors[err.path[0]] = err.message;
      });
      setErrors(formattedErrors);
      return;
    }
    
    console.log('검증된 데이터:', result.data);
    // API 호출 등...
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input
          name="nickname"
          onChange={(e) => setFormData(prev => ({ ...prev, nickname: e.target.value }))}
          placeholder="닉네임"
        />
        {errors.nickname && <span>{errors.nickname}</span>}
      </div>

      <div>
        <input
          name="email"
          onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
          placeholder="이메일"
        />
        {errors.email && <span>{errors.email}</span>}
      </div>

      <button type="submit">가입하기</button>
    </form>
  );
}
```

## zod로 모든 유효성검사를 할 수 있을까?

zod로 모든 유효성 검사를 대체할 수 있을까? zod는 많이 사용되는 유효성검사들을 쉽게 재사용할 수 있다는 강점이 있지만 모든 유효성검사를 대체하기에는 한계가 있다.

zod가 적합한 경우는

- type을 검증하거나
- 입력 값의 형식, 길이, 패턴 등을 검증하거나
- 필드 간 관계를 검증할 때

zod로 하지 않는게 좋은 것

- 복잡한 비즈니스 로직에 연관된 유효성검사
- 서버에서 검증해야 하는 것 (중복 이메일 검사 등)

## form 코드로 알아보는 로직 분리에 대한 나의 생각

Form 코드를 어떻게 분리하는게 효율적일까? Form 코드는 작성하다보면 매우 쉽게 길어지는 것 같다. Form에 들어가는 여러가지 input 필드들, 복잡한 유효성 검사들, API 요청 코드, 타입 정의 등 많은 부분이 들어가기 때문이다.

일단 나는 Form의 필드 부분은 컴포넌트, 유효성 검사나  submit 핸들러 등 로직 부분을 커스텀 훅으로 분리하는 것을 기본으로 두고 했었던 것 같다.

zod를 쓰게 된다면 위 부분에서 필드 유효성 검사와 타입 정의를 zod schema 레벨로 분리를 할 수 있어서 가독성이 더 좋아지는 것 같다.

## 결론

### Zod는 선언적 유효성 검사의 강력한 도구

TypeScript와의 완벽한 통합으로 타입 안정성 확보
메서드 체이닝으로 복잡한 제약조건도 읽기 쉽게 표현

### 모든 검증을 Zod로 해결하려 하지 말 것

비동기 검증, 복잡한 비즈니스 로직은 적절한 곳에서 처리
각 검증의 성격에 맞는 방법 선택이 중요

### 관심사의 분리는 Form에서도 중요

스키마 검증 / 비즈니스 로직 / 서버 검증을 명확히 구분
유지보수 가능하고 테스트하기 쉬운 코드 작성

## 참고자료

- [zod Docs](https://zod.dev/)]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[레거시와 모던의 차이점과 현대화에 필요한 것]]></title>
            <link>https://shipfriend.dev/posts/레거시와-모던의-차이점과-현대화에-필요한-것</link>
            <guid>https://shipfriend.dev/posts/레거시와-모던의-차이점과-현대화에-필요한-것</guid>
            <pubDate>Sun, 23 Nov 2025 08:22:49 GMT</pubDate>
            <description><![CDATA[레거시를 보면서 느낀 점]]></description>
            <content:encoded><![CDATA[최근에 마이그레이션 프로젝트를 진행하면서 느낀 점들과 어떻게 하면 좋을지 나의 생각을 정리해 보면 좋을 것 같아서 적어봤다. 물론 아직 인턴으로 주니어도 아닌 개발자의 입장에서 적은 생각이다… 🌱

![Legacy To Modern Thumbnail](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1763885388689-Gemini_Generated_Image_2zzlwc2zzlwc2zzl-GUoMYTMZ5JsqQUEasAO3rnEQYmiuOb.webp)

# 레거시 프로젝트와 현대 프로젝트

레거시 프로젝트와 현대 프로젝트는 무엇으로 구분하는 것일까에 대해서 생각을 해봤다. 간단하게는 개발 언어와 프레임워크 등의 발전이 있을 수 있겠다. JSP에서 React 혹은 jQuery에서 React가 여기에 해당하지 않을까.

좀 더 크게 본다면 개발 방법론에도 차이가 있을 것 같다. 이전에는 처음 기획을 픽스하고 그대로 개발하는 워터폴 방식의 개발 방법을 적용을 했으니 개발 과정에서 생기는 문제점이나 품질 등을 외면하는 경우가 있었을 것 같다.

→ 워터폴 방식으로 개발하는 경우

→ 이미 다 만들어졌는데 생긴 문제가 100개다.. 이 경우에는 정말 치명적인 문제만 50개 고치고 나머지는 외면할 수 있을 것 같다고 생각했다.

이것보다 더 큰 범위로 생각해 본다면 개발자에 관한 문화가 있지 않을까 생각이 들었다. 회사에서 코드를 보면서 알게 된 사실로는 이 프로젝트가 SI 업체에 맡겨 탄생한 점이라는 것이다. 코드를 작성한 사람이 누군가 따라가보니 SI 회사에서 만들어준 프로젝트였다.

이것처럼 이전에는 처음부터 개발자가 회사에 포함되어 프로덕트를 만드는 구조보다는 외주를 통해 외부에서 제작해주는 형태의 개발이 많았다고 한다. 초기 구현하는 개발자 따로, 유지보수하는 개발자 따로.. 이렇게 책임이 분리되어있는 형태가 되면 레거시를 개선하려는 시도가 생기지 않을 것 같다는 생각을 하게 되었다.

## 레거시의 특징

레거시를 보고 당황했던 경험이 있다. 하지만 보고 생각하면 할 수록 이렇게 될 수 밖에 없는 이유가 있지 않았을까 라는 생각을 하게 되었다.

        레거시 프로젝트의 특징에는 어떤 것들이 있을까. 처음에 내가 생각했던 특징은 아래와 같다.

- 구조화 없는 코드
- 분리가 없거나 일관적이지 않음
- 서버와 강하게 결합되어 있음
- 문서화 부족으로 인해 유지보수 어려워짐
- 확장성 부족해 새로운 시도가 힘들어짐
- 보안 취약성을 가진 코드가 그대로
- 구형 기술의 구조적 문제로 인한 성능 문제

이 중에서 가장 큰 문제는 확장성과 유지보수성이 아닐까 싶다. 물론 보안 취약성은 정말 경제적 피해를 입힐 수 있는 문제로 가장 치명적이지만, 중요한 문제는 확장성과 유지보수성이라고 생각한다. 

보안 취약성 같은 잠재적인 문제를 해결하려면 유지보수를 하기 쉬워야하고, 유지보수를 쉽게 하고 기능을 쉽게 추가하기 위해서는 처음부터 확장성을 고려한 설계가 필요하다고 생각하기 때문이다.

문서화와 확장성이 좋다면 잠재적인 위험과 알려진 위험요소들을 해결할 수 있는 **의지가 생긴다고 믿는다.**

> 이렇게 적고 보니 구조화 없는 코드가 분리 부족을 유발하고, 이는 곧 확장성 저하와 유지보수 어려움을 만드는 것 같다..? 물론 너무 많은 분리는 오히려 독이라고 생각하는 편이기는 하다.

![레거시와 모던의 차이점](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1763886061383-Gemini_Generated_Image_cjdzhicjdzhicjdz%20%281%29-NuKDPfOAiXf2XuRnUjJueGRknnS74h.png)

## 현대 프로젝트의 특징

현대에서는 이런 레거시들의 특징이자 문제점을 많이 개선한 모습으로 보인다. 

- 구조화되어있는 코드
    - 프레임워크 단위로 구조화되기도 하는
- 책임 분리가 컨셉으로 잡혀있는
    - 이전 프로젝트들보다 최근 웹 프로젝트들에서는 책임 분리를 쉽게 들어볼 수 있게 된 것 같다. 그만큼 책임분리하는 원칙이 표준이 되었다는 의미같다.
- 서버와의 결합 약화
    - 이전에 JSP나 템플릿 언어를 활용하여 서버에서 모든 것을 처리하고 내려주던 프로젝트가 아니라 서버의 책임을 어느정도 클라이언트에 분리하여 제공하는 것과 필요한 데이터를 API 형태로 요청하고 응답하는 방식으로 해결했다.
- 개발 문화와 방법의 발전
    - 애자일한 개발 방법론, 쉽게 말하면 자주 보고 자주 고치는 방법론의 도입으로 잠재적인 문제를 좀 더 빠르게 발견하고, 빠르게 해결 할 수 있는 구조가 도입되었다.
    - CI/CD 등의 도입으로 개발 → 배포 → 테스트 과정의 주기가 짧아져 더 적은 시간 내에 많은 문제를 해결 할 수 있게 되었다.

이렇게 많은 문제가 해결되었지만 이게 정답이라고 생각하는 것은 위험하다고 생각하다. 나는 언젠가 리액트도 레거시가 되는 날이 올 수도 있다고 생각하기 때문이다.

## 레거시에서 현대 프로젝트로의 마이그레이션하려면..

레거시에서 현대 프로젝트로의 마이그레이션은 정말 어려운 결정이라고 생각이 들었다. 짧고 작은 부분만 보면 현대화하는 것이 어렵지 않다고 느낄 수 있다. 하지만 거대한 프로젝트이고, 이 변경이 다른 곳에도 영향을 크게 미칠 수 있다고 생각하면 버겁게 느껴지기도 한다.

- 전략적인 준비와 작은 것부터 천천히
- 점진적이고 안전한 접근

쓰다보면서 생각하니 더 어렵게 느껴진다. 백엔드에서 API 명세를 바꾸면 이 API를 활용하는 프론트엔드에서도 모두 변경을 해줘야한다. 혹은 데이터베이스에선 내려주는 필드명이 변경되거나 타입이 변경된다면? 백엔드, 프론트엔드 모두 영향이 갈 수도 있다.

처음에는 레거시는 그대로 두고, 새로운 프로젝트를 완전히 개발해서 레거시를 100% 대치해서 사용하면 안되나? 이런 생각을 했었다.

![빅뱅과 점진적 마이그레이션](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1763886064322-Gemini_Generated_Image_cjdzhicjdzhicjdz-6dPmNXZKi8RtbS9xpJ5zQPmzYOH24A.png)

여러 정보를 알아보고 직접 과정을 지켜보면서 느낀 점은 말이 안된다는 것이었다. 새로운 프로젝트로 완전히 대치하면 생길 수 있는 많은 문제점에 대해서 알게 되었기 때문이다.

점진적으로 개선하는 경우에는 `ABCD` 앱이 있을 때 `A`에서 발생할 수 있는 문제에 대해서만 조사하고, 대비하고, 문제가 생겼을 때 사용할 수 있는 리소스를 적게 가져갈 수 있다. 

하지만, `ABCD`를 한번에 만들어서 대치하는 경우에는? 예상치 못한 문제들이 동시다발적으로 터질 수 있다. 그렇게 되면 모든 리소스가 발생한 문제를 해결하는데 투입이 되어야하고, 서로 영향을 주는 사이드 이펙트가 많이 생길 수 있기 때문에 큰 문제가 된다. (**BigBang** 방식이라고 부르는 것 같다)

## 중요한 것

글을 쓰면서 느낀 점은 단순히 코드레벨에서만 성장을 위해 노력하는 것뿐 아니라 이런 더 큰 관점에서도 계속 생각해봐야겠다고 느꼈다. 레거시를 유발하는 것이 단순히 JSP, JQuery 같은 언어의 문제가 아니라는 점을 다시 한번 생각했고, 개발방법에 대한 고찰과 구조의 설계에 대해서 항상 생각해야겠다고 느꼈다!]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[2025년 회고 글을 작성해요]]></title>
            <link>https://shipfriend.dev/posts/2025년-회고-글을-작성해요</link>
            <guid>https://shipfriend.dev/posts/2025년-회고-글을-작성해요</guid>
            <pubDate>Mon, 10 Nov 2025 02:00:03 GMT</pubDate>
            <description><![CDATA[회고는 처음인데]]></description>
            <content:encoded><![CDATA[연간 회고를 작성할 것입니다..

> 현재 작성 중..



# 2025년 회고를 써보자



## 4학년 1학기



## 4학년 2학기


## 인턴 : 올리브영

## 스터디 활동

## 취준하면서

## 내년 계획


## 결론

]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[Vanilla Extract에 대해서 알아봅시다]]></title>
            <link>https://shipfriend.dev/posts/vanilla-extract에-대해서-알아봅시다</link>
            <guid>https://shipfriend.dev/posts/vanilla-extract에-대해서-알아봅시다</guid>
            <pubDate>Wed, 29 Oct 2025 07:43:23 GMT</pubDate>
            <description><![CDATA[CSS Library]]></description>
            <content:encoded><![CDATA[최근에 신기한 CSS 라이브러리를 써볼 수 있는 기회가 생겼다. TailwindCSS 정착민이었던 나에게는 새로운 도전이었다.. 

![바닐라익스트랙트](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1764640909174-Gemini_Generated_Image_9r1w9a9r1w9a9r1w-FdWcsl3IuwAEIVYjpEOB5EvvWc9Aat.webp)

# Vanilla Extract

일단 vanilla extract는 CSS 라이브러리이다. TailwindCSS는 init 등의 명령어와 환경을 제공해주기 때문에 프레임워크의 성격을 띄지만 이번에 알아볼 VE는 라이브러리이다.


## 기본적인 사용법

VE의 기본적인 사용법은 아래와 같다. 개인적으로는 React Native를 할 때 StyleSheet를 작성하는 느낌이 들었다.

```ts
// 먼저 스타일을 정의하고,
const styles = style({
    display: 'flex',
    gap: '8px'
    backgroundColor: 'green',
});

// 이런 식으로 사용할 수 있다
<Component text={'와우'} className={styles} />
```

정말 쉽게 사용할 수 있다.

## 상태를 활용하는 방법 (조건부 스타일)
개인적으로 이게 vanilla extract의 장점이라고 느꼈다. 웹 개발을 하다보면 어떤 상태가 있을 때 해당 상태의 값에 따라 다른 스타일을 렌더링해야 할 때가 많다. 

그럴 때 Vanilla extract에서 지원하는 recipe 함수를 사용해보자.

```tsx
// 공통 스타일은 base 아래에 작성, 분기하고 싶은 상태를 variants 아래에 작성한다.
const box = recipe({
    base: {
      borderRadius: "8px",
      padding: "16px",
      fontSize: "24px",
      backgroundColor: "darkgray",
    },
    variants: {
      color: {
        red: {
          color: "red",
        },
        blue: {
          color: "blue",
        },
      },
    },
  }),

// 사용은 이렇게
<div className={box({color: 'red'})}>배고프다.</div>
<div className={box({color: 'blue'})}>와우</div>
```
이런 식으로 사용이 가능하다.

<div>

![recipe 대박](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1761721815810-%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-10-29%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%204.10.09-eROo6XqE3PC8HbM8dYL4Ic4oxmM2qs.png)

</div>

### recipe의 활용, compoundVariants
레시피는 상태를 스타일에 반영할 수 있다는 특징이 있다. recipe에는 단일 상태 뿐아니라 조합 상태에도 스타일링을 할 수 있다.

compoundVariants를 사용하면 아래처럼 적용이 가능하다.
```ts
상태가 A 일 때 --> BackgroundColor: white;
상태가 B 일 때 --> BackgroundColor: black;
상태가 A와 B가 둘 다 일 때 --> BackgroundColor: green;
```



## 재미없는 사용법 외의 장점에 대해서 알아보자

사용하는 것이 매우 쉽고, recipe가 base 덕분에 깔끔하게 스타일을 작성할 수 있는 것 같다. 사용법을 알아봤으니 재미없는 부분에 대해서도 알아보자

### 개발이 편리하다 💀
일단 작성 자체가 어렵지 않고, recipe를 통한 공통 스타일 분리 등으로 인해서 **개발하기 편리**하다는 장점이 있다. 그리고 기본적으로     `파일이름.css.ts` 형식으로 스타일 파일을 분리하는데, 그만큼 TypeScript 친화적이라는 장점도 있다. 

### 제로 런타임 오버헤드다 💀
그리고 **빌드 타임에 변환된다는 장점**이 있다. 변환 방식이 크게 두 가지가 있는데 하나는 런타임에 변환되는 것이고, 다른 하나는 빌드 타임에 생성된다는 점이다. 런타임에 생성되는 경우 성능상의 오버헤드가 존재할 수 있다. CSS를 처리하는데 평소보다 노력이 들어간다는 의미이기 때문이다. 하지만 빌드 타임에 하는 경우 물론 빌드 타임이 늘어나기는 하겠지만, 실제 실행할 때 성능상의 오버헤드는 존재하지 않는다. 일반 .css 파일처럼 변환되어 처리되기 때문이다. 성능에 있어서 다른 라이브러리 대비 우위를 가져갈 수 있음이 특징이다.

![Vanilla Extract로 만든 네온사인](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1762672820866-%EC%8A%A4%ED%81%AC%EB%A6%B0%EC%83%B7%202025-11-09%20160444-9TtQzIOWvEN36rkuAZNKPJANf8DShL.png)

## 내 생각
일단 `TailwindCSS`를 엄청 좋아하는 편이었기 때문에 적응하는데 시간이 조금 필요했다. 물론 Tailwind를 접하기 전에도 CSS Module이나 RN 스타일링 등을 경험했었기 때문에 어렵지는 않았다.

Vanilla Extract가 재미있었지만 TailwindCSS의 빠른 개발 경험은 못따라간다고 생각했다. Tailwind에 익숙해지면 간단한 스타일링과 레이아웃은 굉장히 빠르게 구현을 할 수 있는데, Vanilla Extract는 .css.ts 파일을 만들고 export 한 후 className에 넣어주는 과정이 포함되기 때문에 아쉬웠다.

하지만 스타일링과 코드의 명확한 분리가 쉽고, Tailwind의 단점으로 지적되는 횡으로 길어지는 코드에 대해 걱정을 하지 않아도 된다는 것이 특징이다. 

### 나만의 결론
TailwindCSS는 아직 좋다고 생각한다. 하지만 `Vanilla Extract`는 어느정도 규모가 있는 프로젝트에서 스타일링에 소요되는 코스트까지 줄이고 싶거나, `테마&팔레트`를 명확하게 정의하고 스타일과 코드를 완벽하게 분리하고 싶다면 선택할 수 있는 좋은 대안이라고 생각이 들었다.

 
]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[Module Federation에 대해서]]></title>
            <link>https://shipfriend.dev/posts/module-federation에-대해서</link>
            <guid>https://shipfriend.dev/posts/module-federation에-대해서</guid>
            <pubDate>Sun, 19 Oct 2025 06:57:25 GMT</pubDate>
            <description><![CDATA[Micro-Frontend Architecture 위한 수단]]></description>
            <content:encoded><![CDATA[Module Federation은 정말 처음 들어보는 생소한 용어였다. 해당 기술에 대해서 알아보면서 이게 Micro-Frontend Architecture를 위해서 사용하는 일종의 수단이구나 생각했다. 

![Module Federation](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1760843432702-Gemini_Generated_Image_7uhky07uhky07uhk-HQ9elGlEjrKl2r9JdKnfPBQMoZ2Kj4.png)

# Module Federation

## 정의

서로 다른 프로젝트에서 하나 이상의 빌드된 코드를 런타임에 동적으로 로드하고 공유할 수 있게 하는 기술

<aside>
❓


**Federation**


한글로 연맹, 연합이라는 뜻을 가짐
</aside>

## 왜 사용하는가?

개념을 알아보면서 느낀 점은 이 기술은 **마이크로 프론트엔드**와 매우 밀접한 연관을 갖고 있다는 것이었다. Module Federation을 사용하는 가장 큰 이유는 대규모의 프로젝트를 소규모의 독립적인 프로젝트로 분리하는데에 있다고 느꼈다.

그렇기 때문에 일반적으로 혼자서 할 수 있는 혹은 2~4인의 소규모 팀에서는 경험해보기 힘든 분야라고 생각이 들었다. 따라서 큰 규모의 프로젝트를 한다고 상상했을 때 이 기술을 왜 쓰는지 이해할 수 있었다.

### 1. 독립적인 스쿼드 운영

혼자서 개발할 때나 팀프로젝트를 한다면 각자 하나씩 기능을 맡아서 구현해도 전혀 문제될게 없고, 기능 간 서로 연관이 깊기 때문에 머지 충돌이 생겨도 금방 해결할 수 있다.

하지만 대규모 프로젝트거나 기업에서의 애플리케이션은 매우 크기 때문에 머지 충돌이 생길 경우 해결에 시간이 더 오래걸리고, 이해 관계가 얽혀있는 경우가 많다. 

이럴 때 하나의 레포지토리에서 100명이 넘는 개발자가 개발을 하는 경우에는 효율이 나오지 않을 수 있다. 빌드와 배포에서도 하나의 팀이 중요한 버그 수정사항을 커밋하면 전체 앱을 재빌드 해야하기 때문이다.

하지만 Module Federation과 Micro Frontend Architecture를 사용하면 하나의 앱의 여러 부분을 독립적으로 운영할 수 있게 된다. 나의 블로그로 예시를 든다면, A 팀은 메인페이지를 담당하고, B 팀은 블로그 글 리스트를 담당하는 식이다. 

### 2. 점진적 마이그레이션

가장 큰 이유는 위의 독립적인 스쿼드 운영과 마이크로프론트엔드 아키텍쳐이지만, 이 점진적 마이그레이션은 부가적으로 따라오는 장점이라고 생각했다.

마이크로 프론트엔드로 구성하기 시작하면 대규모 업데이트나 대규모 마이그레이션 작업을 점진적으로 하기 좋다. 

```tsx
const App = () => {
  return (
    <>
      <LegacyHeader />           {/* jQuery */}
      <NewReactDashboard />      {/* React - 새로 작성 */}
      <LegacyFooter />           {/* jQuery */}
    </>
  );
};
```

각자가 독립적인 앱이기 때문에 마이그레이션 과정 중 생기는 버그도 전체 앱에 영향을 미치지 않고 독립적으로 해결 할 수 있다.

## 핵심 개념

### Host

호스트는 메인 앱을 지칭, 주로 Remote 패키지들을 로드하는 주체가 되는 애플리케이션

### Remote

독립적인 마이크로 앱, 다른 앱에서 사용할 수 있도록 엔트리포인트를 만들어 노출시킬 필요가 있음.

## 사용 예시

```jsx
// Host 앱에서 Remote 컴포넌트 사용
import React, { lazy, Suspense } from 'react';

// Remote 앱의 컴포넌트를 동적으로 import
const ProductList = lazy(() => import('productApp/ProductList'));
const UserProfile = lazy(() => import('userApp/UserProfile'));

function App() {
  return (
    <div>
      <h1>메인 애플리케이션</h1>
      
      <Suspense fallback={<div>제품 목록 로딩중...</div>}>
        <ProductList />
      </Suspense>
      
      <Suspense fallback={<div>사용자 프로필 로딩중...</div>}>
        <UserProfile />
      </Suspense>
    </div>
  );
}
```

## 장단점

### 장점

- 마이크로 프론트엔드 아키텍쳐를 구현하기 위해 사용하는 방법 중 하나로 사용될 수 있음
- 점진적 마이그레이션이 가능하다.
    - 기존 레거시를 현대화된 프로젝트로 개선하기 위해서는 여러가지 방법이 있다. 하나는 현대화된 프로젝트를 만들고 한번에 레거시를 대체하는 방법, 다른 하나는 Module Federation을 사용해서 점진적으로 일부 앱을 하나하나 현대화하는 방식
- 하나의 공유 컴포넌트 라이브러리로 사용가능
    - 여러가지 마이크로 앱에서 사용되는 컴포넌트의 경우 중복을 최소화하기 위해서 common 앱으로 분리해서 동적으로 로드하여 사용.

### 단점

단점들은 어쩔 수 없이 따라오는 부분들이 많은 것 같다. 특히, 대규모 서비스를 운영하게 되면 필연적으로 생길 수 밖에 없는 문제들이 단점이 될 것 같다. 

#### 내가 생각한 단점

- 어렵다, 러닝커브가 높다고 생각됨
- 마이크로 프론트엔드의 어려움과 결이 같다고 생각함
- host에서 어떻게 remote app들을 사용할지 정의하는 것이 중요할 것 같음
- 각자 remote app을 개발하는 것은 어렵지 않을 것 같지만, 그걸 하나로 합치고, 공통에서 사용해야하는 api를 만드는 것과 각 앱 간의 통신에서 어려움을 느낄 것 같다.

#### 실제 단점

**개발 환경의 복잡성**

각 리모트앱과 호스트앱을 모두 실행 필요

**Host-Remote 통합 설계의 중요성**

Host에서 Remote 앱들을 어떻게 사용할지 정의하는 것이 매우 중요하다. 잘못 설계하면 독립성의 장점을 잃고 강한 결합이 생길 수 있다.

**앱 간 통신과 공통 API 설계**

각 Remote 앱을 개발하는 것은 어렵지 않을 것 같지만, 이를 하나로 합치고, 공통에서 사용해야 하는 API를 만드는 것과 각 앱 간의 통신에서 어려움을 느낄 것 같다.


## 궁금한 점
### 앱 간의 통신은 어떻게 하는가?
앱 간 통신이 필요한 상황이 있다고 가정해보자. 예를 들어 장바구니 리모트앱, 결제 리모트앱이 나누어져있다고 한다면, 장바구니에서 결제를 클릭했을 때 장바구니에 담긴 상품 데이터를 어떻게 결제 페이지로 보낼 수 있을까?

여러 방법들 있지만, 유용하게 사용할 수 있을 것 같은 방법으로는 네 가지를 뽑아 볼 수 있을 것 같다.

- 전역상태 라이브러리 or ContextAPI
- URL 쿼리파라미터로 데이터 전달
- 백엔드 동기화
- 이벤트 버스

모든 remote 앱들을 호출하는 주체인 host 앱에서 전역 상태 Provider를 제공하면 그 아래에서 생성되는 리모트 앱들에서도 **전역 상태를 공유**할 수 있다. 이걸 통해 통신하는 방법이 있다.

이벤트 버스는 중앙에서 메시지를 전달해주는 시스템으로, A 앱에서 **특정 이벤트를 emit하면 그 이벤트를 구독하는 B 앱에서 이벤트에 대한 처리를 진행하는 방식**이다. Event Emitter를 생각하면 쉬울 것 같다. 이런 방식으로 remote 앱 간에 메시징이 가능하다.

![이벤트 버스](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1762015747576-unnamed%20%281%29-JE6SBsWoLdZ27rJenTWGf9DK1lYa0R.jpg)

### 웹뷰와의 차이점
런타임에 모듈로 통합되는 것과 웹 뷰의 차이점은 무엇일까?

먼저 웹 뷰는 완전히 독립된 컨텍스트와 메모리를 갖는다는 점이 차이점이다. 

하지만 Module Federation은 런타임에 **통합**된다는 것이 결정적인 차이점이다. 개발은 독립적으로 하지만 실제 실행은 하나의 앱과 같이(메모리 공유 등) 동작한다고 볼 수 있다.

## 결론

Module Federation은 마이크로 프론트엔드를 구성하기 위해서도 사용하고, 가독성과 개발 효율성을 위해서 사용하기도 한다. 핵심은 특정 모듈을 런타임에 불러와서 사용하기 위함이다. 마이크로 프론트엔드에서 각 마이크로앱을 구현하고 Module Federation을 사용해서 host 앱에서 합치는 방식을 사용한다.

Module Federation이라는 용어에 꽂혀서 글을 작성해봤는데, 작성하면 할 수록 내용이 너무 방대해져서 조금 두서없이 작성한 것 같다. Module Federation은 개념느낌이고, 이걸 설명하려면 Micro Frontend 구조를 설명해야하기 때문에..]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[리액트의 Render 함수와 Component 방식의 차이점]]></title>
            <link>https://shipfriend.dev/posts/리액트의-render-함수와-component-방식의-차이점을-알아보자</link>
            <guid>https://shipfriend.dev/posts/리액트의-render-함수와-component-방식의-차이점을-알아보자</guid>
            <pubDate>Fri, 10 Oct 2025 08:47:53 GMT</pubDate>
            <description><![CDATA[성능, 가독성 차이점]]></description>
            <content:encoded><![CDATA[# 리액트에서 Render 함수와 Component 방식의 차이점을 알아보자
 
![리액트에서 Render 함수와 Component 방식의 차이점을 알아보자](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1764748804108-Gemini_Generated_Image_quaj00quaj00quaj-u71z8q0idH7GSb6rP7n1WZ4gVpHyGY.webp)

## 상황

공통 컴포넌트(캘린더)를 만드는 중 currentView 상태에 따라 다른 뷰를 보여주는 컴포넌트를 만들어야 했다. currentView는 

```ts
type CurrentView = 'year' | 'month' | 'day'
```

로 구성되어있다. 각 상태에 따른 서로 다른 뷰를 렌더링해야했기에 처음에 renderCalendar 함수를 만들어서 사용했다.

```jsx
const renderCalendar = () => {
  switch (currentView) {
    case 'year':
      return <YearDateCalendar />;
    case 'month':
      return <MonthDateCalendar />
    case 'day':
      return <SingleDateCalendar />
    default:
      return null;
  }
};
```

처음 리액트를 배웠을 때도 render 함수를 사용해왔고, 규모가 크지 않다면 render 함수를 자주 사용했다. 예를 들면 아이콘을 렌더링해야하는 경우 같이 작은 규모라면 `const renderIcon = (isChecked: boolean) => isChecked ? <IconA /> : <IconB />` 처럼 사용했던 경험이 많다.

## 성능 차이점

### 리렌더링

사실 두 방식을 이렇다 정해두고 써본 적은 없지만 성능차이가 크지 않을 것이라고 무의식적으로 생각하고 있었다. 하지만 알아보니 꽤 많은 차이가 생길 수 있다는 것을 알게 되었다.

두 방식의 가장 큰 차이점은 렌더 함수의 경우 감싸고 있는 부모 컴포넌트가 리렌더링될 경우 항상 함수가 재생성된다는 점이다. 이때 렌더 함수의 규모가 아이콘을 렌더링하는 정도로 작다면, 큰 상관은 없지만 나의 경우처럼 뷰를 선택적으로 렌더링하는 경우에는 큰 손실이 생길 수 있겠다고 생각했다.

<aside>
💡

함수 재생성의 비용은 어느정도인가?

</aside>

**함수 재생성**, 꽤 많이 들어본 용어라고 생각한다. 그렇다면 이 함수 재생성의 비용은 어느정도일까? 함수 재생성이 매번 이루어지면 문제가 되는걸까?

답은 그렇지 않다. 함수 객체를 생성하는 것은 V8 엔진 단에서 매우 최적화되어 있다고 한다. 그래서 함수 재생성에 들어가는 코스트는 크지 않다. 함수 재생성에 들어가는 비용 때문에 문제가 생기는 것이 아니라, 이로 인해 발생하는 사이드 이펙트 때문이라고 한다.

대표적인 예시로 `useEffect`의 의존성배열에 함수가 있을 경우, 컴포넌트가 리렌더링될 때마다 함수의 참조가 새로 생성되어 effect가 다시 실행된다.

### 커스텀 훅 사용 가능 여부

render 함수 내부에서는 커스텀 훅을 사용할 수 없다. 내부에서 사용할 경우 커스텀 훅은 항상 최상위에서 사용되어야 한다는 리액트의 생애주기에 위반하기 때문에 에러가 발생한다. 

component 내부에서는 커스텀 훅을 사용할 수 있다. 

### 메모이제이션 적용

성능 최적화가 꼭 필요한 도메인이거나 규모가 큰 컴포넌트의 경우 React.memo로 컴포넌트를 메모이제이션할 수 있다. 여기서도 Render와 Component의 차이점이 생긴다.

Render 방식은 **메모이제이션 불가능**하지만 Component 방식은 **가능하다.**
```ts
// Component는 React.memo로 최적화 가능
const YearDateCalendar = React.memo(() => {
  // 불필요한 리렌더링 방지
});

// Render 함수는 memo 적용 불가
const renderCalendar = () => <YearDateCalendar />; // memo 효과 없음
```

## 가독성 차이점

### 매개변수 
> Render 함수

render 함수를 사용하면 매개변수를 최소화 할 수 있다는 것이 장점이다. 보통 render 함수는 컴포넌트 내부에 로직 형태로 작성되기 때문에 컴포넌트에서 사용하고 있는 상태를 전역 변수처럼 사용이 가능하다. 그렇기 때문에 단순히 `render()` 처럼 사용할 수도 있다.

> Component 

이에 비해 컴포넌트의 경우에는 네이밍과 전달되는 props를 통해서 해당 컴포넌트가 무슨 역할을 수행하는지 간단하게 파악을 할 수 있다는 점이 다르다.

```tsx
const Parent = () => {
  const [date, setDate] = useState(new Date());
  
  // 같은 스코프의 date에 접근 가능
  const renderCalendar = () => {
    return <div>{date.toString()}</div>;  
  };
  
  // 하지만 Component는 명시적으로 props 전달 필요
  return <Calendar date={date} />;
};
```

## 결론

결론적으로, 대부분의 경우에 **컴포넌트로 분리해서 사용하는 것이 훨씬 더 좋다.** 

성능적인 문제는 크지 않다. 하지만 유지보수성과 가독성 그리고 사이드이펙트를 방지하기 위해서 컴포넌트 방식을 사용해야겠다고 생각했다.]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[Promise.all 이해하기]]></title>
            <link>https://shipfriend.dev/posts/promise-all-이해하기</link>
            <guid>https://shipfriend.dev/posts/promise-all-이해하기</guid>
            <pubDate>Fri, 29 Aug 2025 15:01:14 GMT</pubDate>
            <description><![CDATA[면접 복기]]></description>
            <content:encoded><![CDATA[# Promise.all 이해하기

최근에 면접을 보게 됐는데, 거기서 API 최적화 방안에 대한 질문이 나왔다. 알고는 있었지만 대비를 하지 못했던 질문이어서 엉뚱한 답변만 했던 것 같다.

<div>

![ㅏㅏㅏㅏㅏㅏㅏㅏㅏ](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1764748255513-Gemini_Generated_Image_r67rj0r67rj0r67r-X1RVG90z5DCWFZrc9a5ZhnnRaFkwKr.webp)

</div>

그래서 이번에 API 최적화 방안에 대한 질문의 대답을 준비하면서 Promise.all의 동작 방식에 대해서 공부해보면 좋겠다고 생각했다.

## Promise

Promise는 JS 프론트엔드를 하다보면 무조건 사용하게 되는 개념이다. 비동기 처리에 대한 상태를 가지고 있는 객체로, 비동기 작업의 대기, 완료, 실패 상태에 따른 처리를 할 수 있도록 만든 Wrapper 객체이다.

## Promise.all

프로미스에 .all 이 붙었다. 용어만 봤을 때 프로미스를 전체 실행한다는건가? 싶었다. 처음에 생각했던 것과 비슷하게 Promise.all은 여러 개의 비동기 작업을 동시에 병렬적으로 실행하고, 모든 작업이 완료될 때까지 기다렸다가 최종 결과를 한 번에 배열로 돌려주는 메서드이다.

- 모든 작업이 성공하면 각 Promise의 결과가 배열 형태로 한 번에 반환
- 한 개라도 실패하면, 첫 번째로 실패한 이유만 반환하고 나머지는 무시됨

```jsx
const promise1 = Promise.resolve(1);
const promise2 = Promise.resolve(2);
const promise3 = Promise.resolve(3);

Promise.all([promise1, promise2, promise3])
  .then((results) => {
    console.log(results); // [1, 2, 3]
  })
  .catch((error) => {
    console.error(error);
  });
```

## Promise.race와 Promise.all의 차이점

Promise.all은 모두 성공해야 반환하지만, race는 가장 먼저 완료되거나 실패한 프로미스의 값을 하나만 반환한다. 

- Promise.all은 전체를 기다림
- Promise.race는 가장 빠른 하나만을 기다림

race는 우리가 아는 레이스와 같은 개념으로 가장 빠르게 실행되는 결과만을 원할 때 사용한다. 언제 쓰이는지 쉽게 이해하기 위한 예제는 아래와 같다.

<aside>
💡

동일한 데이터를 여러 서버에 동시에 요청해서 가장 빠른 서버의 결과만을 사용하고 싶을 때

</aside>
 
## Promise의 다른 메서드들

Promise에는 all과 race 말고도 다른 메서드들이 있다.

- Promise.all
- Promise.race
- Promise.allSettled
- Promise.any
- Promise.resolve
- Promise.reject

allSettled는 모든 결과를 확인하는 것까지는 Promise.all과 똑같다. 차이점은 실패하는 것까지 다 기다린다는 점이다. Settled의 뜻이 안정된, 정착된의 뜻을 가지고 있는 것을 생각하면 완전히 종료될 때까지 기다리는 메서드라는 것을 알 수 있다.

any는 Promise.race와 비교할 수 있다. Promise.any는 가장 먼저 처리되는 결과를 반환하는 것까지는 똑같지만, 가장 먼저 **“성공”**하는 것만 기다린다. 성공 전에 실패가 나오더라도 무시하고, 다음 성공을 기다린다. 만약 모두 실패하는 경우에는 AggregateError를 반환한다.

## Promise.all로 어떤 최적화를 할 수 있을까?

Promise.all로는 어떤 일을 할 수 있을까. 일단 코드를 줄일 수 있는 것이 장점이라고 생각된다. 같은 레벨의 API 요청이라면 일일히 요청할 필요없이 한꺼번에 요청해서 코드도 줄이고 속도도 줄이는 것이 좋을 것 같다.

만약 순차적으로 요청해야하는 API라면? A라는 데이터를 불러오고, A를 payload로 하는 B API 요청을 보내기 위해서는 A API가 완수되기 까지를 기다려야한다. 이 경우에는 Promise.all은 사용하지 않아도 된다.

```jsx
// 순차 처리(비최적화)
const users = await fetchUsers();   // 300ms
const products = await fetchProducts(); // 200ms
const orders = await fetchOrders();    // 250ms
// 총 대기: 약 750ms
```

async / await은 순서가 보장되지 않는 비동기 작업들의 순서를 보장하기 위해서 사용하는 키워드들이다. 즉, 요청 함수 앞에 await을 붙이는 순간에는 그 아래 함수들은 요청이 끝나고 결과가 나오기 전까지는 실행되지 않는다.

그렇다면 같은 레벨이 아닌 함수를 위와 같이 3번 연속으로 동기적으로 실행하게되면 대기 시간이 길어지게 된다.

```jsx
// 병렬 처리(최적화)
const [users, products, orders] = await Promise.all([
  fetchUsers(),           // 300ms
  fetchProducts(),        // 200ms
  fetchOrders()           // 250ms
]);
// 총 대기: 약 300ms (가장 오래 걸리는 작업 기준)
```

위와 같이 최적화를 할 수 있다. 이 경우에는 병렬적으로 처리하기 때문에 가장 오래걸린 작업의 시간 만큼만 소요된다.

## 궁금한 점, 여러 요청 중 늦어지는 요청이 있을 경우에는 어떻게 되나

학습을 하다보니 궁금한 점이 생겼다. 만약 마이페이지에서 **유저 정보**와, **유저가 작성한 글 리스트**, **유저가 작성한 댓글 리스트**들을 렌더링한다고 할 때, 세 요청을 Promise.all로 묶어서 병렬로 요청한다.

여기서 두 요청은 10ms로 매우 빨리 받아지지만, 나머지 하나의 요청이 1000ms가 걸릴 경우, 두 요청에 대한 값으로 렌더링을 먼저 진행하고 늦어지는 값은 나중에 렌더링하도록 할 수 있는건가?

![흠](https://i.pinimg.com/1200x/67/c0/17/67c017853ae82f99399c0bae526d47de.jpg)

만약 Promise.all만 사용하면 가장 늦어지는 결과 값이 나올 때까지 나머지 값을 사용할 수 없다고 한다. 개별적으로 사용하고 싶을 경우에는 then이나 await을 사용해서 처리해야한다고 한다.

await을 사용하는 코드는 결국에 async를 붙여야하기 때문에 복잡해질 수 있어서 가장 보기 편한 코드는 아래처럼 then을 써서 예약해두는게 좋은 방식인 것 같다. 아래처럼 하면 가장 await을 함수 앞에 붙이지 않았기 때문에 3 요청 모두 병렬적으로(거의) 실행되지만, 값이 받아와지는대로 상태를 변경하고, UI를 렌더링 할 수 있다.

```jsx
const MyPage = () => {
  const [userInfo, setUserInfo] = useState(null);
  const [userPosts, setUserPosts] = useState(null);
  const [userComments, setUserComments] = useState(null);

  useEffect(() => {
    // 각각 독립적으로 처리
    fetchUserInfo().then(setUserInfo);
    fetchUserPosts().then(setUserPosts);
    fetchUserComments().then(setUserComments);
  }, []);

  return (
    <div>
      {userInfo ? <UserInfo data={userInfo} /> : <Skeleton />}
      {userPosts ? <PostList data={userPosts} /> : <Skeleton />}
      {userComments ? <CommentList data={userComments} /> : <Skeleton />}
    </div>
  );
};
```

## React Query와 SWR에서는..

위와 같은 병렬처리와 우선으로 처리되는 값에 대한 렌더링을 편하게 사용하기 위한 방법을 React-query와 SWR 같은 서버 상태 관리 라이브러리에서는 편리하게 지원한다.

### React-query

리액트 쿼리에서는 아래와 같이 useQueries 훅을 지원한다. useQuery를 여러번 써도 블락킹 되지 않아서 병렬적이라고 봐도 된다.

```jsx
const MyPage = () => {
  const queries = useQueries({
    queries: [
      { queryKey: ['user'], queryFn: fetchUserInfo },
      { queryKey: ['posts'], queryFn: fetchUserPosts },
      { queryKey: ['comments'], queryFn: fetchUserComments }
    ]
  });

  const [userQuery, postsQuery, commentsQuery] = queries;

  return (
    <div>
      {userQuery.isLoading ? <Skeleton /> : <UserInfo data={userQuery.data} />}
      {postsQuery.isLoading ? <Skeleton /> : <PostList data={postsQuery.data} />}
      {commentsQuery.isLoading ? <Skeleton /> : <CommentList data={commentsQuery.data} />}
    </div>
  );
};
```

지금하고 있는 프로젝트에서 한번 적용해봐야겠다.]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[웹 뷰(Web View)란?]]></title>
            <link>https://shipfriend.dev/posts/웹-뷰-web-view-란</link>
            <guid>https://shipfriend.dev/posts/웹-뷰-web-view-란</guid>
            <pubDate>Sun, 24 Aug 2025 07:42:44 GMT</pubDate>
            <content:encoded><![CDATA[# 웹 뷰(Web View)란?

웹 뷰(Web View)는 네이티브 애플리케이션(모바일 앱, 데스크탑 앱 등) 내에서 웹 콘텐츠(HTML, CSS, JavaScript로 만들어진 웹페이지)를 표시할 수 있게 해주는 컴포넌트이다. 쉽게 말해, 앱 안에 미니 브라우저를 내장하여 웹사이트나 웹 서비스를 그대로 보여주는 역할을 한다.

![썸네일](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1770084617155-Gemini_Generated_Image_m53bzfm53bzfm53b_clean-sJbQ0ee6ODVSY6iJiHAHzGMVLTNmm3.webp)

## 웹 뷰를 사용하는 이유

웹 뷰는 모바일이나 데스크탑 앱에서 웹페이지 화면을 표시하기 위해서 사용한다. 그렇다면 왜 사용하는 것일까? 

### 크로스 플랫폼 대응
리액트 네이티브나 플러터 같은 크로스 플랫폼 프레임워크를 통해서 앱을 만들게 되면 필연적으로 iOS에서만 혹은 안드로이드에서만 지원하는 기능을 만날 수 있다.

화면을 그리는 방식도 플랫폼 별로 다르기 때문에 일관적인 화면을 만들기 위해서 사용한다.

![메타몽](https://i2.ruliweb.com/ori/23/04/09/1876357be511a75f2.gif)

### 개발 효율성
네이티브 기능으로는 복잡하게 만들어야하는 어려운 화면도 쉽게 만들 수 있고 특히 CSS의 스타일링을 활용할 수 있기 때문에 같은 화면이라도 더 빠르게 만들 수 있다는 점이 장점이다.

### 유지보수
앱을 업데이트하지 않고 웹 컨텐츠만 바꾸면 바로 컨텐츠 변경이 가능하기 때문에 자주 변경되는 정보에서 웹 뷰를 자주 사용한다고 한다.

특히 이벤트 페이지나, 공지사항 등에서 자주 사용된다. 


## 프로젝트
최근에 Inforsion이라는 프로젝트를 진행 중이다. 처음엔 PWA로 진행하려고 했으나 리액트 네이티브를 경험해보고 싶어서 RN을 활용한 개발을 진행 중이다.

프로젝트에서 차트를 사용해야하는 기능이 있는데, 이 기능을 어떻게 구현할지 고민을 하다가 웹 뷰에 대해서 학습하게 되었다.

### RN 용 차트 라이브러리 사용 vs Rechart + 웹뷰
최종적으로 고민한 방안은 2가지였다. RN용 차트라이브러리를 사용하는 것과, Rechart라는 리액트 친화 차트 라이브러리와 웹뷰를 결합한 방법 두 가지이다.

> RN 용 라이브러리

RN용 라이브러리를 사용하는 것의 장점은 빠른 개발과 복잡성이 늘어나지 않는다는 점이 었다. 하지만 이 경우 가장 인기있는 Rechart 같은 라이브러리를 사용할 수 없다는 점이 단점이었다.

> 웹 뷰를 사용하는 것

웹 뷰를 사용하면 복잡도가 늘어난다. 앱 개발만 하는 것이 아닌 웹 개발도 같이 해야하기 때문이었다. 하지만 Rechart라는 라이브러리를 사용해볼 수 있다는 점과 웹 뷰 자체에 대한 경험을 쌓을 수 있다는 점이 큰 장점이라고 생각했다.

> 결론

웹 뷰와 Rechart 라이브러리 조합을 사용하기로 결정


Inforsion프로젝트를 개발하면서 생기는 문제 해결 경험들도 블로그에 시리즈를 하나 파서 만들어야겠다.

]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[티스토리 생산성 확장프로그램 - StoryHelper 사용 가이드]]></title>
            <link>https://shipfriend.dev/posts/티스토리-생산성-확장프로그램-storyhelper-사용-가이드</link>
            <guid>https://shipfriend.dev/posts/티스토리-생산성-확장프로그램-storyhelper-사용-가이드</guid>
            <pubDate>Wed, 13 Aug 2025 09:58:48 GMT</pubDate>
            <description><![CDATA[v1.6.0 패치노트]]></description>
            <content:encoded><![CDATA[# StoryHelper 사용 가이드

스토리헬퍼는 티스토리 블로거들을 위한 생산성, 편의성 향상 확장프로그램입니다. 이 가이드는 확장프로그램을 처음 사용하는 분들에게 어떻게 사용하는지 안내드리기 위해 작성되었습니다.

![스토리헬퍼 메인](https://lh3.googleusercontent.com/bBwaoPzK4OiAIYquCcOvg_PiAdQCx70AbsgF2cMl_GPKcx1zKJOGVUDe0EN4xFN36Zpt_ntvEc9yE5vtJEJcyZPw=s1280-w1280-h800)

## 🛠 주요 기능

각 기능들의 활성화와 단축키 지정 등의 부가 기능을 위해서는 확장프로그램을 클릭하면 보이는 팝업 창에서 설정할 수 있습니다.

이 스토리헬퍼의 주요 기능은 아래의 설명과 같습니다.

![팝업창](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1755077828236-storyhelper%20popup-r4LXa2Wp92nQq63NvRTa6wnJc7GDKD.png)

### ✨ 1. 단축키 기능

- 자주 사용하는 기능을 단축키로 빠르게 실행
- 커스텀 단축키 설정 가능
- 블로그 작성 시 효율성 대폭 향상

![단축키 설정](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1755078052283-%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-08-13%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%206.40.44-o2Q6aP2e9y01Rddt3NTKuigH2FGoRJ.png)

> **주의사항**
> 
> 단축키 조정 이후, 새로고침해야 변경된 단축키가 작동합니다. 또한 일부 브라우저 기본 단축키와 호환성 이슈로 작동이 안될 수 있습니다.

#### 단축키 적용 기능 목록
단축키를 설정할 수 있는 기능에는 아래 기능들이 있습니다. 모두 자주 사용되는 기능들로 원하는 단축키를 지정해 사용하면 생산성 향상에 도움이 됩니다.

- 글 발행 기능
- 이미지 업로드 기능
- 에디터 변환 기능 (HTML <-> 기본)
- 이전 글 링크 기능
- 서식 창 기능



### 🖼 2. SEO 이미지 최적화 도구

- 2가지 이미지 최적화 도구 제공
- 이미지 크기 일괄 조정, 이미지 alt 태그 일괄 입력 기능

본문 내부에 있는 모든 이미지에 대해 일괄적으로 크기를 지정하거나 대체텍스트를 작성할 수 있는 기능입니다. 티스토리 에디터의 상단 버튼 목록에 자연스럽게 추가되어있습니다.

![에디터 상단 도구](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1755078195302-%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-08-13%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%206.43.07-nQWZC6ZzAXWB3R7mbiewLIhYdPVaJF.png)

### 📊 3. 실시간 글자수 카운팅

- 실시간 글자수 확인 UI 제공
- 글 작성 중 언제든지 글자수 체크 가능
- 적정 글 길이 관리에 도움

![글자 수 카운터](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1755078259444-%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-08-13%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%206.44.09-UNJxuJcMDB81Urgv7kwRc6xRY6Jeu0.png)

### 🔍 4. SEO 검증 기능 (v1.5+)
1.5 버전 이후에 추가된 기능입니다. 기능이 보이지 않는다면 확장프로그램의 버전을 확인해주세요.

#### 기능 설명
티스토리 글쓰기에서는 검색엔진 최적화를 위한 글 구조화가 중요합니다. 이때, 내가 작성하고 있는 글이 구조화되어 있는지를 실시간으로 검증하는 기능입니다.

- 제목1(H1) 태그 검증: 글에 하나만 존재해야 함
- 이미지 대체 텍스트 검증: 모든 이미지에 alt 텍스트 필수

> **글 내부에 제목1(h1태그)는 하나만 있어야합니다. 아래 경우에서는 제목1이 제대로 작성되었기 때문에 우측 하단 검색엔진 최적화 성공 텍스트가 보입니다.**

![검증 완료](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1755078407409-%E1%84%80%E1%85%B3%E1%86%AF%20%E1%84%80%E1%85%AE%E1%84%8C%E1%85%A9%E1%84%92%E1%85%AA%20%E1%84%80%E1%85%A5%E1%86%B7%E1%84%8C%E1%85%B3%E1%86%BC%20%E1%84%90%E1%85%A9%E1%86%BC%E1%84%80%E1%85%AA-IejfhQojzxDqU71iwmbNHpeXUEZW6Z.png)

> **제목1은 하나만 있지만, 이미지의 크기가 auto이고 alt 태그가 작성되지 않았기 때문에 검색엔진 최적화가 되지 않았다고 보입니다.**

![검증 실패](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1755078408258-%E1%84%80%E1%85%B3%E1%86%AF%20%E1%84%80%E1%85%AE%E1%84%8C%E1%85%A9%E1%84%92%E1%85%AA%20%E1%84%80%E1%85%A5%E1%86%B7%E1%84%8C%E1%85%B3%E1%86%BC%20%E1%84%90%E1%85%A9%E1%86%BC%E1%84%80%E1%85%AA%20%E1%84%89%E1%85%B5%E1%86%AF%E1%84%91%E1%85%A2-PQmAXOUWYWfqd66Kqh8IgG9PQIUNTr.png)

---

## v1.6.0 패치노트

### 변경사항
- 확장프로그램 설정 창 > 크레딧 하단에 버전 표시 추가
- 현재 켜진 기능 시각 오버레이 추가 (좌측 하단)

![스토리헬퍼 시각화 오버레이](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1755078853581-%E1%84%89%E1%85%B5%E1%84%80%E1%85%A1%E1%86%A8%E1%84%92%E1%85%AA%20%E1%84%8B%E1%85%A9%E1%84%87%E1%85%A5%E1%84%85%E1%85%A6%E1%84%8B%E1%85%B5-N3dpyJJPz44SwKZMJ695X9J6y2q1Uh.png)

## 배포 링크

[StoryHelper 다운로드](https://chromewebstore.google.com/detail/storyhelper/inmbdknioncgblpeiiohmdihhidnjpfp?authuser=0&hl=ko)
]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[블로그에 유튜브 임베딩하기]]></title>
            <link>https://shipfriend.dev/posts/블로그에-유튜브-임베딩하기</link>
            <guid>https://shipfriend.dev/posts/블로그에-유튜브-임베딩하기</guid>
            <pubDate>Wed, 30 Jul 2025 11:21:36 GMT</pubDate>
            <description><![CDATA[rehypeRewrite 플러그인 함수 개발]]></description>
            <content:encoded><![CDATA[블로그 글을 쓰다보면 외부 링크를 작성하는 일이 있다. 예를 들어 프리뷰 네트워크 기반 동적 품질 조절 기능 관련 글에는 시연 영상이 링크되어있다.

티스토리 같은 블로그에서는 유튜브 링크를 걸 경우에 유튜브 영상이 embed 되는 것을 볼 수 있다. 하지만 내 블로그에서는 지원하지 않았기 때문에 유튜브 영상이 링크 되어있는 줄도 모르고 지나칠 가능성이 매우 높다고 생각했다. 그래서 유튜브 임베드 기능을 만들어야겠고 생각했다. 

![Youtube](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1764748478124-Gemini_Generated_Image_ktpdhyktpdhyktpd-iM9y8plsghyfgYxzicSlyAAD39tHxV.webp)


# 유튜브 임베드 도입하기

지금은 유튜브 링크를 걸어도 아래와 같이 링크임을 알 수 있도록 파란색 글씨인 것을 제외하고는 유튜브라는 것을 알 수 없는 상황이다.

![임베딩 구현 전](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1753874047261-%EC%9C%A0%ED%8A%9C%EB%B8%8C%20%EC%9E%84%EB%B2%A0%EB%93%9C%20%EC%A0%84-gZOEbR496AmHFrOsnoRecTPtjHvo5E.JPG)

일단 내 블로그는 마크다운 양식으로 어드민 페이지에서 작성해야한다. 그리고 작성된 글은 외부 라이브러리인 UIW 마크다운 라이브러리(@uiw/react-md-editor)를 통해 렌더링된다.

[npm @uiw/react-md-editor](https://www.npmjs.com/package/@uiw/react-md-editor)

내가 직접 구현한 것이 아닌 외부 라이브러리를 사용해서 글을 렌더링하는 것이었기 때문에 내가 원하는 방식으로 렌더링하기 쉽지 않다. 그래서 어떻게 해야할까 고민을 시작했다. 유튜브 링크가 있으면 → 그 다음 줄에 embed 동작을 하는 것에 대해 좀 생각하는 과정을 거쳤어야 했다.

> 유튜브 링크가 있는지 확인한다 → 동영상 Id를 가져온다. → 유튜브 embed iframe을 생성한다. 과정을 거쳐야 한다.
> 

다행히 UIW 라이브러리에는 rehypeRewrite 플러그인을 지원한다. 이 플러그인을 통해 마크다운에서 HTML로 변경하는 과정에서 사용자가 원하는 방식으로 렌더링 덮어씌우기가 가능하다.

```tsx
<MDEditor.Markdown
  style={{
    backgroundColor: 'var(--background)',
    color: 'var(--text-primary)',
  }}
  className={''}
  source={content}
  wrapperElement={{
    'data-color-mode': theme,
  }}
  rehypeRewrite={(node, index?, parent?) => {
    asideStyleRewrite(node);
    renderOpenGraph(node, index || 0, parent as Element | undefined);
    renderYoutubeEmbed(
      node,
      index || 0,
      parent as Element | undefined
    );
  }}
/>
```

이전에도 Notion의 콜아웃 스타일을 내 블로그에도 적용하기 위해 사용했었기 때문에 이 방법을 사용하기로 했다. 위 컴포넌트는 MDEditor의 마크다운을 HTML 형식으로 렌더링해주는 컴포넌트이다. rehypeRewrite prop에 각각의 덮어씌우기 플러그인들을 함수 형태로 분리해서 가독성을 높였다.

![왓](https://i.pinimg.com/736x/cf/d1/84/cfd1840c48c555efa6a548b2bde7bcc8.jpg)

## Youtube 링크 찾기

rewrite를 하기 위해서는 youtube 링크가 포함된 a 태그를 발견해야한다. 

### Rewrite 함수의 시그니쳐

```tsx
type RehypeRewriteFunction = (node: Element, index: number, parent: Element) => void;
```

위는 RehypeRewrite 플러그인에 들어가는 함수의 타입 시그니쳐이다. 마크다운에서 HTML의 DOM 트리로 변환하는 과정에서 node를 하나씩 거쳐가며 rehypeRewrite를 적용하는 방식이다.

아래 uiw의 공식 문서에 포함된 예제를 살펴보자.

```tsx
<MarkdownPreview
  source={source}
  style={{ padding: 16 }}
  rehypeRewrite={(node, index, parent) => {
    if (node.tagName === "a" && parent && /^h(1|2|3|4|5|6)/.test(parent.tagName)) {
      parent.children = parent.children.slice(1)
    }
  }}
/>
```

node의 tagName을 통해 필터링을 하고, 부모 요소의 태그가 헤딩 태그일 경우 부모 요소의 첫번째 자식 요소만 남겨두고 제거하는 코드이다.

a 태그를 찾기 위한 플러그인 함수를 만들어보자. 위 예제처럼 하면 링크 발견은 쉽게 할 수 있다. `if (node.tagName === "a")` 를 적용하면 되기 때문이다. **하지만..**

## 링크 발견까지는 했는데 어떻게 다음 줄에 embed하지?

링크를 발견할 경우 해당 a 태그의 parent는 p태그가 된다. 그렇다면 p 태그를 중심으로 요소를 추가해야 하는데 이 경우 다음 줄에 embed하기 어렵다고 판단했다.

그래서 a 태그를 갖는 p 태그를 찾기로 결정했다.

```tsx
const renderYoutubeEmbed = (node: any, index?: number, parent?: Element) => {
  if (node.type === 'element' && node.tagName === 'p' && node.children) {
    const aTag = node.children.find(
      (node: any) => node.type === 'element' && node.tagName === 'a'
    );
  }
}
```

이렇게 하면 parent는 문서 전체가 되고 요소의 index를 활용하여 a 태그 다음 줄에 iframe을 삽입할 수 있을 것이다.

## Youtube iframe 생성하기

구현한 플러그인의 전체 코드는 아래와 같다. youtube 링크인지 확인하고, createYoutubeIframe 함수를 통해 iframe 요소를 생성한다. 객체를 만드는 함수를 만들어서 분리하여 가독성을 높였다.

유튜브의 링크는 [youtu.be](http://youtu.be) 형식과 [youtube.com/watch](http://youtube.com/watch) 형식 두 가지가 있어서 두 가지 모두에 대응할 수 있도록 구현했다.

`renderYoutubeEmbed 함수의 전체`

```tsx
const renderYoutubeEmbed = (node: any, index?: number, parent?: Element) => {
  if (node.type === 'element' && node.tagName === 'p' && node.children) {
    const aTag = node.children.find(
      (node: any) => node.type === 'element' && node.tagName === 'a'
    );
    if (!aTag) return;

    const href = aTag.properties?.href;
    const isYoutubeLink =
      href &&
      (href.startsWith('https://www.youtube.com/watch') ||
        href.startsWith('https://youtu.be/'));

    if (isYoutubeLink) {
      const urlType = href.startsWith('https://www.youtube.com/watch')
        ? 'watch'
        : 'be';

      const videoId =
        urlType === 'watch'
          ? new URL(href).searchParams.get('v')
          : href.split('/').pop();

      if (videoId) {
        const youtubeEmbed = createYoutubeIframe(videoId, 736, 414);
        // 부모가 존재하고 children 배열이 있는 경우
        if (index && parent && parent.children) {
          // 현재 a 태그 다음 위치에 embed 삽입
          parent.children.splice(index + 1, 0, youtubeEmbed);
        } else return;
      }
    }
  }
};
```

`createYoutubeIframe 함수 코드`

```tsx
const createYoutubeIframe = (
  videoId: string,
  width: number,
  height: number
) => {
  return {
    tagName: 'iframe',
    properties: {
      src: `https://www.youtube.com/embed/${videoId}`,
      width: width,
      height: height,
      frameBorder: '0',
      allowFullScreen: true,
      className: 'youtube-embed',
    },
    children: [],
  };
};
```

위 코드를 통해서 유튜브 임베드를 렌더링하게 된다. 아래 부분 코드를 통해 부모 요소의 index 위치 다음에 유튜브 임베드 iframe을 삽입하는 방식이다.

```tsx
if (videoId) {
  const youtubeEmbed = createYoutubeIframe(videoId, 736, 414);
  // 부모가 존재하고 children 배열이 있는 경우
  if (
    index &&
    parent &&
    parent.children &&
    Array.isArray(parent.children)
  ) {
    parent.children.splice(index + 1, 0, youtubeEmbed);
  } else return;
}
```

이제 유튜브 링크가 존재하는 블럭 다음 줄에 이렇게 Youtube Embed Iframe이 생성되는 것을 볼 수 있다. 

[유튜브 링크](https://www.youtube.com/watch?v=SFA96Nvv0Yg)를 걸면 아래에 자동으로 embed되는 모습이다. 요즘 집중할 때 틀어놓는 앰비언스를 링크해봤다. ㅎㅎ

유튜브에는 시연 영상 위주로 올릴 것 같다.

![끝](https://i.pinimg.com/736x/fd/ff/3e/fdff3ecf99bcaad8a7e056ed98e8f059.jpg)]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[HOC 패턴으로 보호된 라우트 레이아웃 컴포넌트 구현]]></title>
            <link>https://shipfriend.dev/posts/hoc-패턴으로-보호된-라우트-레이아웃-컴포넌트-구현</link>
            <guid>https://shipfriend.dev/posts/hoc-패턴으로-보호된-라우트-레이아웃-컴포넌트-구현</guid>
            <pubDate>Sat, 26 Jul 2025 06:56:48 GMT</pubDate>
            <description><![CDATA[사용자 검증 로직을 추상화하고 재사용해보자]]></description>
            <content:encoded><![CDATA[# HOC 패턴으로 보호된 라우트 구현

반복되는 인가된 사용자 검증 로직을 추상화하고 재사용해보자

![프리뷰 썸네일](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1751001073266-%EC%A0%9C%EB%AA%A9%EC%9D%84%20%EC%9E%85%EB%A0%A5%ED%95%B4%EC%A3%BC%EC%84%B8%EC%9A%94_-001-6KGw7ZU1aviWmZ8zslVvAPNoh6kGeC.png)

## 문제 상황

사용자의 인증 여부가 필요한 페이지들은 늘어나지만, 항상 같은 로직을 사용하는 코드가 있었다. 바로 인증 여부를 검증하는 로직이다. 사용자가 로그인한 상태라면 허용하고 아니라면 거부하는 로직이 중복되고 있었기 때문에 이를 분리하고 재사용할 방법을 찾아본다.

## 고민한 점

어떻게 코드를 분리할 수 있을까? 단순히 커스텀 훅을 사용하는 방법도 있을 것 같다. 

예를 들어 `useAuthCheck` 훅을 만들어, `isLoggedIn` 상태를 가져와 여부에 따른 toast 메시지 처리 등을 하나의 훅으로 만들어 분리해볼 수 있을 것이다.

하지만 더 깔끔한 방법을 시도해보고 싶었다. HOC 패턴을 사용해서.

HOC 패턴은 쉽게 이야기하면 컴포넌트를 감싸는 컴포넌트, Nextjs에서의 layout.tsx와 같은 역할을 하는 패턴이다. HOC패턴은 보통 아래와 같은 형식이다. 핵심은 children을 받아서 그대로 렌더링하고, 핵심 로직의 중복을 분리하는 것이다.

```tsx
const HOCPattern = ({children:React.ReactNode}) => {
	return <div>{children}</div>
}

const Page = () => {
	return <HOCPattern>
		<div>
			<h1>안녕하세요</h1>
		</div>
	</HOCPattern>
}
```

## 주요 기능

아래는 우리 서비스에서 인증 검증 로직을 분리한 코드이다. 인가 여부 검증을 하는 코드를 하나의 레이아웃 컴포넌트에 분리되어있으며, 이제 재사용하기만 하면 된다.

```tsx
import { useEffect } from "react";
import useAuth from "@hooks/useAuth.ts";
import useToast from "@hooks/useToast.ts";
import { useLocation, useNavigate } from "react-router-dom";

interface ProtectedRouteLayoutProps {
  children: React.ReactNode;
}

const ProtectedRouteLayout = ({ children }: ProtectedRouteLayoutProps) => {
  const { isLoggedIn } = useAuth();
  const toast = useToast();
  const location = useLocation();
  const navigate = useNavigate();

  useEffect(() => {
    if (!isLoggedIn) {
      toast.error("로그인이 필요한 서비스입니다.");
      navigate("/login", { state: { from: location }, replace: true });
    }
  }, [isLoggedIn]);

  return <>{children}</>;
};

export default ProtectedRouteLayout;
```

재사용하는 예시는 아래와 같다. ProtectedRouteLayout을 사용하여 감싸기만 하면 된다. 이제 CreateQuestionPage는 인증해야 접근 가능한 페이지가 된다. 

```tsx
  {
    element: (
      <ProtectedRouteLayout>
        <CreateQuestionPage />
      </ProtectedRouteLayout>
    ),
    path: "/questions/create",
  },
  {
    element: <ErrorPage />,
    path: "/*",
  },
```

이전에 계속 반복되는 로직을 분리했기 때문에 개발 생산성이 크게 올랐다.]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[비디오 컴포넌트 렌더링 최적화]]></title>
            <link>https://shipfriend.dev/posts/webrtc-공감-기능-개발하기</link>
            <guid>https://shipfriend.dev/posts/webrtc-공감-기능-개발하기</guid>
            <pubDate>Mon, 30 Jun 2025 14:06:09 GMT</pubDate>
            <description><![CDATA[with WebRTC 공감 기능 개발하기 ]]></description>
            <content:encoded><![CDATA[# 공감 기능 👍

![썸네일](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1751001073266-%EC%A0%9C%EB%AA%A9%EC%9D%84%20%EC%9E%85%EB%A0%A5%ED%95%B4%EC%A3%BC%EC%84%B8%EC%9A%94_-001-6KGw7ZU1aviWmZ8zslVvAPNoh6kGeC.png)

**❓공감 기능**

하단 푸터 툴바에는 리액션 버튼을 구현할 예정이다. 해당 버튼을 누르면 모든 사용자의 화면에서 리액션을 누른 사용자의 비디오 컴포넌트에 리액션을 누른 것을 표시하는 기능이다.

## 구현 방식 👾

소켓 방식으로 구현할 예정이다. 간단하게 시그널링 서버를 중개해서 `reaction` 이라는 이벤트를 만들어서 해당 이벤트를 클라이언트에서 emit하고 그 이벤트를 시그널링 서버 단에서 세션 내부의 모든 사용자에게 전파하는 방식으로 동작한다.

## 고민 💭

1. 모든 사용자에게 전파했을때 해당 리액션의 발신자가 누구인가?
    - 처음 emit 한 사용자의 소켓 id를 senderId로 해서 서버에서 추가한 뒤에 재전파
2. 모든 사용자가 전파받은 리액션을 띄울 때 어떻게 할 것인지?
    1. 고민을 많이한 부분
    2. PeerConnection을 배열 형태로 관리하는 상태에 reaction 속성을 추가하고 VideoContainer의 Props로 전달해주도록 수정
    3. 

## 첫 번째 방식 🪓

SessionPage에서 관리하는 PeerConnection 객체에 reaction 정보를 추가한다.

```tsx
interface PeerConnection {
  peerId: string; // 연결된 상대의 ID
  peerNickname: string; // 상대의 닉네임
  stream: MediaStream; // 상대방의 비디오/오디오 스트림
  reaction?: string;
}
```

그리고 서버에서 리액션 이벤트가 올 때마다 해당 PeerConnection 객체의 reaction 속성을 업데이트한다. 그리고 3초 뒤에 리액션을 지우기 위해서 timeout을 걸어둔다.

```tsx
socket.on(
  "reaction",
  ({ senderId, reaction }: { senderId: string; reaction: string }) => {
    if (senderId === socket.id) {
      setReaction(reaction);
      const timeout = setTimeout(() => setReaction(""), 3000);
    } else {
      addReaction(senderId, reaction);
      const timeout = setTimeout(() => {
        addReaction(senderId, "");
      }, 3000);
    }
  }
);
```

### 발생한 문제

공감 기능은 예상했던대로 작동했다. 모든 사용자에게 뿌린 리액션 이벤트를 기반으로 리액션을 날린 사용자 컴포넌트 **우측 상단에 리액션을 띄우도록 구현**했다.

하지만, 리액션 객체를 수정할 때마다 화면이 깜빡거리면서 리렌더링이 되는 문제가 발생했다.

- 본인이 리액션을 했을 때, 본인을 제외한 피어 컴포넌트가 리렌더링되면서 깜빡임
- 타인이 리액션을 했을 때, 본인을 제외한 타 피어 컴포넌트가 리렌더링되면서 깜빡임

![Nov-11-2024 18-15-55.gif](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1751292332764-Nov-11-2024%2018-15-55-CWCdjkI7yveyxtgwIyqfTTEBaihfAW.gif)

### 원인

리액션 이벤트가 발생하면 본인이 누른 리액션이라면 setReaction 상태를 변경시키고, 다른 피어가 누른 리액션이라면 peers 상태를 조작하기 때문에 VideoContainer 컴포넌트가 리렌더링되기 때문이다. 

> 기존 코드


```tsx
{
  peers.map((peer) => (
    <VideoContainer
      key={peer.peerId}
      ref={(el) => {
        // 비디오 엘리먼트가 있고, 스트림이 있을 때
        if (el && peer.stream) {
          el.srcObject = peer.stream;
        }
        peerVideoRefs.current[peer.peerId] = el;
      }}
      nickname={peer.peerNickname}
      isMicOn={true}
      isVideoOn={true}
      isLocal={false}
      reaction={peer.reaction || ""}
    />
  ))
}
```

> 개선 코드


```tsx
{useMemo(
  () =>
    // 상대방의 비디오 표시
    peers.map((peer) => (
      <VideoContainer
        key={peer.peerId}
        ref={(el) => {
          // 비디오 엘리먼트가 있고, 스트림이 있을 때
          if (el && peer.stream) {
            el.srcObject = peer.stream;
          }
          peerVideoRefs.current[peer.peerId] = el;
        }}
        nickname={peer.peerNickname}
        isMicOn={true}
        isVideoOn={true}
        isLocal={false}
        reaction={peer.reaction || ""}
      />
    )),
  [peers]
)}
```

개선된 코드에서는 peers와 관련된 변경사항일 때만 다른 피어들의 비디오 컴포넌트를 리렌더링하도록 수정했다. useMemo를 사용해서 peers 배열이 변경될 경우에 리렌더링을 트리거한다.

> 성과

이로 인해서 본인 리액션, 마이크 음소거, 비디오 끄기 버튼을 눌러도 다른 사람의 비디오 컴포넌트는 리렌더링이 일어나지 않게 되어 깜빡임 현상이 없어졌다.

**하지만, 본인이 누른 리액션이 다른 사람의 환경에서는 peer의 변경사항이다. 따라서 다른 사람의 화면에서는 리액션을 누른 사람의 화면이 깜빡인다.**

---

## 아직 깜빡이는 문제 원인이 무엇일까? 😓

useMemo를 최적화를 해도 결국 peers 정보가 바뀌면 리렌더링이 되는 상황이었다. reaction 값만 바뀐다면 실제 DOM에는 reaction과 관련된 부분만 리렌더링이 되기 때문에 전체가 깜빡이는 현상이 일어나지 않아야한다.

그렇다면 VideoContainer에 ref를 전달하는게 원인일까? 코드를 다시 읽어보면서 ref가 문제일까라고 의심을 해보기 시작했다. 그리고 VideoContainer에서 ref를 함수 형태로 전달하고 있는 것을 찾아냈다.

> 기존 코드
> 

기존 VideoContainer에는 미디어 스트림을 담은 ref 객체를 전달하고 있다. 그리고 전달되는 ref는 고정된 값이 아닌 함수 형태로 되어있기 때문에 매 렌더링마다 다른 참조를 가지게 된다. (매렌더링마다 함수가 새로 생성)

그래서 의도한 바는 reaction 값만 바뀌는 것이었지만 항상 ref가 같이 생성되고 있었기에 전체 컴포넌트가 리렌더링되었던 것이었다.

```tsx
<VideoContainer
	ref={(el) => {
	  // 비디오 엘리먼트가 있고, 스트림이 있을 때
	  if (el && peer.stream) {
	    el.srcObject = peer.stream;
	  }
	  peerVideoRefs.current[peer.peerId] = el;
	}}
/>
```

> 개선 코드
> 

단순히 ref 값을 개선하기 보다는 전체적인 개선을 해보는 것이 좋겠다고 생각했다.

왜냐하면 ref값을 Props Drilling으로 전달하기에는 번거로운 작업이 껴있었다. 바로 `forwardRef`로 컴포넌트를 감싸줘야하는 것인데, 이 경우 상위 컴포넌트와 하위 컴포넌트 간의 결합도를 증가시킨다.

결합도 뿐만 아니라 가독성 측면에서도 함수로 감싸는 것이기 때문에 좋지 않다고 판단했다.

그래서 다른 webrtc 예제 코드를 살펴보던 도중 https://codesandbox.io/p/sandbox/react-webrtc-video-chat-b649f 해당 예제 코드를 보게 되었다.

해당 예제에서는 비디오 컴포넌트 내부에서도 `stream을 송출하는 목적` 만을 가지는 컴포넌트를 가지고 있었다. 이 컴포넌트에서 영감을 얻어 stream을 넣은 ref를 전달하는 것이 아닌 stream을 전달해서 하위 컴포넌트에서 ref를 만들어서 사용하는 방식으로 변경하기로 했다.

```tsx
<DisplayMediaStream mediaStream={stream} isLocal={isLocal} />
```

```tsx
interface DisplayMediaStreamProps {
  mediaStream: MediaStream | null;
  isLocal: boolean;
}

const DisplayMediaStream = ({
  mediaStream,
  isLocal,
}: DisplayMediaStreamProps) => {
  const videoRef = useRef<HTMLVideoElement | null>(null);

  useEffect(() => {
    if (mediaStream !== null && videoRef.current) {
      videoRef.current.srcObject = mediaStream;
    } else if (videoRef.current && mediaStream === null) {
      // videoRef.current.srcObject.getTracks().forEach((t) => t.stop());
      videoRef.current.srcObject = null;
    }
  }, [mediaStream]);

  return (
    <video
      autoPlay
      playsInline
      muted={isLocal}
      className="w-full"
      stream={stream}
    />
  );
};

export default DisplayMediaStream;

```

`DisplayMediaStream` 컴포넌트에서는 스트림을 매개변수로 받아서 송출하는 역할만을 수행한다.

> 결과
> 

개선 후에는 비디오 컴포넌트가 리렌더링되지 않아 깜빡임 현상이 해결된 것을 볼 수 있다.

![Nov-11-2024 19-58-10.gif](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1751292330551-Nov-11-2024%2019-58-10-9WVAlsOP4d1r6QCWujngEJBH3uO53S.gif)]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[카메라 인디케이터 라이트 항상 표시되는 오류 해결하기]]></title>
            <link>https://shipfriend.dev/posts/카메라-인디케이터-라이트-항상-표시되는-오류-해결하기</link>
            <guid>https://shipfriend.dev/posts/카메라-인디케이터-라이트-항상-표시되는-오류-해결하기</guid>
            <pubDate>Fri, 27 Jun 2025 05:02:10 GMT</pubDate>
            <description><![CDATA[사용자 프라이버시 개선]]></description>
            <content:encoded><![CDATA[# 카메라 인디케이터 라이트 항상 표시되는 오류

> 이 글은 노션에서 마이그레이션된 글입니다.

![섬네일](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1751001073266-%EC%A0%9C%EB%AA%A9%EC%9D%84%20%EC%9E%85%EB%A0%A5%ED%95%B4%EC%A3%BC%EC%84%B8%EC%9A%94_-001-6KGw7ZU1aviWmZ8zslVvAPNoh6kGeC.png)

**이슈 링크 :** https://github.com/boostcampwm-2024/web27-Preview/issues/111

## 🚩 문제 상황

카메라를 끄는 버튼은 이미 구현이 되어있다. 해당 버튼을 누르면 videoTrack의 `enabled` 속성을 false / true로 토글하여 비디오를 끄고 켤 수 있다.

그러나 문제는 `enabled`이 false여서 비디오가 꺼졌다고 하더라도, 실질적으로 비디오 트랙의 픽셀을 모두 검은색으로 채운 빈 프레임을 보낸다고 한다.

실질적인 내용이 없지만 결국에 보내지고 있기 때문에 운영체제, 브라우저는 카메라를 사용 중인 것으로 판단하고 인디케이터를 작동시키는 것이다.

***사용자 입장에서는 비디오를 껐음에도 상단에 카메라를 사용 중이라는 인디케이터가 켜져있으면 불안할 것 같다고 생각이 들었다.*** 


## 💬 고민

- 그렇다면 어떻게 **인디케이터를 비활성화하는** 비디오 끄기를 지원할까?
- `enabled` 속성을 건드리는 것이 아닌 아예 `track.stop()`을 해버리면 인디케이터는 즉시 종료된다.
- 문제는 stop 후 restart 같은 메서드가 없다는 점이다.
- 즉, 비디오를 끄고 나서 다시 키려면 **스트림을 다시 받아와야한다**는 점이다.

## ✨ 해결

### 첫 번째 시도: useState

처음에는 getMedia 함수를 리팩토링해서 options 매개변수를 받도록 수정했다. options은 선택적인 매개변수로 있다면 오디오나 비디오 혹은 모든 미디어 장치로부터 스트림을 얻어오는 함수로 만들었다.

하지만 간과하고 있던 문제가 있었다. 바로 stream을 상태로 관리한다는 점이다.

```tsx
const [stream, setStream] = useState();
```

`stream`은 객체이고 스트림에는 각각 트랙들이 존재한다. 비디오 트랙, 오디오 트랙 정도가 있다. 

비디오를 끄고 나서 다시 키려고하면 비디오 트랙을 불러와서 해당 stream의 내부 트랙에 넣어줘야한다. 그렇지만 stream은 상태로 존재하기 때문에 setStream으로 아예 새로운 객체를 넣어줄 수 밖에 없게 된다. 

이를 통해 `Stream` 객체는 `useState`로 관리하는 것이 아닌 `useRef`로 관리하기로 결정했다. 


**📌 useState 대신 useRef를 사용하기로 한 이유**

- stream 객체의 내부 옵션을 사용해서 내부 객체를 변경하고 싶음
- stream을 useState로 관리하게 되면 setStream으로 값을 지정해주는 것이 리액트가 권장하는 방식이다.

```tsx
const Component = () => {
  const [stream, setStream] = useState(); 
  
	const handle = (newTrack) => {
		// 스트림에 트랙을 추가하는 것
		stream.addTrack(newTrack)
	}  
	
	handle();

  useEffect(() => {
    console.log('stream가 실제로 변경됨');
  }, [stream]);  
};
```

- 위 코드는 stream에 addTrack을 했을때 useEffect 내부의 코드가 실행되는 것을 의도했지만, stream을 직접 수정하기 때문에 실행되지 않는다.
    - 직접 수정은 리액트의 렌더링 사이클을 우회하는 것이므로 권장되지 않는 방식이다.
    - 객체의 내부의 값을 변경해도 객체 자체의 참조가 변경되지 않으므로 useEffect는 실행되지 않는다.
    - 이런 사이드이펙트들이 직접 수정을 사용하면 안되는 이유를 가르쳐주고 있다.

**그래서 stream 객체를 useRef로 관리하기로 결정했다.**

- useRef로 할 경우 불필요한 리렌더링이 방지된다.
- 미디어 스트림 객체 자체는 UI 렌더링에 직접적인 영향을 주지 않는다.
- 무엇보다 useRef로 할 경우 streamRef.current로 조작이 가능하기 때문에 적합하다고 판단했다.
</aside>

### 두 번째 시도: useRef

useRef를 사용하기로 결정했다. 리팩토링에는 오랜 시간이 걸리지 않았다. `useMediaDevices` 훅에서 미디어 스트림과 장치에 대한 책임을 가지고 있도록 했기 때문이었다.

훅 내부에서는 useRef를 사용해 ref 객체를 생성하고 기존에 사용하던 stream 상태는 제거했다. `useMediaDevices`에서는 ref가 아닌 ref가 가진 mediastream 객체를 내보내도록해서 외부에서는 ref 객체에 접근할 수 없게 만들었다.

그결과 useRef를 통한 stream은 되는 것을 확인했다. **하지만 가장 큰 문제**가 하나 있었는데 ref 객체로 만들어졌기 때문에 **mediastream이 생기기 전에 UI 렌더링이 모두 끝났고, 이후 리렌더링이 안되는 문제**였다.

객체가 값을 가지고 있긴 했지만 리렌더링이 일어나지 않은 것이었기 때문에 비디오 토글 버튼을 통해 리렌더링을 트리거해야 스트림이 UI에 반영되는 일이 생겼다.

![image.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1751000476775-111-XoYkvJmNYD5scvDN8uzPrADwQ9IdBF.png)
 

**📌 그럼 useRef를 사용하면 미디어 스트림 반영이 안되는 것일까?**

한참을 고민 끝에 타협점을 찾았다. useState와 useRef를 모두 쓰는 것이다.

실제로 외부에 노출되는 것은 useState로 선언한 스트림 상태가 될 것이고, useRef로 선언한 객체는 내부 값을 변경하기 위해서 사용할 것이다. 

```tsx
const [stream, setStream] = useState();
const streamRef = useRef();

// 지양해야하는 방식
stream.addTrack();

// 설계한 방식
streamRef.current.addTrack();
setStream(streamRef.current)
```

이렇게 하면 상태를 직접 조작하는 방식을 쓰지 않아도 되고, 불변성을 지킬 수 있기 때문에 좋은 방법이라고 생각했다.

 
### 세 번째 시도: useState와 useRef 모두 사용

`useRef` 와 `useState` 를 모두 사용해서 Hook 외부로 공개하는 것은 state로 만든 stream 객체가 될 것이다.

useRef로 만든 ref 객체를 통해 stream 객체 내부 트랙 정보를 수정할 것이고, 수정된 객체를 setStream을 통해 상태를 변경할 계획이다.

이렇게 해서 기대하는 바는 객체 내부를 사이드 이펙트 걱정 없이 수정하고, 수정 완료된 객체를 상태로 업데이트하는 것이다.

```tsx
// 미디어 스트림 토글 관련
const handleVideoToggle = async () => {
  try {
    // 비디오 껐다키기
    if (stream) {
      for (const videoTrack of stream.getVideoTracks()) {
        if (videoTrack.enabled) {
          // 비디오 끄기
          videoTrack.stop();
          videoTrack.enabled = false;
        } else {
          // 새로운 비디오 스트림 생성
          const videoStream = await getMediaStream("video");
          if (videoStream) {
            setVideoLoading(true);
            streamRef.current?.getVideoTracks().forEach((track) => {
              streamRef.current?.removeTrack(track);
            });
            videoStream?.getVideoTracks().forEach((track) => {
              streamRef.current?.addTrack(track);
              console.log("새로운 비디오 트랙을 추가했습니다.");
            });
          } else {
            console.error("비디오 스트림을 생성하지 못했습니다.");
          }
          setStream(streamRef.current);
        }
      }
    }
    setIsVideoOn((prev) => !prev);
  } catch (error) {
    console.error("Error stopping video stream", error);
  }
};
```

위 코드는 비디오를 끄고 켜는 동작을 하는 함수인데, 비디오를 끄고 나서 다시 킬때는 새로운 비디오 트랙을 가져와서 기존 스트림에 추가해주는 방식을 사용하고 있다.

## 🚀 결과

세 번째 시도 끝에 **카메라 인디케이터를 비활성화하고 다시켤때 스트림을 다시 얻어오는 작업**을 끝냈다. 

비디오를 끄고 켜는데 이전보다 시간이 오래걸리는 것은 어쩔 수 없는 부분인 것 같다. 이전에는 단순히 비활성화를 했다면 이번에는 **스트림을 새로 생성하기 때문에 약간의 지연시간**이 생겼다.

이 부분은 로딩 상태를 추가해서 미디어 스트림이 로딩되기 전에 로딩 현황을 알려주도록 구현해야겠다고 생각했다.

**`뿌듯하다.`**

![카메라인디케이터해결.gif](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1751000456101-222-vtx2otwcKtSR9b4uh3zITc0uhXeu5O.gif)]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[Access Token 만료시 일관적인 401 에러 응답 처리]]></title>
            <link>https://shipfriend.dev/posts/access-token-만료시-일관적인-401-에러-응답-처리</link>
            <guid>https://shipfriend.dev/posts/access-token-만료시-일관적인-401-에러-응답-처리</guid>
            <pubDate>Fri, 27 Jun 2025 04:59:34 GMT</pubDate>
            <description><![CDATA[with Axios Interceptor]]></description>
            <content:encoded><![CDATA[# Access Token 만료시 일관적인 401 에러 응답 처리

> 이 글은 노션에서 마이그레이션된 글입니다.

![섬네일](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1751001073266-%EC%A0%9C%EB%AA%A9%EC%9D%84%20%EC%9E%85%EB%A0%A5%ED%95%B4%EC%A3%BC%EC%84%B8%EC%9A%94_-001-6KGw7ZU1aviWmZ8zslVvAPNoh6kGeC.png)

## 배경 및 목적

애플리케이션에서 보호된 리소스에 접근할 때 발생하는 401 인증 에러에 대한 일관된 처리 방식이 필요했다. 특히 액세스 토큰 만료 시 리프레시 토큰을 통한 재발급 과정과 이에 따른 요청 재시도 로직을 효율적으로 구현하고자 했다.

## 해결 과제

기존 방식에서는 다음과 같은 복잡한 프로세스가 필요했다:

1. 보호된 리소스 접근 요청
2. 401 에러 발생 시 리프레시 토큰으로 새 액세스 토큰 발급 요청
3. 원래 요청 재시도

이러한 다중 요청 처리는 마치 데이터베이스의 트랜잭션과 같은 복잡성을 가지고 있어 효율적인 해결책이 필요했다.

## 구현 방안

Axios 인스턴스의 인터셉터 기능을 활용하여 모든 401 에러에 대한 일관된 처리 로직을 구현했다. 주요 처리 절차는 다음과 같다:

1. 401 에러가 발생한 원본 요청을 저장
2. 리프레시 토큰을 사용하여 새로운 액세스 토큰 발급 요청
3. 토큰 재발급 성공 시 원본 요청 재시도
4. 실패 시 세션 만료로 판단하고 로그인 페이지로 리다이렉트

### axios interceptor

```tsx
import axios, { AxiosError } from "axios";
import { refreshAccessToken } from "@/api/user/auth.ts";
import useAuthStore from "@stores/useAuthStore.ts";

export const api = axios.create({
  timeout: 5000,
  withCredentials: true,
});

api.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config;

    if (error.response?.status === 401) {
      try {
        const response = await refreshAccessToken();
        if (response.data.success) {
          return axios(originalRequest!);
        }
      } catch (error) {
        console.error("토큰 재발급 실패", error);
        alert("세션이 만료되었습니다. 다시 로그인해주세요.");
        useAuthStore.persist.clearStorage();
        window.location.href = "/login";
        return Promise.reject(error);
      }
    }
    return Promise.reject(error);
  }
);

```

## 개선 효과

1. 사용자 경험 향상
    - 토큰 만료 시에도 서비스 이용의 연속성 보장
    - 최소한의 지연으로 자동 토큰 재발급 처리
    - 세션 만료 시 명확한 안내와 함께 로그인 페이지로 유도
2. 개발 효율성 증대
    - 401 에러 처리 로직의 중앙화로 코드 중복 제거
    - 일관된 에러 처리로 유지보수성 향상
    - 개별 컴포넌트에서의 토큰 관리 부담 감소

## 유연성 확보

특정 요청에 대해 다른 방식의 401 에러 처리가 필요한 경우, Axios 인스턴스 대신 일반 Axios를 사용하면 되어 유연하게 응답 처리를 할 수 있다는 점이 좋았다.

## 결론

Axios 인터셉터를 활용한 401 에러 처리 방식은 복잡한 토큰 갱신 프로세스를 효율적으로 처리하며, 사용자 경험과 코드 품질을 동시에 개선하는 효과적인 해결책이 되었다.]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[WebRTC DataChannel로 실시간 미디어 상태 공유하기]]></title>
            <link>https://shipfriend.dev/posts/webrtc-datachannel로-실시간-미디어-상태-공유하기</link>
            <guid>https://shipfriend.dev/posts/webrtc-datachannel로-실시간-미디어-상태-공유하기</guid>
            <pubDate>Fri, 27 Jun 2025 04:58:01 GMT</pubDate>
            <description><![CDATA[서버 경유 없이 데이터 주고 받기]]></description>
            <content:encoded><![CDATA[# WebRTC DataChannel로 실시간 미디어 상태 공유하기

> 이 글은 노션에서 마이그레이션된 글입니다.

![섬네일](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1751001073266-%EC%A0%9C%EB%AA%A9%EC%9D%84%20%EC%9E%85%EB%A0%A5%ED%95%B4%EC%A3%BC%EC%84%B8%EC%9A%94_-001-6KGw7ZU1aviWmZ8zslVvAPNoh6kGeC.png)

## DataChannel이란?

DataChannel은 WebRTC의 기능 중 하나로 수립된 RTCPeerConnection 사이에 DataChannel을 열고 해당 채널을 통해 서로 임의의 데이터를 주고받을 수 있게 해주는 기능이다.

### **공부하는 이유**

WebRTC 화상회의를 구현했지만, 유저가 비디오를 끄거나 마이크를 끄고 켜는 것에 대한 상태 공유가 이루어지지 않고 있었다.

내가 마이크를 꺼도 상대방은 내가 마이크를 끈건지 말을 안하고 있는건지 분간이 안갈 수 있다고 생각되어 이런 기능을 필수로 구현해야겠다고 생각했다.

### **DataChannel의 주요 특징**

- 저지연 데이터 전송
- 신뢰성 있는 전송 모드 지원 (reliable/unreliable)
- 양방향 통신
- 다양한 데이터 타입 전송 가능 (문자열, 바이너리 등)

### createDataChannel

데이터 채널을 생성하는 함수로, PeerConnection의 메서드이다.

```tsx
const channel = pc.createDataChannel('채널의 라벨', {
	여긴 옵션이 들어간다.
});
```

## 구현하기

dataChannel은 P2P 연결 간에 생성된다. 하지만 비동기 메서드이기 때문에 생성 후 바로 메시지를 보낼 수 없다. state가 open 인 경우에만 메시지를 보낼 수 있다. 그렇기 때문에 열자마자 바로 보내는 것이 안된다.

이를 해결하기 위해서는 peerConnection의 연결 생성과 종료를 위임받은 커스텀 훅에 아래 데이터 채널을 저장하는 ref 객체를 만들었다. 그리고 피어들의 미디어 상태를 저장할 수 있는 useState 상태를 만들어서 각 피어마다 미디어 상태를 저장하도록 했다.

```tsx
const dataChannels = useRef<{ [peerId: string]: RTCDataChannel }>({});
const [peerMediaStatus, setPeerMediaStatus] = useState<{
  [peerId: string]: {
    audio: boolean;
    video: boolean;
  };
}>({});
```

그리고 화상 회의에 다른 유저가 참가하여 PeerConnection을 생성할 때 데이터채널을 열고 dataChannel ref 객체에 저장한다.

```tsx
const mediaDataChannel = pc.createDataChannel("media-status", {
  ordered: true,
});

mediaDataChannel.onopen = () => {
  dataChannels.current[peerSocketId] = mediaDataChannel;
};

mediaDataChannel.onclose = () => {
  console.log("Media data channel closed.");
};

// DataChannel 해보자
pc.ondatachannel = (event) => {
  const channel = event.channel;
  channel.onmessage = (e) => {
    const data = JSON.parse(e.data);
    console.log(data);
    const { type, status } = data;
    if (type === "audio") {
      if (status) {
        console.log("상대방의 오디오가 켜졌습니다.");
        setPeerMediaStatus((prev) => ({
          ...prev,
          [peerSocketId]: {
            audio: true,
            video: prev[peerSocketId].video ?? true,
          },
        }));
      } else {
        console.log("상대방의 오디오가 꺼졌습니다.");
        setPeerMediaStatus((prev) => ({
          ...prev,
          [peerSocketId]: {
            audio: false,
            video: prev[peerSocketId].video ?? true,
          },
        }));
      }
    } else if (type === "video") {
      // 상대방의 오디오가 켜졌을 때의 처리
      if (status) {
        console.log("상대방의 비디오가 켜졌습니다.");
        setPeerMediaStatus((prev) => ({
          ...prev,
          [peerSocketId]: {
            audio: prev[peerSocketId].audio ?? true,
            video: true,
          },
        }));
      } else {
        console.log("상대방의 비디오가 꺼졌습니다.");
        setPeerMediaStatus((prev) => ({
          ...prev,
          [peerSocketId]: {
            audio: prev[peerSocketId].audio ?? true,
            video: false,
          },
        }));
      }
    }
  };
};
```

이렇게 데이터 채널을 생성한 뒤에 아래 코드로 피어 간 존재하는 모든 데이터채널에 메시지를 보내는 함수를 만들어서 사용했다. 이렇게 하면 위 코드에서 onmessage 이벤트로 수신된 메시지를 처리할 수 있다.

```tsx
const sendMessageToDataChannels = (message: DataChannelMessage) => {
    Object.values(dataChannels.current).forEach((channel) => {
      channel.send(JSON.stringify(message));
    });
  };
  
sendMessageToDataChannels({
  type: "video",
  status: true,
});
```

메시지를 통해 업데이트된 peerMediaStatus는 피어들의 비디오 컴포넌트를 렌더링할 때 사용되어 실시간으로 마이크나 비디오의 상태를 서버를 경유하지 않고 공유할 수 있게 되었다!

## 결과

WebRTC 데이터채널을 활용하여 화상 스터디를 개선하기, 한번 RTC PeerConnection이 수립되면 DataChannel을 통해 서버 경유 없이도 피어 간 직접 통신이 가능한 점을 활용했다. 이를 통해 팀원들의 비디오/마이크 상태 변경을 실시간으로 공유할 수 있게 되었다!

![미디어온오프 상태공유.gif](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1751000269431-%EB%AF%B8%EB%94%94%EC%96%B4%EC%98%A8%EC%98%A4%ED%94%84%20%EC%83%81%ED%83%9C%EA%B3%B5%EC%9C%A0-uo6PVOHvQIVrCOYlGdeA66y45SmFzG.gif)]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[개발, 배포 환경의 Lighthouse 성능 점수 차이 왜 생기는 걸까?]]></title>
            <link>https://shipfriend.dev/posts/development-production-환경의-lighthouse-성능-점수-차이-왜-생기는-걸까</link>
            <guid>https://shipfriend.dev/posts/development-production-환경의-lighthouse-성능-점수-차이-왜-생기는-걸까</guid>
            <pubDate>Sun, 22 Jun 2025 05:44:45 GMT</pubDate>
            <description><![CDATA[빌드시 최적화 이해]]></description>
            <content:encoded><![CDATA[# Development, Production 환경의 Lighthouse 성능 점수 차이 왜 생기는 걸까?

프론트엔드 개발을 하면 성능 측정을 하게 된다. 개발 환경과 배포 환경에서 Lighthouse 도구를 사용해 각각 측정했다. 점수가 생각보다 크게 차이가 나는 것을 발견했다.

![썸네일](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1769647866388-Gemini_Generated_Image_jmw55rjmw55rjmw5_clean-qAdblMc2Mdp1Jz4bZjaZSSvvWToQ7p.webp)

## 개발 환경과 배포 환경이란

웹 개발을 하다보면 개발 환경과 배포 환경을 모두 경험하게 된다. 개발 환경은 실제 개발을 하면서 잦은 디버깅과 빠른 개발을 위한 설정을 지원한다. 반면 배포 환경은 개발 후 만들어진 결과물은 최대한 최적화해야 하는 환경이다.

## 주요 차이점

개발 환경과 프로덕션 환경은 꽤 명확하고 많은 차이점이 존재한다. 

### 개발환경

#### HMR (Hot Module Replacement) 

개발환경은 빠른 개발을 위해 저장 후 바로 변경사항이 적용되는 HMR(Hot Module Replacement) 기술을 지원한다. 

원리는 Replacement라는 단어가 있듯 모듈 전체를 리로드하지 않고, 애플리케이션이 실행되는 동안 교환하는 식으로 추가 or 제거하는 방식이다. React의 리렌더링처럼 변경된 부분만 다시 렌더링되는 느낌이라고 생각하면 쉽다.

vite 같은 번들러들이 이런 기술을 사용해서 개발 환경에서 빠른 빌드를 지원할 수 있게 되었다.

#### 소스맵 (Source Map)

소스맵은 디버깅을 위한 기능이다. 원본 코드 매핑 정보를 담고 있다. 우리가 작성하는 타입스크립트와 리액트 코드들은 모두 브라우저가 읽을 수 있는 자바스크립트로 변환 과정을 거친다.

```ts
// 원본 TypeScript 코드 (app.tsx 15번째 줄)
const handleClick = (): void => {
  throw new Error("버튼 클릭 에러!");
};

// 빌드된 JavaScript 코드 (bundle.js 2847번째 줄)
const n=()=>{throw new Error("버튼 클릭 에러!")};
```

이 경우 원래 내가 작성한 코드가 변하기도 하고, 소스 라인의 수 조차 달라진다. 만약 특정 코드에서 오류를 발생시키고 있다고 가정할 때, 브라우저에서 실행되는 오류 코드의 위치와 실제 내가 작성한 코드의 위치가 불일치 할 가능성이 생긴다.

이를 해결하기 위해 개발 환경에서는 소스맵이라는 매핑 정보를 관리한다. 소스맵이 있다면 브라우저에서 디버깅을 할 수 있게 되는 것이다.

#### 개발용 React

React로 개발을 하다보면 한 번쯤 경험해보는 문제가 있다. 바로 React의 Strict Mode이다. 이건 개발할 때 불필요한 요청이나 렌더링 등을 빠르게 캐치하기 위한 목적으로 만들어져있다.

주요 특징으로는 콘솔 로그가 두 번 찍힌다는 특징이 있다. 그 외에도 추가적인 경고 로직, 검증 로직 등이 개발환경에서만 지원된다.

#### 최적화되지 않은 번들

이 글에서 가장 중요한 내용이다. 개발 환경에서는 번들이 최적화되어있지 않다. 즉, 압축이나 트리 쉐이킹 같은 최적화 기법이 사용되지 않은 환경이라는 뜻이다.

### 프로덕션 환경 (배포 환경)

개발 환경에서는 주로 디버깅에 치중되어있는 모습을 볼 수 있었다. 그렇다면 성능이 중요시되는 배포 환경에서는 어떨까?

#### 코드 압축

성능이 중요하기 때문에 띄어쓰기 한 칸 조차 아끼는 모습을 볼 수 있다.

```ts
// 압축 전 (개발 환경)
function handleUserAuthentication(username, password) {
  if (!username || !password) {
    throw new Error('Username and password are required');
  }
  return authenticateUser(username, password);
}

// 압축 후 (프로덕션)
function h(u,p){if(!u||!p)throw Error('Username and password are required');return a(u,p)}
```

위 코드처럼 아예 코드를 읽기 힘들게 만드는 코드 난독화 기술도 적용할 수 있다.

#### **✨트리 쉐이킹**

개인적으로 가장 중요하다고 생각되는 부분이다. 트리 쉐이킹은 말 그대로 나무를 흔드는 작업이다. 

개발 환경에서는 사용하지 않는 코드도 모두 가지고 있지만, 배포 환경에서는 쓰지 않는 코드, 함수, 컴포넌트 등을 모두 제거한다.

```ts
// 트리 쉐이킹 예시 추가
import { Button, Modal, Tooltip, DatePicker } from 'antd';
// 실제로는 Button만 사용

// 개발 환경: 모든 컴포넌트 로드
// 프로덕션: Button만 번들에 포함
```

이렇게 하면 개발 환경에서는 편의성과 디버깅 용이성을, 배포 환경에서는 성능을 모두 챙길 수 있게 된다.

여담으로 최근에 학교 과제로 유니티 게임을 만들어야 할 일이 있었는데, 욕심으로 3D 게임을 만들기로 했다. 이때, 에셋 포함 프로젝트 파일이 8GB나 되었었다.. (초저퀄 주제에..) 

하지만 빌드 후 게임 파일은 400MB 밖에 되지 않았다. 사용하지 않는 에셋 등과 빌드 최적화로 95%나 없어진 것이다. 웹에서도 마찬가지라고 생각이 들었다.

#### 번들러 최적화

React 생태계의 번들러는 크게 Webpack, Vite 두가지가 떠오른다. 

두 번들러 모두 빌드 과정에서 최적화를 수행하지만 내부적으로 사용하는 기술들이 달라진다. 두 번들러의 차이점에 대해서는 다루지 않지만 모두 코드 스플리팅, 트리 쉐이킹, 코드 압축 등이 최적화를 수행한다.

## 확인해보자
개발 환경에서 블로그 디테일 페이지의 Lighthouse 점수이다. 보다시피 성능 점수가 50점 아래로 떨어져있는 것을 볼 수 있다.
![개발환경](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1750523089302-%EC%8A%A4%ED%81%AC%EB%A6%B0%EC%83%B7%202025-06-21%20230421-hTlEklPeK9GZGC0k009pWn8IicJJQw.png)

측정항목도 Total Blocking Time이 1250 ms가 넘어가는 모습이다.
![개발환경 측정항목](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1750569520212-%EC%8A%A4%ED%81%AC%EB%A6%B0%EC%83%B7%202025-06-22%20141401-zz64XRMFN4dvwbgtuXiDFikhUwkGJ7.png)

다음은 배포 환경이다. 아직 개선해야 할 것들이 있지만, 전반적으로 성능 점수가 25점 이상 올랐고, 권장사항 점수도 채워진 걸 볼 수 있다.
![배포환경](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1750523093815-%EC%8A%A4%ED%81%AC%EB%A6%B0%EC%83%B7%202025-06-22%20012352-YdawiHm9Nb98bBRiC3tZJNlVjjC8jg.png)

개발환경과는 다르게 Total Blocking Time이 크게 줄었고, Speed Index 등 다른 지표들도 같이 감소한 모습이 눈에 띈다.
![배포환경 측정항목](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1750569538447-%EC%8A%A4%ED%81%AC%EB%A6%B0%EC%83%B7%202025-06-22%20141516-lCyk20CHTZbJYHYf3yimLWDRpMAYLQ.png)


### 트리맵 비교
위처럼 트리맵도 비교해보자. 트리맵은 웹사이트를 구성하는 JavaScript 번들의 크기를 시각적으로 보여주는 목적으로 사용된다.

큰 사각형일 수록 차지하는 용량을 의미해서 쉽게 확인할 수 있는 구조로 되어있다.

먼저 개발환경 부터 보자, 가장 먼저 눈에 띄는 점은 쪼개지지 않은 사각형과 오른쪽 상단의 44Mb라는 용량이다. 

![개발환경 트리맵](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1750569674743-%EB%B3%80%ED%99%98%EB%90%9C_%EA%B0%9C%EB%B0%9C%ED%99%98%EA%B2%BD%ED%8A%B8%EB%A6%AC%EB%A7%B5-Rkn430rlJV0MhNj36VKQkM6rjtWyn8.webp)

다음은 배포환경의 트리맵이다. 개발환경과는 달리 여러개의 사각형으로 쪼개져있고, 무엇보다 용량의 크기가 2Mb 수준으로 매우 작다는 점이다.

![배포환경 트리맵](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1750569671371-%EB%B3%80%ED%99%98%EB%90%9C_%EB%B0%B0%ED%8F%AC%ED%99%98%EA%B2%BD%20%ED%8A%B8%EB%A6%AC%EB%A7%B5-8ewgx8CHUaDknZovcRGJ3Fna3lEaqs.webp)

- 파일 크기 최적화로 전체 번들이 44Mb 수준에서 2Mb 수준으로 약 94% 감소했다.
- 코드 스플리팅 최적화로, 기존 코드가 더 작은 여러개의 청크로 분할되어 필요할 때만 로드되도록 만들어졌다.

## 마무리

지금까지 살펴본 개발 환경과 배포 환경의 구조적 차이로 인해 localhost 개발 환경에서 성능 측정을 했을 때와 배포 환경의 차이를 알아볼 수 있었다.

Vite 같은 번들러의 도움으로 빌드 설정을 크게 고민해보지 않았던 것 같다는 생각이 들었다. 빌드 과정에서 일어나는 최적화를 공부하면 프론트엔드에 대해서 더 이해할 수 있지 않을까 하는 생각이 든다.

이번 글을 통해 개발 환경에서의 성능 측정이 얼마나 
실제와 다를 수 있는지 확인할 수 있었다.

#### **핵심 포인트:**
- 성능 측정은 반드시 프로덕션 환경에서
- 트리 쉐이킹의 놀라운 최적화 효과 (94% 크기 감소)
- 로컬에서도 `npm run build && npm start`로 확인 가능
]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[Schema hasn't been registered for model 문제 해결하기]]></title>
            <link>https://shipfriend.dev/posts/schema-hasn-t-been-registered-for-model-문제-해결하기</link>
            <guid>https://shipfriend.dev/posts/schema-hasn-t-been-registered-for-model-문제-해결하기</guid>
            <pubDate>Sun, 01 Jun 2025 10:42:48 GMT</pubDate>
            <description><![CDATA[mongodb populate 문제]]></description>
            <content:encoded><![CDATA[
# Schema hasn't been registered for model 문제 해결하기

## 문제
```text
Schema hasn't been registered for model \"Post\".\nUse mongoose.model(name, schema)
```
이번에 시리즈 디테일 페이지를 개발하면서 생긴 문제의 에러 메시지이다. 

### 문제 코드
현재 블로그의 시리즈에서 시리즈 디테일 페이지(/series/블로그-개발기)로 이동할 경우 시리즈의 디테일과 속한 포스트 목록을 보여주는 페이지가 나온다.

로컬에서는 잘 작동했는데 Vercel 배포 버전에서는 오류가 발생했다.

![오류 화면](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1748773847538-Screenshot%202025-06-01%20at%2019.30.12-LlVbtWDQsnV4HJdUj4FF7lkPK33l6s.JPG)

스키마와 Post 이야기가 있기 때문에 DB Model과 관련이 있겠구나 생각했다. 하지만 두 스키마 정의 코드에는 문제가 없었다.

```ts
posts: [{ type: Schema.Types.ObjectId, ref: 'Post' }],
```

이런 식으로 ref 정의도 잘 되어있었고, 모델 이름도 틀리지 않게 작성했기 때문이다.

### 실제 문제
실제 문제는 아래 코드에서 발생했다. 얼핏 보면 문제가 없어보이지만 문제는 Populate 하는 부분에서 발생했다.

populate는 ObjectId를 기준으로 해당 모델의 데이터를 치환해주는 역할을 수행한다. 아래 코드에서는 Post 모델을 불러와서 검색한 결과를 치환해주는 작용을 한다.

하지만 코드에서는 Post 모델을 불러오고 있지 않았다. 

```ts
import { NextResponse } from 'next/server';
import dbConnect from '@/app/lib/dbConnect';
import Series from '@/app/models/Series';

export async function GET(
  request: Request,
  { params }: { params: { slug: string } }
) {
  try {
    await dbConnect();

    const series = await Series.findOne({ slug: params.slug }).populate({
      path: 'posts',
      options: { sort: { date: 1 } },
    });

    if (!series) {
      return NextResponse.json(
        { error: '해당 시리즈를 찾을 수 없습니다.' },
        { status: 404 }
      );
    }

    return NextResponse.json(series, {
      status: 200,
      headers: {
        'Cache-Control': 'public, max-age=60, s-maxage=60',
      },
    });
  } catch (error: any) {
    return NextResponse.json(
      { error: error.message || '시리즈 조회에 실패했습니다.' },
      { status: 500 }
    );
  }
}
```

## 문제 해결
위 코드에 아래 한 줄을 추가하는 것으로 해결이 됐다.
```ts
import '@/app/models/Post';
```

`import Post from 'xxx'` 대신 `import '@/app/models/Post'` 를 사용한 이유는 import Post 문을 사용할 경우 트리쉐이킹이 적용되는지 배포판에서는 적용이 되지 않았다.

그래서 바로 import 해주는 방식으로 작성하고 나서 문제는 해결되었다.

## 결과
시리즈 디테일 페이지를 만들었다. 원래는 검색 쿼리로 시리즈 내부 포스트 필터링만 했었는데 벨로그처럼 시리즈 페이지가 있으면 좋을 것 같아서 만들어 본 기능이다.
![시리즈 디테일 페이지 모습](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1748774379959-449727201-25d14078-8734-44a4-8943-aa7a2f70951f-2TQ0krT75BQxuBitziytuNPYHgtIbm.png)

]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[티스토리 블로그 확장프로그램 StoryHelper 개발 회고]]></title>
            <link>https://shipfriend.dev/posts/티스토리-블로그-확장프로그램-storyhelper-개발-회고</link>
            <guid>https://shipfriend.dev/posts/티스토리-블로그-확장프로그램-storyhelper-개발-회고</guid>
            <pubDate>Sun, 25 May 2025 13:00:29 GMT</pubDate>
            <description><![CDATA[내가 쓰기 위한 프로그램에서 크롬 웹스토어 등록까지]]></description>
            <content:encoded><![CDATA[내가 쓰기 위한 프로그램에서 크롬 웹스토어 등록까지 

# 티스토리 블로그 확장프로그램 StoryHelper 개발 회고

![스토리헬퍼 메인 이미지](https://lh3.googleusercontent.com/bBwaoPzK4OiAIYquCcOvg_PiAdQCx70AbsgF2cMl_GPKcx1zKJOGVUDe0EN4xFN36Zpt_ntvEc9yE5vtJEJcyZPw=s1280-w1280-h800)

[티스토리 확장프로그램 스토리헬퍼 웹스토어](https://chromewebstore.google.com/detail/storyhelper/inmbdknioncgblpeiiohmdihhidnjpfp?authuser=0&hl=ko)

이 프로그램은 원래 내가 쓰려고 만든 프로그램이었다. 다 만들고 나니 배포도 해보고 싶은 욕심이 생겨 5달러를 내고 웹스토에도 배포했다.

생각했던 것보다 많은 사람이 쓰게 되어 놀랐고, 최근에 신규 기능을 업데이트하고나서 기록으로 남겨두고 싶어 글을 작성하게 되었다.

![](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1748174807578-%EC%8A%A4%ED%86%A0%EB%A6%AC%ED%97%AC%ED%8D%BC-A3fX11mR0vabn77uumxM3ONKCFKPjW.JPG)

주요 기능은 티스토리 블로그를 작성하면서 자동화하고 싶었던 기능들이 주를 이룬다.

## SEO 검색엔진 최적화

티스토리 블로그를 떠나 다른 웹페이지에서 SEO(검색엔진 최적화)는 매우 중요하다. 최적화가 잘 된 글이 검색엔진에게 좋은 점수를 받고, 검색 상위 노출이 되는 구조이기 때문이다.

그렇기에 블로그를 쓰는 사람들에게 SEO는 중요하다. SEO를 위해서는 크게 2가지로 나눌 수 있다.

1. 개발적인 부분
2. 비개발적인 부분

### 개발적인 부분
개발적인 부분의 의미는 코딩과 관련이 있다. 예를 들어 글에 대한 메타데이터가 있을 수 있다. 이 메타데이터는 티스토리나 벨로그에서는 글 작성자가 커스텀할 수 없는 부분이다. 물론 자동으로 해주기 때문에 글 작성자는 크게 신경쓰지 않아도 되는 부분이 된다.

### 비개발적인 부분
내 확장프로그램은 이 비개발적인 부분의 최적화를 도와주는 프로그램이다.

비 개발적이라는 것은 `구조화된 글`과 `대체 텍스트 작성` 등이 있다. 두 요소 모두 글 작성자가 임의로 작성할 수 있다는 점이 차이점이라고 생각한다.

## 기능 소개

기능은 크게 5가지가 있다. 이미지 크기 전체 조절, alt 태그 전체 작성, 글자 수 카운터, 자주 쓰는 기능 단축키 추가, SEO 검증 기능이 있다.

![기능 소개](https://lh3.googleusercontent.com/_PgEeVYy_klVb9gwDSyrlCMuwu_5TeaFdiSG9WilW2iQcjCEo5mzrcxi02o1kqkJuQgSghowzoKhTbv1sZNdvOeDEA=s1280-w1280-h800)
### 모든 이미지 크기 조절  

첫 번째로는 모든 이미지 크기 조절이다. 왜 필요한지 의문이 들 수 있는데 이 기능을 만든 이유는 티스토리의 이미지 사이즈 조절 방식이다.

티스토리에서는 이미지를 업로드하면 `original-width`, `original-height`가 속성으로 들어간다. 하지만 실제 렌더링되는 것을 결정하는 `width`와 `height` 속성은 누락되어있다.

렌더링되면 auto 속성이 자동으로 들어가기 때문에 로딩 전에는 빈공간을 차지하다가 이미지 로딩이 끝나면 크기가 늘어나는 방식이다.

이 방식은 CLS 수치를 크게 낮출 수 있기 때문에 이 기능을 만들었다.

### alt 태그 전체 작성 
이것도 SEO 측면에서 대체 텍스트인 alt 속성이 없으면 안되기 때문에 만든 기능이다. 모든 이미지에 alt 태그를 일괄적으로 작성할 수 있다.

### 단축키 추가
자주 쓰는 기능들을 단축키화 하는 기능이다. 예를 들어서 에디터 전환, 자주쓰는 서식, 이전 글 링크, 이미지 첨부, 글 발행 등 단축키로 할 수 있는 범위를 늘려주는 기능이고, 사실상 핵심이다.

이 기능이 좋은 점은 단축키를 커스텀 할 수 있도록 만든 점이다.

### SEO 검증 기능 (v1.5 업데이트)
이 기능은 위에서 소개한 이미지 고정크기, 이미지 대체텍스트, 제목1 여부 등의 SEO를 지키기 위한 조건들이 잘 작성되어있는지 검사하는 기능이다.

## 어려웠던 점
### DOM 분석
티스토리 에디터의 구조를 파악해야 했던 시간이 있었다. 티스토리는 내부에 있는 에디터가 iframe으로 구현되어있다. document안에 document가 있는 구조라는 것을 개발하면서 알게 되었다.

그리고 최적화 도구 2개를 추가하는 과정에서도 기존 UI와 비슷한 경험을 유지하기 위해 같은 classname을 사용한다거나, 바로 옆에 dom 메서드로 추가할 필요가 있었다.

---

## 우수 확장 프로그램 뱃지
우수 확장프로그램 뱃지를 받았다! 이런게 있는 줄 몰랐는데 구글 크롬의 개발 정책을 준수했나보다.

![우수 확장프로그램 설명](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1748331932590-%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-05-27%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%204.45.19-13kQ74mJn190niMVwnq1GotqqGeKJh.png)

## 후기
토이 프로젝트격이지만 실제로 사용하기 위한 프로그램을 개발했다는 점에서 재미있고 뜻 깊은 경험을 했다고 생각한다.

처음 만들고 6개월 동안은 30명 아래의 사용자만 있었는데 점점 늘어나더니 이제는 100명이 넘게 쓴다는게 너무 신기하다.
]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[SSG 렌더링으로 Vercel 콜드스타트 문제 해결하기]]></title>
            <link>https://shipfriend.dev/posts/직접-해보면서-깨닫는-ssr과-ssg-차이</link>
            <guid>https://shipfriend.dev/posts/직접-해보면서-깨닫는-ssr과-ssg-차이</guid>
            <pubDate>Fri, 02 May 2025 06:07:32 GMT</pubDate>
            <description><![CDATA[직접 해보면서 깨닫는 SSR과 SSG 차이]]></description>
            <content:encoded><![CDATA[# 직접 해보면서 깨닫는 SSR과 SSG 차이
![썸네일](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1746171871123-ssg-BsFlinEdGixDqgliaRBbM0wTPDZWii.webp)
## 문제 상황

현재 내 블로그는 NextJS로 구현되어있다. NextJS를 선택한 이유는 SEO를 최적화하기 간편하기 때문이었고, 실제로 SSR의 장점인 서버 컴포넌트를 사용해서 블로그 글을 렌더링하고 있었다. 

최근 친구의 피드백으로 사이트가 느리다는 피드백을 받았다. 사이트가 성능적으로 문제될 것은 없었는데도 느리다는 것을 깨달았다. 

알아본 결과 Vercel로 배포할 경우, 그리고 Hobby(무료) 요금제일 경우 웹 사이트가 사용되지 않으면 콜드 스타트 상태가 된다는 걸 알게되었다.

### Cold Start
콜드 스타트는 직역하면 차가운 시작이라는 뜻이다. 실제로 기계&자동차 공학에서 유래된 말로 자동차가 예열되지 않은(차가운) 상태에서 시동을 켜려고 하면 더 많은 시간과 에너지를 필요로 하는 상황을 말한다.

컴퓨터 공학에서는 절전 모드에서 다시 켜는 것이라고 생각하면 될 것 같다. vercel은 그렇게 동작하기 때문에 자주 사이트에 접속하지 않으면 처음 서버에서 데이터를 가져오는 시간이 길어질 수 있다는 것을 알게 되었다.

## 콜드스타트 해결하기

콜드 스타트 문제를 해결하기 위한 방법도 여러가지가 있다. 

무식한 방법으로는 콜드 스타트 상태로 들어가지 못하게 지속적으로 서버에 ping 메시지를 날리는 방법이 있었다. 

하지만 이 방법은 뭔가 편법을 쓰는 느낌에 문제를 해결했다는 느낌이 안들어서 고려하지 않았다.

다음 방법으로는 SSG 방식을 사용하는 것이었다. 이전에는 SSR과 SSG의 차이 정도만 알고 있었는데 직접 사용해보는 것은 처음이었다. 

SSG를 사용하면 더 빠른 속도로 로딩이 가능하고, 검색엔진 최적화에도 더 유리하다는 것을 알게 되어 이 방법으로 해결해보기로 결정했다.

## SSG (Static Site Generation)
단어 그대로 정적으로 사이트를 생성하는 방식을 의미한다. 이 방법을 사용하면 빌드 시점에 posts url들이 정적으로 생성되어 배포된다. 

이 방법을 사용하면 기존에 정적인 페이지들 ('/', '/portfolio') 처럼 동적인 페이지들을 렌더링하기 때문에 응답 속도가 매우 빨라진다는 특징이 있다. 그렇기 때문에 Vercel의 백엔드를 가동하는 시간을 필요로 하지 않다는 점이 특징이다.

그 외에도 콜드스타트로 인한 검색엔진 크롤러의 타임아웃 현상을 줄일 수 있다는 점도 장점이다. 

**SSR과의 차이점**
SSR은 서버 사이드 렌더링으로, 서버에서 생성해서 유저에게 전달한다는 점이고, SSG는 빌드 시점에서 생성된 HTML을 정적으로 서빙한다는 점에서 약간의 차이점이 있다. 

생성 시점으로 차이를 두면 될 것 같다. 

- SSG: 빌드 시 페이지 생성 (build time)
- SSR: 요청 시 페이지 생성 (request time)


### NextJS에서 사용하기

NextJS에서는 정말 쉽게 SSG를 사용할 수 있었다. App Router 기준 `generateStaticParams()` 를 사용해서 만들 수 있다.

기존 코드에 아래 코드만 추가하면 SSG를 적용할 수 있다. 빌드 시점에 생성하는 것이다보니 NextJS에게 "~~ 여기 경로를 SSG로 적용해주세요."라고 알리는 목적이다.

```ts
// 정적 생성할 경로 지정
export async function generateStaticParams() {
  await dbConnect();
  const posts = await Post.find({}, 'slug').lean();

  return posts.map((post) => ({
    slug: post.slug,
  }));
}
```

## 빌드 로그 확인
빌드하게 되면 아래와 같은 빌드 로그를 얻을 수 있다. route 옆에 직관적인 기호로 어떤 방식이 적용되었는지 보여준다.

잘보면 /posts/[slug]에 색이 칠해져있는 것으로 SSG가 적용되었음을 알 수 있다!

```plain
├ ƒ /portfolio/[slug]                    11.9 kB         136 kB
├ ○ /posts                               7.96 kB         137 kB
├ ● /posts/[slug]                        390 kB          518 kB
├   ├ /posts/next-js로-나만의-블로그-만들기
├   ├ /posts/블로그-cls-성능-최적화하기
├   ├ /posts/내가-만드는-블로그로-알아보는-seo-최적화
├   └ [+13 more paths]
├ ○ /robots.txt                          0 B                0 B
├ ○ /rss                                 0 B                0 B
├ ○ /series                              2.36 kB         127 kB
└ ○ /sitemap.xml                         0 B                0 B
+ First Load JS shared by all            87.7 kB
  ├ chunks/7023-0674f41d3955597f.js      31.6 kB
  ├ chunks/fd9d1056-d060727c397bec1b.js  53.6 kB
  └ other shared chunks (total)          2.49 kB
○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses getStaticProps)
ƒ  (Dynamic)  server-rendered on demand
```

빌드 시점에 정적인 페이지로 바꿔주는 과정이 필요하기 때문에, 글이 많아지면 필연적으로 빌드 시간이 길어진다고 한다.

## 결과

얼마나 개선이 되었는지를 확인하기 위해 SSG 도입 전 빌드 파일과 도입 후 빌드 파일 간의 로딩 속도를 확인해보자.

### 개선 전
개선 전 데이터로는 1.75초가 걸린다. 로컬 환경에서 production 빌드를 실행시킨 것이기 때문에 성능상으로는 배포환경과 같지만, 다른 점이라면 vercel의 콜드 스타트로 인해 아래 1.75초에 오버헤드가 더 붙는다는 점이다.

![개선 전](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1754902454120-%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-08-11%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%205.53.50-HTsLGSz3Q3G0u4a3eisdLeKnOWu0Aq.png)

### 개선 후 

개선 후 데이터를 보자. 122ms로 거의 15분의 1이 된 모습이다. 확실한 비교를 위해 캐시 없이 비교했다.

![개선 후](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1754902448416-%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-08-11%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%205.52.25-cCRlAXBpoufcjPV5iBhBa6ZoE7Kwk3.png)



이제 SSG로 서빙이 되기 때문에 이전의 SSR 방식보다 더 빠르게 페이지가 로딩이 되는 것을 확인할 수 있다. 백엔드를 거치지 않기 때문에 콜드 스타트 문제도 없다.


---
<aside>
🤔

빌드 시점이라면, 빌드 후 새로운 글을 작성하면 어떻게 되는 것일까? 새로 작성한 글은 이전 처럼 SSR 방식으로 렌더링되고, 기존 글은 SSG로 렌더링 되는 것일까?
</aside>

답은 **SSG로 빌드된 사이트에 새 콘텐츠를 추가하면 그 콘텐츠는 다음 빌드 시점까지 사이트에 반영되지 않는다.**

그렇지만 NextJS에서는 이런 문제를 해결하기 위해 ISR, On Demanding Revalidation 등의 기능을 지원한다.

이론적으로 SSG만 설정하고 별도의 ISR이나 On-demanding 설정을 하지 않는다면 새로운 컨텐츠에 대한 정적 페이지는 생성이 되지 않아야 한다.

*그러나 새로운 글을 발행했을 때 SSG로 문서가 생성되는 것을 확인했다. 찾아보고 여러가지로 생각해본 결과 **Vercel에서 자동으로 ISR을 지원**해주는 것이라고 결론을 지었다.*

왜냐하면 새로운 글에 대해서는 HTML이 생성되었지만(새로운 route가 생겼으므로), 이미 생성된 글에 대한 수정 사항이 반영이 되지 않는 것을 보면 명시적으로 ISR 설정을 안했기 때문에 반영이 안된다고 판단했다.


이걸 해결하기 위해 ISR을 도입하는 것을 고려해야겠다.
]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[FingerPrintJS로 조회수와 좋아요 기능 만들기]]></title>
            <link>https://shipfriend.dev/posts/fingerprintjs로-조회수와-좋아요-기능-만들기</link>
            <guid>https://shipfriend.dev/posts/fingerprintjs로-조회수와-좋아요-기능-만들기</guid>
            <pubDate>Sat, 19 Apr 2025 08:03:46 GMT</pubDate>
            <description><![CDATA[쿠키 대체? FingerprintJS]]></description>
            <content:encoded><![CDATA[# 조회수 좋아요 기능 구현하기

조회수와 좋아요 기능은 블로그에 있어서 쉽게 볼 수 있는 요소이다. 티스토리도 공감이라는 이름으로 존재하고, 벨로그도 이 기능이 있다. 벨로그의 경우에는 내가 좋아요한 포스트로 모아볼 수 있어야하기 때문에 유저 인증이 필수적인 서비스이다.

하지만 나는 일개 개인 블로그이기 때문에 내 블로그에 좋아요 누르려고 회원가입을 하는 사람은 결코 없을 것이다. 그렇다고 누구나 여러번 누를 수 있게 하면 좋아요 수치가 무의미해지는 것 같다고 생각했다.

그리고 조회수, 조회수를 처음에는 간단하게 블로그 데이터를 불러오는 GET 메서드 호출할 때마다 `viewCount`를 올리는 방식으로 구현했다. 하지만 이 경우에는 새로고침을 할 때마다 조회수가 오르기 때문에 이것도 의미없는 데이터가 되어갔다.

![썸네일](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1745081642307-%EB%B3%80%ED%99%98%EB%90%9C_%EC%A0%9C%EB%AA%A9%EC%9D%84%20%EC%9E%85%EB%A0%A5%ED%95%B4%EC%A3%BC%EC%84%B8%EC%9A%94_-001-YeGJkvNy3smhhulneAf0mFAlsnyXij.webp)

## 어떻게 고유하게 유저를 식별하지?

어떻게하면 내 블로그에 방문한 유저를 고유하게 식별해서, 조회수나 좋아요 기능을 지원할 수 있을까라고 고민을 했다. 생각한 건 아래와 같다.

1. 쿠키를 사용하기
다른 서비스들에서도 유저 통계를 집계하기 위해서 쿠키를 많이 사용한다고 한다. 내 블로그에 들어왔을 때 쿠키를 생성해서 유저들에게 나눠주고 다음에 접속했을 때의 대응을 할 수 있게 하는 방식이다.

하지만 한 가지 걸린 점은 특정 국가에서는 통계 목적으로 사용하는 쿠키는 사용자에게 고지 후 사용해야한다는 점이었다. 가끔 특정 웹사이트에 들어가면 팝업 창 형식으로 **더 나은 서비스를 제공하기 위해 쿠키를 허용해주세요** 뭐 이런 내용의 confirm 창을 본 적이 있다. 나는 항상 거절을 눌러왔기 때문에 쿠키를 사용하는 방식은 뒷전으로 두고 다른 방법을 알아봤다.

2. 그냥 새로고침해도 조회수 늘게 하기
내가 처음에 구현한 방법이다. 이 방법으로 하면 부정확할지라도 어떤 글이 인기있는지 정도의 추세는 볼 수 있기 때문에 이렇게 구현했었다.

3. FingerPrintJS 사용하기
이건 이번에 알아보면서 처음 배운 개념이었다. fingerprint라는 단어로부터 유추할 수 있듯 브라우저의 지문이라고 생각하면 쉽다. 물론 실제 지문처럼 겹칠 일이 현저하게 낮은 것은 아니지만, 사용자 브라우저의 고유한 특성을 조합해서 식별 가능한 문자열을 만든다는 점에서 흥미로웠다.

## FingerPrint로 고유하게 식별해보자

fingerprint 기술을 쉽게 확인할 수 있는 [데모사이트](https://fingerprint.com/)가 있다. 여기서 나의 고유 브라우저 지문을 확인할 수 있다. 새로고침을 해도 핑거프린트는 같기 때문에 내가 몇번 방문했는지도 기록해서 보여준다.

이거다!

이걸 사용해서 사용자를 고유하게 식별해서(고유하지 않더라도 짧은 시간 내의 중복은 쉽게 막을 수 있다.) 조회수를 올리거나 좋아요를 받아보자.

## 기능 구현하기

### 좋아요와 조회수 스키마 정의하기
좋아요와 조회수는 fingerprint를 통해 고유하게 식별하고, postId에 의존적이라는 성격 상 이름만 다르지 코드 구성이 같다. 아래는 조회수의 스키마이다. 

처음에는 조회수와 좋아요 같은 작은 개념에 대해서 컬렉션을 새롭게 만드는 것에 대해 무의식적인 거부감이 있었지만 mongodb는 이런 식의 데이터를 처리하기에 특화되어있고, 성능상의 오버헤드는 크지 않다고 알게 되어 이렇게 구현했다.

```tsx
import { Schema, model, models } from 'mongoose';

const viewSchema = new Schema(
  {
    postId: {
      type: Schema.Types.ObjectId,
      ref: 'Post',
      required: true,
    },
    fingerprint: {
      type: String,
      required: true,
    },
    timestamp: {
      type: Date,
      default: Date.now,
    },
  },
  { timestamps: true }
);

viewSchema.index({ postId: 1, fingerprint: 1 }, { unique: true });

const View = models.View || model('View', viewSchema);
export default View;
```
### API 구현하기
이제 조회수를 증가시키는 API를 만들어보자. 조회수 컬렉션에 문서를 추가하는 개념이기 때문에 POST 메서드를 사용해서 만들었다. 

이 코드에서 재밌는 점은 fingerprint를 커스텀 헤더로 받았다는 점이다. 처음에는 body로 받았었다. 하지만 일종의 인증 개념으로 사용하는 것이기 때문에 헤더로 받는게 더 좋을 것 같았고, nextjs에서 미들웨어를 쓸 수 있는지는 잘 모르지만 추후 미들웨어로 만들면 더 편하게 사용 가능할 것이라고 생각이 들었다. 

만약 미들웨어로 만들때 fingerprint를 body로 받으면 좀 귀찮아질 것이라는 생각이 들었다. X-Fingerprint라는 헤더 항목으로 받아서 유저의 fingerprint를 요청으로 받아와 기록하기 위해서 이렇게 구현했다.

```tsx
export const POST = async (request: Request) => {
  const { postId } = await request.json();
  const fingerprint = request.headers.get('X-Fingerprint') || '';

  if (!postId) {
    return new Response('postId가 필요합니다.', { status: 400 });
  }
  if (!fingerprint) {
    return new Response('Fingerprint가 필요합니다.', { status: 400 });
  }

  const existingLike = await View.findOne({ postId, fingerprint });
  if (existingLike) {
    const viewCount = await View.countDocuments({ postId });
    return Response.json(
      { message: '이미 조회한 유저입니다.', viewCount },
      { status: 203 }
    );
  } else {
    const result = await View.create({
      postId,
      fingerprint,
    });

    const viewCount = await View.countDocuments({ postId });

    if (result) {
      return Response.json(
        { message: '조회수 증가 성공', viewCount },
        { status: 200 }
      );
    } else {
      return Response.json(
        { message: '조회수 증가 실패', viewCount },
        { status: 500 }
      );
    }
  }
};
```

### FingerPrint 생성하기 (feat. zustand)
fingerprint를 쉽게 만들어주는 [@fingerprintjs/fingerprintjs](https://www.npmjs.com/package/@fingerprintjs/fingerprintjs)를 사용했다.

간편하게 핑거프린트를 생성해주는 라이브러리이다. 

이렇게 생성한 핑거프린트는 유저가 여러개의 글을 읽는 경우 매번 생성되는 것은 리소스 낭비라고 판단했다. 그래서 한번 생성하면 전역상태로 관리하고, 유저가 다시 들어와도 사용할 수 있도록 localStorage에 저장되는 persist 미들웨어도 사용하기로 결정했다.

```ts
interface FingerprintState {
  fingerprint: string;
  isLoading: boolean;
  error: string | null;
  initialize: () => Promise<void>;
}

const useFingerprintStore = create(
  persist<FingerprintState>(
    (set, get) => ({
      fingerprint: '',
      isLoading: true,
      error: null,
      initialize: async () => {
        if (get().fingerprint) {
          set({ isLoading: false });
          return;
        }

        try {
          const fp = await FingerprintJS.load();
          const result = await fp.get();
          const visitorId = result.visitorId;

          set({
            fingerprint: visitorId,
            isLoading: false,
          });
        } catch (error) {
          set({
            error: error instanceof Error ? error.message : '알 수 없는 오류',
            isLoading: false,
          });
          console.error('Fingerprint 생성 오류:', error);
        }
      },
    }),
    {
      name: 'fingerprint-storage',
    }
  )
);
```
이렇게 만든 전역 상태 store 코드를 사용하는 useFingerprint 커스텀 훅도 하나 더 만들어서, 캡슐화하고 외부에서 사용하는 것들만 노출시키는 방법을 사용했다.

fingerprint가 필요하면 아래처럼 쉽게 꺼내 쓸 수 있게 된 것이다.
```ts
const { fingerprint } = useFingerprint()
```

이렇게 하고 나서는 프론트엔드에서 글을 조회할 때, 조회수을 올리는 요청을 fingerprint와 함께 보내고, 서버에서는 중복 조회인지를 체크하고 조회수를 올리거나 유지하는 등의 행동을 취한다.

## 좋아요 기능은
좋아요 기능은 조회수와 대부분 코드가 같다. 다른 점은 좋아요는 delete 메서드를 만들어 좋아요 취소하는 API를 만들어둔 점과 프론트에서 처리하는 방식이 다르다는 점이다. 그 외에 fingerprint를 헤더로 받고 체킹하는 과정은 조회수와 같다.


## 완성된 모습
![완성본](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1745049754466-%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-04-19%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%205.02.13-3Ata7805edjMFlZYC85m3ueZcNYhc6.png)

다음에는 댓글도 커스텀으로 만들어봐야겠다고 생각했다. 
 


 ]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[프리뷰 | 네트워크 기반 동적 품질 조절 기능 개발 3편]]></title>
            <link>https://shipfriend.dev/posts/프리뷰-네트워크-기반-동적-품질-조절-기능-개발-3편</link>
            <guid>https://shipfriend.dev/posts/프리뷰-네트워크-기반-동적-품질-조절-기능-개발-3편</guid>
            <pubDate>Wed, 09 Apr 2025 08:32:23 GMT</pubDate>
            <description><![CDATA[applyConstraints]]></description>
            <content:encoded><![CDATA[[이전 글](/posts/프리뷰-네트워크-기반-동적-품질-조절-기능-개발-2편)에서는 네트워크 기반 동적 품질 조절 기능에서 필수적이고 핵심인 네트워크 지표를 측정하고 현재 유저의 네트워크 상태가 좋은지 나쁜지를 판별하는 기능을 구현했다.


# 프리뷰 | 네트워크 기반 동적 품질 조절 기능 개발 3편

이번에는 마지막을 장식하는 WebRTC를 사용하기 위해 유저의 미디어 장치를 가져올 때 현재 네트워크 품질에 맞는 프리셋을 사용하도록 하는 기능을 만들었다.

이번 글에서도 어떻게 구현했는지에 대해서 적고 마지막에 테스트 영상으로 마무리해보도록 하겠다.

## 미디어 품질 프리셋
미디어 품질 프리셋은 네트워크 상태에 따라 동적으로 조절되어야하므로 사용자의 네트워크 품질이 낮아지면 프리셋도 함께 low한 프리셋으로 조절되어야 한다.

그렇기 때문에 아래와 같이 비디오의 해상도, 초당 프레임 레이트, 비트레이트를 설정해주었다. (아래 코드 블럭은 일부)

```ts
interface IQualityPreset {
  quality: "ultra" | "high" | "medium" | "low" | "very-low";
  video: {
    width: number;
    height: number;
    frameRate: number;
    videoBitrate: number;
  };
}


  ultra: {
    quality: "ultra",
    video: {
      width: 1280,
      height: 720,
      frameRate: 60,
      videoBitrate: 2500,
    },
  },
  high: {
    quality: "high",
    video: {
      width: 854,
      height: 480,
      frameRate: 30,
      videoBitrate: 1000,
    },
  },
```

## 어떻게 적용하나?

사용자의 미디어 장치를 가져오는 역할은 `useMediaDevices` 커스텀 훅에서 담당하고 있다. 사용자의 현재 네트워크 품질은 전역상태로 관리하고 있기 때문에 `useMediaDevices` 훅에서도 쉽게 접근할 수 있다.

유저의 미디어 장치로 부터 스트림을 얻어오는 함수의 일부분을 보자. preset.video.frameRate 이런 식으로 constraints가 적용된 것을 볼 수 있다.
```ts
// ...중략...
 // 현재의 네트워크 품질 가져오기
      const quality =
        useNetworkStore.getState().currentNetworkQuality || "medium";
      const preset = QualityPreset[quality];

      // 비디오와 오디오 스트림을 따로 가져오기
      let videoStream = null;
      let audioStream = null;

      try {
        videoStream = isVideoOn
          ? await navigator.mediaDevices.getUserMedia({
              video: selectedVideoDeviceId
                ? {
                    deviceId: selectedVideoDeviceId,
                    width: { ideal: preset.video.width },
                    height: { ideal: preset.video.height },
                    frameRate: {
                      ideal: preset.video.frameRate,
                      max: preset.video.frameRate + 5,
                    },
                  }
                : {
                    width: { ideal: preset.video.width },
                    height: { ideal: preset.video.height },
                    frameRate: {
                      ideal: preset.video.frameRate,
                      max: preset.video.frameRate + 5,
                    },
                  },
              audio: false,
            })
          : null;
      } catch (videoError) {
        console.warn("비디오 스트림을 가져오는데 실패했습니다:", videoError);
        setIsVideoOn(false);
      }
```

### **`ideal` 프로퍼티는 무엇인가?**
 `ideal`은 사전적인 의미로 이상적인 이라는 뜻을 가지고 있다. 즉, 가능하다면 이 수치로 맞춰주세요~와 비슷한 의미이다.

> **필요한 이유?**
> 
> `ideal` 대신 고정 값을 사용하기 위한 `exact` 라는 항목이 존재한다. 하지만 만약 exact로 1920 x 1080 의 FHD 해상도를 지정한다면 어떻게 될까? 유저의 미디어 장치가 FHD 화질을 지원한다면 해당 해상도로 출력이 가능하다. 
> 
> 그렇다면 지원하지 않는 장치일 경우? 해본 결과 `OverconstrainedError` 라는 에러를 반환한다. 

## 동적으로 조절하기
위 함수만 수정하면 초반에 얻어오는 스트림에만 적용되기 때문에 useEffect를 사용해서 네트워크 품질이 바뀔 때마다 새롭게 적용해주어야한다.

아래 코드는 `useNetworkStore`에서 현재 네트워크 품질 프리셋 정보 (ex: ultra)를 가져온다. 이후 사전 정의된 프리셋을 가져오고 stream 객체의 video 트랙에 `applyConstarints` 메서드를 활용해 적용해준다.


```ts
useEffect(() => {
    // 현재 네트워크 품질이 변경될 때마다 비디오 품질 조정하는 이펙트
    const currentQuality = useNetworkStore.getState().currentNetworkQuality;
    if (currentQuality && streamRef.current) {
      const videoTrack = streamRef.current.getVideoTracks()[0];
      if (videoTrack) {
        const preset = QualityPreset[currentQuality];

        videoTrack
          .applyConstraints({
            width: { ideal: preset.video.width },
            height: { ideal: preset.video.height },
            frameRate: {
              ideal: preset.video.frameRate,
              max: preset.video.frameRate + 5,
            },
          })
          .catch((error) => {
            console.warn("비디오 제약 조건 적용 실패:", error);
          });

        // // WebRTC 비트레이트 설정 (피어 연결이 있는 경우)
        if (peerConnections.current) {
          for (const peerConnection of Object.values(peerConnections.current)) {
            const sender = peerConnection
              .getSenders()
              .find((s) => s.track?.kind === "video");
            if (sender) {
              const parameters = sender.getParameters();
              if (!parameters.encodings) {
                parameters.encodings = [{}];
              }
              parameters.encodings[0].maxBitrate =
                preset.video.videoBitrate * 1000;
              sender.setParameters(parameters).catch((error) => {
                console.warn("비디오 비트레이트 설정 실패:", error);
              });
            }
          }
        }

        console.log(
          `네트워크 품질 변경: ${currentQuality}, 비디오 설정 업데이트됨`
        );
      }
    }
  }, [useNetworkStore.getState().currentNetworkQuality]);
```

`applyConstraints`가 있는지 모르고 처음에는 설마 품질 바뀔 때마다 새롭게 트랙 가져와야하나? 라는 절망에 빠질 뻔 했으나 다행히 금방 해당 메서드를 발견했다!!

이제 전역 네트워크 품질 상태를 구독하는 useEffect가 만들어졌다. 네트워크 품질이 달라지면 실행되어 품질을 조절해준다.

## 테스트
테스트에 앞서 내 웹캠은 해상도도 낮고 최대 프레임도 30프레임이라 적용되는지를 확인하기 위해 medium 해상도의 비율을 다르게 설정해서 테스트했다.

테스트 영상은 [링크](https://youtu.be/3pD3di-fKoI)를 통해 볼 수 있다. (youtube 임베드 기능을 만들어야겠다..) 자막이랑 확대 정도 편집을 10분 뚝딱해서 가시성은 좀 안좋은 것 같다.. ㅎ

![테스트 영상 미리보기](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1744186964736-%EB%B3%80%ED%99%98%EB%90%9C_Screenshot%202025-04-09%20at%2017.22.27-u7CowebiIl9YZJIqU8FsTFfEqEu2MM.webp)

미디어 품질이 ultra -> high -> medium -> low -> very-low 로 내려가는데, medium일 때만 해상도 비율이 다른 것을 볼 수 있다. 해상도나 프레임률도 내려가긴 하지만 장치 자체가 좋은 장치가 아니라 티가 잘안나서 해상도 비율로 테스트를 했다.

## 결과와 느낀 점
### 결과
결과적으로는 사용자의 네트워크 품질이 낮을 때는 일부러 미디어의 품질을 낮추는 방식으로 오디오를 최대한 살리는 것이 중요하다고 느꼈다. **네트워크 품질을 모니터링**하고 이걸 기반으로 어떤 **constraints를 적용**해 **사용자 경험을 향상**시킬 수 있었다.


### 느낀 점

네부캠 프로젝트를 하면서 **멘토님이 기술적으로 고도화한다면 해볼만한 주제**라고 해주셔서 이전부터 계획만 세워뒀었는데 이렇게 다 만들고 나니 정말 뿌듯하다.

**다양한 상황을 고려한다**라는 것 자체가 프론트엔드 개발에서 많이 중요한 것 같다고도 느꼈고, 네트워크 지표를 공부하면서 CS가 이렇게 쓰일 수 있겠구나 라는 인사이트를 체득할 수 있어서 좋았다.

이 기능을 만들기 위해서 네트워크 품질 지표 공부도 하고, 나름대로 계획도 세워서 구현했는데 원하는대로 잘 작동이 된 것 같아서 기분이 좋다.

기능을 구현하기 위한 학습을 했지만 네트워크의 다른 부분들도 알아두면 좋을 것 같은 내용들이 많이 있었기 때문에 더 공부해야할 것 같다고 느꼈다.

**네트워크 기반 동적 품질 조절 기능 개발 END**
]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[리드미용 블로그 최신 글 뱃지 만들기]]></title>
            <link>https://shipfriend.dev/posts/리드미용-블로그-최신-글-뱃지-만들기</link>
            <guid>https://shipfriend.dev/posts/리드미용-블로그-최신-글-뱃지-만들기</guid>
            <pubDate>Wed, 09 Apr 2025 07:24:56 GMT</pubDate>
            <description><![CDATA[with svg 생성 api와 redirect api]]></description>
            <content:encoded><![CDATA[작년 여름에 마지막으로 깃허브 프로필을 수정하고 그 뒤로 건들지 않았었는데 이번에 좀 더 심플하게 바꿔보려고 했다.

기존에는 velog를 쓰고 있었기 때문에 velog-stats 레포지토리를 사용해서 velog 최신 글을 깃허브 리드미에 뱃지 형태로 띄워주는 기능을 쓰고 있었다. 이 기능이랑 비슷하게 내 블로그의 최신 글로 연결되는 뱃지를 만들어보고 싶다는 생각을 했다.

# 리드미용 블로그 최신 글 뱃지 만들기

일단 어떻게 하는지 감이 안잡혀서 기존 벨로그 최신글 연결 레포지토리도 살펴보고 리드미에 어떻게 작성하는지를 살펴봤다.

아래는 이전에 사용하던 벨로그 최신 글로 리다이렉트되는 뱃지이다. 이 뱃지는 [velog-reademe-stats Repository](https://github.com/eungyeole/velog-readme-stats)에서 사용해볼 수 있다.

[](https://velog-readme-stats.vercel.app/api/?name=shipfriend)

해당 레포지토리를 살펴보면 vercel에 호스팅을 하고 있고, 뱃지를 생성하는 API와 리다이렉트 해주는 API를 두 개 쓰고 있는 것을 알았다.

**그렇다면 해야할 것은 1. SVG 생성 API 2. 최신 글 리다이렉트 주소 API를 만드는 것이다.**

## 완성본 미리보기

내가 만든 뱃지는 아래와 같다.

[![최신 글](https://shipfriend.vercel.app/api/posts/recent)](https://shipfriend.vercel.app/api/redirect/recent)

## SVG 생성하기

짧은 시간에 svg 구조를 만드는 방법을 배우는 건 너무 비효율적이니 Claude를 사용해서 SVG의 구조를 만들었다. svg를 반환하는 api를 만들어야하니 /api/posts/recent의 get method로 만들었다.

아래는 뱃지를 생성하는 함수이다.

```ts
// 메인 배지 SVG 생성 함수
function generateBadgeSVG(
  title: string,
  subTitle: string,
  date: string
): string {
  // 배지 크기 계산
  const width = 400;
  const height = subTitle ? 110 : 80; // 부제목 유무에 따라 높이 조정

  // 투명 배경으로 SVG 시작
  let svg = `<svg xmlns="<http://www.w3.org/2000/svg>" width="${width}" height="${height}" fill="none">
    <!-- 투명 배경 설정 -->
    <defs>
      <clipPath id="roundedRect">
        <rect width="${width}" height="${height}" rx="10" ry="10"/>
      </clipPath>
    </defs>

    <!-- 배경 및 테두리 -->
    <g clip-path="url(#roundedRect)">
      <rect width="${width}" height="${height}" fill="white"/>
    </g>
    <rect width="${width}" height="${height}" fill="none" stroke="${DEEP_GREEN}" stroke-width="1" rx="10" ry="10"/>

    <!-- 배지 제목 영역 - 딥그린 색상 적용 -->
    <text x="20" y="30" font-family="Arial, Helvetica, sans-serif" font-size="12" font-weight="bold" fill="${DEEP_GREEN}">최신 글 | ShipFriend Tech Blog</text>

    <!-- 구분선 - 딥그린 색상 적용 -->
    <line x1="20" y1="40" x2="${width - 20}" y2="40" stroke="${DEEP_GREEN}" stroke-width="1"/>

    <!-- 포스트 제목 -->
    <text x="20" y="65" font-family="Arial, Helvetica, sans-serif" font-size="16" font-weight="bold" fill="#333">${escapeXML(title)}</text>`;

  if (subTitle) {
    // 부제목이 있는 경우
    svg += `
    <!-- 부제목 -->
    <text x="20" y="90" font-family="Arial, Helvetica, sans-serif" font-size="14" fill="#666">${escapeXML(subTitle)}</text>

    <!-- 날짜 (맨 아래) -->
    <text x="${width - 20}" y="${height - 20}" font-family="Arial, Helvetica, sans-serif" font-size="12" fill="#888" text-anchor="end">${date}</text>`;
  } else {
    // 부제목이 없는 경우
    svg += `
    <!-- 날짜 (중간 아래) -->
    <text x="${width - 20}" y="${height - 20}" font-family="Arial, Helvetica, sans-serif" font-size="12" fill="#888" text-anchor="end">${date}</text>`;
  }

  svg += `</svg>`;

  return svg;
}

```

이렇게 생성하고 나서는 아래와 같은 식으로 최신 글을 DB에서 가져오고, SVG를 생성할 때 넣어주고 나서 response로는 `Content-Type`을 image/svg+xml로 해두고 svg를 반환하도록 만들었다.

```ts
await dbConnect();

const latestPost = await Post.findOne({})
  .select('title subTitle slug date')
  .sort({ date: -1 })
  .limit(limit);

const svg = generateBadgeSVG(title, subTitle, dateStr);

return new Response(svg, {
  headers: {
    'Content-Type': 'image/svg+xml',
    'Cache-Control': 'no-cache, no-store, must-revalidate, max-age=0',
  },
});

```

## 리다이렉트 API

위 처럼 만든 svg는 정말 이미지와 같기 때문에 이미지를 클릭했을 때 이동할 수 있는 기능을 만드려면 항상 최신글로 리다이렉트해주는 api를 만드는 것이 중요하다.

혹시 나중에 블로그 주소를 이전할 수도 있으니 리다이렉트하는 url은 환경변수를 사용하도록 만들었다. 최신 글을 가져와 해당 글에 slug를 사용해서 리다이렉트 url을 만든다.

NextResponse 객체의 redirect 메서드를 사용해서 최신 글로 리다이렉트 시킨다. 이 api는 /api/redirect/recent로 만들었고 해당 api에 접근하며 항상 최신 글 주소로 리다이렉트해준다.

```ts
await dbConnect();
const pageUrl = process.env.NEXTAUTH_URL;

// 최신 글 1개 가져오기
const latestPost = await Post.findOne({}).sort({ date: -1 }).select('slug');

// 최신 글로 리다이렉션
return NextResponse.redirect(
  new URL(`${pageUrl}/posts/${latestPost.slug}`)
);

```

### 캬

금방 만들었지만 만들길 잘 한 것 같다.

[![최신 글](https://shipfriend.vercel.app/api/posts/recent)](https://shipfriend.vercel.app/api/redirect/recent)]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[요청을 캐시해서 돈을 아껴보자]]></title>
            <link>https://shipfriend.dev/posts/요청을-캐시해서-돈을-아껴보자</link>
            <guid>https://shipfriend.dev/posts/요청을-캐시해서-돈을-아껴보자</guid>
            <pubDate>Sat, 05 Apr 2025 06:48:20 GMT</pubDate>
            <description><![CDATA[Cache Control]]></description>
            <content:encoded><![CDATA[# 요청을 캐시해서 돈을 아껴보자
제목은 이렇게 썼지만 블로그를 운영하는데 돈은 안든다 ㅎ.. 

캐시에 대한 개념은 익히 들어서 알고 있는 개념이었지만 실제로 적용해본 적이 없다는 것을 깨달았다. 

이번에 캐시를 적용해야겠다고 생각한 이유는 내 블로그에서 posts 페이지에서 series 페이지로 이동할 때마다 글 리스트를 불러오는 Get 요청을 매번 실행하고 있다는 점에서 착안했다.

React Query를 쓰면 요청과 응답에 대한 캐시를 편하게 사용할 수 있다는 점이 있지만 개인 블로그에서 React Query를 쓰기에는 오버 엔지니어링이라는 생각이 들어서 도입하지 않았다. 

## 어떻게 캐시하나요?
가장 기본적인 방법으로 요청에 대한 응답을 캐싱하기로 결정했다. Nextjs 백엔드에서 보내주는 Response 객체에 header를 달아줘야한다.

요청의 헤더는 이렇게 작성했다. 아래는 블로그의 세부 내용을 불러오는 API의 코드다. 
```ts
export async function GET(
  req: NextRequest,
  { params }: { params: { slug: string } }
) {
  try {
    await dbConnect();
    const post = await Post.findOne({ slug: params.slug }).lean();

    if (!post) {
      return Response.json(
        { success: false, error: 'Post not found' },
        { status: 404 }
      );
    }

    return Response.json(
      { success: true, post },
      {
        status: 200,
        headers: {
          'Cache-Control': 'public, max-age=3600, s-maxage=86400',
        },
      }
    );
  } catch (error) {
    console.error(error);
    return Response.json(
      { success: false, error: 'Server Error' },
      { status: 500 }
    );
  }
}
```
여기서 핵심은 header 부분이다. `'Cache-Control': 'public, max-age=3600, s-maxage=86400'` 헤더의 Cache-Control 항목에 직관적으로 적혀있는 것을 볼 수 있다. 

### 캐싱 시스템

public은 캐싱 시스템에 대한 항목이다. public과 private이 있는데 public은 모든 캐싱 계층에 저장된다는 의미이고, private은 브라우저 캐시 같은 사용자 개인 캐시 저장소에만 저장된다는 의미이다.

그렇다면 "모든 캐싱 계층"이란 무슨 말일까?

캐싱 계층은 여러가지가 존재한다. 가장 기본적으로는 브라우저 캐시가 있다. 우리가 사용하는 로컬 브라우저이고 로컬 캐시이고, 개인 사용자만 접근 가능하다. 

그 다음으로는 프록시 캐시(ISP, 통신사 단에서 운영되는 캐시), CDN 캐시(Cloudflare 같은 Cdn 서비스에서 운영되는 캐시), 리버스 프록시 캐시(Nginx같이 서버 앞에 위치해서 요청을 처리하는 것을 도와주는 소프트웨어 단에서 운영되는 캐시이다.)

`private` 지시어는 개인적인 정보가 포함되어있는 경우에 사용해볼 수 있을 것 같다.

### 캐싱 만료

직관적인 의미로 캐시가 언제 만료될지에 대한 정보이다. 대표적으로 `max-age`, `s-maxage`가 있다. max-age=<seconds> 형식으로 몇초 뒤에 만료될 것인지를 적어준다. 

s-max-age는 브라우저 캐시가 아닌 공유 캐시에 저장되는 시간을 지정해주는 것이다. 위에서 잠깐 설명한 프록시 캐시, CDN 캐시에 저장되는 시간이다.

`max-age=3600` 이렇게 지정하면 3600초, 즉 1시간 동안 캐시에 저장된다는 의미이다.

### 캐싱 검증
`must-revalidate`이나 `proxy-revalidate` 같은 것들은 캐시가 만료되었을 시에 어떤 작업을 할 것인지에 대한 정책이다.

`must-revalidate`는 캐싱된 응답의 max-age가 아직 만료되지 않았을 경우에는 그냥 해당 응답을 사용하고 만약 만료되었을 경우에는 무조건 서버에 연결해서 재검증해야한다는 의미이다. 

`proxy-revalidate`는 브라우저  같은 개인 캐시와는 상관없이 프록시 캐시에 저장된 응답이 만료되었을 경우 재검증 받아야한다는 의미이다.

## 어떻게 바뀌었나
캐시 정책 적용 전에는 항상 새로운 응답을 불러와서 사용한다. 
![적용 전](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1743835159006-%EB%B3%80%ED%99%98%EB%90%9C_Screenshot%202025-04-05%20at%2015.07.43-Z5DeAFuXUSmv3CRF1dm280Uf3IJQr2.webp)

적용 후에는 응답 헤더에 아까 정한 `Cache Control` 헤더가 적혀있는 것을 볼 수 있다. 그리고 상태 코드에 보면 200 코드 옆에 (디스크 캐시에서)라고 적혀있다. **`캐시에서 응답을 불러온 것이다!!!`**

![적용 후](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1743835161900-%EB%B3%80%ED%99%98%EB%90%9C_Screenshot%202025-04-05%20at%2015.08.05-IOH2aSNJ2pKfhtOs3lfLrOAyaZgWUg.webp)

## 캐시를 적용하면 무엇이 좋아지나

### 장점
캐시를 적용하면 좋은 점이 많다. 

- 성능 향상, 이전에 받은 리소스를 재활용하기 때문에 페이지 로딩 속도가 빨라진다.
- 네트워크 비용 효율성, 돈을 아낀다는 제목이 여기에서 나온다. vercel의 경우 한달 100GB 트래픽을 지원하는데 이미지를 매번 새로 가져온다면 그만큼 대역폭 소모량도 많아질 것이다. 캐시로 이걸 방지할 수 있겠다.
- 오프라인 지원, 가끔 인터넷이 끊겨도 유튜브 영상이나 블로그 글을 볼 수 있는 것도 캐시 덕분이다. 

### 단점
물론 장점만 있는 것은 아니다. 정책을 잘 지정하지 못한다면 문제가 생길 수도 있다.
- 데이터 신선도, `max-age` 같은 옵션을 너무 오래 지정하게 되면 서버에서는 데이터가 이미 다른 데이터로 변경되었음에도 클라이언트에서는 이전 응답을 재활용하게 된다.
- 보안, 개인 정보나 로그인해야만 얻을 수 있는 리소스에 대해서 캐시하게 되면 유출 가능성에 대해서도 고려해야한다.
- 디버깅 어려움, 이건 개발자에게만 해당되는 일이지만 가끔 개발을 하다보면 예상치 못한 일들이 일어날 때 캐시 초기화를 먼저 해보라고 하는 글들을 흔하게 볼 수 있다. 캐시는 직관적으로 보기 어렵기 때문에 디버깅을 어렵게 할 수 있다는 점이 있다.

이런 단점들에 대해서 생각하면서 캐시 정책을 결정해야 장점만 이용할 수 있을 것 같다.]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[프리뷰 | 네트워크 기반 동적 품질 조절 기능 개발 2편]]></title>
            <link>https://shipfriend.dev/posts/프리뷰-네트워크-기반-동적-품질-조절-기능-개발-2편</link>
            <guid>https://shipfriend.dev/posts/프리뷰-네트워크-기반-동적-품질-조절-기능-개발-2편</guid>
            <pubDate>Mon, 31 Mar 2025 13:59:09 GMT</pubDate>
            <description><![CDATA[네트워크 보고서 저장 및 계산]]></description>
            <content:encoded><![CDATA[[이전 글](/posts/%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%ED%92%88%EC%A7%88%EC%9D%98-%EC%84%B1%EB%8A%A5-%EC%A7%80%ED%91%9C%EC%97%90-%EB%8C%80%ED%95%B4%EC%84%9C)에서는 네트워크 품질을 측정하기 위한 지표들에 대해서 공부한 글을 올렸었다. 이후 5초에 한번씩 피어 커넥션을 순회하며 네트워크 지표들을 측정했다. 이걸 평균내서 하나의 NetworkStat 객체로 만들어내는 것까지 했다.

# 프리뷰 | 네트워크 기반 동적 품질 조절 기능 개발 2편

오늘은 네트워크 스탯을 저장하고 이를 토대로 품질을 결정하는 훅들을 만들었다. 그리고 이를 시각적으로 확인할 수 있는 컴포넌트도 만들었다. 

![네트워크 모니터 컴포넌트](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1743429110844-%EB%B3%80%ED%99%98%EB%90%9C_%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%20%EC%B6%94%EA%B0%80-ORI0SpbeIuKQJIiHtlYrRWIUiZl49C.webp)

## 최근 5회의 평균

네트워크 품질 프리셋을 결정짓는 방법은 전역 상태에 관리되는 네트워크 상태 스택의 최근 5회의 평균으로 계산하기로 결정했다. 이렇게 한 이유는 최근 한 번에 대해서 평균을 내게 될 경우 급격하게 프리셋이 변경될 가능성이 있기 때문이다. 만약 5초 간격으로 화질이 좋고 나쁘게 바뀔 경우 너무 불편할 것이라고 생각이 들었다.

### 품질 단계

품질 단계를 결정하는 코드는 아래와 같다. calculateAverageStats는 매개변수로 들어오는 count 값에 맞춰서 최근 N회의 스탯을 통계내는 함수이다. 해당 함수로 반환된 averageStats의 값을 기준으로 현재 currentNetworkQuality를 판단한다.

0점 부터 시작해서 네트워크 지표가 좋으면 점수를 부여하는 방식으로 계산했다. 대역폭은 좀 더 중요한 지표이기 때문에 대역폭에 좀 더 많은 점수를 주고, 그 외 지표의 점수를 계산하는 방식으로 했다. 

```tsx
  const getNetworkQuality = () => {
    const averageStats = calculateAverageStats(5);
    if (!averageStats) return null; 
    const { jitter, rtt, packetsLossRate, bandwidth } = averageStats;

    const bandwidthMbps = bandwidth / 1000 / 1000;
    const rttMs = rtt * 1000; // 초를 밀리초로 변환
    const packetLossPercent = packetsLossRate * 100;
    const jitterMs = jitter * 1000; // 초를 밀리초로 변환

    let qualityScore = 0;

    if (bandwidthMbps >= 2.5) qualityScore += 40;
    else if (bandwidthMbps >= 1.0) qualityScore += 30;
    else if (bandwidthMbps >= 0.5) qualityScore += 20;
    else if (bandwidthMbps >= 0.25) qualityScore += 10;
    else qualityScore += 0;

    if (rttMs < 50) qualityScore += 25;
    else if (rttMs < 100) qualityScore += 20;
    else if (rttMs < 300) qualityScore += 15;
    else if (rttMs < 500) qualityScore += 5;
    else qualityScore += 0;

    if (packetLossPercent < 0.5) qualityScore += 25;
    else if (packetLossPercent < 2) qualityScore += 20;
    else if (packetLossPercent < 5) qualityScore += 15;
    else if (packetLossPercent < 10) qualityScore += 5;
    else qualityScore += 0;

    if (jitterMs < 10) qualityScore += 10;
    else if (jitterMs < 30) qualityScore += 8;
    else if (jitterMs < 50) qualityScore += 5;
    else if (jitterMs < 100) qualityScore += 2;
    else qualityScore += 0;
 
    if (qualityScore > 80) {
      return "ultra";
    } else if (qualityScore > 60) {
      return "high";
    } else if (qualityScore > 40) {
      return "medium";
    } else if (qualityScore > 20) {
      return "low";
    } else {
      return "very-low";
    }
  };
```

점수를 20 점 단위로 자르고, 각각 ultra, high, medium, low, very-low 의 5단계로 나누었다. 3단계할까 5단계할까 고민했는데, 좀 더 드라마틱한 효과를 보기 위해서 5단계로 쪼개었다. 

### 왜 안바뀌지..?

최근 5번의 평균을 내는 로직을 구현했는데 어째선지 최근 5회 보고서에 같은 값만 찍히는 버그가 있었다. 디버깅 후에 알아낸 사실은

![콘솔 같은거 찍힘..](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1743429105805-%EB%B3%80%ED%99%98%EB%90%9C_%EC%BD%98%EC%86%94%20%EA%B0%99%EC%9D%80%EA%B1%B0%EC%B0%8D%ED%9E%98-aPpV6l8OREcq2orKUYjEmMkS4dWBjz.webp)

```tsx
const {
  networkStats,
  updateNetworkStats,
  setCurrentNetworkQuality,
  currentNetworkQuality,
} = useNetworkStore();

const recentStats = networkStats.length > count ? networkStats.slice(-count) : networkStats;
```

이 코드에 있었다. networkStats는 네트워크 데이터가 담기는 배열인데, 이렇게 계산하고 있었다. recentStats를 불러오는 로직은 틀리지 않았는데 이상하게 값이 변경되지 않았다.

그래서 찾아보니 useNetworkStore()를 구조분해해서 가져오는 networkStats는 이 훅이 마운트 될 때 캡쳐되고 바뀌지 않는다고 한다. 그러니까 나는 변경되지 않는 배열을 가지고 5회 평균을 내고 있었던 것이었다..

그래서 `const networkStats = *useNetworkStore*.getState().networkStats;` 같은 형식으로 항상 새로운 값을 가져오도록 하여 해결했다.

## 네트워크 테스트

기존에 네트워크 테스트는 개발자도구에서 4G나 3G 속도로 제한하는 정도 밖에 해보지 않았다. 하지만 네트워크가 아닌 WebRTC 의 P2P 통신에서는 효과가 없는 것을 확인했다.

다른 네트워크 에뮬레이터 테스트 도구가 있을까 찾아봤다. 처음에는 js 라이브러리 중 찾아보려고 했는데 라이브러리를 사용하는 방법 보다는 외부에서 제어해주는 도구를 쓰는게 더 나을 것 같다고 판단했다.

그래서 찾아본 결과 clumsy라는 도구를 발견했다. 이 도구는 다양한 네트워크 지연 현상을 시뮬레이션하는 도구였다. 랙, 쓰로틀, 대역폭 제한 등의 다양한 상황을 조절해가면서 테스트할 수 있는 도구다.

https://jagt.github.io/clumsy/index.html

![](https://jagt.github.io/clumsy/clumsy-demo.gif)

결과적으로 내가 원하는 것은 네트워크 품질이 안좋아지면 품질 프리셋이 낮아지는 것이다. 그래서 극한의 네트워크 상황을 테스트해야했다.

테스트에 사용한 설정은 아래와 같다. 전반적인 네트워크 품질을 떨어뜨려야했기 때문에 랙, 드랍, 쓰로틀을 걸어줬고, 대역폭을 제한하기 위해서 초당 200KB로 제한했다.

![나의 클럼시 세팅](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1743429091390-%EB%B3%80%ED%99%98%EB%90%9C_%EB%82%98%EC%9D%98%20clumsy%20%EC%84%A4%EC%A0%95-PHfX1nreQhDXFTQkxhoaEfYzXLmSyF.webp)

해당 영상은 clumsy 테스트 하기 전과 후를 보여주는 영상 테스트이다. 테스트 하기 전에는 ultra 품질을 유지하다가, clumsy를 켜는 순간 low로 점점 떨어지는 모습을 볼 수 있다. 

[테스트 영상](https://youtu.be/IydfuO_AV7Y)

![클럼시 테스트 전](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1743429392540-%EB%B3%80%ED%99%98%EB%90%9C_%ED%81%B4%EB%9F%BC%EC%8B%9C%EC%A0%84-n9XTx3tMyGZWQJJmoqiQUDAKwLwtfJ.webp)

화질이 좀 안좋은데 네트워크 품질이 ultra인 것을 볼 수 있다. 이건 클럼시 테스트를 하기 전의 상태이다.

![클럼시 테스트 후](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1743429395289-%EB%B3%80%ED%99%98%EB%90%9C_%ED%81%B4%EB%9F%BC%EC%8B%9C%ED%9B%84-3DEBah9VlKZj47RVvLSoLUyTIjSRKu.webp)

이 사진은 클럼시 테스트 시작 후 20초 정도가 지났을 때의 사진이다. 대역폭이 많이 떨어져서 네트워크 품질은 최하인 'Very-low'에 도달했다. 

이제 네트워크 상태에 따른 품질을 조절하는 것은 모두 완성됐다. 여기서 계산 식만 조율해가면서 최적의 식을 찾으면 되는 정도만 남았다. 이 다음 글에서는 이렇게 결정된 프리셋 단계를 가지고 webrtc 비디오 컴포넌트에 적용하는 것을 해보려고 한다. 👻👻👻👻

> 다음 글

[프리뷰 | 네트워크 기반 동적 품질 조절 기능 개발 3편](/posts/%ED%94%84%EB%A6%AC%EB%B7%B0-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EA%B8%B0%EB%B0%98-%EB%8F%99%EC%A0%81-%ED%92%88%EC%A7%88-%EC%A1%B0%EC%A0%88-%EA%B8%B0%EB%8A%A5-%EA%B0%9C%EB%B0%9C-3%ED%8E%B8)]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[블로그 API 최적화]]></title>
            <link>https://shipfriend.dev/posts/블로그-api-최적화</link>
            <guid>https://shipfriend.dev/posts/블로그-api-최적화</guid>
            <pubDate>Mon, 24 Mar 2025 05:04:52 GMT</pubDate>
            <description><![CDATA[필요한 데이터만 불러오기]]></description>
            <content:encoded><![CDATA[현재 내 블로그는 글 목록을 불러올 때 하나의 글에 대한 모든 데이터를 넣어주고 있다. 이건 매우 비효율적이라는 생각이 들었다. 지금이야 글이 몇개 없지만 300개가 넘어갈 경우에는 300개 글에 대한 모든 세부 데이터를 가져오는 것은 오버헤드가 매우 큰 작업이라고 판단했다.

그래서 API route를 최적화해서 글의 제목과 글의 아이디, slug, 작성일 정도의 데이터를 가져오는 가벼운 API Route를 만들어야겠다고 계획했다.

# API 최적화하기

## 현재 API 코드

현재 get method는 아래와 같이 구성되어있다. 검색 쿼리, 시리즈 정보에 대한 데이터를 searchParams로 같이 넣어주면 해당하는 글 데이터만 가져오는 방식이다.

```tsx
// GET /api/posts - 모든 글 조회
export async function GET(req: Request) {
  try {
    await dbConnect();
    const { searchParams } = new URL(req.url);

    const query = searchParams.get('query') || '';
    const seriesSlug = searchParams.get('series') || '';

    const seriesId = seriesSlug
      ? await Series.findOne({ slug: seriesSlug }, '_id')
      : null;

    // 검색 조건 구성
    const searchConditions = {
      $or: [
        { title: { $regex: query, $options: 'i' } },
        { content: { $regex: query, $options: 'i' } },
        { subTitle: { $regex: query, $options: 'i' } },
      ],
      $and: [],
    };

    if (seriesId) {
      (searchConditions.$and as QuerySelector<string>[]).push({
        seriesId: seriesId._id,
      } as QuerySelector<string>);
    }

    const posts = await Post.find(searchConditions)
      .sort({ date: -1 })
      .limit(10);

    return Response.json({ success: true, posts: posts });
  } catch (error) {
    console.error(error);
    return Response.json(
      { success: false, error: '포스트 목록 불러오기 실패', detail: error },
      { status: 500 }
    );
  }
}
```

여기서 이런 검색 관련 기조는 유지한채 단순 플래그 하나로 가벼운 API로 만들어보려고 한다. 예를 들어 &light=true 같은 `searchParams` 를 사용해서 글의 세부 내용을 가져오지 않도록 하는 방식을 사용할 수 있을 것 같다.

## 최적화한 코드
query문을 변수로 빼고 isCompact 여부에 따라 property를 select해서 보내는 방식으로 최적화를 진행했다. 이 방법으로 이제 글목록을 불러올 때 글에 대한 content는 제외하고 보내준다.

```tsx
const { searchParams } = new URL(req.url);
const isCompact = searchParams.get('compact') === 'true';

let q = Post.find(searchConditions);

if (isCompact) {
  q = q.select(
    'slug title _id subTitle author date tags thumbnailImage seriesId timeToRead createdAt updatedAt'
  );
}

const posts = await q.sort({ date: -1 }).limit(10);
```

이렇게 수정한 API는 /posts 라우트에서 글 목록을 불러올 때와 글을 수정하기 위해 글 목록을 불러오는 admin 페이지에서 유용하게 사용되고 있다.

글이 좀 더 생기면 페이지네이션을 구현할 생각이다.]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[드래그 앤 드롭 기능 예제와 성능 최적화 🚀]]></title>
            <link>https://shipfriend.dev/posts/드래그-앤-드롭-기능-예제와-성능-최적화</link>
            <guid>https://shipfriend.dev/posts/드래그-앤-드롭-기능-예제와-성능-최적화</guid>
            <pubDate>Sun, 16 Mar 2025 09:01:57 GMT</pubDate>
            <description><![CDATA[throttle과 requestAnimationFrame]]></description>
            <content:encoded><![CDATA[> 이 글은 티스토리 블로그에서 이전된 글입니다. [원본 링크](https://oraciondev.tistory.com/entry/%EB%93%9C%EB%9E%98%EA%B7%B8-%EC%95%A4-%EB%93%9C%EB%A1%AD-%EA%B8%B0%EB%8A%A5-%EC%98%88%EC%A0%9C%EC%99%80-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%EA%B9%8C%EC%A7%80-%ED%95%B4%EB%B3%B4%EA%B8%B0)

최근에 드래그 앤 드롭 기능을 만들어보면서 느낀 점은 정말 재미있는 기능이라는 점인 것 같다! 이번에는 간단한 드래그앤드롭,DND 예제를 만들어보고, 그 후에 어떤 최적화를 할 수 있을까에 대해서 고민해보려고 한다.

# 드래그 앤 드롭 기능 예제와 성능 최적화 🚀

일단 dnd 예제를 만들어야하는데, 초기에 필요한 세팅은 클로드의 도움을 받아서 작성했다. 프롬프트를 작성한 예시는 아래와 같다.

> 간단한 드래그앤드롭을 만들기 위한 초기 html과 css만 만들어줘, 기능 구현은 하지 않고 만들어줘
> 

이렇게 해서 만들어진 예시 화면이다. 디자인은 대충 디스코드 느낌 나게 만들어봤다. (색깔만)

![예제 사진](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1742115373567-converted_%E1%84%8B%E1%85%A8%E1%84%8C%E1%85%A6%20%E1%84%8B%E1%85%B5%E1%84%86%E1%85%B5%E1%84%8C%E1%85%B5-idOHjmuzdZqO64iJ0Q6ZUD1mYFr151.webp)

## 드래그 앤 드롭 기능 구현 dragover 성능 최적화까지

간단하게 설명하면 왼쪽에 있는 draggable한 아이템들을 드래그앤드랍으로 오른쪽의 dropzone으로 끌어다 놓으면 아이템을 움직일 수 있도록 구현되어 있다. 그리고 드래그한 상태로 마우스를 dropzone에 가져다 두면 미리보기가 반투명하게 생기도록 하는 기능을 구현해 뒀다.

![최적화 전](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1742115546955-requestAnimationFrame%20%E1%84%8E%E1%85%AC%E1%84%8C%E1%85%A5%E1%86%A8%E1%84%92%E1%85%AA-RwY9bQWVMHnZzvxPmQ4KfTXv52NldE.gif)
 
최적화 전의 모습, 이벤트 핸들러가 엄청나게 호출되는 모습이다!! 😱

위 예제를 가지고 최적화를 해볼 계획이다. 일반적으로 드래그앤드랍에서는 성능적인 문제가 없을 것 같다. 하지만 미리보기를 띄우는 기능에서 dragover 이벤트를 활용하는데 이 이벤트는 클릭과 같이 단발성으로 끝나는 이벤트가 아니다.

드래그한 상태라면 무수히 많이 호출되는 이벤트가 바로 dragover이다. 다른 방법이 있을 수도 있지만 우선 내 예제에서는 dragover 이벤트 핸들러에서 계속해서 마우스가 위치한 좌표값을 비교하는 함수를 사용하기 때문에 이벤트 호출이 되면 될수록 성능적인 부담이 되는 구조이다.

## Drag 최적화 방법 1. throttle

다른 것보다 가장 먼저 떠오른 방법이다. 쓰로틀링과 같은 말을 어디선가 들어본 적 있을 수 있는데 컴퓨터 하드웨어 등의 분야에서 온도가 너무 올랐을 때 발열량을 줄이기 위해 일부러 클럭(작동속도)을 낮추는 등의 기술을 말한다.

이것처럼 드래그 앤 드롭 과정에서 발생하는 수많은 이벤트 핸들러의 호출을 강제로 일정 간격을 두고 실행이 되게끔하는 것이 throttle 최적화의 핵심이다.

리액트의 lodash 같은 라이브러리에서도 많이 사용하는 함수인데, 직접 구현해 보면 아래와 같다. 처음 보면 이해하기 어려울 수 있는데, 그냥 매개변수로 함수와 시간간격을 받아서 시간간격마다 함수가 실행되도록 제한하는 함수이다.

```tsx
const throttle = (func: (...args: any[]) => void, gapTime: number) => {
  // func 지연할 함수, gapTime 지연시간 간격
  let lastTime: NodeJS.Timeout | null = null;
  return (...args: any[]) => {
    if (lastTime) return;
    lastTime = setTimeout(() => {
      func(...args);
      lastTime = null;
    }, gapTime);
  };
};

```

그렇다면, 어떻게 적용할 수 있을까 아래 예시코드처럼 적용할 수 있다. 처음에는 전체 핸들러를 throttle 함수로 감싸서 이벤트 핸들러 발생을 지연시켰다. 이렇게 하면 dragover 이벤트 핸들러를 100ms (예시)에 한번 실행하도록 할 수는 있지만, 정작 중요한 Drop 이벤트가 발생하지 않는 참사가 일어난다.

이벤트 핸들러 자체를 지연시키면, 지연되는 동안(throttle에서 강제로) drop 이벤트가 호출돼도 drop 핸들러가 발동하지 않는다.

그렇다면 아래의 예시코드와는 어떤 차이가 있을까? 아래의 예시코드는 e.preventDefault()와 e.stopPropagation()을 제외한 나머지 미리보기 렌더링 로직을 별도의 함수로 분리하고 해당하는 함수에 throttle을 적용시킨 모습이다.

이렇게 하면 이벤트 핸들러는 그대로 많이 호출이 되지만, 실질적인 성능을 요구하는 작업은 딜레이를 할 수 있기 때문에 drop 이벤트의 발생도 막지 않으면서 성능을 요구하는 작업은 최적화를 진행할 수 있었다.

```tsx
const dragOverHandler = (e: DragEvent) => {
  e.preventDefault();
  e.stopPropagation();
  throttledDragOver(e);
};

const throttledDragOver = throttle((e: DragEvent) => {
  const target = e.target as HTMLElement;
  const closest = target.closest(".drop-zone");
  if (closest) {
	// dragover 되고 있는 위치가 drop-zone일 때
    const elements = [...closest.querySelectorAll(".item")] as HTMLElement[];

    // findIndex는 현재 마우스의 위치에 따라 어디에 미리보기를 넣을지 그 인덱스를 계산하는 함수
    const index = findIndex(elements, e.y);

    // 미리보기 Element
    const preview = document.querySelector(".place") as HTMLElement;

    // 미리보기 넣기
    if (elements.length - 1 === index) {
      closest.insertBefore(document.querySelector(".place"), null);
    } else {
      closest.insertBefore(document.querySelector(".place"), closest.children[index] || null);
    if (preview instanceof HTMLElement) {
      if (elements.length - 1 === index) {
        closest.appendChild(preview);
      } else {
        closest.insertBefore(preview, closest.children[index] || null);
      }
    }
  }
};
// 16ms 마다 한번 실행되도록 제한
}, 16);

```

그렇다면 최적화 후의 모습은 어떨까? 확연하게 호출량이 줄어든 것을 볼 수 있다.

쓰로틀로 지연시켰음에도 부드럽게 전환되는 미리보기 효과

![쓰로틀 최적화 결과](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1742115613091-%E1%84%8A%E1%85%B3%E1%84%85%E1%85%A9%E1%84%90%E1%85%B3%E1%86%AF%E1%84%8E%E1%85%AC%E1%84%8C%E1%85%A5%E1%86%A8%E1%84%92%E1%85%AA%E1%84%80%E1%85%A7%E1%86%AF%E1%84%80%E1%85%AA-bp04pNvXyy0zaKfuYGRQsQSyZXrGzD.gif)

쓰로틀의 지연 시간을 16ms로 한 이유는, 일반적으로 60hz 모니터는 1초에 60번의 화면을 그린다고 한다. 그렇다면 16ms * 60번이면 대충 1초가 되기 때문에 16ms가 넘지 않으면 화면이 전혀 끊겨보이지 않는다. 물론 60hz 모니터 이상이면 다를 수 있다.

## Drag 최적화 방법 2. requestAnimationFrame

이 메서드는 이전에 대충 따라서만 써봐서 학습이 필요했다. 알아보니 throttle처럼 작용한다. throttle은 개발자가 임의의 시간 간격을 넣어줘서 제한한다면, 이 requestAnimationFrame은 사용자의 주사율에 맞게 알잘딱으로 화면을 그려준다는 점이 큰 차이점이다.

그 외에도 백그라운드나 해당 요소가 숨겨지는 경우에 애니메이션을 멈추기 때문에 에너지 측면에서도 절약된다는 점이나, 브라우저 최적화로 가장 최적의 타이밍에 콜백함수를 실행하는 점이 특징이다.

그렇다면 이 기능을 써서 최적화를 해보자. 리퀘스트 애니메이션 프레임을 사용하면 쓰로틀 기법처럼 e.preventDefault()와 e.stopPropagation()을 따로 빼줄 필요가 없다.

```tsx
const dragOverHandler = (e: DragEvent) => {
  e.preventDefault();
  e.stopPropagation();
  renderPreview(e);
};

let raf: number | null = null;
const renderPreview = (e: DragEvent) => {
  if (raf !== null) {
    cancelAnimationFrame(raf);
  }

  raf = requestAnimationFrame(() => {
    const target = e.target as HTMLElement;
    const closest = target.closest(".drop-zone");
    if (closest) {
      const elements = [...closest.querySelectorAll(".item")] as HTMLElement[];
      const index = findIndex(elements, e.y);
      console.log(index);
      const preview = document.querySelector(".place") as HTMLElement;

      // 미리보기 넣기
      if (preview instanceof HTMLElement) {
        if (elements.length - 1 === index) {
          closest.appendChild(preview);
        } else {
          closest.insertBefore(preview, closest.children[index] || null);
        }
      }
    }
    raf = null;
  });
};

```

raf는 requestAnimationFrame의 id 값이 들어가거나 null 값을 가진다. 만약 raf가 id 값을 가지고 있는 상태라면(하나의 프레임이 그려지고 있는 과정) 해당 프레임이 끝날때까지 다른 프레임이 중복을 실행되지 않는다.

그렇게 하나의 프레임이 끝나면 raf를 null 값으로 바꿔서 다음 프레임을 그릴 수 있도록 만든다. 이렇게 최적화해 봤는데, 어떻게 동작할지 확인해 보자

![raf 최적화 후](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1742115664510-requestAnimationFrame%20%E1%84%8E%E1%85%AC%E1%84%8C%E1%85%A5%E1%86%A8%E1%84%92%E1%85%AA-GTUXEzZIlwV1vNAe0RlNoYr2sin9Nr.gif)

테스트를 좀 더 용이하게 하기 위해서 1초마다 한번 콘솔로그를 찍는 interval함수를 만들어 사용했다. 그렇게 확인한 결과 정말 1초에 60번 정도 실행되는 것을 확인할 수 있었다. 신기하다 😱

throttle은 16ms 이상의 시간간격으로 할 경우에는 버벅거림이 약간 생길 수 있지만 이 리퀘스트 애니메이션 프레임은 화면 주사율에 맞게 실행되기 때문에 더 효율적으로 최적화를 할 수 있는 것 같다.

정말 성능이 중요해서 16ms 이상으로 조절해야하는 경우가 아니라면 requestAnimationFrame을 사용해서 최적화를 하는 것이 더 효율적이라고 생각이 들었다.

이번 글에서 살펴본 모든 예제와 코드는 아래 깃허브 리포지토리에서 확인할 수 있다. 브랜치별로 코드가 다른데, performance/drag1 과 drag2는 각각 throttle과 requestAnimationFrame 으로 최적화한 각각의 코드를 볼 수 있다. 그리고 beforeOptimize는 최적화 전의 코드를 볼 수 있다.

[GitHub - ShipFriend0516/laboratory](https://github.com/ShipFriend0516/laboratory)

Contribute to ShipFriend0516/laboratory development by creating an account on GitHub.

최적화에는 정답이 없다고 생각해서 아마 이것보다 더 뛰어난 성능최적화 방법이 있을 것이라고 생각한다. 이번 글에서는 대표적으로 쉽게 활용할 수 있는 2가지 최적화 방법에 대해서 알아봤다. 👍]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[미디어 장치 권한이 없을 때 대응하기]]></title>
            <link>https://shipfriend.dev/posts/미디어-장치-권한이-없을-때-대응하기</link>
            <guid>https://shipfriend.dev/posts/미디어-장치-권한이-없을-때-대응하기</guid>
            <pubDate>Wed, 12 Mar 2025 05:24:55 GMT</pubDate>
            <description><![CDATA[navigator.mediaDevices.enumerateDevices()]]></description>
            <content:encoded><![CDATA[# 미디어 장치 권한이 없을 때 대응하기

미디어 장치 권한을 허락받지 않았을 때 대응하기

## 현재 상황
<aside>📌

 미디어 장치 권한을 수락하지 않았을 때 미디어 장치 목록에 접근이 불가능함</aside>


## 현재 코드

useMediaDevices 커스텀 훅에서 미디어 장치들을 제어하고 있는다. 원래 의도대로라면 미디어 장치를 권한 이슈로 불러오지 못하면 에러가 뜨거나 아예 장치가 [] 빈 배열로 내려오는 것이었다.

```tsx
  useEffect(() => {
    // 비디오 디바이스 목록 가져오기
    const getUserDevices = async () => {
      try {
        const devices = await navigator.mediaDevices.enumerateDevices();
        const audioDevices = devices.filter(
          (device) => device.kind === "audioinput"
        );
        const videoDevices = devices.filter(
          (device) => device.kind === "videoinput"
        );
        setUserAudioDevices(audioDevices);
        setUserVideoDevices(videoDevices);
      } catch (error) {
        console.error("미디어 기기를 찾는데 문제가 발생했습니다.", error);
      }
    };

    getUserDevices();
  }, []);
```

하지만 이 코드에서 에러는 발생하지 않고, 그렇다고 빈배열이 내려오는 것도 아니었다.

```tsx
[
    {
        "deviceId": "",
        "kind": "audioinput",
        "label": "",
        "groupId": ""
    },
    {
        "deviceId": "",
        "kind": "videoinput",
        "label": "",
        "groupId": ""
    },
    {
        "deviceId": "",
        "kind": "audiooutput",
        "label": "",
        "groupId": ""
    }
]
```

실제 컴퓨터에서 미디어 장치가 3개 이상 있었고, 해당 장치들은 권한이 허락되어있지 않아도 위와 같이 숨겨진 채로 정보가 내려왔다.

> 📌 **검증**
> 
>     검증하기 위해 검색해서 알아본 결과 미디어 장치 목록을 가져오는 것은 권한이 없어도 오류가 나지 않고 모두 빈 문자열 값으로 치환되어 내려온다는 것을 알게 되었다.

</aside>

## 해결

```tsx
const dontHavePermission = devices.find((device) => device.deviceId !== "") === undefined;
if (dontHavePermission) {
  setUserAudioDevices([]);
  setUserVideoDevices([]);
} else {
  setUserAudioDevices(audioDevices);
  setUserVideoDevices(videoDevices);
}
```

`dontHavePermission` : 디바이스 목록들이 모두 deviceId가 빈 문자열일 경우에 true 하나라도 있으면 false를 반환

이제 권한이 없어도 제대로 작동하는 것을 확인했다.

![image.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1741757078147-123123%E3%85%81%E3%85%87%E3%84%B9%E3%85%81%E3%84%B9-i2EzvFyyLNd9tSOyd9Jk6Axpkdy0s0.png)]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[다크모드 FOUC 깜빡임 현상 수정하기]]></title>
            <link>https://shipfriend.dev/posts/다크모드-fouc-깜빡임-현상-수정하기</link>
            <guid>https://shipfriend.dev/posts/다크모드-fouc-깜빡임-현상-수정하기</guid>
            <pubDate>Mon, 10 Mar 2025 07:34:55 GMT</pubDate>
            <description><![CDATA[dangerouslySetInnerHTML]]></description>
            <content:encoded><![CDATA[# 다크모드 FOUC 문제 해결하기
![썸네일](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1746162765796-darkmode1-kNc8JzjAw46ZuFxTfBdfe4BreJFLyW.webp)

## 문제 상황

블로그에 다크모드를 만들었다. 다크모드를 만들 때 마다 느꼈지만, 새로고침할 때마다 다크모드가 한타임 늦게 적용돼서 생기는 눈뽕현상이 아주 마음에 들지 않았다.

찾아보니 왜 Flash of Unstyled Content, FOUC 문제라고 불리는 현상이었다. 새로고침하고 나서 다크모드 여부를 해석하고 다크모드 스타일을 적용하기 까지의 딜레이가 있어서 생기는 문제였다.

어두운 밤에 다크모드를 쓰고 있는데 페이지 이동하거나 새로고침할 때마다 배경이 0.5초 흰색으로 변하게 된다면 눈이 아플 수 있다. 이처럼 사용자 경험에 안좋은 영향을 주는 문제이다.

![새로고침할때마다 깜빡임](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1741592028828-Mar-10-2025%2016-33-27-DcE06kbHboyLRj6SX16mPvSw3fE2rr.gif)

해결하기 위한 방법으로 dangerouslySetInnerHTML를 사용했다. 직역 그대로 위험하게 innerHTML 설정하기인데, 왜 위험한지와 어떻게 해결했는지를 적어보려한다.

## Why dangerouslySetInnerHTML?

왜 위험하다고 하는걸까? 왠지 XSS 느낌이 나기도 한다. 리액트에서는 직접 돔요소를 제어하는 일이 적다. 생각해보면 순수 자바스크립트를 쓸 때는 `querySelector()`를 사용해서 돔 요소를 찾고 `$element.innerHTML = 'dfadfasfd'` 이런 식으로 자주 썼다. 하지만 이런 걸 리액트에서 하려면 아래처럼 해야한다.

```tsx
// 아래와 같은 식으로 사용
<script dangerouslySetInnerHTML={} />
<article dangerouslySetInnerHTML={} />

```

찾아보니 리액트에서 위험하다고 표시하는 이유는 XSS 공격에 취약해서라고 한다.

> XSS란 악의적인 스크립트를 포함한 html를 삽입해서 해당 스크립트를 실행시키는 공격이다.
> 

이렇게만 보면 왜 위험하다는건지 잘 와닿지 않는데 아래 예시를 보고 이해해보자.

```tsx
<div>
  <h2>댓글</h2>
    
  {/* 댓글 목록 표시 - XSS 취약점! */}
  <div className="comments-list">
    {comments.map((comment, index) => (
      <div 
        key={index}
        // 위험한 부분: 사용자 입력을 직접 HTML로 렌더링
        dangerouslySetInnerHTML={{ __html: comment }}
      />
    ))}
  </div>
  
  <form onSubmit={handleSubmit}>
    <textarea
      value={newComment}
      onChange={(e) => setNewComment(e.target.value)}
      placeholder="댓글을 입력하세요"
    />
    <button type="submit">댓글 게시</button>
  </form>
</div>
```

이제 이해가 좀 됐다. XSS에 취약하다는 것은 dangerouslySetInnerHTML에 사용할 문자열의 입력 권한을 사용자 혹은 제 3자에게 주는 코드가 위험하다는 의미이다.

만약 위의 코드에서 악의적인 사용자가 정상적인 댓글이 아닌 alert(’댓글’)을 작성하게 되면 해당 댓글이 작성되는 것이 아니라 코드가 실행될 수 있다.

실제로 위 컴포넌트에 댓글로 아래 코드를 입력하게 되면 alert가 실행된다…. ㄷㄷㄷ

```tsx
<iframe src="javascript:alert('iframe을 통한 XSS')"></iframe>
```

![image.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1741591932348-alert-FavGPl02pSmjjIqqfjHGc3AG2w1wWM.png)

### 전달해야하는 값

dangerouslySetInnerHTML 속성에 전달해야하는 값으로는 객체가 들어간다. 이 객체에 `__html`이라는 키를 반드시 포함해야한다.

```tsx
<div dangerouslySetInnerHTML={{__html: 문자열}} />
```

왜 __html이라는 키를 명시적으로 사용해야하는지를 찾아봤다. 이 객체 형식을 사용하는 이유는 일단 명시적으로 __html이라는 키를 통해 html을 삽입한다는 것을 알리는 역할을 한다. 그리고 객체를 사용한 이유는 문자열은 실수로 의도하지 않게 들어갈 수 있지만 객체는 조금 더 개발자의 의도가 반영되었다고 판단해서라고 한다.

그리고 리액트는 가상 돔을 사용하기 때문에 리액트에서 원하는 흐름으로 렌더링되어야한다. 이 속성은 조금 예외적인 메서드이기 때문에 우회해서 html을 삽입한다. 그래서 __html이라는 키 값을 사용해서 위험성을 개발자에게 인지시킨다.

## 다크모드 해결하기

필요한 개념은 학습했다. 다크모드의 플래시 현상을 수정해보자. 플래시 현상이 일어나는 이유는 아래와 같다.

- html 문서 렌더링
- 기본 배경 색상 적용되어있음 (라이트모드)
- 클라이언트 코드 다운 및 실행
- useTheme의 코드가 실행되고 로컬스토리지 값에 따른 테마 모드를 적용 (다크모드)

이 과정에서 기본 배경 색상이 라이트모드이기 때문에 플래시 현상이 일어난다.

### 해결방법

html 최상단에 인라인 스크립트를 작성하는 방식이다. 해당 방식으로 페이지가 렌더링 되는 초기에 배경색을 다크모드에 맞게 변경해줘서 사용자가 페이지를 보기 시작하는 시점 전부터 배경 색을 설정해두는 방식이다.

이 방식을 사용하기 위해서는 최상단 파일 <head>태그 안에 <script> 태그를 넣어줘야한다. 이번에 블로그에 넣은 스크립트는 아래와 같다. head 태그 내에 script 태그를 넣고, 해당 태그에 아래 스크립트를 넣어줬다.

간단하게 설명하면 `localStorage`에 저장된 `theme-storage` 테마 정보를 사용해서 `isDark`를 계산하고, 이에 따라 문서 레벨에 `dark` 클래스를 추가한다. 그리고 바로 적용하기 위해서 `style.backgroundColor`를 조절해주는 방식으로 해결했다.

```tsx
const darkmodeFOUCScript = `
  (function() {
    const savedTheme = JSON.parse(localStorage.getItem('theme-storage')).state.theme;
    const isDark = savedTheme === 'dark';
    if (isDark) {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
    document.documentElement.style.backgroundColor = isDark ? '#1e201e' : '#ffffff';
  })();
`;
            
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ko">
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: darkmodeFOUCScript
          }}
        />
      </head>
      <body
        className={`${pretendard.variable} font-sans antialiased min-h-screen flex flex-col justify-between`}
      >
        <NavBar />
        <main className="flex-grow pb-20">{children}</main>
        <Footer />
        <ToastProvider />
        <GoogleAnalytics gaId={'G-8HJPFDHXEC'} />
      </body>
    </html>
  );
}
```

## 이 외의 방법

이 방법 외에도 `next-themes` 같은 라이브러리를 사용해서 테마 모드를 구현하면 자동으로 필요한 스크립트를 html 문서에 inject해주기 때문에 플래시 문제가 발생하지 않는다고 한다 

> **The Flash**
ThemeProvider automatically injects a script into `next/head` to update the `html` element with the correct attributes before the rest of your page loads. This means the page will not flash under any circumstances, including forced themes, system theme, multiple themes, and incognito. No `noflash.js` required.

그러나 나는 간단한 테마 모드를 적용하기 위해 새로운 의존성 패키지를 설치하는 것은 좋지 않다고 판단해서 직접 구현했다. ]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[WebRTC 개념 정리]]></title>
            <link>https://shipfriend.dev/posts/webrtc-개념-정리</link>
            <guid>https://shipfriend.dev/posts/webrtc-개념-정리</guid>
            <pubDate>Thu, 06 Mar 2025 11:40:39 GMT</pubDate>
            <description><![CDATA[미디어 스트림과 WebRTC]]></description>
            <content:encoded><![CDATA[작년 부캠 그룹 프로젝트에서 우리 팀이 만든 프리뷰를 리팩토링해보기로 결정했다. 이번에는 안 해본 것들 위주로 개선해보려고 했다. 특히 멘토님이 조언해주신 네트워크 관련된 최적화에 도전해보고 싶다고 생각했다.

리팩토링하려고 도메인 지식에 대해서 리마인드해봤다. 6주라는 짧은 시간에 만들어야했기 때문에 도메인 지식에 대한 학습이 미흡했기도 했고, 이번에 제대로 공부해보는게 좋을 것 같아 다시 학습하기로 결정했다.

## 미디어 스트림

미디어 스트림은 오디오나 비디오 같은 미디어 데이터를 스트리밍하는 기술이다. WebRTC 관련 API 중 MediaStream API를 사용하여 구현할 수 있다.

하나의 미디어 스트림 객체에는 트랙이라는 것이 존재하는데, 오디오 트랙, 비디오 트랙 등이 있다.

미디어 스트림에는 하나의 입력과 하나의 출력을 가진다. getUserMedia() 같은 API로 가져온 사용자의 미디어 장치를 입력 출처로 사용하고, <video/>, <audio/> 태그 등을 이용해 출력한다.

## WebRTC

WebRTC는 Web Realtime Communication이라는 뜻으로, 브라우저 간 오디오나 영상 미디어를 포착하고 스트림할 수 있게 해주는 기술이다. 꼭 미디어 데이터가 아니어도 임의의 데이터도 교환할 수 있다. 

현재 프로젝트에 구현되어있는 기능 중 마이크 on&off시 다른 peer 들에게 알리는 기능이 있다. 이 기능은 데이터 채널을 사용해서 서로의 미디어 장치 상태를 교환하는 방식으로 구현되어있다.

특별한 드라이버나 플러그인 없이 웹 API만으로 피어 커넥션을 만들 수 있다는 점이 가장 큰 장점인 것 같다.

특정 두 명이 서로 화상 통화를 한다고 가정했을 때, 두 피어 간의 커넥션은 `RTCPeerConnection` 인터페이스를 통해 이루어진다. 피어 간에 커넥션이 수립이 되면 해당 커넥션에 미디어 스트림 객체를 연결하여 서로 미디어를 전송하고 받을 수 있다.

WebRTC 중 개인적으로 어렵다고 느낀 부분은 커넥션 수립 과정이었다. `offer`, `answer`, `sdp` 등 다소 생소한 개념들이 많이 나오기 때문이다. 

간단하게 설명하면 offer는 연결을 제안하는 과정, answer는 제안을 수락할지 말지에 대한 것, sdp는 서로의 연결 과정에서 필요한 데이터 (코덱 정보, 화질 등)이다.

### 연결 과정

WebRTC는 피어 간 직접 통신을 위한 기술이지만, 초기 연결을 위해 서로를 찾는 메커니즘은 포함되어 있지 않다. (전화번호를 알아야 전화를 할 수 있는 것처럼) 그래서 서로를 찾을 수 있도록 해주는 몇가지 도구들이 있다.

- 시그널링 서버
    - 피어들이 SDP와 ICE 후보를 교환할 수 있도록 해주는 중간 매개체 역할
- STUN 서버
    - NAT 뒤에 있는 진짜 IP 주소를 확인해주는 목적
- TURN 서버
    - 직접 연결이 불가능한 경우, 방화벽이 있을 경우 중개 서버 역할

이런 도구들을 사용해서 연결하는 과정은 다음과 같다.

1. 서로는 시그널링 서버를 통해 서로를 식별한다. 
2. 서로 연결하고자 한다면, 시그널링 서버를 중개해 offer를 보내고, answer를 진행하며 서로 sdp를 교환한다.
3. ice candidate를 진행한다. ice는 네트워크 상에서 서로 연결하기 위한 최적의 경로를 찾기 위한 프레임워크이다. 

<aside>
👻

candidate(후보)가 붙은 이유는 여러가지 경로가 존재하고 그 중 가장 최적의 경로를 찾기 위해서 붙여졌다.

</aside>

1. 최적의 연결 경로를 발견하면, 서로 peerConnection이 수립된다.
2. 커넥션 수립 이후부터는 미디어 스트림이나 임의의 데이터를 중간자 없이 송수신할 수 있게 된다.]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[프리뷰 | 네트워크 기반 동적 품질 조절 기능 개발 1편]]></title>
            <link>https://shipfriend.dev/posts/네트워크-품질의-성능-지표에-대해서</link>
            <guid>https://shipfriend.dev/posts/네트워크-품질의-성능-지표에-대해서</guid>
            <pubDate>Tue, 04 Mar 2025 05:11:11 GMT</pubDate>
            <description><![CDATA[네트워크 품질의 성능 지표에 대해서]]></description>
            <content:encoded><![CDATA[작년 부스트캠프에서 진행했던 프로젝트 WebRTC 화상통화 기반 Preview 프로젝트를 리팩토링하기로 결정했다.

이번에 새롭게 만들어볼 기능은 네트워크 품질에 따른 동적 화질 조절 기능이기 때문에 네트워크 CS를 좀 더 깊게 공부해보면서 만들어보기로 했다.

네트워크 모니터링을 해야하는데, 어떤 지표를 모니터링해야할지 알아야 할 것 같아서 이에 대해 공부해봤다.

# 네트워크 성능 지표

네트워크의 성능을 측정하는 지표로는 여러가지가 사용된다. 대표적으로 지연시간, 지터, 패킷 손실률, 대역폭, RTT 등이 있다. 

- 지연 시간: 데이터가 전송되는데 걸리는 시간
- 지터: 지연시간의 변동성
- 패킷 손실률: 데이터 전송 중 손실되는 데이터 패킷의 비율
- 대역폭: 한번에 네트워크를 통과할 수 있는 데이터 볼륨
- RTT(Round-Trip Time): 지연시간의 일종이지만, 데이터가 전송됐다가 응답되는 시간을 기록한 것

좋은 네트워크 성능은 지연시간이 짧고 변동성이 적으면서 패킷 손실률이 적은 네트워크이다. 반대의 경우는 좋지 못한 네트워크 품질이라고 할 수 있다.

## 지연시간

네트워크 지연의 원인은 여러가지가 있다. 전송 매체가 무선인지 유선인지에 따라 달라질 수 있고 네트워크 트래픽 이동 거리가 길어지면 길어질 수록 지연이 길어진다. 또한 전송하는 데이터의 볼륨이 커질 수록 지연시간이 길어진다. 그 외에도 서버 성능, 네트워크 홉 수 등이 영향을 미친다.

만약 우리 프로젝트에서 지연시간이 길어지면 어떻게 될까? 실시간 화상통화인데 실시간이 아니게 될 수 있다. 지연시간을 줄이기 위해서 전송하는 미디어의 품질과 수신받는 미디어의 품질을 동적으로 조절하는 방식으로 피어커넥션의 지연시간을 줄여볼 수 있을 것 같다.

## 지터

지터는 처음 들어보는 개념이었다. 지터는 지연시간의 변동성을 말한다고 한다. 지터는 네트워크 정체나 타이밍 드리프트 또는 경로 변경 등의 이유로 연속된 데이터 패킷 간의 도착 시간 차이의 변화량을 말한다.

지터가 작을 수록 좋은 네트워크인 것이다. 지터가 커지면 WebRTC에서는 목소리가 로봇처럼 들린다던가 화면이 깨지는 현상 등이 발생한다고 한다.

## 패킷 손실률

패킷 손실률은 말그대로 패킷이 손실된 비율을 의미한다. 그렇다면 왜 발생하는걸까?

- 네트워크 정체
    - 네트워크가 트래픽으로 정체되어 최대 용량에 도달하게 되면 더이상 패킷을 저장할 수 없게 되고 네트워크가 따라 잡을 수 있도록 폐기될 수 있다고 한다.
- 하드웨어 문제
    - 오래된 네트워크 하드웨어는 트래픽을 느리게 할 수 있고 패킷 손실로도 이어질 수 있다고 한다.

문서 같은 정적인 데이터의 경우에 패킷이 손실되도 다운로드 속도가 느려지는 정도지만, WebRTC 같은 실시간성이 요구되는 데이터 교환에서는 아주 약간의 패킷 손실률만으로도 불쾌감을 느낄 정도로 사용자 경험이 안좋아질 수 있다고 한다.

## WebRTC에서의 모니터링

WebRTC의 RTCPeerConnection API 중 getStats() 메서드를 사용하면 현재 커넥션의 여러가지 정보를 모니터링할 수 있도록 Report를 반환한다. 

이 Report 객체에는 지터 등의 네트워크 품질 지표가 포함되어있다. 이런 지표들을 일정 시간마다 반복하면서 모니터링하면서 최근 네트워크 품질이 낮아지게 되면 동적으로 사용자의 비디오 품질을 조절하는 방식으로 최적화를 해보려고 한다.

### 코드 예제

getStats() 메서드는 Promise로 반환되기 때문에 then이나 await을 써줘야한다. 처음에 console.log(stats)를 찍었을 때 결과 값이 안나와서 당황했는데, getStats()의 반환값인 StatReport는 Map과 같은 이터러블한 객체이기 때문에 아래와 같은 식으로 꺼내서 볼 수 있었다.

```tsx
setInterval(()=> {
  pc.getStats().then(stats =>
  {
    console.log('스탯 보고서')
    stats.forEach(stat=> {
      console.log(stat)
    })
  })
}, 10000)
```

무수히 많이 찍히는 모니터링 결과 보고서..

![image.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1741065016162-%E1%84%82%E1%85%A6%E1%84%90%E1%85%B3%E1%84%8B%E1%85%AF%E1%84%8F%E1%85%B3-cauGMahvdSp5VZgm5HB3tyPW9v2Ql5.png)

---
다음 글에서는 실제로 네트워크 품질 모니터링을 위한 기획과 구현에 대해서 작성해봐야겠다.

> 다음 글

[프리뷰 | 네트워크 기반 동적 품질 조절 기능 개발 2편](/posts/%ED%94%84%EB%A6%AC%EB%B7%B0-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EA%B8%B0%EB%B0%98-%EB%8F%99%EC%A0%81-%ED%92%88%EC%A7%88-%EC%A1%B0%EC%A0%88-%EA%B8%B0%EB%8A%A5-%EA%B0%9C%EB%B0%9C-2%ED%8E%B8)]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[1년된 프로젝트 리팩토링하면서 생긴 일]]></title>
            <link>https://shipfriend.dev/posts/1년된-프로젝트-리팩토링하면서-생긴-일</link>
            <guid>https://shipfriend.dev/posts/1년된-프로젝트-리팩토링하면서-생긴-일</guid>
            <pubDate>Sat, 01 Feb 2025 11:19:32 GMT</pubDate>
            <description><![CDATA[1년새 업데이트 되어버린 내 의존성 패키지들..]]></description>
            <content:encoded><![CDATA[프론트엔드 개발은 라이브러리가 풍부해서 좋아한다. 빠르게 변화한다고 말로만은 알고 있었지만, 체감은 하지 못했었다. 하지만 이번에 1년이 넘어간 프로젝트를 리팩토링하게 되면서 몸으로 체득하게 되었다.

프리미티브 프로젝트는 작년에 동아리 홍보를 진행하기 위해서 수행한 프로젝트이다. 이 프로젝트는 프리미티브 동아리를 홍보하기도 하고(주목적), 동아리원들이 프로젝트 공유를  할 수 있는 플랫폼으로 만드는 것을 원했다. 

작년에 만든 기능들을 간단하게 말해보면 홍보 페이지, 동아리 입부 신청서 다운로드 기능, 진행한 프로젝트 전시 기능, 어드민 승인 기반 회원가입 기능 등 많은 기능을 만들었었다.

해당 프로젝트는 한 번의 대규모 리팩토링을 겪은 프로젝트이다. 처음에는 JavaScript + Webpack 프로젝트로 시작해서, 프로젝트 시작 후 4~5개월 후 TypeScript + Vite로 마이그레이션을 진행하며 이 시기에 프로젝트 업로드 기능을 구현했다.

[프리미티브 공식 홈페이지](https://primitive.kr/) 

## 왜 실행이 안돼..? (dependency 패키지 문제)

글 작성 시점 기준 1년이 넘어가는 현재 시점 2025년의 프리미티브에 맞게 프로젝트를 리팩토링하게 되었다. 리팩토링하려고 묵혀둔 github repository를 clone해서 열어봤을 때 바로 다시 노트북을 덮게 만든 요소가 있다. 바로 아래와 같은 장문의 에러메시지였다. `npm install` 명령을 실행했을 때의 결과이다. 

```tsx
npm ERR! code ERESOLVE
npm ERR! ERESOLVE unable to resolve dependency tree
npm ERR! 
npm ERR! While resolving: primitive@0.1.0
npm ERR! Found: react@19.0.0
npm ERR! node_modules/react
npm ERR!   react@"^19.0.0" from the root project
npm ERR! 
npm ERR! Could not resolve dependency:
npm ERR! peer react@"^18.3.1" from react-dom@18.3.1
npm ERR! node_modules/react-dom
npm ERR!   react-dom@"^18.2.0" from the root project
npm ERR! 
npm ERR! Fix the upstream dependency conflict, or retry
npm ERR! this command with --force or --legacy-peer-deps
npm ERR! to accept an incorrect (and potentially broken) dependency resolution.
npm ERR! 
npm ERR! 
npm ERR! For a full report see:
npm ERR! /Users/jeongwoo/.npm/_logs/2025-01-29T17_03_36_111Z-eresolve-report.txt
npm ERR! A complete log of this run can be found in: /Users/jeongwoo/.npm/_logs/2025-01-29T17_03_36_111Z-debug-0.log
```

사실 이전에 프로젝트 업로드 기능을 만들때, 에디터를 외부 라이브러리를 사용했다. 이 라이브러리는 그때 당시 최신 리액트를 지원하지 않기도 하고 여러 라이브러리랑 버전 충돌이 일어났었다. 하지만 그때는 잘 몰랐기도 했고, 빠르게 만드는 것이 모토였기 때문에 그냥 감행했던 기억이 난다.

일단 프로젝트를 실행하기 위한 의존성 설치에서 막혀서 이걸 해결해야했다. 찾아보니 vite도 메이저 버전이 올라가기도 하고, react는 2단계나 메이저 버전이 올라가있었다. 

```tsx
 "dependencies": {
    "@testing-library/jest-dom": "^5.17.0",
    "@testing-library/react": "^13.4.0",
    "@testing-library/user-event": "^13.5.0",
    "@types/jest": "^29.5.12",
    "@types/lodash": "^4.17.4",
    "@types/react-dom": "^18.3.0",
    "dompurify": "^3.2.3",
    "firebase": "^10.12.0",
    "lodash": "^4.17.21",
    "postcss-nesting": "^12.1.5",
    "quill": "^2.0.2",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-icons": "^5.0.1",
    "react-quill": "^2.0.0",
    "react-router-dom": "^6.21.1",
    "react-spring": "^9.7.3",
    "styled-components": "^6.1.11",
    "web-vitals": "^2.1.4",
    "zustand": "^4.5.2"
  },
```

html태그를 안전하게 inject해주는 용도로 사용했던 domfurify도 작년까지만 해도 @types/domfurify 라는 타입패키지를 별도로 설치했어야 했다. 이제는 domfurify 라이브러리에 내장되어서 필요없어졌다고 한다.

작년 네이버 부스트캠프를 하면서 pnpm의 장점을 많이 느껴서 pnpm으로 바꾸기 위해 package-lock 도 삭제하고, npm을 사용하지 않게 되었다. `pnpm outdated` 명령을 치면 아래처럼 깔끔하게 새로운 버전이 나온 패키지를 명시해준다. 일부는 이미 업데이트해서 아래에는 나오지 않지만, 필요에 따라 업데이트해볼 예정이다.

![image.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1738408719792-%EC%8A%A4%ED%81%AC%EB%A6%B0%EC%83%B7%202025-01-22%20185958-hfdvSOxDuQtlczUYI2qUYWvArS281U.png)

## 코드 품질

이전에는 사실 코드 분리에 대해 필요성을 크게 느끼지 못했다. 생각해보면 파일 단위로 이동하는게 귀찮았던 것도 같고, 몰랐기 때문이라고 생각한다.

프미 프로젝트 이후 몇번의 프로젝트를 해보고, 특히 네부캠 그룹 프로젝트 이후 파일 단위로 분리하는게 더 관리하기 편하고, 무엇보다 은근 나누는게 재미있었기 때문에 습관적으로 폴더 단위로 나누게 되었다.

하지만 이 프로젝트는 그 이전이기 때문에 아래처럼 Components 폴더 아래 다 들어가있다……. 이걸 분리하는 것부터 시작해야할 것 같다.

![image.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1738408766264-image-7wIU0OKH6fvWmnEX4FaIzsu4SCsmnK.png)

이 당시에만 해도 커스텀 훅의 장점을 몰랐기 때문에 잘 안썼었는데, 일부 로직들은 훅으로 분리해서 재사용해보는 것도 이번 리팩토링의 목표이다.

1년이 지나 다시보니 정말 새로운 기분이다. 확실히 가독성 부분에서 업데이트가 많이 필요해보인다고 느꼈다.

## 이번에 리팩토링한 것은
이번에는 2025년에 입학하는 신입생들을 위한 새로운 자료를 업데이트하고, 코드레벨 단위로 리팩토링을 진행했다.

## 느낀 점

이번에 리팩토링을 하면서 느낀 것은 지난 1년간 코드레벨 차원에서 많이 성장한게 느껴졌다는 점이었다. 이전에는 코드 분리라던지 폴더구조에 대해 고민을 전혀 안했었는데, 이후 프로젝트를 해보면서 많이 고민했었고, 이제는 어느 정도 가독성 좋은 코드를 작성할 수 있게 된 것 같아서 좋았다.

사실 이 프로젝트는 아직 끝이 아니라 어느정도 추가해보고 싶은 기능이나 디자인 개선 등 여러 개선의 여지가 있는 프로젝트이다. 2월 중으로 차근차근 개선해보고 싶다.]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[Vercel이 말아주는 이미지 CDN 사용해보기]]></title>
            <link>https://shipfriend.dev/posts/vercel이-말아주는-이미지-cdn-사용해보기</link>
            <guid>https://shipfriend.dev/posts/vercel이-말아주는-이미지-cdn-사용해보기</guid>
            <pubDate>Mon, 13 Jan 2025 12:24:56 GMT</pubDate>
            <description><![CDATA[이미지 업로드 with Vercel Blobs ]]></description>
            <content:encoded><![CDATA[# Vercel이 말아주는 이미지 CDN 사용해보기

![Vercel Logo](https://cdn.prod.website-files.com/64c7a317aea92912392c0420/64e6097303f89560552bb8e6_Vercel_2x.webp)

티스토리나 벨로그 같은 블로그 서비스는 이미지를 복사하고 에디터에 붙여넣기를 할 경우에 이미지가 자체 cdn에 업로드되는 것을 볼 수 있다. 이 기능은 글을 작성할 때 사용자가 원하는 이미지를 바로 붙여넣을 수 있기 때문에 블로그에게는 필수기능이라고 생각이 든다.

```tsx
ex) https://velog.velcdn.com/images/shipfriend/post/e2c95daa-397b-4484-a800-31327932598b/image.gif
```

이전까지 내 블로그는 이미지의 링크를 붙여넣는 것만 가능했다. 사실 내부적으로는 vercel의 blobs라는 cdn을 사용하고 있었다. 하지만 **순수 노가다**로 이미지를 업로드하고 링크를 붙여넣었기 때문에 실질적인 업로드 기능이 없었다.

cdn으로는 firebase 정도만 써봤었는데 찾아보니 vercel에서 storage -> blobs라는 이미지 저장소 기능을 지원하는 것을 알게 되었다. (vercel은 참 좋다.)

---

## CDN이란?

**CDN은 Content Delivery Network의 약자**로, 데이터 사용량이 높은 여러 컨텐츠들을 빠르게 업로드하고 빠르게 전송 받을 수 있게 해주는 고마운 녀석이다. 

![CDN이미지](https://library.gabia.com/wp-content/uploads/2020/10/CDN_834X550.png)

### CDN과 일반 DB의 차이?

CDN과 일반 DB의 차이는 아래와 같다.

- 목적
- 데이터 특성
- 분산 방식

가장 큰 차이점은 위치와 개수인 **분산 방식**에 있다. 일반 DB는 특정 위치에 고정적으로 몇 군데만 존재할 수 있지만 CDN은 전세계에 걸쳐 분산된 서버들을 사용한다는 점이 가장 큰 차이점이다.

분산된 서버를 사용하기 때문에 사용자가 있는 위치와 가장 가까운 서버를 사용한다는 특징이 있다. CDN의 성능을 올리기 위해 지역별로 캐싱을 하는 등의 성능 최적화를 한다고 한다.

그리고 **정적인 자산**을 저장하는데에 CDN이 사용된다. 정적인 자산은 사진이나 영상과 같이 한 번 저장되면 잘 변경되지 않는 자산을 의미한다. 일반적인 DB에는 자주 변경되고, 자주 추가되는 데이터들이 저장된다.

### DB에 이미지를 저장하지 않는 이유

처음에는 DB에 이미지를 저장하고 꺼내서 쓰면 되지 않나라고 생각했었다. 그러나 배포를 몇 번해보고 나서 깨달은 점은 이미지나 폰트 파일의 정적 서빙이 은근 **대역폭 비용을 많이 차지한다**는 점이었다.

만약 서비스의 DB에 이미지를 저장하고 서빙한다면, 매번 DB 요청과 함께 무거운 이미지 파일을 전송하게 되는데, 이렇게 되면 비용이 많이 나갈 수 밖에 없다. 이런 비용을 외부 CDN을 사용함으로써 절감할 수 있는 것이다. 단점이라면 CDN 서비스에 의존적이라는 점이지만..

---
## Vercel Blobs

vercel blobs는 vercel에서 지원하는 미디어를 저장하는 서버이다. 사실 CDN이라고는 했지만 Vercel Blobs는 CDN의 특성을 일부 가지고 있을 뿐 CDN은 아니다. AWS의 S3 인스턴스와 비슷한 역할을 한다고 한다. 

무료 플랜의 경우 250MB를 무료로 지원한다. 적은 용량이지만 한번 사용해보는 목적으로는 적절하다고 생각했다.

사용하기 위해서는 vercel 배포 대시보드에서 Storage → Create Database 버튼 클릭 후 Blob을 선택하고 계속하면 된다. 

![Capture-2025-01-13-190325.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1736770906488-Capture-2025-01-13-190325-a96ie3xWMTC9GTMvhC6BQeHE6BeieM.png)

Blob 데이터베이스를 만들고 나면 `BLOB_READ_WRITE_TOKEN` 을 주는데 이건 env 파일에 적고 유출되지 않도록 하자. firebase 같은 경우에는 이런 Token을 process.env.TOKEN 이런 식으로 코드 내에서 사용해야하는데, blobs는 nextjs에 최적화되어있다보니 간단하게 라이브러리 깔아서 써도 내부적으로 env파일을 참조한다.

## 내 블로그에서의 활용

내 블로그에서는 글을 작성할 때 이미지를 업로드할 수 있게 했다. 지금은 업로드 버튼을 눌러야만 업로드가 가능하지만, 추후에 그냥 글 중간에 붙여넣기해도 업로드 될 수 있게 해보려고 한다.

문서화가 잘되어있는 nextjs 덕분에 vercel blobs 업로드도 쉽게 해볼 수 있었다.

[Vercel Blobs 문서](https://vercel.com/docs/storage/vercel-blob/client-upload)

이 문서에서 잘 나와있다. 클라이언트 사이드 업로드와 서버 사이드 업로드로 나뉘는데, 클라이언트 측에서 업로드하는 방식을 선택했다.

클라이언트 사이드에서 업로드를 할 경우 file type의 input의 onChange 이벤트로 발생하는 event의 file를 업로드 시도하는 이벤트 핸들러를 만들어주고, next server route로는 클라이언트에서 업로드된 파일을 vercel blobs에 저장하는 api route를 만들어주는 방식이다.

```tsx
// image upload change 이벤트 핸들러
const uploadToBlob = async (event: ChangeEvent) => {
  try {
    event.preventDefault();
    const target = event.target as HTMLInputElement;
    if (!target.files) {
      throw new Error('이미지가 선택되지 않았습니다.');
    }

    const file = target.files[0];
    const timestamp = new Date().getTime();
    const pathname = `/images/${timestamp}-${file.name}`;
    
    // pathname 저장되는 경로와 파일이름, 중복 방지를 위해 timestamp를 추가했다. 
    const newBlob = await upload(pathname, file, {
      access: 'public',
      handleUploadUrl: '/api/upload',
    });

    setUploadedImages([...uploadedImages, newBlob.url]);
    return;
  } catch (error) {
    console.error('업로드 실패:', error);
    throw error;
  }
};
```

이미지를 업로드하면 이렇게 업로드된 이미지를 볼 수 있고 이미지를 클릭해서 링크를 복사할 수 있도록 했다.

![업로드 화면](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/1736771392849-Jan-13-2025%2021-28-23-zwY1uCkEXMCLwsG8jf61VG4rL45HyN.gif)

### 남아있는 문제들

**글을 쓰다가 나가면, 이미지 링크를 가져올 방법이 없다는 점**이다. 이건 사실 다른 블로그도 똑같지만, 용량이 적으니까 아껴쓰던지 아니면 임시저장 기능을 만들어야할 것 같다.

아니면 글에서 사용되지 않는 사진의 경우 가비지 컬렉팅하는 기능을 만들어봐도 좋을 것 같다.

같은 사진을 한번 더 업로드할 경우에 대한 대비도 생각해봐야할 것 같다.]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[내가 만드는 블로그로 알아보는 SEO 최적화]]></title>
            <link>https://shipfriend.dev/posts/내가-만드는-블로그로-알아보는-seo-최적화</link>
            <guid>https://shipfriend.dev/posts/내가-만드는-블로그로-알아보는-seo-최적화</guid>
            <pubDate>Thu, 19 Dec 2024 17:39:22 GMT</pubDate>
            <description><![CDATA[Nextjs 검색엔진 최적화]]></description>
            <content:encoded><![CDATA[# SEO 최적화를 위해 해야할 것들
블로그이기 때문에 검색엔진 최적화를 해보기에 적합하다고 생각했고, Nextjs를 선택한 이유기도 하기 때문에 검색엔진 최적화를 결정했다. 

## 블로그 주소 최적화

[구글이 권장하는 URl 구조](https://developers.google.com/search/docs/crawling-indexing/url-structure?hl=ko)

위 문서에 따르면 구글에서는 posts/2131vbjbdsf같은 랜덤 형식의 주소보다 의미를 알아볼 수 있는 주소를 권장한다고 한다.

```tsx
// example
// 좋은 예
const url = 'https://blog.com/how-to-work';
// 나쁜 예
const url = 'https://blog.com/3';
```

지금 내가 만든 블로그에서는 정확하게 아래의 나쁜 예와 일치한다.. 그래서 이걸 어떻게 바꿔야할까 생각해봤을 때, 떠오른 방법은 글의 제목을 기준으로 id를 만드는 것이다.

티스토리 블로그의 경우 처음 글을 발행할때의 기준으로 글의 slug를 만든다. 

### Slug와 Id는 같은 개념인가?

아니라고 한다. 둘 다 같은 개념으로 사용할 수는 있지만 일반적으로, id는 따로 존재하고 slug는 검색엔진, 데이터 조회를 위한 방법으로 사용된다.

그래서 slug를 검색엔진 최적화를 위해서 id와 독립적으로 사용하기로 결정했다.

### Slug 생성하기

slug는 글의 제목을 기준으로 생성하도록 구현했다. 띄어쓰기는 -를 사용해서 연결하고 특수문자들은 제거하는 방식으로 slug를 생성하도록했다.

**만약 같은 제목의 글이라면?**

물론 같은 제목의 글을 작성할 일은 많지 않겠지만 slug는 글을 조회할 때도 사용해야하기 때문에, unique 해야한다. 그래서 같은 slug일 경우 slug 뒤에 번호를 붙여주는 방식으로 중복을 방지했다.

### 테스트코드

유틸 함수는 테스트 코드를 통해 검증하는 방식을 사용했다. 특수문자나, 물음표 제거 등의 동작을 테스트하고, 중복 슬러그 생성도 검증하도록 테스트코드를 작성했다.

```tsx
test('중복된 slug 생성', async () => {
  const title = '중복된 제목';
  const Post = {
    findOne: jest
      .fn()
      .mockReturnValueOnce({ slug: '중복된-제목' })
      .mockReturnValueOnce(null),
  };
  const slug = await generateUniqueSlug(
    title,
    Post as unknown as Model<Post>
  );
  expect(slug).toBe('중복된-제목-1');
});
```

### 결과

이제 [http://localhost:3000/posts/블로그-cls-성능-최적화하기](http://localhost:3000/posts/%EB%B8%94%EB%A1%9C%EA%B7%B8-cls-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%ED%95%98%EA%B8%B0) 같은 주소로 접근하면 글을 조회할 수 있도록했다. 

![image.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/seo/slug1-bVDbJBKdZvttmjAT3xq73i2FopqdfU.png)

---

## NextJS 메타데이터와 Server Action

NextJS는 서버 컴포넌트를 지원하기 때문에 SEO에 유리하다. 이걸 개념적으로만 알고 있었는데 이번에 SEO 최적화를 진행하면서 왜 SEO에 좋은지 알게 되었다. 서버 컴포넌트로 만들어서 서버에서 페이지의 metadata를 미리 만들어서 유저에게 전달하는 방식을 사용하고 있다.

### NextJS Metadata 생성

전통적인 서버사이드렌더링을 하는 서비스에서는 메타데이터가 서버에서 만들어져있기 때문에 검색엔진 색인에 유리하다. 하지만 SPA는 자바스크립트를 통해 페이지를 생성하기 때문에 동적인 metadata 생성에 취약할 수 밖에 없다. 

NextJS에서는 metadata를 아래와 같이 동적으로 생성해 내보내기 하면 자동으로 페이지의 메타데이터를 만들어준다.

```tsx
export const generateMetadata = async ({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> => {
  const { post } = await getPostDetail(params.slug);

  return {
    title: post.title,
    description: post.subTitle || post.content.substring(0, 160),
    openGraph: {
      title: post.title,
      description: post.subTitle || post.content.substring(0, 160),
      images: [(post.thumbnailImage as string) || (example2.src as string)],
    },
  };
};
```

하지만 주의할 점은 이런 Metadata를 만들어 내보내기 하는 것은 서버 컴포넌트만 지원한다. 서버 컴포넌트의 경우 Next가 서버에서 미리 만들어서 보내주는데, 이 과정에서 메타데이터를 지정해주는 것이다.

그래서 기존의 글 세부 페이지를 서버 컴포넌트로 만들 필요가 있었다. 처음에는 API를 분리해서 상태없이 렌더링을 하도록 했다. 그러나 서버 컴포넌트의 경우에는 API로 연결할 필요 없이 서버 액션을 사용해서 바로 DB에서 값을 조회하는게 더 이득이라는 것을 알게 되었다.

### Server Action 동작 안됨 이슈

기존의 API를 사용하던 부분을 NextJS 서버 액션을 사용해서 직접 DB를 조회하는 방식을 사용했다. 풀스택 프레임워크다보니 이런게 좋은 것 같다.

그런데 직접 DB를 조작하려니까 slug로 글 데이터를 찾을 수 없는 문제가 생겼다.

문제가 생긴 서버 액션 코드, 이 코드는 프론트엔드 `app/posts/[slug]/page.tsx` 파일 내부에 작성된다. 프론트엔드에서 API 요청과 응답을 처리하는 단계를 건너뛰고 직접 DB를 찌르는 방식이다. 그렇다보니 서버 사이드 렌더링에 적합한 방법인 것 같다.

```tsx
async function getPostDetail(slug: string) {
  await dbConnect();

  const post = await Post.findOne({ slug: slug }).lean();

  if (!post) {
    throw new Error('Post not found');
  }

  return { post: JSON.parse(JSON.stringify(post)) };
}
```

그러나… Post Not Found 문제가 발생했다. 

어라? 분명 API를 사용했을 때는 정상적으로 작동했는데, slug로 포스트가 검색되지 않는 문제였다. 처음에는 DB와의 연결문제인줄 알고 확인했으나 DB는 정상적으로 연결되고 있었다.

```
// 서버 로그
slug %EB%B8%94%EB%A1%9C%EA%B7%B8-cls-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%ED%95%98%EA%B8%B0
post null
 ⨯ app/posts/[slug]/page.tsx (19:11) @ $$ACTION_0
 ⨯ Error: Post not found
    at $$ACTION_0 (./app/posts/[slug]/page.tsx:36:15)
    at async Module.generateMetadata (./app/posts/[slug]/page.tsx:43:22)
digest: "1908036914"

```

![image.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/seo/mongodbconnection-Y0jdSlU8N5WEmGeEhC1Ewo3jR669sK.png)

### use server를 붙여야하나?

‘use client’ 같은 지시어를 붙여야하나 해서 ‘use server’를 사용해봤다. 하지만 결과는 같았다.

이 과정에서 use server의 역할을 알게 되었다. use server는 이 함수가 서버에서만 실행된다고 명시해주는 역할이라고 한다.

```tsx
async function getPostDetail(slug: string) {
  'use server';
  await dbConnect();

  const post = await Post.findOne({ slug: slug }).lean();

  if (!post) {
    throw new Error('Post not found');
  }

  return { post: JSON.parse(JSON.stringify(post)) };
}
```

### decodeURIComponent

같은 코드인데 서버 API에서는 실행되고 서버 액션에서는 실행되지 않는다는 점에서, slug의 인코딩 문제임을 깨닫게 되었다. 서버에 요청을 보낼 때에는 한글로 되어있는 slug를 보내고 제대로 실행되지만, 브라우저에서는 보통 한글 주소를 encoding하기 때문에 %d2 이런식으로 인코딩되어있다. 이걸 사용해서 db에서 검색하니 검색되지 않는 것이 문제였다.

이를 해결하기 위해서, decodeURIComponent를 사용했다. 이 함수는 인코딩된 문자열을 원래대로 복구해주는 함수이다. 반대의 기능으로는 encodeURIComponent가 있다. 이걸 아래와 같이 수정해주니 정상적으로 작동했다.

`const post = await *Post*.findOne({ slug: decodeURI(slug) }).lean();`

위와 같은 여정을 거쳐 아래처럼 meta 태그가 동적으로 생성되는 것을 확인할 수 있다. 이제 이 데이터를 기반으로 구글은 내 글들을 수집해갈 것이다.

![image.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/seo/result-mN8sOHKgIE3UQXYqTK9xucY2tC1IAY.png)

---

## Sitemap과 Robot.txt

이 둘은 검색엔진 최적화에서 자주 언급되는 개념이다. 사이트맵은 서비스에 어떤 페이지들이 있는지 링크와 설명 등을 적는 파일이다. 이걸로 검색엔진 봇들이 사이트를 크롤링한다고 생각하면 된다.

robot.txt는 검색엔진에게 어떤 페이지를 허용할지 비허용할지 등을 정할 수 있고, 특정 검색엔진에서의 크롤링을 제한하는 등의 기능을 지원한다.

### Next.js에서의 sitemap, robot

이번에 next로 프로젝트를 하면서 재미있는 점이 이런 점인 것 같다. nextjs에서는 `app` 폴더 내부에 sitemap.ts, robot.ts 같이 타입스크립트로 작성하면 알아서 sitemap.xml, robot.txt 파일로 생성해준다고 한다.

> Sitemap.ts
> 

사이트맵은 아래와 같이 작성할 수 있다. dbConnect를 사용해서 앞으로 생성될 포스트들에 대해서도 사이트맵을 생성해주는 역할을 수행한다.

```tsx
import { MetadataRoute } from 'next';
import dbConnect from '@/app/lib/dbConnect';
import Post from '@/app/models/Post';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  await dbConnect();
  const posts = await Post.find({}).sort({ date: -1 });

  const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';

  // 정적 페이지 URL
  const staticPages = [
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: 'daily' as const,
      priority: 1,
    },
    {
      url: `${baseUrl}/posts`,
      lastModified: new Date(),
      changeFrequency: 'weekly' as const,
      priority: 0.8,
    },
  ];

  const postUrls = posts.map((post) => ({
    url: `${baseUrl}/posts/${post.slug}`,
    lastModified: new Date(post.updatedAt || post.date),
    changeFrequency: 'weekly' as const,
    priority: 0.7,
  }));

  return [...staticPages, ...postUrls];
}
```

---

## JSONLd 컴포넌트

이건 전에 들어본 적이 없는 개념이었다. JSONLd를 사용하면 페이지에 대한 세부 설명을 작성할 수 있다고 이해했다. 구글이나 네이버 검색을 하다보면 snippet 형태로 보여주는 부분이 있는데 이게 jsonld를 사용하면 가능하다고 한다.

### JSON-LD

JavaScript Object Notation for LInked Data의 약자이다. JSON 형식을 사용해서 링크드 데이터를 표현하는 데이터 직렬화 방식이다.

여기에 들어가는 데이터는 아래처럼 다양하게 존재한다. 주소, 전화번호 같은 데이터부터, 언제 마지막으로 수정되었는지, 무슨 목적의 페이지인지 등을 기재할 수 있도록 되어있다.

```tsx
{
  "@context": "https://schema.org",
  "@type": "Restaurant",
  "name": "맛있는 집",
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "서울특별시 강남구 테헤란로 123",
    "addressLocality": "서울",
    "postalCode": "12345",
    "addressCountry": "KR"
  },
  "telephone": "+82-2-1234-5678",
  "servesCuisine": "Korean",
  "openingHours": "Mo-Sa 11:00-22:00"
}
```

이 JSON-LD는 HTML `<script>` 태그에 삽입하거나 별도로 관리할 수 있어 다양한 환경에서 활용 가능하다. 이번 프로젝트에서는 각 블로그 페이지마다 이 json-ld를 지정해주는 컴포넌트를 만들고자 했다.

script 태그를 사용해서 ld+json 형식으로 제목,소제목, 본문, 저자와 글자 수, 그리고 읽는데 소요되는 시간 등을 지정해주었다.

```tsx
import { Post } from '@/app/types/Post';

const PostJSONLd = ({ post }: { post: Post }) => {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify({
          '@context': 'https://schema.org',
          '@type': 'BlogPosting',
          headline: post.title,
          alternativeHeadline: post.subTitle, // 서브타이틀용
          description: post.subTitle || post.content.substring(0, 160),
          articleBody: post.content, // 본문 전체
          author: {
            '@type': 'Person',
            name: post.author,
          },
          datePublished: new Date(post.date).toISOString(),
          dateModified: new Date(post.updatedAt || post.date).toISOString(),
          wordCount: post.content.split(/\s+/g).length,
          timeRequired: `PT${post.timeToRead}M`, // ISO 8601 duration format
          // 이미지가 있는 경우
          image: post.thumbnailImage,
          // 블로그/사이트 정보
          publisher: {
            '@type': 'Organization',
            name: 'ShipFriend TechBlog',
            logo: {
              '@type': 'ImageObject',
              url: `https://oraciondev.vercel.app/favicon.ico`,
            },
          },
        }),
      }}
    />
  );
};
export default PostJSONLd;
```

위 컴포넌트는 블로그 세부 페이지에 아래와 같이 렌더링 해주었다.

```tsx
return (
  <>
    <PostJSONLd post={post} />
    <section className="bg-transparent w-full flex-grow">
      <article className="post">
        <PostHeader
          title={post?.title || ''}
          subTitle={post?.subTitle || ''}
          author={post?.author || ''}
          date={post?.date || 0}
          timeToRead={post?.timeToRead || 0}
          backgroundThumbnail={post?.thumbnailImage || example2}
        />
        <PostBody loading={false} content={post?.content || ''} />
      </article>
      <Comments />
    </section>
  </>
);
```]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[블로그 CLS 성능 최적화하기]]></title>
            <link>https://shipfriend.dev/posts/블로그-cls-성능-최적화하기</link>
            <guid>https://shipfriend.dev/posts/블로그-cls-성능-최적화하기</guid>
            <pubDate>Wed, 18 Dec 2024 08:10:26 GMT</pubDate>
            <description><![CDATA[with Next.js 이미지 최적화]]></description>
            <content:encoded><![CDATA[CLS는 이전에 운영하던 블로그에서도 최적화를 한 번 해봤기 때문에 어디서 문제가 생기고 있는지 짐작은 가는 상황이었다. 좀 더 확실한 기록을 위해서 lighthouse를 사용해서 성능 테스트를 진행했다. 성능이 28점으로 매우 낮은 것을 볼 수 있었다. CLS 수치도 무려 0.8이 넘는 것을 볼 수 있었다.

![스크린샷 2024-12-18 오후 2.17.44.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/cls/1-stFMJkduy5pUGTeCnB5GI5vYCAGgaM.png)

- CLS 수치

![스크린샷 2024-12-18 오후 2.17.51.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/cls/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202024-12-18%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%202.17.51-7LW3XxHfNCJ9xvPRJqVV2Og74HjlgL.png)

## 첫 시도: 기본 높이 설정하기

처음으로 한 시도는 포스트 Header 부분의 최대 높이를 지정해주는 것이었다. 아래 초록색으로 칠해져있는 섹션인데, 해당 부분의 높이가 지정되어있지 않으면 처음에는 적은 공간을 차지하다가 렌더링 이후에 갑자기 차지하는 공간이 커지면서 CLS 수치에 영향을 주는 것이다.

![image.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/cls/2-r0xjye32yJRwUitqiQ1zRIIEfOulFM.png)

실제로 로딩이 되기 전에는 적은 공간을 차지하다가 로딩이 완료되면 20%정도 공간이 늘어나는 것을 볼 수 있다. 이런 레이아웃 변경이 CLS 수치를 올리는 이유가 된다.

![Dec-18-2024 14-36-54.gif](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/cls/Dec-18-2024%2014-36-54-xW1R2wNI7BH1ggb1aRtGnfSJa6AzD3.gif)

### PostHeader 컴포넌트 높이 지정하기

```tsx
<div
  className={
    'post-header h-[300px] relative overflow-hidden w-full text-center'
  }
>
```

기본 높이를 300px로 지정해주는 것만으로도 수치를 반으로 줄일 수 있었다.

![스크린샷 2024-12-18 오후 2.22.34.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/cls/3-PPAgWpskPOXuYD2y0L47BfySWJajef.png)

### PostBody 컴포넌트 높이 지정하기

헤더 뿐만 아니라 body 컴포넌트도 높이를 지정해줬다. 최소 높이를 500px로 고정을 해뒀다.

이런 높이를 지정해주는 것만으로도 CLS 수치를 많이 낮출 수 있었다.

![image.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/cls/4-OniXPuVOVzZkD7JXJYClwgjOz7IMIp.png)

## 두 번째 시도: 이미지 최적화하기

내 블로그에서는 글 제목 뒤에 흐리게 블러처리를 한 썸네일 이미지를 지원한다. 이 이미지의 해상도는 1024 x 720의 크기를 사용하고 있었는데, 생각해보니 큰 이미지를 사용할 이유가 전혀없다고 판단했다. 왜냐하면 블러처리를 한 이상 해상도는 중요하지 않고 사실상 이미지의 전체적인 색상 정도만 파악하면 되기 때문이다. 그래서 이미지를 최적화하기로 결정했다. 

### 이미지 최적화: 해상도 리사이징

1024 사이즈는 너무 크다고 생각했고, 720과 480을 비교해보고자 했다. 아래는 순서대로 720과 480의 이미지이다. 화질 차이가 전혀 보이지 않는다고 생각해도 될 정도로 미미했다.

따라서 성능상 더 이득인 480 사이즈로 결정했다.

![image.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/cls/5-Tp2RjSdwJl1qspxkEd3G5VGO1PUqGz.png)

![image.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/cls/6-nEnfNmnhmYTjK1NrkyatV4ldLh0skB.png)

해당 최적화 이후에 검사했을 때 드라마틱한 결과는 아니지만 largest Contentful Paint 0.2초 감소 효과를 얻었다.

![image.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/cls/7-rKHFBayZ2T5JnUKbO8IBb1AfNw6FKM.png)

### 이미지 최적화: 로딩 방식 변경

nextjs에서 기본으로 지원하는 이미지 최적화 방법 중 loading 방식을 eager를 선택했다. lazy 방식과 eager 방식이 있는데, eager 방식은 이미지를 페이지 로드와 동시에 즉시 로딩하는 방식이다. lazy는 반대로 화면에서 보이지 않는 경우에는 로딩을 하지 않고 있다가 뷰포트에 가까워지면 로딩을 하는 방식이다.

스크롤 아래에 있는 이미지라면 lazy가 맞겠지만, 메인 페이지에서 바로 보여야하기 때문에 eager 방식을 선택했다. 이 방식으로 로딩했을 때 아래 성능 지표가 크게 개선이 된 것을 볼 수 있었다.

![image.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/cls/8-xB9BCqy2biJakLkDTt977jtLu3LUoT.png)

어떻게 이렇게 개선되었을까? eager 방식을 적용하지 않으면 다른 리소스들과 비슷하게 로딩을 해서 사진을 가져온다. 하지만 eager 방식을 사용하면 브라우저의 프리로더가 이미지의 우선순위를 좀 더 높게 가져가서 먼저 다운로드하게 된다. 이 방식을 사용하면 hero 이미지나 이미지가 시각적으로 많이 중요한 페이지의 경우에 사용하면 LCP 개선에 큰 도움이 된다고 한다.


#### **NextJS가 아니라면?**

순수 자바스크립트나 리액트에서는 link 태그의 rel 속성을 preload를 통해서 비슷하게 프리로드를 지원할 수 있다고 한다.

```tsx
const link = document.createElement('link');
link.rel = 'preload';
link.href = resource.url;
```

### 이미지 최적화: priority 지정

priority를 지정해서 우선순위를 사용하도록 설정해주었다.

```tsx
<Image
  className={'w-full h-full'}
  width={480}
  height={300}
  src={backgroundThumbnail}
  alt={`${title} Thumbnail`}
  loading={'eager'}
  priority={true}
  placeholder={'empty'}
/>
```

이 옵션을 지정하고, CLS 수치가 더 낮아진 것을 확인할 수 있었다.

![image.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/cls/9-hSvwShO1Ht8WYsrvvykk4p7WWSn4Ju.png)

## Total Blocking Time

Total Blocking Time은 배포 환경에서는 제대로 측정이 되지 않는 것 같다. 배포한 환경에서 페이지를 테스트했을 때 모두 초록색 점수로 개선이 된 것을 알 수 있었다.

알아보니 TBT는 개발환경에서는 아래와 같은 이유로 제대로 측정되지 않을 수 있다고 한다.

1. 개발환경의 추가적인 오버헤드
- React의 개발 모드는 다양한 경고와 에러 체크를 수행
- Hot Module Replacement(HMR) 관련 코드가 실행됨
- Source Map 생성 및 처리
- 디버깅을 위한 추가 코드들이 실행
2. 환경에 따른 코드
- 배포환경에서는 코드 압축, 트리 쉐이킹 등 최적화가 적용됨
- 개발환경은 빌드 최적화를 거치지 않은 원본 코드가 실행됨

![스크린샷 2024-12-18 오후 4.52.30.png](https://idmlft3uczstcjaa.public.blob.vercel-storage.com/images/cls/10-zSxkO9eqhVPKmXpDXhh07lLxPqJaqT.png)

다른건 몰라도 검색엔진 최적화는 100점을 만들어보고 싶다고 생각한다.]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
        <item>
            <title><![CDATA[Next.js로 나만의 블로그 만들기]]></title>
            <link>https://shipfriend.dev/posts/next-js로-나만의-블로그-만들기</link>
            <guid>https://shipfriend.dev/posts/next-js로-나만의-블로그-만들기</guid>
            <pubDate>Tue, 17 Dec 2024 18:47:11 GMT</pubDate>
            <description><![CDATA[포트폴리오를 곁들인..]]></description>
            <content:encoded><![CDATA[# Next.js로 나만의 블로그 만들기
12월 6일자로 네이버 부스트캠프 9기를 수료하고 백수의 삶으로 돌아오게 되었다. 수료 후에도 여러 일정이 있어서 바쁘게 보내고 있지만 부캠 기간 중간에 만들고 싶어서 프로젝트 세팅 정도만 해둔 프로젝트를 꺼내서 다시 해보고 싶어졌다.

## 나만의 블로그..?
부캠에서도 개인 블로그를 만들어서 사용하시는 분을 여럿 보기도 했고 프론트엔드 개발자로서 하나 만들어보면 좋을 것 같다는 생각이 계속 들었다.

물론 블로그를 많이 운영하고 있긴 하지만 내가 만들어보는 것도 좋은 경험이 될 것 같아서 만들어보기로 했다.

토이프로젝트 느낌이지만 `SEO`도 챙겨보고 싶다고 생각했기에 Next.js 프레임워크를 선택했다.

![image](https://velog.velcdn.com/images/ejaman/post/c55a70d3-6bb0-4998-a5ee-191639b81036/image.png)


## 초기 기획
처음에는 이렇게 글을 쓰려고 만든다기 보다는 내가 해왔던 프로젝트를 나열하면서 보여주는 **포트폴리오**를 만드려고 했다. 하지만 나는 

> 디자인에는 재능이 없기 때문에 포트폴리오만 만들기에는 아쉬울 것 같았다.

그래서 블로그 기능을 추가해보기로 결정했다.

## 블로그 데이터 저장 방식
처음에는 mongodb를 사용해서 데이터를 저장하기로 계획했었다. 그런데 최근에 있었던 네트워킹 데이에서 DB를 사용하지 않고 md 파일 형식으로 저장하고 관리해도 좋다는 조언이 있었다.

고민을 좀 해봤는데 이미 DB 세팅을 다 해둔 상태여서 버리기 아까운 것도 있고... ㅎ 추후에 바꿔도 좋을 것 같다고 생각이 들어서 `mongodb atlas`를 사용해서 블로그를 만들어보기로 했다.

하지만 해당 조언 덕분에 markdown 문법으로 작성하기로 결정하는데 도움이 되었다. 이 글도 md 문법으로 작성 중이다..!!!

## 앞으로 추가할 기능들
개인 블로그라서 커스터마이징의 가능성이 무궁무진한데 일단은 아래 기능들을 먼저 구현해보고 싶다.
- SEO 지원 (next를 선택한 이유기도 하다. 페이지별 metadata를 어떻게 설정해야할지 공부해봐야 할 것 같다.)
- 포트폴리오 기능
  - 이건 아예 UI도 계획을 안해놔서 좀 생각을 해봐야할 것 같지만 일단은 초기 기획이니까...
- 새글 구독 기능?
  - Footer에 허울뿐인 구독 UI가 있다. 이걸 활용해서 새 글이 올라오면 메일로 알림을 보내주는 것을 상상해보고 있다. **근데 뭔가 비용이 들 것 같은 느낌이다..!**
- 블로그 관련 CRUD 강화
- 내 소개 강화
- 검색 기능
  - AI 검색 이런거 해볼까..? 로컬 AI 모델이 얼마나 가벼운지 모르겠지만 각이 보이면 해보고 싶다.
- 디자인 강화
- CLS 최적화

![image](https://i.pinimg.com/236x/12/8d/e8/128de8ce51ee0c498a4dfa67610f5843.jpg)]]></content:encoded>
            <author>sjw4371@naver.com (개발자 서정우)</author>
        </item>
    </channel>
</rss>