[강동7기 전Z전능 AI PM] 코멘토 청년취업사관학교 DAY 36

DAY 36 | SQL 기초 — 서브쿼리·인라인 뷰·JOIN으로 여러 테이블 엮기

SQL 기초 4일차이자 마지막 날이었다. 지난 사흘이 한 테이블 안에서 조회하고 걸러내고 집계하는 과정이었다면, 오늘은 쿼리 안에 쿼리를 넣는 서브쿼리부터 여러 테이블을 엮는 UNION과 JOIN까지 다루면서 지금까지 배운 걸 조합하는 하루였다. 실무 쿼리 대부분이 결국 이 조합에서 나온다는 게 체감됐다.

서브쿼리 — 쿼리 안에 또 다른 쿼리

서브쿼리는 SQL 안에 또 다른 SQL을 넣는 것으로, 괄호 안에 작성되고 메인 쿼리보다 먼저 실행된다. 종류는 세 가지로 나눴다. 값 하나만 반환하는 스칼라 서브쿼리, FROM절에서 임시 테이블처럼 쓰이는 인라인 뷰, 바깥 쿼리의 값을 참조하며 반복 수행되는 상관 서브쿼리다.

반환하는 행 수에 따라 단일행 서브쿼리(=, <, <=, >, >=, <> 사용)와 다중행 서브쿼리(IN, ANY, ALL, EXISTS 사용)로도 나뉜다.

첫 예제는 WHERE절에서 집계함수를 못 쓰는 문제를 서브쿼리로 푸는 것이었다. Track 테이블에서 Milliseconds가 평균 이상인 곡만 조회하려고 WHERE Milliseconds > AVG(Milliseconds)를 바로 쓰면 에러가 난다(Invalid use of group function). WHERE절은 GROUP BY보다 먼저 실행되기 때문에 집계함수를 직접 받을 수 없다는, 앞서 GROUP BY/HAVING에서 봤던 것과 같은 이유다.

-- 에러: WHERE절에서 집계함수 직접 사용 불가
SELECT Name, Milliseconds
FROM Track
WHERE Milliseconds > AVG(Milliseconds);
 
-- 서브쿼리로 해결: 먼저 계산한 값을 조건으로 사용
SELECT Name, Milliseconds
FROM Track
WHERE Milliseconds > (
    SELECT AVG(Milliseconds) FROM Track
);

이 서브쿼리는 평균값 하나만 반환하는 스칼라 서브쿼리다. 같은 서브쿼리를 SELECT절로 옮기면 모든 행에 평균값이 그대로 출력된다. CASE와 묶으면 평균보다 크면 High, 작으면 Low로 분류하는 것도 자연스럽게 이어진다.

SELECT
    Name,
    Milliseconds,
    CASE
        WHEN Milliseconds > (SELECT AVG(Milliseconds) FROM Track) THEN 'High'
        ELSE 'Low'
    END AS LengthLevel
FROM Track;

스칼라 서브쿼리는 여러 개를 나란히 쓸 수도 있다. Track과 Album 테이블의 행 수를 한 번에 보고 싶으면 서브쿼리 두 개를 SELECT에 나열하면 된다.

SELECT
    (SELECT COUNT(*) FROM Track) AS TrackCount,
    (SELECT COUNT(*) FROM Album) AS AlbumCount;

여기서 짚고 넘어간 규칙이 하나 있다. SELECT절 안에 쓰는 서브쿼리는 반드시 값 하나(스칼라)만 반환해야 한다. SELECT는 결국 셀 하나에 들어갈 값을 채우는 자리라서, 여러 행이 나오는 서브쿼리를 SELECT에 넣으면 에러가 난다.

인라인 뷰 — FROM절 안의 SELECT

인라인 뷰는 FROM절 안에 SELECT문이 들어간 구조다. SELECT로 만든 결과를 임시 테이블처럼 다시 조회할 수 있고, 대신 반드시 별칭(alias)을 붙여야 한다.

계산된 결과를 한 번 더 가공하거나, WHERE에서 집계함수를 쓸 수 없을 때 쓴다. Invoice 테이블에서 고객별 합계를 구한 다음, 그 결과에서 다시 조건을 거는 게 대표적이다.

SELECT *
FROM (
    SELECT CustomerId, SUM(Total) AS TotalSpent
    FROM Invoice
    GROUP BY CustomerId
) AS SalesSummary
WHERE TotalSpent >= 40;

HAVING으로도 같은 결과를 낼 수 있지만, 이미 만들어진 집계 결과 테이블을 놓고 한 번 더 조건을 거는 흐름이 더 명확하게 읽혔다.

중첩은 한 단계로 끝나지 않아도 된다. Track 테이블을 밀리초에서 초로 바꾸고, 그중 5분(300초) 이상인 곡만 걸러낸 다음, 그 결과의 평균을 다시 구하는 3단 구조도 만들어봤다.

SELECT AVG(seconds) AS avg_seconds
FROM (
    SELECT *
    FROM (
        SELECT name, milliseconds, milliseconds / 1000 AS seconds
        FROM Track
    ) AS sub
    WHERE seconds > 300
) AS sub2;

이렇게 SELECT 안에 SELECT가 들어간 모양이라 Nested(중첩) 구조라고도 부른다. 다만 인라인 뷰가 항상 필요한 건 아니다. 간단한 계산이면 WHERE milliseconds / 1000 > 300처럼 한 줄로 끝나기도 한다. 같은 계산을 여러 번 반복하거나 별칭을 재사용해야 할 때 인라인 뷰로 단계를 나누는 게 의미가 있다는 정도로 정리했다.

복수의 테이블 다루기 — UNION과 JOIN

지금까지는 테이블 하나로 조회했다면, 이제부터는 여러 테이블을 다루는 방법이다. 크게 합집합(UNION)과 결합(JOIN) 두 가지로 나뉜다. UNION은 데이터가 아래로 추가되는 행 방향 결합이고, JOIN은 컬럼이 옆으로 붙는 열 방향 결합이다.

UNION은 두 개 이상의 SELECT문을 하나로 연결한다. 단, 합치는 SELECT문들의 열 개수와 자료형이 같아야 한다.

SELECT * FROM sample71_a
UNION
SELECT * FROM sample71_b;

열 이름이 다를 수 있어서 보통 별칭을 붙인다. UNION은 기본적으로 DISTINCT가 적용돼 중복을 제거하는데, 중복을 남기고 싶으면 UNION ALL을 쓴다. ORDER BY는 마지막 SELECT문에만 붙이는 게 규칙이다.

SELECT a AS c FROM sample71_a
UNION ALL
SELECT b AS c FROM sample71_b
ORDER BY c;

JOIN — 내부결합과 외부결합

JOIN은 RDBMS의 꽃이라고 불릴 정도로 중요한 개념이라고 했다. 보통 데이터베이스는 하나의 테이블에 모든 데이터를 몰아넣지 않고 여러 테이블에 나눠 저장하는데, 나뉜 테이블을 다시 하나의 결과로 묶는 방법이 JOIN이다.

먼저 AS(별칭)를 다시 짚었다. 컬럼뿐 아니라 테이블에도 별칭을 붙일 수 있고, 여러 테이블을 다루는 JOIN에서는 사실상 필수다. 긴 테이블명을 줄여주고, 같은 테이블을 두 번 쓰는 SELF JOIN에서는 구분을 위해서도 필요하다.

JOIN은 내부결합(INNER JOIN)과 외부결합(OUTER JOIN)으로 나뉜다. INNER JOIN은 두 테이블 모두에 존재하는 행만 결과로 낸다. LEFT/RIGHT JOIN은 한쪽 테이블의 모든 행을 기준으로, 다른 쪽에서 일치하는 행만 붙이고 없으면 NULL을 채운다.

maker와 product 두 테이블로 이 차이를 눈으로 확인했다. maker에는 있지만 product에는 없는 제조사(Sony)와, product에는 있지만 maker에는 없는 제품(ThinkPad)이 각각 하나씩 있는 상황이었다.

-- LEFT JOIN: maker 기준. Sony는 매칭되는 product가 없어 NULL
SELECT m.MakerName, p.ProductName
FROM maker AS m
LEFT JOIN product AS p
    ON m.MakerID = p.MakerID;
 
-- RIGHT JOIN: product 기준. ThinkPad는 매칭되는 maker가 없어 NULL
SELECT m.MakerName, p.ProductName
FROM maker AS m
RIGHT JOIN product AS p
    ON m.MakerID = p.MakerID;

INNER JOIN으로 같은 조건을 걸면 Sony와 ThinkPad 둘 다 빠지고 양쪽에 모두 존재하는 값만 남는다. FULL OUTER JOIN은 양쪽 모든 행을 다 보여주는 방식인데, MySQL에는 문법이 따로 없어서 LEFT JOIN 결과와 RIGHT JOIN 결과를 UNION으로 합쳐 구현한다는 점도 확인했다.

언제 INNER를 쓰고 언제 OUTER를 쓸까

INNER와 OUTER 중 뭘 쓸지는 “무엇을 보고 싶은가”에 따라 갈린다. 매출이 있는 고객만 필요하면 INNER, 매출이 없더라도 전체 고객을 보고 싶으면 LEFT, 제품이 없는 제조사까지 보고 싶어도 LEFT다. “광고 집행 후 전환이 안 된 사람도 불러와야 할까”, “매출이 없는 고객도 봐야 할까” 같은 질문에 대한 답이 곧 JOIN 방식을 결정한다.

NULL의 의미를 고민해야 한다는 것도 정리했다. LEFT JOIN 결과에서 주문이 NULL인 고객이 나왔을 때, 이 고객이 이탈 고객인지 아직 주문이 없는 신규 고객인지는 쿼리가 알려주지 않는다. 그 해석은 데이터를 보는 사람의 몫으로 남는다.

내부결합 실습은 Customer와 Invoice를 CustomerId 기준으로 INNER JOIN해서 고객과 주문을 이어보는 것으로 진행했다. 3개 이상의 테이블을 잇는 것도 문법상 어렵지 않다. Customer-Invoice-InvoiceLine-Track-Album을 차례로 INNER JOIN해서 고객 이름과 트랙명, 앨범명을 한 번에 뽑아보기도 했다.

SELECT
    c.LastName, c.FirstName,
    t.Name AS TrackName,
    a.Title AS AlbumTitle
FROM Customer c
INNER JOIN Invoice i ON c.CustomerId = i.CustomerId
INNER JOIN InvoiceLine il ON i.InvoiceId = il.InvoiceId
INNER JOIN Track t ON il.TrackId = t.TrackId
INNER JOIN Album a ON t.AlbumId = a.AlbumId
ORDER BY c.LastName, c.FirstName;

JOIN + GROUP BY — 이름 있는 테이블과 숫자 있는 테이블을 붙이기

GROUP BY를 처음 배울 때 들었던 예시가 국가별 매출, 장르별 평균 재생시간이었는데, 실제로 GROUP BY GenreId를 해보면 결과에 1, 2, 3 같은 숫자만 나온다. 장르 이름은 Track이 아니라 Genre 테이블에 있기 때문이다. 실무 집계는 결국 “이름이 있는 테이블 + 숫자가 있는 테이블”을 JOIN으로 붙이고 GROUP BY로 묶는 조합이라는 걸 여기서 정리했다.

절 순서는 그대로다. FROM(JOIN) → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. JOIN은 FROM절의 일부일 뿐이라, GROUP BY 입장에서는 원래 테이블이 하나였는지 셋이었는지 알 필요가 없다. FROM 자리가 테이블 하나에서 “붙여 만든 표” 하나로 바뀐 것뿐이다.

Track과 Genre로 이 흐름을 단계별로 밟아봤다. 먼저 GROUP BY 없이 JOIN만 걸어서 어떤 표가 만들어지는지 확인하고, 장르 기준으로 묶어서 트랙 수를 세고, 마지막으로 HAVING으로 100곡 이상인 장르만 남겼다.

SELECT g.Name AS GenreName, COUNT(*) AS TrackCount
FROM Track t
INNER JOIN Genre g ON t.GenreId = g.GenreId
GROUP BY g.Name
HAVING COUNT(*) > 100
ORDER BY TrackCount DESC;

도전 문제로 나온 Artist-Album-Track 3중 JOIN도 같은 방식으로 단계를 쌓아 풀었다. 아티스트별 트랙 수와 총 재생시간을 구하되 트랙 수가 50곡 이상인 아티스트만 남기는 문제였는데, Artist와 Album을 먼저 붙이고, Track까지 붙인 다음, 아티스트로 묶어 트랙 수를 세고, 총 재생시간을 더하고, HAVING으로 50곡 기준을 걸고, 마지막에 정렬하는 순서로 늘려갔다.

SELECT
    ar.Name AS ArtistName,
    COUNT(t.TrackId) AS TrackCount,
    SUM(t.Milliseconds) AS TotalMs
FROM Artist ar
INNER JOIN Album al ON ar.ArtistId = al.ArtistId
INNER JOIN Track t ON al.AlbumId = t.AlbumId
GROUP BY ar.ArtistId, ar.Name
HAVING COUNT(t.TrackId) >= 50
ORDER BY TotalMs DESC;

한 번에 완성된 쿼리를 보면 복잡해 보이는데, JOIN을 하나씩 붙여가면서 중간 결과를 계속 확인하니 왜 이 모양인지 자연스럽게 따라왔다.

JOIN + 서브쿼리 결합 — 전체 값을 각 행에 함께 보여주기

마지막은 JOIN과 서브쿼리를 같이 쓰는 패턴이었다. Invoice와 Customer를 JOIN한 다음, 서브쿼리로 구한 전체 평균 Total을 WHERE 조건으로 비교하는 식이다.

SELECT
    i.InvoiceId, i.Total,
    c.FirstName, c.LastName
FROM Invoice i
INNER JOIN Customer c ON i.CustomerId = c.CustomerId
WHERE i.Total > (
    SELECT AVG(Total) FROM Invoice
);

SELECT절에 스칼라 서브쿼리를 넣으면 전체 평균이나 전체 고객 수 같은 참조값을 각 행에 나란히 출력할 수도 있다. 고객별 주문 수를 세면서 전체 고객 수를 같은 줄에 같이 보여주는 식이다.

SELECT
    c.CustomerId, c.FirstName,
    COUNT(i.InvoiceId) AS OrderCount,
    (SELECT COUNT(*) FROM Customer) AS TotalCustomerCount
FROM Customer c
LEFT JOIN Invoice i ON c.CustomerId = i.CustomerId
GROUP BY c.CustomerId, c.FirstName;

각 행에 전체 지표를 나란히 붙여두면, 개별 고객의 수치가 전체 대비 얼마나 되는지 별도 계산 없이 한 눈에 비교할 수 있다는 점이 실무적으로 유용해 보였다.

더 나아가기

강의 마지막에는 코딩을 배우는 자세를 여섯 가지로 정리했다. 실제 데이터로 실습하며 배우기, 작은 단위로 쪼개서 이해하기, 문제 해결 중심으로 접근하기, 패턴 찾기, 복습하기, 에러를 두려워하지 않기다. 돌아보면 오늘 다룬 인라인 뷰나 JOIN+GROUP BY도 결국 작은 단계로 쪼개서 하나씩 확인하며 쌓아 올린 결과였다.

추가로 프로그래머스의 SQL 고득점 Kit(https://school.programmers.co.kr/learn/challenges?tab=sql_practice_kit )도 소개받았다. SELECT부터 JOIN까지 난이도별로 문제 세트가 나뉘어 있어서, 코딩테스트에서 SQL 문제를 만날 확률이 25%나 된다는 점을 감안하면 이번 4일간 배운 걸 실전 문제로 복습하기에 맞는 곳이다.

나흘을 돌아보며

SELECT/WHERE로 데이터를 꺼내는 것부터 시작해서, 정렬·연산·CASE로 다듬고, 집계와 GROUP BY로 요약하고, 오늘 서브쿼리와 JOIN으로 여러 테이블을 엮는 데까지 왔다. 결국 실무 쿼리는 이 네 가지를 필요한 만큼 조합해서 쓰는 일이라는 게 나흘을 통해 정리됐다.

취업 부트캠프 청년취업사관학교 새싹 aipm dx교육 ai교육 실무프로젝트 실무경험 취업포트폴리오 포트폴리오 전z전능 코멘토 모비니티