
오늘은 SQL 문제 풀이를 진행하며 겪었던 시행착오와 정답 쿼리를 분석하고, 그 과정에서 배운 핵심 개념들을 정리해보았다.
또한, 논리적 흐흠과 데이터 타입의 특성,
고급 함수 활용에 대해 깊이 있게 다뤄보고자 한다.
날짜 데이터 포맷팅, 복잡한 문자열 기반의 시간 데이터 연산, 그리고 윈도우 함수를 이용한 랭킹 시스템 구현을 다뤘다.
games 테이블에서 2020 시즌(SEASON = 2020)의 경기 중 홈 팀 득점(PTS_home)이 110점 이상인 경기를 조회하라. 단, 경기 날짜(GAME_DATE_EST)는 'YYYY-MM-DD' 형식으로 출력하고, GAME_ID와 PTS_home도 함께 출력해야 한다.SELECT GAME_ID,
date_format(GAME_DATE_EST, '%Y-%m-%d') AS game_date,
PTS_home
FROM games
WHERE season = 2020
AND PTS_home >= 110;DATE_FORMAT(컬럼, 형식) 함수를 정확히 이해하고 사용했다. SQL에서 날짜 데이터는 기본적으로 DATETIME이나 TIMESTAMP 타입을 가지는데, 이를 시각적으로 편한 문자열로 바꾸기 위해 포맷팅이 필수적이다.%Y는 4자리 연도, %m은 숫자 월, %d는 일자를 의미한다.games_details 테이블에서 선수 이름(PLAYER_NAME)이 'JAMES'로 끝나는 선수들의 PLAYER_ID와 PLAYER_NAME을 중복 없이 조회하라.SELECT PLAYER_ID, PLAYER_NAME
FROM games_details
WHERE PLAYER_NAME LIKE '%JAMES'LIKE 연산자와 와일드카드(%)의 활용이 적절했다. %JAMES는 앞에 어떤 문자가 오든 상관없이 끝이 'JAMES'인 문자열을 찾는다.%JAMES%를 사용해야 하며, 정확히 'JAMES'인 경우만 찾으려면 LIKE 대신 = 연산자가 성능상 유리할 수 있다.games_details 테이블에서 각 팀(TEAM_ID)별 평균 득점(PTS)을 계산하고, 그 평균 득점이 105점 이상인 팀의 ID와 평균 득점을 조회하라.SELECT TEAM_ID, avg(PTS) AS '평균 득점'
FROM games_details
GROUP BY TEAM_ID
HAVING avg(PTS) >= 105FROM -> WHERE -> GROUP BY -> HAVING -> SELECT)를 잘 이해하고 있다. 집계 함수(AVG)의 결과에 대한 조건은 WHERE 절이 아닌 HAVING 절에서 처리해야 함을 정확히 적용했다.games_details와 players 테이블을 조인하여, 각 선수(PLAYER_NAME)별 평균 출전 시간(MIN)을 구하라. (단, 데이터가 없는 경우 제외)SELECT p.Player_name, gd.avg(min)
FROM games_details AS gd
JOIN players AS p
ON gd.Team_ID = p.Team_IDSELECT
p.PLAYER_NAME,
SEC_TO_TIME(AVG(TIME_TO_SEC(gd.MIN))) AS avg_playtime
FROM games_details AS gd
JOIN players AS p
ON gd.PLAYER_ID = p.PLAYER_ID
WHERE gd.MIN IS NOT NULL
AND gd.MIN != ''
GROUP BY p.PLAYER_NAME;MIN 컬럼은 '25:30'과 같은 문자열(String) 형태일 가능성이 높다. 문자열에 바로 AVG()를 씌우면 SQL은 이를 제대로 된 숫자로 인식하지 못해 0이나 엉뚱한 값을 반환한다.TIME_TO_SEC()으로 문자열을 초(Second) 단위 정수로 변환 -> AVG()로 평균 초 계산 -> SEC_TO_TIME()으로 다시 시간 형식으로 변환하는 3단계 프로세스가 필요하다.ON gd.Team_ID = p.Team_ID로 조인하면, 특정 팀의 경기 기록이 해당 팀의 모든 선수와 다대다(N:M)에 가깝게 잘못 매핑된다. 선수의 기록을 보려면 반드시 고유 식별자인 PLAYER_ID를 기준으로 조인해야 한다.gd.avg(min)이라는 문법은 존재하지 않는다. 함수는 AVG(gd.min) 형태로 컬럼을 감싸야 한다.TEAM_ID_home)별로 가장 최근에 치른 경기의 날짜, 홈 득점, 그리고 팀의 현재 승패(W, L)를 조회하라.WITH latest_games AS (
SELECT
GAME_ID,
TEAM_ID_home,
GAME_DATE_EST,
PTS_home,
ROW_NUMBER() OVER (
PARTITION BY TEAM_ID_home
ORDER BY GAME_DATE_EST DESC
) AS rn
FROM games
)
SELECT
lg.TEAM_ID_home,
lg.GAME_DATE_EST,
lg.PTS_home,
r.W,
r.L
FROM latest_games AS lg
JOIN ranking AS r
ON lg.TEAM_ID_home = r.TEAM_ID
WHERE lg.rn = 1;그룹별 최신 1건을 뽑는 문제는 일반적인 GROUP BY로는 해결하기 매우 까다롭다. 이때 윈도우 함수(Window Function)가 강력한 도구가 된다.ROW_NUMBER() OVER (PARTITION BY 그룹컬럼 ORDER BY 정렬컬럼 DESC) 구문은 그룹별로 순위를 매긴다. 여기서 순위가 1인 것이 곧 최신 데이터가 된다.WITH 절을 사용해 가상의 테이블(latest_games)을 먼저 정의하고, 메인 쿼리에서 이를 깔끔하게 호출하는 방식이 실무적인 쿼리 작성법이다.이 파트에서는 단순 조회처럼 보이지만 디테일이 필요한 문자열 처리와, 서브쿼리의 종류 중 하나인 스칼라 서브쿼리에 대해 다뤘다.
PTS_away)이 100점 이상인 경기를 찾아, 득점이 높은 순서대로 정렬하여 조회하라.SELECT GAME_ID, VISITOR_TEAM_ID, PTS_away
FROM games
WHERE PTS_away >= 100
ORDER BY PTS_away DESC ORDER BY 컬럼 DESC(내림차순) 구문을 정확하게 사용했다.PLAYER_NAME)에 공백(' ')이 포함된 선수들을 조회하라. (즉, 이름과 성이 모두 있는 선수 등을 찾으라는 의도)SELECT PLAYER_ID, instr(PLAYER_NAME, ' ')
FROM playersSELECT PLAYER_ID, PLAYER_NAME
FROM players
WHERE PLAYER_NAME LIKE '% %';
-- 또는
-- WHERE INSTR(PLAYER_NAME, ' ') > 0;WHERE 절에 조건을 명시해야 한다.INSTR 함수는 위치 인덱스를 반환하므로 > 0 조건을 걸어야 한다. 하지만 단순히 포함 여부만 따질 때는 LIKE '% %'가 훨씬 직관적이고 표준적인 SQL 작성법이다.OREB)와 수비 리바운드(DREB)를 합친 '총 리바운드'의 합계가 1000개 이상인 팀을 조회하라.SELECT TEAM_ID, SUM(OREB + DREB) AS total_rebounds
FROM games_details
GROUP BY TEAM_ID
HAVING SUM(OREB + DREB) >= 1000;SUM() 함수 내부에서 컬럼 간의 연산(OREB + DREB)이 가능함을 잘 알고 활용했다.SELECT gd.PLAYER_ID, p.PLAYER_NAME, count(*) AS game_count
FROM games_details AS gd
JOIN players AS p
ON gd.PLAYER_ID = p.PLAYER_ID
GROUP BY gd.PLAYER_ID, p.PLAYER_NAME
ORDER BY PLAYER_NAME ASC;JOIN을 통해 이름을 가져오고, GROUP BY로 선수별 데이터를 묶은 뒤 COUNT(*)로 행의 개수를 세는 표준적인 집계 패턴을 완벽하게 수행했다.SELECT GAME_ID, GAME_DATE_EST, PTS_home
FROM games
WHERE SEASON = 2020
AND pts_home > (
SELECT avg(PTS_home)
FROM games
WHERE SEASON = 2020
);>, < 등)의 오른쪽에 올 수 있다. 만약 서브쿼리가 여러 행을 반환했다면 에러가 발생했을 것이다.이 파트에서는 서브쿼리가 단순 값을 리턴하는 것을 넘어, 집계 결과와 비교될 때 어떻게 처리해야 하는지, 즉 HAVING 절에서의 서브쿼리 활용을 다뤘다.
SELECT TEAM_ID, AVG(PTS)
FROM games_details
GROUP BY TEAM_ID
HAVING AVG(PTS) >= 20
ORDER BY AVG(PTS) DESCGROUP BY + AVG), 필터링(HAVING), 정렬(ORDER BY)의 삼박자가 잘 맞았다.SELECT p.player_name, t.CITY
FROM players AS p
JOIN teams AS t
ON p.TEAM_ID = t.TEAM_ID
WHERE t.CITY = 'Los Angeles'players 테이블에는 도시 정보가 없으므로 teams 테이블과 조인해야 한다는 사실을 잘 파악했다.SELECT gd.PLAYER_ID, gd.TEAM_ID, g.GAME_ID
FROM games_details AS gd
JOIN games AS g
ON gd.GAME_ID = g.GAME_ID
WHERE g.PTS_home > g.PTS_away;games_details에는 점수 정보가 없거나 불충분할 수 있어 games 테이블과 조인하고, games 테이블 내의 두 컬럼(PTS_home, PTS_away)을 비교하는 조건을 잘 작성했다.SELECT GAME_ID, GAME_DATE_EST, PTS_home
FROM games
WHERE pts_home > (
SELECT avg(PTS_home)
FROM games);SELECT PLAYER_ID, count(*) >
(SELECT sum(RED) FROM games_details)
FROM games_details
GROUP BY PLAYER_IDSELECT
PLAYER_ID,
SUM(REB) AS total_rebounds
FROM games_details
GROUP BY PLAYER_ID
HAVING SUM(REB) > (
SELECT SUM(REB)
FROM games_details
WHERE PLAYER_NAME = 'LeBron James'
);SELECT 절에서 비교를 시도했다. SQL에서 SELECT는 데이터를 '보여주는' 곳이지 데이터를 '거르는' 곳이 아니다. 집계된 결과(SUM(REB))를 기준으로 데이터를 남길지 말지 결정하려면 반드시 HAVING 절을 사용해야 한다.count(*)(경기 수)와 비교하려 했다. 논리적 정합성을 위해 SUM(REB)끼리 비교해야 한다.WHERE PLAYER_NAME = 'LeBron James' 조건이 필수적으로 들어가야 한다.오늘의 시행착오를 통해 다음 세 가지 원칙을 뼈저리게 느꼈다. 이 원칙들은 앞으로의 쿼리 작성에 있어 강력한 가이드라인이 될 것이다.
TIME_TO_SEC -> 숫자 연산 -> SEC_TO_TIME -> 문자열GROUP BY와 JOIN 대신 ROW_NUMBER() OVER(PARTITION BY... ORDER BY...)를 사용하면 쿼리가 훨씬 우아해진다.WHERE는 원본 데이터를 거르고, HAVING은 GROUP BY로 요약된 데이터를 거른다. 집계 함수(SUM, AVG)와 서브쿼리를 비교할 때는 HAVING 절을 확인하자.이 과정을 통해 SQL 레벨이 한 단계 성장했음을 느낀다. 단순한 문법 암기가 아니라, 데이터가 어떻게 흐르고 처리되는지 그 구조를 이해하는 것이 쿼리 최적화와 정답률 상승의 지름길이다.
SQL에 소홀해지다 보니, 이렇게 간단한 문제들도 틀려버린다.
튜터님들께서 자주
"앞으로 SQL을 자주 사용하지 않으니, 코트카타 열심히" 라고 말씀하시는데,
정말 지당한 말씀이라고 생각이 든다..
앞으로 코트카타 더 열심히 하고, 시간 날 때 개념 한 번씩 훑어보자.