shadcn/ui No import alias found 오류 해결: tsconfig 설정

··설명 약 13분·작성: 해비·블로그 소개

shadcn/ui의 No import alias found 오류에서 TypeScript와 Vite 별칭을 어떻게 맞추나요?

No import alias found가 나오면 TypeScript의 @/* paths와 Vite의 @ 별칭이 같은 src 폴더를 가리키는지 봅니다. 실제 파일을 @/로 가져와 타입 검사와 앱 실행을 각각 확인합니다.

적용 조건·범위: components.json은 shadcn CLI를 실제 초기화한 경우에만 존재합니다.

본문 근거: ui.shadcn.com/docs/installation/vite · TypeScript 공식 마이그레이션 지침 · ui.shadcn.com/docs/components-json

증상

CLI 초기화 중 다음과 비슷한 메시지가 나타날 수 있습니다.

No import alias found in your tsconfig.json file.

또는 생성된 component에서 다음 import를 에디터가 찾지 못할 수 있습니다.

import { cn } from '@/lib/utils';

alias란 무엇인가

상대 경로:

import { Button } from '../../../components/ui/button';

alias:

import { Button } from '@/components/ui/button';

@를 src에 매핑하면 파일 위치가 깊어져도 import 경로를 일정하게 유지할 수 있습니다.

TypeScript paths 설정

@/* alias는 tsconfig.json과 실제 app을 검사하는 tsconfig.app.json의 paths에 연결합니다. 아래는 TypeScript 7에서도 사용하는 상대 경로 paths 예시입니다. 기존 compilerOptions는 유지하고 paths 항목을 병합하세요. baseUrl은 추가하지 않습니다. 2026-09-28 확인한 shadcn 문서는 baseUrl 예시를 유지하지만, TypeScript 공식 마이그레이션 지침에 따라 paths 값을 상대 경로로 명시하면 해당 옵션 없이 해석할 수 있습니다.

tsconfig.json

{
  "files": [],
  "references": [
    {
      "path": "./tsconfig.app.json"
    },
    {
      "path": "./tsconfig.node.json"
    }
  ],
  "compilerOptions": {
    "paths": {
      "@/*": [
        "./src/*"
      ]
    }
  }
}

tsconfig.app.json

{
  "compilerOptions": {
    "paths": {
      "@/*": [
        "./src/*"
      ]
    }
  }
}

프로젝트의 실제 Vite/TypeScript 구조에 맞춰 어느 config가 app 파일을 검사하는지 확인합니다.

Vite runtime alias

TypeScript가 alias 타입을 이해하는 것과 Vite가 실제 module 경로를 해석하는 것은 별개의 설정일 수 있습니다.

@types/node를 설치합니다.

npm install -D @types/node

vite.config.ts:

import path from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';


export default defineConfig({
  plugins: [
    react(),
    tailwindcss(),
  ],
  resolve: {
    alias: {
      '@': path.resolve(
        __dirname,
        './src',
      ),
    },
  },
});

components.json

shadcn CLI는 components.json의 alias 설정도 사용합니다.

예:

{
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui",
    "lib": "@/lib",
    "hooks": "@/hooks"
  }
}

TypeScript/Vite alias와 components.json의 경로 규칙이 일치해야 합니다.

Tailwind v4 config

components.json에서 Tailwind v4를 사용할 때 tailwind config 경로는 빈 값으로 두는 구성이 사용됩니다.

{
  "tailwind": {
    "config": "",
    "css": "src/index.css"
  }
}

실제 CSS 파일 위치는 프로젝트에 맞춰 작성합니다.

2026년 package imports 대안

shadcn CLI는 package.json#imports 기반의 #components, #lib 같은 alias도 지원합니다.

단일 Vite 프로젝트에서 기존 @ alias 방식을 사용하고 있다면 반드시 바꿀 필요는 없습니다.

새 프로젝트나 monorepo에서 package imports가 더 자연스러운 경우 대안으로 검토할 수 있습니다.

오류 진단 순서

1. tsconfig의 paths 확인
2. app 파일이 실제 어떤 tsconfig를 사용하는지 확인
3. Vite resolve.alias 확인
4. @types/node 설치 확인
5. components.json alias 확인
6. TypeScript editor 재시작
7. shadcn CLI 재실행
8. npm run build

자주 발생하는 실수

tsconfig만 수정합니다

에디터는 이해하지만 Vite runtime resolve가 실패할 수 있습니다.

vite.config만 수정합니다

개발 서버는 동작하지만 TypeScript editor가 alias를 찾지 못할 수 있습니다.

@/*를 프로젝트 root와 src에 다르게 매핑합니다

하나의 규칙으로 통일합니다.

학습 완료 체크리스트

  • TypeScript paths와 Vite alias의 역할을 구분할 수 있습니다.
  • @/를 ./src/에 연결할 수 있습니다.
  • components.json alias와 실제 경로를 맞출 수 있습니다.
  • Tailwind v4에서 config 경로를 비워 두는 shadcn 설정을 이해했습니다.
  • package imports가 대안이 될 수 있음을 알고 있습니다.

독립 실습: 실제 파일로 alias 연결

별칭 경로에서 가져온 문구가 표시되는지 확인하세요.

예제 크게 보기 (새 탭)

생성 CLI를 실행하기 전에 실제 존재하는 작은 파일로 @ 경로를 검증합니다.

준비와 실행 파일

HTML의 요소·속성과 CSS 속성을 알고, 이 JSX 예제는 React 함수 컴포넌트의 반환 구조를 읽을 수 있어야 합니다. 새 Vite React + TypeScript 앱에서 Tailwind v4 설치의 plugin·CSS 연결 절차를 먼저 완료합니다. 생성된 index.html의 root를 유지하고 아래 세 파일을 각 경로에 저장합니다. TaskFlow 상태·컴포넌트·서버는 사용하지 않습니다.

앞의 paths·Vite alias 설정도 적용합니다. src/lib/message.ts를 만들고 아래 내용을 저장합니다.

export const message = 'alias 연결 성공';

src/App.tsx 전체 파일

import { message } from '@/lib/message';
export default function App() {
  return <h1 className="p-4 text-xl font-medium">{message}</h1>;
}

src/index.css 전체 파일

@import "tailwindcss";

src/main.tsx 전체 파일

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(<StrictMode><App /></StrictMode>);

실행과 예상 결과

npm run dev
npm run build

첫 명령이 표시하는 로컬 주소를 브라우저에서 열고, 두 번째 명령은 별도 터미널에서 실행합니다. TypeScript와 Vite가 모두 @/lib/message를 src/lib/message.ts로 해석하면 화면에 alias 연결 성공이 나옵니다. 이 검사는 shadcn CLI 초기화 성공과 별개입니다.

작은 변형으로 확인하기

paths만 틀리면 타입 검사에서, Vite alias만 틀리면 bundler에서 실패하는지 별도로 확인하고 복구합니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Tailwind CSS로 앱 화면을 다듬습니다.

TaskFlow Vite 프로젝트에 @ alias를 정상 연결한 상태를 확보합니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

TaskFlow 21단계 실행

시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.

예제 크게 보기 (새 탭)

실행 가능한 시작본과 이번 단계

이번 단계 시작 ZIP을 새 폴더에 압축 해제합니다. 이 ZIP은 직전 단계의 완료 파일과 같으며 앞 글의 코드를 따로 모을 필요가 없습니다. 이번 단계 완료 ZIP은 다른 폴더에 풀어 비교합니다.

기존 tsconfig의 @/* paths는 유지하고 Vite alias를 연결합니다. TaskListPage에서 실제 TaskSummary import를 @/components/TaskSummary로 바꿔 두 해석기를 함께 검증합니다.

  1. src/pages/TaskListPage.tsx: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.
  2. vite.config.ts: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.
  3. docs/alias.md: 새 파일로 저장합니다.

짧은 코드 예제는 변경 위치를 설명합니다. 파일 전체를 작성하거나 교체할 때는 완료 파일 전체를 사용하세요. 생략 표시가 있는 부분 코드를 파일 전체로 저장하지 않습니다.

실행: Node.js 22.12 이상에서 ZIP 루트의 package.json·package-lock.json을 유지하고 npm ci 후 npm run dev를 실행합니다. 별도 터미널에서 npm run build를 실행합니다. React·라우터·기존 상태 구조를 유지하고 Tailwind 스타일만 단계적으로 적용합니다.

직접 확인할 결과: TypeScript와 Vite build가 모두 실제 alias import를 해석합니다. shadcn CLI 초기화 성공을 뜻하지 않습니다.

이 시작본은 React step-28의 실제 전체 소스입니다. 기존 글의 일반화된 App.tsx/props 조각과 달리 실제 라우터의 AppLayout·Context·RHF API를 유지한 위치에 반영했습니다. README와 CHANGES.md에 각 파일의 변경 diff가 포함되어 있습니다.

전체 파일·코드 보기

완료 파일 전체

index.html 전체 파일

<!doctype html>
<html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>TaskFlow</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>

package.json 전체 파일

{
  "name": "taskflow-step-28-library-choice",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "vite --host 127.0.0.1",
    "build": "tsc --noEmit && vite build",
    "preview": "vite preview --host 127.0.0.1"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0",
    "react-hook-form": "7.88.0",
    "react-router": "7.18.4"
  },
  "devDependencies": {
    "typescript": "7.0.2",
    "vite": "8.3.0",
    "@vitejs/plugin-react": "6.1.1",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "@types/node": "26.6.2",
    "@tailwindcss/vite": "4.3.3",
    "tailwindcss": "4.3.3"
  }
}

src/App.tsx 전체 파일

import {
  Navigate,
  Route,
  Routes,
} from 'react-router';
import AppLayout from './components/AppLayout';
import SettingsPage from './pages/SettingsPage';
import TaskDetailPage from './pages/TaskDetailPage';
import TaskListPage from './pages/TaskListPage';

export default function App() {
  return (
    <Routes>
      <Route element={<AppLayout />}>
        <Route
          index
          element={<Navigate to="/tasks" replace />}
        />
        <Route
          path="/tasks"
          element={<TaskListPage />}
        />
        <Route
          path="/tasks/:taskId"
          element={<TaskDetailPage />}
        />
        <Route
          path="/settings"
          element={<SettingsPage />}
        />
      </Route>
    </Routes>
  );
}

src/actions/saveProfile.ts 전체 파일

export type SaveProfileState = {
  message: string;
};

export async function saveProfile(
  _previousState: SaveProfileState,
  formData: FormData,
): Promise<SaveProfileState> {
  const displayName = String(
    formData.get('displayName') ?? '',
  ).trim();

  await new Promise((resolve) =>
    setTimeout(resolve, 500),
  );

  if (!displayName) {
    return {
      message: '이름을 입력해 주세요.',
    };
  }

  return {
    message: '프로필을 저장했습니다.',
  };
}

src/actions/updateTask.ts 전체 파일

export async function updateTaskOnServer(
  taskId: string,
): Promise<void> {
  await new Promise((resolve) =>
    setTimeout(resolve, 600),
  );

  if (!taskId || taskId === 'fail') {
    throw new Error('작업 ID가 없습니다.');
  }
}

src/components/AppLayout.tsx 전체 파일

import {
  NavLink,
  Outlet,
} from 'react-router';
import Header from './Header';

export default function AppLayout() {
  return (
    <main className="mx-auto w-full max-w-3xl px-4 py-8 sm:px-6 sm:py-10 lg:py-12 text-gray-950 dark:text-gray-100">
      <Header />

      <nav aria-label="주요 메뉴">
        <NavLink to="/tasks">
          작업
        </NavLink>
        {' · '}
        <NavLink to="/settings">
          설정
        </NavLink>
      </nav>

      <Outlet />
    </main>
  );
}

src/components/AsyncDemo.tsx 전체 파일

import { useState } from 'react';
import OptimisticTaskList from './OptimisticTaskList';
import type { Task } from '../types/task';
export default function AsyncDemo() {
 const [tasks, setTasks] = useState<Task[]>([{id:'success',title:'저장 성공 실습',completed:false},{id:'fail',title:'저장 실패·복구 실습',completed:false}]);
 return <details><summary>낙관적 UI 실습</summary><p>실제 서버가 아닌 지연 모의 요청입니다. 아래 목록은 기본 작업 목록과 분리됩니다.</p>
 <OptimisticTaskList tasks={tasks} onCommitToggle={id => setTasks(previous => previous.map(task => task.id === id ? {...task,completed:!task.completed} : task))} /></details>;
}

src/components/Header.tsx 전체 파일

export default function Header() {
  return (
    <header>
      <p className="mb-2 text-sm font-medium text-task-muted dark:text-gray-400">
        React Learning Project
      </p>


      <h1 className="text-3xl font-bold text-task-text dark:text-gray-100">
        TaskFlow
      </h1>


      <p className="mt-3 text-base text-gray-600 dark:text-gray-400">
        오늘 할 일을 하나씩 정리해 보세요.
      </p>
    </header>
  );
}

src/components/NetworkStatus.tsx 전체 파일

import { useState } from 'react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
export default function NetworkStatus() {
 const [showDebugMessage, setShowDebugMessage] = useState(false);
 const isOnline = useNetworkStatus(showDebugMessage);
 return <section aria-label="네트워크 상태"><p>네트워크: {isOnline ? '온라인' : '오프라인'}</p>
 <label><input type="checkbox" checked={showDebugMessage} onChange={e => setShowDebugMessage(e.target.checked)} />네트워크 로그 표시</label></section>;
}

src/components/OptimisticTaskList.tsx 전체 파일

import {
  useTransition,
  useOptimistic,
  useState,
} from 'react';
import { updateTaskOnServer } from '../actions/updateTask';
import type { Task } from '../types/task';

type OptimisticTaskListProps = {
  tasks: Task[];
  onCommitToggle: (taskId: string) => void;
};

export default function OptimisticTaskList({
  tasks,
  onCommitToggle,
}: OptimisticTaskListProps) {
  const [isPending, startTransition] = useTransition();
  const [errorMessage, setErrorMessage] =
    useState('');

  const [optimisticTasks, toggleOptimistic] =
    useOptimistic(
      tasks,
      (currentTasks, taskId: string) =>
        currentTasks.map((task) =>
          task.id === taskId
            ? {
                ...task,
                completed: !task.completed,
              }
            : task,
        ),
    );

  const handleToggle = (taskId: string) => {
    setErrorMessage('');

    startTransition(async () => {
      toggleOptimistic(taskId);

      try {
        await updateTaskOnServer(taskId);
        startTransition(() => onCommitToggle(taskId));
      } catch {
        setErrorMessage(
          '저장에 실패했습니다. 기존 상태로 돌아갑니다.',
        );
      }
    });
  };

  return (
    <section>
      {errorMessage ? (
        <p role="alert">{errorMessage}</p>
      ) : null}

      <ul>
        {optimisticTasks.map((task) => (
          <li key={task.id}>
            <button
              type="button"
              disabled={isPending}
              onClick={() => handleToggle(task.id)}
            >
              {task.completed ? '완료 취소' : '완료'}
            </button>
            <span>{task.title}</span>
          </li>
        ))}
      </ul>
    </section>
  );
}

src/components/ProfileSaveForm.tsx 전체 파일

import { useActionState } from 'react';
import {
  saveProfile,
  type SaveProfileState,
} from '../actions/saveProfile';

const initialState: SaveProfileState = {
  message: '',
};

export default function ProfileSaveForm() {
  const [state, formAction, isPending] =
    useActionState(saveProfile, initialState);

  return (
    <form action={formAction}>
      <label>
        표시 이름
        <input name="displayName" />
      </label>

      <button disabled={isPending}>
        {isPending ? '저장 중...' : '저장'}
      </button>

      <p>{state.message}</p>
    </form>
  );
}

src/components/StateResetDemo.tsx 전체 파일

import { useState } from 'react';
import TaskEditor from './TaskEditor';
import type { Task } from '../types/task';
const examples: Task[] = [
 { id: 'example-1', title: '첫 번째 작업', completed: false },
 { id: 'example-2', title: '두 번째 작업', completed: false },
];
export default function StateResetDemo() {
 const [selectedId, setSelectedId] = useState(examples[0].id);
 const selectedTask = examples.find(task => task.id === selectedId)!;
 return <details><summary>state 초기화 실습</summary>
 <p>입력값은 저장되지 않습니다. 작업을 바꾸면 key가 달라져 입력값이 초기화됩니다.</p>
 {examples.map(task => <button type="button" key={task.id} onClick={() => setSelectedId(task.id)}>{task.title} 선택</button>)}
 <TaskEditor key={selectedTask.id} task={selectedTask} />
 </details>;
}

src/components/StatsSkeleton.tsx 전체 파일

export default function StatsSkeleton() {
  return (
    <section aria-busy="true">
      <h2>작업 통계</h2>
      <p>통계를 불러오는 중...</p>
    </section>
  );
}

src/components/TaskCard.tsx 전체 파일

import type { ReactNode } from 'react';

type TaskCardProps = {
  children: ReactNode;
  actions?: ReactNode;
  className?: string;
};

export default function TaskCard({
  children,
  actions,
  className,
}: TaskCardProps) {
  return (
    <li className={['@container border p-4', className ?? 'border-task-border bg-task-surface text-task-text dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100'].join(' ')}>
      <div className="flex flex-col gap-3 @md:flex-row @md:items-center">
        {children}
      </div>
      {actions ? (
        <div className="task-card__actions">
          {actions}
        </div>
      ) : null}
    </li>
  );
}

src/components/TaskEditor.tsx 전체 파일

import { useState } from 'react';
import type { Task } from '../types/task';

type TaskEditorProps = {
  task: Task;
};

export default function TaskEditor({
  task,
}: TaskEditorProps) {
  const [title, setTitle] = useState(task.title);

  return (
    <section>
      <h2>작업 편집</h2>
      <label htmlFor="editor-title">편집 입력 연습</label>
      <input id="editor-title"
        value={title}
        onChange={(event) => setTitle(event.target.value)}
      />
      <p>선택한 작업 ID: {task.id}</p>
    </section>
  );
}

src/components/TaskFilter.tsx 전체 파일

import type { Dispatch, SetStateAction } from 'react';
import type { TaskStatusFilter } from '../types/task';
type TaskFilterProps = {
  keyword: string;
  onKeywordChange: Dispatch<SetStateAction<string>>;
  status: TaskStatusFilter;
  onStatusChange: Dispatch<SetStateAction<TaskStatusFilter>>;
  onReset: () => void;
};
const options = [
  { value: 'all', label: '전체' },
  { value: 'active', label: '진행 중' },
  { value: 'completed', label: '완료' },
] as const;
const baseButton = 'transition-colors duration-150 motion-reduce:transition-none min-h-11 border px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black dark:focus-visible:outline-white';
const stateClasses = {
  selected: 'border-black bg-black text-white dark:border-white dark:bg-white dark:text-black',
  idle: 'border-gray-400 bg-white text-gray-900 dark:border-gray-600 dark:bg-gray-950 dark:text-gray-100',
} as const;
export default function TaskFilter({ keyword, onKeywordChange, status, onStatusChange, onReset }: TaskFilterProps) {
  return (
    <section aria-label="작업 필터" className="grid gap-3">
      <label htmlFor="task-keyword">작업 검색</label>
      <input id="task-keyword" value={keyword} onChange={event => onKeywordChange(event.target.value)} className="min-w-0 border border-gray-300 bg-white px-3 py-2 text-gray-950" />
      <div className="flex flex-wrap gap-2" role="group" aria-label="작업 상태">
        {options.map(option => {
          const selected = status === option.value;
          return (
            <button key={option.value} type="button" aria-pressed={selected} onClick={() => onStatusChange(option.value)} className={[baseButton, selected ? stateClasses.selected : stateClasses.idle].join(' ')}>
              {option.label}
            </button>
          );
        })}
      </div>
      <button type="button" onClick={onReset} className="justify-self-start border border-gray-400 px-3 py-2">필터 초기화</button>
    </section>
  );
}

src/components/TaskForm.tsx 전체 파일

import { useForm } from 'react-hook-form';
import { useTaskContext } from '../context/TaskContext';

type TaskFormValues = {
  title: string;
};

export default function TaskForm() {
  const { dispatch } = useTaskContext();

  const {
    register,
    handleSubmit,
    setValue,
    setFocus,
    formState: { errors, isSubmitting: isPending },
  } = useForm<TaskFormValues>({
    defaultValues: {
      title: '',
    },
  });

  const onSubmit = (values: TaskFormValues) => {
    const title = values.title.trim();

    dispatch({
      type: 'task/added',
      task: {
        id: crypto.randomUUID(),
        title,
        completed: false,
      },
    });

    setValue('title', '');
    setFocus('title');
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate className="flex flex-col gap-2 sm:flex-row">
      <label htmlFor="task-title">
        새 작업
      </label>

      <input
        className="peer min-h-11 min-w-0 grow border border-gray-300 bg-white px-3 py-2 text-gray-950"
        id="task-title"
        required
        minLength={2}
        aria-invalid={Boolean(errors.title)}
        aria-describedby={
          errors.title ? 'task-title-error' : undefined
        }
        {...register('title', {
          setValueAs: (value: string) => value.trim(),
          required: '작업 제목을 입력해 주세요.',
          minLength: {
            value: 2,
            message: '작업 제목은 2자 이상 입력해 주세요.',
          },
          validate: (value) =>
            value.trim().length > 0 ||
            '공백만 입력할 수 없습니다.',
        })}
      />

      <span className="hidden text-sm peer-invalid:block">작업 제목을 입력하세요.</span>

      {errors.title && (
        <p id="task-title-error" role="alert">
          {errors.title.message}
        </p>
      )}

      <button type="submit" disabled={isPending} className="min-h-11 shrink-0 border border-black bg-black px-4 py-2 text-white disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-500 focus-visible:outline-2 focus-visible:outline-offset-2">
        {isPending ? '저장 중...' : '추가'}
      </button>
    </form>
  );
}

src/components/TaskItem.tsx 전체 파일

import { Link } from 'react-router';
import TaskCard from './TaskCard';
import { useTaskContext } from '../context/TaskContext';
import type { Task } from '../types/task';

const taskStateClasses = {
  active: 'border-task-border bg-task-surface text-task-text dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100',
  completed: 'border-gray-400 bg-gray-100 text-gray-600 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-400',
} as const;

type TaskItemProps = {
  task: Task;
};

export default function TaskItem({
  task,
}: TaskItemProps) {
  const { dispatch } = useTaskContext();

  const titleClassName = task.completed
    ? 'min-w-0 grow wrap-anywhere text-gray-500 line-through dark:text-gray-400'
    : 'min-w-0 grow wrap-anywhere text-task-text dark:text-gray-100';

  return (
    <TaskCard className={taskStateClasses[task.completed ? 'completed' : 'active']}>
      <button
        className="min-h-11 shrink-0 border border-gray-400 px-3 py-2 aria-pressed:border-black aria-pressed:bg-black aria-pressed:text-white focus-visible:outline-2 focus-visible:outline-offset-2"
        type="button"
        aria-pressed={task.completed}
        onClick={() =>
          dispatch({
            type: 'task/toggled',
            id: task.id,
          })
        }
      >
        {task.completed ? '완료 취소' : '완료'}
      </button>

      <span className={titleClassName}>{task.title}</span>

      <button
        className="min-h-11 shrink-0 border border-gray-400 px-3 py-2 aria-pressed:border-black aria-pressed:bg-black aria-pressed:text-white focus-visible:outline-2 focus-visible:outline-offset-2"
        type="button"
        onClick={() =>
          dispatch({
            type: 'task/deleted',
            id: task.id,
          })
        }
      >
        삭제
      </button>
    <Link to={`/tasks/${task.id}`}>상세</Link>
    </TaskCard>
  );
}

src/components/TaskList.tsx 전체 파일

import { useTaskContext } from '../context/TaskContext';
import type { Task } from '../types/task';
import TaskItem from './TaskItem';


type TaskListProps = {
  tasks: Task[];
};


export default function TaskList({
  tasks,
}: TaskListProps) {
  const { tasks: allTasks } = useTaskContext();


  if (allTasks.length === 0) {
    return <p>등록된 작업이 없습니다.</p>;
  }


  if (tasks.length === 0) {
    return <p>조건에 맞는 작업이 없습니다.</p>;
  }


  return (
    <ul className="flex flex-col gap-3">
      {tasks.map((task) => (
        <TaskItem key={task.id} task={task} />
      ))}
    </ul>
  );
}

src/components/TaskPreferences.tsx 전체 파일

export default function TaskPreferences() {
  return (
    <label className="flex min-h-11 items-center gap-3 border border-gray-300 bg-white p-3 has-checked:border-black has-checked:bg-gray-100">
      <input
        type="checkbox"
        className="size-4"
      />
      완료 작업 강조
    </label>
  );
}

src/components/TaskStats.tsx 전체 파일

import type { Task } from '../types/task';

type TaskStatsProps = {
  tasks: Task[];
};

export default function TaskStats({
  tasks,
}: TaskStatsProps) {
  const completedCount = tasks.filter(
    (task) => task.completed,
  ).length;

  return (
    <section>
      <h2>작업 통계</h2>
      <p>전체: {tasks.length}</p>
      <p>완료: {completedCount}</p>
      <p>진행 중: {tasks.length - completedCount}</p>
    </section>
  );
}

src/components/TaskSummary.tsx 전체 파일

type TaskSummaryProps = {
  total: number;
  completed: number;
  remaining: number;
};


export default function TaskSummary({
  total,
  completed,
  remaining,
}: TaskSummaryProps) {
  const items = [
    ['전체', total],
    ['완료', completed],
    ['남음', remaining],
  ] as const;


  return (
    <section
      aria-label="작업 요약"
      className="grid grid-cols-1 gap-3 sm:grid-cols-3"
    >
      {items.map(([label, value]) => (
        <div
          key={label}
          className="border border-task-border bg-task-surface p-4 text-task-text dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100"
        >
          <p className="text-sm text-task-muted dark:text-gray-400">
            {label}
          </p>
          <strong className="mt-2 block text-2xl font-bold text-task-text dark:text-gray-100">
            {value}
          </strong>
        </div>
      ))}
    </section>
  );
}

src/components/ThemeSettings.tsx 전체 파일

type Theme = 'light' | 'dark';


type ThemeSettingsProps = {
  theme: Theme;
  onChange: (theme: Theme) => void;
};


export default function ThemeSettings({
  theme,
  onChange,
}: ThemeSettingsProps) {
  return (
    <fieldset className="grid gap-2">
      <legend className="text-sm font-medium">
        화면 테마
      </legend>


      <div className="flex gap-2">
        <button
          type="button"
          aria-pressed={theme === 'light'}
          onClick={() => onChange('light')}
          className="min-h-11 border border-gray-400 px-3 py-2 aria-pressed:border-black aria-pressed:bg-black aria-pressed:text-white dark:border-gray-600 dark:aria-pressed:border-white dark:aria-pressed:bg-white dark:aria-pressed:text-black"
        >
          라이트
        </button>


        <button
          type="button"
          aria-pressed={theme === 'dark'}
          onClick={() => onChange('dark')}
          className="min-h-11 border border-gray-400 px-3 py-2 aria-pressed:border-black aria-pressed:bg-black aria-pressed:text-white dark:border-gray-600 dark:aria-pressed:border-white dark:aria-pressed:bg-white dark:aria-pressed:text-black"
        >
          다크
        </button>
      </div>
    </fieldset>
  );
}

src/context/TaskContext.tsx 전체 파일

import {
  createContext,
  useContext,
  useReducer,
  type Dispatch,
  type ReactNode,
} from 'react';
import { taskReducer } from '../reducers/taskReducer';
import type {
  Task,
  TaskAction,
} from '../types/task';

type TaskContextValue = {
  tasks: Task[];
  dispatch: Dispatch<TaskAction>;
};

const TaskContext = createContext<
  TaskContextValue | undefined
>(undefined);

const initialTasks: Task[] = [
  {
    id: 'task-1',
    title: 'Reducer와 Context 연결하기',
    completed: false,
  },
];

export function TaskProvider({
  children,
}: {
  children: ReactNode;
}) {
  const [tasks, dispatch] = useReducer(
    taskReducer,
    initialTasks,
  );

  return (
    <TaskContext.Provider
      value={{ tasks, dispatch }}
    >
      {children}
    </TaskContext.Provider>
  );
}

export function useTaskContext() {
  const value = useContext(TaskContext);

  if (!value) {
    throw new Error(
      'useTaskContext는 TaskProvider 안에서 사용해야 합니다.',
    );
  }

  return value;
}

src/hooks/useNetworkStatus.ts 전체 파일

import {
  useEffect,
  useEffectEvent,
  useState,
} from 'react';

export function useNetworkStatus(
  showDebugMessage: boolean,
) {
  const [isOnline, setIsOnline] = useState(
    navigator.onLine,
  );

  const onOnline = useEffectEvent(() => {
    if (showDebugMessage) {
      console.log('TaskFlow가 온라인 상태입니다.');
    }

    setIsOnline(true);
  });

  const onOffline = useEffectEvent(() => {
    if (showDebugMessage) {
      console.log('TaskFlow가 오프라인 상태입니다.');
    }

    setIsOnline(false);
  });

  useEffect(() => {
    const handleOnline = () => onOnline();
    const handleOffline = () => onOffline();

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener(
        'online',
        handleOnline,
      );
      window.removeEventListener(
        'offline',
        handleOffline,
      );
    };
  }, []);

  return isOnline;
}

src/hooks/useTaskFilter.ts 전체 파일

import {
  useDeferredValue,
  useState,
  useTransition,
  type Dispatch,
  type SetStateAction,
} from 'react';
import type {
  Task,
  TaskStatusFilter,
} from '../types/task';

export function useTaskFilter(tasks: Task[]) {
  const [keyword, setKeyword] = useState('');
  const [status, setStatusState] =
    useState<TaskStatusFilter>('all');
  const [isPending, startTransition] =
    useTransition();

  const deferredKeyword =
    useDeferredValue(keyword);

  const normalizedKeyword = deferredKeyword
    .trim()
    .toLowerCase();

  const visibleTasks = tasks.filter((task) => {
    const matchesKeyword = task.title
      .toLowerCase()
      .includes(normalizedKeyword);

    const matchesStatus =
      status === 'all' ||
      (status === 'active' && !task.completed) ||
      (status === 'completed' && task.completed);

    return matchesKeyword && matchesStatus;
  });

  const setStatus: Dispatch<
    SetStateAction<TaskStatusFilter>
  > = (nextStatus) => {
    startTransition(() => {
      setStatusState(nextStatus);
    });
  };

  const resetFilter = () => {
    setKeyword('');
    startTransition(() => {
      setStatusState('all');
    });
  };

  return {
    keyword,
    setKeyword,
    status,
    setStatus,
    visibleTasks,
    resetFilter,
    isStale: keyword !== deferredKeyword,
    isPending,
  };
}

src/hooks/useTaskStats.ts 전체 파일

import { useTaskContext } from '../context/TaskContext';

export function useTaskStats() {
  const { tasks } = useTaskContext();

  const totalCount = tasks.length;

  const completedCount = tasks.filter(
    (task) => task.completed,
  ).length;

  const remainingCount =
    totalCount - completedCount;

  return {
    totalCount,
    completedCount,
    remainingCount,
  };
}

src/index.css 전체 파일

@import "tailwindcss";
@layer base {
  body { font-family: Arial, sans-serif; color: #111; background: #f5f5f5; }
  h2 { font-size: 24px; font-weight: 700; }
  h3 { font-size: 20px; font-weight: 500; }
  button, input, select { min-height: 44px; border: 1px solid #aaa; padding: 8px 12px; }
  button { cursor: pointer; }
  button:disabled { cursor: wait; opacity: .6; }
  input:not([type="checkbox"]), select { max-width: 100%; }
  form, section { margin-block: 20px; }
  details { margin-block: 24px; }
  summary { cursor: pointer; min-height: 44px; }
  :focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
}

@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@layer base { html[data-theme=dark] body { background: #111827; color: #f3f4f6; } }

@theme {
  --color-task-surface: #ffffff;
  --color-task-border: #d1d5db;
  --color-task-text: #111827;
  --color-task-muted: #6b7280;
}

@utility content-auto { content-visibility: auto; }

src/main.tsx 전체 파일

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import './index.css';
import App from './App';
import { TaskProvider } from './context/TaskContext';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <TaskProvider>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </TaskProvider>
  </StrictMode>,
);

src/pages/SettingsPage.tsx 전체 파일

import { useEffect, useState } from 'react';
import ThemeSettings from '../components/ThemeSettings';
import TaskPreferences from '../components/TaskPreferences';
import AsyncDemo from '../components/AsyncDemo';
import ProfileSaveForm from '../components/ProfileSaveForm';

export default function SettingsPage() {
  const [theme, setTheme] = useState<'light' | 'dark'>(() => document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light');
  useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]);
  return (
    <div className="grid grid-cols-1 gap-6 md:grid-cols-[12rem_minmax(0,1fr)]">
      <aside className="border border-gray-300 bg-white p-4 dark:border-gray-700 dark:bg-gray-950">설정 메뉴</aside>
      <section className="min-w-0">
      <h2>설정</h2>
      <p>TaskFlow 설정 화면입니다.</p>
      <ThemeSettings theme={theme} onChange={setTheme} />
      <TaskPreferences />
      <ProfileSaveForm /><AsyncDemo />
      </section>
    </div>
  );
}

src/pages/TaskDetailPage.tsx 전체 파일

import TaskItem from '../components/TaskItem';
import {
  Link,
  useParams,
} from 'react-router';
import { useTaskContext } from '../context/TaskContext';

export default function TaskDetailPage() {
  const { taskId } = useParams();
  const { tasks } = useTaskContext();

  const task = tasks.find(
    (item) => item.id === taskId,
  );

  if (!task) {
    return (
      <section>
        <h2>작업을 찾을 수 없습니다.</h2>
        <Link to="/tasks">
          목록으로 돌아가기
        </Link>
      </section>
    );
  }

  return (
    <section>
      <h2 className="wrap-anywhere">{task.title}</h2>
      <aside className="w-64 max-w-full" aria-label="좁은 작업 카드"><ul><TaskItem task={task} /></ul></aside>
      <p>
        상태: {task.completed ? '완료' : '진행 중'}
      </p>
      <Link to="/tasks">
        목록으로 돌아가기
      </Link>
    </section>
  );
}

src/pages/TaskListPage.tsx 전체 파일

import { useEffect, lazy, Suspense } from 'react';
import TaskForm from '../components/TaskForm';
import TaskList from '../components/TaskList';
import TaskFilter from '../components/TaskFilter';
import StateResetDemo from '../components/StateResetDemo';
import NetworkStatus from '../components/NetworkStatus';
import TaskSummary from '@/components/TaskSummary';
import { useTaskContext } from '../context/TaskContext';
import { useTaskStats } from '../hooks/useTaskStats';
import { useTaskFilter } from '../hooks/useTaskFilter';
import StatsSkeleton from '../components/StatsSkeleton';
const TaskStats = lazy(() => import('../components/TaskStats'));
export default function TaskListPage() {
 const { tasks } = useTaskContext();
 const { totalCount, completedCount, remainingCount } = useTaskStats();
 const { keyword, setKeyword, status, setStatus, visibleTasks, resetFilter, isStale, isPending } = useTaskFilter(tasks);
 useEffect(() => { document.title = `TaskFlow (${remainingCount})`; }, [remainingCount]);
 return <section className="mt-8">
 <h2>작업</h2>
 <NetworkStatus />
 <TaskSummary total={totalCount} completed={completedCount} remaining={remainingCount} />
 <TaskForm />
 <TaskFilter keyword={keyword} onKeywordChange={setKeyword} status={status} onStatusChange={setStatus} onReset={resetFilter} />
 <p role="status">{isStale ? "검색 결과 갱신 중..." : isPending ? "필터 갱신 중..." : ""}</p>
 <section className="content-auto" aria-label="작업 목록"><TaskList tasks={visibleTasks} /></section>
 <Suspense fallback={<StatsSkeleton />}><TaskStats tasks={tasks} /></Suspense>
 <StateResetDemo />
 
 </section>;
}

src/reducers/taskReducer.ts 전체 파일

import type {
  Task,
  TaskAction,
} from '../types/task';

export function taskReducer(
  tasks: Task[],
  action: TaskAction,
): Task[] {
  switch (action.type) {
    case 'task/added':
      return [...tasks, action.task];

    case 'task/toggled':
      return tasks.map((task) =>
        task.id === action.id
          ? { ...task, completed: !task.completed }
          : task,
      );

    case 'task/deleted':
      return tasks.filter(
        (task) => task.id !== action.id,
      );

    default:
      return tasks;
  }
}

src/types/task.ts 전체 파일

export type TaskId = string;

export type Task = {
  id: TaskId;
  title: string;
  completed: boolean;
};

export type TaskAction =
  | {
      type: 'task/added';
      task: Task;
    }
  | {
      type: 'task/toggled';
      id: TaskId;
    }
  | {
      type: 'task/deleted';
      id: TaskId;
    };

export type TaskStatusFilter = 'all' | 'active' | 'completed';

tsconfig.json 전체 파일

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": [
      "ES2022",
      "DOM",
      "DOM.Iterable"
    ],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "types": [
      "vite/client"
    ],
    "paths": {
      "@/*": [
        "./src/*"
      ]
    }
  },
  "include": [
    "src",
    "vite.config.ts"
  ]
}

vite.config.ts 전체 파일

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { fileURLToPath, URL } from 'node:url';

export default defineConfig({
  plugins: [react(), tailwindcss()],
  resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
});

위 파일과 ZIP의 동명 파일은 동일합니다. package-lock.json과 회차별 README·CHANGES 문서는 ZIP에 포함합니다. 생성 산출물·node_modules·실제 환경 변수 파일은 포함하지 않습니다.

시작본과 이번 변경

Radix UI와 shadcn/ui 차이: 컴포넌트를 고를 때 헷갈리는 기준 정리의 하단 완료 상태를 기준으로 이어갑니다. 기존 설명은 이전 글의 변경 내용을 연결하는 안내입니다. 위 시작 ZIP에 해당 상태의 전체 파일을 제공하므로 이제 별도로 조립하지 않아도 됩니다.

아래 변경 파일과 각 코드의 적용 위치를 따라 이번 속성만 바꾼 뒤, 기존 추가·완료·삭제 기능을 확인하고 이 글의 실행 확인 항목으로 화면을 비교합니다. 부분 코드의 생략 영역에는 이전 단계의 props·상태·handler를 유지합니다.

이전 체크포인트

tailwind-step-20-ui-library-choice

현재 상태

Tailwind TaskFlow 자체에는 shadcn/ui가 필요하지 않습니다.

이 회차는 React 29회차 shadcn/ui 설치 과정에서 오류가 발생했을 때 사용할 수 있는 독립 해결 체크포인트입니다.

이번 목표

TaskFlow Vite 프로젝트에 @ alias를 정상 연결한 상태를 확보합니다.

변경 파일

tsconfig.json
tsconfig.app.json
vite.config.ts
components.json

components.json은 shadcn CLI를 실제 초기화한 경우에만 존재합니다.

검증

npm run build
npx shadcn@latest init

또는 이미 초기화했다면 component 추가로 경로를 확인합니다.

npx shadcn@latest add button

생성된 import가 정상 resolve되는지 확인합니다.

완료 체크포인트

tailwind-step-21-shadcn-alias-fix

직접 만드는 화면과 적용 전후 점검

독자 질문: shadcn ui No import alias found 오류 해결 tsconfig 설정을 읽고 무엇을 직접 확인해야 할까요?

만들 화면: shadcn/ui 설치 선택 시 alias 오류를 확인하는 진단 화면을 준비합니다.

  1. tsconfig paths의 @/*, Vite resolve.alias, components.json aliases의 경로가 같은 src를 가리키는지 확인합니다.
  2. 빌드와 CLI 초기화의 오류가 사라지고 생성된 import가 해석되는지 각각 확인합니다.
  3. 실제 CLI를 실행하지 않았으면 오류 해결 성공이라고 기록하지 않습니다.

다음 학습: Tailwind CSS flex 레이아웃 깨질 때 확인할 것 — https://blogflow.kr/tailwind-flex-layout/

검증 기록: 이 편집 작업에서 화면 변경·개발 서버·빌드·브라우저 조작은 실행하지 않았습니다. 위 내용은 독자 실습의 예상 확인 기준이며 실제 결과는 미확인입니다.

다음 학습

다음 글에서는 Tailwind Flexbox 자체는 적용됐지만 긴 콘텐츠와 flex item 최소 너비 때문에 레이아웃이 깨지는 문제를 진단합니다.

다음 글: Tailwind CSS flex 레이아웃 깨질 때 확인할 것

공식 문서

글 소개·학습 준비·전체 목차
shadcn/ui: 경로 별칭 오류 해결 표지

Tailwind CSS · 21 / 23 · 선택 오류 해결 · shadcn/ui import alias

shadcn/ui의 No import alias found 오류가 발생하면 TypeScript의 @/* paths와 Vite의 @ alias가 같은 src 경로를 가리키는지 먼저 확인합니다. 작은 파일을 @/ 경로로 import해 타입 검사와 앱 실행을 각각 확인한 뒤 shadcn/ui 초기화를 진행합니다.

이 문제는 Tailwind class 자체의 오류가 아니라 TypeScript와 Vite가 @ 경로를 어떻게 해석하는지에 관한 설정 문제입니다.

이 글은 선택 오류 해결 글입니다. Tailwind 과정의 필수 흐름에는 필요하지 않으며 shadcn/ui를 실제 도입할 때 참고하면 됩니다.

이번 글에서 배우는 내용

  • import alias가 필요한 이유
  • tsconfig paths
  • Vite resolve.alias
  • tsconfig.json과 tsconfig.app.json
  • components.json aliases
  • @types/node
  • TypeScript alias와 runtime bundler alias 차이
  • 2026년 shadcn package imports 대안

단독 학습 선수 지식

HTML 요소·속성, CSS의 선택자·속성·box model을 먼저 익힙니다. React 예제는 함수 컴포넌트와 JSX를 사용합니다. 상단 실습은 새 Vite 앱에서 실행하고, TaskFlow 완료본은 하단에서만 사용합니다.

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 23개 · 필수 16개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기