Gidhub BE Developer

성능 향상을 위한 SQL 작성법

2018-11-20
goodGid
DB

  • DBMS는 저장되어 있는 데이터를 효율적으로 검색할 수 있게 인덱스를 사용한다.

  • 웹 애플리케이션의 백엔드 성능을 높이려고 종종 실행하는 SQL 튜닝이란

  • SQL이 DBMS의 인덱스를 활용하도록 SQL을 수정하는 것이라고 할 수 있다.

  • 그러니 인덱스를 잘 이해하고 있다면 더 좋은 SQL을 작성할 수 있을 것이고

  • 훨씬 더 성능 좋은 애플리케이션을 만들 수 있을 것이다.

  • 이 글에서는 CUBRID를 중심으로 알아본다.


인덱스 구조

  • 거의 모든 DBMS는 B-Tree 계열 인덱스를 사용한다.

  • 인덱스 종류에 대한 특별한 언급이 없다면 아마도 B-Tree 계열 인덱스를 의미할 것이다.

  • CUBRID는 B+-Tree를 이용하고 있다.

  • B+-Tree는 B-Tree의 한 종류로서

  • 일반적인 B-Tree와 달리 데이터 포인터를 리프(Leaf) 노드에만 저장한다.

  • 리프 노드의 상위 레벨인 비리프(Non-Leaf) 노드는 전형적인 B-Tree 로 구성되며 리프 노드를 빠르게 찾는 인덱스 역할을 한다.

  • 리프 노드에는 키와 키에 대응하는 데이터의 포인터가 저장되어 있다.

  • 다음 그림은 전형적인 B+-Tree 모습이다.


인덱스 스캔

  • B+-Tree의 리프 노드는 링크드 리스트(linked list)로 서로 연결되어 있고

  • 저장된 정렬되어 있어서 순차 처리가 용이하다.

  • 그렇기 때문에 범위를 검색하는 데 유리하다.

  • 테이블에서는 처음부터 끝까지 모든 레코드를 읽어야 완전한 결과 집합을 얻을 수 있지만

  • 인덱스는 키-컬럼 순으로 정렬되어 있기 때문에 특정 위치에서 검색을 시작해서 검색 조건이 일치하지 않는 값을 만나는 순간 검색을 멈출 수 있다.

  • 이것을 인덱스 범위 스캔(Index Range Scan)이라고 한다.

  • CUBRID는 범위 스캔을 B+-Tree 검색의 기본 연산으로 제공하고 있다.

  • 범위 스캔에는 두 개의 키가 필요한데

  • 범위의 양 끝을 표현하는 하위 키(Lower Key)상위 키(Upper Key)이다.


  • 인덱스 범위 스캔은 다음 그림과 같이 두 단계로 진행된다.

  • 첫 번째 단계에서는 루트에서부터 트리를 순회하여 리프 노드에서 하위 키를 찾는다.

  • 두 번째 단계에서는 첫 번째 단계에서 찾은 키에서부터 상위 키까지 순차적으로 레코드를 읽어 처리한다.

  • 상위 키가 현재 노드에서 발견되지 않으면 다음 노드를 읽어 상위 키를 가진 노드까지 검색을 계속해 나간다.

  • 상위 키까지 순차 검색이 끝나면 전체 범위 검색이 완료된다.

  • 두 번째 단계에서 상위 키까지 찾아가는 과정은 레코드에서 키를 읽어와 상위 키와 비교하는 과정의 연속이다.

  • 상위 키가 최대 키이면 현재 노드의 키부터 마지막 노드까지 모두 검색 결과에 포함되기 때문에

  • 비교 연산을 할 필요가 없어져서 검색의 성능이 좋아진다.

  • 검색 성능을 위해 옵티마이저는 입력된 쿼리를 재작성(rewrite)하며

  • CUBRID는 특정 키를 찾는 검색도 범위 검색으로 변환하여 수행한다.

  • 특정 키를 찾는 검색을 범위 검색으로 변환할 때에는 하위 키와 상위 키 모두를 찾으려는 키로 동일하게 설정한다.


인덱스 스캔을 이용한 질의 처리 과정

  • 다음 그림은 CUBRID에서 아래의 SQL 질의로 테이블과 인덱스를 생성하고 데이터를 입력했을 때

  • 인덱스 리프 노드와 테이블 데이터의 관계를 나타낸 그림이다.

  • 왼쪽 인덱스 리프 노드에는 인덱스 키와 키에 대응 되는 OID(레코드의 물리적 주소 값)가 저장되어 있다.

CREATE TABLE tbl  
(a INT NOT NULL,
b STRING,  
c BIGINT);

CREATE INDEX idx ON tbl  
(a, b);

INSERT INTO tbl VALUES  
(1, 'ZZZ', 123456),
(4, 'BBB', 123456789),
(1, 'AAA', 123'),
…
(이하 생략)

SELECT * FROM tbl  
WHERE a > 1 AND a < 5  
AND b < 'K'  
AND c > 10000  
ORDER BY b;  
  • 위와 같은 SELECT 질의에서 WHERE 절에 있는 검색 조건은 다음과 같이 3가지로 나눌 수 있다.

    • Key Range: 인덱스 스캔 범위로 활용하는 조건이다(a > 1 AND a < 5)

    • Key Filter: Key Range에 포함할 수 없지만 인덱스 키로 처리 가능한 조건이다(b < ‘K’)

    • Data Filter: 인덱스를 사용할 수 없는 조건이다. 테이블에서 레코드를 읽어야만 처리 가능한 조건이다(c > 10000)


  • CUBRID의 질의 처리 과정은 다음과 같다.

    • 인덱스 스캔인 경우 먼저 Key Range와 Key Filter를 적용하여 조건에 부합하는 OID 리스트를 만든다.
    • 이 과정은 Key Range의 시작부터 끝까지 계속된다.

    • OID를 이용해 데이터 페이지에서 해당 레코드를 읽어 Data Filter를 적용하거나
    • SELECT 리스트에 기술된 컬럼 값을 읽어와 결과를 저장하는 임시 페이지에 기록한다.

    • ORDER BY 절이나 GROUP BY 절이 있으면 임시 페이지에 저장된 레코드를 정렬하여 최종 결과를 생성한다.


  • 다음 그림은 위의 SELECT 질의가 처리되는 과정이다.


인덱스 사용하기

  • 옵티마이저가 인덱스를 사용하게 하려면 WHERE 절에 범위(Range) 조건이 있어야 한다.

  • 범위 조건은 “크다, 작다, 크거나 같다, 작거나 같다, 같다”와 같은 비교문으로 기술한다.

  • 만약 범위 조건이 없다면 옵티마이저는 테이블 순차 스캔을 시도할 것이다.


  • 두 개 이상의 컬럼을 묶어서 인덱스를 만들 때는 컬럼의 순서가 매우 중요하다.

  • 이런 인덱스를 다중 컬럼 인덱스(Multi-Column Index) 또는 복합 인덱스라고 한다.

  • 복합 인덱스에서는 WHERE 절에 인덱스 키의 첫 번째 컬럼을 사용해야 인덱스 스캔을 수행한다.

  • 인덱스가 여러 컬럼으로 조합되어 있을 때

  • 컬럼 가운데 한 가지 컬럼만 사용해도 무방하다고 알려져 있는데 잘못 알려진 것이라고 할 수 있다.

  • 첫 번째 컬럼이 없는 상태에서는 두 번째 컬럼이 정렬된 상태라고 할 수 없기 때문에 범위를 정의할 수 없다.

  • 따라서 반드시 첫 번째 컬럼이 조건에 있어야 하며 첫 번째 이후의 컬럼은 조건에 없어도 상관없다.


  • 인덱스값의 대소 비교를 토대로 트리를 구성한다.

  • 따라서 값의 대소 비교가 아닌 조건은 B+-Tree를 사용해서 값을 찾을 수 없다.

  • <>, != 와 같이 부정형 조건이나 NULL 비교는 인덱스를 사용할 수 없다.

  • 인덱스의 컬럼을 조건절에서 가공할 때도 인덱스를 사용할 수 없다.

  • 다음은 인덱스를 사용하지 못하는 쿼리와 튜닝 후 인덱스를 사용하도록 수정한 쿼리의 예이다.

  • 작성한 SQL 질의가 DBMS에서 실행될 때 인덱스 스캔을 이용하는지 확인하려면 질의 실행 계획을 출력해 봐야 한다.

  • CUBRID Manager의 질의 편집기에 질의 실행 계획을 출력하는 기능이 있다.

  • 다음 그림은 CUBRID Manager에서 질의 실행 계획을 출력한 예다.

  • 질의 실행 계획에서는 테이블 스캔(sscan)이나 인덱스 스캔(iscan) 여부

  • 예상되는 CPU 및 I/O 비용, 예상 결과 집합의 레코드 개수, 예상 페이지 접근 개수 등을 볼 수 있다.


인덱스 활용 최적화

  • B+-Tree는 특성상 어떤 리프 페이지에 접근하든 거의 동일한 비용이 든다.

  • B+-Tree를 사용할 때 가장 큰 비용이 드는 부분은

  • Key Range의 시작부터 끝까지 인덱스 리프 노드를 따라 진행하는 스캔과

  • 여기에 대응하는 테이블 데이터의 스캔이다.


  • 대부분의 DBMS가 페이지(또는 블록) 단위로 I/O를 수행하며 CUBRID도 예외는 아니다.

  • 즉 하나의 레코드에서 하나의 컬럼만 읽으려 해도 레코드가 속한 페이지 전체를 디스크로부터 읽어온다.

  • 따라서 질의 성능을 좌우하는 가장 중요한 성능 지표는 I/O를 수행하는 페이지 개수이며

  • 이 개수는 옵티마이저의 판단에 가장 큰 영향을 미친다.

  • 옵티마이저가 인덱스를 읽을지 테이블을 읽을지 결정하는데 있어 가장 중요한 판단 기준은

  • 읽어야 할 레코드 가 아니라 읽어야 할 페이지 개수인 것이다.


  • 디스크 I/O는 메모리 액세스에 비해 비용이 아주 크다.

  • 질의 수행에 필요한 모든 데이터 페이지와 인덱스 페이지를

  • 데이터베이스 버퍼에 로드해서 처리할 수 있다면 좋지만 그러기에는 한계가 있다.

  • 결국 디스크 I/O를 최소화하고 대부분의 연산을 데이터베이스 버퍼에서 처리할 수 있도록

  • 질의 처리 과정에서 액세스하는 페이지 수를 최소화하는 것이 튜닝의 핵심이다.

  • 액세스하는 페이지 수가 적으면 자연스럽게 물리적으로 디스크에서 읽어야 할 페이지 수도 줄기 때문에

  • 데이터베이스 버퍼 히트율(DB buffer hit ratio)이 높아져서 데이터베이스의 전체적인 성능이 높아진다.

  • 그러면 지금부터 인덱스 스캔 과정에서 액세스해야 할 페이지 수를 줄일 수 있는 기법을 알아보자.


Key Filter 활용

  • 앞서 설명한 바와 같이 Key Filter

  • Key Range에는 포함되지 않지만 인덱스 키로 처리할 수 있는 조건이다.

  • 이러한 Key Filter가 WHERE 조건절에 포함되면 데이터 페이지에 접근하는 횟수를 줄일 수 있다.

  • 데이터 페이지는 랜덤 액세스로 읽기 때문에 인덱스 페이지 스캔보다 많은 비용이 든다.

  • 따라서 WHERE절에 Key Filter를 포함하면 성능 향상에 유리하다.

  • 또한 Data Filter가 Key Filter로 적용될 수 있도록 인덱스에 컬럼을 추가하는 것도 방법이 될 수 있다.

  • 예를 들어 user 테이블에 (groupid, name)으로 구성된 인덱스 idx_1이 있는 상태에서 아래 질의를 수행한다고 하자.

SELECT * FROM user  
WHERE groupid = 10  
AND age > 40;  
  • groupid=10인 조건을 만족하는 레코드가 100건이고 그 중 age > 40인 레코드가 10건이라고 하면

  • 인덱스 스캔으로 100건의 OID를 가져온 후 최악의 경우 데이터 페이지로 100회의 액세스를 수행할 것이다.

  • 그러나 idx_1 인덱스에 age 컬럼을 추가하여 (groupid, name, age)로 만들면

  • age > 40 조건이 Key Filter 조건으로 처리되어 인덱스 스캔으로 10건의 OID만 추출할 수 있다.


커버링 인덱스

  • 만약 사용하는 인덱스SELECT 질의에 대한 결과를 모두 얻을 수 있는 상황이라면

  • 데이터 페이지에 저장되어 있는 레코드를 읽어오지 않아도 인덱스 키의 값만으로도 결과를 얻을 수 있다.

  • 이와 같이 인덱스가 하나의 질의를 모두 커버 한 경우를 커버링 인덱스(Covering Index)라고 한다.

  • CUBRID 2008 R4.0는 커버링 인덱스를 지원한다.

SELECT a, b FROM tbl  
WHERE a > 1 AND a < 5  
AND b < 'K'  
ORDER BY b;  
  • 위의 SQL 질의에서 커버링 인덱스를 적용할 수 있다.

  • 질의에 사용한 컬럼은 a, b 뿐이고 모두 인덱스 컬럼이기 때문이다.

  • 질의에 커버링 인덱스가 활용되는지 확인하려면

  • 다음 그림과 같이 질의 실행 계획에 covers라는 표시가 있는지 보면 된다.

  • 커버링 인덱스는 데이터 페이지를 읽지 않는다는 점

  • 그리고 해당 질의를 자주 사용하면 인덱스가 데이터베이스 버퍼에 캐시되어 있을 가능성이 높다는 점에서

  • 디스크 I/O를 줄이는 데 큰 역할을 한다.

  • 따라서 레코드 크기에 비해 인덱스 키의 크기가 작고

  • 커버링 인덱스를 이용하는 질의가 자주 수행되는 것이 확실하다면

  • 커버링 인덱스를 사용하여 SELECT 질의 성능을 크게 향상시킬 수 있다.


정렬 연산 대체

  • 인덱스 스캔으로 생성한 결과 집합인덱스 컬럼 순으로 정렬된 상태이므로

  • ORDER BY 절이나 GROUP BY 절에 의한 정렬 연산을 생략하도록 질의를 작성할 수 있다.


  • 정렬 연산을 생략하려면 인덱스 컬럼의 순서대로 ORDER BY 절이나 GROUP BY 절에 컬럼이 지정되어야 한다.

  • 예를 들어 인덱스 키가 (a, b)로 되어 있다면

  • “ORDER BY a” 또는 “ORDER BY a, b”처럼 정렬할 컬럼이 명시되어야 한다.

  • “ORDER BY b”는 정렬 연산을 대체할 수 없다.

  • 인덱스 (a, b)에서 b는 a가 같은 값일 경우에만 정렬된 상태를 보장하기 때문이다.

  • 하지만 “a = 2”와 같은 조건이 WHERE 절에 있다면 “ORDER BY b”도 정렬 연산을 대체할 수 있다.


  • 즉 인덱스 컬럼이 조건절에서 ‘=’ 연산자로 동등 비교되는 경우에는

  • (= “a = 2”와 같은 조건이 WHERE 절에 있다면)

  • 해당 컬럼이 ORDER BY나 GROUP BY 절에서 중간에 생략되어도 된다.

  • (= “ORDER BY b”도 정렬 연산을 대체할 수 있다.)


  • GROUP BY에 대한 정렬 연산 대체는 CUBRID 2008 R4.0부터 지원된다.

  • 아래 질의는 정렬 연산 없이 인덱스 스캔만으로 정렬된 결과를 만들어 내는 예이다.

SELECT * FROM tbl  
WHERE a = 2 AND b < 'K'  
ORDER BY b;

SELECT COUNT(*) FROM tbl  
WHERE a > 1 AND a < 5  
AND b < 'K'  
AND c > 10000  
GROUP BY a;  
  • 정렬 연산이 인덱스 스캔으로 대체되는지 확인하려면

  • 다음 그림과 같이 질의 실행 계획에 skip ORDER BY 또는 skip GROUP BY가 표시되는지 확인하면 된다.

  • 앞에서 인덱스 스캔을 하려면 조건절에 인덱스의 첫 번째 컬럼이 명시돼야 한다고 설명했다.

  • 하지만 인덱스 컬럼에 NOT NULL 제약 조건이 설정돼 있다면

  • 옵티마이저는 조건절에 인덱스 첫 번째 컬럼이 없더라도

  • 최소 키 값과 최대 키 값으로 Key Range를 자동으로 추가하여 인덱스 스캔을 할 수 있게 최적화한다.

  • 즉 인덱스 리프 노드의 처음부터 끝까지 스캔하는데

  • 이를 오라클에서는 인덱스 전체 범위 스캔(Index Full Range Scan)이라고 한다.

SELECT * FROM tbl  
WHERE b < 'K'  
ORDER BY a;  
  • 위 SQL 질의는 옵티마이저에 의해 인덱스 전체 범위 스캔이 수행되는 예다.

  • 위 질의문의 실행 계획을 확인하면

  • 다음 그림처럼 Key Range가 자동으로 추가되어 ORDER BY 정렬 연산이 생략되는 것을 알 수 있다.


LIMIT 최적화

  • LIMIT 절은 질의의 최종 결과 개수를 제한한다.

  • CUBRID 2008 R4.0에서는 Data Filter가 없는 질의에 LIMIT 절이 있으면

  • Key Range에 해당하는 키 값 전부를 스캔할 필요 없이

  • LIMIT 절에 기술된 개수 만큼의 결과를 확보했을 때 바로 스캔을 중단할 수 있다.

  • 이렇게 하면 범위의 끝까지 스캔하고 나서 결국 버리게 되는 페이지를 액세스하지 않기 때문에 불필요한 I/O를 줄일 수 있다.

SELECT * FROM tbl  
WHERE a = 2 AND b < 'K'  
ORDER BY b  
LIMIT 3;  
  • 이 SQL 질의는 LIMIT 최적화로 필요한 결과를 얻은 후 인덱스 스캔이 중단되는 예다.

  • 만약 a = 2인 인덱스 키가 10페이지에 걸쳐 저장돼 있더라도

  • LIMIT 절에 명시한 3개의 키 값만 스캔하므로 1개의 페이지만 읽게 된다.


  • IN 절을 사용한 질의에도 LIMIT 최적화를 적용할 수 있다.

  • CUBRID는 인덱스 컬럼이 IN 절에 사용되면 Key Range를 IN 절에 사용된 개수만큼 생성하고

  • 각 Key Range에 대해 인덱스 스캔을 수행한다.

  • 다만 아래 질의처럼 LIMIT 절에 결과 개수가 명시되면

  • 3번의 인덱스 스캔에 대해 각각 3건의 결과만 구하고 인덱스 스캔을 중단한다.

  • 즉 각 인덱스 스캔에 대해서 LIMIT 최적화가 적용되는 것이다.

SELECT * FROM tbl  
WHERE a IN (2, 4, 5)  
AND b < 'K'  
ORDER BY b  
LIMIT 3;  
  • ORDER BY 절은 전체 결과에 대한 정렬을 의미하기 때문에

  • Key Range가 여러 개이면 각 인덱스 스캔 결과를 모아서 다시 정렬해야 한다.

  • 하지만 인덱스 스캔의 결과로 정렬을 대체할 수 있을 때에는 스캔 과정에서 바로 병합(merge)할 수 있다.

  • CUBRID는 이 과정을 In-Place Sorting이라고 부른다.


  • 다음 그림을 보면서 자세히 설명하면

  • 먼저 첫 번째 범위(a = 2 AND b < ‘K’)를 스캔하여 3건의 OID를 확보한다.

  • 그 다음 두 번째 범위(a = 4 AND b < ‘K’)에 대한 스캔을 시도하는데

  • 이 범위의 첫 번째 키(4, ‘DAA’)는 첫 번째 범위의 마지막 스캔 키(2, ‘CCC’)보다 b 컬럼의 값이 크기 때문에 바로 스캔을 중단한다.

  • 마찬가지로 다음 세 번째 범위인 a = 5 AND b < ‘K’에 대한 스캔에서도

  • 두 번째 키를 읽은 후 바로 스캔을 중단한다.

  • 이처럼 In-Place Sorting 기법은 인덱스 스캔 범위를 더욱 축소하고

  • 최종 결과에 대한 별도의 정렬을 수행하지 않기 때문에 성능 향상에 많은 도움을 준다.


은 총알은 없다(No Silver Bullet)

  • 인덱스가 좋다고 인덱스를 많이 만드는 것은 능사가 아니다.

  • 오히려 인덱스 관리 비용이 증가하고 INSERT, UPDATE, DELETE의 성능 저하의 원인이 될 수 있다.

  • 인덱스 사용 시 다음 내용을 고려하자.


  1. 인덱스 키의 크기는 되도록 작게 설계해야 성능에 유리하다.

  2. 분포도가 좋은 컬럼(좁은 범위), 기본 키, 조인의 연결 고리가 되는 컬럼을 인덱스로 구성한다.

  3. 단일 인덱스 여러 개보다 다중 컬럼 인덱스의 생성을 고려한다.

  4. 업데이트가 빈번하지 않은 컬럼으로 인덱스를 구성한다.

  5. JOIN 시 자주 사용하는 컬럼은 인덱스로 등록한다.

  6. 되도록 동등 비교(=)를 사용한다.

  7. WHERE 절에서 자주 사용하는 컬럼에는 인덱스 추가를 고려한다.

  8. 인덱스를 많이 생성하는 것은 INSERT/UPDATE/DELETE의 성능 저하의 원인이 될 수 있다.

  9. 인덱스 스캔이 테이블 순차 스캔보다 항상 빠르지는 않다.
    보통 선택도(selectivity)가 5~10% 이내인 경우에 인덱스 스캔이 우수하다.


  • 정리하면 데이터베이스 튜닝의 핵심은

  • 적절한 수의 인덱스를 생성하고 질의가 이 인덱스를 활용할 수 있도록 질의를 최적화하는 것이다.

  • 이를 위해서는 DBMS에 구현된 인덱스 구조와 다양한 활용 기법을 이해하고

  • 질의 패턴과 사용 빈도, I/O 비용, 저장 공간에 대한 비용을 전체적으로 고려해야 한다.


Reference


Recommend

Index