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

포인트 원장(point_history)만으로도 잔액과 통계를 만들 수 있지만, 실제 화면이나 운영 도구에서는 거의 항상 “사용자 정보”가 함께 필요합니다. 예를 들어 이런 질문들은 point_history만으로는 답이 부족합니다.

  • 이 uid가 누구인지(가입일, 상태 등) 함께 보고 싶다
  • 회원 목록을 보면서 각 회원의 잔액/최근 활동을 한 줄로 요약하고 싶다
  • 최근 7일 동안 사용이 많은 회원을 “회원 정보와 함께” 추려 보고 싶다

이때 필요한 것이 JOIN입니다. JOIN은 테이블을 “합치는” 기능이지만, 정확히는 두 테이블의 행을 연결해 새로운 결과를 만드는 방식입니다. 이번 글에서는 포인트 시스템에서 가장 흔한 JOIN 패턴(회원 + 원장 요약)을 중심으로, INNER JOIN/LEFT JOIN의 차이와 실전에서 헷갈리는 포인트를 정리합니다.

이 단원의 목적: “회원 목록 + 포인트 요약”을 안정적으로 뽑아내기

JOIN은 문법을 외우는 것보다 “어떤 결과를 만들고 싶은지”를 먼저 그리는 것이 중요합니다. 이번 글의 목표는 아래 3가지입니다.

  1. INNER JOIN과 LEFT JOIN의 차이를 이해하고 상황에 맞게 선택한다
  2. 집계(GROUP BY)와 JOIN을 결합해 ‘회원별 잔액/통계’를 만든다
  3. 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일 적립 합계” 같은 흔한 요구를 여러 방식으로 풀어보겠습니다.

댓글

이 블로그의 인기 게시물

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

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