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

SQL을 배우다 보면 같은 결과를 만드는 방법이 여러 개라는 사실을 자주 만나게 됩니다. 특히 “서브쿼리로도 되는데 JOIN으로도 된다”는 상황이 흔합니다. 초반에는 둘 다 동작하면 그걸로 끝내기 쉽지만, 데이터가 커지고 요구사항이 늘어나면 “읽기 쉬운 쿼리”, “수정하기 쉬운 쿼리”, “오답이 덜 나는 쿼리”의 차이가 크게 드러납니다. 이번 글에서는 포인트 시스템에서 정말 자주 나오는 질문 하나로 비교해봅니다. “최근 30일 동안의 적립 합계(earned_30d)를 사용자별로 구하라.” 이 한 문장에 조건, 집계, 그룹핑, 결측 처리까지 다 들어있어서 연습 주제로 적당합니다. 이 단원의 목적: “정답을 만드는 방법”보다 “선택 기준”을 갖기 서브쿼리와 JOIN의 선택은 취향 문제가 아니라, 유지보수성과 안정성의 문제입니다. 이번 글의 목표는 아래 3가지입니다. 서브쿼리/파생 테이블(derived table)이 언제 읽기 쉬운지 이해한다 JOIN으로 풀었을 때 발생하기 쉬운 중복 집계 위험을 피하는 방법을 익힌다 동일 결과를 여러 방식으로 작성해 보고, 변경에 강한 형태를 고른다 0) 기준 정의: “적립(earned)”은 무엇인가 “적립 합계”라고 했을 때, 어떤 action_type을 적립으로 볼지는 정책입니다. 이 글에서는 다음을 적립 계열로 가정합니다. CHARGE: 일반 적립 USE_CANCEL: 사용 취소로 되돌림(결과적으로 +) ADJUST: 운영 정정(양수/음수 모두 가능하지만, 여기서는 ‘적립’ 합계에 포함 여부를 명확히 해야 함) 여기서 ADJUST는 정책에 따라 “적립으로 분류하지 않고 별도 집계”로 두는 경우도 많습니다. 다만 학습을 위해 “적립 계열로 묶되, 조건으로 쉽게 분리할 수 있다”는 방향으로 진행합니다. 1) 가장 단순한 답: point_history만으로 uid별 earned_30d 구하기 users 테이블을 굳이 붙일 필요가 없다면, point_history만으로도 답은 나옵니다. ...

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

포인트 원장(point_history)만으로도 잔액과 통계를 만들 수 있지만, 실제 화면이나 운영 도구에서는 거의 항상 “사용자 정보”가 함께 필요합니다. 예를 들어 이런 질문들은 point_history만으로는 답이 부족합니다. 이 uid가 누구인지(가입일, 상태 등) 함께 보고 싶다 회원 목록을 보면서 각 회원의 잔액/최근 활동을 한 줄로 요약하고 싶다 최근 7일 동안 사용이 많은 회원을 “회원 정보와 함께” 추려 보고 싶다 이때 필요한 것이 JOIN입니다. JOIN은 테이블을 “합치는” 기능이지만, 정확히는 두 테이블의 행을 연결 해 새로운 결과를 만드는 방식입니다. 이번 글에서는 포인트 시스템에서 가장 흔한 JOIN 패턴(회원 + 원장 요약)을 중심으로, INNER JOIN/LEFT JOIN의 차이와 실전에서 헷갈리는 포인트를 정리합니다. 이 단원의 목적: “회원 목록 + 포인트 요약”을 안정적으로 뽑아내기 JOIN은 문법을 외우는 것보다 “어떤 결과를 만들고 싶은지”를 먼저 그리는 것이 중요합니다. 이번 글의 목표는 아래 3가지입니다. INNER JOIN과 LEFT JOIN의 차이를 이해하고 상황에 맞게 선택한다 집계(GROUP BY)와 JOIN을 결합해 ‘회원별 잔액/통계’를 만든다 JOIN에서 자주 발생하는 오류(행 폭증, 중복 집계)를 피하는 패턴을 익힌다 준비: users에 최소한의 컬럼을 더해 “JOIN 결과가 읽히게” 만들기 이전 글에서는 users가 uid와 created_at만 있었지만, JOIN 결과를 보기 편하게 하려면 display_name 같은 컬럼이 하나 있으면 좋습니다. 실제 서비스라면 이메일/닉네임/상태값이 있을 텐데, 여기서는 학습 목적상 간단히 display_name을 추가합니다. -- 이미 있다면 생략 ALTER TABLE users ADD COLUMN display_name VARCHAR(50) NULL; -- 예시 값 입력(학습용) UPDATE users SET display_n...

GROUP BY/HAVING: “이상 징후”를 집계로 찾아내는 방법

SUM/COUNT로 리포트를 만들 수 있게 되면 다음 단계로 자연스럽게 넘어갑니다. “숫자를 보는 것”에서 끝나지 않고, 이상한 패턴을 자동으로 걸러내는 것 입니다. 포인트 시스템에서는 특히 아래 같은 상황이 자주 문제로 이어집니다. 특정 사용자에게 적립 건수가 갑자기 폭증한다 사용(차감)이 짧은 시간에 몰려 들어온다 만료(EXPIRE)가 특정 기간에 비정상적으로 많다(정책/배치 오류 가능성) ref_id가 없는 이벤트가 예상보다 많다(추적성 저하) 이런 패턴은 개별 행을 보는 SELECT만으로는 잘 안 보입니다. 그룹으로 묶어서 요약 해야 드러납니다. 이번 글은 GROUP BY로 “요약 테이블을 만들고”, HAVING으로 “요약 결과에 조건을 거는” 흐름을 익히는 데 집중합니다. 이 단원의 목적: “원장 → 요약 → 필터”로 운영 질문에 답하기 HAVING은 WHERE와 비슷해 보이지만, 역할이 다릅니다. WHERE는 행을 고르는 조건 , HAVING은 그룹(집계 결과)을 고르는 조건 입니다. 이번 글의 목표는 아래 3가지입니다. GROUP BY로 유저/기간/행위별 요약을 만든다 HAVING으로 “집계값 기준 필터링”을 정확히 적용한다 실제 포인트 운영에서 의미 있는 탐지 예시를 여러 형태로 만든다 1) WHERE vs HAVING: 역할이 다르다 WHERE는 집계 이전에 적용됩니다. 즉, 어떤 행을 집계에 포함시킬지 결정합니다. HAVING은 집계 이후에 적용됩니다. 즉, 집계 결과가 특정 조건을 만족하는 그룹만 남깁니다. 예시: 최근 30일 내역만 대상으로 “행위별 건수”를 본다 SET @uid := '11111111-1111-1111-1111-111111111111'; SELECT action_type, COUNT(*) AS cnt FROM point_history WHERE uid = @uid AND hide = 0 AND created_at >= NOW() - INTERVAL 30 DAY ...

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

포인트 시스템의 “진짜 쓸모”는 원장(point_history)에 기록된 사실을 사람이 이해할 수 있는 형태로 요약하는 데서 나옵니다. 화면에서는 잔액 한 줄로 보이지만, 그 뒤에는 적립/사용/만료/취소가 뒤섞인 수많은 기록이 존재합니다. 그래서 포인트 시스템에서 SQL의 핵심은 단순 조회(SELECT)보다 집계(aggregation) 입니다. 집계는 원장 데이터를 합치고(SUM), 개수를 세고(COUNT), 기준별로 묶어서(GROUP BY) “의미 있는 숫자”로 바꾸는 과정입니다. 이 단원의 목적: 원장 데이터로 ‘요약 결과’를 만드는 습관 만들기 이번 글에서는 아래 3가지를 목표로 합니다. SUM으로 잔액을 만들고, 기간별 합계를 안정적으로 계산한다 COUNT로 이벤트 건수를 집계해 “양”을 파악한다 집계를 할 때 자주 발생하는 실수(NULL, 숨김 데이터, 중복 기준)를 피한다 준비: 집계를 위한 최소 데이터 점검 집계는 데이터가 적으면 티가 안 나지만, 데이터가 쌓이면 작은 실수가 큰 오차로 확대됩니다. 그래서 집계 전에 “필수 조건”을 먼저 확인해 두면 좋습니다. amount가 NULL이 아닌가 숨김 데이터(hide=1)가 섞여 있다면 집계에서 제외할 것인가 부호 정책(적립 + / 차감 -)이 일관적인가 아래 예시는 hide 컬럼이 있다고 가정하고, 집계에서는 hide=0만 포함하는 패턴으로 진행합니다. SET @uid := '11111111-1111-1111-1111-111111111111'; 1) 잔액 계산의 기본: SUM(amount) 가장 기본적인 잔액 조회는 “해당 사용자의 amount를 전부 더하는 것”입니다. 이 방식의 장점은 단순함과 신뢰성입니다. 원장이 올바르면, 잔액은 항상 재현 가능합니다. SELECT uid, SUM(amount) AS balance FROM point_history WHERE uid = @uid AND hide = 0 GROUP BY uid; 여기서...

UPDATE/DELETE 기초: 포인트 원장을 망치지 않는 ‘정정(ADJUST)’과 ‘소프트 삭제’ 전략

포인트 시스템을 실제로 운영하면 “완벽하게 들어오는 데이터”만 존재하지 않습니다. 이벤트 지급이 잘못되거나, 고객 문의로 일부 포인트를 되돌려야 하거나, 테스트 데이터가 섞여 들어오거나, 배치 로직이 한 번 삐끗할 수도 있습니다. 이때 초보자가 가장 쉽게 선택하는 방법이 기존 행을 UPDATE로 고치거나 DELETE로 지워버리는 것 입니다. 하지만 포인트 원장은 “기록” 자체가 신뢰의 기반이기 때문에, 과거 기록을 조용히 바꾸거나 삭제하면 나중에 추적이 불가능해집니다. 오늘은 깔끔해 보이지만, 몇 달 뒤에는 “왜 잔액이 이렇게 되었지?”를 아무도 설명할 수 없는 상태가 될 수 있습니다. 이 단원의 목적: UPDATE/DELETE를 ‘원장 철학’에 맞게 쓰는 기준 세우기 UPDATE/DELETE는 강력하지만, 원장성 데이터를 다룰 때는 사용 기준이 필요합니다. 이번 글의 목표는 아래 3가지입니다. 포인트 시스템에서 UPDATE/DELETE를 써도 되는 경우와 피해야 하는 경우를 구분한다 기록을 고치지 않고도 결과를 바로잡는 정정(ADJUST) 패턴을 만든다 물리 삭제 대신 소프트 삭제(숨김 처리)를 적용해 추적 가능성을 유지한다 1) 원장 데이터는 “수정”보다 “추가”가 기본이다 포인트 원장은 회계의 장부와 비슷합니다. 장부의 기존 줄을 지우거나 고치는 대신, 잘못된 기록이 있었다면 반대되는 기록을 추가 해 결과를 맞춥니다. 이렇게 하면 “무슨 일이 있었는지”가 남기 때문에 나중에 확인이 가능합니다. 이 관점에서 UPDATE/DELETE는 ‘금지’가 아니라 ‘제한적으로만 사용’이 됩니다. 예를 들어 메모를 수정하거나, 테스트 데이터를 숨기는 정도는 가능하지만, amount나 action_type을 바꿔서 결과를 바꾸는 방식은 위험합니다. 2) UPDATE 기본: 특정 행을 ‘조건으로’ 정확히 집어서 수정한다 UPDATE에서 가장 중요한 건 WHERE 입니다. WHERE가 없으면 테이블 전체가 수정됩니다. 그래서 UPDATE는 항...

INSERT 기초: 적립/차감 이벤트를 ‘원장’에 일관되게 기록하는 패턴

포인트 시스템에서 INSERT는 단순한 데이터 입력이 아닙니다. 한 줄의 INSERT는 “어떤 일이 있었는지”를 원장에 남기는 행위이고, 이 기록은 나중에 정산, 고객 문의 대응, 장애 복구의 근거가 됩니다. 그래서 포인트 원장(point_history)에 데이터를 넣을 때는 “일단 들어가면 된다”보다 일관성 있게 들어가야 한다 가 더 중요합니다. 이번 글에서는 INSERT 문법 자체를 익히면서, 포인트 시스템에서 자주 발생하는 실수(부호 뒤집힘, ref_id 누락, created_at 불일치)를 줄이는 입력 패턴을 정리합니다. 이 단원의 목적: INSERT를 ‘기록 규칙’으로 만들기 많은 시스템에서 데이터 품질은 조회가 아니라 입력에서 결정됩니다. 포인트 원장에 기록되는 한 줄 한 줄이 흔들리면, 나중에 SUM으로 잔액을 계산할 때도, 특정 주문을 추적할 때도 “해석 비용”이 계속 발생합니다. 이번 글의 목표는 다음 3가지입니다. 포인트 원장에 반드시 들어가야 하는 최소 컬럼을 고정한다 적립/차감/만료/취소 같은 이벤트를 INSERT 템플릿으로 통일한다 중복/재시도 상황에서 안전하게 기록되는 방향(멱등성)을 염두에 둔다 1) 먼저, 포인트 원장에 “반드시” 들어가야 할 것 시스템마다 컬럼 구성은 다르지만, 원장이라는 성격을 유지하려면 아래 항목은 사실상 필수입니다. uid : 누구에게 발생한 일인지 action_type : 어떤 행위인지(적립/사용/만료/취소 등) amount : 얼마가 변했는지(부호 정책 포함) created_at : 언제 발생했는지 ref_id : 무엇(주문/이벤트/요청)에 의해 발생했는지(가능하면) 여기서 가장 많이 흔들리는 부분이 amount의 부호 정책 입니다. “적립은 +, 차감은 -”처럼 한 번 정했으면 끝까지 고정하는 편이, 조회 쿼리(특히 SUM)와 운영 점검이 쉬워집니다. 2) INSERT 기본 문법: 한 건 기록 가장 기본적인 형태는 아래처럼 한 줄을 넣는 것입니다. 연습할 때...

인덱스 기초: 포인트 내역 조회 속도를 결정하는 (uid, created_at) 인덱스의 의미

포인트 원장(point_history)은 시간이 지날수록 계속 쌓입니다. 초기에는 수천 건이라 체감이 없지만, 몇 달만 지나도 수십만 건, 몇 년이면 수천만 건이 될 수 있습니다. 이때 가장 먼저 느려지는 화면이 보통 “내 포인트 내역 보기”입니다. 조회가 느려지면 사용자는 새로고침을 반복하고, 그 과정에서 요청이 중복되거나(재시도), 운영팀은 “왜 이렇게 느려졌지?”라는 원인 분석에 시간을 쓰게 됩니다. 그래서 포인트 시스템에서는 처음부터 조회 패턴에 맞는 인덱스 를 설계해 두는 편이 안전합니다. 이 단원의 목적: 인덱스를 “추상 개념”이 아니라 “조회 패턴의 설계”로 이해하기 인덱스는 단순히 “빠르게 만드는 옵션”이 아닙니다. 어떤 인덱스를 만들지는 결국 어떤 질문을 자주 던질 것인가 에 대한 답입니다. 이 글에서는 포인트 내역의 대표 조회 패턴을 기준으로, 인덱스가 왜 필요한지와 어떤 조합이 현실적으로 효과적인지를 정리합니다. 1) 인덱스는 “찾기 위한 목차”다 테이블을 책에 비유하면, 전체 데이터를 훑는 것은 책을 처음부터 끝까지 읽는 것과 비슷합니다. 반면 인덱스는 “목차”나 “색인”처럼 원하는 페이지로 바로 이동하게 해줍니다. 데이터베이스가 인덱스를 사용하면, 조건에 맞는 행을 찾기 위해 테이블 전체를 읽는 대신(풀 스캔), 인덱스에서 필요한 범위만 탐색한 뒤 해당 행을 접근합니다. 특히 데이터가 커질수록 이 차이는 압도적으로 커집니다. 2) 포인트 내역의 대표 조회 패턴 3가지 인덱스는 “자주 쓰는 조회”를 기준으로 잡는 것이 기본입니다. 포인트 내역(point_history)에서 거의 고정적으로 반복되는 패턴은 아래 3가지입니다. 사용자 기준 : 특정 uid의 내역을 본다 기간 기준 : 최근 30일, 특정 달 등 created_at 구간으로 자른다 정렬 기준 : 최신순(created_at DESC, id DESC)으로 보여준다 그래서 인덱스 설계는 자연스럽게 (uid, created_at) 로 모입니다. 3...