STUDY NOTE · 개념 정리
SQL JOIN과 GROUP BY 개념 정리: 조인 종류, 실행 순서, 집계 함수
Database
JOIN은 여러 테이블의 행을 조건에 맞춰 옆으로 붙이는 것이고, GROUP BY는 행들을 같은 값끼리 묶어 하나의 요약 행으로 줄이는 것이다. JOIN은 행을 늘리거나 유지하고, GROUP BY는 행을 줄인다.
1. 예제 데이터
members
| member_id | name |
| 1 | 김철수 |
| 2 | 이영희 |
| 3 | 박민수 |
orders
| order_id | member_id | amount |
| 10 | 1 | 30000 |
| 11 | 1 | 20000 |
| 12 | 2 | 50000 |
| 13 | 9 | 10000 |
박민수(3)는 주문이 없고, 주문 13의 회원(9)은 members에 없다.
2. JOIN의 종류
| 종류 | 결과에 포함되는 행 | 짝이 없을 때 |
| INNER JOIN | 양쪽 모두 조건이 맞는 행만 | 제외 |
| LEFT (OUTER) JOIN | 왼쪽 테이블의 모든 행 | 오른쪽 컬럼이 NULL |
| RIGHT (OUTER) JOIN | 오른쪽 테이블의 모든 행 | 왼쪽 컬럼이 NULL |
| FULL OUTER JOIN | 양쪽 모든 행 | 없는 쪽이 NULL (MySQL·MariaDB 미지원) |
| CROSS JOIN | 모든 조합 (행 수 = A × B) | 조건 없음 |
| SELF JOIN | 같은 테이블끼리 조인 | 예: 직원-상사 관계 |
3. INNER JOIN과 LEFT JOIN 결과 비교
SELECT m.name, o.order_id, o.amount
FROM members m
[INNER | LEFT] JOIN orders o ON o.member_id = m.member_id;
INNER JOIN 결과 (짝이 있는 것만)
| name | order_id | amount |
| 김철수 | 10 | 30000 |
| 김철수 | 11 | 20000 |
| 이영희 | 12 | 50000 |
LEFT JOIN 결과 (members는 전부)
| name | order_id | amount |
| 김철수 | 10 | 30000 |
| 김철수 | 11 | 20000 |
| 이영희 | 12 | 50000 |
| 박민수 | NULL | NULL |
두 결과 모두 주문 13은 없다. 왼쪽(members)에 회원 9가 없기 때문이다. 그리고 김철수가 두 번 나온다. 1:N 관계를 조인하면 1쪽 행이 N개만큼 복제된다. 이 성질이 GROUP BY와 만날 때 중요해진다.
4. GROUP BY와 집계 함수
SELECT member_id, COUNT(*) AS cnt, SUM(amount) AS total
FROM orders
GROUP BY member_id;
| member_id | cnt | total |
| 1 | 2 | 50000 |
| 2 | 1 | 50000 |
| 9 | 1 | 10000 |
| 집계 함수 | 의미 | NULL 처리 |
| COUNT(*) | 행의 개수 | NULL 포함해서 셈 |
| COUNT(컬럼) | 그 컬럼이 NULL이 아닌 행의 개수 | NULL 제외 |
| COUNT(DISTINCT 컬럼) | 중복 제거한 값의 개수 | NULL 제외 |
| SUM / AVG | 합계 / 평균 | NULL 무시 (AVG 분모에서도 제외) |
| MAX / MIN | 최댓값 / 최솟값 | NULL 무시 |
GROUP BY를 쓰면 SELECT에는 GROUP BY에 쓴 컬럼과 집계 함수만 올 수 있다. 그룹 안에서 값이 여러 개인 컬럼은 무엇을 보여줄지 정해지지 않기 때문이다. MySQL 5.7.5 이상은 ONLY_FULL_GROUP_BY가 기본이라 에러가 나지만, MariaDB는 기본값이 아니어서 임의의 값을 그냥 돌려준다.
5. WHERE vs HAVING
| 구분 | WHERE | HAVING |
| 거르는 대상 | 개별 행 | GROUP BY로 묶인 그룹 |
| 실행 시점 | 그룹화 전 | 그룹화 후 |
| 집계 함수 사용 | 불가 | 가능 |
| 예 | WHERE amount >= 10000 | HAVING SUM(amount) >= 50000 |
6. SQL의 논리적 실행 순서
| 순서 | 절 | 하는 일 |
| 1 | FROM, JOIN, ON | 대상 테이블을 합쳐 행 집합 생성 |
| 2 | WHERE | 행 필터링 |
| 3 | GROUP BY | 그룹화 |
| 4 | HAVING | 그룹 필터링 |
| 5 | SELECT | 출력할 컬럼·계산식 결정 |
| 6 | ORDER BY | 정렬 |
| 7 | LIMIT | 개수 제한 |
이 순서 때문에 WHERE에서는 SELECT의 별칭을 쓸 수 없다(아직 SELECT가 실행되지 않았음). 실제 실행 계획은 옵티마이저가 바꿀 수 있지만, 결과는 이 순서로 실행한 것과 같다.
7. JOIN + GROUP BY: 회원별 주문 수
SELECT m.name, COUNT(o.order_id) AS order_cnt
FROM members m
LEFT JOIN orders o ON o.member_id = m.member_id
GROUP BY m.member_id, m.name;
| name | order_cnt |
| 김철수 | 2 |
| 이영희 | 1 |
| 박민수 | 0 |
COUNT(*)를 쓰면 박민수가 1이 된다. LEFT JOIN으로 NULL이 채워진 행도 행 하나이기 때문이다. 오른쪽 테이블의 컬럼을 세야 0이 나온다.
8. 실제로 어떻게 적용되나
- 주문 없는 회원까지 포함한 통계: 기준이 되는 테이블(회원)을 왼쪽에 두고 LEFT JOIN한다. 이때 오른쪽 테이블에 대한 조건(예: 이번 달 주문만)을 WHERE에 쓰면 NULL 행이 걸러져 INNER JOIN과 같아진다. 오른쪽 조건은 ON에 쓴다.
- 1:N 조인 후 합계가 부풀려지는 문제: 주문(1)과 주문상품(N)을 조인하면 주문 행이 상품 수만큼 복제된다. 이 상태에서 주문 단위 값(배송비)을 SUM하면 상품 수만큼 중복으로 더해진다. N쪽을 먼저 GROUP BY로 집계해 1:1로 만든 뒤 조인한다.
- 그룹별 최신 1건: "회원별 마지막 주문 정보 전체"처럼 그룹의 대표 행이 필요하면 GROUP BY로는 다른 컬럼을 가져올 수 없다.
ROW_NUMBER() OVER (PARTITION BY member_id ORDER BY ordered_at DESC)같은 윈도 함수를 쓴다. - 매출 상위 회원 조회: 집계 결과에 조건을 거는 것이므로 HAVING을 쓴다. 집계와 무관한 조건(기간 등)은 WHERE로 먼저 걸러야 묶을 행이 줄어 빠르다.
9. 면접 질문으로 정리
- Q. INNER JOIN과 LEFT JOIN의 차이는? INNER JOIN은 양쪽 테이블에서 조건이 맞는 행만 반환하고, LEFT JOIN은 왼쪽 테이블의 모든 행을 반환하며 오른쪽에 짝이 없으면 NULL로 채운다.
- Q. WHERE와 HAVING의 차이는? WHERE는 그룹화 전에 개별 행을 거르고, HAVING은 GROUP BY 이후 그룹을 거르며 집계 함수를 조건에 쓸 수 있다.
- Q. COUNT(*)와 COUNT(컬럼)의 차이는? COUNT(*)는 NULL 여부와 관계없이 행 수를 세고, COUNT(컬럼)은 해당 컬럼이 NULL이 아닌 행만 센다.
- Q. SQL 실행 순서는? FROM/JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT 순서로 논리적으로 처리된다.