3주 전에 팀 후배가 슬랙으로 스크린샷을 보내왔다. Claude한테 "React로 테이블 컴포넌트 만들어줘"라고 했더니 class 컴포넌트에 jQuery까지 섞인 코드가 나왔다고. "AI가 아직 멀었네요"라는 한마디와 함께. 나는 같은 요청을 다르게 써봤다. 우리 프로젝트의 기술 스택, 기존 컴포넌트 예시, 원하는 결과물의 형태를 같이 넣었다. 30초 만에 바로 쓸 수 있는 코드가 나왔다. AI가 달라진 게 아니다. 프롬프트가 달랐을 뿐이다.
이 글은 "프롬프트를 구체적으로 쓰세요" 같은 뻔한 말을 하려고 쓴 게 아니다. 개발 업무에서 실제로 효과가 있었던 프롬프트 패턴들을, before/after와 함께 공유하려고 한다.
Context Sandwich: 기술 스택 + 현재 코드 + 원하는 결과
AI한테 코드를 요청할 때 가장 흔한 실수가 맥락 없이 던지는 거다. "정렬 함수 만들어줘"라고 하면 AI 입장에서는 어떤 언어인지, 어떤 데이터 구조인지, 프로젝트 컨벤션이 뭔지 아무것도 모른다. 그래서 세상에서 가장 평균적인 코드를 뱉는다.
내가 쓰는 패턴은 "context sandwich"다. 세 층으로 쌓는다.
나쁜 프롬프트:
React로 드롭다운 컴포넌트 만들어줘
좋은 프롬프트:
[기술 스택]
Next.js 14 (App Router), TypeScript, Tailwind CSS, Radix UI 사용 중
[현재 코드 — 비슷한 컴포넌트]
// 우리 프로젝트의 Select 컴포넌트
export function Select({ options, value, onChange, placeholder }: SelectProps) {
return (
<RadixSelect.Root value={value} onValueChange={onChange}>
<RadixSelect.Trigger className="flex items-center justify-between rounded-lg border border-gray-200 px-3 py-2 text-sm">
<RadixSelect.Value placeholder={placeholder} />
</RadixSelect.Trigger>
<RadixSelect.Content className="rounded-lg border bg-white shadow-md">
{options.map((opt) => (
<RadixSelect.Item key={opt.value} value={opt.value} className="px-3 py-2 text-sm hover:bg-gray-50">
{opt.label}
</RadixSelect.Item>
))}
</RadixSelect.Content>
</RadixSelect.Root>
);
}
[원하는 결과]
위 Select와 같은 스타일로 multi-select 드롭다운을 만들어줘.
선택된 항목은 태그 형태로 표시, 최대 선택 수 제한 가능, 검색 기능 포함.
이렇게 하면 AI가 기존 코드의 네이밍 규칙, 스타일링 방식, 라이브러리 선택까지 전부 맞춰서 코드를 뱉는다. 처음 이 방식으로 바꿨을 때 결과물의 "바로 쓸 수 있는 비율"이 체감상 3배는 올라갔다.
핵심은 기존 코드를 예시로 넣는 것이다. AI는 텍스트에서 패턴을 뽑아내는 기계다. "Tailwind 써줘"라는 말보다, 실제 Tailwind이 적용된 코드 한 덩어리가 훨씬 정확한 가이드가 된다.
처음에는 이렇게 프롬프트를 쓰는 게 시간 낭비처럼 느껴질 수 있다. "그냥 빨리 물어보고 안 되면 다시 물어보면 되지 않나?" 나도 그렇게 생각했다. 근데 해보면 안다. 모호한 프롬프트로 3번 왔다갔다하는 것보다, 잘 쓴 프롬프트 한 번이 훨씬 빠르다. 첫 번째 결과물의 퀄리티가 높으면 수정 사이클 자체가 줄어든다.
Few-shot: 기존 코드 2-3개를 예시로 던지기
Context sandwich의 연장인데, 약간 다른 상황에서 쓴다. 새로운 종류의 코드를 만들어달라고 할 때가 아니라, 기존 패턴을 반복해야 할 때.
실제로 내가 자주 쓰는 케이스가 API hook을 만드는 거다. 우리 팀은 React Query를 감싸는 custom hook을 일정한 패턴으로 만든다. 새 API가 추가될 때마다 같은 패턴의 hook을 만들어야 하는데, 이걸 매번 손으로 치는 건 비효율적이다.
아래 두 hook의 패턴을 따라서 새로운 hook을 만들어줘.
[예시 1]
export function useOrders(params: OrderListParams) {
return useQuery({
queryKey: orderKeys.list(params),
queryFn: () => orderApi.getList(params),
staleTime: 1000 * 60 * 5,
});
}
[예시 2]
export function useProducts(params: ProductListParams) {
return useQuery({
queryKey: productKeys.list(params),
queryFn: () => productApi.getList(params),
staleTime: 1000 * 60 * 5,
});
}
[요청]
위와 같은 패턴으로 쿠폰 목록을 가져오는 useCoupons hook을 만들어줘.
API 함수는 couponApi.getList, queryKey factory는 couponKeys.
params 타입은 CouponListParams이고, status, page, limit 필드를 가져.
이게 few-shot prompting이다. 머신러닝에서 온 개념인데, 실무에서는 그냥 "내 코드 보여주고 같은 식으로 해달라고 하는 것"이다. AI가 예시에서 패턴을 추출하기 때문에 "queryKey factory를 써줘" 같은 설명을 안 해도 된다. 보여주면 안다.
두세 개면 충분하다. 하나만 보여주면 우연의 여지가 있고, 다섯 개 이상은 토큰 낭비다.
"이거 고쳐줘" vs 구체적 질문의 힘
디버깅할 때도 마찬가지다. 에러가 났을 때 스택 트레이스를 복붙하고 "이거 왜 그래?"라고 물으면, AI는 가능한 원인 다섯 가지를 나열한다. 그중에 정답이 있을 수도 있고 없을 수도 있다. 돌아가면서 다 시도해봐야 하니까 결국 시간이 더 걸린다.
이전에 디버깅 글에서 "재현 → 격리 → 원인 → 수정"이라는 프로세스를 썼는데, AI한테 질문할 때도 같은 원칙이 적용된다. 일단 내가 먼저 재현하고, 격리까지 해놓고, 그 상태에서 물어야 한다.
나쁜 프롬프트:
TypeError: Cannot read properties of undefined (reading 'map')
이거 왜 그래?
좋은 프롬프트:
[상황]
Next.js App Router에서 서버 컴포넌트가 클라이언트 컴포넌트에 데이터를 props로 넘기는 구조
[재현 조건]
1. /dashboard 페이지 첫 로드 시에는 정상 동작
2. 다른 페이지 갔다가 브라우저 뒤로가기로 돌아오면 에러 발생
3. 새로고침하면 다시 정상
[에러]
TypeError: Cannot read properties of undefined (reading 'map')
→ DashboardChart 컴포넌트의 data.items.map() 라인
[현재 코드]
// page.tsx (서버 컴포넌트)
export default async function DashboardPage() {
const data = await fetchDashboard();
return <DashboardChart data={data} />;
}
// DashboardChart.tsx (클라이언트 컴포넌트)
'use client';
export function DashboardChart({ data }: { data: DashboardData }) {
return data.items.map(item => <Bar key={item.id} value={item.value} />);
}
[내 추측]
뒤로가기 시 서버 컴포넌트가 다시 실행되지 않아서 data가 undefined인 것 같은데,
이게 bfcache 관련인지 Next.js의 라우터 캐시 관련인지 모르겠음.
원인 분석해줘.
두 번째 프롬프트의 결과는 완전히 다르다. AI가 바로 Next.js의 라우터 캐시와 bfcache의 차이를 설명하고, 해당 시나리오에서 정확히 어떤 일이 일어나는지 짚어준다. 그리고 data?.items?.map() 같은 임시 방편이 아니라 근본적인 해결책을 제시한다.
여기서 중요한 건 "내 추측"을 같이 넣는 것이다. 추측이 맞으면 AI가 확인해주고, 틀리면 왜 틀린지 설명해준다. 어느 쪽이든 내가 배운다.
System Prompt: 프로젝트 맥락을 한번에 주기
매번 기술 스택을 타이핑하는 건 귀찮다. 그래서 요즘은 프로젝트 루트에 맥락 파일을 하나 만들어둔다.
Claude Code를 쓰면 CLAUDE.md, Cursor를 쓰면 .cursorrules 파일이 이 역할을 한다. 내가 실제로 쓰고 있는 CLAUDE.md의 일부를 공유한다.
# Project Context
## 기술 스택
- Next.js 14 (App Router)
- TypeScript (strict mode)
- Tailwind CSS + Radix UI
- TanStack Query v5
- Zustand (전역 상태)
- Vitest + Testing Library
## 코드 컨벤션
- 컴포넌트: PascalCase, named export
- hook: use 접두사, 파일당 하나
- API 함수: xxxApi 네이밍 (orderApi, productApi)
- Query Key: xxxKeys factory 패턴
## 디렉토리 구조
src/
components/ # 공통 UI 컴포넌트
features/ # 도메인별 기능 (orders/, products/)
hooks/ # 공통 hook
lib/ # 유틸리티, API 클라이언트
types/ # 공유 타입
## 중요 규칙
- any 사용 금지. unknown 쓸 것
- barrel export(index.ts) 사용하지 않음
- CSS-in-JS 사용하지 않음 (Tailwind만)
- 에러 처리는 Error Boundary + toast 조합이 파일이 있으면 AI가 매 대화마다 이 맥락을 자동으로 읽는다. "Tailwind 써줘", "우리는 named export 써", "any 쓰지 마" 같은 말을 반복할 필요가 없어진다.
한번 세팅해두면 프로젝트 내내 쓸 수 있으니까, 처음 10분 투자로 이후 수백 번의 대화가 전부 달라진다. 팀 프로젝트라면 이 파일을 레포에 커밋해서 팀원 모두가 같은 맥락으로 AI를 쓸 수 있게 하는 게 좋다.
우리 팀에서는 이 파일을 PR 리뷰 대상에 포함시켰다. 새로운 컨벤션이 추가되거나 라이브러리가 바뀌면 CLAUDE.md도 같이 업데이트한다. 처음에는 "이걸 왜 관리해야 돼?"라는 반응이었는데, 한 달 지나니까 "이거 없으면 불편해서 못 쓰겠다"로 바뀌었다. 신규 입사자 온보딩할 때도 이 파일이 프로젝트 구조를 이해하는 좋은 출발점이 된다는 부수 효과도 있었다.
Chain of Thought: 복잡한 작업을 단계별로 쪼개기
프롬프트 하나에 모든 걸 넣으려다 보면 결과물이 이상해진다. 특히 리팩토링처럼 여러 파일에 걸쳐 있는 작업은 한번에 시키면 AI가 중간에 맥락을 잃는다.
지난달에 결제 플로우를 리팩토링했을 때의 경험이다. 처음에는 이렇게 요청했다.
결제 페이지 코드를 리팩토링해줘.
현재 하나의 거대한 컴포넌트에 결제 수단 선택, 주문 요약, 쿠폰 적용,
최종 결제 로직이 전부 들어있어. 관심사별로 분리하고,
에러 핸들링도 추가하고, 테스트도 작성해줘.
결과는 엉망이었다. 분리한 컴포넌트 간의 상태 공유가 꼬이고, 에러 핸들링이 절반만 적용되고, 테스트는 기존 코드 기준으로 작성되어 있었다.
그 뒤로는 단계를 나눠서 요청한다.
[1단계] 현재 PaymentPage 컴포넌트의 구조를 분석해줘.
어떤 관심사들이 섞여 있는지, 상태 흐름이 어떻게 되는지 파악해서
리팩토링 계획을 세워줘. 코드는 아직 고치지 마.
AI가 분석 결과와 계획을 내놓으면, 그걸 검토하고 다음으로 넘어간다.
[2단계] 1단계 분석에서 나온 계획 중에서,
결제 수단 선택 부분만 PaymentMethodSelector로 분리해줘.
나머지는 그대로 두고. 기존 동작이 바뀌면 안 돼.
[3단계] PaymentMethodSelector 분리한 상태에서,
쿠폰 적용 로직을 useCoupon hook으로 분리해줘.
이런 식이다. 한 단계가 끝날 때마다 코드가 정상 동작하는지 확인하고, 다음 단계로 넘어간다. 전체를 한번에 하는 것보다 시간이 좀 더 걸리는 것 같지만, 실제로는 더 빠르다. 한번에 하면 결과물을 디버깅하는 시간이 더 길거든.
이 방식의 또 다른 장점은 내가 각 단계에서 AI의 결정을 검토할 수 있다는 거다. "아, 이 부분은 hook으로 빼지 말고 컴포넌트 안에 두는 게 낫겠다"처럼 중간에 방향을 수정할 수 있다. 한번에 전체를 시키면 이런 조율이 불가능하다.
사실 이 방법은 프로그래밍 자체의 원칙이기도 하다. 큰 문제를 작은 문제로 쪼개서 하나씩 해결하는 것. AI를 쓸 때도 같은 원칙이 적용된다는 게 재밌다. 프롬프트를 잘 쓰는 법을 고민하다 보면, 결국 문제를 잘 정의하는 법을 연습하게 된다.
AI를 쓰지 말아야 할 때
여기까지 읽으면 모든 코딩을 AI한테 시키면 될 것 같지만, 쓰면 안 되는 영역이 분명히 있다.
보안 관련 코드. 인증/인가 로직, 토큰 처리, 암호화 구현 같은 건 AI가 생성한 코드를 절대 그대로 쓰면 안 된다. AI는 보안 취약점을 만들어내는 데 재능이 있다. "돌아가긴 하는데 취약한" 코드를 자신 있게 생성한다. 지난해에 한 오픈소스 프로젝트에서 AI가 생성한 JWT 검증 코드가 알고리즘 혼동 공격에 취약한 채로 6개월간 프로덕션에서 돌아간 사례가 있었다.
비즈니스 크리티컬 로직. 정산 금액 계산, 재고 차감, 포인트 적립 같은 돈이 오가는 로직은 내가 한 줄 한 줄 이해하고 있어야 한다. AI한테 시켜서 만든 뒤 "대충 맞겠지"하고 넘기면, 나중에 edge case에서 터졌을 때 원인을 파악할 수가 없다.
이해 없이 복붙. 가장 위험한 패턴이다. AI가 생성한 코드를 이해하지 못한 채로 프로젝트에 넣는 것. 당장은 동작하지만, 그 코드에서 버그가 나면 그때부터 진짜 문제가 시작된다. 내가 이해하지 못하는 코드는 내 코드가 아니다.
팀에서도 비슷한 사고가 한 번 있었다. 후배가 AI로 생성한 폼 validation 코드를 리뷰 없이 머지했는데, 특정 조건에서 validation을 통째로 건너뛰는 로직이 숨어 있었다. 정규표현식 패턴이 잘못된 거였는데, 코드만 보면 그럴듯해 보여서 아무도 못 잡았다. 프로덕션에서 잘못된 데이터가 3일 동안 쌓인 뒤에야 발견했다. 그 뒤로 우리 팀에는 "AI 생성 코드는 내가 직접 짠 코드보다 더 꼼꼼히 리뷰한다"는 규칙이 생겼다.
AI를 "코드를 대신 짜주는 도구"가 아니라 "내 생각을 빠르게 구현하는 도구"로 쓰는 게 맞다. 방향과 판단은 내가 하고, 타이핑과 패턴 반복은 AI가 하는 것.
실전 Before/After 모음
마지막으로, 내가 실제로 사용했던 프롬프트의 before/after를 몇 개 더 공유한다.
케이스 1: 유틸 함수 작성
Before:
날짜 포맷팅 함수 만들어줘
AI 응답: moment.js를 import하는 함수가 나왔다. 우리 프로젝트에 moment가 없다.
After:
date-fns를 사용해서 날짜 포맷팅 유틸 함수를 만들어줘.
요구사항:
- 입력: Date 객체 또는 ISO string
- "오늘", "어제", "3일 전" 같은 상대 시간 표시 (7일 이내)
- 7일 초과면 "2026.03.12" 형식
- 한국어 locale
기존 유틸 스타일:
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
}).format(amount);
}
AI 응답: date-fns의 differenceInDays, format을 사용하고, 기존 유틸과 같은 스타일(named export, 타입 명시, 한 줄 JSDoc)로 작성된 함수가 나왔다.
케이스 2: 테스트 코드 작성
Before:
이 컴포넌트 테스트 코드 작성해줘
[컴포넌트 코드 붙여넣기]
After:
아래 컴포넌트의 테스트를 작성해줘.
[테스트 환경]
Vitest + React Testing Library
[기존 테스트 스타일 예시]
describe('OrderCard', () => {
it('주문 번호와 금액을 표시한다', () => {
render(<OrderCard order={mockOrder} />);
expect(screen.getByText('ORD-2024-001')).toBeInTheDocument();
expect(screen.getByText('₩35,000')).toBeInTheDocument();
});
it('취소된 주문은 취소 배지를 보여준다', () => {
render(<OrderCard order={{ ...mockOrder, status: 'cancelled' }} />);
expect(screen.getByText('취소됨')).toBeInTheDocument();
});
});
[테스트 대상 컴포넌트]
// CouponBadge.tsx
export function CouponBadge({ coupon }: { coupon: Coupon }) {
const isExpired = new Date(coupon.expiresAt) < new Date();
// ... 컴포넌트 코드
}
[테스트해야 할 케이스]
1. 쿠폰명과 할인율 표시
2. 만료된 쿠폰의 시각적 구분
3. 클릭 시 onSelect 콜백 호출
기존 테스트 예시를 넣는 것만으로 describe/it의 작성 스타일, 한글 테스트명, assertion 패턴이 전부 맞춰진다.
케이스 3: 코드 리뷰 요청
이건 좀 다른 용도인데, PR을 올리기 전에 AI한테 먼저 리뷰를 부탁하는 것도 유용하다.
아래 코드를 리뷰해줘.
관점:
1. 성능 이슈 (불필요한 re-render, 메모이제이션 누락)
2. 에러 처리 누락
3. 타입 안정성
현재 구현 의도: 사용자가 필터를 변경할 때마다 debounce된 API 호출을 하고,
결과를 테이블에 표시. 필터 상태는 URL search params와 동기화.
[코드]
// ... 코드 붙여넣기
"리뷰해줘"만 던지면 코딩 컨벤션이나 네이밍 같은 사소한 것부터 시작한다. 관점을 지정하면 내가 실제로 궁금한 부분에 대한 피드백을 받을 수 있다.
프롬프트도 결국 커뮤니케이션이다
이것저것 패턴을 써봤지만, 결국 프롬프트 엔지니어링은 커뮤니케이션 스킬이다. 동료한테 작업을 부탁할 때 "이거 해줘"라고만 하면 결과물이 내 기대와 다를 수밖에 없다. 맥락을 주고, 예시를 보여주고, 원하는 결과를 구체적으로 설명하면 결과가 달라진다. AI도 똑같다.
한 가지 더. 프롬프트를 잘 쓰는 능력은 결국 내가 뭘 원하는지 정확히 아는 것에서 나온다. "그냥 좋은 코드"를 원하면 프롬프트도 모호해진다. "이 컴포넌트가 이런 상황에서 이렇게 동작해야 한다"를 알면 프롬프트도 구체적으로 쓸 수 있다.
AI 도구가 계속 발전하면서 코드 생성 품질은 점점 올라가고 있다. 그런데 도구가 좋아질수록, 그 도구를 제대로 쓰는 사람과 대충 쓰는 사람의 생산성 차이는 더 벌어진다. 프롬프트에 3분 더 투자하는 습관이 하루에 2시간을 아끼는 습관이 되는 건, 내가 직접 경험한 일이다.
