스마트웰 — 프론트엔드 개발자
담당 범위
고객용 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
| 진입 시 로드되는 청크 | 최적화 전 | 최종 | 감소 |
|---|---|---|---|
| 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 | 상대적으로 양호 |
실측 결과 공통 원인은 네 가지로 좁혀졌습니다.
-
폴드 아래 이미지에
loading="lazy"미적용 → 진입 시 전부 로드 - LCP 요소인 이벤트 배너에 우선순위 미지정
-
img에 명시적 width/height 없음 → CLS 발생 가능 - S3 이미지가 jpg/png 원본 그대로 (로컬 assets만 일부 WebP)
파이프라인 구축
4번 이슈는 코드가 아니라 캐싱과 포맷 문제였습니다. 이미지를 S3에서 직접 받아오는 구조였습니다. CDN이 없어 캐싱이 되지 않으니 같은 이미지를 n번 요청하면 서울 리전 버킷에서 n번 그대로 나갔고, 포맷도 PNG/JPEG 원본이었습니다.
문제를 둘로 나눴습니다. 반복 호출은 CloudFront를 앞에 두면 코드 변경 없이 줄어듭니다. 포맷은 어드민이 이미지를 올릴 때마다 사람이 변환할 수 없으므로 자동화가 필요했고, 업로드 이벤트에 반응하는 Lambda를 골랐습니다. 변환을 백엔드 업로드 API에 넣으면 업로드 응답에 변환 시간이 붙고 변환 실패가 업로드 실패가 되는데, S3 트리거는 그 둘을 떼어놓습니다. 새로 올라오는 이미지부터 적용하고, 이미 쌓여 있던 기존 이미지는 다음 단계로 미뤘습니다.
결정 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분 타임아웃을 큐로 우회
목록 수집과 개별 변환 사이에 큐를 두면 개별 변환이 건당 수 초로 끝나 타임아웃 문제가 구조적으로 사라집니다. 배치가 끝나면 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가 달라 환경 분기 코드가 컴포넌트 곳곳에 중복되고 있었습니다. 각 컴포넌트에서 분기하는 방식과 어댑터 레이어로 집중하는 방식을 비교했는데, 분기 코드가 퍼지면 새 실행 환경을 지원할 때마다 모든 컴포넌트를 수정해야 한다는 점이 결정적이었습니다.
웹 → 네이티브 호출 경로
앱에서 웹으로 오는 응답은 _receive() →
CustomEvent로 컴포넌트에 전파됩니다.
브라우저에서는 같은 인터페이스가 아무 일도 하지 않는
stub으로 대체되므로,
호출부는 자신이 어느 환경에서 도는지 알 필요가
없습니다.
브릿지 어댑터
-
window.MobileBridge.call(ACTION, payload)단일 인터페이스 · 20초 타임아웃 -
앱 → 웹 응답은
_receive()오버라이딩 후 CustomEvent로 전파 - 브라우저에서는 동일 인터페이스를 no-op stub으로 대체
모듈 분리
- 스토리지·결제·파일 다운로드·연락처·네비게이션을 각각 독립 모듈로
- 새 네이티브 기능이 늘어도 브릿지 인터페이스는 그대로
- 플랫폼이 추가돼도 어댑터 내부만 교체
스토리지 3계층 fallback
setItem
removeItem
Zustand persist의 커스텀 스토리지 어댑터로 연결 → 인증 상태 자동 직렬화·복원
환경 감지는 이 어댑터 내부에서만 일어납니다. 결과적으로 컴포넌트와 스토어 어디에도 환경 분기 코드가 남지 않았고, 스토리지 관련 변경은 단일 파일만 수정하면 되는 구조가 됐습니다.
Axios 인터셉터에 isRefreshing Promise를
공유하는 큐를 두었습니다. 재시도 플래그로 무한 루프를
막고, 갱신이 최종 실패하면 전체 로그아웃으로 일관되게
처리했습니다.
폴링도 함께 정리했습니다. 단순
setInterval은 백그라운드나 오프라인
상태에서도 호출이 계속되어 불필요한 서버 부하를 만들고
있었습니다. 지수 백오프에 지터를 더해 재시도 몰림을
방지하고, visibilitychange로 중지·재개,
navigator.onLine으로 오프라인 감지,
AbortController로 진행 중 요청 취소를
처리하는 재사용 가능한 훅으로 만들었습니다.
렌더 블로킹 리소스 분석과 개선 계획 수립
렌더 블로킹 리소스 3건 — 외부 폰트 하나가 83%
결제 스크립트는 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에만 한정하지 않았습니다. 여러
어드민에서 반복되는
인증, 권한별 메뉴 구성, 검색 상태 관리도 공통
기반으로 설계했습니다. 인증은 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 초기 설계
브랜드별 분양몰을 별도 저장소로 복제하지 않고 하나의 코드베이스에서 운영할 수 있도록 Multi-Tenant 구조를 설계하고 초기 구현했습니다. 이후 사업 요구사항이 변경되어 현재는 재기획 단계이며, 아래 내용은 운영 성과가 아닌 설계·초기 구현 범위입니다.
단일 코드베이스로 여러 브랜드 서비스하기
서브도메인 기반 테넌트 판별
💡 브랜드마다 레포를 복제하면 공통 기능을 고칠 때마다 전부 손봐야 합니다. 분양몰은 기능 차이가 아니라 설정·브랜딩 차이가 대부분이라 단일 코드베이스에서 런타임에 판별하는 쪽을 택했습니다. 폐쇄몰은 비로그인 시 가격을 감추고 로그인으로 유도합니다.
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+ 이미지 캐싱을 먼저 적용해 교체 없이 해결