2025.09 ~ 진행중·프론트엔드 단독

PiCKCARE

반려동물 스타트업의 프론트엔드를 단독으로 맡아, 메인 서비스부터 B2B CRM·스튜디오·네이티브 앱·사내 자동화 도구까지 다섯 개 저장소를 만들었습니다.

Next.jsReactTypeScriptReact NativeTanStack QueryPlaywright

pickcare-frontend

반려동물 케어 플랫폼의 메인 서비스. 커뮤니티·매거진·마켓·픽스팟(장소 찾기)을 담은 Next.js App Router 앱입니다.

Next.js 16React 19TypeScriptTanStack Query 5Redux ToolkitZustandTipTap 3TailwindSentryVitestPlaywright

인증 토큰을 브라우저 JS에서 걷어낸 HttpOnly 쿠키 전환 + single-flight 리프레시

## PROBLEM

  • access·refresh 토큰을 js-cookie로 브라우저 JS가 직접 읽고 썼습니다. XSS 한 번이면 세션이 통째로 털리는 구조였습니다.
  • apiClient가 헤더 부착·메모리 토큰 보관·선제 갱신을 전부 떠안아 313줄까지 부풀어 있었습니다.
  • 같은 시점에 401이 여러 개 터지면 리프레시 요청이 그 수만큼 나갔고, 멀티탭에서 한 탭이 로그아웃해도 다른 탭은 로그인 상태로 남았습니다.
  • 카카오 OAuth 콜백에 state 검증이 없어 CSRF에 열려 있었습니다.

## SOLUTION

  • 두 단계로 나눠 옮겼습니다. 1단계는 axios withCredentials 전환과 쿠키 직접 접근 제거, 부팅 시 silent refresh 도입, 카카오 state를 매 진입마다 발급해 콜백에서 검증하는 것까지.
  • 2단계로 authSlice의 토큰 필드와 액션을 전부 없애 프론트가 토큰을 아예 들고 있지 않게 했습니다. apiClient에는 401 감지 후 재시도만 남겼습니다.
  • 인증 인프라를 authSession · authRefresh · fetchWithAuthRetry · authBroadcast 네 모듈로 쪼갰습니다.
  • 리프레시는 single-flight 큐로 직렬화하되, 락 상태를 globalThis에 박아 HMR이나 모듈 이중 로드에도 큐가 갈라지지 않게 했습니다. axios 인터셉터와 SSE raw fetch가 같은 큐를 씁니다.
  • BroadcastChannel로 멀티탭 로그아웃을 맞추고, 수신 시 현재 경로를 저장해 재로그인 후 돌아오게 했습니다.
  • API base URL이 프로덕션에서 비면 요청이 자기 origin으로 새어 전 사용자가 동시에 로그아웃되므로, 그 경우 아예 throw하도록 막았습니다.

## RESULT

  • apiClient 313줄 → 137줄. 기능은 늘었는데 코드는 절반 이하가 됐습니다.
  • authSlice 150줄 → 80줄. 2단계 커밋 단독으로 21개 파일 순 240줄 삭제.
  • 동시 401이 몇 개가 터져도 리프레시는 1회만 나갑니다.
  • 브라우저 JS가 토큰을 보관하지도 접근하지도 않는 구조로 전환. 서버 사이드에서만 쿠키를 읽습니다.

iOS에서만 죽는 크래시를 커밋 단위 이분 탐색으로 추적해 회피책 없이 해결

## PROBLEM

  • 픽스팟 목록 페이지가 iOS 사파리에서만 렌더러 크래시로 죽었습니다. 데스크톱 크롬과 안드로이드는 멀쩡했고 로컬 재현도 안 되며 스택도 안 남았습니다.
  • 그 페이지는 전국 17개 시도 선택 모달을 품은 55KB짜리 단일 컴포넌트(1,272줄)였습니다. 후보가 너무 많아 코드만 읽어서는 좁힐 수 없었습니다.

## SOLUTION

  • 가설을 하나씩 배포해 실기기에서 확인하는 방식으로 범위를 좁혔습니다. 파일명 인코딩 → 이미지 최적화 파이프라인 → CSS 배경 전환 → enum 인라인화 → 상수 제거 순으로 변수를 하나씩 소거했습니다.
  • 여기서 원인이 이미지가 아니라 hydration 쪽이라는 판단으로 방향을 틀었습니다. useSearchParams를 Suspense 없이 쓰면 App Router가 컴포넌트 전체를 동기 hydrate 한다는 걸 확인했습니다.
  • 클라이언트 전용 렌더링을 걸어 크래시가 멈추는 것까지 보고, 55KB 컴포넌트의 동기 hydration이 WebKit 렌더러 메모리를 넘긴다는 쪽으로 원인을 확정했습니다.
  • 다만 그렇게 덮으면 SSR과 SEO를 잃습니다. 회피책을 되돌리고 컴포넌트 자체를 줄였습니다. useState 17개를 Record 하나로 묶고, useEffect 의존성 23개를 7개로, 17-way 삼항 체인 5곳을 단일 조회로 바꿨습니다.

## RESULT

  • 마지막 커밋 단독으로 파일이 1,272줄 → 1,138줄.
  • 회피책을 걷어내고 점진 hydration으로 복귀 — SSR과 SEO를 유지한 채 크래시를 없앴습니다.
  • 원인 추적부터 근본 수정까지 9커밋 1시간 54분.
  • 이후 같은 페이지에서 재발한 커밋은 없습니다.

ProseMirror까지 내려가 만든 서비스 전용 리치 텍스트 에디터

## PROBLEM

  • 매거진·커뮤니티·공지에 이미지 그룹, 슬라이더, 콜라주, 장소 카드, 투표 같은 서비스 고유 블록이 필요했는데 기성 에디터의 확장 API만으로는 붙지 않았습니다.
  • 이미지 드래그 정렬에서 브라우저 HTML5 DnD와 dnd-kit이 트랜잭션을 이중으로 발행해 윈도우에서 이미지가 복제됐습니다.
  • 클립보드 이미지 붙여넣기는 외부 URL·SVG 마크업·문서 내 총량 같은 케이스를 감당할 수 없어 아예 막아둔 상태였습니다.
  • 에디터와 TipTap이 에디터를 쓰지 않는 페이지의 초기 번들에까지 실려 있었고, 장식용 폰트가 전 페이지에서 preload 되고 있었습니다.

## SOLUTION

  • TipTap Node·Extension·Mark를 16종 직접 구현했습니다. 그중 7개 파일은 ProseMirror Plugin API를 직접 씁니다.
  • 붙여넣기를 차단에서 실제 삽입으로 전환하면서 외부 URL의 dataURL 변환, SVG 판별, 행당 5장·문서 총량 상한, 실패 시 폴백 안내를 붙였습니다.
  • 드래그 복제 버그는 트랜잭션과 DOM 레벨에 진단 로그를 심어 원인을 이중 처리 충돌로 확정한 뒤 해결하고, 로그는 개발 모드 게이트로 정리했습니다.
  • 에디터를 호출처 7곳 전부에서 동적 import로 떼어내 에디터 페이지에서만 받도록 하고, 장식용 폰트 10종의 preload를 껐습니다.

## RESULT

  • 에디터 계열 코드 14,226줄 — 커스텀 익스텐션 2,734줄에 에디터·노드뷰·툴바 11,492줄.
  • 단일 파일 5,503줄짜리 에디터가 초기 번들에서 빠졌습니다.
  • 폰트 preload 링크 238개 → 0개 (커밋 기록 기준).
  • 이미지 크기 정규화와 정렬 로직은 순수 함수로 빼내 회귀 테스트로 고정했습니다.

테스트가 0이던 프론트에 2층 자동화 테스트와 CI 구축

## PROBLEM

  • 프론트에 자동화 테스트가 전혀 없었고 회귀 검증은 전부 수동 QA였습니다.
  • E2E를 붙이려 해도 테스트가 실수로 운영 API나 CDN을 때릴 위험이 있어 도입이 막혀 있었습니다.
  • dev 서버의 첫 컴파일 지연 때문에 CI에서 E2E가 산발적으로 깨졌습니다.

## SOLUTION

  • Vitest로 순수 함수를, Playwright로 E2E를 맡는 2층 구성으로 잡았습니다. 테스트 가능한 순수 함수를 먼저 뽑아내는 리팩터링을 같이 했습니다.
  • E2E용 ApiMock 클래스를 직접 만들었습니다. 모든 요청을 가로채고 운영 도메인은 물리적으로 abort 시켜, 운영 API 호출이 구조적으로 불가능하게 했습니다.
  • 로그인·어드민·가입대기 세 종류 세션을 fixture로 주입해 권한 게이트 시나리오를 덮었습니다.
  • CI E2E를 dev 서버가 아니라 프로덕션 빌드 산출물로 돌려 첫 컴파일 race 노이즈를 없앴습니다.

## RESULT

  • 단위 테스트 파일 19개 / describe 71개 / 테스트 172개, 전부 통과.
  • E2E 시나리오 130개, 스펙 17파일. 브라우저 프로젝트는 Chromium과 WebKit 4종으로 구성하고, CI는 속도를 위해 Chromium 2종만 돌립니다.
  • API를 전부 모킹하므로 CI에 secret이 필요 없습니다.
  • E2E가 운영 도메인을 건드리면 즉시 실패합니다. 사고를 정책이 아니라 코드로 막았습니다.

불필요한 서버 왕복과 중복 호출을 원인별로 제거한 성능 개선

## PROBLEM

  • 행사 목록에서 필터를 누를 때마다 서버 컴포넌트가 재실행돼 행사 100건을 다시 받아왔습니다. 정작 필터링은 클라이언트가 다시 했으므로 왕복 전체가 낭비였습니다.
  • 픽스팟 목록은 초기화 이펙트가 마운트마다 새 빈 객체를 반환해 참조가 바뀌었고, 그게 연쇄 재조회를 일으켰습니다.
  • 타인 프로필 팝오버가 프로필·펫·성장 API를 따로 호출했습니다.
  • 지역과 업종을 곱한 SEO 페이지를 빌드마다 정적 생성해 빌드 비용이 계속 불어났습니다.

## SOLUTION

  • 원인마다 다른 처방을 붙였습니다. 행사 목록은 router.replace를 history.replaceState로 바꿔 URL만 맞추고 서버 요청은 끊었습니다. 매 렌더 새 객체라 무력화돼 있던 하위 useMemo도 같이 살아났습니다.
  • 픽스팟은 값이 같으면 이전 참조를 그대로 반환하게 고쳤습니다.
  • 프로필 팝오버는 통합 응답에서 필요한 것을 추출하되 훅 반환 형태는 그대로 둬 UI에 영향이 가지 않게 했습니다.
  • SEO 페이지는 상위 레벨만 정적 생성하고 나머지는 ISR로 넘겼습니다. sitemap도 상한을 둔 동적 생성으로 다시 썼습니다.
  • 그 밖에 모달 5개 동적 import, 이미지 WebP 일괄 전환, next/image 전면 적용을 했습니다. AVIF는 실측해보니 WebP보다 느리고 커서 포맷에서 뺐습니다.

## RESULT

  • 필터 클릭 시 나가던 서버 요청 1건 → 0건 (브라우저에서 확인, 커밋 기록 기준).
  • 픽스팟 목록 조회 9건 → 3건, 프로필 팝오버 3회 → 1회 (커밋 기록 기준).
  • 빌드 시 정적 생성 페이지 893개 → 수십 개 (커밋 기록 기준).
  • next/image 적용 131파일, 남은 원시 img 태그 5곳.

pickcare-biz

반려동물 매장 사장님용 멀티테넌트 CRM. 예약 캘린더, 고객·반려동물 차트, 매출 분석을 담은 어드민입니다.

Next.js 16React 19React CompilerTailwind v4TanStack TableFullCalendarTremorMSWZustandVitest

백엔드 없이 먼저 개발하기 — 목 백엔드와 계약 테스트 분리

## PROBLEM

  • 초반에 백엔드 API가 아직 없어 UI를 먼저 만들어야 했습니다.
  • 하드코딩 목으로는 검색·정렬·페이지네이션·404 같은 실제 동작을 확인할 수 없었습니다.
  • 나중에 실 API가 붙는 시점에 스펙이 어긋나면 그때 가서야 터집니다.

## SOLUTION

  • MSW로 인메모리 목 백엔드를 세웠습니다. 핸들러가 실제 저장소처럼 동작해 검색·정렬·페이지네이션·에러까지 재현됩니다.
  • 테스트를 계층별로 나눠, 평소에는 목을 상대로 빠르게 돌리고 실 백엔드를 상대로 하는 계약 테스트는 별도 명령으로 분리했습니다.
  • 그래서 '목이 관대해서 통과하던 것'을 계약 테스트가 잡아냅니다.

## RESULT

  • 목 핸들러 63개, 핸들러 정의 910줄과 인메모리 DB 526줄.
  • 테스트 13파일 90개가 3.41초에 전부 통과.
  • 백엔드 준비 여부와 무관하게 화면 개발이 진행됐습니다.

단일 매장에서 업체 + 다중 서비스 매장 모델로 전환

## PROBLEM

  • 초기 모델은 '회사 하나 = 매장 하나 = 미용'이었습니다. 이후 한 업체가 미용·유치원·호텔을 같이 하고, 같은 업종의 지점이 여러 개일 수 있게 요구가 바뀌었습니다.
  • 메뉴 노출이 로컬 상태에 걸려 있어 실제 계약과 무관하게 보였고, 미보유 서비스 URL을 직접 치면 그대로 들어가졌습니다.
  • 모든 조회가 '어느 매장 데이터인지' 모르는 상태라 지점이 늘면 데이터가 섞입니다.

## SOLUTION

  • 타입과 API를 업체 아래 서비스 매장 배열이 붙는 구조로 다시 정의하고, 노출 판단 근거를 로컬 상태에서 로그인 응답의 실데이터로 바꿨습니다.
  • 업종별 라우트 세그먼트를 만들고 각 레이아웃에 가드를 걸었습니다. 다만 매장 정보가 아직 확정되지 않은 로딩 중에는 통과시키고, 미보유가 확정된 경우에만 막아 초기 깜빡임을 없앴습니다.
  • 매장 컨텍스트 헬퍼 3종을 만들어 배선 규칙을 하나로 모았습니다. 선택 매장이 현재 업종에 속하면 그것을, 아니면 그 업종의 첫 매장으로 떨어지는 규칙을 헬퍼 안에 가뒀습니다.
  • 스위처를 업종 단위에서 매장 단위로 바꾸되 라벨을 분기해, 업종당 매장이 하나인 기존 사용자에게는 이전과 똑같이 보이게 했습니다.

## RESULT

  • 17개 커밋 / 56개 파일 / +1,385 −381 규모의 구조 전환.
  • 고객 조회 7곳, 펫 조회 7곳, 대시보드 지표 5곳을 매장 기준으로 전환.
  • 회귀 테스트 15건을 함께 추가해 전부 통과.
  • 목 데이터도 같은 모델로 갱신해 매장별 등급 기준이 독립인지를 통합 테스트로 검증.

폰트만이 아니라 레이아웃 치수까지 함께 커지는 글자 크기 프리셋

## PROBLEM

  • 주 사용자가 시니어 매장 사장님이라 기본 폰트로는 읽기 어렵다는 요구가 있었습니다.
  • 브라우저 확대나 폰트만 키우는 방식은 사이드바 폭·캘린더 슬롯 높이·버튼 높이가 그대로여서 글자가 잘리거나 카드 밖으로 튀어나갔습니다. 실제로 같은 종류의 회귀가 반복해서 올라왔습니다.
  • 게다가 커스텀 폰트 토큰을 쓰면 tailwind-merge가 이를 색상 클래스로 오인해 글자색을 지워버리는 버그까지 있었습니다.

## SOLUTION

  • html 속성 하나로 폰트 10종·여백 4종·컴포넌트 치수 8종을 동시에 바꾸는 CSS 변수 스케일을 4단계로 정의했습니다.
  • 각 값의 근거를 주석에 남겼습니다. 본문 16px, 테이블 14px, 터치 타겟 44px 같은 값은 디자인 가이드라인에서 가져왔습니다.
  • Tailwind v4의 인라인 테마로 변수를 유틸리티로 노출해, 컴포넌트가 프리셋의 존재를 몰라도 자동으로 반응하게 했습니다.
  • 선택값은 저장하되 재수화 시점에 DOM 속성을 직접 세팅해 새로고침 후 깜빡임을 없앴습니다.
  • tailwind-merge 버그는 호출부를 고치는 대신 커스텀 토큰을 폰트 크기 그룹으로 등록해 공용 유틸 한 곳에서 끝냈습니다.

## RESULT

  • 프리셋당 22개 변수 × 4단계 = 88개 값이 단일 소스에서 나옵니다.
  • 88개 파일에서 반응형 타이포 유틸을 613회 사용 — 앱 전역이 프리셋에 반응합니다.
  • tailwind-merge 수정은 2파일 24줄로, 같은 패턴의 잠재 버그까지 함께 없앴습니다.
  • 시니어용 간소 모드는 폰트·테마와 독립된 축으로 분리해 조합할 수 있게 했습니다.

pickcare-studio

반려동물 업종 특화 웹사이트 제작 스튜디오. 섹션 카탈로그와 업종별 데모 템플릿을 담았습니다.

Next.js 16TailwindTypeScriptEmblaLenis

섹션 코드를 한 줄도 안 고치고 업종 테마를 갈아끼우는 토큰 시스템

## PROBLEM

  • 업종별 데모 사이트를 계속 늘려야 하는 구조였습니다.
  • 색상과 라운드가 컴포넌트에 박히면 업종이 하나 늘 때마다 섹션 전체를 복제해야 하고, 브랜드 색 하나 바꾸는 데도 전 파일을 훑어야 합니다.
  • 게다가 한 페이지 안에서 스튜디오 자신의 UI와 고객 데모 UI가 다른 색으로 공존해야 해서, 전역 테마 전환만으로는 안 됐습니다.

## SOLUTION

  • Tailwind의 색상과 라운드를 값이 아니라 CSS 변수 참조로 매핑해, 토큰만 바꾸면 유틸리티 클래스 전체가 따라오게 했습니다.
  • 테마별 스코프 블록 안에서만 토큰을 재정의했습니다. 전역이 아니라 서브트리 단위 리스킨이라 한 페이지에 여러 테마가 같이 삽니다.
  • 컴포넌트는 시맨틱 토큰만 쓰고 원색 클래스를 쓰지 않는 규칙을 정하고, 각 템플릿 레이아웃이 최상단에 테마를 걸어 하위 섹션 전부를 바꿉니다.

## RESULT

  • 테마 5종에 기본까지 6개 룩앤필을 165줄짜리 CSS 하나로 관리.
  • 시맨틱 토큰이 223개 파일에서 2,169회 쓰여, 166개 섹션이 테마와 무관하게 재사용됩니다.
  • 신규 업종 템플릿을 하나 더할 때 든 실제 비용은 커밋 전체 219줄 추가. 기존 섹션 컴포넌트 수정은 4줄이 전부였습니다.

스크린샷이 아니라 실제 컴포넌트가 도는 섹션 카탈로그

## PROBLEM

  • 고객에게 '이런 섹션 중에 고르세요'를 보여줘야 하는데, 스크린샷 카탈로그는 호버·드래그·자동재생 같은 실제 동작을 못 보여주고 코드가 바뀌면 바로 거짓말이 됩니다.
  • 섹션이 100개를 넘어가면 카탈로그 페이지도 그만큼 늘어 유지보수가 불가능해집니다.
  • 가로 스크롤이나 드롭다운처럼 프레임 밖으로 넘치는 섹션은 일반 카드에 담으면 잘립니다.

## SOLUTION

  • 관심사를 둘로 갈랐습니다. 한쪽은 카테고리와 변형 메타데이터만, 다른 쪽은 아이디와 실제 컴포넌트 매핑만 담습니다.
  • 동적 라우트 하나가 카탈로그를 순회해 전 카테고리 페이지를 정적 생성합니다.
  • 카탈로그는 섹션을 props 없이 렌더한다는 계약을 세워, 컴포넌트의 기본값이 곧 데모가 되게 했습니다. 데모 전용 더미 코드가 따로 없습니다.
  • 프레임 처리는 코드가 아니라 데이터로 분기시켰습니다. 스크롤 플래그가 붙으면 내부 스크롤 박스에, 넘침 플래그가 붙으면 클리핑 없이 렌더합니다.

## RESULT

  • 25개 카테고리 140개 변형이 전부 실제 렌더. 스크린샷 0장.
  • 섹션 166개를 302줄짜리 레지스트리 하나가 매핑하고, 카탈로그 라우트는 96줄짜리 파일 하나입니다.
  • 섹션을 하나 추가하는 비용이 컴포넌트 1개 + 레지스트리 1줄 + 메타 1줄로 고정됐습니다.
  • 카테고리 재편 같은 리팩터링도 데이터 파일 하나 수정으로 끝납니다.

pickcare-app

웹앱을 웹뷰로 감싸는 대신 네이티브 화면으로 다시 만드는 이식 프로젝트.

ExpoReact Nativeexpo-routerReanimatedNativeWindTanStack QueryRedux Toolkit

'전부 옮긴다' 대신 도달 가능성을 실측해 범위를 절반으로 줄인 이식 설계

## PROBLEM

  • 웹앱은 페이지 119개에 클라이언트 컴포넌트만 471파일입니다. 그대로 복사하면 웹에만 있는 죽은 코드까지 함께 이사합니다.
  • 실제로 훅 상당수가 전역 배럴에서만 참조되고 그 배럴을 아무도 쓰지 않는 상태였습니다.
  • 에디터·3D·QR 스캐너·지도처럼 RN에 대응물이 없는 웹 전용 의존성이 있어, 착수 전에 판정하지 않으면 중반에 막힙니다.

## SOLUTION

  • 라우트별 import 도달 가능성을 계산해 1차 범위를 사용자 기능 63라우트로 확정하고, 어드민·파트너·마케팅 라우트를 근거와 함께 제외했습니다.
  • 훅 104개 중 라우트에서 실제로 닿는 것이 21개뿐이고 나머지는 배럴에만 걸려 '쓰이는 것처럼' 보이던 것을 밝혀내, 전역 배럴을 만들지 않는 것을 이식 규칙으로 박았습니다.
  • 대체 불가 의존성 15종을 버릴지 바꿀지 유지할지 미리 판정해 표로 남겼습니다.
  • 이식과 리팩터링을 섞지 않는다를 원칙으로 두고 로직과 함수명을 그대로 옮긴 뒤 검증했습니다. 문제가 났을 때 원인이 이식인지 개선인지 가리기 위해서입니다.
  • 타입 체크 통과가 동작 보장은 아니라서, 런타임 사각지대를 에뮬레이터에서 직접 실행해 확인했습니다. 그 결과 사전 우려 2건이 기우로 판명나 방식을 되돌렸습니다.

## RESULT

  • 웹 페이지 119개 중 1차 범위를 63라우트로 확정.
  • API 모듈 59개 / 71파일 / 10,267줄을 3일 만에 이식.
  • 'RN에서는 SSE 스트리밍이 불가능하다'는 조사 결과를 공식 문서와 런타임 확인으로 뒤집고, 별도 라이브러리 없이 채팅 스트리밍을 복구.
  • ESLint 경고 0건 유지.

아이콘 3,188종 자동 매핑으로 웹 호출부를 고치지 않고 이식

## PROBLEM

  • 웹은 아이콘을 CSS 의사요소로 그립니다. RN에는 의사요소가 없습니다.
  • 손으로 옮기면 아이콘 쓰는 모든 호출부를 고쳐야 하고, 누락은 컴파일이 아니라 런타임에 '아이콘이 안 보임'으로만 드러납니다.
  • 폰트도 같은 함정이 있었습니다. 웹은 가변 폰트를 쓰는데 RN은 그 포맷을 지원하지 않고, 커스텀 폰트에는 굵기 지정이 오류 없이 무시됩니다.

## SOLUTION

  • 아이콘 폰트를 번들하고 클래스명에서 코드포인트로 가는 매핑을 자동 생성해, 호출 형태를 웹과 100% 동일하게 유지했습니다. 그래서 호출부를 손대지 않습니다.
  • 가변 폰트를 정적 웨이트 4종으로 뽑아 등록하고, 굵기 지정이 폰트 패밀리 선택으로 이어지도록 Text 프리미티브 하나로 통일했습니다.
  • 미등록 아이콘은 개발 모드 경고로 즉시 드러나게 했습니다.
  • 장식용 아이콘까지 스크린리더가 원시 클래스명을 읽으면 잡음이 되므로, 명시 라벨이나 동작이 없으면 접근성 트리에서 뺐습니다.

## RESULT

  • 글리프 매핑 3,188종 자동 생성.
  • 웹에서 실제로 쓰이는 아이콘 139종이 전부 매핑에 존재 — 스크립트로 전수 대조해 누락 0건.
  • 폰트 자산 5종을 엔트리에서 일괄 로드하되, 로딩이 실패해도 스플래시에 갇히지 않게 처리.

백엔드 배포 전에 인증 분기 결함 4건을 순수 함수와 테스트로 잡아냄

## PROBLEM

  • 카카오 SDK는 네이티브 모듈이라 개발 빌드로만 검증되고, 백엔드 소셜 로그인 엔드포인트도 아직 막혀 있어 실기 왕복 검증 자체가 불가능했습니다.
  • 그런데 라우트 가드는 잘못되면 앱을 아예 못 쓰게 만듭니다. 가입 완료 여부·복귀 경로·부팅 세션 복원이 얽혀 있고, 웹에서 이 판정을 하던 미들웨어가 앱에는 없습니다.

## SOLUTION

  • 라우팅과 스토어에서 판정만 떼어 부수효과 없는 순수 함수 3개로 분리했습니다. 실제 이동은 훅이 맡습니다.
  • 그 결과 백엔드가 준비되기 전에도 인증 상태별 동작을 단위 테스트로 검증할 수 있었고, 네 가지를 찾았습니다.
  • 가입 미완료 시 세션 상태가 갱신되지 않아 가입 화면과 로그인 화면 사이를 무한히 오가던 문제. 가드는 옳았고 상태가 거짓이었습니다.
  • 복귀 경로를 검증 없이 넘기면 라우터가 그것을 외부 링크로 판단해 브라우저를 여는 문제. 로그인 직후, 사용자 신뢰가 가장 높은 순간에 외부 사이트로 튕겨나가는 경로였습니다.
  • 같은 쿼리 키가 중복되면 배열이 오는데 타입 선언이 그걸 감춰 런타임에 죽던 문제, 그리고 프로필 조회 실패 시 로딩 상태가 남아 리다이렉트가 아예 일어나지 않던 문제.
  • 덧붙여 세션 복원 디스패치 순서를 바꿔, 로딩은 끝났는데 가입 여부는 아직 갱신 전인 중간 상태를 구조적으로 없앴습니다. 리액트 배칭에 기대지 않기 위해서입니다.

## RESULT

  • 테스트 5스위트 32케이스가 0.462초에 전부 통과.
  • 결함 4건을 백엔드 배포 전에 코드로 확정 — 실기 검증이 불가능한 구간을 테스트로 대체했습니다.
  • 웹의 single-flight 리프레시 락을 그대로 옮겨, 동시 401에도 리프레시가 1회만 나가게 유지. 락이 없으면 토큰 회전과 겹쳐 전원 로그아웃이 납니다.

pickcare-cardnews-factory

인스타그램 카드뉴스를 AI로 양산하는 사내 도구. 카피 생성부터 사람 승인, 배경 이미지, 카드 조립, 결과물 출력까지 한 줄로 잇습니다.

Node.jsFigma Plugin APIOpenAIGoogle Sheets APIzod

Figma Plugin API로 카드 5장 세트를 코드가 조립하게 만든 렌더링 엔진

## PROBLEM

  • 카드뉴스 한 세트는 1080×1350 카드 다섯 장이고 카피 길이는 세트마다 다릅니다. 디자이너가 매번 마스터를 복제해 텍스트를 갈아끼우면 세트 수에 비례해 작업량이 습니다.
  • 플러그인 자동화에서 흔한 '레이어 트리 순서대로 텍스트 노드를 채우기'는, 디자이너가 레이어를 하나만 추가해도 매핑이 통째로 어긋납니다.
  • auto-layout의 hug 박스는 텍스트를 넣은 뒤에야 최종 높이가 확정됩니다. 미리 잡아둔 좌표를 그대로 쓰면 긴 본문이 들어온 카드에서 박스가 밖으로 잘려 나갑니다.

## SOLUTION

  • 마스터 프레임 4종을 손이 아니라 코드로 생성하는 빌더를 짰습니다. 프레임·오토레이아웃·칩·액센트바까지 전부 코드 정의입니다.
  • 텍스트 주입을 순서가 아니라 레이어 이름 조회로 구현했습니다. 디자이너가 마스터를 자유롭게 손봐도 양산 로직이 깨지지 않습니다.
  • 높이 확정 시점 문제는 텍스트를 다 채운 뒤 확정된 높이로 카피 박스를 하단 기준 재정렬해 해결했습니다. 다만 CTA 카드는 중앙 정렬이 디자인 의도라 플래그로 분기해 대상에서 뺐습니다.
  • 각 카드를 PNG로 뽑아 로컬 브리지 서버에 저장하되, export 실패는 격리해 PNG 저장이 실패해도 카드 생성은 계속되게 했습니다.

## RESULT

  • 실제로 44세트 220장의 완성 카드가 생성됐습니다.
  • 플러그인 382줄로 마스터 생성부터 카드 양산, 이미지 출력까지 전 과정을 덮습니다.
  • 플러그인 UI를 버튼 두 개로 줄여 비개발자가 그대로 씁니다. 서버가 안 떠 있으면 복구 안내까지 띄웁니다.

LLM 카피를 '렌더 가능한가'로 먼저 걸러내는 검증 + 재생성 루프

## PROBLEM

  • 표지 디자인이 '정확히 3줄, 고정 폭' 전제인데 LLM 헤드라인은 줄 수도 길이도 제각각이었습니다. 문장으로는 멀쩡한데 텍스트 박스에서 깨지는 카피가 그대로 쌓였습니다.
  • 품질 판단을 사람에게 전부 떠넘기면 승인자가 '디자인에 안 들어가는 카피'를 하나하나 걸러야 해서 사람 승인 단계가 병목이 됩니다.

## SOLUTION

  • 렌더 제약을 코드 규칙으로 명시했습니다. 3줄, 줄당 공백 제외 7~12자. 값은 코드가 아니라 설정에 둬서 디자인이 바뀌면 코드 수정 없이 조정합니다.
  • 검증기를 외부 의존성 0인 순수 함수로 뺐습니다. 덕분에 API 키 없이 테스트가 돌고 CI 없이도 회귀를 잡습니다.
  • 규칙 위반 시 위반 내용을 그대로 피드백으로 만들어 해당 앵글만 다시 뽑습니다. 다섯 개를 전부 재생성하지 않습니다.
  • 규칙을 통과하면 2차로 LLM 자기평가를 돌려 기준 미달이면 피드백을 담아 재생성하되, 최대 3회로 끊어 무한 루프를 막았습니다.
  • LLM 출력 계약은 스키마로 고정하고, 모델이 이스케이프 문자를 문자 그대로 뱉는 문제는 검증 전 정규화 단계에서 흡수했습니다.

## RESULT

  • 규칙 검증 테스트 5개가 전부 통과. 정상 케이스와 줄 수 불일치, 과장·과단 줄, 공백 제외 카운팅을 덮습니다.
  • 테스트 러너를 Node 내장으로 써서 개발 의존성이 0입니다. 런타임 의존성도 9개뿐입니다.
  • 검증기 22줄, 테스트 32줄 — 검증 로직이 테스트보다 짧은 수준으로 얇게 유지됐습니다.

돌아보면

다섯 저장소를 관통하는 습관이 하나 있습니다. 재현되지 않는 문제를 재현되게 만들고 나서 고쳤다는 것입니다. iOS에서만 죽는 크래시는 가설을 배포해가며 변수를 소거했고, 캘린더 드래그가 항상 실패하던 버그는 목이 너무 관대해서 안 잡히던 계약 차이였습니다. 실기 검증이 막힌 앱 인증 분기는 판정을 순수 함수로 떼어내 테스트로 대신 확인했습니다.

그 다음이 같은 실수가 다시 안 나게 만드는 일이었습니다. E2E가 운영 API를 못 때리게 물리적으로 막고, 조용히 틀리는 세 부류를 린트 규칙으로 세우고, 목과 실 백엔드를 겨루는 계약 테스트를 분리했습니다. 사람 기억에 기대는 규칙은 결국 깨진다고 보는 편입니다.

아직 남은 것도 많습니다. 앱은 이식 중이고, CRM은 실 백엔드와 맞춰가는 단계입니다. 프론트엔드 아키텍처 쪽 결정들은 별도 글에 더 자세히 적어뒀습니다.