데이터베이스에 대량의 데이터를 삽입(insert) 할 때, 같은 데이터를 넣더라도 방식에 따라 속도 차이가 최대 5배 이상 나는 경우가 있다.

 

 100만 건의 데이터를 DB에 넣는다고 가정해보자. (Oracle, PostgreSQL, MySQL 모두 해당된다)

 

설명
1. 단건 insert (1건씩 insert 수행, 오토커밋)
2. 단건 insert + 수동 커밋 (트랜잭션 한 번에 묶음)
3. PreparedStatement 반복 (JDBC 배치 처리)
4. ORM (BatchSize 설정)
5. Bulk insert SQL (insert into values (...), (....))

 

위와 같은 방식이 있을것이다. 각각의 방식이 왜 차이가 생기는지 정리를 해보자.

 

 

1. 단건 insert (1건씩 insert + 오토커밋)

for (int i = 0; i < 1000000; i++) {
    String sql = "INSERT INTO users (name, email) VALUES (?, ?)";
    try (Connection conn = dataSource.getConnection();
         PreparedStatement pstmt = conn.prepareStatement(sql)) {
        pstmt.setString(1, "User" + i);
        pstmt.setString(2, "user" + i + "@example.com");
        pstmt.executeUpdate(); // 매번 DB round-trip 발생
    }
}

 

위 방식은 

  • 커넥션 매번 생성/종료 : 너무 느림
  • 매번 오토커밋 : DB 트랜잭션 비용 증가
  • I/O 부하 증가 : 디스크 flush가 반복됨

이런 단점들로 인해 가장 느리다(100만 건 기준 수 분 이상 소요될 수 있음)

 

 

 

2. 단건 insert + 수동 커밋 (트랜잭션 묶기)

Connection conn = dataSource.getConnection();
conn.setAutoCommit(false);
PreparedStatement pstmt = conn.prepareStatement("INSERT INTO users (name, email) VALUES (?, ?)");

for (int i = 0; i < 1000000; i++) {
    pstmt.setString(1, "User" + i);
    pstmt.setString(2, "user" + i + "@example.com");
    pstmt.executeUpdate();
}
conn.commit();

 

위의 1번 방식보다는 개선되었다.

  • 트랜잭션이 한 번만 발생 → 비용 절감
  • 커넥션 재사용
  • 단건 처리지만 I/O flush 횟수 감소

하지만 여전히 100만 건 DB 요청을 100만 번 한다.

 

 

 

3. JDBC Batch Insert

Connection conn = dataSource.getConnection();
conn.setAutoCommit(false);
PreparedStatement pstmt = conn.prepareStatement("INSERT INTO users (name, email) VALUES (?, ?)");

for (int i = 0; i < 1000000; i++) {
    pstmt.setString(1, "User" + i);
    pstmt.setString(2, "user" + i + "@example.com");
    pstmt.addBatch();

    if (i % 1000 == 0) {
        pstmt.executeBatch();
        pstmt.clearBatch();
    }
}
pstmt.executeBatch(); // 남은 것 처리
conn.commit();

 

위 방식의 핵심은

  • addBatch() 로 모아서 executeBatch()로 일괄처리
  • DB round-trip이 100만 번 1,000번으로 줄어든다
  • 트랜잭션도 한 번만 발생

그렇다면 성능은 개선되었을까?

  • 일반적인 기본 insert의 3~5배 이상 빠름
  • PostgreSQL, Oracle, MySQL 모두 지원한다
  • 메모리 사용량만 주의가 필요하다 (Batch 크기가 너무 크면 OOM 발생 가능성이 있다)

 

 

4. ORM (JAP/Hibernate) + BatchSize 설정

@Entity
public class User {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private String email;
}

 

기본 insert (JAP) : 

for (int i = 0; i < 1000000; i++) {
    User u = new User("User" + i, "user" + i + "@email.com");
    entityManager.persist(u);
}

 

위 방식에서의 문제점은

  • insert 쿼리 100만 번 발생
  • flush/commit 지연 발생
  • 트랜잭션이 너무 길어지면 성능 저하/락 발생

개선하는 방법은 Batch 설정이다

properties : 

spring.jpa.properties.hibernate.jdbc.batch_size=1000
spring.jpa.properties.hibernate.order_inserts=true

 

→ Hibernate가 내부적으로 JDBC addBatch()를 호출하도록 해준다

 

이렇게 개선후의 성능은

  • 거의 JDBC batch insert 수준까지 도달 가능하다
  • ORM 코드 유지 + 성능 모두 확보가 가능하다

 

 

5. Raw SQL bulk insert (특정 DB 지원)

INSERT INTO users (name, email)
VALUES 
('User1', 'user1@email.com'),
('User2', 'user2@email.com'),
('User3', 'user3@email.com');
-- 수천 개 VALUES 묶음

 

특징 : 

  • 가장 빠른 방식 (DB 엔진 최적화)
  • 단점 : 가변 데이터 처리에 어려움이 있다
  • 일반적으로 ORM에서는 사용하기 힘들다
  • 대량 데이터 마이그레이션, 초기 로딩에 적합하다

 

마지막으로  실행 결과를 비교해보자

방식 실행 시간 특징
단건 insert (오토커밋) 약 800초 최악
단건 insert + 수동 커밋 약 400초 약간 개선
JDBC batch insert 약 90초 훨씬 빠름
JPA + batch 설정 약 110초 안정적 + 실무 적합
raw bulk insert 약 40초 가장 빠름 (단, 가변 데이터 처리에 어려움이 있다)

 

쿼리 계획이란?

쿼리 계획(Execution Plan)은 오라클 데이터베이스가 SQL 문장을 실행할 때 "어떤 방법으로 데이터를 읽고 처리할지" 미리 계산해서 보여주는 설명서이다.

 

즉,

  • 테이블을 풀스캔할지?
  • 인덱스를 탈지?
  • 조인을 어떤 순서로 할지? 

등을 알려주는 실행 청사진이다.

 

이걸 보면 쿼리 성능 문제를 미리 예측하거나 최적화할 수 있다.

 

 

SQL Developer에서 Explain Plan 확인하는 기본 방법

  1. SQL Developer 실행
  2. 오라클 DB에 로그인
  3. 쿼리 작성  ex) SELECT * FROM employees WHERE department_id = 10;
  4. 쿼리 블록을 드래그하거나 선택한 후, 상단 메뉴에서 Explain Plan(F10) 버튼 클릭 또는 오른쪽 클릭 → Explain Plan 선택

 

Explain Plan 결과 화면 읽는 방법

컬럼 설명
Operation 어떤 작업을 했는지(Full Table Scan, Index Scan 등)
Object Name 대상 테이블이나 인덱스 이름
Rows (Cardinality) 예측 결과로 읽게 될 데이터 건수
Cost 이 작업에 필요한 예상 리소스 소비량(CPU, IO 등)
Bytes 읽어야 할 데이터 크기

 

Cardinality 가 높다 = 많은 데이터가 나온다. / Cardinality가 낮다 = 적은 데이터가 나온다.

 

자주 나오는 Operation 종류 및 의미

Operation 의미
TABLE ACCESS FULL 테이블 전체를 읽는다( 성능 위험 신호 But 더 좋을때도 있음!)
INDEX RANGE SCAN 인덱스를 범위 검색(좋음)
INDEX UNIQUE SCAN 인덱스를 정확히 하나만 검색(매우 좋음)
NESTED LOOPS 두 테이블을 반복적으로 조인(작은 데이터에 적합)
HASH JOIN 대량 데이터 조인 (메모리 사용 많음)
SORT AGGREGATE 집계 연산(SUM, COUNT 등)

 

 

 

Explain Plan 볼 때 주의할 점!

1. Cost가 낮다고 무조건 좋은 게 아니다.

Cost는 참고용이고 "IO 비용"을 줄이는게 최종 목표이다.

 

2. 풀스캔(TABLE ACCESS FULL)이 무조건 나쁜 건 아니다.

아주 작은 테이블이라면 풀스캔이 더 빠를 수도 있다. 하지만 큰 테이블이라면 반드시 인덱스 활용을 고려해봐야한다.

 

3. 실행계획이 변할 수 있다.

바인드 변수, 통계 정보, 힌트 등에 따라 변동이 가능하다. ALWAYS 실제 데이터와 상황을 고려해야 한다.

 

4. AUTOTRACE나 DBMS_XPLAN.DISPLAY를 써보자

Explain Plan은 예측, Autotrace는 진짜 실행 기반이므로 함께 보는 게 좋다.

대부분의 테이블에 날짜 데이터가 등록된 컬럼이 있을 것이다. 날짜별로 그룹화 하여 조회하려면 어떻게 해야할까??

 

1. TRUNC 함수 사용

TRUNC 함수를 사용하면 날짜별로 잘라낼 수 있다.

 

* 월 단위로 자르는 예시

SELECT TRUNC(REG_DT, 'MM') AS month_start,
       COUNT(*) AS cnt
FROM your_table
GROUP BY TRUNC(REG_DT, 'MM')
ORDER BY month_start;

이 쿼리는 REG_DT의 월의 첫 날(예: 2024-08-01) 기준으로 그룹화하여 각 월에 해당하는 데이터 건수를 보여준다.

 

2. TO_CHAR 함수 사용 

날짜를 원하는 형식(예: 'YYYY-MM')으로 변환하여 그룹화할 수도 있다.

SELECT TO_CHAR(REG_DT, 'YYYY-MM') AS month,
       COUNT(*) AS cnt
FROM your_table
GROUP BY TO_CHAR(REG_DT, 'YYYY-MM')
ORDER BY month;

 

* 만약 REG_DT 컬럼이 문자열 형식이라면, 먼저 TO_DATE 또는 TO_TIMESTAMP 함수를 사용하여 날짜로 변환해야 한다.

컬럼 삭제 중 해당 에러가 떴다. 

 

원인

  • 테이블이 다른 세션에서 사용 중이다.

다른 사람이 테이블에 대해 SELECT나 UPDATE를 하고 있거나, 트랜잭션이 열려 있어서 락이 걸려 있는 상태이다. Oracle은 ALTER TABLE 같은 DDL 문장을 실행할 때 해당 테이블에 대해 exclusive lock을 걸려고 시도하는데, 이미 누군가 쓰고 있다면 그 락을 못 걸고 에러가 난다.

 

누군가 테이블을 사용하고 있군... 

 

 

해결 방법

  1. 잠깜 기다렸다가 다시 시도 : 누군가 사용하고 있다는건 일시적인 현상이므로 가장 직관적인  해결 방법인것 같다.
  2. 락 걸린 세션 확인하기(DBA 권한 필요)
  3. 락 잡고 있는 세션 종료(주의! 꼭 필요한 경우에만 진행한다.)

* 락 걸린 세션 확인 쿼리

SELECT
  l.session_id,
  s.serial#,
  s.username,
  s.status,
  o.object_name
FROM
  v$locked_object l
  JOIN dba_objects o ON l.object_id = o.object_id
  JOIN v$session s ON l.session_id = s.sid
WHERE
  o.object_name = 'A';

 

 

* 세션 종료

ALTER SYSTEM KILL SESSION '세션ID,시리얼번호' IMMEDIATE;

일을 하다 보면 데이터가 중복으로 들어가 있는 테이블에서 특정 조건을 만족하는 데이터만 조회하는 경우가 정말 많다. 

매번 찾아보기도 힘들고 한번 알아두면 누구보다 빠르게 쿼리를 작성할 수 있기 때문에 처음이자 마지막으로 정리를 하려고 한다. 

 

 

우선 GROUP BY는 각종 집계함수, 그룹함수와 함께 쓰이며 그룹화된 정보를 제공한다. 데이터를 그룹으로 나누어, 그룹별로 집계된 정보를 출력하고 비교할 때 GROUP BY 가 사용된다.

 

GROUP BY로 그룹화된 결과에 대해 조건을 걸 수 있는데 이것이 HAVING 절이다. 그룹별로 적용할 수 있는 함수 및 연산자를 사용할 수 있다.

 

1. HAVING에서 사용할 수 있는 연산자

연산자 설명 예제
= 특정 값과 같은 그룹 선택 HAVING COUNT(*) = 10
!=, <> 특정 값과 다른 그룹 선택 HAVING COUNT(*) <> 5
>, <, >=, <= 특정 값보다 크거나 작은 그룹 선택 HAVING SUM(salary) > 50000
IN 특정 값 목록에 포함된 그룹 선택 HAVING department IN ('HR', 'IT')
NOT IN 특정 값 목록에 포함되지 않은 그룹 선택 HAVING department NOT IN ('HR', 'IT')
BETWEEN 값의 범위를 지정 HAVING SUM(sales) BETWEEN 10000 AND 50000
LIKE 특정 패턴과 일치하는 그룹 선택 HAVING department LIKE 'Tech%'
IS NULL, IS NOT NULL 그룹 내 집계 결과가 NULL인지 확인 HAVING AVG(salary) IS NOT NULL

 

 

2. HAVING에서 사용할 수 있는 집계 함수

HAVING 절에서는 GROUP BY로 묶인 그룹에 대한 집계 함수(Aggregate Function)를 사용할 수 있다.

집계 함수 설명 예제
COUNT(*) 그룹 내 행 개수 HAVING COUNT(*) > 5
COUNT(column) 특정 컬럼 값이 NULL이 아닌 행 개수 HAVING COUNT(department) > 10
SUM(column) 그룹 내 합계 HAVING SUM(sales) > 50000
AVG(column) 그룹 내 평균 HAVING AVG(salary) >= 40000
MIN(column) 그룹 내 최소값 HAVING MIN(salary) > 20000
MAX(column) 그룹 내 최대값 HAVING MAX(salary) < 100000
STDDEV(column) 그룹 내 표준편차 HAVING STDDEV(salary) > 10000
VARIANCE(column) 그룹 내 분산 HAVING VARIANCE(salary) > 5000
MEDIAN(column) 그룹 내 중앙값 (Oracle 전용) HAVING MEDIAN(salary) > 45000

 

 

3. HAVING에서 여러 조건을 조합하는 논리 연산자

여러 조건을 AND, OR, NOT 연산자로 조합할 수 있다.

연산자 설명 예제
AND 여러 조건을 모두 만족해야 함 HAVING COUNT(*) > 5 AND SUM(sales) > 10000
OR 하나라도 만족하면 선택 HAVING COUNT(*) > 5 OR SUM(sales) > 10000
NOT 특정 조건을 제외 HAVING NOT COUNT(*) > 10

 

 

 

4. HAVING 절 예제

 

4-1. COUNT() 사용하여 특정 개수 이상인 그룹만 조회

SELECT department, COUNT(*) AS employee_count
FROM employees
GROUP BY department
HAVING COUNT(*) > 5;

 

* 각 부서별 직원 수가 5명 이상인 부서만 조회

 

 

4-2. SUM() 사용하여 총 매출이 50,000 이상인 그룹만 조회

SELECT region, SUM(sales) AS total_sales
FROM sales_data
GROUP BY region
HAVING SUM(sales) >= 50000;

 

* 각 지역(region)별 총 매출이 50,000 이상인 그룹만 조회

 

 

4-3. AVG() + MAX() 사용

SELECT department, AVG(salary) AS avg_salary, MAX(salary) AS max_salary
FROM employees
GROUP BY department
HAVING AVG(salary) > 40000 AND MAX(salary) < 100000;

* 부서별 평균 급여가 40,000 이상이고, 최대 급여가 100,000 미만인 부서만 조회

 

 

4-4. COUNT(DISTINCT column) 사용하여 중복 제거 후 개수 비교

SELECT category, COUNT(DISTINCT product_id) AS unique_products
FROM products
GROUP BY category
HAVING COUNT(DISTINCT product_id) > 10;

* 카테고리별 고유한 상품 개수가 10개 이상인 경우만 조회

 

 

4-5. HAVING + ORDER BY 함께 사용

SELECT department, COUNT(*) AS employee_count
FROM employees
GROUP BY department
HAVING COUNT(*) > 5
ORDER BY employee_count DESC;

* 직원 수가 5명 이상인 부서를 조회하면서, 직원 수가 많은 순으로 정렬

 

 

 

COUNT() 함수

 

오라클에서 COUNT() 함수는 특정 컬럼 또는 표현식의 개수를 세는 집계함수이다.

HAVING COUNT(*) 에서 괄호 안 * 에 들어갈 수  있는 요소를 정리해보자.

 

1. COUNT(*) (전체 행 개수)

SELECT department, COUNT(*) AS total_count
FROM employees
GROUP BY department
HAVING COUNT(*) > 5;

* 각 부서별 총 직원 수가 5명 이상인 부서만 조회

* NULL 값을 포함한 모든 행을 개수로 셈

 

 

2. 특정 컬럼 값 개수( COUNT(column) )

SELECT department, COUNT(salary) AS non_null_salaries
FROM employees
GROUP BY department
HAVING COUNT(salary) > 5;

* 부서별 salary가 NULL이 아닌 행의 개수를 카운트

* NULL 값은 개수에서 제외된다!!!!!!

 

 

3. DISTINCT을 사용하여 중복 제거한 개수( COUNT(DISTINCT column) )

SELECT department, COUNT(DISTINCT job_title) AS unique_jobs
FROM employees
GROUP BY department
HAVING COUNT(DISTINCT job_title) > 3;

* 부서별 중복되지 않은 직무(job_title) 개수가 3개 이상인 부서만 조회

* NULL 값은 개수에서 제외된다.

 

 

4. 특정 조건을 만족하는 값 개수 ( COUNT(CASE WHEN ...) ) 

SELECT department, 
       COUNT(CASE WHEN salary > 50000 THEN 1 END) AS high_salary_count
FROM employees
GROUP BY department
HAVING COUNT(CASE WHEN salary > 50000 THEN 1 END) > 3;

* 급여(salary)가 50,000 이상인 직원 수가 3명 이상인 부서만 조회

* 조건을 만족하지 않는 행은 개수에서 제외됨

 

 

5. 다중 도건 사용 ( COUNT(CASE WHEN ...) 여러개 ) ** 이걸 쓸 일이 많다.

SELECT department, 
       COUNT(CASE WHEN salary > 50000 THEN 1 END) AS high_salary_count,
       COUNT(CASE WHEN job_title = 'Manager' THEN 1 END) AS manager_count
FROM employees
GROUP BY department
HAVING COUNT(CASE WHEN salary > 50000 THEN 1 END) > 3
   AND COUNT(CASE WHEN job_title = 'Manager' THEN 1 END) > 2;

* 급여 50,000 이상 직원 수가 3명 이상이고, 매니저가 2명 이상인 부서만 조회

 

 

6. NULL 값 포함 여부 체크 ( COUNT(NULLIF(column, 조건)) )

SELECT department, COUNT(NULLIF(salary, 0)) AS non_zero_salaries
FROM employees
GROUP BY department
HAVING COUNT(NULLIF(salary, 0)) > 5;

* 급여가 0이 아닌 직원 수가 5명 이상인 부서만 조회

* NULLIF(salary, 0)는 salary가 0이면 NULL 로 변환하여 COUNT 에서 제외시킨다.

 

 

7. 조합 사용 (COUNT + SUM/AVG 등)

 SELECT department, COUNT(*) AS total_employees, SUM(salary) AS total_salary
FROM employees
GROUP BY department
HAVING COUNT(*) > 10 AND SUM(salary) > 500000;

* 직원 수가 10명 이상이고, 급여 총합이 500,000 이상인 부서만 조회

 

 

외울건 아니지만 이런 경우에 이런걸 쓰는구나~ 라는걸 알고 있으면 쿼리를 짤때 도움이 많이 될 것 같다.

 

24년 8월달에 개발한 소스를 운영서버에 반영후 새벽시간대 갑자기 테이블의 INACTIVE LOCK이 발생하였다. 

 

INACTIVE LOCK이 발생한 테이블은 기존에 아무런 문제가 없던 SELECT 쿼리였다. 

 

8월달에 적용된 목록은

  1. 새벽시간대 영화관련 서버의 인물 정보를 연동받아 데이터를 최신화하는 배치의 로직 주석처리 
  2. 쿼리 튜닝 2건
  3. 실시간으로 연동되던 이미지 정보를 배치작업으로 변경해 실시간 -> 배치로 변경 

이렇게 3가지 였다. 

 

원인을 찾던 중 3번 항목의 로직에서 트랜잭션 처리가 제대로 되지 않아 발생할 수 있다는 의견이 있어 트랜잭션 처리를 추가후 긴급빌드를 진행하게 되었다. 

 

빌드후 모니터링시에는 아무 문제가 없어 해결된줄 알았지만 이전과 동일한 새벽 시간대에 INACTIVE LOCK이 발생하였다. 이후 원인을 명확하게 발견하지 못해 우선 서비스는 정상적으로 진행되어야 하기 때문에 8월달 개발사항을 적용하기 전의 배포버전으로 원복을 진행하였다. 

 

8월에 적용된 목록중 1번과 2번 항목은 문제가 없을 것으로 판단하여 차주 하나씩 적용 후 모니터링을 진행하기로 하였고, 원인이라고 생각이 되는 3번에 대해 모든 로직과 쿼리등을 전수조사 하였다.

 

해당 로직을 확인해보니 과도하게 모든 쿼리가 MERGE INTO 문으로 처리되어 있다는 것을 발견하였다. 

 

오라클 MERGE

 

MERGE INTO 구문은 두 가지 작업을 결합한 구문이다.

  • UPDATE : 이미 존재하는 데이터를 업데이트 한다.
  • INSERT : 해당 조건에 맞는 데이터가 없으면 새 데이터를 삽입한다.

업데이트와 삽입을 동시에 처리할 수 있는 매력적인 구문으로 조건에 맞는 경우에는 업데이트, 조건에 맞지 않으면 삽입을 진행한다. UPDATE 와 INSERT 구문을 따로 따로 만들지 않아도 되고, 로직이 보기에 더 간결해져 해당 구문으로 INSERT 가 발생할 수 있는 배포작업 테이블을 해당 구문으로 처리하였다. 

 

하지만 MERGE INTO 구문은 업데이트와 삽입 작업이 모두 수행될 수 있기 때문에 이 작업들에 따른 락(LOCK)이 필요하다는 것을 알았다. 

 

오라클에서 MERGE 구문에 의해 발생하는 락은 주로 DML 락 (Data Manipulation Lock)이다. 

  • UPDATE 작업 : UPDATE를 수행하는 동안, 대상 행에 대해 TM락(Table Lock, Row-Level Lock)이 발생하여, 해당 행이 다른 트랜잭션에서 수정되지 않도록 보호한다.              

        * TM 락은 테이블에 대한 락이지만, 행 수준에서 발생하여 동시에 다른 행은 수정 가능하게 한다.

        * UPDATE 작업은 행 수준에서 공유 락(S) 또는 공유 - 행 - SRX 락을 발생시킬 수 있다.

 

  • INSERT 작업 : 새로운 데이터를 삽입할 때는, 새로 삽입되는 행에 대해 TX 락(Transactional Lock, Row Exclusive Lock)이 발생한다. 

        *삽입되는 동안 새롭게 생성되는 행들은 다른 트랜잭션에서 조회하거나 수정할 수 없다.

 

 

오라클의 MERGE INTO는 기본적으로 업데이트삽입 작업을 하나의 트랜잭션 안에서 처리하는 방식이므로, 다음과 같은 락 메커니즘이 발생한다.

  1. 매칭되는 행에 대한 UPDATE:
    • UPDATE 조건에 해당하는 행에 대해 Row Exclusive (RX) 락이 발생한다. 이는 해당 행이 수정 중이기 때문에 다른 트랜잭션이 해당 행에 접근하여 업데이트하거나 삭제할 수 없도록 보호한다.
    • 동시에 다른 트랜잭션에서 해당 행을 조회(SELECT)하는 것은 가능합니다. 즉, 읽기에는 영향을 주지 않으나 쓰기에는 제한이 발생한다.
  2. 매칭되지 않는 행에 대한 INSERT:
    • INSERT 구문은 새로운 행을 삽입하기 때문에 삽입되는 행에 대해 Row Exclusive (RX) 락이 발생한다.
    • 새로 삽입된 행은 트랜잭션이 커밋되기 전까지는 다른 트랜잭션에서 조회하거나 수정할 수 없다.
  3. 테이블 수준의 락(TX 락):
    • MERGE가 수행되는 테이블 자체에는 TX 락이 걸립니다. 이는 트랜잭션 단위로 데이터를 일관되게 관리하기 위한 락으로, 트랜잭션이 완료될 때까지 해당 테이블에 대한 DML 작업이 순차적으로 처리된다

 

락 경쟁과 동시성 문제

  • 경쟁 조건: MERGE INTO 구문은 업데이트와 삽입이 동시에 발생하기 때문에, 다수의 세션에서 같은 테이블에 대해 동시에 MERGE 작업을 수행할 경우 락 경쟁이 발생할 수 있다. 이는 다음과 같은 상황에서 발생할 수 있다.
    • 두 개 이상의 트랜잭션이 같은 행에 대해 업데이트하려고 시도할 때.
    • 두 개 이상의 트랜잭션이 동일한 조건에서 삽입하려고 할 때.
  • 해결 방안: 락 경쟁을 피하기 위해 행 수준에서 락을 최소화하는 방식으로 처리하거나, 가능한 비동시적으로 작업이 이루어질 수 있도록 스케줄링하는 것이 좋다.

 

 

이걸 알았다면 배포작업에 대한 로직을 MERGE INTO 구문으로 처리하지 않았을 것이다....

 

개발을 진행할때 여러가지 요소를 인지하고 시야를 넓게 가져야 겠다. 또 내가 개발하려는 방향이 맞는지 한번 더 확인해봐야겠다. 

 

 

얼마전 배치를 추가하여 운영서버에 반영하였는데 위와 같은 에러가 뜨면서 쿼리들이 Lock에 걸리기 시작했다. Lock이 걸린 세션을 Kill 한 후 killed session의 connection을 정리 하려고 하였으나 lock에 걸려 서비스에 문제가 생겨 서버를 재기동하는 상황까지 발생하였다. 원인 분석이 명확하게 되지 않아 여러가지 방법을 시도하여 보았다.

 

  1. 배치주기 변경 : 기존 5초마다 반복실행하던 배치를 6분마다 작동으로 배치주기를 변경하였다.
  2. Lock이 걸린 쿼리를 사용하는 배치 인덱스 생성 및 쿼리 수정 : Lock 이 걸린 쿼리를 포함하고 있는 배치내에 모든 쿼리들을 전수조사하여 실행속도가 늦거나 인덱스를 타지 않고 Full Scan을 타는 쿼리들을 수정하였다.

위 두가지 개선책을 운영에 반영하여 봤지만 여전히 배치 처리량이 많은 새벽 3시에 쿼리가 Lock이 걸렸다. jboss JDBC 내의 pool size 가 작게 잡혀있어 해당 에러가 발생하였나 알아보았으나 아니였다.

 

이렇게 갈피를 잡지 못하던 와중 로직을 확인해보니 트랜잭션 처리가 되어있지 않은 것이 확인되었다. 배포대상을 조회 후 UPDATE 쿼리 실행 후 트랜잭션 반환이 제대로 이루어지지 않은 것이다.

 

해결책은 

  1. 필요한 곳에 트랜잭션을 선언하여 사용
  2. 함수 자체에 트랜잭션을 선언

 

// 필요한 곳에 트랜잭션을 생성하여 사용
public class A {

	@Autowired
    private DataSourceTransactionManager txManager;
    
    public void B {
    	TransactionStatus transactionStatus = CommonUtils.getTransactionStatus(txManager);
        
        // select - update 로직 
        
        txManager.commit(transactionStatus); // commit 처리
    }

}

 

 

// 함수 자체에 트랜잭션 처리
public class A {
    
    @Transactional
    public void B {
    	
    }

}

 

위와 같이 2가지 방법중 택1을 하여 문제를 해결할 수 있는 것으로 확인되었고, 이후 운영에 반영 후 결과는 지켜봐야 할 것 같다.

데이터를 찾는 두 가지 방법

어떤 초등학교를 방문해 '홍길동' 학생을 찾는 방법은 두 가지다. 첫째는, 1학년 1반부터 6학년 맨 마지막 반까지 모든 교실을 돌며 홍길동 학생을 찾는 것이다. 둘째는, 교무실에서 학생 명부를 조회해 홍길동 학생이 있는 교실만 찾아가는 것이다. 둘 중 어느 쪽이 빠를까? 홍길동 학생이 많다면 전자가 빠르고, 몇 안되면 후자가 빠르다.

 

데이터베이스 테이블에서 데이터를 찾는 방법도 아래 두 가지다. 수십 년에 걸쳐 DBMS가 발전해 왔는데도 이 두 방법에서 크게 벗어나지 못하고 있다.

  • 테이블 전체를 스캔한다.
  • 인덱스를 이용한다.

 

 

인덱스 튜닝의 두 가지 핵심요소

인덱스는 큰 테이블에서 소량 데이터를 검색할 때 사용한다. 온라인 트랜잭션 처리 시스템에서는 소량 데이터를 주로 검색하므로 인덱스 튜닝이 무엇보다 중요하다. 

세부적인 인덱스 튜닝 방법으로 여러 가지가 있지만, 핵심요소는 크게 두 가지로 나뉜다. 첫번째는 인덱스 스캔 과정에서 발생하는 비효율을 줄이는 것이다. 즉 '인덱스 스캔 효율화 튜닝'이다.

 

예를 들어, 학생명부에서 키가 170cm ~ 173cm인 홍길동 학생을 찾는 경우로 예를 들어보자. 학생명부를 이름과 키순으로 정렬해 두었다면, 소량만 스캔하면 된다.

이름 학년-반-번호
강수지 171 4학년 3반 37번
김철수 180 3학년 2반 13번
...    
이영희 172 6학년 4반 19번
...    
홍길동 168 2학년 6반 24번
홍길동 170 5학년 1반 16번
홍길동 173 1학년 5반 15번
....    

 

반면, 학생명부를 시력과 이름순으로 정렬해 두었다면, 똑같이 두 명을 찾는데도 많은 양을 스캔해야 한다.

 

시력 이름 학년-반-번호
168 홍길동  
....    
170 홍길동  
171 강수지  
172 이영희  
173 홍길동  
...    
180 김철수  

 

인덱스 튜닝의 두 번째 핵심요소는 테이블 액세스 횟수를 줄이는 것이다. 인덱스 스캔 후 테이블 레코드를 액세스할 때 랜덤 I/O 방식을 사용하므로 이를 '랜덤 액세스 최소화 튜닝'이라고 한다.

 

인덱스 스캔 효율화 튜닝과 랜덤 액세스 최소화 튜닝 둘 다  중요하지만, 더 중요한 하나를 고른다면 랜덤 액세스 최소화 튜닝이다. 성능에 미치는 영향이 크기 때문이다. SQL 튜닝은 랜덤 I/O와의 전쟁이다.

 

 

 

인덱스 구조

인덱스는 대용량 테이블에서 필요한 데이터만 빠르게 효율적으로 액세스하기 위해 사용하는 오브젝트다. 모든 책 뒤쪽에 있는 색인과 같은 역할을 한다. 데이터베스에서 인덱스 없이 데이터를 검색하려면, 테이블을 처음부터 끝까지 모두 읽어야 한다. 반면, 인덱스를 이용하면 일부만 읽고 멈출 수 있다. 즉, 범위 스캔(Range Scan)이 가능하다. 범위 스캔이 가능한 이유는 인덱스가 정렬돼 있기 때문이다. 

DBMS는 일반적으로 B*Tree 인덱스를 사용한다. 나무(Tree)를 거꾸로 뒤집은 모양이여서 뿌리(Root)가 위쪽에 있고, 가지(Branch)를 거쳐 맨 아래에 잎사귀(Leaf)가 있다.

 

인덱스 구조

 

루트와 브랜치 블록에 있는 각 레코드는 하위 블록에 대한 주소값을 갖는다. 키값은 하위 블록에 저장된 키값의 범위를 나타낸다. 

  • ROWID = 데이터 블록 주소 + 로우 번호
  • 데이터 블록 주소 = 데이터 파일 번호 + 블록 번호
  • 블록 번호 : 데이터파일 내에서 부여한 상대적  순번
  • 로우 번호 : 블록 내 순번 

인덱스 탐색 과정은 수직적 탐색과 수평적 탐색으로 나눌 수 있다.

  • 수직적 탐색 : 인덱스 스캔 시작지점을 찾는 과정
  • 수평적 탐색 : 데이터를 찾는 과정

 

 

인덱스 수직적 탐색 

정렬된 인덱스 레코드 중 조건을 만족하는 첫 번째 레코드를 찾는 과정이다. 즉, 인덱스 스캔 시작지점을 찾는 과정이다.

인덱스 수직적 탐색은 루트(Root) 블록에서부터 시작한다. 루트를 포함해 브랜치(Branch) 블록에 저장된 각 인덱스 레코드는 하위 블록에 대한 주소값을 갖는다. 루트에서 시작해 리프(Leaf) 블록까지 수직적 탐색이 가능한 이유다.

수직적 탐색 과정에 찾고자 하는 값보다 크거나 같은 값을 만나면, 바로 직전 레코드가 가리키는 하위 블록으로 이동한다.

 

수직적 탐색은 '조건을 만족하는 레코드'를 찾는 과정이 아니라 '조건을 만족하는 첫 번째 레코드'를 찾는 과정임을 반드시 기억해야 한다.

 

 

인덱스 수평적 탐색

수직적 탐색을 통해 스캔 시작점을 찾았으면, 찾고자 하는 데이터가 더 안 나타날 때까지 인덱스 리프 블록을 수평적으로 스캔한다. 인덱스에서 본격적으로 데이터를 찾는 과정이다.

인덱스 리프 블록끼리는 서로 앞뒤 블록에 대한 주소값을 갖는다. 즉, 양방향 연결 리스트(double linked list) 구조다. 좌에서 우로, 또는 우에서 좌로 수평적 탐색이 가능한 이유다. 

인덱스를 수평적으로 탐색하는 이유는 첫째, 조건절을 만족하는 데이터를 모두 찾기 위해서고 둘째, ROWID를 얻기 위해서다. 

 

'Back-End > DB' 카테고리의 다른 글

오라클 MERGE INTO 구문과 INACTIVE  (1) 2024.10.18
DB Connection is not associated  (1) 2024.09.10
캐시 탐색 메커니즘  (2) 2024.07.30
데이터 저장 구조 및 I/O 메커니즘  (1) 2024.07.29
SQL 공유 및 재사용  (0) 2024.07.26

Direct Path I/O를 제외한 모든 블록 I/O는 메모리 버퍼캐시를 경유한다. 

  • 인덱스 루트 블록을 읽을 때
  • 인덱스 루트 블록에서 얻은 주소 정보로 브랜치 블록을 읽을 때
  • 인덱스 브랜치 블록에서 얻은 주소 정보로 리프 블록을 읽을 때
  • 인덱스 리프 블록에서 얻은 주소 정보로 테이블 블록을 읽을 때
  • 테이블 블록을 Full Scan 할 때

버퍼캐시 해시 구조

 

DBMS는 위와 같이 버퍼캐시를 해시 구조로 관리한다.

예를 들어, 버퍼캐시에서 20번 블록을 찾고자 하다고 가정해보자. 블록 번호를 5로 나누면 나머지가 0이다. 이 블록이 캐싱돼 있다면 버퍼 헤더가 첫 번째 해시 체인에 연결돼 있을 것이다. 이 블록이 캐싱돼 있다면 버퍼 헤더가 첫 번째 해시 체인에 연결돼 있을 것이므로 찾을 때 항상 첫 번째 해시 체인만 탐색하면 된다.

 

버퍼캐시에서 블록을 찾을 때 이처럼 해시 알고리즘으로 버퍼 헤더를 찾고, 거기서 얻은 포인터(Pointer)로 버퍼 블록을 액세스하는 방식을 사용한다.

  • 같은 입력 값은 항상 동일한 해시 체인(=버킷)에 연결됨
  • 다른 입력 값이 동일한 해시 체인에 연결될 수 있음
  • 해시 체인 내에서는 정렬이 보장되지 않음

버퍼캐시는 SGA 구성요소이므로 버퍼캐시에 캐싱된 버퍼블록은 모두 공유자원이다. 공유자원은 말 그대로 모두에게 권한이 있기 때문에 누구나 접근할 수 있다.

두 개 이상의 프로세스가 동시에 접근하려고 할 때는 문제가 발생한다. 블록 정합성에 문제가 생길 수 있기 때문이다. 따라서 내부에서는 한 프로세스씩 순차적으로 접근하도록 구현해야 하며, 이를 위해 직렬화(serialization) 메커니즘이 필요하다. 

'Back-End > DB' 카테고리의 다른 글

DB Connection is not associated  (1) 2024.09.10
인덱스 구조 및 탐색  (0) 2024.08.05
데이터 저장 구조 및 I/O 메커니즘  (1) 2024.07.29
SQL 공유 및 재사용  (0) 2024.07.26
SQL 파싱과 최적화  (1) 2024.07.25

SQL이 느린 이유

SQL이 느린 이유는 대부분 I/O 때문이다. 구체적으로 디스크 I/O 때문이다.

 

그렇다면, I/O란 무엇일까?

 

'I/O = 잠(SLEEP)'이라고 생각하면 쉽다. OS 또는 I/O 서브시스템이 I/O를 처리하는 동안 프로세스는 잠을 자기 때문이다. 프로세스가 일하지 않고 잠을 자는 이유는 여러가지가 있지만, I/O가 가장 대표적이고 절대 비중을 차지한다. 

 

프로세스(Process)는 '실행 중인 프로그램'이며, 생명주기를 갖는다. 즉, 생성(new) 이후 종료(terminated) 전까지 준비(ready)와 실행(running)과 대기(waiting) 상태를 반복한다. 실행 중인 프로세스는 interrupt에 의해 수시로 실행 준비 상태(Runnable Queue)로 전환했다가 다시 실행 상태로 전환한다. 여러 프로세스가 하나의 CPU를 공유할 수 있지만, 특정 순간에는 하나의 프로세스만 CPU를 사용할 수 있기 때문에 이런 메커니즘이 필요하다. 

프로세스 생명주기

 

interrupt 없이 열심히 일하던 프로세스도 디스크에서 데이터를 읽어야 할 땐 CPU를 OS에 반환하고 잠시 수면(waiting) 상태에서 I/O가 완료되기를 기다린다. 정해진 OS 함수를 호출(I/O Call)하고 CPU를 반환한 채 알람을 설정하고 대기 큐(Wait Queue)에서 잠을 자는 것이다. 열심히 일해야 할 프로세스가 한가하게 잠을 자고 있으니 I/O가 많으면 성능이 느릴 수 밖에 없다. 

 

 

 

데이터베이스 저장 구조

데이터를 저장하려면 먼저 테이블스페이스를 생성해야 한다. 테이블스페이스는 세그먼트를 담는 콘테이너로서, 여러 개의 데이터파일(디스크 상의 물리적인 OS 파일)로 구성된다.

 

테이블스페이스

 

테이블스페이스를 생성했으면 위와 같이 세그먼트를 생성한다. 세그먼트는 테이블, 인덱스처럼 데이터 저장공간이 필요한 오브젝트다. 테이블, 인덱스를 생성할 때 데이터를 어떤 테이블스페이스에 저장할지를 지정한다.

 

세그먼트는 여러 익스텐트로 구성된다. 파티션 구조가 아니라면 테이블도 하나의 세그먼트고, 인덱스도 하나의 세그먼트다. 테이블 또는 인덱스가 파티션 구조라면, 각 파티션이 하나의 세그먼트가 된다. LOB 컬럼은 그 자체가 하나의 세그먼트를 구성하므로 자신이 속한 테이블과 다른 별도 공간에 값을 저장한다.

 

익스텐트공간을 확장하는 단위이다. 테이블이나 인덱스에 데이터를 입력하다가 공간이 부족해지면 해당 오브젝트가 속한 테이블스페이스로부터 익스텐트를 추가로 할당받는다. 익스텐트는 연속된 블록들의 집합이기도 하다. 

익스텐트 단위로 공간을 확장하지만, 사용자가 입력한 레코드를 실제로 저장하는 공간은 데이터 블록이다. 한 블록은 하나의 테이블만 독점한다. 즉, 한 블록에 저장된 레코드는 모두 같은 테이블 레코드다.

 

세그먼트 공간이 부족해지면 테이블스페이스로부터 익스텐트를 추가로 할당받는다고 했는데, 세그먼트에 할당된 모든 익스텐트가 같은 데이터파일에 위치하지 않을 수 있다. 

 

테이블스페이스 익스텐트

 

익스텐트  내 블록은 서로 연속된 공간이지만, 익스텐트끼리는 연속된 공간이 아니라는 사실을 위의 그림을 통해 알 수 있다. 

-- 오라클에서 세그먼트에 할당된 익스텐트 목록 조회 방법
SQL >
select segment_type, tablespace_name, extent_id, file_id, block_id, blocks
from dba_extents
and segment_name = 'MY_SEGMENT'
order by extent_id;

 

더보기

DBA(Data Block Address)

모든 데이터 블록은 디스크 상에서 몇 번 데이터파일의 몇 번째 블록인지를 나타내는 자신만의 고유 주소값을 갖는다. 이 주소값을'DBA(Data Block Address)'라고 부른다. 데이터를 읽고 쓰는 단위가 블록이므로 데이터를 읽으려면 먼저 DBA부터 확인해야 한다.

인덱스를 이용해 테이블 레코드를 읽을 때는 인덱스 ROWID를 이용해야한다. ROWID는 DBA + 로우 번호(블록 내 순번)로 구성되므로 이를 분해하면 읽어야 할 테이블 레코드가 저장된 DBA를 알 수 있다. 

테이블을 스캔할 때는 테이블 세그먼트 헤더에 저장된 익스텐트 맵을 이용한다. 익스텐트 맵을 통해 각 익스텐트의 첫 번째 블록 DBA를 알 수 있다. 

 

블록, 익스텐트, 세그먼트, 테이블스페이스, 데이터파일을 정의하면 다음과 같다.

  • 블록 : 데이터를 읽고 쓰는 단위
  • 익스텐트 : 공간을 확장하는 단위, 연속된 블록 집합
  • 세그먼트 : 데이터 저장공간이 필요한 오브젝트(테이블, 인덱스, 파티, LOB 등)
  • 테이블스페이스 : 세그먼트를 담는 컨테이너
  • 데이터파일 : 디스크 상의 물리적인 OS 파일

테이블스페이스 ERD

 

 

블록 단위 I/O

데이터 I/O 단위가 블록이므로 특정 레코드 하나를 읽고 싶어도 해당 블록을 통째로 읽는다. 심지어 1Byte짜리 컬럼 하나만 읽고 싶어도 블록을 통째로 읽는다. 오라클은 기본적으로 8KB 크기의 블록을 사용하므로 1Byte를 읽기 위해 8KB를 읽는 셈이다. 

-- 오라클 데이터베이스의 블록 사이즈 확인 방법.
SQL > show parameter block_size

 

테이블뿐만 아니라 인덱스도 블록 단위로 데이터를 읽고 쓴다. 

 

 

 

시퀀셜 액세스 vs 랜덤 액세스

테이블 또는 인덱스 블록을 액세스하는(=읽는) 방식으로는 시퀀셜 엑세스와 랜덤 액세스, 두 가지가 있다.

 

첫째, 시퀀셜(Sequential) 액세스는 논리적 또는 물리적으로 연결된 순서에 따라 차례대로 블록을 읽는 방식이다. 인덱스 리프 블록은 앞뒤를 가리키는 주소값을 통해 논리적으로 서로 연결돼 있다. 이 주소 값에 따라 앞 또는 뒤로 순차적으로 스캔하는 방식이 시퀀셜 액세스다.

 

테이블 블록 간에는 서로 논리적인 연결고리를 가지고 있지 않다. 그럼, 테이블은 어떻게 시퀀셜 방식으로 엑세스할까?

 

오라클은 세그먼트에 할당된 익스텐트 목록을 세그먼트 헤더에 맵(map)으로 관리한다. 익스텐트 맵은 각 익스텐트의 첫 번째 블록 주소 값을 갖는다. 

읽어야 할 익스텐트 목록을 익스텐트 맵에서 얻고, 각 익스텐트의 첫 번째 블록 뒤에 연속해서 저장된 블록은 순서대로 읽으면, 그것이 곧 Full Table Scan이다. 

 

둘때, 랜덤(Random) 액세스는 논리적, 물리적인 순서를 따르지 않고, 레코드 하나를 읽기 위해 한 블록씩 접근(=touch)하는 방식이다. 

 

 

 

논리적 I/O vs 물리적 I/O

DB버퍼캐시

디스크 I/O가 SQL 성능을 결정한다. SQL을 수행하는 과정에 계속해서 데이터 블록을 읽는데, 자주 읽는 블록을 매번 디스크에서 읽는 것은 매우 비효율적이다. 모든 DBMS에 데이터 캐싱 메커니즘이 필수인 이유다.

SGA

 

데이터를 캐싱하는 'DB버퍼캐시'도 SGA의 가장 중요한 구성요소 중 하나다. 라이브러리 캐시가 SQL과 실행계획, DB 저장형 함수/프로시저 등을 캐싱하는 '코드 캐시'라고 한다면, DB버퍼캐시는 '데이터 캐시' 라고 할 수 있다.

 

디스크에서 어렵게 읽은 데이터 블록을 캐싱해 둠으로써 같은 블록에 대한 반복적인 I/O Call을 줄이는 데 목적이 있다.

DB Buffer Cache

 

위 그림처럼 서버 프로세스와 데이터파일 사이에 버퍼캐시가 있으므로 데이터 블록을 읽을 땐 항상 버퍼캐시부터 탐색한다. 운 좋게 캐시에서 블록을 찾는다면 바쁜 시간에 프로레스가 잠(I/O Call)을 자지 않아도 된다. 버퍼캐시는 공유메모리 영역이므로 같은 블록을 읽는 다른 프로세스도 이득을 본다.

-- 오라클 SQL*Plus에서 버퍼캐시 확인.
SQL > show spa

 

 

 

논리적 I/O vs 물리적 I/O

논리적 블록 I/O는 SQL을 처리하는 과정에 발생한 총 블록 I/O를 말한다. 

 

위 그림의 좌측처럼 메모리상의 버퍼 캐시를 경유하므로 메모리 I/O가 곧 논리적 I/O라고 생각해도 무방하다.

 

물리적 블록 I/O는 디스크에서 발생한 총 블록 I/O를 말한다. SQL 처리 도중 읽어야 할 블록을 버퍼캐시에서 찾지 못할 때만 디스크를 액세스하므로 논리적 블록 I/O 중 일부를 물리적으로 I/O 한다.

 

메모리 I/O는 전기적 신호인 데 반해, 디스크 I/O는 액세스 암을 통해 물리적 작용이 일어나므로 메모리 I/O에 비해 상당히 느리다. 보통 10,000배쯤 느리다.

 

데이터베이스 세계에서 논리적 일량과 물리적 일량을 정의해 보자. SQL을 수행하려면 데이터가 담긴 블록을 읽어야 한다. SQL이 참조하는 테이블에 데이터를 입력하거나 삭제하지 않는 상황에서 조건절에 같은 변수 값을 입력하면, 아무리 여러 번 실행해도 매번 읽는 블록 수는 같다. SQL을 수행하면서 읽은 총 블록 I/O가 논리적 I/O다.

 

DB 버퍼캐시에서 블록을 찾지 못해 디스크에서 읽은 블록 I/O가 물리적 I/O다. 데이터 입력이나 삭제가 없어도 물리적 I/O는 SQL을 실행할 때마다 다르다. 연속해서 실행하면 DB 버퍼캐시에서 해당 테이블 블록의 점유율이 점점 높아지기 때문이다. 

 

 

버퍼캐시 히트율

 

 

Single Block I/O vs Multiblock I/O

메모리 캐시가 클수록 좋지만, 데이터를 모두 캐시에 적재할 수는 없다. 비용적인 한계, 기술적인 한계 때문에 전체 데이터 중 일부만 캐시에 적재해서 읽을 수 있다.

캐시에서 찾지 못한 데이터 블록은 I/O Call을 통해 디스크에서 DB 버퍼캐시로 적재하고서 읽는다. I/O Call을 할 때, 한 번에 한 블록씩 요청하기도 하고, 여러 블록씩 요청하기도 한다. 

한 번에 한 블록씩 요청해서 메모리에 적재하는 방식을 'Single Block I/O'라고 한다. 많은 벽돌을 실어 나를 때 손수레를 이용하는 것처럼 한 번에 여러 블록씩 요청해서 메모리에 적재하는 방식을 'Multiblock I/O'라고 한다.

 

인덱스를 이용할 때는 기본적으로 인덱스와 테이블 블록 모두 Single Block I/O 방식을 사용한다. 

  • 인덱스 루트 블록을 읽을 때
  • 인덱스 루트 블록에서 얻은 주소 정보로 브랜치 블록을 읽을 때
  • 인덱스 브랜치 블록에서 얻은 주소 정보로 리프 블록을 읽을 때
  • 인덱스 리프 블록에서 얻은 주소 정보로 테이블 블록을 읽을 때

반대로, 많은 데이터 블록을 읽을 때는 Multiblock I/O 방식이 효율적이다. 그래서 인덱스를 이용하지 않고 테이블 전체를 스캔할 때 이 방식을 사용한다. 테이블이 클수록 Multiblock I/O 단위도 크면 좋다. 프로세스가 잠자는 횟수를 줄여주는 데 이유가 있다.

 

 

Table Full Scan vs Index Range Scan

테이블에 저장된 데이터를 읽는 방식은 두 가지다.

Table Full Scan은 말 그대로 테이블에 속한 블록 '전체'를 읽어서 사용자가 원하는 데이터를 찾는 방식이다. 인덱스를 이용한 테이블 액세스는 인덱스에서 '일정량'을 스캔하면서 얻은 ROWID로 테이블 레코드를 찾아가는 방식이다. ROWID는 테이블 레코드가 디스크 상에 어디 저장됐는지를 가리키는 위치 정보다.

 

인덱스를 이용하는데 성능이 느린 경우는 왜그럴까?

 

시퀀셜 액세스와 랜덤 액세스, Single Block I/O와 Multiblock I/O 등등 I/O 메커니즘 관점에서 Table Full Scan과 Index Range Scan의 본질을 알아보자. 

 

Table Full Scan은 시퀀셜 액세스와 Multiblock I/O 방식으로 디스크 블록을 읽는다. 한 블록에 속한 모든 레코드를 한 번에 읽어 들이고, 캐시에서 못 찾으면 '한 번의 수면(I/O Call)을 통해 인접한 수십~수백 개 블록을 한꺼번에 I/O하는 메커니즘'이다. 이 방식을 사용하는 SQL은 스토리지 스캔 성능이 좋아지는 만큼 성능도 빨라진다.

 

큰 테이블에서 소량 데이터를 검색할때는 반드시 인덱스를 이용해야 한다.

Index Range Scan을 통한 테이블 액세스는 랜덤 액세스와 Single Block I/O 방식으로 디스크 블록을 읽는다. 캐시에서 블록을 못 찾으면, '레코드를 찾기 위해 매번 잠을 자는 I/O 메커니즘'이다. 따라서 많은 데이터를 읽을 때는 Table Full Scan보다 불리하다. 읽을 데이터가 일정량을 넘으면 인덱스보다 Table Full Scan이 유리하다.

'Back-End > DB' 카테고리의 다른 글

인덱스 구조 및 탐색  (0) 2024.08.05
캐시 탐색 메커니즘  (2) 2024.07.30
SQL 공유 및 재사용  (0) 2024.07.26
SQL 파싱과 최적화  (1) 2024.07.25
Lock 과 트랜잭션 동시성 제어 (Oracle)  (3) 2024.07.23

+ Recent posts