SQLite 윈도우 함수

내 이 세상 도처에서 쉴 곳을 찾아보았으나, 마침내 찾아낸, 컴퓨터가 있는 구석방보다 나은 곳은 없더라.

SQLite 윈도우 함수

SQLite는 작고 빠른 데이터베이스 엔진이다. 다른 DBMS 처럼 서버 프로세스들 띄울 필요 없이 명령행 셸(sqlite3)로 데이터베이스 파일을 열어 SQL을 실행할 수 있다. CSV 파일을 바로 SQLite 테이블로 로드할 수 있어, 간단한 데이터 분석 시 유용하게 사용할 수 있다.

SQLite에서 지원하는 SQL 수준은 매우 초보적이었던 것으로 기억하는 데, 최근 다시 보니 윈도우 함수뿐 아니라 재귀적 CTE(Common Table Expressin)까지 지원한다는 사실을 알게 되었다. 이 글에서는 예전 기억을 되살리며 SQLite에서 윈도우 함수를 간단히 테스트 해보려 한다.

Oracle 데이터베이스로 SQL을 배울 때 사용했던 HR 스키마(Human Resources Sample Schema)를 사용할 것이다. ERD는 다음과 같다. 상용 ERD 도구를 이용하면 테이블 관계를 나타낼 때 어떤 컬럼이 이용되는지까지 표시할 수 있지만, PlantUML에서는 그렇게까지는 못 하는 것 같다.

HR schema

관계에 레이블을 넣을 수 있지만, 그 경우 레이블이 테이블과 겹쳐 그림을 알아보기가 더 어려워져서 넣지 않기로 했다. 관계에 어떤 컬럼이 사용되는지는 각 테이블의 PK 컬럼 이름을 보면 쉽게 알 수 있다. 사실 여기서는 EmployeesDepartments 만 사용하므로 크게 중요하지도 않다.

데이터베이스 준비

쿼리를 테스트하려면 먼저 테이블을 만들고 데이터를 로드해야 한다. Human Resources Sample Schema에서 SQL 스크립트를 다운로드한 다음 SQLite에서 실행해 테이블과 인덱스를 생성하고 데이터를 로드할 수 있다. Oracle에서만 동작하는 코드는 모두 삭제한다.

SQLite 셸의 디폴트 모드는 list 인데, 알아보기가 쉽지 않다. 모드를 column 또는 table, box 로 바꾸면 쿼리 실행 결과가 알아보기 쉬운 형태로 표시된다. HR 스키마에서 다운로드한 스크립트는 .read 명령을 사용해 실행할 수 있다.

$ sqlite3 hr.db
SQLite version 3.43.2 2023-10-10 13:08:14
Enter ".help" for usage hints.
sqlite> .mode column
sqlite> .read hr_create.sql
sqlite> .read hr_populate.sql

윈도우 함수 테스트

윈도우 함수는 입력 값을 윈도우에서 취하는 함수다. 여기서 윈도우는 select 결과에서 하나 또는 그 이상의 행을 말한다. 윈도우 함수에는 over 절이 따르며 이를 통해 파티션, 프레임 등을 설정한다. 윈도우 함수를 이용하면 쿼리로 해결하기 매우 어려워 보이는 문제도 쉽게 해결할 수 있다.

부서별 최고 급여 수령자

각 부서별로 급여가 가장 높은 직원 목록을 알고 싶다면 어떻게 쿼리를 작성해야 할까? 윈도우 함수를 쓰지 않고 작업하려면 매우 복잡하고 비직관적인 쿼리를 작성해야 할 것이다. 윈도우 함수 rank 를 사용하면 다음과 같이 쉽게 구할 수 있다. over 절을 보면 부서 별로 파티션을 하고 급여로 정렬해 rank 를 구하는 것을 볼 수 있다.

select department_name dept_name
     , employee_id emp_id
     , emp_name
     , job_title
     , salary
from (
    select employee_id
         , first_name ||' '||last_name emp_name
         , department_name
         , job_title
         , salary
         , rank() over (partition by department_id order by salary desc) rnk
    from employees
    join departments using (department_id)
    join jobs using (job_id)
    )
where rnk = 1
order by salary desc
;
dept_name         emp_id  emp_name          job_title                        salary
----------------  ------  ----------------  -------------------------------  ------
Executive         100     Steven King       President                        24000
Sales             145     John Singh        Sales Manager                    14000
Marketing         201     Michael Martinez  Marketing Manager                13000
Finance           108     Nancy Gruenberg   Finance Manager                  12008
Accounting        205     Shelley Higgins   Accounting Manager               12008
Purchasing        114     Den Li            Purchasing Manager               11000
Public Relations  204     Hermann Brown     Public Relations Representative  10000
IT                103     Alexander James   Programmer                       9000
Shipping          121     Adam Fripp        Stock Manager                    8200
Human Resources   203     Susan Jacobs      Human Resources Representative   6500
Administration    200     Jennifer Whalen   Administration Assistant         4400

연도별 누적 직원 수

회사가 성장함에 따라 직원수가 늘어났다. 시간이 지남에 따라 직원이 어떻게 늘어났는지 확인하기 위해 연도별 누적 직원 수가 필요하다고 생각해보자. 이 문제 역시 윈도우 함수를 사용하면 간단히 해결할 수 있다. 연도별로 group by 해 연도별 직원수를 구한 다음, 윈도우 함수로 누적 합계를 구하면 된다.

날짜 함수는 DB마다 다르다. SQLite에서는 strftime 함수를 사용해 날짜에서 연도를 뽑아낼 수 있다.

select yyyy
     , sum(cnt) over (order by yyyy) cnt
from (
    select strftime('%Y', hire_date) yyyy
         , count() cnt
    from employees
    group by strftime('%Y', hire_date)
    )
;
yyyy  cnt
----  ---
2011  1
2012  8
2013  14
2014  24
2015  53
2016  77
2017  96
2018  107

전년 대비 증가 직원 수

여기에 조금 더해서, 연도별로 전년 대비 증가 직원수도 알고 싶다면 어떻게 해야 할까? 위 쿼리에서 sum 함수에 대한 프레임을 설정할 때 파티션을 지정하지 않았으므로, 프레임은 처음부터 현재 행까지가 된다. 처음부터 현재 행 바로 전까지만 합계를 구할 수 있다면 전년 대비 증가 직원수를 쉽게 구할 수 있다.

윈도우 함수에서는 다음과 같은 식으로 프레임을 정의해, 처음부터 현재 행 바로 전까지만 합을 구하는 것이 가능하다. 현재 행을 기준으로 해당 연도까지 누적 직원 수와 그 전년도까지 누적 직원수를 구했다면, 증가 직원수는 그 차가 된다.

select yyyy
     , cnt1 cnt
     , cnt1 - cnt2 delta
from (
    select yyyy
         , sum(cnt) over (order by yyyy) cnt1
         , sum(cnt) over (order by yyyy
                          range between unbounded preceding
                          and current row
                          exclude current row) cnt2
    from (
        select strftime('%Y', hire_date) yyyy
             , count() cnt
        from employees
        group by strftime('%Y', hire_date)
        )
    )
;
yyyy  cnt  delta
----  ---  -----
2011  1
2012  8    7
2013  14   6
2014  24   10
2015  53   29
2016  77   24
2017  96   19
2018  107  11

윈도우 함수에서 프레임 설정에 대한 상세 사항은 Window Functions > Frame Specifications를 참조하기 바란다.

위 쿼리에서 두 번째 sumover 절이 너무 복잡한 것 같다. 만약 연도별 누적 직원수 쿼리 결과에서 바로 전 행을 참조할 수 있다면 어떨까? 다음과 같이 lag 함수를 사용하면 쿼리를 조금 간단하게 만들 수 있다. 현재 행 누적 직원수에서 바로 전 행 누적 직원수를 빼면 증가한 직원수가 된다.

select yyyy
     , cnt
     , cnt - lag(cnt, 1) over (order by yyyy) delta
from (
    select yyyy
         , sum(cnt) over (order by yyyy) cnt
    from (
        select strftime('%Y', hire_date) yyyy
             , count() cnt
        from employees
        group by strftime('%Y', hire_date)
        )
    )
;
yyyy  cnt  delta
----  ---  -----
2011  1
2012  8    7
2013  14   6
2014  24   10
2015  53   29
2016  77   24
2017  96   19
2018  107  11

결론

SQLite에서 몇 가지 예제를 통해 윈도우 함수 사용법을 간단히 확인해봤다. 한 가지 주목할 점은 sum, count 와 같은 일반 집계 함수(aggregate function) 뒤에 over 절을 추가하면, 집계 함수가 over 절로 명시한 윈도우 프레임 안의 행에 대해 동작한다는 점이다.

여기서 살펴본 예제만으로 윈도우 함수를 제대로 알기에는 무리가 있겠지만, 윈도우, 파티션, 프레임 등의 개념을 이해하고 나면 윈도우 함수를 활용하는 것이 재미있어진다. 윈도우 함수를 활용하면 쿼리로 불가능해 보이는 작업도 간단히 해결할 수 있는 경우가 많다.

사족

이 글은 Emacs에서 org-mode(ob-sqlite)를 사용해 작성했다. 쿼리를 실행하면 결과과 본문에 삽입되며, 쿼리를 쉽게 수정하고 실행할 수 있다. SQLite 셸에서 복잡한 쿼리를 편집하는 것은 매우 불편하므로, Emacs에서 쿼리를 편집/실행할 수 있다는 것은 큰 장점이 아닐 수 없다.

그러나 몇 가지 불편한 점도 있었다. 일단 Emacs에 내장된 sql-mode가 별로 훌륭하지 않은 듯 하다. 쿼리를 편집할 때 제멋대로 들여쓰기를 바꿔버리는 문제가 있는데, org-mode에서 편집할 때만 그런 것 같다. 내 Emacs 설정이 잘못되어 그런 것인지도 모르겠다.

코드블록에서 Sqlite에 대해 쿼리를 실행하려면 언어를 sqlite 로 설정해야 하는데, 이렇게 하면 HTML로 렌더링했을 때 언어가 SQL로 인식되지 않아 문법 강조가 되지 않는다. 또 쿼리를 실행하면 결과가 본문에 삽입되긴 하는데, 이게 올바른 결과 형식으로 들어가지 않는다. HTML로 제대로 렌더링 되게 하려면 약간 수정이 필요하다.