DB 성능은 연동하는 모든 서버 성능에 영향을 준다. DB full scan 같이 테이블의 모든 데이터를 순차적으로 읽으면 쿼리 실행 시간이 길어지고 이는 전체 서비스가 느려지는 문제로까지 이어질 수 있다. 따라서 DB에 대해 잘 파악한 후 사용하면 DB로 인한 성능 문제를 해결할 수 있을 것이다.

 

1. 인트로

DB 테이블을 설계할 때는 조회 기능과 트래픽 규모를 고려해야 하고, 그렇지 않을 경우 심각한 성능 문제로 이어질 수 있다.

 

DB full scan

일단 DB full scan의 특징은 다음과 같다. 

  • 테이블의 모든 데이터를 순차적으로 읽는 것
  • where 절에 있는 조건에 대응하는 인덱스가 없을 때 발생한다
  • 발생 시 응답시간이 기하급수적으로 증가할 가능성이 높다
  • 쿼리에서 like 조건이 풀 스캔을 유발한다

 

만약 데이터 추가 속도가 그리 크지 않고, 트래픽이 많지 않은 서비스의 경우 별도의 성능 최적화를 하지 않고 full scan으로 하더라도 큰 성능 저하가 발생하지 않는다. 반대로 데이터 추가 속도가 빠르고(ex. 인기 커뮤니티 게시글), 트래픽이 많은 경우 full scan으로 데이터를 가져오게되면 큰 성능 저하 나아가 DB CPU 100% 사용으로 DB가 제 기능을 못할 수 있다.

 

위처럼 트래픽이 큰 상황에 풀 스캔으로 인한 성능 저하를 방지하기 위해선 조회 패턴을 기준으로 인덱스를 설계해야 한다.

 

검색 기능 향상할 수 있는 다른 방법으로는 1) elastic search 같은 검색엔진 사용, 2) DB의 전문(full text) 검색 기능(Oracle Text, MYSQL FULL TEXT) 사용이 있다.

 

2. 단일 인덱스와 복합 인덱스

쿼리1

select *
  from activityLog 
where usrId = 123
  and activityDate = '2024-07-31'
order by activityDateTime desc

위 쿼리를 실행하고, 성능 문제가 없으려면 where절 조건인 userId를 포함한 인덱스가 필요하다. 여기서 고려해야할 점은 activityDate를 포함하느냐 하지 않느냐이다. 이 기준은 사용자당 가질 수 있는 데이터가 얼마나 될지 가늠해보았을 때 정할 수 있다.

 

  • 단일 인덱스
    • userId만 인덱스로 사용
    • 연간 몇 백건 처럼 사용자가 비교적 적을 때 사용하기 적합
  • 복합 인덱스
    • (userId, activityDate)를 인덱스로 사용
    • 연간 만 건 이상 처럼 사용자 데이터가 비교적 클 때 사용하기 적합

 

쿼리2

select activityDate
      ,activityType
      ,count(activityType)
  from activityLog
where activityDate = '2024-07-28'
group by activityType
;

위 쿼리를 실행할 때 activityDate를 인덱스로 사용해 데이터 풀 스캔을 방지한다. 이때 group by 절 컬럼인 activityType을 인덱스에 포함할지 안 할지는 쿼리 실행 빈도와 실행 시간을 검토해서 그 포함 여부를 결정할 수 있다.

 

위 쿼리에서 (activityDate, activityType)의 복합 인덱스를 커버링 인덱스라고도 한다.

커버링 인덱스 : 쿼리(SELECT, WHERE, ORDER BY, GROUP BY)에 필요한 모든 컬럼을 인덱스가 포함하고 있어, 실제 테이블 데이터 블록에 접근(Random I/O)하지 않고 인덱스만 스캔하여 결과를 반환하는 최적화 기법입니다. 디스크 I/O를 크게 줄여 검색 성능을 극적으로 향상시킵니다.

 

3. 선택도(Selectivity)

선택도는 인덱스에서 특정 칼럼의 고유한 값 비율을 나타낸다. 인덱스를 생성할 때는 선택도가 높은 칼럼을 골라야 한다.

 

선택도가 높을 수록, 해당 칼럼에 고유한 값이 많고, 인덱스를 이용한 조회효율이 증가한다.

선택도가 낮을 수록, 해당 칼럼에 고유한 값이 적다

 

하지만 항상 선택도가 높은 컬럼만 인덱스에 포함되는 것은 아니다.

선택도가 낮아도 반복 실행되는 쿼리에서 조건으로 쓰이는 컬럼이라면 인덱스를 걸어 실행 속도를 조금이라도 올리는 게 낫다.

 

4. 커버링 인덱스

커버링 인덱스는 특정 쿼리를 실행하는 데 필요한 칼럼을 모두 포함하는 인덱스를 말한다.

커버링 인덱스를 사용하면 쿼리 실행 효율을 높일 수 있다.

 

쿼리1

-- (activityDate, activityType) 인덱스가 있는 경우
-- 인덱스를 사용해 읽을 데이터를 빠르게 선택할 수 있지만
-- 데이터 선택 후 컬럼값을 조회하기 위해 실제 데이터를 읽기는 해야 한다.

select *
  from activityLog
where activityDate = '2024-07-31'
  and activityType = 'VISIT'
;

 

쿼리2

-- (activityDate, activityType) 인덱스의 각 컬럼값만 조회하므로
-- 별도로 실제 데이터를 읽어올 필요 없어 쿼리 실행 시간이 빨라진다.

select activityDate
      ,activityType
   from activityLog
where activityDate = '2024-07-31'
  and activityType = 'VISIT'
;

 

 

5. 인덱스는 필요한 만큼만 만들기

인덱스를 매번 새로 생성한다고해서 무조건 성능에 좋은 영향만을 미치는 것은 아니다.

인덱스가 효과를 발휘하려면 하루에 만들어내는 데이터 개수가 조회 성능에 영향을 줄 만큼 많아야 한다. 오히려 효과가 적은 인덱스를 추가하면 성능이 나빠질 수 있다.

 

인덱스는 조회 속도는 빠르게 해주지만 단점도 있는데,

1) 데이터 추가/변경/삭제 시에는 인덱스 관리에 따른 비용(시간)이 추가되고

2) 인덱스 자체도 데이터이기 때문에 인덱스가 많아질수록 메모리와 디스크 사용량도 함께 증가한다.

 

 

인덱스가 아니어도 조회 성능을 개선할 방법이 있다.

 

4. 미리 집계하기

예시로 설문에 좋아요 기능이 있고, 이 설문 조사 목록 조회 시 답변 수와 좋아요 수를 표시하는 기능이 있다고 하자. 그 쿼리는 다음과 같다.

select s.id
      ,s.subject
      ,(select count(*) from answer a where a.surveyId = s.id) as answerCnt
      ,(select count(*) from liked l where l.surveyId = s.id) as likeCnt
  from survey s
order by id desc
limit 30
;

 

데이터나 사용자가 많지 않은 상황이라면 서브쿼리로 조회해도 성능 문제가 없지만 사용자가 많고, 설문과 좋아요 개수가 많다면 쿼리 실행 시간이 증가할 것이고 트래픽이 증가한다면 상황은 더 악화될 수 있다.

 

Count나 sum 같은 집계 쿼리를 조회 시점에 실행하면서 발생하는 성능 문제를 제거하는 법은 간단한데, 집계 데이터를 미리 계산해서 별도 컬럼에 저장하면 된다.

 

  • 구현 방법
    • survey 테이블에 답변자 수(answerCnt), 좋아요 수(likedCnt)를 저장할 컬럼을 추가한다
    • answer 테이블에 답변 추가 시, survey 테이블의 answerCnt + 1로 update
    • 좋아요 클릭 시 survey 테이블의 likedCnt + 1, 좋아요 취소시 survey 테이블의 likedCnt - 1 로 update
-- survey 테이블의 answerCnt + 1
update survey set answerCnt = answerCnt + 1
where surveyId = 아이디;


-- 좋아요 취소, liked 데이터 삭제, survey 테이블 likedCnt - 1
delete from liked where answerId = 설문ID and memberId = 회원ID;
update survey set likedCnt = likedCnt - 1 where surveyId = 설문ID;

 

 

이제 설문 조사 목록을 조회하는 쿼리는 이제 count(답변자), count(좋아요)의 서브 쿼리가 필요하지 않다. 서브 쿼리 시간만큼 쿼리 실행 시간이 줄어들어 조회 속도가 빨라진다.

select s.id
      ,s.subject
      ,answerCnt
      ,likeCnt
  from survey s
order by id desc
limit 30
;

 

집계 컬럼의 한계점

집계용 컬럼을 survey 테이블에 추가해 쿼리 실행 속도가 빨라진 것은 사실이지만, 이는 테이블의 정규화 trade-off한 것이다. 

정규화는 RDB의 설계에서 데이터 중복을 줄이고 데이터 무결성을 위해 정규형에 맞도록 구조화하는 프로세스이다. 

 

이 정의에 따르면 survey 테이블에 answerCnt 컬럼을 추가하는 것은 데이터를 중복하는 것이고, count(*) 값과 answerCnt의 값이 불일치하는 무결성이 깨지는 문제가 발생할 수도 있다.

 

하지만 설문 예제에서는 약간의 불일치를 감수하더라도 조회 속도 개선을 위해 실시간 집계용 컬럼을 추가하는 것이 맞을 수도 있다. answer 테이블의 데이터 개수가 10,150인데 answerCnt 컬럼의 값이 10,149라고 해도 심각한 문제는 아닐 수 있기 때문이다.

 

게다가 정확한 값은 언제든지 구할 수 있다. 관리툴에서 최종 보고서를 생성할 때는 집계 컬럼을 사용하는 것이 아닌 서브쿼리를 사용하면 되기 때문이다.

 

5. 페이징

게시글 데이터가 10만개 있고, 화면에는 한 번에 10개의 게시글만 보여준다고하면 페이지가 넘어갈 때마다 그에 해당하는 게시글을 offset으로 select 해와야한다. 여기서 문제는 DB가 다음에 불러올 id의 값을 알지 못한다는 것이다.

 

select id
      ,subject
      ,writer
      ,regdt
  from article
order by id desc
limit 10 offset 99990
;

 

위 쿼리를 실행할 때 99,991번째 id부터 바로 조회하면 좋겠지만, DB는 어떤 id 값이 99,9991 번째인지 알지 못한다.

그래서 DB 역순 정렬 -> 99,990개 셈 -> 10개 데이터 조회 의 과정을 거쳐 위 쿼리의 결과값을 가져온다.

 

 

1) 특정 ID 기준으로 조회

지정한 오프셋으로 이동하기 위해 데이터를 세는 시간을 줄이는 방법은 특정 ID를 기준으로 조회하는 것이다.

select *
  from article
where deleted = false
order by id desc
limit 10
;

 

위 쿼리를 첫번째 실행 후 마지막 데이터의 id가 9985일 경우, 다음 10개 데이터를 읽을 때는 앞서 읽어온 마지막 id를 사용해서 조회하면 된다. 해당 쿼리는 다음과 같다. 오프셋을 사용했을 때는 지정한 오프셋만큼 데이터를 세는 시간이 필요한데, 아래 쿼리는 이 과정이 생략되어 실행 시간이 빨라진다.

select *
  from article
where id < 9985 -- 직전 조회시 마지막 id
  and deleted = false
order by id desc
limit 10 
;

 

 

2) 예시 - 프론트엔드에서 다음에 읽어올 데이터가 존재하는지 알려주기

이때는 데이터를 1개만 더 읽어서 판단하면 되는데

  • 11개의 데이터를 조회하고
    • 조회 결과가 11개이면 다음에 읽을 데이터가 존재하는 것이므로 응답 결과를 true로,
    • 조회결과가 10개 이하면 false로 응답하면된다.
select *
  from article
where id < 1001
  and deleted = false
order by id desc
limit 11
;

 

 

6. 조회 범위 제한해서 성능 개선하기

조회 성능을 개선하는 방법 중 하나는 조회 범위를 시간 기준으로 제한하는 것이다.

 

시간(기간) 기준 조회

시간(기간)을 기준으로 조회하고, 해당 시간 컬럼을 인덱스로 등록하면 성능이 유지될 수 있다.

select title, ...
  from news
where regdt >= '2024-08-09 00:00:00'
  and regdt <= '2024-08-10 00:00:00'
order by regdt desc
limit 100
;

 

 

최신데이터 조회

대부분의 기능은 최신 데이터를 주로 조회하기에 쿼리도 가장 최근의 데이터를 제공하는 방식으로 작성한다.

예시로 고객에게 매달 한 번씩 제공하는 점검 결과가 있다고 할 때, 대부분의 고객은 최근에 받은 점검 결과가 정상인지에 관심이 있으며, 3년 전에 받은 결과에는 관심이 없다. 이런 경우 최근 6개월간의 점검 결과만 제공해도 서비스에는 문제가 되지 않는다.

 

최신 점검 결과만 제공한다면 조회 쿼리를 다음과 같이 단일 쿼리로 단순화할 수 있다.

select *
  from checkResult
where cust_id = ?
  and checkMonth >= ?
order by checkMonth desc
;

 

조회 범위를 제한하면 조회 속도가 향상되고, 비교적 최신 데이터들을 조회해오기에 DB 메모리 캐시에 최신 데이터를 저장할 수 있게 되고, 이는 곧 캐시 효율이 높아지는 방향으로 이어지고, 이후 응답 속도도 빨라지는 결과를 얻을 수 있다.

 

7. 단편화와 최적화

일반적인 DB에서는 DELETE 쿼리를 실행하더라도 DB가 사용하는 디스크 용량은 줄어들지 않는다. DB는 해당 데이터가 삭제되었다는 표시만 남기고, 삭제된 공간은 향후 재사용한다. 하지만 데이터가 반복적으로 추가되고, 변경되고, 삭제되는 과정이 반복되면 단편화(fragmentation) 현상이 발생할 수 있다.  

 

단편화 현상

  • 데이터가 흩어져 저장되고 빈 공간이 생기는 현상
  • 단편화가 심해지면 디스크 I/O가 증가해 쿼리 성능이 떨어질 수 있고
  • 보관된 데이터 크기보다 더 많은 디스크 공간을 사용하므로 디스크 낭비가 발생할 수 있다.

 

8. Primary - Replica 구조 

트래픽, 특히 조회 트래픽이 많이 증가할 경우, DB의 성능 자체를 향상시키는 수직확장 말고도 수평확장을 할 수 있는데, Primary-Replica 구조가 그 중 하나이다. 조회 트래픽 비중이 높은 서비스의 경우에 이 구조를 사용하면 처리량을 효과적으로 증가시킬 수 있다.

서버는 데이터 변경 쿼리는 Primary DB를 통해, 조회 쿼리는 Replica DB를 통해 실행한다. 조회 기능에 대한 트래픽이 증가하면, 복제 DB를 추가해 조회 처리량을 확장할 수 있다.

 

 

+ Recent posts