개념
화면이 말하는 것과 데이터가 말하는 것
결제 화면에 "주문이 완료되었습니다"가 뜬다. 여기서 테스트를 통과시키면 우리가 확인한 것은 그 문구가 떴다는 사실 하나 다. 그 뒤에서 벌어졌을 수 있는 일은 이런 것들이다.
- 주문 행이 1건이 아니라 2건 들어갔다(더블 클릭·재시도)
- 주문 금액은 맞는데 주문상품 행의 합계가 다르다
- 쿠폰이 사용 처리되지 않아 같은 쿠폰을 다시 쓸 수 있다
- 회원 등급이
VIP인데 할인율은SILVER기준으로 계산됐다
이 중 어느 것도 화면만 봐서는 알 수 없다. 전부 조회 한두 줄이면 알 수 있다. 이 레슨의 목표는 그 한두 줄을 스스로 쓸 수 있게 하는 것이다.
모듈 9(레슨 5)에서 SELECT·WHERE·JOIN 의 모양을 처음 봤다. 여기서는 그 문법을 검증 도구 로 쓴다. 문법 설명이 목적이 아니라 "무엇을 물어볼 것인가"가 목적이다. 참고로 퍼널·코호트·리텐션처럼
제품 분석 을 위한 SQL 은 모듈 45(레슨 8)가 맡는다. 이 모듈은 "내가 방금 한 조작이 데이터에 제대로 반영됐는가" 를 확인하는 조회에 집중한다.
먼저 원칙 하나 — QA 는 읽기만 한다
문법보다 이것이 먼저다. QA 가 운영 데이터베이스에서 실행해도 되는 것은 SELECT 뿐이다.
UPDATE·DELETE·INSERT·TRUNCATE·ALTER 는 테스트 데이터를 만들고 싶다는 선의로도 실행하지 않는다. 이유는 셋이다.
WHERE를 빠뜨리면 전부 바뀐다.UPDATE orders SET status = 'PAID'에서WHERE한 줄을 안 썼을 때 무슨 일이 벌어지는지는 설명이 필요 없다. 이 실수는 숙련도와 무관하게 일어난다- 데이터를 고치면 그 뒤의 모든 테스트 결과가 증거로서 가치를 잃는다. "내가 손으로 넣은 데이터에서 재현됐다"는 개발자에게 반쯤 무의미한 정보다
- 트랜잭션 잠금(lock)이 운영 장애를 만든다. 쓰기 문장은 행이나 테이블에 잠금을 걸고, 커밋하지 않은 채 창을 닫으면 그 잠금이 남는다. 다른 요청들이 그 행을 기다리다 타임아웃되고, 장애의 원인이 "QA 가 열어 둔 세션"인 사고가 실제로 반복된다
그래서 요구할 것은 하나다. 읽기 전용(read-only) 계정. DBA 에게 요청할 때는 이렇게 말하면 된다 — "운영 DB 에 SELECT 권한만 있는 계정과, 가능하면 읽기 전용 복제본(read replica)
접속 정보를 주세요." 복제본을 주면 조회가 운영 트래픽에 주는 영향까지 사라진다.
읽기만 하더라도 조심할 것이 남는다. 큰 테이블을 통째로 조회하지 않는다 — SELECT * FROM orders 는 수천만 행을 끌어와 네트워크와 DB 메모리를 먹는다. 항상 WHERE 로 기간을 자르고
LIMIT 를 건다. 조회 창을 열어 둔 채 자리를 뜨지 않는 것도 같은 이유다.
예제 스키마 — 이 모듈은 여기서 끝까지 간다
레슨 1과 레슨 2가 같은 스키마를 쓴다. 커머스에서 가장 흔한 네 테이블이다.
-- 회원
members(
member_id BIGINT PRIMARY KEY,
email VARCHAR(255),
name VARCHAR(50),
grade VARCHAR(10), -- 'BASIC' | 'SILVER' | 'GOLD' | 'VIP'
joined_at TIMESTAMP,
withdrawn_at TIMESTAMP -- 탈퇴하지 않았으면 NULL
)
-- 주문 (헤더)
orders(
order_id BIGINT PRIMARY KEY,
member_id BIGINT, -- members.member_id 참조
ordered_at TIMESTAMP,
status VARCHAR(20), -- 'PLACED'|'PAID'|'SHIPPED'|'DELIVERED'|'CANCELLED'|'REFUNDED'
total_amount INT, -- 결제 금액 (할인 반영, 배송비 포함)
discount_amount INT, -- 할인 합계
shipping_fee INT,
coupon_id BIGINT -- 쿠폰 미사용이면 NULL
)
-- 주문상품 (라인)
order_items(
order_item_id BIGINT PRIMARY KEY,
order_id BIGINT, -- orders.order_id 참조
product_id BIGINT,
product_name VARCHAR(200),
unit_price INT,
qty INT,
item_amount INT -- 보통 unit_price * qty
)
-- 쿠폰
coupons(
coupon_id BIGINT PRIMARY KEY,
code VARCHAR(30),
discount_type VARCHAR(10), -- 'RATE' | 'AMOUNT'
discount_value INT, -- RATE 면 %, AMOUNT 면 원
min_order_amount INT,
valid_from DATE,
valid_to DATE,
member_id BIGINT, -- 발급 대상 회원
used_at TIMESTAMP -- 미사용이면 NULL
)관계는 이렇다. 회원 한 명이 주문 여러 건을 갖고, 주문 한 건이 주문상품 여러 행을 갖는다. 쿠폰은 회원에게 발급되고 주문 한 건에 사용된다.
members 1 ──< orders 1 ──< order_items
│ │
└──< coupons ──┘1단계 — SELECT 한 줄
가장 작은 조회부터 간다.
SELECT * FROM members;"members 테이블의 모든 열, 모든 행을 보여 달라"는 뜻이다. * 는 "전부"다. 실무에서 * 는 탐색할 때만 쓴다. 어떤 열이 있는지 모를 때 한 번 보고, 그 다음부터는 필요한 열만 적는다.
2단계 — WHERE 로 자른다
SELECT member_id, email, grade
FROM members
WHERE grade = 'VIP';조건을 붙이는 방법은 몇 개만 알면 된다.
| 연산자 | 뜻 | 예 |
|---|---|---|
=, <> | 같다 / 다르다 | status <> 'CANCELLED' |
>, >=, <, <= | 크다·작다 | total_amount >= 50000 |
BETWEEN a AND b | a 이상 b 이하(양 끝 포함) | qty BETWEEN 1 AND 10 |
IN (…) | 목록 중 하나 | status IN ('PAID','SHIPPED') |
LIKE | 문자열 패턴(% 는 아무 글자 0개 이상) | email LIKE '%@test.com' |
IS NULL / IS NOT NULL | 값이 비었는가 | withdrawn_at IS NULL |
AND / OR / NOT | 조건 결합 | grade = 'VIP' AND withdrawn_at IS NULL |
내가 방금 만든 주문을 찾는다면 이렇게 쓴다.
SELECT order_id, member_id, ordered_at, status, total_amount
FROM orders
WHERE member_id = 10023
ORDER BY ordered_at DESC
LIMIT 10;ORDER BY … DESC 는 최신순 정렬이고(ASC 는 오름차순, 생략하면 ASC), LIMIT 10 은 10행만 가져온다. 테스트 중 만든 데이터를 확인하는 조회는 거의 항상 이 모양 이다 — 내 계정으로
좁히고, 최신순으로 정렬하고, 몇 건만 본다.
3단계 — NULL 을 조심한다
여기서 초보자가 가장 많이 틀린다. SQL 에서 NULL 은 "값이 없음"이고, NULL 과의 비교는 참도 거짓도 아닌 "알 수 없음" 이 된다.
-- 탈퇴하지 않은 회원을 찾고 싶다. 이 조회는 0건을 돌려준다
SELECT * FROM members WHERE withdrawn_at = NULL;
-- 이렇게 써야 한다
SELECT * FROM members WHERE withdrawn_at IS NULL;더 위험한 것은 부정 조건이다.
-- 쿠폰을 안 쓴 주문을 찾으려 했지만, coupon_id 가 NULL 인 행은 빠진다
SELECT * FROM orders WHERE coupon_id <> 999;
-- 의도대로 쓰려면 NULL 을 명시적으로 포함시킨다
SELECT * FROM orders WHERE coupon_id IS NULL OR coupon_id <> 999;"결과가 0건인데 이상하다" 싶으면 가장 먼저 의심할 것이 NULL 이다. 정합성 조회에서 이 함정이 어떻게 결함을 숨기는지는 레슨 2에서 다시 다룬다.
4단계 — JOIN 으로 테이블을 잇는다
주문만 봐서는 "누가 주문했는지"를 알 수 없다. orders 에는 member_id 숫자만 있다. 이름을 같이 보려면 두 테이블을 이어야 한다.
SELECT o.order_id,
o.ordered_at,
o.total_amount,
m.name,
m.grade
FROM orders AS o
JOIN members AS m ON o.member_id = m.member_id
WHERE o.ordered_at >= DATE '2026-09-01'
ORDER BY o.ordered_at DESC
LIMIT 20;읽는 순서는 이렇다. FROM orders AS o 로 주문 테이블에 o 라는 별명을 붙이고, JOIN members AS m ON … 으로 "주문의 member_id 와 회원의 member_id 가 같은 행끼리 붙여라"라고
지시한다. 그러면 한 행에 주문 정보와 회원 정보가 같이 나온다.
JOIN 의 종류는 처음엔 두 개만 알면 된다.
| 종류 | 동작 | QA 가 쓰는 곳 |
|---|---|---|
INNER JOIN(그냥 JOIN) | 양쪽에 다 있는 행만 | 정상 케이스 확인 |
LEFT JOIN | 왼쪽은 전부, 오른쪽은 있으면 붙이고 없으면 NULL | 없는 것을 찾을 때 |
두 번째가 QA 에게 결정적이다. "주문은 있는데 주문상품이 하나도 없는 주문"을 찾는 조회를 보자.
SELECT o.order_id, o.ordered_at, o.total_amount
FROM orders AS o
LEFT JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE oi.order_item_id IS NULL;LEFT JOIN 으로 붙였는데 오른쪽이 NULL 이라는 것은 짝이 없다 는 뜻이다. 이 패턴 하나로 고아 레코드·누락을 전부 찾을 수 있다. 레슨 2 전체가 사실상 이 패턴의 응용이다.
5단계 — GROUP BY 와 집계로 "합계가 맞는가"를 묻는다
집계 함수는 다섯 개면 충분하다.
| 함수 | 뜻 | 주의 |
|---|---|---|
COUNT(*) | 행 수 | NULL 도 센다 |
COUNT(col) | 그 열이 NULL 이 아닌 행 수 | COUNT(*) 와 다른 값이 나오면 그 자체가 정보다 |
SUM(col) | 합계 | 대상이 0건이면 0 이 아니라 NULL |
AVG, MIN, MAX | 평균·최소·최대 | NULL 은 제외하고 계산 |
-- 주문 한 건의 주문상품 합계가 주문 헤더 금액과 맞는지 본다
SELECT o.order_id,
o.total_amount,
o.discount_amount,
o.shipping_fee,
SUM(oi.item_amount) AS items_sum,
COUNT(*) AS item_count
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.order_id = 880123
GROUP BY o.order_id, o.total_amount, o.discount_amount, o.shipping_fee;GROUP BY 는 "같은 값끼리 묶어서 한 행으로 만들라"는 뜻이다. 여기서는 주문 하나로 묶었으니 결과가 한 행이고, 그 행에 주문상품 합계가 들어 있다. 이제 눈으로 검산할 수 있다 —
items_sum - discount_amount + shipping_fee 가 total_amount 와 같아야 한다.
묶은 뒤에 거는 조건은 WHERE 가 아니라 HAVING 이다.
-- 주문상품이 20행을 넘는 주문 (대량 주문·중복 적재 의심)
SELECT order_id, COUNT(*) AS item_count
FROM order_items
GROUP BY order_id
HAVING COUNT(*) > 20
ORDER BY item_count DESC;WHERE 는 묶기 전 개별 행을 거르고, HAVING 은 묶은 결과를 거른다. "집계값으로 조건을 걸고 싶다" = HAVING 으로 외워도 된다.
방언 차이 — 이것만 알면 당황하지 않는다
표준 SQL 로 썼지만 제품마다 다른 곳이 있다. 실무에서 부딪히는 지점은 대개 이 정도다.
| 하고 싶은 것 | PostgreSQL | MySQL | Oracle | SQL Server |
|---|---|---|---|---|
| 행 수 제한 | LIMIT 10 | LIMIT 10 | FETCH FIRST 10 ROWS ONLY | SELECT TOP 10 |
| 현재 시각 | NOW() | NOW() | SYSDATE | GETDATE() |
| 문자열 붙이기 | `a | b` | CONCAT(a,b) | |
NULL 대체 | COALESCE(a,0) | IFNULL(a,0) | NVL(a,0) | ISNULL(a,0) |
| 날짜만 자르기 | date_trunc('day', t) | DATE(t) | TRUNC(t) | CAST(t AS DATE) |
COALESCE 는 어디서나 통하므로 이것만 쓰는 습관을 들이면 편하다. 접속하기 전에 "이 DB 가 무엇인지"를 먼저 묻는 것 이 방언 문제의 유일한 예방책이다.
조회하기 전 체크 — 시간대
QA 가 가장 자주 속는 것이 시간대다. 서버가 UTC 로 저장하고 화면이 KST 로 보여 주면 날짜 조건이 하루 어긋나 결과가 비어 보인다. 조회 결과가 이상하면 ordered_at 값을 그대로 출력해
화면과 몇 시간 차이인지 먼저 확인한다. 이 확인은 레슨 4(로그 타임스탬프)에서 다시 쓴다.
실무에서 왜 필요한가
SQL 을 못 쓰는 QA 는 결함을 발견해도 경계까지만 말할 수 있다. "쿠폰 적용 후 금액이 이상합니다." 개발자는 이 말을 받아 자기가 조회하고, 원인을 찾고, 다시 설명해 준다. 왕복 두세 번에 반나절이 간다.
SQL 을 쓰는 QA 는 결함 리포트에 조회 결과를 붙인다. "쿠폰 WELCOME10(정률 10%)을 적용한 주문 880123 에서 discount_amount 가 1,000 으로 들어갔습니다. 주문상품 합계가
52,000 이므로 5,200 이어야 합니다. 같은 쿠폰을 쓴 최근 20건 중 7건이 동일 증상이고, 전부 주문상품이 2행 이상인 주문입니다." 이 리포트는 원인 범위를 거의 좁혀 놓은 상태로 도착한다.
개발자가 재현할 필요조차 없을 때도 있다.
효과는 리포트 품질에서 끝나지 않는다. 통과 판정의 근거가 생기고, 정상 케이스에서도 조회해 보면 "화면은 맞는데 데이터가 틀린" 결함이 나온다. 이런 결함은 몇 달 뒤 정산·통계에서 터지고 그때는 되돌릴 수 없다. 또 결함의 영향 범위를 말할 수 있다 — "이 결함이 발생한 주문이 지난 한 달에 1,240건"은 우선순위 회의에서 결정을 바꾸는 문장이다(모듈 6).
API 응답을 검증하는 일(모듈 12)과 헷갈리지 않아야 한다. API 응답은 시스템이 내놓겠다고 약속한 값 이고, DB 조회는 시스템이 실제로 저장한 값 이다. 응답은 200 OK 인데 저장이 실패한 결함, 응답은 정상인데 두 번 저장된 결함은 DB 를 봐야만 보인다.
채용시장에서의 위치
"SQL 활용 가능"은 신입·주니어 QA 공고 우대사항의 단골이다. 그런데 면접에서 실제로 묻는 것은 문법이 아니라 용도 다. "SQL 어디까지 하세요?"에 "SELECT, JOIN 할 줄 압니다"라고 답하면 대화가 거기서 끝난다. "테스트할 때 주로 주문 헤더와 주문상품 합계가 맞는지, 쿠폰이 사용 처리됐는지를 조회해서 확인했습니다"라고 답하면 다음 질문이 이어지고, 그 대화가 곧 평가다.
면접에서 자주 나오는 화이트보드 문제도 범위가 좁다. JOIN 으로 두 테이블 잇기, GROUP BY 로 건수 세기, LEFT JOIN … IS NULL 로 없는 것 찾기 — 이 셋이면 대부분을 덮는다.