AI로 풀스택 개발하기 — Claude Code + Supabase 실전

@JavaPark · April 04, 2026 · 21 min read

안녕하세요, 자바파커입니다.

"설계까지 끝났으니, 이제 진짜 코드를 작성할 차례입니다."

지난 두 포스팅에서 AI와 함께 기획하고, ERD/API/와이어프레임까지 설계하는 과정을 다뤘습니다. 이번 포스팅에서는 드디어 실제 코드를 작성합니다. Claude Code와 Supabase를 활용해서 "독서 기록 웹앱"의 핵심 기능을 구현하는 과정을 단계별로 보여드리겠습니다.


개발 환경 세팅 — Next.js + Supabase 프로젝트 초기화

1단계: Next.js 프로젝트 생성

터미널에서 아래 명령어를 실행합니다.

npx create-next-app@latest book-log \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*"

2단계: 필수 패키지 설치

cd book-log

# Supabase
npm install @supabase/supabase-js @supabase/ssr

# UI 유틸리티
npm install clsx tailwind-merge

# 아이콘
npm install lucide-react

# 날짜 처리
npm install date-fns

3단계: 환경 변수 설정

.env.local 파일을 생성하고, Supabase 프로젝트의 URL과 anon key를 입력합니다.

NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR...

이 값은 Supabase 대시보드 > Settings > API에서 확인할 수 있습니다.


Supabase 셋업 — 프로젝트 생성부터 테이블 생성까지

Supabase 프로젝트 생성

  1. supabase.com에서 회원가입
  2. "New Project" 클릭
  3. 프로젝트명: book-log
  4. 비밀번호 설정 (DB 접근용, 반드시 기록해두세요)
  5. 리전: Northeast Asia (Tokyo) 선택

SQL Editor에서 테이블 생성

Supabase 대시보드 > SQL Editor에서, 이전 포스팅에서 설계한 SQL을 실행합니다. 여기서 핵심 부분만 다시 정리하겠습니다.

-- 커스텀 타입
CREATE TYPE reading_status AS ENUM ('want_to_read', 'reading', 'finished');
CREATE TYPE memo_type AS ENUM ('note', 'quote', 'review');

-- profiles 테이블
CREATE TABLE profiles (
  id UUID REFERENCES auth.users(id) ON DELETE CASCADE PRIMARY KEY,
  username TEXT UNIQUE,
  avatar_url TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- books 테이블
CREATE TABLE books (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  isbn VARCHAR(13) UNIQUE,
  title TEXT NOT NULL,
  author TEXT NOT NULL,
  publisher TEXT,
  cover_image_url TEXT,
  page_count INTEGER,
  published_date DATE,
  description TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- reading_records 테이블
CREATE TABLE reading_records (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
  book_id UUID REFERENCES books(id) ON DELETE CASCADE NOT NULL,
  status reading_status DEFAULT 'want_to_read',
  start_date DATE,
  end_date DATE,
  rating SMALLINT CHECK (rating >= 1 AND rating <= 5),
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_id, book_id)
);

-- memos 테이블
CREATE TABLE memos (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
  book_id UUID REFERENCES books(id) ON DELETE CASCADE NOT NULL,
  content TEXT NOT NULL,
  page_number INTEGER,
  memo_type memo_type DEFAULT 'note',
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

신규 가입 시 자동 프로필 생성 트리거

사용자가 가입하면 자동으로 profiles 테이블에 레코드가 생성되도록 합니다.

CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.profiles (id, username, avatar_url)
  VALUES (
    NEW.id,
    NEW.raw_user_meta_data->>'name',
    NEW.raw_user_meta_data->>'avatar_url'
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();

인증 구현 — Supabase Auth로 소셜 로그인

Supabase 클라이언트 설정

// src/lib/supabase.ts
import { createBrowserClient } from '@supabase/ssr';

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
}
// src/lib/supabase-server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export async function createServerSupabaseClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // Server Component에서는 쿠키 설정 불가
          }
        },
      },
    }
  );
}

인증 미들웨어

인증되지 않은 사용자가 보호된 페이지에 접근하면 로그인 페이지로 리다이렉트합니다.

// src/middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';

export async function middleware(request: NextRequest) {
  let supabaseResponse = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          );
          supabaseResponse = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  const {
    data: { user },
  } = await supabase.auth.getUser();

  // 인증되지 않은 사용자 리다이렉트
  if (
    !user &&
    !request.nextUrl.pathname.startsWith('/login') &&
    !request.nextUrl.pathname.startsWith('/auth') &&
    request.nextUrl.pathname !== '/'
  ) {
    const url = request.nextUrl.clone();
    url.pathname = '/login';
    return NextResponse.redirect(url);
  }

  return supabaseResponse;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

로그인 페이지

// src/app/login/page.tsx
'use client';

import { createClient } from '@/lib/supabase';

export default function LoginPage() {
  const supabase = createClient();

  const handleGoogleLogin = async () => {
    await supabase.auth.signInWithOAuth({
      provider: 'google',
      options: {
        redirectTo: `${window.location.origin}/auth/callback`,
      },
    });
  };

  const handleGithubLogin = async () => {
    await supabase.auth.signInWithOAuth({
      provider: 'github',
      options: {
        redirectTo: `${window.location.origin}/auth/callback`,
      },
    });
  };

  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-50">
      <div className="w-full max-w-md space-y-8 rounded-xl bg-white p-8 shadow-lg">
        <div className="text-center">
          <h1 className="text-3xl font-bold text-gray-900">BookLog</h1>
          <p className="mt-2 text-gray-600">
            나만의 독서 기록을 시작하세요
          </p>
        </div>

        <div className="space-y-4">
          <button
            onClick={handleGoogleLogin}
            className="flex w-full items-center justify-center gap-3 rounded-lg border border-gray-300 bg-white px-4 py-3 text-gray-700 transition hover:bg-gray-50"
          >
            <svg className="h-5 w-5" viewBox="0 0 24 24">
              <path
                fill="#4285F4"
                d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
              />
              <path
                fill="#34A853"
                d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
              />
              <path
                fill="#FBBC05"
                d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
              />
              <path
                fill="#EA4335"
                d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
              />
            </svg>
            Google로 로그인
          </button>

          <button
            onClick={handleGithubLogin}
            className="flex w-full items-center justify-center gap-3 rounded-lg bg-gray-900 px-4 py-3 text-white transition hover:bg-gray-800"
          >
            <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
              <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
            </svg>
            GitHub로 로그인
          </button>
        </div>
      </div>
    </div>
  );
}

OAuth 콜백 처리

// src/app/auth/callback/route.ts
import { createServerSupabaseClient } from '@/lib/supabase-server';
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get('code');

  if (code) {
    const supabase = await createServerSupabaseClient();
    await supabase.auth.exchangeCodeForSession(code);
  }

  return NextResponse.redirect(`${origin}/dashboard`);
}

CRUD 구현 — Claude Code로 "독서 기록 CRUD 만들어줘"

이제 핵심 기능인 독서 기록 CRUD를 구현합니다. Claude Code에게 아래와 같이 요청하면 됩니다.

reading_records 테이블에 대한 CRUD 서비스 함수를 만들어줘.

- Supabase JS Client 사용
- TypeScript
- 에러 처리 포함
- book 정보도 함께 조회 (join)

서비스 레이어 — 독서 기록 CRUD

// src/services/records.ts
import { createClient } from '@/lib/supabase';
import type { ReadingStatus } from '@/types/database';

const supabase = createClient();

// 내 독서 기록 전체 조회
export async function getMyRecords(status?: ReadingStatus) {
  let query = supabase
    .from('reading_records')
    .select(`
      *,
      book:books(*)
    `)
    .order('updated_at', { ascending: false });

  if (status) {
    query = query.eq('status', status);
  }

  const { data, error } = await query;
  if (error) throw error;
  return data;
}

// 독서 기록 생성
export async function createRecord(
  bookId: string,
  status: ReadingStatus = 'want_to_read'
) {
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) throw new Error('인증이 필요합니다');

  const { data, error } = await supabase
    .from('reading_records')
    .insert({
      user_id: user.id,
      book_id: bookId,
      status,
      start_date: status === 'reading' ? new Date().toISOString() : null,
    })
    .select(`*, book:books(*)`)
    .single();

  if (error) throw error;
  return data;
}

// 독서 상태 변경
export async function updateRecordStatus(
  recordId: string,
  status: ReadingStatus
) {
  const updateData: Record<string, unknown> = { status };

  if (status === 'reading') {
    updateData.start_date = new Date().toISOString();
  } else if (status === 'finished') {
    updateData.end_date = new Date().toISOString();
  }

  const { data, error } = await supabase
    .from('reading_records')
    .update(updateData)
    .eq('id', recordId)
    .select(`*, book:books(*)`)
    .single();

  if (error) throw error;
  return data;
}

// 별점 등록
export async function updateRating(recordId: string, rating: number) {
  const { data, error } = await supabase
    .from('reading_records')
    .update({ rating })
    .eq('id', recordId)
    .select()
    .single();

  if (error) throw error;
  return data;
}

// 독서 기록 삭제
export async function deleteRecord(recordId: string) {
  const { error } = await supabase
    .from('reading_records')
    .delete()
    .eq('id', recordId);

  if (error) throw error;
}

책 검색 및 등록 서비스

// src/services/books.ts
import { createClient } from '@/lib/supabase';

const supabase = createClient();

// 알라딘 API로 ISBN 검색 (서버사이드)
export async function searchBookByISBN(isbn: string) {
  const response = await fetch(`/api/books/search?isbn=${isbn}`);
  if (!response.ok) throw new Error('책을 찾을 수 없습니다');
  return response.json();
}

// 제목으로 책 검색 (DB)
export async function searchBooks(query: string) {
  const { data, error } = await supabase
    .from('books')
    .select('*')
    .or(`title.ilike.%${query}%,author.ilike.%${query}%`)
    .limit(20);

  if (error) throw error;
  return data ?? [];
}

// 책 등록 (DB에 없는 경우)
export async function registerBook(book: {
  isbn?: string;
  title: string;
  author: string;
  publisher?: string;
  cover_image_url?: string;
  page_count?: number;
  description?: string;
}) {
  // ISBN으로 중복 체크
  if (book.isbn) {
    const { data: existing } = await supabase
      .from('books')
      .select('id')
      .eq('isbn', book.isbn)
      .single();

    if (existing) return existing;
  }

  const { data, error } = await supabase
    .from('books')
    .insert(book)
    .select()
    .single();

  if (error) throw error;
  return data;
}

ISBN 검색 API 라우트

// src/app/api/books/search/route.ts
import { NextResponse } from 'next/server';

const ALADIN_API_KEY = process.env.ALADIN_API_KEY;

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const isbn = searchParams.get('isbn');

  if (!isbn) {
    return NextResponse.json(
      { error: 'ISBN이 필요합니다' },
      { status: 400 }
    );
  }

  try {
    // 알라딘 API 호출
    const response = await fetch(
      `http://www.aladin.co.kr/ttb/api/ItemLookUp.aspx?` +
      `ttbkey=${ALADIN_API_KEY}&itemIdType=ISBN13&ItemId=${isbn}` +
      `&output=js&Version=20131101`
    );

    const data = await response.json();

    if (!data.item || data.item.length === 0) {
      return NextResponse.json(
        { error: '책을 찾을 수 없습니다' },
        { status: 404 }
      );
    }

    const item = data.item[0];
    return NextResponse.json({
      isbn: isbn,
      title: item.title,
      author: item.author,
      publisher: item.publisher,
      cover_image_url: item.cover,
      page_count: item.subInfo?.itemPage || null,
      published_date: item.pubDate,
      description: item.description,
    });
  } catch (error) {
    return NextResponse.json(
      { error: '검색 중 오류가 발생했습니다' },
      { status: 500 }
    );
  }
}

UI 구현 — Tailwind CSS로 반응형 대시보드

대시보드 페이지

Claude Code에게 "이전 포스팅의 와이어프레임 기반으로 대시보드를 구현해줘"라고 요청하면 아래와 같은 코드를 생성합니다.

// src/app/dashboard/page.tsx
import { createServerSupabaseClient } from '@/lib/supabase-server';
import { BookList } from '@/components/books/BookList';
import { ReadingStats } from '@/components/records/ReadingStats';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  const supabase = await createServerSupabaseClient();

  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect('/login');

  // 독서 기록 조회 (책 정보 포함)
  const { data: records } = await supabase
    .from('reading_records')
    .select(`*, book:books(*)`)
    .eq('user_id', user.id)
    .order('updated_at', { ascending: false });

  const reading = records?.filter((r) => r.status === 'reading') ?? [];
  const finished = records?.filter((r) => r.status === 'finished') ?? [];
  const wantToRead = records?.filter((r) => r.status === 'want_to_read') ?? [];

  return (
    <div className="mx-auto max-w-6xl px-4 py-8">
      {/* 독서 통계 */}
      <ReadingStats
        readingCount={reading.length}
        finishedCount={finished.length}
        wantToReadCount={wantToRead.length}
      />

      {/* 탭 기반 독서 목록 */}
      <div className="mt-8">
        <BookList
          reading={reading}
          finished={finished}
          wantToRead={wantToRead}
        />
      </div>
    </div>
  );
}

책 카드 컴포넌트

// src/components/books/BookCard.tsx
'use client';

import Image from 'next/image';
import Link from 'next/link';
import { Star } from 'lucide-react';
import type { ReadingRecord } from '@/types/database';

interface BookCardProps {
  record: ReadingRecord & { book: any };
}

export function BookCard({ record }: BookCardProps) {
  const { book, status, rating } = record;

  const statusLabel = {
    reading: '읽는 중',
    finished: '완료',
    want_to_read: '읽고 싶은',
  }[status];

  const statusColor = {
    reading: 'bg-blue-100 text-blue-800',
    finished: 'bg-green-100 text-green-800',
    want_to_read: 'bg-yellow-100 text-yellow-800',
  }[status];

  return (
    <Link href={`/books/${book.id}`}>
      <div className="group rounded-lg border border-gray-200 bg-white p-4 transition hover:shadow-md">
        {/* 책 커버 */}
        <div className="relative aspect-[3/4] w-full overflow-hidden rounded-md bg-gray-100">
          {book.cover_image_url ? (
            <Image
              src={book.cover_image_url}
              alt={`${book.title} 표지`}
              fill
              className="object-cover transition group-hover:scale-105"
            />
          ) : (
            <div className="flex h-full items-center justify-center text-gray-400">
              No Cover
            </div>
          )}
        </div>

        {/* 책 정보 */}
        <div className="mt-3 space-y-1">
          <span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${statusColor}`}>
            {statusLabel}
          </span>
          <h3 className="line-clamp-2 text-sm font-semibold text-gray-900">
            {book.title}
          </h3>
          <p className="text-xs text-gray-500">{book.author}</p>

          {/* 별점 */}
          {rating && (
            <div className="flex gap-0.5">
              {[1, 2, 3, 4, 5].map((i) => (
                <Star
                  key={i}
                  className={`h-3.5 w-3.5 ${
                    i <= rating
                      ? 'fill-yellow-400 text-yellow-400'
                      : 'text-gray-300'
                  }`}
                />
              ))}
            </div>
          )}
        </div>
      </div>
    </Link>
  );
}

독서 통계 컴포넌트

// src/components/records/ReadingStats.tsx
import { BookOpen, CheckCircle, Bookmark } from 'lucide-react';

interface ReadingStatsProps {
  readingCount: number;
  finishedCount: number;
  wantToReadCount: number;
}

export function ReadingStats({
  readingCount,
  finishedCount,
  wantToReadCount,
}: ReadingStatsProps) {
  const stats = [
    {
      label: '읽는 중',
      count: readingCount,
      icon: BookOpen,
      color: 'text-blue-600 bg-blue-50',
    },
    {
      label: '완료',
      count: finishedCount,
      icon: CheckCircle,
      color: 'text-green-600 bg-green-50',
    },
    {
      label: '읽고 싶은',
      count: wantToReadCount,
      icon: Bookmark,
      color: 'text-yellow-600 bg-yellow-50',
    },
  ];

  return (
    <div className="grid grid-cols-3 gap-4">
      {stats.map((stat) => (
        <div
          key={stat.label}
          className="rounded-lg border border-gray-200 bg-white p-4"
        >
          <div className="flex items-center gap-3">
            <div className={`rounded-lg p-2 ${stat.color}`}>
              <stat.icon className="h-5 w-5" />
            </div>
            <div>
              <p className="text-2xl font-bold text-gray-900">
                {stat.count}
              </p>
              <p className="text-sm text-gray-500">{stat.label}</p>
            </div>
          </div>
        </div>
      ))}
    </div>
  );
}

Claude Code 활용 팁 — 효과적인 프롬프트 작성법

1. CLAUDE.md로 프로젝트 컨텍스트 공유하기

프로젝트 루트에 CLAUDE.md 파일을 두면, Claude Code가 매번 새 대화를 시작할 때 자동으로 읽어서 컨텍스트를 파악합니다.

# BookLog

## 기술 스택
- Next.js 14 (App Router, TypeScript)
- Supabase (PostgreSQL, Auth, RLS)
- Tailwind CSS
- Vercel 배포

## DB 스키마
- profiles: 사용자 프로필 (auth.users 연동)
- books: 책 정보 (ISBN, 제목, 저자)
- reading_records: 독서 기록 (상태, 별점, 날짜)
- memos: 독서 메모 (노트, 인용, 리뷰)

## 코딩 컨벤션
- 컴포넌트: src/components/ 하위 기능별 분류
- 서비스: src/services/ (Supabase 쿼리 로직)
- 서버 컴포넌트 기본, 클라이언트 컴포넌트는 'use client' 명시
- 에러 처리: try/catch + 사용자 친화적 메시지

2. 구체적인 지시를 내리세요

나쁜 예시:

메모 기능 만들어줘

좋은 예시:

memos 테이블에 대한 CRUD를 구현해줘.

- src/services/memos.ts에 서비스 함수
- src/components/memos/MemoEditor.tsx에 작성/수정 폼
- src/components/memos/MemoCard.tsx에 메모 카드
- 메모 타입: note, quote, review (라디오 버튼으로 선택)
- 인용구(quote) 타입은 페이지 번호 입력 필드 추가
- Tailwind CSS, 반응형

3. 단계별로 요청하세요

한 번에 모든 걸 요청하면 결과물의 품질이 떨어집니다. 서비스 함수 -> 컴포넌트 -> 페이지 순서로 나눠서 요청하는 것이 좋습니다.

1단계: "memos 서비스 함수만 만들어줘" -> 검토 -> 확정
2단계: "이 서비스를 사용하는 MemoEditor 컴포넌트 만들어줘" -> 검토 -> 확정
3단계: "대시보드에 메모 섹션 추가해줘" -> 검토 -> 확정

테스트 — AI가 만든 코드를 검증하는 방법

AI가 생성한 코드를 무조건 신뢰하면 안 됩니다. 특히 아래 항목은 반드시 직접 확인하세요.

수동 테스트 체크리스트

항목 확인 사항
인증 로그인/로그아웃 정상 동작
RLS 다른 사용자의 데이터가 보이지 않는지
CRUD 생성/조회/수정/삭제 각각 동작 확인
엣지 케이스 빈 입력, 긴 텍스트, 특수문자
반응형 모바일/태블릿/데스크톱
에러 처리 네트워크 오류, 서버 오류 시 UI

Claude Code로 테스트 코드 생성하기

BookCard 컴포넌트의 단위 테스트를 작성해줘.

- Vitest + React Testing Library 사용
- 테스트 케이스:
  1. 책 제목과 저자가 렌더링되는지
  2. 상태에 따라 올바른 배지 색상이 적용되는지
  3. 별점이 올바르게 표시되는지
  4. 커버 이미지가 없을 때 대체 텍스트가 표시되는지

생성된 테스트 코드 예시:

// src/components/books/BookCard.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { BookCard } from './BookCard';

const mockRecord = {
  id: '1',
  user_id: 'user-1',
  book_id: 'book-1',
  status: 'reading' as const,
  rating: 4,
  start_date: '2026-01-01',
  end_date: null,
  created_at: '2026-01-01',
  updated_at: '2026-01-01',
  book: {
    id: 'book-1',
    isbn: '9788936434267',
    title: '채식주의자',
    author: '한강',
    publisher: '창비',
    cover_image_url: 'https://example.com/cover.jpg',
    page_count: 247,
    published_date: '2007-10-30',
    description: null,
    created_at: '2026-01-01',
  },
};

describe('BookCard', () => {
  it('책 제목과 저자가 렌더링된다', () => {
    render(<BookCard record={mockRecord} />);

    expect(screen.getByText('채식주의자')).toBeInTheDocument();
    expect(screen.getByText('한강')).toBeInTheDocument();
  });

  it('읽는 중 상태 배지가 표시된다', () => {
    render(<BookCard record={mockRecord} />);

    const badge = screen.getByText('읽는 중');
    expect(badge).toHaveClass('bg-blue-100');
  });

  it('별점 4개가 채워져 표시된다', () => {
    render(<BookCard record={mockRecord} />);

    const filledStars = document.querySelectorAll('.fill-yellow-400');
    expect(filledStars).toHaveLength(4);
  });

  it('커버 이미지가 없으면 대체 텍스트가 표시된다', () => {
    const noCoverRecord = {
      ...mockRecord,
      book: { ...mockRecord.book, cover_image_url: null },
    };

    render(<BookCard record={noCoverRecord} />);
    expect(screen.getByText('No Cover')).toBeInTheDocument();
  });
});

RLS 테스트 — 가장 중요한 보안 검증

RLS가 제대로 동작하는지 확인하는 가장 간단한 방법은 Supabase SQL Editor에서 직접 쿼리를 실행하는 것입니다.

-- 특정 사용자로 로그인한 상태를 시뮬레이션
SET request.jwt.claims = '{"sub": "user-id-here"}';

-- 이 쿼리로 해당 사용자의 데이터만 조회되는지 확인
SELECT * FROM reading_records;

개발 결과물 요약

이 세 포스팅에 걸쳐 만든 "독서 기록 웹앱"의 구성을 정리하면 다음과 같습니다.

항목 내용
프론트엔드 Next.js 14 (App Router) + Tailwind CSS
백엔드 Supabase (PostgreSQL + Auth + RLS)
인증 Google, GitHub 소셜 로그인
핵심 기능 책 검색/등록, 독서 기록 CRUD, 메모 CRUD
AI 도구 Claude Code (설계, 코드 생성, 테스트)
개발 기간 약 2주 (기획 포함)

자주 묻는 질문 (FAQ)

Q1. Claude Code 없이 다른 AI 코딩 도구로도 이 과정이 가능한가요?

가능합니다. GitHub Copilot, Cursor 등도 비슷한 작업을 할 수 있습니다. 다만 Claude Code는 프로젝트 전체 컨텍스트를 이해하고 파일 간의 관계를 파악하는 데 강점이 있어서, 이 시리즈에서는 Claude Code를 중심으로 설명했습니다.

Q2. Supabase 무료 플랜으로 실제 서비스 운영이 가능한가요?

사이드 프로젝트 수준이라면 충분합니다. 무료 플랜은 500MB 데이터베이스, 1GB 파일 스토리지, 50,000 월간 활성 사용자를 제공합니다. 다만 프로젝트가 7일간 비활성 상태면 일시 중지되므로, 실제 서비스 운영 시에는 Pro 플랜(월 $25)을 고려하세요.

Q3. AI가 생성한 코드의 품질은 믿을 만한가요?

"초안"으로는 훌륭하지만, 프로덕션에 바로 쓰기에는 부족한 경우가 많습니다. 특히 에러 처리, 엣지 케이스, 보안(RLS 정책) 부분은 반드시 직접 검토하세요. AI를 "주니어 개발자"라고 생각하면 됩니다 — 빠르게 코드를 짜지만, 시니어의 리뷰가 필요한 수준입니다.


여러분도 AI와 함께 사이드 프로젝트를 만들어보고 계신가요? 어떤 도구를 쓰고, 어떤 점이 좋았는지 댓글로 공유해주세요!

다음 포스팅에서는 "Vercel + GitHub Actions로 자동 배포 파이프라인 구축하기"를 주제로, 만든 앱을 세상에 공개하는 과정을 다뤄보겠습니다.

@JavaPark
AI 시대의 개발자 도구, 실전 경험을 공유합니다