백엔드 서버에서 데이터베이스는 단순히 데이터를 저장하는 공간이 아니다. 데이터의 무결성을 보장하고, 여러 요청이 동시에 발생하는 상황을 제어하며, 필요한 데이터를 효율적으로 조회하는 핵심 시스템이다.
특히 게임 서버에서는 유저 정보, 인벤토리, 재화, 결제 내역, 게임 진행 기록 등 다양한 데이터를 관리한다. 데이터의 특성과 중요도에 따라 적절한 저장 구조를 설계해야 하며, 잘못된 설계는 데이터 불일치, 성능 저하, 동시성 문제로 이어질 수 있다.
이번 글에서는 RDBMS의 기본 개념부터 NoSQL과의 차이, 스키마 설계, 키와 제약 조건, 인덱스, 실행 계획, 쿼리 최적화, Connection Pool, 데이터베이스 마이그레이션까지 정리한다.
관계형 데이터베이스와 NoSQL 비교
RDBMS란?
RDBMS(Relational Database Management System)는 데이터를 테이블 형태로 관리하는 관계형 데이터베이스 관리 시스템이다.
테이블은 행(Row)과 열(Column)로 구성되며, 각 테이블은 기본키와 외래키 등을 이용해 서로 관계를 맺을 수 있다.
대표적인 RDBMS는 다음과 같다.
- MySQL
- PostgreSQL
- MariaDB
- Microsoft SQL Server
- Oracle Database
예를 들어 게임 서버에서 유저 정보를 저장하는 테이블은 다음과 같이 설계할 수 있다.
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
nickname VARCHAR(30) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
이 테이블은 유저의 고유 ID, 닉네임, 이메일, 생성 시각을 관리한다.
PRIMARY KEY는 각 유저를 식별하고, UNIQUE는 이메일 중복을 방지한다. 이러한 제약 조건을 데이터베이스에 설정하면 애플리케이션 코드의 실수로 잘못된 데이터가 저장되는 것을 방지할 수 있다.
NoSQL이란?
NoSQL은 전통적인 관계형 테이블 구조에 한정되지 않는 다양한 데이터베이스를 의미한다.
대표적인 유형은 다음과 같다.
| 유형 | 특징 | 대표 제품 |
|---|---|---|
| 문서형 | JSON과 유사한 문서 단위 저장 | MongoDB |
| 키-값형 | 키를 이용한 빠른 데이터 접근 | Redis |
| 컬럼 패밀리형 | 대규모 분산 데이터 처리에 적합 | Apache Cassandra |
| 그래프형 | 데이터 간 연결 관계를 중심으로 관리 | Neo4j |
NoSQL이라고 해서 모두 스키마가 없거나 트랜잭션을 지원하지 않는 것은 아니다. 데이터 모델과 일관성, 트랜잭션 지원 방식이 제품마다 다르다.
RDBMS와 NoSQL의 차이
| 비교 항목 | RDBMS | NoSQL |
|---|---|---|
| 데이터 모델 | 테이블과 관계 | 문서, 키-값 등 |
| 스키마 | 일반적으로 명시적 | 제품과 설계에 따라 유연함 |
| 관계 표현 | JOIN과 외래키 | 중첩, 참조, 별도 조회 등 |
| 트랜잭션 | 강력한 트랜잭션 지원 | 제품별로 지원 범위가 다름 |
| 확장 방식 | 수직 확장 및 다양한 분산 구성 | 수평 확장을 지원하는 제품이 많음 |
| 적합한 사례 | 재화, 결제, 계정 정보 | 캐시, 이벤트 데이터, 유연한 문서 데이터 |
게임 서버에서의 선택
게임 서버에서는 데이터의 성격에 따라 저장소를 구분할 수 있다.
- MySQL: 유저 정보, 인벤토리, 재화, 거래 및 결제 내역
- Redis: 세션, 캐시, 임시 상태, 만료가 필요한 데이터
- MongoDB: 문서 형태의 유연한 데이터나 특정 이벤트 데이터
- Loki: 애플리케이션 로그 검색 및 분석
여기서 중요한 점은 저장소의 종류보다 데이터의 요구사항이다.
예를 들어 유저의 재화가 100에서 50으로 감소했다면, 해당 변경은 정확하게 저장되어야 한다. 캐시나 로그만으로 이를 처리하면 장애 발생 시 데이터가 유실되거나 불일치할 수 있다.
따라서 영속성과 정합성이 중요한 데이터는 적절한 트랜잭션을 갖춘 저장소를 기준 데이터 저장소(Source of Truth)로 사용하는 것이 일반적이다.
스키마 설계와 정규화
스키마란?
스키마(Schema)는 데이터베이스의 구조를 정의한 것이다.
테이블의 이름, 컬럼, 자료형, 기본키, 외래키, 제약 조건 등을 포함한다.
예를 들어 유저와 아이템을 관리하는 데이터베이스를 다음과 같이 설계할 수 있다.
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
nickname VARCHAR(30) NOT NULL
);
CREATE TABLE items (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL
);
CREATE TABLE inventory (
user_id BIGINT NOT NULL,
item_id BIGINT NOT NULL,
quantity INT NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, item_id),
FOREIGN KEY (user_id)
REFERENCES users(id),
FOREIGN KEY (item_id)
REFERENCES items(id),
CHECK (quantity >= 0)
);
이 구조는 유저, 아이템, 인벤토리 정보를 분리한다.
inventory는 유저와 아이템의 관계를 표현하며, 각 유저가 특정 아이템을 몇 개 보유하고 있는지 저장한다.
동일한 유저가 동일한 아이템을 여러 행으로 중복 보유하지 않도록 복합 기본키 (user_id, item_id)를 사용했다.
단, 실제 아이템 시스템에서 장비마다 고유한 강화 수치나 내구도가 필요하다면 아이템 인스턴스를 별도로 모델링해야 한다.
정규화란?
정규화(Normalization)는 데이터의 중복을 줄이고 삽입, 수정, 삭제 과정에서 발생할 수 있는 이상 현상을 방지하도록 테이블 구조를 설계하는 과정이다.
예를 들어 다음과 같은 테이블이 있다고 가정한다.
| user_id | nickname | item_name | quantity |
|---|---|---|---|
| 1 | PlayerA | Sword | 1 |
| 1 | PlayerA | Potion | 10 |
| 2 | PlayerB | Sword | 1 |
이 구조에서는 동일한 유저의 닉네임이 여러 행에 반복된다.
닉네임이 변경되면 해당 유저의 모든 행을 수정해야 한다. 일부 행만 변경하면 서로 다른 닉네임이 동일한 유저에게 연결되는 문제가 발생할 수 있다.
정규화된 구조에서는 유저 정보와 아이템 정보를 별도 테이블로 분리하고, 외래키로 관계를 연결한다.
정규화의 주요 단계
제1정규형(1NF)
각 컬럼은 하나의 값만 가지도록 설계한다.
잘못된 예:
| user_id | items |
|---|---|
| 1 | Sword, Potion, Shield |
개선된 구조에서는 아이템마다 별도의 행을 사용하거나 인벤토리 테이블로 분리한다.
제2정규형(2NF)
제1정규형을 만족하면서 기본키의 일부에만 종속되는 속성을 제거한다.
예를 들어 기본키가 (user_id, item_id)인 테이블에서 nickname이 user_id에만 종속된다면 유저 정보 테이블로 분리하는 것이 적절하다.
제3정규형(3NF)
기본키가 아닌 속성이 다른 기본키 외 속성에 종속되는 이행적 종속을 제거한다.
예를 들어 유저 테이블에 department_id와 department_name을 함께 저장하고, 부서 이름이 부서 ID에 의해 결정된다면 부서 정보를 별도 테이블로 분리할 수 있다.
정규화와 성능의 균형
정규화는 중복과 데이터 이상을 줄이는 데 유리하지만, 지나치게 많은 테이블을 분리하면 조회할 때 JOIN이 늘어날 수 있다.
이 경우 실제 조회 패턴과 측정 결과를 바탕으로 일부 데이터를 중복 저장하는 반정규화(Denormalization)를 고려할 수 있다.
다만 중복 데이터를 사용한다면 동기화 규칙과 데이터 불일치 복구 전략이 필요하다.
설계 원칙은 정규화를 기본으로 하고, 측정된 성능 문제에 한해 의도적으로 반정규화하는 것이다.
기본키와 외래키
기본키(Primary Key)
기본키는 테이블의 각 행을 고유하게 식별하는 컬럼 또는 컬럼 조합이다.
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
nickname VARCHAR(30) NOT NULL
);
여기서 id는 각 유저를 식별하는 기본키다.
기본키는 중복될 수 없으며 NULL 값을 허용하지 않는다.
일반적으로 다음 두 가지 방식을 사용한다.
- 자연키(Natural Key): 이메일, 사업자 등록번호 등 실제 업무에서 의미가 있는 값
- 대리키(Surrogate Key): 별도의 ID를 생성해 식별자로 사용하는 방식
이메일을 기본키로 사용하면 이메일 변경 시 관계를 수정해야 할 수 있다. 따라서 변경 가능성이 있는 값보다는 별도의 ID를 기본키로 사용하는 경우가 많다.
외래키(Foreign Key)
외래키는 다른 테이블의 기본키 또는 유니크 키를 참조하여 테이블 간 관계의 무결성을 보장한다.
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
total_price BIGINT NOT NULL,
FOREIGN KEY (user_id)
REFERENCES users(id)
);
이 구조에서는 존재하지 않는 유저 ID를 주문에 연결할 수 없다.
외래키는 잘못된 참조 관계가 저장되는 것을 방지한다. 다만 외래키를 사용하지 않는 시스템에서는 애플리케이션에서 동일한 무결성을 보장하도록 별도 검증을 구현해야 한다.
삭제 정책
참조 대상이 삭제될 때의 동작도 설계해야 한다.
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE
주요 옵션은 다음과 같다.
CASCADE: 참조 대상이 삭제되면 관련 행도 삭제한다.RESTRICT또는NO ACTION: 참조 관계 때문에 삭제를 제한한다. 세부 동작은 DBMS에 따라 다를 수 있다.SET NULL: 참조 컬럼을 NULL로 변경한다. 해당 컬럼이 NULL을 허용해야 한다.
게임 서버의 결제 및 거래 데이터에 무조건 CASCADE를 적용하면 이력이 함께 삭제될 위험이 있다. 중요한 데이터는 삭제 대신 상태 변경이나 별도 보존 정책을 사용하는 편이 적절할 수 있다.
유니크 제약 조건(UNIQUE Constraint)
유니크 제약 조건은 특정 컬럼 또는 컬럼 조합의 값이 중복되지 않도록 제한한다.
예를 들어 이메일과 닉네임을 중복 없이 관리하려면 다음과 같이 설정할 수 있다.
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(255) NOT NULL,
nickname VARCHAR(30) NOT NULL,
UNIQUE (email),
UNIQUE (nickname)
);
이렇게 하면 두 요청이 동시에 동일한 닉네임을 등록하려고 해도 데이터베이스가 중복 저장을 차단한다.
애플리케이션에서 먼저 중복을 조회하는 것만으로는 충분하지 않다.
SELECT id
FROM users
WHERE nickname = 'PlayerA';
두 요청이 동시에 실행되면 다음과 같은 상황이 발생할 수 있다.
- 요청 A: 닉네임이 존재하지 않음을 확인
- 요청 B: 닉네임이 존재하지 않음을 확인
- 요청 A: 닉네임 등록
- 요청 B: 동일한 닉네임 등록 시도
이를 경쟁 조건(Race Condition)이라고 한다.
따라서 애플리케이션에서 사전 조회를 수행하더라도 데이터베이스에 유니크 제약 조건을 설정해야 한다.
유니크 제약 위반이 발생하면 해당 오류를 애플리케이션에서 식별하여 적절한 응답으로 변환한다.
예를 들어 닉네임 중복은 HTTP 409 Conflict로 반환할 수 있다.
참고로 NULL 처리 방식과 대소문자 비교 규칙은 DBMS 및 컬럼의 Collation 설정에 따라 다를 수 있다.
인덱스(Index)와 실행 계획(Execution Plan)
인덱스란?
인덱스는 데이터베이스가 특정 데이터를 더 빠르게 찾도록 지원하는 자료구조다.
책의 색인과 비슷하다. 색인이 없다면 원하는 내용을 찾기 위해 책 전체를 확인해야 하지만, 색인이 있다면 해당 내용이 있는 위치를 빠르게 찾을 수 있다.
MySQL의 InnoDB에서는 일반적인 보조 인덱스에 B+Tree 계열의 자료구조가 사용된다.
예를 들어 다음 쿼리를 실행한다고 가정한다.
SELECT *
FROM users
WHERE email = 'player@example.com';
이메일 인덱스가 없다면 데이터베이스는 많은 행을 검사해야 할 수 있다.
다음과 같이 인덱스를 생성하면 이메일을 기준으로 검색하는 데 도움이 된다.
CREATE INDEX idx_users_email
ON users(email);
단, 이미 UNIQUE(email)을 선언했다면 해당 유니크 제약을 구현하기 위한 인덱스가 생성되므로 같은 목적으로 별도 인덱스를 추가할 필요는 없다.
인덱스의 비용
인덱스는 조회 성능을 개선하지만 공짜가 아니다.
- 추가 저장 공간이 필요하다.
- INSERT, UPDATE, DELETE 시 인덱스도 관리해야 한다.
- 인덱스가 지나치게 많으면 쓰기 성능이 저하될 수 있다.
- 선택도가 낮은 컬럼은 단독 인덱스의 효율이 낮을 수 있다.
따라서 모든 컬럼에 인덱스를 생성하기보다는 실제 쿼리의 검색 조건, 정렬 조건, JOIN 조건을 기준으로 설계해야 한다.
복합 인덱스
여러 컬럼을 조합한 인덱스를 복합 인덱스(Composite Index)라고 한다.
CREATE INDEX idx_users_status_created
ON users(status, created_at);
다음 쿼리는 해당 인덱스를 활용할 가능성이 있다.
SELECT id, created_at
FROM users
WHERE status = 'ACTIVE'
ORDER BY created_at DESC;
복합 인덱스는 컬럼의 순서가 중요하다.
일반적으로 (status, created_at) 인덱스는 status 조건이 있는 조회에 유리하다. 반면 created_at만으로 조회할 때는 인덱스를 효율적으로 활용하지 못할 수 있다.
다만 실제 활용 여부는 데이터 분포, 쿼리 형태, 옵티마이저 판단에 따라 달라진다.
실행 계획 확인하기
실행 계획은 데이터베이스가 쿼리를 어떻게 실행할지 보여주는 정보다.
MySQL에서는 EXPLAIN을 사용한다.
EXPLAIN
SELECT id, nickname
FROM users
WHERE email = 'player@example.com';
주요 확인 항목은 다음과 같다.
| 항목 | 의미 |
|---|---|
type | 테이블 접근 방식 |
possible_keys | 사용 후보가 될 수 있는 인덱스 |
key | 실제 선택된 인덱스 |
rows | 검사할 것으로 예상되는 행 수 |
filtered | 조건 적용 후 남을 것으로 예상되는 비율 |
Extra | 추가 실행 정보 |
type이 ALL이면 일반적으로 전체 테이블 스캔을 의미한다. 하지만 테이블이 작거나 조회 대상이 대부분의 행이라면 전체 스캔이 더 효율적일 수도 있다.
반대로 인덱스를 사용한다고 무조건 빠른 것도 아니다. 인덱스를 통해 많은 행을 읽고 다시 테이블에 접근한다면 비효율적일 수 있다.
MySQL에서는 EXPLAIN ANALYZE를 통해 지원되는 쿼리의 실제 실행 통계를 확인할 수도 있다. 이 명령은 쿼리를 실제로 실행하므로 운영 환경에서는 부하와 실행 결과를 고려해야 한다.
쿼리 최적화(Query Optimization)
쿼리 최적화는 데이터베이스에서 필요한 데이터를 적은 비용으로 조회하고 변경하도록 SQL과 데이터 구조를 개선하는 과정이다.
핵심은 SQL 문법을 짧게 만드는 것이 아니라, 불필요한 데이터 접근과 작업을 줄이는 것이다.
필요한 컬럼만 조회하기
다음 쿼리는 모든 컬럼을 조회한다.
SELECT *
FROM users
WHERE id = 1;
실제 사용에 필요한 컬럼만 조회하는 편이 좋다.
SELECT id, nickname
FROM users
WHERE id = 1;
필요한 컬럼만 조회하면 네트워크 전송량과 데이터 처리 비용을 줄일 수 있다. 적절한 인덱스가 있다면 테이블에 추가 접근하지 않고 결과를 반환하는 커버링 인덱스(Covering Index)를 활용할 가능성도 생긴다.
인덱스를 활용할 수 있는 조건 작성하기
다음 쿼리는 컬럼에 함수를 적용한다.
SELECT *
FROM users
WHERE DATE(created_at) = '2026-10-24';
이런 조건은 일반적인 created_at 인덱스를 효율적으로 활용하기 어렵게 만들 수 있다.
대신 범위 조건으로 변경할 수 있다.
SELECT *
FROM users
WHERE created_at >= '2026-10-24 00:00:00'
AND created_at < '2026-10-25 00:00:00';
이 방식은 해당 날짜의 시작 시각 이상, 다음 날 시작 시각 미만인 데이터를 조회한다. 일반적인 인덱스 범위 검색에 적합한 형태다.
N+1 쿼리 문제
N+1 문제는 한 번의 목록 조회 후 각 항목을 처리하기 위해 추가 쿼리를 반복 실행하는 문제다.
const users = await getUsers();
for (const user of users) {
user.items = await getItemsByUserId(user.id);
}
유저를 한 번 조회한 뒤 유저 수만큼 인벤토리를 조회하므로, 유저가 100명이면 최소 101회의 조회가 발생할 수 있다.
가능하다면 일괄 조회로 개선한다.
SELECT
u.id,
u.nickname,
i.item_id,
i.quantity
FROM users AS u
LEFT JOIN inventory AS i
ON i.user_id = u.id
WHERE u.id IN (1, 2, 3, 4, 5);
애플리케이션에서는 반환된 행을 유저별로 그룹화하면 된다.
다만 JOIN은 결과 행 수를 늘릴 수 있으므로 여러 개의 일대다 관계를 동시에 JOIN할 때는 중복 행과 데이터 전송량을 주의해야 한다.
페이지네이션 최적화
많은 데이터를 조회할 때 LIMIT과 OFFSET을 사용할 수 있다.
SELECT id, nickname
FROM users
ORDER BY id
LIMIT 20 OFFSET 10000;
OFFSET이 커지면 앞쪽 행을 건너뛰는 비용이 커질 수 있다.
데이터가 많고 다음 페이지를 순차적으로 조회하는 경우에는 커서 기반 페이지네이션을 고려할 수 있다.
SELECT id, nickname
FROM users
WHERE id > 10000
ORDER BY id
LIMIT 20;
이 방식은 id가 적절한 인덱스를 가지고 있을 때 효율적이다. 실제로는 마지막으로 조회한 ID를 커서로 전달하면 된다.
최적화 작업 순서
쿼리 성능을 개선할 때는 다음 순서로 접근한다.
- 실제로 느린 쿼리를 식별한다.
- 실행 계획과 실제 실행 시간을 확인한다.
- 검사하는 행 수와 반환하는 행 수를 비교한다.
- 인덱스, JOIN, 정렬, 페이지네이션을 검토한다.
- 변경 전후의 성능을 동일한 조건에서 측정한다.
근거 없이 인덱스를 추가하거나 SQL을 복잡하게 변경하는 것은 최적화가 아니다. 측정 결과로 개선 효과를 검증해야 한다.
Connection Pool
Connection Pool이란?
Connection Pool은 데이터베이스 연결을 미리 생성하고 재사용하는 구조다.
애플리케이션이 쿼리를 실행할 때마다 데이터베이스 연결을 새로 생성하면 연결 설정과 해제에 비용이 발생한다. 동시 요청이 많은 서버에서는 이러한 비용이 누적될 수 있다.
Connection Pool은 연결을 일정 개수 유지하고, 요청이 연결을 빌려 사용한 뒤 반환하도록 한다.
Client Request
|
v
Application Server
|
v
Connection Pool
|
+---- Connection 1
+---- Connection 2
+---- Connection 3
|
v
Database
Node.js와 MySQL 예제
mysql2/promise를 사용하는 예제다.
import mysql from "mysql2/promise";
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 100
});
export async function getUserById(id: number) {
const [rows] = await pool.execute(
`
SELECT id, nickname
FROM users
WHERE id = ?
`,
[id]
);
return rows;
}
createPool()은 연결 풀을 생성한다. connectionLimit은 이 풀에서 유지할 수 있는 최대 연결 수를 설정하며, execute()의 매개변수 바인딩은 사용자 입력을 SQL 코드와 분리해 SQL Injection 위험을 줄이는 데 도움이 된다.
일반적인 단일 쿼리에는 위와 같은 풀 기반 실행을 사용할 수 있다. 여러 쿼리를 하나의 트랜잭션으로 묶을 때는 동일한 연결을 확보하고 모든 작업을 해당 연결에서 실행해야 한다.
Connection Pool 설정 시 고려 사항
연결 수
연결 수가 너무 적으면 요청이 대기할 수 있다. 너무 많으면 데이터베이스의 메모리와 처리 자원을 과도하게 사용할 수 있다.
또한 서버 프로세스가 여러 개라면 연결 수가 프로세스마다 별도로 계산된다.
예를 들어 애플리케이션 프로세스가 4개이고 각 풀의 연결 한도가 10이라면, 최대 40개의 연결을 만들 수 있다. 다른 서비스의 연결까지 고려하여 데이터베이스의 연결 한도를 설계해야 한다.
대기열
모든 연결이 사용 중이면 새 요청은 대기한다. 대기 시간이 지나치게 길어지면 API 응답 지연과 타임아웃으로 이어질 수 있다.
연결 해제
서버를 종료할 때는 풀을 정상적으로 종료하여 연결과 관련 자원을 정리해야 한다.
await pool.end();
실제 서버에서는 진행 중인 요청을 정리한 뒤 풀을 종료하도록 Graceful Shutdown 절차와 연결해야 한다.
트랜잭션
트랜잭션 도중 서로 다른 연결을 사용하면 하나의 원자적인 작업으로 처리되지 않는다. 따라서 트랜잭션 전체를 동일한 연결에서 실행하고, 성공 시 커밋하고 실패 시 롤백해야 한다.
데이터베이스 마이그레이션(Database Migration)
마이그레이션이란?
데이터베이스 마이그레이션은 데이터베이스의 스키마나 데이터를 버전별 변경 사항에 따라 관리하는 방법이다.
개발이 진행되면 새로운 컬럼을 추가하거나, 테이블을 분리하거나, 인덱스를 생성하는 등의 변경이 발생한다.
이때 개발자가 각 환경에서 SQL을 직접 실행하면 다음과 같은 문제가 발생할 수 있다.
- 개발 환경과 운영 환경의 스키마가 달라진다.
- 어떤 변경이 적용되었는지 추적하기 어렵다.
- 배포 과정에서 SQL 실행을 누락할 수 있다.
- 변경 순서가 달라져 애플리케이션 오류가 발생할 수 있다.
마이그레이션 도구는 이러한 변경을 버전으로 관리하고, 적용 여부를 추적하도록 돕는다.
대표적인 도구는 다음과 같다.
- Prisma Migrate
- TypeORM Migrations
- Knex Migrations
- Sequelize Migrations
- Flyway
- Liquibase
마이그레이션 예제
기존 users 테이블에 last_login_at 컬럼을 추가한다고 가정한다.
ALTER TABLE users
ADD COLUMN last_login_at TIMESTAMP NULL;
이 SQL을 마이그레이션 파일로 관리하면 변경 내용을 코드와 함께 버전 관리할 수 있다.
도구에 따라 마이그레이션은 up과 down 형태로 구성할 수 있다.
export async function up(queryRunner: QueryRunner) {
await queryRunner.query(`
ALTER TABLE users
ADD COLUMN last_login_at TIMESTAMP NULL
`);
}
export async function down(queryRunner: QueryRunner) {
await queryRunner.query(`
ALTER TABLE users
DROP COLUMN last_login_at
`);
}
위 코드는 TypeORM과 유사한 마이그레이션 인터페이스를 사용하는 예시다. 실제 프로젝트에서는 해당 ORM의 버전과 설정에 맞춰 QueryRunner 등을 가져와야 한다.
up은 변경을 적용하고, down은 변경을 되돌리는 역할을 한다. 그러나 모든 변경이 안전하게 되돌릴 수 있는 것은 아니다. 컬럼 삭제나 데이터 변환으로 정보가 사라졌다면 단순히 반대 SQL을 실행하는 것만으로 원상 복구할 수 없다.
안전한 배포 전략
운영 환경에서는 스키마 변경과 애플리케이션 배포의 순서가 중요하다.
예를 들어 새로운 필수 컬럼을 추가한다고 가정한다.
기존 애플리케이션이 해당 컬럼을 모르는 상태에서 즉시 필수 컬럼을 추가하면 기존 INSERT 쿼리가 실패할 수 있다.
이 문제를 줄이기 위해 확장-전환-축소(Expand-Migrate-Contract) 전략을 사용할 수 있다.
확장(Expand)
기존 애플리케이션과 호환되는 형태로 컬럼이나 테이블을 추가한다. 필요한 경우 nullable 컬럼이나 기본값을 고려한다.
전환(Migrate)
애플리케이션을 새 스키마와 호환되도록 배포하고, 기존 데이터가 있다면 백필(Backfill) 작업을 수행한다.
축소(Contract)
기존 애플리케이션이 더 이상 사용하지 않는 컬럼이나 구조를 제거한다. 이전 버전의 애플리케이션이 실행 중이지 않은지 확인한 뒤 진행한다.
이 방식은 무중단 배포가 필요한 서비스에서 특히 유용하다.
마이그레이션 운영 시 주의 사항
- 배포 전에 마이그레이션을 검증한다.
- 대용량 테이블의 인덱스 생성과 컬럼 변경에 따른 잠금 및 부하를 확인한다.
- 마이그레이션 실행 권한과 애플리케이션의 일반 DB 권한을 분리하는 방안을 고려한다.
- 중요한 데이터 변경은 백업과 복구 절차를 준비한다.
- 여러 서버가 동시에 마이그레이션을 실행하지 않도록 실행 주체를 통제한다.
- 데이터 손실 가능성이 있는 변경은 롤백 SQL만 믿지 말고 복구 계획을 마련한다.
마이그레이션은 단순히 SQL을 버전 관리하는 작업이 아니다. 애플리케이션 버전, 데이터 상태, 배포 순서까지 함께 관리하는 운영 절차다.