EXPLAIN 기초: 점검/리포트 쿼리가 느려질 때 “왜 느린지” 확인하는 방법

포인트 원장(point_history)은 시간이 지날수록 커집니다. 데이터가 커지면, 기능 쿼리(내역/잔액)뿐 아니라 점검 쿼리(원장 vs balance 비교, 월별 집계, ref_id 그룹핑)도 점점 느려집니다. 이때 흔히 “인덱스를 더 만들면 되지 않을까?”라고 생각하지만, 인덱스는 무조건 늘린다고 좋아지지 않습니다. 어떤 쿼리가 어떤 방식으로 실행되는지 모르면 잘못된 인덱스를 만들거나, 효과 없는 튜닝을 반복할 가능성이 큽니다. 그래서 필요한 도구가 EXPLAIN 입니다. EXPLAIN은 “이 쿼리를 DB가 어떤 순서로 실행할지”를 보여주는 실행 계획입니다. 즉, 느린 이유를 추측하지 않고 눈으로 확인 할 수 있게 해줍니다. 이 단원의 목적: 실행 계획을 보고 “어디를 고칠지” 결정하기 이번 글의 목표는 아래 3가지입니다. EXPLAIN 결과에서 최소한의 핵심 항목을 읽을 수 있게 된다 포인트 시스템 쿼리에서 자주 나오는 느린 패턴(풀 스캔, filesort)을 구분한다 인덱스를 “조회 패턴에 맞게” 설계하는 감각을 만든다 1) EXPLAIN 사용법: SELECT 앞에 붙이면 된다 가장 기본은 SELECT 앞에 EXPLAIN을 붙이는 것입니다. EXPLAIN SELECT id, action_type, amount, created_at FROM point_history WHERE uid = '11111111-1111-1111-1111-111111111111' AND hide = 0 ORDER BY created_at DESC, id DESC LIMIT 50; 실행 결과는 DBMS/버전에 따라 조금씩 다르지만, 공통적으로 “어떤 테이블을 어떤 방식으로 읽는지”를 보여줍니다. 2) EXPLAIN에서 최소로 봐야 할 6가지 컬럼 EXPLAIN은 항목이 많습니다. 초반에는 아래 6개만 봐도 충분합니다. table : 어떤 테이블을 읽는지 type : 접근 방식(대략 “얼마나 똑똑하게 찾는지”) possi...

점검 SQL: “원장 합계(SUM) vs balance” 불일치를 찾아내고 원인을 좁히는 방법

포인트 시스템은 “정상 처리”만 잘 만들면 끝나는 것처럼 보이지만, 실제로는 시간이 지날수록 다양한 이유로 데이터가 어긋날 수 있습니다. 예를 들어 이런 상황이 생깁니다. 중간 장애로 트랜잭션이 일부만 반영된 것처럼 보이는 케이스(재처리/수동 개입 포함) 운영 정정(ADJUST)이나 소프트 삭제(hide) 처리 기준이 흔들린 케이스 배치 작업(만료/정산)이 예상과 다른 시간에 들어간 케이스 코드 변경 이후 특정 action_type이 누락/중복 집계된 케이스 그래서 “점검 쿼리”는 기능 쿼리만큼 중요합니다. 특히 balance 테이블(잔액 캐시)을 함께 쓰는 구조라면, 주기적으로 원장 합계와 balance가 같은지 확인해야 합니다. 이 단원의 목적: 불일치를 “발견”하고 “원인”을 좁힐 수 있는 쿼리 세트 만들기 이번 글에서는 아래 3가지를 목표로 합니다. 원장 합계(SUM)와 balance를 비교해 불일치 UID를 뽑는다 불일치가 발견되면 어디서부터 어긋났는지 범위를 좁힌다 자주 발생하는 유형(숨김 처리, 중복 요청, 배치 만료)을 점검 항목으로 만든다 1) 1차 점검: uid별 “원장 잔액”과 “balance 잔액” 비교 가장 기본은 uid별로 두 값을 나란히 놓고 비교하는 것입니다. 여기서는 hide=0만 원장 합계에 포함한다고 가정합니다. SELECT b.uid, b.balance AS balance_cache, COALESCE(l.ledger_balance, 0) AS balance_ledger, b.balance - COALESCE(l.ledger_balance, 0) AS diff FROM point_balance b LEFT JOIN ( SELECT uid, SUM(amount) AS ledger_balance FROM point_history WHERE hide = 0 GROUP BY uid ) l ON l.uid = b.uid WHERE b.balance <> COALESCE(l.ledger_...

저장 프로시저 기초: “포인트 사용”을 DB에서 하나의 호출로 처리하기

지금까지는 애플리케이션이 여러 SQL을 순서대로 실행한다는 관점으로 진행했습니다. 그런데 포인트 사용처럼 “항상 같은 흐름”을 따르는 작업은, DB에 로직을 저장해 하나의 호출로 실행 하는 방식도 많이 사용합니다. 이때 등장하는 것이 저장 프로시저(Stored Procedure) 입니다. 저장 프로시저는 “자주 쓰는 SQL 묶음”을 DB 안에 저장해두고, 필요할 때 CALL 로 호출하는 형태입니다. 장점은 흐름이 한 곳에 고정된다는 점이고, 단점은 로직이 DB에 묶이면서 운영/배포/테스트 전략이 필요해진다는 점입니다. 이 단원의 목적: 핵심 작업(포인트 사용)을 “단일 진입점”으로 만들기 포인트 사용은 멱등성/동시성/원장 기록/상태 관리가 같이 얽히는 대표 작업입니다. 프로시저로 만들면 다음 같은 효과를 기대할 수 있습니다. 처리 흐름(선점 → 차감 → 기록 → 완료)이 한 곳에 모인다 누가 호출하든(웹/앱/배치) 같은 규칙으로 처리된다 실수로 순서를 바꾸거나 일부 단계를 누락할 위험이 줄어든다 이번 글에서는 “요청 ID 선점 테이블(point_requests)”과 “잔액 테이블(point_balance)”을 사용하는 흐름을 기반으로, 포인트 사용 프로시저를 구성하는 예시를 보여줍니다. 1) 준비: 필요한 테이블/제약조건 요약 프로시저 예시는 아래 전제를 둡니다. point_requests : (uid, request_id) UNIQUE로 중복 요청 차단 point_balance : uid PK, balance 컬럼에 현재 잔액 저장 point_history : 원장 기록, hide=0 기본 그리고 포인트 사용은 “조건부 UPDATE(잔액 충분할 때만 차감)” 패턴을 사용합니다. 2) 프로시저 설계: 입력값과 출력값을 먼저 정한다 프로시저는 “인자”로 입력을 받고, 필요하면 결과를 출력합니다. 포인트 사용 프로시저라면 최소 입력은 다음이 필요합니다. p_uid: 사용자 p_request_id: 멱등 키(중복 ...

VIEW 기초: 복잡한 리포트 쿼리를 “가상 테이블”로 고정해 재사용하기

포인트 시스템을 만들다 보면 쿼리가 점점 길어집니다. 회원별 잔액, 최근 30일 적립/사용, 최근 활동 시각, 이상 징후용 집계까지 한 번에 뽑으려면 JOIN과 서브쿼리가 늘어나고, 같은 로직이 여러 곳에서 반복되기 시작합니다. 이때 도움이 되는 도구가 VIEW(뷰) 입니다. VIEW는 데이터를 저장하는 테이블이 아니라, 특정 SELECT 쿼리에 이름을 붙여 “가상 테이블처럼” 쓰는 기능 입니다. 즉, “자주 쓰는 쿼리 조합을 한 번 정의해두고, 이후에는 SELECT로 간단히 가져오는 방식”입니다. 이 단원의 목적: “반복되는 리포트 쿼리”를 한 곳에서 관리하기 VIEW를 도입하는 이유는 속도를 올리기 위해서가 아니라, 복잡도를 낮추기 위해서 인 경우가 많습니다. 이번 글에서는 아래 3가지를 목표로 합니다. VIEW가 무엇이고, 테이블과 무엇이 다른지 이해한다 포인트 시스템에서 자주 쓰는 “회원별 요약” VIEW를 만든다 VIEW를 쓸 때의 주의점(성능, 권한, 업데이트 가능 여부)을 정리한다 1) VIEW는 “저장된 SELECT”다 VIEW는 실행 결과를 저장하는 게 아니라, 정의된 SELECT를 매번 실행해서 결과를 보여줍니다(일반적인 VIEW 기준). 그래서 VIEW는 아래처럼 생각하면 이해가 쉽습니다. 테이블: 데이터가 실제로 저장됨 뷰: SQL 정의가 저장됨(결과는 요청할 때 계산) 즉, VIEW를 만든다고 해서 데이터가 복제되거나 늘어나지 않습니다. 대신 “이 SELECT를 앞으로는 view_name이라는 이름으로 부르겠다”는 약속을 만들어주는 것입니다. 2) 첫 번째 VIEW: 사용자별 현재 잔액(원장 기반) 가장 기본은 잔액 뷰입니다. point_history 원장을 uid별로 SUM해서 “현재 잔액”처럼 보이게 만들 수 있습니다. hide=1(숨김)은 제외한다고 가정합니다. CREATE OR REPLACE VIEW v_user_balance AS SELECT uid, SUM(amount) AS bala...

멱등성 + 동시성 결합: “요청 ID 선점”으로 중복 차감까지 한 번에 막기

동시성(잠금)만 잘 잡아도 잔액이 마이너스로 내려가는 문제는 많이 줄어듭니다. 그런데 운영에서 더 자주 만나는 현실은 “동시에 두 번”이 아니라 같은 요청이 재전송되어 여러 번 들어오는 것 입니다. 네트워크 타임아웃, 버튼 중복 클릭, 서버 재시도 로직은 생각보다 흔합니다. 그래서 포인트 사용 처리에서 정말 중요한 것은 아래 두 가지를 동시에 만족시키는 것입니다. 동시에 여러 요청이 들어와도 잔액이 깨지지 않는다(동시성) 같은 요청이 여러 번 들어와도 결과는 한 번만 반영된다(멱등성) 이번 글에서는 이 둘을 같이 해결하는 대표 패턴인 “요청 ID 선점(Reservation)” 전략을 다룹니다. 핵심은 간단합니다. “먼저 이 요청을 내가 처리하겠다고 기록하고(중복 차단), 그 다음 실제 차감을 진행한다.” 이 단원의 목적: “중복 요청”을 DB가 구조적으로 거절하게 만들기 멱등성은 코드로도 구현할 수 있지만, 서비스가 커질수록 여러 서버/여러 경로에서 동일 요청이 들어오며 코드만으로 일관성을 유지하기가 어려워집니다. 그래서 데이터베이스에 “중복을 거절하는 규칙(UNIQUE)”을 두고, 그 규칙을 활용해 처리 흐름을 설계하는 것이 강력합니다. 이번 글의 목표는 아래 3가지입니다. 요청 ID를 어디에 저장하고 어떤 UNIQUE를 걸어야 하는지 정한다 요청을 먼저 선점한 뒤 잔액 차감을 진행하는 트랜잭션 흐름을 만든다 실패/재시도 상황에서 “어떤 상태가 남는지”를 명확히 이해한다 1) 요청 ID 선점용 테이블 만들기 point_history는 원장이고, 요청 선점은 “처리 상태”를 관리하는 성격이 강합니다. 그래서 보통 별도 테이블을 둡니다. 이 테이블은 “요청이 들어왔는지”, “처리되었는지”, “실패했는지”를 추적할 수 있어야 합니다. CREATE TABLE IF NOT EXISTS point_requests ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, uid CHAR(36) NOT...

동시성(잠금) 기초: 동시에 차감 요청이 들어와도 잔액이 깨지지 않게 만드는 패턴

트랜잭션을 적용했다고 해서 모든 문제가 끝나지는 않습니다. 트랜잭션은 “중간 실패”를 막아주지만, 동시에 들어오는 요청(동시성) 까지 자동으로 해결해주지는 않습니다. 포인트 시스템에서 동시성이 터지는 대표 장면은 이렇습니다. 한 사용자가 거의 동시에 두 번 결제를 눌렀거나, 모바일/웹이 동시에 요청을 보내거나, 네트워크 재시도 때문에 같은 차감 요청이 겹쳐 들어옵니다. 이때 방어가 없으면 아래 같은 일이 생길 수 있습니다. 잔액이 충분하다고 판단한 두 요청이 동시에 차감을 진행해 잔액이 마이너스 가 된다 중복 차감이 발생한다(멱등성 문제와 결합) 원장과 balance 테이블이 순간적으로 어긋난다 이번 글에서는 “잠금(락)”을 어렵게 설명하기보다, 포인트 시스템에서 가장 현실적으로 쓰이는 동시성 방어 패턴을 중심으로 정리합니다. 이 단원의 목적: “읽고 판단 → 쓰기” 사이의 틈을 없애기 동시성 문제는 대부분 이 틈에서 발생합니다. 현재 잔액을 읽는다(충분하네) 차감한다 두 요청이 동시에 1번을 수행하면 둘 다 “충분하다”고 판단할 수 있습니다. 그래서 중요한 것은 1번과 2번 사이를 안전하게 만드는 것입니다. 이번 글의 목표는 아래 3가지입니다. 포인트 시스템에서 동시성 문제가 발생하는 구조를 이해한다 대표 잠금 패턴 2가지(행 잠금, 조건부 UPDATE)를 익힌다 원장(point_history)과 잔액(point_balance)을 함께 쓰는 경우의 안전한 흐름을 만든다 전제: 잔액을 어디에 두는가(원장만 vs balance 테이블) 원장만 쓰는 구조(매번 SUM으로 잔액 계산)는 개념적으로 단순하지만, “차감 가능 여부 확인”이 매번 무겁고, 동시성 제어도 구현 방식이 복잡해질 수 있습니다. 그래서 실무에서는 잔액을 별도 테이블(point_balance)로 캐시하고, 원장은 그대로 기록하는 “원장 + 잔액 캐시” 조합을 많이 씁니다. 이 글은 그 구조를 기준으로 설명합니다. -- 예시: 잔액 테이블 C...

트랜잭션 기초: 적립/사용 처리 중 ‘중간 실패’가 있어도 데이터가 깨지지 않게 하기

지금까지는 “SQL이 정상적으로 실행된다”는 전제에서 원장과 조회를 만들었습니다. 하지만 실제 시스템은 언제든 실패할 수 있습니다. 네트워크가 끊길 수도 있고, 디스크가 꽉 찰 수도 있고, 잠깐의 락 경합으로 타임아웃이 날 수도 있습니다. 포인트처럼 숫자에 민감한 도메인에서 가장 위험한 상황은 여러 작업 중 일부만 성공하는 것 입니다. 예를 들어 다음 같은 경우는 운영에서 큰 문제로 이어집니다. 포인트 사용(차감) 원장은 기록됐는데, 주문 상태 업데이트가 실패했다 결제는 성공했는데, 포인트 적립 원장 기록이 실패했다 balance 테이블을 함께 쓰는데, 원장만 기록되고 balance 갱신이 실패했다 이런 “반쯤 성공”을 막기 위한 도구가 트랜잭션(Transaction) 입니다. 트랜잭션은 여러 SQL을 하나의 묶음으로 실행해서, 전부 성공하면 확정(COMMIT), 하나라도 실패하면 전부 되돌림(ROLLBACK)으로 처리합니다. 이 단원의 목적: 포인트 처리의 “원자성(Atomicity)” 확보 트랜잭션을 이해할 때 핵심은 “문법”이 아니라 “보장되는 성질”입니다. 이번 글의 목표는 아래 3가지입니다. 트랜잭션이 어떤 문제를 해결하는지(중간 실패, 일관성 깨짐)를 이해한다 포인트 적립/사용에서 트랜잭션을 어떻게 적용하는지 기본 패턴을 익힌다 balance 테이블을 함께 쓸 때 반드시 지켜야 할 트랜잭션 규칙을 정리한다 1) 트랜잭션의 핵심 개념: “전부 또는 전무(All or Nothing)” 트랜잭션은 여러 SQL을 하나의 작업처럼 다룹니다. 아래처럼 생각하면 이해가 빠릅니다. COMMIT: 지금까지의 변경을 “확정”한다 ROLLBACK: 지금까지의 변경을 “되돌린다” BEGIN/START TRANSACTION: 변경 묶음을 시작한다 가장 단순한 형태의 흐름은 이렇습니다. START TRANSACTION; -- 여러 작업 수행 -- 1) 포인트 원장 기록 -- 2) (필요 시) balance 갱신 -- 3) ...

서브쿼리 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...

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

포인트 시스템에서 가장 골치 아픈 문제는 “한 번만 처리되어야 하는 요청이 두 번 처리되는 것”입니다. 네트워크 재시도, 클라이언트 중복 클릭, 서버 타임아웃 후 재요청은 운영에서 흔한 일이고, 이때 코드만으로 완벽하게 막는 건 생각보다 어렵습니다. 그래서 많은 서비스는 마지막 방어선으로 키(Primary Key/Unique Key) 를 활용합니다. 키 설계가 잘 되면, 애플리케이션이 실수하더라도 DB가 중복을 거절 합니다. 이번 글에서는 “키는 그냥 ID 하나”라는 수준을 넘어서, 포인트 원장(히스토리)에서 실제로 필요한 멱등성(idempotency) 을 어떻게 만들지에 초점을 맞춥니다. 이 단원의 목적: “중복이 생길 수밖에 없는 현실”을 전제로 설계하기 포인트 기능은 보통 결제, 이벤트, 쿠폰, 고객센터 보정 등 다양한 경로에서 호출됩니다. 호출 경로가 늘어날수록 중복 처리 가능성도 늘어납니다. 이번 글의 목표는 아래 3가지입니다. PK/UNIQUE의 역할을 정확히 이해하고 “어디에 걸어야 하는지” 결정한다 포인트 원장에서 중복을 막는 대표 전략(요청 ID/ref_id)을 설계한다 실제로 중복 요청이 들어오는 상황을 가정해, DB 레벨에서 안전하게 막는다 1) PRIMARY KEY는 “행을 식별하는 유일한 주소”다 PRIMARY KEY(PK)는 테이블에서 한 행을 유일하게 식별합니다. 흔히 AUTO_INCREMENT id를 PK로 두는 이유는 단순합니다. 쉽고, 빠르고, 안정적 이기 때문입니다. 하지만 포인트 시스템에서는 “id가 유일하다”만으로는 부족합니다. id는 단지 “기록의 번호”일 뿐, “같은 요청이 두 번 들어왔다”는 문제를 막아주지 않습니다. 즉, PK는 필요조건이지만 중복 방지의 충분조건은 아닙니다. -- 기록 자체의 식별자(기본) PRIMARY KEY (id) 2) UNIQUE는 “같은 의미의 기록이 두 번 들어오는 것”을 막는다 UNIQUE는 “비즈니스적으로 같은 의미”의 중복을 차단하는 도구입니다. ...

데이터 타입과 제약조건: 포인트 시스템에서 “틀린 데이터”는 애초에 못 들어오게 막아야 한다

포인트 시스템을 운영하다 보면, 조회 쿼리를 아무리 잘 만들어도 해결되지 않는 문제가 있습니다. 바로 데이터 자체가 잘못 들어오는 경우 입니다. 한 번 잘못 적립되거나 중복 차감이 발생하면, 그 이후부터는 “정상 데이터 위에 예외 처리를 덧칠하는 작업”이 계속 늘어납니다. 반대로, 초기에 데이터 타입과 제약조건을 제대로 잡아두면 많은 문제가 입력 단계에서 차단 됩니다. 이 단원의 목적: SQL을 “조회 언어”가 아니라 “데이터 품질 언어”로 쓰기 SQL을 배우는 초반에는 SELECT/WHERE가 눈에 들어오지만, 실제 서비스에서는 스키마 설계(타입/제약조건) 가 시스템의 신뢰도를 좌우합니다. 이번 글의 목표는 다음 3가지입니다. 포인트 시스템에 맞는 타입을 고르고, 오버플로우/범위 문제를 예방한다 NOT NULL, DEFAULT, CHECK(가능한 DBMS 한정)로 “불완전한 입력”을 막는다 UNIQUE/FK로 “중복 적립”과 “없는 사용자에 대한 적립” 같은 사고를 차단한다 1) 숫자 타입: INT로 충분한가, BIGINT가 필요한가 포인트는 결국 숫자입니다. 그런데 숫자는 시간이 지나면서 쌓이고, 서비스가 커지면 “상상보다 빨리” 범위를 넘길 수 있습니다. 그래서 초기에 아래를 구분해서 생각하는 편이 안전합니다. amount(변동량) : 한 번의 적립/차감 크기 balance(잔액) : 누적 결과 id(일련번호) : 내역이 쌓이는 속도에 따라 매우 빠르게 증가 amount는 INT가 흔히 충분하지만, balance는 BIGINT가 안전하다 단건 적립이 수천~수만 포인트 수준이라면 amount는 INT로 충분한 경우가 많습니다. 하지만 balance는 장기간 누적될 수 있고, 이벤트나 정책 변경으로 규모가 커질 수 있습니다. 그래서 잔액(balance)을 별도 테이블에 둔다면 BIGINT 를 권장하는 이유가 여기에 있습니다. -- 내역 일련번호는 빠르게 늘어날 수 있으므로 BIGINT 계열이 흔히 쓰입니다. id BIGI...

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 뒤에 오는...

SELECT 기초로 “내 포인트 잔액/내역” 조회하기: 문법보다 ‘질문’을 먼저 만든다

SQL을 처음 배울 때 가장 빠른 길은 문법을 외우는 게 아니라, “데이터베이스에 어떤 질문을 던질 것인지”를 먼저 정하는 것입니다. 포인트 시스템에서 가장 자주 나오는 질문은 딱 2개입니다. 지금 잔액이 얼마인가? 어떤 내역 때문에 이렇게 되었는가? 이번 글은 SELECT의 핵심 문법(컬럼 선택, 별칭, 정렬, 제한)을 익히면서 위 2개 질문을 실제 쿼리로 만드는 데 집중합니다. 준비: 예시 데이터(작게 넣고 바로 확인하기) 실습을 위해 사용자 1명과 포인트 내역 몇 건을 넣습니다. 부호 정책은 “적립 +, 차감 -”로 통일합니다. -- 사용자 1명 INSERT INTO users (uid, created_at) VALUES ('11111111-1111-1111-1111-111111111111', NOW()); -- 내역 5건 (적립 3, 사용 1, 만료 1) INSERT INTO point_history (uid, point_type, action_type, amount, ref_id, created_at) VALUES ('11111111-1111-1111-1111-111111111111', 'FREE', 'CHARGE', 1000, 'EVENT_20251214', NOW() - INTERVAL 10 DAY), ('11111111-1111-1111-1111-111111111111', 'PAID', 'CHARGE', 500, 'ORDER_9001', NOW() - INTERVAL 7 DAY), ('11111111-1111-1111-1111-111111111111', 'FREE', 'USE', -300, 'USE_7001', NOW() - INTERVAL 5 DAY), ('11111111-1111-1111-1111-111111111111', ...

포인트 시스템에 필요한 최소 테이블 목록 설계: “나중에 고치기 어려운 것”부터 잡는다

포인트 기능을 만들 때 가장 흔한 시행착오는 “일단 구현하고 나중에 정리하자”입니다. 그런데 포인트는 금액에 준하는 데이터라, 설계가 흔들리면 운영에서 바로 비용으로 돌아옵니다. 이번 글에서는 SQL 문법보다 한 단계 앞에서, 포인트 시스템에 필요한 최소 테이블 을 확정하고, 각 테이블이 어떤 질문(조회/검증)에 답해야 하는지까지 정리합니다. 포인트 데이터는 2가지 질문을 반드시 견뎌야 한다 왜 잔액이 이 숫자인가? (원장/히스토리로 추적 가능해야 함) 지금 잔액이 얼마인가? (빠르게 조회되어야 함) 첫 번째 질문은 “감사(추적)”의 영역이고, 두 번째는 “성능”의 영역입니다. 이 둘을 동시에 만족시키는 가장 단단한 출발점은 원장(내역) 테이블을 중심 으로 두고, 필요하면 잔액 테이블을 추가하는 방식입니다. 최소 테이블 2개 + 선택 테이블 1개 처음부터 많은 테이블을 만들 필요는 없습니다. 아래 2개는 필수, 1개는 성능 목적의 선택입니다. 구분 테이블 필요 이유 대표 조회 필수 users uid(사용자 식별) 기준을 고정 회원별 잔액/내역 조회 필수 point_history (원장) 적립/차감/만료/취소의 “사실”을 남김 기간별 내역, 합계, 감사 추적 선택 point_balance “지금 잔액”을 즉시 응답하기 위한 캐시성 테이블 uid로 잔액 1건 조회 1) users: 최소 컬럼만으로 시작해도 된다 여기서 중요한 건 회원정보가 아니라 uid가 일관되게 유지되는 것 입니다. uid는 문자열(UUID)일 수도 있고 정수형 id일 수도 있습니다. 어떤 것을 쓰든 “한 번 정하면 바꾸기 어렵다”는 점만 기억하면 됩니다. CREATE TABLE users ( uid CHAR(36) PRIMARY KEY, -- UUID 가정 created_at DATETIME NOT NULL ); 2) point_history(원장): 포인트 시스템의 중심 포인트는 잔액보다 “내역”이 핵심입...