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를 관리한다. 첫 렌더에서 useState → useEffect → useSingleQuery 순서로 호출됐으면, 이후 모든 렌더에서 정확히 같은 순서여야 한다. 조건문 안에 넣으면 렌더마다 순서가 달라져서 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를 prod → dev로 변경하면:
| 시점 | 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);
이번 작업의 버그: isAutoSyncLoading이 arePropsEqual에 없었음 → 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);
원칙: 객체, 배열, 함수는 매 렌더마다 새 참조가 생긴다. 의존성 배열에 들어가는 값이라면 useMemo나 useCallback으로 참조를 안정시킨다.
요약 테이블
| 개념 | 핵심 | 실수하면 |
|---|---|---|
| Hook 규칙 | 항상 같은 순서로, 최상위에서만 호출 | state 꼬임, 크래시 |
| useEffect 타이밍 | 렌더 이후 실행, state 반영은 다음 렌더 | stale data 1렌더 노출 |
| React.memo 커스텀 비교 | 화이트리스트 방식은 누락 시 조용히 실패 | UI 업데이트 안 됨 |
| TS 타입 캐스팅 | 런타임 검증 없음 | undefined인데 에러도 없음 |
| 의존성 배열 참조 | 인라인 객체/함수는 매번 새 참조 | 불필요한 effect 재실행 |