바이브 코딩 망상

발행: (2025년 12월 15일 오후 10:47 GMT+9)
7 min read
원문: Dev.to

Source: Dev.to

Introduction

저는 최근에 무서운 코드 리뷰에 참석했습니다. 한 주니어 엔지니어—똑똑하고 열정적이며 좋은 의도를 가진—가 새로운 기능을 바이브 코딩했습니다. 그는 기능의 “바이브”를 LLM에 설명하고, 그 출력을 우리 레포지토리에 붙여넣은 뒤 PR을 열었습니다. 코드는 동작했습니다: 픽셀은 제자리에 있었고, 버튼도 클릭됐으며, 데이터도 로드됐습니다.

하지만 파일 안에는 하드코딩된 타임아웃 값, 세 개의 컴포넌트에 중복된 상태 로직, 그리고 지나치게 관대하게 설정된 의존성 배열을 가진 useEffect 훅이 들어 있었습니다. 이것은 “해피 패스” 걸작이었지만, 프로덕션에서는 폭발할 수 있는 코드였습니다.

현재 AI 물결의 약속은 자연어가 새로운 프로그래밍 언어가 된다는 것입니다. 컴퓨터에게 원하는 것을 말하면, 어떻게 할지는 AI가 처리합니다. 이는 위험합니다. 왜냐하면 어떻게 하는 부분에 버그와 보안 취약점이 숨어 있기 때문입니다.

Vibe‑coded example

// UserProfile.jsx (The Vibe Version)
import React, { useState, useEffect } from 'react';

const UserProfile = ({ userId }) => {
  const [user, setUser] = useState(null);
  const [isEditing, setIsEditing] = useState(false);
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');

  useEffect(() => {
    fetch(`https://api.example.com/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setName(data.name);
        setEmail(data.email);
      });
  }, [userId]);

  const handleSave = () => {
    fetch(`https://api.example.com/users/${userId}`, {
      method: 'PUT',
      body: JSON.stringify({ name, email }),
      headers: { 'Content-Type': 'application/json' }
    }).then(() => {
      setIsEditing(false);
      // refetch to update
      fetch(`https://api.example.com/users/${userId}`)
        .then(res => res.json())
        .then(data => setUser(data));
    });
  };

  if (!user) return Loading...;

  return (
    
      {isEditing ? (
        
           setName(e.target.value)} />
           setEmail(e.target.value)} />
          Save
        
      ) : (
        
          
## {user.name}

          
{user.email}

           setIsEditing(true)}>Edit
        
      )}
    
  );
};

export default UserProfile;

주니어 개발자에게는 깔끔해 보이고 동작합니다. 하지만 시니어 엔지니어 입장에서는 여러 경고 신호가 보입니다.

Why the Vibe code is problematic

Race condition

useEffectuserId가 바뀔 때마다 fetch를 실행합니다. 빠른 연속 변경은 응답이 뒤섞여 잘못된 사용자 데이터를 표시하게 만들 수 있습니다. AI는 자동으로 AbortController를 사용해 오래된 요청을 취소하지 않습니다.

State desynchronization

별도의 로컬 상태(name, email)가 user와 수동으로 동기화됩니다. 부모가 user를 업데이트하면 로컬 필드가 오래되어 버그가 발생할 수 있습니다.

Missing error handling

fetch 프로미스에 .catch()가 없습니다. API가 다운되면 컴포넌트가 크래시하거나 아무것도 표시되지 않을 수 있는데, 이는 AI가 해피 패스만 가정하기 때문입니다.

Hard‑coded fragility

URL이 하드코딩돼 있고, 저장 동작에 대한 로딩 상태가 없습니다. 빠른 클릭을 여러 번 하면 중복 PUT 요청이 발생할 수 있습니다.

Security oversight

인증 헤더가 전송되지 않습니다. 실제 애플리케이션에서는 401 응답으로 조용히 실패하게 됩니다.

Specification Engineering approach

Define types and contracts

// types.ts
export type User = {
  id: string;
  name: string;
  email: string;
  updatedAt: string;
};

export type UserProfileProps = {
  userId: string;
  onUpdate?: (user: User) => void;
  // Dependency injection for fetching prevents hardcoded URLs
  fetchUser: (id: string, signal: AbortSignal) => Promise;
  updateUser: (id: string, data: Partial) => Promise;
};

Prompt with explicit constraints

Prompt:
Generate a UserProfile component based on the UserProfileProps interface above.
Constraints:

  • Use useQuery (or a similar pattern) to handle loading and error states explicitly.
  • Handle race conditions with AbortSignal.
  • Show a loading state on the Save button to prevent double‑submission.
  • Do not duplicate state; treat server data as the source of truth.
  • Display user‑friendly error UI instead of console logs.

Engineered version

// UserProfile.tsx (The Engineered Version)
import React, { useState, useEffect } from 'react';
import { User, UserProfileProps } from './types';

// Utility for safe state management
type FetchState = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

export const UserProfile = ({
  userId,
  fetchUser,
  updateUser,
  onUpdate,
}: UserProfileProps) => {
  const [state, setState] = useState>({ status: 'idle' });
  const [isSaving, setIsSaving] = useState(false);
  const [editForm, setEditForm] = useState | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    setState({ status: 'loading' });

    fetchUser(userId, controller.signal)
      .then(data => setState({ status: 'success', data }))
      .catch(error => {
        if (error.name !== 'AbortError') {
          setState({ status: 'error', error });
        }
      });

    return () => controller.abort();
  }, [userId, fetchUser]);

  const handleSave = async () => {
    if (!editForm || state.status !== 'success') return;

    try {
      setIsSaving(true);
      const updated = await updateUser(userId, editForm);
      setState({ status: 'success', data: updated });
      setEditForm(null); // Exit edit mode
      onUpdate?.(updated);
    } catch (e) {
      alert('Failed to save'); // Ideally use a toast system here
    } finally {
      setIsSaving(false);
    }
  };

  if (state.status === 'loading') return Loading...;
  if (state.status === 'error')
    return Error: {state.error.message};

  const user = state.status === 'success' ? state.data : null;

  return (
    
      {editForm ? (
        
           setEditForm({ ...editForm, name: e.target.value })}
          />
           setEditForm({ ...editForm, email: e.target.value })}
          />
          
            {isSaving ? 'Saving…' : 'Save'}
          
        
      ) : (
        user && (
          
            
## {user.name}

            
{user.email}

             setEditForm({ name: user.name, email: user.email })}>
              Edit
            
          
        )
      )}
    
  );
};

Takeaway

해결책은 AI 사용을 중단하는 것이 아니라 어떻게 사용하는지를 바꾸는 것입니다.

  1. 프롬프트하기 전에 명확한 사양, 타입, 제약 조건을 작성하세요.
  2. AI가 만든 스니펫을 초안으로 보고, 반드시 리뷰하고 테스트하며 프로덕션에 맞게 강화하세요.
  3. “어떻게” 하는 부분—에러 처리, 레이스 컨디션 완화, 보안, 상태 관리—를 명시적으로 유지해 코드가 신뢰성 있고 유지보수 가능하며 안전하도록 하세요.
Back to Blog

관련 글

더 보기 »