C-5: 조인 완전정복 — 흩어진 표를 이어 붙여 한 화면으로
목차 29
안녕하세요, 홍순구 튜터입니다. 지난 시간에 우리는 GROUP BY로 회원별 게시물 수를 셌어요. 그런데 두 가지가 아쉬웠죠. 결과에 회원 번호(member_id)만 나오고 회원 이름은 못 붙였고, 게시물이 0개인 회원 3명(7·8·10번)은 결과에서 통째로 빠졌어요. 이름은 member 표에 있고 게시물 수는 post 표에서 세니까, 두 표가 서로 따로 놀았던 거예요.
오늘은 드디어 그 두 표를 이어 붙여요. 이게 조인(JOIN)이에요. 두 표를 연결하면 "회원 이름과 그 회원의 게시물 수를 한 줄에", 그리고 "글이 없는 회원도 0개로" 보여 줄 수 있어요. 조인은 SQLD 2과목에서 가장 자주, 가장 깊게 나오는 주제예요. 종류가 다섯 가지나 되고 헷갈리는 함정도 많아서, 오늘 하나씩 천천히 가를 거예요.
오늘의 여정
① 조인은 왜 필요한가 — 흩어진 표와 새 식구 셋
② EQUI JOIN — 게시물에 글쓴이 이름 붙이기
③ 같은 조인, 다른 문법 — USING·NATURAL·전통 콤마 조인
④ 여러 표 잇고 집계 얹기 — 회원·게시물·좋아요
⑤ OUTER JOIN — 짝 없는 행도 살리기
⑥ 특수 조인 — CROSS와 NON-EQUI
⑦ SELF JOIN — 나를 둘러싼 팔로우 관계
⑧ 조인 총정리 — 다섯 조인 비교와 빈출 함정
💡 오늘 수업의 핵심 — "흩어진 여러 표를 키로 이어 붙이고(JOIN), 짝 없는 행까지 살리는(OUTER) 다섯 가지 조인을 가른다"
🎯 학습 목표
- 두 표를 키로 잇는 EQUI 조인과, 짝 없는 행을 다루는 INNER / OUTER(LEFT·RIGHT·FULL)의 차이를 이해한다. (SQLD 2과목 'SQL 활용 — 조인')
- ANSI 표준 문법(
ON·USING·NATURAL)과 Oracle 전통 문법((+))을 대조하고, 같은 조인을 두 방식으로 쓴다. (SQLD 2과목 '표준 조인') - CROSS·NON-EQUI·SELF 조인의 쓰임을 구분하고, 조인이 몇 건을 돌려줄지 예측한다.
Step 1: "조인은 왜 필요한가 — 흩어진 표와 새 식구 셋"
먼저 왜 조인이 필요한지부터 잡아요. 지난 시간 마지막에 만든 쿼리를 다시 볼게요. 회원별 게시물 수를 센 결과예요.
SELECT member_id, COUNT(*) AS 게시물수
FROM post
GROUP BY member_id;
| member_id | 게시물수 |
|---|---|
| 1 | 7 |
| 2 | 7 |
| ... | ... |
회원 번호는 나오는데 이름이 없어요. "1번이 누구더라?" 하고 매번 member 표를 따로 찾아봐야 하죠. 회원 이름은 member 표에, 게시물은 post 표에 나뉘어 있으니까요. 두 표를 이어 붙여 "1번 = 김재훈"을 한 줄에 보여 주는 게 조인이에요.
조인을 한마디로 하면 두 명단을 맞대어 보는 일이에요. 한쪽 명단엔 회원 번호와 이름이, 다른 쪽엔 회원 번호와 게시물이 있어요. 두 명단에 공통으로 있는 회원 번호를 맞춰 한 줄로 합치는 거예요.
오늘은 표가 늘어요. 지금까지 member(회원)와 post(게시물) 둘뿐이었는데, 인스타그램의 관계를 담은 표 셋이 합류해요. 댓글·좋아요·팔로우예요.
오늘 등장하는 다섯 표의 관계 (A ──< B : A 하나가 B 를 여러 개)
member ──< post
post ──< post_comment
post ──< post_like
member ──< follow >── member
회원 1명이 게시물 여러 개를, 게시물 1개가 댓글·좋아요를 여러 개 가져요. 그리고 follow는 회원이 회원을 따르는 관계라, member 표가 양쪽에 두 번 나와요. 표 이름이 조금 낯설죠. 댓글은 comment, 좋아요는 like가 자연스럽지만 이 둘은 Oracle이 명령어로 쓰는 예약어라 표 이름으로 쓰면 오류가 나요. 그래서 post_comment·post_like로 지었어요. 팔로우는 예약어가 아니라 follow 그대로예요. 세 표에는 검산하기 좋게 경계 데이터를 심어 뒀어요. 좋아요가 0인 게시물, 댓글이 0인 게시물, 서로 맞팔한 쌍, 한쪽만 따르는 외팔 같은 거예요. 조인을 배우면서 이 경계들이 결과를 어떻게 가르는지 눈으로 확인할 거예요.
💡 한 줄 정리
조인은 공통 키(예: member_id)로 여러 표를 한 줄로 이어 붙이는 일이며, 오늘 회원·게시물에 댓글·좋아요·팔로우 세 표가 더해진다.
🙋 학생 질문 — "튜터님, 데이터를 처음부터 한 표에 다 넣으면 조인이 필요 없지 않나요?"
좋은 질문이에요. 한 표에 회원 이름·게시물·댓글·좋아요를 다 욱여넣으면 어떻게 될까요? 회원이 게시물을 7개 쓰면 회원 이름이 7번 중복되고, 거기에 댓글·좋아요까지 곱해지면 같은 정보가 수십 번 반복돼요. 이건 B 카테고리에서 배운 정규화에 정면으로 어긋나요. 그래서 정보를 주제별로 표를 나눠 저장하고(중복 없이), 볼 때만 조인으로 잠깐 이어 붙이는 거예요. "저장은 나눠서, 조회는 합쳐서"가 관계형 데이터베이스의 기본 전략이에요. 그 합치는 도구가 바로 오늘 배우는 조인이고요.
Step 2: "EQUI JOIN — 게시물에 글쓴이 이름 붙이기"
가장 기본 조인부터 해요. 두 표를 공통 키가 같은 행끼리 잇는 거예요. post의 member_id와 member의 member_id가 같은 행을 맞춰 붙여요. 게시물마다 글쓴이 이름을 달아 볼게요.
-- sql/queries/C5_join.sql
SELECT p.post_id, m.nickname, p.caption
FROM post p
JOIN member m ON p.member_id = m.member_id;
| post_id | nickname | caption |
|---|---|---|
| 1 | 김재훈 | 첫 게시물입니다 #안녕 ... |
| 2 | 김재훈 | 오늘도 코딩 #개발 ... |
| 3 | 김재훈 | 주말 산책 |
| 4 | 이민지 | 아침 커피 한 잔 #카페 |
| 5 | 이민지 | 필름 카메라 #사진 |
(전체 50건 중 일부예요.) 드디어 게시물 옆에 글쓴이 이름이 붙었어요. FROM post p는 post 표에 p라는 짧은 별명을, JOIN member m은 member 표에 m이라는 별명을 붙인 거예요. 표가 둘이라 member_id처럼 양쪽에 다 있는 컬럼은 p.member_id, m.member_id로 어느 표 것인지 밝혀 줘야 해요.
핵심은 ON p.member_id = m.member_id예요. "두 표에서 회원 번호가 같은 행끼리 짝지어라"라는 조인 조건이에요. 이렇게 조인 조건이 등호(=)로 같은지 보는 조인을 EQUI 조인(등가 조인)이라고 불러요. 우리가 쓰는 조인의 대부분이 이 등가 조인이에요.
게시물은 50개고, 모든 게시물엔 글쓴이(member_id)가 반드시 있어요. 그래서 결과도 정확히 50건이에요. 게시물 하나하나에 짝이 되는 회원 한 명이 붙은 거예요.
⚠️ 함정 —
JOIN만 쓰면 INNER 조인(내부 조인)이에요.INNER는 생략할 수 있어서INNER JOIN과JOIN은 완전히 같아요. 양쪽에 짝이 다 있는 행만 남기는 게 INNER의 성격인데, 이건 Step 5에서 OUTER와 비교하며 똑똑히 봐요. ★빈출
💡 한 줄 정리
EQUI 조인은 ON 표A.키 = 표B.키로 두 표의 같은 키를 맞춰 잇고, JOIN은 곧 INNER JOIN이라 양쪽에 짝이 있는 행만 남긴다.
🙋 학생 질문 — "튜터님, 표 별명(p, m)을 꼭 붙여야 하나요?"
조인에서는 거의 필수라고 보면 돼요. 표가 둘 이상이 되면 같은 이름의 컬럼이 양쪽에 있을 수 있어요. post에도 member_id, member에도 member_id가 있죠. 이때 그냥 member_id라고만 쓰면 데이터베이스가 "어느 표의 member_id냐?"며 헷갈려 에러를 내요. p.member_id, m.member_id로 표 별명을 앞에 붙이면 명확해지고요. 별명은 member처럼 긴 이름을 매번 쓰지 않아도 돼 쿼리도 짧아져요. 양쪽에만 있는 컬럼(예: caption)은 별명을 생략해도 되지만, 습관적으로 다 붙여 두면 읽기 편하고 안전해요.
Step 3: "같은 조인, 다른 문법 — USING·NATURAL·전통 콤마 조인"
방금 쓴 EQUI 조인을 똑같이 만드는 다른 문법이 몇 가지 더 있어요. SQLD가 "이 조인을 다른 문법으로 바꾸면?"을 즐겨 물어서, 네 가지를 나란히 익혀 둬요. 모두 결과는 50건으로 같아요.
먼저 USING이에요. 양쪽 표의 조인 키 이름이 똑같을 때(member_id처럼) 쓸 수 있어요.
-- sql/queries/C5_join.sql
SELECT post_id, nickname
FROM post JOIN member USING (member_id);
ON p.member_id = m.member_id 대신 USING (member_id) 한 줄로 끝나요. 단 주의할 게 있어요. USING으로 묶은 컬럼은 양쪽 공통이 되어 표 별명을 못 붙여요. p.member_id라고 쓰면 오히려 에러가 나고, 그냥 member_id라고만 써야 해요.
다음은 NATURAL JOIN이에요. 이름이 같은 컬럼을 데이터베이스가 알아서 다 찾아 조인 조건으로 써요.
SELECT post_id, nickname
FROM post NATURAL JOIN member;
조인 조건을 한 글자도 안 적었는데 50건이 나와요. post와 member에 이름이 같은 컬럼이 member_id 하나뿐이라, 그걸 자동으로 조인 키로 잡은 거예요. 편해 보이지만 위험도 있어요. 나중에 두 표에 우연히 이름이 같은 컬럼(예: created_at)이 생기면, 그것까지 멋대로 조인 조건에 넣어 결과가 확 바뀌어요.
⚠️ 함정 —
NATURAL JOIN은 이름이 같은 컬럼을 전부 자동으로 조인 조건에 넣어요. 의도와 다른 컬럼이 끼면 결과가 틀어지므로 실무에선 조심해서 써요. SQLD에서NATURAL JOIN이 어느 컬럼으로 조인되는지를 묻는 문제가 나와요. ★빈출
마지막은 Oracle 전통 문법인 콤마 조인이에요. FROM에 표를 콤마로 나열하고, 조인 조건을 WHERE에 적어요.
SELECT p.post_id, m.nickname
FROM post p, member m
WHERE p.member_id = m.member_id;
WHERE에 적은 등호 조건이 곧 조인 조건이에요. 결과는 역시 50건으로, 앞의 JOIN ... ON과 똑같아요. ANSI 표준이 나오기 전부터 Oracle이 쓰던 방식이라, 오래된 쿼리에서 자주 만나요.
💡 한 줄 정리
같은 EQUI 조인을 ON·USING·NATURAL JOIN·콤마 조인(전통) 네 방식으로 쓸 수 있고, 결과는 모두 같지만 NATURAL은 같은 이름 컬럼을 자동으로 묶어 위험하다.
🙋 학생 질문 — "튜터님, 그럼 넷 중에 뭘 써야 하나요?"
실무 권장은 JOIN ... ON이에요. 🌟 조인 조건이 ON 뒤에 또렷이 보여서, 어느 컬럼으로 잇는지 한눈에 읽혀요. USING은 키 이름이 양쪽 같을 때 짧게 쓰기 좋지만, 그 컬럼에 표 별명을 못 붙이는 제약이 있어요. NATURAL JOIN은 짧지만 "이름이 같으면 다 조인"이라 예측이 어려워 잘 안 써요. 콤마 조인은 오래된 코드 호환을 위해 알아 두는 정도예요. 조인 조건을 깜빡 WHERE에 안 적으면 모든 조합이 쏟아지는 사고(Step 6의 카티션 곱)가 나기 쉽거든요. 정리하면 새로 짤 땐 ON, 나머지는 "읽을 줄 알면 되는" 문법이에요.
Step 4: "여러 표 잇고 집계 얹기 — 회원·게시물·좋아요"
조인은 두 표에서 멈추지 않아요. 표 셋, 넷도 줄줄이 이을 수 있어요. 회원-게시물-좋아요 세 표를 이어, 좋아요 한 건마다 "누가 쓴 어느 게시물인지"를 붙여 볼게요.
-- sql/queries/C5_join.sql
SELECT m.nickname, p.post_id
FROM member m
JOIN post p ON m.member_id = p.member_id
JOIN post_like l ON p.post_id = l.post_id;
member와 post를 잇고, 거기에 다시 post_like를 이었어요. JOIN을 두 번 쓴 거죠. 결과는 32건이에요. 좋아요가 32건이니까, 좋아요 한 건마다 게시물과 글쓴이가 붙어 32줄이 나온 거예요.
이제 지난 시간에 배운 집계(GROUP BY)를 이 조인 위에 얹어 볼게요. "회원이 받은 좋아요 수"를 세요. 회원의 게시물에 달린 좋아요를 회원별로 묶는 거예요.
SELECT m.nickname, COUNT(*) AS 받은_좋아요
FROM member m
JOIN post p ON m.member_id = p.member_id
JOIN post_like l ON p.post_id = l.post_id
GROUP BY m.nickname
ORDER BY 받은_좋아요 DESC;
| nickname | 받은_좋아요 |
|---|---|
| 강지수 | 8 |
| 김유나 | 6 |
| 김재훈 | 6 |
| 이민지 | 5 |
| 박지훈 | 4 |
| 정하루 | 1 |
| 최도연 | 1 |
| 한소라 | 1 |
강지수가 받은 좋아요가 8개로 가장 많네요. 조인으로 세 표를 한 줄로 합친 다음, 그 위에 GROUP BY로 회원별로 묶고 COUNT로 센 거예요. "조인 위에 집계를 얹는다"는 게 이런 모습이에요.
⚠️ 함정 — 조인은 짝이 여러 개면 행을 불려요. 좋아요가 3개인 게시물은 조인 후 3줄이 돼요. 그래서 이 결과로 "게시물 수"를 세면 안 돼요. 좋아요 수만큼 부풀려진 값이 나오거든요. 게시물 수를 세려면
COUNT(DISTINCT p.post_id)로 중복을 걷어내야 해요. SQLD에서 조인 후 부풀려진 행을 그대로 센 값을 오답으로 깔아 둬요. ★빈출
💡 한 줄 정리
JOIN을 거듭 써서 표 셋 이상을 잇고 그 위에 GROUP BY 집계를 얹을 수 있으며, 조인은 짝이 여럿이면 행을 불리므로 개수를 셀 땐 DISTINCT에 주의한다.
🙋 학생 질문 — "튜터님, 조인 순서를 바꾸면 결과도 바뀌나요?"
INNER 조인끼리는 순서를 바꿔도 결과(행의 내용과 건수)가 같아요. member를 먼저 post에 잇든, post를 먼저 post_like에 잇든, 최종으로 짝이 맞는 행들이 합쳐지는 건 똑같거든요. 그래서 위 쿼리에서 세 표를 어떤 차례로 적어도 32건이 나와요. 다만 Step 5에서 배울 OUTER 조인이 섞이면 이야기가 달라져요. "왼쪽을 살린다"는 방향이 있어서 순서가 결과를 바꿀 수 있어요. 지금은 "INNER끼리는 순서 무관"만 기억해 두고, OUTER가 끼면 한 번 더 생각하기로 해요.
Step 5: "OUTER JOIN — 짝 없는 행도 살리기"
오늘의 가장 중요한 조인이에요. 지난 시간의 두 번째 아쉬움, 기억하시죠. 게시물이 0개인 회원 3명(7·8·10번)이 결과에서 빠졌던 거요. 그 이유가 바로 지금까지 쓴 게 INNER 조인이기 때문이에요. INNER는 양쪽에 짝이 다 있는 행만 남겨요. 게시물이 없는 회원은 post 표에 짝이 없으니 버려지는 거예요.
INNER JOIN : 양쪽에 짝이 있는 행만 남긴다
· 회원(글 있음) + 게시물 → 짝 맞음 → 결과에 포함
· 회원(글 0개) → 짝 없음 → 버려진다 (윤태양·한나래·이민수)
LEFT OUTER JOIN : 왼쪽(회원)은 짝이 없어도 다 남긴다
· 회원(글 0개) → 게시물 쪽은 NULL → 결과에 포함 (0개로 셀 수 있다)
회원을 한 명도 빠뜨리지 않으려면 OUTER 조인(외부 조인)을 써요. LEFT OUTER JOIN은 왼쪽 표를 전부 남기고, 오른쪽에 짝이 없으면 그쪽을 NULL로 채워요. 회원을 왼쪽에 두고 게시물을 이어 볼게요.
-- sql/queries/C5_join.sql
SELECT m.member_id, m.nickname, p.post_id
FROM member m
LEFT JOIN post p ON m.member_id = p.member_id;
이 결과는 53건이에요. 게시물 50건에, 글이 없는 회원 3명이 NULL을 달고 한 줄씩 더해져 53건이 된 거예요(OUTER는 생략 가능해서 LEFT JOIN이라고만 써도 같아요). 짝이 없는 회원이 사라지지 않고 살아남았죠.
이제 지난 시간의 숙제를 마저 풀어요. 회원별 게시물 수를, 0개인 회원까지 0으로 보여 주는 거예요. LEFT JOIN 위에 집계를 얹어요.
SELECT m.nickname, COUNT(p.post_id) AS 게시물수
FROM member m
LEFT JOIN post p ON m.member_id = p.member_id
GROUP BY m.nickname
ORDER BY 게시물수 DESC;
| nickname | 게시물수 |
|---|---|
| 김재훈 | 7 |
| 이민지 | 7 |
| 김유나 | 6 |
| 박승우 | 6 |
| 정하루 | 6 |
| 강지수 | 5 |
| 박지훈 | 5 |
| 최도연 | 4 |
| 한소라 | 4 |
| 윤태양 | 0 |
| 이민수 | 0 |
| 한나래 | 0 |
지난 시간엔 9명만 나왔는데, 이번엔 12명이 다 나왔어요. 윤태양·한나래·이민수가 0으로 살아났죠. 여기엔 작은 비밀이 있어요. COUNT(*)가 아니라 COUNT(p.post_id)를 썼어요. 짝이 없는 회원은 p.post_id가 NULL인데, COUNT(컬럼)은 지난 시간에 배운 대로 NULL을 안 세요. 그래서 게시물 없는 회원이 0으로 딱 떨어지는 거예요. 만약 COUNT(*)를 썼다면 NULL이라도 행은 있으니 1로 잘못 세였을 거예요.
방향을 바꾼 RIGHT OUTER JOIN도 있어요. 오른쪽 표를 다 남겨요. A LEFT JOIN B와 B RIGHT JOIN A는 같은 결과예요. 그리고 양쪽을 다 살리는 FULL OUTER JOIN도 있어요. 우리 데이터에선 게시물에 주인 없는 행이 없어서, member FULL OUTER JOIN post도 53건으로 LEFT와 같아요. 양쪽에 서로 짝 없는 행이 다 있을 때라야 FULL이 빛을 발해요.
마지막으로 Oracle 전통 문법의 OUTER 조인이에요. 짝이 없을 수 있는 쪽에 (+) 기호를 붙여요.
SELECT m.member_id, p.post_id
FROM member m, post p
WHERE m.member_id = p.member_id(+);
post 쪽에 (+)를 붙였어요. "post에 짝이 없어도 member는 남겨라"라는 뜻이라, LEFT JOIN과 똑같은 53건이 나와요. 짝이 없는 쪽에 (+)가 붙는다고 기억하면 돼요.
⚠️ 함정 — Oracle
(+)는FULL OUTER JOIN을 표현하지 못해요. 양쪽을 다 살리는 FULL은 ANSI 문법(FULL OUTER JOIN)으로만 가능해요. 그리고(+)는 짝 없는 쪽에 붙는데, 방향을 반대로 붙이면 결과가 뒤집혀요. SQLD에서(+)위치를 주고 LEFT인지 RIGHT인지, FULL이 되는지를 묻는 함정이 단골이에요. ★빈출
💡 한 줄 정리
OUTER 조인은 짝 없는 행을 NULL로 채워 살리며(LEFT는 왼쪽, RIGHT는 오른쪽, FULL은 양쪽), Oracle (+)는 짝 없는 쪽에 붙고 FULL은 표현하지 못한다.
🙋 학생 질문 — "튜터님, LEFT JOIN 하고 COUNT(*) 쓰면 왜 안 돼요?"
COUNT(*)는 행 자체를 세기 때문이에요. LEFT JOIN으로 게시물 없는 회원도 한 줄은 만들어져요. 게시물 칸이 NULL일 뿐 행은 존재하죠. 그래서 COUNT(*)로 세면 그 NULL 행도 1로 세어, 게시물 0개인 회원이 1개로 잘못 나와요. 반대로 COUNT(p.post_id)는 post_id가 NULL인 행을 건너뛰니, 게시물 없는 회원이 정확히 0으로 떨어져요. "OUTER 조인 결과를 셀 땐 COUNT(*)가 아니라 COUNT(짝 쪽 컬럼)"이 안전한 습관이에요. 지난 시간에 배운 COUNT(*)와 COUNT(컬럼)의 NULL 차이가 여기서 그대로 힘을 발휘해요.
Step 6: "특수 조인 — CROSS와 NON-EQUI"
지금까지는 조인 조건이 등호(=)인 EQUI 조인이었어요. 이번엔 조금 다른 두 조인을 봐요. 조건이 아예 없는 CROSS와, 조건이 등호가 아닌 NON-EQUI예요.
먼저 CROSS 조인이에요. 조인 조건 없이 두 표의 모든 조합을 만들어요. 이걸 카티션 곱(Cartesian product, 곱집합)이라고도 불러요.
-- sql/queries/C5_join.sql
SELECT COUNT(*) FROM member CROSS JOIN post;
| COUNT(*) |
|---|
| 600 |
회원 12명 × 게시물 50개 = 600건이에요. 회원 한 명이 게시물 50개 전부와 짝지어지고, 그게 12명만큼 반복돼요. 의미 있는 조인이라기보단, "모든 경우의 수"가 필요할 때(예: 모든 회원에게 모든 요일을 붙이기) 가끔 써요.
CROSS JOIN : 조인 조건 없이 모든 조합 (곱집합)
회원 12명 × 게시물 50개 = 600 조합
⚠️ 함정 — Step 3의 콤마 조인에서
WHERE의 조인 조건을 깜빡 빼먹으면, 결과가 CROSS 조인이 돼 버려요. 의도는 50건인데 600건이 쏟아지는 거죠. 실무 사고의 단골 원인이에요. SQLD에서도 조인 조건 없는 콤마 조인의 결과 건수(전체 곱)를 묻는 문제가 나와요. ★빈출
다음은 NON-EQUI 조인(비등가 조인)이에요. 조인 조건이 등호가 아니라 부등호(<·>·BETWEEN)인 조인이에요. 같은 회원의 게시물끼리, "더 이른 글 → 더 나중 글"을 짝지어 볼게요. 강지수(6번)의 글로 좁혀서요.
SELECT p1.post_id AS 이른글, p2.post_id AS 나중글
FROM post p1
JOIN post p2 ON p1.member_id = p2.member_id
AND p1.created_at < p2.created_at
WHERE p1.member_id = 6;
조인 조건이 두 줄이에요. p1.member_id = p2.member_id는 같은 회원(등호)이고, p1.created_at < p2.created_at은 작성일이 더 이른지 보는 부등호예요. 이 부등호 조건이 NON-EQUI예요. 강지수의 게시물은 날짜가 5개라, 둘씩 "이른 것 → 나중 것"으로 짝지으면 10쌍이 나와요.
강지수의 게시물 날짜 5개를 "이른 글 → 나중 글"로 짝
05-17 → 05-20, 06-04, 06-10, 06-18 (4쌍)
05-20 → 06-04, 06-10, 06-18 (3쌍)
06-04 → 06-10, 06-18 (2쌍)
06-10 → 06-18 (1쌍)
합계 10쌍
등급표처럼 "이 점수는 어느 구간에 드나"를 BETWEEN으로 잇는 것도 대표적인 NON-EQUI예요. 핵심은 "조인 조건이 꼭 =일 필요는 없다"예요.
💡 한 줄 정리
CROSS 조인은 조건 없이 모든 조합(곱집합)을 만들고(12 × 50 = 600), NON-EQUI 조인은 조인 조건이 등호가 아닌 부등호(<·BETWEEN 등)인 조인이다.
🙋 학생 질문 — "튜터님, CROSS 조인은 실수로 만드는 것 말고 진짜 쓸 데가 있나요?"
네, 일부러 쓰는 경우가 있어요. "모든 조합이 필요할 때"예요. 예를 들어 회원 12명 각각에게 요일 7개를 모두 붙여 "회원 × 요일" 빈칸 표(12 × 7 = 84칸)를 만들고 싶을 때, CROSS 조인으로 뼈대를 만든 뒤 거기에 실제 데이터를 채워요. 보고서에서 "데이터가 없는 칸도 0으로 빠짐없이 보여 줘야 할 때" 이런 식으로 모든 조합을 먼저 깔고 시작하죠. 다만 표가 크면 곱이 폭발하니(만 건 × 만 건 = 1억 건) 조심해서 써요. 대부분은 "실수로 만들면 안 되는 조인"으로 기억하되, "의도적으로 모든 조합이 필요할 때 쓰는 도구"라는 쓰임도 함께 알아 두세요.
Step 7: "SELF JOIN — 나를 둘러싼 팔로우 관계"
이번엔 같은 표를 자기 자신과 잇는 SELF 조인(자기 조인)이에요. 팔로우가 딱 그런 관계예요. 회원이 회원을 따르니까요. follow 표에는 따르는 사람(follower_id)과 따라지는 사람(following_id)이 둘 다 회원 번호예요. 둘 다 member 표를 가리키죠.
"누가 누구를 팔로우하는지" 이름으로 보려면, member 표를 두 번 이어야 해요. 한 번은 따르는 사람 이름을, 한 번은 따라지는 사람 이름을 붙이려고요.
-- sql/queries/C5_join.sql
SELECT me.nickname AS 따르는사람, you.nickname AS 따라지는사람
FROM follow f
JOIN member me ON f.follower_id = me.member_id
JOIN member you ON f.following_id = you.member_id;
| 따르는사람 | 따라지는사람 |
|---|---|
| 김재훈 | 이민지 |
| 김재훈 | 강지수 |
| 이민지 | 김재훈 |
| 이민지 | 정하루 |
| 박승우 | 김재훈 |
| 정하루 | 김재훈 |
(전체 16건 중 일부예요.) 같은 member 표에 me와 you라는 다른 별명을 붙여 두 번 이은 게 보이시죠. 같은 표라도 별명이 다르면 데이터베이스는 서로 다른 표처럼 다뤄요. 이게 자기 조인의 핵심이에요. 별명으로 같은 표를 둘로 갈라 쓰는 거예요.
자기 조인으로 재미있는 걸 찾을 수 있어요. 서로 맞팔(서로 팔로우)한 쌍이에요. "A가 B를 따르고, 동시에 B도 A를 따르는" 경우를 찾는 거예요. follow를 자기 자신과, 방향을 뒤집어 이어요.
SELECT f1.follower_id, f1.following_id
FROM follow f1
JOIN follow f2 ON f1.follower_id = f2.following_id
AND f1.following_id = f2.follower_id
WHERE f1.follower_id < f1.following_id;
| follower_id | following_id |
|---|---|
| 1 | 2 |
| 1 | 6 |
| 2 | 4 |
| 9 | 12 |
맞팔 4쌍이 나왔어요. 이름으로 보면 김재훈↔이민지, 김재훈↔강지수, 이민지↔정하루, 김유나↔박지훈이에요. 조인 조건이 묘하죠. f1의 따르는 사람이 f2의 따라지는 사람과 같고(f1.follower_id = f2.following_id), 그 반대도 같으면(f1.following_id = f2.follower_id) 서로 맞팔이에요. 맨 끝 WHERE f1.follower_id < f1.following_id는 같은 쌍이 두 번(1-2, 2-1) 나오지 않게 한 번만 남기는 거예요.
⚠️ 함정 — 자기 조인은 같은 표를 두 번 적되 반드시 다른 별명을 붙여야 해요. 별명 없이 같은 표를 두 번 쓰면 데이터베이스가 구분을 못 해 에러가 나요. SQLD에서 자기 조인의 별명과 조인 조건을 주고 결과를 묻는 문제가 나와요. ★빈출
참, follow 표엔 댓글의 대댓글도 비슷한 자기 조인 소재가 있어요. post_comment의 parent_comment_id가 부모 댓글을 가리켜서, 댓글 표를 자기 자신과 이으면 "대댓글 → 원댓글"을 붙일 수 있어요. 다만 댓글의 댓글의 댓글처럼 깊이가 깊어지면 자기 조인만으론 한계가 있어요. 그 깊은 트리를 한 번에 펼치는 방법은 다음 단계 주제로 남겨 둬요.
💡 한 줄 정리
SELF 조인은 같은 표에 서로 다른 별명을 붙여 자기 자신과 잇는 조인이며, 팔로우처럼 한 표 안에서 회원이 회원을 가리키는 관계를 풀 때 쓴다.
🙋 학생 질문 — "튜터님, 맞팔 결과에서 WHERE f1.follower_id < f1.following_id 는 왜 넣어요?"
중복을 없애려고요. 맞팔은 양방향이라, 조건만 걸면 김재훈→이민지 쌍과 이민지→김재훈 쌍이 둘 다 맞팔로 잡혀요. 같은 한 쌍인데 결과에 두 줄로 나오는 거죠. f1.follower_id < f1.following_id는 "번호가 작은 쪽이 따르는 사람일 때만 남겨라"라는 뜻이에요. 그러면 1-2는 남고 2-1은 빠져서, 한 쌍이 딱 한 번만 나와요. 만약 이 조건을 빼면 8줄(4쌍 × 2)이 나와요. "양방향 관계에서 쌍을 한 번만 세고 싶을 때" 번호 대소로 한쪽만 남기는 건 자주 쓰는 기법이니 기억해 두세요.
Step 8: "조인 총정리 — 다섯 조인 비교와 빈출 함정"
오늘 배운 다섯 조인을 한 표로 모아요. 시험에서 "이 조인의 결과 건수는?"을 물으면, 이 표의 성격으로 답을 가려낼 수 있어요.
| 조인 | 무엇을 남기나 | 짝 없는 행 | 우리 예 |
|---|---|---|---|
| INNER (EQUI) | 양쪽에 짝이 있는 행만 | 버린다 | member-post 50건 |
| LEFT OUTER | 왼쪽 전부 + 오른쪽 짝 | 왼쪽 살린다 | member LEFT post 53건 |
| RIGHT OUTER | 오른쪽 전부 + 왼쪽 짝 | 오른쪽 살린다 | LEFT의 거울 |
| FULL OUTER | 양쪽 전부 | 양쪽 살린다 | 53건 (주인 없는 글 없어 LEFT와 같음) |
| CROSS | 모든 조합 | (조건 없음) | 12 × 50 = 600건 |
INNER는 짝 있는 것만, OUTER는 짝 없는 쪽까지 살린다는 게 핵심이에요. 그리고 Oracle (+)는 OUTER를 표현하는 전통 문법인데, 짝 없는 쪽에 붙고 FULL은 못 만든다는 것까지요.
마지막으로 가장 잘 틀리는 함정 하나만 더 짚어요. OUTER 조인을 해 놓고 WHERE에서 그 쪽 컬럼에 조건을 걸면, 살려 둔 행이 도로 사라져요. 예를 들어 회원을 다 살리려고 LEFT JOIN을 했는데, WHERE p.post_id IS NOT NULL을 걸면 NULL이던 회원이 다 걸러져 결국 INNER와 똑같아져요.
LEFT JOIN 만으로 조회 → 53건 (글 0개 회원 3명 포함)
LEFT JOIN 한 뒤 WHERE p.post_id IS NOT NULL → 50건 (3명이 도로 사라짐 = INNER)
OUTER로 애써 살린 행을 WHERE가 다시 죽이는 거예요. "OUTER 조인한 표의 컬럼은 WHERE가 아니라 조인 조건(ON)에서 거른다"가 안전한 습관이에요.
⚠️ 함정 —
LEFT/RIGHT OUTER JOIN뒤에WHERE로 짝 없는 쪽(NULL) 컬럼에 조건을 걸면, NULL 행이 걸러져 OUTER가 INNER로 되돌아가요. SQLD에서 OUTER 조인에WHERE조건을 슬쩍 붙여 놓고 결과 건수를 묻는 함정이 자주 나와요. ★빈출
💡 한 줄 정리
다섯 조인은 "짝 없는 행을 어떻게 다루나"로 갈리며(INNER는 버리고, OUTER는 살리고, CROSS는 모든 조합), OUTER 뒤의 WHERE는 살린 NULL 행을 도로 죽일 수 있다.
🙋 학생 질문 — "튜터님, 조인 종류가 너무 많아요. 실무에선 뭘 제일 많이 써요?"
90%는 INNER 조인과 LEFT OUTER 조인이에요. 🌟 "두 표에 다 있는 것만 보고 싶다"면 INNER, "한쪽은 빠짐없이 다 보여 주고 짝 없으면 빈칸으로 둔다"면 LEFT예요. 회원 목록에 게시물 수를 붙이는데 글 안 쓴 회원도 0으로 보여 줘야 한다면 LEFT, 좋아요가 달린 게시물만 보면 된다면 INNER인 식이죠. RIGHT는 LEFT로 바꿔 쓸 수 있어 잘 안 쓰고, FULL은 가끔, CROSS는 의도할 때만이에요. 그러니 INNER와 LEFT 둘만 확실히 잡으면 실무 조인의 대부분이 풀려요. 나머지는 "이런 게 있다"로 알아 두면 충분해요.
마무리
오늘은 흩어진 표를 이어 붙이는 조인을 배웠어요. EQUI 조인으로 회원 이름을 게시물에 붙였고, 같은 조인을 ON·USING·NATURAL·콤마 네 문법으로 써 봤죠. 표 셋을 이어 그 위에 집계를 얹었고, OUTER 조인으로 지난 시간에 빠졌던 글 0개 회원을 0으로 살려 냈어요. 그리고 CROSS(모든 조합)·NON-EQUI(부등호 조인)·SELF(자기 조인)까지, 팔로우 맞팔을 찾는 데에 자기 조인을 써 봤고요.
오늘 배운 핵심 세 가지
- 💡 하나 — 조인은 공통 키로 표를 잇고, INNER는 양쪽 짝 있는 행만(EQUI member-post 50건), OUTER는 짝 없는 행도 NULL로 살린다(LEFT 53건).
JOIN은 곧INNER JOIN이다. - 💡 둘 — 같은 조인을 ANSI(
ON·USING·NATURAL)와 Oracle 전통(콤마·(+)) 두 갈래로 쓸 수 있고,(+)는 짝 없는 쪽에 붙으며 FULL은 표현하지 못한다. - 💡 셋 — CROSS는 모든 조합(
12 × 50 = 600), NON-EQUI는 부등호 조인, SELF는 같은 표를 다른 별명으로 잇는 조인(팔로우 맞팔)이다.
다음 시간 예고
오늘 자기 조인으로 맞팔(서로 팔로우)을 찾았어요. 그런데 "서로 팔로우한 사람만"이나 "내가 따르는데 상대는 안 따르는 짝사랑만" 같은 건, 조인 말고 더 깔끔한 방법이 있어요. 두 조회 결과를 집합처럼 더하고 빼고 겹치는 부분만 남기는 거예요. 다음 시간엔 집합 연산자를 배워요. UNION(합집합)으로 전체 관계를, INTERSECT(교집합)로 맞팔을, MINUS(차집합)로 짝사랑을 한 번에 가려낼 거예요. 오늘 만든 follow 표를 그대로 써서요.
과제
오늘 배운 조인을 직접 써 보는 과제예요. 결과가 몇 건 나올지, 어떤 값이 나올지 먼저 예상한 뒤 실행해 확인해 보세요.
[기초] 두 표를 이어 이름 붙이기
다음을 조회하는 SQL을 써 보세요. (가) post_comment(댓글)와 member를 이어, 댓글 내용 옆에 댓글을 쓴 회원의 이름(nickname)을 붙여 보세요. (나) post_like(좋아요)와 post를 이어, 좋아요가 눌린 게시물의 caption을 붙여 보세요. (다) (가)와 (나)가 각각 몇 건 나오는지 세어 보고, 댓글 수(15)·좋아요 수(32)와 맞는지 확인해 보세요.
[응용] 짝 없는 행 살리기
다음을 해 보세요. (가) post(게시물)를 왼쪽에 두고 post_like(좋아요)를 LEFT JOIN해, 게시물별 좋아요 수를 0개인 게시물까지 포함해 구해 보세요. (나) 좋아요가 하나도 없는 게시물이 몇 건인지 세어 보세요. (다) (가)를 INNER 조인으로 바꾸면 결과 게시물 수가 어떻게 달라지는지 비교하고, 그 차이가 왜 생기는지 한 줄로 적어 보세요.
[심화] 자기 조인과 방향
follow 표로 다음을 해 보세요. (가) 자기 조인으로 "내가 따르는데 상대는 나를 안 따르는" 외팔(짝사랑) 관계를 찾아보세요. (힌트: LEFT JOIN으로 반대 방향 팔로우를 이어 보고, 그게 없는 행만 남기세요.) (나) 회원별로 "내가 따르는 사람 수(팔로잉)"를 구해 보세요. (다) 아무도 팔로우하지 않는(팔로워가 0명인) 회원이 누구인지, member를 왼쪽에 둔 OUTER 조인으로 찾아보세요.
생각해볼 주제
1. INNER 조인과 OUTER 조인, 무엇을 언제 쓸까
INNER는 양쪽에 짝이 있는 행만 남기고, OUTER(LEFT)는 한쪽을 짝이 없어도 다 남겨요. "좋아요가 달린 게시물만 보고 싶을 때"와 "모든 게시물을 좋아요 0개까지 다 보여 주고 싶을 때", 어느 조인을 써야 할지 생각해 보세요. 그리고 무심코 INNER를 써서 "데이터가 0인 행"이 보고서에서 통째로 사라지는 실수가 실무에서 어떤 문제로 이어질 수 있는지도 함께 고민해 보세요.
2. ANSI 표준 조인과 Oracle 전통 조인, 어느 쪽을 쓸까
같은 조인을 JOIN ... ON(ANSI 표준)으로도, 콤마와 WHERE·(+)(Oracle 전통)로도 쓸 수 있어요. 두 방식 중 어느 쪽이 읽고 유지하기 좋을지 생각해 보세요. 특히 조인 조건과 데이터를 거르는 조건이 WHERE에 뒤섞이는 전통 방식과, 조인 조건은 ON에 따로 두는 표준 방식의 차이가, 표가 셋·넷으로 늘어날 때 어떻게 벌어지는지 떠올려 보세요.
3. 조인이 행을 불리는 문제를 어떻게 다룰까
조인은 짝이 여러 개면 행을 불려요. 좋아요가 3개인 게시물은 좋아요 표와 조인하면 3줄이 돼요. 이 상태로 게시물 수를 세면 부풀려진 값이 나오죠. 이 "행 불어남"이 왜 생기는지, 그리고 정확한 개수를 세려면 DISTINCT를 쓰거나 집계 범위를 어떻게 잡아야 하는지 생각해 보세요. 표 여러 개를 한 번에 이어 각각의 개수를 동시에 셀 때 이 함정이 특히 위험한 이유도 함께 고민해 보세요.
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안이에요. 정답을 그대로 베끼기보다, 왜 그 조인을 그렇게 골랐는지 흐름을 따라와 주세요. "짝이 있는 것만 볼까, 짝 없는 것도 살릴까", "조인 조건을 ON에 둘까 WHERE에 둘까"를 떠올리는 감각을 기르는 게 목표예요.
🎯 [과제 1 예시답안] 두 표를 이어 이름 붙이기
채점 포인트
| 항목 | 배점 | 핵심 |
|---|---|---|
| (가) 댓글 + 회원 EQUI 조인 | 35% | ON c.member_id = m.member_id · 15건 |
| (나) 좋아요 + 게시물 EQUI 조인 | 35% | ON l.post_id = p.post_id · 32건 |
| (다) 건수 검산 | 30% | 댓글 15 · 좋아요 32와 일치 |
풀이 예시
(가) 댓글에 작성자 이름 붙이기
SELECT c.content AS 댓글내용, m.nickname AS 작성자
FROM post_comment c
JOIN member m ON c.member_id = m.member_id;
| 댓글내용 | 작성자 |
|---|---|
| 첫 게시물 축하해요! | 이민지 |
| 시작이 좋네요 ㅎㅎ | 강지수 |
| 감사합니다 민지님 | 김재훈 |
(전체 15건 중 일부예요.) 댓글 표의 member_id와 회원 표의 member_id를 맞춰, 댓글마다 쓴 사람 이름을 붙였어요.
(나) 좋아요가 눌린 게시물 붙이기
SELECT l.member_id AS 누른회원, p.caption AS 게시물
FROM post_like l
JOIN post p ON l.post_id = p.post_id;
좋아요 한 건마다 어느 게시물에 눌렸는지 caption이 붙어 32건이 나와요.
(다) 건수 검산
SELECT COUNT(*) FROM post_comment c JOIN member m ON c.member_id = m.member_id; -- 15
SELECT COUNT(*) FROM post_like l JOIN post p ON l.post_id = p.post_id; -- 32
각각 15건, 32건이에요. 댓글이 15건, 좋아요가 32건이고 모든 댓글·좋아요엔 짝이 되는 회원·게시물이 반드시 있으니, INNER 조인이라도 하나도 안 빠지고 그대로 나와요.
💡 튜터의 한마디 — 조인 결과 건수를 예측하는 감을 기르는 게 핵심이에요. 댓글·좋아요는 반드시 회원·게시물을 가리키니(외래키), INNER 조인을 해도 원래 건수가 그대로 보존돼요. "짝이 빠질 수 있는가?"를 먼저 따지면, INNER로 충분한지 OUTER가 필요한지 바로 판단할 수 있어요.
🎯 [과제 2 예시답안] 짝 없는 행 살리기
채점 포인트
| 항목 | 배점 | 핵심 |
|---|---|---|
(가) LEFT JOIN + 집계 |
35% | 게시물 50개 전부, 0 포함 |
| (나) 좋아요 0 게시물 | 30% | WHERE l.post_id IS NULL · 37건 |
| (다) INNER 비교 | 35% | 50 → 13 · 0인 게시물이 사라짐 |
풀이 예시
(가) 게시물별 좋아요 수 (0 포함)
SELECT p.post_id, COUNT(l.post_id) AS 좋아요수
FROM post p
LEFT JOIN post_like l ON p.post_id = l.post_id
GROUP BY p.post_id
ORDER BY 좋아요수 DESC, p.post_id;
| post_id | 좋아요수 |
|---|---|
| 12 | 6 |
| 1 | 5 |
| 40 | 4 |
| ... | ... |
게시물 50개가 모두 나와요. 12번 게시물이 좋아요 6개로 가장 인기예요. LEFT JOIN이라 좋아요가 0인 게시물도 빠지지 않고, COUNT(l.post_id)가 NULL을 안 세서 정확히 0으로 떨어져요.
(나) 좋아요가 하나도 없는 게시물
SELECT COUNT(*) AS 좋아요0_게시물
FROM post p
LEFT JOIN post_like l ON p.post_id = l.post_id
WHERE l.post_id IS NULL;
| 좋아요0_게시물 |
|---|
| 37 |
좋아요가 0인 게시물이 37개예요. LEFT JOIN으로 살린 게시물 중 좋아요 쪽이 NULL인 것만 골라냈어요.
(다) INNER로 바꾸면
LEFT JOIN을 JOIN(INNER)으로 바꾸면, 게시물 그룹이 50개에서 13개로 줄어요. 좋아요가 한 번이라도 눌린 게시물이 13개뿐이거든요. 나머지 37개는 좋아요 표에 짝이 없어 INNER에서 버려져요. "0개인 게시물이 통째로 사라진다"가 핵심이에요.
💡 튜터의 한마디 — (다)가 오늘의 가장 중요한 함정이에요. "게시물별 좋아요 수"를 묻는데 INNER로 짜면, 좋아요 0개인 게시물이 결과에서 빠져 "게시물이 13개뿐"인 것처럼 보여요. 0을 0으로 보여 줘야 하는 통계라면 반드시 OUTER예요. INNER와 LEFT의 한 글자 차이가 50과 13을 가른다는 걸 손으로 확인해 보세요.
🎯 [과제 3 예시답안] 자기 조인과 방향
채점 포인트
| 항목 | 배점 | 핵심 |
|---|---|---|
| (가) 외팔(짝사랑) 자기 조인 | 40% | OUTER 자기 조인 + IS NULL · 8건 |
| (나) 회원별 팔로잉 수 | 30% | GROUP BY follower_id |
| (다) 팔로워 0명 회원 | 30% | member 왼쪽 OUTER · 6명 |
풀이 예시
(가) 짝사랑(외팔) 찾기
SELECT f1.follower_id AS 따르는사람, f1.following_id AS 따라지는사람
FROM follow f1
LEFT JOIN follow f2 ON f1.follower_id = f2.following_id
AND f1.following_id = f2.follower_id
WHERE f2.follower_id IS NULL;
내가 따르는 관계(f1)에 반대 방향 팔로우(f2)를 LEFT JOIN으로 이어 보고, 그게 NULL인(상대가 나를 안 따르는) 행만 남겼어요. 8건이 나와요. 예를 들어 박승우는 김재훈을 따르는데 김재훈은 박승우를 안 따르니, 이건 외팔이에요.
(나) 회원별 팔로잉 수
SELECT m.nickname, COUNT(f.following_id) AS 팔로잉수
FROM member m
LEFT JOIN follow f ON m.member_id = f.follower_id
GROUP BY m.nickname
ORDER BY 팔로잉수 DESC, m.nickname;
| nickname | 팔로잉수 |
|---|---|
| 김재훈 | 2 |
| 박지훈 | 2 |
| 이민지 | 2 |
| 정하루 | 2 |
| 강지수 | 1 |
| ... | ... |
follower_id(따르는 사람) 기준으로 묶어 세면 "내가 몇 명을 따르는가"가 나와요.
(다) 팔로워가 0명인 회원
SELECT m.nickname
FROM member m
LEFT JOIN follow f ON m.member_id = f.following_id
WHERE f.following_id IS NULL
ORDER BY m.member_id;
회원을 왼쪽에 두고, following_id(따라지는 사람) 기준으로 OUTER 조인한 뒤 짝이 NULL인 회원만 남겼어요. 박승우·최도연·윤태양·한나래·이민수·한소라 6명이에요. 아무도 이들을 팔로우하지 않은 거죠.
💡 튜터의 한마디 — 팔로우는 방향이 있어서, follower_id로 묶으면 팔로잉 수, following_id로 묶으면 팔로워 수예요. 같은 표인데 어느 컬럼을 기준 삼느냐로 의미가 정반대가 돼요. 그리고 "0명"을 찾으려면 OUTER 조인 + IS NULL이 정석이에요. "관계가 없는 것"은 INNER로는 절대 못 찾아요. 관계가 있어야 짝이 맞으니까요.
🤔 [생각해볼 주제 1] INNER 조인과 OUTER 조인, 무엇을 언제 쓸까
문제 상황 요약
INNER는 양쪽에 짝이 있는 행만 남기고, OUTER(LEFT)는 한쪽을 짝이 없어도 다 남겨요. "좋아요가 달린 게시물만"과 "모든 게시물을 0개까지"는 어느 조인을 써야 할까요?
튜터의 가이드 및 해설
기준은 "짝이 없는 쪽을 결과에 남길 것인가"예요. "좋아요가 달린 게시물만" 보고 싶다면 INNER가 맞아요. 좋아요가 없는 게시물은 어차피 안 볼 거니까 버려도 돼요. 반대로 "모든 게시물을 좋아요 0개까지 다" 보여 줘야 한다면 LEFT예요. 좋아요가 없어도 게시물은 남겨야 하니까요.
실수는 보통 "0을 보여 줘야 하는데 INNER를 쓰는" 방향에서 나와요. 게시물별 좋아요 수를 INNER로 세면, 좋아요 0개인 게시물이 통째로 빠져요. 50개여야 할 결과가 13개로 나오는데 에러는 안 나니, 한참 뒤에야 "어, 게시물이 왜 이것밖에 없지?" 하고 알아채요. 그래서 통계나 보고서처럼 "빠짐없이 다 보여 줘야 하는" 화면은 OUTER를 기본으로 두는 게 안전해요.
🎯 SQLD는 이렇게 나온다
같은 데이터에 INNER와 LEFT를 각각 적용한 쿼리를 주고 결과 건수를 비교하는 문제가 단골이에요. 특히 "짝 없는 행이 몇 개 빠지는가"가 핵심이에요. INNER는 양쪽 교집합, LEFT는 왼쪽 전부라는 한 줄을 정확히 기억하고, 짝 없는 행 수를 더하거나 빼서 건수를 계산하면 안 틀려요.
💡 실무에선
"이 조회에서 0이나 없음을 보여 줘야 하는가"를 먼저 물어요. 매출 0인 날, 주문 0건인 회원처럼 "없음"이 의미 있는 정보라면 OUTER가 필수예요. 무심코 INNER로 짜면 그 "없음"들이 조용히 사라져, 평균이나 합계가 실제보다 좋게 나오는 착시가 생겨요. "데이터가 빠진 게 아니라 조인에서 빠진 것"인지 늘 의심하는 습관이 사고를 막아요.
🤔 [생각해볼 주제 2] ANSI 표준 조인과 Oracle 전통 조인, 어느 쪽을 쓸까
문제 상황 요약
같은 조인을 JOIN ... ON(ANSI 표준)으로도, 콤마와 WHERE·(+)(Oracle 전통)로도 쓸 수 있어요. 표가 셋·넷으로 늘어날 때 두 방식의 차이는 어떻게 벌어질까요?
튜터의 가이드 및 해설
핵심은 "조인 조건과 데이터 거르는 조건이 섞이는가"예요. ANSI 표준은 조인 조건을 ON에, 데이터 거르는 조건을 WHERE에 따로 둬요. 역할이 갈려 있어 읽기 좋아요. 반면 전통 방식은 둘 다 WHERE에 들어가요. 표가 둘일 땐 괜찮지만, 넷이 되면 WHERE에 조인 조건 3개와 데이터 조건 여러 개가 한데 뒤섞여요. 어느 게 표를 잇는 조건이고 어느 게 거르는 조건인지 한눈에 안 보이죠.
(+)도 마찬가지예요. OUTER 조인을 (+) 기호 하나로 표시하는데, 기호를 빠뜨리거나 한쪽에만 붙이면 조용히 결과가 틀어져요. 그리고 (+)로는 FULL OUTER JOIN을 아예 못 만들어요. 그래서 새로 짜는 쿼리는 ANSI 표준(JOIN ... ON)을 권하고, 전통 방식은 오래된 코드를 읽기 위해 알아 두는 정도예요.
🎯 SQLD는 이렇게 나온다
같은 조인을 ANSI와 Oracle 전통 두 방식으로 주고 "서로 같은 결과인가", "이 (+)는 LEFT인가 RIGHT인가"를 묻는 문제가 단골이에요. (+)는 짝 없는 쪽에 붙고 FULL은 표현 못 한다는 점, 그리고 콤마 조인에서 조인 조건이 빠지면 카티션 곱이 된다는 점이 핵심 함정이에요.
💡 실무에선
팀의 코드 스타일을 따르되, 새 쿼리는 ANSI 표준으로 통일하는 곳이 많아요. 조인 조건을 ON에 모아 두면, 표가 많아져도 "이 표는 무엇으로 잇는가"가 또렷이 보여 유지보수가 쉬워요. 오래된 (+) 쿼리를 ANSI로 바꾸는 작업도 흔한데, 이때 OUTER 방향을 헷갈리면 결과가 바뀌니 건수를 꼭 비교 검증해요.
🤔 [생각해볼 주제 3] 조인이 행을 불리는 문제를 어떻게 다룰까
문제 상황 요약
조인은 짝이 여러 개면 행을 불려요. 좋아요가 3개인 게시물은 좋아요 표와 조인하면 3줄이 돼요. 이 상태로 게시물 수를 세면 부풀려진 값이 나오죠. 이 "행 불어남"을 어떻게 다뤄야 할까요?
튜터의 가이드 및 해설
먼저 왜 불어나는지부터 봐요. 조인은 한쪽 행이 다른 쪽의 짝과 일일이 만나 행을 만들어요. 게시물 1개에 좋아요가 3개면, 그 게시물은 좋아요 각각과 만나 3줄이 돼요. 게시물 자체는 하나인데 결과엔 3번 나오는 거예요. 이때 COUNT(*)로 게시물을 세면 3으로 잘못 세져요.
해법은 두 가지예요. 하나는 COUNT(DISTINCT p.post_id)로 중복을 걷어내 "서로 다른 게시물"만 세는 거예요. 다른 하나는 집계 범위를 분명히 하는 거예요. "게시물 수"와 "좋아요 수"는 세는 대상이 다르니, 한 쿼리에서 둘을 동시에 COUNT(*)로 세면 둘 다 부풀려진 같은 값이 나와요. 세려는 대상마다 DISTINCT를 정확히 걸거나, 표를 나눠 따로 세야 해요.
🎯 SQLD는 이렇게 나온다
조인 후 행이 불어난 결과를 주고 "게시물은 몇 개인가"를 물으며, 불어난 행 수를 정답인 척 깔아 두는 함정이 자주 나와요. COUNT(*)와 COUNT(DISTINCT 컬럼)의 차이, 그리고 "1:N 조인은 N쪽 개수만큼 행이 늘어난다"는 원리를 기억하면 안 틀려요.
💡 실무에선
여러 표를 한 번에 이어 각각의 개수를 동시에 세는 쿼리가 가장 위험해요. 게시물에 좋아요와 댓글을 함께 조인해 "좋아요 수, 댓글 수"를 한 줄로 세면, 두 수가 서로를 부풀려 둘 다 틀려요. 이럴 땐 좋아요와 댓글을 따로 집계하거나 DISTINCT를 정확히 쓰는 게 정석이에요. "조인하면 행이 늘 수 있다"를 늘 의식하는 게 첫걸음이에요.