본문 바로가기

CS/DB

인덱스로 성능 개선하기 (feat. B-Tree 인덱스)

이번 포스팅은 금주에 겪었던 서비스 속도 이슈의 원인을 분석하고 그 과정을 어떻게 해결했는지에 대한 내용을 기록해보려고 합니다. 


문제  발생 

현재 재직 중인 회사에서는 특정 판매처(ex. 카페 24)와 서비스 간의 재고연동 기능을 지원하고 있습니다. 기능에 대한 설명을 간단히 하자면, 판매처의 재고 수량과 자사 서비스의 재고 수량을 동기화시키는 과정을 의미합니다. 해당 기능은 과거에도 지원하고 있었으며 최근에 레거시(classic asp)를 Laravel Framework로 이전하는 과정에서 속도 이슈가 발생했습니다. 

 

기존의 코드도 재고연동 속도는 느렸지만, 현재는 그걸 감안하지 못할 정도의 수준이 되었다는 것입니다. (상품 1000건 기준 대기 시간 15분 이상) 원인을 파악하는 과정에서 알게 된 사실은 여러 가지가 있었지만 그중 하나는 재고 연동 내역을 기록하는 과정도 포함되어 있었습니다. 

 

과거에는 재고연동을 기록하는 시점을 모든 상품에 대한 재고 연동이 끝나는 시점에서 단 한번 기록하고 프로세스가 종료되었습니다. 현재는 상품의 옵션단위 별로 재고연동 내역을 기록하는 식으로 기능이 수정되었습니다. 예를 들어 과거에는 연동단위별로 재고연동 내역을 기록하고 있었다면 현재는 상품옵션 수 단위로 기록하기 때문에 상품옵션수가 12000건이라면 12000개의 row수가 생성되어야 했습니다. 

 

사실 물리적으로 DB 작업의 수가 늘어난 건 맞지만, 그 수준이 전체 성능을 크게 좌우할 정도 영향이 크지는 않다고 판단했습니다. 그렇지만 항상 문제는 예상하지 못한 부분에서 터졌고 이 케이스도 마찬가지였습니다. 변경된 재고연동 기록 프로세스는 다음과 같았습니다.

  • 재고연동 시작 전 상품 옵션 단위별로 재고연동 기록을 생성한다. (연동 대기 상태)
  • 재고연동 종료 후 해당 기록을 Upadate 한다. (연동 완료 상태)

이때, 문제가 발생한 시점은 연동 기록을 update 하는 시점에 있었습니다. 해당 작업이 row마다 무려 1분 이상씩 걸렸기 때문입니다. 

  • 재고연동 시작 전 상품 옵션 단위별로 재고연동 기록을 생성한다. (연동 대기 상태)
  • 재고연동 종료 후 해당 기록을 Upadate 한다. (연동 완료 상태)

재고 연동을 할 상품의 수가 많아질수록 재고 연동 기록을 update 하는 작업의 시간은 1분 보다 더 오래 걸리는 것을 확인했습니다. 

결론적으로 재고연동 기록을 위한 대상 테이블에 update의 where 조건의 칼럼을 새로 인덱스를 추가해서 서비스 성능을 올릴 수 있었습니다.

Schema::table([재고연동 대상 테이블], function (Blueprint $table) {
    $table->index([update시 where 조건문에서 참되는 칼럼들]);
});

 

 

문제는 이런 식으로 해결은 했지만, 인덱스가 정확히 무엇이며 어떻게 update 성능이 빨라지는 결과를 도출했는지에 대해 정확히 설명할 수 없었습니다. 그래서 인덱스에 대해 추가적으로 학습한 내용을 이번 포스팅에서 기록하고 공유해 보려고 합니다!


인덱스(Index)란?

DBMS는 테이블의 모든 데이터를 검색해서 원하는 결과를 가져오면 시간이 오래 걸리기 때문에 칼럼의 값과 해당 레코드가 저장된 주소를 키(key)와 값(value)의 쌍으로 인덱스를 만들어 두는 것입니다. 

 

인덱스는 칼럼의 값을 주어진 순서로 미리 정렬해서 보관하는 특징이 있습니다. 자료구조로 따지면, sortedList에 해당합니다. sortedList는 데이터 저장마다 항상 정렬이 필요하기 때문에 데이터를 저장하는 과정이 느리고 복잡하지만 원하는 값을 빨리 찾을 수 있다는 장점이 있습니다. 때문에 DBMS 인덱스도 인덱스가 많은 테이블은 당연히 insert, update, delete 문장의 처리는 느려지지만, select는 매우 빠르게 처리될 수 있습니다. 

 

결론적으로 인덱스는 데이터의 저장(insert, update, delete) 성능을 희생하고 대신 데이터의 읽기 속도를 높이는 기능입니다. 

 

여기까지의 설명만 본다면 위의 재고연동 내역을 update 하는 과정에서 index를 사용해서 성능이 빨라진 케이스는 설명하기 어려워집니다. 인덱스는 update 성능을 희생한다고 했기 때문입니다. 

 

인덱스가 추가될 경우 쓰기 작업에 추가적인 연산이 필요해서 성능이 저하될 수 있지만, update, delete 작업이 특정 조건을 기반으로 하는 경우 그 조건에 인덱스가 존재하면 해당 레코드를 찾는 시간이 단축되어 오히려 성능이 개선될 수 있다고도 합니다. 이 케이스에서는 update 작업의 성능 병목이 실제로 조건에 맞는 데이터를 찾는 과정에서 있었기 때문에 인덱스 추가로 인해 검색 과정이 빨라지면서 update 성능이 향상되었다고 할 수 있습니다. 

 

그래서 최종적으로 인덱스는, 특정 레코드의 검색 속도를 향상하는 DBMS 기술이라고 정의할 수 있습니다. 


B-Tree 인덱스

인덱스는 분류하는 기준이 여러 가지가 있습니다. 아래처럼 3가지 분류 기준에 의해서 인덱스를 분류할 수 있습니다. 이 중 이번 포스팅에서 언급하고자 하는 분류 기준은 데이터를 저장하는 방식입니다. 

  • 데이터를 관리하는 방식
  • 데이터를 저장하는 방식
  • 데이터의 중복 허용 여부

인덱스는 데이터를 저장하는 방식(알고리즘)으로 분류할 경우 대표적으로 B-Tree 인덱스와 Hash 인덱스로 구분할 수 있습니다. 

  • B-Tree 인덱스: 칼럼의 값을 변형하지 않고 원래의 값을 이용해 인덱싱하는 알고리즘입니다. 데이터의 범위 검색에 최적화되어 있습니다. 
  • Hash 인덱스: 칼럼의 값으로 해시값을 계산해서 인덱싱하는 알고리즘으로 매우 빠른 검색을 지원합니다. 다만, 값을 변형해서 인덱싱 하므로 일부만 검색하거나 범위를 검색할 때는 해시 인덱스를 사용할 수 없습니다. 

앞서 Laravel 마이그레이션을 코드를 통해 SQL Server에 인덱스를 다음과 같이 추가했습니다. 알아보니 라라벨에서는'Shema::table' 메서드를 사용해서 인덱스를 생성할 때는 특별한 설정을 하지 않은 대부분의 DBMS의 경우 B-Tree 인덱스를 기본값으로 사용한다고 합니다. HASH 인덱스를 생성하려면 MySQL과 같은 DBMS에서는'USING HASH' 옵션을 명시하지만, 라라벨에서는 기본적으로 인덱스 생성 메서드에서는 지원하지 않는다고 합니다.  

Schema::table([재고연동 대상 테이블], function (Blueprint $table) {
    $table->index([update시 where 조건문에서 참되는 칼럼들]);
});

 

 

그래서 이번 포스팅에서 간단히 B-Tree 인덱스란 무엇이며 구조에 대해 추가 학습한 내용을 기록했습니다. (학습은  Real Mysql 8.0 책을 기본 베이스로 기타 레퍼런스를 읽고 학습했습니다.)

 

B-Tree 인덱스는 칼럼의 원본 값을 변형시키지 않고 인덱스 구조체 내에서 항상 정렬된 상태로 유지하는 인덱싱 방법입니다. B-Tree의 'B'는 많은 사람들이 'Binary'로 알고 있지만 'Balanced'의 의미라고 합니다.  B-Tree 인덱스의 기본적인 구조는 트리 구조 형태를 띠고 있습니다. 아래 그림에서 왼쪽부터 루트 노드, 브랜치 노드, 리프 노드, 데이터 파일에 해당합니다. 

B-Tree 인덱스 구조

 

데이터베이스에서 인덱스와 실제 데이터가 저장된 데이터는 따로 관리되며 인덱스의 리프 노드는 항상 실제 데이터 레코드를 찾아가기 위한 주소값을 가지고 있습니다. 그림 처럼 인덱스의 키 값은 모두 정렬되어 있지만, 데이터 파일의 레코드는 정렬되지 않고 임의의 순서로 저장되어 있습니다. 

 

데이터 파일 레코드는 insert 된 순서대로 저장된다고 생각할 수 있지만, 항상 insert만 되는 경우에는 그 말이 맞을 수도 있습니다. 하지만 일반적인 경우에선 특정 레코드가 delete가 되면 다음 레코드가 insert 될 때는 DBMS에 의해서 빈자리부터 채워지기 때문에 데이터 레코드는 정렬되어 있지 않은 상태를 유지합니다

 

MySQL의 경우 테이블의 스토리지 엔지에 따라서 인덱스에 새로운 키가 추가될 경우 키를 즉시 인덱스에 저장될 수도 있고 그렇지 않을 수도 있습니다. 일례로 InnoDB의 경우는 즉시 인덱스에 추가하지만, MyISAM의 경우는 그렇지 않습니다. SQL Server의 경우는 자체적으로 통합된 스토리지 엔진을 사용하여 인덱스 키를 추가합니다. 사용하는 DBMS마다 인덱스 키를 추가하는 세부적인 과정은 다를 수 있지만, 인데스 키를 추가하는 원리는 비슷합니다. 

  • 새로운 인덱스 키를 추가할 인덱스 페이지를 찾기 위해서 B-Tree 탐색을 하고 특정 페이지에 인덱스 키를 추가합니다. 

때문에 B-Tree 인덱스는 상대적으로 쓰기 작업(새로운 키를 추가하는 작업)에 비용이 많이드는 특징이 있습니다.

 

B-Tree 인덱스를 추가하기 전후의 시간 복잡도는 크게 차이가 있습니다. 예를 들어 아래와 같은 쿼리의 검색 시간 복잡도는 O(n)입니다. 이는 전체 테이블을 스캔하는 것을 의미하며 쿼리 조건에 맞는 데이터를 찾기 위해 모든 행을 순차적으로 검사하는 것을 의미합니다. 

SELECT * FROM employees WHERE last_name = 'Smith'

 

즉, 데이터의 양이 많아질수록 검색 속도가 비례해서 느려진다는 것을 의미합니다. 반면에 B-Tree 인덱스의 경우 검색 시간 복잡도는 O(logn)으로 감소합니다. 아래 그래프처럼 n의 수가 커질수록 인덱스의 추가 여부로 서비스 성능이 크게 좌우될 수 있음을 예상할 수 있습니다. 

 

 

 

결론적으로, B-Tree 인덱스는 이처럼 트리구조를 통해 데이터 파일의 특정 레코드를 빠르게 검색하는 기능을 제공하며 검색 처리 성능을 크게 향상할 수 있습니다. 


마무리

금주에 발생했던 서비스 속도 이슈로 인해 데이터 검색을 위해 DBMS의 인덱싱을 추가 전후의 성능차이를 알 수 있는 좋은 경험이 됐습니다. 다만 인덱스를 추가하는 것은 앞서 언급했듯이 데이터 저장(insert, update, delete) 성능을 희생해야 할 수 있기 때문에 적절한 사용이 필요하다는 것을 다시 한번 상기하는 것도 좋을 것 같습니다. 

반응형

'CS > DB' 카테고리의 다른 글

프로시저(Procedure) 동작 방식과 동시성 문제  (0) 2024.07.08