서브쿼리 vs JOIN: “최근 30일 적립 합계”를 가장 깔끔하게 구하는 기준

SQL을 배우다 보면 같은 결과를 만드는 방법이 여러 개라는 사실을 자주 만나게 됩니다. 특히 “서브쿼리로도 되는데 JOIN으로도 된다”는 상황이 흔합니다. 초반에는 둘 다 동작하면 그걸로 끝내기 쉽지만, 데이터가 커지고 요구사항이 늘어나면 “읽기 쉬운 쿼리”, “수정하기 쉬운 쿼리”, “오답이 덜 나는 쿼리”의 차이가 크게 드러납니다.

이번 글에서는 포인트 시스템에서 정말 자주 나오는 질문 하나로 비교해봅니다. “최근 30일 동안의 적립 합계(earned_30d)를 사용자별로 구하라.” 이 한 문장에 조건, 집계, 그룹핑, 결측 처리까지 다 들어있어서 연습 주제로 적당합니다.

이 단원의 목적: “정답을 만드는 방법”보다 “선택 기준”을 갖기

서브쿼리와 JOIN의 선택은 취향 문제가 아니라, 유지보수성과 안정성의 문제입니다. 이번 글의 목표는 아래 3가지입니다.

  1. 서브쿼리/파생 테이블(derived table)이 언제 읽기 쉬운지 이해한다
  2. JOIN으로 풀었을 때 발생하기 쉬운 중복 집계 위험을 피하는 방법을 익힌다
  3. 동일 결과를 여러 방식으로 작성해 보고, 변경에 강한 형태를 고른다

0) 기준 정의: “적립(earned)”은 무엇인가

“적립 합계”라고 했을 때, 어떤 action_type을 적립으로 볼지는 정책입니다. 이 글에서는 다음을 적립 계열로 가정합니다.

  • CHARGE: 일반 적립
  • USE_CANCEL: 사용 취소로 되돌림(결과적으로 +)
  • ADJUST: 운영 정정(양수/음수 모두 가능하지만, 여기서는 ‘적립’ 합계에 포함 여부를 명확히 해야 함)

여기서 ADJUST는 정책에 따라 “적립으로 분류하지 않고 별도 집계”로 두는 경우도 많습니다. 다만 학습을 위해 “적립 계열로 묶되, 조건으로 쉽게 분리할 수 있다”는 방향으로 진행합니다.

1) 가장 단순한 답: point_history만으로 uid별 earned_30d 구하기

users 테이블을 굳이 붙일 필요가 없다면, point_history만으로도 답은 나옵니다. 이 쿼리는 “기록이 있는 사용자만” 결과에 나타납니다.

SELECT uid, SUM(amount) AS earned_30d FROM point_history WHERE hide = 0 AND created_at >= NOW() - INTERVAL 30 DAY AND action_type IN ('CHARGE', 'USE_CANCEL', 'ADJUST') GROUP BY uid ORDER BY earned_30d DESC;

이 방식은 간단하고 빠르지만, 결과에 “포인트 내역이 없는 사용자”는 등장하지 않습니다. 회원 목록 화면처럼 “users를 기준으로 전부 보여줘야 하는 요구”가 생기면 users를 붙여야 합니다.

2) 방식 A: 파생 테이블(서브쿼리)로 먼저 요약하고 users에 붙이기

이 방식은 많은 실무에서 선호됩니다. 이유는 간단합니다. 요약 결과를 uid당 1행으로 만든 뒤 붙이기 때문에, JOIN으로 인한 중복 집계 위험이 거의 없습니다.

SELECT u.uid, u.display_name, COALESCE(s.earned_30d, 0) AS earned_30d FROM users u LEFT JOIN ( SELECT uid, SUM(amount) AS earned_30d FROM point_history WHERE hide = 0 AND created_at >= NOW() - INTERVAL 30 DAY AND action_type IN ('CHARGE', 'USE_CANCEL', 'ADJUST') GROUP BY uid ) s ON s.uid = u.uid ORDER BY earned_30d DESC;

이 방식이 읽기 쉬운 이유

  • 서브쿼리 안쪽은 “earned_30d를 만드는 규칙”만 집중해서 읽을 수 있다
  • 바깥쪽은 “회원 목록에 붙여서 보여준다”는 목적만 남는다
  • 요약이 uid당 1행이므로, users와 붙여도 행 폭증이 생기지 않는다

3) 방식 B: JOIN 후에 GROUP BY로 한 번에 해결하기

같은 결과를 JOIN으로도 만들 수 있습니다. 다만 조건을 어디에 두느냐에 따라 결과가 달라질 수 있어 주의가 필요합니다.

SELECT u.uid, u.display_name, COALESCE(SUM(p.amount), 0) AS earned_30d FROM users u LEFT JOIN point_history p ON p.uid = u.uid AND p.hide = 0 AND p.created_at >= NOW() - INTERVAL 30 DAY AND p.action_type IN ('CHARGE', 'USE_CANCEL', 'ADJUST') GROUP BY u.uid, u.display_name ORDER BY earned_30d DESC;

이 쿼리도 “최근 30일 적립 합계”를 계산합니다. 중요한 포인트는 조건을 WHERE가 아니라 ON 절에 둔 것입니다. LEFT JOIN을 유지하면서 “포인트 내역이 없는 사용자도 0으로” 나오게 하려면, p에 대한 필터는 ON에 두는 편이 안전합니다.

4) 서브쿼리 vs JOIN, 선택 기준 5가지

(1) 결과가 ‘uid당 1행’으로 보장되어야 하는가

보장되어야 한다면 “먼저 집계 → 붙이기(서브쿼리)” 방식이 안정적입니다. JOIN으로 한 번에 처리할 수도 있지만, 테이블이 늘어날수록 중복 집계 위험이 커집니다.

(2) 조건이 복잡해질 가능성이 큰가

예를 들어 “최근 30일 적립”에 더해 “최근 30일 사용”도 함께 보여달라는 요구가 나오면 집계가 여러 개로 늘어납니다. 이때는 집계를 각각 분리한 뒤(users에 여러 번 LEFT JOIN) 붙이는 형태가 읽기 쉬운 경우가 많습니다.

(3) ‘회원이 기준’인가, ‘포인트 내역이 기준’인가

회원 목록 화면은 users 기준이므로 LEFT JOIN이 자연스럽습니다. 반면 운영 리포트는 point_history 기준으로도 충분할 수 있습니다. 기준 테이블을 잘못 잡으면 결과에서 누락되는 행이 생깁니다.

(4) 성능 튜닝 포인트가 분명한가

서브쿼리 방식은 “요약을 만드는 쿼리”와 “붙이는 쿼리”가 분리되어 어떤 부분이 느린지 파악이 쉬운 편입니다. JOIN 한 방 쿼리는 간결해 보이지만, 조건이 늘어나면 실행 계획이 복잡해질 수 있습니다.

(5) 오답 위험(중복 집계)을 얼마나 줄이고 싶은가

포인트처럼 숫자에 민감한 도메인은 오답 비용이 큽니다. 그래서 “집계를 먼저 만들고 붙이는 패턴”이 안전한 선택이 되는 경우가 많습니다.

5) 확장 예제: earned_30d + used_30d를 한 화면에 같이 보여주기

요구가 늘어나는 상황을 가정해봅니다. “최근 30일 적립 합계”와 “최근 30일 사용 합계”를 같이 보고 싶다면, 서브쿼리(요약) 방식이 구조적으로 깔끔해집니다.

SELECT u.uid, u.display_name, COALESCE(e.earned_30d, 0) AS earned_30d, COALESCE(x.used_30d, 0) AS used_30d FROM users u LEFT JOIN ( SELECT uid, SUM(amount) AS earned_30d FROM point_history WHERE hide = 0 AND created_at >= NOW() - INTERVAL 30 DAY AND action_type IN ('CHARGE','USE_CANCEL','ADJUST') GROUP BY uid ) e ON e.uid = u.uid LEFT JOIN ( SELECT uid, -SUM(amount) AS used_30d FROM point_history WHERE hide = 0 AND created_at >= NOW() - INTERVAL 30 DAY AND action_type IN ('USE','REFUND','EXPIRE') GROUP BY uid ) x ON x.uid = u.uid ORDER BY earned_30d DESC;

“요약을 두 번 만든다”는 점이 번거로워 보일 수 있지만, 각 요약의 규칙이 분리되어 있어 읽기/수정/검증이 쉬워집니다.

6) 마지막 점검: 결과가 맞는지 빠르게 검증하는 방법

집계 쿼리는 항상 검증 루틴이 있어야 합니다. 특정 uid 하나를 골라, 직접 원장 내역을 뽑아보고 합계가 맞는지 비교하는 방식이 가장 확실합니다.

-- (1) 특정 사용자 원장 내역 확인 SELECT action_type, amount, created_at FROM point_history WHERE uid = @uid AND hide = 0 AND created_at >= NOW() - INTERVAL 30 DAY ORDER BY created_at ASC, id ASC; -- (2) 같은 조건으로 earned_30d만 따로 합계 SELECT SUM(amount) AS earned_30d FROM point_history WHERE uid = @uid AND hide = 0 AND created_at >= NOW() - INTERVAL 30 DAY AND action_type IN ('CHARGE','USE_CANCEL','ADJUST');

이 검증을 몇 번만 반복해도, 서브쿼리/조인 방식이 “어떤 조건에서 오답이 나기 쉬운지” 감각이 빠르게 잡힙니다.

다음 글 예고: 트랜잭션 기초 — 적립/사용 중 ‘중간 실패’가 있어도 데이터가 안 깨지게 하기

지금까지는 “정상적으로 기록이 들어간다”는 전제에서 쿼리를 만들었습니다. 하지만 실제 시스템에서는 중간에 실패가 발생합니다. 예를 들어 결제는 성공했는데 포인트 적립 INSERT가 실패하거나, 사용 차감은 되었는데 주문 상태 업데이트가 실패하는 상황이 생길 수 있습니다. 다음 글에서는 이런 문제를 트랜잭션으로 어떻게 막는지, 그리고 포인트 시스템에서 트랜잭션이 왜 필수에 가까운지 정리합니다.

댓글

이 블로그의 인기 게시물

JOIN 기초: users와 point_history를 합쳐 ‘회원별 요약(잔액/최근 활동)’ 만들기

집계 기초: SUM/COUNT로 잔액·기간별 적립/사용 리포트 만들기

키 설계 기초: PRIMARY KEY/UNIQUE로 ‘중복 적립’과 ‘중복 차감’을 구조적으로 막는 방법