오늘은 Spotify/Melon/Youtube Music 같은 음악 스트리밍 서비스에서 쓸 법한 DB 스키마를 기반으로 다양한 SQL 실습을 하며, 그 과정 중에서 오답, 실패한 시도, 정답을 위한 과정, 최종 해결 쿼리까지 모두 기록한 TIL이다.

오늘 실습에서는 아래와 같은 음악 도메인 스키마를 사용했다.
-- Artists table
CREATE TABLE artists (
artist_id INT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
debut_year INT
);
-- Albums table
CREATE TABLE albums (
album_id INT PRIMARY KEY,
artist_id INT,
title VARCHAR(255) NOT NULL,
release_year INT,
FOREIGN KEY (artist_id) REFERENCES artists(artist_id)
);
-- Tracks table
CREATE TABLE tracks (
track_id INT PRIMARY KEY,
album_id INT,
title VARCHAR(255) NOT NULL,
duration_sec INT,
genre VARCHAR(50),
FOREIGN KEY (album_id) REFERENCES albums(album_id)
);
-- Users table
CREATE TABLE users (
user_id INT PRIMARY KEY,
nickname VARCHAR(255),
signup_date DATE
);
-- Play history table
CREATE TABLE plays (
play_id INT PRIMARY KEY,
user_id INT,
track_id INT,
played_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(user_id),
FOREIGN KEY (track_id) REFERENCES tracks(track_id)
);
SELECT t.title, COUNT(*) AS play_count
FROM tracks t
JOIN plays p ON t.track_id = p.track_id
ORDER BY play_count DESC
LIMIT 10;
GROUP BY 없음 → MySQL ONLY_FULL_GROUP_BY 설정이면 에러SELECT t.title, COUNT(*) AS play_count
FROM tracks t
JOIN plays p ON t.track_id = p.track_id
GROUP BY t.title
ORDER BY play_count DESC
LIMIT 10;
→ JOIN을 하나 더 해야 한다 (albums → artists)
SELECT a.name AS artist_name, t.title AS track_title, COUNT(*) AS play_count
FROM plays p
JOIN tracks t ON p.track_id = t.track_id
JOIN albums al ON t.album_id = al.album_id
JOIN artists a ON al.artist_id = a.artist_id
GROUP BY a.name, t.title
ORDER BY play_count DESC
LIMIT 10;
SELECT a.name, a.debut_year
FROM artists a
JOIN albums al ON a.artist_id = al.artist_id
JOIN tracks t ON al.album_id = t.album_id
WHERE t.genre = 'Indie'
ORDER BY a.debut_year
LIMIT 1;
SELECT DISTINCT a.artist_id, a.name, a.debut_year
FROM artists a
JOIN albums al ON a.artist_id = al.artist_id
JOIN tracks t ON al.album_id = t.album_id
WHERE t.genre = 'Pop'
ORDER BY a.debut_year
LIMIT 1;
하지만 'Pop 장르 트랙을 가진 아티스트 중 데뷔년도가 가장 빠른 사람'을 찾는 더 깔끔한 방식: 서브쿼리 활용
SELECT a.name, a.debut_year
FROM artists a
WHERE a.artist_id IN (
SELECT DISTINCT al.artist_id
FROM albums al
JOIN tracks t ON al.album_id = t.album_id
WHERE t.genre = 'Pop'
)
ORDER BY a.debut_year ASC
LIMIT 1;
SELECT u.nickname, t.genre, COUNT(*) AS cnt
FROM users u
JOIN plays p ON u.user_id = p.user_id
JOIN tracks t ON p.track_id = t.track_id
GROUP BY u.user_id
ORDER BY cnt DESC;
SELECT u.user_id, t.genre, COUNT(*) AS cnt
FROM users u
JOIN plays p ON u.user_id = p.user_id
JOIN tracks t ON p.track_id = t.track_id
GROUP BY u.user_id, t.genre;
이제 유저별로 장르별 재생 수가 나옴 → 그중 MAX 장르를 뽑으면 됨.
SELECT final.user_id, final.genre, final.cnt
FROM (
SELECT ug.user_id, ug.genre, ug.cnt
FROM (
SELECT u.user_id, t.genre, COUNT(*) AS cnt
FROM users u
JOIN plays p ON u.user_id = p.user_id
JOIN tracks t ON p.track_id = t.track_id
GROUP BY u.user_id, t.genre
) ug
JOIN (
SELECT user_id, MAX(cnt) AS max_cnt
FROM (
SELECT u.user_id, t.genre, COUNT(*) AS cnt
FROM users u
JOIN plays p ON u.user_id = p.user_id
JOIN tracks t ON p.track_id = t.track_id
GROUP BY u.user_id, t.genre
) sub
GROUP BY user_id
) mx
ON ug.user_id = mx.user_id AND ug.cnt = mx.max_cnt
) final;
SELECT a.name, COUNT(*)
FROM plays p
JOIN tracks t ON p.track_id = t.track_id
JOIN albums al ON t.album_id = al.album_id
JOIN artists a ON al.artist_id = a.artist_id
WHERE p.user_id = 10
GROUP BY a.artist_id;
SELECT a.name, COUNT(*) AS play_count
FROM plays p
JOIN tracks t ON p.track_id = t.track_id
JOIN albums al ON t.album_id = al.album_id
JOIN artists a ON al.artist_id = a.artist_id
WHERE p.user_id = 10
GROUP BY a.artist_id, a.name
ORDER BY play_count DESC
LIMIT 1;
SELECT *
FROM tracks t
JOIN albums al;
SELECT title FROM tracks JOIN albums;
SELECT *
FROM artists a
LEFT JOIN albums al ON a.artist_id = al.artist_id
WHERE al.album_id IS NOT NULL;
WHERE artist_id = (SELECT artist_id FROM albums);
SELECT *
FROM (SELECT * FROM tracks);
→ ERROR: Every derived table must have its own alias
SELECT (SELECT debut_year FROM artists ORDER BY debut_year);
→ 다중 행 반환 에러
SQL 사고 과정, JOIN 구조 분석, 서브쿼리를 왜 사용하는지, INDEX가 필요할 수 있는 지점, 실제 서비스에서 쿼리 최적화가 어떤 식으로 일어나는지 등의 내용을 자세히 서술했다. 또한 문제를 해결하며 겪은 시행착오를 실례로 다시 설명했다.
음악 스트리밍 서비스에서는 수많은 테이블이 서로 연결된다. 아티스트–앨범–트랙은 기본 구조이고, 여기에 사용자 정보(users), 재생 기록(plays), 플레이리스트(playlists), 좋아요 정보(likes), 구독 상품(subscription) 같은 데이터가 붙는다. 이런 구조에서 JOIN은 비즈니스 지표를 구축하는 핵심 도구가 된다.
예를 들어, 특정 유저가 가장 많이 듣는 아티스트를 구하는 쿼리는 사용자 추천 모델의 기반이 된다. 또 어떤 장르가 특정 연령대에서 많이 들리는지 확인하면 큐레이션 알고리즘의 근거가 될 수 있다. 그리고 이러한 데이터는 대개 여러 테이블을 한 번에 묶어야 나오기 때문에 JOIN이 필수다.
서브쿼리는 크게 두 가지 이유로 쓰게 된다.
JOIN만으로는 답을 내기 까다로운 문제도 있고,
필터링 조건이 최대 재생 수를 가진 장르, 특정 장르를 가진 아티스트, 재생 수 상위 5% 안에 드는 사용자처럼 복잡해지면 서브쿼리가 훨씬 직관적이고 유지보수하기도 쉽다.
오늘 실습 과정에서 자연스럽게 등장한 패턴들은 음악 서비스뿐 아니라 모든 서비스에서 자주 쓰인다.
GROUP BY user_id, genre 같은 중간 결과에서 MAX(cnt)를 구한 뒤 다시 JOIN해야 원하는 행만 뽑을 수 있기 때문쿼리를 작성하기 전에 데이터를 먼저 이미지화해보면 에러가 줄어든다.
GROUP BY와 ORDER BY는 의도에 따라 결과가 완전히 달라진다.
서브쿼리는 과용하면 느려질 수 있다.
SQL은 "문법"보다 "사고 과정"이 훨씬 더 중요하다.
이 네 가지를 명확히 하면 서브쿼리든 JOIN이든 자연스럽게 흘러간다.
지금까지 작성한 문제들은 음악 도메인 SQL의 좋은 예시였고, 앞으로도 조금 더 난도 높은 문제(예: 윈도우 함수 포함, 재생 패턴 분석, retention 기반 지표 계산 등)를 이어서 작성해볼 수 있을 것 같다. 재밌었다 오늘~!#@!~@!~