Navigation

2. 이름과 모듈 경계

식별자와 경로, 가져오기, 공개 진입점, 상수 위치에 소유자와 출처를 드러냅니다. 타입 이름은 값의 역할과 수명을 나타내고, 소유자 경로에 있는 정보를 반복하지 않습니다. 여기서 소유자는 자기 폴더가 있는 모듈 하나입니다. 그 폴더 안 파일은 그 소유자만 씁니다.

2.1 프로젝트 전반의 상수는 루트 constant 폴더에 주제별 파일로 둡니다

프로젝트 전반의 상수를 주제별로 모아 위치와 이름을 일관되게 유지합니다.

상수 자리 고르기

상수 위치는 사용처 수가 아니라 소유자로 정합니다. 소유자 전용 배치는 규칙 2.2를 따릅니다.

flowchart LR
	q1{"소유자를 지워도<br>값이 남는가?"} -- 예 --> q2{"배포 환경마다<br>달라지는가?"} -- 예 --> r3("config 폴더")
	q1 -- 아니요 --> r1("소유자 _constant 폴더")
	q2 -- 아니요 --> r2("루트 constant 폴더")
소유 범위파일이름
프로젝트 전반constant/<주제>.ts<주제>_<이름>
한 소유자<owner>/_constant/<주제>.ts<주제>_<이름>

선언과 내보내기

선언 대상규범
파일, 상수 이름파일마다 주제를 하나 정하고 상수에 주제 접두사를 붙입니다. 한 단어 상수는 만들지 않습니다
내보내기모듈 스코프에서 상수마다 이름 붙여 내보냅니다. config 같은 색인 객체로 묶지 않습니다
객체, 배열 값함께 읽히는 값이면 상수 하나로 둡니다. 펼치는 것은 내보낼 이름이지 값의 구조가 아닙니다
사용자에게 보이는 문장copy_empty_value_text처럼 copy 주제로 모아 번역 파일로 옮기기 쉽게 둡니다
환경마다 달라지는 값, 기능 플래그규칙 2.6에 따라 config에 둡니다
색상, 간격 등 디자인 토큰스타일시트의 CSS 변수를 단일 출처로 둡니다

파일, 심볼 표기는 규칙 2.3을 따릅니다. constant에는 코드와 함께 바뀌는 값만 둡니다.

프로젝트 전반의 값을 쓰는 자리에서 선언합니다.

page/products/pg-products.tsxts
const default_page_size = 20;
const request_timeout_ms = 20_000;

const productClient = createClient({timeoutMs: request_timeout_ms});
const productQuery = useProductQuery({client: productClient, pageSize: default_page_size});
page/orders/pg-orders.tsxts
// 다른 화면이 같은 값을 다시 선언
const default_page_size = 20;

const orderQuery = useOrderQuery({pageSize: default_page_size});

루트 constant 폴더에 둔 이름을 쓰는 자리에서 가져옵니다.

page/products/pg-products.tsxts
import {api_request_timeout_ms} from "@/constant/api";
import {pagination_default_page_size} from "@/constant/pagination";

const productClient = createClient({timeoutMs: api_request_timeout_ms});
const productQuery = useProductQuery({client: productClient, pageSize: pagination_default_page_size});
page/orders/pg-orders.tsxts
import {pagination_default_page_size} from "@/constant/pagination";

const orderQuery = useOrderQuery({pageSize: pagination_default_page_size});

객체 하나에 모아 색인을 손으로 유지합니다.

constant/config.tsts
export const config = {
	api: {request_timeout_ms: 20_000},
	pagination: {default_page_size: 20},
} as const;

주제 파일에 상수를 하나씩 이름 붙여 내보냅니다.

constant/api.tsts
/**
 * 요청 하나의 최대 대기 시간. 게이트웨이가 30초에 끊으므로 그 전에 실패 표시
 */
export const api_request_timeout_ms = 20_000;

// constant/pagination.ts
/**
 * 목록 화면이 처음 불러오는 개수
 */
export const pagination_default_page_size = 20;

함께 보기

2.2 소유자 전용 상수는 소유자 _constant 폴더에 둡니다

소유자 전용 상수를 함께 관리하고 파일 이름과 상수 이름에서 소유자 표현을 반복하지 않습니다.

한 소유자의 상수는 그 소유자 아래 _constant에 둡니다. 루트와 소유자를 구분하는 기준은 규칙 2.1를 따릅니다.

대상배치, 이름
상수_constant/<주제>.ts에 <주제>_ 접두사로 선언합니다
소유자 문맥폴더 경로가 이미 드러내므로 이름에 반복하지 않습니다. page/product-detail/_constant/legend.ts에는 legend_hit_tolerance_px를 둡니다
파서 묶음, 스키마 등 함수를 담은 계약같은 _constant에 계약별 파일로 둡니다
파일이 하나뿐인 경우_constant 폴더를 유지합니다
소유자를 지워도 남는 값루트 상수 규칙에 따라 옮깁니다

계약 파일의 이름은 규칙 2.3을 따릅니다. 소유자 아래에 config, constants, common 폴더는 만들지 않습니다.

한 소유자의 상수를 루트로 올립니다.

constant/chart.tsts
// 상품 상세 화면만 쓰는 값이 루트에 있음
export const chart_axis_tick_count = 6;

소유자 아래 주제 파일에 둡니다.

page/product-detail/_constant/chart.tsts
/**
 * 상품 상세 차트의 축 눈금 수. 표시 폭이 좁아 여섯을 넘으면 라벨 겹침
 */
export const chart_axis_tick_count = 6;

파일 이름에 소유자 이름을 되풀이하고 주제를 객체 하나에 모읍니다.

page/product-detail/_constant/product-detail.tsts
export const product_detail_config = {
	chart_axis_tick_count: 6,
	table_page_size: 20,
} as const;

주제마다 파일을 나누고 상수를 개별 이름으로 내보냅니다.

page/product-detail/_constant/chart.tsts
export const chart_axis_tick_count = 6;

// page/product-detail/_constant/table.ts
export const table_page_size = 20;

함께 보기

2.3 파일과 심볼은 역할에 맞는 표기를 씁니다

파일과 심볼의 표기가 역할을 드러내 읽는 사람이 종류를 바로 압니다.

역할별 표기

파일과 심볼은 선언 문법이 아니라 역할에 맞게 이름 짓습니다. const로 선언해도 함수, 훅, 스키마, API 결과, 요청 객체, 지역 파생값을 불변 데이터 상수로 보지 않습니다.

자리표기
파일 이름kebab-case
폴더 이름kebab-case 단수. 프레임워크가 강제하는 이름과 활성화된 프레임워크 규약의 라우트 그룹 표기는 예외입니다
타입, interface, 컴포넌트PascalCase
모듈 스코프 불변 데이터 상수, 값 집합과 그 소유 하위 키snake_case
그 외 변수, 함수, 객체 키, 스키마 키, 타입 필드camelCase

불변 데이터 상수는 한 번 선언해 같은 의미로 쓰는 리터럴, 기본값, 값 집합, 조회표입니다. 함수는 동사, 상수는 주제 접두사와 snake_case, 컴포넌트는 레이어 접두사로 종류를 드러냅니다. 한 단어 상수는 만들지 않습니다. 함수 파일 이름은 내보낸 이름(format-usd.ts → formatUsd), 상수 파일 이름은 공유하는 주제(api.ts → api_*)입니다.

외부 계약이 정한 이름

외부 계약이 정한 이름과 키는 원래 표기를 유지합니다. API 응답, 요청, 생성 DTO, 라이브러리 인자, DOM 속성, 환경 변수와 모듈 상수에 담긴 외부 설정도 같습니다.

역할과 맞지 않는 표기를 씁니다.

userSettings.tsts
// 우리가 선언한 타입은 PascalCase, 그 필드는 camelCase
interface User_Profile {
	avatar_url: string;
}

파일 이름은 kebab-case, 타입 필드는 camelCase로 씁니다.

user-settings.tsts
/**
 * 사용자 프로필
 */
interface UserProfile {
	/**
	 * 프로필 이미지 주소
	 */
	avatarUrl: string;
}

불변 데이터 상수와 값 집합의 이름과 키를 camelCase로 적습니다.

const retryPolicy = {
	maxAttempts: 3,
} as const;

const productStatus = {
	draft: "draft",
	waitingReview: "waiting_review",
	active: "active",
} as const;

불변 데이터 상수와 값 집합은 이름과 상수 키를 모두 snake_case로 적습니다.

/**
 * 요청 재시도 정책. 하위 키도 상수 키
 */
const retry_policy = {
	max_attempts: 3,
} as const;

/**
 * 상품 게시 상태 값 집합
 */
const product_status = {
	draft: "draft",
	waiting_review: "waiting_review",
	active: "active",
} as const;

밖으로 나가는 키를 우리 표기로 바꿉니다.

// 서버 계약은 {category_id, product_name}인데 우리 표기로 바꿔 전송
/**
 * 상품 저장 요청 조립
 */
const toProductSaveBody = (values: ProductFormValues) => {
	return {
		categoryId: values.categoryId,
		productName: values.name.trim(),
	};
};

밖으로 나가는 키만 받는 쪽 표기를 그대로 씁니다.

/**
 * 상품 저장 요청 조립. 서버 계약이 snake_case라 그 표기 그대로 전달
 */
const toProductSaveBody = (values: ProductFormValues) => {
	return {
		category_id: values.categoryId,
		product_name: values.name.trim(),
	};
};

2.4 index.ts 배럴을 만들지 않고 필요한 파일에서 바로 가져옵니다

배럴이나 재노출 계층 없이 선언의 출처를 직접 확인할 수 있습니다.

필요한 파일에서 직접 가져오고 선언 앞에 export를 붙여 이름으로 내보냅니다. index.ts 배럴이나 파일 끝의 export {…} 목록은 만들지 않습니다.

형태판정
역할 폴더나 여러 파일을 index.ts로 재노출배럴이므로 만들지 않습니다
같은 파일이 소유한 export const Dialog = { Root, Header } as const재노출 계층이 아닌 조립 객체이므로 허용합니다
default 내보내기vite.config.ts처럼 도구가 요구하는 계약에만 씁니다
타입만 가져오기import type으로 실행 의존과 구분합니다

경로 형식은 규칙 2.5를 따릅니다. 같은 경로라도 값, 타입 가져오기를 바꾸면 이 규칙을 적용합니다.

배럴과 섞인 가져오기로 경계를 흐립니다.

import {pagination_default_page_size, toDisplayDate, UserProfile} from "./index";

필요한 파일에서 이름으로 바로 가져옵니다.

import type {UserProfile} from "@/type/user-profile";
import {pagination_default_page_size} from "@/constant/pagination";
import {toDisplayDate} from "@/util/date/to-display-date";

default로 내보내 사용처마다 다른 이름이 생깁니다.

component/ui/tabs/ui-tabs.tsxtsx
const UiTabs = (props: UiTabsProps) => {
	return <div role="tablist">{props.children}</div>;
};

export default UiTabs;
page/settings/pg-settings.tsxtsx
// 사용처마다 이름을 지어 같은 컴포넌트가 파일마다 다른 이름
import Tabs from "@/component/ui/tabs/ui-tabs";

선언 앞에 export를 붙여 사용처가 그 이름으로 가져옵니다.

component/ui/tabs/ui-tabs.tsxtsx
export const UiTabs = (props: UiTabsProps) => {
	return <div role="tablist">{props.children}</div>;
};
page/settings/pg-settings.tsxtsx
import {UiTabs} from "@/component/ui/tabs/ui-tabs";

함께 보기

2.5 가져오기 경로는 @/로 씁니다

가져오기 경로를 통일하고 가져오는 파일의 위치로 접근 범위를 판단합니다.

경로 표기

심볼은 @/ 절대경로로 가져옵니다. 편집기 자동 가져오기가 만드는 형식입니다. 심볼 없이 같은 폴더의 파일만 불러올 때는 ./를 허용하며, ../는 쓰지 않습니다.

flowchart LR
	q1{"심볼을<br>가져오는가?"} -- 아니요 --> q2{"같은 폴더의<br>파일인가?"} -- 예 --> r2("./ 로 파일만 불러옴")
	q1 -- 예 --> r1("@/ 절대경로")
	q2 -- 아니요 --> r1

접근 가능한 소유 경계는 경로 표기가 아니라 가져오는 파일의 위치로 판단합니다. 가져오기 방향은 프레임워크 규칙을 따릅니다.

src 아래 루트 폴더

src 아래 루트담는 것
componentcomponent/ui, component/widget
page화면과 라우트 프레임. 그룹 표기와 진입 파일의 자리는 활성화된 프레임워크 규약이 정합니다. 내부 파일은 그 소유자만, 진입 파일은 라우터만 가져옵니다
route 또는 routes.tsx라우트 트리 선언. 파일 하나면 src/routes.tsx로 두고, 여러 선언 파일이 필요하면 src/route로 묶습니다. page의 진입 파일을 가져와 URL과 잇습니다
constant프로젝트 전반의 상수
config환경마다 달라지는 값
util프로젝트 전반의 함수. 받는 값의 종류별 폴더로 묶습니다
type프로젝트 전반의 계약
hook여러 소유자가 쓰는 훅
store여러 화면의 공유 상태. 파일 이름은 use-<name>-store.ts입니다
service서버 통신 클라이언트
asset아이콘 등 정적 자원

루트의 소유자는 프로젝트이며 constant, util, type, hook에도 소유자 아래 역할 폴더의 규칙을 적용합니다. 배치는 규칙 2.1와 규칙 3.4이 정합니다.

상대경로로 심볼을 가져옵니다.

page/product-detail/product-table-section/pg-product-table-section.tsxts
import {PgReviewSection} from "./_pg-review-section";
import {toSummary} from "../_function/to-summary";

심볼은 @/, 같은 폴더의 CSS 파일만 ./로 씁니다.

page/product-detail/product-table-section/pg-product-table-section.tsxts
import {toSummary} from "@/page/product-detail/_function/to-summary";
import {PgReviewSection} from "@/page/product-detail/product-table-section/_pg-review-section";

import "./pg-product-table-section.css";

함께 보기

2.6 환경 값은 config/env.ts를 거쳐서 읽습니다

환경마다 달라지는 값이 쓰는 파일로 흩어지지 않고 한 파일에서 읽힙니다.

값의 자리

환경 값은 루트 config/env.ts에서만 읽고 env_ 상수로 내보냅니다. 다른 파일은 그 이름을 쓰며 import.meta.env와 process.env를 직접 읽지 않습니다.

flowchart LR
	q1{"배포 환경마다<br>달라지는가?"} -- 예 --> q2{"기능을 켜고 끄는<br>플래그인가?"} -- 예 --> r2("config/feature.ts 의<br>feature_ 상수")
	q1 -- 아니요 --> r1("constant 폴더")
	q2 -- 아니요 --> r3("config/env.ts 의<br>env_ 상수")

feature_ 상수는 env_ 값에서 파생합니다. 배포 환경은 프로젝트 단위이므로 config는 루트에만 둡니다.

읽을 때 확인할 것

읽을 때 확인할 것처리
키가 없음리터럴로 덮지 않고 규칙 5.1에 따라 드러냅니다
VITE_ 등 외부 접두사읽는 자리에서 내부 이름으로 바꿔 앱 안에 퍼지지 않게 합니다
비밀값클라이언트에 노출되는 접두사로 읽지 않습니다. 해당 값은 브라우저에서 보입니다

쓰는 파일마다 직접 읽고 없을 때 리터럴로 덮습니다.

service/product-client.tsts
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000";
service/order-export-client.tsts
// 다른 파일이 같은 키를 다시 읽고 같은 리터럴로 덮음
const orderExportBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000";

config/env.ts가 한 번 읽고 없으면 드러냅니다.

config/env.tsts
if (!import.meta.env.VITE_API_BASE_URL) {
	throw new MissingEnvironmentValueError("VITE_API_BASE_URL");
}

/**
 * API 서버 주소. 배포 환경마다 다름
 */
export const env_api_base_url = import.meta.env.VITE_API_BASE_URL;
service/product-client.tsts
import {env_api_base_url} from "@/config/env";

const productClient = createClient({baseUrl: env_api_base_url});

함께 보기

2.7 타입 이름은 값의 역할과 수명으로 짓습니다

이름만 읽고 값이 무엇이며 어느 시점에 존재하는지 구분할 수 있습니다.

역할어 고르기

값의 역할과 수명을 판단한 뒤, 의미를 더하는 역할어만 붙입니다. 도메인 명사로 충분하면 ChartPoint, TableRow처럼 씁니다.

역할어사용하는 때
Params함수나 훅의 여러 입력을 객체 하나로 묶을 때
Options호출부가 동작을 선택적으로 조절할 때
Payload이벤트, 적용, 저장 경계를 한 번 넘어가는 메시지일 때
State시간에 따라 바뀌며 소유자가 보관할 때
Draft아직 적용하거나 저장하지 않은 편집 중 값일 때
Snapshot한 시점의 목록, 상태, 메타데이터를 함께 고정할 때
Content컴포넌트나 섹션이 바로 소비할 완성된 내용 묶음일 때
Config동작이나 표시 정책을 선언할 때
Resolved*원본, 기본값, 현재 조건을 합쳐 값이 확정됐을 때
Condition필터나 적용 여부를 가르는 조건일 때
Criterion정렬, 평가 기준 한 건일 때
Setting사용자가 고르거나 조절하는 설정 한 건일 때
Row, Column, Item, Point, Series컬렉션 안 한 요소의 역할이 분명할 때
Result더 구체적인 결과 명사가 없을 때만
Spec외부 명세나 검증할 요구사항 자체를 나타낼 때만
Model식별성, 행동, 도메인 규칙을 가진 실제 모델일 때만

이름을 정하는 기준

이름을 정할 대상기준
이미 필요한 계약역할어를 고릅니다. Params, Content, Snapshot을 쓰려고 타입을 만들지 않으며, 맞는 기존 계약이나 추론되는 익명 결과를 유지합니다
소유자 안의 타입폴더 경로가 이미 드러내는 도메인을 반복하지 않습니다. orders/_type/에서는 SummarySnapshot입니다
소유자 밖으로 내보내는 타입문맥이 사라지거나 이름이 충돌할 때만 필요한 도메인 접두를 유지합니다
타입과 파일 이름summary-snapshot.ts처럼 실제 명사를 씁니다
외부, 생성된 계약이름과 DTO 같은 접미사를 보존합니다. 내부 계약에는 이를 구별용 접미사로 붙이지 않습니다
Props, Handle, Slot, Renderer해당 프레임워크 규칙을 따릅니다

단순 가공, 표시 결과에는 VM, ViewModel, 막연한 Model과 그 대응 파일 이름을 쓰지 않습니다.

소유자와 막연한 화면 계약 접미사를 반복합니다.

/**
 * 주문 화면 데이터
 */
interface OrderViewModel {
	/**
	 * 조회 시점의 행 목록
	 */
	rows: OrderRow[];
	/**
	 * 조회에 사용한 필터
	 */
	filters: OrderFilters;
}

const orderVM: OrderViewModel = response.data;

한 조회 시점에 고정된 값이라는 역할을 이름에 표시합니다.

// page/orders/_type/summary-snapshot.ts: 폴더 이름이 이미 orders
/**
 * 한 조회 시점의 주문 요약 목록과 조건
 */
interface SummarySnapshot {
	/**
	 * 조회 시점의 행 목록
	 */
	rows: OrderRow[];
	/**
	 * 조회에 사용한 필터
	 */
	filters: OrderFilters;
}

const summarySnapshot: SummarySnapshot = responseOrderSummarySuspense.data;

함께 보기