경력기술서 · Frontend Engineer / 프론트엔드 팀 리더
Emailljhmoa@gmail.com GitHubhttps://github.com/wjdgh4058 Phone010-8869-4058
| 회사 | 기간 | 직위 / 담당 |
|---|---|---|
| 주식회사 로워드 | 2025.01 ~ 재직 중 | 프론트엔드 팀 리더 — 아키텍처 설계, 전 영역 구현, 코드 리뷰 및 기술 의사결정 총괄 |
| 주식회사 더블티 | 2023.08 ~ 2025.01 (1년 6개월) | 프론트엔드SW 개발팀 연구원 — 로워드·레듀 서비스 프론트엔드 초기 구축 |
※ 로워드 키워드 분석 플랫폼과 레듀 LMS는 더블티 재직 시 착수해 2025.01 로워드 이직 후 연속 수행한 프로젝트로, 아래 프로젝트 단위로 통합 기술합니다.
'use client' 경계를 구조가 강제하게 함| 기간 | 2025.06 ~ 진행 중 |
|---|---|
| 역할 | 설계 및 구현 단독 담당 |
| 규모 | 17종 노드 · 75개 필드 에디터 · 레이아웃 / 기본 / 위젯 3계층 노드 체계 |
| 기술 스택 | React 19, Next.js RSC & Server Actions, TypeScript, CSS Container Queries, framer-motion, embla, prismjs |
| 성격 | 비개발 직군이 노드 트리를 조립해 랜딩페이지를 만들고, 산출물이 그대로 서버 렌더되는 페이지 빌더 |
| 단계 | 내용 |
|---|---|
| Phase 1 (2025.06 ~ 2026.07) | 표현 모델 재설계와 편집기 구축 |
| Phase 2 (2026.08 ~ ) | 편집 모드 분리, 재사용 컴포넌트(커스텀 위젯) 시스템, 영속 계층 및 백엔드 전환 준비 |
블록 조립형이었던 1차 빌더는 2024.11 완성 이후 실제 하위몰 운영에 투입되었고, 약 1년간의 운영에서 구조적 한계가 드러났습니다. 특히 몰마다 성격이 달라 하위몰이 늘 때마다 그 몰에 맞는 블록을 새로 만들어 줘야 하는 상황이 반복되면서, "개발자를 빼기 위해 만든 도구가 다시 개발자를 부르는" 상태가 된 것이 2차 착수의 직접적 계기였습니다.
| 한계 | 왜 구조적인가 |
|---|---|
| 새 디자인 요구 = 새 블록 개발 | 표현 단위가 "완성된 블록"이라 조합으로 파생 불가 → 개발자 병목 재발 |
| 레이아웃 구성 불가 | 중첩·그리드 개념이 없어 "2단 안에 3단" 같은 구조가 원천 불가 |
| 블록별 스타일 옵션 제각각 | 블록 N개 × 옵션 M개가 곱셈으로 증가 → 유지보수 발산 |
| PC/모바일이 블록 내부 하드코딩 | 운영자가 모바일 조정 불가 → 결국 개발 요청 |
interface ISchema { id: string; nodes: INode[] }
interface INode {
id: string; type: NodeType; name: string;
styles: Partial<Record<'basic'|'design'|'advanced', IStyleGroup>>;
responsive?: { mobile?: Partial<Record<StyleTab, IStyleFields>> };
children?: INode[];
}
RenderNode({ node, type: 'client' | 'server' })
├─ 'client' → BuilderNode(훅 사용) → *ClientWrapper → 시각 컴포넌트 + 편집 chrome
└─ 'server' → *ServerWrapper(async) → 시각 컴포넌트
RenderNode 자체는 어떤 훅도 호출하지 않습니다. 훅을 쓰는 것은 type === 'client'일 때만 위임되는 내부 컴포넌트뿐 → 서버 컴포넌트 트리에서 그대로 사용 가능. 최상단에 훅이 하나라도 들어가면 사용자 페이지 렌더 전체가 깨지므로 아키텍처 불변식으로 문서화
문제 — 빌더의 모바일 모드는 뷰포트가 아니라 캔버스 폭을 375px로 제한합니다(브라우저 창은 그대로).
따라서 @media (max-width: 768px)는 프리뷰에서 절대 발동하지 않아 WYSIWYG이 원천적으로 불가능합니다.
해결 — 컨테이너 쿼리(@container) 채택. 375px 프레임을 컨테이너로 만들면 프리뷰에서 정확히 발동하고,
실제 페이지에서는 래퍼에 container-type: inline-size를 주면 동일하게 동작합니다.
node.responsive.mobile[tab][field]에 바뀐 필드만 저장, 미지정 필드는 PC 값 상속. 전체 트리를 복제하면 PC 수정이 모바일에 전파되지 않아 두 벌 유지보수가 됩니다buildResponsiveCss — PC 스타일과 모바일 병합 스타일을 diff해 노드별 @container (max-width:768px) 규칙을 생성·주입, 노드 루트마다 안정적인 ldn-{id} 클래스 부여initial 리셋 선언을 먼저 emit(shorthand 리셋 → longhand override 순으로 캐스케이드 정합)msm은 프리뷰에서 발동하지 않으므로 동일 임계값의 컨테이너 쿼리 변형 cmsm을 정의하고 "스튜디오 렌더 노드 안에서는 cmsm, 그 외에는 msm"을 규칙화실제 겪은 버그 — 모바일 모드에서 위젯 설정을 바꿨는데 화면이 무반응. 값은 정상 저장됨. 원인은 렌더가 그 필드의 모바일 override를 읽지 않기 때문이었습니다.
이건 개별 버그가 아니라 모델의 구멍이었습니다. device-aware가 기본값인데 렌더가 지원하지 않는 필드가 그 기본값을 받으면 반드시 이 증상이 납니다. 따라서 모든 필드를 두 부류 중 하나로 명시 분류하도록 강제했습니다.
| 분류 | 정의 |
|---|---|
| device-aware (기본) | 렌더가 실제로 그 필드로부터 @container 규칙을 생성하는 경우만 |
| unified | 렌더가 base만 읽는 공통 설정 — 읽기·쓰기를 모두 base로 강제해 어느 모드에서 편집하든 동일 동작 |
"위젯 필드 추가 시 이 분류를 의도적으로 결정할 것"을 가이드에 명문화했습니다. 기본값을 그냥 쓰면 조용히 망가지는 종류라, 코드가 아니라 프로세스로 막아야 한다고 판단했습니다.
applySchema)을 통과하도록 강제 — 히스토리는 우회 경로가 하나라도 생기면 즉시 신뢰를 잃습니다useRef로 동기 추적 — useState 값은 배칭 때문에 push 시점에 직전 상태가 아닐 수 있습니다. 스냅샷 스택은 직전 상태의 정확성이 생명이라 ref로 동기 확보하고 렌더 트리거만 state로 분리field:{device}:{nodeId}:{tab}:{field}를 키로 같은 필드의 연속 편집을 한 항목으로 묶고, 다른 필드로 이동하면 자동으로 끊기게 처리| 규칙 | 이유 |
|---|---|
| gridCell은 같은 그리드 내 형제 셀의 before/after만 | 다른 그리드로 이동하면 양쪽 셀 개수가 동시에 깨짐 |
| 일반 노드가 셀을 대상으로 하면 inside만 | 셀의 형제가 되면 셀 개수 ≠ cols × rows |
| grid의 직접 inside 금지 | grid의 자식은 반드시 셀 |
| inside는 container/gridCell만 | 무의미한 트리 방지 |
| 자기 자신의 자손으로 드롭 금지 | 트리 순환 = 렌더 무한 재귀 |
삭제도 타입별로 의미를 다르게 정의했습니다. gridCell 삭제는 제거가 아니라 초기값 리셋 — 셀이 사라지면 그리드가 깨지므로 사용자의 "지우기" 의도를 "내용 비우기"로 번역했습니다.
가장 까다로웠던 것은 "gridCell을 복사하면 무엇을 붙여넣어야 하는가"였습니다. 셀 자체를 복제하면 정합성이 깨지므로 셀의 내용(children)을 복제 대상으로 삼고, 대상 컨텍스트에 따라 셀 내부 append / 형제 삽입 / 루트 추가로 분기했습니다. 붙여넣을 때마다 서브트리 전체에 새 uuid를 재발급하고(id 충돌 = 선택·렌더 전부 오작동), 붙여넣은 노드를 자동 선택해 바로 편집 가능하도록 했습니다.
카드 안의 슬롯(이미지/제목/부제목/설명)을 자유 배치하는 기능으로, 핵심 문제는 편집 화면과 실제 페이지의 카드 폭이 다르다는 것이었습니다.
| 요소 | 방식 | 이유 |
|---|---|---|
| 슬롯 좌표 | 카드 대비 % | 픽셀 저장 시 폭이 달라지면 배치가 무너짐 |
| 카드 높이 | aspect-ratio: 기준폭 / cardHeight | 폭이 무엇이든 비율로 높이 결정 |
| 텍스트 크기 | cqw (카드가 container) | 카드 폭에 비례해 스케일 |
| 모바일 폰트 | 기준폭을 바꿔 cqw 재계산 | 비례만 하면 좁은 폭에서 읽을 수 없게 작아짐 |
| 모바일 기본 배치 | PC와 별도 상수 | 모바일은 폰트가 상대적으로 커서 같은 좌표면 슬롯이 겹침 |
편집기와 실제 렌더가 동일한 헬퍼 모듈을 공유하도록 모듈 경계를 강제했습니다(한쪽만 고치면 편집 결과와 출력이 갈라짐). 편집 모달은 로컬 draft + 자체 undo/redo를 갖고 저장 시에만 커밋해 취소가 무손실이며, 모달 내부 ⌘Z가 전역 undo로 새어나가지 않습니다. 히스토리 항목이 당시 보던 슬라이드 인덱스를 함께 기억해 undo 시 화면이 튀지 않도록 했습니다.
!important 규칙이 아무 효과가 없는데, 하필 Tailwind 클래스가 붙은 요소에서만 그렇다important: true라 모든 유틸이 @layer utilities 안의 !important로 나갑니다. CSS 캐스케이드에서 !important는 레이어 우선순위가 역전되어, unlayered !important는 specificity가 아무리 높아도 layered에 집니다. <style> 주입은 기본이 unlayered입니다@layer utilities { … }로 감싸 같은 레이어에서 specificity로 승부. 재현 조건이 좁아 다음 사람이 반드시 헤맬 종류라 증상 패턴까지 가이드에 기록@container는 가장 가까운 조상 컨테이너를 기준으로 합니다. 폰트 cqw 스케일링을 위해 각 카드에 container-type을 부여해 둔 상태라, 위젯 내부 쿼리가 위젯이 아닌 카드를 측정하고 있었습니다노드 추가가 여러 파일에 걸친 등록 절차라 하나라도 빠지면 조용히 실패합니다(스키마엔 들어가는데 화면/인스펙터에 안 보임). 이 실패 모드를 없애기 위해 절차를 문서로 고정했습니다.
1. nodes.tsx 노드 템플릿 정의
2. components/nodes/ 시각 컴포넌트 + Client/Server 래퍼
3. RenderNode.tsx 타입 등록 ← 빠지면 렌더 안 됨
4. renderField.tsx 필드 등록 ← 빠지면 인스펙터에 안 보임
5. fields/** 필드 에디터 추가 (+ unified 여부 결정)
6. public/studio/ 아이콘/썸네일
추가로 75개 필드에 반복되던 배선 보일러플레이트를 useField 훅으로 추출하면서, unified 결정 지점을 한 곳에 모아 ④의 실수를 구조적으로 줄였습니다.
Phase 1로 표현력 문제는 해결됐지만, 운영이 시작되자 다른 종류의 병목이 드러났습니다.
이 기능 전체가 단 하나의 결정 위에 서 있습니다. 페이지는 위젯의 내용을 저장하지 않는다. widgetId 참조만 저장하고, 펼치는 것은 렌더 시점에만 한다.
| 대안 | 검토 결과 |
|---|---|
| 삽입 시 내용을 복사 (스냅샷) | 구현은 훨씬 쉽지만 동기화 문제가 그대로 남습니다. 이 기능의 존재 이유 자체가 사라짐 |
| 참조만 저장 (채택) | "위젯 1회 수정 → 사용 중인 모든 페이지 자동 반영"이 성립. 대신 참조 모델 특유의 문제(고아 참조, 순환, 삭제 영향)를 전부 설계해야 함 |
이 결정을 코드가 지키도록 구조화했습니다.
customWidget 노드는 styles.basic.fields.widgetId만 가진 leaf입니다CAN_HAVE_CHILDREN_TYPES에서 제외 → 드롭 규칙(Phase 1 ⑥)이 자동으로 내부 삽입을 막아, 어떤 내용도 저장된 스키마에 들어갈 수 없습니다| 라우트 | mode | 저장 대상 |
|---|---|---|
/admin/studio | — | 런처: 페이지 문서 + 커스텀 위젯 목록 |
/admin/studio/pages/[documentId] | page | IStudioDocument (페이지) |
/admin/studio/widgets/[widgetId] | component | ICustomWidget (재사용 노드 그룹) |
[id]가 new면 생성 — id는 첫 저장 시 발급. 생성/편집을 별도 화면으로 나누지 않아 UI가 하나로 유지됩니다커스텀 위젯 안에 커스텀 위젯을 허용하면 순환 탐지, 재귀 resolve, 다단계 캐시 무효화가 전부 필요해집니다. 운영상 요구가 없는 기능에 이 복잡도를 지불할 이유가 없다고 판단해 한 단계로 고정했습니다. 단, "금지"는 UI에서 숨기는 것만으로는 지켜지지 않아 3중 방어로 강제했습니다.
| 계층 | 방어 |
|---|---|
| UI | component 모드에서 LNB 팔레트가 커스텀 위젯 카드를 필터링 |
| 클립보드 | pasteNode가 component 모드에서 customWidget 클립보드를 거부 — UI를 우회하는 가장 명백한 경로 |
| 서버 | insertWidget / updateWidget이 노드 트리를 재귀 검사 후 throw — 최종 방어선 |
UI만 막고 끝냈다면 클립보드로 뚫렸을 것이고, 서버 검증이 없었다면 API 직접 호출로 뚫렸을 것입니다.
cloneNodeWithScopedIds(node, instanceId) → `${instanceId}__${originalId}`
.ldn-{id}로 스코프되고 buildResponsiveCss도 node.id를 키로 씁니다. 같은 위젯을 한 페이지에 두 번 넣으면 id가 겹쳐 서로의 스타일을 덮어씁니다펼쳐진 내부 노드는 저장된 스키마에 존재하지 않습니다. 여기에 편집 UI를 붙이면 hover 테두리가 떠서 정작 위젯 자체의 선택 테두리가 묻히고, 선택/삭제 버튼이 보이지만 눌러도 스키마에서 찾을 수 없어 아무 일도 일어나지 않습니다. 그런데 동작까지 죽이면 안 됩니다. 이건 미리보기이므로 버튼은 눌리고, 캐러셀은 넘어가고, 캘린더는 페이지가 넘어가야 합니다. 정확히 두 가지만 꺼야 합니다.
| 끄는 것 | 방법 |
|---|---|
| 편집 chrome | BuilderNode가 { chrome: null } 전달 |
| 저장될 곳이 없는 편집(더블클릭 인라인 편집, 빈 컨테이너 "요소 추가" 가이드) | 각 wrapper가 useReadOnlyNode()로 판단해 비활성 |
후자를 켜두면 동작하는 것처럼 보이다가 조용히 되돌아갑니다 — updateNodeField가 스키마에 없는 노드를 찾지 못하기 때문입니다. 사용자 입장에서 가장 나쁜 종류의 버그입니다.
pointer-events-none을 걸면 안 됩니다 — 미리보기 상호작용이 전부 죽고, 정작 chrome은 버튼이 pointer-events-auto로 다시 켜져서 막히지도 않습니다onClick={builder ? preventDefault : undefined}로 링크 이동을 막고 있어서, builder가 없으면 노드가 자신이 유저 페이지에 있다고 착각해 빌더에서 링크를 누르면 실제로 이동해 버립니다컨텍스트를 쓴 이유 — 노드 렌더는 각 ClientWrapper가 재귀로 돌립니다. props로 내리려면 모든 wrapper를 수정해야 하지만, 컨텍스트로 흘리면 RenderNode 한 곳에서만 판단하면 됩니다.
페이지가 id만 들고 있으므로, 참조된 위젯을 지우면 그 페이지들에서 경고 없이 사라집니다.
removeWidget이 사용처를 조회해 throw + 사용 중인 페이지 목록 반환
디자인 시스템의 LDModal은 명령형이라 내부적으로 createRoot를 호출합니다. 즉 별도의 React 트리가 되어 App Router 컨텍스트가 닿지 않습니다.
피커는 위젯 미리보기를 렌더하는데, 위젯 7종이 전부 useRouter/Link를 사용하므로
invariant expected app router to be mounted로 크래시했습니다.
→ 선언형 LDModalView를 빌더 트리 안에서 렌더하도록 전환. DOM은 portal되지만 React 트리는 유지되어 모든 컨텍스트가 살아 있습니다.
각 호출부(LNB의 삽입, LIB의 교체)가 open 상태를 소유하고 결과를 onClose로 받는 구조로 정리했습니다.
카드마다 위젯의 실제 노드를 1200px로 렌더한 뒤 카드 폭에 맞춰 scale합니다. → 썸네일이 절대 낡지 않습니다. 성립 조건 두 가지를 별도로 처리했습니다.
PreviewSelectionProvider) — 모든 ClientWrapper가 useStudioSelection()을 무조건 호출하므로, 실제 provider가 없는 곳에서 렌더하면 throw합니다container-type: inline-size — 없으면 @container 규칙이 모달 폭을 측정해 미리보기가 모바일 레이아웃으로 렌더됩니다여기서는 pointer-events-none이 맞습니다(④와 반대) — 썸네일은 클릭을 카드 선택으로 통과시켜야 하기 때문입니다. 같은 기법이 맥락에 따라 정답이 되기도 오답이 되기도 한다는 점을 문서에 함께 남겼습니다.
html2canvas 방식의 자동 캡처를 검토했으나 CORS가 걸린 CDN 이미지, 구글 호스팅 웹폰트, 컨테이너 쿼리 레이아웃, embla의 transform에서 전부 깨집니다. 무엇보다 캡처 실패는 위젯에 대해 거짓말하는 썸네일을 남깁니다. 라이브 렌더는 비용이 들지만 절대 거짓말하지 않습니다. 기각 근거를 문서에 남겨 다음 사람이 같은 검토를 반복하지 않도록 했습니다.
위젯이 늘어날 것을 전제로 서버 기반 이름 검색 + 페이지네이션을 적용하고, WidgetPreview에 memo를 걸어
검색어 타이핑·카드 선택 같은 목록 무관 상태 변화에 전 카드가 재렌더되지 않도록 했습니다(카드 하나가 노드 트리 전체를 그리므로 비용이 큼).
목록 화면은 nodes를 제외한 가벼운 요약 API를, 피커는 nodes 포함 API를 쓰도록 응답을 용도별로 분리했습니다.
백엔드 API가 아직 없는 상태에서 기능을 완성해야 했습니다.
listDocuments / getDocument / insertDocument / updateDocument / removeDocument / listWidgets / listWidgetDetails / getWidget / insertWidget / updateWidget / removeWidget / getWidgetUsage) → 전환 시 각 함수 안쪽만 fetch로 교체되고 호출부는 손대지 않습니다{ list, totalCount } 형태로 미리 맞춰 백엔드 페이징 계약과 어긋나지 않도록 처리os.tmpdir()에 둔 이유 — 프로젝트 안에 두면 next dev의 watcher가 매 저장마다 리빌드합니다"함수 몸통만 바꾸면 끝"처럼 보이지만 그때 함께 결정해야 하는 것이 있어서 별도 문서로 남겼습니다.
revalidateTag('studio-widget-{id}') 한 줄로 그 위젯을 쓰는 모든 페이지가 무효화됩니다. 따라서 "문서 + 참조 위젯을 한 번에 조립해 주는 엔드포인트"를 만들면 안 된다고 명시했습니다 — 태그가 하나로 뭉개져 위젯 수정 전파라는 핵심 기능이 죽습니다. 라운드트립을 아끼려다 기능을 잃는 전형적인 함정입니다. 서버 경로에서는 CustomWidgetServerWrapper가 렌더 트리 안에서 자기 정의를 fetch하므로 태그가 해당 라우트에 자동 등록되어, 문서 응답에 widgetIds를 미리 담을 필요가 없습니다getWidget N회(서버는 fetch 캐시가 완화하지만 클라이언트 빌더는 그렇지 않음), 피커 카드마다 데이터 위젯 API 호출, getWidgetUsage의 전 문서 스캔(→ 역참조 테이블로 대체)throw new Error(메시지)인데 프로덕션에서는 Next가 메시지를 가림 → rsltCd 기반 문구 생성 필요), 영속성, 정렬/페이징이 문서를 쓴 이유 — 전환 작업을 제가 하지 않을 수도 있고, 하더라도 몇 달 뒤입니다. "코드에 아직 없는 것"이야말로 인계에서 유실되는 부분이라고 판단했습니다.
dirtyRef로 미저장 변경 추적(스키마 변경·undo/redo·이름 편집에서 set, 저장 시 clear) → 취소 확인 모달 + beforeunload 가드window.history.replaceState로 주소만 교체합니다. router.replace를 쓰면 라우트가 다시 렌더되면서 편집 상태와 undo 히스토리가 전부 날아갑니다노코드 빌더에는 반드시 "여기서만은 직접 짜야 하는" 구간이 생깁니다(외부 임베드, 일회성 마크업). 이를 위해 입력한 HTML을 그대로 렌더하는 노드를 추가했습니다.
<script>가 빌더와 실제 페이지에서 다르게 동작합니다 — 빌더는 innerHTML 주입이라 HTML 스펙상 실행되지 않고, 유저 페이지는 문서에 스트리밍되어 파싱되므로 실행됩니다. 이걸 "고쳐서" 빌더에서도 실행되게 만들면 안 됩니다 — 서드파티 임베드가 어드민 UI 안에서 실행되어 관리자 화면을 깨뜨릴 수 있습니다. 고치는 대신 에디터에 각주로 명시하는 쪽을 택했습니다<style>이 노드 스코프가 아니라 전역이라는 점도 저렴한 해결책이 없어 경고로 처리await import()로 지연 로드해 prism이 빌더 초기 번들에 들어가지 않도록 했고, prism 토큰 색상은 .studio-code-editor 스코프에 두어 Toast UI 에디터의 코드블록과 충돌하지 않도록 격리TypeError: Cannot read properties of undefined (reading 'call')util.ts가 LNB 카탈로그(nodes.tsx)에서 그리드 셀 템플릿을 import하고 있었습니다. 그런데 nodes.tsx는 모듈 스코프에 lucide 아이콘과 JSX를 든 빌더 전용 파일입니다. RenderNode가 util.ts를 import하므로, util.ts가 import하는 모든 것이 모든 사용자 페이지의 RSC 번들에 딸려 들어가 모듈 초기화 단계에서 터진 것입니다gridCellTemplate.ts, 아이콘 없음)로 분리하고, 빌더용으로만 nodes.tsx에서 재export
nodeRenderers를 Record<NodeType, …>로 타이핑해, NodeType에 타입을 추가하고 렌더러 등록을 빠뜨리면 컴파일 에러가 나도록 했습니다.
Phase 1의 확장 프로토콜에서 "등록을 빠뜨리면 조용히 빈 노드가 된다"가 가장 흔한 실수였는데, 이 중 타입으로 막을 수 있는 절반을 타입으로 옮긴 것입니다.
getWidget 요청 dedupe, 피커 카드 lazy 렌더, getWidgetUsage 역참조 테이블화, 스키마 버전 필드 + 마이그레이션 체인, 순수 로직 테스트 도입| 기간 | 2025.08 ~ 진행 중 |
|---|---|
| 역할 | 프론트엔드 팀 리드 — 전체 아키텍처 설계, 규약 수립, 구현, 코드 리뷰, 백엔드·마케팅팀 스펙 협의 |
| 규모 | 5개 독립 배포 앱 · 91개 라우트 · 공용 패키지 6개 · 협업 5명+ |
| 서비스 | bridge.loword.co.kr |
| 기술 스택 | Next.js 15 (App Router) · React 19 · TypeScript · Turborepo · pnpm workspace · Tailwind CSS v4 · Zustand · react-hook-form + Zod · Socket.IO · Docker · AWS ECR · GA4 / GTM / Meta Pixel · Toss Payments |
| 서비스 | 역할 |
|---|---|
| Bridge Main | 랜딩 · 상품 소개 · 구매 퍼널(원패스 호스팅, 워드프레스 호스팅, 도메인) |
| SSO | 통합 로그인 · 회원가입 · 약관 · 추천인 · 내 정보 |
| Bridge Console | 로그인 후 사용자 콘솔(호스팅 / 도메인 / 워드프레스 / 결제 관리) |
| Bridge Affiliate | 파트너 대시보드 · 정산 |
| Admin | 내부 운영 어드민 |
| Storybook | 공용 UI 카탈로그 |
호스팅 서비스는 결제와 서버 프로비저닝을 다루기 때문에 장애 비용이 큽니다. 단일 앱 구조에서는 랜딩 페이지 수정 배포가 실패하면 결제 콘솔과 로그인까지 함께 중단되고, 어드민의 무거운 화면 하나가 사용자 서비스 성능에 영향을 줍니다. 서비스 성격도 완전히 달랐습니다 — 랜딩은 SEO·정적 렌더링이, 콘솔은 인증·실시간성이 중요하고, 어드민은 색인되면 안 됩니다.
서비스를 도메인·배포 단위까지 완전히 분리하는 방향을 택했습니다. 단일 앱 내부에서 라우트 그룹으로만 나누는 방식은 코드 경계는 만들어주지만 런타임과 배포를 공유하므로 장애 격리가 되지 않기 때문입니다. 요구사항이 "코드 정리"가 아니라 "영향도 축소"였으므로 프로세스가 분리되어야 했습니다.
output: 'standalone' 빌드앱을 5개로 분리하자 버튼·인풋·모달·토스트 같은 공통 UI를 서비스마다 중복 구현하게 되었습니다. 더 큰 제약은 디자인 시스템이 아직 완성되지 않은 단계였다는 점 — 컴포넌트 스펙과 디자인 토큰이 계속 바뀌는 시기였습니다.
공용 컴포넌트를 별도 npm 패키지로 publish하는 방식을 먼저 검토했으나 기각했습니다.
디자인 시스템이 확정되기 전에는 publish → 버전 bump → 5개 앱 install 사이클이 매 변경마다 반복되어 변경 속도를 감당할 수 없기 때문입니다.
대신 Turborepo + pnpm workspace를 선택해 workspace:* 링크로 패키지 수정이 별도 배포 없이 즉시 전파되게 했습니다.
@loword/ui·@loword/nextjs-ui의 컴포넌트 중 디자인 시스템으로 대체 가능한 것을 치환하는 마이그레이션을 전제로 두었습니다.
LD* 프리픽스와 2계층 단방향 의존을 규약으로 고정한 것도, 나중에 치환 대상을 기계적으로 식별할 수 있게 하기 위해서였습니다.
(전사 디자인 시스템은 3장)
@loword/nextjs-ui(Next 의존: Provider·서버액션·훅·테이블) → @loword/ui(프레임워크 비의존: 순수 UI·디자인 토큰·Zod 스키마). 역방향 import 금지를 규약화해 UI 패키지가 Next에 종속되어 Storybook 등 타 환경에서 쓸 수 없게 되는 것을 방지@loword/ui는 ui:, @loword/nextjs-ui는 nui:, 앱은 prefix 없음. 세 출처의 클래스가 서로를 덮어쓰는 문제를 규약으로 원천 차단하고 앱 레이아웃의 스타일 import 순서까지 고정LDForm(SPA 제출) / LDNativeForm(백엔드 네이티브 POST) 2종으로 서로 다른 제출 방식을 동일 인터페이스로 처리AuthSocketProvider로 추출하는 구조를 설계해 각 앱을 3줄 래퍼로 축소NonEmptyArray 제네릭으로 step 이름을 컴파일 타임에 제한, 원패스 호스팅 11스텝 구매 퍼널에 적용결과 — 5개 앱이 동일한 UI·검증·스타일 기준을 공유하면서도 각자 독립 배포되는 구조를 유지했고, 공용 패키지 수정이 즉시 전 앱에 반영되어 디자인 시스템 형성기의 빈번한 변경을 배포 사이클 없이 흡수했습니다.
도메인을 분리해 장애 격리를 얻은 대가가 캐시에서 나타났습니다.
revalidateTag를 호출해도 다른 앱의 캐시는 그대로 남습니다cache: 'no-store')은 기각 — 조회 성능과 서버 부하를 포기하는 선택이고, 개인화 데이터가 많은 서비스라 무캐시 전환의 비용이 컸습니다. 문제는 캐시 자체가 아니라 무효화 경로의 부재라고 판단했습니다users-me, hosting-list, domain-detail …)로 정의하면 같은 태그를 여러 앱이 동시에 구독할 수 있어, 사건 하나가 여러 서비스의 캐시를 함께 무효화합니다<tag>-<userId> 개인 태그를 함께 부착. 개인 태그는 accessToken JWT의 sub를 읽어 생성users-me는 4개 앱(main·console·affiliate·sso) 전부의 허용 목록에 포함되어, 내 정보 변경 하나가 4개 서비스의 캐시를 동시에 무효화| 경로 | 트리거 | 역할 |
|---|---|---|
서버 액션 revalidateTag | 프론트가 변경 주체(알림 읽음·삭제, 전화번호 저장) | 자기 앱 캐시 즉시 무효화. 소켓·웹훅을 기다리지 않음 |
웹훅 POST /api/revalidate | 백엔드 상태 변경(프로비저닝 완료, 결제, 파트너 승인) | 여러 앱에 fan-out — 크로스 도메인 전파의 본체 |
소켓 이벤트 → router.refresh() | 실시간 이벤트 | 화면을 다시 그리게 하는 트리거 |
tag === prefix || tag.startsWith(prefix + '-'))으로 개인 태그를 자동 허용 — 백엔드는 hosting-list-1234만 보내면 되고, 어느 앱이 그 태그를 쓰는지 알 필요 없음{ ok, accepted, rejected } 응답과 서버 로그로 무효화 성공/거부를 관측 가능하게 함 — 태그를 목록에 빠뜨렸을 때 조용히 stale 되지 않도록"웹훅 = 서버 캐시를 비우는 쪽 / 소켓 = 화면을 다시 그리게 하는 쪽"으로 책임을 명시하고, 둘 중 하나만 있으면 각각 "사용자가 새로고침해야 보임" / "refresh해도 캐시된 옛 데이터가 다시 옴"이 되는 이유를 시퀀스 다이어그램으로 팀에 공유했습니다.
결과 — 독립 배포·독립 캐시 구조를 유지하면서도 한 도메인의 상태 변경이 나머지 서비스 화면에 일관되게 전파되는 경로를 확보했습니다. 전체 사용자 캐시가 무효화되는 사고를 규약으로 차단하고, 새 태그 추가 시 백엔드·프론트 양쪽에 반영하는 프로토콜을 정착시켰습니다.
호스팅 인스턴스 생성, 도메인 연결, 워드프레스 설치는 백엔드 비동기 작업으로 수 분이 걸립니다. 사용자가 완료 여부를 알기 위해 새로고침을 반복해야 하는 상태였고, 이는 "결제했는데 아무 일도 안 일어난다"는 문의로 이어집니다.
폴링이 아니라 소켓을 택한 이유는 완료 시점이 수십 초에서 수 분까지 편차가 커, 짧은 간격 폴링은 서버 부하가 크고 긴 간격은 체감이 나쁘기 때문입니다.
화면 갱신 방식으로는 RSC 구조를 유지하는 router.refresh()를 기본으로 택했습니다 —
클라이언트 페칭으로 전환하면 깜빡임이 없지만 서버 컴포넌트를 클라이언트 컴포넌트로 바꿔야 해 구조 변경 비용이 크기 때문입니다.
useSocketEvent이 콜백 실행 전 ensureValidToken()을 await. 소켓 이벤트로 촉발된 재조회가 만료 토큰으로 나가 401이 되는 문제를 28개 구독 지점에 일괄 적용connect_error를 429(rate limit, 토스트 안내) / 401·unauthorized(재시도 없이 즉시 disconnect)로 분기revalidateXxx(); router.refresh();(기본) ② 클라이언트 fetch(깜빡임 없음, 구조 전환 필요). "①로 시작하고 깜빡임이 문제되면 ②로"라는 판단 기준을 규약화하고, 렌더링 없는 핸들러 컴포넌트를 서버 컴포넌트 옆에 배치하는 패턴으로 정형화session:revoked)는 안내 후 강제 로그아웃결과 — 프로비저닝·상태 변경이 사용자 조작 없이 화면에 반영됩니다. 무효화(웹훅)와 리렌더(소켓)를 짝으로 설계했기 때문에 refresh 시점에 최신 데이터가 보장됩니다.
서비스를 도메인별로 분리했으므로 인증도 도메인 경계를 넘어야 했습니다. 또한 브릿지 서비스는 약관 동의와 주소 등록이 선행되어야 이용 가능한데, 이 온보딩 상태를 각 앱이 개별 판단하면 5곳에 중복 로직이 생깁니다.
토큰을 localStorage가 아닌 상위 도메인 공유 쿠키에 담았습니다. localStorage는 origin 격리라 서비스 간 공유가 불가능하고, 무엇보다 미들웨어(서버)에서 읽을 수 없어 서버 사이드 가드를 만들 수 없기 때문입니다. 온보딩 상태 판단은 별도 API 조회 대신 JWT 클레임을 사용했습니다 — 미들웨어에서 추가 네트워크 왕복 없이 판정할 수 있기 때문입니다.
jwtVerify 후 클레임 기반 온보딩 유도(약관 미동의 → 약관, 주소 미등록 → 주소, 검증 실패 → 로그아웃) ③ accessToken 부재 + refreshToken 존재 시 미들웨어에서 refresh 후 set-cookie 릴레이 ④ x-device-type·x-pathname 요청 메타 주입 ⑤ 어필리에이트 유입 추적serverFetch(401 + 특정 errorCode일 때 refresh 후 1회 재시도, 새 쿠키 반영) / serverFetchNotRefresh(재시도 없음, 캐시 태그 조회용). 사용 기준을 문서화Set-Cookie를 생략해 CDN 캐시 오염을 방지하고, 쿠키 값의 percent 인코딩이 클라이언트에서 디코딩되지 않아 상대경로로 해석되던 버그를 실측으로 확인해 정규화 처리를 추가결과 — 5개 서비스가 하나의 로그인 상태를 공유하고, 온보딩 가드가 미들웨어 한 곳에 집중되어 앱별 중복이 제거되었습니다.
도메인 분리에는 마케팅 측면의 대가가 따랐습니다. 유입(랜딩) → 가입(SSO) → 결제(콘솔)가 서로 다른 도메인에서 일어나므로 GA 세션이 도메인 경계에서 끊기고 전환 경로가 소실됩니다. 광고비 집행 판단의 근거가 되는 데이터가 신뢰할 수 없는 상태였고, 이커머스 전환 이벤트 규격 자체가 없었습니다.
next/script로 직접 부트스트랩afterInteractive 전략으로 직접 부트스트랩하며 linker.domains에 3개 도메인 등록sub를 서버에서 디코드해 user_id로 전송, 크로스 도메인·크로스 디바이스 사용자 단위 분석 가능. 동시에 user_id 유무를 전환 이벤트의 인증 게이트로 겸용ecommerce.transaction_id / value / currency / items[])에 맞춰 purchase(도메인·호스팅), begin_checkout 4종, refund(콘솔 환불로 결제 반대편 지표 수집), signup, login, submit_form, pre-registration, Meta CompleteRegistration 2종page_view에 한글 페이지명 매핑 설계 — 앱 타입 × pathname → '원 패스 호스팅 도메인 설정' 형태의 page_title 생성. 동적 라우트(공지 상세 slug 디코딩)와 카테고리 쿼리 필터까지 처리해, 마케팅팀이 URL이 아닌 화면 이름으로 퍼널을 조회할 수 있게 함isbot으로 봇 트래픽을 제외하고 HTML 문서 요청에만 발급하며, 프록시 환경을 고려해 x-forwarded-proto/x-forwarded-host 기반으로 secure·domain 옵션을 동적 결정. 채널톡 프로필에 연결해 비회원 상담자를 식별결과 — 도메인이 분리된 상태에서도 유입 → 가입 → 결제 전환 경로를 하나의 세션으로 측정할 수 있게 되었고, 마케팅팀이 GA4에서 화면 단위 퍼널과 이커머스 전환을 조회할 수 있는 기반을 확보했습니다.
기존 login 이벤트가 로그인 시도 직전에 status: 'success'로 발사되어 비밀번호 오류·OAuth 취소까지 전환으로 집계되고 있었습니다.
근본 원인은 구조적 제약이었습니다 — SSO는 통합 로그인이라 성공 시 백엔드가 302로 다른 서비스에 착지시키므로 SSO 프론트에는 성공 콜백이 존재하지 않습니다.
회원가입도 폼이 백엔드에 네이티브 POST를 쏘기 때문에 성공 콜백이 없어, 제출 시점에 발사하면 이메일 중복·서버 에러까지 가입으로 집계되었습니다.
page_view 컴포넌트에 얹지 않고 별도 컴포넌트로 분리 — 두 앱은 page_view를 끄고 login만 수집하는 상태여서 실제로 수명이 다르기 때문user_id는 앱별 서버 액션을 prop 주입받아 전달(공용 패키지가 서버 액션을 직접 호출하지 않는 기존 관례 유지). 값이 없으면 미발사하도록 해 로그인 성공 확인을 겸함user_id 게이트로 차단결과 — 로그인·가입 전환 수치가 실제 성공 건수를 반영하게 되었고, 사용자 단위 분석이 가능해졌습니다. 남은 미확인 전제(소셜 재로그인이 동일 착지 페이지를 경유하는지 등)는 오집계 가능성과 함께 문서에 명시해 후속 확인 대상으로 남겼습니다.
같은 모노레포에 색인되어야 하는 서비스(랜딩)와 색인되면 안 되는 서비스(어드민·콘솔·개인화 구매 퍼널)가 공존한다는 점이 이 프로젝트 SEO의 고유 조건이었습니다.
메타데이터는 페이지별 작성 대신 getMetadata() 팩토리 + 상수 중앙화로 구성했고(손으로 쓰면 누락이 반드시 발생),
robots.txt 정적 파일 대신 환경 인지형 robots.ts를 택해 환경 전환 시 파일을 수동 교체하는 실수를 없앴습니다 —
운영이 아니면 전체 disallow로 개발·QA 색인을 차단하고, 운영에서는 개인화 경로만 선택적으로 제외합니다.
문제 — 모노레포에서 앱별 독립 배포를 하려면 각 이미지가 자기 앱과 그 의존 패키지만 포함해야 합니다. 레포 전체를 복사해 빌드하면 이미지가 커지고, 무관한 앱의 변경만으로도 매번 전체 의존성이 재설치됩니다.
선택 — turbo prune --docker로 서브그래프를 추출하는 방식을 택했습니다. 이것이 2-1의 "앱별 독립 배포"와 2-2의 "모노레포"를 동시에 성립시키는 지점입니다.
또한 의존성 메타(out/json)만 먼저 복사해 install 레이어를 캐시하고 소스는 그 뒤에 복사하도록 순서를 잡았습니다 — 소스 변경이 install 레이어를 무효화하지 않게 하기 위해서입니다.
deploy.sh 하나로 ECR 로그인 → 빌드 → 태깅 → 푸시를 원커맨드화하고 dev 인자로 개발/운영 이미지 분리turbo.json globalEnv에 웹훅 시크릿을 등록해 환경 변수가 캐시 키에 반영되도록 처리useSearchParams()를 호출해 정적 prerender되는 모든 페이지의 빌드가 실패하던 문제가 4개 앱에 공통으로 있었습니다. 페이지가 해당 훅을 직접 쓰는지와 무관하게 공통 조상에서 발생하는 문제였습니다. children 전체를 Suspense로 감싸면 빌드는 통과하지만 정적 렌더링이 CSR로 폴백되므로, 토큰 감지 로직만 별도 컴포넌트로 분리해 그 컴포넌트만 Suspense로 격리하고 children은 경계 밖에 두었습니다. 4개 앱 동시 해결 + 정적 렌더링 유지Set-Cookie를 생략해 CDN 캐시를 보호사용자 콘솔에서 가장 자주 필요한 정보는 생성한 블로그의 주소와 워드프레스 관리자 주소·ID·비밀번호입니다. 로그인해서 확인하려는 것이 사실상 이것뿐인 경우도 많습니다. 그런데 기존 구조에서는 이 정보에 도달하려면 3단계를 이동해야 했고, 특히 고연령 사용자층에서 경로를 찾지 못해 이탈하거나 문의로 이어지는 문제가 있었습니다.
메뉴 이름을 바꾸거나 안내를 추가하는 방식은 택하지 않았습니다. 경로를 설명해서 찾게 만드는 것보다 가장 자주 쓰는 정보를 진입 화면으로 끌어올리는 편이 낫다고 봤고, 인스턴스별 카드를 대시보드에 직접 배치해 블로그 주소·이름·관리자 주소·ID·PW를 한 화면에서 확인하도록 했습니다.
실제로 제약이 있었습니다. 인스턴스당 블로그를 3개까지 생성할 수 있어, 한 슬라이드에 카드가 3벌 놓입니다. 주소·이름·관리자 주소·ID·PW가 세 세트로 늘어나면 화면은 정보로 가득 차고, 정작 지금 확인하려는 블로그가 어느 것인지 알기 어려워집니다. 그래서 문제를 "무엇을 올릴 것인가"에서 "올린 것 중 무엇을 지금 보게 할 것인가"로 다시 잡았습니다.
값의 실제 사용 목적이 "읽는 것"이 아니라 "다른 곳에 붙여넣는 것"이라는 점에서 항목마다 복사 동작을 배치했습니다. 드래그 선택 자체가 부담인 사용자를 고려한 결정입니다. 블로그 열기와 관리자 페이지 이동은 새 창으로 분리해 콘솔로 돌아오는 경로를 잃지 않도록 했고, 호스팅·도메인이 없는 계정에는 빈 상태를 그대로 두지 않고 다음 행동을 제시하는 안내를 노출했습니다.
주요 정보 도달률과 관련 문의 건수를 개선 전후로 비교해 효과를 수치로 확인하는 것이 다음 단계입니다.
.gitignore에 규칙이 있는데도 .env 4개가 계속 커밋 대상으로 잡히던 문제의 원인을 ".gitignore는 untracked 파일에만 적용된다"는 점으로 특정하고, git rm --cached로 추적을 끊은 뒤 git check-ignore로 검증. 원격 히스토리에 남은 값은 노출로 간주하고 rotate 필요성을 명시적으로 고지, 히스토리 재작성은 별도 작업으로 분리normalize('NFC') 선처리로 해결해 20여 개 필드에 일괄 적용추천 링크로 유입해도 코드가 저장되지 않는 문제. 원인은 두 겹이었습니다 — 미들웨어의 3개 리다이렉트 분기(약관·주소·로그아웃)가 쿠키를 심는 래퍼를 거치지 않아 이미 파싱한 코드가 버려졌고, 동시에 복귀용 referer에 query string이 빠져 있어 쿠키에도 URL에도 남지 않았습니다. 3개 분기를 래퍼로 감싸고 referer에 search를 포함해 해결했으며, 인접 앱의 동일 계열 결함(복귀 referer가 자기 오리진이 아님)도 발견해 별건으로 기록했습니다.
A 서비스로 유입했는데 B 서비스의 추천 코드가 자동 입력·잠기는 현상.
원인은 판정 근거가 "이번 요청이 무엇인가"가 아니라 "이 탭이 예전에 어디를 거쳤는가"(sessionStorage)였던 것입니다.
부가 원인으로 저장 로직이 useReducer의 리듀서 내부 side effect에 있어(React가 금지하는 non-pure reducer 패턴) 리렌더가 실제 일어나야만 저장되는 구조였고, 이것이 간헐성의 원인이었습니다.
전역 우선순위는 소비자가 8곳이라 건드리지 않고 해당 화면에서만 URL의 명시적 신호가 세션 추론을 덮도록 읽기 쪽을 고쳤고, dispatch를 래핑해 호출 즉시 동기 저장하도록 쓰기 쪽도 고쳤습니다 — 호출부 13곳을 한 줄도 바꾸지 않고 적용했습니다.
이전 커밋의 원인 서술이 부정확했던 점(업데이트 큐는 provider의 fiber에 붙으므로 언마운트로 유실되지 않음)까지 문서에서 정정했습니다.
남의 도메인 ID로 설정 페이지에 접근하면 크래시 없이 값만 비어 있는 설정 화면이 렌더되고, 그 상태로 저장하면 id가 undefined인 채 요청이 나가던 문제.
원인은 공용 fetcher가 response.json() 본문만 반환하고 HTTP status를 버리는 설계여서 403이 페이지에 "데이터 없음"으로 도착한 것이었습니다.
status를 살리는 fetcher 변경은 영향 범위가 넓어, 판정 기준을 응답 본문의 errorCode로 두고 목록으로 redirect하는 방식을 택했습니다.
추가로 Next.js 내부 구현(fetch 패치·incremental cache)까지 확인해 403이 Data Cache에 저장되지 않고 캐시 키에 Cookie가 포함되므로 사용자 간 응답 혼입이 없음을 검증했으며, 동일하게 무방비인 다른 상세 페이지들을 남은 범위로 명시했습니다.
비회원이 도메인을 검색·선택하고 결제를 누르면 로그인으로 이탈했다가 복귀 시 화면이 초기 상태로 보여, 검색부터 다시 반복해야 했습니다. 상태는 localStorage에 남아 있었으나 복원 경로가 없었습니다. 결제 훅에 로그인 이탈 시점을 호출부가 가로챌 수 있는 옵셔널 콜백을 추가(다른 앱·다른 결제 케이스에 영향 없는 추가형 변경)하고, 복원을 2단계로 설계했습니다. 재개 플래그를 URL 쿼리로 넘기지 않은 이유는 referer가 SSO의 세션·미들웨어를 거치며 중첩 인코딩되기 때문입니다. 도메인 목록 재조회는 여러 페이지 순차 요청이라 결제 모달 표시가 눈에 띄게 늦어져 결제 모달을 닫은 시점으로 미뤘고, 이미 카드가 등록된 회원에게 카드 등록창이 다시 뜨는 문제(비동기로 채워지는 값을 클로저가 고정)도 함께 해결했습니다.
| 프로젝트 | @loword/loword-design-system — 전사 공용 React 컴포넌트 패키지 (NPM 공개 배포) |
|---|---|
| 패키지 | npmjs.com/package/@loword/loword-design-system |
| 기간 | 2026.03 ~ 진행 중 (약 5.5개월) |
| 역할 | 설계 총괄 / 팀 리드 — 아키텍처·토큰 체계·패키징 설계, 개발 가이드 수립, 코드리뷰·릴리즈 총괄, 핵심 인프라 직접 구현 |
| 팀 구성 | 프론트엔드 6명 |
| 기술 스택 | React 19, TypeScript, Tailwind CSS v4, Storybook, Vitest + Playwright, Radix UI, react-hook-form, TanStack Table, Tiptap, GitHub Actions |
| 산출 규모 | 컴포넌트 48종 · Base 37개 · 폼 래퍼 29개 · 공개 API 146개 · 스토리 65개 · PR 48건 · 릴리즈 18회 |
| 도입 현황 | 사내 서비스 2곳(ledu-mono, ledu-frontend)에서 사용 중 · 브릿지 모노레포의 공용 패키지(@loword/ui / @loword/nextjs-ui) 치환이 다음 대상 |
기존 monorepo의 레거시 UI 패키지는 UI와 기능이 강결합되어 커스터마이징이 사실상 불가능했습니다. 새 요구사항마다 컴포넌트 내부 분기를 추가하는 방식이라, 컴포넌트가 커질수록 분기가 곱으로 증가하고 수정 시 영향 범위를 예측할 수 없었습니다.
Ant Design류의 "하나의 컴포넌트 + props 분기" 대신 Base → Custom → Function 3계층 분리를 채택했습니다.
| 레이어 | 책임 | 규칙 |
|---|---|---|
{Name}Base | HTML 구조 + 접근성(ARIA)만 | 시각 스타일을 갖지 않는다 |
LD{Name} (Custom) | 프리셋(variant/color/size) + 자유 스타일 | Base 위에만 얹는다 |
LDForm{Name} 등 | 외부 로직 주입(폼·테이블·명령형 API) | Custom을 래핑하고 Base를 직접 쓰지 않는다 |
핵심 원칙은 "확장은 아래 레이어 수정이 아니라 위 레이어 추가로만 한다"입니다. props 분기는 요구사항이 늘 때마다 기존 코드를 건드려야 하지만, 레이어 분리는 기존 것을 그대로 둔 채 새 레이어를 추가하면 되기 때문입니다.
SelectBase에서 LDSelect와 LDMultiSelect가 Base 수정 없이 파생 — 설계 의도가 실제로 검증된 사례LDFormField 하나(react-hook-form + render-prop)를 재사용해 29개 폼 래퍼로 확장. 검증 관심사를 Function 레이어에 격리해 디자인 컴포넌트가 폼 상태를 몰라도 되게 했습니다
"Base는 스타일을 갖지 않는다"는 규칙이 선언에 그치면 시간이 지나며 무너집니다.
실제로 일부 컴포넌트는 Base 382줄이 디자인을 전부 갖고 Custom 57줄은 className만 통과시키는 역전 상태였습니다.
더 근본적으로는 a11y 애드온이 test: 'todo'로 설정되어 있어 위반이 있어도 테스트가 통과했고, 그래서 접근성 결함이 계속 쌓이고 있었습니다.
test: 'error'로 승격해 CI 실패 조건으로 전환. 단, color-contrast 규칙만 제외했습니다 — 디자인 토큰의 명도 대비는 컴포넌트 구현이 아니라 토큰 설계 차원의 별도 사안이라, 이걸 섞으면 게이트 자체가 무력화되기 때문입니다. "통과할 수 없는 게이트는 곧 꺼지는 게이트"라는 판단이었습니다role="combobox"·aria-expanded·aria-controls·aria-activedescendant·role="listbox"가 하나도 없어 스크린리더가 아무것도 읽지 못하던 상태를 발견. useId 기반 id 규칙을 Context로 공유해 입력창과 항목이 같은 참조를 쓰도록 하고 combobox ARIA 일체를 구현Drawer.Title이 아닌 plain <h2>를 써서 aria-labelledby가 걸리지 않아 접근 가능한 이름이 없던 문제 해결focus: → focus-within:으로 이관해 시각 동작을 보존aria-controls가 가리키는 대상이 사라지던 문제 해결(Content는 항상 렌더하고 chevron만 조건부로 숨김)
컴포넌트마다 클래스 조합 방식이 달랐습니다. clsx computed key 객체, 3중 중첩 Record(triggerPadding[variant][placement][size] = 27칸),
인라인 삼항이 혼재해 읽는 사람이 매번 다른 규칙을 익혀야 했고, 어떤 조합에 어떤 클래스가 붙는지 한눈에 보이지 않았습니다.
cva·tailwind-variants가 같은 문제를 풀지만, NPM 배포 패키지 특성상 소비처 번들에 의존성이 얹히는 것을 피하려고 자체 구현했습니다.
base(항상) / variants(축 하나로 결정) / compoundVariants(축 조합에서만 결정) 3위계로 스타일을 성격에 따라 배치하고,
병합은 기존 cn()(clsx + tailwind-merge)에 위임해 중복 구현을 피했습니다.
| 항목 | 수치 |
|---|---|
| 캐시 도입 전 → 후 | 1.42µs → 0.18µs/call (축 4개·108조합·compound 34행 기준) |
| 리팩터링 이전 방식(중첩 Record + cn()) | 0.23µs/call → 직접 조회보다도 빠름 |
| tailwind-variants 3.3.0 (동일 설정) | 4.57µs/call → 약 25배 차이 |
| 동등성 검증 | 108개 조합 전수 비교, 출력 클래스 집합 불일치 0건 |
extend 상속 때문에 런타임에 규칙이 달라질 수 있어 캐시 키에 compound 규칙 전체의 서명을 넣느라 매 호출 규칙을 순회합니다. 자체 구현은 extend가 없어 설정이 정의 시점에 고정이므로 그 비용을 내지 않습니다
공통 토큰 타입에서 일부만 골라 쓰는 컴포넌트가 많았습니다. 흔히 쓰는 Extract<Size, 'small' | 'huge'>는 오타나 삭제된 값을 에러 없이 조용히 제거합니다.
더 심각한 것은, 각 컴포넌트가 'solid' | 'outline' | … 유니온을 손으로 다시 선언하고 있어 공통 타입과 어긋나도 컴파일 단계에서 전혀 잡히지 않았다는 점입니다.
Subset<Parent, T extends Parent> = T — 한 줄이지만 의미가 반대입니다. 부모에 없는 값을 적으면 그 자리에서 컴파일이 멈추고, 공통 타입에서 값이 빠지면 그걸 쓰던 컴포넌트가 전부 에러로 드러나 어디를 고쳐야 하는지 타입 체커가 알려줍니다Subset<Variant, …> 형태로 변경. 이 과정에서 LDDatePicker 계열이 쓰던 filled가 공통 Variant에 아예 없었다는 사실이 드러났고, 공통 타입에 추가해 해소했습니다. 타입 설계가 실제 불일치를 찾아낸 사례tv() 축 명세 제네릭 — 기존에는 variants에 적은 게 곧 타입이라 축 누락·오타가 새 옵션으로 흡수되고, 에러는 한참 뒤 호출부에서야 났습니다. 축 구성을 타입 인자로 못박아 정의하는 자리에서 검증되게 했습니다as prop 타입 — 렌더 엘리먼트를 교체해도 해당 엘리먼트의 DOM 속성 타입이 보존되도록 정의
테이블 컴포넌트는 TanStack Table 위에 올라가 있는데, 내부 래퍼(TableField)가 패키지 루트로 공개되어 있었고 table 인스턴스가 render props로 소비처에 그대로 노출되어 있었습니다.
이 상태에서는 소비처 코드가 TanStack의 타입과 API에 직접 결합되어, 라이브러리를 교체하거나 버전을 올릴 때 소비처가 전부 깨집니다.
디자인 시스템이 라이브러리를 감싸는 의미가 사라진 상태였습니다. 같은 문제가 LDDataCalendarBase, LDLNBBase 등 내부 Base 레이어의 루트 공개에도 있었습니다.
TableField → HeadlessTable로 개명하고 barrel에서 제외해 비공개 전환, LDDataCalendarBase·LDLNBBase도 동일하게 비공개화. Base 레이어는 패키지 내부의 구현 단위이지 소비 단위가 아니라는 것을 이름과 export 양쪽에서 일관되게 표현HeadlessTable 내부에 가뒀습니다. 소비처는 라이브러리를 몰라도 테이블을 쓸 수 있습니다pageable 대신 TanStack의 rowCount를 채택. 자체 어휘는 배우는 비용이 들고 라이브러리 문서와 대조도 안 되는데, 그 비용을 낼 만한 추상화 이득이 없다고 판단stickyHeader → classNames.scroll로 일반화) 제거"선택 색이 부드럽게 전환되지 않고 즉시 튄다"는 시각적 증상에서 출발했지만, 원인은 전부 렌더 구조에 있었습니다.
| 원인 | 진단 | 조치 |
|---|---|---|
| 컴포넌트 타입이 매 렌더 새로 생성 | 캘린더가 components prop 안에 인라인 화살표 함수로 오버라이드를 정의 → 렌더마다 컴포넌트 타입이 바뀜 → React는 타입이 다르면 재조정이 아니라 unmount/remount → 캘린더 DOM 전체가 매번 새로 생성. 트랜지션은 값 변화에 반응하는 것이라 새로 마운트된 노드에는 걸리지 않아 색이 튀었음 |
오버라이드를 모듈 스코프 상수로 승격해 타입 참조 고정, 재발 방지 주석 명시 |
| 고빈도·저빈도 상태의 context 혼재 | LNB는 리사이즈 드래그 중 매 mousemove마다 width가 바뀌는데, 이를 읽지도 않는 Header/Group/MenuItem까지 전부 리렌더. TeamHistory도 스크롤 index 변화에 무관한 Sections가 리렌더 | 고빈도 값만 별도 context로 분리하고 각 파트가 필요한 것만 구독. 나머지 value는 useMemo, 핸들러는 useCallback으로 참조 고정 |
| ResizeObserver 과다 재구독 | 구독 effect의 deps에 관찰과 무관한 값들이 들어 있어, 값 하나만 바뀌어도 disconnect 후 전체 재observe. 실제 필요 조건은 관찰 대상 목록의 변화뿐 | 최신 콜백을 ref로 참조하고 deps를 [items]로 축소 |
| 아무도 읽지 않는 state | ToggleButton context의 focus 상태를 읽는 파트가 하나도 없어, 포커스/블러마다 Root가 리렌더되기만 하고 효과가 전무(포커스 스타일은 CSS가 담당) | state·context 필드·이벤트 래핑 제거 |
| 스크롤 프레임마다 대량 리렌더 | 커스텀 오버레이 스크롤바를 state로 구현하면 스크롤 프레임마다 시간 항목 60개가 리렌더 | 썸 위치만 DOM을 직접 갱신해 리렌더 경로에서 제외 |
LDCalendar 단독 스토리에서는 정상으로 보이고 controlled로 쓰는 LDDatePicker에서만 드러났습니다.
컴포넌트 라이브러리는 "우리 환경에서 잘 도는 것"과 "소비처 사용 패턴에서 잘 도는 것"이 다를 수 있다는 것을, 앞선 CSS 캐스케이드 버그에 이어 두 번째로 확인한 사례입니다.
다른 프로젝트에서 barrel import로 쓰려면 배포가 필요했으나, 빌드 실패·CJS 출력·타입 미노출·barrel 부재로 배포 불가 상태였습니다.
tsc가 컴포넌트별 .js + .d.ts를 미러링하고, tsc-alias가 @/* alias를 해소하며 상대 import에 .js 확장자를 부여해 유효한 ESM을 만들도록 구성styles.css)만 쓰는 소비처는 Tailwind 설치 자체가 불필요하고, 소비처의 Tailwind가 실제로 필요한 지점은 토큰 원본(theme.css) 경로뿐입니다. 이 경계를 문서에 명시sideEffects를 CSS로만 한정해 트리셰이킹을 확보하고, 진입점 3종(barrel / 컴포넌트 개별 경로 / 스타일)을 exports로 노출결과 — 0.0.1 최초 발행 이후 릴리즈 18회, 사내 서비스 2곳에서 사용 중.
디자인 시스템은 여러 명이 동시에 작업하는데, 각자 작업 브랜치를 main으로 직접 머지하고 어느 정도 모이면 npm 버전을 올려 배포하는 구조였습니다.
배포라는 단위가 코드상 아무 실체도 갖지 못한다는 것이 근본 문제였습니다.
| 문제 | 실제 증상 |
|---|---|
| 이번 배포 변경사항이 중앙화되지 않음 | "0.0.19에 뭐가 들어가는지" 알려면 머지된 PR을 시간순으로 역추적해야 했음 |
| 소비처 관점 정보가 배포 단위로 정리되지 않음 | breaking change와 마이그레이션 안내가 CHANGELOG 안에 날짜순으로 흩어져, 소비처가 업그레이드할 때 무엇을 고쳐야 하는지 한 번에 파악 불가 |
| CHANGELOG 상시 충돌 / 자동화 부재 | 모두가 단일 파일 맨 위에 추가하니 팀원 PR 머지마다 충돌. GitHub Actions 워크플로는 0개 |
main을 "항상 npm에 배포된 것과 동일한 상태"로 고정하고, 그 사이에 이번 배포의 집결지인 release/vX.Y.Z 브랜치를 두었습니다.
팀원은 main이 아니라 이 브랜치에서 분기하고 이 브랜치로 PR합니다.
이 구조의 핵심은 브랜치가 아니라 release/* → main draft PR 하나입니다. 이 PR이 곧 "이번 배포 대시보드"가 됩니다 —
누적 diff, 포함된 작업 목록, 여러 사람의 변경이 합쳐진 상태의 통합 CI, 소비처가 읽을 릴리즈 노트가 한 화면에 모입니다.
"이번 배포에 뭐가 들어가나?"에 대한 답이 URL 하나가 되는 것이 이 설계의 목적이었습니다.
함께 정한 제약 — 열려 있는 release 브랜치는 항상 하나로 강제했습니다(둘 이상이면 워크플로가 중단). 둘 이상이면 팀원이 어디로 PR해야 할지 알 수 없어 모델 자체가 무너집니다.
배포 단위를 만들어도 "무엇이 바뀌었는지"를 사람이 매번 정리하면 자동화가 아닙니다. 그리고 기존 CHANGELOG는 독자가 섞여 있었습니다 — 팀은 "왜 이렇게 고쳤는지"를, 소비처는 "무엇이 달라졌고 내 코드를 어떻게 고쳐야 하는지"를 알아야 하는데 한 파일에 뒤섞여 있었습니다.
작업 하나당 .changes/{브랜치명}.md 파일 하나를 남기게 하고, 이 파일을 변경 기록의 유일한 원본으로 정했습니다.
한 파일 안에 ## 팀 기록과 ## 소비처 두 섹션을 두어, 배포 시 각각 CHANGELOG와 릴리즈 노트로 자동 분배됩니다.
"이번엔 patch인가 minor인가"를 매번 사람이 상의하면 자동화가 아니고, 기준이 사람마다 다르면 소비처가 예고 없이 깨집니다. 그래서 기준을 하나의 질문으로 고정했습니다 — "이 버전을 올리면, 소비처가 코드를 한 줄이라도 고쳐야 하는가?" "눈에 띄게 바뀌었나"가 아니라는 점이 핵심입니다.
## 소비처에는 분명히 적음) / 애매하면 높은 쪽으로(낮게 잡으면 소비처가 깨지고 높게 잡으면 숫자만 올라가는 비대칭 비용)
npm publish는 되돌릴 수 없는 유일한 지점입니다. 이 지점을 축으로 파이프라인 순서를 배치했습니다.
검증 (노트 존재 · npm 중복 · 인증 · 타입 체크 · 테스트)
→ npm publish ← 되돌릴 수 없는 지점
→ 태그 → CHANGELOG 갱신 + .changes/ 비움 → Release 게시 → 다음 사이클 자동 오픈
.changes/도 손대지 않은 상태로 남습니다. 실제로 v0.1.0 첫 배포가 npm 인증 실패로 중단됐을 때 main이 온전히 남아 토큰만 고쳐 재시도할 수 있었습니다 — 설계 의도가 실전에서 검증된 지점입니다릴리즈 노트의 사실관계(변경 항목·PR 목록)는 changeset에서 기계적으로 생성되지만, 소비처가 읽을 요약 문단은 문장 작업입니다. 이 부분만 Claude Code Action에 맡겼습니다.
새 프로세스를 도입하면 팀원은 반드시 실수합니다. 특히 GitHub이 PR base를 항상 main으로 기본 설정하기 때문에 매번 바꿔야 하는데 이것이 가장 흔한 실수였습니다. 이때 CI가 빨간 X만 띄우면 팀원은 로그를 뒤지고 결국 저에게 물어보게 됩니다 — 자동화가 오히려 리드를 병목으로 만드는 구조입니다.
그래서 검사가 실패하면 Step Summary에 해결 방법을 렌더링하도록 했습니다. 브랜치 규칙 실패에는 현재 base/head와 바꾸는 방법, 이미 main에서 분기했을 때의 rebase 명령까지. changeset 누락에는 그 PR이 만들어야 할 정확한 파일 경로(브랜치명에서 자동 생성)와 템플릿 전문을. annotation은 첫 줄만 PR 화면에 노출되므로 로그를 펼치지 않아도 보이도록 Summary에 함께 남겼습니다.
저장소가 free 플랜 private이라 GitHub이 브랜치 보호 규칙을 제공하지 않습니다. 즉 CI가 실패해도 머지를 막을 수 없습니다.
v0.1.0 · v0.1.1 두 번의 실 배포로 워크플로 5종이 모두 동작하는 것을 확인하고, 첫 사이클에서 드러난 결함 7건을 원인 단위로 규명·수정했습니다. 로컬과 CI 환경 차이(뷰포트 미고정으로 인한 플레이키 테스트), GitHub Actions 동작 방식(라벨 트리거 누락, Actions의 PR 생성 차단 정책), npm의 응답 특성(scoped 패키지 권한이 없으면 401이 아니라 404) 등 실제로 돌려보지 않으면 알 수 없는 것들이었습니다.
이 CI/CD 작업 자체도 만든 프로세스를 따라 changeset을 남기고 release 브랜치를 통해 머지했습니다. 자기가 만든 프로세스를 자기가 먼저 쓰면서 불편한 지점을 찾는 방식으로 검증했습니다.
| 구분 | 도입 전 | 도입 후 |
|---|---|---|
| "이번 배포에 뭐가 들어가나?" | 머지된 PR을 시간순 역추적 | release PR 한 화면 |
| 소비처가 받는 정보 | CHANGELOG에 날짜순으로 흩어짐 | 배포 단위 릴리즈 노트가 npm·GitHub Release에 자동 게시 |
| CHANGELOG 충돌 | 팀원 PR 머지마다 발생 | 구조적으로 0 |
| 배포 작업 | 전 과정 수동, 워크플로 0개 | 버전 결정·노트 생성·publish·태그·Release·다음 사이클까지 자동 (5종 1,008줄) |
문제 — 소비처가 배포된 styles.css(= 컴파일된 CSS)만 import하면, 그것은 디자인 시스템이 사용한 클래스만 담긴 스냅샷이라 소비처가 쓰는 클래스는 생성되지 않았습니다.
더 근본적으로, 컴파일본에서 Tailwind v4의 @theme는 :root 변수로 박제되어 소비처 Tailwind가 이를 토큰으로 인식조차 못 하는 상태였습니다.
결정 — 토큰·유틸 레이어만 모은 raw 소스 진입점(theme.css)을 별도 export.
여기에 @import 'tailwindcss'와 컴포넌트 스타일은 의도적으로 넣지 않았습니다 — 소비처가 이미 import하므로 중복 시 충돌하고, 컴포넌트 스타일은 컴파일본이 담당하기 때문입니다.
결과로 소비처가 자기 Tailwind 파이프라인에서 토큰 기반 클래스를 별도 정의 없이 on-demand 생성할 수 있게 되었습니다.
@utility(= utilities 레이어)로 정의되어 소비자의 임의값 유틸과 동일 레이어·동일 specificity였고, 미리 컴파일된 CSS를 앱 CSS보다 나중에 import하면 소스 순서로 디자인 시스템이 이겼습니다. Storybook은 단일 빌드라 우연히 정상 동작하고 있었던 것이 발견을 늦춘 원인입니다@layer components로 이전(components < utilities이므로 소비자 유틸이 로드 순서와 무관하게 항상 우선), 단일 목적 유틸과 @theme 변수는 @utility 유지. 전환 전 variant 미사용을 확인해 손실이 없음을 검증docs/css-layers.md에 레이어 서열 규칙, 컴포넌트 프리셋을 @layer components에 두는 이유, 소비처 override 판정 3단계(① cn()의 tailwind-merge dedupe → ② CSS 레이어 싸움 → ③ style prop 우회), 5단계 진단 흐름을 정리. 같은 증상을 매번 처음부터 추적하던 비용을 없애는 것이 목적이었습니다문제 — 캘린더의 선택·hover·today 링·예약 점 색이 Base에 직접 박혀 있어, 소비처가 브랜드 색을 바꿔도 선택 표시는 파란색으로 고정됐습니다. 색이 붙는 버튼이 classNames 슬롯으로 노출되지도 않아 덮어쓸 경로 자체가 없었고, "Base에는 시각 스타일을 두지 않는다"는 규칙에도 어긋났습니다.
| 방식 | 검토 결과 |
|---|---|
| 색 이름 프리셋(point/danger/…) — 다른 컴포넌트에서 쓰는 방식 | 색마다 배경·글자·링·띠 짝을 모두 정의한 매핑 테이블이 필요한데, 캘린더 강조 색은 앱당 하나로 고정되는 성격이라 대부분 쓰이지 않을 조합만 쌓임 |
| CSS 변수 3개 + colorSet prop (채택) | 기존에 쓰던 --cell-size 등과 같은 자리·같은 방식이라 새 규칙이 늘지 않음. 일부만 지정하면 나머지는 기본값이 유지되도록 설계 |
핵심 판단 — "프리셋이 정말 필요해지면 colorSet에 값을 넣는 얇은 레이어로 얹을 수 있고, 그때도 Base는 다시 손대지 않아도 된다"는 점을 근거로 채택했습니다. 3-Layer 설계의 "위에 얹어서 확장한다"는 원칙을 확장점 설계에도 그대로 적용한 사례입니다.
부수 성과 — 이 작업 중 선택 시 색이 차오르던 애니메이션이 색을 직접 물고 있어 다른 색을 지정하면 파란색이 번졌다 튀는 문제, from/to가 서로 다른 CSS 속성을 애니메이션하던 결함을 발견해 함께 제거했습니다. 캘린더를 쓰는 모든 지점(단독·DatePicker·DateRangePicker·각 Form 래퍼)에 스토리를 붙여 확장점이 실제로 닿는지 검증했습니다.
6명이 병렬로 작업하는 상황에서 컨벤션이 문서화되지 않으면, tv() 도입 이전에 겪었던 문제가 조직 차원에서 반복됩니다 —
읽는 사람이 컴포넌트마다 다른 규칙을 새로 익혀야 하는 상태.
architecture.md(3-Layer + 신규 컴포넌트 추가 가이드), style-conventions.md(tv() 작성 규칙 및 제한사항), css-layers.md(캐스케이드·override 진단), type-conventions.mddocs/deprecated/로 보관해 "기준으로 삼지 않되 맥락은 남긴다"는 처리를 했습니다cn()/tv() 내부 문자열까지 린트 대상에 포함되도록 설정해 검사에서 새는 구멍을 막았습니다lint --max-warnings 0, a11y CI 실패 조건. GUI Git 클라이언트가 로그인 셸을 거치지 않아 PATH에 pnpm이 없는 문제까지 훅에서 처리하고, ANSI 색상을 렌더링하지 않는 출력 패널을 고려해 구분선·기호·소요시간으로 읽히는 로그를 설계레거시 UI 패키지 2곳의 컴포넌트를 전수 조사해 완료 / 기존 컴포넌트의 변형으로 커버 가능 / 추가 검토 / 대상 아님 4분류로 정리하고, 제외 사유를 항목별로 명시했습니다 — 단순 래핑(Next.js Link, 외부 캐러셀), 단일 요소로 너무 단순한 것, 서비스 특화 컴포넌트, 3-Layer 적용 대상이 아닌 애니메이션 유틸.
디자인 시스템의 정체성은 무엇을 담느냐보다 무엇을 담지 않느냐로 정의된다고 보고, 판단 근거를 남겨 이후 팀원이 같은 기준으로 결정하도록 했습니다. 도입 우선순위는 추측이 아니라 실사용 빈도로 정했습니다(예: 소비처에서 외부 라이브러리 컴포넌트를 22회 사용하는 것을 확인한 뒤 대체 컴포넌트 추가).
같은 기준을 브릿지 모노레포의 공용 패키지에도 적용하고 있습니다. 브릿지의 @loword/ui·@loword/nextjs-ui는
디자인 시스템이 없던 시기에 5개 앱의 중복을 흡수하기 위해 만든 임시 수렴점이므로(2-2),
디자인 시스템이 커버하는 컴포넌트는 치환하고 서비스 특화 컴포넌트만 모노레포 패키지에 남기는 것이 목표입니다.
두 벌을 영구히 유지하면 "어느 쪽이 기준인가"가 흐려져, 디자인 시스템을 만든 이유 자체가 사라집니다.
| 기간 | 2024.01 ~ 재직 중 (약 2년 7개월) · 더블티 재직 시 착수해 로워드 이직 후 연속 수행 |
|---|---|
| 역할 | 프론트엔드 팀 리더 — 아키텍처 설계, 전 영역 구현, 코드 리뷰 및 기술 의사결정 총괄 |
| 팀 구성 | 프론트엔드 누적 기여자 20명+ (동시 4~6명), 백엔드·디자인·마케팅 협업 |
| 규모 | 어드민 9개 도메인 48개 화면 · API 모듈 24개 · 약 83,000 LOC |
| 기술 스택 | Next.js 15 (App Router), React 19, TypeScript 5, Tailwind v4, Recoil, Ant Design 5, 자체 디자인 시스템, axios, Docker |
| 서비스 성격 | 사용자 사이트(공개 페이지)와 관리자 콘솔을 하나의 Next.js 앱에서 서빙하는 B2B2C SaaS |
| 서비스 | presslearn.co.kr (멀티테넌트 LMS 인스턴스) |
초기에는 단일 강의 판매 사이트였으나, 동일한 구조의 몰을 여러 고객사에 제공하는 B2B2C 모델로 확장하는 것이 사업 방향이었습니다. 이 전환에서 프론트엔드의 핵심 과제는 두 가지였습니다.
이 두 가지가 이후 2년 7개월의 모든 기술 의사결정의 기준이 되었습니다.
몰마다 코드베이스를 복제하는 방식은 초기에는 빠르지만, 공통 기능 수정이 몰 수만큼 반복되고 몰별로 코드가 갈라지면서 어느 몰에 어떤 수정이 들어갔는지 추적 불가능한 상태로 수렴합니다. 동시에 각 몰은 SEO와 브랜딩상 독립 도메인을 써야 했고, 디자인·메뉴 구성도 전부 달랐습니다.
| 후보 | 검토 결과 |
|---|---|
서브패스 (/mall-a/...) | 독립 도메인 요구와 충돌. SEO상 각 몰이 별도 사이트여야 함 |
| 빌드 타임 분기 | 몰마다 별도 빌드·이미지 필요 → 배포 파이프라인이 몰 수에 비례해 증가 |
| HTTP 헤더 (채택) | 프론트는 도메인만 교체, 백엔드는 헤더 하나로 분기. API 계약이 단일하게 유지됨 |
NEXT_PUBLIC_* 환경변수는 빌드 시점에 번들에 인라인됩니다. 그대로 쓰면 몰마다 별도 빌드가 필요해져 ①의 이점이 사라집니다.
→ /api/config Route Handler로 런타임에 내려주고, axios 인터셉터가 모듈 스코프에 캐싱하도록 구현했습니다.
process.env를 직접 읽어 오버헤드 없음
로고·컬러셋·헤더/푸터 메뉴·약관·SEO 설정·외부 서비스·하단 모바일 메뉴를 백엔드에서 내려받아 루트 레이아웃이 조립하도록 설계했습니다.
Ant Design ConfigProvider의 colorPrimary·colorLink까지 몰 설정값을 주입해 디자인 토큰 수준에서 테넌트별 브랜딩이 적용됩니다.
루트 레이아웃의 6개 설정 fetch는 Promise.all로 병렬화해 TTFB 영향을 최소화했습니다.
몰을 관리하는 몰(거래처 관리)이 필요해, 메뉴 정의에 forMaster 플래그를 두어 동일한 어드민 코드에서 권한 계층을 분리했습니다.
별도 앱을 만들지 않은 이유는, 관리 화면의 90%가 동일해 두 벌 유지 비용이 이득을 넘기 때문입니다.
NEXT_PUBLIC_MALL_ID, HOST) 주입으로 축소몰 운영자가 배너 하나, 쿠폰 하나를 바꾸려고 개발팀에 요청하면 개발팀이 사업 확장의 병목이 됩니다. 몰이 늘어날수록 이 비용은 선형 이상으로 증가합니다.
기능을 상상해서 만들지 않고, 실제로 들어온 개발 요청을 분류해 그것을 전부 어드민으로 흡수하는 방향으로 범위를 잡았습니다.
| 도메인 | 주요 화면 |
|---|---|
| 기본관리 | 몰 기본정보, 약관/개인정보처리방침, 외부 서비스 설정, 외부 코드 설정, SEO 설정, 강사관리, 결제관리, 어뷰징 관리, FAQ |
| 회원관리 | 회원 리스트, 회원 상세, 회원 등급 |
| 마일리지 | 기본설정, 사용설정, 지급설정, 지급/차감 내역 |
| 알림 | 카카오 알림톡 설정·템플릿·발송내역, 자동메일 설정·템플릿·발송내역 |
| 클래스 | 카테고리 관리, 클래스 리스트/등록, 차수 관리 |
| 프로모션 | 쿠폰, 출석체크, 진척률 프로모션 |
| 게시판 | 게시판 관리, 게시글 리스트, 베스트 리뷰, 상단 고정 게시글 |
| 디자인 | 상단/푸터/모바일 하단 메뉴, 메인 배너, 랜딩페이지 |
| 매출 | 매출 대시보드, 요금 청구 |
48개 화면을 파일 라우트로 쪼개면 세그먼트마다 레이아웃·권한 가드·브레드크럼·사이드메뉴가 중복됩니다.
app/admin/[pages]/[detail] 한 파일이 {'경로/상세': <Component/>} 맵으로 분기하도록 설계해,
셸을 한 곳에 두고 화면 추가를 등록 3줄로 줄였습니다.
트레이드오프를 명확히 인지하고 대응했습니다 — 라우트 단위 코드 스플리팅이 자연스럽게 되지 않고, 미등록 경로가 조용히 대시보드로 폴백됩니다. 이를 숨기지 않고 "어드민 화면 추가 시 반드시 함께 고쳐야 할 3곳"을 온보딩 문서에 명문화해 팀 실수를 방지했습니다.
사용자 페이지는 SEO 때문에 서버 컴포넌트 + Next 캐시 태그(next: { tags, revalidate })로 캐싱합니다.
그대로 두면 어드민에서 저장해도 사용자 화면에 반영이 지연됩니다.
→ 각 서버 액션 파일에 revalidateTag 무효화 함수를 짝으로 정의해, 어드민 저장 시점에 해당 태그만 정확히 무효화하도록 설계했습니다.
캐시 성능과 운영 즉시성의 충돌을 태그 단위 무효화로 해소한 사례입니다.
| 경로 | 용도 | 이유 |
|---|---|---|
| 클라이언트 axios (4개 인스턴스 + 인터셉터) | 인터랙션 데이터, 어드민 CRUD | 토큰 자동 주입, 갱신 큐잉 필요 |
Server Actions ('use server') | SEO/SSR 필요 데이터 | 쿠키 직접 접근 + Next 캐시 태그 활용 |
섞어 쓰면 인증과 캐시가 어긋납니다. 이 경계를 문서로 고정하고, 특히 notLoginedApi라는 이름과 달리
토큰이 있으면 자동으로 실어 보낸다는 함정을 명시했습니다.
강의 판매몰의 매출은 랜딩페이지 실험 속도에 직결됩니다. 그런데 랜딩 한 장마다 개발자가 붙으면 실험 사이클이 주 단위가 되고, 마케팅팀이 아이디어를 검증할 수 없습니다.
처음부터 무한 자유도의 빌더를 만드는 선택지도 있었지만, 두 가지 이유로 배제했습니다.
대신 운영 중인 실제 랜딩페이지를 역분석해 반복되는 블록(메인 슬라이드, 그리드 배너, 타이틀, 2/3단 그리드, 강의 카드, 후기 슬라이드, 앵커, 타이머 배너, DB 수집 폼 등)을 추출하고, 블록마다 시각 컴포넌트 + 입력폼 쌍을 두었습니다.
자유도를 제한하는 대신 이 기준을 즉시 만족시키는 것을 목표로 했습니다. 실사용 데이터가 쌓여야 2차의 요구사항이 검증된다고 판단했고, 실제로 1년간의 사용 패턴이 2차 설계의 근거가 되었습니다.
타이머 배너·무료강의 신청·DB 수집 폼 블록이 직접 purchase / purchase_livefree dataLayer 이벤트를 push하도록 구현했습니다.
→ 마케터가 만든 페이지가 자동으로 계측되는 상태가 됩니다. 계측을 나중에 붙이면 반드시 누락되고, 누락된 기간의 데이터는 복구되지 않습니다.
블록별 dynamic import로 에디터 초기 번들을 분리해, 블록 수가 늘어도 초기 로딩이 비례해 무거워지지 않도록 했습니다.
강의 판매몰은 검색 유입과 광고 전환 측정이 곧 매출입니다. 그런데 몰마다 광고 대행사·픽셀 계정·태그 구성이 전부 다릅니다. 몰이 늘 때마다 스크립트를 코드에 추가하는 방식은 지속 불가능합니다.
generateMetadata에서 강의 상세 > 랜딩페이지 > 몰 기본 SEO 설정 우선순위로
title/description/OG/Twitter/keywords/canonical을 조립했습니다.
→ 어드민에서 강의별로 채우지 않아도 몰 기본값으로 항상 유효한 메타가 나가고, 채우면 정밀해집니다.
"안 채우면 빈 메타"가 되는 설계는 실무에서 반드시 빈 메타를 만듭니다.
robots: noindex로 분리구 사이트에서 이전할 때 URL이 바뀌면 그동안 쌓은 검색 랭킹과 백링크 자산이 통째로 소실됩니다. 미들웨어에서 게시판/게시글/클래스/랜딩/회원가입/마이페이지 등의 구 URL을 매핑 테이블로 처리했습니다.
x-pathname / x-url / x-search 헤더를 주입 — 서버 컴포넌트가 현재 URL을 읽는 유일한 통로GA4 / GTM / Meta Pixel / Naver 서치어드바이저 / Google Search Console / 카카오 플러스친구를 서비스 코드 기준으로 스위칭 렌더하고, 그 외 임의 스크립트는 "외부 코드 설정"에서 head/body 위치와 적용 경로(전체/특정 URL)를 지정해 주입하도록 설계했습니다. → 몰마다 다른 마케팅 스택을 코드 수정 없이 연동.
자사 컨테이너와 광고 대행사 컨테이너가 공존하는 것이 실무 상황입니다.
콤마 구분 입력을 파싱해 컨테이너별로 별도 dataLayer(dataLayer, dataLayer2…)를 분리 초기화했습니다.
하나를 공유하면 서로의 이벤트가 섞여 대행사 리포트가 오염됩니다.
useRef 가드로 중복 init 방지, 로그인 상태 변화 시에만 사용자 데이터 갱신
page_view → begin_checkout → purchase로 퍼널을 정의하고, 무료/쿠폰 전액할인 케이스를 별도 이벤트로 분리했습니다.
0원 전환을 같은 purchase로 보내면 ROAS가 왜곡되어 광고 알고리즘이 잘못된 방향으로 학습하기 때문입니다.
보유 강의 목록을 별도 이벤트로 push해 리타게팅 제외 세그먼트를 만들 수 있게 했습니다.
카카오 로그인 같은 OAuth 리다이렉트를 거치면 UTM 파라미터가 유실되어 어트리뷰션이 끊깁니다.
UTM을 sessionStorage에 보존하고 카카오 로그인 state 파라미터에 실어 왕복시켜,
회원가입/결제 전환까지 캠페인 정보가 이어지도록 했습니다(중첩 url 파라미터 안의 UTM까지 파싱).
"왜 지금 하는가"가 이 작업의 핵심 근거였습니다. React 19 대응이 늦어질수록 Ant Design·자체 디자인 시스템·서드파티 생태계가 먼저 올라가버려, 이후 업그레이드 비용이 기하급수로 증가합니다. 부채가 커지기 전에 처리한다는 판단으로 일정을 확보했습니다.
| 이슈 | 원인 | 대응 |
|---|---|---|
| Recoil이 React 19에서 SSR 크래시 | Recoil 0.7.7이 React가 제거한 내부 API를 참조 | patch-package + postinstall로 패치 고정. 동시에 아카이브 라이브러리 리스크를 인지하고 사용처 41개 파일 규모를 근거로 Jotai/Zustand 이전을 중기 과제로 문서화 |
| params/searchParams의 Promise화 | Next 15 breaking change | props를 통째로 하위에 내리는 기존 패턴을 전부 고치는 대신 진입점에서 한 번 언랩해 기존 형태로 재조립 → 변경 범위 최소화 |
/signup 페이지 전체 크래시 |
루트 레이아웃이 headers()를 쓰면서 앱 전체가 dynamic 렌더로 전환 → Next 14에서 서버 실행되지 않던 클라이언트 서브트리가 서버 렌더되며 window is not defined |
원인 규명 후 "렌더 본문에서 window 참조 금지"를 규칙화 |
| 파일 업로드 시 413 Body exceeded 1MB | AntD Upload가 action/customRequest 없이 현재 URL로 자동 POST하고, Next 15에서 이게 Server Action으로 처리되며 1MB 제한에 걸림 | customRequest로 stray POST 차단. "안전한지 판별하는 기준"까지 문서화(조건부 LIST_IGNORE 반환은 여전히 취약함을 명시) |
| 프로덕션에서만 색상이 깨지는 버그 | 디자인 시스템과 앱의 Tailwind가 동일 유틸을 중복 정의하는데 한쪽에만 !important가 있고, 프로덕션 CSS minifier가 규칙을 병합하며 뒤 선언을 남김 → !important 소실. dev는 병합이 없어 재현 불가 | globals.css import 순서로 해결하고 재현되지 않는 이유까지 문서화 |
| AntD 커스텀 스타일 미적용 / lint 크래시 / ref deprecated 경고 | @ant-design/cssinjs, es-iterator-helpers, rc 계열 패키지의 중복 설치 | dedupe 절차 확립 및 문서화 |
기여자가 누적 20명을 넘어가면서 "내가 아는 것"과 "팀이 아는 것"의 격차가 사고로 이어지는 국면에 들어섰습니다. 특히 이 레포에는 모르면 반드시 사고가 나는 항목들이 있었습니다(멀티테넌트 구조, 데이터 페칭 2경로 혼용, CSS import 순서, AntD Upload 자동 POST).
모든 변경을 원인/이유 + 수정 형식으로, 코드 변경과 같은 커밋에 누적 기록하도록 규칙화했습니다(현재 970줄).
루트에 전역 요약, Studio 하위에 별도 상세 가이드를 두어 관련 없는 작업에 Studio 세부사항이 로드되지 않도록 컨텍스트를 분할했습니다. 팀의 AI 도구 활용 시 불필요한 컨텍스트가 정확도를 떨어뜨리는 문제를 구조로 해결한 사례입니다.
작업 브랜치 네이밍(feat/ fix/ refactor/ chore/), 커밋 메시지 형식, Prettier/ESLint 설정 정착.
ESLint의 exhaustive-deps가 비활성이라는 사실을 "도구가 잡아주지 않으니 직접 확인해야 한다"고 문서에 명시해, 설정의 한계를 팀이 인지하도록 했습니다.
| 기간 | 2023.11 ~ 진행 중 (2024.04 오픈 후 고도화·운영) · 더블티 재직 시 착수해 로워드 이직 후 연속 수행 |
|---|---|
| 역할 | 프론트엔드 리드 — 아키텍처 설계부터 운영까지 전담 |
| 규모 | 61개 라우트 · Container 74 / Presenter 74 / Styled 111 파일 |
| 기술 스택 | Next.js 14 (App Router), React 18, TypeScript, styled-components, Recoil, NGINX, AWS(EC2·S3·CloudFront·Route53), pm2 |
| 서비스 성격 | 키워드 검색량·경쟁도를 조회하고 분석 리포트를 제공하는 B2C 마케팅 데이터 SaaS |
| 서비스 | loword.co.kr |
검색 유입이 매출의 핵심인 서비스인데 CRA 기반 CSR 구조라 크롤러가 빈 HTML을 수신했습니다. 이 작업을 "SSR 도입"이 아니라 데이터가 클라이언트로 넘어가는 경계를 다시 긋는 작업으로 접근한 것이 이후 모든 설계의 출발점이 되었습니다.
'use client'가 상위로 번지면 서버 컴포넌트 도입의 이점이 조용히 사라집니다.
이건 문서로 막히지 않는다고 판단해 Container(서버) · Presenter(클라이언트) · Styled 3-파일 컨벤션으로 분리했습니다(74 / 74 / 111 파일).
경계를 지키는 일이 개인의 주의력이 아니라 파일을 만드는 절차 자체에 들어가게 하는 것이 목적이었습니다.
QAPage + acceptedAnswer를 선언하면 구조화 데이터 오류로 리치 결과 자격 자체를 잃습니다. 조건을 만족하지 못하면 Review로 폴백하도록 분기______로 지정 — 제목에 하이픈이 흔히 포함되므로 일반적인 - 구분자는 역파싱을 깨뜨립니다. 제목 내 문자와 충돌하지 않으면서 역파싱 가능한 구분자를 선택& 하나로 사이트맵 전체가 무효화됩니다. 이스케이프 처리를 사이트맵 생성의 필수 단계로 고정UGC 본문은 유료 콘텐츠라 비로그인 사용자에게 전부 보여주면 전환이 발생하지 않습니다. 반대로 크롤러에게 가려서 내보내면 본문이 색인되지 않아 롱테일 검색 유입이 사라집니다. 전환율과 색인이 정면으로 상충하는 구조였습니다.
| 방안 | 기각 사유 |
|---|---|
| CSS blur / overlay 처리 | 소스 보기로 즉시 뚫림. 시각적 가림일 뿐 데이터는 이미 전송됨 |
| 클라이언트에서 문자열 slice | 자르는 시점에 원본이 이미 브라우저에 도착해 있으므로 무의미 |
| 서버 컴포넌트에서 봇 판별 후 분기 (채택) | 잘라낸 데이터가 RSC 페이로드에 바이트 단위로 존재하지 않음 |
userAgent() 봇 판별을 서버 컴포넌트에서 수행 — 비로그인 사용자에겐 서버 단에서 잘라 내려보내고, 크롤러에겐 전문 제공<p> 5개로 둔 이유 — 문자 수로 자르면 태그 중간이 끊겨 마크업이 파손됩니다Promise.all로 묶어도 "가장 느린 것"을 기다리는 성질은 변하지 않으므로, 페이지를 22개 Parallel Route 슬롯으로 물리적으로 분해해 블록 단위로 독립 스트리밍되게 했습니다force-dynamic을 택한 이유 — 검색 결과가 사용자·쿼리·플랜별로 달라지므로 정적 캐시는 오답을 서빙합니다LoadingAnswer 전용 컴포넌트로 만든 이유 — 답변 개수를 이미 알고 있어 자리를 미리 잡으면 CLS가 0이 됩니다kinAnswerDetail_{id})와 revalidateTag 37개소로, 상호작용 1건에 페이지 전체가 재검증되던 비용을 변경된 리소스로 한정applySetCookie의 필요성 — 미들웨어가 Set-Cookie를 실어도 같은 요청의 서버 컴포넌트는 옛 쿠키로 fetch하므로 첫 화면이 로그아웃 상태로 렌더됩니다. 갱신된 토큰을 요청 헤더에 역주입해 같은 사이클 안에서 반영되도록 처리tokens && !userInfo?.userId 조건이 없으면 같은 세션에서 이벤트가 반복 발화되어 전환 수가 부풀려집니다. 소셜 채널별로 login 이벤트를 분리 계측next/image 최적화 대상에서 이탈합니다키워드 분석 결과는 지표 종류가 많아 한 화면의 정보 밀도가 높습니다. 숙련 사용자에게는 다 보이는 편이 유리하지만, 그렇지 않은 사용자에게는 무엇을 먼저 봐야 할지 알 수 없는 화면이 됩니다. 항목을 줄이면 그 지표를 쓰던 사용자를 잃으므로, 정보의 양이 아니라 우선순위가 화면에 드러나지 않는 것이 문제라고 보고 노출 여부가 아닌 배치와 시각적 위계로 접근했습니다.
| 항목 | 수치 |
|---|---|
| 라우트 | 61개 |
| 3-파일 컨벤션 적용 | Container 74 / Presenter 74 / Styled 111 파일 |
| 구조화 데이터 | JSON-LD 13종 |
| 스트리밍 분해 | Parallel Route 슬롯 22개 |
| 캐시 무효화 지점 | revalidateTag 37개소 |
| 웹 성능 | Lighthouse 평균 40 → 94 |
| 기간 | 2026.05 ~ 진행 중 |
|---|---|
| 역할 | 도입 제안 및 컨텍스트 체계 설계 주도 |
| 범위 | 개발팀 전체(프론트엔드·백엔드) · 러닝 프로젝트 4개 |
| 도구 | Claude Code 팀 플랜 · Markdown 기반 규칙 레포 |
| 배경 | 개인별 사용은 있었으나 팀 단위 도입은 없던 상태 |
저는 처음부터 AI 활용에 긍정적이지 않았습니다. 자신이 작성한 코드는 명확히 알고 있어야 하고, 내가 모르는 코드는 없는 편이 낫다고 생각했기 때문입니다. 실제로 신입·팀원 코드를 리뷰하며 "왜 이렇게 구현했는지"를 물었을 때 답하지 못하는 경우를 반복적으로 확인했습니다.
같은 내용을 물어도 어떻게 묻느냐에 따라 답의 품질이 천차만별이었습니다. 검토할 만한 답을 받으려면 질문의 질이 먼저 올라가야 한다고 보고, 개인 차원에서 두 가지를 시도했습니다.
그러나 두 방법 모두 한계가 분명했습니다. 개인 코칭은 인원 수에 비례해 확장되지 않고, 무엇보다 새 팀원이 합류할 때 그 지식 베이스가 이전되지 않습니다. 같은 설명을 매번 처음부터 반복하게 됩니다.
검토 시점에 좋다고 평가되는 도구는 이미 많았고, 모델 성능은 상향 평준화되어 선정의 결정 요인이 아니라고 판단했습니다. 그래서 기준을 두 가지로 세웠습니다.
| 기준 | 이유 |
|---|---|
| 팀 단위 컨텍스트 공유가 가능한가 | 각자의 프롬프트로 각자 학습하면 축적물이 개인에게 남습니다. 학습된 컨텍스트도 회사의 자원이라고 보았기 때문에, 하나의 컨텍스트를 팀이 공유할 수 있어야 했습니다 |
| 토큰 비용을 통제할 수 있는가 | 도입 제안이 승인되려면 비용 구조를 먼저 설계해 둬야 합니다. 통제 수단이 없으면 사용량이 늘수록 도입 자체가 재검토 대상이 됩니다 |
이 기준으로 Claude Code 팀 플랜을 채택했습니다. 하나의 컨텍스트에 개발팀의 문화·규칙·컨벤션을 학습시켜 두면, 신규 합류자가 시트를 할당받아 쓰는 순간부터 팀 기준을 그대로 이어받고 질문 방식에도 최소한의 안전선이 생긴다고 판단했습니다. 개인 코칭으로는 만들 수 없었던 인수인계 경로가 도구 자체에 생기는 셈입니다.
규칙을 한 파일에 몰아넣으면 관련 없는 작업에도 전체가 로드되어 비용이 발생하고, 정확도도 떨어집니다. 반대로 흩어 놓으면 어디를 봐야 할지 알 수 없습니다. 그래서 규칙의 적용 범위와 로드 단위를 일치시키는 방향으로 계층을 나눴습니다.
| 계층 | 담는 내용 | 로드 범위 |
|---|---|---|
| 전사 개발 공통 | 커밋 메시지·브랜치 네이밍 규칙, "원인/이유 + 수정" 변경 이력 포맷, 문서 작성 기준 | 최초 1회 조회 후 유지 — 갱신되지 않으면 재조회 비용이 발생하지 않도록 구성 |
| 팀별(FE / BE) | FE: Container/Presenter 3파일 컨벤션, 'use client' 경계, 데이터 페칭 2경로 사용 기준, cn()·tv() 스타일 규칙, CSS 레이어 서열, 타입 Subset 판단 기준, 네이밍(I·T·LD) | 해당 팀에서만 조회 |
| 프로젝트별(4개 레포) | 레포마다 다른 특성과 주의점 — 어드민 화면 추가 시 함께 고쳐야 할 3곳, globals.css import 순서, 캐시 태그·웹훅 접두사 규약, 소켓 갱신 2방식 판단 기준 등 | 해당 레포 작업 시에만 |
| 서브시스템별 | 레듀 안의 Studio는 스키마 정의와 노드 구조가 복잡해 전체를 훑는 비용이 컸음 — 노드 추가 프로토콜, unified / device-aware 필드 분류, 드롭 규칙, 렌더 경로 불변식을 별도 문서로 분리 | Studio 작업 시에만 |
Studio를 따로 뺀 것이 이 설계의 요지를 잘 보여줍니다. Studio 가이드는 레듀 작업의 대부분에는 필요하지 않은데 분량은 가장 큰 문서였습니다. 공통 파일에 합치면 무관한 작업마다 그 비용을 내게 되므로, 파일을 세분화하는 것 자체가 곧 비용 설계였습니다.
각 계층의 문서는 도입에 맞춰 새로 작성했고, 이후 프로젝트가 진행되는 과정에서 계속 갱신하고 있습니다. Studio 가이드도 도입 이후 Phase 2를 진행하며 노드·필드 규약이 늘어난 만큼 함께 보강했습니다.
아래 6가지는 로워드 FE 팀의 기준으로 제가 정리해 공개한 글의 항목입니다.
각 항목이 실제 어떤 결정으로 나타났는지 위 프로젝트에서 대응되는 사례를 함께 적었습니다.
원문 — 로워드 FE 팀으로 일을 한다는 것은?
기능을 받으면 구현부터 시작하지 않고, 왜 필요한지와 전체 흐름에서 어떤 위치인지부터 이해하려고 합니다. 레듀 어드민의 범위는 상상해서 정하지 않고 실제로 들어온 개발 요청을 분류해 역산했고(4-2), 1차 페이지 빌더의 블록은 운영 중인 랜딩을 역분석해 추출했으며(4-3), 디자인 시스템은 레거시 컴포넌트를 전수 조사해 무엇을 담지 않을지부터 기준을 세웠습니다(3-11). 브릿지에서 라우트 그룹 분리가 아니라 배포 단위 분리를 택한 것도, 요구가 "코드 정리"가 아니라 "장애 영향도 축소"임을 먼저 확정했기 때문입니다(2-1).
같은 것을 여러 번 만들지 않고 한 번 잘 만들어 계속 쓰는 구조를 우선합니다. 몰마다 코드를 복제하는 대신 테넌트를 데이터로 분리했고(4-1), 5개 앱의 공통 UI는 모노레포와 2계층 단방향 의존으로 수렴시켰으며(2-2), 그 기준을 코드와 연결된 디자인 시스템으로 고정했습니다(3장). 빌더에서도 같은 문제가 나타나 페이지가 정의를 복사하지 않고 참조만 저장하는 모델로 전환했습니다(1장 Phase 2).
같은 결과를 내는 방법은 늘 여러 개라, 무엇을 선택했는지보다 왜 선택했는지를 설명할 수 있어야 한다고 생각합니다.
이 문서의 각 절에 채택안과 함께 검토하고 기각한 대안을 남긴 이유입니다 —
테넌트 식별 3안 비교(4-1), 복사 vs 참조(1장 Phase 2), 캐시 태그를 앱 경계가 아닌 데이터 경계로 둔 근거(2-3),
콘텐츠 보호 3안 비교(5-3), colorSet 확장점 2안 비교(3-9).
tv()를 직접 만들 때는 성능을 실측하고 기존 라이브러리와 108개 조합을 전수 비교한 뒤에야 도입했습니다(3-3).
감수한 한계도 같이 적습니다 — extend·slots 미지원, 어드민 라우팅의 코드 스플리팅 손실, 브랜치 분기 문제.
같은 실수가 반복되면 개인이 아니라 구조나 프로세스의 문제로 봅니다.
접근성 위반이 계속 쌓이던 원인이 검사가 todo였다는 데 있어 CI 실패 조건으로 승격했고(3-2),
모바일 override가 조용히 무시되던 버그는 필드를 unified / device-aware로 명시 분류하도록 강제해 차단했습니다(1장 ④).
타입으로 막을 수 있는 것은 타입으로 옮기고(렌더러 레지스트리 exhaustive 타이핑), 막을 수 없는 것은 규약과 문서로 남깁니다.
재현이 어려운 사고일수록 원인과 경위를 기록해 다음 사람이 같은 곳에서 헤매지 않게 합니다 — RSC 번들 오염, CSS 캐스케이드 2종, 프로덕션 전용 !important 소실.
기술은 목적이 아니라 도구라고 생각해서, 필요하면 적극적으로 도입하고 필요하지 않으면 바꾸지 않습니다.
디자인 시스템이 확정되기 전이라 npm publish 사이클을 감당할 수 없다고 판단해 workspace 링크를 택했고(2-2),
배포 패키지에 의존성을 얹지 않기 위해 스타일 엔진을 직접 만들되 기능 축소를 대가로 명시했습니다(3-3).
반대로 Next 16 메이저 점프는 이득 대비 리스크가 크다고 보고 제외했고(2-12),
화면 갱신도 구조 변경 비용이 큰 클라이언트 페칭 대신 router.refresh()를 기본으로 두고 필요할 때만 전환하도록 기준을 정했습니다(2-4).
작업 전에 방향을 맞추는 시간이 결국 전체 시간을 줄인다고 생각합니다. 백엔드와는 캐시 태그·웹훅 요청 규격·소켓 이벤트명을 합의하고 태그 추가 시 양쪽에 반영하는 프로토콜을 만들었고(2-14), 마케팅·데이터팀에는 전환 이벤트를 재설계하기 전에 "기존 지표와 연속선상에서 해석 불가"라는 영향도를 먼저 공유했습니다(2-7). 팀 안에서는 변경 이력을 "원인/이유 + 수정" 형식으로 남겨 작성자가 아닌 사람이 맥락을 잡을 수 있게 하고, 코드를 읽으면 알 수 있는 것이 아니라 모르면 사고가 나는 것 중심으로 온보딩 문서를 씁니다(4-6, 2-14).