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_name = 'UserA' WHERE uid = '11111111-1111-1111-1111-111111111111'; 1) INNER JOIN: 두 테이블 모두에 존재하는 행만 연결
INNER JOIN은 “매칭되는 행이 있는 것만” 결과에 남깁니다. 즉 users에 있고 point_history에도 기록이 있는 사용자만 조회됩니다.
SELECT u.uid, u.display_name, p.id, p.action_type, p.amount, p.created_at FROM users u JOIN point_history p ON p.uid = u.uid WHERE p.hide = 0 ORDER BY p.created_at DESC, p.id DESC LIMIT 20; 이 쿼리는 “최근 포인트 내역 20건”을 사용자 정보와 함께 보여줍니다. 관리자 페이지에서 “최근 포인트 이벤트 로그” 같은 화면이 필요할 때 흔히 쓰는 형태입니다.
2) LEFT JOIN: users는 전부 나오고, 매칭이 없으면 NULL로 채움
회원 목록을 보여줄 때는 “포인트 기록이 없는 회원도” 목록에 보여야 할 수 있습니다. 예를 들어 신규 가입자는 아직 포인트 내역이 없을 수 있습니다. 이런 경우 INNER JOIN을 쓰면 신규 가입자가 목록에서 사라집니다. 그래서 회원 목록 중심 조회에서는 LEFT JOIN이 자주 쓰입니다.
SELECT u.uid, u.display_name, p.id, p.action_type, p.amount FROM users u LEFT JOIN point_history p ON p.uid = u.uid AND p.hide = 0 WHERE u.uid = '11111111-1111-1111-1111-111111111111'; 중요한 포인트는 p.hide = 0 조건을 ON 절에 둔 것입니다. 이 차이는 꽤 큽니다.
LEFT JOIN에서 “조건을 WHERE에 두면” 사실상 INNER JOIN이 된다
LEFT JOIN으로 붙인 테이블(p)의 컬럼 조건을 WHERE에 두면, p가 NULL인 행은 WHERE 조건을 통과하지 못해 사라집니다. 즉 결과적으로 INNER JOIN처럼 동작할 수 있습니다.
-- (주의) 이 쿼리는 p.hide=0 때문에 p가 NULL인 회원이 빠질 수 있습니다. SELECT u.uid, u.display_name, p.id FROM users u LEFT JOIN point_history p ON p.uid = u.uid WHERE p.hide = 0; 회원을 “항상” 살리고 싶다면, p 관련 필터는 ON에 두는 습관이 안전합니다.
3) 회원별 잔액 만들기: JOIN + GROUP BY
이제 가장 실용적인 형태로 가봅니다. “회원 목록”을 뽑으면서 각 회원의 잔액을 한 줄로 보여주고 싶습니다. 잔액은 point_history의 SUM(amount)입니다.
SELECT u.uid, u.display_name, COALESCE(SUM(p.amount), 0) AS balance FROM users u LEFT JOIN point_history p ON p.uid = u.uid AND p.hide = 0 GROUP BY u.uid, u.display_name ORDER BY balance DESC; 여기서 COALESCE는 NULL을 0으로 바꿉니다. 포인트 내역이 없는 회원은 SUM이 NULL이 될 수 있는데, 화면에서는 0으로 보이는 편이 자연스럽기 때문입니다.
4) ‘최근 활동 시각’도 함께: MAX(created_at)
운영에서 “마지막으로 포인트가 움직인 시간”은 꽤 중요한 힌트입니다. 유저가 최근에 활동했는지, 특정 이벤트 이후로 정지했는지 같은 판단에 도움이 됩니다. 이 값은 원장에서 created_at의 최대값(MAX)을 구하면 됩니다.
SELECT u.uid, u.display_name, COALESCE(SUM(p.amount), 0) AS balance, MAX(p.created_at) AS last_point_activity_at FROM users u LEFT JOIN point_history p ON p.uid = u.uid AND p.hide = 0 GROUP BY u.uid, u.display_name ORDER BY last_point_activity_at DESC; last_point_activity_at이 NULL이면 “포인트 내역이 한 번도 없는 사용자”라는 의미입니다.
5) JOIN에서 가장 흔한 실수: “행이 폭증한다”
JOIN은 연결이기 때문에, 조합에 따라 결과 행 수가 쉽게 늘어납니다. 예를 들어 users 1명에 point_history가 100건이면, 단순 JOIN 결과는 100행이 됩니다. 이건 정상입니다.
문제는 여기서 “또 다른 테이블”을 JOIN했을 때 생깁니다. 예를 들어 포인트 내역에 별도의 tags 테이블이 1:N으로 붙으면 100건이 300건으로 늘어나는 식의 폭증이 발생할 수 있습니다. 이 상태에서 SUM을 하면 같은 amount가 여러 번 더해져 오답이 됩니다.
그래서 집계를 하려면 “집계를 먼저 하고 붙이는 방식(서브쿼리/CTE)”이 안전할 때가 많습니다. 이 패턴은 다음 글(서브쿼리 vs JOIN)에서 더 깊게 다룹니다.
6) 실전 패턴: 집계를 먼저 만든 뒤 users에 붙이기
아래는 point_history에서 uid별로 잔액/최근활동을 먼저 요약한 뒤, 그 결과를 users에 붙이는 방식입니다. 큰 데이터에서도 안정적이고, JOIN으로 인한 중복 집계 위험이 줄어듭니다.
SELECT u.uid, u.display_name, COALESCE(s.balance, 0) AS balance, s.last_point_activity_at FROM users u LEFT JOIN ( SELECT uid, SUM(amount) AS balance, MAX(created_at) AS last_point_activity_at FROM point_history WHERE hide = 0 GROUP BY uid ) s ON s.uid = u.uid ORDER BY balance DESC; 이 형태는 “요약(집계) 결과는 uid당 1행”이라는 점이 보장되기 때문에 사용자 테이블과 붙일 때 결과가 안정적입니다. 데이터가 커질수록 이런 패턴이 더 자주 등장합니다.
7) 성능 감각: JOIN에서도 인덱스가 중요하다
JOIN은 ON 조건으로 행을 매칭합니다. 그래서 ON에 사용되는 컬럼(여기서는 uid)에 인덱스가 있으면 훨씬 유리합니다. users는 PK가 uid라면 자동으로 인덱스가 있고, point_history는 (uid, created_at) 인덱스를 만들었다면 uid 매칭에 유리합니다.
지금 단계에서는 “JOIN 키에는 인덱스가 필요하다” 정도만 잡아도 충분합니다. 후반 성능 단원에서 EXPLAIN과 함께 더 자세히 다룹니다.
다음 글 예고: 서브쿼리 vs JOIN — “최근 30일 적립 합계”를 가장 깔끔하게 구하는 법
다음 글에서는 많은 사람이 헷갈려 하는 주제를 정리합니다. 같은 결과를 만들 수 있는데도 서브쿼리로도 되고 JOIN으로도 될 때, 어떤 기준으로 선택하면 쿼리가 더 읽기 쉽고 안전해지는지 다룹니다. 특히 “최근 30일 적립 합계” 같은 흔한 요구를 여러 방식으로 풀어보겠습니다.
댓글
댓글 쓰기