바이브 코딩 망상
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
useEffect는 userId가 바뀔 때마다 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 aUserProfilecomponent based on theUserProfilePropsinterface 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 사용을 중단하는 것이 아니라 어떻게 사용하는지를 바꾸는 것입니다.
- 프롬프트하기 전에 명확한 사양, 타입, 제약 조건을 작성하세요.
- AI가 만든 스니펫을 초안으로 보고, 반드시 리뷰하고 테스트하며 프로덕션에 맞게 강화하세요.
- “어떻게” 하는 부분—에러 처리, 레이스 컨디션 완화, 보안, 상태 관리—를 명시적으로 유지해 코드가 신뢰성 있고 유지보수 가능하며 안전하도록 하세요.