부트캠프 사전퀘스트를 풀고 정답지와 비교하면서 알게 된 유용한 연산자와 함수 정리

 

 

문제 1. sparta_students 테이블에서 입학년도(enrollment_year)가 2021년인 학생과 2023년인 학생을 선택하는 쿼리를 작성해주세요!

//나의 코드
SELECT *
FROM sparta_students
WHERE enrollment_year = 2021 or enrollment_year = 2023


//정답지 코드
SELECT *
FROM sparta_students 
WHERE enrollment_year IN (2021, 2023)

 

강의에서 본 것도 같지만 기억이 안나는 이슈로 활용하지 못했던 IN 연산자

조회하고자 하는 값이 여러개일 때 위와 같이 IN 연산자를 활용하면 간단하게 해결 가능하다.

IN (조회할 값)

 

 

문제 2. team_projects 테이블에서 2022년에 시작된 프로젝트를 선택하는 쿼리를 작성해주세요! 단, start_date < ‘2023-01-01’ 조건을 사용하지 말고 쿼리를 작성해주세요!

//나의 코드
SELECT *
FROM team_projects
WHERE substr(start_date, 1, 4) = 2022 


//정답지 코드
SELECT *
FROM team_projects 
WHERE YEAR(start_date) = 2022;

 

여태까지 substr 함수를 이용해서 필요한 부분만 잘라서 사용했는데, Year(), Month(), Day() 함수를 활용하면 년, 월, 일 부분만 정수로 추출이 가능하다.

YEAR(date)

 

 

문제 3. team_projects 테이블에서 현재 진행중인 프로젝트를 선택하는 쿼리를 작성해주세요. 단, 지금 시점의 날짜를 하드코딩해서 쿼리하지 말아주세요!

//나의 코드
SELECT *
FROM team_projects
WHERE end_date is null


//정답지 코드
SELECT *
FROM team_projects 
WHERE CURDATE() BETWEEN start_date AND end_date;

 

정답지 로직을 생각하긴 했지만, 현재 시점의 날짜를 구하는 함수를 몰라서 일단 짠 코드가 end_date is null 로 조회하는 것이었다.

만약 진행 중인 프로젝트여도 마감일이 존재하거나, 더미값을 넣어놨다면 아무 데이터도 조회되지 않을 거기 때문에 정답지대로 작성하는 게 맞는 것 같다.

 

CURDATE() 는 현재 날짜를 반환하는 함수 (YYYY-MM-DD 출력)

CURTIME() 은 현재 시간을 반환하는 함수 (hh:mm:ss 출력)

NOW() 는 현재 날짜와 시간을 반환하는 함수 (YYYY-MM-DD hh:mm:ss 출력)

 

구글링을 통해 알게 된 추가 함수 SUBDATE(date, days) 는 date값에서 days값 만큼의 전 일 수의 날짜를 반환할 수 있다.

ex. subdate(curdate(), 7) 은 현재에서 일주일 전 날짜를 반환한다.

 

 

문제 4. team_projects 테이블에서 각 프로젝트의 지속 기간을 일 수로 계산하는 쿼리를 작성해주세요!

//나의 코드
SELECT name, date(end_date)-date(start_date) '지속 기간'
FROM team_projects


//정답지 코드
SELECT name, DATEDIFF(end_date, start_date) AS '지속 기간'
FROM team_projects;

 

date 함수를 사용하면 결과값의 형식이 문자열이 아닌 date 형식이기 때문에 마이너스 연산자를 사용해도 되겠지..? 싶은 마음에 시도해본 나의 코드이다.

정답지 코드에서는 datediff() 를 사용했다.

DATEDIFF(date1, date2) 형식으로, 자동으로 date1-date2 값을 반환해준다. (일 수 차이 계산)

 

만약 일 수가 아니라 달 수, 연 수, 분 수 등을 알고 싶다면 TIMESTAMPDIFF(unit, date1, date2) 를 사용해주면 된다.

unit에는 month, minute 등과 같이 단위 명이 들어간다.

TIMESTAMPDIFF 함수는 date2-date1 값을 반환한다.

 

 

문제 3, 문제 4에서 통용되게 사용할 수 있는 함수가 있다.

바로 DATE_SUB(), DATE_ADD() 함수이다.

DATE_SUB(기준 날짜, interval 정수 단위) / DATE_ADD(기준 날짜, interval 정수 단위)

예를 들어 DATE_SUB(CURDATE(), INTERVAL 5 YEAR)는 현재 날짜에서 5년을 뺀 날짜이다.

정수 위치에 음수를 사용하면 서로 반대되는 함수로도 이용할 수 있다.

DATE_SUB(CURDATE(), INTERVAL 5 YEAR) = DATE_ADD(CURDATE(), INTERVAL -5 YEAR)

 

 

문제 5. lol_users 테이블에서 가장 늦게 게임을 시작한(join_date) 유저의 이름을 선택하는 쿼리를 작성해주세요!

//나의 코드
SELECT name
FROM lol_users
WHERE MAX(join_date)


//정답지 코드
SELECT name 
FROM lol_users 
ORDER BY join_date DESC LIMIT 1;

 

이 문제는 내가 짠 코드를 DBeaver에서 저장된 테이블로 테이블과 컬럼을 바꿔서 실행했을 때 에러가 뜨길래 나름대로 max 함수를 써보고 싶다는 생각에 재시도하느라 시간이 꽤 소요된 문제다.

일단 에러가 떴다는 생각에 에러 메세지를 확인하지 않고(고쳐야 할 문제점 발견!) 다른 쿼리를 짜서 시도했다.

select name, max(join_date)
from lol_users

 

실행하면 결과물은 출력이 되는데, name과 join_date가 하나의 레코드에서 출력되는 것이 아니라

전체 레코드 조회를 했을 때 첫 번째 레코드의 name과 가장 큰 join_date 값이 하나의 레코드로 출력되는 것이었다.

사실 당연한 결과였다.

max() 함수 때문에 레코드는 하나가 출력이 되지만, select 문에 작성했으니 name과 max(join_date)간에는 아무런 연관이 없는 것이다.

이에 나는 역시 where 절이 필요하다는 생각에 where 절을 추가한 코드를 작성했고, 또다시 첫 번째로 작성했던 코드의 에러와 같은 에러를 만났다.

select name
from lol_users
where join_date = max(join_date)

 

SQL Error [1111] [HY000]: Invalid use of group function 바로 이 에러다.

그제서야 나는 에러를 검색해봤고, 간단히 말하자면 집계함수(여러 행으로부터 하나의 결과값을 반환하는 함수)는 where 절에 사용할 수 없다는 것이다.

이 문제는 where절 대신 having절을 사용하면 된다고 하는데 캠프에서 준 강의에는 having절에 대한 내용이 없기 때문에, having절을 사용해서 재시도를 하진 않았다.

거기다 배웠던 SQL을 많이 잊어버린 상태라 심화 SQL에 대해 다시 공부할 필요를 느꼈다.

 

정답지처럼 join_date가 큰 순서로 레코드를 정렬하고 그 중 하나의 레코드만 출력하면 되는 간단한 문제였지만, 문제를 보자마자 max() 함수를 사용하면 되겠다는 생각에 사로잡혀서 멀리 돌아오게 되었다.

이 문제를 통해 얻은 교훈은 에러 메세지를 꼭 확인하자내가 짠 로직이 옳은 방법인지 다시 한 번 생각하자이다.

 

 

문제 6. lol_users 테이블에서 지역별로 레이팅이 높은 순으로 유저들을 정렬해서 나열하는 쿼리를 작성해주세요!

//나의 코드
SELECT *
FROM lol_users
GROUP BY region
ORDER BY rating DESC


//정답지 코드
SELECT *
FROM lol_users 
ORDER BY region, rating DESC;

 

이 문제는 정답지 코드처럼 order by만 사용하여 지역별, 레이팅별 정렬을 했어야 하는데,

아무 생각없이 지역별로 group by를 해서 레코드가 9개만 출력되었다.

게다가 무작위로 특정 지역의 레코드의 rating이 0값을 가져서 뭐 때문인지 조건을 바꿔가며 실행을 해봤는데 결론은 집계 함수없이 group by를 사용했기 때문이었다.

집계 함수 없이 group by절을 사용했더니 전체 레코드 중 지역마다 첫 번째 레코드 값이 삽입되어 있었다.

하필 특정 지역의 첫 번째 레코드가 rating값이 0이어서 더욱 이상하게 느꼈던 것 같다.

결론은 특정 상황이 아니라면 집계 함수 없이 group by절을 사용하지 말자!

 

 

문제 7. 익명화된 유저들의 아이디(created_at)와 유저가 획득한 날짜별 평균 포인트(average_points)가 포함된 테이블을 만들어봅시다!

//나의 코드
select date(substr(created_at, 1, 10)) as created_at, round(avg(point)) as average_points
from point_users
group by 1


//정답 코드
SELECT 
    DATE(p.created_at) AS created_at,
    ROUND(AVG(p.point)) AS average_points
FROM 
    point_users p
GROUP BY 
    DATE(p.created_at);

 

- DATE() 함수는 데이터가 날짜+시간 형식이어도 날짜 값만 반환해준다.

- ROUND() 함수는 반올림한 값을 반환한다.

실습. 음식 타입별로 주문 건수가 가장 많은 상점 3개씩 조회하기

select cuisine_type, restaurant_name, cnt_order, ranking
from
(
select cuisine_type, restaurant_name, cnt_order,
	   rank() over (partition by cuisine_type order by cnt_order desc) ranking
from
(
select cuisine_type, restaurant_name, count(1) cnt_order
from food_orders
group by 1, 2
) a
) b
where ranking<=3

 

a 서브 쿼리

요리 종류 별, 음식점 별로 주문 수 출력

 

 

 

 

 

 

b 서브 쿼리

rank() over 함수를 사용해서 주문 수에 대해 랭킹

 

partition by : cuisine_type 각각 순위 매기기

order by : cnt_order 기준으로 순위 매기기

desc : cnt_order가 큰 순서

 

 

전체 쿼리

b 서브 쿼리에서 출력한 결과물에서 각 요리 종류 별로 3위까지만 출력

 

 

 

 

 


 

 

실습. 각 음식점의 주문건이 해당 음식 타입에서 차지하는 비율을 구하고, 주문건이 낮은 순으로 정렬했을 때 누적 합 구하기

select cuisine_type, restaurant_name, cnt_order, sum_cuisine, cnt_order/sum_cuisine
from
(
select cuisine_type, restaurant_name, cnt_order,
	   sum(cnt_order) over (partition by cuisine_type) sum_cuisine,
	   sum(cnt_order) over (partition by cuisine_type order by cnt_order) cum_cuisine
from
(
select cuisine_type, restaurant_name, count(1) cnt_order
from food_orders
group by 1, 2
) a
) b

 

a 서브 쿼리는 rank 실습 문제와 동일

 

b 서브 쿼리

sum() over 함수를 사용해서 요리 종류 별 주문 총 합계, 누적 합계 출력

 

order by : cnt_order 기준으로 누적 합계 계산

cuisine_type 별로 합계가 계산되는 것을 볼 수 있다

 

partition by : cuisine_type별로 총 합계 계산

 

> cuisine_type별로 합계가 계산되는 것을 볼 수 있다

 

 

sum() over 함수는 인자 order by 와 세트로 사용하여, 누적 합계를 구하는 함수이다.

만약 order by를 cnt_order가 아니라 quantity로 변경하면 아래 사진과 같이 quantity를 기준으로 누적 합계를 계산한다.

 

 

총 주문 수량을 의미하는 cnt_order와 하나의 주문 건당 주문된 메뉴 수량을 의미하는 quantity을 곱하여 더하면 누적 합계가 나온다.

 

 

 

 

 

전체 쿼리처럼 cnt_order / sum_cuisine 코드를 추가하면 각 음식점 주문 건이 요리 종류에서 차지하는 비율을 구할 수 있다.

 


 

 

문제점과 해결 방법

위의 sum() over 실습 결과물을 보면 cnt_order가 동일한 레코드의 누적 합계가 동일한 수로 출력되는 것을 볼 수 있다.

cnt_order를 기준으로 정렬되어 있기 때문에, cnt_order가 동일한 값을 가지면 그들간의 우선 순위를 지정할 수 없어서 SQL 엔진이 이 값들을 한 번에 더하여 출력했기 때문이다.

이 문제는 order by에 개별적인 값을 가지는 컬럼을 추가해주면 간단하게 해결된다.

나는 restaurant_name이라는 컬럼을 정렬 조건에 추가했다.

sum(cnt_order) over (partition by cuisine_type order by cnt_order, restaurant_name) cum_cuisine

 

 

실습. 성별, 연령별 주문건수 Pivot Table 뷰 만들기 (나이는 10~59세 사이, 연령 순으로 내림차순)

select age,
       max(if(gender='male', order_count, 0)) male,
       max(if(gender='female', order_count, 0)) female
from 
(
select b.gender,
       case when age between 10 and 19 then 10
            when age between 20 and 29 then 20
            when age between 30 and 39 then 30
            when age between 40 and 49 then 40
            when age between 50 and 59 then 50 end age,
       count(1) order_count
from food_orders a inner join customers b on a.customer_id=b.customer_id
where b.age between 10 and 59
group by 1, 2
) t
group by 1
order by age

 

 

1. 서브쿼리에서 customer_id 컬럼으로 food_orders와 customers 테이블 조인

2. 성별과 연령 데이터를 뽑아올 때, 각 연령을 10대, 20대, 30대 등으로 카테고리화

서브 쿼리만 실행했을 때 결과물

 

3. 연령대별 행으로 설정하여 오름차순으로 정리

4. 열(컬럼)에는 male, female로 나뉘어 cnt_order(주문 수)값을 가지도록 함

 

전체 결과물

 

즉, 이 코드는 서브쿼리 결과물을 성별을 기준으로 나누어 pivot table을 생성한 것이다.

 

 


 

 

이때 max() 의 역할은 무엇일까?

강의에서는 max 함수를 단순히 pivot table을 만들기 위한 함수라고만 설명하고 넘어갔는데, 왜 max를 사용해야 할 지 궁금했던 나는 또 구글링을 시도했으나 도움을 얻진 못했다.

그래서 1차로 max함수를 제외하고 값을 출력해보았다.

 

max 함수없이 실행한 결과물

 

결과물은 특정 결과물에 0값이 출력되었다.

Chat gpt에게 물어봤을 때, 결론적으로는 최대값을 구하기 위함이라는 누구나 아는 답을 알려주었다.

나는 결국 종이와 펜을 가지고 가상으로 데이터를 하나하나 입력했을 때의 원리를 끄적여보았다.

 


 

 

편의상 연령대는 10대와 20대만 보겠다.

  male female
10    
20    

 

 

먼저 위의 테이블에 10대 여자의 데이터를 삽입해보자.

10대 여자는 임의로 25명으로 지정했다.

if(gender='male', cnt_order, 0) "male",

if(gender='female', cnt_order, 0) "female"

 

이 코드를 순서대로 진행해보면 지금 삽입될 데이터는 gender에 'female' 값을 가지고 있으므로 male 에는 0이 삽입되고, female 에는 25가 삽입된다.

  male female
10 0 25
20    

 

 

이번에는 20대 남자의 데이터를 17명이라고 가정하고 삽입해보자.

마찬가지로 이 데이터는 gender 값이 'male' 이므로 male 에 17이 삽입되고, female 에 0이 삽입된다. 

  male female
10 0 25
20 17 0

 

 

그렇다면 10대 남자의 데이터가 이미 0으로 입력된 테이블에 10대 남자의 데이터를 삽입하면 어떻게 될까?

이 결과물은 위에서 서브쿼리만 진행했을 때의 결과물과, max 함수를 사용하지 않고 실행한 결과물을 비교하면 쉽게 알 수 있다.

max 함수없이 실행한 결과물을 보면, female 컬럼은 40대만 값을 가지고 나머지는 다 0이다.

서브 쿼리만 실행했을 때 결과물을 보면, 유일하게 40대 데이터만 여자의 데이터가 남자의 데이터보다 우선으로 정렬되어 있다. 

이 두 가지의 결과물을 통해 나는 먼저 0이 삽입된 데이터에 새로운 데이터를 삽입해도 그대로 0값을 가지고 있다는 것을 알 수 있었다. 

그렇기 때문에 0이 아닌 값을 가지는 10대 남자 또는 0이 아닌 값을 가지는 20대 여자의 데이터를 삽입해도 위의 표와 같은 결과물을 출력할 것이다.

 

이러한 문제점을 해결하기 위해 max() 함수를 사용한다.

입력되는 값 중 최대값의 데이터를 pivot table에 삽입하기 위해 max() 함수를 if문과 함께 사용하는 것이다.

 

실습. 이메일 도메인 별로 고객 수와 평균 연령 구하기

customers 테이블 중 일부 레코드

 


 

고객의 정보는 customers 테이블에 있고, 도메인 별로 카테고리화해서 데이터를 가져와야 하기 때문에 아래와 같이 쿼리를 작성했다.

 

SELECT email "도메인", count(1), avg(age)
FROM customers
group by 1

 

 

 

물론 select문에서 단순히 email 컬럼명을 사용했기 때문에, 이렇게 실행하면 customers 테이블의 모든 데이터를 그대로 출력하는 꼴이 된다.

 

customers 테이블에 저장된 이메일 형식

 

도메인 별로 값을 가져오기 위해서는 이메일 형식에서 @ 뒷부분만 필요하다.

부트캠프 강의 영상에서는 원활한 진행을 위해 모든 데이터의 이메일 아이디가 8자로 삽입되어 있어, substr(email, 10) 이라는 함수를 사용하면 손쉽게 @ 뒷부분을 얻을 수 있었다.

 

 

 

 

하지만 현실에서 모든 고객의 이메일 아이디가 8자일 수는 없기 때문에 @ 뒤의 문자열만 가져오는 함수를 사용해야 한다.

이때 생각한 방법은

1. @의 위치를 찾아 substr 함수로 해당 위치 뒤의 문자열만 가져온다.

2. input된 문자열부터의 값만 가져오는 함수를 사용한다.

 


 

 

일단 1번 방법으로 해결하기 위해 구글링하여 나온 함수가 바로 locate 함수.

locate 함수는 locate(str, column) 형식으로 사용한다.

column이라는 컬럼에서 str이라는 문자열을 찾아 위치를 반환해준다.

만약 해당 문자열이 없다면 0을 반환해준다.

 

instr 함수도 같은 기능을 하지만, 인자의 순서가 반대로 instr(column, str) 이다.

 

avg(age)를 평균 연령으로 변경하는 걸 깜빡했다

 

 

위의 사진은 email 데이터에서 '@'부터 '.' 사이의 문자열만 가져오도록 하여 출력한 결과물이다.

결과는 제대로 출력이 되지만,  보시다시피 매우 지저분하다.

위의 코드는 .(dot) 부터의 문자열을 제거하기 위해 저런 방식을 사용했다.

naver가 아닌 naver.com 으로 데이터를 출력하려면 더 간편한 방식이 가능하다.

 

 

 

이렇게 locate 함수를 이용하여 '@'의 위치를 구하고, 그 위치를 substr 함수의 인자로 전달하여 도메인만 출력하는 1번 방식으로 결과를 도출해 보았다.

 

 


 

 

2번 방법은 substring_index 함수를 사용하면 된다.

substring_index(column, str, locate_of_use) 형식이며, str을 기준으로 특정 순서의 문자열을 반환한다.

따라서 감자, 고구마, 밤 같이 하나의 특수문자로 이루어진 문자열을 분리할 때 유용한 함수이다.

 

내가 구상한 방법은 substring_index 함수를 통해 전체 문자열에서 @ 앞까지의 문자열을 제거하는 방법이다. 

진행했을 때 아래 사진과 같은

Truncated incorrect DOUBLE value 라는 에러가 뜨고 결과값도 이상하게 출력되는 것을 볼 수 있다.

데이터의 앞부분이 잘리는 것 때문에 오류가 나는 것 같다.

 

 

 

이 오류를 해결하기 위해 부트캠프 튜터님께 질문을 드렸고, 오류의 원인과 해결 방법을 찾을 수 있었다.

먼저 오류는 데이터가 잘리는 것에 대한 오류가 아닌, 데이터 타입이 일치하지 않아서 발생하는 오류였다.

substring_index 함수로 반환되는 값은 문자열이지만 마이너스 연산은 double 타입의 데이터를 반환할 때 사용하기 때문에 오류가 발생하는 것이다.

 

+substring_index의 세번째 인자를 양수로 주면 앞에서부터 문자열을 분리하여 반환하고, 음수로 주면 뒤에서부터 문자열을 분리하여 반환한다고 한다.

즉, substring_index(email, '@', -1) 이라는 코드를 통해 바로 @ 뒷부분의 문자열만 얻을 수 있는 것이다.

 

 

지금 생각해보면 substring_index(email, '@', 2) 부분은 굳이 substring_index 함수를 쓰지 않더라도 해당 레코드의 데이터를 가져오면 됐었다.. 

문자열을 가지고 마이너스 연산을 하려고 했던 것은 위에서 시도했던 locate 함수를 사용했던 기억 때문에 또 같은 방식을 무의식적으로 사용한 것 같다.

 

훨씬 간단해진 코드