🟣 쪼갠 표를 다시 이어야 한다
앞 장에서 중복을 없애려고 회원과 주문을 두 표로 나눴다. 대가는 곧바로 찾아온다. "회원 이름과 그 회원이 언제 주문했는지"를 한 번에 보려면 두 표를 다시 이어야 한다. 주문 표에는 회원 번호만 있고 이름은 회원 표에 있기 때문이다.
두 표를 잇는 연산이 조인이다. 조인은 정규화의 필연적인 짝이다. 데이터를 나눠 저장한 대가를 조회 시점에 조인으로 치른다. 그래서 정규화를 이해한 사람은 조인을 피할 수 없고, 조인을 잘 쓰는 것이 관계형 데이터베이스를 잘 쓰는 것과 거의 같은 말이 된다.
🟣 조인의 원리: 곱한 다음 거른다
조인이 실제로 무슨 일을 하는지 보면 의외로 단순하다. 개념적으로 두 표의 모든 행을 서로 짝지어 거대한 조합을 만든 다음, 그중 조건에 맞는 짝만 남긴다. 회원이 3명이고 주문이 10건이면 30개의 조합이 생기고, 그 조합에서 "회원 표의 회원 번호와 주문 표의 회원 번호가 같은" 짝만 골라낸다.
조건 없이 두 표를 곱하기만 하면 서로 무관한 행까지 전부 짝지어진 카티전 곱이 나온다. 대개는 실수지만, 이 원리를 알면 조인의 정체가 "곱한 뒤 조건으로 거르기"라는 게 분명해진다. 실제로는 데이터베이스가 이 곱을 통째로 만들지 않고 인덱스를 써서 짝을 바로 찾지만, 결과가 무엇인지를 이해하는 데는 이 그림이 맞다.
가장 많이 쓰는 것은 양쪽에 짝이 있는 행만 남기는 내부 조인이다.
SELECT m.name, o.ordered_at
FROM members m
JOIN orders o ON m.member_id = o.member_id
WHERE m.grade = 'VIP';ON이 두 표를 잇는 조건이고, WHERE는 이어 붙인 결과에서 다시 거르는 조건이다. VIP 회원의 주문만 이름과 함께 뽑는 이 질의가, 정규화로 흩어놨던 정보를 화면에 필요한 형태로 되돌린다.
🟣 외부 조인: 짝 없는 행을 살린다
내부 조인에는 함정이 하나 있다. 주문을 한 번도 안 한 회원은 결과에서 사라진다. 짝지을 주문 행이 없으니 조건을 통과하지 못하기 때문이다. 그런데 "주문 건수까지 포함해 전체 회원 목록"을 만들려면 주문이 0건인 회원도 나와야 한다.
이때 쓰는 것이 외부 조인이다. 한쪽 표의 행은 짝이 없어도 무조건 남기고, 반대쪽에서 짝을 못 찾으면 그 자리를 빈 값으로 채운다.
SELECT m.name, o.order_id
FROM members m
LEFT JOIN orders o ON m.member_id = o.member_id;LEFT JOIN은 왼쪽 표인 회원을 전부 남긴다. 주문이 없는 회원은 order_id 자리가 빈 값으로 나온다. 오른쪽을 기준으로 삼으면 RIGHT JOIN, 양쪽 모두 짝 없는 행까지 남기면 FULL OUTER JOIN이다. 실무에서는 대부분 왼쪽을 기준 표로 두는 LEFT JOIN으로 정리된다. "이 목록은 빠짐없이 다 나와야 하고, 저쪽은 있으면 붙이고 없으면 말고"라는 요구가 그 형태이기 때문이다.
내부 조인과 외부 조인을 가르는 질문은 늘 하나다. 짝이 없는 행을 결과에서 지울 것인가, 살릴 것인가. 이 한 줄로 어느 조인을 쓸지가 정해진다.
🟣 집계와 그룹: 여러 행을 하나로 접는다
이제 값을 세거나 더하는 쪽으로 넘어간다. "회원별 주문 건수"나 "등급별 평균 주문액" 같은 질문은 여러 행을 하나의 숫자로 접는다. 이 접기를 하는 것이 집계 함수다. 개수를 세는 COUNT, 더하는 SUM, 평균을 내는 AVG, 최대·최소를 찾는 MAX·MIN이 있다. 하나의 값이 아니라 여러 행을 입력으로 받아 값 하나를 낸다고 해서 다중 행 함수라 부른다.
집계를 어떤 단위로 묶을지는 GROUP BY가 정한다.
SELECT m.grade, COUNT(*) AS order_count, AVG(o.amount) AS avg_amount
FROM members m
JOIN orders o ON m.member_id = o.member_id
GROUP BY m.grade
HAVING COUNT(*) >= 5;GROUP BY m.grade는 같은 등급끼리 행을 한 뭉치로 묶고, 각 뭉치마다 집계 함수를 한 번씩 적용한다. 등급이 세 개면 결과도 세 줄이다.
여기서 초보자가 가장 자주 헷갈리는 것이 WHERE와 HAVING의 차이다. WHERE는 묶기 전에 개별 행을 거르고, HAVING은 묶은 뒤에 뭉치를 거른다. "가입한 지 1년 넘은 회원만"은 개별 행 조건이니 WHERE, "주문이 5건 이상인 등급만"은 집계 결과에 대한 조건이니 HAVING이다. 집계 함수를 조건에 쓰고 싶으면 HAVING을 써야 한다는 규칙이 여기서 나온다. 거르는 시점이 묶기 이전이냐 이후냐가 둘을 가른다.
🟣 서브쿼리: 질의 안에 질의를 넣는다
조인만으로 안 풀리는 질문이 있다. "평균 주문액보다 많이 주문한 회원"을 뽑으려면, 먼저 평균을 구하고 그 값과 각 회원을 비교해야 한다. 하나의 질의 안에서 다른 질의의 결과를 재료로 쓰는 것이 서브쿼리다.
서브쿼리는 놓이는 자리에 따라 성격이 갈린다. 값 하나를 돌려주면 비교식에 그대로 끼운다.
SELECT name
FROM members
WHERE member_id IN (
SELECT member_id FROM orders WHERE amount > 100000
);안쪽 질의가 "10만 원 넘게 주문한 회원 번호 목록"을 만들고, 바깥 질의가 그 목록에 드는 회원의 이름을 뽑는다. IN은 목록 안에 있는지를 본다.
목록이 크거나 존재 여부만 궁금할 때는 EXISTS가 더 낫다. EXISTS는 안쪽 질의가 한 행이라도 만들어내면 참이 되고, 첫 행을 찾는 순간 멈춘다.
SELECT m.name
FROM members m
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.member_id = m.member_id AND o.amount > 100000
);안쪽 질의가 바깥 행의 값(m.member_id)을 참조하는 이런 형태를 상관 서브쿼리라 한다. 바깥 회원 한 명마다 안쪽이 한 번씩 실행되며 "이 회원에게 10만 원 넘는 주문이 있는가"만 확인한다. 목록 전체를 만들 필요가 없어서, 존재 여부만 볼 때는 IN보다 EXISTS가 대체로 유리하다. IN과 EXISTS 중 무엇을 쓰느냐가 성능을 가르는 경우가 실제로 많다.
서브쿼리를 FROM 절에 넣으면 그 결과가 하나의 임시 표처럼 쓰인다. 이를 인라인 뷰라 하는데, 집계한 결과를 다시 조인하거나 거를 때 요긴하다. 서브쿼리는 결국 "복잡한 질문을 작은 질문 여러 개로 쪼개 조립하는" 도구다.
🟣 정리
- 정규화로 표를 쪼갠 대가를 조회 시점에 조인으로 치른다. 조인은 두 표를 짝지은 뒤 조건에 맞는 짝만 남기는 연산이다.
- 내부 조인은 양쪽에 짝이 있는 행만, 외부 조인은 한쪽의 짝 없는 행까지 살린다. "짝 없는 행을 지울 것인가 살릴 것인가"가 둘을 가른다.
- 집계 함수는 여러 행을 값 하나로 접고,
GROUP BY가 접는 단위를 정한다.WHERE는 묶기 전,HAVING은 묶은 뒤에 거른다. - 서브쿼리는 질의 안에 질의를 넣어 복잡한 질문을 조립한다. 존재 여부만 볼 때는
IN보다EXISTS가 대체로 빠르다.
조인은 앱 개발의 흔한 함정과도 닿아 있다. ORM으로 회원 목록을 불러온 뒤 각 회원의 주문을 하나씩 다시 조회하면, 회원 수만큼 질의가 반복되는 N+1 문제가 생긴다. 조인 한 번이면 될 일을 수백 번의 질의로 나눠 던지는 것이다. 조인의 원리를 알면 이 문제가 왜 생기고 왜 조인이나 일괄 조회로 풀리는지가 보인다. 면접에서 N+1을 묻는 이유도 결국 "쪼갠 데이터를 어떻게 효율적으로 다시 잇는가"를 이해했는지 확인하려는 것이었다.
이제 데이터를 넣고 조회하는 법은 갖췄다. 그런데 지금까지는 나 혼자 데이터베이스를 쓴다고 가정했다. 실제 서비스에서는 수천 명이 같은 순간에 같은 상품 재고를 주문한다. 여럿이 동시에 같은 데이터를 건드릴 때 어떻게 깨지지 않는가. 다음 장의 트랜잭션이 그 답이다.