이정호

경력기술서 · Frontend Engineer / 프론트엔드 팀 리더

Emailljhmoa@gmail.com GitHubhttps://github.com/wjdgh4058 Phone010-8869-4058

경력 요약

회사기간직위 / 담당
주식회사 로워드2025.01 ~ 재직 중프론트엔드 팀 리더 — 아키텍처 설계, 전 영역 구현, 코드 리뷰 및 기술 의사결정 총괄
주식회사 더블티2023.08 ~ 2025.01 (1년 6개월)프론트엔드SW 개발팀 연구원 — 로워드·레듀 서비스 프론트엔드 초기 구축

※ 로워드 키워드 분석 플랫폼과 레듀 LMS는 더블티 재직 시 착수해 2025.01 로워드 이직 후 연속 수행한 프로젝트로, 아래 프로젝트 단위로 통합 기술합니다.

프로젝트 요약

Studio — 노코드 페이지 빌더 (2차)  (2025.06 ~ 진행 중) · 설계·구현 단독상세 → 1장
브릿지(Bridge) — 웹호스팅 SaaS  (2025.08 ~ 진행 중) · 프론트엔드 팀 리드상세 → 2장
@loword/loword-design-system  (2026.03 ~ 진행 중) · 설계 총괄 / 팀 리드상세 → 3장
레듀 — 멀티테넌트 LMS / 강의 판매 플랫폼  (2024.01 ~ 진행 중) · 프론트엔드 리드상세 → 4장
로워드(loword.co.kr) — 키워드 인텔리전스 플랫폼  (2023.11 ~ 진행 중) · 프론트엔드 리드상세 → 5장
팀 AI 개발 환경 도입 및 컨텍스트 체계 설계  (2026.05 ~ 진행 중) · 도입 제안·설계 주도상세 → 6장

1. Studio — 노코드 페이지 빌더 (2차)

기간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 ~ )편집 모드 분리, 재사용 컴포넌트(커스텀 위젯) 시스템, 영속 계층 및 백엔드 전환 준비

Phase 1 — 표현 모델 재설계와 편집기 구축

배경 및 문제

블록 조립형이었던 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) → 시각 컴포넌트

③ 반응형 — 미디어 쿼리가 아닌 컨테이너 쿼리

문제 — 빌더의 모바일 모드는 뷰포트가 아니라 캔버스 폭을 375px로 제한합니다(브라우저 창은 그대로). 따라서 @media (max-width: 768px)는 프리뷰에서 절대 발동하지 않아 WYSIWYG이 원천적으로 불가능합니다.

해결 — 컨테이너 쿼리(@container) 채택. 375px 프레임을 컨테이너로 만들면 프리뷰에서 정확히 발동하고, 실제 페이지에서는 래퍼에 container-type: inline-size를 주면 동일하게 동작합니다.

④ unified vs device-aware — 침묵 버그의 구조적 차단

실제 겪은 버그 — 모바일 모드에서 위젯 설정을 바꿨는데 화면이 무반응. 값은 정상 저장됨. 원인은 렌더가 그 필드의 모바일 override를 읽지 않기 때문이었습니다.

이건 개별 버그가 아니라 모델의 구멍이었습니다. device-aware가 기본값인데 렌더가 지원하지 않는 필드가 그 기본값을 받으면 반드시 이 증상이 납니다. 따라서 모든 필드를 두 부류 중 하나로 명시 분류하도록 강제했습니다.

분류정의
device-aware (기본)렌더가 실제로 그 필드로부터 @container 규칙을 생성하는 경우만
unified렌더가 base만 읽는 공통 설정 — 읽기·쓰기를 모두 base로 강제해 어느 모드에서 편집하든 동일 동작

"위젯 필드 추가 시 이 분류를 의도적으로 결정할 것"을 가이드에 명문화했습니다. 기본값을 그냥 쓰면 조용히 망가지는 종류라, 코드가 아니라 프로세스로 막아야 한다고 판단했습니다.

⑤ undo/redo — "사용자의 한 동작 = 한 번의 undo"

⑥ 드롭 규칙 — 스키마 정합성을 규칙으로 강제

규칙이유
gridCell은 같은 그리드 내 형제 셀의 before/after만다른 그리드로 이동하면 양쪽 셀 개수가 동시에 깨짐
일반 노드가 셀을 대상으로 하면 inside만셀의 형제가 되면 셀 개수 ≠ cols × rows
grid의 직접 inside 금지grid의 자식은 반드시 셀
inside는 container/gridCell만무의미한 트리 방지
자기 자신의 자손으로 드롭 금지트리 순환 = 렌더 무한 재귀

삭제도 타입별로 의미를 다르게 정의했습니다. gridCell 삭제는 제거가 아니라 초기값 리셋 — 셀이 사라지면 그리드가 깨지므로 사용자의 "지우기" 의도를 "내용 비우기"로 번역했습니다.

⑦ 클립보드 — 복사 대상의 의미론

가장 까다로웠던 것은 "gridCell을 복사하면 무엇을 붙여넣어야 하는가"였습니다. 셀 자체를 복제하면 정합성이 깨지므로 셀의 내용(children)을 복제 대상으로 삼고, 대상 컨텍스트에 따라 셀 내부 append / 형제 삽입 / 루트 추가로 분기했습니다. 붙여넣을 때마다 서브트리 전체에 새 uuid를 재발급하고(id 충돌 = 선택·렌더 전부 오작동), 붙여넣은 노드를 자동 선택해 바로 편집 가능하도록 했습니다.

⑧ WYSIWYG 카드 템플릿 에디터 — 폭에 무관한 편집

카드 안의 슬롯(이미지/제목/부제목/설명)을 자유 배치하는 기능으로, 핵심 문제는 편집 화면과 실제 페이지의 카드 폭이 다르다는 것이었습니다.

요소방식이유
슬롯 좌표카드 대비 %픽셀 저장 시 폭이 달라지면 배치가 무너짐
카드 높이aspect-ratio: 기준폭 / cardHeight폭이 무엇이든 비율로 높이 결정
텍스트 크기cqw (카드가 container)카드 폭에 비례해 스케일
모바일 폰트기준폭을 바꿔 cqw 재계산비례만 하면 좁은 폭에서 읽을 수 없게 작아짐
모바일 기본 배치PC와 별도 상수모바일은 폰트가 상대적으로 커서 같은 좌표면 슬롯이 겹침

편집기와 실제 렌더가 동일한 헬퍼 모듈을 공유하도록 모듈 경계를 강제했습니다(한쪽만 고치면 편집 결과와 출력이 갈라짐). 편집 모달은 로컬 draft + 자체 undo/redo를 갖고 저장 시에만 커밋해 취소가 무손실이며, 모달 내부 ⌘Z가 전역 undo로 새어나가지 않습니다. 히스토리 항목이 당시 보던 슬라이드 인덱스를 함께 기억해 undo 시 화면이 튀지 않도록 했습니다.

⑨ 두 개의 CSS 캐스케이드 함정 규명

(1) !important의 레이어 우선순위 역전
(2) 중첩 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 성과

Phase 2 — 재사용 컴포넌트 시스템 (커스텀 위젯)

배경 및 문제

Phase 1로 표현력 문제는 해결됐지만, 운영이 시작되자 다른 종류의 병목이 드러났습니다.

진단 — 1차 빌더가 "개발자 병목"이었다면 2차 운영의 병목은 복제된 콘텐츠의 동기화였습니다. 디자인 툴의 컴포넌트/심볼, 프론트엔드의 컴포넌트 개념이 해결하는 것과 정확히 같은 문제이고, 노코드 환경에서도 같은 답이 필요하다고 판단했습니다.

핵심 설계 결정 — "복사가 아니라 참조"

이 기능 전체가 단 하나의 결정 위에 서 있습니다. 페이지는 위젯의 내용을 저장하지 않는다. widgetId 참조만 저장하고, 펼치는 것은 렌더 시점에만 한다.

대안검토 결과
삽입 시 내용을 복사 (스냅샷)구현은 훨씬 쉽지만 동기화 문제가 그대로 남습니다. 이 기능의 존재 이유 자체가 사라짐
참조만 저장 (채택)"위젯 1회 수정 → 사용 중인 모든 페이지 자동 반영"이 성립. 대신 참조 모델 특유의 문제(고아 참조, 순환, 삭제 영향)를 전부 설계해야 함

이 결정을 코드가 지키도록 구조화했습니다.

① 편집 모드 분리 — 하나의 빌더로 두 대상을 편집

라우트mode저장 대상
/admin/studio런처: 페이지 문서 + 커스텀 위젯 목록
/admin/studio/pages/[documentId]pageIStudioDocument (페이지)
/admin/studio/widgets/[widgetId]componentICustomWidget (재사용 노드 그룹)

② 중첩 금지 — 의존성 그래프를 1단계로 고정

커스텀 위젯 안에 커스텀 위젯을 허용하면 순환 탐지, 재귀 resolve, 다단계 캐시 무효화가 전부 필요해집니다. 운영상 요구가 없는 기능에 이 복잡도를 지불할 이유가 없다고 판단해 한 단계로 고정했습니다. 단, "금지"는 UI에서 숨기는 것만으로는 지켜지지 않아 3중 방어로 강제했습니다.

계층방어
UIcomponent 모드에서 LNB 팔레트가 커스텀 위젯 카드를 필터링
클립보드pasteNode가 component 모드에서 customWidget 클립보드를 거부 — UI를 우회하는 가장 명백한 경로
서버insertWidget / updateWidget이 노드 트리를 재귀 검사 후 throw — 최종 방어선

UI만 막고 끝냈다면 클립보드로 뚫렸을 것이고, 서버 검증이 없었다면 API 직접 호출로 뚫렸을 것입니다.

③ 결정적 id 재발급 — 랜덤이면 hydration이 깨진다

cloneNodeWithScopedIds(node, instanceId)  →  `${instanceId}__${originalId}`

④ ReadOnlyNodeContext — "편집 불가"와 "상호작용 불가"는 다르다

펼쳐진 내부 노드는 저장된 스키마에 존재하지 않습니다. 여기에 편집 UI를 붙이면 hover 테두리가 떠서 정작 위젯 자체의 선택 테두리가 묻히고, 선택/삭제 버튼이 보이지만 눌러도 스키마에서 찾을 수 없어 아무 일도 일어나지 않습니다. 그런데 동작까지 죽이면 안 됩니다. 이건 미리보기이므로 버튼은 눌리고, 캐러셀은 넘어가고, 캘린더는 페이지가 넘어가야 합니다. 정확히 두 가지만 꺼야 합니다.

끄는 것방법
편집 chromeBuilderNode가 { chrome: null } 전달
저장될 곳이 없는 편집(더블클릭 인라인 편집, 빈 컨테이너 "요소 추가" 가이드)각 wrapper가 useReadOnlyNode()로 판단해 비활성

후자를 켜두면 동작하는 것처럼 보이다가 조용히 되돌아갑니다 — updateNodeField가 스키마에 없는 노드를 찾지 못하기 때문입니다. 사용자 입장에서 가장 나쁜 종류의 버그입니다.

명시적으로 문서화한 두 함정

컨텍스트를 쓴 이유 — 노드 렌더는 각 ClientWrapper가 재귀로 돌립니다. props로 내리려면 모든 wrapper를 수정해야 하지만, 컨텍스트로 흘리면 RenderNode 한 곳에서만 판단하면 됩니다.

⑤ 삭제 가드 — 참조 모델의 필연적 부작용 대응

페이지가 id만 들고 있으므로, 참조된 위젯을 지우면 그 페이지들에서 경고 없이 사라집니다.

⑥ 위젯 피커 모달 — 두 개의 함정을 정면으로 해결

(1) 명령형 모달을 쓸 수 없었던 이유

디자인 시스템의 LDModal은 명령형이라 내부적으로 createRoot를 호출합니다. 즉 별도의 React 트리가 되어 App Router 컨텍스트가 닿지 않습니다. 피커는 위젯 미리보기를 렌더하는데, 위젯 7종이 전부 useRouter/Link를 사용하므로 invariant expected app router to be mounted로 크래시했습니다. → 선언형 LDModalView를 빌더 트리 안에서 렌더하도록 전환. DOM은 portal되지만 React 트리는 유지되어 모든 컨텍스트가 살아 있습니다. 각 호출부(LNB의 삽입, LIB의 교체)가 open 상태를 소유하고 결과를 onClose로 받는 구조로 정리했습니다.

(2) 썸네일 대신 라이브 미리보기

카드마다 위젯의 실제 노드를 1200px로 렌더한 뒤 카드 폭에 맞춰 scale합니다. → 썸네일이 절대 낡지 않습니다. 성립 조건 두 가지를 별도로 처리했습니다.

여기서는 pointer-events-none이 맞습니다(④와 반대) — 썸네일은 클릭을 카드 선택으로 통과시켜야 하기 때문입니다. 같은 기법이 맥락에 따라 정답이 되기도 오답이 되기도 한다는 점을 문서에 함께 남겼습니다.

(3) 자동 스크린샷 캡처는 검토 후 기각

html2canvas 방식의 자동 캡처를 검토했으나 CORS가 걸린 CDN 이미지, 구글 호스팅 웹폰트, 컨테이너 쿼리 레이아웃, embla의 transform에서 전부 깨집니다. 무엇보다 캡처 실패는 위젯에 대해 거짓말하는 썸네일을 남깁니다. 라이브 렌더는 비용이 들지만 절대 거짓말하지 않습니다. 기각 근거를 문서에 남겨 다음 사람이 같은 검토를 반복하지 않도록 했습니다.

(4) 성능 대응

위젯이 늘어날 것을 전제로 서버 기반 이름 검색 + 페이지네이션을 적용하고, WidgetPreview에 memo를 걸어 검색어 타이핑·카드 선택 같은 목록 무관 상태 변화에 전 카드가 재렌더되지 않도록 했습니다(카드 하나가 노드 트리 전체를 그리므로 비용이 큼). 목록 화면은 nodes를 제외한 가벼운 요약 API를, 피커는 nodes 포함 API를 쓰도록 응답을 용도별로 분리했습니다.

⑦ 백엔드 부재 상황의 영속 계층 설계

백엔드 API가 아직 없는 상태에서 기능을 완성해야 했습니다.

⑧ API 전환 체크리스트 문서화 (108줄)

"함수 몸통만 바꾸면 끝"처럼 보이지만 그때 함께 결정해야 하는 것이 있어서 별도 문서로 남겼습니다.

이 문서를 쓴 이유 — 전환 작업을 제가 하지 않을 수도 있고, 하더라도 몇 달 뒤입니다. "코드에 아직 없는 것"이야말로 인계에서 유실되는 부분이라고 판단했습니다.

⑨ 저장 흐름 — 작업물 유실 방지

⑩ code 노드 — 통제된 escape hatch

노코드 빌더에는 반드시 "여기서만은 직접 짜야 하는" 구간이 생깁니다(외부 임베드, 일회성 마크업). 이를 위해 입력한 HTML을 그대로 렌더하는 노드를 추가했습니다.

⑪ RSC 번들 오염 사고 — 유저 페이지 전체 500

⑫ 타입 수준 exhaustiveness 확보

nodeRenderersRecord<NodeType, …>로 타이핑해, NodeType에 타입을 추가하고 렌더러 등록을 빠뜨리면 컴파일 에러가 나도록 했습니다. Phase 1의 확장 프로토콜에서 "등록을 빠뜨리면 조용히 빈 노드가 된다"가 가장 흔한 실수였는데, 이 중 타입으로 막을 수 있는 절반을 타입으로 옮긴 것입니다.

Phase 2 성과

현재 상태 및 남은 과제

2. 브릿지(Bridge) — 웹호스팅 SaaS

기간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 카탈로그

2-1. 서비스 도메인 분리 — 장애 영향도 축소

배경 및 문제

호스팅 서비스는 결제와 서버 프로비저닝을 다루기 때문에 장애 비용이 큽니다. 단일 앱 구조에서는 랜딩 페이지 수정 배포가 실패하면 결제 콘솔과 로그인까지 함께 중단되고, 어드민의 무거운 화면 하나가 사용자 서비스 성능에 영향을 줍니다. 서비스 성격도 완전히 달랐습니다 — 랜딩은 SEO·정적 렌더링이, 콘솔은 인증·실시간성이 중요하고, 어드민은 색인되면 안 됩니다.

선택과 이유

서비스를 도메인·배포 단위까지 완전히 분리하는 방향을 택했습니다. 단일 앱 내부에서 라우트 그룹으로만 나누는 방식은 코드 경계는 만들어주지만 런타임과 배포를 공유하므로 장애 격리가 되지 않기 때문입니다. 요구사항이 "코드 정리"가 아니라 "영향도 축소"였으므로 프로세스가 분리되어야 했습니다.

구현 및 결과

2-2. Turborepo 모노레포 — 공통 컴포넌트 공유

배경 및 문제

앱을 5개로 분리하자 버튼·인풋·모달·토스트 같은 공통 UI를 서비스마다 중복 구현하게 되었습니다. 더 큰 제약은 디자인 시스템이 아직 완성되지 않은 단계였다는 점 — 컴포넌트 스펙과 디자인 토큰이 계속 바뀌는 시기였습니다.

선택과 이유

공용 컴포넌트를 별도 npm 패키지로 publish하는 방식을 먼저 검토했으나 기각했습니다. 디자인 시스템이 확정되기 전에는 publish → 버전 bump → 5개 앱 install 사이클이 매 변경마다 반복되어 변경 속도를 감당할 수 없기 때문입니다. 대신 Turborepo + pnpm workspace를 선택해 workspace:* 링크로 패키지 수정이 별도 배포 없이 즉시 전파되게 했습니다.

이 구조를 최종형이 아니라 과도기로 설계함 — 공용 패키지는 디자인 시스템이 없는 동안의 임시 수렴점이고, 전사 디자인 시스템이 구축되면 @loword/ui·@loword/nextjs-ui의 컴포넌트 중 디자인 시스템으로 대체 가능한 것을 치환하는 마이그레이션을 전제로 두었습니다. LD* 프리픽스와 2계층 단방향 의존을 규약으로 고정한 것도, 나중에 치환 대상을 기계적으로 식별할 수 있게 하기 위해서였습니다. (전사 디자인 시스템은 3장)
구현

결과 — 5개 앱이 동일한 UI·검증·스타일 기준을 공유하면서도 각자 독립 배포되는 구조를 유지했고, 공용 패키지 수정이 즉시 전 앱에 반영되어 디자인 시스템 형성기의 빈번한 변경을 배포 사이클 없이 흡수했습니다.

2-3. 도메인 간 캐시 무효화 아키텍처 ★

배경 및 문제

도메인을 분리해 장애 격리를 얻은 대가가 캐시에서 나타났습니다.

핵심 과제 — 분리된 도메인들이 서로의 캐시에 개입할 수 있는 경로를 직접 설계해야 했습니다.
선택과 이유
① 태그 규약
② 무효화 3경로
경로트리거역할
서버 액션 revalidateTag프론트가 변경 주체(알림 읽음·삭제, 전화번호 저장)자기 앱 캐시 즉시 무효화. 소켓·웹훅을 기다리지 않음
웹훅 POST /api/revalidate백엔드 상태 변경(프로비저닝 완료, 결제, 파트너 승인)여러 앱에 fan-out — 크로스 도메인 전파의 본체
소켓 이벤트 → router.refresh()실시간 이벤트화면을 다시 그리게 하는 트리거
③ 웹훅 규격화
④ 역할 분담 규약 문서화

"웹훅 = 서버 캐시를 비우는 쪽 / 소켓 = 화면을 다시 그리게 하는 쪽"으로 책임을 명시하고, 둘 중 하나만 있으면 각각 "사용자가 새로고침해야 보임" / "refresh해도 캐시된 옛 데이터가 다시 옴"이 되는 이유를 시퀀스 다이어그램으로 팀에 공유했습니다.

결과 — 독립 배포·독립 캐시 구조를 유지하면서도 한 도메인의 상태 변경이 나머지 서비스 화면에 일관되게 전파되는 경로를 확보했습니다. 전체 사용자 캐시가 무효화되는 사고를 규약으로 차단하고, 새 태그 추가 시 백엔드·프론트 양쪽에 반영하는 프로토콜을 정착시켰습니다.

2-4. Socket.IO 실시간 상태 반영

배경 및 문제

호스팅 인스턴스 생성, 도메인 연결, 워드프레스 설치는 백엔드 비동기 작업으로 수 분이 걸립니다. 사용자가 완료 여부를 알기 위해 새로고침을 반복해야 하는 상태였고, 이는 "결제했는데 아무 일도 안 일어난다"는 문의로 이어집니다.

선택과 이유

폴링이 아니라 소켓을 택한 이유는 완료 시점이 수십 초에서 수 분까지 편차가 커, 짧은 간격 폴링은 서버 부하가 크고 긴 간격은 체감이 나쁘기 때문입니다. 화면 갱신 방식으로는 RSC 구조를 유지하는 router.refresh()를 기본으로 택했습니다 — 클라이언트 페칭으로 전환하면 깜빡임이 없지만 서버 컴포넌트를 클라이언트 컴포넌트로 바꿔야 해 구조 변경 비용이 크기 때문입니다.

구현

결과 — 프로비저닝·상태 변경이 사용자 조작 없이 화면에 반영됩니다. 무효화(웹훅)와 리렌더(소켓)를 짝으로 설계했기 때문에 refresh 시점에 최신 데이터가 보장됩니다.

2-5. 크로스 서브도메인 SSO 인증

배경 및 문제

서비스를 도메인별로 분리했으므로 인증도 도메인 경계를 넘어야 했습니다. 또한 브릿지 서비스는 약관 동의와 주소 등록이 선행되어야 이용 가능한데, 이 온보딩 상태를 각 앱이 개별 판단하면 5곳에 중복 로직이 생깁니다.

선택과 이유

토큰을 localStorage가 아닌 상위 도메인 공유 쿠키에 담았습니다. localStorage는 origin 격리라 서비스 간 공유가 불가능하고, 무엇보다 미들웨어(서버)에서 읽을 수 없어 서버 사이드 가드를 만들 수 없기 때문입니다. 온보딩 상태 판단은 별도 API 조회 대신 JWT 클레임을 사용했습니다 — 미들웨어에서 추가 네트워크 왕복 없이 판정할 수 있기 때문입니다.

구현

결과 — 5개 서비스가 하나의 로그인 상태를 공유하고, 온보딩 가드가 미들웨어 한 곳에 집중되어 앱별 중복이 제거되었습니다.

2-6. 마케팅 트래킹 인프라 (GA4 · GTM · Meta Pixel)

배경 및 문제

도메인 분리에는 마케팅 측면의 대가가 따랐습니다. 유입(랜딩) → 가입(SSO) → 결제(콘솔)가 서로 다른 도메인에서 일어나므로 GA 세션이 도메인 경계에서 끊기고 전환 경로가 소실됩니다. 광고비 집행 판단의 근거가 되는 데이터가 신뢰할 수 없는 상태였고, 이커머스 전환 이벤트 규격 자체가 없었습니다.

선택과 이유
구현

결과 — 도메인이 분리된 상태에서도 유입 → 가입 → 결제 전환 경로를 하나의 세션으로 측정할 수 있게 되었고, 마케팅팀이 GA4에서 화면 단위 퍼널과 이커머스 전환을 조회할 수 있는 기반을 확보했습니다.

2-7. 전환 지표 신뢰도 개선

배경 및 문제

기존 login 이벤트가 로그인 시도 직전status: 'success'로 발사되어 비밀번호 오류·OAuth 취소까지 전환으로 집계되고 있었습니다. 근본 원인은 구조적 제약이었습니다 — SSO는 통합 로그인이라 성공 시 백엔드가 302로 다른 서비스에 착지시키므로 SSO 프론트에는 성공 콜백이 존재하지 않습니다. 회원가입도 폼이 백엔드에 네이티브 POST를 쏘기 때문에 성공 콜백이 없어, 제출 시점에 발사하면 이메일 중복·서버 에러까지 가입으로 집계되었습니다.

선택과 이유
구현

결과 — 로그인·가입 전환 수치가 실제 성공 건수를 반영하게 되었고, 사용자 단위 분석이 가능해졌습니다. 남은 미확인 전제(소셜 재로그인이 동일 착지 페이지를 경유하는지 등)는 오집계 가능성과 함께 문서에 명시해 후속 확인 대상으로 남겼습니다.

2-8. SEO — 색인 대상과 비대상의 분리

같은 모노레포에 색인되어야 하는 서비스(랜딩)와 색인되면 안 되는 서비스(어드민·콘솔·개인화 구매 퍼널)가 공존한다는 점이 이 프로젝트 SEO의 고유 조건이었습니다. 메타데이터는 페이지별 작성 대신 getMetadata() 팩토리 + 상수 중앙화로 구성했고(손으로 쓰면 누락이 반드시 발생), robots.txt 정적 파일 대신 환경 인지형 robots.ts를 택해 환경 전환 시 파일을 수동 교체하는 실수를 없앴습니다 — 운영이 아니면 전체 disallow로 개발·QA 색인을 차단하고, 운영에서는 개인화 경로만 선택적으로 제외합니다.

2-9. 빌드 / 배포 파이프라인

문제 — 모노레포에서 앱별 독립 배포를 하려면 각 이미지가 자기 앱과 그 의존 패키지만 포함해야 합니다. 레포 전체를 복사해 빌드하면 이미지가 커지고, 무관한 앱의 변경만으로도 매번 전체 의존성이 재설치됩니다.

선택turbo prune --docker로 서브그래프를 추출하는 방식을 택했습니다. 이것이 2-1의 "앱별 독립 배포"와 2-2의 "모노레포"를 동시에 성립시키는 지점입니다. 또한 의존성 메타(out/json)만 먼저 복사해 install 레이어를 캐시하고 소스는 그 뒤에 복사하도록 순서를 잡았습니다 — 소스 변경이 install 레이어를 무효화하지 않게 하기 위해서입니다.

2-10. 렌더링 / 성능 최적화

2-11. 시니어 사용자를 위한 콘솔 대시보드 정보 구조 개선

배경 및 문제

사용자 콘솔에서 가장 자주 필요한 정보는 생성한 블로그의 주소와 워드프레스 관리자 주소·ID·비밀번호입니다. 로그인해서 확인하려는 것이 사실상 이것뿐인 경우도 많습니다. 그런데 기존 구조에서는 이 정보에 도달하려면 3단계를 이동해야 했고, 특히 고연령 사용자층에서 경로를 찾지 못해 이탈하거나 문의로 이어지는 문제가 있었습니다.

1차 판단 — 찾게 만들 것인가, 찾을 필요를 없앨 것인가

메뉴 이름을 바꾸거나 안내를 추가하는 방식은 택하지 않았습니다. 경로를 설명해서 찾게 만드는 것보다 가장 자주 쓰는 정보를 진입 화면으로 끌어올리는 편이 낫다고 봤고, 인스턴스별 카드를 대시보드에 직접 배치해 블로그 주소·이름·관리자 주소·ID·PW를 한 화면에서 확인하도록 했습니다.

2차 판단 — 진입 단계 축소만으로는 답이 되지 않음
여기서 방향을 한 번 더 점검했습니다 — 진입 단계를 줄이는 것이 언제나 정답은 아닙니다. 대시보드는 필요한 정보를 요약해 집약하는 화면이라, 올릴 것을 늘리면 도달 비용은 줄지만 판독 비용이 늘어납니다. 찾기 어려운 화면이 보기 어려운 화면으로 바뀌는 것뿐이라면 개선이 아닙니다.

실제로 제약이 있었습니다. 인스턴스당 블로그를 3개까지 생성할 수 있어, 한 슬라이드에 카드가 3벌 놓입니다. 주소·이름·관리자 주소·ID·PW가 세 세트로 늘어나면 화면은 정보로 가득 차고, 정작 지금 확인하려는 블로그가 어느 것인지 알기 어려워집니다. 그래서 문제를 "무엇을 올릴 것인가"에서 "올린 것 중 무엇을 지금 보게 할 것인가"로 다시 잡았습니다.

해결 — 정보를 줄이지 않고 위계를 부여

값의 실제 사용 목적이 "읽는 것"이 아니라 "다른 곳에 붙여넣는 것"이라는 점에서 항목마다 복사 동작을 배치했습니다. 드래그 선택 자체가 부담인 사용자를 고려한 결정입니다. 블로그 열기와 관리자 페이지 이동은 새 창으로 분리해 콘솔로 돌아오는 경로를 잃지 않도록 했고, 호스팅·도메인이 없는 계정에는 빈 상태를 그대로 두지 않고 다음 행동을 제시하는 안내를 노출했습니다.

남은 과제

주요 정보 도달률과 관련 문의 건수를 개선 전후로 비교해 효과를 수치로 확인하는 것이 다음 단계입니다.

2-12. 보안

2-13. 대표 트러블슈팅

① 추천인 코드 유실 — 파트너 정산 직결

추천 링크로 유입해도 코드가 저장되지 않는 문제. 원인은 두 겹이었습니다 — 미들웨어의 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의 세션·미들웨어를 거치며 중첩 인코딩되기 때문입니다. 도메인 목록 재조회는 여러 페이지 순차 요청이라 결제 모달 표시가 눈에 띄게 늦어져 결제 모달을 닫은 시점으로 미뤘고, 이미 카드가 등록된 회원에게 카드 등록창이 다시 뜨는 문제(비동기로 채워지는 값을 클로저가 고정)도 함께 해결했습니다.

2-14. 팀 리딩 · 프로세스

3. @loword/loword-design-system — 전사 공용 디자인 시스템

프로젝트@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) 치환이 다음 대상

3-1. UI·스타일·기능을 분리한 3-Layer 아키텍처 설계

배경 및 문제

기존 monorepo의 레거시 UI 패키지는 UI와 기능이 강결합되어 커스터마이징이 사실상 불가능했습니다. 새 요구사항마다 컴포넌트 내부 분기를 추가하는 방식이라, 컴포넌트가 커질수록 분기가 곱으로 증가하고 수정 시 영향 범위를 예측할 수 없었습니다.

결정과 근거

Ant Design류의 "하나의 컴포넌트 + props 분기" 대신 Base → Custom → Function 3계층 분리를 채택했습니다.

레이어책임규칙
{Name}BaseHTML 구조 + 접근성(ARIA)만시각 스타일을 갖지 않는다
LD{Name} (Custom)프리셋(variant/color/size) + 자유 스타일Base 위에만 얹는다
LDForm{Name}외부 로직 주입(폼·테이블·명령형 API)Custom을 래핑하고 Base를 직접 쓰지 않는다

핵심 원칙은 "확장은 아래 레이어 수정이 아니라 위 레이어 추가로만 한다"입니다. props 분기는 요구사항이 늘 때마다 기존 코드를 건드려야 하지만, 레이어 분리는 기존 것을 그대로 둔 채 새 레이어를 추가하면 되기 때문입니다.

결과

3-2. 아키텍처 규칙을 접근성 결함 탐지 도구로 활용 + 접근성의 CI 게이트화

배경 및 문제

"Base는 스타일을 갖지 않는다"는 규칙이 선언에 그치면 시간이 지나며 무너집니다. 실제로 일부 컴포넌트는 Base 382줄이 디자인을 전부 갖고 Custom 57줄은 className만 통과시키는 역전 상태였습니다. 더 근본적으로는 a11y 애드온이 test: 'todo'로 설정되어 있어 위반이 있어도 테스트가 통과했고, 그래서 접근성 결함이 계속 쌓이고 있었습니다.

결정과 근거
결과 — 규칙과 게이트가 실제로 잡아낸 결함
의의 — 아키텍처 규칙은 결함을 발견하게 하고, CI 게이트는 결함이 다시 쌓이지 않게 합니다. 두 가지를 함께 갖춰야 접근성이 일회성 캠페인으로 끝나지 않는다고 판단했습니다.

3-3. 스타일 프리셋 엔진 tv() 자체 구현

배경 및 문제

컴포넌트마다 클래스 조합 방식이 달랐습니다. 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건

3-4. 타입 설계로 "조용한 실패"를 컴파일 에러로 전환

배경 및 문제

공통 토큰 타입에서 일부만 골라 쓰는 컴포넌트가 많았습니다. 흔히 쓰는 Extract<Size, 'small' | 'huge'>는 오타나 삭제된 값을 에러 없이 조용히 제거합니다. 더 심각한 것은, 각 컴포넌트가 'solid' | 'outline' | … 유니온을 손으로 다시 선언하고 있어 공통 타입과 어긋나도 컴파일 단계에서 전혀 잡히지 않았다는 점입니다.

결정과 근거

3-5. 공개 API 경계 설계 — 외부 라이브러리를 패키지 안에 가두기

배경 및 문제

테이블 컴포넌트는 TanStack Table 위에 올라가 있는데, 내부 래퍼(TableField)가 패키지 루트로 공개되어 있었고 table 인스턴스가 render props로 소비처에 그대로 노출되어 있었습니다. 이 상태에서는 소비처 코드가 TanStack의 타입과 API에 직접 결합되어, 라이브러리를 교체하거나 버전을 올릴 때 소비처가 전부 깨집니다. 디자인 시스템이 라이브러리를 감싸는 의미가 사라진 상태였습니다. 같은 문제가 LDDataCalendarBase, LDLNBBase 등 내부 Base 레이어의 루트 공개에도 있었습니다.

결정과 근거
의의 — breaking change를 감수한 정리였고, 각 변경에 마이그레이션 경로를 함께 문서화했습니다. 초기 배포 단계(0.x)에서 경계를 확정하지 않으면 나중에는 못 고친다는 판단으로, 소비처가 2곳일 때 처리했습니다.

3-6. 렌더 성능 진단 및 최적화

"선택 색이 부드럽게 전환되지 않고 즉시 튄다"는 시각적 증상에서 출발했지만, 원인은 전부 렌더 구조에 있었습니다.

원인진단조치
컴포넌트 타입이 매 렌더 새로 생성 캘린더가 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 캐스케이드 버그에 이어 두 번째로 확인한 사례입니다.

3-7. NPM(ESM) 배포 파이프라인 및 의존성 구조 설계

배경 및 문제

다른 프로젝트에서 barrel import로 쓰려면 배포가 필요했으나, 빌드 실패·CJS 출력·타입 미노출·barrel 부재로 배포 불가 상태였습니다.

결정과 실행

결과 — 0.0.1 최초 발행 이후 릴리즈 18회, 사내 서비스 2곳에서 사용 중.

3-8. 릴리즈 파이프라인 설계 및 배포 자동화

기간2026.08 (설계 → 구현 → 실전 검증 → 결함 수정까지 한 사이클) 역할단독 설계·구현·운영 산출물워크플로 5종 1,008줄 · 자체 changeset 도구 402줄 · 팀 실무 문서 266줄

문제 정의 — "배포 단위"라는 개념이 없었다

디자인 시스템은 여러 명이 동시에 작업하는데, 각자 작업 브랜치를 main으로 직접 머지하고 어느 정도 모이면 npm 버전을 올려 배포하는 구조였습니다. 배포라는 단위가 코드상 아무 실체도 갖지 못한다는 것이 근본 문제였습니다.

문제실제 증상
이번 배포 변경사항이 중앙화되지 않음"0.0.19에 뭐가 들어가는지" 알려면 머지된 PR을 시간순으로 역추적해야 했음
소비처 관점 정보가 배포 단위로 정리되지 않음breaking change와 마이그레이션 안내가 CHANGELOG 안에 날짜순으로 흩어져, 소비처가 업그레이드할 때 무엇을 고쳐야 하는지 한 번에 파악 불가
CHANGELOG 상시 충돌 / 자동화 부재모두가 단일 파일 맨 위에 추가하니 팀원 PR 머지마다 충돌. GitHub Actions 워크플로는 0개
설계의 출발점 — 도구를 도입하는 것이 목적이 아니라 배포라는 단위에 실체를 부여하는 것이었습니다. 목표는 하나였습니다: 각자의 작업으로 생긴 변경사항을 소비처가 한 번에 알 수 있게 하고, 그 과정을 자동화한다.
핵심 설계 — 배포 1건 = PR 1건

main을 "항상 npm에 배포된 것과 동일한 상태"로 고정하고, 그 사이에 이번 배포의 집결지인 release/vX.Y.Z 브랜치를 두었습니다. 팀원은 main이 아니라 이 브랜치에서 분기하고 이 브랜치로 PR합니다.

이 구조의 핵심은 브랜치가 아니라 release/*main draft PR 하나입니다. 이 PR이 곧 "이번 배포 대시보드"가 됩니다 — 누적 diff, 포함된 작업 목록, 여러 사람의 변경이 합쳐진 상태의 통합 CI, 소비처가 읽을 릴리즈 노트가 한 화면에 모입니다. "이번 배포에 뭐가 들어가나?"에 대한 답이 URL 하나가 되는 것이 이 설계의 목적이었습니다.

함께 정한 제약 — 열려 있는 release 브랜치는 항상 하나로 강제했습니다(둘 이상이면 워크플로가 중단). 둘 이상이면 팀원이 어디로 PR해야 할지 알 수 없어 모델 자체가 무너집니다.

changeset — 단일 출처, 두 독자의 분리

배포 단위를 만들어도 "무엇이 바뀌었는지"를 사람이 매번 정리하면 자동화가 아닙니다. 그리고 기존 CHANGELOG는 독자가 섞여 있었습니다 — 팀은 "왜 이렇게 고쳤는지"를, 소비처는 "무엇이 달라졌고 내 코드를 어떻게 고쳐야 하는지"를 알아야 하는데 한 파일에 뒤섞여 있었습니다.

작업 하나당 .changes/{브랜치명}.md 파일 하나를 남기게 하고, 이 파일을 변경 기록의 유일한 원본으로 정했습니다. 한 파일 안에 ## 팀 기록## 소비처 두 섹션을 두어, 배포 시 각각 CHANGELOG와 릴리즈 노트로 자동 분배됩니다.

버전 결정 — 판단 기준을 질문 하나로 압축

"이번엔 patch인가 minor인가"를 매번 사람이 상의하면 자동화가 아니고, 기준이 사람마다 다르면 소비처가 예고 없이 깨집니다. 그래서 기준을 하나의 질문으로 고정했습니다 — "이 버전을 올리면, 소비처가 코드를 한 줄이라도 고쳐야 하는가?" "눈에 띄게 바뀌었나"가 아니라는 점이 핵심입니다.

기성 도구(@changesets/cli)를 쓰지 않은 이유
안전성 — 되돌릴 수 없는 지점을 기준으로 순서를 정함

npm publish는 되돌릴 수 없는 유일한 지점입니다. 이 지점을 축으로 파이프라인 순서를 배치했습니다.

검증 (노트 존재 · npm 중복 · 인증 · 타입 체크 · 테스트)
  → npm publish              ← 되돌릴 수 없는 지점
  → 태그 → CHANGELOG 갱신 + .changes/ 비움 → Release 게시 → 다음 사이클 자동 오픈
AI를 파이프라인 단계로 편입 — 권한을 나눠서 안전하게

릴리즈 노트의 사실관계(변경 항목·PR 목록)는 changeset에서 기계적으로 생성되지만, 소비처가 읽을 요약 문단은 문장 작업입니다. 이 부분만 Claude Code Action에 맡겼습니다.

운영 중 실제 사고와 대응 — v0.1.1 배포에서 요약 정리가 수동 실행뿐이라 실제로 건너뛰어졌고, 자리표시자가 GitHub Release 본문에 그대로 나갔습니다. 그 배포는 소비처 변경이 없어 피해가 없었지만, breaking change가 있는 배포였다면 소비처가 요약 없는 노트를 받습니다. draft 해제 시 자동 실행을 추가하고, 그것마저 건너뛰어질 수 있으므로 자리표시자가 남아 있으면 머지를 막는 검사를 CI에 넣었습니다. 사람의 기억에 의존하는 단계는 언젠가 반드시 빠진다는 것을 확인한 사례입니다.
CI를 "막는 도구"가 아니라 "가르치는 도구"로

새 프로세스를 도입하면 팀원은 반드시 실수합니다. 특히 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줄)

3-9. 디자인 토큰 체계, CSS 배포 구조, 확장점 설계

(1) 컴파일 산출물의 한계를 발견하고 이중 진입점 도입

문제 — 소비처가 배포된 styles.css(= 컴파일된 CSS)만 import하면, 그것은 디자인 시스템이 사용한 클래스만 담긴 스냅샷이라 소비처가 쓰는 클래스는 생성되지 않았습니다. 더 근본적으로, 컴파일본에서 Tailwind v4의 @theme:root 변수로 박제되어 소비처 Tailwind가 이를 토큰으로 인식조차 못 하는 상태였습니다.

결정 — 토큰·유틸 레이어만 모은 raw 소스 진입점(theme.css)을 별도 export. 여기에 @import 'tailwindcss'와 컴포넌트 스타일은 의도적으로 넣지 않았습니다 — 소비처가 이미 import하므로 중복 시 충돌하고, 컴포넌트 스타일은 컴파일본이 담당하기 때문입니다. 결과로 소비처가 자기 Tailwind 파이프라인에서 토큰 기반 클래스를 별도 정의 없이 on-demand 생성할 수 있게 되었습니다.

(2) 소비처에서만 재현되던 캐스케이드 버그 규명 및 진단 절차 문서화
(3) 확장점의 모양을 사용 패턴에서 도출 — colorSet

문제 — 캘린더의 선택·hover·today 링·예약 점 색이 Base에 직접 박혀 있어, 소비처가 브랜드 색을 바꿔도 선택 표시는 파란색으로 고정됐습니다. 색이 붙는 버튼이 classNames 슬롯으로 노출되지도 않아 덮어쓸 경로 자체가 없었고, "Base에는 시각 스타일을 두지 않는다"는 규칙에도 어긋났습니다.

방식검토 결과
색 이름 프리셋(point/danger/…) — 다른 컴포넌트에서 쓰는 방식색마다 배경·글자·링·띠 짝을 모두 정의한 매핑 테이블이 필요한데, 캘린더 강조 색은 앱당 하나로 고정되는 성격이라 대부분 쓰이지 않을 조합만 쌓임
CSS 변수 3개 + colorSet prop (채택)기존에 쓰던 --cell-size 등과 같은 자리·같은 방식이라 새 규칙이 늘지 않음. 일부만 지정하면 나머지는 기본값이 유지되도록 설계

핵심 판단 — "프리셋이 정말 필요해지면 colorSet에 값을 넣는 얇은 레이어로 얹을 수 있고, 그때도 Base는 다시 손대지 않아도 된다"는 점을 근거로 채택했습니다. 3-Layer 설계의 "위에 얹어서 확장한다"는 원칙을 확장점 설계에도 그대로 적용한 사례입니다.

부수 성과 — 이 작업 중 선택 시 색이 차오르던 애니메이션이 색을 직접 물고 있어 다른 색을 지정하면 파란색이 번졌다 튀는 문제, from/to가 서로 다른 CSS 속성을 애니메이션하던 결함을 발견해 함께 제거했습니다. 캘린더를 쓰는 모든 지점(단독·DatePicker·DateRangePicker·각 Form 래퍼)에 스토리를 붙여 확장점이 실제로 닿는지 검증했습니다.

3-10. 팀 개발 규칙 수립 및 리딩

배경 및 문제

6명이 병렬로 작업하는 상황에서 컨벤션이 문서화되지 않으면, tv() 도입 이전에 겪었던 문제가 조직 차원에서 반복됩니다 — 읽는 사람이 컴포넌트마다 다른 규칙을 새로 익혀야 하는 상태.

실행

3-11. 레거시 마이그레이션 전략 — "무엇을 넣지 않을지"의 기준

레거시 UI 패키지 2곳의 컴포넌트를 전수 조사해 완료 / 기존 컴포넌트의 변형으로 커버 가능 / 추가 검토 / 대상 아님 4분류로 정리하고, 제외 사유를 항목별로 명시했습니다 — 단순 래핑(Next.js Link, 외부 캐러셀), 단일 요소로 너무 단순한 것, 서비스 특화 컴포넌트, 3-Layer 적용 대상이 아닌 애니메이션 유틸.

디자인 시스템의 정체성은 무엇을 담느냐보다 무엇을 담지 않느냐로 정의된다고 보고, 판단 근거를 남겨 이후 팀원이 같은 기준으로 결정하도록 했습니다. 도입 우선순위는 추측이 아니라 실사용 빈도로 정했습니다(예: 소비처에서 외부 라이브러리 컴포넌트를 22회 사용하는 것을 확인한 뒤 대체 컴포넌트 추가).

같은 기준을 브릿지 모노레포의 공용 패키지에도 적용하고 있습니다. 브릿지의 @loword/ui·@loword/nextjs-ui는 디자인 시스템이 없던 시기에 5개 앱의 중복을 흡수하기 위해 만든 임시 수렴점이므로(2-2), 디자인 시스템이 커버하는 컴포넌트는 치환하고 서비스 특화 컴포넌트만 모노레포 패키지에 남기는 것이 목표입니다. 두 벌을 영구히 유지하면 "어느 쪽이 기준인가"가 흐려져, 디자인 시스템을 만든 이유 자체가 사라집니다.

4. 레듀 — 멀티테넌트 LMS / 강의 판매 플랫폼

기간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개월의 모든 기술 의사결정의 기준이 되었습니다.

4-1. 멀티테넌트 아키텍처 설계 및 구축

기간2024.01 ~ 2024.06 (초기 설계) / 이후 지속 개선 역할아키텍처 설계 및 구현 단독 담당 기술Next.js App Router, Route Handler, axios Interceptor, Docker

배경 및 문제

몰마다 코드베이스를 복제하는 방식은 초기에는 빠르지만, 공통 기능 수정이 몰 수만큼 반복되고 몰별로 코드가 갈라지면서 어느 몰에 어떤 수정이 들어갔는지 추적 불가능한 상태로 수렴합니다. 동시에 각 몰은 SEO와 브랜딩상 독립 도메인을 써야 했고, 디자인·메뉴 구성도 전부 달랐습니다.

① 테넌트 식별 방식 — 서브패스/빌드분기가 아닌 HTTP 헤더(X-MALL-ID)
후보검토 결과
서브패스 (/mall-a/...)독립 도메인 요구와 충돌. SEO상 각 몰이 별도 사이트여야 함
빌드 타임 분기몰마다 별도 빌드·이미지 필요 → 배포 파이프라인이 몰 수에 비례해 증가
HTTP 헤더 (채택)프론트는 도메인만 교체, 백엔드는 헤더 하나로 분기. API 계약이 단일하게 유지됨
② 클라이언트 MALL_ID를 빌드타임이 아닌 런타임에 주입

NEXT_PUBLIC_* 환경변수는 빌드 시점에 번들에 인라인됩니다. 그대로 쓰면 몰마다 별도 빌드가 필요해져 ①의 이점이 사라집니다. → /api/config Route Handler로 런타임에 내려주고, axios 인터셉터가 모듈 스코프에 캐싱하도록 구현했습니다.

③ 사이트 외형을 코드가 아닌 데이터로

로고·컬러셋·헤더/푸터 메뉴·약관·SEO 설정·외부 서비스·하단 모바일 메뉴를 백엔드에서 내려받아 루트 레이아웃이 조립하도록 설계했습니다. Ant Design ConfigProvidercolorPrimary·colorLink까지 몰 설정값을 주입해 디자인 토큰 수준에서 테넌트별 브랜딩이 적용됩니다. 루트 레이아웃의 6개 설정 fetch는 Promise.all로 병렬화해 TTFB 영향을 최소화했습니다.

④ 마스터 몰(MASTER_MALL) 개념 도입

몰을 관리하는 몰(거래처 관리)이 필요해, 메뉴 정의에 forMaster 플래그를 두어 동일한 어드민 코드에서 권한 계층을 분리했습니다. 별도 앱을 만들지 않은 이유는, 관리 화면의 90%가 동일해 두 벌 유지 비용이 이득을 넘기 때문입니다.

성과
인지하고 있는 한계 — 배포 단위별 장기 브랜치가 갈라져 주요 두 브랜치 간 diff가 300+ 파일에 이르는 상태입니다. 문제로 인식하고 문서화했으며, 몰별 차이를 브랜치가 아닌 MALL_ID 기반 런타임 설정으로 수렴시키는 것이 다음 단계 과제입니다.

4-2. 관리자 콘솔 설계 및 구현 (9개 도메인 48개 화면)

기간2024.02 ~ 지속 역할전체 정보구조 설계 및 구현 주도 (팀원과 분담) 기술Next.js App Router, Ant Design Pro Components, Recoil, Server Actions

배경 및 문제

몰 운영자가 배너 하나, 쿠폰 하나를 바꾸려고 개발팀에 요청하면 개발팀이 사업 확장의 병목이 됩니다. 몰이 늘어날수록 이 비용은 선형 이상으로 증가합니다.

① 범위 설정 — "개발 요청이 들어오는 항목"에서 역산

기능을 상상해서 만들지 않고, 실제로 들어온 개발 요청을 분류해 그것을 전부 어드민으로 흡수하는 방향으로 범위를 잡았습니다.

도메인주요 화면
기본관리몰 기본정보, 약관/개인정보처리방침, 외부 서비스 설정, 외부 코드 설정, 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라는 이름과 달리 토큰이 있으면 자동으로 실어 보낸다는 함정을 명시했습니다.

성과

4-3. 페이지 빌더 1차 — 블록 조립형 Studio

기간2024.05 ~ 2024.11 (더블티 재직 시) 역할설계 및 구현 주도 규모130파일 / 약 19,000 LOC 기술React, react-sortablejs, dynamic import, Ant Design

배경 및 문제

강의 판매몰의 매출은 랜딩페이지 실험 속도에 직결됩니다. 그런데 랜딩 한 장마다 개발자가 붙으면 실험 사이클이 주 단위가 되고, 마케팅팀이 아이디어를 검증할 수 없습니다.

① "범용 빌더"를 만들지 않기로 한 결정

처음부터 무한 자유도의 빌더를 만드는 선택지도 있었지만, 두 가지 이유로 배제했습니다.

대신 운영 중인 실제 랜딩페이지를 역분석해 반복되는 블록(메인 슬라이드, 그리드 배너, 타이틀, 2/3단 그리드, 강의 카드, 후기 슬라이드, 앵커, 타이머 배너, DB 수집 폼 등)을 추출하고, 블록마다 시각 컴포넌트 + 입력폼 쌍을 두었습니다.

② 성공 기준을 "마케터가 30분 안에 랜딩 하나 완성"으로 정의

자유도를 제한하는 대신 이 기준을 즉시 만족시키는 것을 목표로 했습니다. 실사용 데이터가 쌓여야 2차의 요구사항이 검증된다고 판단했고, 실제로 1년간의 사용 패턴이 2차 설계의 근거가 되었습니다.

③ 계측을 나중에 붙이지 않고 블록에 내장

타이머 배너·무료강의 신청·DB 수집 폼 블록이 직접 purchase / purchase_livefree dataLayer 이벤트를 push하도록 구현했습니다. → 마케터가 만든 페이지가 자동으로 계측되는 상태가 됩니다. 계측을 나중에 붙이면 반드시 누락되고, 누락된 기간의 데이터는 복구되지 않습니다.

④ 성능

블록별 dynamic import로 에디터 초기 번들을 분리해, 블록 수가 늘어도 초기 로딩이 비례해 무거워지지 않도록 했습니다.

성과

4-4. SEO 및 그로스 계측 인프라 구축

기간2024.03 ~ 지속 역할설계 및 구현 단독 담당 (마케팅팀과 요구사항 협의) 기술Next.js Metadata API, Middleware, GTM, GA4, Meta Pixel, Naver WCS

배경 및 문제

강의 판매몰은 검색 유입과 광고 전환 측정이 곧 매출입니다. 그런데 몰마다 광고 대행사·픽셀 계정·태그 구성이 전부 다릅니다. 몰이 늘 때마다 스크립트를 코드에 추가하는 방식은 지속 불가능합니다.

① SEO 메타데이터 — 계층 폴백

generateMetadata에서 강의 상세 > 랜딩페이지 > 몰 기본 SEO 설정 우선순위로 title/description/OG/Twitter/keywords/canonical을 조립했습니다. → 어드민에서 강의별로 채우지 않아도 몰 기본값으로 항상 유효한 메타가 나가고, 채우면 정밀해집니다. "안 채우면 빈 메타"가 되는 설계는 실무에서 반드시 빈 메타를 만듭니다.

② 레거시 URL 301 마이그레이션

구 사이트에서 이전할 때 URL이 바뀌면 그동안 쌓은 검색 랭킹과 백링크 자산이 통째로 소실됩니다. 미들웨어에서 게시판/게시글/클래스/랜딩/회원가입/마이페이지 등의 구 URL을 매핑 테이블로 처리했습니다.

③ 외부 서비스/스크립트를 어드민 등록형으로

GA4 / GTM / Meta Pixel / Naver 서치어드바이저 / Google Search Console / 카카오 플러스친구를 서비스 코드 기준으로 스위칭 렌더하고, 그 외 임의 스크립트는 "외부 코드 설정"에서 head/body 위치와 적용 경로(전체/특정 URL)를 지정해 주입하도록 설계했습니다. → 몰마다 다른 마케팅 스택을 코드 수정 없이 연동.

④ GTM 다중 컨테이너 지원

자사 컨테이너와 광고 대행사 컨테이너가 공존하는 것이 실무 상황입니다. 콤마 구분 입력을 파싱해 컨테이너별로 별도 dataLayer(dataLayer, dataLayer2…)를 분리 초기화했습니다. 하나를 공유하면 서로의 이벤트가 섞여 대행사 리포트가 오염됩니다.

⑤ Meta Pixel — Advanced Matching + SPA 대응
⑥ 커머스 퍼널 이벤트 설계

page_view → begin_checkout → purchase로 퍼널을 정의하고, 무료/쿠폰 전액할인 케이스를 별도 이벤트로 분리했습니다. 0원 전환을 같은 purchase로 보내면 ROAS가 왜곡되어 광고 알고리즘이 잘못된 방향으로 학습하기 때문입니다. 보유 강의 목록을 별도 이벤트로 push해 리타게팅 제외 세그먼트를 만들 수 있게 했습니다.

⑦ UTM 캠페인 세션 보존

카카오 로그인 같은 OAuth 리다이렉트를 거치면 UTM 파라미터가 유실되어 어트리뷰션이 끊깁니다. UTM을 sessionStorage에 보존하고 카카오 로그인 state 파라미터에 실어 왕복시켜, 회원가입/결제 전환까지 캠페인 정보가 이어지도록 했습니다(중첩 url 파라미터 안의 UTM까지 파싱).

성과

4-5. 프레임워크 마이그레이션 (Next 14→15 / React 18→19 / Tailwind v3→v4)

기간2025.09 ~ 2025.12 역할마이그레이션 계획 수립 및 실행 리드 규모83,000 LOC 규모 애플리케이션 전체

배경 및 의사결정

"왜 지금 하는가"가 이 작업의 핵심 근거였습니다. 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 절차 확립 및 문서화
성과

4-6. 팀 리딩 및 엔지니어링 문화

기간2024.01 ~ 지속 역할프론트엔드 팀 리더

배경

기여자가 누적 20명을 넘어가면서 "내가 아는 것"과 "팀이 아는 것"의 격차가 사고로 이어지는 국면에 들어섰습니다. 특히 이 레포에는 모르면 반드시 사고가 나는 항목들이 있었습니다(멀티테넌트 구조, 데이터 페칭 2경로 혼용, CSS import 순서, AntD Upload 자동 POST).

① "원인 기반" 변경 이력 문화 정착

모든 변경을 원인/이유 + 수정 형식으로, 코드 변경과 같은 커밋에 누적 기록하도록 규칙화했습니다(현재 970줄).

② 함정 중심 온보딩 문서 작성 (548줄)
③ AI 에이전트용 컨텍스트 문서 체계

루트에 전역 요약, Studio 하위에 별도 상세 가이드를 두어 관련 없는 작업에 Studio 세부사항이 로드되지 않도록 컨텍스트를 분할했습니다. 팀의 AI 도구 활용 시 불필요한 컨텍스트가 정확도를 떨어뜨리는 문제를 구조로 해결한 사례입니다.

④ 코드 리뷰 및 컨벤션

작업 브랜치 네이밍(feat/ fix/ refactor/ chore/), 커밋 메시지 형식, Prettier/ESLint 설정 정착. ESLint의 exhaustive-deps가 비활성이라는 사실을 "도구가 잡아주지 않으니 직접 확인해야 한다"고 문서에 명시해, 설정의 한계를 팀이 인지하도록 했습니다.

성과

5. 로워드(loword.co.kr) — 키워드 인텔리전스 플랫폼

기간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

5-1. CSR → 서버 컴포넌트 전환

배경 및 문제

검색 유입이 매출의 핵심인 서비스인데 CRA 기반 CSR 구조라 크롤러가 빈 HTML을 수신했습니다. 이 작업을 "SSR 도입"이 아니라 데이터가 클라이언트로 넘어가는 경계를 다시 긋는 작업으로 접근한 것이 이후 모든 설계의 출발점이 되었습니다.

전환 근거 3가지
구조가 규칙을 강제하게 하기

'use client'가 상위로 번지면 서버 컴포넌트 도입의 이점이 조용히 사라집니다. 이건 문서로 막히지 않는다고 판단해 Container(서버) · Presenter(클라이언트) · Styled 3-파일 컨벤션으로 분리했습니다(74 / 74 / 111 파일). 경계를 지키는 일이 개인의 주의력이 아니라 파일을 만드는 절차 자체에 들어가게 하는 것이 목적이었습니다.

5-2. SEO 아키텍처

5-3. 봇 감지 분기 콘텐츠 서빙 ★

배경 및 문제

UGC 본문은 유료 콘텐츠라 비로그인 사용자에게 전부 보여주면 전환이 발생하지 않습니다. 반대로 크롤러에게 가려서 내보내면 본문이 색인되지 않아 롱테일 검색 유입이 사라집니다. 전환율과 색인이 정면으로 상충하는 구조였습니다.

검토하고 버린 방안
방안기각 사유
CSS blur / overlay 처리소스 보기로 즉시 뚫림. 시각적 가림일 뿐 데이터는 이미 전송됨
클라이언트에서 문자열 slice자르는 시점에 원본이 이미 브라우저에 도착해 있으므로 무의미
서버 컴포넌트에서 봇 판별 후 분기 (채택)잘라낸 데이터가 RSC 페이로드에 바이트 단위로 존재하지 않음
구현과 논지

5-4. 렌더링 · 스트리밍

5-5. Tag 기반 정밀 캐시 무효화

5-6. Edge Middleware 인증·권한 파이프라인

5-7. GA4 · GTM 마케팅 계측

5-8. 네트워크 · 인증 레이어

5-9. 기반 구조

5-10. 검색 결과 화면의 정보 위계 설계

키워드 분석 결과는 지표 종류가 많아 한 화면의 정보 밀도가 높습니다. 숙련 사용자에게는 다 보이는 편이 유리하지만, 그렇지 않은 사용자에게는 무엇을 먼저 봐야 할지 알 수 없는 화면이 됩니다. 항목을 줄이면 그 지표를 쓰던 사용자를 잃으므로, 정보의 양이 아니라 우선순위가 화면에 드러나지 않는 것이 문제라고 보고 노출 여부가 아닌 배치와 시각적 위계로 접근했습니다.

5-11. 정량 요약

항목수치
라우트61개
3-파일 컨벤션 적용Container 74 / Presenter 74 / Styled 111 파일
구조화 데이터JSON-LD 13종
스트리밍 분해Parallel Route 슬롯 22개
캐시 무효화 지점revalidateTag 37개소
웹 성능Lighthouse 평균 40 → 94

6. 팀 AI 개발 환경 도입 및 컨텍스트 체계 설계

기간2026.05 ~ 진행 중
역할도입 제안 및 컨텍스트 체계 설계 주도
범위개발팀 전체(프론트엔드·백엔드) · 러닝 프로젝트 4개
도구Claude Code 팀 플랜 · Markdown 기반 규칙 레포
배경개인별 사용은 있었으나 팀 단위 도입은 없던 상태

6-1. 출발점은 도입 찬성이 아니라 회의론이었음

저는 처음부터 AI 활용에 긍정적이지 않았습니다. 자신이 작성한 코드는 명확히 알고 있어야 하고, 내가 모르는 코드는 없는 편이 낫다고 생각했기 때문입니다. 실제로 신입·팀원 코드를 리뷰하며 "왜 이렇게 구현했는지"를 물었을 때 답하지 못하는 경우를 반복적으로 확인했습니다.

당시 판단알고 쓰는 AI는 득이 되지만 모르고 쓰는 AI는 독이 된다. 그래서 초기에는 도입을 추진하지 않고, 팀원들에게 의존하지 말고 참고용으로만 쓰도록 당부하는 데서 멈췄습니다.

6-2. 문제 재정의 — "질문 역량"에서 "규칙의 시스템화"로

같은 내용을 물어도 어떻게 묻느냐에 따라 답의 품질이 천차만별이었습니다. 검토할 만한 답을 받으려면 질문의 질이 먼저 올라가야 한다고 보고, 개인 차원에서 두 가지를 시도했습니다.

그러나 두 방법 모두 한계가 분명했습니다. 개인 코칭은 인원 수에 비례해 확장되지 않고, 무엇보다 새 팀원이 합류할 때 그 지식 베이스가 이전되지 않습니다. 같은 설명을 매번 처음부터 반복하게 됩니다.

전환점 — 이 지점에서 문제를 다시 정의했습니다. 해결해야 할 것은 "개인이 질문을 잘하게 만드는 일"이 아니라 "팀의 규칙을 시스템으로 남기는 일"이었습니다. 개인의 주의력에 의존하는 해법은 반복되는 실수를 막지 못한다는, 다른 프로젝트에서와 같은 판단이었습니다.

6-3. 도구 선정 기준 — 성능이 아니라 시스템화 가능성과 비용

검토 시점에 좋다고 평가되는 도구는 이미 많았고, 모델 성능은 상향 평준화되어 선정의 결정 요인이 아니라고 판단했습니다. 그래서 기준을 두 가지로 세웠습니다.

기준이유
팀 단위 컨텍스트 공유가 가능한가각자의 프롬프트로 각자 학습하면 축적물이 개인에게 남습니다. 학습된 컨텍스트도 회사의 자원이라고 보았기 때문에, 하나의 컨텍스트를 팀이 공유할 수 있어야 했습니다
토큰 비용을 통제할 수 있는가도입 제안이 승인되려면 비용 구조를 먼저 설계해 둬야 합니다. 통제 수단이 없으면 사용량이 늘수록 도입 자체가 재검토 대상이 됩니다

이 기준으로 Claude Code 팀 플랜을 채택했습니다. 하나의 컨텍스트에 개발팀의 문화·규칙·컨벤션을 학습시켜 두면, 신규 합류자가 시트를 할당받아 쓰는 순간부터 팀 기준을 그대로 이어받고 질문 방식에도 최소한의 안전선이 생긴다고 판단했습니다. 개인 코칭으로는 만들 수 없었던 인수인계 경로가 도구 자체에 생기는 셈입니다.

6-4. 4계층 컨텍스트 체계 설계 — 규칙 시스템화와 비용 절감을 동시에

규칙을 한 파일에 몰아넣으면 관련 없는 작업에도 전체가 로드되어 비용이 발생하고, 정확도도 떨어집니다. 반대로 흩어 놓으면 어디를 봐야 할지 알 수 없습니다. 그래서 규칙의 적용 범위와 로드 단위를 일치시키는 방향으로 계층을 나눴습니다.

계층담는 내용로드 범위
전사 개발 공통커밋 메시지·브랜치 네이밍 규칙, "원인/이유 + 수정" 변경 이력 포맷, 문서 작성 기준최초 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-5. 결과

남은 과제 — 생산성은 아직 관찰에 기반한 추정입니다. 도입 효과를 계속 근거로 쓰려면 리뷰 소요 시간이나 재작업 횟수처럼 측정 가능한 지표를 정해 두고 추이를 남기는 것이 다음 단계 과제라고 보고 있습니다.
도입 후에도 유지하는 전제 — 6-1의 판단은 철회하지 않았습니다. 컨텍스트 체계는 모르고 쓰는 것을 허용하기 위한 장치가 아니라, 결과물이 팀의 기준 안에 있도록 하는 안전선입니다. 코드리뷰에서 근거를 설명해야 하는 책임은 그대로 두었습니다.

학력

일하는 방식

아래 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).