Portfolio · 2026

느린 화면과 복잡한 구조를
측정 가능한 개선으로 바꿉니다

김초슬 · 프론트엔드 개발자 · 웹 개발 3년 11개월

React · React Native · 웹 성능 최적화 · 프론트엔드 아키텍처

React 웹과 React Native 앱을 함께 개발해 왔습니다. 화면을 구현하는 데서 그치지 않고, 로딩 성능·공통 구조·배포 과정에서 반복되는 문제를 측정하고 원인을 찾아 개선합니다.

성과 하이라이트

81.5% 초기 진입 JavaScript
2,129.30 kB → 393.52 kB
95.8% root 청크 감소
770.76 kB → 32.73 kB
50% 세 번째 어드민 구축 시
개발 시간 단축
4초 → 2초 LCP 개선
Core Web Vitals

오픈소스 기여

대표 사례 03 · Open Source

TanStack Query — SSR 하이드레이션 버그 수정

PR #10074 · query-core 머지 · 2026.04

TanStack/query 저장소의 Pull Request #10074 화면. Merged 상태이며 메인테이너 TkDodo가 13개 커밋을 TanStack:main으로 머지했고, 변경량은 +307 −32.

서버에서 prefetchInfiniteQuery가 실패하면 클라이언트의 useSuspenseInfiniteQuery가 올바른 구조 대신 Cannot read properties of undefined로 크래시하는 버그였습니다. 원인은 dehydration → hydration 과정에서 infinite query 메타데이터가 유실되어, 하이드레이트된 쿼리가 일반 쿼리로 취급되는 것이었습니다.

처음에는 하이드레이션 시점에 behavior를 주입하는 방향으로 냈는데, 리뷰에서 메인테이너가 “쿼리 타입을 Query 클래스 자체에 저장하는 방식”을 제안했습니다. 그쪽이 호출부마다 흩어져 있던 주입 코드를 걷어낼 수 있어 최종 구현을 그 방향으로 바꿨습니다.

  1. QueryqueryType 필드를 추가
  2. Query.fetch()queryType === 'infinite'일 때 스스로 behavior를 적용하도록 변경
  3. 리뷰 피드백 반영 — as any 제거
  4. 사용자가 지정한 hydration defaultOptions가 덮이지 않도록 회귀 방지

접근 방식은 PR을 올리기 전에 Discussion #10027에서 먼저 논의하고 승인을 받았습니다. 결과적으로 13커밋 · 10파일 · +307 −32로 머지되었고, query-core를 포함해 19개 패키지가 패치 릴리스되었습니다.

경력

스마트웰 — 프론트엔드 개발자

2025.04 ~ 재직 중 · B2B 복지 플랫폼 · 프론트 3명 / 백엔드 5명

선물찬스 사내 기념일 복지 상품 구매 서비스 — 앱 웹뷰 + 반응형 웹
React 19Vite 6 TypeScriptReact Router v7 ZustandTanStack Query Tailwind AWS S3 · Lambda · CloudFront · SQS
통합관리 백오피스 시스템 · 셀러 · 대리점 어드민 3종 — 단일 모노레포
React 19TypeScript pnpm workspaceMUI React Hook Form + ZodMSW Storybook DockerGitLab CI
복지찬스 임직원 복지몰 모바일 앱 — iOS · Android
React NativeTypeScript React NavigationFCM 유니버설 링크
담당 범위

고객용 React 웹과 React Native 앱, 사내 운영 어드민을 개발했습니다. 기능 구현뿐 아니라 성능 진단, 공통 구조 설계, 배포 자동화까지 맡았습니다.

대표 기여

웹뷰 초기 로드 JS를 81.5% 줄이고, 어드민 3종을 모노레포로 설계해 세 번째 어드민 개발 시간을 50% 단축했습니다.

대표 사례 01 · Performance

선물찬스 — RN 웹뷰 기반 사내 기념일 복지 상품 구매 서비스

웹뷰 초기 로딩 5초 문제 개선

앱 진입 시 약 5초간 흰 화면이 노출됐습니다. 처음 적용한 스플래시 화면은 대기 시간을 가릴 뿐 실제 첫 로딩 시간을 줄이지 못했습니다. 그래서 화면 처리가 아니라 초기 다운로드에 포함된 JavaScript의 구성과 크기를 원인으로 보고 빌드 산출물을 분석했습니다.

빌드 산출물을 확인해 보니 초기 청크에 첫 화면과 무관한 라이브러리까지 전부 들어 있었습니다. 라이브러리별 실제 사용 시점을 조사해 Vite manualChunks로 xlsx(엑셀 다운로드), Lottie(탭바), framer-motion(모달), GSAP(가입 페이지), react-table(테이블 페이지)을 지연 로딩 청크로 분리했습니다. 진입점 root.tsx의 GSAP import를 제거해 무조건 초기 번들에 포함되던 것을 사용처로 옮겼습니다.

진입 시 내려받는 JS

최적화 전
2,129.30 kB
최종
393.52 kB
−81.5%
진입 시 로드되는 청크 최적화 전 최종 감소
root 770.76 kB 32.73 kB −95.8%
root (gzip) 302.15 kB 12.12 kB −96.0%
진입 라우트 1,358.54 kB 360.79 kB −73.4%

지연 로드로 옮긴 청크 — 총 1,847.34 kB. 총 번들은 +0.1%로 그대로입니다.
줄인 것이 아니라 받는 시점을 옮긴 것
이 핵심입니다.

대표 사례 02 · Performance

이미지 전송 최적화 — CloudFront + Lambda 자동 WebP

이미지 전송 병목 진단

메인 페이지가 느리다는 체감 이슈가 있어 리소스별 응답 시간을 실측했습니다.

리소스 실측 문제
이벤트 배너 이미지 757ms LCP 요소인데 fetchpriority 미지정
상품 이미지 400-600ms 실제 렌더 이미지 크기보다 훨씬 큰 원본 이미지 서빙
각 쇼핑 테마 이미지 300~464ms 이미지 최적화 필요
카테고리 이미지 100~200ms 상대적으로 양호

실측 결과 공통 원인은 네 가지로 좁혀졌습니다.

  1. 폴드 아래 이미지에 loading="lazy" 미적용 → 진입 시 전부 로드
  2. LCP 요소인 이벤트 배너에 우선순위 미지정
  3. img에 명시적 width/height 없음 → CLS 발생 가능
  4. S3 이미지가 jpg/png 원본 그대로 (로컬 assets만 일부 WebP)
파이프라인 구축

4번 이슈는 코드가 아니라 캐싱과 포맷 문제였습니다. 이미지를 S3에서 직접 받아오는 구조였습니다. CDN이 없어 캐싱이 되지 않으니 같은 이미지를 n번 요청하면 서울 리전 버킷에서 n번 그대로 나갔고, 포맷도 PNG/JPEG 원본이었습니다.

문제를 둘로 나눴습니다. 반복 호출은 CloudFront를 앞에 두면 코드 변경 없이 줄어듭니다. 포맷은 어드민이 이미지를 올릴 때마다 사람이 변환할 수 없으므로 자동화가 필요했고, 업로드 이벤트에 반응하는 Lambda를 골랐습니다. 변환을 백엔드 업로드 API에 넣으면 업로드 응답에 변환 시간이 붙고 변환 실패가 업로드 실패가 되는데, S3 트리거는 그 둘을 떼어놓습니다. 새로 올라오는 이미지부터 적용하고, 이미 쌓여 있던 기존 이미지는 다음 단계로 미뤘습니다.

어드민 업로드
PNG / JPEG
S3
PUT 트리거
Lambda
Sharp Layer · q85
S3 (WebP)
동일 버킷
CloudFront
CDN 서빙

결정 1 — 순서. “전부 WebP로 바꿔달라”고 백엔드에 요청하는 게 직관적이지만, S3 직접 URL에 확장자만 바꾸면 CDN 이득이 없고 나중에 CloudFront를 붙일 때 URL을 또 바꿔야 합니다. CloudFront를 먼저 붙이고, 백엔드 변경은 설정 1줄 + 코드 1줄로 최소화했습니다.

결정 2 — 변환 시점. S3 트리거(업로드할 때 미리 변환)와 Lambda@Edge(요청할 때 실시간 변환)를 비교했습니다. Lambda@Edge는 Accept 헤더로 브라우저의 WebP 지원 여부를 판별해 자동 분기하므로 구형 브라우저까지 대응됩니다. 선물찬스는 자사 앱 웹뷰가 주 환경이라 그 대응이 필요하지 않았습니다. 원본과 WebP를 둘 다 저장해 S3 비용이 2배가 되지만, 구현과 운영이 단순한 S3 트리거를 선택했습니다.

비교 S3 트리거 Lambda@Edge
변환 시점 업로드 시 미리 요청 시 실시간
S3 저장 원본 + WebP (2배) 원본만
브라우저 호환 직접 처리 필요 Accept 헤더로 자동 분기
구현 난이도 낮음 높음
기존 이미지 무중단 배치 마이그레이션

파이프라인은 신규 업로드에만 동작했고 기존 이미지 전량은 원본 포맷으로 남아 있었습니다. Lambda 하나로 버킷 전체를 순회하는 방식은 15분 실행 제한에 걸릴 것이 명확했고, 대상 개수를 미리 알 수 없어 “돌려보고 실패하면 재시도”는 위험했습니다.

15분 타임아웃을 큐로 우회

Dispatcher
1회용 · 전체 스캔
SQS
10개씩 · 180초
Processor
기존 함수 재사용
S3 WebP
HeadObject 스킵

목록 수집과 개별 변환 사이에 큐를 두면 개별 변환이 건당 수 초로 끝나 타임아웃 문제가 구조적으로 사라집니다. 배치가 끝나면 SQS와 Dispatcher를 지우고 트리거만 제거하면 원래 상태로 돌아갑니다.

  • 배치용 코드를 새로 만들지 않음. 기존 Processor가 eventSource로 S3 이벤트와 SQS 메시지를 판별해 함께 처리
  • 멱등성 — 변환 전 HeadObject로 대상 존재를 확인하고 이미 변환된 건은 스킵 → 재실행해도 안전

동일 이미지의 원본과 변환본 — 운영 CloudFront에서 실측 전/후 결과

테마 이미지 PNG 원본 WebP 감소
ede88d58… 10.33 kB 2.76 kB −73.3%
5ab7b31a… 13.51 kB 3.70 kB −72.6%
11dc64cf… 8.69 kB 2.68 kB −69.1%
합계 32.53 kB 9.14 kB −71.9%
웹뷰 운영 안정화 자세히 보기네이티브 브리지, 인증 요청 직렬화, 폴링과 렌더 블로킹 분석

웹뷰 ↔ 네이티브 경계 설계

iOS 웹뷰 / Android 웹뷰 / 모바일 브라우저마다 사용 가능한 API가 달라 환경 분기 코드가 컴포넌트 곳곳에 중복되고 있었습니다. 각 컴포넌트에서 분기하는 방식과 어댑터 레이어로 집중하는 방식을 비교했는데, 분기 코드가 퍼지면 새 실행 환경을 지원할 때마다 모든 컴포넌트를 수정해야 한다는 점이 결정적이었습니다.

웹 → 네이티브 호출 경로

컴포넌트
환경을 모름
MobileBridge.call()
단일 인터페이스 · 20초 타임아웃
웹뷰
네이티브 모듈 실행
브라우저
no-op stub

앱에서 웹으로 오는 응답은 _receive()CustomEvent로 컴포넌트에 전파됩니다. 브라우저에서는 같은 인터페이스가 아무 일도 하지 않는 stub으로 대체되므로, 호출부는 자신이 어느 환경에서 도는지 알 필요가 없습니다.

브릿지 어댑터
  • window.MobileBridge.call(ACTION, payload) 단일 인터페이스 · 20초 타임아웃
  • 앱 → 웹 응답은 _receive() 오버라이딩 후 CustomEvent로 전파
  • 브라우저에서는 동일 인터페이스를 no-op stub으로 대체
모듈 분리
  • 스토리지·결제·파일 다운로드·연락처·네비게이션을 각각 독립 모듈로
  • 새 네이티브 기능이 늘어도 브릿지 인터페이스는 그대로
  • 플랫폼이 추가돼도 어댑터 내부만 교체

스토리지 3계층 fallback

getItem
setItem
removeItem
웹뷰 네이티브 스토리지
브라우저 localStorage
그 외 메모리 (Map)

Zustand persist의 커스텀 스토리지 어댑터로 연결 → 인증 상태 자동 직렬화·복원

환경 감지는 이 어댑터 내부에서만 일어납니다. 결과적으로 컴포넌트와 스토어 어디에도 환경 분기 코드가 남지 않았고, 스토리지 관련 변경은 단일 파일만 수정하면 되는 구조가 됐습니다.

Axios 인터셉터에 isRefreshing Promise를 공유하는 큐를 두었습니다. 재시도 플래그로 무한 루프를 막고, 갱신이 최종 실패하면 전체 로그아웃으로 일관되게 처리했습니다.

폴링도 함께 정리했습니다. 단순 setInterval은 백그라운드나 오프라인 상태에서도 호출이 계속되어 불필요한 서버 부하를 만들고 있었습니다. 지수 백오프에 지터를 더해 재시도 몰림을 방지하고, visibilitychange로 중지·재개, navigator.onLine으로 오프라인 감지, AbortController로 진행 중 요청 취소를 처리하는 재사용 가능한 훅으로 만들었습니다.

렌더 블로킹 리소스 분석과 개선 계획 수립

렌더 블로킹 리소스 3건 — 외부 폰트 하나가 83%

Google Fonts
184 ms
메인 CSS
19 ms
결제 스크립트
18 ms

결제 스크립트는 HTTP/1.1인 데다 결제와 무관한 메인 페이지에서도 로드되고 있었습니다.

측정 시점의 FCP/LCP 영향은 0 ms였습니다. 다만 이는 측정 환경이 빠른 네트워크였기 때문이라고 판단해, 느린 네트워크에서의 체감을 근거로 개선 대상을 유지하고 효과 순으로 우선순위를 매겼습니다 — 폰트 셀프 호스팅·서브셋 → 결제 스크립트 조건부 로드 → Critical CSS 인라인.

대표 사례 03 · Architecture

통합관리 백오피스 — 어드민 3종 모노레포

시스템·셀러 어드민을 새로 구축하면서, 두 개의 화면을 각각 만드는 것이 아니라 앞으로 추가될 운영 주체까지 수용할 수 있는 통합 어드민의 기반을 먼저 설계해야 했습니다. 당시에는 확립된 디자인 시스템도, 추가될 어드민의 개수도 정해져 있지 않았습니다.

인증·레이아웃·권한별 메뉴·검색 폼·데이터 테이블처럼 모든 어드민이 반복해서 사용하는 영역과, 시스템·셀러처럼 업무별로 달라지는 영역을 나눴습니다. 프로젝트를 각각 만들면 공통 기능을 여러 저장소에서 중복 관리해야 하고, 새 어드민이 추가될 때마다 기반 구조부터 다시 구성해야 한다고 판단했습니다.

그래서 각 어드민은 독립적으로 개발·배포하되 공통 UI와 유틸리티는 패키지로 공유하는 pnpm workspace 기반 모노레포를 선택했습니다. 디자인을 동일하게 보이게 하는 것을 넘어, 공통 기능의 변경을 한 곳에서 관리하고 새로운 어드민을 기존 구조 위에 추가할 수 있게 하는 것이 목적이었습니다. 이후 영업대행 어드민을 추가하면서 구조를 바꾸지 않고 확장할 수 있음을 확인했습니다.

워크스페이스 구조

admin-monorepo/
├── apps/
│   ├── system-admin      ─┐
│   ├── seller-admin        workspace:* 로 참조
│   └── agency-admin      
└── packages/            
    ├── ui-components  ◄──┘  Atomic Design
    │   ├── atoms            Button · Select · RadioGroup
    │   ├── molecules        FormTextField · FileUploader
    │   └── organisms        DataTable · SearchFiltersTable
    ├── utils                공통 유틸
    └── mocks                MSW

설계 효과

공통 변경 반영
공통 패키지
1개
어드민
3개
공통 UI 수정 사항을 시스템·셀러·대리점 어드민에 함께 적용
중복 구현 감소
공통 패키지
컴포넌트와 유틸리티를 한 곳에서 관리해 반복 개발 범위 축소
신규 어드민 개발 시간
−50%
기존 워크스페이스를 재사용해 대리점 어드민 구축 시간 단축
확장성 검증 대리점 어드민을 추가할 때 기존 구조를 변경하지 않고 공통 기반을 그대로 재사용했습니다.

모노레포의 공통화 범위는 UI에만 한정하지 않았습니다. 여러 어드민에서 반복되는 인증, 권한별 메뉴 구성, 검색 상태 관리도 공통 기반으로 설계했습니다. 인증은 localStorage 토큰 방식에서 HttpOnly Cookie + Silent Refresh 방식으로 전환해 XSS 발생 시 토큰이 직접 노출될 위험을 낮췄습니다. 메뉴 API를 기준으로 라우트를 동적으로 구성해 권한이나 메뉴가 변경돼도 프론트엔드를 다시 배포하지 않도록 했고, useParameterQuery<T>로 URL과 검색 조건을 동기화해 새로고침과 뒤로가기 이후에도 조회 상태가 유지되도록 했습니다.

어드민 운영 체계 개선 자세히 보기Storybook, 코드 품질 자동화, Feature Flag, 업무 기록 자동화

디자인 시스템 문서화 — Storybook + GitHub Pages

공유 UI 라이브러리를 만들어도 무엇이 있는지 모르면 안 쓰게 됩니다. Storybook 8.6(webpack5 + SWC)을 ui-components 패키지에 독립 구성하고 DataTable·SearchFiltersTable·StatCard·FormTextField·Tabs 등 핵심 컴포넌트의 스토리를 작성했습니다. GitHub Pages로 배포해 개발 환경을 띄우지 않고도 Props 변형과 인터랙션을 확인할 수 있게 했고, 디자이너도 같은 화면을 봅니다.

코드 품질 자동화 — pre-commit 차단

수동 코드리뷰만으로 컨벤션을 유지하는 데 한계가 있다고 봤습니다. CI 단계에서 막는 방법과 커밋 단계에서 막는 방법 중 피드백 루프가 더 짧은 커밋 단계를 택해 ESLint·Prettier·Husky·lint-staged를 구성했습니다. CI는 이미 푸시한 뒤에야 알려주지만 pre-commit은 손이 아직 그 코드에 있을 때 알려줍니다. 도입 과정에서 약 100여 개의 any 타입을 구체적인 타입으로 개선했습니다.

배포와 릴리즈 분리 — Feature Flag

테스트 확인용 코드가 develop에 머지되면 이후 운영 배포까지 딸려 나가는 문제가 반복됐습니다. cherry-pick으로 그때그때 빼는 건 단기 해결책일 뿐이고, 근본 원인은 코드 배포와 기능 릴리즈가 항상 같은 시점에 묶여 있는 구조라고 판단했습니다. Unleash로 Feature Flag 환경을 구성해 미완성 기능은 플래그로 숨긴 채 배포하고, 노출 시점은 서버에서 토글하도록 분리했습니다.

팀 업무 기록 자동화 — GitLab CI + Notion API

누가 무엇을 하고 있는지 공유하려고 매번 수동으로 알리고 있었습니다. 팀이 이미 Notion을 쓰고 있었으므로 도구를 늘리지 않고 기존 흐름에 붙이기로 하고, GitLab Runner를 Docker executor로 재설정한 뒤 .gitlab-ci.yml에서 Notion API를 호출하도록 연동했습니다. develop·main에 push하면 커밋 정보가 자동으로 기록됩니다. 커밋 메시지의 태그를 걷어내고 제목만 남겨 읽히게 다듬었습니다.

코드를 짜는 일만 개발은 아니라고 생각합니다. 반복되는 커뮤니케이션 비용을 파이프라인으로 옮긴 것도 같은 종류의 문제 해결이었습니다.

분양몰 Multi-Tenant 초기 설계 자세히 보기설계·초기 구현 완료 · 요구사항 변경으로 현재 재기획 중

분양몰 — Next.js 15 Multi-Tenant 초기 설계

2025.09–10 설계·초기 구현 · 요구사항 변경으로 현재 재기획 중

브랜드별 분양몰을 별도 저장소로 복제하지 않고 하나의 코드베이스에서 운영할 수 있도록 Multi-Tenant 구조를 설계하고 초기 구현했습니다. 이후 사업 요구사항이 변경되어 현재는 재기획 단계이며, 아래 내용은 운영 성과가 아닌 설계·초기 구현 범위입니다.

단일 코드베이스로 여러 브랜드 서비스하기

서브도메인 기반 테넌트 판별

brand-a.도메인
요청 hostname
테넌트 판별
/api/tenant/by-domain
설정 적용
브랜딩 · 몰 유형
렌더
오픈몰 / 폐쇄몰 분기

💡 브랜드마다 레포를 복제하면 공통 기능을 고칠 때마다 전부 손봐야 합니다. 분양몰은 기능 차이가 아니라 설정·브랜딩 차이가 대부분이라 단일 코드베이스에서 런타임에 판별하는 쪽을 택했습니다. 폐쇄몰은 비로그인 시 가격을 감추고 로그인으로 유도합니다.

Docker 3단계 멀티스테이지(deps → builder → runner)로 컨테이너화하고 Next.js standalone 모드와 non-root 실행으로 이미지 크기와 권한을 줄였습니다. 이 과정에서 컨테이너 안의 localhost가 호스트가 아니라 자기 자신을 가리켜 테넌트 검증 API 호출이 실패했는데, host.docker.internal 경유로 해결했습니다. 여기에 Next.js가 빌드 시점에 NODE_ENV=production을 하드코딩하는 문제가 겹쳐 환경 판별이 불가능했고, DOCKER_ENV 커스텀 변수를 따로 두어 우회했습니다.

proxy API 레이어도 정리했습니다. baseURL이 두 곳에서 설정되고 useProxy 분기가 뒤에서 무효화되는 구조라 코드를 읽어도 실제 요청 URL을 예측할 수 없었습니다. 결정 지점을 하나로 모으고 참조되지 않는 필드를 걷어냈습니다.

React Native 앱 기여 자세히 보기유니버설 링크, FCM, 리스트 성능과 테스트 전략 재정립

복지찬스 — React Native

앱 간 이동과 알림

  • 유니버설 링크 — URI 스킴은 앱 미설치 시 오류가 나고 다른 앱이 스킴을 가로챌 위험이 있어, 웹 폴백이 가능한 쪽을 택했습니다. iOS Associated Domains / Android App Links + React Navigation linking 통합, 앱 포그라운드·백그라운드·종료 상태별 분기
  • FCM 푸시 — 서버 이벤트에 반응하고 사용자를 타겟팅할 수 있어 로컬 알림 대신 채택. 알림 클릭 시 딥링크로 연결해 해당 화면으로 바로 진입
  • 리스트 성능 — 프로파일링으로 이미지 로딩과 불필요한 리렌더링을 원인으로 특정. 라이브러리 교체는 마이그레이션 비용이 커서 순서를 정했고, 메모이제이션 + getItemLayout + 이미지 캐싱을 먼저 적용해 교체 없이 해결

인텔리콘 연구소 — 프론트엔드 개발자

2024.09 ~ 2025.03 · 법률 AI 플랫폼 (lawgpt.dev, nanumi.ai, 학교폭력 특화 서비스)

ReactTypeScript SSENext.js
  • SSE 스트리밍 안정화 — 간헐적 요청 실패로 데이터가 불안정했습니다. 실패 요청이 한꺼번에 재시도되지 않도록 Promise 기반 지수 백오프를 적용하고 최대 3회로 제한해 연결 안정성을 높였습니다.
  • 채팅 서비스 레이어 분리 — 로직이 컴포넌트에 흩어져 중복과 테스트 난이도 문제가 있었습니다. 싱글톤과 커스텀 이벤트 이미터로 상태 관리·이벤트 전파를 UI 바깥으로 분리해 코드 중복 40% 감소.
  • 클라이언트 보안의 한계 인식 — 비회원 PDF 노출 제한에 CSS 블러를 썼는데 개발자 도구로 우회 가능한 취약점을 발견했습니다. 클라이언트에서의 제한은 근본적으로 우회 가능하다는 점을 인정하고, 라우터 기반 인증 미들웨어로 서버에서 콘텐츠를 동적 제어하도록 전환해 회원가입 유도율 10% 증가.
  • 모바일·리소스 최적화safe-area-inset으로 노치 영역에서 주요 UI가 가려지는 문제를 해결하고, SVG 스프라이트로 반복 아이콘의 HTTP 요청 수를 줄였습니다.

엔포트버스 — 웹 퍼블리셔

2022.03 ~ 2024.02 · 버스 예약 플랫폼

HTML/CSSJavaScript PostCSSBrowserStack Lighthouse

직무명은 퍼블리셔였으나 반응형 설계, 크로스브라우징, 웹 접근성, Core Web Vitals 개선 등 프론트엔드 실무 전반을 수행했습니다.

  • 반응형 웹 — Mobile First 기준 320px ~ 1920px 대응. Flexbox/Grid 유동 재배치, srcset/sizes 해상도별 이미지. 좌석 선택 UI처럼 인터랙션이 복잡한 컴포넌트도 화면 크기별 조작 방식을 설계.
  • Core Web Vitals / SEO — Lighthouse 분석으로 이미지 용량과 렌더 블로킹을 원인으로 특정. WebP + <picture>, loading="lazy", LCP 대상 preload, minify, font-display: swap을 적용해 LCP를 4초대에서 2초대로 개선하고 검색 엔진이 페이지를 안정적으로 수집할 수 있도록 기술적 SEO 기반을 정비했습니다.
  • 웹 접근성 — 시맨틱 마크업, 대체 텍스트, 키보드 내비게이션, 색상 대비 4.5:1 이상. NVDA·VoiceOver로 실제 검증. Lighthouse 접근성 60점대 → 90점대 (WCAG 2.1 AA).
  • 크로스브라우징 — PostCSS/Autoprefixer, core-js 폴리필, Can I Use 사전 확인 프로세스. BrowserStack으로 IE11 포함 실기 테스트.

개선 전후 — 막대는 구간 표기를 시각화한 것

LCP 전
4초대
LCP 후
2초대
접근성 전
60점대
접근성 후
90점대

이미지 WebP 전환과 렌더 블로킹 제거로 LCP를 절반으로 줄였고, 시맨틱 마크업·키보드 내비게이션·색상 대비 개선으로 Lighthouse 접근성을 WCAG 2.1 AA 수준까지 올렸습니다. 퍼블리셔 직무명 뒤에서 실제로 한 일이 이것입니다.

사이드 프로젝트

HealingTrip — 치유 여행 추천 앱

프론트엔드 2명 · 백엔드 2명 · AI 1명
한국관광공사 TourAPI를 활용해 2026 관광데이터 활용 공모전 출품을 목표로 개발 중인 모바일 앱.
건강 상태를 반영해 여행지를 추천하고, 추천 일정을 지도에서 편집·확정

Frontend

React NativeTypeScript Expo RouterNativeWind ZustandTanStack Query

Backend

Java 21Spring Boot 3.3 JPAMyBatis PostgreSQL

✨ 프론트엔드에서는 API·상태 관리 구조와 OAuth 3종, 지도 기반 일정 편집을 맡았고, 백엔드에서는 장소 검색 → 상세 확인 → 일정 편집 → 일정 확정 흐름을 화면부터 데이터 저장까지 연결했습니다.

클린 아키텍처 설계 — 백엔드 교체에 영향받지 않는 OAuth 구조

로그인 화면을 개발할 당시 세 가지 조건이 겹쳤습니다.

  • 백엔드 API가 아직 준비되지 않음
  • 카카오·구글·애플의 SDK와 인증 방식이 서로 다름
  • UI가 SDK·API에 직접 의존하면 백엔드 연결이나 Provider 변경 시 화면까지 함께 수정해야 함

이 결합을 막기 위해 인증 규칙은 Domain 계약으로 정의하고, 외부 SDK·API 구현은 Infrastructure로 분리했습니다.

레이어 의존 관계와 로그인 실행 흐름

코드 의존·계약 구현 로그인 실행 흐름

코드 의존 방향

로그인 실행 흐름

💡 Domain은 OAuth SDK와 axios 어느 쪽도 알지 못합니다. 코드 의존은 점선 방향대로 Infrastructure가 Domain 계약을 구현하고, 실제 로그인은 실선 방향대로 위에서 아래로 실행됩니다.

OAuth Provider — 서로 다른 SDK를 하나의 계약으로 연결

  • DomainAuthProviderAuthBackend 인터페이스, Session, AuthError를 정의하고 Expo·axios·OAuth SDK에는 의존하지 않도록 구성
  • Infrastructure — 카카오·애플은 비동기 factory로, React Hook 호출이 필요한 구글은 useGoogleProvider로 구현. SDK 사용 방식은 달라도 모두 동일한 AuthProvider 계약을 반환
  • ApplicationuseSignIn이 주입받은 Provider를 실행하고 Result<Session, AuthError>를 반환해 성공·취소· 네트워크 오류를 UI가 타입에 따라 처리하도록 구성
  • UI — 로그인 화면은 선택한 Provider와 결과만 다루고, 각 SDK의 인증 절차와 백엔드 엔드포인트는 알지 않도록 분리

lib/api — 여러 기능이 공유하는 횡단 관심사

axios 인스턴스·인터셉터·공통 오류·QueryClient처럼 여러 기능이 함께 쓰는 도구를 Auth Domain 안에 두면 Trip·Place가 같은 코드를 다시 만들게 됩니다. 그래서 기능별 API 구현은 infrastructure/{feature}에 두고, 여러 기능이 공유하는 부분만 lib/api로 분리했습니다.

여러 기능의 API 구현에 겹쳐지는 횡단 관심사

infrastructure/{feature}lib/api에 의존해 Domain과 Application은 HTTP 구현 세부사항을 알지 않습니다. 일반 API는 TanStack Query가 오류로 인식하도록 ApiError를 throw하고, authApi 경계에서 이를 로그인 화면이 분기할 수 있는 Result<Session, AuthError>로 변환합니다.

백엔드 구현 — 장소 검색부터 일정 확정까지

  • 지도 범위·키워드 기반 장소 검색 API — 실제 TourAPI 데이터가 적재되는 tourism_contents 기준으로 검색 API 구현. 지도 이동 시에는 바운딩박스로, 키워드 입력 시에는 전국 범위로 검색하도록 MyBatis 쿼리와 API 계약을 연결
  • 장소 상세 API — 검색 결과에서 선택한 장소의 공통 정보와 관광지·레포츠·음식점별 이용 정보를 분기 조회해 지도 위 상세 바텀시트와 연결
  • 일정 확정 API — TanStack Query 캐시의 일자·장소 배열을 요청 본문으로 전달하고, 배열 순서로 방문 순서를 계산해 course_places를 전체 교체하는 저장 로직 구현

프론트 상태와 서버 저장 모델을 하나의 계약으로 설계 일정 편집 중에는 쿼리 캐시가 화면의 단일 원천이 되고, 확정 시점에만 일자와 장소 ID를 서버로 전송합니다. 서버에서는 course_places가 코스의 순서와 소속을 표현하는 관계 데이터라는 점을 기준으로 diff API를 늘리지 않고 전체 교체를 선택했습니다. 저장 전에는 장소 ID의 존재 여부를 검증해 잘못된 참조가 들어오지 않도록 했습니다.

개발 과정 자세히 보기서버 상태 관리, 지도 검색 버그, 협업 환경

서버 상태와 UI 상태 분리

  • 초기 일정 편집 화면은 useQuery로 받은 일정을 useState에 복사하고, API 응답이 도착하면 useEffect로 다시 동기화했습니다. 이 구조는 Query 캐시와 로컬 상태가 같은 데이터를 각각 보관해 한쪽만 바뀌면 화면과 확정 요청이 달라질 수 있었습니다. 일정은 Query 데이터에서 직접 렌더링하고, 장소 추가·삭제·재정렬은 queryClient.setQueryData로 같은 캐시를 갱신하도록 변경했습니다. 선택된 날짜처럼 서버가 알 필요 없는 UI 상태만 useState에 남겼습니다.

지도

  • 네이버지도 네이티브 SDK 연동, 뷰포트 기반 장소 검색. 지도가 제주를 보여주는 중에 “담양”을 검색하면 결과가 0개가 되는 UX 결함을 발견하고, 키워드가 있을 때는 바운딩박스를 보내지 않도록 분기해 일반적인 지도 앱의 기대와 일치시킴. onCameraIdle 300 ms 디바운스로 드래그 중 요청 폭주 방지

협업 환경

이슈·화면 ID·브랜치·PR을 연결하고 저장소에 기술 스택과 버전 제약을 문서화해, 팀원과 AI 도구가 동일한 기준으로 작업할 수 있게 했습니다. 백엔드는 팀원의 코드리뷰를 받으며 구현해, 프론트엔드의 상태 변화가 서버에서 어떤 데이터 모델로 저장되는지 끝까지 확인했습니다.

기술 스택

주력
TypeScript · React 19 · React Native · Vite · Zustand · TanStack Query · Tailwind CSS · React Router · Storybook
실무 사용
Next.js 15 · Expo · MUI · shadcn/ui · React Hook Form + Zod · MSW · pnpm workspace(모노레포) · ESLint / Prettier / Husky / TDD
인프라·배포
AWS (S3 · Lambda · CloudFront · SQS · IAM · EC2 · Route 53) · Docker · GitLab CI/CD · Jenkins · Unleash
백엔드
Java 21 · Spring Boot 3.3 · JPA + MyBatis · PostgreSQL