“지난달 재구매한 고객만 뽑아 주세요.” 이 한 줄짜리 요청에 답하려고 SQL 창을 열었다가 30분을 보낸 경험이 있을 겁니다. 조인을 어디까지 걸어야 하는지, 중복은 어디서 생기는지 따지다 보면 쿼리는 계속 길어집니다. AI SQL 쿼리 작성은 이 30분을 2분으로 줄여 줍니다. 문제는 그 2분 뒤에 나온 쿼리가 에러 없이 실행되면서 틀린 숫자를 내놓는다는 것입니다. 문법 오류는 데이터베이스가 잡아 주지만, 매출이 3배로 부풀려진 결과는 아무도 잡아 주지 않습니다. 그대로 보고서에 들어갑니다. 이번 글에서는 AI에게 무엇을 줘야 쓸만한 쿼리가 나오는지, AI가 반복적으로 틀리는 지점이 어디인지, 그리고 실행 버튼을 누르기 전에 거쳐야 할 검증 절차를 정리합니다.
스키마를 주지 않으면 전부 지어냅니다
AI가 SQL을 못 쓰는 게 아닙니다. 내 데이터베이스가 어떻게 생겼는지 모르는 것이 문제입니다. 테이블명과 컬럼명을 알려 주지 않으면 AI는 “일반적으로 이런 이름일 것”이라고 추측해서 채웁니다.
# 이렇게 물으면 거의 못 씁니다
지난달 매출 상위 고객 뽑는 SQL 짜줘
# AI가 내놓는 것 — 테이블명도 컬럼명도 전부 지어낸 것입니다
SELECT customer_name, SUM(total_price) AS revenue
FROM sales
GROUP BY customer_name
ORDER BY revenue DESC
LIMIT 10;
이 쿼리는 문법적으로 완벽합니다. 그런데 우리 데이터베이스에 sales 테이블이 없고, 금액 컬럼은 total_price가 아니라 total_amount이고, 고객명은 다른 테이블에 있습니다. 결국 손으로 전부 고치게 되고, 고치는 동안 AI가 놓친 조건까지 같이 놓칩니다. “지난달”이라고만 말했으니 상태가 refunded인 환불 주문이 그대로 매출에 섞였는데, 그건 쿼리를 고치면서도 눈에 들어오지 않습니다.
그래서 첫 단계는 스키마를 뽑아 오는 것입니다. 한 번만 해 두면 계속 재사용할 수 있습니다.
-- MySQL / MariaDB
SHOW CREATE TABLE orders;
SHOW CREATE TABLE order_items;
-- PostgreSQL (psql 안에서)
\d+ orders
-- PostgreSQL (psql 없이 SQL로)
SELECT table_name, column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name IN ('orders', 'order_items', 'customers')
ORDER BY table_name, ordinal_position;
-- SQLite
.schema orders
실제로 쓸만한 결과가 나오는 프롬프트
스키마를 붙이는 것만으로 정확도가 크게 올라가지만, 여기에 집계 단위와 제외 조건을 명시하면 한 번 더 좋아집니다. 사람에게 일을 시킬 때 필요한 정보와 정확히 같습니다.
아래 스키마를 쓰는 PostgreSQL 15 쿼리를 작성해 주세요.
[스키마]
customers(id BIGINT PK, name TEXT, created_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ NULL)
orders(id BIGINT PK, customer_id BIGINT FK->customers.id,
status TEXT, -- 'paid' | 'refunded' | 'pending'
ordered_at TIMESTAMPTZ, total_amount NUMERIC(12,2))
order_items(id BIGINT PK, order_id BIGINT FK->orders.id,
product_id BIGINT, quantity INT, unit_price NUMERIC(12,2))
[구하려는 것]
2026년 8월에 status='paid'인 주문 기준으로 고객별 결제 금액 합계 상위 10명.
deleted_at 이 NULL 이 아닌 고객(탈퇴)은 제외.
금액은 orders.total_amount 를 쓰고 order_items 는 참조하지 않아도 됩니다.
[제약]
- 시간대는 Asia/Seoul 기준으로 월 경계를 잡아 주세요.
- orders 는 약 4천만 행입니다. ordered_at 에 인덱스가 있습니다.
- 표준 SQL 에서 벗어나는 함수를 쓰면 왜 썼는지 한 줄로 설명해 주세요.
- 쿼리 뒤에 "이 쿼리가 틀릴 수 있는 경우"를 3가지 적어 주세요.
마지막 줄이 이 프롬프트의 핵심입니다. “이 쿼리가 틀릴 수 있는 경우를 3가지 적어 주세요”를 붙이면 AI가 스스로 가정을 드러냅니다. “환불 주문이 refunded 상태로만 표시되고 paid 행이 그대로 남아 있다면 중복 집계됩니다” 같은 답이 돌아오는데, 이게 바로 내가 검증해야 할 목록입니다. AI에게 답을 맡기는 대신 확인할 것의 목록을 받아 내는 방식이고, 정규식을 만들 때도 같은 방법이 통합니다. AI로 정규식 만드는 법에서 다룬 접근과 동일한 구조입니다.
스키마가 크면 관련 테이블 3~5개만 골라서 넣으세요. 전체를 통째로 붙이면 오히려 엉뚱한 테이블을 끌어다 씁니다. 그리고 컬럼 주석은 아깝지 않으니 꼭 넣으세요. status에 어떤 값이 들어가는지 한 줄 적어 주는 것만으로 결과가 달라집니다.
AI가 반복적으로 틀리는 다섯 가지
여러 번 겪으면 패턴이 보입니다. 아래 다섯 개는 모델을 바꿔도 비슷하게 나타나고, 공통점은 전부 에러 없이 실행된다는 점입니다.
| 패턴 | 증상 | 확인 방법 |
|---|---|---|
| 조인 팬아웃 | 금액·개수가 몇 배로 부풀려짐 | 조인 전후 COUNT(*) 비교 |
NOT IN + NULL | 결과가 0행 | NOT EXISTS로 바꿔 비교 |
| LEFT JOIN을 WHERE로 무력화 | 없는 쪽 행이 전부 사라짐 | 조건을 ON으로 옮겨 행 수 비교 |
날짜 경계 (BETWEEN) | 마지막 날 하루가 거의 다 빠짐 | 경계일 데이터를 직접 조회 |
| 방언 혼용 | 다른 DB에서는 문법 오류 | SELECT version()으로 먼저 확인 |
이 중 가장 조용하고 가장 비싼 건 첫 번째, 조인 팬아웃입니다. 한 주문에 상품이 여러 개 담기는 구조에서 order_items를 조인한 뒤 주문 금액을 SUM하면 금액이 상품 개수만큼 중복됩니다.
-- AI가 자주 내놓는 형태: 조인 후 바로 SUM
SELECT c.name, SUM(o.total_amount) AS revenue
FROM customers c
JOIN orders o ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id -- 여기서 행이 불어납니다
WHERE o.status = 'paid'
GROUP BY c.name;
-- 주문에 상품이 3개 담겨 있으면 total_amount 가 3번 더해집니다.
-- 금액이 정확히 몇 배로 뻥튀기되는지 규칙이 없어서 눈치채기 어렵습니다.
-- 고쳐 쓴 형태: 집계 단위를 먼저 하나로 줄입니다
SELECT c.name, SUM(o.total_amount) AS revenue
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid'
GROUP BY c.id, c.name;
판별법은 간단합니다. 조인을 추가했을 때 전체 행 수가 늘어나면 그 뒤의 SUM은 전부 의심해야 합니다. 조인 전 COUNT(*)와 조인 후 COUNT(*)를 나란히 비교하면 1분 안에 확인됩니다. 불가피하게 조인이 필요하면 서브쿼리에서 먼저 집계해 1행으로 줄인 뒤 조인하세요.
NULL과 조인 방향 — 에러 없이 틀리는 구간
NULL은 SQL에서 “값이 없음”이 아니라 “알 수 없음”으로 취급됩니다. 그래서 비교 연산에 NULL이 끼면 결과가 참도 거짓도 아닌 상태가 되고, 조건절은 그 행을 조용히 버립니다.
-- 1) NOT IN 에 NULL 이 섞이면 결과가 통째로 빕니다
SELECT * FROM customers
WHERE id NOT IN (SELECT customer_id FROM orders);
-- orders.customer_id 에 NULL 이 한 건이라도 있으면 0행이 나옵니다.
-- 에러도 경고도 없습니다. "주문 없는 고객이 없네요" 하고 넘어가게 됩니다.
-- 안전한 형태
SELECT * FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
-- 2) COUNT(*) 와 COUNT(컬럼) 은 다릅니다
SELECT COUNT(*) AS 전체행,
COUNT(refunded_at) AS 환불된건, -- NULL 은 세지 않습니다
COUNT(DISTINCT customer_id) AS 고객수
FROM orders;
-- 3) LEFT JOIN 인데 WHERE 에 조건을 달면 INNER JOIN 이 됩니다
SELECT c.name, o.id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid'; -- 주문 없는 고객이 전부 사라집니다
-- 의도대로 쓰려면 조건을 ON 으로 옮깁니다
SELECT c.name, o.id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid';
세 번째 케이스는 특히 자주 봅니다. AI에게 “주문 이력이 없는 고객도 함께 보여 줘”라고 요청하면 LEFT JOIN을 써 주는데, 뒤이어 “결제 완료 건만”이라는 조건을 추가로 요청하면 그걸 WHERE에 붙여 버립니다. 그 순간 LEFT JOIN은 사실상 INNER JOIN이 되고, 처음 요청했던 “주문 없는 고객”이 결과에서 전부 빠집니다. 요청을 나눠서 하지 말고 한 번에 다 적는 편이 안전합니다.
날짜는 거의 항상 한 번 틀립니다
날짜 조건은 AI가 가장 자신 있게, 그리고 가장 자주 틀리는 부분입니다. BETWEEN은 양쪽 경계를 모두 포함하지만 날짜 문자열은 그날 00:00:00으로 해석됩니다. 시각이 붙은 컬럼에 쓰면 마지막 날의 대부분이 빠집니다.
-- 8월 한 달을 뽑으려는 의도인데 8월 31일 00:00:01 이후가 전부 빠집니다
SELECT * FROM orders
WHERE ordered_at BETWEEN '2026-08-01' AND '2026-08-31';
-- 시각이 붙은 컬럼은 반열린 구간으로 잡습니다 (>= 시작, < 다음달 1일)
SELECT * FROM orders
WHERE ordered_at >= '2026-08-01'
AND ordered_at < '2026-09-01';
-- 시간대까지 신경 써야 하면 (PostgreSQL, 컬럼이 timestamptz)
SELECT * FROM orders
WHERE ordered_at >= TIMESTAMPTZ '2026-08-01 00:00+09'
AND ordered_at < TIMESTAMPTZ '2026-09-01 00:00+09';
-- 컬럼을 함수로 감싸면 인덱스를 못 씁니다 (풀스캔)
WHERE DATE(ordered_at) = '2026-08-01' -- 느립니다
WHERE ordered_at >= '2026-08-01' AND ordered_at < '2026-08-02' -- 인덱스 사용
규칙 하나만 기억하면 됩니다. 시각이 있는 컬럼은 >= 시작, < 다음 구간 시작으로 씁니다. 월말이 30일인지 31일인지 따질 필요도 없어지고, 윤년도 알아서 처리됩니다. 시간대가 섞인 서비스라면 한 가지가 더 있습니다. 컬럼이 UTC로 저장돼 있는데 “8월”을 한국 시간 기준으로 보고 싶다면, 9시간 차이만큼 결과가 밀립니다. 월 마감 숫자가 해마다 조금씩 안 맞는다면 대개 여기입니다. 한국 표준시는 일광절약시간을 쓰지 않으므로 +09 고정으로 잡아도 됩니다.
그리고 성능 문제 한 가지. DATE(ordered_at) = ...처럼 컬럼을 함수로 감싸면 인덱스를 타지 못해 테이블 전체를 읽습니다. 수천만 행에서는 몇 초가 몇 분이 됩니다. 범위 조건으로 바꾸면 같은 결과를 인덱스로 가져옵니다.
실행 전에 거치는 4단계
AI가 준 쿼리를 바로 실행하지 않습니다. 순서대로 밟으면 오래 걸리지도 않습니다 — 익숙해지면 1~2분입니다.
- 쿼리를 소리 내어 읽습니다. 조인 조건과
WHERE를 한국어로 풀어 말해 보면, 내가 요청한 것과 다른 부분이 그 자리에서 걸립니다. - 행 수를 먼저 셉니다. 본 쿼리 대신
COUNT(*)로 감싸 몇 행이 걸리는지 봅니다. 자릿수가 예상과 다르면 여기서 멈춥니다. - 샘플 20행을 눈으로 봅니다. 합계만 보면 이상한 걸 못 찾습니다. 개별 행을 보면 중복이나 엉뚱한 상태값이 바로 보입니다.
EXPLAIN으로 계획을 봅니다. 큰 테이블에Seq Scan/ALL이 뜨면 실행 전에 인덱스를 확인합니다.
-- 1) 먼저 몇 행이 걸리는지만 확인합니다
SELECT COUNT(*) FROM orders
WHERE status = 'pending' AND ordered_at < '2026-06-01';
-- 예상과 자릿수가 다르면 여기서 멈춥니다.
-- 2) 실제로 어떤 행인지 눈으로 봅니다
SELECT id, customer_id, status, ordered_at FROM orders
WHERE status = 'pending' AND ordered_at < '2026-06-01'
ORDER BY ordered_at
LIMIT 20;
-- 3) 실행 계획을 봅니다 (EXPLAIN 만 붙이면 실행되지 않습니다)
EXPLAIN SELECT ... ;
EXPLAIN은 쿼리를 실행하지 않고 계획만 보여 주므로 안전합니다. 하지만 EXPLAIN ANALYZE는 다릅니다. 실제로 실행한 뒤 걸린 시간을 알려 주는 명령입니다. SELECT라면 상관없지만 UPDATE나 DELETE에 붙이면 데이터가 정말로 바뀝니다. “계획만 보려고” 붙였다가 사고가 나는 대표적인 경로입니다.
-- 주의: EXPLAIN ANALYZE 는 쿼리를 실제로 실행합니다.
-- UPDATE/DELETE 에 붙이면 데이터가 정말 바뀝니다.
BEGIN;
EXPLAIN ANALYZE DELETE FROM orders WHERE ordered_at < '2020-01-01';
ROLLBACK; -- 트랜잭션으로 감싸야 안전합니다
UPDATE와 DELETE는 규칙이 하나 더 있습니다
조회 쿼리가 틀리면 숫자를 다시 뽑으면 됩니다. 그런데 AI가 써 준 UPDATE나 DELETE가 틀리면 되돌릴 수 없습니다. 여기에는 예외 없이 지킬 규칙이 있습니다 — 같은 조건의 SELECT로 먼저 확인하고, 트랜잭션으로 감싸서 실행합니다.
-- PostgreSQL / MySQL(InnoDB) / SQLite 공통 패턴
BEGIN;
UPDATE orders
SET status = 'cancelled'
WHERE status = 'pending'
AND ordered_at < '2026-06-01';
-- 몇 행이 바뀌었는지 확인합니다. 숫자가 예상과 맞나요?
-- 결과 쪽 데이터를 SELECT 로 직접 들여다봐도 됩니다 (아직 커밋 전입니다).
ROLLBACK; -- 확신이 서면 이 줄만 COMMIT 으로 바꿉니다
ROLLBACK을 먼저 적어 두고 시작하는 게 요령입니다. 실행해 보고 영향 행 수와 결과를 확인한 다음, 확신이 설 때 그 한 단어만 COMMIT으로 바꿉니다. 순서를 반대로 하면(먼저 COMMIT을 적어 두면) 습관적으로 전체 실행을 눌렀을 때 그대로 커밋됩니다.
추가 안전장치도 몇 가지 걸어 둘 수 있습니다.
-- MySQL: WHERE 에 키가 없거나 LIMIT 이 없는 UPDATE/DELETE 를 거부합니다
SET sql_safe_updates = 1;
DELETE FROM orders; -- Error 1175 로 막힙니다
-- PostgreSQL: 이 트랜잭션에서는 쓰기를 금지합니다
BEGIN;
SET TRANSACTION READ ONLY;
-- 이 안에서는 어떤 UPDATE/DELETE 도 에러가 납니다
-- 가장 확실한 방법: 조회용 계정을 따로 만들어 그걸로 붙습니다
CREATE USER analyst WITH PASSWORD '...';
GRANT CONNECT ON DATABASE shop TO analyst;
GRANT USAGE ON SCHEMA public TO analyst;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO analyst;
조회 전용 계정을 따로 만들어 두는 게 가장 확실합니다. AI가 아무리 위험한 DELETE를 써 줘도 권한이 없으면 실행되지 않습니다. 데이터 분석용으로 붙을 때는 이 계정을 쓰고, 쓰기가 필요할 때만 계정을 바꾸세요. 접속 정보를 코드에 박아 두지 않는 방법은 환경변수(.env) 파일로 API 키 안전하게 관리하는 법에 정리해 뒀습니다.
방언 차이 — 실행조차 안 되는 경우
AI는 학습 데이터에서 가장 흔한 방언으로 답하는 경향이 있습니다. 어떤 데이터베이스를 쓰는지 말하지 않으면 MySQL과 PostgreSQL 문법이 섞여 나오기도 합니다. 프롬프트 첫 줄에 제품과 버전을 적어 두세요.
| 하려는 일 | MySQL | PostgreSQL | SQL Server | SQLite |
|---|---|---|---|---|
| 행 수 제한 | LIMIT 10 | LIMIT 10 | TOP 10 / FETCH | LIMIT 10 |
| 현재 시각 | NOW() | NOW() | GETDATE() | datetime('now') |
| 문자열 연결 | CONCAT(a,b) | a || b | a + b | a || b |
| NULL 대체 | IFNULL | COALESCE | ISNULL | IFNULL |
| 날짜 포맷 | DATE_FORMAT | TO_CHAR | FORMAT | strftime |
COALESCE는 네 곳 모두에서 동작하는 표준 함수입니다. 특별한 이유가 없으면 IFNULL이나 ISNULL 대신 COALESCE를 쓰세요. AI가 IFNULL을 써 줬는데 나중에 PostgreSQL로 옮기게 되면 그때 전부 고쳐야 합니다.
정수 나눗셈도 제품마다 다릅니다. PostgreSQL·SQL Server·SQLite에서 5/2는 2가 되지만 MySQL에서는 2.5입니다. 전환율이나 비율을 계산하는 쿼리에서 결과가 전부 0으로 나온다면 대개 이 문제입니다. COUNT(*) * 1.0 / total처럼 한쪽을 실수로 만들어 두면 어디서든 같게 동작합니다.
두 번 물어서 맞춰 보는 방법
검증 방법 중 비용 대비 효과가 가장 좋은 건 같은 질문을 다르게 두 번 시켜서 결과를 비교하는 것입니다. 새 대화창을 열고, 앞서 받은 쿼리는 보여 주지 않은 채 같은 요구사항을 다시 줍니다. 구조가 다른 쿼리가 나오는데, 두 결과가 같으면 신뢰도가 꽤 올라갑니다. 다르면 반드시 한쪽이 틀렸으니 어디서 갈렸는지 찾으면 됩니다.
-- AI가 준 쿼리(A)와 내가 다르게 쓴 쿼리(B)의 결과를 맞춰 봅니다
SELECT COUNT(*) FROM (
SELECT customer_id, SUM(total_amount) AS amt FROM orders
WHERE status = 'paid' GROUP BY customer_id
EXCEPT
SELECT customer_id, SUM(total_amount) AS amt FROM orders o
WHERE EXISTS (SELECT 1 FROM orders x WHERE x.id = o.id AND x.status = 'paid')
GROUP BY customer_id
) AS diff;
-- 0 이 나오면 두 쿼리의 결과가 같습니다.
-- MySQL 8.0.31 미만에는 EXCEPT 가 없어서 LEFT JOIN 으로 대신 맞춰 봐야 합니다.
규모가 큰 테이블에서 두 번 돌리기 부담스러우면, 조건을 좁혀 특정 고객 한 명이나 하루치만 뽑아 비교하세요. 손으로 계산할 수 있는 크기까지 줄이면 검산이 확실해집니다. 실제로 틀린 쿼리는 대부분 이 단계에서 걸립니다.
어디까지 맡기고 어디부터 직접 보나
작업 종류에 따라 AI에게 맡겨도 되는 범위가 다릅니다. 기준을 정해 두면 판단이 빨라집니다.
| 작업 | 맡겨도 되는 정도 | 이유 |
|---|---|---|
| 문법·함수 이름 찾기 | 거의 그대로 써도 됨 | 틀리면 에러가 나므로 바로 드러남 |
| 긴 쿼리 포매팅·주석 달기 | 거의 그대로 써도 됨 | 결과값을 바꾸지 않음 |
| 단일 테이블 조회·필터 | 검증 1~2단계만 | 틀려도 눈으로 바로 확인 가능 |
| 여러 테이블 조인 집계 | 4단계 전부 | 팬아웃·NULL이 조용히 숫자를 바꿈 |
UPDATE / DELETE | 트랜잭션 필수 | 되돌릴 수 없음 |
스키마 변경 (ALTER) | 직접 검토 후 스테이징 먼저 | 운영 테이블 잠금·다운타임 위험 |
경험상 제일 위험한 구간은 “그럴듯한 중간 난이도”입니다. 너무 단순하면 검증이 쉽고, 너무 복잡하면 애초에 의심하면서 봅니다. 조인 두세 개에 집계가 하나 들어간 쿼리 — 읽으면 이해되고 결과도 그럴듯한 그 지점이 그냥 통과되기 쉽습니다. 엑셀 수식을 AI로 만들 때도 같은 함정이 있어서 AI로 엑셀 함수·수식 자동 생성하는 법에 비슷한 이야기를 적어 뒀습니다.
AI가 쓴 쿼리의 결과를 보고서나 의사결정에 쓸 거라면, 그 숫자에 대한 책임은 쿼리를 실행한 사람에게 있습니다. “AI가 그렇게 줬다”는 설명은 이미 나간 보고서를 되돌리지 못합니다.
마무리
오늘은 두 가지만 해 보세요. 자주 쓰는 테이블 3~4개의 스키마를 뽑아 메모 앱에 저장해 두는 것, 그리고 다음에 AI에게 쿼리를 부탁할 때 프롬프트 끝에 “이 쿼리가 틀릴 수 있는 경우 3가지”를 붙여 보는 것입니다. 스키마는 한 번 저장하면 계속 복사해 쓰면 되고, 마지막 한 줄은 타이핑 10초면 됩니다. 이 두 개만으로도 손으로 고치는 양이 눈에 띄게 줄어듭니다.
다음 글에서는 지금까지 다룬 자동화·AI 활용 주제들을 실제 업무 흐름에 어떻게 엮는지 이어서 정리하겠습니다.