Relational Databases & SQL
🎯 학습 목표
- ERD를 보고 테이블 간 관계(1:N, N:M)를 이해하고 직접 설계할 수 있다
- JOIN, GROUP BY, 서브쿼리를 사용해 복잡한 데이터를 추출할 수 있다
- 인덱스가 쿼리 성능에 미치는 영향을 EXPLAIN으로 확인할 수 있다
- 트랜잭션과 ACID 속성을 이해하고 실제 금융/주문 시스템에 적용할 수 있다
- PostgreSQL의 JSON 타입을 활용해 구조화·비구조화 데이터를 함께 저장할 수 있다
데이터베이스는 모든 백엔드 시스템의 심장입니다. AI 모델이 아무리 뛰어나도, 학습 데이터·사용자 정보·실험 결과를 안전하게 저장하고 빠르게 조회하지 못하면 서비스가 될 수 없습니다. 관계형 데이터베이스(RDBMS)는 50년 이상 검증된 기술로, 2024년에도 여전히 대부분의 프로덕션 시스템에서 핵심 데이터 저장소로 사용됩니다.
SQL(Structured Query Language)은 데이터베이스와 대화하는 언어입니다. Python이나 JavaScript와 달리 선언적 언어입니다 — "어떻게 가져와라"가 아니라 "무엇을 원한다"고 말하면 데이터베이스 엔진이 최적의 방법을 찾습니다. 이 점이 처음에는 어색하지만, 익숙해지면 강력한 표현력을 제공합니다.
AI 엔지니어가 SQL을 알아야 하는 이유는 명확합니다. 모델 학습 데이터는 대부분 데이터베이스에서 나오고, 실험 결과와 모델 메타데이터는 데이터베이스에 저장되며, A/B 테스트 분석도 SQL 쿼리로 합니다. MLflow, Weights & Biases 같은 ML 플랫폼도 내부적으로 SQL DB를 사용합니다.
핵심 내용
관계형 모델: 테이블, 키, 관계
관계형 데이터베이스는 데이터를 테이블(Table) 로 저장합니다. 테이블은 엑셀 시트와 비슷하지만, 훨씬 엄격한 규칙이 있습니다. 각 테이블은 행(Row, 레코드) 과 열(Column, 속성) 으로 구성됩니다.
기본 키(Primary Key, PK) 는 테이블 내에서 각 행을 유일하게 식별하는 열입니다. 중복이 없고 NULL이 될 수 없습니다. 보통 id 열에 자동 증가 정수(SERIAL or BIGSERIAL) 또는 UUID를 사용합니다.
외래 키(Foreign Key, FK) 는 다른 테이블의 기본 키를 참조하는 열입니다. 테이블 간의 관계를 표현합니다. 예를 들어 orders.user_id는 users.id를 참조하는 외래 키입니다.
테이블 간 관계는 세 가지 유형이 있습니다.
- 1:1 관계: 사용자 ↔ 사용자 프로필 (한 사용자에 프로필 하나) - 1:N 관계: 사용자 ↔ 주문 (한 사용자가 여러 주문) - N:M 관계: 모델 ↔ 태그 (한 모델에 여러 태그, 한 태그가 여러 모델). 중간 테이블(Junction Table)이 필요합니다.
SQL 핵심 문법: CRUD 그 이상
SQL은 크게 DDL(테이블 정의), DML(데이터 조작), DCL(권한 관리)로 나뉩니다. 실무에서 가장 많이 쓰는 것은 DML의 SELECT입니다.
기본 SELECT 구조는 SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT 순서로 작성합니다. 실제 실행 순서는 FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT입니다.
JOIN 은 여러 테이블을 결합합니다.
- INNER JOIN: 양쪽 테이블에 모두 존재하는 행만 반환
- LEFT JOIN: 왼쪽 테이블 전체 + 오른쪽에 매칭되는 행 (없으면 NULL)
- RIGHT JOIN: 오른쪽 테이블 전체 + 왼쪽에 매칭되는 행
집계 함수와 GROUP BY는 데이터를 요약합니다. COUNT, SUM, AVG, MAX, MIN을 GROUP BY와 함께 사용합니다. HAVING은 그룹화 이후에 필터링할 때 씁니다(WHERE는 그룹화 이전).
서브쿼리와 CTE는 복잡한 쿼리를 가독성 있게 분리합니다. WITH cte_name AS (SELECT ...) 형태의 CTE(Common Table Expression)는 재사용 가능한 임시 뷰입니다.
인덱스: 검색 속도의 핵심
인덱스 없는 데이터베이스 쿼리는 책의 목차 없이 내용을 찾는 것과 같습니다. 10행짜리 테이블에서는 전체를 뒤져도 빠르지만, 1억 행 테이블에서는 인덱스 없이 WHERE user_id = 12345 쿼리를 실행하면 전체 행을 스캔(Seq Scan)합니다.
B-Tree 인덱스 는 PostgreSQL의 기본 인덱스 타입입니다. 정렬된 트리 구조로 =, <, >, BETWEEN, LIKE 'prefix%' 조건에 효율적입니다.
복합 인덱스(Composite Index) 는 여러 열을 묶어서 인덱스를 만듭니다. CREATE INDEX ON events (user_id, created_at)처럼 만들면 WHERE user_id = ? ORDER BY created_at 쿼리를 인덱스만으로 처리할 수 있습니다. 열 순서가 중요합니다 — 첫 번째 열이 필터 조건에 없으면 인덱스가 사용되지 않습니다.
`EXPLAIN ANALYZE` 로 쿼리 실행 계획을 확인합니다. Seq Scan이 보이면 인덱스가 필요하다는 신호입니다. Index Scan이나 Bitmap Index Scan이 보이면 인덱스를 잘 활용하고 있습니다.
트랜잭션과 ACID 보장
트랜잭션은 하나의 논리적 작업 단위입니다. 계좌 이체를 예로 들면, A 계좌에서 빼고 B 계좌에 더하는 두 작업이 하나의 트랜잭션이어야 합니다. 중간에 서버가 죽으면 두 작업이 모두 취소되어야 합니다.
RDBMS는 ACID 속성으로 트랜잭션의 신뢰성을 보장합니다.
A(Atomicity, 원자성) = 트랜잭션 내 모든 작업이 전부 성공하거나 전부 실패합니다.
C(Consistency, 일관성) = 트랜잭션 전후로 데이터베이스의 무결성 제약(FK, NOT NULL, UNIQUE 등)이 유지됩니다.
I(Isolation, 격리성) = 동시에 실행되는 트랜잭션들이 서로 영향을 주지 않습니다. 격리 수준(Read Uncommitted → Read Committed → Repeatable Read → Serializable)에 따라 성능과 일관성을 트레이드오프합니다.
D(Durability, 지속성) = 커밋된 트랜잭션은 시스템 장애가 나도 유지됩니다. WAL(Write-Ahead Log)이 이를 보장합니다.
PostgreSQL: AI 엔지니어의 데이터베이스
PostgreSQL은 오픈소스 RDBMS 중 가장 기능이 풍부하고 확장성이 높습니다. AI/ML 워크플로에서 특히 유용한 기능들이 있습니다.
JSONB 타입 은 JSON을 바이너리 형태로 저장하고 인덱싱을 지원합니다. 스키마가 유동적인 모델 하이퍼파라미터나 실험 설정을 저장할 때 유용합니다. SELECT config->'learning_rate' 같은 JSON 경로 쿼리를 지원합니다.
`pgvector` 확장 을 사용하면 임베딩 벡터를 PostgreSQL에 저장하고 근사 최근접 이웃(ANN) 검색을 할 수 있습니다. Pinecone 같은 전용 벡터 DB 없이도 RAG 시스템을 구축할 수 있습니다.
`pg_cron` 으로 데이터 정리, 통계 집계, 모델 학습 트리거 등을 SQL 스케줄러로 실행할 수 있습니다.
파티셔닝(Partitioning)은 대용량 테이블을 날짜나 범위로 분할하여 쿼리 성능을 높입니다. 수억 건의 로그 데이터를 월별로 파티셔닝하면 최근 한 달 조회 시 나머지 파티션은 건너뜁니다.
💡 비유로 이해하기
관계형 데이터베이스를 처음 이해하는 데 가장 좋은 비유는 엑셀 시트입니다. 테이블 = 시트, 행 = 데이터 한 줄, 열 = 각 항목(이름, 나이, 이메일). 엑셀로 고객 목록을 관리할 수 있죠.
그런데 엑셀의 한계가 있습니다. 고객 한 명이 여러 주문을 하면 어떻게 하죠? 같은 시트에 주문마다 행을 추가하면 고객 이름이 중복됩니다. 고객이 이름을 바꾸면 모든 행을 찾아서 수정해야 합니다. 이것이 데이터 중복과 업데이트 이상 문제입니다.
관계형 데이터베이스는 이 문제를 정규화(Normalization) 로 해결합니다. 고객 시트와 주문 시트를 분리하고, 주문 시트에 고객 ID만 저장합니다. 이제 고객 이름이 바뀌어도 고객 시트 한 곳만 수정하면 됩니다. JOIN은 두 시트를 고객 ID 기준으로 합쳐서 보여주는 VLOOKUP의 슈퍼 버전입니다.
💻 코드 예시
Python의 asyncpg 라이브러리로 PostgreSQL에 비동기 연결하고, ML 실험 결과를 저장·조회하는 패턴을 구현합니다. 실제 MLOps 시스템에서 사용하는 구조입니다.
import asyncpg
import asyncio
from typing import Optional
async def get_db_pool():
return await asyncpg.create_pool(
dsn="postgresql://user:password@localhost:5432/mlops",
min_size=2,
max_size=10, # 최대 10개 동시 연결
)
async def save_experiment(pool, run_id: str, metrics: dict, config: dict):
async with pool.acquire() as conn:
# JSONB로 유동적인 설정·지표 저장
await conn.execute("""
INSERT INTO experiments (run_id, metrics, config, created_at)
VALUES ($1, $2::jsonb, $3::jsonb, NOW())
ON CONFLICT (run_id) DO UPDATE
SET metrics = EXCLUDED.metrics
""", run_id, str(metrics).replace("'", '"'), str(config).replace("'", '"'))
async def get_top_experiments(pool, metric: str = "accuracy", top_n: int = 5):
async with pool.acquire() as conn:
rows = await conn.fetch("""
SELECT
run_id,
metrics->>'accuracy' AS accuracy,
metrics->>'loss' AS loss,
config->>'learning_rate' AS lr,
created_at
FROM experiments
WHERE metrics->>$1 IS NOT NULL
ORDER BY (metrics->>'accuracy')::float DESC
LIMIT $2
""", metric, top_n)
return [dict(r) for r in rows]
async def main():
pool = await get_db_pool()
# 실험 결과 저장
await save_experiment(
pool,
run_id="exp-2024-001",
metrics={"accuracy": 0.923, "loss": 0.187},
config={"learning_rate": 0.001, "batch_size": 32},
)
# 상위 5개 실험 조회
top = await get_top_experiments(pool)
for exp in top:
print(exp)
await pool.close()
asyncio.run(main())
create_pool은 커넥션 풀을 생성합니다. 매 요청마다 새 연결을 여닫는 비용을 없애고, max_size=10으로 동시 쿼리 수를 제한합니다. \(1`, `\)2 플레이스홀더는 SQL 인젝션을 방지하는 파라미터화된 쿼리입니다. metrics->>'accuracy' 는 JSONB에서 특정 키의 값을 문자열로 추출하고, ::float 캐스트로 숫자 정렬합니다.
🏭 현업에서의 평가
✅ 시니어가 보는 것
- N+1 쿼리 문제를 인식하고 JOIN이나 서브쿼리로 해결할 수 있는가
- 인덱스를 무분별하게 추가하지 않고 쓰기 성능 비용을 인지하는가
- 트랜잭션 격리 수준 선택의 이유를 설명할 수 있는가
- EXPLAIN ANALYZE 결과를 읽고 병목을 찾을 수 있는가
⚠️ 레드 플래그
- ORM만 쓰고 실제 실행되는 SQL을 확인하지 않는 경우
- 모든 열에 인덱스를 만들면 빠를 것이라고 생각하는 경우
- 트랜잭션 없이 여러 테이블을 수정하는 코드를 작성하는 경우
🎤 예상 인터뷰 질문
- 1억 건 로그 테이블에서 특정 사용자의 최근 7일 활동을 빠르게 조회하려면 어떤 인덱스를 만들겠어요?
- ORM을 쓸 때 N+1 문제가 발생하는 상황을 설명하고, 어떻게 해결하시겠어요?
- 동시에 같은 재고를 구매하는 두 요청이 들어올 때 재고가 마이너스가 되지 않도록 하려면 어떻게 하나요?
✨ 핵심 요약
정규화로 중복 제거
데이터는 한 곳에만 저장하고 키로 참조한다. 중복은 업데이트 이상을 유발한다.
SELECT 실행 순서
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. 작성 순서와 다르다.
인덱스는 공짜가 아니다
인덱스는 읽기를 빠르게 하지만 쓰기를 느리게 하고 저장 공간을 추가로 사용한다.
EXPLAIN ANALYZE
쿼리 성능 문제는 추측하지 말고 EXPLAIN ANALYZE로 실행 계획을 확인한다.
ACID = 신뢰성
트랜잭션의 ACID 속성이 데이터 무결성을 보장한다. 돈을 다루는 모든 로직은 트랜잭션 안에 있어야 한다.
JSONB for 유연성
PostgreSQL의 JSONB는 구조화·비구조화 데이터를 함께 저장하면서도 인덱싱과 쿼리를 지원한다.
pgvector for RAG
pgvector 확장으로 PostgreSQL이 벡터 DB가 될 수 있다. 별도의 Pinecone 없이 RAG를 구축할 수 있다.