문서 읽는 데 54분 · D2

D-2: 서브쿼리 — 쿼리 속의 쿼리, 한 조회를 다른 조회의 재료로

목차 29
전체 24강 중 14강 · 데이터베이스
난이도 · 입문

ℹ️Oracle SQL로 SQLD 자격증을 대비하며 실전 SQL을 쌓는 트랙이에요. 프로그래밍 경험이 없어도 시작할 수 있고, 부트캠프에서는 자바 기초 다음·스프링 부트 직전에 배치하길 권해요.

안녕하세요, 홍순구 튜터입니다. 지난 시간엔 두 조회 결과를 집합처럼 위아래로 포개는 집합 연산을 배웠어요. UNION 으로 합치고, INTERSECT 로 겹치는 것만 남기고, MINUS 로 뺐죠. 그런데 마지막에 아쉬운 점 하나를 남겨 뒀어요. 그때 쓴 두 조회는 서로 독립적이었다는 거예요. 한쪽 결과를 다른 쪽이 들여다보진 못했죠.

예를 들어 "평균보다 좋아요를 많이 받은 게시물" 을 찾으려면, 먼저 평균이 얼마인지 구하고 그 값을 조건으로 다시 걸러야 해요. 한 조회의 결과를 다른 조회가 재료로 받아 써야 하는 거죠. 오늘 배우는 서브쿼리가 바로 그 일을 해요. 쿼리 안에 또 다른 쿼리를 중첩하는, 한 단계 깊은 SQL 이에요.

텍스트
 오늘의 여정
   ① 서브쿼리란 — 단일행 서브쿼리 (= < > 오른쪽에 한 값)
   ② IN / NOT IN — 여러 값 목록과 견주기
   ③ NOT IN 의 NULL 함정 — 공집합이 되는 사고
   ④ ANY / ALL — 최소·최대와 비교하기
   ⑤ 상관 서브쿼리 — 바깥 행마다 다시 도는 안쪽 쿼리
   ⑥ EXISTS — 있느냐 없느냐로 묻기
   ⑦ 인라인 뷰 — 서브쿼리를 표처럼 FROM 에
   ⑧ WITH — 서브쿼리에 이름 붙이기

💡 오늘 수업의 핵심 — "쿼리 속에 쿼리를 중첩해, 한 조회의 결과를 다른 조회의 조건·재료·표로 쓴다. 평균보다 좋아요 많은 게시물을 한 쿼리로 가려낸다"

🎯 학습 목표

  • 서브쿼리의 세 위치(WHERE·FROM·SELECT)와 반환 형태(단일행·다중행·다중컬럼)에 따른 종류를 이해한다. (SQLD 2과목 'SQL 활용 — 서브쿼리')
  • IN·ANY·ALL·EXISTS 의 차이와 NOT IN + NULL 함정을 익힌다.
  • 상관 서브쿼리·인라인 뷰·WITH(CTE)로 점점 복잡해지는 질문을 단계적으로 푼다.

Step 1: "쿼리 속의 쿼리 — 단일행 서브쿼리"

지금까지 우리가 쓴 SELECT 는 표에서 곧장 답을 골라 왔어요. 그런데 가끔은 답을 고르기 전에 먼저 다른 걸 알아내야 할 때가 있어요. "1번 게시물을 쓴 사람의 이름이 뭐지?" 를 생각해 보세요. 우리는 member 표에 이름이 있다는 걸 알지만, 1번 게시물을 누가 썼는지는 post 표를 봐야 알 수 있어요. 두 단계를 거쳐야 하죠.

먼저 이렇게 풀 수 있어요. post 표에서 1번 게시물의 작성자 번호를 알아내고요.

SQL
SELECT member_id FROM post WHERE post_id = 1;

이 조회는 1 한 값을 돌려줘요. 이제 이 값을 member 표 조회의 조건으로 넣으면 되죠. 그런데 이 두 단계를 한 번에 합칠 수 있어요. 안쪽 조회를 괄호로 감싸 바깥 조회의 조건으로 그대로 넣는 거예요.

SQL
-- sql/queries/D2_subquery.sql
SELECT username, nickname
FROM   member
WHERE  member_id = (SELECT member_id FROM post WHERE post_id = 1);

괄호 안의 SELECT 가 먼저 실행돼 1 을 내놓고, 바깥 SELECTWHERE member_id = 1 처럼 동작해요. 결과는 한 줄, jaehoon 김재훈이에요. 이렇게 다른 쿼리 안에 들어간 쿼리를 서브쿼리(subquery), 바깥 쿼리를 메인 쿼리라고 불러요.

여기서 안쪽 서브쿼리는 값 하나만 돌려줬어요. 이렇게 딱 한 행, 한 값을 돌려주는 서브쿼리를 단일행 서브쿼리라고 해요. 값이 하나니까 =·<·>·<=·>=·<> 같은 비교 연산자의 오른쪽에 자연스럽게 들어갈 수 있죠.

이번엔 조건이 여러 줄을 돌려줘도 되는 경우를 봐요. "12번 게시물을 올린 사람이 쓴 모든 게시물" 을 찾아볼게요.

SQL
SELECT post_id, caption
FROM   post
WHERE  member_id = (SELECT member_id FROM post WHERE post_id = 12)
ORDER  BY post_id;

안쪽은 여전히 한 값(6, 강지수)을 돌려주고, 바깥은 그 회원이 쓴 게시물을 모아 와요. 결과는 5줄이에요 — 12·29·30·39·48번. 서브쿼리는 한 값을 줬지만 메인 쿼리 결과는 여러 줄일 수 있다는 점, 헷갈리지 마세요. 서브쿼리가 단일행이라는 건 안쪽이 한 값을 준다는 뜻이지, 바깥 결과가 한 줄이라는 뜻이 아니에요.

그럼 안쪽 서브쿼리가 실수로 여러 줄을 돌려주면 어떻게 될까요? = 는 한 값하고만 비교할 수 있는데 말이죠. (★빈출)

SQL
-- 안쪽이 50줄(모든 게시물의 작성자)을 돌려주는데 = 로 비교
SELECT nickname FROM member WHERE member_id = (SELECT member_id FROM post);

이러면 "단일행 서브쿼리가 한 행을 넘겨준다(ORA-01427)" 는 에러가 떠요. = 의 오른쪽엔 값이 하나여야 하는데 50개가 쏟아지니 비교할 수가 없는 거죠. 단일행 비교 연산자를 쓸 땐 안쪽이 정말 한 값만 돌려주는지 늘 확인해야 해요.

⚠️ 함정=·<·> 같은 단일행 비교 연산자의 오른쪽 서브쿼리가 두 행 이상을 돌려주면 ORA-01427 로 멈춰요. 여러 행이 나올 수 있는 조건이면 = 대신 다음에 배울 IN 을 써야 해요. SQLD 는 "이 서브쿼리가 단일행인가 다중행인가" 를 자주 물어요. ★빈출

오늘의 목표였던 "평균보다 좋아요 많은 게시물" 도 사실 같은 원리예요. 먼저 평균 좋아요 수를 한 값으로 구하고, 그 값을 > 의 오른쪽에 넣으면 되죠. 다만 평균을 구하는 안쪽 쿼리가 조금 더 손이 가서, 그건 뒤쪽에서 인라인 뷰까지 배운 뒤에 완성할게요.

💡 한 줄 정리

서브쿼리는 쿼리 안에 중첩된 또 다른 쿼리이고, 한 값을 돌려주는 단일행 서브쿼리는 =·<·> 같은 비교 연산자의 오른쪽에 넣어 쓴다.

🙋 학생 질문 — "튜터님, 서브쿼리랑 지난 시간에 배운 조인은 뭐가 다른가요? 둘 다 표 두 개를 쓰잖아요."

좋은 질문이에요. 조인은 두 표를 옆으로 이어 붙여서 한 줄에 양쪽 컬럼을 같이 보여줘요. 반면 서브쿼리는 안쪽 조회의 결과를 바깥 조회가 조건이나 재료로 받아 쓸 뿐, 안쪽 표의 컬럼이 최종 결과에 나오진 않아요. 방금 예로 보면 1번 게시물 작성자를 알아내는 데 post 표를 썼지만, 최종 결과엔 member 표의 이름만 나왔죠. "양쪽 컬럼을 한 줄에 같이 봐야 한다" 면 조인이, "한쪽은 조건을 정하는 데만 쓰고 결과엔 안 나와도 된다" 면 서브쿼리가 잘 맞아요. 사실 많은 문제는 둘 다로 풀 수 있고, 어느 쪽이 더 읽기 쉬운지로 고르게 돼요.


Step 2: "여러 값과 견주기 — IN 과 NOT IN"

Step 1 끝에서 단일행 비교 연산자는 안쪽이 한 값일 때만 쓸 수 있다고 했어요. 그런데 현실의 질문은 대부분 여러 값과 견줘요. "좋아요를 한 번이라도 받은 게시물" 을 떠올려 보세요. 좋아요가 눌린 게시물 번호는 한둘이 아니라 여러 개죠. 이렇게 안쪽 서브쿼리가 여러 행을 돌려줄 때 쓰는 게 IN 이에요. "이 값이 그 목록 안에 있는가?" 를 묻는 연산자죠.

post_like 표에는 좋아요 기록이 들어 있어요. 거기서 게시물 번호만 뽑으면 "좋아요가 한 번이라도 눌린 게시물 번호 목록" 이 돼요. 그 목록 안에 있는 게시물만 골라 볼게요.

SQL
-- sql/queries/D2_subquery.sql
SELECT post_id, caption
FROM   post
WHERE  post_id IN (SELECT post_id FROM post_like)
ORDER  BY post_id;

안쪽 서브쿼리가 좋아요 눌린 게시물 번호들을 모아 오고, 바깥은 그 목록에 속한 게시물만 남겨요. 결과는 13줄이에요. 게시물 50개 중 13개만 좋아요를 받았다는 뜻이죠.

지난 시간에 배운 INTERSECT 가 떠오르지 않나요? "양쪽에 다 있는 것만" 남기는 교집합이었죠. post_id IN (서브쿼리) 도 비슷한 일을 해요. 다만 집합 연산은 두 조회를 대등하게 포개는 반면, 서브쿼리는 바깥이 주인이고 안쪽은 조건을 대주는 관계라는 차이가 있어요.

반대로 "좋아요를 한 번도 못 받은 게시물" 은 NOT IN 으로 뒤집으면 돼요.

SQL
SELECT COUNT(*) AS unliked_cnt
FROM   post
WHERE  post_id NOT IN (SELECT post_id FROM post_like);

목록에 없는 게시물만 세니 37이 나와요. 13개는 받았고 37개는 못 받았으니 합쳐서 50개, 딱 맞죠. 이 NOT IN 은 지난 시간의 MINUS(차집합)와 통하는 데가 있어요. "전체에서 좋아요 받은 것을 뺀 나머지" 니까요.

그런데 여기엔 다음 Step 에서 따로 다룰 만큼 중요한 함정이 숨어 있어요. 방금은 post_like.post_id 가 NULL 이 될 수 없는 컬럼이라 NOT IN 이 얌전히 동작했어요. 안쪽 목록에 NULL 이 섞이는 순간 이야기가 완전히 달라지는데, 그건 잠시 뒤에 볼게요.

마지막으로 IN 을 한 컬럼이 아니라 두 컬럼을 묶어서 쓸 수도 있어요. "각 회원이 가장 최근에 올린 게시물" 을 찾아볼게요. 회원마다 최신 작성일을 구한 뒤, (회원번호, 그 최신일) 쌍과 정확히 맞아떨어지는 게시물을 고르는 거예요.

SQL
SELECT post_id, member_id, created_at
FROM   post
WHERE  (member_id, created_at) IN (SELECT member_id, MAX(created_at)
                                   FROM   post
                                   GROUP  BY member_id)
ORDER  BY member_id;

괄호로 두 컬럼을 묶어 (member_id, created_at) 쌍을 통째로 비교했어요. 이렇게 짝을 지어 비교하는 걸 다중컬럼 서브쿼리라고 해요. 결과는 9줄 — 글을 한 번이라도 쓴 회원이 9명이고, 그 9명 각자의 가장 최근 게시물이 한 줄씩 나온 거예요.

⚠️ 함정IN 의 오른쪽 서브쿼리는 여러 행을 돌려줘도 괜찮지만, 다중컬럼으로 쓸 땐 바깥의 컬럼 개수·순서와 안쪽 SELECT 의 컬럼이 정확히 짝을 이뤄야 해요. (member_id, created_at) 인데 안쪽이 (created_at, member_id) 순이면 엉뚱한 결과가 나와요. ★빈출

💡 한 줄 정리

안쪽 서브쿼리가 여러 행을 돌려줄 땐 IN(목록에 있는가)·NOT IN(목록에 없는가)으로 견주고, 컬럼을 괄호로 묶으면 두 값을 짝지어 비교하는 다중컬럼 서브쿼리가 된다.

🙋 학생 질문 — "튜터님, IN 안에 (1, 3, 5) 처럼 값을 직접 적던 거랑 서브쿼리를 넣는 거랑 같은 건가요?"

네, 정확히 같은 IN 이에요. C-1 에서 WHERE member_id IN (1, 3, 5) 처럼 값을 손으로 적었죠. 그때는 목록을 우리가 직접 정해 줬고, 지금은 그 목록을 서브쿼리가 만들어 줄 뿐이에요. IN 입장에선 "오른쪽에 값들의 목록이 온다" 는 점이 똑같아요. 차이는 손으로 적은 목록은 고정이지만, 서브쿼리 목록은 데이터가 바뀌면 자동으로 따라 바뀐다는 거예요. 좋아요가 새로 눌리면 IN (SELECT post_id FROM post_like) 의 목록도 저절로 늘어나죠. 그래서 "지금 데이터 기준으로 목록을 뽑아야 할 때" 는 값을 직접 적는 대신 서브쿼리를 넣어요.


Step 3: "⚠️ NOT IN 의 함정 — NULL 한 방울"

이번 Step 은 통째로 함정 하나에 바쳐요. SQLD 서브쿼리 단원의 단골 출제 포인트이고, 실무에서도 베테랑이 종종 당하는 미묘한 사고예요. 바로 NOT IN 서브쿼리에 NULL 이 섞이는 경우예요.

상황을 만들어 볼게요. 우리 post_comment(댓글) 표에는 대댓글이 있어요. 대댓글은 parent_comment_id 에 "내가 누구한테 단 답글인지" 부모 댓글 번호가 적혀 있고, 최상위 댓글은 거기가 NULL 이에요. 댓글 15개 중 대댓글은 3개뿐이라 나머지 12개는 parent_comment_id 가 NULL 이죠.

이제 "한 번도 누군가의 부모가 된 적 없는 댓글" 을 찾고 싶다고 해봐요. 즉 아무도 거기에 답글을 달지 않은 댓글이요. 자연스럽게 이렇게 쓰게 돼요.

SQL
-- sql/queries/D2_subquery.sql
SELECT comment_id
FROM   post_comment
WHERE  comment_id NOT IN (SELECT parent_comment_id FROM post_comment);

안쪽은 "부모로 지목된 댓글 번호" 목록이고, 바깥은 그 목록에 없는 댓글을 고르려는 거예요. 부모가 된 댓글은 3개({1, 6, 8})뿐이니, 상식적으로는 나머지 12개가 나와야 할 것 같죠. 그런데 실행하면 결과가 0줄이에요. 한 줄도 안 나와요.

왜 이런 일이 벌어질까요? 안쪽 서브쿼리가 돌려준 목록을 들여다보면 {1, 6, 8, NULL, NULL, ...} 처럼 NULL 이 잔뜩 섞여 있어요. 12개 댓글의 parent_comment_id 가 NULL 이니까요. 그런데 NOT IN 은 속으로 이렇게 풀려요.

텍스트
 comment_id NOT IN (1, 6, 8, NULL)
   = comment_id <> 1  AND  comment_id <> 6  AND  comment_id <> 8  AND  comment_id <> NULL

C-3 에서 배운 걸 떠올려 보세요. 어떤 값을 NULL 과 <>(다른가?) 로 비교하면 결과는 참도 거짓도 아닌 UNKNOWN 이었죠. 그리고 AND 로 묶인 조건에 UNKNOWN 이 하나라도 끼면 전체가 참이 될 수 없어요. 그래서 모든 행이 조건을 통과하지 못하고, 결과가 통째로 공집합이 돼버려요. 단 하나의 NULL 이 쿼리 전체를 빈손으로 만든 거예요.

고치는 법은 간단해요. 안쪽 서브쿼리에서 NULL 을 미리 걷어내면 돼요.

SQL
SELECT comment_id
FROM   post_comment
WHERE  comment_id NOT IN (SELECT parent_comment_id
                          FROM   post_comment
                          WHERE  parent_comment_id IS NOT NULL)
ORDER  BY comment_id;

WHERE parent_comment_id IS NOT NULL 한 줄로 목록에서 NULL 을 빼니, 이제 의도대로 12줄이 나와요. 부모가 된 {1, 6, 8} 을 뺀 나머지 댓글들이죠.

여기서 지난 시간의 MINUS 와 비교하면 재밌어요. 같은 "빼기" 인데 MINUS 는 NULL 이 섞여도 멀쩡히 동작했어요. NULL 끼리는 같은 값으로 쳐서 잘 빼줬거든요. 반면 NOT IN 은 NULL 한 방울에 무너졌죠. 의도가 같아도 도구에 따라 NULL 을 다루는 방식이 다르다는 걸 꼭 기억하세요.

⚠️ 함정NOT IN 서브쿼리의 결과에 NULL 이 하나라도 섞이면 전체 결과가 공집합이 돼요. NOT IN 을 쓸 땐 안쪽 컬럼이 NULL 일 수 있는지 반드시 확인하고, 그렇다면 IS NOT NULL 로 걸러내세요. (이 함정이 없는 더 안전한 대안은 Step 6 의 NOT EXISTS 예요.) ★빈출

💡 한 줄 정리

NOT IN 서브쿼리 결과에 NULL 이 섞이면 모든 비교가 UNKNOWN 이 되어 결과가 공집합이 되므로, 안쪽에서 IS NOT NULL 로 NULL 을 걸러내야 한다.

🙋 학생 질문 — "튜터님, 그럼 IN(NOT 없는) 도 NULL 이 섞이면 위험한가요?"

INNOT IN 만큼 위험하진 않아요. IN 은 "목록 중 하나라도 같으면 참" 인데, 같은 값을 실제로 찾으면 NULL 이 섞여 있어도 그 행은 참으로 잘 통과해요. NULL 때문에 손해 보는 건 "목록에 없는 행" 인데, 어차피 그런 행은 IN 에선 빠지는 게 맞으니 결과가 크게 어긋나지 않아요. 문제가 되는 건 NOT IN 이에요. "목록에 없어야 통과" 인데 NULL 과의 비교가 UNKNOWN 이 되면서 통과해야 할 행까지 전부 막혀버리거든요. 그래서 NULL 걱정은 NOT IN 에서 특히 날을 세워야 해요. 정리하면, IN 은 대체로 안전, NOT IN 은 NULL 점검 필수예요.


Step 4: "ANY 와 ALL — 최소·최대와 견주기"

IN 은 "목록 중 하나와 같은가" 를 물었어요. 그런데 "목록의 값들보다 큰가, 작은가" 처럼 크기를 비교하고 싶을 때가 있어요. 이때 쓰는 게 ANYALL 이에요. 둘 다 서브쿼리가 돌려준 여러 값과 한꺼번에 크기를 견주는 연산자인데, 의미가 정반대라 헷갈리기 쉬워요. 천천히 잡아 볼게요.

예제로 회원 5번(최도연)이 쓴 게시물들의 caption 글자 수를 기준으로 삼을게요. 회원 5번은 게시물 4개를 썼고, 각 caption 의 글자 수는 7·9·10·13 이에요. (LENGTH 는 C-2 에서 배운 글자 수 함수죠.)

먼저 > ALL 이에요. ALL 은 "모두" 니까, > ALL (7, 9, 10, 13) 은 "이 네 값 모두보다 큰가", 즉 가장 큰 13 보다도 큰가를 물어요.

SQL
-- sql/queries/D2_subquery.sql
SELECT COUNT(*) AS gt_all_cnt
FROM   post
WHERE  LENGTH(caption) > ALL (SELECT LENGTH(caption) FROM post WHERE member_id = 5);

결과는 7이에요. caption 글자 수가 13 보다 큰 게시물이 7개라는 뜻이죠. > ALL 은 결국 "안쪽의 최댓값보다 크냐" 와 같아요.

이번엔 > ANY 예요. ANY 는 "어느 하나라도" 니까, > ANY (7, 9, 10, 13) 은 "이 중 어느 하나보다라도 큰가", 즉 가장 작은 7 보다 크기만 하면 참이에요.

SQL
SELECT COUNT(*) AS gt_any_cnt
FROM   post
WHERE  LENGTH(caption) > ANY (SELECT LENGTH(caption) FROM post WHERE member_id = 5);

결과는 34예요. 글자 수가 7 보다 큰 게시물이 34개죠. > ANY 는 "안쪽의 최솟값보다 크냐" 와 같아요.

정리하면 이렇게 갈려요.

텍스트
 > ALL    안쪽 값 "모두" 보다 커야 한다     최댓값보다 큼   (13 초과  7개)
 > ANY    안쪽 값 "어느 하나" 보다 크면     최솟값보다 큼   ( 7 초과  34개)

방향을 뒤집어 < ALL 은 최솟값보다 작은가, < ANY 는 최댓값보다 작은가가 돼요. 부등호와 ANY/ALL 조합이 헷갈릴 땐 "ALL 은 모두라 까다롭고(최대·최소까지 넘어야 함), ANY 는 하나라도라 너그럽다" 로 기억하면 편해요.

한 가지 더. = ANY 는 사실 IN 과 똑같아요. "어느 하나와 같으면 참" 이니까요.

SQL
SELECT COUNT(*) AS eq_any_cnt
FROM   post
WHERE  post_id = ANY (SELECT post_id FROM post_like);

이건 Step 2 의 IN 예제와 똑같이 13이 나와요. IN= ANY 의 다른 이름인 셈이죠.

⚠️ 함정> ANY 는 최솟값 기준, > ALL 은 최댓값 기준이라 의미가 정반대예요. SQLD 는 > ANY·< ALL 같은 조합의 결과를 자주 물어요. "ALL=최대·최소까지, ANY=하나만" 으로 방향을 외워 두세요. ★빈출

💡 한 줄 정리

ANY 는 서브쿼리 값 중 어느 하나와의 비교(> ANY = 최솟값 초과), ALL 은 모든 값과의 비교(> ALL = 최댓값 초과)이며, = ANYIN 과 같다.

🙋 학생 질문 — "튜터님, > ANY 가 최솟값보다 크면 참인 거면, 그냥 > (최솟값) 으로 쓰면 안 되나요?"

결과만 보면 같아요. > ANY (서브쿼리)> (서브쿼리의 최솟값) 과 같은 행을 골라내죠. 그런데 우리가 안쪽 값들의 최솟값이 얼마인지 미리 모를 때가 많아요. 회원 5번의 게시물 글자 수가 7·9·10·13 이라는 걸 방금 세어 봐서 알지만, 데이터가 바뀌면 최솟값도 바뀌잖아요. > ANY 를 쓰면 "안쪽에서 알아서 가장 작은 값을 찾아 그보다 큰지 비교해라" 라고 한 번에 맡길 수 있어요. 최솟값을 따로 구하는 수고를 덜어주는 거죠. 그리고 안쪽이 MIN·MAX 로 깔끔하게 떨어지지 않는 복잡한 조회일 때도 ANY·ALL 한 글자로 의도를 분명히 드러낼 수 있어요.


Step 5: "바깥을 들여다보는 서브쿼리 — 상관 서브쿼리"

지금까지 본 서브쿼리는 모두 "안쪽이 먼저 혼자 실행돼 값을 내놓고, 그 값을 바깥이 받아 쓰는" 형태였어요. 안쪽 쿼리는 바깥을 전혀 몰라도 됐죠. 그래서 안쪽은 딱 한 번만 실행됐어요. 그런데 오늘 배울 상관 서브쿼리(correlated subquery)는 좀 달라요. 안쪽 쿼리가 바깥 쿼리의 값을 들여다봐요. 그래서 바깥 행이 바뀔 때마다 안쪽이 다시 실행돼요.

예제로 "게시물을 6개 이상 쓴 회원" 을 찾아볼게요.

SQL
-- sql/queries/D2_subquery.sql
SELECT m.member_id, m.nickname
FROM   member m
WHERE  (SELECT COUNT(*) FROM post p WHERE p.member_id = m.member_id) >= 6
ORDER  BY m.member_id;

안쪽 서브쿼리를 잘 보세요. WHERE p.member_id = m.member_id 에서 m.member_id 는 바깥 member m 의 컬럼이에요. 안쪽 post 쿼리가 바깥 회원의 번호를 가져다 쓰고 있죠. 그래서 동작이 이래요. 바깥이 1번 회원을 보면 안쪽은 "1번이 쓴 게시물 수" 를 세고, 2번 회원으로 넘어가면 안쪽이 다시 "2번이 쓴 게시물 수" 를 세요. 회원 한 명 한 명마다 안쪽 쿼리가 새로 도는 거예요.

결과는 5명이에요 — 김재훈·이민지·박승우·정하루·김유나. 각자 게시물을 6개 이상 썼죠. 회원 12명을 훑으면서 안쪽 카운트가 12번 다시 계산된 셈이에요.

앞 Step 들의 일반 서브쿼리와 비교하면 차이가 또렷해요.

텍스트
 일반 서브쿼리   안쪽이 바깥과 무관     딱 한 번 실행  그 값을 바깥이 재사용
 상관 서브쿼리   안쪽이 바깥 컬럼 참조  바깥 행마다 다시 실행

상관 서브쿼리는 "바깥의 각 행에 대해, 그 행과 관련된 무언가를 안쪽에서 계산" 할 때 써요. 방금처럼 회원별로 자기 게시물 수를 세는 것처럼요. 안쪽이 바깥을 참조하니, 안쪽만 떼어내선 실행할 수 없다는 점도 일반 서브쿼리와 달라요. m.member_idm 이 없으면 안쪽은 무슨 회원인지 알 수 없으니까요.

💡 한 줄 정리

상관 서브쿼리는 안쪽 쿼리가 바깥 쿼리의 컬럼을 참조해 바깥 행마다 다시 실행되며, "각 행에 대해 관련된 값을 계산" 할 때 쓴다.

🙋 학생 질문 — "튜터님, 상관 서브쿼리는 행마다 다시 돈다면서요. 그럼 엄청 느리지 않나요?"

행마다 다시 돈다는 건 어디까지나 "의미상" 그렇게 동작한다는 뜻이에요. 우리가 결과를 이해할 땐 "바깥 행마다 안쪽이 다시 계산된다" 고 보면 정확해요. 다만 실제 데이터베이스는 똑똑해서, 이런 쿼리를 내부적으로 조인이나 그룹 계산으로 바꿔 한 번에 처리하는 경우가 많아요. 그러니 "상관 서브쿼리 = 무조건 느림" 이라고 단정할 필욘 없어요. 물론 데이터가 아주 많고 안쪽이 무거우면 부담이 될 수 있어서, 같은 결과를 조인이나 다음에 배울 인라인 뷰로 바꿔 쓰기도 해요. 지금 단계에선 "상관 서브쿼리가 어떻게 동작하는지" 를 정확히 이해하는 데 집중하면 충분해요. 성능을 다듬는 건 그다음 이야기예요.


Step 6: "있느냐 없느냐 — EXISTS vs IN"

상관 서브쿼리를 배웠으니, 그 짝꿍인 EXISTS 를 볼 차례예요. EXISTS 는 값을 비교하는 게 아니라 "안쪽 조회 결과가 한 줄이라도 있느냐 없느냐" 만 따져요. 있으면 참, 없으면 거짓이죠. 주로 상관 서브쿼리와 함께 써요.

"댓글이 하나라도 달린 게시물" 을 찾아볼게요.

SQL
-- sql/queries/D2_subquery.sql
SELECT COUNT(*) AS commented_cnt
FROM   post p
WHERE  EXISTS (SELECT 1 FROM post_comment c WHERE c.post_id = p.post_id);

안쪽은 "바깥 게시물 p 에 달린 댓글" 을 찾아요. c.post_id = p.post_id 로 바깥을 참조하니 이것도 상관 서브쿼리죠. EXISTS 는 그 댓글이 한 줄이라도 있으면 그 게시물을 통과시켜요. 결과는 9, 댓글이 달린 게시물이 9개예요.

여기서 SELECT 1 이 눈에 띄죠? EXISTS 는 "있는지 없는지" 만 보니까 안쪽에서 무슨 컬럼을 가져오는지는 중요하지 않아요. 그래서 관례로 SELECT 1 처럼 아무 상수나 적어요. SELECT * 를 써도 결과는 같아요. "존재 여부만 본다" 는 뜻을 분명히 하려고 1 을 적는 거예요.

반대로 "댓글이 하나도 없는 게시물" 은 NOT EXISTS 예요.

SQL
SELECT COUNT(*) AS uncommented_cnt
FROM   post p
WHERE  NOT EXISTS (SELECT 1 FROM post_comment c WHERE c.post_id = p.post_id);

결과는 41이에요. 9 + 41 = 50, 딱 맞죠.

자, 이제 두 도구를 모두 배웠으니 INEXISTS 를 나란히 견줘 볼게요. 둘 다 "어떤 조건을 만족하는 행이 있는지" 를 묻는다는 점에선 비슷하지만, 묻는 방식이 달라요.

구분 IN EXISTS
묻는 방식 안쪽이 값 목록을 만들고, 바깥 값이 그 안에 있는지 바깥 행마다 안쪽에 맞는 게 있는지
서브쿼리 보통 일반 서브쿼리 (목록을 한 번 만듦) 보통 상관 서브쿼리 (행마다 확인)
NULL NOT IN 은 NULL 에 취약 (Step 3) NOT EXISTS 는 NULL 에 안전

마지막 줄이 특히 중요해요. Step 3 에서 NOT IN 이 NULL 한 방울에 무너지는 걸 봤죠? 같은 일을 NOT EXISTS 로 하면 그 함정이 없어요. EXISTS 는 값을 <> 로 비교하는 게 아니라 그냥 "행이 있나" 만 보기 때문에, NULL 이 끼어들 틈이 없거든요. 그래서 "목록에 없는 것" 을 안전하게 찾고 싶을 땐 NOT IN 보다 NOT EXISTS 가 더 든든한 선택이에요.

⚠️ 함정NOT IN 은 서브쿼리에 NULL 이 있으면 공집합이 되지만, NOT EXISTS 는 NULL 과 무관하게 안전해요. "없는 것 찾기" 에서 NULL 가능성이 있으면 NOT EXISTS 를 우선 고려하세요. SQLD 단골 비교 포인트예요. ★빈출

💡 한 줄 정리

EXISTS 는 값이 아니라 "행이 있느냐 없느냐" 를 묻는 연산자로 상관 서브쿼리와 함께 쓰며, NOT EXISTSNOT IN 의 NULL 함정이 없는 안전한 대안이다.

🙋 학생 질문 — "튜터님, 그럼 IN 은 안 쓰고 항상 EXISTS 만 쓰면 안 되나요? 더 안전하다면서요."

안전하다는 건 주로 NOT 을 붙인 "없는 것 찾기" 에서 그래요. NULL 함정 때문에요. 하지만 둘은 잘 맞는 쓰임이 조금씩 달라요. 안쪽에서 작고 고정된 목록이 나오는 경우엔 IN 이 읽기에 더 직관적이에요. "이 값이 이 목록 안에 있나" 가 눈에 바로 들어오죠. 반대로 바깥 행 하나하나에 대해 "관련된 게 있는지" 를 확인하는 경우엔 EXISTS 가 자연스럽고요. 옛날엔 "데이터 양에 따라 뭐가 빠르다" 는 이야기도 많았는데, 요즘 데이터베이스는 둘을 비슷하게 최적화해 주는 편이라 성능보다는 "어느 쪽이 의도를 잘 드러내는가" 로 고르면 돼요. 다만 NOT IN 으로 없는 걸 찾을 땐 NULL 을 꼭 떠올리고, 미심쩍으면 NOT EXISTS 로 가는 습관을 들이세요.


Step 7: "서브쿼리를 표처럼 — 인라인 뷰"

지금까지 서브쿼리는 WHERE 절(조건)에 들어갔어요. 그런데 서브쿼리는 FROM 절에도 들어갈 수 있어요. FROM 뒤엔 원래 표 이름이 오는데, 거기에 서브쿼리를 넣으면 그 조회 결과를 마치 임시 표처럼 쓸 수 있어요. 이렇게 FROM 절에 들어간 서브쿼리를 인라인 뷰(inline view)라고 불러요. "그 위치에 끼워 넣은 가상의 표" 라는 뜻이에요.

이게 왜 필요할까요? 드디어 오늘의 목표인 "평균보다 좋아요 많은 게시물" 을 풀 때가 됐거든요. 이걸 풀려면 두 단계가 필요해요. 먼저 게시물마다 좋아요 수를 세고, 그다음 그 좋아요 수가 평균보다 큰 것만 골라야 하죠. 그런데 post_like 표엔 좋아요 수라는 컬럼이 없어요. 좋아요 기록이 한 줄씩 들어 있을 뿐이죠. 그러니 "게시물별 좋아요 수" 라는 표를 먼저 만들어야 해요.

C-4 에서 배운 GROUP BY 로 게시물별 좋아요 수를 셀 수 있어요. 이 조회를 FROM 절에 인라인 뷰로 넣어, 그 위에서 다시 거르는 거예요.

SQL
-- sql/queries/D2_subquery.sql
SELECT post_id, like_cnt
FROM   (SELECT post_id, COUNT(*) AS like_cnt
        FROM   post_like
        GROUP  BY post_id)
WHERE  like_cnt > (SELECT AVG(cnt)
                   FROM   (SELECT COUNT(*) AS cnt FROM post_like GROUP BY post_id))
ORDER  BY like_cnt DESC, post_id;

FROM 괄호 안의 인라인 뷰가 "게시물 번호 + 좋아요 수" 라는 임시 표를 만들어요. 바깥은 그 임시 표를 진짜 표처럼 다루면서, like_cnt 가 평균보다 큰 행만 골라요. 평균은 또 다른 서브쿼리로 구했고요. 좋아요를 받은 게시물들의 평균 좋아요 수가 2.46 이에요.

결과는 5줄이에요.

post_id like_cnt
12 6
1 5
40 4
5 3
18 3

지난 시간 마지막에 "아직은 못 한다" 고 미뤄 뒀던 바로 그 질문이에요. 평균 좋아요(2.46)를 넘긴 게시물 5개가 깔끔하게 나왔죠. 인라인 뷰 덕분에 "먼저 좋아요 수를 세서 임시 표로 만들고, 그 위에서 평균과 비교" 하는 두 단계를 한 쿼리에 담을 수 있었어요.

인라인 뷰가 없으면 이런 "집계한 결과를 다시 집계·비교" 하는 질문은 풀기 어려워요. WHERE 는 개별 행만 보지 집계 결과를 직접 조건으로 걸 수 없거든요(그래서 C-4 에서 HAVING 을 따로 배웠죠). 인라인 뷰는 "한 번 집계한 결과를 새 표로 만들어 그 위에서 또 다루게" 해줘서, 분석 쿼리의 폭을 크게 넓혀 줘요.

⚠️ 함정 — 인라인 뷰 안에서 COUNT(*) 같은 집계에 별칭(like_cnt)을 붙이지 않으면, 바깥에서 그 값을 컬럼 이름으로 가리킬 수 없어요. 인라인 뷰의 계산 컬럼엔 반드시 별칭을 달아 바깥에서 부를 이름을 만들어 주세요. ★빈출

💡 한 줄 정리

FROM 절에 넣은 서브쿼리(인라인 뷰)는 그 조회 결과를 임시 표처럼 쓰게 해줘, "집계한 결과를 다시 거르고 비교" 하는 두 단계 질문을 한 쿼리로 풀게 해준다.

🙋 학생 질문 — "튜터님, 인라인 뷰랑 C-4 에서 배운 HAVING 이랑 비슷한 것 같은데 뭐가 달라요?"

겹치는 데가 있어요. 둘 다 "집계한 값으로 거르기" 를 할 수 있거든요. 방금 예제도 사실 GROUP BY post_id HAVING COUNT(*) > ... 로 풀 수도 있어요. 차이는 이래요. HAVING 은 그 집계 결과를 "거르기" 만 할 수 있어요. 반면 인라인 뷰는 집계 결과를 임시 표로 만들어 두기 때문에, 거르는 것뿐 아니라 그 위에서 또 조인하거나, 또 집계하거나, 정렬하거나, 다른 서브쿼리와 엮는 등 훨씬 자유롭게 다룰 수 있어요. 방금처럼 "게시물별 좋아요 수의 평균" 같은 "집계한 값을 다시 집계" 하는 일은 HAVING 하나로는 안 되고 인라인 뷰가 필요하죠. 간단한 거르기는 HAVING, 집계 결과를 더 주물러야 하면 인라인 뷰로 생각하면 돼요.


Step 8: "서브쿼리에 이름 붙이기 — WITH"

Step 7 의 쿼리를 다시 볼까요? post_likeGROUP BY 하는 똑같은 조회가 두 번 나와요. 한 번은 FROM 절 인라인 뷰로, 또 한 번은 평균을 구하는 WHERE 절 서브쿼리 안에요. 같은 걸 두 번 적었으니 읽기도 번거롭고, 나중에 고칠 때 한쪽만 고치면 틀어질 위험도 있죠.

이럴 때 WITH 절을 쓰면 깔끔해져요. WITH 는 서브쿼리를 쿼리 맨 위로 빼내 이름을 붙여 두는 문법이에요. 한 번 정의해 두면 아래 메인 쿼리에서 그 이름을 진짜 표처럼 몇 번이고 가져다 쓸 수 있어요. 이렇게 이름 붙인 임시 결과를 CTE(Common Table Expression, 공통 테이블 식)라고 불러요.

Step 7 과 똑같은 결과를 WITH 로 다시 써 볼게요.

SQL
-- sql/queries/D2_subquery.sql
WITH post_likes AS (
    SELECT post_id, COUNT(*) AS like_cnt
    FROM   post_like
    GROUP  BY post_id
)
SELECT post_id, like_cnt
FROM   post_likes
WHERE  like_cnt > (SELECT AVG(like_cnt) FROM post_likes)
ORDER  BY like_cnt DESC, post_id;

맨 위 WITH post_likes AS (...) 가 "게시물별 좋아요 수" 조회에 post_likes 라는 이름을 붙였어요. 그 아래 메인 쿼리는 post_likesFROM 에서도, 평균을 구하는 서브쿼리에서도 가져다 썼죠. 같은 조회를 두 번 적던 걸 한 번 정의로 합친 거예요. 결과는 Step 7 과 똑같은 5줄이에요.

WITH 의 장점은 두 가지예요. 하나는 방금처럼 같은 서브쿼리를 재사용하는 거고, 다른 하나는 가독성이에요. 서브쿼리가 FROM 절 안에 깊이 중첩되면 쿼리를 안쪽부터 거꾸로 읽어야 하는데, WITH 로 위에 빼 두면 "먼저 post_likes 를 이렇게 만들고, 그걸로 이렇게 조회한다" 는 흐름을 위에서 아래로 자연스럽게 읽을 수 있어요. 복잡한 분석 쿼리일수록 WITH 한 줄이 읽는 사람을 크게 도와줘요.

⚠️ 함정WITH 로 정의한 이름은 바로 그 쿼리 안에서만 살아 있어요. 쿼리가 끝나면 사라지죠. 진짜 표를 만드는 게 아니라 그 쿼리 동안만 쓰는 임시 이름이라는 점을 기억하세요.

WITH 에는 한 가지 더 강력한 쓰임이 있어요. 자기 자신을 참조해 계층 구조를 파고드는 재귀 CTE 인데요. 우리 댓글 표의 대댓글처럼 "부모-자식이 이어지는 트리" 를 따라 내려가는 조회예요. 그건 다음 시간에 본격적으로 다뤄요.

💡 한 줄 정리

WITH 절(CTE)은 서브쿼리를 쿼리 맨 위로 빼내 이름을 붙여 두는 문법으로, 같은 서브쿼리의 재사용과 복잡한 쿼리의 가독성을 함께 잡아 준다.

🙋 학생 질문 — "튜터님, WITH 랑 인라인 뷰는 결국 같은 거 아닌가요? 둘 다 서브쿼리를 표처럼 쓰잖아요."

맞아요, 하는 일은 거의 같아요. 둘 다 서브쿼리 결과를 임시 표처럼 다루죠. 방금 봤듯이 Step 7 의 인라인 뷰 쿼리와 Step 8 의 WITH 쿼리는 결과가 똑같고요. 차이는 "어디에 적느냐" 예요. 인라인 뷰는 쓰는 곳(FROM 절)에 바로 적고, WITH 는 쿼리 맨 위에 이름을 붙여 빼 둬요. 그래서 서브쿼리를 한 번만 쓰고 단순하면 인라인 뷰가 간결하고, 같은 서브쿼리를 여러 번 쓰거나 쿼리가 복잡해 흐름을 정리하고 싶으면 WITH 가 읽기 좋아요. 취향과 상황에 따라 고르면 되는데, 실무에선 복잡한 분석 쿼리에 WITH 를 즐겨 써요. 위에서부터 차근차근 읽히거든요.


마무리

오늘은 쿼리 안에 또 다른 쿼리를 중첩하는 서브쿼리를 배웠어요. 비교 연산자 오른쪽의 단일행 서브쿼리부터, IN·ANY·ALL 로 여러 값과 견주기, 바깥 행마다 다시 도는 상관 서브쿼리와 EXISTS, 그리고 FROM 절의 인라인 뷰와 WITH 까지요. 지난 시간 독립적이던 두 조회를, 이제 한쪽이 다른 쪽을 들여다보게 중첩할 수 있게 됐죠. 미뤄 뒀던 "평균보다 좋아요 많은 게시물" 도 인라인 뷰로 풀어냈고요.

오늘 배운 핵심 세 가지

  • 💡 하나 — 서브쿼리는 위치(WHERE·FROM·SELECT)와 반환 형태(단일행·다중행·다중컬럼)로 나뉘며, 단일행은 =·>, 다중행은 IN·ANY·ALL·EXISTS 로 견준다.
  • 💡 NOT IN 은 서브쿼리에 NULL 이 섞이면 공집합이 되는 함정이 있고, NOT EXISTS 가 그 함정 없는 안전한 대안이다.
  • 💡 — 상관 서브쿼리는 바깥 행마다 다시 실행되고, 인라인 뷰(FROM 절)와 WITH(CTE)는 서브쿼리를 임시 표처럼 다뤄 집계 결과를 다시 거르고 비교하게 해준다.

다음 시간 예고

오늘 WITH 끝에서 살짝 흘린 재귀 CTE 기억나시죠? 우리 댓글 표엔 댓글에 달린 대댓글, 그 대댓글에 또 달린 답글처럼 부모-자식으로 이어지는 구조가 있어요. 이런 트리를 위에서 아래로 한 단계씩 파고드는 게 다음 시간에 배울 계층형 질의예요. Oracle 의 START WITH·CONNECT BY 로 대댓글이 몇 단계까지 이어지는지 따라 내려가 볼 거예요. 서브쿼리가 "쿼리 속 쿼리" 였다면, 계층형은 "자기 자신을 따라가는 쿼리" 예요.


과제

오늘 배운 서브쿼리를 직접 써 보는 과제예요. 결과가 몇 건 나올지 먼저 예상한 뒤 실행해 맞는지 확인해 보세요.

[기초] 단일행과 다중행 서브쿼리

member·post·post_like 표로 다음을 해 보세요. (가) 가장 마지막에(작성일이 가장 늦은) 올라온 게시물을 쓴 회원의 usernamenickname 을, 단일행 서브쿼리로 구해 보세요. (작성일의 최댓값을 안쪽에서 구하면 돼요.) (나) 좋아요를 한 번이라도 받은 게시물의 작성자(회원) member_id 를 중복 없이 구해 보세요. IN 과 서브쿼리를 쓰면 돼요. (다) (나)에서 IN= ANY 로 바꿔도 결과가 같은지 확인하고, 왜 그런지 한 줄로 적어 보세요.

[응용] NOT IN 함정과 NOT EXISTS

post·post_like·post_comment 표로 다음을 해 보세요. (가) 좋아요를 한 번도 못 받은 게시물 수를 NOT IN 으로 구해 보세요. (나) 같은 결과를 NOT EXISTS 로도 구해 보세요. (다) 만약 post_like.post_id 에 NULL 이 섞일 수 있는 상황이라면 (가)와 (나) 중 어느 쪽이 위험한지, Step 3 에서 배운 NULL 함정으로 설명해 보세요.

[심화] 상관 서브쿼리와 인라인 뷰

member·post·post_like 표로 다음을 해 보세요. (가) 회원별로 자기가 쓴 게시물 수를, 상관 서브쿼리를 써서 member 한 줄마다 옆에 붙여 조회해 보세요. (나) 게시물별 좋아요 수를 인라인 뷰로 만든 뒤, 좋아요가 3개 이상인 게시물만 골라 보세요. (다) (나)를 WITH 절로 다시 써서 같은 결과가 나오는지 확인해 보세요.


생각해볼 주제

1. IN 으로 풀까, EXISTS 로 풀까, 조인으로 풀까

"좋아요를 받은 게시물" 같은 질문은 IN 서브쿼리로도, EXISTS 로도, 지난 시간 배운 조인으로도 풀 수 있어요. 세 방법이 같은 답을 내는데도 왜 여러 가지가 있는지 생각해 보세요. 결과에 안쪽 표의 컬럼이 필요한가, 없는 것을 찾는가(NULL 함정), 읽는 사람에게 의도가 잘 드러나는가 같은 기준으로 어느 쪽이 나은지 따져 보세요.

2. 상관 서브쿼리는 왜 "행마다 다시 돈다" 고 말할까

상관 서브쿼리는 바깥 행마다 안쪽이 다시 실행된다고 배웠어요. 그런데 실제 데이터베이스는 이걸 그대로 행마다 반복하지 않고 더 똑똑하게 처리하기도 해요. 그렇다면 우리가 "행마다 다시 돈다" 고 이해하는 게 왜 여전히 쓸모 있는지 생각해 보세요. 동작을 정확히 이해하는 것과 내부 최적화를 아는 것이 각각 어떤 상황에서 도움이 되는지도요.

3. 서브쿼리를 어디까지 중첩해도 될까

서브쿼리 안에 서브쿼리, 또 그 안에 서브쿼리를 넣으면 아주 복잡한 질문도 한 쿼리로 풀 수 있어요. 하지만 깊이 중첩된 쿼리는 읽기가 점점 어려워지죠. 언제까지 중첩으로 가고, 언제 WITH 로 단계를 나눠 이름을 붙이는 게 나은지 생각해 보세요. "한 쿼리에 다 담기" 와 "읽기 쉽게 나누기" 사이의 균형을 어디서 잡을지 고민해 보세요.

✅ 예시 답안정답 보기

과제와 생각해볼 주제의 예시답안이에요. 정답을 그대로 베끼기보다, "이 질문은 안쪽에서 무엇을 먼저 구해야 하나", "그 결과를 바깥이 조건으로 받을까, 표로 받을까", "없는 것을 찾을 때 NULL 은 괜찮나" 를 떠올리는 감각을 기르는 게 목표예요.


🎯 [과제 1 예시답안] 단일행과 다중행 서브쿼리

채점 포인트

항목 배점 핵심
(가) 단일행 서브쿼리 40% 작성일 최댓값 → 그 회원 · 박지훈(12)
(나) IN 다중행 서브쿼리 35% 좋아요 받은 게시물의 작성자 · 8명
(다) = ANY 동치 25% IN = = ANY 이므로 결과 같음

풀이 예시

(가) 가장 늦게 올라온 게시물의 작성자 (단일행 서브쿼리)

작성일의 최댓값을 안쪽에서 구하고, 그 날짜의 게시물을 쓴 회원을 단계적으로 좁혀요. 서브쿼리 안에 서브쿼리가 한 번 더 들어간 형태죠.

SQL
SELECT username, nickname
FROM   member
WHERE  member_id = (SELECT member_id
                    FROM   post
                    WHERE  created_at = (SELECT MAX(created_at) FROM post));
username nickname
jihun 박지훈

한 줄이에요. 가장 늦은 작성일(2026-06-27)의 게시물을 쓴 회원이 12번 박지훈이죠. 안쪽 MAX(created_at) 가 한 값을 내고, 그 위 서브쿼리가 그 날짜의 작성자(12) 한 값을 내고, 바깥이 그 회원의 이름을 가져온 거예요.

(나) 좋아요를 받은 게시물의 작성자 (IN)

SQL
SELECT DISTINCT member_id
FROM   post
WHERE  post_id IN (SELECT post_id FROM post_like)
ORDER  BY member_id;
member_id
1
2
4
5
6
9
11
12

8명이에요. 좋아요가 한 번이라도 눌린 게시물 13개의 작성자를 중복 없이 추리니 8명이 나와요. 게시물을 쓴 회원은 9명인데, 그중 3번 박승우의 게시물엔 좋아요가 하나도 없어 8명이 된 거죠.

(다) IN 을 = ANY 로 바꾸기

SQL
SELECT DISTINCT member_id
FROM   post
WHERE  member_id = ANY (SELECT member_id FROM post WHERE post_id IN (SELECT post_id FROM post_like))
ORDER  BY member_id;

결과는 (나)와 똑같이 8명이에요. IN= ANY 의 다른 이름이라, "목록 중 어느 하나와 같으면 참" 이라는 의미가 완전히 같거든요.

💡 튜터의 한마디 — (가)처럼 단계가 분명한 질문은 "안쪽에서 무엇을 먼저 구해야 하나" 를 거꾸로 짚으면 서브쿼리 구조가 보여요. 최댓값 → 그 날짜의 작성자 → 그 사람 이름, 이렇게요. 안쪽이 한 값이면 =, 여러 값이면 IN(= = ANY)을 쓴다는 갈림길만 잡으면 돼요.


🎯 [과제 2 예시답안] NOT IN 함정과 NOT EXISTS

채점 포인트

항목 배점 핵심
(가) NOT IN 30% 좋아요 못 받은 게시물 · 37건
(나) NOT EXISTS 30% 같은 결과 · 37건
(다) NULL 위험 비교 40% NULL 섞이면 NOT IN 만 공집합

풀이 예시

(가) 좋아요를 한 번도 못 받은 게시물 (NOT IN)

SQL
SELECT COUNT(*) AS unliked_cnt
FROM   post
WHERE  post_id NOT IN (SELECT post_id FROM post_like);

37이에요. 게시물 50개 중 좋아요 받은 13개를 빼면 37개죠.

(나) 같은 결과를 NOT EXISTS 로

SQL
SELECT COUNT(*) AS unliked_cnt
FROM   post p
WHERE  NOT EXISTS (SELECT 1 FROM post_like l WHERE l.post_id = p.post_id);

똑같이 37이에요. NOT EXISTS 는 "이 게시물에 달린 좋아요가 하나도 없으면 통과" 라서, 좋아요가 0인 게시물 37개를 정확히 골라내죠.

(다) NULL 이 섞일 수 있다면 어느 쪽이 위험한가

(가)의 NOT IN 이 위험해요. 지금은 post_like.post_id 가 NULL 이 될 수 없는 컬럼이라 둘 다 멀쩡히 37을 냈지만, 만약 그 컬럼에 NULL 이 섞일 수 있다면 이야기가 달라져요. NOT IN 의 목록에 NULL 이 하나라도 들어오면, Step 3 에서 본 것처럼 모든 비교가 UNKNOWN 이 되어 결과가 통째로 공집합(0건)이 돼버려요. 반면 (나)의 NOT EXISTS 는 값을 <> 로 비교하는 게 아니라 "행이 있나 없나" 만 보기 때문에 NULL 과 무관하게 안전하게 37을 유지해요. 그래서 "없는 것 찾기 + NULL 가능성" 이면 NOT EXISTS 가 정답이에요.

💡 튜터의 한마디 — 지금 데이터에서 두 쿼리가 같은 답을 낸다고 안심하면 안 돼요. 데이터가 바뀌어 NULL 이 끼는 순간 NOT IN 만 조용히 0건으로 무너지거든요. "지금은 같지만 NULL 이 들어오면 갈린다" 를 설명할 수 있어야 진짜 이해한 거예요.


🎯 [과제 3 예시답안] 상관 서브쿼리와 인라인 뷰

채점 포인트

항목 배점 핵심
(가) 상관 서브쿼리 35% 회원별 게시물 수 · 0개 회원 포함 12행
(나) 인라인 뷰 35% 좋아요 3개 이상 · 5건
(다) WITH 재작성 30% 같은 5건

풀이 예시

(가) 회원별 게시물 수 (상관 서브쿼리)

member 한 줄마다, 그 회원의 번호를 안쪽 post 카운트가 받아 쓰는 상관 서브쿼리예요.

SQL
SELECT m.member_id, m.nickname,
       (SELECT COUNT(*) FROM post p WHERE p.member_id = m.member_id) AS post_cnt
FROM   member m
ORDER  BY m.member_id;
member_id nickname post_cnt
1 김재훈 7
2 이민지 7
3 박승우 6
4 정하루 6
5 최도연 4
6 강지수 5
7 윤태양 0
8 한나래 0
9 김유나 6
10 이민수 0
11 한소라 4
12 박지훈 5

12줄이에요. 회원 한 명도 빠짐없이 나오면서 옆에 게시물 수가 붙었죠. 눈여겨볼 건 7·8·10번이에요. 게시물을 한 개도 안 썼는데 0 으로 또박또박 나와요. 상관 서브쿼리가 member 를 기준으로 돌기 때문에, 게시물이 없는 회원도 카운트 0 으로 살아남는 거예요.

(나) 좋아요 3개 이상인 게시물 (인라인 뷰)

SQL
SELECT post_id, like_cnt
FROM   (SELECT post_id, COUNT(*) AS like_cnt
        FROM   post_like
        GROUP  BY post_id)
WHERE  like_cnt >= 3
ORDER  BY like_cnt DESC, post_id;
post_id like_cnt
12 6
1 5
40 4
5 3
18 3

5줄이에요. 인라인 뷰가 "게시물별 좋아요 수" 임시 표를 만들고, 바깥이 그중 3개 이상인 것만 골랐죠.

(다) WITH 로 다시 쓰기

SQL
WITH post_likes AS (
    SELECT post_id, COUNT(*) AS like_cnt
    FROM   post_like
    GROUP  BY post_id
)
SELECT post_id, like_cnt
FROM   post_likes
WHERE  like_cnt >= 3
ORDER  BY like_cnt DESC, post_id;

결과는 (나)와 똑같은 5줄이에요. FROM 절 안에 있던 인라인 뷰를 post_likes 라는 이름으로 위에 빼낸 것뿐이죠. 결과가 같다는 걸 직접 확인하면 "인라인 뷰와 WITH 는 적는 위치만 다르다" 가 분명해져요.

💡 튜터의 한마디 — (가)가 상관 서브쿼리의 핵심을 보여줘요. 게시물이 0개인 회원까지 0으로 나오는 건, 안쪽이 member 의 각 행을 기준으로 새로 도는 덕분이에요. 만약 조인으로 같은 걸 구하면 게시물 없는 회원이 빠지기 쉬운데, 그건 다음에 배울 OUTER 조인과 엮이는 이야기예요.


🤔 [생각해볼 주제 1] IN 으로 풀까, EXISTS 로 풀까, 조인으로 풀까

문제 상황 요약

"좋아요를 받은 게시물" 은 IN 서브쿼리로도, EXISTS 로도, 조인으로도 같은 답이 나와요. 같은 결과를 내는 길이 셋이나 있는데, 무엇을 기준으로 골라야 할까요.

튜터의 가이드 및 해설

세 방법은 "무엇을 보여줘야 하는가" 와 "없는 것을 찾는가" 에서 갈려요.

먼저 결과에 안쪽 표의 컬럼이 필요한지를 봐요. "좋아요 받은 게시물의 번호" 만 필요하면 IN 이나 EXISTS 로 충분해요. 안쪽 post_like 의 컬럼은 결과에 안 나와도 되니까요. 그런데 "각 게시물이 받은 좋아요 수" 나 "마지막으로 좋아요가 눌린 시각" 처럼 안쪽 표의 값까지 한 줄에 같이 봐야 한다면 조인이 맞아요. 서브쿼리는 안쪽을 조건으로만 쓰고 결과엔 못 꺼내거든요.

다음은 "없는 것 찾기" 예요. "좋아요를 못 받은 게시물" 처럼 부정으로 가면 NULL 함정이 끼어들어요. NOT IN 은 목록에 NULL 이 섞이면 공집합이 되지만 NOT EXISTS 는 안전하죠. 그래서 없는 것을 찾을 땐 NOT EXISTS 를 우선 떠올려요.

마지막은 조인의 함정이에요. 한 게시물에 좋아요가 여러 개면, 조인 결과에 그 게시물이 좋아요 수만큼 중복으로 나와요. 그래서 "좋아요 받은 게시물 목록" 을 조인으로 구하면 DISTINCT 가 필요해질 수 있어요. 반면 IN·EXISTS 는 게시물을 한 번씩만 내보내죠.

🎯 SQLD는 이렇게 나온다

IN 서브쿼리를 조인으로 바꿨더니 결과 건수가 달라지는(중복으로 늘어나는) 사례가 단골이에요. "같은 의미인데 왜 건수가 다른가" 를 물으면 1:N 조인의 중복을 짚어야 해요. 그리고 = ANYIN 과 같다는 점, NOT INNOT EXISTS 가 NULL 에서 갈린다는 점도 자주 나와요.

💡 실무에선

요즘 데이터베이스는 INEXISTS 를 비슷하게 최적화해 주는 편이라, 성능보다 "어느 쪽이 의도를 잘 드러내고 NULL 에 안전한가" 로 골라요. 없는 것 찾기는 NOT EXISTS, 안쪽 값을 같이 봐야 하면 조인(중복은 DISTINCT 나 집계로 정리), 단순 포함 확인은 IN 이 무난해요.


🤔 [생각해볼 주제 2] 상관 서브쿼리는 왜 "행마다 다시 돈다" 고 말할까

문제 상황 요약

상관 서브쿼리는 바깥 행마다 안쪽이 다시 실행된다고 배웠어요. 그런데 실제 데이터베이스는 이걸 그대로 반복하지 않고 더 영리하게 처리하기도 해요. 그렇다면 "행마다 다시 돈다" 는 설명은 왜 여전히 쓸모 있을까요.

튜터의 가이드 및 해설

"행마다 다시 돈다" 는 동작의 의미를 그리는 모델이에요. 결과가 무엇이 될지를 정확히 예측하게 해주죠. 예를 들어 WHERE (SELECT COUNT(*) ...) >= 6 을 두고 "회원 한 명마다 그 회원의 게시물 수를 세서 6 이상인지 본다" 고 읽으면, 게시물 0개인 회원이 왜 0으로 나오는지, 결과가 왜 5명인지 또박또박 따라갈 수 있어요. 이 의미 모델이 흔들리면 결과를 잘못 예측하게 돼요.

반대로 "실제로 행마다 반복 실행되느냐" 는 성능의 영역이에요. 데이터베이스는 이런 쿼리를 내부적으로 조인 비슷한 방식으로 한 번에 처리하는 경우가 많아요. 이건 "얼마나 빠른가" 를 추정할 때 도움이 되죠.

그러니까 두 가지는 층위가 달라요. 하나는 "무엇을 계산하는가"(의미), 다른 하나는 "얼마나 빠르게 계산하는가"(실행). 결과를 예측할 땐 의미 모델로, 느린 쿼리를 손볼 땐 실제 실행 방식으로 생각하면 돼요. 둘을 섞어 "행마다 도니까 무조건 느리다" 고 단정하는 게 가장 흔한 실수예요.

🎯 SQLD는 이렇게 나온다

상관 서브쿼리의 결과 건수를 묻는 문제는 "바깥 행마다 안쪽이 다시 평가된다" 는 의미 모델로 풀어야 정확해요. 그리고 일반 서브쿼리와 상관 서브쿼리를 구분하는 기준(안쪽이 바깥 컬럼을 참조하는가)을 묻는 문제도 자주 나와요.

💡 실무에선

실행계획을 열어 보면 상관 서브쿼리가 세미조인이나 해시 조인으로 풀려 있는 경우가 흔해요. 그래서 "상관 서브쿼리 = 느림" 이라고 미리 겁먹기보다, 느리면 실행계획을 확인하고 필요할 때 조인이나 WITH 로 바꿔 보는 순서로 접근해요.


🤔 [생각해볼 주제 3] 서브쿼리를 어디까지 중첩해도 될까

문제 상황 요약

서브쿼리 안에 서브쿼리, 또 그 안에 서브쿼리를 넣으면 복잡한 질문도 한 쿼리로 풀 수 있어요. 하지만 깊이 중첩된 쿼리는 읽기가 점점 어려워지죠. 언제까지 중첩으로 가고, 언제 WITH 로 나누는 게 나을까요.

튜터의 가이드 및 해설

중첩이 두세 단을 넘어가면 쿼리를 안쪽부터 거꾸로 읽어야 해서 가독성이 빠르게 떨어져요. FROM 절 안에 또 FROM 절, 그 안에 또 조건... 이렇게 되면 작성한 본인도 며칠 뒤엔 헷갈리죠.

WITH 는 이 중첩을 펼쳐 위에서 아래로 읽히게 만들어요. "먼저 이 표를 만들고(post_likes), 그걸로 이렇게 거른다" 는 단계가 이름과 함께 순서대로 보이거든요. 사람은 깊이 들어가는 것보다 차례로 읽는 걸 훨씬 편해해요.

그렇다고 무조건 WITH 가 정답은 아니에요. 한 번만 쓰는 간단한 서브쿼리까지 전부 WITH 로 빼면 오히려 단계만 늘어나 번거로워요. 기준을 잡자면 이래요. 같은 서브쿼리를 두 번 이상 재사용하거나, 중첩이 세 단을 넘거나, 각 단계에 "이건 게시물별 좋아요 수" 처럼 의미 있는 이름을 붙일 수 있으면 WITH 로 나누는 게 좋아요. 그 외 단순한 한 단짜리는 인라인 뷰나 서브쿼리로 그냥 두는 게 간결하고요.

🎯 SQLD는 이렇게 나온다

인라인 뷰로 쓴 쿼리와 WITH 로 쓴 쿼리가 같은 결과를 내는지 확인하는 문제, WITH 로 정의한 이름의 통용 범위(그 쿼리 안에서만 유효)를 묻는 문제가 나와요. 둘은 적는 위치만 다르고 결과는 같다는 게 핵심이에요.

💡 실무에선

복잡한 분석 쿼리는 WITH 로 단계를 나눠 쓰는 게 사실상 표준이에요. 단계마다 이름이 붙으니 동료가 리뷰하기 쉽고, 중간 단계만 떼어 실행해 보며 디버깅하기도 좋거든요. "한 쿼리에 다 욱여넣기" 보다 "읽는 사람이 따라올 수 있게 나누기" 를 우선하는 감각이 실무에선 더 값져요.

전체 목록 데이터베이스