주니어 백엔드 개발자가 반드시 알아야 할 실무 지식 ‐ 성능을 좌우하는 DB 설계와 쿼리 - dnwls16071/Backend_Summary GitHub Wiki
📚 성능과 DB
풀 스캔(Full Scan)⭐
- 풀 스캔은 테이블의 모든 데이터를 순차적으로 읽는 것을 말한다. 보통 쿼리에서 where 절에 있는 조건에 대응하는 인덱스가 없을 때 발생한다.
- 인덱스를 사용하는 것보다 전체 데이터를 탐색하는 것이 더 빠를 때에도 풀 스캔이 발생한다.
- 데이터 개수가 적을 때에는 풀 스캔을 해도 성능 문제가 겉으로는 드러나지 않지만 데이터 개수가 늘어나면 어느 순간 응답 시간이 기하급수적으로 증가하게 된다.
- 따라서 DB를 설계할 때 풀 스캔 발생 가능성을 항상 염두에 두어야 한다.
📚 인덱스 설계
조회 트래픽을 고려한 인덱스 설계 방법 - 선택도를 고려한 인덱스 칼럼⭐
- 인덱스를 생성할 때는 선택도(selectivity)가 높은 컬럼을 골라야 한다.
- 여기서 선택도란, 인덱스에서 특정 칼럼의 고유한 값 비율을 말한다. 선택도가 높으면 해당 컬럼에 고유한 값이 많다는 뜻이며 선택도가 낮으면 고유한 값이 적다는 뜻이다.
- 선택도가 높을수록(=고유한 값이 많을수록) 인덱스를 이용한 조회 효율이 높아진다.
조회 트래픽을 고려한 인덱스 설계 방법 - 커버링 인덱스⭐
- 특정 쿼리를 실행하는데 필요한 칼럼을 모두 포함하는 인덱스를 말한다.
조회 트래픽을 고려한 인덱스 설계 방법 - 뭐든 과유불급이라 했다. 인덱스는 필요한 만큼만 만들기⭐
- 효과가 적은 인덱스를 추가하면 오히려 성능이 나빠질 수 있다.
- 인덱스는 조회 속도를 빠르게 해주지만 데이터 추가, 변경, 삭제 시에는 인덱스 관리에 따른 비용이 추가된다.
- 인덱스 자체도 데이터이기 때문에 인덱스가 많아질수록 메모리와 디스크 사용량도 함께 증가한다.
인덱스말고도 개선할 수 있는 방법 - 전체 개수를 세지 않기⭐
- 데이터가 적을 때는 count 쿼리를 실행해도 큰 문제가 없다.
- 그러나 문제는 데이터가 급격히 증가하기 시작할 때 발생한다.
- 데이터가 많아질수록 count 실행 시간도 증가하는데 그 이유는 조건에 해당하는 모든 데이터를 탐색해야 하기 때문이다.
- 커버링 인덱스를 사용하더라도 전체 인덱스를 스캔해야하며, 커버링 인덱스가 아니라면 전체 데이터를 모두 읽어야 한다.
인덱스말고도 개선할 수 있는 방법 - 오래된 데이터 삭제 및 분리 보관하기⭐
- 데이터 개수가 늘어나면 늘어날수록 쿼리 실행 시간은 그에 비례해 증가한다.
- 즉, 데이터 개수가 증가하지 않으면 실행 시간을 일정 수준으로 유지할 수 있다.
- 데이터 증가 폭을 낮추는 방법 중 하나로 과거 데이터 삭제가 있다. 과거 데이터를 삭제하면 데이터 개수를 일정 수준으로 유지할 수 있어 성능 또한 일정 수준으로 유지된다.
- 단편화와 최적화
- DELETE 쿼리를 이용해 테이블에서 데이터를 삭제하더라도 DB가 사용하는 디스크 용량은 줄어들지 않는다.
- DB는 해당 데이터가 삭제되었다는 표시만을 남기고 삭제된 공간을 향후 재사용한다.
- 하지만 데이터가 반복적으로 추가되고 변경되고 삭제되는 과정에서 데이터가 흩어져 저장되고 빈 공간이 생기는 단편화 현상이 심해질 수 있다.
- 단편화가 심해지게 되면 디스크 I/O가 증가하면서 쿼리 성능이 저하될 수 있다. 또한 테이블에 실제로 보관된 데이터 크기보다 더 많은 디스크 공간을 사용하게 되어 디스크 낭비도 발생한다.
- 단편화로 인한 성능 저하를 해결하는 방법 중 하나는 최적화가 있다. 최적화는 데이터를 재배치해 단편화를 줄이고 물리적인 디스크 사용량까지 줄어주는 효과가 있다.
인덱스말고도 개선할 수 있는 방법 - DB 장비 확장하기⭐
- DB 장비를 확장하면 성능 향상을 볼 수 있다.
- DB를 수평 확장하면 DB가 처리할 수 있는 트래픽을 늘릴 수 있다.
- 조회 트래픽 비중이 높은 서비스의 경우 Primary - Replica 구조를 사용해 처리량을 효과적으로 증가시킬 수 있다.
- 서버는 데이터 변경 쿼리는 주 DB를 통해, 조히 쿼리는 복제 DB를 통해 실행한다. 조회 기능에 대한 트래픽이 증가하면 복제 DB를 추가해 조회 처리량을 확장할 수 있다.
📚 부가적인 사항
쿼리 타임아웃⭐
- 응답 지연으로 인한 재시도는 서버 부하를 더욱 가중시킨다. 왜냐하면 앞선 요청을 아직 처리 중인 상황에서 새로운 요청이 유입되기 때문이다.
- 이런 식으로 재시도가 반복되면 동시에 처리해야 하는 요청 수가 기하급수적으로 늘어나고 서버 부하는 폭증하게 된다.
- 이런 상황을 방지하는 방법 중 하나로 쿼리 실행 시간을 제한하는 것이다.
- 예를 들어 쿼리 실행 시간을 5초로 제한하면 트래픽이 증가하는 상황에서 쿼리 실행 시간이 5초를 넘길 경우 제한 시간 초과로 에러가 발생한다.
- 사용자가 재시도를 하더라도 이전 요청이 여전히 처리 중인 상태가 아니기 때문에 동시 요청 수의 폭증을 막을 수가 있다.
상태 변경 기능은 복제 DB에서 조회하지 않는다.⭐
- 앞서 정리했던 내용 중 조회 성능을 개선하기 위해 주 DB - 복제 DB로 구성한다고 했는데 여기서 SELECT 쿼리를 무조건 복제 DB에서 실행시키라는 것이 아니다.
- SELECT 쿼리를 무조건 복제 DB에서만 실행하게 되면 문제가 발생할 수 있다.
❗문제1. 주 DB와 복제 DB는 순간적으로 데이터가 일치하지 않을 수 있다. 주 DB에서 변경된 데이터는 다음 2단계를 거쳐 복제 DB에 반영된다.
- 네트워크를 통해 복제 DB에 전달
- 복제 DB는 자체 데이터에 변경 내용을 반영
- 주 DB에서 복제 DB로의 데이터 복제에는 지연이 발생한다.
- 이 지연 시간만큼 주 DB와 복제 DB는 일시적으로 서로 다른 값을 가지게 된다. 그렇게 되면 잘못된 데이터를 조회해 사용자 요청을 제대로 처리할 수 없게 된다.
❗문제2. 트랜잭션 문제가 발생할 수 있다.
- 주 DB와 복제 DB 간 데이터 복제는 트랜잭션의 커밋 시점에 이루어진다.
- 주 DB의 트랜잭션 범위 내에서 데이터를 변경하고 복제 DB에서 변경 대상이 되는 데이터를 조회하면 데이터 불일치로 인해 문제가 발생한다.
- INSERT, UPDATE, DELETE 쿼리를 실행하는 기능에서 변경 대상 데이터를 조회해야 한다면 복제 DB가 아닌 주 DB에서 SELECT 쿼리를 실행해야 데이터의 정합성을 보장할 수 있다.
배치 쿼리 실행시간 증가⭐
- 배치 프로그램은 데이터를 일괄로 조회하거나 집계하거나 생성하는 작업을 수행한다.
- 집계 쿼리는 그 특성상 많은 양의 메모리를 사용하게 되며, 특정 임계점을 넘기면 실행 시간이 예측할 수 없을 만큼 길어질 수 있다.
- 이런 문제를 예방하려면 배치에서 사용하는 쿼리의 실행 시간을 지속적으로 추적해야 한다.
- 문제를 해결하기 위한 방법으로 3가지 정도가 있는데 그 중 DB 장비 사양을 높이는 것은 단기적인 해결책이다.
- DB 장비 사양 올리기
- 커버링 인덱스 활용
- 데이터를 일정 크기로 나눠서 처리하기
- 집계 쿼리는 특성상 많은 데이터를 스캔해야한다. 이 때, 집계 대상 칼럼이 인덱스에 포함되어 있다면 데이터를 직접 읽지 않고 인덱스만 스캔해 집계를 수행할 수 있다.
- 커버링 인덱스를 활용하면 처리 속도는 빨라지고 DB가 사용하는 메모리도 줄어든다.
- 데이터를 일정 크기로 나눠 처리하는 것도 해결 방법이다. 데이터를 나눠서 처리하면 짧은 간격으로 수행하면서 메모리도 절약할 수 있다.
타입이 다른 컬럼 조인을 주의한다.⭐
- 조인 과정에서 두 칼럼의 값을 비교하는 과정에서 DB는 타입 변환을 수행한다.
- 비교하는 칼럼의 타입이 달라 인덱스를 활용하지 못하는 문제를 해결하려면 두 칼럼의 타입을 맞춰야 한다.
- 비교 대상 칼럼 타입을 맞추면 쿼리 실행 중 발생하는 불필요한 타입 변환을 줄일 수 있고 실행 시간이 길어지는 문제도 방지할 수 있다.
DB 최대 연결 개수⭐
- 다음과 같은 상황이라고 가정 → API 서버는 3대, 트래픽이 증가하는 상황이라 수평 확장이 필요, DB 서버 CPU 사용률은 20% 수준으로 여유가 있는 상황
- 트래픽 증가를 감당하기 위해 API 서버를 추가할 수 있다.
- 그러나 만약 예를 들어 DB의 최대 연결 개수가 100개로 설정된 상황이라면 API 서버 커넥션 개수가 30개일 때 4대로 늘리게 되면 필요한 커넥션 개수는 총 120개가 된다.
- 하지만 DB는 100개까지만 연결을 허용하므로 20개의 커넥션을 얻지 못하고 연결 실패가 발생하게 된다. 이 경우에는 DB 최대 연결 개수를 늘려주는 것만으로도 문제를 해결할 수 있다.
- 그러나 현재 상황이 DB 서버 CPU 사용률이 낮기 때문에 문제가 되지 않으나 만약 DB 서버 CPU 사용률이 높은 상황이라면 연결 개수를 늘리면 안 된다.
- 연결 수가 많아질수록 DB 부하는 증가하고 성능 저하가 발생할 수 있기 때문이다. 이런 경우에는 캐시 서버를 활용하거나 쿼리 튜닝같은 조치를 취해 DB 부하를 낮추고 필요할 때 연결 개수를 늘리는 것이 좋다.
📚 실패와 트랜잭션 고려
Ex) 회원가입 기능 예시(가입과 메일 발송)
@Transactional
public void join(JoinRequest join) {
// ...
memberDao.insert(member); // DB에 데이터 추가
mailClient.sendMail(...); // 메일 발송
}
- 스프링에서
@Transactional은 AOP를 활용한 선언적 트랜잭션 관리 방식을 지원한다. - 런타임 예외가 발생하면 전체 트랜잭션을 롤백하게 된다. 만약 메일 발송에 실패하면 회원 가입 역시 실패하게 되는 것이다.
@Transactional
public void join(JoinRequest join) {
// ...
memberDao.insert(member); // DB에 데이터 추가
try {
mailClient.sendMail(...); // 메일 발송
} catch (Exception ex) {
// 메일 발송 오류 무시
// 로그로 모니터링
}
}
- 예시 코드이며 예시 코드의 의도는 트랜잭션의 경계를 명확히 해야 한다는 것을 전달하기 위함이다.
- 스프링에서는
@TransactionalEventListener어노테이션으로도 명확히 트랜잭션 경계를 지정할 수 있다.