백엔드 개발자를 위한 프론트엔드 문법 함정과 디버깅

이 문서는 문법 튜토리얼이 아니라 실전 버그 예방 노트다. let/const, props, useState 같은 입문 설명보다 개인 프로젝트에서 실제로 부딪히는 JavaScript, TypeScript, React, CSS, 빌드 환경의 함정과 디버깅 포인트를 정리한다.

truthy/falsy와 기본값 처리는 왜 위험할까?

문제가 되는 이유

  • JavaScript의 ||는 왼쪽 값이 falsy면 오른쪽 값을 선택한다.
  • 0, "", false도 falsy라서 정상 값이 기본값으로 덮일 수 있다.
  • ??null 또는 undefined일 때만 오른쪽 값을 선택한다.
  • 할인율, 재고 수량, 공개 여부처럼 0false가 도메인 의미를 가진 값이면 기본값 처리 하나가 곧 데이터 오류가 된다.

잘못된 예시

type Product = {
  name: string;
  discountRate?: number | null;
};
 
function DiscountBadge({ product }: { product: Product }) {
  const rate = product.discountRate || 10;
 
  return <span>{rate}% 할인</span>;
}

discountRate0이면 “할인 없음”이 아니라 10% 할인으로 표시된다.

개선 예시

function DiscountBadge({ product }: { product: Product }) {
  const rate = product.discountRate ?? 10;
 
  return <span>{rate}% 할인</span>;
}

판단 기준

  • 0, "", false가 의미 있는 값이면 ??를 우선 검토한다.
  • “비어 있음”을 넓게 보고 싶을 때만 ||를 쓴다.
  • 서버 응답 nullable 필드는 nullundefined를 명시적으로 모델링한다.

디버깅 포인트

  • 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는 내부 async callback을 기다리지 않는다.
  • 순차 실행이 필요한지, 병렬 실행이 가능한지에 따라 코드가 달라져야 한다.

잘못된 예시

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.allPromise.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;
};

실제 응답에서 avatarUrlnull일 수 있으면 런타임에서 깨진다.

개선 예시

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로 늘어나면 폴더 구조를 정리한다.

참고 문서