
SQL Server 성능 튜닝을 제대로 이해하려면 가장 먼저 알아야 하는 것이 바로 인덱스(Index) 이다.
이번 실습에서도:
같은 다양한 성능 이슈를 다뤘지만, 결국 대부분의 문제는 인덱스와 연결되어 있었다.
따라서 본격적인 실습 내용을 보기 전에, 먼저 SQL Server 인덱스 개념을 정리하고 시작한다.
인덱스는 데이터를 빠르게 찾기 위한 자료구조이다.
책에서 원하는 내용을 찾을 때:
SQL Server에서도 인덱스가 없다면 원하는 데이터를 찾기 위해 테이블 전체를 읽어야 한다.
이를 Table Scan이라고 한다.
데이터 양이 적을 때는 큰 문제가 없지만, 데이터가 수백만 건 이상으로 증가하면 성능 차이가 매우 커진다.
인덱스는 원하는 데이터 위치를 빠르게 찾을 수 있게 해준다.
예를 들어:
SELECT *
FROM Users
WHERE Email = 'test@test.com'
같은 쿼리에서 Email 컬럼에 인덱스가 존재하면 SQL Server는 전체 데이터를 읽지 않고 필요한 데이터만 바로 찾을 수 있다.
인덱스가 없으면 SQL Server는 테이블 전체를 읽는다.
즉:
이 발생한다.
인덱스를 사용하면 필요한 데이터만 읽기 때문에 I/O가 감소한다.
실제 운영 환경에서는:
를 처리해야 한다.
인덱스 없이 운영하면:
가 발생한다.
따라서 인덱스는 단순 최적화가 아니라 필수 요소에 가깝다.
SQL Server는 대부분 B-Tree(B+Tree) 구조를 사용한다.
구조는 다음과 같다.
| 구조 | 역할 |
|---|---|
| Root Node | 검색 시작점 |
| Intermediate Node | 중간 탐색 |
| Leaf Node | 실제 데이터 또는 데이터 위치 저장 |
예를 들어 다음과 같은 데이터가 있다고 가정한다.
1, 5, 10, 20, 50, 100
SQL Server는 이 값을 트리 형태로 정렬하여 저장한다.
검색 시에는:
과정을 거친다.
즉:
전체 데이터를 순차 탐색하지 않아도 된다.
이것이 인덱스가 빠른 이유이다.
Clustered Index는 실제 데이터 자체가 정렬된다.
즉:
가 동일하다.
| 특징 | 설명 |
|---|---|
| 테이블당 1개만 가능 | 실제 데이터 순서는 하나만 존재 가능 |
| 범위 검색에 강함 | BETWEEN, ORDER BY 최적화 |
| Primary Key에 자주 사용 | 기본 키 생성 시 자동 생성되는 경우 많음 |
CREATE CLUSTERED INDEX IX_Users_Id
ON Users(UserId);
이 경우 Users 테이블 데이터 자체가 UserId 기준으로 정렬된다.
Nonclustered Index는 실제 데이터와 별도 구조로 존재한다.
인덱스에는:
만 저장된다.
| 구조 | 설명 |
|---|---|
| 별도 인덱스 구조 존재 | 데이터와 분리 |
| Leaf Node | 실제 데이터 위치 저장 |
| 여러 개 생성 가능 | 최대 999개 |
CREATE NONCLUSTERED INDEX IX_Users_Email
ON Users(Email);
이 경우 Email 기반 검색이 빨라진다.
실행 계획에서 가장 중요하게 보는 것 중 하나가:
이다.
Table Scan은 테이블 전체를 읽는다.
즉:
1행부터 끝까지 전부 읽음
데이터가 많을수록 매우 느려진다.
Index Seek는 필요한 데이터만 찾는다.
즉:
원하는 위치만 바로 접근
따라서 훨씬 빠르다.
실행 계획(Execution Plan)에서:
를 확인할 수 있다.
튜닝에서 가장 기본적으로 보는 부분이다.
Covering Index는 쿼리에 필요한 모든 컬럼이 인덱스에 포함된 경우를 말한다.
즉:
하다.
CREATE INDEX IX_Orders_CustomerId
ON Orders(CustomerId)
INCLUDE (OrderDate, TotalAmount);
| 장점 | 설명 |
|---|---|
| Key Lookup 제거 | 추가 테이블 접근 감소 |
| Logical Read 감소 | 성능 향상 |
| 실행 계획 단순화 | I/O 절약 |
Key Lookup은 SQL Server가:
하는 작업이다.
즉:
인덱스만으로 필요한 컬럼이 부족함
을 의미한다.
Key Lookup은:
를 유발한다.
특히 결과 건수가 많을수록 심각해진다.
데이터가 지속적으로 변경되면 인덱스 페이지 순서가 깨진다.
이를 Fragmentation이라고 한다.
| 작업 | 영향 |
|---|---|
| INSERT | 페이지 분할 발생 |
| UPDATE | 데이터 이동 발생 |
| DELETE | 빈 공간 증가 |
| 문제 | 설명 |
|---|---|
| 디스크 I/O 증가 | 페이지 순서 깨짐 |
| Logical Read 증가 | 더 많은 페이지 읽기 |
| 성능 저하 | 응답 속도 감소 |
SQL Server에서는 다음 DMV로 확인 가능하다.
sys.dm_db_index_physical_stats
조각화 해결 방법은 크게 2가지이다.
| 구분 | REORGANIZE | REBUILD |
|---|---|---|
| 방식 | 페이지 재정렬 | 인덱스 새 생성 |
| 부하 | 낮음 | 높음 |
| 통계 갱신 | X | O |
| 추천 구간 | 10~30% | 30% 이상 |
SQL Server는 쿼리를 실행하기 전에:
“어떻게 실행하는 것이 가장 효율적인가”
를 계산한다.
이 결과가 실행 계획이다.
| 요소 | 의미 |
|---|---|
| Index Seek | 효율적 검색 |
| Index Scan | 인덱스 전체 탐색 |
| Table Scan | 테이블 전체 탐색 |
| Key Lookup | 원본 데이터 재접근 |
| Hash Match | 해시 기반 조인 |
| Sort | 정렬 작업 |
실행 계획은 Query Optimizer가 생성한다.
Optimizer는:
을 기반으로 가장 효율적이라고 판단한 플랜을 선택한다.
하지만 항상 완벽하지는 않다.
따라서:
같은 문제도 발생한다.
SQL Server의 Query Store는:
기능을 제공한다.
특히:
분석에 매우 유용하다.
Blocking은 트랜잭션이 서로 Lock을 기다리는 현상이다.
예를 들어:
하면 세션 B는 대기 상태가 된다.
Blocking 완화를 위해 SQL Server는 Snapshot 기반 격리 수준을 제공한다.
대표적으로:
READ_COMMITTED_SNAPSHOT
이 있다.
기존 Read Committed:
Snapshot 기반:
즉:
Reader와 Writer 충돌 감소
효과가 있다.
이번 실습의 핵심은 단순 SQL 문법이 아니었다.
진짜 중요한 것은:
이번 실습에서는 조각화를 의도적으로 발생시켰다.
USE AdventureWorks2017
GO
SELECT i.name Index_Name
, avg_fragmentation_in_percent
, db_name(database_id)
, i.object_id
, i.index_id
, index_type_desc
FROM sys.dm_db_index_physical_stats(
db_id('AdventureWorks2017'),
object_id('person.address'),
NULL,
NULL,
'DETAILED'
) ps
INNER JOIN sys.indexes i
ON ps.object_id = i.object_id
AND ps.index_id = i.index_id
WHERE avg_fragmentation_in_percent > 50
처음에는 조각화가 거의 없기 때문에 결과가 나오지 않는다.
USE AdventureWorks2017
GO
INSERT INTO [Person].[Address]
(
[AddressLine1],
[AddressLine2],
[City],
[StateProvinceID],
[PostalCode],
[SpatialLocation],
[rowguid],
[ModifiedDate]
)
SELECT
AddressLine1,
AddressLine2,
'Amsterdam',
StateProvinceID,
PostalCode,
SpatialLocation,
newid(),
getdate()
FROM Person.Address;
GO
이 작업으로 Person.Address 테이블 크기가 증가하며 페이지 분할(Page Split)이 발생한다.
결과적으로 인덱스 조각화율이 크게 증가한다.
SET STATISTICS IO,TIME ON
GO
USE AdventureWorks2017
GO
SELECT DISTINCT (StateProvinceID)
,count(StateProvinceID) AS CustomerCount
FROM person.Address
GROUP BY StateProvinceID
ORDER BY count(StateProvinceID) DESC;
GO
메시지 탭에서 Logical Read 값을 확인할 수 있다.
조각화가 심해질수록 더 많은 페이지를 읽게 된다.
조각화 문제 해결을 위해 REBUILD를 수행한다.
USE AdventureWorks2017
GO
ALTER INDEX [IX_Address_StateProvinceID]
ON [Person].[Address]
REBUILD PARTITION = ALL
REBUILD는 인덱스를 새로 만드는 작업이다.
결과:
| 구분 | REORGANIZE | REBUILD |
|---|---|---|
| 방식 | 페이지 재정렬 | 인덱스 새 생성 |
| 부하 | 낮음 | 높음 |
| 통계 갱신 | X | O |
| 권장 구간 | 10~30% | 30% 이상 |
SQL Server는 쿼리를 실행하기 전에 “어떻게 실행할지” 계획을 만든다.
이것이 Execution Plan이다.
실행 계획을 통해:
등이 어떻게 발생하는지 확인할 수 있다.
다음 쿼리를 실행했다.
SELECT BusinessEntityID,
NationalIDNumber,
LoginID,
HireDate,
JobTitle
FROM HumanResources.Employee
WHERE NationalIDNumber = 14417807;
실행 계획에서 경고가 발생했다.
원인은 Implicit Conversion이다.
NationalIDNumber 컬럼 타입은:
nvarchar(15)
이다.
하지만 비교값은 숫자(INT)이다.
따라서 SQL Server가 내부적으로:
CONVERT_IMPLICIT(...)
를 수행한다.
Implicit Conversion은 다음 문제를 만든다.
| 문제 | 설명 |
|---|---|
| CPU 증가 | 형 변환 연산 발생 |
| 인덱스 비효율 | Seek 최적화 방해 |
| 실행 계획 악화 | Optimizer가 비효율 플랜 선택 |
WHERE NationalIDNumber = '14417807';
문자열로 맞춰주면 된다.
이렇게 하면:
해진다.
ALTER TABLE [HumanResources].[Employee]
ALTER COLUMN [NationalIDNumber] INT NOT NULL;
하지만 여기서 중요한 문제가 발생한다.
NationalIDNumber는 기존 인덱스에서 사용 중이었다.
따라서:
순서가 필요하다.
즉 운영 환경에서는 다운타임 문제가 발생할 수 있다.
Lab10에서는 Key Lookup 문제를 분석했다.
실행 계획에서:
이 발생했다.
현재 인덱스는:
(ProductID)
만 포함한다.
하지만 SELECT에서 필요한 컬럼은 더 많다.
따라서 SQL Server는:
을 수행한다.
이 추가 접근이 Key Lookup이다.
해결 방법은 Covering Index이다.
CREATE NONCLUSTERED INDEX
[IX_SalesOrderDetail_ProductID]
ON [Sales].[SalesOrderDetail]
([ProductID],[ModifiedDate])
INCLUDE (
[CarrierTrackingNumber],
[OrderQty],
[UnitPrice]
)
WITH (DROP_EXISTING = on);
핵심:
결과:
Query Store는 SQL Server의 성능 분석 기능이다.
활성화:
ALTER DATABASE [AdventureWorks2017]
SET QUERY_STORE = ON;
실행 계획 중 더 좋은 플랜을 강제로 사용 가능하다.
실습에서는:
두 개를 비교 후:
Force Plan
기능으로 좋은 플랜을 강제 적용했다.
Blocking은 트랜잭션이 서로 잠금을 기다리는 현상이다.
BEGIN TRANSACTION
UPDATE Person.Person
SET LastName = LastName;
트랜잭션 종료 안 함.
SELECT TOP (1000)
[LastName],
[FirstName],
[Title]
FROM Person.Person
WHERE FirstName = 'David'
결과:
첫 번째 세션이 Lock을 유지 중이기 때문이다.
두 번째 세션은 같은 데이터 접근 시도 중이라 대기 상태가 된다.
Blocking 추적을 위해 Extended Events를 사용했다.
blocked_process_report
를 통해:
확인 가능하다.
Blocking 완화를 위해 Snapshot Isolation 기반 설정을 적용했다.
ALTER DATABASE AdventureWorks2017
SET READ_COMMITTED_SNAPSHOT ON
WITH ROLLBACK IMMEDIATE;
기존 Read Committed:
READ_COMMITTED_SNAPSHOT:
즉:
Reader와 Writer 충돌 감소
이번 실습에서는 단순 SQL 작성이 아니라 실제 운영 환경 수준의 SQL Server 성능 튜닝 과정을 경험했다.
특히 다음 내용을 실제로 확인할 수 있었다.
결국 SQL 성능 튜닝의 핵심은:
“왜 SQL Server가 그런 실행 계획을 선택했는가”
를 이해하는 것이라는 점을 확인할 수 있었다.