본문으로 건너뛰기

반복되는 이미지 생성 작업, Google Opal로 자동화하기

· 약 3분
김현재
개발자

들어가며

공시공시 프로젝트를 개발하다 보니 캐릭터 일러스트 이미지를 주기적으로 생성해야 하는 상황이 생겼습니다. 문제는 매번 같은 작업을 반복해야 한다는 것이었습니다.

  1. 베이스 이미지를 AI 툴에 업로드한다
  2. 원하는 캐릭터 컨셉에 맞게 프롬프트를 작성한다
  3. 이미지를 생성한다

몇 번은 괜찮지만, 이 과정이 반복될수록 점점 귀찮아졌습니다. 특히 프롬프트를 매번 새로 작성하는 것이 가장 번거로웠습니다. 그래서 이 과정을 자동화할 수 없을까 고민하다 Google Opal을 활용한 에이전틱 워크플로우를 구성하게 되었습니다.

Google Opal이란?

Google Opal은 노드를 연결하는 방식으로 AI 워크플로우를 구성할 수 있는 서비스입니다. 각 노드가 하나의 작업 단위가 되어, 노드 간 연결을 통해 자동화 흐름을 만들 수 있습니다.

문제 정의

기존 방식의 가장 큰 문제는 매번 프롬프트를 처음부터 작성해야 한다는 것이었습니다.

이미지 품질을 일정하게 유지하려면 베이스 이미지를 첨부하고 동일한 프롬프트 구조를 사용해야 하는데, 이 과정이 반복적으로 번거로웠습니다. 그래서 자동화의 목표는 두 가지로 정의했습니다.

  • 베이스 이미지는 워크플로우 안에 한 번만 등록해두기
  • 캐릭터 컨셉만 말하면 나머지는 자동으로 처리되게 하기

워크플로우 설계

전체 흐름은 다음과 같이 4단계로 구성했습니다.

1단계 — 컨셉 입력

워크플로우가 시작되면 사용자에게 어떤 캐릭터를 만들고 싶은지 물어봅니다. 예를 들어 "마법사 캐릭터이고, 모자는 보라색, 지팡이를 들고 있으면 좋겠어" 같은 자연어로 입력할 수 있습니다.

사용자 입력

2단계 — 프롬프트 후보 3개 생성

입력받은 컨셉을 바탕으로 AI가 이미지 생성에 사용할 프롬프트 후보 3개를 자동으로 작성합니다. 사용자는 3개의 후보 중 가장 마음에 드는 것을 선택하면 됩니다.

직접 프롬프트를 작성하는 것보다 훨씬 빠르고, 예상치 못한 아이디어를 발견할 수 있다는 장점도 있었습니다.

프롬프트 생성

3단계 — 프롬프트 조합

선택된 프롬프트는 미리 설계해둔 템플릿 안에 자동으로 삽입됩니다. 이 템플릿에는 베이스 이미지의 스타일을 유지하기 위한 고정 구문들이 포함되어 있어, 매번 따로 작성하지 않아도 됩니다.

[선택된 컨셉 프롬프트], [고정 스타일 프롬프트], [고정 후처리 프롬프트]

프롬프트 생성2

4단계 — 이미지 생성

조합된 프롬프트와 베이스 이미지를 함께 이미지 생성 모델에 전달하여 최종 이미지를 생성합니다.

프롬프트 생성3

결과

이 워크플로우를 구성한 이후로 이미지 생성 작업이 훨씬 간단해졌습니다. 캐릭터 컨셉만 입력하면 나머지는 자동으로 처리되고, 프롬프트 조합 실수도 없어졌습니다.

단순 반복 작업처럼 보이는 것도 충분히 자동화할 수 있다는 것, 그리고 에이전틱 워크플로우가 생각보다 훨씬 실용적이라는 것을 직접 경험할 수 있었습니다.

아래 사진은 Google Opal을 통해 생성한 워크플로우 전체 노드 사진입니다. 전체 노드

이렇게 생성해서 실제 서비스에 사용된 이미지는 다음과 같습니다. 전체 이미지

마무리

이번 글에서는 Google Opal을 활용해 캐릭터 이미지 생성 작업을 자동화한 경험을 공유했습니다. 비슷하게 반복되는 작업이 있다면 에이전틱 워크플로우 도입을 한 번 고려해보시길 추천드립니다.

위에서 생성한 이미지를 활용해 제작된 투자 성향 테스트는 아래 주소에서 확인하실 수 있습니다.

https://www.gongsi-gongsi.kr/investment-mbti

디버깅 시간 80% 단축, Sentry로 실시간 대응하기

· 약 5분
김현재
개발자

들어가며

1인으로 AI 에이전트를 사용해 서비스를 개발/운영하다 보면 챙겨야 할 일이 생각보다 훨씬 많습니다. 기획, 디자인, 개발, 배포, 마케팅까지 혼자 다 해야 하다 보니 어딘가 한 군데는 반드시 구멍이 생깁니다.

그 중에서도 런타임 에러 모니터링은 가장 놓치기 쉬운 부분 중 하나입니다. 개발 환경에서는 멀쩡하게 동작하던 서비스가, 실제 사용자가 쓰는 프로덕션 환경에서 발생하고 있어도 개발자가 직접 확인하기는 어렵습니다.

저도 서비스를 배포하고 나서 한동안은 에러가 없다고 생각했습니다. 그런데 Sentry를 달고 나서야 "이런 에러가 이렇게 많이 터지고 있었나?" 싶었습니다. 이번 포스팅에서는 Sentry가 무엇인지, 어떻게 설정하는지, 그리고 실제로 어떤 에러들을 잡았는지 공유해보려 합니다.

다음 이미지는 실제로 6천회가 넘는 에러가 발생한 이미지입니다.

에러 발생 로그

Sentry란?

Sentry실시간 에러 추적(Error Tracking) 플랫폼입니다. 프론트엔드, 백엔드, 모바일 등 다양한 환경을 지원하며, 에러가 발생했을 때 어떤 유저가, 어떤 환경에서, 어떤 코드 경로를 통해 에러를 만났는지 상세하게 알려줍니다.

왜 필요한가

로컬에서 console.error로 에러를 확인하는 것과 실제 프로덕션 환경에서 에러를 추적하는 것은 많은 차이가 있습니다.

서비스의 사용자가 되어본다고 가정해보겠습니다. 만약 처음 방문한 낯선 플랫폼에서 버튼을 눌렀다가 에러를 마주친다면, 여러분은 친절하게 문의나 신고 기능을 통해 그 사실을 개발자에게 알리시나요? 저는 정말 애정이 깊은 서비스가 아닌 이상, 대체로 피드백을 남기지 않고 그냥 넘어가곤 했습니다.

결국 대부분의 사용자는 에러가 발생해도 별다른 신고 없이 조용히 서비스를 떠납니다. Sentry는 이런 조용한 이탈을 막기 위한 중요한 역할을 수행합니다.

Next.js에 Sentry 설정하기

패키지 설치

npx @sentry/wizard@latest -i nextjs

Sentry에서 공식으로 제공하는 wizard를 사용하면 대부분의 설정을 자동으로 해줍니다. 실행하면 다음과 같은 질문들이 나옵니다.

? Are you using Sentry SaaS or self-hosted Sentry? › Sentry SaaS (sentry.io)
? Do you already have a Sentry account? › Yes
? Select your Sentry project › my-project
? Do you want to route Sentry requests through your Next.js server? › Yes (recommended)
? Do you want to enable React component annotations? › Yes

wizard가 완료되면 아래 파일들이 자동으로 생성됩니다.

sentry.client.config.ts   # 클라이언트 사이드 설정
sentry.server.config.ts # 서버 사이드 설정
sentry.edge.config.ts # Edge runtime 설정
instrumentation.ts # Next.js instrumentation hook

생성된 설정 파일 확인

// sentry.client.config.ts
import * as Sentry from '@sentry/nextjs';

Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,

// 에러 발생 시 Sentry로 전송되는 비율 (0.0 ~ 1.0)
// 프로덕션에서는 1.0, 트래픽이 많다면 0.1~0.5로 조정
tracesSampleRate: 1,

// 세션 리플레이 - 에러 발생 전후 사용자 화면 녹화
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,

integrations: [Sentry.replayIntegration()],

// 개발 환경에서는 비활성화
enabled: process.env.NODE_ENV === 'production',
});

무료 버전을 사용하신다면 enabled: process.env.NODE_ENV === 'production' 설정을 꼭 추가해야 합니다. 개발 중에도 Sentry로 이벤트가 전송되면 무료 한도를 금방 소진할 수 있습니다.

환경 변수 설정

.env.local에 DSN을 추가합니다. DSN은 Sentry 프로젝트 설정 > Client Keys에서 확인할 수 있습니다.

# .env.local
NEXT_PUBLIC_SENTRY_DSN=https://xxxxxxxx@o0.ingest.sentry.io/xxxxxxxx

# Source Map 업로드용
SENTRY_AUTH_TOKEN=sntrys_xxxxxxxxxxxx
SENTRY_ORG=your-org
SENTRY_PROJECT=your-project

next.config.ts 설정

wizard가 자동으로 withSentryConfig를 추가해줍니다. 직접 설정한다면 아래를 참고하세요.

// next.config.ts
import { withSentryConfig } from '@sentry/nextjs';
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
// 기존 설정
};

export default withSentryConfig(nextConfig, {
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,

// Source Map을 Sentry에 업로드하고 빌드 결과물에서는 숨김
// 이 설정이 없으면 에러 스택 트레이스가 minify된 코드로 보임
hideSourceMaps: true,

// 빌드 시 Sentry CLI 출력 숨기기
silent: !process.env.CI,
});

Source Map 설정은 특히 중요합니다. 이 설정 없이는 Sentry에서 에러 위치가 main-abc123.js:1:48392 같은 난독화된 코드로 표시됩니다. 설정이 완료되면 Sentry에서 아래와 같이 확인할 수 있습니다.

스택 트레이스

수동으로 에러 캡처하기

자동으로 잡히지 않는 에러는 직접 캡처할 수 있습니다.

import * as Sentry from '@sentry/nextjs';

// 예외 캡처
try {
await riskyOperation();
} catch (error) {
Sentry.captureException(error, {
extra: {
userId: user.id,
action: 'riskyOperation',
},
});
}

// 메시지 캡처 (에러는 아니지만 추적이 필요한 경우)
Sentry.captureMessage('결제 프로세스 중 예상치 못한 분기 발생', {
level: 'warning',
extra: { orderId: order.id },
});

실제로 잡은 에러들

설정을 마치고 나서 제가 실제로 마주한 에러들입니다.

서비스 운영 중 발견되는 에러 케이스는 계속해서 추가·업데이트할 예정입니다.

케이스 1: 프로덕션에서 내부 API가 401을 반환하는 에러

Error: Failed to fetch company info
at getCompanyInfo (entities/company/api/company-info/client.ts:25)

단순한 API 호출 실패처럼 보였지만, Sentry breadcrumb를 열어보니 실제 원인이 달랐습니다.

GET https://gongsi-ppt0t95cd-napoldevelopers-projects.vercel.app/api/companies/00462121
→ 401 Unauthorized

사용자는 www.gongsi-gongsi.kr에 접속했는데, SSR 중 내부 API 호출이 Vercel 프리뷰 배포 URL로 향하고 있었습니다. 이 프리뷰 URL은 Vercel 인증이 걸려있어 401이 반환된 것입니다.

원인은 getBaseUrl()process.env.VERCEL_URL을 그대로 사용하는 패턴에 있었습니다.

// ❌ 문제 있는 코드
function getBaseUrl() {
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // 배포별 고유 URL이 들어옴
}

VERCEL_URL은 Vercel이 자동으로 설정하는 배포별 고유 URL입니다 (예: my-app-abc123.vercel.app). 커스텀 도메인이 아니기 때문에 Vercel 인증을 통과하지 못했습니다.

수정은 간단합니다. NEXT_PUBLIC_APP_URL 환경변수를 직접 설정하고 우선 사용하도록 바꿉니다.

// ✅ 수정된 코드
function getBaseUrl() {
if (typeof window !== 'undefined') return window.location.origin;
if (process.env.NEXT_PUBLIC_APP_URL) return process.env.NEXT_PUBLIC_APP_URL;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return 'http://localhost:3000';
}

그리고 Vercel 환경변수에 추가합니다.

NEXT_PUBLIC_APP_URL=https://www.gongsi-gongsi.kr

보너스: 이 에러를 처음 발견한 게 실제 유저가 아니라 ChatGPT 크롤러 봇이었습니다. Sentry를 달지 않았다면 얼마나 많은 유저가 에러를 보고 그냥 나갔을지 알 수 없는 상황이었습니다.

openai 공식 크롤러 봇

Sentry를 더 잘 활용하는 팁

알림 설정

대시보드를 매일 확인할 수 없으니, Slack 알림을 연동해두는 것이 좋습니다.

Sentry > Settings > Integrations > Slack에서 연동하면, 새로운 에러가 처음 발생하거나 특정 임계값을 넘을 때 Slack으로 알림을 받을 수 있습니다.

[새 이슈] TypeError: Cannot read properties of undefined
프로젝트: my-service | 환경: production
첫 발생: 방금 전 | 영향받은 유저: 1명
→ Sentry에서 확인하기

민감 정보 필터링

유저의 비밀번호나 토큰 같은 민감 정보가 에러 페이로드에 담기지 않도록 필터링합니다.

Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
beforeSend(event) {
// 요청 헤더에서 Authorization 제거
if (event.request?.headers) {
delete event.request.headers['Authorization'];
}
return event;
},
});

마무리

Sentry 설정은 생각보다 간단하고, 세팅에 드는 시간 대비 얻는 가치가 크다고 느꼈습니다. 생각보다 많은 런타임 에러가 발생하고 있었고, 개발 환경에서는 이 모든 에러를 발견하지 못했습니다.

참고

도커 이미지 78% 경량화와 CDN 적용기

· 약 4분
김현재
개발자

들어가며

학교 캡스톤 프로젝트로 시작한 체험단 서비스인 ‘체험콕'을 개발중이며, 실제 운영까지 목표로 하다 보니 CI/CD 구축은 필수였습니다.

다만 서비스 초기에 설정해둔 CI/CD 과정은 실제로 서비스가 가동되기까지 오랜 시간이 걸렸고, 이 문제를 해결하기 위해 필자가 겪었던 과정을 정리했습니다. 또 저와 같은 주니어 개발자들이 본인의 프로젝트에도 직접 적용해보는 과정을 겪으면 좋겠다는 마음에 이번 포스팅을 작성했습니다.

도커 이미지 경량화

Next.js 프로젝트를 컨테이너로 배포하기 위해서는 도커 이미지를 빌드해야 합니다. 도커 허브에 푸시하거나 빌드 서버에서 배포 서버로 이미지를 옮기는 상황을 고려하면, 이 도커 이미지의 용량이 작을수록 속도 면에서 유리할 것이라고 예측할 수 있습니다. 결국 도커 이미지의 용량을 줄이기 위해서는 빌드 파일의 용량을 경량화 시켜야 했고, Next.js에서는 빌드 결과물을 경량화할 수 있는 기능을 제공하고 있습니다. 저는 이 기능을 이용해서 경량화를 진행했습니다.

Next.js standalone 공식문서

standalone 옵션으로 빌드 결과물 경량화하기

먼저 Next.js 프로젝트의 next.config.tsoutput: ‘standalone’ 옵션을 추가했습니다.

next.config.ts의 output옵션 설정

옵션을 추가한 이후 next build 명령어를 실행하면, 빌드 결과물과 함께 .next/standalone 폴더가 생성됩니다. 이 폴더 안에는 프로덕션 서버 구동에 필요한 최소한의 의존성 파일만 존재합니다.

따라서 이미지를 빌드할 때 standalone 폴더와 함께 public, .next/static 폴더 그리고 환경 변수 파일 등 서비스 구동에 필요한 파일을 포함시키면 됩니다.

빌드 결과

Next.js 공식 문서에서는 static과 같은 정적 파일 (js, css 등)은 CDN을 통해 서빙하는 것을 권장하고 있습니다. 이 내용은 아래 섹션 CDN 파트에서 다룰 예정입니다.

체험콕의 경우에는 Jenkins가 구동 중인 서버에서 이미지를 빌드하고 있습니다. Jenkins 파이프라인에서 프로젝트를 빌드 할 때, Jenkins에 저장된 환경 변수 파일을 주입하는 방식을 사용합니다. 빌드가 완료되면, 위에서 설명한 서비스 구동에 필수적인 파일들만 포함하여 최종 도커 이미지를 생성합니다.

아래 이미지는 standalone 옵션을 사용하여 생성된 도커 이미지와, 사용하지 않고 생성된 도커 이미지의 용량 비교 사진입니다. standalone 옵션 적용 전인 1.58GB에서 354.4MB로 약 78%의 용량 개선이 이뤄졌습니다.

도커 이미지 용량 비교

빌드 시간 단축

CI/CD 구조를 개편하면서 빌드 시간도 단축됐습니다. 기존 6분 많게는 8분까지 걸리던 전체 파이프라인이 약 4분 내외로 완료되어 2~3분 가량의 시간을 단축시켰습니다. (아쉽게도 변경 전 빌드 시간을 확인하지 못하여 변경 후 사진만 업로드했습니다. 추후 확인이 가능하면 추가로 업데이트 할 예정입니다.)

파이프라인 소요 시간

CDN 적용

앞서 언급했듯이 publicstatic 폴더에 있는 정적 데이터(css, js, 이미지, 폰트 등)는 CDN을 통해 서빙하는 것이 성능 최적화에 유리하기 때문에 Next.js 공식 문서에서 권장하고 있습니다.

이 글에서 AWS S3 버킷 생성 또는 CloudFront 설정과 같은 구체적인 클라우드 서비스 설정 방법은 다루지 않을 예정입니다. 개인마다 사용하는 환경이 다를 수 있고, 이 부분은 본인이 사용하고 있는 스펙에 맞춰 다른 블로그를 참고해주길 바랍니다.

CDN을 연동하는 방법은 매우 간단합니다. next.config.ts 파일에서 assetPrefix 옵션을 설정해주면 끝입니다.

CDN 설정

저는 S3에 정적 파일들을 업로드하여 CDN과 연동했습니다. 주의할 점은 static 폴더 내부의 파일 또는 폴더들은 빌드마다 명칭이 변경됩니다. 따라서 빌드마다 새로 업로드를 해주도록 설정했습니다.

그렇다면 왜 빌드마다 명칭이 변경될까요?

CDN은 엣지 서버를 통해 정적 파일을 서빙합니다. 그리고 각 엣지서버는 더 나은 성능을 위해 정적 파일들의 정보를 캐싱합니다. 이 때, 우리가 프로젝트의 기능을 업데이트해서 새로 빌드하고 올렸는데 파일의 명칭이 동일하다면 엣지 서버는 파일이 변경되었음을 인식하지 못합니다. 이러한 문제를 방지하기 위해 빌드 시 파일명에 고유한 해시값을 붙여 파일이 변경되었음을 감지하도록 하며, 사용자는 이를 통해 최신 버전의 파일을 전달받습니다.

S3에 업로드한 정적 파일

이렇게 설정을 마무리하고 실제 배포 서비스에서 데이터를 어떻게 받아오는지 확인했습니다. CDN 적용 전에는 서비스를 실행중인 Next.js 서버에서 파일을 받아오는 것을 확인할 수 있습니다.

CDN 적용 전

CDN 적용 후에는 설정한 CloudFront 주소에서 받아오는 것을 확인할 수 있습니다. CDN 적용 후

마무리

CI/CD 파이프라인을 직접 구축하고 CDN을 적용해본 경험은 아주 값진 경험이었습니다. 주니어 개발자로서 인프라를 직접 만져볼 기회도 적었고, 어드민이나 B2B 서비스 개발에 주로 참여하다 보니 CDN 같은 기술을 사용할 기회도 많지 않았습니다. 이번 경험을 통해 서비스 성능 최적화의 중요성과 인프라에 대한 이해도를 한층 높일 수 있었고, 앞으로도 많은 시도를 해볼 예정입니다.

Map vs Object, 200ms 차이가 나는 진짜 이유 (ft. Hidden Class)

· 약 6분
김현재
개발자

들어가며

자바스크립트를 활용해 개발 또는 알고리즘 문제를 풀다 보면 Map 객체를 사용해야 하는 경우가 많습니다. 그런데, Map이 Object와 크게 다르지 않아 보이는데 독자 분들은 이 둘의 차이점에 대해 알고 계신가요? 이 글에서는 Map에 대해 알아보며, Object와의 차이점은 무엇이며 자바스크립트 엔진은 객체를 어떻게 최적화하는지 알아보려고 합니다.

Map이란?

Map 객체는 키-값의 쌍을 저장하는 데이터 구조입니다. new Map() 생성자를 통해 생성할 수 있으며, 여러 내장 기능을 제공합니다.

const myMap = new Map();

Map 객체에 요소를 추가하고 싶다면 set() 메서드를 사용하면 됩니다.

myMap.set('name', '일순신');

Map의 키는 유일합니다. 따라서 같은 키로 여러 번 값을 설정하면 마지막 값으로 덮어 씌워집니다.

myMap.set('name', '일순신');
myMap.set('name', '이순신'); // <<- 이 값으로 덮어씌워져요

Map 객체가 제공하는 주요 메서드는 아래와 같습니다.

  • map.set(key, value) : 값 저장
  • map.get(key) : key에 해당하는 값 반환
  • map.has(key) : key 존재 여부에 따라 true or false 반환
  • map.delete(key) : key에 해당하는 값 삭제
  • map.clear() : 객체 안의 모든 요소 삭제
  • map.size : 요소의 개수 반환

더 궁금한 Map의 기능이 있다면, 아래 MDN 문서를 참고하는 것을 추천드립니다.

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Map

Object와 Map의 차이

자바스크립트이 Object와 Map은 비슷해 보이지만, 몇 가지 차이점이 존재합니다. 둘이 어떻게 다른지 아래 내용을 통해 알아봅시다. 편의를 위해 Object와 Map은 다음 코드와 같이 정의되어 있다고 가정하겠습니다.

const myMap = new Map();
const myObj = {};

키 유형

Object의 키는 String 또는 Symbol 만 가능합니다. 반면, Map은 어떤 값이든 키로 사용할 수 있습니다.(객체, 함수, 원시값 등)

myMap.set({ name: '홍길동' }, { id: 0 }); // 가능
myObj[{ name: '홍길동' }] = { id: 0 }; // 불가능

키 순서

Object의 키는 순서가 보장되지 않습니다. 하지만 Map은 삽입 순서대로 키를 유지하는 이터러블 객체입니다.

이터러블(Iterable) 객체란? 데이터를 순차적으로 반복할 수 있는 객체를 의미합니다.

순회

Map은 이터러블 객체이므로 for..offorEach같은 반복문을 사용할 수 있습니다. 반면 Object는 Object.keys()Object.entries()같은 메서드를 통해 간접적으로 순회해야 하며, 키 순서가 보장되지 않는다는 특징을 가지고 있습니다.

myMap.forEach((item) => {
console.log(item);
});

직렬화

Object는 JSON.stringify()JSON.parse()를 사용해 JSON으로 직렬화하거나 역직렬화할 수 있습니다. 하지만 Map은 기본적으로 지원되는 메서드가 없습니다.

만약 Map 객체를 직렬화 하고 싶다면 배열 또는 객체로 변환한 후에 직렬화를 해야합니다.

const myMap = new Map([
['id', 1],
['name', 'Gemini'],
]);

// 1. 직렬화 (Array로 변환)
const json = JSON.stringify(Array.from(myMap.entries()));
console.log(json); // [["id",1],["name","Gemini"]]

// 2. 역직렬화 (다시 Map으로 복구)
const restoredMap = new Map(JSON.parse(json));

Object와 Map 성능 비교

Object와 Map은 요소 추가/삭제 성능에서 차이를 보입니다. 특히, 객체 프로퍼티의 빈번한 추가/삭제가 필요한 경우 Map이 Object보다 더 효율적입니다. 이 이유는 아래 히든 클래스에서 다룰 예정이니 끝까지 읽어주세요!

먼저 아래 코드의 실행 결과를 확인해서 Object와 Map의 성능 차이를 확인할 수 있습니다.

const iterations = 1000000;

// Object 테스트
console.time('Object 추가/삭제');
for (let i = 0; i < iterations; i++) {
myObj[`key${i}`] = i;
}
for (let i = 0; i < iterations; i++) {
delete myObj[`key${i}`];
}
console.timeEnd('Object 추가/삭제');

// Map 테스트
console.time('Map 추가/삭제');
for (let i = 0; i < iterations; i++) {
myMap.set(`key${i}`, i);
}
for (let i = 0; i < iterations; i++) {
myMap.delete(`key${i}`);
}
console.timeEnd('Map 추가/삭제');

위 코드를 실행하면 약 200ms 정도 차이가 나는 결과를 볼 수 있습니다.

Object와 Map 성능 테스트

그렇다면 어떠한 이유로 성능 차이가 발생하는 걸까요? 이 질문에 대한 답을 알아보기 위해 다음 챕터로 넘어가봅시다.

자바스크립트의 성능 최적화

자바스크립트는 동적 타이핑 언어로, 런타임에 객체의 프로퍼티를 자유롭게 추가하거나 수정할 수 있는 유연성을 제공합니다. 이로 인해 자바스크립트는 속성 접근 시 동적으로 프로퍼티를 조회해야 하는 특성을 지니고 있습니다. 초기 자바스크립트 엔진은 객체를 해시 테이블 구조로 관리했으나, 현대 엔진(v8, SpiderMonkey 등)은 히든 클래스와 인라인 캐싱 같은 기법을 적용해 최적화를 하고 있습니다.

전통적인 해시 테이블 기반 객체 속성 접근 방식은 다음과 같습니다. 객체에서 x 라는 문자열 키에 해당하는 값을 찾는 과정을 예시로 들겠습니다.

  1. 'x' 문자열을 해시 함수로 해싱하여 해시값을 생성합니다.
  2. 생성한 해시값을 사용해 해시 테이블의 버킷 인덱스를 계산합니다.
  3. 해당 버킷에 접근하여, 충돌이 발생한 경우 버킷 내에서 선형 탐색 또는 충돌 해결 방식을 수행합니다.
  4. 키 문자열을 비교하여 일치하는 항목을 찾습니다.
  5. 연결된 값을 반환합니다.

히든 클래스

현대 자바스크립트 엔진(V8 등)은 히든 클래스를 활용해 객체의 속성 접근 성능을 최적화해야 합니다. 히든 클래스는 객체의 프로퍼티 구조, 프로퍼티 오프셋(메모리 내 위치), 그리고 프로퍼티 추가/삭제에 따른 전환 정보를 저장하고 있는 내부 데이터 구조입니다. 이를 통해 V8은 객체 구조 변경 시 히든 클래스를 생성하거나 재사용해 성능을 높이고 있습니다.

코드와 함께 히든 클래스가 어떻게 생성되고 사용되는지 알아봅시다. 편의상 현대 자바스크립트 엔진은 V8로 표기하겠습니다.

빈 객체를 생성하면 V8 엔진은 해당 객체에 대한 초기 히든 클래스(C0)를 생성합니다. 이 시점의 히든 클래스는 아직 프로퍼티가 없습니다.

const point = {};

객체에 x프로퍼티를 추가하면, V8 엔진은 기존 히든 클래스(C0)를 기반으로 새로운 히든 클래스(C1)를 생성합니다. 히든 클래스(C1)은 프로퍼티 x와 메모리 오프셋 정보를 포함하며, C0 -> C1 전환 경로를 기록합니다.

point.x = 10;

객체에 새로운 프로퍼티 y를 추가합니다. V8은 기존 히든 클래스(C1)를 상속하여 또 다른 히든 클래스(C2)를 생성합니다. 내부 구조는 히든 클래스(C1)과 동일합니다.

V8 엔진은 동일한 구조(같은 프로퍼티를 같은 순서로 추가한 객체)를 가진 객체들이 동일한 히든 클래스를 공유하도록 합니다. 이를 통해 속성 접근 시 성능을 최적화하고 있습니다.

주의할 점은 객체 프로퍼티티를 동일한 순서가 아닌 다른 순서로 추가하거나 삭제하면 히든 클래스를 재사용하지 못합니다. 따라서 객체 구조를 일관되게 유지하는 것이 중요합니다.

다만 객체의 프로퍼티가 빈번하게 추가/수정 될 경우 히든 클래스 생성 오버헤드를 유발합니다. 따라서 객체의 프로퍼티가 빈번하게 변경되는 경우 Map이 더 유리합니다.

인라인 캐싱

인라인 캐싱은 자주 사용되는 객체의 속성이나 메서드 접근 결과를 캐싱하여 동일한 접근이 발생했을 때 빠르게 결과를 제공해주는 최적화 기법입니다.

function getGeo(geo) {
const { x, y } = geo;
return [x, y];
}

const seoul = { x: 10, y: 10 };
const gyeonggido = { x: 5, y: 5 };

console.log(getGeo(seoul)); // 1번 호출
console.log(getGeo(gyeonggido)); // 2번 호출

1번 호출이 일어날 때 V8 엔진은 seoul 객체의 x, y 프로퍼티에 대한 접근을 캐싱합니다. 그리고 2번 호출이 일어날 때 엔진은 gyeonggido 객체가 seoul 객체와 동일한 히든 클래스를 가진다는 것을 감지하고, 1번 호출에서 캐싱된 결과를 사용해 계산을 빠르게 수행합니다.

마무리

객체 구조가 고정적이고 단순하며, JSON 직렬화가 필요하거나 프로토타입 상속을 활용해야 한다면 Object를 사용하는 것을 추천합니다.

반면, 객체의 프로퍼티 추가/삭제가 빈번하게 발생하거나, 다양한 키 유형을 사용해야 하고, 이터러블한 특성이 필요하다면 Map이 유리합니다.

콜백이 싫어서 만들어진 Promise

· 약 4분
김현재
개발자

들어가며

자바스크립트에서 비동기 작업은 어떻게 처리할 수 있을까요?

콜백, 프로미스, async/await.. 굉장히 많은 방법이 존재합니다. 그렇다면 이 키워드들은 어떤 특징을 가지고 있을까요? 이번 글에서는 각각의 특징에 대해 알아보려고 합니다.

콜백 함수란?

자바스크립트에는 콜백 함수라는 개념이 존재합니다.

콜백 함수는 "다른 함수에 인자로 전달되어, 특정 시점에 실행되는 함수" 를 말합니다. 쉽게 말해 "나중에 너가 할 일 다 끝나면 이 함수 좀 대신 실행해줘"라고 부탁하는 것과 같습니다. 콜백 함수라는 개념이 있는 이유는 자바스크립트의 함수가 일급 객체이기 때문입니다. 먼저 일급 객체에 대해 알아봅시다.

일급 객체

자바스크립트에서는 함수를 일급 객체로 취급합니다. 이는 함수를 으로 다룰 수 있다는 뜻입니다. 일급 객체가 되기 위한 조건은 크게 세 가지입니다.

  1. 함수를 변수에 담을 수 있어야합니다.
  2. 다른 함수를 호출할 때 파라미터로 넘길 수 있어야합니다.
  3. 함수가 또 다른 함수를 반환할 수 있어야합니다.

콜백 함수를 통한 비동기 작업

아래 예시 코드를 살펴봅시다. setTimeout을 이용해 1초마다 숫자를 1씩 증가시키는 코드입니다.

function increase(count, callback) {
setTimeout(() => {
const increased = count + 1;
console.log(increased);
if (callback) {
callback(increased);
}
}, 1000);
}

increase(0, (count) => {
increase(count, (count) => {
increase(count, (count) => {
increase(count, (count) => {
console.log('종료');
});
});
});
});

increase 함수는 1초를 기다린 후, 계산이 끝나면 넘겨준 callback을 호출하며 결과값을 전달합니다.

Promise란?

프로미스는 자바스크립트의 객체로 비동기 작업의 완료 또는 실패를 나타내는 객체입니다. 프로미스는 다음 중 하나의 상태를 가질 수 있습니다.

  • 대기(pending) : 초기 상태
  • 이행(fulfilled) : 연산이 성공적으로 완료됨
  • 거부(rejected) : 연산이 실패함

Promise로 비동기 처리하기

프로미스 객체는 new 키워드와 함께 Promise 생성자 함수를 이용하여 생성할 수 있습니다. 또한 프로미스 생성자의 매개변수로 두 개의 매개변수를 가진 콜백 함수를 넣을 수 있습니다. 매개변수는 각각 작업이 성공했을 때의 resolve 객체, 작업이 실패했을 때의 reject 객체입니다.

const promise = new Promise((resolve, reject) => {
// 비동기 작업

if (성공) {
resolve();
} else {
reject;
}
});

우리가 자주 사용하는 fetch 메서드 또한 프로미스를 반환한다는 사실을 알고 계셨나요? 아래 예제는 https://jsonplaceholder.typicode.com/ 를 이용해서 작성했습니다. API 요청 테스트가 필요할 때 사용하시면 좋은 서비스입니다.

function getUser() {
return fetch('https://jsonplaceholder.typicode.com/todos/1').then((response) => {
if (!response.ok) {
throw new Error('API 요청 실패');
}
return response.json();
});
}

function getPost(userId) {
return fetch(`https://jsonplaceholder.typicode.com/posts/${userId}`).then((response) => {
if (!response.ok) {
throw new Error('API 요청 실패');
}
return response.json();
});
}

function getComment(postId) {
return fetch(`https://jsonplaceholder.typicode.com/posts/${postId}/comments`).then((response) => {
if (!response.ok) {
throw new Error('API 요청 실패');
}
return response.json();
});
}

getUser().then(({ userId }) =>
getPost(userId)
.then(({ id }) => getComment(id))
.then((comment) => console.log(comment)),
);

이렇게 작성된 함수들을 프로미스 체인(then)을 이용하여 비동기 작업을 처리할 수 있습니다.

then() 함수는 새로운 프로미스를 반환합니다.

async/await

async/await을 사용하면 프로미스를 더욱 편하게 사용할 수 있습니다. async/await은 프로미스의 문법적 설탕(Syntax Sugar)입니다.

문법적 설탕(Syntax Sugar)은 기능은 그대로 유지하지만, 가독성을 높이고 더 간결하게 사용할 수 있도록 변경하는 것을 의미합니다.

async 함수

async 키워드를 function 앞에 위치시키면 해당 함수는 항상 프로미스를 반환합니다. 만약에 함수 내부에서 강제로 프로미스가 아닌 값을 반환하게 하더라도 이행된 프로미스를 반환합니다.

async function foo() {
return 1;
}

foo().then((data) => console.log(data)); // 1

await

awaitasync 함수 내부에서만 동작합니다. await 키워드를 만나면 프로미스가 처리될 때까지 기다리고 결과는 그 이후에 반환됩니다.

그렇다면 위에 프로미스로 작성한 코드를 async 함수들로 변경하면 아래와 같습니다.

async function getUser() {
const response = await fetch(`https://jsonplaceholder.typicode.com/todos/1`);

if (!response.ok) {
throw new Error('API 요청 실패');
}

return response.json();
}

async function getPost(userId) {
const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${userId}`);

if (!response.ok) {
throw new Error('API 요청 실패');
}

return response.json();
}

async function getComment(postId) {
const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}/comments`);

if (!response.ok) {
throw new Error('API 요청 실패');
}

return response.json();
}

이후 이 함수들을 순차적으로 실행시키기 위한 메인 함수도 하나 만들어 주겠습니다.

async function main() {
try {
const userData = await getUser();
const postData = await getPost(userData.id);
const commentData = await getComment(postData.id);

console.log(commentData);
} catch (error) {
console.error('Error: ', error);
}
}

main();

위처럼 작성된 코드는 우리가 원하던 대로 비동기 코드이지만 순차적으로 실행되게 됩니다.

용어 정리

지금까지 학습한 내용을 간단하게 정리해보겠습니다.

Promise

프로미스란 비동기 작업의 완료 또는 실패를 나타내는 객체입니다. 과거 비동기 작업을 콜백으로 처리했을 때 발생했던 콜백 지옥 문제와 중첩된 에러 핸들링 문제를 then 메서드를 활용해 프로미스 체이닝으로 해결할 수 있습니다. 또한 매개변수로 주어진 콜백은 주어진 순서대로 실행되는 것을 보장합니다.

async/await

function 키워드 앞에 async 키워드를 붙여 async 함수를 생성할 수 있습니다. await 키워드는 반드시 async 함수 내부에서만 사용이 가능하며 try/catch 문을 통해 에러를 핸들링합니다.

마무리

이번 글에서는 자바스크립트에서 비동기를 처리하기 위해 사용하는 콜백 함수, 프로미스, async/await에 대해 알아봤습니다. 자바스크립트 언어가 발전함에 따라 과거의 불편했던 점을 개선해나가는 모습을 볼 수 있습니다. 과연 앞으로는 어떤 문법들이 등장할까요?

똑똑하게 라이브러리 관리하기: npm, yarn, pnpm

· 약 7분
김현재
개발자

들어가며

JavaScript 프로젝트를 시작하면 가장 먼저 하는 일이 npm install입니다. 너무 자연스럽게 사용하고 있지만, 패키지 매니저가 정확히 어떤 일을 하는지, 그리고 npm 외에 yarn과 pnpm은 어떤 차이가 있는지 궁금해진 적이 있을 것입니다.

이번 포스팅에서는 패키지 매니저가 무엇인지부터 시작하여, npm, yarn, pnpm 각각의 특징과 장단점을 비교하고, 마지막으로 모노레포 환경에서 pnpm이 주목받는 이유까지 정리해보려고 합니다. 저도 모노레포 환경에서 pnpm을 선호하고 있습니다!

패키지 매니저란?

패키지 매니저는 프로젝트에서 사용하는 외부 라이브러리(의존성)를 설치하고 관리하는 도구입니다.

우리가 코드를 작성할 때 import React from 'react'처럼 간단하게 외부 라이브러리를 가져다 쓸 수 있는 이유는 패키지 매니저가 그 뒤에서 많은 일을 해주기 때문입니다. ECMAScript 표준에서는 원래 절대 경로나 상대 경로로만 모듈을 import할 수 있지만, 패키지 매니저가 'react'라는 이름만으로도 올바른 파일을 찾을 수 있도록 환경을 만들어줍니다.

package.json에 버전 범위를 명시하면 패키지 매니저가 정확한 버전을 결정합니다.

{
"dependencies": {
"react": "^18.2.0"
}
}

여기서 ^캐럿(Caret) 기호로, 버전 범위를 지정하는 키워드 중 하나입니다.

버전 범위 키워드

npm은 Semantic Versioning(SemVer) 규칙을 따릅니다. 버전은 MAJOR.MINOR.PATCH 형태로 구성되며, 각각의 의미는 다음과 같습니다.

  • MAJOR: 기존 API와 호환되지 않는 변경
  • MINOR: 하위 호환되는 새로운 기능 추가
  • PATCH: 하위 호환되는 버그 수정

이 SemVer를 기반으로, package.json에서는 다양한 키워드를 사용하여 설치할 버전의 범위를 지정할 수 있습니다.

키워드예시범위설명
^ (캐럿)^18.2.0>= 18.2.0, < 19.0.0MAJOR 버전 고정, MINOR와 PATCH 업데이트 허용
~ (틸드)~18.2.0>= 18.2.0, < 18.3.0MAJOR와 MINOR 고정, PATCH 업데이트만 허용
없음 (정확)18.2.018.2.0정확히 해당 버전만 설치
**모든 버전최신 버전 설치
>=, <=>=18.0.0>= 18.0.0비교 연산자로 범위 지정

가장 많이 사용되는 것은 ^(캐럿)입니다. npm install로 패키지를 설치하면 기본적으로 캐럿이 붙습니다. 캐럿은 MAJOR 버전이 바뀌지 않는 선에서 최신 버전을 허용하기 때문에, 하위 호환성을 유지하면서도 버그 수정이나 새로운 기능을 자동으로 반영할 수 있습니다.

이처럼 버전 범위 안에서 어떤 버전을 실제로 설치할지 결정하는 것이 패키지 매니저의 역할입니다.

패키지 매니저의 동작 과정

패키지 매니저는 크게 세 단계를 거쳐 동작합니다.

Resolution

모든 의존성의 정확한 버전을 결정하는 단계입니다. ^18.2.0과 같은 범위를 18.3.1처럼 구체적인 버전으로 고정하고, 의존성이 가진 의존성(transitive dependencies)까지 재귀적으로 탐색합니다.

이 결과는 package-lock.json이나 yarn.lock 같은 lock 파일에 저장되어, 다른 환경에서도 동일한 버전을 설치할 수 있도록 보장합니다.

Fetch

결정된 버전의 패키지 파일을 npm 레지스트리에서 다운로드하는 단계입니다.

다운로드한 패키지를 소스 코드에서 실제로 사용할 수 있도록 연결하는 단계입니다. 이 단계에서 npm, yarn, pnpm의 접근 방식이 크게 달라집니다.

npm

npm(Node Package Manager)은 Node.js와 함께 기본으로 제공되는 가장 오래된 패키지 매니저입니다.

동작 방식

npm은 node_modules 디렉토리에 패키지를 직접 설치합니다. 각 패키지가 자신만의 node_modules를 가질 수 있어, 중첩된 구조가 만들어집니다.

my-project/
└─ node_modules/
├─ react/
└─ some-library/
└─ node_modules/
└─ another-library/

의존성을 찾을 때는 현재 디렉토리의 node_modules부터 시작하여 상위 디렉토리로 올라가며 탐색합니다.

장점

  • 별도 설치 불필요: Node.js에 기본 포함되어 있어 추가 설치가 필요 없습니다.
  • 높은 호환성: 가장 널리 사용되므로 대부분의 패키지와 도구가 npm을 기본으로 지원합니다.
  • 낮은 학습 곡선: 별다른 설정 없이 바로 사용할 수 있습니다.

단점

  • 느린 설치 속도: 파일 I/O가 많아 설치 속도가 느립니다.
  • 디스크 용량: 같은 패키지가 여러 곳에 중복 설치될 수 있어 node_modules 용량이 매우 커질 수 있습니다.
  • 유령 의존성(Phantom Dependency): hoisting 최적화로 인해 package.json에 명시하지 않은 패키지도 사용할 수 있는 문제가 발생합니다.

레거시 버전의 npm은 hoisting이 일어나지 않습니다. npm 최적화를 위해 버전업과 함께 hoisting 기능이 추가되었습니다.

yarn

yarn은 Facebook(현 Meta)에서 개발한 패키지 매니저로, npm의 단점을 보완하기 위해 등장했습니다. 특히 yarn v2 이후 도입된 PnP(Plug'n'Play) 방식이 핵심적인 차별점입니다.

PnP(Plug'n'Play)

PnP는 node_modules 디렉토리 자체를 없애고, JavaScript 객체(Map)로 의존성 관계를 관리하는 방식입니다.

yarn install을 실행하면 .pnp.cjs 파일이 생성됩니다.

// .pnp.cjs (간략화)
[
'my-project',
[
{
packageLocation: './',
packageDependencies: [['react', 'npm:18.2.0']],
},
],
][
('react',
[
[
'npm:18.2.0',
{
packageLocation: './.yarn/cache/react-npm-18.2.0-...',
packageDependencies: [['loose-envify', 'npm:1.4.0']],
},
],
])
];

Node.js가 이 Map을 메모리에 올려두고, import나 require가 호출될 때 파일 시스템을 순회하는 대신 Map에서 바로 경로를 찾아줍니다.

장점

  • 빠른 설치 속도: 파일 하나(.pnp.cjs)만 생성하면 되므로 설치가 매우 빠릅니다.
  • 빠른 실행 속도: 파일 시스템 순회 없이 Map 연산으로 모듈을 찾아 실행 속도도 빠릅니다.
  • 엄격한 의존성 관리: package.json에 명시하지 않은 패키지는 사용할 수 없어 유령 의존성 문제가 발생하지 않습니다.
  • 플러그인 아키텍처: 코어 외 대부분의 기능이 플러그인으로 구성되어 있어 확장성이 뛰어납니다.

단점

  • 호환성 이슈: node_modules가 없기 때문에 node_modules의 존재를 가정하는 도구들과 호환되지 않을 수 있습니다.
  • 높은 학습 곡선: PnP 개념 자체가 기존 방식과 다르기 때문에 초기 학습이 필요합니다.
  • Node.js 프로세스 시작 속도: .pnp.cjs 파일을 메모리에 로드하는 과정에서 프로세스 시작이 다소 느려질 수 있습니다.

pnpm

pnpm(Performant npm)은 이름 그대로 npm의 성능을 개선하는 것에 집중한 패키지 매니저입니다.

동작 방식

pnpm은 node_modules 디렉토리를 유지하면서도 Hard LinkContent-Addressable Storage 방식을 사용합니다.

모든 패키지 파일은 전역 저장소(~/.pnpm-store)에 한 번만 저장되고, 각 프로젝트의 node_modules에는 이 파일들의 하드 링크가 생성됩니다. 파일을 실제로 복사하는 것이 아니기 때문에 디스크 용량을 크게 절약할 수 있습니다.

my-project/
└─ node_modules/
├─ .pnpm/ # 실제 패키지들 (하드 링크)
│ ├─ react@18.2.0/
│ └─ lodash@4.17.21/
├─ react -> .pnpm/react@18.2.0/ # 심볼릭 링크
└─ lodash -> .pnpm/lodash@4.17.21/ # 심볼릭 링크

장점

  • 빠른 설치 속도: npm 대비 매우 빠릅니다. 이미 전역 저장소에 있는 패키지는 다시 다운로드하지 않습니다.
  • 디스크 용량 절약: 하드 링크 방식 덕분에 같은 패키지를 여러 프로젝트에서 사용해도 실제 파일은 하나만 저장됩니다.
  • 엄격한 의존성 관리: package.json에 명시하지 않은 패키지에 접근할 수 없어 유령 의존성 문제를 방지합니다.
  • 높은 호환성: node_modules 구조를 유지하기 때문에 기존 도구들과의 호환성이 우수합니다.

단점

  • yarn PnP 대비 느림: 여전히 파일 시스템을 순회하는 방식이기 때문에 yarn PnP보다는 느립니다.
  • 하드 링크 관련 이슈: 일부 환경에서 하드 링크가 지원되지 않거나, 파일 수정 시 다른 프로젝트에도 영향을 줄 수 있는 잠재적 위험이 있습니다.

비교 요약

항목npmyarn (PnP)pnpm
설치 속도느림매우 빠름빠름
디스크 용량작음매우 작음
호환성우수낮음우수
유령 의존성 방지XOO
학습 곡선낮음높음낮음

모노레포에서 pnpm을 사용하는 이유

모노레포(Monorepo)는 하나의 저장소에서 여러 프로젝트(패키지)를 관리하는 방식입니다. 모노레포 환경에서 pnpm이 자주 선택되는 데에는 몇 가지 이유가 있습니다.

워크스페이스 지원이 강력하다

pnpm은 pnpm-workspace.yaml 파일 하나로 워크스페이스를 설정할 수 있습니다.

packages:
- 'packages/*'
- 'apps/*'

각 패키지 간의 의존성 관리, 스크립트 실행, 버전 관리 등을 체계적으로 지원합니다. --filter 옵션을 통해 특정 패키지만 대상으로 명령어를 실행하는 것도 간편합니다.

# 특정 패키지에서만 빌드 실행
pnpm --filter @my-org/web build

# 특정 패키지와 그 의존성까지 포함하여 빌드
pnpm --filter @my-org/web... build

디스크 용량 절약 효과가 극대화된다

모노레포에서는 여러 프로젝트가 같은 패키지를 공유하는 경우가 많습니다. npm은 각 프로젝트마다 동일한 패키지를 중복 설치하지만, pnpm은 하드 링크를 통해 하나의 파일만 유지합니다. 프로젝트 수가 많아질수록 이 차이는 더 커집니다.

엄격한 의존성 관리가 필수적이다

모노레포에서는 여러 패키지가 서로 다른 의존성을 가지고 있어, 유령 의존성 문제가 발생하면 디버깅이 매우 어려워집니다. pnpm의 엄격한 의존성 격리는 각 패키지가 자신이 명시한 의존성만 사용하도록 보장하여, 이런 문제를 사전에 방지합니다.

높은 호환성을 유지한다

yarn PnP도 성능과 엄격한 의존성 관리 측면에서 우수하지만, node_modules를 제거하는 접근 방식 때문에 일부 도구와의 호환성 문제가 발생할 수 있습니다. pnpm은 node_modules 구조를 유지하면서도 위의 장점들을 제공하기 때문에, 기존 도구나 라이브러리와의 호환성을 유지하면서 모노레포의 이점을 누릴 수 있습니다.

이러한 이유로 Turborepo, Nx 같은 모노레포 도구들도 pnpm을 기본 패키지 매니저로 권장하거나 강력하게 지원하고 있습니다.

마무리

지금까지 패키지 매니저의 개념부터 npm, yarn, pnpm의 특징과 차이점까지 살펴보았습니다.

정리하자면, npm은 안정적이고 호환성이 높지만 성능 면에서 아쉽고, yarn PnP는 혁신적인 접근 방식으로 성능과 정확성을 극대화했지만 호환성이라는 과제가 있으며, pnpm은 성능과 호환성 사이에서 좋은 균형을 이루고 있습니다. 프로젝트의 규모와 요구사항에 따라 적절한 패키지 매니저를 선택하는 것이 중요합니다.

참고

안녕 ESLint, Prettier: Biome으로 도구 통합하기

· 약 5분
김현재
개발자

들어가며

바로 이전 글인 협업을 위한 코드 규칙을 정하자 에서 eslintprettier 를 이용해 협업에서 중요한 요소인 코드의 품질 관리와 일관성에 대해 다루었습니다. 이후 여러 자료를 살펴보던 중 Biome 라는 새로운 도구에 대해 알게 되었고, eslintprettier 를 사용하는 프로젝트에서 Biome 로 변경하게된 이유와 변경 전후의 성능 차이에 대해 이번 포스팅에서 상세히 작성하려고 합니다.

Biome이란

Biome은 JavaScript, TypeScript, JSX, TSX, JSON 등의 언어를 지원하며, 총 293개의 규칙을 제공하는 툴체인입니다. 하지만 현재는 컴파일러의 기초 작업 단계에 있으며, 주로 Code FormatterLinter 기능을 제공하고 있습니다.

툴체인은 소스 코드를 컴파일러, 링커, 라이브러리 등을 의미합니다.

최근 Biome의 사용자가 점차 늘어나고 있어 많은 개발자들에게 주목을 받는 프로젝트라고 할 수 있습니다. 참고로 Biome은 prettier에서 개최한 대회에서 우승을 차지한 프로젝트입니다

npm trend에서 biome 사용량 증가 차트

그렇다면 왜 Biome을 사용해야 하는 걸까요?

Biome의 장점

  • 빠른 속도 : Rust로 구현되어 있어 매우 빠른 속도를 자랑합니다. 공식 홈페이지에 따르면 prettier 보다 약 35배 빠른 속도라고 소개하고 있습니다.

biome과 prettier 속도 비교

  • 간편한 설정 : eslint, prettier 보다 설정이 단순해 빠르게 적용할 수 있습니다.
  • 다양한 언어 지원 : 여러 언어를 한 번에 관리할 수 있어 편리합니다.

Biome Formatter 소개글 Biome Linter 소개글 Biome에서 지원하는 언어 목록

Rust로 구현된 프로그램이 Javascript 보다 빠른 이유는 병렬처리와 컴파일 타임의 차이에 있습니다. Rust로 구현된 프로그램은 사전에 컴파일이 완료되어 바이너리 코드로 즉시 작동하며, 싱글 스레드로 작동하는 Javascript와는 다르게 병렬적으로 작동합니다.

직접 사용해보자

위에서 Biome에 대해서 간략하게 알아보았습니다. 저는 현재 eslintprettier 를 사용중인 프로젝트가 있으며, 이 프로젝트를 Biome을 사용하도록 마이그레이션 진행을 해보겠습니다.

Biome 설치

저는 yarn 을 사용하고 있어서 아래의 명령어를 통해 설치했습니다. 혹시 yarn 이 아닌 다른 패키지 매니저를 사용한다면 아래의 공식 문서 페이지를 참고해서 본인의 환경에 맞는 명령어를 사용하면 됩니다.

Biome 설치 가이드

yarn add --dev --exact @biomejs/biome

모듈이 설치되었다면 설정 파일을 생성해야 합니다. 아래 명령어를 통해 생성합시다.

yarn biome init

명령어 실행이 완료되었다면 root 디렉토리에 biome.json 이 생성되었을 것입니다.

{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": []
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
}
}

다음으로는 Biome 플러그인을 설치합니다.

biome 익스텐션 설치

설치를 하지 않으면 biome 에러가 발생하지 않습니다. biome 익스텐션 설치 전 설치후에는 biome 에서 에러가 발생합니다. biome 익스텐션 설치 후

eslint, prettier 마이그레이션

기존 프로젝트에 있던 설정들을 biome.json 으로 옮겨봅시다.

아래 명령어를 통해 기존 eslint 설정을 biome.json 으로 가져올 수 있습니다. 하지만 저의 환경에서는 에러가 발생하고 마이그레이션이 작동하지 않았습니다. biome 마이그레이션 에러

아직 Biome 은 아직 레퍼런스가 많지 않아서 혹시라도 이 글을 보는 독자들이 에러를 해결하는데 도움이 되고자 해결 방법도 함께 작성했습니다.

해결 방법은 아래와 같습니다.

node_modules/eslint-config-next/index.js 에서 require('@rushstack/eslint-patch/modern-module-resolution') 를 주석처리 후 다시 마이그레이션 명령어를 실행하면 작동합니다.

명령어를 실행하면 아래 코드처럼 자동으로 biome.json 의 설정이 적용됩니다.

{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false },
"files": { "ignoreUnknown": false, "ignore": [] },
"formatter": {
"enabled": true,
"useEditorconfig": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 100,
"attributePosition": "auto",
"bracketSpacing": true
},
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
"rules": {
"recommended": false,
"a11y": {
"noAriaUnsupportedElements": "warn",
"noBlankTarget": "error",
"useAltText": "warn",
"useAriaPropsForRole": "warn",
"useValidAriaProps": "warn",
"useValidAriaValues": "warn"
},
"complexity": {
"noExtraBooleanCast": "error",
"noMultipleSpacesInRegularExpressionLiterals": "error",
"noUselessCatch": "error",
"noUselessTypeConstraint": "error",
"noWith": "error"
},
"correctness": {
"noChildrenProp": "error",
"noConstAssign": "off",
"noConstantCondition": "error",
"noEmptyCharacterClassInRegex": "error",
"noEmptyPattern": "error",
"noGlobalObjectCalls": "off",
"noInvalidBuiltinInstantiation": "off",
"noInvalidConstructorSuper": "off",
"noNewSymbol": "off",
"noNonoctalDecimalEscape": "error",
"noPrecisionLoss": "error",
"noSelfAssign": "error"
// ...
},
"security": { "noDangerouslySetInnerHtmlWithChildren": "error" },
"style": {
"noArguments": "error",
"noNamespace": "error",
"noVar": "error",
"useAsConstAssertion": "error",
"useBlockStatements": "off",
"useConst": "error"
},
"suspicious": {
"noAsyncPromiseExecutor": "error",
"noCatchAssign": "error",
"noClassAssign": "off",
"noCommentText": "error",
"noCompareNegZero": "error",
"noControlCharactersInRegex": "error",
"noDebugger": "error",
"noDuplicateCase": "error",
"noDuplicateClassMembers": "off",
"noDuplicateJsxProps": "error",
"noDuplicateObjectKeys": "off",
"noDuplicateParameters": "off"
// ...
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "all",
"semicolons": "always",
"arrowParentheses": "asNeeded",
"bracketSameLine": false,
"quoteStyle": "single",
"attributePosition": "auto",
"bracketSpacing": true
},
"globals": [
"OffscreenCanvas",
"onpointerleave",
"onemptied",
"onkeypress",
"onloadeddata",
"onmouseup"
// ...
]
}
}

성능 측정

Biome 설정과 마이그레이션이 완료 되었으니 가장 중요한 성능 측정을 했습니다.

eslint와 biome 성능 측정

기존 eslint를 사용할 때 작업 시간은 약 2.17초였으나, Biome을 사용하니 0.17초로 약 92.17% 작업 시간이 단축되었습니다.

eslint 시간 측정

biome 시간 측정

prettier와 biome 성능 측정

기존 prettier 사용 시 작업 시간은 약 1.43초였으나, Biome을 사용하니 0.16초로 약 88.81% 작업 시간이 단축되었습니다.

prettier 시간 측정

biome 시간 측정

그 외

주로 다룬 내용 외에도 Biome 을 사용하면서 알게된 꿀팁들을 공유하려고 합니다.

reporter

package.jsonscripts 에 명령어를 추가합니다.

  "scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "biome lint --write",
"format": "biome format --write",
"reporter": "biome check --reporter=summary"
},

reporter 명령어를 사용한다면 Biome이 친절하게 결과를 리포트 형식으로 제공해줍니다. reporter 결과

제외 파일 설정

biome.json에서 제외할 파일들에 대해 설정할 수 있습니다. 이 포스팅에서는 빈 배열로 진행했지만, 실제 실행결과 node_modules 파일까지 분석하는 경우가 있기에 이를 명시해서 검사 파일에서 제외시켜줍시다.

// biome.json
"files": { "ignoreUnknown": false, "ignore": ["node_modules", ".next", ".github", "public"] },

마무리

현재 진행 중인 프로젝트를 시작한 지 1개월밖에 되지 않은 시점입니다. 1개월 전 eslint의 버전을 v9로 올리면서 여러 가지 어려움을 겪었는데, 그로부터 한 달 만에 Biome으로 마이그레이션을 하니 다소 아쉬운 느낌이 들기도 합니다.

현재 시점(2025/04/19) 다시 eslint + prettier 설정으로 돌아왔습니다. 돌아온 이유는 아직 biome에서 tailwind 자동 정렬 기능이 지원되고 있지 않으며, import order 또한 커스텀 설정이 불가능하기 때문입니다.

한달만에 마이그레이션이라니...

하지만 이번 경험을 통해 더 빠르고 효율적인 도구를 발견했다는 점에서 긍정적으로 생각합니다. 앞으로 Biome이 더욱 안정화되고 기능이 확장된다면, 협업 환경에서 코드 품질 관리와 개발 생산성 향상에 큰 도움이 될 것으로 기대합니다.

누가 짜도 한 사람이 짠 것처럼: 협업을 이끄는 코드 규칙

· 약 6분
김현재
개발자

들어가며

최근 캡스톤 디자인 수업을 듣고 있습니다. 캡스톤 디자인 프로젝트로 체험단 웹서비스를 개발하기로 결정했고, 개발을 시작하기 위해 언제나 스트레스를 받는 환경 세팅을 진행했습니다. 이번 프로젝트는 next 최신 버전인 15.2.2 버전을 사용하기로 결정했으며, 이로 인해 기존 세팅과 다른 점들이 있어 애를 먹은 부분이 존재했습니다. 특히 eslint 버전이 9 버전으로 올라갔고, 설정 방법이 약간 달라져서 이번 경험을 기록하기 위한 포스팅을 작성하게 되었습니다.

개발자에게 협업이란

개발자에게 있어 협업이란 굉장히 중요한 요소입니다. 많은 직무의 동료들과 협업을 하겠지만, 이번 포스팅은 개발자들과의 협업시 필요한 코드의 품질 관리와 일관성입니다. 이러한 문제들을 해결하기 위해서는 코드 규칙을 정하고, 이를 자동으로 적용할 수 있는 도구들이 필요합니다. 따라서 이번 포스팅에서는 코드 규칙을 정해 오류를 사전에 방지할 수 있는 eslint 와 코드의 포맷을 일관되게 만들어주는 prettier 를 다룰 예정입니다.

코드 스타일 유지

일관된 코드 스타일을 유지하는 것은 여러 개발자가 같은 코드를 볼 때 가독성을 높이고, 협업 시 발생할 수 있는 혼란을 줄이는 데 매우 중요합니다. 코드가 일관되게 작성되면, 팀원들은 서로의 코드를 쉽게 이해하고 수정할 수 있게 됩니다. 이는 특히 프로젝트의 크기가 커질수록 필수입니다.

prettier

prettier 는 코드 포캣팅 도구로, 코드의 스타일을 일관되게 자동으로 정리해줍니다. 예를 들어, 들여쓰기, 줄 바꿈, 세미콜론 사용 여부 등 다양한 규칙을 직접 설정할 수 있습니다.

자세한 내용은 아래 공식문서를 참고해주시면 됩니다.

prettier 공식문서

prettier 설치

저는 yarn 을 사용하고 있어서 아래의 명령어를 통해 prettier 를 추가했습니다.

npm install --save-dev --save-exact prettier

yarn add --dev --exact prettier

pnpm add --save-dev --save-exact prettier

--exact 명령어는 패키지 버전을 정확히 설치하기 위한 옵션입니다.

// --exact 사용
"prettier": "3.2.5"

// --exact 미사용
"prettier": "^3.2.5"

.prettierrc

prettier 를 설치했다면 설정 파일은 루트 디렉토리에 .prettierrc 를 생성하여 설정합니다. 저는 아래와 같이 설정했습니다. 각 옵션에 대해서는 주석을 참고해주시면 됩니다.

prettier option 공식문서

{
"printWidth": 100, // 한 줄의 최대 너비
"singleQuote": true, // 단일 인용부호(')로 사용할지 여부
"trailingComma": "all", // 마지막 항목 뒤에 쉼표 사용 여부
"tabWidth": 2, // 탭 너비
"arrowParens": "avoid", // 화살표 함수에서 단일 파라미터에 괄호 사용 여부
"semi": true // 명령문의 끝에 세미콜론 추가 여부
}

vscode 설정

먼저 vscode의 extension에서 prettier를 설치합니다.

prettier 익스텐션

설치가 되었다면 ctrl + , 키를 통해 설정 화면으로 이동합니다. 이후 위 검색창에 Default Formatter 를 검색하여 Prettier 로 설정합니다.

default formatter 설정

다음으로 검색창에 Format on save mode 를 검색 후 사용하도록 체크해줍니다. 이 옵션은 저장시 코드 포맷터인 prettier 가 작동하여 스타일을 적용해주는 옵션입니다.

format on save 설정

마지막으로 setting.json 파일에서 아래 내용을 추가해줍니다.

{
"files.autoSave": "afterDelay",
"files.autoSaveDelay": 3000,
"editor.formatOnSave": true
}

추가로 vscode 설정에서도 prettier 포맷 설정을 할 수 있는데, 프로젝트에 .prettierrc 파일이 있다면 이 파일이 적용 순위가 더 높기 때문에 설정값이 무시됩니다.

코드 규칙 설정

코드 규칙 설정은 팀의 코드 품질을 높이고, 협업을 원활하게 하기 위한 중요한 과정입니다. 명확한 규칙을 정함으로써 팀원들은 동일한 기준에 따라 코드를 작성할 수 있습니다. 또한 이러한 코드는 가독성과 일관성을 크게 향상시켜줍니다.

eslint

prettier 는 코드 스타일 즉 포맷팅을 위한 도구라면, eslint 는 코드의 품질을 검사하고 유지하기 위한 도구입니다. Javascript, Typescript 코드에서 문법 오류, 코드 스타일 위반, 그리고 잠재적인 버그를 찾아내는 역할을 합니다.

자세한 내용은 아래 공식문서를 참고해주시면 됩니다.

eslint 공식문서

eslint 설치

eslint 는 아래 명령어를 통해 설치와 설정 파일을 생성할 수 있습니다.

npm init @eslint/config@latest

yarn create @eslint/config

pnpm create @eslint/config@latest

bun create @eslint/config@latest

만약 이 글을 읽고 있는 독자가 npx create-next-app@latest 를 이용해서 next 프로젝트를 시작한다면 터미널에서 eslint 사용여부를 선택할 수 있습니다.

Would you like to use ESLint? No / Yes

.eslintrc.json (ESLint 8 이하)

package.json 에서 본인이 설치한 eslint 버전을 확인해보자. 버전이 8 이하라면 아래와 같이 린트 규칙을 설정할 수 있습니다.

먼저 루트 디렉토리에 .eslintrc.json 파일을 생성합니다. 필자의 프로젝트는 아래와 같이 설정되어있습니다.

{
// ESLint 규칙 세트를 상속받아서 사용
"extends": ["next/core-web-vitals", "next/typescript", "plugin:prettier/recommended", "plugin:import/recommended"],

// 개별적으로 적용시킬 규칙. 사용하려면 rules에 추가해야함
"plugins": ["prettier", "import"],

// ESLint가 코드에서 검사할 규칙들 정의
"rules": {
"prettier/prettier": "error",
"import/no-named-as-default": "off",
"import/order": [
"error",
{
"groups": ["builtin", "external", "internal", ["parent", "sibling"], "index", "object", "type", "unknown"],
"pathGroups": [
{
"pattern": "react*",
"group": "builtin",
"position": "before"
},
{
"pattern": "next",
"group": "builtin"
},
{
"pattern": "@/assets/**",
"group": "internal",
"position": "after"
},
{
"pattern": "@/store/**",
"group": "internal",
"position": "after"
},
{
"pattern": "@/lib/**",
"group": "internal",
"position": "after"
},
{
"pattern": "@/components/**",
"group": "internal",
"position": "after"
}
],
"pathGroupsExcludedImportTypes": ["react-hook-form"],
"newlines-between": "always",
"alphabetize": {
"order": "asc",
"caseInsensitive": true
}
}
]
}
}

eslint.config.js (ESLint 9 이상)

eslint 9 버전부터는 기존의 .eslintrc.json 또는 .eslintrc.yaml 형식이 지원되지 않고, 새로운 Flat Config 형식으로 설정 파일을 작성해야 합니다. Flat Config는 JavaScript 파일(eslint.config.js)로 작성하며, JS Object를 직접 export 하는 방식입니다.

기존 문자열 기반 설정에서 벗어나 코드로 직접 설정할 수 있어 가독성과 확장성이 좋아졌습니다.

현재 저의 설정 파일은 아래와 같습니다.

import { FlatCompat } from '@eslint/eslintrc';
import js from '@eslint/js';

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

const eslintConfig = [
...compat.config({
ignorePatterns: ['node_modules/'],
extends: ['next', 'prettier', 'next/core-web-vitals', 'next/typescript', 'eslint:recommended'],
plugins: ['import'],
rules: {
// 상대 경로 사용 금지
// 'no-restricted-imports': [
// 'error',
// {
// patterns: ['.*'],
// },
// ],
'no-undef': 'off',

// HTML 엔티티 사용 가능
'react/no-unescaped-entities': 'off',

// 페이지 전용 폰트 방지
'@next/next/no-page-custom-font': 'off',

// 선언은 했지만 사용되지 않은 변수
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'warn',

// any 타입 사용 금지
'@typescript-eslint/no-explicit-any': 'error',

// 모듈 정렬
'import/order': [
'error',
{
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'unknown'],
pathGroups: [
{
pattern: 'react', // React 관련
group: 'external',
position: 'before',
},
{
pattern: 'next', // Next.js 관련
group: 'external',
position: 'after',
},
{
pattern: 'next/font/google', // Next.js 폰트 관련
group: 'external',
position: 'after',
},
{
pattern: 'configs/**', // configs 폴더
group: 'internal',
position: 'after',
},
{
pattern: 'hooks/**', // hooks 폴더
group: 'internal',
position: 'after',
},
{
pattern: 'libs/**', // libs 폴더
group: 'internal',
position: 'after',
},
{
pattern: 'services/**', // services 폴더
group: 'internal',
position: 'after',
},
{
pattern: 'stores/**', // stores 폴더
group: 'internal',
position: 'after',
},
{
pattern: 'types/**', // types 폴더
group: 'internal',
position: 'after',
},
{
pattern: 'utils/**', // utils 폴더
group: 'internal',
position: 'after',
},
{
pattern: 'constants/**', // public 폴더
group: 'internal',
position: 'after',
},
{
pattern: 'public/**', // public 폴더
group: 'internal',
position: 'after',
},
{
pattern: '@tanstack/**', // @tanstack 관련 라이브러리
group: 'internal',
position: 'after', // 가장 마지막에 배치
},
{
pattern: 'embla-carousel-autoplay', // embla-carousel-autoplay 라이브러리
group: 'internal',
position: 'after', // 가장 마지막에 배치
},
],
pathGroupsExcludedImportTypes: ['@tanstack', 'embla-carousel-autoplay'],
'newlines-between': 'always',
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
},
}),
];

export default eslintConfig;

위와 마찬가지로 각각의 설정은 공식문서를 참고해서 본인이 직접 작성하는 것을 권장합니다.

마무리

이번 포스팅에서는 eslint 8, eslint 9 버전의 설정 방법과, 협업에 필수적인 코드 스타일 관리 도구인 prettier 사용법에 대해 다뤄보았습니다. 특히 eslint의 Flat Config 도입으로 설정 방식이 크게 바뀌면서 초기 환경 세팅에 다소 어려움이 있을 수 있지만, 이는 더 깔끔하고 유지보수가 쉬운 설정을 가능하게 한다는 점에서 긍정적인 변화라 할 수 있습니다.

저는 코드 품질과 일관성을 유지하는 것은 협업의 기본이자 프로젝트 성공의 중요한 부분이라고 생각합니다. 따라서 prettier와 eslint를 적절히 활용해 자동화된 코드 스타일 검사 및 포맷팅 환경을 구축하는 것을 적극 추천합니다. 앞으로도 최신 도구와 변화에 유연하게 대응하며, 쾌적한 개발 환경을 만들어가길 바랍니다.

추가: 이 글을 기존 블로그에서 옮기면서 문득 든 생각이 있습니다. 과거에는 개발자들끼리 이해하기 쉽고, 관리하기 쉬운 네이밍 또는 파일 구조들이 중요했다면 현 시점에서는 AI 에이전트가 코드를 해석하기 쉬운 방식이 더 중요할 수 있겠다는 생각이 들었습니다. 따라서 추후 프론트엔드 진영에서 레포지토리를 관리하는 여러 방법론들이 AI 에이전트 성능에 어떤 영향을 주는지 알아보는 시간을 가져보려고 합니다. 기대해주세요!

RSC + Streaming SSR로 대시보드 체감 속도 2배 높이기

· 약 4분
김현재
개발자

들어가며

사내 보안 대시보드를 개발하던 중, 이상한 점을 발견했습니다. 기능 구현은 완료됐는데 Lighthouse를 돌려보니 FCP(First Contentful Paint)가 3초를 훌쩍 넘기고 있었습니다. 사용자는 페이지에 접속하자마자 3초 이상 빈 화면을 마주하고 있었던 것입니다.

원인이 무엇이였을까요? 대시보드 페이지는 여러 API를 동시에 호출해야 했고, SSR 환경에서 서버가 모든 데이터를 다 받아온 뒤에야 HTML을 클라이언트로 전송하고 있었습니다. 모든 응답이 끝날 때까지 기다리는 구조였으니, FCP가 3초를 넘는 건 당연한 결과였습니다.

이번 포스팅에서는 Next.js의 RSC(React Server Components)Streaming SSR 을 조합하여 이 문제를 해결한 경험을 정리했습니다.

본 포스팅은 사내 프로젝트의 경험을 바탕으로 작성되었으나, 보안 정책상 실제 코드와 이미지를 그대로 사용할 수 없습니다. 따라서 프로젝트에서 해결한 핵심 로직과 메커니즘을 동일하게 재현한 별도의 데모 코드와 자료를 활용하여 정리했습니다.

기존 방식의 문제점

App Router에서는 페이지 컴포넌트 자체를 async로 선언하고 직접 데이터를 fetch할 수 있습니다. Promise.all로 병렬 요청을 보내면 총 대기 시간을 줄일 수 있지만, 근본적인 한계가 있습니다.

// page.tsx — async Server Component
export default async function DashboardPage() {
// 병렬 요청이지만, 모두 완료될 때까지 렌더링 불가
const [threats, assets, compliance] = await Promise.all([
fetchThreats(), // 800ms
fetchAssets(), // 700ms
fetchCompliance(), // 1200ms ← 이 API가 전체를 결정
]);

return (
<div>
<ThreatCard data={threats} />
<AssetCard data={assets} />
<ComplianceCard data={compliance} />
</div>
);
}
서버                          브라우저
|── fetchThreats() ──▶ 800ms |
|── fetchAssets() ───▶ 700ms | 사용자는
|── fetchCompliance()▶ 1200ms | 이 동안
| | 아무것도
|◀── 모든 응답 완료 ──────────| 볼 수 없음
|── HTML 전송 ───────────────▶|

Promise.all로 병렬 요청을 보내더라도, 모든 응답이 완료될 때까지 HTML 전송 자체가 불가능합니다. 가장 느린 API(1,200ms)가 전체 페이지 속도를 결정하며, 이는 Web Vital의 핵심 지표인 FCP에 직접적인 영향을 줍니다.

그렇다면 await을 하나씩 순차적으로 사용하면 어떨까요? 오히려 더 느려집니다. 직렬로 요청하면 800 + 700 + 1,200 = 2,700ms를 기다려야 하기 때문입니다. Promise.all이든 순차 await이든, 페이지 컴포넌트의 async 함수가 완전히 끝나야 React가 렌더링을 시작한다는 구조 자체가 문제입니다. 그렇다면 어떻게 이 문제를 해결할 수 있을까요?

해결책: RSC + Streaming SSR

Next.js App Router는 두 가지 핵심 기능을 제공합니다.

  • RSC(React Server Components): 컴포넌트 자체가 async 함수가 될 수 있어, 컴포넌트 단위로 데이터 패칭이 가능합니다.
  • Streaming SSR: HTML을 조각(chunk) 단위로 나눠서 준비되는 대로 클라이언트에 전송합니다.

이 두 가지를 Suspense와 조합하면 섹션별로 독립적인 스트리밍이 가능해집니다.

구현 방법

루트 페이지를 Shell로 만들기

페이지 컴포넌트를 async로 선언하지 않으면, 서버는 데이터를 기다리지 않고 즉시 HTML을 생성해 전송합니다. 이 Shell이 클라이언트에 도착하는 순간 FCP가 기록됩니다.

Shell이란 데이터 없이 즉시 렌더링할 수 있는 페이지의 뼈대를 말합니다. 사용자는 빈 화면 대신 페이지 구조를 먼저 볼 수 있습니다.

// page.tsx — async 없이 즉시 렌더링
export default function DashboardPage() {
return <main>{/* 각 섹션은 Suspense로 감싸 독립적으로 스트리밍 */}</main>;
}

각 섹션을 RSC로 분리하기

// ThreatSection.tsx — async RSC
export default async function ThreatSection() {
const data = await fetchThreatData(); // 이 컴포넌트만을 위한 fetch
return <ThreatCard data={data} />;
}

각 섹션이 독립적인 컴포넌트로 분리되어 있기 때문에, 모든 fetch가 동시에 시작됩니다. 페이지 컴포넌트가 await Promise.all로 묶어서 기다리지 않아도 됩니다.

Suspense로 감싸 스켈레톤 UI 제공하기

Suspense로 감싼 RSC는 데이터가 준비될 때까지 fallback(스켈레톤)을 보여줍니다. 데이터가 도착하면 React가 스켈레톤을 실제 컴포넌트로 교체하고, 그 결과를 스트리밍으로 클라이언트에 전송합니다.

<main>
<Suspense fallback={<ThreatCardSkeleton />}>
<ThreatSection /> {/* 800ms 후 스트리밍 */}
</Suspense>

<Suspense fallback={<AssetCardSkeleton />}>
<AssetSection /> {/* 700ms 후 스트리밍 */}
</Suspense>

<Suspense fallback={<IssueTableSkeleton />}>
<IssueSection /> {/* 600ms 후 스트리밍 */}
</Suspense>

<Suspense fallback={<ComplianceTableSkeleton />}>
<ComplianceSection /> {/* 1200ms 후 스트리밍 */}
</Suspense>
</main>

스트리밍 타임라인

이렇게 구현하면 다음과 같은 순서로 화면이 채워집니다.

0ms     → Shell 즉시 전송 (FCP 기록)
0ms → 4개 섹션이 동시에 fetch 시작 (병렬)
+600ms → IssueSection 완료, 스트리밍
+700ms → AssetSection 완료, 스트리밍
+800ms → ThreatSection 완료, 스트리밍
+1200ms → ComplianceSection 완료, 스트리밍

기존 방식이라면 1,200ms가 지나도록 빈 화면이었겠지만, 이제는 600ms부터 순차적으로 콘텐츠가 채워집니다.

성능 비교

지표기존 방식RSC + Streaming
FCP (First Contentful Paint)~1,200ms 이상~200ms (shell)
LCP (Largest Contentful Paint)~3,000ms~900ms
체감 로딩 시간~3,000ms~1,500ms 이하

수치상으로는 절반이지만, 사용자가 느끼는 체감 속도 차이는 훨씬 큽니다. 기존에는 로딩이 끝날 때까지 아무것도 할 수 없었다면, 이제는 화면이 뜨는 순간부터 순차적으로 정보를 확인할 수 있습니다.

주의할 점

스켈레톤 UI는 필수

Suspensefallback이 없으면 섹션 자리가 비어있다가 갑자기 내용이 나타나 레이아웃 시프트(CLS)가 발생합니다. 실제 컴포넌트와 동일한 크기의 스켈레톤을 반드시 제공해야 합니다.

섹션 경계 설계

Suspense 경계를 너무 잘게 나누면 관리가 복잡해지고, 너무 크게 잡으면 스트리밍 효과가 줄어듭니다. 사용자에게 의미 있는 단위로 섹션을 나누는 것이 중요합니다.

마무리

CSR에서 SSR로 전환하면 성능이 좋아질 거라 막연히 기대했습니다. 하지만 SSR도 만능이 아니었습니다. 서버에서 모든 데이터를 기다렸다가 한 번에 렌더링하는 구조는, 오히려 CSR보다 긴 공백 화면을 만들어낼 수 있습니다.

RSC와 Streaming SSR은 그 한계를 보완하기 위해 Next.js가 내놓은 해답이었습니다. 이번 경험을 통해 기술을 도입할 때 "왜 이 기술이 등장했는가"를 먼저 이해하는 것이 중요하다는 걸 다시 한번 느꼈습니다.

개발자가 마케팅을 이해해야 하는 이유

· 약 6분
김현재
개발자

들어가며

개발자로서 자신이 만든 웹서비스가 많은 사용들에게 노출되는 것은 중요합니다. 저 또한 이 블로그를 노출 시키기 위해 SEO에 대해 학습하고 적용하는 경험을 했습니다. 이번 포스팅에서는 이러한 경험들에 대해 작성해보려고 합니다.

해당 글을 작성했을 당시는 Next.js 기반의 블로그를 운영했으며, 현재는 Docusaurs 기반의 블로그로 이전한 상황입니다. 글 내용과 맞지 않는 부분이 있을 수 있다는 점 알려드립니다.

SEO

SEO는 Search Engine Optimization 의 약자로 웹페이지가 검색 결과에서 높은 순위를 차지하도록 최적화하는 기술과 전략을 의미합니다. 이를 통해 더 많은 트래픽을 유도하고, 사용자에게 유용한 정보를 제공하며, 궁극적으로 웹페이지의 가치를 높이는 것을 목표로 합니다.

한마디로 가치 있는 웹페이지를 구현할수록 검색엔진은 웹페이지에게 높은 순위를 부여하며, 순위가 높을 수록 검색 결과 상단에 위치하게 됩니다.

SEO는 마케팅이다

많은 개발자들이 본인의 서비스를 개발하기 위해 다양한 기술 스택을 공부하고, 더 나아가 예쁜 코드를 작성하려고 노력합니다. 하지만 이렇게 공들여 개발한 서비스가 항상 시장에서 좋은 평가를 받을 수 있지는 않습니다.

시장에서 우리의 서비스를 사용하는 소비자는 요즘 핫한 기술이 적용되었는지, 코드가 예쁘게 작성되었는지에는 대부분 관심이 없습니다. 유저 입장에서는 서비스가 문제 없이 동작만 하면 됩니다. 그렇다고 유지보수가 힘든 코드를 작성해도 된다는 것은 아닙니다.

그렇다면 서비스가 성공하기 위해서는 어떻게 해야할까요? 정말 다양한 요소가 작용하겠지만, 저는 서비스가 시장에서 성공하기 위해서는 사용자들이 쉽게 접근할 수 있어야 한다고 생각합니다. 많은 사용자가 서비스를 이용하게 되려면, 특정 키워드로 검색했을 때 상위에 노출되어야 하고, 이를 통해 자연스럽게 유입이 이루어져야 합니다. 결국 사용자들에게 자주 노출되는 것이 중요한 것입니다.

출처 : 모바일인덱스

위 이미지는 모바일인덱스라는 서비스에서 캡쳐한 이미지입니다. 큰 개발사에서 개발한 게임과, 유튜브 광고에서 매번 등장하는 저퀄리티 게임등이 존재합니다. 근데 의아한 점이 존재합니다. 많은 인력과 자금을 투입한 게임보다도 유튜브 광고에 등장하는 저예산 게임들의 수익이 높은 경우가 존재합니다.

결국 서비스는 많은 사람들에게 노출되어야 하며, 웹페이지를 노출시키기 위해 우리는 SEO를 해야합니다.

SERP

SERP는 Search Engine Result Page 의 약자로, 검색엔진 결과 페이지를 의미합니다. 사용자가 검색엔진에 질의를 입력했을 때, 그에 대한 결과가 표시되는 페이지를 말합니다. SERP는 사용자의 검색 의도에 맞춰 다양한 형태의 결과를 제공하는데, 여기에는 웹페이지 링크, 이미지, 동영상, 뉴스 기사, 지도 정보 등이 포함될 수 있습니다.

위 이미지는 구글 검색창에 스마트 워치 키워드를 검색한 결과입니다. 지금 보고 있는 검색 결과 화면이 SERP 이며 SERP는 크게 두 영역으로 볼 수 있습니다. 상단 스폰서 키워드가 있는 페이지 링크들은 광고 영역, 스폰서 키워드가 붙지 않은 하단 영역을 자연 검색 영역이라고 합니다.

보통 사용자의 경우 광고 영역보다 자연 검색 영역을 더 신뢰합니다. 저 또한 광고 영역보다 자연 검색 영역을 신뢰했던 기억이 있습니다. 그렇기에 우리는 SERP 에서 자연 검색 영역 상단에 웹페이지가 위치할 수 있도록 SEO 작업을 수행해야 합니다.

구글 검색 엔진 동작

그렇다면 검색 엔진은 어떻게 동작할까요?

구글 검색 엔진을 예시로 들어 검색 엔진이 어떻게 동작하는지 알아봅시다. 구글 검색 엔진은 크게 3가지 단계로 나뉘어 동작합니다. 구글봇이 웹페이지 컨텐츠를 수집하는 크롤링 (Crawling), 수집한 정보를 주제별로 색인해서 보관하는 인덱싱 (Indexing) 마지막으로 검색 의도에 맞춰 콘텐츠에 순위를 부여하는 랭킹 (Ranking) 으로 나뉘어 동작합니다. 구글 검색 엔진의 자세한 구조도가 궁금하시다면 아래 그림과 주소를 참고해주세요.

(구글 검색 엔진 논문)

높은 수준의 Google 아키텍처

크롤링

구글 봇은 웹페이지의 콘텐츠를 수집합니다. 이 글을 작성하고 있는 시점에도 새로운 웹페이지는 계속해서 등장하고 있습니다. 하지만 이 모든 웹페이지가 등록되는 중앙 레지스트리는 없기 때문에 계속해서 새 페이지와 업데이트된 페이지를 파악해야 합니다.

구글 아키텍처 그림을 확인해봅시다. URL 서버에서 크롤러에게 URL 목록을 전송합니다. 전달받은 URL은 여러 분산 크롤러에 의해 크롤링되며, 크롤링 된 데이터를 스토어 서버에 전송합니다. 이후 압축하여 레포지토리에 저장합니다.

크롤러가 모든 페이지를 크롤링하는 것은 아닙니다. 사이트 소유자는 크롤링을 허용하지 않는 페이지를 정의할 수 있습니다. 크롤링을 허용하지 않는 페이지를 정의해 둔다면 크롤러는 해당 페이지를 크롤링하지 않습니다.

크롤링을 허용하지 않는 방법에 대해서는 robots.txt 작성법에서 살펴봅시다.

색인 생성

인덱싱 기능은 인덱서소터 에 의해 수행됩니다. 인덱서는 여러 기능을 수행하며 대표적으로 문서의 압축을 풀고, 구문을 분석합니다. 그리고 각 문서는 히트 라는 단어 발생 집합으로 변환됩니다. 히트에는 단어, 문서 내 위치, 글꼴 크기, 대문자 등이 기록됩니다. (저는 히트가 잘 이해가지 않아서 하나의 객체로 생각하고 학습했습니다). 인덱서는 히트를 배럴 로 분배하여 부분적으로 정렬된 인덱스를 생성합니다.

인덱서는 또 다른 기능을 수행하는데, 모든 웹 페이지의 링크를 구문 분석을 수행하고 링크에 대한 정보를 앵커 에 저장합니다. 이 앵커 파일에는 링크의 출발지, 목적지, 링크의 텍스트등의 정보가 포함되어 있습니다.

검색결과 게재

구글은 각 히트를 여러 가지 유형 중 하나로 간주하며, 각 유형별로 가중치가 설정되어 있습니다. 유형 가중치는 유형별로 인덱싱된 벡터를 구성하며, 히트 목록에서 각 유형의 히트 횟수를 계산합니다.

그런 다음 모든 카운트는 카운트 가중치로 변환합니다. 카운트 가중치는 처음에는 카운트에 따라 선형적으로 증가하지만 빠르게 감소하여 특정 카운트 이상은 도움이 되지 않습니다. 카운트 가중치 벡터와 유형 가중치 벡터의 도트 곱을 구하여 문서의 IR 점수를 계산합니다. 마지막으로 IR 점수와 PageRank를 결합하여 문서에 최종 순위를 부여합니다.

SEO 작업을 해보자

지금까지 구글 검색 엔진 논문을 읽어보며 구조에 대해 학습했습니다. 다음으로는 이 블로그를 만들며 Nextjs에서 SEO를 위해 작업했던 내용들을 정리하려고 합니다.

앞서 말씀드렸듯이 해당 글은 과거 Next.js 기반의 블로그를 제작하는 과정에서 작성되었습니다.

메타태그

메타태그는 웹페이지 정보를 명시하기 위한 목적으로 사용되는 HTML 태그입니다. 저는 아래와 같이 작성했습니다. 여러 페이지에서 자주 사용하게 되는 값들은 분리해서 사용중입니다. 이 방식은 shadcnui 레포지토리를 참고했습니다.

// /src/config/site/index.ts

export const siteConfig = {
name: '김현재 | Napol',
url: 'https://www.napol.dev',
ogImage: 'https://www.napol.dev/opengraph-image.png',
description: '현재가 작성하는 현재의 개발 기록',
links: {
github: 'https://github.com/NapolDeveloper',
},
};

export type SiteConfig = typeof siteConfig;
// /app/layout.tsx
export const metadata: Metadata = {
title: {
default: siteConfig.name,
template: `%s`,
},
metadataBase: new URL(siteConfig.url),
description: siteConfig.description,
keywords: [
'프론트엔드',
'자바스크립트',
'타입스크립트',
'리액트',
'넥트스',
'frontend',
'js',
'javascript',
'ts',
'typescript',
'react',
'nextjs',
],
authors: [
{
name: 'Napol',
url: 'https://www.napol.dev',
},
],
creator: 'Napol',
openGraph: {
type: 'website',
locale: 'ko_KR',
url: siteConfig.url,
title: siteConfig.name,
description: siteConfig.description,
siteName: siteConfig.name,
images: [
{
url: siteConfig.ogImage,
width: 1200,
height: 630,
alt: siteConfig.name,
},
],
},
twitter: {
card: 'summary_large_image',
title: siteConfig.name,
description: siteConfig.description,
images: [siteConfig.ogImage],
creator: '@napol',
},
icons: {
icon: '/favicon.ico',
},
manifest: `${siteConfig.url}/site.webmanifest`,
verification: {
other: {
'naver-site-verification': '9c40f297bb089b11c9605077e36f31c1c8203b19',
},
},
};

사이트맵 (sitemap)

사이트맵은 사이트에 있는 페이지, 동영상 및 기타 파일과 그 관계에 관한 정보를 제공하는 파일입니다. 필자는 아래와 같이 구성했습니다.

// /app/sitemap.ts

import type { MetadataRoute } from 'next';

import { siteConfig } from '@/config/site';
import { getCategoryList, getPostList } from '@/lib/post';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const categoryList = getCategoryList();
const postList = await getPostList();

const generatedCategorySitemap = categoryList.map(category => ({
url: `${siteConfig.url}/blog/${category}`,
lastModified: new Date(),
}));

const generatedPostSitemap = postList.map(post => ({
url: `${siteConfig.url}${post.url}`,
lastModified: post.date,
}));

return [...generatedCategorySitemap, ...generatedPostSitemap];
}

필자의 블로그는 포스트가 추가될 때마다 포스트의 주소 또한 추가되기에 동적으로 사이트맵 파일을 생성하는 코드를 작성했습니다.

nextjs sitemap 공식문서

robots.txt

위 크롤링 파트에서 잠깐 언급했던 부분입니다. 크롤러에게 특정 페이지에 대해 크롤링 허용/제한을 하는 파일입니다.

// /app/robots.ts

import type { MetadataRoute } from 'next';

import { siteConfig } from '@/config/site';

export default function robots(): MetadataRoute.Robots {
return {
rules: {
// 크롤러 지정
userAgent: '*',
// 크롤링을 허용할 경로
allow: '/',
},
sitemap: `${siteConfig.url}/sitemap.xml`,
};
}

마무리

SEO에는 엄청나게 많은 내용들이 남아있습니다. 하지만 포스팅 하나에 전부 담기에는 벅찼습니다. 따라서 더 공부하고 싶은 독자분들을 위해 밑에 참고 자료들 링크를 첨부하니 SEO에 대해 궁금하다면 꼭 확인해보도록 합시다.

tbwakorea SEO

nextjs SEO 구글 서치 콘솔 네이버 웹마스터 가이드