AI에게 더 잘 말하는 대신 구조를 바꾼 10개 버전
여행 계획을 짜주는 앱을 혼자 만들고 있다. AI가 일정을 만들어주고, 사람이 그걸 고치고, 친구들과 같이 다듬는 서비스다. 한 달 동안 열 개의 버전을 찍었는데, 돌아보니 그 열 개를 관통하는 교훈이 하나 있었다.
AI가 이상하게 굴 때, 프롬프트에 지시를 더하면 거의 항상 실패한다. 구조를 바꿔야 통한다.
이 글은 그 명제를 일곱 번 다시 배운 기록이다.
AI가 없는 식당을 지어냈다
처음 만든 기능은 「빈 시간 채우기」였다. 일정에 틈이 있으면 AI가 갈 만한 곳을 넣어준다. 잘 돌아갔다. 문제는 그 식당들이 실제로 없었다는 것이다. 「하카타 모츠나베 전문점」처럼 그럴듯한 이름이 나오는데 검색하면 없었다. 좌표도 그럴듯한 숫자였지만 실제로는 논밭 한가운데였다.
처음엔 사후 검증으로 막으려 했다. AI가 만든 장소를 지도 API에 하나씩 물어보고 못 찾으면 경고를 띄웠다. 동작은 했지만 틀린 데이터는 이미 저장된 뒤였고, 사용자가 항목을 하나씩 열어봐야 경고가 보였다.
그래서 순서를 뒤집었다.
기존: AI가 장소를 만든다 → 지도에서 검증한다 → 없으면 경고
변경: 지도에서 후보를 모은다 → AI는 번호만 고른다AI에게 주는 응답 형식에서 이름 필드를 없앴다. 후보 목록에 1번부터 번호를 매겨 주고 고른 번호만 돌려받는다. 프롬프트에도 「목록에 없는 장소는 절대 만들지 마세요」라고 적어뒀지만, 진짜로 막는 건 그 문장이 아니다. 지어낼 이름 필드 자체가 응답 형식에 없다는 사실이다.
덤으로 API 호출이 오히려 줄었다. 검증 방식은 항목 20~30개를 하나씩 물어봐야 했는데, 접지 방식은 날짜당 한 번 후보를 모으면 끝이고 그마저 일주일간 캐시된다.
가격이 잘못된 행동을 유도하고 있었다
AI 기능이 셋 있었다. 일정 통째로 만들기, 빈 시간 채우기, 장소 추천하기. 이 중 빈 시간 채우기에만 접지를 적용했는데, 요금표를 보다가 이상한 걸 발견했다.
| 기능 | 크레딧 | 만드는 항목 | 항목당 |
|---|---|---|---|
| 통째 생성 (지어냄) | 5 | 20~24개 | 0.2 |
| 빈틈 채우기 (실재 보장) | 3 | 3~5개 | 2.0 |
가짜를 만드는 기능이 열 배 쌌다. 의도한 게 아니었다. 접지를 하면 지도 API 비용이 붙으니 요금에 반영했더니 이렇게 됐다. 결과적으로 사용자를 가짜 쪽으로 유도하는 가격표를 만들어둔 셈이고, 그렇게 만들어진 가짜 좌표가 다시 빈 시간 채우기의 입력이 되고 있었다.
세 기능을 전부 접지했다. 그런데 통째 생성은 「이 시간대에 식당」 같은 단순한 조건으로 후보를 모으니 결과가 형편없었다. 후쿠오카에 갔는데 이탈리안 레스토랑이 줄줄이 나왔다.
여기서 또 뒤집었다. AI에게 「장소」가 아니라 「지도 검색어」를 만들게 했다.
기존: 좌표 + 식당 종류 → 지도 → 후보 → AI가 번호 선택
변경: AI가 "하카타 모츠나베" → 지도 → 후보 → AI가 번호 선택
↑ AI가 만드는 건 검색어지 장소가 아니다실재하는 가게를 돌려주는 건 여전히 지도 API다. 접지는 그대로다. 대신 「후쿠오카 하면 모츠나베, 명란, 라멘」이라는 AI의 지역 지식이 검색어에 실린다. 이탈리안 문제가 여기서 풀렸다.
「숨은 맛집」은 정렬로 만들 수 없다
여행 취향 화면에 「어떤 장소를 좋아하세요?」를 넣었다. 대표적인 곳, 현지인이 가는 곳, 그리고 숨은 곳. 세 번째가 문제였다. 무엇을 해도 유명한 관광지가 계속 나왔다.
프롬프트를 고쳐봤다. 「관광객이 잘 모르는 곳을」 「유명한 곳은 제외」. 소용없었다. 그다음엔 정렬을 만졌다. 후보를 「리뷰 수가 뒷받침하는 평점」 순으로 세우는데, 베이즈 평균을 쓴다.
function bayesianScore(candidate, minReviews) {
const reviews = candidate.reviewCount ?? 0;
const rating = candidate.rating ?? BASELINE_RATING;
return (
(reviews / (reviews + minReviews)) * rating +
(minReviews / (reviews + minReviews)) * BASELINE_RATING
);
}리뷰가 적은 곳의 점수를 기준선 쪽으로 끌어당긴다. 리뷰 3개에 별 5개인 신생 가게가 리뷰 3000개에 별 4.5인 노포를 이기면 안 되니까. 「그럼 minReviews를 낮추면 되지 않나?」 하고 값을 줄여봤지만 여전히 유명한 곳이 1등이었다.
당연한 일이었다. 이 수식은 리뷰 수에 대해 단조증가한다. 평점이 기준선보다 높기만 하면 리뷰가 많을수록 점수가 항상 올라간다. minReviews를 어떻게 조절해도 순서가 뒤집히지 않는다. 「숨은 곳」은 이 정렬로는 만들 수 없는 개념이었다. 튜닝의 문제가 아니라 수식의 성질 문제였다.
리뷰 수 상한을 두거나 별도의 「무명 점수」를 만들 수도 있었다. 대신 질문을 던졌다. 애초에 AI가 자동으로 짜주는 일정에 「숨은 맛집」이 말이 되나? 숨은 맛집은 직접 발품 팔아 찾는 것이고, 버튼 한 번에 나오는 순간 이미 숨은 곳이 아니다. 사람들이 원하는 건 「현지인이 가는 곳」에 가깝다.
선택지를 지웠다. 만들 수 없는 기능을 만들려고 애쓰는 것보다, 그게 왜 불가능한지 이해하고 요구사항을 고치는 게 나았다.
가격은 추측하지 않고 로그에서 꺼냈다
AI 호출 비용이 전부 개인 지출이었다. 서비스를 남에게 열려면 결제를 붙여야 했다. 안 그러면 남의 여행을 내 돈으로 만들어주는 구조가 되니까.
다행히 몇 달 전부터 모든 AI 호출의 실제 비용을 로그에 쌓아두고 있었다. 실측이 두 가지를 알려줬다.
첫째, 비용의 59%가 AI가 아니라 지도 API였다. 「AI 비용」이라고 뭉뚱그려 생각했는데 절반 이상이 장소를 찾는 값이었다. 모르고 가격을 정했으면 크게 틀렸을 것이다. 그리고 지도 결과는 캐시되므로 사용자가 늘수록 1인당 원가가 내려간다. 같은 도시를 여러 사람이 계획하면 캐시를 공유하니까.
둘째, 코드 주석의 「1크레딧 = 30원」이 실측과 어긋났다. 실제 원가는 15.4원, 캐시 덕에 절반이었다.
기능별 마진도 제각각이었다. 「장소 추천」만 유독 얇았다 — AI는 거의 안 쓰고 지도만 쓰는데 요금이 낮았다. 3크레딧에서 4로 올렸다.
이 변경이 테스트 하나의 성격을 바꿨다. 원래는 「크레딧 = 원가」를 등식으로 고정하는 테스트였는데, 이제 마진이 의도적으로 들어가므로 하한을 잠그도록 고쳤다. 「크레딧은 원가보다 작으면 안 된다」. 등식으로 두면 마진을 조정할 때마다 깨지고, 그러면 그 테스트가 무엇을 지키는지 흐려진다.
무료 크레딧을 먼저 쓰게 한 이유
결제를 붙이면서 잔액이 두 종류가 됐다. 매달 초기화되는 무료분과 돈 주고 산 충전분. 소멸하는 쪽부터 쓰는 게 맞다. 반대로 하면 사용자가 손해를 본다.
export function checkCredits(profile, now, cost) {
const needsReset = !profile?.resetAt || new Date(profile.resetAt) <= now;
const used = needsReset ? 0 : (profile?.used ?? 0);
const freeRemaining = Math.max(0, MONTHLY_FREE_CREDITS - used);
const paidRemaining = Math.max(0, profile?.paidCredits ?? 0);
// 소멸하는 무료분을 먼저 쓴다
const fromFree = Math.min(freeRemaining, cost);
const fromPaid = cost - fromFree;
return {
allowed: freeRemaining + paidRemaining >= cost,
freeRemaining,
paidRemaining,
nextUsed: used + fromFree,
nextPaidCredits: paidRemaining - fromPaid,
};
}중요한 건 이 함수가 차감 후의 값까지 돌려준다는 점이다. 「얼마 남았는지」만 알려주고 계산을 호출하는 쪽에 맡기면, 세 개의 AI 기능이 각자 계산하다가 언젠가 어긋난다. 결정까지 내려서 주면 호출하는 쪽은 판단하지 않고 받아쓰기만 한다.
결제 적립은 웹훅에서만 한다
브라우저 복귀를 기준으로 적립하면 결제하자마자 창을 닫은 사용자는 크레딧을 못 받는다. 돈은 나갔는데. 그래서 충전 버튼은 주문만 만들고, 적립은 결제사가 보내는 서버 알림이 처리한다.
그러면 중복이 문제가 된다. 두 종류를 각각 막아야 했다. 재전송은 주문번호 UNIQUE 제약과 「이미 처리됐으면 무시」로, 동시 도착은 행 잠금으로 줄 세워서.
SELECT ... INTO ... FROM payments
WHERE order_id = p_order_id
FOR UPDATE; -- 동시에 도착한 두 알림을 줄 세운다
IF v_status = 'paid' THEN
RETURN FALSE; -- 이미 적립됨. 오류가 아니라 정상이다
END IF;검증하면서 함정을 하나 만났다. 함수를 호출하면서 같은 쿼리 안에서 결과를 확인했더니, 분명 성공했는데 상태가 그대로였다. PostgreSQL에서 서브쿼리는 문장이 시작된 시점의 스냅샷을 읽기 때문에 같은 문장 안에서 방금 바뀐 값을 볼 수 없다. 쿼리를 나눠 확인하니 정상이었다. 고장난 건 함수가 아니라 내 검증 방법이었다.
그리고 금액은 절대 브라우저에서 받지 않는다. 크레딧 개수만 받고 금액은 서버 상품표에서 찾는다. 받았다면 1,000크레딧을 10원에 사는 요청을 막을 방법이 없다.
있는 줄 알았던 것, 없는 줄 알았던 것
「일정에 하루를 추가할 수 없다」는 요청을 받고 화면을 만들려 코드를 열었더니, 날짜를 바꾸는 API는 이미 있었다. 없는 건 화면이 아니라 결과 처리였다 — 날짜를 줄이면 범위 밖으로 밀려난 항목들이 일정에도 아이디어 보드에도 안 나오는 유령이 됐다. 진짜 할 일은 그것이었다.
반대 방향의 착각도 있었다. 「사용자 피드백 8건을 처리하자」고 버전을 잡았는데 실제로 열려 있는 건 4건이었다. 두 건은 다른 버전에서 이미 해결됐는데 표시만 안 돼 있었다.
지금 하고 있는 것
다음은 여러 명이 같이 여행을 짜는 기능이다. 설계하다가 이름 때문에 한 번 갈아엎었다. 일정을 짜는 사람과 아이디어만 올리는 사람을 나누면서 후자를 「기여자」라고 불렀는데, 지적을 받았다. 「기여자는 여행 멤버 아니야? 같이 가는 사람이잖아.」
맞는 말이었다. 「기여자」는 오픈소스에서 패치 보내는 사람에게 쓰는 말이지, 같이 비행기 타고 같은 호텔에 묵는 친구에게 쓸 말이 아니다. 그리고 틀린 건 이름만이 아니었다.
내가 쓴 구도: 멤버에 등급이 있다 (상위 → 하위)
맞는 구도: 전원이 동행자다. 그중 일정을 짜는 사람이 정해진다친구끼리 여행 가면 실제로 이렇다. 한두 명이 총무처럼 일정을 짜고 나머지는 「여기 가보고 싶다」를 던진다. 나머지가 덜 중요한 멤버인 게 아니라 역할이 다른 것이다. 이름이 문서를 끌고 간 게 눈에 보였다. 「기여자」라고 부르니 못 주는 것 목록을 쓰게 됐고, 「동행」으로 바꾸니 이 사람에게 필요한 게 뭔지 묻게 됐다.
권한의 경계는 새로 만들지 않았다. 초기에 일정 데이터를 갈아엎으면서 「날짜가 있으면 확정 일정, 없으면 아이디어」라는 선이 이미 생겨 있었다. 거기에 권한을 얹으면 된다.
CREATE POLICY "items_update" ON items FOR UPDATE
USING (
is_trip_planner(trip_id)
OR (is_trip_member(trip_id) AND date IS NULL AND created_by = auth.uid())
)
WITH CHECK (
is_trip_planner(trip_id)
OR (is_trip_member(trip_id) AND date IS NULL AND created_by = auth.uid())
);USING은 수정하기 전 행을, WITH CHECK는 수정한 결과를 검사한다. 둘 다 「날짜가 없어야 한다」를 요구하면, 동행이 자기 아이디어에 날짜를 채워 확정 일정으로 밀어 올리는 것이 자동으로 막힌다. 권한 검사 코드를 API에 한 줄도 쓰지 않고 가장 위험한 경로가 닫힌다.
그리고 설계하다가 기존 정책에서 구멍을 찾았다. WITH CHECK 없이 USING만 걸린 수정 정책이 있었는데, PostgreSQL은 이 경우 USING 조건을 결과 행에도 적용한다. 그래서 멤버가 「여행 소유자를 나로 바꾸는」 수정을 하면 결과 행에 자기가 여전히 멤버로 남으므로 통과한다. 지금까지는 멤버가 전부 신뢰하는 편집자라 드러나지 않았지만, 역할을 나누는 순간 치명적이 된다.
돌아보며
열 개 버전을 관통한 패턴을 다시 적으면 이렇다. AI가 이상하게 굴면 프롬프트를 고치고 싶어진다. 거의 항상 실패한다.
- 「없는 장소를 만들지 마세요」 → 실패. 이름 필드를 없애니 통했다
- 「유명한 곳 말고」 → 실패. 정렬 수식상 불가능한 요구였다
- 「지역 특산 요리로」 → 부족. 검색어 만드는 규칙을 기준별로 가르니 통했다
그리고 하나 더. 추측하지 말고 측정할 것. 가격은 로그에서 꺼냈고 그래서 비용의 절반 이상이 AI가 아니라는 걸 알았다. 「없는 기능」은 코드를 열어보니 이미 있었다. 「8건」은 세어보니 4건이었다.
내가 머릿속에 그린 시스템과 실제 시스템은 계속 어긋난다. 어긋난 걸 발견하는 가장 싼 방법은 직접 확인해보는 것이었다.