16일차의 핵심은 SQL 문법을 “알고 있다”에서 끝내지 않고, 실제 데이터 분석 문제에 적용해본 것이었다. 전날까지는 where, group by, having, join을 각각 배웠다면, 이번에는 학생-동아리 테이블, Netflix 데이터, 의류 리뷰 데이터를 보면서 어떤 테이블에서 출발해야 하는지, 어떤 조건을 먼저 걸어야 하는지, 집계 결과를 다시 어떻게 가공해야 하는지를 계속 연습했다.
특히 이날은 SQL이 단순 조회 언어가 아니라는 걸 좀 더 느꼈다. 데이터를 넣고, 테이블을 연결하고, 조건으로 자르고, 그룹으로 묶고, 다시 정렬하고, 필요한 경우 서브쿼리로 한 번 더 감싸는 과정이 이어졌다. Netflix 문제에서는 titles와 credits를 연결해서 감독/배우 정보를 가져와야 했고, 리뷰 데이터에서는 CASE WHEN + LIKE + SUM으로 특정 키워드 언급 수를 세는 방식까지 다뤘다. 문제 목록 자체도 IMDb/TMDB 평점, decade별 콘텐츠 수, 제작국가별 평균, 최다 출연 배우, 감독 조회, 최고 평점 영화 조회처럼 SQL EDA에서 자주 나오는 형태였다.
1. MySQL에 SQL 파일과 CSV를 넣는 흐름부터 다시 정리
SQL 분석을 하려면 우선 데이터가 DB 안에 들어가 있어야 한다. 그래서 시작 전에 MySQL 접속, DB 생성, SQL 파일 실행, CSV 입력 흐름을 다시 정리했다. 이 부분은 매번 새 데이터셋을 받을 때 반복될 것 같아서 따로 메뉴얼처럼 남겨두는 게 좋았다.
/usr/local/mysql/bin/mysql --local-infile=1 -u username -p
- -local-infile=1은 로컬 CSV 파일을 MySQL로 불러올 때 필요한 옵션이다. 접속 후에는 DB를 만들고, 반드시 사용할 DB를 선택해야 한다.
CREATE DATABASE dbname;
USE dbname;
SELECT DATABASE();
여기서 USE dbname;을 빼먹으면 이후에 SOURCE나 LOAD DATA를 할 때 No database selected 같은 에러가 날 수 있다. 그래서 DB를 만든 뒤에는 무조건 현재 DB를 확인하는 습관이 필요했다.
SQL 파일은 SOURCE로 실행했다.
SOURCE /Users/yourname/Desktop/file.sql;
CSV는 LOAD DATA LOCAL INFILE로 넣었다.
LOAD DATA LOCAL INFILE '/경로/filename.csv'
INTO TABLE tablename
FIELDS TERMINATED BY ','
ENCLOSED BY '"'
LINES TERMINATED BY '\\n'
IGNORE 1 ROWS;
여기서 IGNORE 1 ROWS는 CSV 첫 줄이 컬럼명일 때 건너뛰기 위한 옵션이다. 데이터가 잘 들어갔는지는 아래처럼 확인했다.
SHOW TABLES;
DESC tablename;
SELECT *
FROM tablename
LIMIT 10;
이 부분에서 느낀 건, SQL 문제를 풀기 전에 데이터 로딩 단계에서 막히면 아무것도 못 한다는 점이다. 특히 DB 선택, 테이블 존재 여부, local_infile 설정 같은 건 쿼리 실력과 별개로 계속 챙겨야 할 기본 세팅이었다.
2. 오전 실습: 학생과 동아리는 다대다 관계였다
오전에는 학생, 동아리, 학생-동아리 가입 정보를 저장하기 위해 3개의 테이블을 만들었다. stdtbl은 학생 정보, clubtbl은 동아리 정보, stdclubtbl은 어떤 학생이 어떤 동아리에 가입했는지를 저장하는 연결 테이블이었다.
USE sqldb;
CREATE TABLE stdtbl
(
stdName VARCHAR(10) NOT NULL PRIMARY KEY,
addr CHAR(4) NOT NULL
);
CREATE TABLE clubtbl
(
clubName VARCHAR(10) NOT NULL PRIMARY KEY,
roomNo CHAR(4) NOT NULL
);
CREATE TABLE stdclubtbl
(
num INT AUTO_INCREMENT NOT NULL PRIMARY KEY,
stdName VARCHAR(10) NOT NULL,
clubName VARCHAR(10) NOT NULL,
FOREIGN KEY(stdName) REFERENCES stdtbl(stdName),
FOREIGN KEY(clubName) REFERENCES clubtbl(clubName)
);
처음에는 학생 테이블과 동아리 테이블만 있으면 될 것 같지만, 실제로는 그렇지 않았다. 학생 한 명은 여러 동아리에 가입할 수 있고, 동아리 하나에도 여러 학생이 가입할 수 있다. 즉, 학생과 동아리는 다대다 관계다. 그래서 두 테이블을 바로 연결하는 게 아니라, 중간에 stdclubtbl 같은 연결 테이블이 필요했다.
INSERT INTO stdtbl VALUES
('김범수','경남'),
('성시경','서울'),
('조용필','경기'),
('은지원','경북'),
('바비킴','서울');
INSERT INTO clubtbl VALUES
('수영','101호'),
('바둑','102호'),
('축구','103호'),
('봉사','104호');
INSERT INTO stdclubtbl VALUES
(NULL, '김범수','바둑'),
(NULL, '김범수','축구'),
(NULL, '조용필','축구'),
(NULL, '은지원','축구'),
(NULL, '은지원','봉사'),
(NULL, '바비킴','봉사');
stdclubtbl의 num은 AUTO_INCREMENT라서 NULL을 넣으면 자동으로 번호가 들어간다. 그리고 stdName, clubName은 각각 학생 테이블과 동아리 테이블을 참조하는 외래키다. 이 구조를 보니까 RDBMS에서 말하는 “관계”가 실제 테이블 설계에서는 어떻게 표현되는지 좀 더 명확해졌다.
3. INNER JOIN: 동아리에 가입한 학생만 보기
3개 테이블에서 필요한 정보를 가져오려면 JOIN이 필요했다. 학생 이름과 주소는 stdtbl, 동아리 이름은 stdclubtbl, 동아리방 번호는 clubtbl에 있어서 한 테이블만 봐서는 원하는 결과가 나오지 않았다.
SELECT S.stdName, S.addr, SC.clubName, C.roomNo
FROM stdtbl S
INNER JOIN stdclubtbl SC
ON S.stdName = SC.stdName
INNER JOIN clubtbl C
ON C.clubName = SC.clubName;
이 쿼리는 동아리에 가입한 학생만 보여준다. INNER JOIN은 양쪽 테이블에 연결되는 데이터가 있어야 결과에 나오기 때문에, 학생 테이블에는 있지만 동아리 신청 내역이 없는 성시경은 결과에서 빠진다.
여기서 중요한 건 테이블을 한 번에 “붙인다”기보다, 출발점을 잡고 조금씩 확장하는 느낌이었다. 먼저 학생 테이블에서 출발하고, 학생 이름으로 가입 테이블을 붙이고, 다시 동아리 이름으로 동아리 테이블을 붙인다.
stdtbl.stdName = stdclubtbl.stdName
stdclubtbl.clubName = clubtbl.clubName
이 연결 기준을 잡지 못하면 JOIN은 그냥 문법일 뿐이다. 이때부터 JOIN은 “테이블을 합치는 명령어”라기보다, 흩어진 정보를 어떤 경로로 따라갈 것인지 정하는 과정처럼 느껴졌다.
4. LEFT JOIN: 신청하지 않은 학생까지 확인하기
두 번째로는 모든 학생의 동아리 신청 여부를 확인했다. 이 경우에는 동아리를 신청하지 않은 학생도 결과에 나와야 하므로 LEFT JOIN이 필요했다.
SELECT S.stdName, SC.clubName, C.roomNo
FROM stdtbl S
LEFT JOIN stdclubtbl SC
ON S.stdName = SC.stdName
LEFT JOIN clubtbl C
ON SC.clubName = C.clubName;
여기서는 stdtbl이 기준 테이블이다. 모든 학생을 보여줘야 하므로 학생 테이블을 왼쪽에 두고, 가입 내역과 동아리 정보를 붙였다. 신청 내역이 있는 학생은 동아리명과 방 번호가 나오고, 없는 학생은 NULL로 나온다.
이 부분이 INNER JOIN과 확실히 달랐다.
INNER JOIN = 연결되는 데이터만 보기
LEFT JOIN = 기준 테이블은 모두 보기
그래서 “신청한 학생 목록”을 만들 때는 INNER JOIN, “전체 학생의 신청 여부”를 확인할 때는 LEFT JOIN을 쓰는 게 맞다. 같은 테이블을 연결하더라도, 내가 보고 싶은 결과가 무엇인지에 따라 JOIN 종류가 달라졌다.
5. SQL EDA 복습: SELECT, DISTINCT, CASE WHEN
오후에는 SQL EDA에서 자주 쓰는 문법을 다시 정리했다. SELECT, DISTINCT, GROUP BY, CASE WHEN, 서브쿼리, 순위 함수 같은 내용이 나왔다. 자료에서도 AS는 별칭을 만들 때 쓰지만 where에서는 바로 사용할 수 없고, 컬럼명에 공백이 있으면 백틱을 사용해야 한다고 정리되어 있었다. 또 DISTINCT col1, col2, col3은 col1만 유니크하게 보는 게 아니라 세 컬럼 조합의 유니크를 본다는 점도 중요했다.
CASE WHEN은 원본 데이터를 내가 원하는 기준으로 다시 분류할 때 많이 사용했다.
SELECT
userID,
birthYear,
CASE
WHEN birthYear BETWEEN 1960 AND 1969 THEN '60년대'
WHEN birthYear BETWEEN 1970 AND 1979 THEN '70년대'
WHEN birthYear BETWEEN 1980 AND 1989 THEN '80년대'
ELSE '기타'
END AS `세대`
FROM usertbl;
이렇게 하면 출생년도를 그대로 보는 게 아니라, 60년대/70년대/80년대처럼 분석하기 좋은 기준으로 바꿔 볼 수 있다. 그 다음에는 이 결과를 세대별로 묶어서 고객 수까지 볼 수 있다.
SELECT
CASE
WHEN birthYear BETWEEN 1960 AND 1969 THEN '60년대'
WHEN birthYear BETWEEN 1970 AND 1979 THEN '70년대'
WHEN birthYear BETWEEN 1980 AND 1989 THEN '80년대'
ELSE '기타'
END AS `세대`,
COUNT(1) AS `고객수`
FROM usertbl
GROUP BY 1;
여기서 GROUP BY 1은 SELECT의 첫 번째 컬럼을 기준으로 묶는다는 의미다. 이건 짧게 쓰기에는 편하지만, 처음 보는 사람에게는 헷갈릴 수 있어서 상황에 따라 컬럼명을 직접 쓰는 게 더 읽기 좋을 수도 있겠다고 느꼈다.
6. FROM 서브쿼리: 계산한 결과를 다시 쓰고 싶을 때
이날 가장 중요하게 남은 개념 중 하나는 FROM절 서브쿼리였다. 단순히 WHERE 안에서 조건값을 찾는 서브쿼리가 아니라, 내가 한 번 계산한 결과를 다시 테이블처럼 감싸서 사용하는 방식이었다.
예를 들어 고객별 총 구매금액을 만든 뒤, 그 금액을 기준으로 고객 등급을 붙이고 싶다고 하면 Total은 원본 테이블에 있던 컬럼이 아니다. SUM(price * amount)로 계산해서 만든 1차 가공 결과다. 이 결과를 다시 기준으로 쓰려면 한 번 감싸서 테이블처럼 만들어야 한다.
SELECT
A.userID,
A.Total,
CASE
WHEN A.Total >= 1500 THEN 'VIP'
WHEN A.Total >= 1000 THEN '우수'
WHEN A.Total > 0 THEN '일반'
ELSE 'Ghost'
END AS `등급`
FROM (
SELECT
userID,
SUM(price * amount) AS Total
FROM buytbl
GROUP BY userID
) A;
처음에는 Total이라는 별칭을 만들었으니까 바로 쓸 수 있을 것 같았는데, 그렇지 않았다. 그 값은 DB에 원래 저장된 값이 아니라 쿼리 결과로 잠깐 만들어진 값이었다. 그래서 그 결과를 다시 쓰려면 FROM ( ... ) A처럼 감싸야 했다.
강의 내용에서도 원본 데이터에서 1차 가공을 하고, 그 결과를 바탕으로 2차 가공을 할 때는 원본에서 바로 갈 수 없고, 가공 결과를 하나의 테이블처럼 포장해서 사용해야 한다고 설명했다. 특히 MySQL에서는 FROM 안의 서브쿼리에 A 같은 별칭을 반드시 붙여야 한다는 점도 강조되었다.
이 부분은 쿼리를 앞에서부터 읽으면 더 헷갈렸다. 오히려 안쪽 덩어리를 먼저 보고, “아 이게 A라는 임시 테이블이구나”라고 생각해야 이해가 쉬웠다.
원본 데이터
→ 1차 가공: 고객별 총 구매금액 계산
→ 2차 가공: 구매금액 기준 등급 부여
이제부터 복잡한 SQL을 볼 때는 긴 문장처럼 읽기보다, 어떤 덩어리가 어떤 임시 테이블을 만드는지 먼저 봐야겠다고 느꼈다.
7. Netflix 데이터: 문제를 풀기 전에 구조부터 확인하기
Netflix 실습에서는 titles와 credits 두 테이블을 사용했다. titles는 콘텐츠 메타데이터이고, credits는 콘텐츠에 참여한 배우/감독 정보였다.
USE netflix;
SHOW TABLES;
SELECT *
FROM titles;
SELECT *
FROM credits;
분석을 바로 시작하기 전에 먼저 값의 종류를 확인했다.
SELECT DISTINCT type
FROM titles;
SELECT DISTINCT age_certification
FROM titles;
SELECT DISTINCT role
FROM credits;
SELECT COUNT(1)
FROM titles;
SELECT COUNT(1)
FROM credits;
여기서 titles.type에는 MOVIE, SHOW가 있었고, credits.role에는 ACTOR, DIRECTOR가 있었다. 또 age_certification은 겉으로 보기엔 비어 있는 값이 있었는데, 이게 NULL인지 빈 문자열인지 직접 확인해야 했다.
이 단계가 생각보다 중요했다. 문제를 풀기 전에 어떤 테이블에 어떤 컬럼이 있는지, 각 컬럼에 어떤 값들이 들어 있는지, 전체 데이터가 몇 건인지 확인하지 않으면 이후 쿼리가 흔들린다. 녹취에서도 titles는 Netflix 콘텐츠 메타데이터이고, credits는 콘텐츠 참여 인물 데이터이며, 두 테이블은 콘텐츠 고유값인 id로 연결된다고 설명했다.
8. 평점 문제: 정렬보다 먼저 필터 조건을 놓치면 안 됐다
1번은 IMDb 평점 기준 상위 10개 영화를 조회하는 문제였다. 처음에는 단순히 imdb_score로 정렬하고 limit 10을 걸면 될 것처럼 보인다. 그런데 문제에서 “영화”라고 했기 때문에 type = "MOVIE" 조건이 먼저 필요했다.
SELECT *
FROM titles
WHERE type = "MOVIE"
ORDER BY imdb_score DESC
LIMIT 10;
2번도 비슷했다. 이번에는 TV 쇼 중 IMDb 평점이 낮은 순서로 10개를 조회했다.
SELECT title, imdb_score
FROM titles
WHERE type = "SHOW"
ORDER BY imdb_score ASC
LIMIT 10;
이런 문제는 쉬워 보이지만, 조건 하나를 놓치면 결과가 완전히 달라진다. “상위 10개”라는 말에 먼저 반응하면 전체 데이터를 정렬하게 되고, 정작 MOVIE나 SHOW 조건을 빠뜨릴 수 있다. 그래서 SQL 문제는 정렬보다 먼저 분석 대상이 누구인지를 필터링하는 게 중요했다.
9. 평균과 decade: GROUP BY 패턴이 반복됐다
3번은 영화와 TV 쇼의 평균 IMDb/TMDB 평점을 구하는 문제였다. 여기서 GROUP BY type이 필요했다.
SELECT
type,
ROUND(AVG(imdb_score), 2) AS AvgIMDB,
ROUND(AVG(tmdb_score), 2) AS AvgTMDB
FROM titles
GROUP BY type;
평균값은 그대로 두면 소수점이 길게 나오므로 ROUND()로 소수점 둘째 자리까지 정리했다. 이건 단순히 보기 좋게 만드는 문제가 아니라, 리포팅할 때 결과를 사람이 읽을 수 있게 정리하는 과정이라는 느낌이었다.
4번은 10년대별 영화와 TV 쇼의 개수를 구하는 문제였다. 처음에는 CASE WHEN으로 1940년대, 1950년대, 1960년대처럼 나눌 수도 있지만, 범위가 많아지면 너무 길어진다. 그래서 FLOOR()를 사용했다.
SELECT
type,
FLOOR(release_year / 10) * 10 AS Decade,
COUNT(1) AS Counting
FROM titles
GROUP BY type, Decade
ORDER BY Decade DESC, type DESC;
FLOOR(release_year / 10) * 10은 2021을 2020으로, 2017을 2010으로 묶어준다. 나이대나 연도 구간을 만들 때도 비슷하게 쓸 수 있을 것 같다.
이쯤에서 SQL EDA의 기본 패턴이 좀 보였다.
기준 컬럼 확인
→ 필요한 값 선택
→ GROUP BY로 묶기
→ 집계 함수 적용
→ ORDER BY로 정렬
10. 빈 문자열 처리: NULL이라고 단정하면 안 됐다
7번은 영화에서 가장 많이 등장하는 연령 등급 Top 5를 찾는 문제였다. 단, 연령 등급이 없는 데이터는 제외해야 했다. 여기서 제일 중요한 건 결측처럼 보이는 값이 NULL이 아니라 빈 문자열 ""이었다는 점이다.
SELECT *
FROM titles
WHERE age_certification IS NULL;
SELECT *
FROM titles
WHERE age_certification = "";
직접 확인해보니 IS NULL로는 잡히지 않고, age_certification = ""로 잡히는 데이터가 있었다. 그래서 최종 필터는 이렇게 작성했다.
SELECT
age_certification,
COUNT(1) AS Cnt
FROM titles
WHERE age_certification != ""
AND type = "MOVIE"
GROUP BY age_certification
ORDER BY Cnt DESC
LIMIT 5;
이 부분은 실제 데이터 분석에서 꽤 중요할 것 같다. 결측값이라고 해서 항상 NULL로 들어오는 게 아니다. 빈 문자열, 공백, 이상한 문자처럼 들어올 수 있으니까, 쿼리 전에 DISTINCT, IS NULL, = ""를 직접 확인해야 한다.
11. 최다 출연 배우: 이름보다 ID로 묶는 게 안전했다
8번은 영화와 TV 쇼를 통합해서 가장 많이 출연한 배우 Top 10을 찾는 문제였다. 여기서는 credits 테이블에서 role = "ACTOR"만 필터링하고, 배우별로 출연 횟수를 세면 된다.
SELECT
person_id,
COUNT(1) AS Cnt,
ANY_VALUE(name) AS name
FROM credits
WHERE role = "ACTOR"
GROUP BY person_id
ORDER BY Cnt DESC
LIMIT 10;
처음엔 이름으로 묶어도 되지 않을까 싶지만, 동명이인이 있을 수 있다. 그래서 사람을 구분하는 코드값인 person_id로 묶는 게 더 안전하다. 이건 DB에서 이름 같은 텍스트보다 고유 ID를 기준으로 봐야 하는 이유를 보여주는 예시였다.
ANY_VALUE(name)은 person_id로 묶었을 때 이름을 함께 보여주기 위한 방식으로 사용했다. ONLY_FULL_GROUP_BY 설정 때문에 집계하지 않은 컬럼을 바로 같이 보여주면 문제가 생길 수 있어서, 임의의 대표값을 보여주는 용도로 썼다.
12. titles와 credits JOIN: 감독 정보는 다른 테이블에 있었다
9번부터는 titles와 credits를 조인해야 했다. 영화 제목과 출시년도는 titles에 있지만, 감독 이름은 credits에 있기 때문이다.
SELECT
T.title,
T.release_year,
C.role,
C.name
FROM titles T
INNER JOIN credits C
ON T.id = C.id
WHERE T.type = "MOVIE"
AND T.release_year >= 2010
AND C.role = "DIRECTOR"
ORDER BY T.release_year DESC;
여기서 중요한 건 조건이 양쪽 테이블에 흩어져 있다는 점이었다.
영화 여부, 출시년도, 평점 → titles
감독 이름, 역할 → credits
연결 기준 → titles.id = credits.id
또 하나 헷갈릴 수 있는 점은 감독이 여러 명일 수 있다는 것이다. 제목별 모든 감독을 표시하면 결과 건수가 많아지고, 제목별 감독 1명만 표시하려면 GROUP BY T.title로 묶어야 한다.
SELECT
T.title,
T.release_year,
C.role,
C.name
FROM titles T
INNER JOIN credits C
ON T.id = C.id
WHERE T.type = "MOVIE"
AND T.release_year >= 2010
AND C.role = "DIRECTOR"
GROUP BY T.title
ORDER BY T.release_year DESC;
문제에서도 제목별 모든 감독을 표시한 경우와 제목별 감독 1명만 표시한 경우의 결과 건수를 비교하도록 되어 있었다. 이걸 보면서 JOIN 결과는 항상 1:1이라고 생각하면 안 된다는 걸 다시 느꼈다. 영화 하나에 감독 여러 명, 배우 여러 명이 연결될 수 있기 때문에 결과 행 수가 늘어날 수 있다.
13. 최고 평점 문제: 서브쿼리는 바깥 조건을 자동으로 따라오지 않는다
11번은 IMDb 평점이 가장 높은 영화의 제목과 평점을 출력하는 문제였다. 최고점인 영화가 여러 개라면 모두 출력해야 하므로 ORDER BY ... LIMIT 1보다 MAX() 서브쿼리가 더 적절했다.
SELECT title, imdb_score
FROM titles
WHERE imdb_score = (
SELECT MAX(imdb_score)
FROM titles
WHERE type = "MOVIE"
);
12번은 2015년에 출시된 영화 중 IMDb 평점이 가장 높은 영화의 제목과 감독 이름을 조회하는 문제였다. 여기서 제일 크게 배운 건 서브쿼리에도 같은 범위 조건을 넣어야 한다는 점이었다.
SELECT
T.title,
T.imdb_score,
C.name
FROM titles T
INNER JOIN credits C
ON T.id = C.id
WHERE T.release_year = 2015
AND C.role = "DIRECTOR"
AND T.type = "MOVIE"
AND T.imdb_score = (
SELECT MAX(imdb_score)
FROM titles
WHERE release_year = 2015
AND type = "MOVIE"
);
처음에는 바깥 쿼리에서 release_year = 2015와 type = "MOVIE"를 걸었으니까 충분하다고 생각하기 쉽다. 그런데 최고점을 구하는 서브쿼리는 별도로 실행되는 쿼리다. 그래서 안쪽에서도 2015년 영화라는 조건을 다시 넣어야 “2015년에 출시된 영화 중 최고점”이 된다.
이건 이날 Netflix 문제 중에서 가장 크게 남은 포인트였다.
서브쿼리는 별도 쿼리다.
최고점을 비교하려면 서브쿼리도 같은 범위에서 계산해야 한다.
14. 의류 리뷰 데이터: SQL로 텍스트 EDA를 해봤다
Netflix 문제 뒤에는 의류 리뷰 데이터 dataset2를 봤다. 리뷰 한 건이 하나의 레코드이고, 나이, 리뷰 제목, 리뷰 본문, 평점, 추천 여부, 카테고리 정보가 들어 있는 데이터였다.
먼저 Trend 부서의 평점 3점 이하 리뷰를 조회했다.
SELECT *
FROM dataset2
WHERE `Department Name` = "Trend"
AND Rating <= 3;
그 다음 이 불만 리뷰를 나이대별로 묶었다.
SELECT
FLOOR(Age / 10) * 10 AS AgeBand,
COUNT(1) AS Cnt
FROM dataset2
WHERE `Department Name` = "Trend"
AND Rating <= 3
GROUP BY 1
ORDER BY 2 DESC;
이렇게 하면 Trend 카테고리에서 어떤 나이대가 낮은 평점을 많이 남겼는지 볼 수 있다. 다만 집계만 보고 끝내면 이유를 알 수 없으니, 특정 나이대의 리뷰 본문도 직접 확인했다.
SELECT `Review Text`
FROM dataset2
WHERE `Department Name` = "Trend"
AND Rating <= 3
AND Age BETWEEN 50 AND 59
LIMIT 5;
이 부분은 좀 데이터 분석답게 느껴졌다. 숫자로 “어느 나이대가 많다”를 보고, 다시 텍스트로 “왜 그런지”를 확인하는 흐름이었기 때문이다.
15. CASE WHEN + LIKE: 특정 단어 언급 수 세기
리뷰 데이터에서는 size, large, small 같은 단어가 몇 번 언급됐는지도 확인했다. 이때 LIKE와 CASE WHEN을 같이 사용했다.
SELECT
COUNT(1),
SUM(CASE WHEN `Review Text` LIKE "%size%" THEN 1 ELSE 0 END) AS SizeCount
FROM dataset2;
조건에 맞으면 1, 아니면 0으로 바꾼 뒤 SUM()으로 더하는 방식이다. 여러 키워드를 한 번에 세려면 이렇게 작성할 수 있다.
SELECT
COUNT(1),
SUM(CASE WHEN `Review Text` LIKE "%size%" THEN 1 ELSE 0 END) AS SizeCount,
SUM(CASE WHEN `Review Text` LIKE "%large%" THEN 1 ELSE 0 END) AS LargeCount,
SUM(CASE WHEN `Review Text` LIKE "%small%" THEN 1 ELSE 0 END) AS SmallCount
FROM dataset2;
카테고리별로 보고 싶으면 GROUP BY를 붙이면 된다.
SELECT
`Department Name`,
COUNT(1),
SUM(CASE WHEN `Review Text` LIKE "%size%" THEN 1 ELSE 0 END) AS SizeCount,
SUM(CASE WHEN `Review Text` LIKE "%large%" THEN 1 ELSE 0 END) AS LargeCount,
SUM(CASE WHEN `Review Text` LIKE "%small%" THEN 1 ELSE 0 END) AS SmallCount
FROM dataset2
GROUP BY 1;
SQL EDA 자료에서도 CASE WHEN은 조건별 값을 출력할 때뿐 아니라, 특정 조건만 집계할 때 사용할 수 있다고 정리되어 있었다. 예를 들어 특정 지역이면 1, 아니면 0으로 만들어 SUM()하는 방식이다.
이건 꽤 유용했다. SQL만으로도 간단한 텍스트 EDA가 가능했고, 특정 키워드가 어느 카테고리에서 많이 나오는지 볼 수 있었다. 물론 단순 단어 검색은 문맥까지 이해하지는 못하므로, 이후에는 실제 리뷰를 같이 읽거나 GPT 같은 도구로 문맥을 보완해야 할 것 같다.
16. Selenium 세팅은 다음 실습 준비 정도로 정리
마지막에는 Selenium 실습을 위한 ChromeDriver 세팅도 봤다. 크롬 브라우저를 코드로 제어하려면 ChromeDriver가 필요하고, 이때 크롬 브라우저 버전과 드라이버 버전이 맞아야 한다. 녹취에서도 버전이 맞지 않으면 어제까지 돌아가던 코드가 안 돌아가는 경우가 많다고 설명했다.
내 경우에는 Chrome for Testing 페이지에서 현재 크롬 버전을 확인하고, mac-arm64용 ChromeDriver를 받는 흐름으로 정리했다.
Chrome 버전 확인
→ Chrome for Testing에서 동일 버전대 확인
→ chromedriver mac-arm64 zip 다운로드
→ 압축 해제
→ 실행 파일 경로 확인
Selenium은 기본 Anaconda 설치에 포함되지 않을 수 있어서 별도 설치가 필요했다.
pip install selenium
또는 Anaconda 환경에서는 아래처럼 설치할 수도 있다.
conda install -c conda-forge selenium
Python에서는 아래 모듈들을 사용한다.
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
다만 이날의 중심은 Selenium이 아니라 SQL EDA였기 때문에, Selenium은 다음 웹 자동화 실습을 위한 준비 정도로만 이해했다. 여기서 남은 건 “Selenium 코드는 오래된 블로그 글을 그대로 따라 하면 안 되고, 버전과 문법을 확인해야 한다”는 점이었다.
마무리
16일차는 SQL 문법을 실제 분석 문제에 적용하기 시작한 날이었다. 오전에는 3개 테이블을 연결하면서 다대다 관계와 연결 테이블을 이해했고, 오후에는 Netflix 데이터와 리뷰 데이터를 가지고 where, group by, order by, join, subquery, case when, like를 계속 써봤다.
특히 Netflix 문제를 풀면서 SQL은 문법보다 문제 조건을 읽는 게 더 중요하다는 걸 느꼈다. MOVIE인지 SHOW인지, 결측이 NULL인지 빈 문자열인지, 감독이 한 명인지 여러 명인지, 최고점을 구할 때 서브쿼리 안에도 같은 조건이 들어가야 하는지에 따라 결과가 달라졌다.
오늘 가장 크게 남은 건 아래 내용이다.
1. JOIN은 테이블을 붙이는 문법이 아니라, 흩어진 정보를 연결하는 방식이다.
2. SQL EDA는 GROUP BY, 집계 함수, ORDER BY, LIMIT 조합이 계속 반복된다.
3. 결측처럼 보이는 값이 NULL인지 빈 문자열인지 직접 확인해야 한다.
4. 서브쿼리는 바깥 조건을 자동으로 따라오지 않는다.
5. CASE WHEN은 분류뿐 아니라 조건부 카운팅에도 쓸 수 있다.
6. 복잡한 SQL은 앞에서부터 읽지 말고, 덩어리 단위로 봐야 한다.
이제 SQL을 볼 때 단순히 “이 문법이 뭐였지?”보다, 먼저 어떤 테이블에서 출발하는지, 어떤 조건으로 데이터를 자르는지, 어떤 기준으로 묶는지, 그리고 계산한 결과를 다시 쓰는지를 봐야겠다. 16일차는 그 감각이 조금 생긴 날이었다.
'[SK플래닛] ASAC 빅데이터전문가 11기 > 학습기록' 카테고리의 다른 글
| [SK플래닛] ASAC 빅데이터전문가 11기 | 19일차 (0) | 2026.05.15 |
|---|---|
| [SK플래닛] ASAC 빅데이터전문가 11기 | 17일차 (0) | 2026.05.14 |
| [SK플래닛] ASAC 빅데이터전문가 11기 | 16일차 (0) | 2026.05.08 |
| [SK플래닛] ASAC 빅데이터전문가 11기 | 15일차 (0) | 2026.05.07 |
| [SK플래닛] ASAC 빅데이터전문가 11기 | 14일차 (2) | 2026.05.06 |
