검색/TypeScript 프런트엔드 모노레포에서 패키지 경계를 지키며 작업하기

TypeScript 프런트엔드 모노레포에서 패키지 경계를 지키며 작업하기

모듈형 TypeScript 프런트엔드 코드베이스를 AI가 고칠 때 쓴다. 패키지 경계와 프레임워크에 기대지 않는 코어, 상태를 담당 action으로만 바꾸는 법, 파일과 테스트의 위치, 커밋 전 검사, 릴리스 순서, UI 계층 나누는 법을 다룬다.

이 AI 규칙을 사용하려면
## 패키지 경계는 공개 export로만 넘기 - 멀티 패키지 워크스페이스에서는 각 패키지에 단일 책임만 부여하고, 경계를 넘나들 때는 반드시 공개 패키지 export를 통해서만 접근한다. 애플리케이션 코드에서 다른 패키지의 내부 구현을 직접 import하지 않는다. 내부 구현을 import하면 호출하는 쪽이 비공개 구조에 종속되고, 구조가 바뀔 때 깨지기 때문이다. 경계는 리뷰가 아니라 자동화된 린트와 아키텍처 검사로 강제하고, 적절한 위치가 없으면 의도적으로 새로 만들고 문서화된 컨벤션을 갱신한다. - 공유 코어는 프레임워크에 종속되지 않게, 그리고 브라우저 DOM에서 자유롭게 유지한다. UI 프레임워크 바인딩과 DOM/CSS 반영은 전용 레이어에 둔다. WASM 모듈이나 대형 SDK 같은 무거운 런타임 의존성은 각 호출 지점에서 그때그때 import하지 말고, 하나의 중앙 모듈에서 로드해 호출자에게 주입된 인스턴스를 전달한다. - 다른 패키지의 API를 전달하기만 하는 재수출(re-export) 셰임 파일을 만들지 않는다. 호출 지점에서 소유 패키지를 직접 import한다. 이미 확립된 공개 API를 유지하기 위한 목적일 때만 호환성 배럴(barrel)이 소유 패키지를 재수출할 수 있다. - 더 작은 import가 의존성 의도를 명확하게 드러낸다면, 하나의 포괄적인 배럴보다 좁고 구체적인 서브패스 export를 우선한다. ## 공유 상태는 담당 action을 통해서만 바꾸기 - 큰 서브시스템은 계속 커지는 하나의 파일 대신, 공유 컨텍스트와 도메인별 액션 모듈(viewport, selection, history, structure 등)로 구성한다. 동작을 추가하기 전에 기존 폴더와 주변 패턴을 살펴보고, 가장 잘 맞는 도메인 모듈에 변경을 배치한다. 지금 열려 있는 파일이라는 이유만으로 관련 없는 로직을 쌓아 넣지 않는다. - 모든 상태 변경은 반드시 소유 setter나 action을 거치도록 해서, 이벤트・undo 배치・파생 업데이트 같은 부수 효과가 일관되게 발생하게 한다. 공유 상태 필드에 직접 대입하지 않는다. 직접 대입하면 이런 부수 효과가 조용히 실행되지 않는다. 컴포넌트 안에 모듈 수준의 가변 상태를 두지 말고, 공유 상태는 스토어에 보관한다. - 변경 사항은 타입이 지정된 이벤트 시스템을 통해 노출하고, 구독자는 폴링이 아니라 이벤트에 반응하게 한다. 도메인 모듈들은 애플리케이션이나 프레임워크 코드를 import하는 방식이 아니라 컨텍스트 객체를 통해 상태를 공유한다. - 애플리케이션 래퍼는 코어 주위에 반응형 상태, 영속성, 플랫폼 I/O를 추가할 수 있지만, 코어 자체는 계속 프레임워크에 독립적으로 유지한다. 공유 코드에서 브라우저 API를 사용할 때는 사용 전에 명시적인 런타임 체크로 보호한다. ## TypeScript에서 any와 non-null 단언 쓰지 않기 - `any`는 금지한다. 정확한 타입, 제네릭, 선언 병합(declaration merging)을 사용한다. non-null assertion도 금지한다. 대신 가드, 옵셔널 체이닝, nullish coalescing을 사용한다. 두 규칙 모두 타입 체커가 실제 오류를 가리지 않고 잡아낼 수 있게 해준다. - 느슨한 원시 타입보다 정확한 유니온 타입을 우선한다 — `number | string | null` 대신 작은 문자열 리터럴 유니온을 사용하는 식이다. 이미 이름이 붙은 공유 타입이 있다면 타입 정의를 인라인으로 다시 쓰지 말고 import해서 재사용한다. - 반복되거나 여러 기능에 걸쳐 쓰이는 상수는 코드베이스 곳곳에 리터럴로 중복하지 말고, 공유 상수 모듈에 이름을 붙여 정의한다. - 식별자나 보안과 관련된 어떤 값에도 비암호화 난수 소스를 사용하지 않는다. 항상 암호학적 RNG를 사용한다. 중첩된 데이터를 변경할 때는 얕은 spread가 아니라 structured clone으로 중첩 객체를 깊은 복사한다. ## 폴더를 도메인별로 나누고 테스트를 종류별 자리에 두기 - 깊은 구조적 격식보다는 도메인 네임스페이스 기준으로 구성한다. 애플리케이션 서비스, 라우트나 레이아웃 뷰, UI는 각각 별도의 최상위 영역에 둔다. - 파일이 여러 개인 도메인은 같은 접두사를 반복하는 형제 파일들 대신 서브폴더를 사용한다. 도메인에 두 번째 파일이 생기는 시점에 이름에 접두사를 붙이지 말고 바로 폴더를 만든다 — `selection-hit-test`보다 `selection/hit-test`를 우선한다. - `index`, `types`, `context` 같은 관례적인 엔트리포인트 파일명은 소문자로 유지하고, 컴포넌트 파일은 해당 생태계의 케이싱 관례를 따른다. 코드나 테스트를 즉흥적으로 아무 데나 두지 않는다. 적절한 위치가 없으면 의도적으로 새로 만들고 문서화한다. - 테스트 배치는 엄격하게 위치 기준으로 유지한다. 각 테스트 유형은 정해진 디렉터리(end-to-end, integration, unit, 공유 헬퍼, 패키지별)에 둔다. 사용자에게 보이는 동작은 end-to-end 테스트에, 내부 상태와 데이터 검증은 unit 테스트에 둔다. 임시 테스트 파일이나 프로파일링용 테스트 파일은 절대 커밋하지 않는다. ## 같은 코드를 직접 짜지 말고 기존 라이브러리 쓰기 - 이미 의존성이 해주는 일을 직접 구현하지 않는다. 먼저 기존 의존성을 확인하고, 맞는 것이 없으면 직접 구현을 인라인으로 넣기보다 품질 좋은 라이브러리를 도입한다 — 예를 들어 unified diff에는 diff 라이브러리를, 색상 연산에는 직접 짠 루프가 아니라 color 라이브러리를 사용한다. - 커스텀 UI, 컨트롤, 컴포저블을 만들기 전에, 로컬 사용 방식에서 추측하지 말고 의존성의 upstream 문서를 읽는다. 머신 리더블한 문서 엔트리포인트가 제공되면 그것을 우선한다. upstream 문서가 로컬 패턴과 어긋나면 현재 upstream API를 따르고, 로컬 래퍼는 의도적으로 갱신한다. - 유지보수 가능한 프로그램과 헬퍼는 루트에 즉흥적인 스크립트로 두지 말고, 도메인별로 구성된 전용 tools 영역에 각각 집중된 테스트와 함께 둔다. scripts 영역은 tools 영역에서 import하는 아주 작은 호환용 엔트리포인트 셰임만을 위해 남겨두고, 구현 로직은 셰임에 넣지 않는다. - tools 영역이 제품 코드와 동일한 기준을 유지하도록, 표준 체크에 툴링 테스트와 린트・포맷 커버리지를 포함시킨다. ## 커밋 전에 품질 검사를 모두 통과하고 Conventional Commits로 쓰기 - 커밋 전과 리뷰를 위해 변경 사항을 여는 전, 두 번 모두 전체 품질 게이트를 실행한다: 오류 0건인 타입 인식 린트와 typecheck, import 정렬을 포함한 포매터, 클론 0건인 복사-붙여넣기・중복 탐지, 그리고 관련된 unit・end-to-end 테스트 스위트. - 제출 전에 셀프 리뷰를 한다. 중복이 늘어나면 공유 헬퍼를 추출하거나 기존 타입을 재사용한다. 이름이 붙은 타입과 중복되는 인라인 타입 정의, 그리고 복사-붙여넣기된 로직을 제거한다. 파일은 수백 줄 이내로 유지하고, 커지면 도메인별로 분리한다. - 커밋 메시지는 Conventional Commits 형식(`feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `build`, `ci`, `chore`)으로 작성한다. 첫 줄은 소문자 타입으로 시작하고 명령형으로 짧게, 스코프는 선택적으로 붙인다. 본문에는 근거를 적고, 본문의 각 줄은 대문자로 시작한다. - 제목과 본문에서 제품명과 약어의 대소문자 표기는 그대로 유지하고, 약어를 소문자 문장으로 뭉개지 않는다. 스코프는 정직하게 가장 좁은 범위로 쓰고, 변경이 관련 없는 여러 영역에 걸쳐 있으면 스코프를 생략한다. ## 정해진 순서로 릴리스하고 체인지로그를 최신으로 유지하기 - 정해진 순서의 릴리스 절차를 따른다: 버전이 적혀 있는 모든 파일에서 버전을 올리고, changelog의 "Unreleased" 섹션 항목을 날짜가 붙은 새 버전 헤딩 아래로 옮기고, 릴리스 메시지로 커밋한 다음 태그를 만들어 푸시한다. 태그를 붙이기 전에 서명이나 릴리스용 시크릿이 존재하는지 확인한다. - 빌드・릴리스 워크플로를 패키징과 배포의 단일 진실 공급원(single source of truth)으로 취급하고, 중복된 패키지 목록이 있다면 이것과 동기화 상태를 유지한다. 프로덕션 배포는 릴리스 태그나 명시적인 수동 실행에만 걸리게 하고, 일반적인 default 브랜치 푸시로는 걸리지 않게 해서 배포가 항상 의도적으로 이루어지게 한다. - 문서는 항상 최신 상태로 유지한다. 동작이 바뀔 때마다 changelog의 "Unreleased" 섹션과 사용자 대상 문서를 갱신하고, 아키텍처나 컨벤션이 바뀌면 기여자・에이전트 참고 문서를 갱신한다. - 공개된 문서에는 추측성 내용이나 내부 구현 계획을 넣지 않는다 — 임시 계획은 무시되는 스크래치 영역에 두고, 지속적으로 유효한 방향만 정본 문서로 정리한다 — 그리고 자리만 채우는 로컬라이즈 사본을 만들지 않는다. 실제 번역이 준비되기 전까지는 로컬라이즈된 내비게이션을 정본 페이지로 연결한다. ## 공유 UI 기본 컴포넌트가 앱 코드를 import하지 않게 하기 - 공유되는 재사용 가능한 UI 프리미티브 레이어를 기능(feature) 레이어, 셸(shell) 레이어와 분리한다. 공유 프리미티브와 저수준 스타일 컨트롤은 애플리케이션 서비스, 스토어, 기능 패널을 import해서는 안 된다. 기능별 컨트롤은 해당 기능 컴포넌트가 소유한다. - 조건부 유틸리티 클래스 분기나 슬롯마다 일회성 class prop을 흩뿌리지 말고, 한 곳에서 정의되고 인스턴스별로 병합되는 정적이고 타입이 지정된 테마 변형(variant)을 통해 스타일링을 해결한다. 시각적 상태는 시맨틱 data attribute를 통해 바인딩하고, 타입이 지정된 variant는 스크립트에서 해결한다. - 테스트 로케이터로는 사용자 대상 role, label, text를 우선한다. 식별용으로는 스코프가 지정된 시맨틱 attribute를 노출하고, 공유 컴포넌트 안에서 전역적으로 고유한 id를 만들어내기보다는 드문 통합 경계에만 전용 test id를 아껴서 사용한다. - 컴포넌트가 명시적으로 렌더리스(renderless) 프리미티브가 아닌 이상, 명령형 setter나 action을 슬롯으로 전달하지 않는다. 대신 모델 바인딩, emit되는 이벤트, 일반 prop을 우선한다. 원시 인라인 SVG나 유니코드 심볼 대신 아이콘 라이브러리를 사용하고, 단축키 텍스트를 라벨에 직접 박아넣지 말고 커맨드 메타데이터에서 키 바인딩을 렌더링해서 라벨을 시맨틱하게 유지한다.