vibe coding으로 주말에 만든 프로토타입을 사내 프로덕션에 올렸다가 인증 우회로 깨진 기록
최환
@hwan_dev
Karpathy가 2025년 2월 트윗에서 "vibe coding"이라는 말을 만들었다. "vibes에 완전히 맡기고, 코드가 존재한다는 사실조차 잊는" 코딩. 원문은 "I just see stuff, say stuff, run stuff, and copy-paste stuff, and it mostly works"였다(Karpathy 원 트윗). 핵심은 출력을 꼼꼼히 읽지 않고 받아들인다는 점이고, 이게 diff를 계속 읽는 AI-보조 엔지니어링과 갈리는 지점이다.
나는 이걸 주말 사이드로 한 번 정의대로 굴려봤다. 프로토타입은 빠르게 나왔고, 그걸 사내 내부 도구로 프로덕션에 올렸다가 인증이 통째로 우회되는 걸 일주일 뒤에 발견했다. 무엇이 깨졌는지, 어디까지가 vibe로 괜찮았고 어디서부터 안 됐는지를 영수증과 함께 적는다.
환경
- 만든 것: 사내 운영팀용 작은 대시보드. 로그인하면 팀별 집계를 보여주고, 관리자만 특정 레코드를 수정. Next.js(App Router) + Supabase, 서울 리전.
- 방식: 에이전트(Cursor + Claude)에 자연어로 "팀별 집계 페이지 만들어줘", "관리자만 수정 가능하게 해줘" 식으로 지시. 생성된 코드는 거의 읽지 않고 받아들였다. 에러가 나면 에러 메시지를 그대로 다시 붙여넣어서 통과시켰다. 정확히 vibe coding의 정의대로 했다.
- 결과: 토요일 오후 3시간 만에 로그인 → 대시보드 → 수정까지 도는 빌드가 나왔다. 내 계정으로 들어가서 전부 동작했고, 데모로는 멀쩡했다.
여기까지는 vibe coding이 잘 하는 영역이다. 버려도 되는 탐색, blast radius 작은 프로토타입. 문제는 이걸 "잘 도네" 하고 내부에 배포한 거였다.
증상
배포 일주일 뒤, 운영팀의 비-관리자 계정으로 들어간 동료가 슬랙에 스크린샷을 올렸다. 수정 버튼이 보였고, 눌렀더니 실제로 저장이 됐다. 관리자만 되어야 하는 동작이었다.
서버 라우트를 그제서야 처음 열어봤다. 에이전트가 만들어준 수정 API는 이렇게 생겨 있었다.
// app/api/records/[id]/route.ts
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
const body = await req.json()
const { data, error } = await supabase
.from('records')
.update({ value: body.value })
.eq('id', params.id)
if (error) return Response.json({ error: error.message }, { status: 500 })
return Response.json({ data })
}
권한 체크가 한 줄도 없었다. 클라이언트 컴포넌트에서 role !== 'admin'이면 수정 버튼을 숨기는 코드는 있었다. 그래서 내 화면(관리자)에서는 버튼이 안 보였고, 데모할 때 "관리자만 보이네" 하고 넘어갔던 거다. UI에서 버튼을 숨긴 것과 서버에서 권한을 막은 것을 같은 것으로 착각했다. 비-관리자는 버튼이 안 보일 뿐, curl로 PATCH를 직접 쏘면 그냥 통과했다.
재현은 한 줄이었다.
curl -X PATCH https://[내부도메인]/api/records/42 \
-H "Content-Type: application/json" \
-d '{"value":"overwritten"}'
# 세션 쿠키 없이도 200
세션 쿠키조차 필요 없었다. Supabase 클라이언트를 service_role 키로 서버에서 초기화해 놨기 때문이다. 에이전트가 "서버에서 DB 쓰려면" 하고 가장 권한 센 키를 박아넣었고, RLS는 그 키 앞에서 무력화된다. 인증 없는 요청이 관리자 권한으로 DB를 쓰고 있었다.
원인
세 가지가 겹쳤다.
- "된 것 같지만 아님." 데모가 통과한 건 내가 관리자 계정으로만 테스트했기 때문이다. 신규/비권한 사용자 기준으로 코어 플로우를 빈 상태에서 끝까지 밟아보지 않았다. 이건 vibe coding 고유의 함정이라기보다, vibe coding이 이 검증을 건너뛰기 쉽게 만든다는 게 핵심이다.
- 이해 못 한 기술부채. service_role 키가 서버에 박혀 있다는 걸 나는 배포 시점에 몰랐다. 코드를 안 읽었으니까. 생성된 코드가 "왜 동작하는지"를 모르는 채로 동작했다.
- 입력검증·권한이 자연어 지시에 안 들어 있었다. "관리자만 수정"이라고 말했지만 그건 UI 의미로 해석됐다. 권한이 돈/PII/인증 경계에 걸리는 순간, "대충 말하면 알아서"가 정확히 깨지는 지점이다.
이게 나 하나의 부주의였으면 좋겠는데, 그렇지 않다는 정황이 있다. API 보안 업체 Escape.tech가 vibe-coded 프로덕션 앱을 스캔한 결과를 65% 보안 이슈, 58% critical 취약점, 400건 이상 노출된 시크릿, 175건 PII 노출(은행 계좌 데이터 포함)로 보고했다(Escape.tech via CSA, vendor 발표·미검증). Veracode가 100개 이상 LLM을 보안 민감 코딩 태스크로 테스트했을 때 생성 코드 샘플의 45%가 OWASP Top 10 취약점을 포함했다고 발표했다(Veracode via CSA, vendor 발표·미검증). 두 수치 모두 벤더 자체 발표라 그대로 신뢰할 건 아니지만, 내가 만든 게 정확히 그 "권한 체크 누락" 버킷이라는 건 코드로 확인된 사실이다.
해결 diff
권한을 서버에서, 사용자 세션 기준으로 검사하도록 바꿨다. service_role 키는 서버 사이드 관리 작업 한 곳으로 격리하고, 일반 요청 경로에서 제거했다.
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
+ const supabase = createServerClient(/* 쿠키 기반 anon 클라이언트 */)
+ const { data: { user } } = await supabase.auth.getUser()
+ if (!user) return Response.json({ error: 'unauthorized' }, { status: 401 })
+
+ const { data: profile } = await supabase
+ .from('profiles').select('role').eq('id', user.id).single()
+ if (profile?.role !== 'admin') {
+ return Response.json({ error: 'forbidden' }, { status: 403 })
+ }
+
const body = await req.json()
+ if (typeof body.value !== 'string' || body.value.length > 500) {
+ return Response.json({ error: 'invalid value' }, { status: 400 })
+ }
const { data, error } = await supabase
.from('records')
.update({ value: body.value })
.eq('id', params.id)
if (error) return Response.json({ error: error.message }, { status: 500 })
return Response.json({ data })
}
그리고 RLS 정책을 실제로 켜서, 키가 새더라도 테이블 레벨에서 한 번 더 막히게 했다. UI의 버튼 숨김은 UX일 뿐 보안 경계가 아니라는 걸 명시적으로 분리했다.
검증
같은 curl을 다시 쐈다.
curl -X PATCH .../api/records/42 -d '{"value":"x"}' # 401 (쿠키 없음)
# 비-관리자 세션으로 # 403
# 관리자 세션으로 # 200
# value에 600자 # 400
세 가지 권한 상태(없음 / 비권한 / 권한)와 입력검증이 각각 다른 코드로 나뉘는지를 확인했다. 이번엔 내 계정만이 아니라 빈 상태·비권한 계정으로도 코어 플로우를 끝까지 밟았다.
한국 맥락 함정 하나
여기에 더해, 토큰 비용 얘기를 안 할 수가 없다. 같은 작업을 한글 지시·한글 주석으로 굴리면 영어 대비 토큰이 더 든다. 토크나이저 비교 자료들은 한국어가 영어 대비 대략 1.5~3배 토큰을 쓴다고 보는데, 모델·문장에 따라 편차가 크고 이 범위 자체가 출처마다 다른 미검증 추정치다(토크나이저 벤치마크). 실무에서 이게 의미하는 건, vibe coding으로 "에러 붙여넣고 또 붙여넣고"를 반복하는 루프가 영어권보다 더 빨리 사용량 한도(Claude Code의 5시간 윈도우/주간 캡 등)를 태운다는 거다. 권한·결제(이니시스/토스)·개인정보 코드는 어차피 vibe로 돌리면 안 되는데, 한글 토큰 비용까지 겹치면 "그냥 직접 짜고 diff 읽는 게 싸다"가 더 일찍 참이 된다.
전이 규칙(다음엔 이렇게)
이번 일로 정한 선은 단순하다. "프로토타입은 vibe, 프로덕션은 엔지니어링"이라는 흔한 슬로건을, 코드 단위가 아니라 경계 단위로 다시 정의했다.
- 인증·권한·돈·PII에 닿는 경로는 vibe 금지. 에이전트가 짜더라도 그 경로의 diff는 한 줄씩 읽는다. 나머지(레이아웃, 집계 쿼리, 더미 페이지)는 vibe로 둬도 blast radius가 작다.
- UI에서 숨긴 것과 서버에서 막은 것을 절대 같은 걸로 치지 않는다. 권한은 서버에서, 세션 기준으로.
- service_role / admin 키를 에이전트가 "편하니까" 박는 패턴을 가장 먼저 의심한다. 가장 센 키가 일반 요청 경로에 있으면 그게 곧 인증 우회 구멍이다.
- 데모 통과는 검증이 아니다. 신규·비권한 사용자 기준으로 빈 상태에서 코어 플로우를 끝까지 밟는다. 내 관리자 계정으로 되는 건 아무것도 증명하지 않는다.
- 테스트를 계약으로 쓴다. "비-관리자는 PATCH에서 403"을 e2e로 박아두면, 다음에 에이전트가 같은 코드를 다시 생성해도 빨간불로 잡힌다.
vibe coding이 나쁘다는 결론은 아니다. 주말 3시간에 도는 프로토타입을 뽑은 건 진짜였고 그 가치는 유효하다. 다만 "코드가 존재한다는 걸 잊는다"는 정의가 곧 "권한 경계가 존재한다는 걸 잊는다"로 번지는 순간 프로덕션에서 깨진다. 깨지는 지점이 랜덤이 아니라 인증·권한·입력검증이라는 게, 위의 벤더 수치와 내 한 건짜리 사고가 같은 방향을 가리킨 부분이다.