본문으로 건너뛰기
중급약 40분

SQL 로 결과를 직접 확인하기 — SELECT 한 줄에서 JOIN·집계까지

화면이 "주문 완료"라고 말해도 데이터가 그렇다는 보장은 없다. 회원·주문·주문상품·쿠폰 네 테이블로 된 예제 스키마를 놓고 SELECT 한 줄에서 시작해 WHERE·ORDER BY·JOIN·GROUP BY·HAVING 까지 올라간다. NULL 의 함정과 방언 차이를 짚고, QA 가 운영 DB 에서 읽기만 해야 하는 이유를 처음부터 못 박는다.

  • #SQL
  • #데이터검증
  • #JOIN
  • #집계
  • #읽기전용

개념

화면이 말하는 것과 데이터가 말하는 것

결제 화면에 "주문이 완료되었습니다"가 뜬다. 여기서 테스트를 통과시키면 우리가 확인한 것은 그 문구가 떴다는 사실 하나 다. 그 뒤에서 벌어졌을 수 있는 일은 이런 것들이다.

  • 주문 행이 1건이 아니라 2건 들어갔다(더블 클릭·재시도)
  • 주문 금액은 맞는데 주문상품 행의 합계가 다르다
  • 쿠폰이 사용 처리되지 않아 같은 쿠폰을 다시 쓸 수 있다
  • 회원 등급이 VIP 인데 할인율은 SILVER 기준으로 계산됐다

이 중 어느 것도 화면만 봐서는 알 수 없다. 전부 조회 한두 줄이면 알 수 있다. 이 레슨의 목표는 그 한두 줄을 스스로 쓸 수 있게 하는 것이다.

모듈 9(레슨 5)에서 SELECT·WHERE·JOIN 의 모양을 처음 봤다. 여기서는 그 문법을 검증 도구 로 쓴다. 문법 설명이 목적이 아니라 "무엇을 물어볼 것인가"가 목적이다. 참고로 퍼널·코호트·리텐션처럼 제품 분석 을 위한 SQL 은 모듈 45(레슨 8)가 맡는다. 이 모듈은 "내가 방금 한 조작이 데이터에 제대로 반영됐는가" 를 확인하는 조회에 집중한다.

먼저 원칙 하나 — QA 는 읽기만 한다

문법보다 이것이 먼저다. QA 가 운영 데이터베이스에서 실행해도 되는 것은 SELECT 뿐이다.

UPDATE·DELETE·INSERT·TRUNCATE·ALTER 는 테스트 데이터를 만들고 싶다는 선의로도 실행하지 않는다. 이유는 셋이다.

  1. WHERE 를 빠뜨리면 전부 바뀐다. UPDATE orders SET status = 'PAID' 에서 WHERE 한 줄을 안 썼을 때 무슨 일이 벌어지는지는 설명이 필요 없다. 이 실수는 숙련도와 무관하게 일어난다
  2. 데이터를 고치면 그 뒤의 모든 테스트 결과가 증거로서 가치를 잃는다. "내가 손으로 넣은 데이터에서 재현됐다"는 개발자에게 반쯤 무의미한 정보다
  3. 트랜잭션 잠금(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 ba 이상 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 로 썼지만 제품마다 다른 곳이 있다. 실무에서 부딪히는 지점은 대개 이 정도다.

하고 싶은 것PostgreSQLMySQLOracleSQL Server
행 수 제한LIMIT 10LIMIT 10FETCH FIRST 10 ROWS ONLYSELECT TOP 10
현재 시각NOW()NOW()SYSDATEGETDATE()
문자열 붙이기`ab`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 로 없는 것 찾기 — 이 셋이면 대부분을 덮는다.

실습 과제

과제 1 — 조회 환경 만들고 스키마 올리기 (25분)

로컬에 SQLite 나 PostgreSQL 중 편한 것을 설치한다(설치가 부담되면 브라우저에서 도는 SQL 연습 사이트도 된다). 위 예제 스키마 네 테이블을 만들고, 다음 조건을 만족하는 데이터를 손으로 넣는다.

  • 회원 4명 — VIP 1명, GOLD 1명, BASIC 2명. 그중 1명은 탈퇴(withdrawn_at 값 있음)
  • 주문 6건 — 최소 1건은 CANCELLED, 1건은 쿠폰 사용, 1건은 주문상품이 하나도 없는 주문
  • 주문상품 10행 이상 — 그중 1행은 item_amount 를 일부러 unit_price * qty 와 다르게 넣는다
  • 쿠폰 3건 — 1건은 사용됨(used_at 값 있음), 1건은 기간 만료

과제 2 — 여섯 개의 조회 (30분)

과제 1의 데이터에서 다음을 조회한다. 각 조회마다 결과를 보기 전에 예상 행 수를 먼저 적는다. 예상과 다르면 그 차이가 배울 지점이다.

  1. 탈퇴하지 않은 VIP 회원의 이메일
  2. 최근 주문 5건을 회원 이름과 함께(최신순)
  3. 회원별 주문 건수와 주문 금액 합계
  4. 주문상품이 하나도 없는 주문
  5. 주문상품이 3행을 넘는 주문(HAVING 사용)
  6. 쿠폰을 사용한 주문의 주문번호·쿠폰코드·할인금액

과제 3 — 화면과 데이터 대조 (20분)

자신이 테스트하는 제품(없으면 과제 1의 데이터)에서 직접 한 건을 만들고 화면에 보이는 값 5개를 적는다. 그 다음 조회로 같은 5개 값을 꺼내 나란히 놓는다.

항목화면 값조회 값일치
주문번호
결제금액
할인금액
주문상태
주문시각

주문시각에서 시차가 나오면 그 이유(UTC vs KST)를 한 줄로 적는다. 불일치가 하나라도 나왔다면 그것이 이번 주에 등록할 결함일 가능성이 높다.

자가 체크리스트

  • QA 가 운영 DB 에서 쓰기 문장을 실행하면 안 되는 이유를 세 가지로 말할 수 있다
  • 읽기 전용 계정과 읽기 전용 복제본을 DBA 에게 요청하는 문장을 쓸 수 있다
  • WHERE·ORDER BY·LIMIT 를 조합해 내가 방금 만든 데이터를 찾아낼 수 있다
  • IS NULL 과 = NULL 의 차이, 부정 조건에서 NULL 행이 빠지는 이유를 설명할 수 있다
  • INNER JOIN 과 LEFT JOIN 의 차이를 말하고, 없는 것을 찾을 때 어느 쪽을 쓰는지 안다
  • GROUP BY 와 집계 함수로 주문 헤더와 주문상품 합계를 대조할 수 있다
  • WHERE 와 HAVING 을 구분해 쓸 수 있다
  • 접속한 DB 제품이 무엇인지 먼저 확인하고 방언 차이(행 수 제한·NULL 대체)를 처리할 수 있다
  • 조회 결과의 시각이 화면과 다를 때 시간대를 먼저 의심할 수 있다

흔한 실수

  • 테스트 데이터를 만들려고 운영 DB 에 INSERT·UPDATE 를 실행한다. 선의라도 안 된다. 읽기 전용 계정을 받아 두면 애초에 실수할 수 없다.
  • SELECT * 로 대형 테이블을 통째로 조회한다. 기간 조건과 LIMIT 를 습관처럼 붙인다. 운영 DB 에서는 조회 하나가 장애가 될 수 있다.
  • = NULL 로 빈 값을 찾는다. 결과가 0건인데 이상하면 NULL 을 가장 먼저 의심한다. IS NULL 을 쓴다.
  • 부정 조건에서 NULL 행이 빠지는 것을 모른다. coupon_id <> 999 는 쿠폰 미사용 주문을 제외해 버린다. IS NULL OR 을 같이 쓴다.
  • 조회 결과를 결함 리포트에 붙이지 않는다. 스스로 찾아 놓고 "금액이 이상합니다"라고만 쓰면 절반은 버린 것이다. 조회문과 결과를 함께 붙인다.

참고 자료

  • PostgreSQL 공식 문서 — SELECT — 표준에 가장 가까운 문법 원문. JOIN 종류와 GROUP BY·HAVING 의 평가 순서를 확인할 수 있다
  • 레슨 s3-m09-l05 — SQL 기초(이 레슨의 출발점)
  • 레슨 s3-m09-l01 — 코드 읽기 기초(조건문·데이터 흐름 감각)
  • 레슨 s3-m61-l02 — 정합성 조회(이 스키마를 그대로 이어서 사용한다)
  • 레슨 s4-m12-l01 — API 테스트(응답 검증과 DB 검증의 역할 구분)

낯선 말 풀이

이 레슨에 나온 약어입니다. 본문에서도 처음 나올 때 점선으로 표시해 두었습니다 (제목과 링크 글자는 그대로 둡니다).

SQL — 구조화 질의 언어
저장소에 "이런 조건인 줄을 보여 달라"고 묻는 말. 영어 문장에 가까워서 읽기가 비교적 쉽다.
QA — 품질보증
만든 것이 쓸 만한지 확인하고, 애초에 덜 망가지게 만드는 방법을 정하는 일. "테스트"는 그 일의 일부다.
DB — 데이터베이스
데이터를 넣어 두고 꺼내 쓰는 저장소. 보통 표 여러 개가 서로 연결된 형태다.
NULL — 값 없음
"값이 아직 없다" 를 나타내는 특별한 표시. 0 이나 빈 글자와는 다르다.
PostgreSQL — 포스트그레에스큐엘
널리 쓰이는 무료 데이터 저장소 제품 중 하나.
MySQL — 마이에스큐엘
가장 널리 쓰이는 무료 데이터 저장소 제품 중 하나.
UTC — 협정 세계시
세계 공통 기준 시각. 한국 시간보다 9시간 느리다.
KST — 한국 표준시
한국의 기준 시각. 세계 기준시보다 9시간 빠르다.
API — 응용 프로그램 인터페이스
프로그램끼리 주고받는 창구. 사람이 화면 버튼을 누르는 대신, 프로그램이 정해진 주소로 요청을 보내 답을 받는다.

용어집 전체 보기

이 레슨의 실습

실습은 별도 저장소에서 진행합니다. 새 탭으로 열립니다.