React TIL — COREPL-4114 작업에서 배운 것들

Dev Portal ApplicationConfig 버그 수정 및 리팩토링 과정에서 정리한 React 핵심 개념들

2026-04-07


1. Hook의 2가지 규칙 (Rules of Hooks)

React Hook은 이름이 use로 시작하는 함수이고, 두 가지 절대 규칙이 있다.

규칙 1: 최상위에서만 호출한다

조건문, 반복문, 중첩 함수 안에서 Hook을 호출하면 안 된다.

// ❌ BAD — 조건에 따라 Hook 호출 여부가 달라짐
if (env === 'prod') {
  const [data] = useSingleQuery(...);
}

// ✅ GOOD — 항상 호출하되, 인자로 제어
const [data] = useSingleQuery(
  env === 'prod' ? { appName, env } : null,
  async (req) => {
    if (!req) return null;
    return fetchData(req);
  }
);

왜? React는 Hook을 호출 순서로 내부 state를 관리한다. 첫 렌더에서 useStateuseEffectuseSingleQuery 순서로 호출됐으면, 이후 모든 렌더에서 정확히 같은 순서여야 한다. 조건문 안에 넣으면 렌더마다 순서가 달라져서 state가 꼬인다.

실무 패턴: Hook은 항상 호출하되, 내부에서 인자가 null/undefined면 skip하도록 구현한다.

// useRemiconDesiredEnvVars 실제 코드
const request = appName && env && profile ? { appName, env, profile } : null;
const [desiredEnvVars] = useSingleQuery(request, async (req) => {
  if (!req) return null;  // null이면 API 호출 안 함
  return fetchRemiconDesiredEnvVars(req);
});

규칙 2: React 함수 컴포넌트 또는 커스텀 Hook 안에서만 호출한다

// ❌ BAD — 일반 함수
function formatData() {
  const [state] = useState(0);  // Error!
}

// ✅ GOOD — 커스텀 Hook (use로 시작하면 Hook으로 인식)
function useFormattedData() {
  const [state] = useState(0);  // OK
  return state;
}

useAutoSyncToggleHandler, useCanaryMigrationHandler 같은 커스텀 Hook이 use로 시작하는 이유. 이 접두사 덕분에 내부에서 useState, useCallback 등을 호출할 수 있다.


2. useEffect의 실행 타이밍 — "렌더 이후"

useEffect는 렌더링과 화면 그리기가 끝난 후 비동기로 실행된다.

[렌더] → [화면 그림] → [useEffect 실행] → [state 업데이트] → [다음 렌더]

이것 때문에 생기는 "1렌더 갭" 문제

useSingleQuery 내부:

useEffect(() => {
  if (arg changed) {
    setLoading(true);     // ← 이 state 변경은 "다음 렌더"에 반영
    queryFn(arg).then(res => setValue(res));
  }
}, [arg]);

env를 proddev로 변경하면:

시점 queryParams.env deployment isDeploymentLoading 설명
렌더 1 'dev' (바뀜) prod 데이터 (옛것) false (effect 전) stale data 노출
effect 실행 true 설정 setLoading(true)
렌더 2 'dev' prod 데이터 true (반영됨) 로딩 표시
fetch 완료 'dev' dev 데이터 false 정상

렌더 1에서 isDeploymentLoading=false인데 deployment는 이전 env 데이터. 이 틈에 잘못된 데이터로 동작하는 버그가 생겼다.

해결: 동기적 stale 감지

useEffect의 loading 업데이트를 기다리지 않고, 렌더 시점에 데이터 자체를 검증:

// deployment.environment가 현재 선택된 env와 다르면 stale
const isDeploymentStale = deployment != null && deployment.environment !== queryParams.env;
const isDeploymentReady = !isDeploymentLoading && !isDeploymentStale;

원칙: 서버 데이터에 "이 데이터가 어떤 요청의 결과인지" 식별할 수 있는 필드(environment, profile 등)가 있으면, 이걸로 렌더 시점에 동기적 검증이 가능하다.


3. React.memo와 커스텀 비교 함수의 함정

React.memo(Component, arePropsEqual?)는 props가 같으면 re-render를 건너뛴다.

기본 동작 (두 번째 인자 없음)

모든 props를 === (shallow equality)로 비교. 하나라도 다르면 re-render.

커스텀 비교 함수 (화이트리스트 방식)

특정 props만 골라서 비교:

const arePropsEqual = (prev, next) => {
  if (prev.env !== next.env) return false;
  if (prev.appName !== next.appName) return false;
  // isAutoSyncLoading 안 넣음!
  return true;  // "같다" → re-render 안 함
};
export const ConfigurationDisplay = React.memo(Component, arePropsEqual);

이번 작업의 버그: isAutoSyncLoadingarePropsEqual에 없었음 → true로 바뀌어도 memo가 "같다"고 판단 → Spinner가 안 보임.

왜 위험한가

  • 새 prop 추가할 때 arePropsEqual에도 넣어야 하는데, 빠뜨려도 에러가 없다
  • 조용히 UI가 멈출 뿐, 콘솔 에러도 안 난다
  • 성능이 critical하지 않은 어드민 페이지에서는 오히려 해가 됨

결론: arePropsEqual을 삭제하고 기본 React.memo를 쓰거나, memo 자체를 빼는 게 안전. 어드민 페이지의 re-render 비용은 무시할 수준.


4. TypeScript 타입 캐스팅 ≠ 런타임 검증

const res = await ky.get('/api/...').json<ApiResponse<RemiconDesiredEnvVars>>();

이것은 TypeScript에게 "이 JSON이 이 타입이야"라고 알려주기만 할 뿐, 실제 JSON 구조를 검증하지 않는다.

// 인터페이스 정의
interface RemiconDesiredEnvVars {
  envs: Record<string, string>;     // 코드에서 기대하는 필드명
}

// 실제 API 응답
{ "envVars": { "DD_TRACE_...": "..." } }  // 실제 필드명은 envVars

// 결과
result.envs  // → undefined (에러 없이 조용히 실패)
result.envVars  // → 데이터 있음 (하지만 TypeScript는 모름)
  • 컴파일 에러 없음
  • 런타임 에러도 없음
  • .envs에 접근하면 그냥 undefined

교훈: API 응답 타입을 정의할 때 실제 JSON 응답과 반드시 대조한다. TypeScript는 네트워크 데이터를 검증하지 않는다. 런타임 검증이 필요하면 zod, io-ts 같은 라이브러리를 쓸 수 있다.


5. useEffect 의존성 배열과 참조 동일성

useEffect는 의존성 배열의 값이 바뀔 때만 실행된다. 비교는 Object.is (≈ ===).

인라인 함수 = 매번 새 참조

useSingleQuery(request, async (req) => { ... });
//                       ↑ 매 렌더마다 새 함수 객체 생성

useSingleQuery 내부에서 queryFn이 의존성 배열에 있으면, 인라인 함수가 매번 새로 생성되므로 effect가 매 렌더마다 실행된다.

// useSingleQuery 내부
useEffect(() => {
  if (_.isEqual(arg, lastArg)) return;  // 이 체크로 실제 fetch는 방지
  // ...
}, [queryFn, arg, lastArg]);  // queryFn이 매번 바뀌므로 effect 매번 실행

_.isEqual 체크로 실제 fetch는 방지하지만, 불필요한 effect 실행 자체는 발생.

해결: useCallback

const stableQueryFn = useCallback(async (req) => {
  if (!req) return null;
  return fetchRemiconDesiredEnvVars(req);
}, []);  // 의존성 없음 → 참조 고정

useSingleQuery(request, stableQueryFn);

원칙: 객체, 배열, 함수는 매 렌더마다 새 참조가 생긴다. 의존성 배열에 들어가는 값이라면 useMemouseCallback으로 참조를 안정시킨다.


요약 테이블

개념 핵심 실수하면
Hook 규칙 항상 같은 순서로, 최상위에서만 호출 state 꼬임, 크래시
useEffect 타이밍 렌더 이후 실행, state 반영은 다음 렌더 stale data 1렌더 노출
React.memo 커스텀 비교 화이트리스트 방식은 누락 시 조용히 실패 UI 업데이트 안 됨
TS 타입 캐스팅 런타임 검증 없음 undefined인데 에러도 없음
의존성 배열 참조 인라인 객체/함수는 매번 새 참조 불필요한 effect 재실행