김영인
FRONTEND DEVELOPER
정량 지표로 병목을 진단하고 개선 효과를 수치로 검증하며, 성능 문제를 구조적으로 해결합니다. 변경 영향 범위를 예측할 수 있는 코드 아키텍처를 설계해, 빠른 개선이 안정적으로 쌓이는 환경을 만듭니다.

제품의 핵심 흐름을설계했습니다.
Frontend Lead
2026.01 — 2026.07
B2B SaaS 스타트업
제품 흐름과 프론트엔드 아키텍처를 설계하고, 인증·온보딩·결제 경험을 구현했습니다.
도구보다 사용하는 맥락을 봅니다.
인터페이스, 상태, 검증, 배포까지 제품을 완성하는 데 사용하는 기술입니다.
01
Languages
TypeScript · JavaScript
02
Frontend
React · Next.js (App Router) · HTML5 · CSS3 · Radix UI · shadcn/ui
03
Styling
Tailwind CSS · Styled Components
04
Data / State
TanStack Query (React Query) · Zustand · Redux Toolkit · nuqs
05
Forms / Validation
React Hook Form · Zod
06
Testing
Vitest · React Testing Library (RTL) · Playwright · MSW
07
Backend / DB
Supabase (PostgreSQL/Auth/Storage) · MySQL
08
Image / Media
ImageKit (CDN/이미지 변환)
09
Notifications
Firebase Cloud Messaging
10
DevOps / Tools
Git · GitHub · Vercel · AWS S3 · ESLint 9 · Prettier · Husky · lint-staged · pnpm
11
Collaboration
Jira · Figma · Notion
12
AI Tools
Claude Code · Codex · Cursor AI
문제의 원인부터검증 가능한 결과까지.
공개 가능한 개인 프로젝트에서, 성능과 데이터 흐름을 어떻게 관찰하고 선택했는지 정리했습니다.
- 01Myblog2025.08 ~ 2025.09 · 개인 프로젝트 (100%)
- 02승룡이네집 도서 검색 시스템2025.03 ~ 2025.04 · 개인 프로젝트 (100%)
2025.08 ~ 2025.09 · 개인 프로젝트 (100%)
Myblog
Velog에서 부족했던 해시태그 AND 필터·검색을 직접 구현해, 내용이 기억나지 않을 때 해시태그로 빠르게 재탐색할 수 있는 위키형 개인 기술 블로그입니다.
CASE FOCUS
읽기 비중이 극단적으로 높은 블로그에서, 동일 게시글 재방문 시에도 매번 DB를 조회하고 hydration 이후에도 클라이언트 중복 fetch가 발생하는 구조였습니다. mutation 이벤트 기반 태그 무효화와 클라이언트 hydration 계약 통일이라는 두 축으로 재정렬해, 캐시 히트 시 DB 쿼리 0건·백그라운드 불필요 요청 0건을 달성했습니다.
LIVE myblog-navy-kappa.vercel.app · SOURCE github.com/kimyoungyin/myblog
Next.js 15 · React 19 · TypeScript 5 · TanStack Query 5 · Zustand 5 · Supabase (PostgreSQL/Auth/Storage) · Tailwind CSS 4 · shadcn/ui

Problem · 왜 이 문제가 중요했는가
블로그는 읽기 비중이 극단적으로 높고 콘텐츠 변경이 드문 서비스입니다. 그러나 서버 캐시 계층 없이 구성하면 동일 게시글을 반복 방문할 때도 요청마다 Supabase DB에 직접 접근하는 구조가 됩니다.
게시글 상세 페이지는 동일 postId에 대한 읽기가 반복되더라도 매번 DB를 조회했고, 글 목록과 검색은 서버에서 내려주는 초기 데이터와 클라이언트 queryKey가 일치하지 않아 hydration 이후에도 중복 fetch가 발생하는 구조였습니다. 또한 갱신 빈도가 낮은 목록에 주기적 polling이 설정되어 있어 불필요한 백그라운드 요청이 지속되었습니다.
서버 캐시 계층의 부재와 서버-클라이언트 데이터 흐름의 불일치, 두 가지가 핵심이었습니다.
Analyze · 어떤 선택을 했는가
1 / 2revalidatePath는 경로 단위로 무효화 범위가 넓어, 댓글 하나가 추가되어도 해당 게시글 페이지 전체가 재생성됩니다. 글 본문·댓글·해시태그·조회수가 독립적으로 무효화되어야 하는 데이터 구조에서는 범위를 제어할 수 없어 기각했습니다.
ISR(revalidate: number)은 주기적으로 콘텐츠가 갱신되는 서비스에 적합하지만, 이 블로그는 작성·수정이라는 mutation 이벤트 기반으로 갱신됩니다. TTL이 남아 있는 동안 mutation 직후에도 구버전 스냅샷이 독자에게 노출될 수 있어 정합성을 보장하기 어렵다고 판단했습니다.
Redis 같은 외부 캐시는 세밀한 제어가 가능하지만 추가 인프라 비용과 운영 복잡도가 따릅니다. 개인 블로그 규모에서는 과도한 설계라고 보았습니다.
Analyze · 어떤 선택을 했는가
2 / 2unstable_cache + revalidateTag 조합을 선택했습니다. TanStack Query가 query factory 패턴으로 key 계층을 설계해 무효화 범위를 제어하듯, Data Cache에도 posts·post-${id}·comments·hashtags 태그 계층을 두어 mutation 종류에 따라 영향받는 캐시만 선택 무효화할 수 있습니다. Data Cache는 Next.js 서버가 관리하므로 별도 인프라 없이 요청 간 스냅샷을 공유할 수 있습니다. 다만 TTL 내 stale 가능성이 있으므로 revalidateTag를 주 전략으로, TTL은 보조 안전망으로 두기로 했습니다.
클라이언트 측 문제는 두 가지로 진단했습니다. /search가 initialData 방식을, /posts가 prefetchInfiniteQuery 방식을 써서 hydration 계약이 달랐고, 동일한 화면이라도 서버 시드가 클라이언트 TanStack 캐시로 이어지지 않아 중복 fetch가 발생했습니다. 또한 변경이 드문 글 목록에 주기적 polling이 설정되어 있어 실제 갱신과 무관한 반복 요청이 지속됐습니다. 두 문제 모두 mutation 이벤트 후 명시적으로 동기화하는 방식으로 해결할 수 있으므로, 클라이언트 패칭 계약을 단일 방식으로 통일하고 polling을 제거하기로 결정했습니다.
Action · 무엇을 구현했는가
1 / 2글·댓글·해시태그에 해당하는 모든 서버 읽기 함수를 unstable_cache로 감쌌습니다. 각 함수는 데이터 종류와 식별자를 기반으로 캐시 키를 구성하고, 무효화 태그를 지정해 mutation 시 영향받는 스코프만 갱신할 수 있도록 했습니다.
태그 문자열과 unstable_cache 키 배열의 대응을 cache-tags.ts에 상수로 중앙화했습니다. revalidateTag 호출과 unstable_cache 등록이 동일 상수를 참조하도록 해 무효화 누락이나 불일치 가능성을 차단했습니다.
좋아요 여부는 userId + postId 조합을 캐시 키에 포함해야 하므로 Data Cache에서 제외했습니다. Server Action으로 fresh 조회하고 클라이언트 TanStack Query와 낙관적 업데이트로 처리해 공용 글 스냅샷과의 캐시 경계를 분리했습니다.
unstable_cache 패턴. 캐시 키 ['post', id]는 id와 정합적이고, 호출 시 래퍼를 구성한 뒤 즉시 실행(())하면 Next가 해당 키로 스냅샷을 재사용한다(팩토리로 분리해 재사용할 수도 있으나 예시는 직관용).Action · 무엇을 구현했는가
2 / 2/posts와 /search가 제각각이던 패칭 방식을 단일 계약으로 통일했습니다. 두 경로 모두 서버에서 prefetchInfiniteQuery → dehydrate → HydrationBoundary로 시드하고, 클라이언트 useInfiniteQuery가 동일 queryKey를 바라보도록 맞췄습니다. 글 목록에 설정되어 있던 주기적 polling은 제거하고, mutation 후 revalidateTag와 invalidateQueries 연결로 대체했습니다.
Result · 어떻게 확인했는가
캐시 히트 시 DB 쿼리
0건
Supabase API 로그 기준 — 동일 게시글 재방문 시 두 번째 요청부터 DB 접근 없음 확인
독립 무효화 태그
3종
posts·comments·hashtags로 분리. 예: 댓글 추가 시 comments 태그만 무효화, 글 본문 스냅샷 유지
불필요 백그라운드 요청
0건
목록 polling 제거 후 — mutation 직후 revalidateTag → invalidateQueries 연결로 대체
2025.03 ~ 2025.04 · 개인 프로젝트 (100%)
승룡이네집도서 검색 시스템
군 복무 중 700~800권 규모 도서에 대한 검색 시스템이 없고 수기·분산 관리로 자료와 실제 위치가 어긋나던 문제를 해결하기 위해 개발한 도서 위치 안내 서비스입니다.
CASE FOCUS
첫 방문 시 Slow 4G 기준 LCP가 4.69s에 달했고, 이미지가 브라우저 캐시에 올라있어도 모달에서 DB 응답이 올 때까지 렌더를 기다리는 구조였습니다. 이미지 전달 경로·요청 시점·데이터 확정 흐름 세 가지를 재정렬해 Cold 기준 LCP를 2.81s(40% 단축)로 줄이고, 모달 전환 직후 skeleton 없이 이미지가 즉시 표시되도록 했습니다.
LIVE seongryung.vercel.app · SOURCE github.com/kimyoungyin/seongryung
Next.js 15 · React 19 · TypeScript · Tailwind CSS · 이전: MySQL (AWS RDS) · 변경: Supabase (PostgreSQL) · 이전: AWS S3 · 변경: Supabase Storage

Problem · 왜 이 문제가 중요했는가
사용자가 도서관 책을 검색한 뒤 위치를 확인하기까지의 흐름에서, 이미지 로딩이 세 지점에 걸쳐 사용자를 기다리게 만들었습니다.
메인 화면의 위치 지도 이미지는 페이지에서 가장 큰 영역을 차지하는 LCP 요소였지만, 첫 방문 시 Slow 4G 환경 기준으로 LCP가 4.69s까지 지연됐습니다.
검색 결과 카드에서 상세 페이지(모달)로 넘어가면, 전환이 이루어진 뒤에야 지도 이미지 다운로드가 본격화됐습니다. 이미 상세 화면 안에 있는데도 이미지가 뒤늦게 붙어 LCP 마커가 약 5.8s까지 늦게 찍혔습니다.
모달에서는 이미지가 브라우저 캐시에 있더라도 location 번호를 DB에서 받아야 렌더가 시작되는 구조였습니다. useEffect → Server Action → DB 조회 → location 확정 → <Image> 렌더의 순차 워터폴이 preload 효과를 상쇄하며 skeleton이 노출됐습니다.
세 문제 모두 이미지 전송 크기, 이미지 요청 시점, 렌더에 필요한 데이터 확정 시점이 각각 사용자의 전환 흐름보다 늦게 정렬된 데서 비롯되었습니다.


Analyze · 어떤 선택을 했는가
1 / 2Chrome DevTools Performance·Network·Lighthouse를 병행해 병목을 측정했습니다. JPEG 기준 약 76KB 이미지에 다운로드만 약 2.45s가 소요됐고, LCP 후보 이미지의 Resource Load Delay도 함께 관찰됐습니다. Warm 캐시 상태에서는 체감이 개선됐지만 Cold Start에서는 동일한 지연이 반복됐습니다.
이미지 최적화 방법으로 Next.js 내장 Image Optimization, Cloudflare Images, ImageKit을 검토했습니다.
Next.js 내장 방식은 서버 측 변환이라 트래픽 증가 시 서버 부하·비용이 함께 증가합니다. 또한 위치 지도 이미지의 컨테이너 폭이 뷰포트에 따라 유동하는 구조라, 정적 deviceSizes breakpoint 기반 srcset + sizes 힌트 방식으로는 렌더 시점의 실제 폭에 정확히 맞는 크기를 요청하기 어려워 과다 전송이 발생할 수 있었습니다. Cloudflare Images는 Supabase Storage와의 연동에 별도 파이프라인 구성이 필요해 현재 스택 대비 복잡도 증가가 컸습니다.
Analyze · 어떤 선택을 했는가
2 / 2- 01
ImageKit은 커스텀 로더에서 렌더 시점의 실제 폭(
w-{actual_width})을 URL 파라미터로 주입할 수 있어 동적 컨테이너에서도 정확한 크기를 요청할 수 있고, URL 파라미터 기반 실시간 변환과 글로벌 Edge 캐싱, Supabase Origin 연동 단순함을 함께 제공합니다. ImageKit으로 결정했습니다. - 02
이미지 경로가 CDN으로 바뀐 뒤에도 검색 카드→모달 전환 시 LCP 타임라인이 늦게 찍히는 문제가 남았습니다. 사용자가 카드를 클릭하기 전, 포인터가 링크 위에 올라오거나 탭하기 직전에 LCP 후보 이미지 요청을 시작해 전환 후 다운로드 대기 시간을 줄이는 방향으로 결정했습니다.
- 03
DB 워터폴은
ToLocationButton이 이미locationprop을 보유하고 있다는 점에서 해법을 찾았습니다. 해당 값을 URL 쿼리 파라미터(?loc=N)로 모달에 전달하면, 모달 마운트 시점에useSearchParams()로 동기적으로 location을 읽을 수 있어 Server Action 호출이 불필요해집니다. URL에 직접 접근하는 경우에는 기존 DB 조회 폴백을 유지하기로 결정했습니다. - 04
CDN 도입으로 외부 서비스 의존성이 추가되지만, 서버 부하 분산과 Edge 캐싱 효과로 Cold Start 편차를 구조적으로 줄일 수 있다고 판단했습니다.
Action · 무엇을 구현했는가
1 / 2위치 지도처럼 LCP에 직접 영향을 주는 대용량 이미지는 Supabase Storage에서 ImageKit Origin으로 경로를 옮겼습니다. 책 표지 등 상대적으로 가벼운 이미지는 기존 Supabase URL을 유지했습니다.
ImageKit 경로에는 next/image 커스텀 로더를 연결해 모든 요청에 f-auto·w-{width}·q-{quality} 변환 파라미터를 일관 적용했습니다. 페이지 유형에 따라 LCP 후보가 달라지므로 priority를 분기 적용했습니다. 풀 페이지(/location/[id])에서는 상단 표지에, 인터셉트 모달에서는 화면 대부분을 차지하는 지도에 적용했습니다.
Action · 무엇을 구현했는가
2 / 2ToLocationButton의 onMouseEnter와 onPointerDown에 동일한 선요청 핸들러를 연결해, 사용자가 아직 상세 화면에 도달하기 전에 지도 이미지 요청을 시작했습니다. 선요청 URL은 getLocationMapPreloadUrl로 생성해 실제 렌더에 쓰이는 변환 파라미터와 동일하게 맞췄습니다.
ToLocationButton의 href에 ?loc=N을 추가해 모달이 마운트 직후 useSearchParams()로 location을 동기적으로 읽도록 변경했습니다. 이로써 useEffect 실행 없이 이미지 렌더가 시작됩니다. loc 파라미터 없이 URL에 직접 접근하는 경우에는 기존 DB 조회 폴백이 실행됩니다.
location을 URL 파라미터로 전달해 DB 조회 없이 모달 마운트 직후 이미지 렌더링


Result · 어떻게 확인했는가
1 / 2 — 지표이미지 전송 용량
76KB → 26KB
약 66% 감소
이미지 다운로드 시간
2.45s → 1.58s
약 35% 단축
첫 방문 품질
1.88s 단축(Cold LCP)
Lighthouse Cold 기준 반복 측정 — 캐시 미적재 상태 기준
LCP (1단계, Cold·Slow 4G)
4.69s → 2.81s
약 40% 단축
LCP (2단계, preload)
~5.8s → ~4.3s
약 25% 단축
모달 이미지 렌더 지연 (3단계, DB 워터폴 제거)
DB 조회 대기 → 즉시 렌더 (0ms)
soft navigation 경로에서 Server Action 호출 완전 제거
Result · 어떻게 확인했는가
2 / 2 — 측정 근거



제품 조직에서의 경험은대화에서 공유하겠습니다.
공개 범위를 넘어서는 제품·협업·의사결정 경험은 지원 과정에서 맥락과 함께 설명드릴 수 있습니다.
상세 경험 문의하기 — yin199859@gmail.com
구현 뒤에 남긴 판단의 기록.
문제를 다시 만나도 재현할 수 있도록 기술적 선택과 맥락을 글로 정리합니다.
- 01
Singleton Promise 기반 JWT 토큰 갱신 구현
Axios 인터셉터와 세션 검증 훅에서 동시에 발생하던 JWT refresh 이중 호출 Race Condition을 Singleton Promise로 단일화해, 토큰 갱신과 리다이렉트 과정의 불안정성을 제거했습니다.
Feb 2026 - 02
인증과 Next.js (1): CORS 에러 제대로 이해하기 with Next.js
Next.js 환경에서 CORS 에러가 왜 발생하는지 Same-Origin Policy 관점으로 정리하고, Route Handler 프록시 등 해결 전략을 구조적으로 설명합니다.
Mar 2026 - 03
TanStack Query 캐시 키 안정화: 다중 선택 상태를 정렬 기반 집합으로 리팩토링
다중 선택 토글 순서에 따라 TanStack Query queryKey가 달라져 캐시 분산/재요청이 늘어나는 문제를, 스토어 레벨에서 ID 집합을 정렬해 키를 안정화했습니다.
Feb 2026 - 04
AppSidebar: 웹 접근성(WAI-ARIA) 준수 및 rAF 인터랙션 최적화
WAI-ARIA 역할과 키보드 핸들러로 접근성을 보강하고, mousemove에 rAF 기반 렌더 스케줄링을 적용해 사이드바 리사이즈 jank를 줄였습니다.
Feb 2026
모든 글 보기 — myblog-navy-kappa.vercel.app
배움의 방향을바꾼 순간.
동국대학교(서울)
2018.02 ~ 2023.07
식품생명공학과 졸업
교양 수업에서 프로그래밍을 처음 접한 후, 소프트웨어로 문제를 해결하고 서비스를 직접 배포할 수 있다는 점에 매력을 느껴 졸업 전 개발자로 전향을 결심했습니다.
함께 해결할 문제를이야기해요.
- yin199859@gmail.com
- GITHUB
- github.com/kimyoungyin