변세준

변세준

프론트엔드 개발자

화면을 하나 더 만드는 것이 아니라, 다음 화면을 더 싸게 만들 수 있게 정리하는 것

  1. 01NextourAI가 찾아주는 맞춤형 패키지 여행 예약 플랫폼
  2. 02Junsgram레거시 Next.js 13 코드베이스를 Next.js 16 / React 19 / Auth.js v5로 전면 현대화한 사진 공유 앱
  3. 03Seoul Date서울 데이트 의사결정을 "날씨 × 위치 × 장소 데이터"로 압축한, 지도 중심 실시간 추천 플랫폼
  4. 04SEFLIXTMDB 데이터를 "탐색 → 미리보기 → 결정"의 흐름으로 압축한, 이미지 중심 영화 탐색 SPA
  5. 05SHOPPYReact 기반 패션 커머스 웹 애플리케이션

Works

01

Nextour

AI가 찾아주는 맞춤형 패키지 여행 예약 플랫폼

측정
ADR
61건
0001–0061, 결번 없음
테스트
1,324건
165개 파일 · 2026.08 기준
구조
결제
2-phase
좌석 선점 → PG 승인 → DB 확정
환불
3-phase saga
외부 IO를 DB 트랜잭션 밖으로 격리
멱등성
3중
Idempotency-Key · RefundJob 게이트 · providerEventId
아키텍처
FSD 5-레이어
app · widgets 13 · features 20 · entities 10 · shared
성능 계측
자체 RUM
Web Vitals p75 · PostgreSQL percentile_cont

화면

  • 검색 — AI 자연어 하이브리드 랭킹 결과
  • 상품 상세(PDP) — 일정·출발일·실시간 좌석
  • 체크아웃 — 2-phase 결제 플로우
  • 관리자 대시보드 — 매출·점유율·취소율

아키텍처

NEXTOUR FSD 5-레이어 단방향 의존성app, widgets, features, entities, shared 순으로 상위 레이어만 하위 레이어를 import하며 barrel index.ts와 ESLint 규칙이 이를 강제한다 app라우팅 widgets13 슬라이스 features20 슬라이스 entities10 슬라이스 shared도메인 무지 barrel index.ts공개 API만 노출 ESLint 규칙깊은 경로 차단
NEXTOUR FSD 5-레이어 단방향 의존성상위 레이어만 하위 레이어를 import 한다(역방향·동일 레이어 cross-slice import 금지). 강제 수단은 barrel index.ts 공개 API 컨벤션과 ESLint 규칙이다. 외부 import 는 항상 @/entities/{name} 형태의 barrel 경로만 사용한다.
NEXTOUR 3-Phase 환불 sagaPhase 1 DB 트랜잭션에서 멱등 게이트와 예약, Phase 2에서 트랜잭션 바깥의 외부 PG 호출, Phase 3 DB 트랜잭션에서 정산이 이뤄지며 PG 실패 시 PENDING으로 적재해 cron worker가 재시도한다 Phase 1 · DB Tx 멱등 게이트원장 조건부 차감 Phase 2 · 트랜잭션 밖 외부 PG 호출Idempotency-Key Phase 3 · DB Tx 원장 정산이벤트 적재 실패 시 PENDINGcron worker 재시도
NEXTOUR 3-Phase 환불 saga환불은 우리 DB 와 Toss PG 에 걸친 분산 트랜잭션이다. 외부 IO(Phase 2)를 DB Tx 밖으로 격리해 락 윈도우를 마이크로초로 줄이고, PG 실패는 RefundJob = PENDING 으로 적재해 cron worker 가 자가 치유한다. 3중 멱등성으로 이중 환불을 차단한다.

트러블슈팅

외부 PG 호출을 DB 트랜잭션 밖으로

Situation
환불은 자사 DB와 외부 PG(토스)에 걸친 분산 트랜잭션입니다. PG 호출과 DB 갱신이 한 트랜잭션 안에 있으면, PG는 성공했는데 DB 커밋이 실패하는 순간 고객 돈은 나갔지만 시스템은 환불을 모르는 상태가 됩니다.
Cause
외부 네트워크 호출이 트랜잭션 안에 있으면 두 가지가 동시에 문제가 됩니다. PG 응답을 기다리는 수 초 동안 DB 락이 유지되고, 실패 지점에 따라 결과가 갈립니다.
Solution
Phase를 셋으로 쪼갰습니다. Phase 1(DB Tx)에서 멱등 게이트를 통과시키고 원장을 조건부 차감해 예약합니다. Phase 2는 트랜잭션 밖에서 PG를 호출합니다. Phase 3(DB Tx)에서 정산하고 이벤트를 적재합니다. PG 호출이 실패하면 RefundJob을 PENDING으로 적재하고 지수 백오프로 cron worker가 재시도합니다. 그동안에도 예약 상태는 무결성을 유지합니다.
Outcome
락 윈도우가 마이크로초 단위로 줄었고, PG 실패가 데이터 불일치로 이어지지 않습니다. 3중 멱등성으로 이중 환불도 차단됩니다. (ADR-0003)

기술 스택

  • Next.js 16 (App Router)
  • TypeScript
  • PostgreSQL + pgvector
  • Prisma ORM
  • Anthropic Claude API
  • Auth.js v5
  • 토스페이먼츠
  • Vercel
02

Junsgram

레거시 Next.js 13 코드베이스를 Next.js 16 / React 19 / Auth.js v5로 전면 현대화한 사진 공유 앱

측정
Next.js
13.516
App Router · Turbopack
React
1819
인증
NextAuth v4Auth.js v5
단위 테스트
20건
Vitest
E2E
5건
Playwright
구조
CI
전 PR 게이팅
lint · typecheck · test · build

화면

  • 로그인
  • 프로필

아키텍처

Junsgram 신뢰 경계 아키텍처클라이언트 요청이 API 경계에서 zod 스키마로 검증되고 행위자는 Auth.js 세션에서 도출되며 Sanity 접근은 GROQ 파라미터화로 인젝션을 차단한다 클라이언트SWR 옵티미스틱 UI API 경계zod 스키마 검증 Sanity CMSGROQ 파라미터화 Auth.js v5 세션행위자는 여기서 도출 전 PR CI 게이팅lint · typecheck · test · build
Junsgram 신뢰 경계 아키텍처

트러블슈팅

로그인할 때마다 유저가 중복 생성되던 버그

Situation
Auth.js v5로 마이그레이션하던 중, Sanity에 유저 문서가 계속 늘어나는 것을 발견했습니다.
Cause
앱이 Sanity 유저 문서의 키로 user.id를 쓰고 있었습니다. 그런데 이 값은 세션마다 새로 생성되는 랜덤 UUID였습니다. 같은 사람이 로그인해도 매번 다른 키가 나오니, 조회에 실패하고 새 문서를 만들었습니다.
Solution
로그인 방식이 같으면 변하지 않는 Google providerAccountId로 키를 교체했습니다.
Outcome
중복 생성이 멈췄습니다. 이 버그는 마이그레이션 때문에 생긴 게 아니라 원래 있던 문제였고, 전환 과정에서 인증 흐름을 다시 읽다가 드러났습니다.

기술 스택

  • Next.js 16
  • React 19
  • TypeScript (strict)
  • Auth.js v5
  • Vercel
03

Seoul Date

서울 데이트 의사결정을 "날씨 × 위치 × 장소 데이터"로 압축한, 지도 중심 실시간 추천 플랫폼

측정
API 응답
314,546 bytes9,935 bytes
식당 추천 5건 기준 · 96.84% 절감
Accessibility
96
Lighthouse
POI
2,608건
한 · 영 · 일 · 중(간체)
ADR
4건
구조
플랫폼
Web + Native
동일 /api 계약 공유

화면

  • 지역 변경 동작 · 영상 3.7
  • 추천 식당 UI (펼침/리스트) · 영상 2.3
  • 추천 식당 · 맛집 연동 · 영상 3.9
  • iPhone 12 (390×844 뷰포트)
  • iPad mini (768×1024 뷰포트)

아키텍처

서울, 너와 함께 BFF 아키텍처웹과 React Native 앱이 동일한 API 계약을 공유하고 Next.js Route Handlers가 BFF 계층으로 외부 API 키를 서버에만 보관한다 웹 클라이언트Next.js · Zustand 네이티브 앱React Native · Expo 키가 여기서 멈춘다 BFF 계층Route Handlers 동일한 /api 계약 공유 외부 APIMaps · OpenWeather 정적 POI2,608건 · 4개 언어 응답 페이로드 314KB → 9.9KBbase64 내장 대신 URL 반환
서울, 너와 함께 BFF 아키텍처

트러블슈팅

응답에 이미지를 담으면서 페이로드가 폭증

Situation
식당 추천 API 응답이 5건 기준 314,546 bytes에 달했습니다. 목록 하나 여는 데 300KB가 넘게 오갔습니다.
Cause
장소 사진을 base64로 인코딩해 JSON 안에 직접 넣고 있었습니다. base64는 원본보다 약 33% 커지는 데다, 응답 전체가 하나의 JSON이라 이미지가 다 도착할 때까지 목록을 그릴 수 없었습니다.
Solution
응답에는 이미지 URL만 담고, 실제 이미지는 브라우저가 별도로 받아가게 했습니다. 텍스트 정보가 먼저 도착해 목록이 즉시 그려지고, 이미지는 뒤따라 채워집니다.
Outcome
9,935 bytes로 줄었습니다. 96.84% 절감입니다. 이미지 로딩이 목록 렌더를 막지 않는 구조가 되었습니다.

기술 스택

  • Next.js 16 (App Router)
  • TypeScript (strict)
  • Zustand
  • Google Maps API
  • OpenWeather API
  • Vercel
04

SEFLIX

TMDB 데이터를 "탐색 → 미리보기 → 결정"의 흐름으로 압축한, 이미지 중심 영화 탐색 SPA

측정
Performance
93
Lighthouse 13.2.0 · Emulated Desktop · 2026.06.17
Accessibility
98
Best Practices
100
SEO
100
LCP
1.6s
Good 구간
CLS
0
Good 구간
TBT
0ms
Good 구간

화면

  • 카드 호버 → 3초 뒤 트레일러 자동재생 (핵심 UX) · 영상 7.5
  • 정렬 / 장르 / 개봉연도 필터 동작 · 영상 8.6
  • 상세 · 예고편 모달 · 리뷰/추천 탭

아키텍처

SEFLIX Redux 단방향 데이터 흐름React 컴포넌트가 Thunk 액션을 dispatch하면 TMDB API를 호출하고 응답이 Redux Slice에 반영되어 다시 컴포넌트로 흐른다 React 컴포넌트pages · component Thunk 액션비동기 요청 처리 TMDB REST API영화 데이터 5종 Redux SlicemovieReducer URL 쿼리 = 탐색 상태의 단일 진실새로고침 · 뒤로가기 · 공유에도 복원
SEFLIX Redux 단방향 데이터 흐름

트러블슈팅

예고편 모달이 중복으로 열리는 문제

Situation
카드 사이로 마우스를 빠르게 옮기면 예고편 모달이 여러 개 겹쳐 열렸습니다.
Cause
호버할 때마다 영상 정보를 비동기로 요청하는데, 응답 도착 순서가 요청 순서와 다를 수 있습니다. 먼저 벗어난 카드의 응답이 나중에 도착하면, 이미 떠나온 카드의 모달이 뒤늦게 열립니다.
Solution
카드마다 UUID를 부여하고 전역 modalId와 일치할 때만 모달을 열도록 했습니다. 응답이 도착해도 자기 차례가 아니면 무시됩니다. 호버 3초 후에 요청하고 벗어나면 취소해, 스쳐 지나가는 호버는 애초에 요청하지 않습니다.
Outcome
모달 중복이 사라졌고, 불필요한 API 호출도 함께 줄었습니다.

기술 스택

  • React 18
  • Redux Toolkit
  • React Router 6
  • TMDB REST API
  • Vercel / Netlify
05

SHOPPY

React 기반 패션 커머스 웹 애플리케이션

화면

  • 상품 목록 (검색·정렬)

아키텍처

SHOPPY 서버 상태 캡슐화 구조컴포넌트는 커스텀 훅만 호출하고 React Query가 캐싱과 갱신을 담당하며 fetch 얇은 래퍼가 Firebase REST를 직접 제어한다 컴포넌트useCart · useProducts React Query 훅캐싱 · 갱신 · 로딩 Firebase RESTfetch 얇은 래퍼 enabled 가드비로그인 요청 차단 자체 디자인 시스템 Refined Monoink · paper · accent 토큰으로 통일
SHOPPY 서버 상태 캡슐화 구조

트러블슈팅

비로그인 상태에서 발생하던 불필요한 요청

Situation
로그인하지 않은 사용자가 페이지에 들어와도 장바구니 조회 요청이 나갔습니다.
Cause
useCart 훅이 컴포넌트 마운트 시점에 무조건 실행됐습니다. uid가 없으면 조회할 대상 자체가 없는데도 요청은 그대로 나갔습니다.
Solution
React Query의 enabled: !!uid 가드를 걸어 uid가 확정된 뒤에만 쿼리가 실행되게 했습니다.
Outcome
비로그인 사용자의 불필요한 요청이 사라졌습니다. Firebase REST 호출을 fetch 얇은 래퍼로 직접 제어하고 모든 읽기에 빈 값 폴백을 두어, 네트워크 실패가 화면 전체를 깨뜨리지 않도록 했습니다.

기술 스택

  • React 18
  • React Router 6
  • TanStack Query 4
  • Firebase (Auth + RTDB)
  • Cloudinary
  • Tailwind CSS 3

Contact

지금은 그 정리를 사람이 아니라 파이프라인이 하게 만드는 일을 하고 있습니다.

Email
qustpwns93 [at] gmail [dot] com
이력서
요청하시면 이메일로 보내드립니다.

이 사이트에 대하여

  • Next.js 16 App Router · TypeScript strict · 정적 export (서버 런타임 없음) · Cloudflare Pages
  • 스크린샷 15장은 빌드 전 sharp 스크립트가 640/1280/1920 폭의 AVIF·WebP로 파생합니다. 페이지는 파생본만 쓰고, 16MB짜리 원본은 확대할 때만 받아갑니다.
  • 화면 녹화 5편은 poster만 깔고 재생 전까지 아무것도 내려받지 않습니다. 그중 2편은 원래 GIF였고 mp4로 바꾸면서 3.3MB가 443KB가 됐습니다.
  • 아키텍처 다이어그램 6개는 인라인 SVG입니다. 이미지로 넣으면 바깥 문서의 테마를 볼 수 없어, 라이트/다크 토글에 함께 반응하도록 마크업째 박았습니다.
  • Pretendard는 이 페이지가 실제로 쓰는 글자만 남겨 자기호스팅합니다. 6.7MB 원본에서 507자(한글 412자)를 추린 107KB입니다. 조각난 dynamic subset을 15번 요청하던 것이 preload 한 번으로 바뀌었습니다.
  • 글자 목록은 손으로 적지 않고 빌드된 HTML과 번들 JS에서 뽑습니다. 런타임에 조립되는 문자열은 HTML에 없기 때문입니다. 서브셋한 폰트의 cmap을 되읽어 필요한 글자가 하나라도 빠져 있으면 빌드가 멈춥니다.
  • 렌더러에게 "이 글자를 실제로 어떤 폰트로 그렸냐"고 묻는 헤드리스 브라우저 검사는 따로 두었습니다. 배포 CI에는 브라우저가 없고, 폰트는 문구가 바뀔 때만 영향을 받아 매 배포마다 돌 이유가 없습니다. 이 검사가 mono 라벨의 한글이 통째로 시스템 폰트로 떨어지고 있던 것을 잡아냈습니다.
  • 나중에 문구를 고쳐도 깨지지 않도록 자주 쓰는 한글 452자를 따로 116KB 조각에 담아 두었습니다. unicode-range가 그 글자들로만 좁혀져 있어서, 화면에 실제로 나타나기 전까지는 요청되지 않습니다. 같이 넣었다면 222KB를 매번 받아야 했고 LCP가 그만큼 늦었습니다.