WHERE로 “조건별 포인트 내역”을 정확하게 필터링하기: 필터가 흔들리면 데이터도 신뢰를 잃는다
포인트 시스템에서 조회는 단순히 “보여주기”가 아닙니다. 포인트는 금액에 준하는 성격을 가지기 때문에, 내역 조회가 조금만 애매해져도 운영에서 바로 문제로 이어집니다. 특히 사용자가 가장 자주 쓰는 기능이 기간/유형/상태 같은 필터인데, 이 필터가 틀리면 “내 포인트가 왜 이래?”라는 불신으로 연결됩니다.
이 단원의 목적: “질문을 정확한 조건으로 번역하는 능력” 만들기
WHERE는 SQL의 필터링(조건) 문법이지만, 실제로는 사용자의 질문을 데이터베이스 언어로 번역하는 과정입니다. 이번 글의 목표는 세 가지입니다.
- 포인트 조회 화면에서 자주 나오는 요구사항을 WHERE로 안정적으로 구현한다
- AND/OR 조합에서 발생하는 실수를 “괄호”로 확실히 제어한다
- 운영 데이터가 커져도 느려지지 않게, 조건을 인덱스 친화적으로 작성하는 감각을 익힌다
준비: 실습 데이터(이전 글의 데이터가 있다면 생략 가능)
-- uid 1명 기준 예시 SET @uid := '11111111-1111-1111-1111-111111111111';
-- 최근 30일 기준으로 몇 건 더 섞어두면 필터 연습이 쉬워집니다.
INSERT INTO point_history (uid, point_type, action_type, amount, ref_id, created_at)
VALUES
(@uid, 'FREE', 'CHARGE', 150, 'EVENT_20251201', NOW() - INTERVAL 13 DAY),
(@uid, 'PAID', 'USE', -80, 'USE_7010', NOW() - INTERVAL 12 DAY),
(@uid, 'FREE', 'ADJUST', 30, 'ADMIN_0001', NOW() - INTERVAL 11 DAY);
1) WHERE의 이론: “조건식은 참/거짓을 만든다”
WHERE 뒤에 오는 것은 단순한 문장이 아니라, 각 행(row)에 대해 참(True)인지 거짓(False)인지 평가되는 조건식입니다. 조건식이 참인 행만 결과에 남습니다. SQL은 결국 “행을 남길지 말지”를 결정하는 언어이고, WHERE는 그 결정을 담당합니다.
비교 연산자: =, !=, >, >=, <, <=
SELECT id, action_type, amount, created_at FROM point_history WHERE uid = @uid AND amount < 0 ORDER BY created_at DESC, id DESC; 위 쿼리는 “내역 중 차감(음수)만” 남깁니다. 포인트 시스템에서 “사용 내역만 보고 싶다”는 요구는 사실상 이 조건으로 시작합니다.
2) 가장 많이 쓰는 필터 1: 기간(날짜) 조건
포인트 내역의 대부분은 기간 필터로 조회합니다. 예: “최근 30일”, “이번 달”, “특정 날짜 구간”. 날짜 조건은 성능에도 큰 영향을 주기 때문에, 습관을 제대로 잡는 게 중요합니다.
(1) 최근 N일
SELECT id, action_type, amount, ref_id, created_at FROM point_history WHERE uid = @uid AND created_at >= NOW() - INTERVAL 30 DAY ORDER BY created_at DESC, id DESC; (2) 특정 구간: 시작일~종료일
구간 조건은 보통 “시작일 포함, 종료일 미포함” 패턴이 실수를 줄입니다. 특히 하루 단위 조회에서 종료일을 포함(23:59:59)으로 잡으면 누락/중복이 생기기 쉽습니다.
-- 예: 2025-12-01 00:00:00 이상, 2026-01-01 00:00:00 미만 SELECT id, action_type, amount, created_at FROM point_history WHERE uid = @uid AND created_at >= '2025-12-01 00:00:00' AND created_at < '2026-01-01 00:00:00' ORDER BY created_at DESC, id DESC; (3) BETWEEN은 편하지만, “포함 범위”를 명확히 이해해야 한다
BETWEEN은 양 끝을 모두 포함합니다. 즉 BETWEEN A AND B는 >= A 이면서 <= B입니다. 종료 시각을 정확히 다루지 않으면 의도치 않게 결과가 흔들릴 수 있습니다.
-- created_at이 날짜+시간인 경우, BETWEEN을 쓸 때 종료값을 신중히 잡아야 합니다. SELECT id, action_type, amount, created_at FROM point_history WHERE uid = @uid AND created_at BETWEEN '2025-12-01 00:00:00' AND '2025-12-31 23:59:59' ORDER BY created_at DESC, id DESC; 3) 가장 많이 쓰는 필터 2: 포인트 유형(FREE/PAID)과 행동(CHARGE/USE 등)
포인트 화면에는 대개 “무료 포인트만 보기”, “적립만 보기”, “사용만 보기” 같은 체크박스가 있습니다. 이건 WHERE에서 동등 비교(=)와 집합 조건(IN)으로 처리하는 경우가 많습니다.
(1) 무료 포인트만 보기
SELECT id, point_type, action_type, amount, created_at FROM point_history WHERE uid = @uid AND point_type = 'FREE' ORDER BY created_at DESC, id DESC; (2) “적립 계열”만 보기: IN으로 묶기
적립은 CHARGE만 있는 것이 아니라, 운영 보정(ADJUST), 사용취소(USE_CANCEL)처럼 “결과적으로 +”가 되는 이벤트가 있을 수 있습니다. 따라서 화면에서 “적립 내역”은 보통 여러 action_type을 묶어서 정의합니다.
SELECT id, action_type, amount, created_at FROM point_history WHERE uid = @uid AND action_type IN ('CHARGE', 'USE_CANCEL', 'ADJUST') ORDER BY created_at DESC, id DESC; (3) “차감 계열”만 보기
SELECT id, action_type, amount, created_at FROM point_history WHERE uid = @uid AND action_type IN ('USE', 'REFUND', 'EXPIRE') ORDER BY created_at DESC, id DESC; 여기서 한 가지 운영 팁이 있습니다. “차감 계열 = amount < 0”으로도 필터링이 가능하지만, 정책이 바뀌거나 예외 케이스가 들어오면(예: 특정 이벤트가 0원 기록), action_type 기준이 더 명확한 기준이 되는 경우가 많습니다.
4) AND/OR 이론: 우선순위를 모르면 필터가 틀린다
WHERE에서 가장 많이 하는 실수는 AND/OR 조합입니다. SQL에서 AND가 OR보다 우선순위가 높습니다. 즉, 괄호가 없으면 데이터베이스는 “AND부터 먼저 묶어서” 평가합니다. 그래서 사람이 읽는 의도와 DB가 해석하는 방식이 어긋날 수 있습니다.
실수 예시: “무료 포인트 중 적립 또는 사용 내역”을 원했는데 결과가 섞이는 경우
-- 의도: (FREE) AND (CHARGE 또는 USE) -- 하지만 괄호가 없으면: (FREE AND CHARGE) OR USE 로 해석될 수 있습니다. SELECT id, point_type, action_type, amount FROM point_history WHERE uid = @uid AND point_type = 'FREE' AND action_type = 'CHARGE' OR action_type = 'USE' ORDER BY id DESC; 위 쿼리는 FREE가 아닌 USE까지 섞여 나올 수 있습니다. 올바른 방식은 괄호로 의도를 고정하는 것입니다.
SELECT id, point_type, action_type, amount FROM point_history WHERE uid = @uid AND point_type = 'FREE' AND (action_type = 'CHARGE' OR action_type = 'USE') ORDER BY created_at DESC, id DESC; 실무에서는 OR 조건을 IN으로 바꿔 가독성을 높이는 편이 안전합니다.
SELECT id, point_type, action_type, amount FROM point_history WHERE uid = @uid AND point_type = 'FREE' AND action_type IN ('CHARGE', 'USE') ORDER BY created_at DESC, id DESC; 5) NULL 이론: “값이 없음”은 = NULL로 비교하지 않는다
ref_id는 이벤트에 따라 NULL이 될 수 있습니다. 여기서 초보자가 가장 많이 하는 실수는 ref_id = NULL 같은 비교입니다. SQL에서 NULL은 “값이 없다”라기보다 “알 수 없다/정의되지 않았다”에 가까운 개념이라, 일반 비교 연산자로는 참/거짓을 만들 수 없습니다. 그래서 NULL은 전용 문법을 씁니다.
-- ref_id가 없는 내역만 SELECT id, action_type, amount, ref_id FROM point_history WHERE uid = @uid AND ref_id IS NULL ORDER BY created_at DESC, id DESC; -- ref_id가 있는 내역만 SELECT id, action_type, amount, ref_id FROM point_history WHERE uid = @uid AND ref_id IS NOT NULL ORDER BY created_at DESC, id DESC; 6) LIKE/부분검색: 운영 점검에는 유용하지만 남용하면 느려진다
ref_id가 특정 접두어를 가진다면(예: ORDER_, EVENT_), LIKE로 찾을 수 있습니다. 다만 LIKE는 패턴에 따라 인덱스를 못 타는 경우가 많아, 운영 트래픽이 높은 화면에는 신중하게 적용해야 합니다.
-- 접두어 검색(인덱스에 유리한 편) SELECT id, action_type, amount, ref_id FROM point_history WHERE uid = @uid AND ref_id LIKE 'ORDER_%' ORDER BY created_at DESC, id DESC; -- 앞에 %가 붙는 검색은 대개 인덱스 활용이 어렵습니다(주의) SELECT id, action_type, amount, ref_id FROM point_history WHERE uid = @uid AND ref_id LIKE '%9001%' ORDER BY created_at DESC, id DESC; 7) 성능 감각: “조건을 먼저 좁히는 순서”가 인덱스와 연결된다
포인트 내역 테이블은 시간이 갈수록 커집니다. 그래서 조건을 잡을 때 인덱스를 고려하는 게 좋습니다. 예를 들어 (uid, created_at) 복합 인덱스가 있다면, 아래처럼 uid로 먼저 좁히고 그 다음 기간 조건을 거는 패턴이 흔히 잘 맞습니다. 정확한 실행 계획(EXPLAIN)은 뒤에서 다루겠지만, 지금은 “인덱스가 좋아하는 조건 형태”를 익히는 단계로 충분합니다.
SELECT id, action_type, amount, created_at FROM point_history WHERE uid = @uid AND created_at >= NOW() - INTERVAL 30 DAY ORDER BY created_at DESC, id DESC LIMIT 50; 8) 실전 조합 예제: 화면 필터를 그대로 쿼리로 옮기기
아래는 실제 화면에서 흔한 필터 조합을 그대로 쿼리로 만든 예시입니다. 이런 형태로 글을 쌓아두면, 단순 문법 글이 아니라 “실무에서 바로 써먹는 레퍼런스”가 됩니다.
(1) “최근 30일 + 무료 포인트 + 적립 내역만”
SELECT id, point_type, action_type, amount, ref_id, created_at FROM point_history WHERE uid = @uid AND created_at >= NOW() - INTERVAL 30 DAY AND point_type = 'FREE' AND action_type IN ('CHARGE', 'USE_CANCEL', 'ADJUST') ORDER BY created_at DESC, id DESC; (2) “이번 달 + 차감 내역(사용/만료/환불)만”
SELECT id, action_type, amount, ref_id, created_at FROM point_history WHERE uid = @uid AND created_at >= DATE_FORMAT(NOW(), '%Y-%m-01 00:00:00') AND created_at < DATE_FORMAT(DATE_ADD(NOW(), INTERVAL 1 MONTH), '%Y-%m-01 00:00:00') AND action_type IN ('USE', 'EXPIRE', 'REFUND') ORDER BY created_at DESC, id DESC; (3) “특정 주문(ref_id) 관련 내역만”
SELECT id, action_type, amount, ref_id, created_at FROM point_history WHERE uid = @uid AND ref_id = 'ORDER_9001' ORDER BY created_at ASC, id ASC; 주문 단위 추적은 운영 대응에 특히 유용합니다. 문의가 들어왔을 때 “ORDER_9001로 적립이 있었고, 이후 사용이 있었는지”를 한 번에 확인할 수 있기 때문입니다.
다음 글 예고: 데이터 타입과 제약조건으로 “잘못 들어오는 데이터”를 원천 차단하기
WHERE로 조회가 가능해졌다면, 다음 단계는 “조회 이전에 데이터가 깨지지 않게” 하는 것입니다. 다음 글에서는 포인트 시스템에 맞는 데이터 타입(INT/BIGINT), ENUM의 장단점, NOT NULL/DEFAULT, 그리고 중복 방지(UNIQUE) 같은 제약조건을 정리하면서, 나중에 고치기 어려운 오류를 초기에 막는 설계 습관을 다룹니다.
댓글
댓글 쓰기