현재 포스트는 이후에 습득한 지식으로 인해 내용이 추가, 수정, 삭제될 수 있음
체계적으로 정리된 데이터의 모음으로 대량의 데이터를 효율적으로 검색, 접근, 수정할 수 있도록 구성되어있다.
DBMS는 이러한 데이터베이스를 관리하는 소프트웨어로 데이터를 정의, 생성, 유지, 제어하는 기능을 제공한다.
데이터베이스의 구조를 시각적으로 표현한 모델, 엔티티(Entity), 관계(Relationship), 속성(Attribute)으로 구성됨
관계형 데이터베이스에서 가장 중요한 알파이자 오메가는 데이터 무결성을 유지하는 것이다.
데이터베이스의 무결성을 해치는, 혹은 해칠 수 있는 가장 큰 가능성은 데이터 중복이다.
하나의 레코드를 수정했을 때 중복된 다른 데이터들의 값도 변경을 해줘야 하기 때문에 데이터베이스의 무결성을 유지하기 위해선 데이터 중복을 최대한 없애는 것이 좋다.

위와 같은 테이블에서 데이터베이스 과목의 담당 교수가 김교수에서 최교수로 바뀌었다고 가정해보자.

만약 실수로 4번째 레코드의 교수 이름의 업데이트를 완수하지 못했다면 이 테이블은 무결성을 만족하지 못한다.
처음부터 모든 중복된 데이터들의 업데이트를 완벽하게 보장할 수 있도록 조치를 취하면 이러한 문제를 해결할 수 있어 보이지만 더 좋은 방법은 중복 데이터를 없애는 것이다.
이러한 데이터 중복을 설계 단계부터 예방하는 순차적 방법이 있는데 이것이 바로 3단계로 이루어진 데이터베이스 정규화이다.
모든 필드는 원자값(Atomic Value)만 가져야 함. 즉, 하나의 필드는 하나의 값만 가지고 있어야 한다.

1NF를 만족하고, 완전 함수 종속을 만족해야함
완전 함수 종속이란?
속성 B가 속성 A에 종속될 때 B가 A의 부분집합이 아닌 A 전체에 종속되는 경우 B는 A에 완전 함수 종속되었다고 한다. 이 때 A가 복합키가 아닌경우 부분집합이 없기 때문에 무조건 완전 함수 종속을 만족한다. 즉 제 2 정규화는 기본키가 복합키인 경우에만 진행할 수 있다.

2NF를 만족하고, 이행적 종속(Transitive Dependency)되지 않아야 함.
이행적 종속이란?
속성 C가 속성 B에 종속되고 속성 B가 속성 A에 종속이 되는 경우 속성 C가 속성 A에 이행적 종속된다고 표현한다.

모든 결정자가 후보 키가 되도록 함

반정규화란 데이터베이스 설계에서 성능을 최적화하기 위해 일부러 정규화된 구조를 일부 해제하는 과정을 말함.
지나치게 정규화된 데이터베이스는 잦은 조인 연산으로 인해 성능상의 문제를 일으킬 수 있음.
또한 쿼리를 복잡하게 만들어서 개발에 어려움이 있을 수 있음.
개인적으로 생각하는 반정규화가 필요한 경우
- 조인 연산이 자주 일어나고 조인 연산 과정에 비용이 많이 드는 경우
- 데이터의 무결성보다 실시간 응답이 중요한 시스템
이진 탐색 트리에서 알 수 있듯 어떠한 데이터를 검색할 때 데이터가 정렬되어 있으면 Full Scan을 할 필요 없이 탐색 범위를 좁혀가며 O(logN)의 시간 복잡도로 수행할 수 있다.
데이터베이스에서도 특정 열의 검색을 빠르게 하기 위해서 해당 열을 순서대로 정렬한 공간을 따로 저장해두는데 이를 인덱스라고 한다.
이때 인덱스는 일반적으로 B-Tree, B+Tree라는 자료구조로 정의된다.
일반적으로 Primary Key는 자동으로 정렬되어있기 때문에 인덱스 자료 구조가 필요 없다.
B+Tree와 같은 정렬된 자료구조로 인덱스를 사용하면 검색 속도가 O(logN)이 되어 빠르다는 장점은 있지만 데이터를 삽입, 삭제, 수정할 경우의 시간 복잡도도 O(logN)이기 때문에 인덱스가 많아진다면 삽입과 삭제 과정에서 효율이 감소한다는 단점이 있다.
또한 인덱스도 엄연히 원본 데이터와 별개로 하드 용량을 차지하는 데이터이기 때문에 용량적인 측면에서도 손해가 있다.
데이터베이스에서 트리거란 특정한 이벤트가 발생할 때 자동으로 실행되는 SQL 구문들을 의미한다.
깃허브 액션과 같은 느낌이라고 보면 된다.
트리거를 사용해서 데이터의 무결성을 보장하고 자동화된 작업을 해줄 수 있다.
예를 들어 특정 레코드의 값이 수정되었을 때 updated_at 컬럼의 값을 현재 시간으로 바꿔주거나. 수행된 작업들을 로깅할 수도 있다.
하지만 Django를 사용하는 나에게 있어 이 모든 과정은 굳이 트리거를 사용하지 않아도 django 내부에서 트리거(데이터베이스의 트리거가 아니라 django 코드 내에서의 트리거)가 이미 구현되어있기 때문에 별 의미가 없다.
그렇다면 django에서 언제 트리거를 사용하면 좋을까?
게시글의 좋아요 수 집계 테이블을 구현한다고 가정하자.
View 수준에서 사용자가 좋아요를 누르고 포스트와 좋아요의 관계 레코드가 생성이 되었다면 좋아요 수 집계 테이블에서 해당 포스트의 좋아요 수를 1 올려준다.
데이터베이스 수준에서 SQL로 포스트와 좋아요 중계 테이블에 삽입, 삭제가 일어날 때 마다 해당 좋아요 수 집계 테이블에서 해당 포스트의 좋아요 수를 1 증가, 감소 시키는 트리거를 생성한다.
각각의 장단점을 알아보자.
1번 방법의 장단점
- 장점: 구현이 간단하고 직관적이며 디버깅 때 용이하다.
- 단점: 데이터의 무결성을 보장하기 어렵다.
2번 방법의 장단점
- 장점: 데이터의 무결성을 보장하고 장고 코드가 깔끔해진다(?).
- 단점: 디버깅이 용이하지 않다.
2번 방법을 사용하면 데이터베이스의 무결성을 보장할 수 있다.
트랜잭션의 원자성 덕분에 좋아요 수 증가 쿼리에 오류가 났을 때 이전에(혹은 이후에) 추가된 좋아요 관계 레코드 역시 롤백되기 때문에 데이터베이스에 무결성이 깨지기 쉽지 않다.
반면에 1번 방법은 좋아요 레코드 추가 이후 좋아요 수 증가 코드 실행 중 문제가 생기면 실제 좋아요 수와 기록된 좋아요 수가 달라지기 때문에 비교적 무결성 문제가 발생하기 쉽다.
따라서 추가적으로 Celery-beat등을 통해 무결성 검사를 진행해야 한다.
동시성 문제는 두 방법 모두 고질적인 문제이기 때문에 따로 데이터베이스 잠금, F() 객체 등을 통해 해결해야한다.
프로시저
프로그래밍에서, 특정 작업을 수행하도록 설계된 명령어(데이터베이스에선 SQL)들의 집합을 의미
스토어드 프로시저
데이터베이스에 컴파일된 상태로 저장된 프로시저
스토어드 프로시저를 사용하면 호출하여 재사용할 수 있어서 재사용성, 일관성, 유지보수를 높일 수 있으며 미리 컴파일된 상태로 저장되기 때문에 컴파일 시간을 단축하여 성능 향상을 도모할 수 있다.
특히 코드를 캡슐화할 수 있기 때문에 보안적인 측면에서도 이득이다.
다만 특정 버전에서 최초 컴파일 시 인덱스 사용 여부 고정 문제가 있다는데 아직 잘 모르겠어서 이후 정리할 예정이다.
트랜잭션은 데이터베이스 내에서 일어나는 하나의 독립적인 작업 단위를 의미한다.
트랜잭션이 동시에 실행될 때 데이터 일관성을 유지하기 위해 설정하는 규칙.
트랜잭션이 다른 트랜잭션의 중간 상태나 결과에 접근할 수 있는지를 결정함.
트랜잭션이 커밋되지 않은 변경사항을 읽을 수 있음, 가장 낮은 격리 수준으로 Dirty Read 문제가 발생할 수 있음
Dirty Read란?
한 트랜잭션이 다른 트랜잭션이 아직 커밋하지 않은 데이터를 읽는 상황. 이 데이터는 아직 확정되지 않았기 때문에 나중에 롤백될 수 있음. 만약 롤백이 발생하면, 이전에 읽은 데이터는 무효가 되며, 잘못된 데이터를 읽었다고 할 수 있다.
성능은 가장 좋아지지만 일관성 문제가 많음
예시
트랜잭션 A: 사용자 A의 계좌에서 100달러를 인출.
트랜잭션 B: A가 커밋되지 않은 상태(확정되지 않은 상태)에서 사용자 A의 계좌 잔액을 조회.트랜잭션 B는 100달러이 인출 된 후의 잔액을 조회한다.
어떠한 이유로 트랜잭션 A가 롤백된다면, 실제 데이터는 100달러를 인출되기 전이 되고 트랜잭션 B는 잘못된 데이터를 읽은게 된다.
트랜잭션이 커밋된 데이터만 읽을 수 있음, Dirty Read는 발생하지 않지만 Non-Repeatable Read가 발생할 수 있음.
Repeatable Read
어떤 트랜잭션에서 같은 질의를 사용했을 때 질의를 아무리 여러번 해도 항상 같은 데이터만 읽어드리는 경우
Non-Repeatable Read란?
한 트랜잭션이 같은 데이터를 두 번 읽을 때, 그 사이에 다른 트랜잭션이 그 데이터를 수정하거나 삭제하면 처음 읽었을 때와 두 번째 읽었을 때의 데이터가 달라지는 문제
예시
트랜잭션 A: 사용자 A의 계좌 잔액을 조회.
트랜잭션 B: 사용자 A의 계좌에서 100달러를 인출
트랜잭션 A: 사용자 A의 계좌 잔액을 조회.트랜잭션 A는 처음 읽은 데이터와 나중에 읽은 데이터가 다름
트랜잭션 동안 동일한 데이터를 여러 번 읽을 때 항상 동일한 값을 읽을 수 있음, Non-Repeatable Read가 방지되지만 Phantom Read는 발생할 수 있음.
Phantom Read란?
트랜잭션이 같은 쿼리를 여러 번 실행할 때, 그 사이에 다른 트랜잭션이 새로운 행을 삽입하여 결과 집합에 "유령" 행이 나타나는 상황
가장 높은 격리 수준으로, 트랜잭션이 완료될 때까지 다른 트랜잭션이 그 영역에 해당되는 데이터에 대한 수정 및 입력이 불가능, 가장 높은 데이터 일관성을 제공하지만 성능이 가장 낮음
EXPLAIN은 SQL에서 쿼리의 실행 계획을 분석하고 이해하는 데 사용되는 도구로 데이터베이스 엔진이 특정 쿼리를 어떻게 실행할 것인지에 대한 내부적인 동작을 설명한다.
EXPLAIN을 사용하면 쿼리가 어떻게 실행될지, 즉 테이블 스캔, 인덱스 사용, 조인 순서 등을 알 수 있다. 이를 통해 성능이 저하될 가능성이 있는 부분을 사전에 파악할 수 있다.
복잡한 쿼리에서 성능이 예상보다 좋지 않을 때, 예기치 않은 방식으로 쿼리를 실행할 때 이를 통해 그 이유를 확인하고 쿼리를 수정할 수 있다.
EXPLAIN
SELECT
c.id,
c.name,
SUM(bp.quantity * p.price) AS total_spent
FROM
customers c
JOIN
booking b ON c.id = b.customer_id
JOIN
booking_product_items bp ON b.id = bp.booking_id
JOIN
product p ON bp.product_id = p.id
WHERE
b.booking_date BETWEEN '2024-01-01' AND '2024-12-31'
GROUP BY
c.id, c.name
HAVING
total_spent >= 500
ORDER BY
total_spent DESC;
위와 같이 쿼리 앞에 EXPLAIN을 붙이면 결과로 아래와 같은 결과가 반환된다.

여기서 각 행은 MySQL이 수행하는 작업이다.
현재 그림에서 행이 4개인데 위 SELECT 쿼리를 실행할 때 DBMS가 4개의 테이블 접근 작업을 진행할 것이라는 의미이다.
이때 id가 전부 1로 같기 때문에 4개 전부 동일한 수준에서 일어난다.
동일한 수준에서 일어난다는 것은 논리적으로 동일하다는거지 물리적으로 동시에 일어나는 작업이라는 뜻은 아니다.
중요한 행만 설명하자면
select_type: SELECT 문이 쿼리 내에서 어떤 유형인지 출력
가능한 값
- SIMPLE: 하위 쿼리가 없고, 단일 테이블을 대상으로 하는 단순 SELECT
- PRIMARY: 쿼리에서 가장 바깥쪽 SELECT, 즉 최상위 SELECT
- UNION: UNION의 두 번째 또는 이후의 SELECT
- SUBQUERY: 하위 쿼리에서 사용된 SELECT
- DERIVED: FROM 절에 포함된 서브쿼리가 생성한 파생 테이블을 위한 SELECT
- UNCACHEABLE SUBQUERY: 결과가 캐시될 수 없는 하위 쿼리
- UNCACHEABLE UNION: 캐시되지 않는 UNION의 두 번째 또는 이후 SELECT
type: MySQL이 각 테이블에 접근하는 방법, 즉 조인의 유형
가능한 값
- ALL: 테이블의 모든 행을 스캔
- index: 인덱스를 스캔
- range: 인덱스의 특정 범위를 스캔
- ref: 특정 값에 해당하는 하나 이상의 행을 인덱스를 통해 검색
- eq_ref: UNIQUE 또는 PRIMARY KEY를 사용하여 정확히 하나의 행을 검색
- const: 상수로 처리할 수 있는 경우. PK 또는 UNIQUE 인덱스에 의해 하나의 행이 반환될 때
- NULL: 접근하지 않음 (일반적으로 상수 값이 반환되는 경우).
possible_keys: 쿼리에서 사용될 수 있는 인덱스
가능한 값
- 사용 가능한 인덱스 이름들
- 인덱스가 없거나 사용되지 않는 경우 NULL
key: 쿼리에서 실제로 사용된 인덱스
가능한 값
- 사용된 인덱스 이름
- 인덱스가 사용되지 않은 경우 NULL
key_len: 사용된 인덱스의 키 길이(바이트 단위), MySQL이 얼마나 많은 인덱스를 사용하는지 보여줌
가능한 값
- 정수
ref: 조인할 때 어떤 열이나 상수가 인덱스와 비교되는지
가능한 값
- 열 이름, 상수 값, 또는 NULL.
rows: MySQL이 이 작업에서 몇 개의 행을 검토해야 하는지 추정한 값
가능한 값
- 정수, 값이 클 수록 쿼리가 더 많은 행을 스캔해야 함을 의미
filtered: 테이블에서 조건에 맞는 행의 비율(%)
가능한 값
- 정수, 값이 낮을수록
WHERE조건이 강력하게 작용한 것
extra: 추가 정보나 최적화된 내용, MySQL이 적용한 최적화나 추가 작업에 대해 표시
가능한 값
- Using index: 인덱스만으로 데이터를 가져옴. 테이블의 데이터를 읽을 필요가 없음
- Using where:
WHERE조건을 사용하여 행을 필터링함.- Using temporary: MySQL이 임시 테이블을 사용하여 결과를 생성함.
- Using filesort: MySQL이 정렬을 위해 메모리나 디스크에 파일을 사용함.
- Using join buffer: 조인을 위해 메모리 버퍼를 사용함 (보통 성능이 떨어질 수 있는 신호).
- Impossible WHERE:
WHERE조건이 항상 거짓이므로 아무 결과도 반환하지 않음.- Distinct: 중복을 제거하기 위해
DISTINCT가 사용됨.
Type
위에서 아래 순서대로 성능이 좋다
- ALL: 전체 테이블 스캔이 이루어지므로, 대규모 데이터셋에서는 비효율적
- index: 인덱스를 스캔하지만 전체 인덱스를 스캔하기에 여전히 비효율적
- range: 대량의 데이터를 스캔하지 않기 때문에 상대적으로 효율적
- ref: range보다 상대적으로 효율적
- eq_ref: 정확히 하나의 행을 찾기 때문에 효율적
- const: 성능이 제일 좋음
key_len
필요한 인덱스 길이가 짧을수록, 인덱스가 더 적은 데이터를 다루기 때문에 성능이 더 좋음
filtered
값이 적을 수록 원래 데이터에서 많이 필터가 되었다는 뜻이니 성능이 좋다.
만약 퍼센트가 높다면 WHERE을 해도 데이터가 많이 걸러지지 않았다는 뜻이기 때문에 조건을 재검토할 필요가 있음을 알 수 있다.
rows
값이 적을 수록 다룰 데이터가 적다는 의미이니 성능이 좋다.
값이 크다면 인덱스를 추가하거나 쿼리 최적화를 할 필요가 있음을 알 수 있다.
Extra
- Using temporary
문제: GROUP BY 또는 ORDER BY를 사용할 때 임시 테이블이 생성될 수 있음. MySQL이 임시 테이블을 사용하면, 메모리나 디스크 I/O가 발생하므로 성능이 저하될 수 있음
해결: 인덱스를 잘 설정하면 임시 테이블 생성을 피할 수 있음(GROUP BY나 ORDER BY에 사용되는 열에 인덱스를 추가), 가능하다면 중복된 정렬 또는 그룹화를 피하기- Using filesort
문제: MySQL이 정렬을 위해 메모리 또는 디스크에 파일을 사용하게 되면, 쿼리 성능이 상당히 떨어질 수 있음.
해결: ORDER BY에 사용되는 열에 적절한 인덱스를 추가, 특히 복합 인덱스는 여러 열에 대해 정렬을 수행할 때 유용(쿼리가 ORDER BY col1, col2를 사용한다면 (col1, col2) 복합 인덱스를 추가)- Using join buffer
문제: 조인을 수행할 때 조인되는 데이터가 메모리에 다 담기지 않을 때 발생하며, 성능 저하를 초래할 수 있음.
해결: 조인에 사용되는 열에 적절한 인덱스를 추가, 조인의 순서를 최적화하여 작은 테이블을 먼저 조인하도록 쿼리를 재작성, 조인을 서브쿼리로 대체할 수 있다면 대체- Using index
인덱스만으로 필요한 데이터를 모두 가져오는 경우. 테이블의 행을 읽지 않아도 되므로 매우 효율적.
행복한 고민이지만 나중에 자신이 서비스하는 서버에 유저가 많아지고 규모가 커진다면 필연적으로 DB에 부하가 일어날 것이다.
그렇다면 DB의 성능을 최적화 하기 위해 어떤 방법들을 시도할 수 있을까?
서버 업그레이드: 더 빠른 CPU, 더 많은 메모리, SSD 스토리지 등을 활용하여 하드웨어 성능을 개선.
네트워크 성능: 고속 네트워크, 로드 밸런싱, 네트워크 레이턴시 감소 등을 통해 네트워크 병목 현상을 줄임.
메모리 설정: DBMS의 버퍼 캐시, 공유 메모리 크기 등을 적절히 설정하여 메모리 사용 효율성을 극대화.
병렬 처리 설정: 여러 프로세서를 이용한 병렬 쿼리 처리 설정을 통해 대용량 데이터 처리를 가속화.
커넥션 풀링: 데이터베이스 연결의 재사용을 통해 연결/해제의 오버헤드를 줄임.
정규화: 데이터의 중복을 줄이고 무결성을 유지하기 위해 데이터베이스를 정규화.
역정규화: 성능을 위해 일부 중복을 허용하고 데이터 접근을 빠르게 하기 위해 테이블을 역정규화.
파티셔닝: 대규모 테이블을 파티션으로 나누어 특정 쿼리가 더 작은 부분집합에서만 실행되도록 함.
기본 인덱스: 자주 조회되는 컬럼에 인덱스를 생성하여 조회 성능을 개선.
복합 인덱스: 여러 컬럼을 결합한 인덱스를 통해 복합 조건 조회를 최적화.
커버링 인덱스: 쿼리가 필요한 모든 컬럼을 포함하는 인덱스를 생성하여 인덱스만으로 쿼리를 해결.
클러스터드 인덱스: 테이블의 물리적 순서를 지정하여 데이터 접근 성능을 향상.
쿼리 리팩토링: 비효율적인 쿼리를 재작성하여 성능을 개선.
서브쿼리 최적화: 서브쿼리를 JOIN으로 변경하거나 필요 없는 서브쿼리를 제거.
쿼리 힌트: DBMS에게 쿼리 실행 계획에 대한 힌트를 제공하여 최적의 실행 계획을 유도.
자동 쿼리 최적화 도구 사용: SQL 프로파일러, 쿼리 최적화 도구 등을 사용하여 문제 쿼리를 식별하고 최적화.
애플리케이션 레벨 캐싱: 자주 변경되지 않는 데이터를 애플리케이션 레벨에서 캐시하여 데이터베이스 접근을 줄임.
데이터베이스 레벨 캐싱: DBMS의 캐시 메커니즘을 활용하여 쿼리 결과를 캐시.
분산 데이터베이스: 데이터베이스를 샤딩 또는 복제하여 여러 서버에 분산.
클러스터링: 다수의 서버를 하나의 데이터베이스 시스템으로 동작하도록 구성하여 성능 및 가용성을 개선.
데이터베이스 이중화: 장애 복구 및 읽기 부하 분산을 위해 데이터베이스를 이중화.
모니터링 도구 사용: DB 성능 모니터링 도구를 통해 성능 병목을 실시간으로 모니터링.
로그 분석: 쿼리 로그, 실행 계획 로그 등을 분석하여 성능 문제를 식별하고 해결.
정기적인 리뷰: 정기적으로 성능 리뷰를 수행하여 최적화 필요성을 점검.
운영 체제 설정 최적화: DBMS에 맞춘 운영 체제 설정 조정.
파일 시스템 최적화: 데이터베이스 파일이 위치한 디스크 및 파일 시스템의 성능 최적화.