데이터베이스 핵심 정리

프로그램에서 데이터를 다루다 보면 결국 저장해야 하는 문제가 생긴다.

게임의 플레이어 정보, 아이템, 재화, 설정값, 로그 같은 데이터는 프로그램이 종료된다고 사라지면 안 된다. 서버에서는 동시에 여러 사용자가 같은 데이터를 읽고 수정하기도 한다.

데이터베이스는 이런 데이터를 저장하고 조회하고 변경하기 위한 시스템이다.

단순히 데이터를 파일에 저장하는 것과 다른 점은 많은 데이터에 대해 일관된 방식으로 접근하고, 여러 사용자가 동시에 작업하더라도 데이터의 상태를 관리할 수 있다는 것이다.


데이터베이스와 DBMS

데이터베이스(Database)는 데이터를 구조적으로 저장한 공간이라고 볼 수 있다.

그리고 그 데이터를 관리하는 소프트웨어가 DBMS(Database Management System)다.

Application -> DBMS -> Database -> Storage

개발자가 직접 디스크의 특정 위치에 데이터를 저장하고 검색하는 대신 DBMS에 요청한다.

대표적인 관계형 DBMS로는 PostgreSQL, MySQL, MariaDB, SQL Server 등이 있다.

MongoDB 같은 NoSQL 데이터베이스도 있다.

중요한 것은 데이터베이스 종류를 먼저 외우는 것이 아니라 어떤 데이터와 접근 패턴을 가지고 있는가를 보는 것이다.


관계형 데이터베이스

가장 많이 사용되는 형태 중 하나가 관계형 데이터베이스(Relational Database)다.

데이터를 테이블 형태로 저장한다.

각 행은 하나의 데이터를 나타내고, 각 열은 데이터의 속성을 나타낸다.

여러 테이블 사이의 관계도 표현할 수 있다.

관계형 데이터베이스는 데이터를 구조적으로 관리하고 SQL을 통해 원하는 데이터를 조회할 수 있다는 장점이 있다.


SQL

관계형 데이터베이스에서는 SQL(Structured Query Language)을 사용해 데이터를 조회하고 변경한다.

SELECT id, name, level
FROM Player
WHERE level >= 20;

데이터를 추가할 수도 있다.

INSERT INTO Player (name, level)
VALUES ('Alice', 20);

수정과 삭제도 가능하다.

UPDATE Player
SET level = 21
WHERE id = 1;
DELETE FROM Player
WHERE id = 1;

SQL은 단순히 데이터를 가져오는 언어가 아니다.

테이블을 만들고, 권한을 관리하고, 트랜잭션을 제어하고, 여러 테이블을 조합하는 등 데이터베이스의 다양한 기능을 다룬다.


Primary Key

테이블에서 각 행을 고유하게 식별할 수 있어야 한다.

이를 위해 Primary Key(PK)를 사용한다.

여기서 id가 Primary Key라고 하면 각 플레이어를 고유하게 식별할 수 있다.

SELECT *
FROM Player
WHERE id = 10;

Primary Key는 중복될 수 없고 일반적으로 NULL을 허용하지 않는다.

데이터를 식별하는 기준이기 때문에 테이블 설계에서 상당히 중요한 부분이다.


Foreign Key

테이블과 테이블 사이의 관계를 표현할 때 사용하는 것이 Foreign Key(FK)다.

예를 들어


가 있다면 Item.player_idPlayer.id를 참조하도록 만들 수 있다.

이렇게 하면 데이터 사이의 관계를 명확하게 표현할 수 있다.


JOIN

관계형 데이터베이스에서 여러 테이블의 데이터를 함께 조회할 때 JOIN을 사용한다.

SELECT Player.name, Item.item_name
FROM Player
JOIN Item
    ON Player.id = Item.player_id;

결과는 개념적으로 다음과 같다.


테이블을 여러 개로 나누어 저장하더라도 필요할 때 JOIN을 이용해 하나의 결과로 가져올 수 있다.

대표적인 JOIN에는 다음이 있다.

INNER JOIN
LEFT JOIN
RIGHT JOIN
FULL OUTER JOIN

실무에서는 특히 INNER JOINLEFT JOIN을 자주 만나게 된다.


정규화

데이터베이스를 설계할 때 같은 데이터를 여러 곳에 중복해서 저장하면 문제가 생길 수 있다.

예를 들어

사용자의 이메일이 변경되면 여러 행을 수정해야 한다.

일부만 수정되면 데이터가 서로 다른 상태가 될 수도 있다.

이런 문제를 줄이기 위해 데이터를 적절한 테이블로 분리하는 것이 정규화(Normalization)다.

이제 사용자 정보는 한 곳에서 관리한다.

정규화의 목적은 단순히 테이블을 많이 만드는 것이 아니다.

데이터의 중복을 줄이고 데이터 변경 시 발생할 수 있는 이상 현상을 줄이는 것이다.


반정규화

그렇다고 모든 데이터를 최대한 분리하는 것이 항상 좋은 것은 아니다.

테이블을 지나치게 분리하면 JOIN이 많아지고 조회 비용이 증가할 수 있다.

그래서 조회 성능이나 시스템 구조에 따라 일부 데이터를 의도적으로 중복 저장하기도 한다.

이것을 반정규화(Denormalization)라고 한다.

정규화
-> 중복 감소
-> 데이터 일관성 관리 용이

반정규화
-> 중복 증가
-> 조회 성능 또는 구조 단순화

결국 데이터베이스 설계도 트레이드오프다.

무조건 정규화하거나 무조건 중복을 제거하는 것이 정답은 아니다.


Index

데이터가 많아지면 단순히 테이블 전체를 검색하는 방식은 느려질 수 있다.

예를 들어 플레이어가 1억 명 있는데 다음 쿼리를 실행한다고 하자.

SELECT *
FROM Player
WHERE email = 'alice@example.com';

email에 인덱스가 없다면 많은 데이터를 직접 확인해야 할 수 있다.

여기에 인덱스를 생성할 수 있다.

CREATE INDEX idx_player_email
ON Player(email);

인덱스를 사용하면 특정 값을 찾는 작업을 훨씬 빠르게 만들 수 있다.

대신 인덱스에도 비용이 있다.

데이터를 추가하거나 수정할 때 인덱스도 함께 관리해야 하고 디스크 공간도 사용한다.

그래서 모든 컬럼에 인덱스를 만드는 것은 좋지 않다.

자주 조회되는 조건과 실제 쿼리 패턴을 보고 필요한 인덱스를 선택해야 한다.


B-Tree와 인덱스

관계형 데이터베이스에서 인덱스는 다양한 자료구조를 사용할 수 있지만 B-Tree 계열 구조가 일반적으로 많이 사용된다.

개념적으로는 다음과 같은 구조다.

정렬된 구조를 이용하기 때문에 특정 값을 찾거나 범위 검색을 효율적으로 처리할 수 있다.

예를 들어

WHERE level >= 50

같은 범위 검색에서도 인덱스가 활용될 수 있다.

다만 실제로 어떤 인덱스를 사용했는지는 데이터베이스의 실행 계획을 확인해야 한다.

인덱스를 만들었다고 해서 모든 쿼리가 자동으로 빨라지는 것은 아니다.


복합 인덱스

여러 컬럼을 묶어서 하나의 인덱스로 만들 수도 있다.

CREATE INDEX idx_player_server_level
ON Player(server_id, level);

이런 인덱스는 다음과 같은 쿼리에서 활용될 수 있다.

SELECT *
FROM Player
WHERE server_id = 1
  AND level >= 50;

복합 인덱스에서는 컬럼의 순서가 중요하다.

단순히 필요한 컬럼을 모두 넣는다고 좋은 인덱스가 되는 것은 아니다.

실제 조회 조건과 정렬 조건 등을 기준으로 인덱스 구조를 결정해야 한다.


Transaction

데이터베이스에서 여러 작업을 하나의 논리적인 작업으로 처리해야 하는 경우가 있다.

예를 들어 게임에서 플레이어의 재화를 차감하고 아이템을 지급한다고 하자.

재화 -100
아이템 +1

재화만 차감되고 아이템 지급이 실패하면 데이터가 이상해진다.

이 두 작업은 하나의 작업으로 묶어야 한다.

Transaction

재화 -100
아이템 +1 -> 둘 다 성공

중간에 문제가 생기면 변경사항을 되돌린다.

재화 -100
아이템 지급 실패 -> Rollback -> 원래 상태

이것이 Transaction의 기본적인 개념이다.


ACID

트랜잭션의 특성을 설명할 때 ACID라는 개념을 사용한다.

Atomicity

트랜잭션의 작업은 하나의 단위로 처리된다.

전부 성공 또는 전부 실패

Consistency

트랜잭션 전후로 데이터베이스가 정의된 규칙을 만족해야 한다.

Isolation

여러 트랜잭션이 동시에 실행될 때 서로의 작업이 어떻게 보이는지를 제어한다.

Durability

트랜잭션이 정상적으로 커밋되었다면 시스템에 문제가 발생해도 결과가 유지되어야 한다.

이 네 가지를 각각 이해하는 것보다 실제 동시성 문제와 함께 보는 것이 좋다.


동시성 문제

데이터베이스는 여러 사용자가 동시에 접근한다.

예를 들어 재화가 100인데 두 요청이 동시에 100을 차감하려고 한다고 하자.

Player Gold = 100

Request A -> 100 확인
Request B -> 100 확인

A -> 0 저장
B -> 0 저장

두 요청 모두 100을 확인했기 때문에 실제로는 두 번 차감되어야 하는 상황에서도 데이터가 한 번만 변경될 수 있다.

이런 문제를 Race Condition 관점에서 볼 수 있다.

데이터베이스는 트랜잭션과 동시성 제어를 통해 이런 문제를 처리한다.


Isolation Level

트랜잭션 간의 격리 수준을 조절할 수 있다.

대표적으로

READ UNCOMMITTED
READ COMMITTED
REPEATABLE READ
SERIALIZABLE

등이 있다.

격리 수준이 높아질수록 다른 트랜잭션의 영향을 더 강하게 제한할 수 있지만 일반적으로 동시 처리 성능과의 트레이드오프가 생긴다.

어떤 수준이 무조건 가장 좋은 것은 아니다.

서비스에서 요구하는 데이터 일관성과 성능을 기준으로 선택해야 한다.


Lock

동시에 같은 데이터를 수정하는 문제를 해결하기 위해 Lock을 사용할 수 있다.

Transaction A -> Row Lock -> 데이터 수정 -> Commit -> Lock 해제

다른 트랜잭션은 해당 데이터의 상태에 따라 기다리게 된다.

하지만 Lock을 과도하게 사용하면 대기 시간이 증가하고, 잘못 설계하면 Deadlock이 발생할 수도 있다.

데이터베이스 성능 문제를 볼 때 단순히 “쿼리가 느리다”만 보는 것이 아니라 Lock을 기다리고 있는지도 확인할 필요가 있다.


Deadlock

두 트랜잭션이 서로가 가진 Lock을 기다리면 Deadlock이 발생할 수 있다.

Transaction A
Lock A 획득 ->Lock B 대기

Transaction B  
Lock B 획득 -> Lock A 대기

둘 다 상대방이 Lock을 해제하기를 기다린다.

데이터베이스는 Deadlock을 감지하고 한쪽 트랜잭션을 실패시키는 방식 등으로 처리할 수 있다.

애플리케이션에서도 이런 실패가 발생할 수 있다는 것을 고려하고 적절한 재시도 정책 등을 설계할 필요가 있다.


NoSQL

관계형 데이터베이스만 있는 것은 아니다.

NoSQL은 관계형 모델과 다른 방식으로 데이터를 저장하는 데이터베이스를 통칭한다.

대표적인 형태로

Document
Key-Value
Column Family
Graph

등이 있다.

예를 들어 Document DB에서는 JSON과 비슷한 형태로 데이터를 저장할 수 있다.

{
  "id": 1,
  "name": "Alice",
  "level": 20,
  "items": [
    "sword",
    "shield"
  ]
}

스키마가 유연하고 특정 조회 패턴에 맞춰 데이터를 구성하기 편한 경우가 있다.

반면 관계형 데이터베이스가 제공하는 강한 관계 모델과 트랜잭션 특성이 필요한 시스템이라면 RDB가 더 적합할 수 있다.

NoSQL이 RDB보다 무조건 빠르거나 좋은 것은 아니다.


데이터베이스 선택

데이터베이스를 선택할 때는 유행보다 데이터의 특성을 먼저 보는 것이 좋다.

데이터 구조는?
관계가 복잡한가?
트랜잭션이 중요한가?
조회 패턴은?
쓰기와 읽기 비율은?
데이터 규모는?
수평 확장이 필요한가?
일관성이 얼마나 중요한가?

예를 들어 결제나 계정 데이터처럼 강한 일관성이 필요한 영역과 대규모 로그처럼 단순하게 쌓이는 데이터는 요구사항이 다르다.

하나의 데이터베이스로 모든 문제를 해결해야 한다는 법도 없다.

서비스에 따라 여러 저장소를 함께 사용할 수도 있다.


Cache

데이터베이스를 매번 직접 조회하는 것이 비효율적인 경우에는 캐시를 사용할 수 있다.

대표적으로 Redis 같은 인메모리 저장소가 사용된다.

Application -> Cache -> Database

자주 사용하는 데이터를 메모리에 저장해두면 데이터베이스까지 접근하는 횟수를 줄일 수 있다.

예를 들어 게임에서 자주 조회되는 플레이어의 세션 정보나 랭킹 데이터를 캐시에 저장할 수 있다.

다만 캐시가 추가되면 데이터가 여러 곳에 존재하게 된다.

Cache + Database

그러면 데이터가 서로 다른 상태가 되는 Cache Invalidation 문제가 생긴다.

캐시는 성능을 개선하는 강력한 방법이지만 그만큼 시스템의 복잡도도 증가한다.


데이터베이스 성능을 볼 때

쿼리가 느리다고 해서 무조건 데이터베이스 서버의 성능이 부족한 것은 아니다.

먼저 실제 쿼리가 어떻게 실행되는지를 봐야 한다.

대표적으로 실행 계획을 확인할 수 있다.

EXPLAIN
SELECT *
FROM Player
WHERE email = 'alice@example.com';

실행 계획을 통해 어떤 인덱스를 사용하는지, 테이블을 어떻게 읽는지, JOIN을 어떻게 수행하는지 등을 확인할 수 있다.

성능 개선은 보통

느린 쿼리 발견 -> 실행 계획 확인 -> 병목 원인 확인 -> 인덱스 / 쿼리 / 구조 개선 -> 다시 측정

순서로 진행하는 편이 좋다.

감으로 인덱스를 추가하는 것보다 실제 쿼리와 측정 결과를 기반으로 수정하는 것이 낫다.


데이터베이스를 설계할 때

데이터베이스 설계는 테이블부터 만드는 작업이 아니다.

먼저 어떤 데이터를 저장하고 어떻게 접근할 것인지부터 생각해야 한다.

요구사항 -> 데이터 모델 -> 테이블 / 관계 설계 -> 제약조건 -> 인덱스 -> 쿼리 -> 성능 측정

특히 실제 서비스에서는 쓰기보다 읽기가 많은지, 특정 데이터를 어떻게 조회하는지가 인덱스와 테이블 구조에 큰 영향을 준다.

데이터 모델과 쿼리는 서로 분리해서 생각하기 어렵다.


데이터베이스에서 결국 보는 것

데이터베이스를 공부하면서 결국 반복해서 만나게 되는 개념은 다음과 같다.

데이터 모델링 -> 어떤 구조로 저장할 것인가

SQL -> 어떻게 데이터를 조회하고 변경할 것인가

Index -> 어떻게 빠르게 찾을 것인가

Transaction -> 여러 변경을 어떻게 하나의 작업으로 묶을 것인가

Concurrency -> 동시에 접근할 때 어떻게 일관성을 유지할 것인가

Replication -> 데이터를 여러 서버에 어떻게 복제할 것인가

Partitioning -> 많은 데이터를 어떻게 나눠서 관리할 것인가

Cache -> 반복적인 조회를 어떻게 줄일 것인가

데이터베이스는 단순한 저장 공간이 아니다.

애플리케이션이 데이터를 어떻게 읽고 쓰는지, 여러 요청이 동시에 들어왔을 때 어떤 상태를 유지해야 하는지, 장애가 발생했을 때 데이터를 어떻게 보존할지까지 연결되어 있다.

개발할 때 데이터베이스를 단순히 SELECT와 INSERT를 사용하는 저장소로 생각하면 규모가 커질수록 문제가 생긴다.

데이터의 구조, 접근 패턴, 일관성, 동시성, 성능을 함께 설계하는 것이 데이터베이스를 제대로 사용하는 핵심이라고 보면 된다.