Use Cases (업데이트: 2026. 6. 2.)

Claude Code로 Vue 3 개발하기: TypeScript, Pinia, 테스트 실전

Claude Code로 Vue 3, TypeScript, Pinia, Composable, Vitest를 실무 흐름에 맞게 개선하는 방법.

Claude Code로 Vue 3 개발하기: TypeScript, Pinia, 테스트 실전

Vue 3 개발에서 Claude Code의 장점은 컴포넌트를 빠르게 생성하는 데서 끝나지 않습니다. 기존 SFC를 읽고, TypeScript 타입을 유지하고, 반복되는 로직을 Composable로 빼고, 공유 상태를 Pinia로 정리한 뒤 Vitest 테스트까지 추가하는 흐름에서 효과가 큽니다. 이런 작업은 데모 코드보다 실제 팀의 유지보수에 더 가깝습니다.

Vue는 유연한 만큼 코드 스타일이 쉽게 섞입니다. 한 프로젝트 안에 Options API, Composition API, script setup, Pinia, 예전 Vuex 습관, Nuxt 규칙, 로컬 헬퍼가 함께 있을 수 있습니다. Claude Code에 “Vue 폼 만들어줘”라고만 요청하면 동작은 하지만 프로젝트 경계와 맞지 않는 코드가 나올 수 있습니다.

이 글은 Claude Code + Vue 3 + TypeScript를 기준으로, 기존 Vue 수정, 폼 구현, Pinia 상태 관리, Composable 추출, Vitest 테스트 추가를 다룹니다. Vue 기본은 Vue TypeScript with Composition API, 상태 관리는 Pinia 공식 문서, 테스트는 Vitest 가이드를 함께 확인하세요. Claude Code의 타입 설계는 TypeScript Tips, 검증 범위는 Testing Strategy Guide도 참고할 수 있습니다.

프로젝트 전제

대상은 Vue 3, TypeScript, Vite, Pinia, Vitest를 사용하는 업무용 앱입니다. 고객 지원, 관리자 화면, 예약 관리, 내부 승인, 콘텐츠 관리처럼 폼, 목록, 검색, 페이지네이션, 공유 상태, 테스트가 함께 등장하는 화면을 가정합니다.

Claude Code에는 harness, 즉 에이전트가 안전하게 작업하는 발판을 줘야 합니다. 허용 파일, 금지 사항, 기존 규칙, 실행할 명령, 리뷰 관점을 명확히 적는 것입니다. 이 발판이 없으면 Claude Code가 국소 문제를 해결하면서 any를 넣거나 UI 문구를 바꾸거나 상태 책임을 잘못 옮길 수 있습니다.

상황Claude Code에 맡길 일사람이 결정할 일
기존 Vue 수정SFC 읽기, 중복 로직 찾기, 타입 보강UI 호환성, 릴리스 리스크
폼 구현입력 상태, 검증, 제출 흐름문구, API 계약
Pinia 상태store 타입, getter, action, 테스트영속화 여부, 책임 경계
Composable 추출필터, 페이지네이션, 재사용 로직실제 재사용 가치
테스트 추가정상계, 예외계, 회귀 테스트보장 범위

전체 구조

예제 코드가 늘어날수록 중요한 질문은 “Claude Code가 쓸 수 있는가”가 아니라 “어디에 책임을 둘 것인가”입니다. 경계가 명확하면 생성된 diff도 리뷰하기 쉽습니다.

flowchart LR
  A[Vue SFC] --> B[Composable]
  A --> C[Pinia Store]
  B --> D[Vitest]
  C --> D
  E[Claude Code Prompt] --> A
  E --> B
  E --> C
  E --> D

SFC는 화면과 사용자 이벤트를 담당합니다. Composable은 필터링과 페이지네이션 같은 재사용 가능한 반응형 로직을 담당합니다. Pinia store는 여러 화면에서 공유하는 상태를 담당합니다. Vitest는 깨지면 안 되는 동작을 고정합니다.

Vue 3 + TypeScript 환경 만들기

새로 검증한다면 Vue 공식 스캐폴딩으로 시작합니다. 기존 저장소라면 먼저 현재 typecheck와 단위 테스트가 통과하는지 확인해야 Claude Code의 변경을 공정하게 평가할 수 있습니다.

npm create vue@latest support-desk-vue -- --typescript --router --pinia --vitest
cd support-desk-vue
npm install
npm run dev

“최신 Vue로 만들어줘”라고만 쓰면 안 됩니다. npm, pnpm, yarn 중 무엇을 쓰는지, Vue Router가 있는지, Pinia가 표준인지, 어떤 테스트 명령을 실행해야 하는지 명시하세요. 기존 앱에서는 package.json, vite.config.ts, tsconfig.app.json, src/components, src/stores를 먼저 읽게 하는 것이 좋습니다.

유스케이스 1: 타입 안전한 폼 SFC

폼은 v-model, props, emit, 검증, 비동기 제출, store action이 한곳에 모이기 때문에 Vue 초보자도 자주 막히는 주제입니다. 아래 예시는 src/components/TicketForm.vue로 사용할 수 있습니다. props를 직접 바꾸지 않고 로컬 ref에 편집 값을 보관한 뒤 store에 전달합니다.

<script setup lang="ts">
import { computed, ref } from 'vue';
import { useTicketStore, type TicketPriority } from '@/stores/ticketStore';

const props = withDefaults(
  defineProps<{
    initialTitle?: string;
    defaultPriority?: TicketPriority;
  }>(),
  {
    initialTitle: '',
    defaultPriority: 'medium',
  },
);

const emit = defineEmits<{
  submitted: [id: string];
}>();

const store = useTicketStore();
const title = ref(props.initialTitle);
const description = ref('');
const priority = ref<TicketPriority>(props.defaultPriority);
const touched = ref(false);

const titleError = computed(() => {
  if (!touched.value) return '';
  if (title.value.trim().length < 5) return 'Use at least 5 characters.';
  return '';
});

const descriptionError = computed(() => {
  if (!touched.value) return '';
  if (description.value.trim().length < 20) return 'Use at least 20 characters.';
  return '';
});

const canSubmit = computed(() => {
  return (
    title.value.trim().length >= 5 &&
    description.value.trim().length >= 20 &&
    !store.isSaving
  );
});

async function submit() {
  touched.value = true;
  if (!canSubmit.value) return;

  const ticket = await store.saveTicket({
    title: title.value.trim(),
    description: description.value.trim(),
    priority: priority.value,
  });

  title.value = '';
  description.value = '';
  priority.value = props.defaultPriority;
  touched.value = false;
  emit('submitted', ticket.id);
}
</script>

<template>
  <form class="ticket-form" @submit.prevent="submit">
    <label>
      Title
      <input v-model="title" name="title" @blur="touched = true" />
    </label>
    <p v-if="titleError" class="error">{{ titleError }}</p>

    <label>
      Description
      <textarea v-model="description" name="description" @blur="touched = true" />
    </label>
    <p v-if="descriptionError" class="error">{{ descriptionError }}</p>

    <label>
      Priority
      <select v-model="priority" name="priority">
        <option value="low">Low</option>
        <option value="medium">Medium</option>
        <option value="high">High</option>
      </select>
    </label>

    <button type="submit" :disabled="!canSubmit">
      {{ store.isSaving ? 'Saving...' : 'Create ticket' }}
    </button>
  </form>
</template>

Claude Code에 이런 SFC를 요청할 때는 “props를 직접 수정하지 말 것”, “any를 도입하지 말 것”, “UI 라이브러리를 추가하지 말 것”, “API 세부사항을 컴포넌트에 넣지 말 것”을 같이 적어야 합니다.

유스케이스 2: Pinia로 상태 분리

티켓 목록을 여러 화면에서 사용한다면 SFC가 모든 상태를 들고 있으면 안 됩니다. Pinia store에 공유 목록, 저장 중 플래그, 열린 티켓 getter, 추가와 닫기 action을 둡니다.

import { computed, ref } from 'vue';
import { defineStore } from 'pinia';

export type TicketPriority = 'low' | 'medium' | 'high';
export type TicketStatus = 'open' | 'closed';

export interface Ticket {
  id: string;
  title: string;
  description: string;
  priority: TicketPriority;
  status: TicketStatus;
  createdAt: string;
}

export type NewTicketInput = Pick<Ticket, 'title' | 'description' | 'priority'>;

export const useTicketStore = defineStore('tickets', () => {
  const tickets = ref<Ticket[]>([]);
  const isSaving = ref(false);

  const openTickets = computed(() => {
    return tickets.value.filter((ticket) => ticket.status === 'open');
  });

  function addTicket(input: NewTicketInput) {
    const ticket: Ticket = {
      id: crypto.randomUUID(),
      ...input,
      status: 'open',
      createdAt: new Date().toISOString(),
    };

    tickets.value.unshift(ticket);
    return ticket;
  }

  async function saveTicket(input: NewTicketInput) {
    isSaving.value = true;
    try {
      await new Promise((resolve) => setTimeout(resolve, 150));
      return addTicket(input);
    } finally {
      isSaving.value = false;
    }
  }

  function closeTicket(id: string) {
    const ticket = tickets.value.find((item) => item.id === id);
    if (ticket) ticket.status = 'closed';
  }

  return {
    tickets,
    isSaving,
    openTickets,
    addTicket,
    saveTicket,
    closeTicket,
  };
});

실제 서비스에서는 saveTicket이 타입이 있는 API client를 호출합니다. 그래도 먼저 store의 데이터 형태를 안정화하고, 다음에 네트워크 호출을 붙이고, 마지막에 에러 처리와 재시도를 더하는 편이 리뷰하기 쉽습니다.

유스케이스 3: Composable 추출

Composable은 Vue의 반응형 로직을 함수로 재사용하는 방식입니다. 쉽게 말하면 화면에서 로직만 꺼낸 부품입니다. 검색, 우선순위 필터, 페이지네이션은 UI와 분리하기 쉽습니다.

import { computed, ref, watch, type Ref } from 'vue';
import type { Ticket, TicketPriority } from '@/stores/ticketStore';

export function useFilteredTickets(tickets: Ref<Ticket[]>) {
  const query = ref('');
  const selectedPriority = ref<TicketPriority | 'all'>('all');
  const currentPage = ref(1);
  const pageSize = ref(10);

  const filteredTickets = computed(() => {
    const normalizedQuery = query.value.trim().toLowerCase();

    return tickets.value.filter((ticket) => {
      const matchesQuery =
        normalizedQuery.length === 0 ||
        ticket.title.toLowerCase().includes(normalizedQuery) ||
        ticket.description.toLowerCase().includes(normalizedQuery);

      const matchesPriority =
        selectedPriority.value === 'all' ||
        ticket.priority === selectedPriority.value;

      return matchesQuery && matchesPriority;
    });
  });

  const totalPages = computed(() => {
    return Math.max(1, Math.ceil(filteredTickets.value.length / pageSize.value));
  });

  const pagedTickets = computed(() => {
    const start = (currentPage.value - 1) * pageSize.value;
    return filteredTickets.value.slice(start, start + pageSize.value);
  });

  watch([query, selectedPriority], () => {
    currentPage.value = 1;
  });

  return {
    query,
    selectedPriority,
    currentPage,
    pageSize,
    filteredTickets,
    pagedTickets,
    totalPages,
  };
}

여기서 watch는 필터 조건이 바뀔 때 페이지를 1로 되돌리는 명확한 부작용만 담당합니다. 단순 파생값은 computed로 둡니다. Claude Code 프롬프트에도 “파생값은 computed, 부작용만 watch”라고 적어두면 좋습니다.

유스케이스 4: Vitest 테스트 추가

구현 속도가 빨라질수록 회귀 테스트가 중요합니다. 먼저 Pinia store를 테스트하면 DOM보다 안정적으로 업무 동작을 확인할 수 있습니다.

import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useTicketStore } from './ticketStore';

describe('useTicketStore', () => {
  beforeEach(() => {
    setActivePinia(createPinia());
    vi.stubGlobal('crypto', {
      randomUUID: () => 'ticket-1',
    });
  });

  it('adds a new open ticket', () => {
    const store = useTicketStore();

    const ticket = store.addTicket({
      title: 'Cannot submit expense form',
      description: 'The submit button stays disabled after all fields are filled.',
      priority: 'high',
    });

    expect(ticket.id).toBe('ticket-1');
    expect(store.tickets).toHaveLength(1);
    expect(store.openTickets).toHaveLength(1);
  });

  it('closes an existing ticket', () => {
    const store = useTicketStore();
    const ticket = store.addTicket({
      title: 'Profile image is broken',
      description: 'The image URL returns 404 on the account settings page.',
      priority: 'medium',
    });

    store.closeTicket(ticket.id);

    expect(store.openTickets).toHaveLength(0);
    expect(store.tickets[0]?.status).toBe('closed');
  });
});

“테스트를 많이 써줘”보다 “짧은 입력, 유효한 입력, 저장 중 상태, 저장 성공, 저장 실패, store 상태 변화를 확인해줘”라고 요청하는 것이 좋습니다. 그러면 실제 장애를 막는 테스트가 나옵니다.

기존 Vue 수정용 프롬프트

기존 코드에서는 큰 재작성보다 작은 단계가 안전합니다. 먼저 문제를 읽고, 타입을 보강하고, 하나의 Composable만 추출하고, 필요한 경우 Pinia를 정리한 뒤 테스트를 추가합니다.

You are working in a Vue 3 + TypeScript + Pinia project.

Goal:
Refactor the ticket form so validation logic is typed, reusable, and covered by Vitest.

Allowed files:
- src/components/TicketForm.vue
- src/composables/useFilteredTickets.ts
- src/stores/ticketStore.ts
- src/stores/ticketStore.test.ts

Rules:
- Use <script setup lang="ts">.
- Do not mutate props directly.
- Do not introduce any.
- Prefer computed for derived values.
- Use watch only for explicit side effects.
- Keep UI text unchanged unless a validation message is missing.

Verification:
- npm run typecheck
- npm run test:unit

After editing, explain the risk areas and the tests you added.

이 프롬프트는 Claude Code가 판단할 일과 사람이 결정할 일을 분리합니다. 추출 방식과 테스트 초안은 Claude Code가 도울 수 있지만, UI 문구, API 계약, 접근성 문구, 릴리스 타이밍은 사람이 확인해야 합니다.

자주 발생하는 함정

첫째, Options API와 Composition API를 계획 없이 섞는 것입니다. Vue 3는 둘 다 지원하지만 한 컴포넌트에 data, methods, setup, script setup이 섞이면 리뷰가 어려워집니다.

둘째, refreactive를 오해하는 것입니다. 기본값, 배열, 교체될 수 있는 값은 ref가 다루기 쉽습니다. reactive는 묶음 객체에 좋지만 구조 분해 때 기대와 달라질 수 있습니다.

셋째, props를 직접 수정하는 것입니다. 자식이 부모 상태를 몰래 바꾸면 데이터 흐름을 읽기 어렵습니다. 로컬 ref, emit, 또는 팀이 합의한 defineModel을 사용하세요.

넷째, watch를 남용하는 것입니다. 계산 가능한 값은 computed입니다. watch는 라우트 동기화, 페이지 초기화, API 호출, 저장소 쓰기 같은 부작용에만 사용합니다.

다섯째, 타입이 any로 무너지는 것입니다. Claude Code가 타입 오류를 피하려고 as any를 넣을 수 있으므로 any, unknown, 타입 단언, 빈 배열 추론을 꼭 확인합니다.

여섯째, SFC를 지나치게 쪼개는 것입니다. 재사용되지 않는 작은 컴포넌트가 많으면 props와 emit만 늘어납니다. 먼저 Composable로 로직을 빼고 UI는 실제 재사용이 보일 때 나눕니다.

일곱째, 테스트 부족입니다. 생성된 코드가 깔끔해 보여도 경계값은 빠질 수 있습니다. 폼에서는 짧은 입력, 저장 중, 성공, 실패, store 변화까지 확인해야 합니다.

실무 적용과 CTA

실제 저장소에서는 “화면 전체를 만들어줘”로 시작하지 않는 편이 좋습니다. Claude Code에 현재 컴포넌트를 읽고 위험을 정리하게 한 뒤, 작은 변경과 검증을 반복하세요. diff가 작을수록 리뷰 품질이 올라갑니다.

팀에서는 CLAUDE.md에 규칙을 남깁니다. Vue는 script setup, 공유 상태는 Pinia setup store, 파생값은 computed, 부작용은 watch, 확인 명령은 npm run typechecknpm run test:unit처럼 정리합니다. 실제 팀의 Vue 코드베이스에 Claude Code 규칙과 리뷰 흐름을 도입하려면 Claude Code 교육 및 상담에서 함께 설계할 수 있습니다.

정리

Claude Code는 Vue 3의 기존 수정, 폼, Pinia, Composable, 테스트 작업을 빠르게 만듭니다. 하지만 품질은 프롬프트 경계에 달려 있습니다. 어떤 파일을 고칠 수 있는지, 어떤 Vue 스타일을 쓸지, 무엇을 바꾸면 안 되는지, 어떻게 검증할지를 명확히 적어야 합니다.

처음에는 하나의 SFC, 하나의 Composable, 하나의 store, 하나의 테스트 파일로 시작하세요. 작은 루프가 Vue의 사고방식을 익히면서도 운영 코드에 가까운 결과를 만듭니다.

실제로 시도한 결과

작은 Vue 3 + TypeScript 프로젝트에서 이 흐름을 시험했습니다. Claude Code에 폼, store, Composable, 테스트를 한 번에 요청했을 때는 diff가 커져 리뷰 시간이 늘었습니다. SFC, Pinia, Composable, Vitest 순서로 나누자 확인이 쉬웠습니다. 특히 “props를 직접 수정하지 말 것”, “any를 도입하지 말 것”, “파생값은 computed를 사용할 것”이라는 제약이 효과적이었고, npm run typechecknpm run test:unit으로 검증하기 좋은 구조가 되었습니다.

#Claude Code #Vue.js #Nuxt.js #Pinia #Composition API
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.