레이블이 프론트엔드인 게시물을 표시합니다. 모든 게시물 표시
레이블이 프론트엔드인 게시물을 표시합니다. 모든 게시물 표시

목요일

Next.js Hydration 불일치 에러 해결하기

🔍 검색 키워드: Next.js hydration 불일치, SSR 하이드레이션 에러, hydration mismatch 해결, Next.js 렌더링 차이

Next.js Hydration 불일치 에러 해결하기

증상

Next.js 앱을 개발하거나 프로덕션에 배포했을 때, 브라우저 콘솔에 다음과 같은 에러가 나타난다:

Error: Hydration failed because the initial UI does not match what was rendered on the server.

또는 더 자세한 메시지:

Warning: Text content did not match. Server: "Monday" Client: "Sunday"

페이지는 렌더링되지만, 콘솔에 에러가 쌓이고 JavaScript가 제대로 작동하지 않을 수 있다. 특히 동적 콘텐츠나 클라이언트 전용 데이터가 있을 때 자주 발생한다.

원인

Hydration 불일치는 서버에서 렌더링한 HTML과 클라이언트에서 처음 렌더링한 React 컴포넌트가 다를 때 발생한다.

주요 원인들:

  1. 타임존/로케일 의존 데이터: new Date(), toLocaleDateString() 등이 서버와 클라이언트에서 다른 값 반환
  2. Math.random() 사용: 서버와 클라이언트가 다른 난수 생성
  3. 클라이언트 전용 hook 오남용: useEffect 없이 클라이언트 전용 코드 실행
  4. 브라우저 API 직접 접근: window, document 같은 브라우저 객체에 SSR 중 접근
  5. 조건부 렌더링 불일치: if (isMobile) 등의 조건이 서버와 클라이언트에서 다름

해결방법

1. useEffect로 클라이언트 전용 렌더링 (권장)

export default function Page() {
    const [mounted, setMounted] = React.useState(false);
    
      React.useEffect(() => {
          setMounted(true);
            }, []);
            
              if (!mounted) {
                  return <div>로딩 중...</div>;
                    }
                    
                      return <div>{new Date().toLocaleDateString()}</div>;
                      }

이렇게 하면 서버에서는 "로딩 중..." 렌더링, 클라이언트에서 hydrate 후 날짜 표시 → 불일치 없음.

2. suppressHydrationWarning 사용 (임시방편)

export default function Page() {
    return (
        <div suppressHydrationWarning>
              {new Date().toLocaleDateString()}
                  </div>
                    );
                    }

⚠️ 경고만 무시하고 실제 불일치는 해결 안 함. 간단한 경우만 사용.

3. 동적 콘텐츠는 서버에서 처리

// app/page.jsx (서버 컴포넌트)
  import { getServerData } from '@/lib/api';
  
  export default async function Page() {
    const data = await getServerData();
      return <div>{data.title}</div>;
      }

서버에서 필요한 데이터를 모두 fetch → 클라이언트에서는 같은 데이터 사용 → 불일치 자동 해결.

4. 브라우저 API는 useEffect 안에서만

export default function Page() {
    const [scrollY, setScrollY] = React.useState(0);
    
      React.useEffect(() => {
          const handleScroll = () => setScrollY(window.scrollY);
              window.addEventListener('scroll', handleScroll);
                  return () => window.removeEventListener('scroll', handleScroll);
                    }, []);
                    
                      return <div>스크롤: {scrollY}px</div>;
                      }

5. 타임존 문제 해결

// lib/date.js
  export function getCurrentDate() {
    // 서버와 클라이언트 모두 동일한 timezone 사용
      const now = new Date();
        return now.toISOString().split('T')[0];
        }
        
        // app/page.jsx
        import { getCurrentDate } from '@/lib/date';
        
        export default function Page() {
          const dateStr = getCurrentDate();
            return <div>{dateStr}</div>;
            }

정리표

상황 원인 해결법
new Date().toString() 다름 서버/클라이언트 타임존 차이 ISO 문자열 사용 또는 useEffect로 지연
Math.random() 불일치 난수 생성 차이 서버에서 난수 생성 후 props로 전달
window.location 에러 SSR 중 브라우저 API 접근 useEffect 안에서만 접근
모바일/데스크톱 렌더링 다름 미디어쿼리 판정 차이 CSS media query 사용 또는 useEffect 지연
조건부 렌더링 불일치 클라이언트 상태 기반 조건 서버/클라이언트 동일 조건 또는 useEffect 지연

TIP: Next.js 13+ 서버 컴포넌트를 최대한 활용하면 이런 문제가 대폭 줄어든다. 클라이언트 컴포넌트는 진짜 필요한 부분만 사용하자.

수요일

React useEffect cleanup 누락 — 타이머·이벤트 리스너가 중복으로 쌓이는 이유

🔍 검색 키워드: react useEffect cleanup, useEffect 정리 함수, setInterval 중복 실행, addEventListener 중복 등록, StrictMode 두 번 실행, useEffect 메모리 누수

요약: useEffect가 타이머·구독·이벤트 리스너처럼 계속 살아 있는 것을 만들어 놓고 정리(cleanup) 함수를 반환하지 않으면, 의존성이 바뀌어 effect가 다시 실행될 때마다 이전 것이 그대로 남아 중복으로 쌓인다. setup이 만든 것을 그대로 되돌리는 함수를 return하면 해결된다. React 공식 문서 기준으로 cleanup은 의존성이 바뀐 커밋마다 setup보다 먼저, 그리고 언마운트 후 한 번 더 실행된다.

useEffect의 cleanup은 언제 실행되나요?

React 공식 useEffect 문서는 실행 순서를 다음과 같이 못 박는다.

  • 컴포넌트가 화면에 추가될 때(mount) setup 코드가 실행된다.
  • 의존성이 바뀐 커밋마다, 먼저 이전 props·state로 cleanup이 실행되고 그다음 새 값으로 setup이 실행된다.
  • 컴포넌트가 화면에서 제거된 뒤(unmount) cleanup이 마지막으로 한 번 더 실행된다.

문서는 cleanup이 "setup 함수가 하던 일을 멈추거나 되돌려야 한다"고 설명한다. 즉 cleanup은 선택 사항이 아니라 setup의 거울상이다. setup에서 무언가를 만들었다면 cleanup에서 그걸 없애야 한다.

테스트 환경

아래 숫자는 이 환경에서 직접 실행한 결과다. 다만 React 런타임을 띄워 컴포넌트를 렌더한 것이 아니라, effect가 반복 실행될 때 타이머와 리스너가 어떻게 쌓이는지를 Node에서 같은 구조로 재현한 것이다. React의 동작 순서 자체는 공식 문서를 근거로 한다.

  • macOS 27.0.1 (Darwin 27.0.0, arm64)
  • node --version 출력: v20.20.2

cleanup이 없으면 실제로 무엇이 쌓이나요?

1. 타이머: 되돌리지 않으면 그대로 중첩된다

effect가 세 번 실행되는 상황을 그대로 만들어 보자.

let ticks = 0;
const timers = [];

function effectNoCleanup() {           // cleanup 없는 useEffect
  timers.push(setInterval(() => { ticks++; }, 20));
}
for (let i = 0; i < 3; i++) effectNoCleanup();   // 의존성이 바뀌어 3번 재실행

// cleanup 있는 버전
let ticks2 = 0, cur = null;
const effectWithCleanup = () => {
  if (cur) clearInterval(cur);         // 이전 effect의 cleanup
  cur = setInterval(() => { ticks2++; }, 20);
};
for (let i = 0; i < 3; i++) effectWithCleanup();

직접 실행 결과 (각각 약 100ms 동안 관찰)

cleanup 없음 - 약 100ms 동안 tick: 12 회 (타이머 3 개 동시 작동)
cleanup 있음 - 약 100ms 동안 tick: 4 회 (타이머 1개)

같은 시간에 콜백이 세 배로 돈다. 화면에서는 "숫자가 갑자기 빨리 올라간다", "API가 몇 배로 호출된다"로 나타난다. 의존성이 자주 바뀌는 컴포넌트라면 재실행 횟수만큼 배수로 늘어난다.

2. 이벤트 리스너: 여기는 사정이 조금 다르다

"cleanup이 없으면 리스너가 무조건 쌓인다"는 설명을 자주 보는데, 절반만 맞다. DOM의 addEventListener는 같은 타입·같은 콜백 참조·같은 capture 옵션이면 중복 등록을 무시한다. 직접 확인해 보자.

const t2 = new EventTarget();
let c2 = 0;
const h2 = () => { c2++; };
t2.addEventListener("ping", h2);
t2.addEventListener("ping", h2);        // 같은 참조를 두 번
t2.dispatchEvent(new Event("ping"));

const t3 = new EventTarget();
let c3 = 0;
for (let i = 0; i < 3; i++)
  t3.addEventListener("ping", () => { c3++; });   // 매번 새 화살표 함수
t3.dispatchEvent(new Event("ping"));

직접 실행 결과

동일 참조 2번 등록 -> 호출: 1 회
매번 새 참조 3번 등록 -> 호출: 3 회

핵심은 이것이다. 컴포넌트 안에서 매 렌더마다 새로 만들어지는 인라인 핸들러를 등록하면 참조가 매번 다르므로 중복 등록이 그대로 쌓인다. 반대로 컴포넌트 밖에 선언한 함수나 useCallback으로 참조를 고정한 핸들러는 같은 참조라 중복 등록이 무시된다. 그래서 "어떤 코드는 리스너가 쌓이고 어떤 코드는 멀쩡해 보이는" 현상이 생긴다. 물론 어느 쪽이든 언마운트 시 리스너를 떼지 않으면 그 자체로 누수이므로 cleanup은 필요하다.

effect는 왜 자꾸 다시 실행되나요?

cleanup 문제가 드러나려면 effect가 반복 실행돼야 하는데, 그 원인은 보통 의존성 배열이다. React 공식 문서는 "React는 각 의존성을 이전 값과 Object.is로 비교한다"고 명시한다. Object.is가 객체와 함수를 어떻게 비교하는지 직접 확인해 보면 이렇다.

$ node -e '...'
Object.is({a:1},{a:1}) = false
Object.is([1],[1])     = false
Object.is(f,f)         = true
Object.is(()=>{},()=>{}) = false

내용이 같아도 렌더마다 새로 만든 객체·배열·함수는 전부 다른 값으로 취급된다. 공식 문서도 "컴포넌트 안에서 정의된 객체나 함수가 의존성에 들어가면 effect가 필요 이상으로 자주 재실행될 위험이 있다"고 경고하며, 해결책으로 그 객체를 effect 안에서 만들라고 안내한다.

// 문제: options가 매 렌더 새로 만들어진다
const options = { serverUrl, roomId };
useEffect(() => {
  const connection = createConnection(options);
  connection.connect();
  return () => connection.disconnect();
}, [options]);          // 매 커밋마다 다른 값

// 해결: 객체를 effect 안으로 옮기고 원시값만 의존성에 둔다
useEffect(() => {
  const options = { serverUrl, roomId };
  const connection = createConnection(options);
  connection.connect();
  return () => connection.disconnect();
}, [roomId]);

어떻게 해결하나요? 상황별 cleanup 패턴

// 1. 타이머
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

// 2. 이벤트 리스너 - 등록과 해제에 같은 참조를 써야 한다
useEffect(() => {
  const onResize = () => setWidth(window.innerWidth);
  window.addEventListener("resize", onResize);
  return () => window.removeEventListener("resize", onResize);
}, []);

// 3. 외부 스토어 구독
useEffect(() => {
  const unsubscribe = store.subscribe(handleChange);
  return unsubscribe;
}, [store]);

// 4. 네트워크 요청 - 늦게 도착한 응답이 새 상태를 덮어쓰지 않게
useEffect(() => {
  const controller = new AbortController();
  fetch(url, { signal: controller.signal })
    .then((r) => r.json())
    .then(setData)
    .catch((e) => { if (e.name !== "AbortError") setError(e); });
  return () => controller.abort();
}, [url]);

// 5. WebSocket
useEffect(() => {
  const ws = new WebSocket(url);
  return () => ws.close();
}, [url]);

2번에서 removeEventListener에 넘기는 함수가 등록할 때와 다른 참조면 해제가 되지 않는다. 등록·해제 모두 effect 안에서 선언한 같은 onResize를 쓰는 것이 중요하다.

개발 모드에서만 두 번 실행되는 건 왜 그런가요?

React 공식 StrictMode 문서는 "Strict Mode가 켜져 있으면 React는 모든 Effect에 대해 개발 환경에서 setup과 cleanup을 한 번 더 실행한다"고 설명한다. 그리고 "이 검사들은 개발 환경에서만 실행되며 프로덕션 빌드에는 영향을 주지 않는다"고 덧붙인다.

즉 콘솔에 로그가 두 번 찍히는 것은 버그가 아니라, cleanup이 setup을 제대로 되돌리는지 확인하는 의도된 스트레스 테스트다. 문서의 표현을 빌리면 setup 대신 setup → cleanup → setup을 돌려서 빠진 cleanup을 눈에 띄게 만드는 것이다. 두 번 실행이 문제를 일으킨다면 StrictMode를 끌 것이 아니라 cleanup을 제대로 구현해야 한다. 프로덕션에서도 사용자가 페이지를 왔다 갔다 하면 같은 상황이 벌어진다.

주의할 점과 한계

  • cleanup에서 상태를 다시 세팅하는 식으로 "정리"하려 들면 안 된다. cleanup은 setup이 만든 외부 자원을 되돌리는 자리다.
  • 구독 해제 함수를 그대로 return할 때, 그 함수가 cleanup 인자를 받는 형태라면 의도치 않은 인자가 전달될 수 있다. 그런 경우는 return () => unsubscribe();처럼 감싸는 편이 안전하다.
  • 이 글의 수치는 Node 20.20.2에서 effect 반복 실행 구조를 재현한 것이다. React 렌더러를 띄워 측정한 값이 아니며, 실행 순서와 StrictMode 동작은 React 공식 문서를 근거로 했다.
  • React 18 이전 버전은 StrictMode의 이중 setup+cleanup 동작이 없다. 버전에 따라 콘솔에 보이는 횟수가 다를 수 있다.

정리표

setup에서 만든 것cleanup에서 할 일빠뜨리면 생기는 증상
setInterval / setTimeoutclearInterval / clearTimeout타이머가 중첩되어 콜백이 배수로 실행
addEventListener같은 참조로 removeEventListener인라인 핸들러면 중복 등록, 언마운트 후에도 잔존
store.subscribe반환된 unsubscribe 호출사라진 컴포넌트가 계속 상태 갱신 시도
fetch / axios 요청AbortController로 abort늦게 온 응답이 최신 상태를 덮어씀
new WebSocketws.close()연결이 계속 쌓여 서버 커넥션 고갈
외부 라이브러리 인스턴스destroy() 등 해제 API 호출DOM 노드와 리스너가 메모리에 잔존

자주 묻는 질문(FAQ)

cleanup 함수는 반드시 반환해야 하나요?

setup이 아무것도 만들지 않았다면 필요 없다. 예를 들어 값을 계산해서 state에 넣기만 하는 effect에는 되돌릴 것이 없다. 반대로 타이머, 구독, 이벤트 리스너, 소켓처럼 effect가 끝난 뒤에도 계속 살아 있는 것을 만들었다면 반드시 반환해야 한다.

개발 모드에서 effect가 두 번 실행되는데 StrictMode를 꺼야 하나요?

끄지 않는 편이 좋다. React 공식 문서에 따르면 이 동작은 개발 환경에서만 실행되고 프로덕션 빌드에는 영향을 주지 않으며, 빠진 cleanup을 드러내기 위한 의도된 검사다. 두 번 실행되어 문제가 생긴다면 그 문제는 프로덕션에서도 재마운트 시 똑같이 발생한다.

이벤트 리스너는 중복 등록이 안 된다고 들었는데 왜 쌓이나요?

같은 함수 참조일 때만 중복이 무시되기 때문이다. 직접 확인한 결과 동일 참조를 두 번 등록하면 핸들러가 1회만 호출됐지만, 매번 새로 만든 화살표 함수를 세 번 등록하니 3회 호출됐다. 컴포넌트 안에서 인라인으로 정의한 핸들러는 렌더마다 새 참조라서 쌓인다.

cleanup을 넣었는데도 중복 실행이 줄지 않습니다. 무엇을 봐야 하나요?

removeEventListener에 넘기는 함수가 등록 때와 같은 참조인지 먼저 확인한다. 그다음 의존성 배열을 본다. 렌더마다 새로 만들어지는 객체나 함수가 의존성에 들어 있으면 effect가 매 커밋마다 재실행되므로, 정리와 재생성이 끝없이 반복되며 네트워크 요청 같은 부작용이 계속 발생한다.

관련 글: React useEffect 무한루프 — 의존성 배열 검증과 최적화 전략 · React useEffect 무한루프 문제 해결하기

공식 문서: React — useEffect · React — StrictMode

최종 업데이트: 2026-10-13 · 작성: 내멋스레 사는 이야기

화요일

Zustand persist 직렬화 에러 완벽 해결법

🔍 검색 키워드: Zustand persist 에러, 직렬화 실패, JSON stringify, localStorage 버그, 상태 관리 디버깅

Zustand persist 직렬화 에러 해결법

증상

Zustand의 persist 미들웨어를 사용할 때 다음과 같은 에러가 발생한다:

TypeError: Converting circular structure to JSON
    at JSON.stringify (<anonymous>)
      at persist.ts:42

원인

Zustand persist는 상태를 JSON으로 직렬화하여 localStorage에 저장합니다. 순환 참조, 함수, Date/Map/Set 등 특수 객체, undefined 값이 포함되면 에러가 발생합니다.

해결 방법

방법 1: partialize로 직렬화 가능한 상태만 저장

const useStore = create(
    persist(
        (set) => ({ user: { id: 1, createdAt: new Date() }, count: 0 }),
            {
                  name: 'app-storage',
                        partialize: (state) => ({
                                user: { id: state.user.id, createdAt: state.user.createdAt.toISOString() },
                                        count: state.count,
                                              }),
                                                  }
                                                    )
                                                    );

방법 2: 버전 마이그레이션

const useStore = create(
    persist(
        (set) => ({ version: 2, theme: 'dark' }),
            { name: 'app-storage', version: 2, migrate: (s, v) => v < 2 ? { ...s, version: 2 } : s }
              )
              );

정리표

에러 상황원인해결법
Converting circular structure to JSON순환 참조 객체partialize로 필드 선택
Unexpected token u in JSONundefined 값 저장됨초기값을 null로 설정
함수가 저장되지 않음JSON 미지원 타입함수는 상태에서 제외

팁: Chrome DevTools → Application → Local Storage에서 저장된 값을 직접 확인하고 JSON.parse()로 유효성을 검사하세요.

금요일

TypeScript cannot find module 해결 — ts(2307) 에러 완벽 정리

🔍 검색 키워드: typescript cannot find module, ts2307 에러 해결, tsconfig paths 설정, typescript module not found, cannot find module or its corresponding type declarations

TypeScript 프로젝트 하다 보면 한 번쯤은 꼭 만나는 에러다.

Cannot find module 'X' or its corresponding type declarations. ts(2307)

빌드도 안 되고, IDE에서도 빨간 줄 잔뜩 그어지면서 당황하게 된다. 이 에러는 발생 원인이 여러 가지라 원인을 제대로 모르면 해결책을 찾아 헤매다 시간만 버린다.


1. 증상

error TS2307: Cannot find module '../utils/logger' or its corresponding type declarations.
error TS2307: Cannot find module '@/components/Button' or its corresponding type declarations.
error TS2307: Cannot find module 'lodash' or its corresponding type declarations.

import 구문에서 빨간 줄이 생기고, tsc 실행 시 컴파일이 실패한다. VSCode 같은 IDE에서도 동일하게 에러가 표시된다.


2. 원인별 분류

크게 4가지 패턴으로 나뉜다.

패턴 1: @types 패키지 누락

서드파티 라이브러리를 설치했지만 TypeScript 타입 정의 패키지를 설치하지 않은 경우다. lodash, express, node 같은 라이브러리는 별도의 @types/xxx 패키지가 필요하다.

# 확인 방법
ls node_modules/@types/

# 해결
npm install --save-dev @types/lodash
npm install --save-dev @types/node
npm install --save-dev @types/express

패턴 2: tsconfig.json 경로 별칭(Path Alias) 미설정

@/, ~/ 같은 경로 별칭을 사용하는데 tsconfig에 paths 설정이 없는 경우다. Vite, Next.js, CRA 등에서 흔하게 발생한다.

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "~/*": ["src/*"]
    }
  }
}
주의: Next.js는 tsconfig.json의 paths를 자동으로 인식하지만, Node.js 환경에서 직접 실행할 때는 tsconfig-paths 패키지가 추가로 필요하다.
npm install --save-dev tsconfig-paths

# 실행 시
ts-node -r tsconfig-paths/register src/index.ts

패턴 3: moduleResolution 설정 불일치

Node.js 16+ 혹은 ES Modules를 사용할 때 moduleResolution 설정이 맞지 않는 경우다.

// tsconfig.json
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  }
}

NodeNext 설정에서는 import 시 파일 확장자를 명시해야 한다.

// ❌ 에러
import { logger } from './utils/logger';

// ✅ 정상
import { logger } from './utils/logger.js';

컴파일된 결과가 .js이기 때문에 소스에서도 .js 확장자를 써야 한다. 처음엔 이상하게 느껴지지만 ESM 스펙이 이렇다.

패턴 4: node_modules 손상 또는 누락

패키지가 설치되어 있는데도 에러가 난다면 node_modules 자체가 손상됐을 가능성이 있다.

# 클린 재설치
rm -rf node_modules package-lock.json
npm install

3. 원인 진단 체크리스트

점검 항목 확인 방법 해결 방법
@types 패키지 설치 여부 ls node_modules/@types/ npm i -D @types/xxx
tsconfig paths 설정 tsconfig.json 확인 baseUrl + paths 추가
moduleResolution 설정 tsconfig.json 확인 NodeNext 또는 bundler
node_modules 상태 ls node_modules/[패키지명] 클린 재설치
상대 경로 확장자 import 구문 확인 .js 확장자 명시

4. --traceResolution 으로 정확히 추적하기

어디서 모듈을 찾는지 추적하고 싶다면 이 플래그를 쓴다.

tsc --traceResolution 2>&1 | grep -A 5 "Cannot find module"

TypeScript가 어떤 경로를 탐색했는지 전부 출력해준다. 설정 문제인지, 패키지 문제인지 바로 보인다.


5. 실제 사례 — Next.js에서 @/components 경로 에러

Cannot find module '@/components/Button' or its corresponding type declarations.

Next.js 프로젝트에서 흔히 겪는 케이스다.

// tsconfig.json — 이 설정이 없으면 에러 발생
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./*"]
    }
  }
}

Next.js 13+는 프로젝트 생성 시 자동으로 이 설정을 넣어주지만, 수동으로 구성한 프로젝트나 마이그레이션한 경우 누락되기 쉽다.


6. Jest + TypeScript 환경

Jestl��서도 같은 에러가 발생할 수 있다. Jest는 tsconfig의 paths를 직접 읽지 않기 때문에 별도 설정이 필요하다.

npm install --save-dev babel-jest @babel/preset-typescript ts-jest
// jest.config.js
module.exports = {
  preset: 'ts-jest',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
};

moduleNameMapper로 tsconfig의 paths와 동일하게 맞춰줘야 한다.


정리

TypeScript cannot find module 에러는 딱 떨어지는 단일 원인이 아니다. 발생 맥락을 보고 위 패턴 중 어디에 해당하는지 진단하는 게 먼저다. --traceResolution으로 탐색 경로를 직접 확인하면 대부분 30분 안에 해결된다.

작성일: 2026-06-26

화요일

TypeScript "Type is not assignable" 에러 완전 정복

🔍 검색 키워드: TypeScript 타입 에러, Type is not assignable to type, TS2322, TS2345, TypeScript 타입 오류 해결, typescript type error 해결

TypeScript 쓰다 보면 한 번쯤은 이 에러를 만난다.

Type 'string' is not assignable to type 'number'.  ts(2322)

처음엔 당황스럽지만, 패턴을 이해하면 금방 잡힌다. 이 글은 자주 나오는 케이스별로 원인과 해결을 정리했다.


에러가 생기는 이유

TypeScript는 정적 타입 언어다. 컴파일 타임에 타입을 검사하기 때문에, 선언된 타입과 실제 할당값이 다르면 에러를 낸다. Java의 컴파일 에러와 같은 맥락이다.


레벨 1 (초보자) — 기본 타입 불일치

증상

let count: number = "5";  // ❌ TS2322

원인

number 타입으로 선언된 변수에 string을 대입했다.

해결

// 방법 1: 타입에 맞는 값으로 수정
let count: number = 5;

// 방법 2: 타입 변환
let count: number = parseInt("5");

// 방법 3: 타입 선언 수정
let count: string = "5";

레벨 2 (중급자) — 함수 파라미터 타입 불일치

증상

function greet(name: string): string {
  return `Hello, ${name}`;
}
greet(123);  // ❌ TS2345

해결

// 방법 1: 올바른 타입으로 전달
greet("홍길동");

// 방법 2: Union 타입 사용
function greet(name: string | number): string {
  return `Hello, ${String(name)}`;
}

레벨 3 (중급자) — Object 타입 구조 불일치

증상

interface User {
  id: number;
  name: string;
  email: string;
}
const user: User = {
  id: 1,
  name: "홍길동"
  // email 누락 → ❌ TS2322
};

해결

// 방법 1: 빠진 프로퍼티 추가
const user: User = { id: 1, name: "홍길동", email: "hong@example.com" };

// 방법 2: 선택적 프로퍼티로 변경
interface User { id: number; name: string; email?: string; }

// 방법 3: Partial 사용
const partialUser: Partial<User> = { id: 1, name: "홍길동" };

레벨 4 (중급자) — 배열/제네릭 타입 불일치

증상

const ids: number[] = [1, 2, "3", 4];  // ❌ TS2322

function first<T>(arr: T[]): T { return arr[0]; }
const result: number = first(["a", "b"]);  // ❌ TS2322

해결

const ids: (number | string)[] = [1, 2, "3", 4];
const result: string = first(["a", "b"]);

레벨 5 (실무자) — null/undefined 처리

증상

function getUser(id: number): User | null {
  return id === 1 ? { id: 1, name: "홍길동", email: "hong@example.com" } : null;
}
const user: User = getUser(999);  // ❌ TS2322: 'User | null'

해결

// 방법 1: 타입에 null 포함
const user: User | null = getUser(999);

// 방법 2: null 체크 후 사용 (Type narrowing)
const maybeUser = getUser(999);
if (maybeUser !== null) {
  const user: User = maybeUser;
}

// 방법 3: Non-null assertion
const user: User = getUser(1)!;

// 방법 4: Nullish coalescing으로 기본값
const user: User = getUser(999) ?? { id: 0, name: "Guest", email: "" };

레벨 6 (고급자) — 타입 추론 문제

증상

const config = { mode: "development" };  // TypeScript가 string으로 추론
function setup(mode: "development" | "production") {}
setup(config.mode);  // ❌ TS2345

해결

// 방법 1: as const로 리터럴 타입 고정
const config = { mode: "development" } as const;

// 방법 2: 명시적 타입 선언
const config: { mode: "development" | "production" } = { mode: "development" };

// 방법 3: 타입 단언
setup(config.mode as "development" | "production");

상황별 체크리스트

상황확인 포인트
변수 할당 에러선언 타입과 값 타입 일치 여부
함수 인자 에러함수 시그니처와 전달값 타입 비교
객체 에러인터페이스 필수 프로퍼티 누락 여부
null 에러strictNullChecks 활성화 여부 확인
리터럴 타입 에러as const 또는 명시적 타입 선언 필요
any 남용any 대신 unknown + 타입 가드 사용

자주 하는 실수 — any로 도배

// ❌ TypeScript 쓰는 의미 없음
const data: any = fetchData();

// ✅ unknown 쓰고 타입 가드로 좁혀라
const data: unknown = fetchData();
if (typeof data === "string") {
  console.log(data.toUpperCase());
}

마무리

TypeScript 타입 에러는 대부분 세 가지다.

  1. 타입을 잘못 선언했거나
  2. 값이 여러 타입이 될 수 있는데 하나만 선언했거나
  3. null/undefined 처리를 안 했거나

에러 메시지를 읽으면 어느 쪽인지 대부분 나온다. ts(숫자) 에러코드로 TypeScript 공식 문서에서 정확한 설명도 찾을 수 있다.