백엔드 개발자를 위한 프론트엔드 문법 함정과 디버깅
이 문서는 문법 튜토리얼이 아니라 실전 버그 예방 노트다. let/const, props, useState 같은 입문 설명보다 개인 프로젝트에서 실제로 부딪히는 JavaScript, TypeScript, React, CSS, 빌드 환경의 함정과 디버깅 포인트를 정리한다.
truthy/falsy와 기본값 처리는 왜 위험할까?
문제가 되는 이유
- JavaScript의
||는 왼쪽 값이 falsy면 오른쪽 값을 선택한다. 0,"",false도 falsy라서 정상 값이 기본값으로 덮일 수 있다.??는null또는undefined일 때만 오른쪽 값을 선택한다.- 할인율, 재고 수량, 공개 여부처럼
0과false가 도메인 의미를 가진 값이면 기본값 처리 하나가 곧 데이터 오류가 된다.
잘못된 예시
type Product = {
name: string;
discountRate?: number | null;
};
function DiscountBadge({ product }: { product: Product }) {
const rate = product.discountRate || 10;
return <span>{rate}% 할인</span>;
}discountRate가 0이면 “할인 없음”이 아니라 10% 할인으로 표시된다.
개선 예시
function DiscountBadge({ product }: { product: Product }) {
const rate = product.discountRate ?? 10;
return <span>{rate}% 할인</span>;
}판단 기준
0,"",false가 의미 있는 값이면??를 우선 검토한다.- “비어 있음”을 넓게 보고 싶을 때만
||를 쓴다. - 서버 응답 nullable 필드는
null과undefined를 명시적으로 모델링한다.
디버깅 포인트
- UI에서 0원, 0개, 빈 문자열,
false설정이 사라지면||를 검색한다. - API 응답 값이
null인지0인지 Network panel에서 먼저 확인한다. - 기본값이 필요한 필드마다 “없음”이
null인지,undefined인지, 빈 문자열인지 API 계약을 확인한다.
==와 ===를 섞어 쓰면 어떤 버그가 생길까?
문제가 되는 이유
==는 타입 변환 후 비교한다.- form input, URL query param, JSON 응답이 섞이면
"1"과1이 우연히 같아져 버그를 숨긴다.
잘못된 예시
const selectedCategoryId = searchParams.get("categoryId"); // string | null
const selected = categories.find((category) => category.id == selectedCategoryId);개선 예시
const rawCategoryId = searchParams.get("categoryId");
const categoryIdText = rawCategoryId?.trim();
const selectedCategoryId =
categoryIdText === undefined || categoryIdText === "" ? null : Number(categoryIdText);
const selected =
selectedCategoryId !== null && Number.isFinite(selectedCategoryId)
? categories.find((category) => category.id === selectedCategoryId)
: undefined;판단 기준
- 비교는
===를 기본으로 둔다. - 타입 변환이 필요하면 비교 직전에 암묵 변환에 맡기지 말고 명시적으로 변환한다.
- URL, input, storage에서 온 값은 대부분 string으로 본다.
디버깅 포인트
- 조건문이 예상보다 넓게 통과하면 값과 함께
typeof를 찍는다. - ESLint
eqeqeq규칙을 켜서 우발적==를 막는다. - URL query, form value, storage value를 domain 타입으로 변환하는 함수를 따로 두면 비교 버그가 줄어든다.
async/await와 반복문을 조합할 때 무엇을 조심해야 할까?
문제가 되는 이유
forEach는 내부asynccallback을 기다리지 않는다.- 순차 실행이 필요한지, 병렬 실행이 가능한지에 따라 코드가 달라져야 한다.
잘못된 예시
async function uploadAll(files: File[]) {
files.forEach(async (file) => {
await uploadFile(file);
});
toast.success("업로드 완료");
}toast가 실제 업로드 완료 전에 뜰 수 있다.
개선 예시
async function uploadAll(files: File[]) {
await Promise.all(files.map((file) => uploadFile(file)));
toast.success("업로드 완료");
}순서가 중요하면 for...of를 쓴다.
async function uploadSequentially(files: File[]) {
for (const file of files) {
await uploadFile(file);
}
}판단 기준
- 서로 독립적인 요청은
Promise.all로 병렬 처리한다. - 서버 rate limit, 순서, 의존성이 있으면
for...of로 순차 처리한다. - 실패 일부를 허용해야 하면
Promise.allSettled를 검토한다. - 병렬 업로드처럼 요청 수가 많으면 전체 병렬이 아니라 동시성 제한도 고려한다.
디버깅 포인트
- 완료 toast, loading 해제, redirect가 너무 빨리 일어나면
forEach(async ...)를 찾는다. - Network panel에서 요청 종료 시점과 UI 상태 변경 시점을 비교한다.
Promise.all과 Promise.allSettled는 언제 다르게 써야 할까?
문제가 되는 이유
Promise.all은 하나라도 reject되면 전체가 reject된다.- 일부 실패를 UI에 표시하고 나머지 성공 결과를 살려야 하는 경우에는 맞지 않을 수 있다.
잘못된 예시
const [profile, notifications, recommendations] = await Promise.all([
fetchProfile(),
fetchNotifications(),
fetchRecommendations(),
]);추천 API 하나가 실패해도 전체 대시보드가 실패 화면이 된다.
개선 예시
const results = await Promise.allSettled([
fetchProfile(),
fetchNotifications(),
fetchRecommendations(),
]);
const [profileResult, notificationResult, recommendationResult] = results;
if (profileResult.status === "rejected") {
throw profileResult.reason;
}
const notifications =
notificationResult.status === "fulfilled" ? notificationResult.value : [];
const recommendations =
recommendationResult.status === "fulfilled" ? recommendationResult.value : [];성공한 값과 실패한 이유를 화면 정책으로 나누면 더 명확하다.
const optionalPanels = await Promise.allSettled([
fetchNotifications(),
fetchRecommendations(),
]);
const failedPanelCount = optionalPanels.filter(
(result) => result.status === "rejected",
).length;판단 기준
- 핵심 데이터 하나라도 실패하면 화면 전체가 의미 없을 때는
Promise.all. - 부가 데이터 일부 실패를 허용할 때는
Promise.allSettled. - 실패 허용 여부는 UX와 API 중요도 기준으로 결정한다.
- 실패를 허용한다면 실패한 패널을 숨길지, 빈 상태로 보여줄지, 재시도 버튼을 줄지까지 정한다.
디버깅 포인트
- 대시보드가 사소한 API 하나 때문에 전체 error state가 되는지 확인한다.
- 각 요청의 필수/부가 여부를 코드 이름과 UI에서 드러낸다.
객체와 배열 mutation 때문에 React 화면이 안 바뀌는 이유는 무엇일까?
문제가 되는 이유
- React state는 참조가 바뀌었는지를 기준으로 변경을 감지한다.
- 기존 객체나 배열을 직접 수정하면 값은 바뀌었지만 참조가 같아서 렌더링이 기대대로 일어나지 않을 수 있다.
잘못된 예시
function TodoList() {
const [todos, setTodos] = useState([{ id: 1, title: "정리", done: false }]);
function toggle(id: number) {
const todo = todos.find((item) => item.id === id);
if (todo) {
todo.done = !todo.done;
setTodos(todos);
}
}
}개선 예시
function TodoList() {
const [todos, setTodos] = useState([{ id: 1, title: "정리", done: false }]);
function toggle(id: number) {
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo,
),
);
}
}판단 기준
- React state의 객체와 배열은 불변 업데이트를 기본으로 한다.
- nested state가 깊어지면 상태 구조를 평평하게 만들거나 reducer를 검토한다.
- 서버 cache를 수정할 때도 기존 cache 객체를 직접 mutate하지 않는다.
디버깅 포인트
- DevTools에서 값은 바뀌었는데 UI가 그대로면 mutation을 의심한다.
push,splice, 직접 대입을 검색하고map,filter, spread로 바꾼다.
JavaScript Date와 timezone은 왜 개인 프로젝트에서도 터질까?
문제가 되는 이유
- 날짜만 있는 값과 특정 시각을 가리키는 timestamp는 다른 모델이다.
new Date("2026-06-30")처럼 date-only 문자열을Date로 파싱하면 UTC 기준 시각으로 해석되어 사용자 timezone에 따라 전날이나 다음날처럼 표시될 수 있다.- 백엔드와 프론트가 UTC, local time, date-only의 의미를 합의하지 않으면 예약, 마감일, 생일이 틀어진다.
잘못된 예시
const dueDate = new Date(task.dueDate); // "2026-06-30"
return dueDate.toLocaleDateString();개선 예시
type Task = {
dueDate: string; // yyyy-MM-dd, date-only
};
function formatDateOnly(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new Error(`Invalid date-only value: ${value}`);
}
const [year, month, day] = value.split("-");
return `${year}.${month}.${day}`;
}시각이 필요한 값은 ISO timestamp로 명확히 다룬다.
type Comment = {
createdAt: string; // ISO 8601 instant, e.g. 2026-06-30T05:12:00Z
};
const createdAt = new Date(comment.createdAt);판단 기준
- 생일, 마감일, 영업일은 date-only로 다룬다.
- 생성 시각, 결제 시각, 로그 시각은 timezone이 있는 timestamp로 다룬다.
- 서버 API 문서에 timezone 기준을 적는다.
- 화면 포맷은 사용자의 locale을 따를지, 서비스 기준 timezone을 따를지 기능별로 정한다.
디버깅 포인트
- 날짜가 하루 밀리면 date-only 문자열을
Date로 파싱했는지 확인한다. - Network panel의 원본 값과 화면 포맷 값을 비교한다.
number input 값이 실제로는 string인 문제를 어떻게 다룰까?
문제가 되는 이유
- DOM input의
value는 대부분 string이다. - TypeScript에서 state를 number로 선언해도
event.target.value는 string으로 들어온다. - 빈 문자열,
0,NaN처리가 섞이면 form 버그가 생긴다.
잘못된 예시
const [price, setPrice] = useState(0);
return (
<input
type="number"
value={price}
onChange={(event) => setPrice(event.target.value)}
/>
);개선 예시
const [priceInput, setPriceInput] = useState("");
const [priceError, setPriceError] = useState<string | null>(null);
function parsePrice(value: string) {
if (value.trim() === "") return null;
const price = Number(value);
return Number.isFinite(price) ? price : null;
}
function submit() {
const price = parsePrice(priceInput);
if (price === null) {
setPriceError("가격을 숫자로 입력해 주세요.");
return;
}
setPriceError(null);
return saveProduct({ price });
}
return (
<>
<input
type="number"
value={priceInput}
onChange={(event) => setPriceInput(event.currentTarget.value)}
aria-invalid={!!priceError}
/>
{priceError && <p role="alert">{priceError}</p>}
</>
);React Hook Form과 Zod를 쓴다면 빈 문자열 처리까지 포함해 변환 지점을 명시한다. z.coerce.number()는 ""를 0으로 바꿀 수 있으므로 금액, 수량, 나이 입력에서 의도한 동작인지 확인해야 한다.
const schema = z.object({
price: z.preprocess(
(value) => (value === "" ? undefined : value),
z.coerce.number().min(0),
),
});판단 기준
- 입력 중인 값은 string일 수 있고, 제출 직전 domain 값으로 변환할 수 있다.
- 빈 입력을 허용해야 하면
number | ""또는 form library의 transform을 쓴다. - 금액은 floating point 대신 정수 원 단위나 decimal 정책을 백엔드와 맞춘다.
valueAsNumber를 쓰더라도 빈 값은NaN이 될 수 있으므로 validation schema에서 막아야 한다.
디버깅 포인트
- number인데 validation이 이상하면 실제 state 타입을 확인한다.
NaN이 API payload로 나가지 않는지 Network panel에서 본다.
optional chaining이 에러를 숨기는 경우는 언제일까?
문제가 되는 이유
?.는 nullish 접근 에러를 막지만, 필수 데이터가 빠진 문제까지 조용히 undefined로 만든다.- 화면이 빈칸으로 보이고 실제 계약 위반을 늦게 발견할 수 있다.
잘못된 예시
function Profile({ user }: { user?: User }) {
return <h1>{user?.name}</h1>;
}user가 반드시 있어야 하는 화면에서도 빈 제목만 보인다.
개선 예시
function Profile({ user }: { user: User }) {
return <h1>{user.name}</h1>;
}데이터 로딩 경계에서만 분기한다.
if (query.isLoading) return <Spinner />;
if (query.isError) return <ErrorView />;
if (!query.data) return <EmptyState />;
return <Profile user={query.data} />;판단 기준
- optional chaining은 진짜 optional인 데이터에만 쓴다.
- 필수 데이터는 로딩, 에러, empty 경계에서 좁힌 뒤 컴포넌트에 넘긴다.
- API 계약 위반은 조용히 숨기지 말고 에러로 드러낸다.
- UI 깊은 곳에서
?.가 늘어난다면 데이터 경계에서 view model로 변환할 시점이다.
디버깅 포인트
- 화면이 빈칸인데 에러가 없다면
?.가 과하게 쓰였는지 확인한다. - TypeScript 타입에서 optional이 실제 도메인 optional인지 임시 회피인지 확인한다.
TypeScript의 any, unknown, as, non-null assertion은 언제 위험할까?
문제가 되는 이유
any는 타입 검사를 꺼 버린다.as와!는 컴파일러를 설득할 뿐 런타임 값을 바꾸지 않는다.- 외부 입력, API 응답, storage 값은 신뢰할 수 없는 값이므로 검증 없이 확정하면 런타임 에러가 난다.
잘못된 예시
async function getMe() {
const response = await fetch("/api/me");
return (await response.json()) as User;
}
const name = user!.profile!.name;개선 예시
const userSchema = z.object({
id: z.number(),
email: z.string().email(),
profile: z.object({
name: z.string(),
}),
});
async function getMe() {
const response = await fetch("/api/me");
const body: unknown = await response.json();
return userSchema.parse(body);
}판단 기준
- 외부 입력은
unknown으로 받고 검증 후 좁힌다. as는 타입 시스템이 표현하지 못하는 좁은 경계에서만 쓴다.!는 “절대 null이 아니다”가 코드 구조로 증명될 때만 쓴다.- 생성된 API 타입처럼 신뢰 가능한 경계가 있다면
as보다 타입 생성 파이프라인 자체를 신뢰 근거로 남긴다.
디버깅 포인트
Cannot read properties of undefined가 나면as,any,!를 먼저 검색한다.- API 응답 검증 도구나 MSW fixture로 계약을 재현한다.
nullable API 응답 타입을 어떻게 표현해야 할까?
문제가 되는 이유
- 백엔드에서
null이 올 수 있는데 타입이 non-null이면 프론트는 잘못된 확신으로 코드를 작성한다. - 반대로 모든 필드를 optional로 만들면 어느 값이 필수인지 알 수 없다.
잘못된 예시
type UserResponse = {
id: number;
nickname: string;
avatarUrl: string;
};실제 응답에서 avatarUrl이 null일 수 있으면 런타임에서 깨진다.
개선 예시
type UserResponse = {
id: number;
nickname: string;
avatarUrl: string | null;
};
function toUserView(user: UserResponse) {
return {
id: user.id,
nickname: user.nickname,
avatarUrl: user.avatarUrl ?? "/images/default-avatar.png",
};
}판단 기준
null은 “값이 없음”이라는 서버의 명시적 응답이다.undefined는 프론트 객체에서 필드가 없거나 아직 계산되지 않은 경우에 더 자연스럽다.- nullable은 API 타입에 드러내고, view model에서 기본값으로 좁힌다.
- optional 필드는 “필드가 없을 수 있음”이고 nullable 필드는 “필드는 있지만 값이 없을 수 있음”이라는 차이를 팀 계약에 남긴다.
디버깅 포인트
- 이미지, 날짜, 사용자 이름에서 빈 값 에러가 나면 nullable 계약을 확인한다.
- OpenAPI와 실제 Network 응답이 같은지 비교한다.
union type과 discriminated union은 왜 실전에서 유용할까?
문제가 되는 이유
- UI는 loading, error, empty, success처럼 서로 다른 상태를 가진다.
- boolean 여러 개로 상태를 표현하면 불가능한 조합이 생긴다.
잘못된 예시
type PageState = {
isLoading: boolean;
isError: boolean;
data?: Project[];
error?: Error;
};isLoading: true이면서 data가 있는 이상한 상태가 가능하다.
개선 예시
type PageState =
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "empty" }
| { status: "success"; data: Project[] };
function ProjectList({ state }: { state: PageState }) {
switch (state.status) {
case "loading":
return <Spinner />;
case "error":
return <ErrorView error={state.error} />;
case "empty":
return <EmptyState />;
case "success":
return <List projects={state.data} />;
default:
return assertNever(state);
}
}
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}판단 기준
- 한 번에 하나만 가능한 상태는 discriminated union이 잘 맞는다.
- 서버 enum도 string union으로 표현하면 화면 분기가 안전해진다.
- 상태 조합이 늘어나면 boolean 묶음을 의심한다.
assertNever를 쓰면 새 상태를 추가했을 때 빠진 분기를 컴파일 단계에서 발견하기 쉽다.
디버깅 포인트
- UI가 loading과 error를 동시에 보여주면 상태 모델을 union으로 바꿔 본다.
- switch에서 빠진 case를 TypeScript가 잡도록 exhaustiveness check를 추가한다.
React stale closure는 왜 생길까?
문제가 되는 이유
- React 함수 컴포넌트는 렌더링마다 새로운 값을 가진다.
- 비동기 callback이나 interval은 생성 당시의 state를 캡처할 수 있다.
- “현재 state”를 보고 있다고 생각했지만 오래된 값을 보고 있을 수 있다.
잘못된 예시
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
}개선 예시
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount((prev) => prev + 1);
}, 1000);
return () => clearInterval(id);
}, []);
}판단 기준
- 이전 state를 기반으로 다음 state를 만들면 functional update를 쓴다.
- effect 안에서 참조하는 값은 dependency에 넣거나 ref/functional update로 의도를 명확히 한다.
- 이벤트 handler와 effect callback의 생성 시점을 구분한다.
- “최신 값을 읽어야 하는가”와 “이전 값으로 다음 값을 계산해야 하는가”를 나누면 ref와 functional update 선택이 쉬워진다.
디버깅 포인트
- interval, timeout, subscription, async callback에서 값이 예전 값이면 stale closure를 의심한다.
- React DevTools로 렌더링마다 state가 어떻게 바뀌는지 본다.
useEffect dependency array 실수는 왜 자주 생길까?
문제가 되는 이유
- effect는 렌더링 결과를 외부 시스템과 동기화하는 도구다.
- dependency를 빼면 오래된 값을 쓰고, 불필요한 객체나 함수를 넣으면 effect가 과도하게 반복된다.
잘못된 예시
function SearchPage({ keyword }: { keyword: string }) {
const [items, setItems] = useState<Item[]>([]);
useEffect(() => {
fetch(`/api/items?keyword=${keyword}`)
.then((response) => response.json())
.then(setItems);
}, []);
}keyword가 바뀌어도 다시 요청하지 않는다.
개선 예시
function SearchPage({ keyword }: { keyword: string }) {
const [items, setItems] = useState<Item[]>([]);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
setError(null);
fetch(`/api/items?keyword=${encodeURIComponent(keyword)}`, {
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
throw new Error("검색 요청에 실패했습니다.");
}
const body: unknown = await response.json();
return itemListSchema.parse(body);
})
.then(setItems)
.catch((error) => {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
setError(error instanceof Error ? error : new Error("알 수 없는 오류"));
});
return () => controller.abort();
}, [keyword]);
if (error) return <ErrorView message={error.message} />;
return <ItemList items={items} />;
}판단 기준
- effect 안에서 읽는 렌더링 값은 dependency에 포함한다.
- API 서버 상태는 가능하면 TanStack Query로 관리한다.
- dependency를 없애려고 lint를 끄기보다 코드 구조를 바꾼다.
- fetch를 직접 effect에 넣는다면 실패 처리와 cleanup까지 한 묶음으로 작성한다.
디버깅 포인트
- 값은 바뀌었는데 effect가 실행되지 않으면 dependency 누락을 본다.
- 요청이 무한 반복되면 매 렌더링마다 새 객체나 함수가 dependency로 들어가는지 확인한다.
React list key를 index로 두면 언제 문제가 될까?
문제가 되는 이유
- key는 React가 list item identity를 추적하는 기준이다.
- index key는 reorder, insert, delete가 있을 때 잘못된 DOM과 state 재사용을 만들 수 있다.
잘못된 예시
{todos.map((todo, index) => (
<TodoItem key={index} todo={todo} />
))}개선 예시
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}판단 기준
- 서버 id처럼 안정적이고 고유한 값을 key로 쓴다.
- 정렬, 필터, 삽입, 삭제가 전혀 없는 정적 list에만 index key를 허용한다.
- 임시 생성 항목은 client id를 만들어 둔다.
디버깅 포인트
- list를 삭제했는데 다른 row input 값이 바뀌면 key를 확인한다.
- React warning이 없어도 index key는 논리 버그를 만들 수 있다.
controlled/uncontrolled input 경고는 왜 생길까?
문제가 되는 이유
- React input은
value를 state로 제어하는 controlled 방식과 DOM에 맡기는 uncontrolled 방식이 있다. - 처음에는
undefined였다가 나중에 string이 되면 React가 mode가 바뀐 것으로 본다.
잘못된 예시
function ProfileForm({ user }: { user?: User }) {
const [name, setName] = useState(user?.name);
return <input value={name} onChange={(e) => setName(e.target.value)} />;
}개선 예시
function ProfileForm({ user }: { user?: User }) {
const [name, setName] = useState(user?.name ?? "");
return <input value={name} onChange={(e) => setName(e.target.value)} />;
}판단 기준
- controlled input의 value는 항상 string, number, readonly string array 중 하나로 유지한다.
- form 초기값은
undefined대신 빈 문자열이나 명시적인 default value를 둔다. - React Hook Form을 쓰면 controlled와 uncontrolled 경계를 이해한다.
- API 데이터가 늦게 도착하는 form은 초기 렌더링을 미루거나
reset으로 값을 주입한다.
디버깅 포인트
- console warning에 controlled/uncontrolled가 보이면 초기 state를 확인한다.
- API 로딩 전 form을 렌더링하는지, reset 시점을 확인한다.
setState 직후 값이 바로 바뀌었다고 생각하면 왜 틀릴까?
문제가 되는 이유
- React state는 렌더링 시점의 snapshot이다.
setState는 다음 렌더링을 예약하며, 현재 함수 안의 변수 값을 즉시 바꾸지 않는다.
잘못된 예시
function Counter() {
const [count, setCount] = useState(0);
function increase() {
setCount(count + 1);
console.log(count); // 여전히 이전 값
}
}개선 예시
function Counter() {
const [count, setCount] = useState(0);
function increase() {
setCount((prev) => prev + 1);
}
useEffect(() => {
console.log("count changed", count);
}, [count]);
}판단 기준
- 다음 값 계산에 이전 state가 필요하면 functional update를 쓴다.
- 변경 후 side effect는 event handler 안의 이전 값이 아니라 effect나 계산된 next value로 다룬다.
- 같은 이벤트 안의 여러 state update는 batching될 수 있으므로 로그 위치와 렌더링 시점을 구분한다.
디버깅 포인트
- console log 위치가 렌더링 전인지 후인지 구분한다.
- 같은 event handler 안에서 여러 번 setState할 때 batching을 고려한다.
CSS specificity, cascade, inheritance는 왜 “스타일이 안 먹는다”를 만들까?
문제가 되는 이유
- CSS는 나중에 선언된 규칙만이 아니라 specificity와 cascade layer, inheritance의 영향을 받는다.
- component library와 Tailwind, 직접 CSS가 섞이면 어느 규칙이 이기는지 추적이 어려워진다.
잘못된 예시
.button {
color: white;
}
#app .dialog .button.primary {
color: black;
}뒤쪽 selector가 더 구체적이라 .button 수정이 먹지 않는다.
개선 예시
.button {
color: var(--button-fg);
}
.button[data-variant="primary"] {
--button-fg: white;
}판단 기준
- selector를 깊게 만들기보다 component variant와 CSS variable을 활용한다.
!important는 라이브러리 override 같은 제한된 경우에만 쓴다.- Tailwind를 쓸 때도 반복되는 variant는 컴포넌트로 묶는다.
- styling 문제를 해결하려고 selector specificity를 계속 높이면 다음 수정 비용이 더 커진다.
디버깅 포인트
- DevTools Computed panel에서 어떤 규칙이 이겼는지 확인한다.
- selector가 점점 길어지면 구조를 단순화한다.
flex/grid에서 width가 예상대로 안 먹는 이유는 무엇일까?
문제가 되는 이유
- flex item은 기본적으로 내용 크기보다 작아지지 않으려는 성질이 있다.
- 긴 텍스트, table, code block이 있으면 부모 너비를 밀어내고 overflow가 생긴다.
- grid와 flex에서
min-width: 0이 없으면 말줄임이나 shrink가 동작하지 않는 경우가 많다.
잘못된 예시
<div className="layout">
<aside>...</aside>
<main className="content">
<p className="title">{veryLongTitle}</p>
</main>
</div>.layout {
display: flex;
}
.content {
flex: 1;
}
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}개선 예시
.layout {
display: flex;
}
.content {
flex: 1;
min-width: 0;
}
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}판단 기준
- flex/grid child 안에서 ellipsis가 안 되면
min-width: 0을 먼저 의심한다. - scroll이 필요한 영역과 shrink가 필요한 영역을 분리한다.
- layout container에는 명확한 width, height, overflow 정책을 둔다.
- table, code block, 긴 URL처럼 intrinsic size가 큰 자식은 별도 overflow 전략을 둔다.
디버깅 포인트
- DevTools Layout panel에서 실제 box size와 overflow를 확인한다.
min-width: auto가 적용되는 flex item인지 확인한다.
overflow는 단순히 스크롤을 만드는 속성일까?
문제가 되는 이유
overflow는 clipping, scroll container, sticky 동작, focus outline 표시에도 영향을 준다.- 부모에
overflow: hidden이 있으면 dropdown, tooltip, focus ring이 잘릴 수 있다.
잘못된 예시
.card {
overflow: hidden;
}
.dropdown {
position: absolute;
top: 100%;
}dropdown이 card 밖으로 열릴 때 잘릴 수 있다.
개선 예시
<div className="card">
<button ref={triggerRef}>메뉴</button>
<DropdownPortal anchorRef={triggerRef} />
</div>또는 clipping이 필요한 요소와 overlay trigger를 분리한다.
판단 기준
- rounded corner clipping과 overlay는 충돌하기 쉽다.
- dropdown, modal, tooltip은 portal 사용을 검토한다.
- 스크롤 컨테이너는 keyboard focus와 sticky에 영향을 준다.
overflow: hidden은 시각적 마감에는 편하지만 overlay와 focus ring을 자를 수 있는 구조적 결정이다.
디버깅 포인트
- overlay가 잘리면 부모 chain의
overflow를 확인한다. - focus outline이 안 보이면 clipping과 outline offset을 확인한다.
position: sticky가 안 붙는 이유는 무엇일까?
문제가 되는 이유
- sticky는 가장 가까운 scroll container와 offset 기준으로 동작한다.
- 부모의 overflow, 높이, sticky 요소의 위치 조건이 맞지 않으면 기대처럼 고정되지 않는다.
잘못된 예시
.page {
overflow: hidden;
}
.toolbar {
position: sticky;
top: 0;
}개선 예시
.page {
min-height: 100dvh;
}
.toolbar {
position: sticky;
top: 0;
z-index: 10;
}스크롤 영역을 의도적으로 나누는 경우에는 sticky가 붙을 scroll container를 명확히 한다.
판단 기준
- sticky에는
top,bottom,left,right중 필요한 offset이 있어야 한다. - 부모 overflow가 sticky 기준을 바꾼다.
- table header sticky는 table 구조와 z-index까지 함께 본다.
- sticky가 붙을 스크롤 컨테이너가 누구인지 DevTools에서 확인한다.
디버깅 포인트
- 부모 요소 중
overflow: hidden/auto/scroll이 있는지 확인한다. - sticky 요소가 실제로 스크롤 범위 안에 있는지 DevTools로 본다.
z-index를 올렸는데 왜 위로 안 올라올까?
문제가 되는 이유
z-index는 stacking context 안에서만 경쟁한다.- transform, opacity, position, filter, isolation 같은 속성이 새로운 stacking context를 만들 수 있다.
- 자식의
z-index가 아무리 높아도 부모 stacking context 밖의 요소를 이기지 못할 수 있다.
잘못된 예시
.panel {
transform: translateZ(0);
}
.panel .dropdown {
position: absolute;
z-index: 9999;
}
.header {
position: relative;
z-index: 10;
}개선 예시
function UserMenu() {
return (
<>
<button>메뉴</button>
{createPortal(<MenuContent />, document.body)}
</>
);
}판단 기준
- modal, toast, dropdown은 portal과 layer scale을 정한다.
z-index: 9999를 반복하기보다 layer token을 둔다.- transform을 layout hack으로 남발하지 않는다.
- stacking context를 만드는 속성을 부모에 추가하면 자식 overlay의 우선순위도 함께 바뀔 수 있다.
디버깅 포인트
- DevTools에서 stacking context를 만든 부모를 추적한다.
position,transform,opacity,filter,isolation속성을 검색한다.
모바일 viewport와 100vh는 왜 함정일까?
문제가 되는 이유
- 모바일 브라우저 주소창과 하단 UI 때문에 CSS viewport height가 사용자 눈에 보이는 높이와 다를 수 있다.
100vh를 고정으로 쓰면 입력창, 주소창 변화, safe area에서 버튼이 잘릴 수 있다.
잘못된 예시
.login-page {
height: 100vh;
}개선 예시
.login-page {
min-height: 100dvh;
padding-bottom: env(safe-area-inset-bottom);
}fallback이 필요하면 min-height와 layout 흐름을 함께 둔다.
판단 기준
- 모바일 full-height 화면은
dvh,svh,lvh지원을 확인한다. - fixed bottom button은 safe area와 keyboard 상황을 확인한다.
- 콘텐츠가 넘칠 수 있으면
height보다min-height가 안전하다. - 입력 화면은 키보드가 올라온 상태까지 직접 확인해야 한다.
디버깅 포인트
- 모바일 실제 기기 또는 Playwright mobile viewport에서 확인한다.
- iOS Safari에서 주소창 접힘과 input focus 시 layout을 본다.
env 변수가 런타임이 아니라 빌드 타임에 박히는 문제는 왜 중요할까?
문제가 되는 이유
- Vite의
import.meta.env값은 빌드 시점에 코드로 치환된다. - 정적 파일로 배포한 뒤 서버 env만 바꿔도 이미 만들어진 JS bundle은 바뀌지 않는다.
- 브라우저로 전달된 env 값은 secret이 아니다.
잘못된 예시
const apiSecret = import.meta.env.VITE_API_SECRET;
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;VITE_API_SECRET은 bundle에 포함되어 노출된다.
개선 예시
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
if (!apiBaseUrl) {
throw new Error("VITE_API_BASE_URL is required");
}secret은 서버나 배포 플랫폼의 server-side environment에 둔다.
판단 기준
VITE_prefix 값은 공개되어도 되는 설정만 둔다.- 배포 후 값을 바꿔야 하면 runtime config endpoint나 서버 렌더링 환경을 검토한다.
- stage/prod 별 API URL, Cookie domain, CORS origin을 함께 관리한다.
- “환경 변수”라는 이름 때문에 secret처럼 느껴지더라도 브라우저 bundle에 들어가는 순간 공개 정보다.
디버깅 포인트
- 운영 bundle에서 예전 API URL을 호출하면 build artifact와 배포 pipeline을 확인한다.
- 브라우저 DevTools에서 source 검색으로 env 값이 노출되는지 확인한다.
.ts와 .tsx를 전부 .tsx로 통일하면 왜 불편할까?
문제가 되는 이유
.tsx는 JSX 파싱 모드라서 일부 TypeScript generic 문법과 JSX 문법이 충돌할 수 있다.- 파일 확장자는 “순수 로직”과 “UI 컴포넌트”를 구분하는 협업 신호이기도 하다.
잘못된 예시
const identity = <T>(value: T) => value;.tsx에서는 <T>를 JSX 태그로 오해할 수 있다.
개선 예시
// utils/identity.ts
const identity = <T>(value: T) => value;실제로 JSX가 없다면 .ts를 쓴다.
.tsx 안에서 generic arrow function을 꼭 써야 한다면 JSX와 구분하기 위해 trailing comma를 둔다.
const identity = <T,>(value: T) => value;// components/Button.tsx
export function Button({ children }: { children: React.ReactNode }) {
return <button>{children}</button>;
}판단 기준
- JSX를 포함하면
.tsx. - 타입 정의, API 함수, 유틸, schema, query key는
.ts. - UI 계층과 로직 계층을 파일 확장자만 봐도 알 수 있게 한다.
디버깅 포인트
- generic arrow function이 이상하게 파싱되면 확장자와 trailing comma를 확인한다.
- 컴포넌트가 아닌 파일이
.tsx로 늘어나면 폴더 구조를 정리한다.
참고 문서
- MDN Web Docs, Nullish coalescing operator
- MDN Web Docs, Equality comparisons and sameness
- MDN Web Docs, Promise.all
- MDN Web Docs, Promise.allSettled
- MDN Web Docs, AbortController
- MDN Web Docs, HTMLInputElement value
- MDN Web Docs, CSS Specificity
- MDN Web Docs, Stacking context
- React Docs, State as a Snapshot
- React Docs, Synchronizing with Effects
- React Docs, Rendering Lists
- React Docs, input
- TypeScript Handbook, Narrowing
- Vite Docs, Env Variables and Modes