Write a solution to find all the authors that viewed at least one of their own
articles. Return the result table sorted by id in ascending order.
최소 1번 이상 author_id와 viewer_id가 같은 author_id 출력하는 문제이다.
SELECT DISTINCT author_id as id
FROM Views
WHERE author_id = viewer_id
ORDER BY 1 ASC;
Reformat the table such that there is a department id column and a revenue column
for each month. Return the result table in any order.
다음과 같은 Department table에서 id와 month 컬럼을 분리하여 테이블을 Reformat 하는 문제이다.
Input: Department table
| id | revenue | month |
|---|---|---|
| 1 | 8000 | Jan |
| 2 | 9000 | Jan |
| 3 | 10000 | Feb |
| 1 | 7000 | Feb |
| 1 | 6000 | Mar |
Reformat 해야 하는 결과 형태는 다음과 같다.
Output:
| id | Jan_Revenue | Feb_Revenue | Mar_Revenue | ... | Dec_Revenue |
|---|---|---|---|---|---|
| 1 | 8000 | 7000 | 6000 | ... | null |
| 2 | 9000 | null | null | ... | null |
| 3 | null | 10000 | null | ... | null |
(Month의 Value들은 다음과 같다. "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
처음 문제를 접했을 때는 세로로 긴 데이터를 가로로 펼쳐야 하길래, 당연히 복잡한 윈도우 함수나 내가 모르는 특별한 내장 함수가 있을 줄 알았다. 하지만 막상 파고들어 보니 생각보다 정직한(?) 노가다를 요구하는 문제였다.
이러한 작업을 데이터베이스 용어로 '피벗(Pivot)' 또는 '행-열 변환'이라고 부른다. 기계가 저장하기 좋은 형태(세로)를 사람이 보기 좋은 형태(가로)로 바꾸는 작업이다. Oracle이나 MS SQL 같은 곳에서는 PIVOT이라는 전용 함수를 지원하지만, 아쉽게도 MySQL에서는 이를 직접 구현해야 한다.
이를 위해 사용하는 핵심 기술이 바로 조건부 집계(Conditional Aggregation)다.
GROUP BY: 결과 테이블은 id별로 한 줄씩 나와야 하므로 id를 기준으로 묶어준다.CASE WHEN (또는 IF): 각 월(Month)에 해당하는 컬럼(기둥)을 세우고, 해당 월일 때만 revenue를 가져오고 아니면 NULL을 주도록 조건을 건다.SUM (또는 MAX): 그룹화된 여러 행 중에서 우리가 조건문으로 골라낸 특정 값을 단일 값으로 추출하기 위해 집계 함수로 감싸준다.문제를 풀다 보면 한 가지 의문이 생긴다.
"예를 들어, 원본 데이터에 month가 'Dec'인 데이터가 아예 없더라도 12월 컬럼은 존재해야 하는데, 이건 어떻게 처리되는 거지?"
결론부터 말하자면, 데이터가 없더라도 컬럼은 정상적으로 만들어지며 값은 NULL로 채워진다. 그 이유는 SQL의 실행 흐름에 있다.
SELECT 절이 결정한다: SQL은 원본 데이터 유무와 상관없이 SELECT 절에 작성한 컬럼 13개(id 1개 + 월 12개)를 무조건 결과 테이블의 기둥으로 세운다.ELSE null 처리됨: 12월 데이터가 원본에 단 한 줄도 없다면, CASE WHEN month = 'Dec' 조건을 만족하는 데이터가 없으므로 모든 행이 ELSE null에 걸려 null을 반환한다.GROUP BY로 묶인 상태에서 null들만 모아서 SUM()을 하게 되므로, 최종 결과도 자연스럽게 null이 된다.CASE WHEN 대신 IF() 사용하기처음에 작성했던 정석적인 CASE WHEN 쿼리는 다음과 같다.
# 정석적인 CASE WHEN 구문
SELECT
id,
SUM(CASE WHEN month = 'Jan' THEN revenue ELSE NULL END) AS Jan_Revenue,
SUM(CASE WHEN month = 'Feb' THEN revenue ELSE NULL END) AS Feb_Revenue,
-- ... (12월까지 반복) ...
SUM(CASE WHEN month = 'Dec' THEN revenue ELSE NULL END) AS Dec_Revenue
FROM
Department
GROUP BY
id;
틀린 코드는 아니지만 12달을 다 적으려니 코드가 너무 길어지고 가독성이 떨어진다. 어떻게 하면 더 줄일 수 있을까 찾아보니, MySQL 환경에서는 IF() 함수를 활용해 훨씬 간결하게 작성할 수 있었다.
IF(조건, 참일 때 값, 거짓일 때 값) 이 구조를 적용하여 코드를 리팩토링한 최종 정답은 다음과 같다.
SELECT
id,
SUM(IF(month = 'Jan', revenue, null)) AS Jan_Revenue,
SUM(IF(month = 'Feb', revenue, null)) AS Feb_Revenue,
SUM(IF(month = 'Mar', revenue, null)) AS Mar_Revenue,
SUM(IF(month = 'Apr', revenue, null)) AS Apr_Revenue,
SUM(IF(month = 'May', revenue, null)) AS May_Revenue,
SUM(IF(month = 'Jun', revenue, null)) AS Jun_Revenue,
SUM(IF(month = 'Jul', revenue, null)) AS Jul_Revenue,
SUM(IF(month = 'Aug', revenue, null)) AS Aug_Revenue,
SUM(IF(month = 'Sep', revenue, null)) AS Sep_Revenue,
SUM(IF(month = 'Oct', revenue, null)) AS Oct_Revenue,
SUM(IF(month = 'Nov', revenue, null)) AS Nov_Revenue,
SUM(IF(month = 'Dec', revenue, null)) AS Dec_Revenue
FROM
Department
GROUP BY
id;
처음엔 반복 작업이 귀찮게 느껴졌지만, 조건부 집계를 통해 데이터베이스가 어떤 원리로 피벗 테이블을 생성하는지 정확하게 이해할 수 있는 좋은 문제였다!
We define query quality as:
The average of the ratio between query rating and its position.
We also define poor query percentage as:
The percentage of all queries with rating less than 3.
Write a solution to find each query_name, the quality and poor_query_percentage.
Both quality and poor_query_percentage should be rounded to 2 decimal places.
Return the result table in any order.
데이터베이스 쿼리 실행 결과가 담긴 Queries 테이블이 주어지고, 이 데이터를 바탕으로 각 쿼리 이름(query_name)별로 다음 두 가지 지표를 구해야 한다.
rating)을 위치(position)로 나눈 값들의 평균두 값 모두 소수점 둘째 자리까지 반올림해야 하며, 순서 상관없이 출력합니다.
Input: Queries table
| query_name | result | position | rating |
|---|---|---|---|
| Dog | Golden Retriever | 1 | 5 |
| Dog | German Shepherd | 2 | 5 |
| Dog | Mule | 200 | 1 |
| Cat | Shirazi | 5 | 2 |
| Cat | Siamese | 3 | 3 |
| Cat | Sphynx | 7 | 4 |
Output:
| query_name | quality | poor_query_percentage |
|---|---|---|
| Dog | 2.50 | 33.33 |
| Cat | 0.66 | 33.33 |
처음 문제를 보고 핵심 로직은 바로 파악했다.
1. rating / position을 계산해야 한다.
2. query_name으로 GROUP BY를 묶어야 한다.
그래서 다음과 같이 쿼리를 짰는데, 집계 함수를 겹쳐 쓴 부분과 낮은 평점의 비율을 구하는 서브쿼리 부분에서 코드가 꼬이기 시작했다.
# ❌ 수정 전 초기 접근
SELECT
query_name,
AVG(SUM(rating/position)) as quality
-- (SELECT
-- quality/COUNT(rating)
-- FROM q
-- WHERE rating < quality)
-- as poor_percentage
FROM Queries q
GROUP BY query_name;
핵심 요구사항은 잘 파악했지만, 문법적으로 아쉬운 부분들이 있었다. 이를 어떻게 개선할 수 있을까?
1. quality 계산: AVG()와 SUM()은 겹쳐 쓸 필요가 없다!
AVG() 함수는 이미 내부에 '합계를 구한 뒤 개수로 나누는' 기능이 포함되어 있다. 따라서 각 행의 rating / position 값을 구한 뒤 바로 AVG()로 감싸주기만 하면 된다.
수정 전: AVG(SUM(rating / position))
수정 후: AVG(rating / position)
2. poor_query_percentage 계산: 서브쿼리 대신 IF() 활용하기
조건부 집계를 사용하면 서브쿼리 없이 한 줄로 끝낼 수 있다. 평점이 3 미만이면 1을, 아니면 0을 주도록 한 뒤 전체 개수로 나누면 된다.
수정: SUM(IF(rating < 3, 1, 0)) / COUNT(*) * 100
3. 소수점 둘째 자리 반올림: ROUND()
문제 조건에 맞게 계산된 값들을 ROUND(값, 2)로 감싸준다.
위에서 SUM과 COUNT를 조합해서 퍼센트를 구했지만, MySQL에서는 이를 AVG()와 IF()를 결합하여 엄청나게 짧게 줄일 수 있는 마법 같은 팁이 있다.
ROUND(AVG(IF(rating < 3, 100, 0)), 2) AS poor_query_percentage
🤔 어떻게 이게 가능할까?
IF(rating < 3, 100, 0) 로직을 통해 3점 미만이면 100, 아니면 0이라는 값을 준다.
예를 들어 위 문제의 'Dog'의 경우 점수가 5, 5, 1이므로 IF문을 거치면 0, 0, 100이 된다. 이 세 숫자의 평균(AVG)을 구하면 (0 + 0 + 100) / 3 = 33.33이 되어 자연스럽게 백분율(Percentage)이 계산된다!
실무에서도 비율이나 퍼센트를 구할 때 정말 자주 쓰이는 세련된 패턴이니 꼭 기억해두자.
SELECT
query_name,
ROUND(AVG(rating / position), 2) AS quality,
ROUND(AVG(IF(rating < 3, 100, 0)), 2) AS poor_query_percentage
FROM
Queries
WHERE
query_name IS NOT NULL
GROUP BY
query_name;
집계 함수(AVG)의 특성을 정확히 이해하고 중복 사용을 피하자.
서브쿼리가 생각날 때, 조건부 집계(IF 또는 CASE WHEN)로 더 간단하게 해결할 수 있는지 먼저 고민해 보자.
AVG(IF(조건, 100, 0)) 패턴은 백분율을 구하는 치트키다!
Write a solution to find the average selling price for each product.
average_price should be rounded to 2 decimal places.
If a product does not have any sold units,
its average selling price is assumed to be 0.
Return the result table in any order.
제품의 가격 변동 이력이 담긴 Prices 테이블과, 제품 판매 기록이 담긴 UnitsSold 테이블이 주어지고, 이 두 테이블을 활용하여 각 제품별 평균 판매 가격(Average Selling Price)을 구해야 한다.
(해당 제품의 총 매출액) / (해당 제품의 총 판매 개수)로 계산합니다.0으로 처리해야 합니다.Input: Prices table
| product_id | start_date | end_date | price |
|---|---|---|---|
| 1 | 2019-02-17 | 2019-02-28 | 5 |
| 1 | 2019-03-01 | 2019-03-22 | 20 |
| 2 | 2019-02-01 | 2019-02-20 | 15 |
| 2 | 2019-02-21 | 2019-03-31 | 30 |
Input: UnitsSold table
| product_id | purchase_date | units |
|---|---|---|
| 1 | 2019-02-25 | 100 |
| 1 | 2019-03-01 | 15 |
| 2 | 2019-02-10 | 200 |
| 2 | 2019-03-22 | 30 |
Output:
| product_id | average_price |
|---|---|
| 1 | 6.96 |
| 2 | 16.96 |
가장 먼저 떠오른 생각은 product_id로 두 테이블을 조인하고, 총액을 구한 뒤 개수로 나누어 평균(AVG)을 내는 것이었다. 그래서 아래와 같이 쿼리를 작성했다.
# ❌ 초기 접근 쿼리
SELECT
p.product_id,
ROUND(AVG(p.price * us.units / us.units), 2) as average_price
FROM Prices p
JOIN UnitsSold us
ON p.product_id = us.product_id
GROUP BY p.product_id;
논리적으로 그럴싸해 보였지만, 결과는 오답이었다. 이 쿼리에는 데이터베이스의 작동 방식을 오해한 3가지 결정적인 함정이 숨어있었다.
함정 1. 평균 계산의 오류: (price * units) / units
가장 흔히 하는 수학적 실수다. p.price * us.units / us.units를 수식으로 보면, 곱한 units를 다시 units로 나누었기 때문에 결국 units는 약분되어 사라진다. 즉, 데이터베이스는 AVG(p.price)를 계산한 것과 완전히 똑같이 행동하게 되어 판매량(가중치)을 전혀 반영하지 못하는 단순 가격 평균을 내버린다.
해결책: '가중 평균'의 정확한 공식인 (총 매출액) / (총 판매 개수)를 SUM 함수를 이용해 명시적으로 적어주어야 한다.
수정: SUM(p.price * us.units) / SUM(us.units)
함정 2. 기간(Date) 조건 누락: 가격은 시간에 따라 변한다!
단순히 ON p.product_id = us.product_id만 조건으로 주면 대참사가 일어난다. 1번 상품의 경우 2월 가격(5달러)과 3월 가격(20달러)이라는 두 개의 데이터가 존재한다. 단순히 id만으로 조인하면, 2월 25일에 팔린 물건이 3월 가격표와도 억지로 짝지어지는 크로스 조인(Cross Join) 현상이 발생한다.
해결책: 물건이 판매된 날짜(purchase_date)가 가격표에 명시된 기간(start_date ~ end_date) 안에 있을 때만 조인해야 한다.
수정: ON p.product_id = us.product_id AND us.purchase_date BETWEEN p.start_date AND p.end_date
함정 3. 팔리지 않은 상품의 처리: INNER JOIN의 한계
문제 조건 중 "판매 기록이 없는 상품의 평균 가격은 0으로 간주한다." 라는 문장이 있다. 기본 JOIN (INNER JOIN)을 사용하면 한 번도 안 팔린 상품은 조인 과정에서 매칭되지 않아 결과 테이블에서 아예 증발해 버린다.
해결책: 가격표(Prices)를 기준으로 모두 살려두고 판매 기록을 갖다 붙이는 LEFT JOIN을 사용해야 한다. 그리고 판매 기록이 없어 평균값이 NULL로 나올 경우, IFNULL() 함수를 이용해 0으로 바꿔주어야 한다.
✨ 최종 정답 코드
위의 3가지 문제를 모두 수정한 깔끔한 정답 코드는 다음과 같다.
SELECT
p.product_id,
IFNULL(ROUND(SUM(p.price * us.units) / SUM(us.units), 2), 0) AS average_price
FROM
Prices p
LEFT JOIN
UnitsSold us
ON p.product_id = us.product_id
AND us.purchase_date BETWEEN p.start_date AND p.end_date
GROUP BY
p.product_id;
날짜 이력 데이터 조인 패턴: 가격이나 상태가 시간에 따라 변하는 이력(History) 테이블을 조인할 때는 ON A.id = B.id AND date BETWEEN start AND end 조건을 반드시 기억하자. 실무에서도 숨 쉬듯 사용하는 필수 패턴이다.
가중 평균의 정석: 수학적인 비례/가중 평균을 구할 때는 AVG() 함수에 의존하기보다, 분자와 분모를 각각 SUM()으로 구해서 직접 나누는 것이 데이터 오염을 막는 가장 확실하고 정확한 방법이다.
엣지 케이스 고려: 조인하기 전에 '데이터가 없는 경우(팔리지 않은 경우)'를 어떻게 처리할지 항상 고민하고 LEFT JOIN과 IFNULL을 적절히 활용하자.