AI 코딩 에이전트가 만드는 숨겨진 프로덕션 버그 7가지 (터지기 전에 잡는 법)
AI 코딩 에이전트가 45초 만에 기능을 뚝딱 만들어줬어요. 컴파일 되고, 테스트 통과하고, PR도 깔끔해요. 금요일 저녁에 자신 있게 배포했죠.
토요일 아침에 일어나니 DB가 CPU 100%고, Redis 클러스터가 응답을 안 하고, 고객 세 명이 서로의 데이터를 보고 있다고 제보해요. 온콜 엔지니어는 한 번도 본 적 없는 AI 생성 코드를 들여다보는데, 에러 로그에 아무것도 안 찍혀 있어요. AI가 에러 로깅을 안 넣었거든요.
가상의 시나리오가 아니에요. 업계 전반에서 매일 일어나고 있는 일이에요. 2025년 Endor Labs 연구에 따르면 AI 생성 코드의 62%에서 보안 약점이나 설계 결함이 발견됐어요. 그런데 진짜 무서운 건 눈에 보이는 버그가 아니라 숨어 있는 버그예요. 멀쩡해 보이고, 단위 테스트도 통과하고, 코드 리뷰도 넘기고, 프로덕션에서 아무도 생각 못 했던 조건에서 터지는 코드요.
이 가이드에서는 AI 코딩 에이전트가 일관되게 만들어내는 7가지 위험한 숨겨진 버그 패턴을 까봅니다. 패턴마다 AI가 왜 이렇게 만드는지, 프로덕션 전에 어떻게 잡는지, 실전에서 검증된 수정법까지 다뤄요. 이론적 위험이 아니라 2025-2026년에 AI 지원 개발을 운영하는 회사들에서 나온 실제 프로덕션 장애 패턴이에요.
패턴 1: 캐시 스탬피드
AI가 만드는 코드
AI에게 캐싱을 추가해달라고 하면 교과서 같은 cache-aside 로직을 만들어요:
async function getProduct(id: string): Promise<Product> { const cached = await redis.get(`product:${id}`); if (cached) { return JSON.parse(cached); } const product = await db.query('SELECT * FROM products WHERE id = $1', [id]); await redis.set(`product:${id}`, JSON.stringify(product), 'EX', 3600); return product; }
단독 테스트에선 완벽해요. 코드 리뷰도 깔끔하고요.
왜 터지나
프로덕션 트래픽에서 인기 캐시 키가 만료되면, 수백 개의 동시 요청이 전부 캐시 미스를 겪어요. 전부 다 같은 쿼리로 DB를 때려요. DB CPU가 100%로 튀고, 쿼리가 타임아웃 나기 시작하면서 관련 없는 서비스까지 연쇄적으로 죽어요.
이게 캐시 스탬피드(thundering herd 문제)예요. AI가 이걸 만드는 이유는 학습 데이터가 싱글 스레드, 저트래픽 환경을 가정한 튜토리얼 캐싱 예제 위주거든요.
탐지 전략
// 부하 테스트 스위트에 추가 it('동시 캐시 미스에서 스탬피드가 발생하지 않아야 한다', async () => { await redis.del('product:popular-item'); // 같은 키에 100개 동시 요청 시뮬레이션 const requests = Array.from({ length: 100 }, () => getProduct('popular-item') ); const dbQuerySpy = vi.spyOn(db, 'query'); await Promise.all(requests); // DB를 1번만 때렸어야 정상 expect(dbQuerySpy).toHaveBeenCalledTimes(1); });
프로덕션 수정
import { Mutex } from 'async-mutex'; const locks = new Map<string, Mutex>(); async function getProduct(id: string): Promise<Product> { const cacheKey = `product:${id}`; const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); } // 키별 락으로 스탬피드 방지 if (!locks.has(cacheKey)) { locks.set(cacheKey, new Mutex()); } const mutex = locks.get(cacheKey)!; return mutex.runExclusive(async () => { // 락 획득 후 다시 확인 (double-checked locking) const rechecked = await redis.get(cacheKey); if (rechecked) { return JSON.parse(rechecked); } const product = await db.query( 'SELECT * FROM products WHERE id = $1', [id] ); await redis.set(cacheKey, JSON.stringify(product), 'EX', 3600); return product; }); }
핵심은 double-checked locking이에요. 첫 번째 요청이 락을 잡고 DB에서 가져와서 캐시를 채우면, 나머지 동시 요청은 락에서 기다렸다가 이미 채워진 캐시를 바로 리턴해요. DB를 한 번만 때리는 거죠.
패턴 2: 커넥션 풀 고갈
AI가 만드는 코드
AI는 async/await을 좋아하지만 DB 커넥션을 누수시키는 코드를 일관되게 만들어요:
async function processOrder(orderId: string) { const client = await pool.connect(); const order = await client.query( 'SELECT * FROM orders WHERE id = $1', [orderId] ); const inventory = await client.query( 'SELECT * FROM inventory WHERE product_id = $1', [order.rows[0].product_id] ); await client.query( 'UPDATE inventory SET quantity = quantity - $1 WHERE product_id = $2', [order.rows[0].quantity, order.rows[0].product_id] ); client.release(); return { success: true }; }
왜 터지나
쿼리 중 하나라도 에러가 나면 client.release()가 실행 안 돼요. 제약 조건 위반, 타임아웃, 데드락 뭐든요. 커넥션이 누수돼요. 누수가 쌓이면 pool.connect()가 무한 대기하기 시작해요. 모든 커넥션이 버려진 핸들러에 잡혀 있으니까요. 앱 전체가 에러 로그 하나 없이 멈춰요. 행(hang)이 커넥션 획득 단계에서 일어나거든요.
AI가 이걸 만드는 이유는 학습 데이터가 "잘 되는 경우"만 보여주기 때문이에요. 커넥션 관리에서 에러 핸들링은 튜토리얼에서 거의 안 다뤄요.
탐지 전략
// 프로덕션 풀 메트릭 모니터링 setInterval(() => { const { totalCount, idleCount, waitingCount } = pool; logger.info('pool_metrics', { total: totalCount, idle: idleCount, waiting: waitingCount, active: totalCount - idleCount, }); if (waitingCount > 5) { logger.warn('pool_pressure', { message: '커넥션 풀 압력 증가', waiting: waitingCount, }); } }, 10_000);
프로덕션 수정
async function processOrder(orderId: string) { const client = await pool.connect(); try { await client.query('BEGIN'); const order = await client.query( 'SELECT * FROM orders WHERE id = $1', [orderId] ); const inventory = await client.query( 'SELECT * FROM inventory WHERE product_id = $1', [order.rows[0].product_id] ); await client.query( 'UPDATE inventory SET quantity = quantity - $1 WHERE product_id = $2', [order.rows[0].quantity, order.rows[0].product_id] ); await client.query('COMMIT'); return { success: true }; } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); // 에러가 나든 안 나든 반드시 반환 } }
try/catch/finally 패턴이 무슨 일이 일어나든 커넥션 반환을 보장해요. AI 에이전트가 가장 일관되게 빠뜨리는 패턴이에요.
패턴 3: 사일런트 데이터 오염
AI가 만드는 코드
API 데이터를 처리할 때 AI는 외부 입력을 무조건 신뢰해요:
app.post('/api/users/:id/profile', async (req, res) => { const { name, email, role } = req.body; await db.query( 'UPDATE users SET name = $1, email = $2, role = $3 WHERE id = $4', [name, email, role, req.params.id] ); res.json({ success: true }); });
왜 터지나
이 엔드포인트를 사용하면 인증된 사용자 누구나 자기 role을 admin으로 바꿀 수 있어요. AI는 DB 스키마에 role 컬럼이 있으니까 request body의 role과 패턴 매칭을 해버린 거예요. 권한 검증은 고려 안 하고요.
더 교묘한 건 name이나 email에 아무 문자열이나 들어간다는 거예요. 이름에 10MB 문자열을 넣거나, 이메일에 not-an-email을 넣을 수 있어요. 저장은 "성공"하지만 하위 시스템이 하나씩 깨져요. 이메일 발송이 터지고, CSV 내보내기가 크래시하고, 검색 인덱스가 부풀어요.
이게 사일런트 데이터 오염이에요. 쓰기는 성공하는데 잘못된 데이터가 시스템 전체에 느린 독처럼 퍼지는 거예요.
탐지 전략
import { z } from 'zod'; const UpdateProfileSchema = z.object({ name: z.string().min(1).max(100).trim(), email: z.string().email().max(254).toLowerCase(), // 주목: 'role'은 스키마에 없음 }); // 통합 테스트: 금지된 필드가 거부되는지 확인 it('프로필 업데이트로 역할 에스컬레이션이 불가능해야 한다', async () => { const res = await request(app) .post('/api/users/user-1/profile') .send({ name: 'Hacker', email: '[email protected]', role: 'admin' }) .set('Authorization', `Bearer ${userToken}`); const user = await db.query('SELECT role FROM users WHERE id = $1', ['user-1']); expect(user.rows[0].role).toBe('member'); // 'admin'이 아님 });
프로덕션 수정
app.post('/api/users/:id/profile', async (req, res) => { // 1. 입력 검증 — 알 수 없는 필드 제거 const parsed = UpdateProfileSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: 'Validation failed', issues: parsed.error.issues, }); } // 2. 검증된 데이터만 사용 (role 주입 불가능) const { name, email } = parsed.data; // 3. 본인 프로필만 수정 가능한지 확인 if (req.params.id !== req.user.id) { return res.status(403).json({ error: 'Forbidden' }); } await db.query( 'UPDATE users SET name = $1, email = $2 WHERE id = $3', [name, email, req.params.id] ); res.json({ success: true }); });
수정에는 세 겹의 방어가 있어요. 스키마 검증(role 제거), 권한 검사(본인만), 제약된 데이터 타입(이메일 형식, 이름 길이). AI가 만든 코드에서 세 가지를 전부 구현하는 경우는 거의 없어요.
패턴 4: 핸들링 안 된 레이스 컨디션
AI가 만드는 코드
"좋아요" 토글 기능을 구현할 때:
async function toggleLike(userId: string, postId: string) { const existing = await db.query( 'SELECT id FROM likes WHERE user_id = $1 AND post_id = $2', [userId, postId] ); if (existing.rows.length > 0) { await db.query('DELETE FROM likes WHERE id = $1', [existing.rows[0].id]); await db.query( 'UPDATE posts SET like_count = like_count - 1 WHERE id = $1', [postId] ); return { liked: false }; } else { await db.query( 'INSERT INTO likes (user_id, post_id) VALUES ($1, $2)', [userId, postId] ); await db.query( 'UPDATE posts SET like_count = like_count + 1 WHERE id = $1', [postId] ); return { liked: true }; } }
왜 터지나
연결 상태가 안 좋은 폰에서 좋아요 버튼을 더블 탭하면? 첫 번째 요청이 확인했을 때 like 레코드가 없어요. 두 번째 요청도 밀리초 차이로 도착해서 확인했을 때 역시 없어요(첫 번째 INSERT가 아직 커밋 안 됐으니까요). 둘 다 INSERT를 실행해요. like_count가 2 늘어나요. 사용자에게 좋아요 레코드가 두 개 생기고, 카운트는 영구적으로 동기화가 깨져요.
테스트에서는 안 보여요. 테스트는 순차 실행이니까요. 동시 프로덕션 트래픽에서만 나타나요.
탐지 전략
it('동시 좋아요 토글을 정확히 처리해야 한다', async () => { const results = await Promise.all([ toggleLike('user-1', 'post-1'), toggleLike('user-1', 'post-1'), ]); const likes = await db.query( 'SELECT COUNT(*) FROM likes WHERE user_id = $1 AND post_id = $2', ['user-1', 'post-1'] ); // 0 또는 1이어야지, 절대 2가 되면 안 됨 expect(Number(likes.rows[0].count)).toBeLessThanOrEqual(1); });
프로덕션 수정
async function toggleLike(userId: string, postId: string) { return await db.transaction(async (tx) => { // 행 수준 락으로 동시 수정 방지 const existing = await tx.query( `SELECT id FROM likes WHERE user_id = $1 AND post_id = $2 FOR UPDATE`, // ← 행 수준 락 [userId, postId] ); if (existing.rows.length > 0) { await tx.query('DELETE FROM likes WHERE id = $1', [existing.rows[0].id]); await tx.query( 'UPDATE posts SET like_count = like_count - 1 WHERE id = $1', [postId] ); return { liked: false }; } else { await tx.query( `INSERT INTO likes (user_id, post_id) VALUES ($1, $2) ON CONFLICT (user_id, post_id) DO NOTHING`, // ← 멱등성 보장 [userId, postId] ); await tx.query( 'UPDATE posts SET like_count = like_count + 1 WHERE id = $1', [postId] ); return { liked: true }; } }); }
핵심 두 가지: FOR UPDATE가 행 수준 락을 걸어서 동시 작업을 직렬화하고, ON CONFLICT DO NOTHING이 타이밍 상 중복이 발생해도 안전한 안전망 역할을 해요. AI가 FOR UPDATE를 만드는 경우는 거의 없어요. 튜토리얼 코드에 거의 안 나오거든요.
패턴 5: 리트라이 폭풍
AI가 만드는 코드
"이 API 호출을 더 안정적으로 만들어줘"라고 하면:
async function callPaymentAPI(data: PaymentRequest): Promise<PaymentResult> { const MAX_RETRIES = 3; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { try { const response = await fetch('https://api.payment.com/charge', { method: 'POST', body: JSON.stringify(data), headers: { 'Content-Type': 'application/json' }, }); if (!response.ok) { throw new Error(`Payment API error: ${response.status}`); } return await response.json(); } catch (error) { if (attempt === MAX_RETRIES - 1) throw error; // 바로 재시도 } } throw new Error('Unreachable'); }
왜 터지나
결제 API가 성능 저하 상태(200ms가 아니라 5초 걸리는 상태)일 때, 들어오는 모든 요청이 이미 힘들어하는 서비스에 3배로 불을 질러요. 동시 사용자 1,000명이면 리트라이 때문에 3,000개 요청이 몰려요. 장애를 증폭시키고 피드백 루프를 만들어서 우리 서비스와 하위 API 전체를 날릴 수 있어요.
더 심각한 건: POST /charge 엔드포인트를 리트라이한다는 거예요. 첫 번째 요청이 이미 성공했는데 응답이 느려서 리트라이가 발동하면 이중 결제가 일어나요. 고객이 두 번 과금돼요.
탐지 전략
it('즉시 재시도가 아니라 지수 백오프를 구현해야 한다', async () => { let callTimestamps: number[] = []; vi.spyOn(global, 'fetch').mockImplementation(async () => { callTimestamps.push(Date.now()); throw new Error('Service unavailable'); }); await expect(callPaymentAPI(mockData)).rejects.toThrow(); // 재시도 간 지수적 지연이 있는지 검증 for (let i = 1; i < callTimestamps.length; i++) { const delay = callTimestamps[i] - callTimestamps[i - 1]; expect(delay).toBeGreaterThan(500 * Math.pow(2, i - 1)); } });
프로덕션 수정
async function callPaymentAPI(data: PaymentRequest): Promise<PaymentResult> { // 1. 멱등성 키로 이중 결제 방지 const idempotencyKey = crypto.randomUUID(); // 2. 지터 포함 지수 백오프 const MAX_RETRIES = 3; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); const response = await fetch('https://api.payment.com/charge', { method: 'POST', body: JSON.stringify(data), headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey, // ← 이중 결제 방지 }, signal: controller.signal, }); clearTimeout(timeout); // 3. 재시도 가능한 상태 코드만 재시도 if (response.status === 429 || response.status >= 500) { throw new RetryableError(response.status); } if (!response.ok) { // 4xx는 재시도 불가 (잘못된 입력, 인증 실패) throw new NonRetryableError(response.status); } return await response.json(); } catch (error) { if (error instanceof NonRetryableError) throw error; if (attempt === MAX_RETRIES - 1) throw error; // 4. 지터 포함 지수 백오프 const baseDelay = 1000 * Math.pow(2, attempt); const jitter = Math.random() * 500; await new Promise(resolve => setTimeout(resolve, baseDelay + jitter) ); } } throw new Error('Unreachable'); }
네 가지가 추가됐어요: 멱등성 키(이중 결제 방지), 지터 포함 지수 백오프(동기화된 리트라이 폭풍 방지), 재시도 가능/불가능 에러 분류(400 에러는 재시도하지 않음), 명시적 타임아웃(행잉 커넥션 방지). AI 에이전트는 보통 이 중 하나도 구현 안 해요.
패턴 6: 느린 메모리 누수
AI가 만드는 코드
이벤트 처리나 WebSocket 핸들러를 만들 때:
class NotificationService { private listeners: Map<string, Set<(data: any) => void>> = new Map(); subscribe(userId: string, callback: (data: any) => void) { if (!this.listeners.has(userId)) { this.listeners.set(userId, new Set()); } this.listeners.get(userId)!.add(callback); } notify(userId: string, data: any) { this.listeners.get(userId)?.forEach(cb => cb(data)); } } // WebSocket 핸들러에서 wss.on('connection', (ws, req) => { const userId = extractUserId(req); const callback = (data: any) => { ws.send(JSON.stringify(data)); }; notificationService.subscribe(userId, callback); ws.on('message', (msg) => { /* 메시지 핸들링 */ }); });
왜 터지나
WebSocket이 끊어질 때 unsubscribe를 안 해요. 매 연결마다 콜백이 Set에 추가되지만 제거되는 건 하나도 없어요. 프로덕션 트래픽으로 일주일 돌리면 listeners Map에 죽은 콜백이 수백만 개 쌓여요. 닫힌 WebSocket 연결을 가리키는 콜백들이요. 메모리가 선형으로 증가하다가 Node.js 프로세스가 OOM으로 크래시해요.
개발 환경에서는 안 보여요. 프로세스가 자주 재시작되니까요. 프로덕션에서는 며칠이나 몇 주가 걸려야 나타나서, 원래 코드 변경과 연결짓기가 극도로 어렵죠.
탐지 전략
it('연결 해제 시 리스너를 정리해야 한다', async () => { const ws = new MockWebSocket(); simulateConnection(ws, 'user-1'); const listenersBefore = notificationService.getListenerCount('user-1'); expect(listenersBefore).toBe(1); // 연결 해제 시뮬레이션 ws.emit('close'); const listenersAfter = notificationService.getListenerCount('user-1'); expect(listenersAfter).toBe(0); }); // 프로덕션 모니터링 setInterval(() => { let total = 0; notificationService.listeners.forEach((set) => { total += set.size; }); logger.info('listener_count', { total }); // 활성 연결 수의 1.5배를 넘으면 알림 }, 60_000);
프로덕션 수정
wss.on('connection', (ws, req) => { const userId = extractUserId(req); const callback = (data: any) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(data)); } }; notificationService.subscribe(userId, callback); // 연결 해제 시 정리 — 빠져 있던 핵심 조각 ws.on('close', () => { notificationService.unsubscribe(userId, callback); }); ws.on('error', () => { notificationService.unsubscribe(userId, callback); }); ws.on('message', (msg) => { /* 메시지 핸들링 */ }); }); // 빠져 있던 unsubscribe 메서드 추가 class NotificationService { // ... 기존 코드 ... unsubscribe(userId: string, callback: (data: any) => void) { const userListeners = this.listeners.get(userId); if (userListeners) { userListeners.delete(callback); if (userListeners.size === 0) { this.listeners.delete(userId); // 빈 Set도 정리 } } } getListenerCount(userId: string): number { return this.listeners.get(userId)?.size ?? 0; } }
수정에서 close와 error 이벤트 모두에 정리 핸들러를 추가하고, 전송 전 readyState 체크, 그리고 빈 Set 정리까지 해요. AI는 subscribe는 일관되게 만들지만 unsubscribe는 까먹어요. 학습 데이터에 정리 코드가 거의 안 나오거든요.
패턴 7: 인증 컨텍스트 누수
AI가 만드는 코드
인증 미들웨어와 서비스 레이어를 만들 때:
// 인증 미들웨어 function authMiddleware(req: Request, res: Response, next: NextFunction) { const token = req.headers.authorization?.split(' ')[1]; const decoded = jwt.verify(token!, process.env.JWT_SECRET!); req.user = decoded as AuthUser; next(); } // 인증 컨텍스트를 사용하는 서비스 class OrderService { async getOrders(userId: string) { return db.query('SELECT * FROM orders WHERE user_id = $1', [userId]); } } // 컨트롤러 app.get('/api/orders', authMiddleware, async (req, res) => { const orders = await orderService.getOrders(req.query.userId as string); res.json(orders); });
왜 터지나
인증 미들웨어가 JWT를 올바르게 검증하고 req.user를 설정해요. 그런데 컨트롤러가 req.user.id(검증된 토큰에서 가져온 값) 대신 req.query.userId(URL 쿼리 스트링에서 가져온 값)를 서비스에 넘겨요. 인증된 사용자라면 누구나 userId 쿼리 파라미터만 바꾸면 다른 사용자의 주문을 볼 수 있어요.
이게 IDOR 취약점(Insecure Direct Object Reference)이에요. AI가 세 가지 컴포넌트를 각각은 올바르게 만들었지만, 연결 부분에서 보안 구멍을 만든 거예요. AI의 패턴 매칭이 userId라는 변수명을 쿼리 스트링에서 찾아 연결한 거예요. 안전해서가 아니라 이름이 같으니까요.
탐지 전략
it('다른 사용자의 주문에 접근할 수 없어야 한다', async () => { // 사용자 A의 토큰 const tokenA = generateToken({ id: 'user-a', role: 'member' }); // 사용자 B의 주문에 접근 시도 const res = await request(app) .get('/api/orders?userId=user-b') .set('Authorization', `Bearer ${tokenA}`); // 403을 반환하거나, 사용자 A의 주문만 반환해야 함 const orders = res.body; orders.forEach((order: any) => { expect(order.user_id).toBe('user-a'); }); });
프로덕션 수정
// 컨트롤러는 반드시 검증된 인증 컨텍스트를 사용해야 함 app.get('/api/orders', authMiddleware, async (req, res) => { // ✅ JWT에서 온 검증된 신원 사용, 쿼리 파라미터 무시 const orders = await orderService.getOrders(req.user.id); res.json(orders); }); // 교차 사용자 접근이 필요한 어드민 엔드포인트의 경우: app.get('/api/admin/orders/:userId', authMiddleware, requireRole('admin'), async (req, res) => { const orders = await orderService.getOrders(req.params.userId); res.json(orders); } );
원칙은 간단해요: 검증된 사용자 정보가 있으면 클라이언트가 보내준 값은 절대 쓰지 마세요. 인증된 사용자의 ID는 JWT에서 꺼내야 해요. 쿼리 스트링은 필터링이나 페이지네이션용이지, 누구인지 확인하는 용도가 아니에요.
메타 패턴: AI 에이전트가 왜 이런 버그를 만들까
7가지 패턴 모두 같은 근본 원인을 공유해요: AI 에이전트는 해피 패스에 최적화되어 있어요.
학습 데이터가 튜토리얼, 문서 예제, Stack Overflow 답변 위주라서 "잘 되는 경우"만 보여줘요. 실제 프로덕션 코드는 "잘못될 때 어떻게 할지"에 복잡성 예산의 80%를 써요.
학습 데이터 분포 vs. 프로덕션 코드 현실:
AI 학습 데이터: 프로덕션 코드:
───────────── ───────────────
해피 패스: 85% 해피 패스: 20%
에러 핸들링: 10% 에러 핸들링: 35%
엣지 케이스: 3% 엣지 케이스: 25%
동시성: 1% 동시성: 10%
정리: 1% 정리: 10%
이게 체계적인 사각지대를 만들어요. AI가 정상 동작을 처리하는 20%의 코드는 아름다운데, 코드를 프로덕션급으로 만드는 80%는 무시하거나 겉핥기만 해요.
탐지 체크리스트
AI 생성 코드를 머지하기 전에 이 체크리스트를 돌리세요:
| 분류 | 질문 | "아니오"면 → |
|---|---|---|
| 동시성 | 이 코드가 1000개 동시 요청에서 동작하나요? | 락 / FOR UPDATE / 멱등성 추가 |
| 커넥션 관리 | 모든 커넥션이 finally 블록에서 반환되나요? | try/finally 또는 커넥션 헬퍼 사용 |
| 입력 검증 | 모든 외부 입력이 검증되고 제약되나요? | API 경계에 Zod 스키마 추가 |
| 인증 컨텍스트 | 검증된 사용자 정보를 쓰나요, 클라이언트가 넘긴 값이 아니라? | req.query/req.params를 req.user로 교체 |
| 리트라이 로직 | 지수 백오프 + 멱등성 키를 쓰나요? | 단순 리트라이 루프 교체 |
| 리소스 정리 | 이벤트 리스너, 타이머, 구독이 정리되나요? | close/error/unsubscribe 핸들러 추가 |
| 에러 전파 | 에러에 디버깅할 수 있는 맥락이 담겨 있나요? | correlation ID 포함 구조화 로깅 추가 |
| 상태 일관성 | 두 쓰기 사이에 크래시하면 데이터가 불일치하나요? | DB 트랜잭션으로 감싸기 |
방어 레이어 구축하기
가장 효과적인 방어는 AI 생성 코드를 한 줄씩 리뷰하는 게 아니에요. 이런 패턴을 자동으로 잡는 인프라를 만드는 거예요.
1. 동시성 필수 통합 테스트
코드가 동작하는지만 테스트하지 마세요. 동시 부하에서도 동작하는지 테스트하세요. 모든 쓰기 연산에 Promise.all() 테스트를 추가해서 레이스 컨디션에서도 정확한지 검증해보세요.
2. 커넥션 풀 모니터링
활성 커넥션, 대기 커넥션, 유휴 커넥션에 대한 실시간 풀 메트릭을 옵저버빌리티 스택에 추가하세요. waiting > 5이면 알림을 보내세요. 장애가 되기 전에 커넥션 누수를 잡아요.
3. 모든 경계에서 스키마 검증
데이터가 신뢰 경계를 넘는 모든 지점에서 검증 라이브러리(Zod, Valibot, ArkType)를 사용하세요. API 엔드포인트, 큐 컨슈머, 웹훅 핸들러, 서드파티 API 응답 전부요. 데이터 형태가 기대한 대로일 거라고 절대 믿지 마세요.
4. Auth-by-Default 아키텍처
인증과 인가를 실수로 건너뛸 수 없는 코드베이스 구조를 만드세요. 서비스는 raw user ID가 아니라 검증된 AuthContext 파라미터를 받아야 해요. 서비스 메서드에 AuthContext가 없으면 컴파일 에러가 나게 하세요.
5. 정적 분석 규칙
알려진 안티 패턴을 플래그하는 커스텀 ESLint 또는 Biome 규칙을 만드세요:
finally블록에release()가 없는pool.connect()- 딜레이 없는
fetch()리트라이 루프 - 정리 없는
addEventListener()/subscribe() - 신원 식별에
req.query또는req.params를 사용하는 DB 쿼리
결론
AI 코딩 에이전트는 규율과 함께 쓰면 생산성 부스터예요. 규율 없이 쓰면 부채 증폭기고요.
이 가이드의 7가지 패턴은 엣지 케이스가 아니에요. AI 생성 코드에서 가장 흔하고, 가장 손해가 크고, 가장 예측 가능한 실패 패턴이에요. 전부 인프라로 예방 가능해요. 동시성 테스트, 커넥션 모니터링, 스키마 검증, auth-by-default 아키텍처, 정적 분석으로요.
AI 에이전트가 첫 번째 초안을 써요. 엔지니어링 규율이 최종본을 써요. 이 차이를 마스터하는 팀이 더 빠르게 출시하면서 덜 터질 거예요. 그렇지 못한 팀은 금요일 저녁에 배포한 "PR에서는 괜찮아 보이던" AI 코드 때문에 토요일 아침 장애를 계속 디버깅하게 될 거예요.
멀쩡해 보이는 코드가 가장 위험한 코드예요.
관련 도구 둘러보기
Pockit의 무료 개발자 도구를 사용해 보세요