SQL이냐 NoSQL이냐는 잘못된 질문이다
"SQL이냐 NoSQL이냐"라는 질문 자체가 잘못됐다
이 질문을 받으면 저는 되묻고 싶어집니다. NoSQL이라고 뭉뚱그리는 것 안에는 문서 저장소(MongoDB), 키-값 저장소(DynamoDB, Redis), 와이드 컬럼(Cassandra), 그래프 DB(Neo4j)가 전부 섞여 있습니다. 이것들은 서로 SQL 데이터베이스와 다른 만큼이나 서로 다릅니다. "NoSQL을 쓰겠다"는 답은 "탈것을 타겠다"는 답과 비슷합니다. 자전거를 탈지 화물트럭을 탈지는 완전히 다른 결정인데도요.
진짜 물어야 할 질문은 데이터의 관계와 접근 패턴입니다. 데이터 간에 조인이 자주 필요하고 트랜잭션 정합성이 중요한가(주문-결제-재고처럼) — 그렇다면 관계형 DB가 여전히 기본값입니다. 반대로 데이터가 사실상 독립적인 문서들의 집합이고 스키마가 자주 바뀌는가(사용자 프로필, 상품 카탈로그) — 그렇다면 문서 저장소가 더 잘 맞을 수 있습니다. 조회 패턴이 "특정 키로 초고속 단건 조회"에 집중돼 있는가(세션, 캐시) — 그렇다면 키-값 저장소입니다.
복제 — 같은 데이터를 여러 벌 두는 이유
복제(replication)는 같은 데이터를 여러 서버에 복사해두는 것입니다. 두 가지를 동시에 얻으려는 목적입니다. 하나는 가용성 — 주 서버(primary)가 죽어도 복제본(replica) 중 하나를 새 주 서버로 승격시켜 서비스를 이어갈 수 있습니다. 다른 하나는 읽기 처리량 — 읽기 요청을 여러 복제본에 분산시켜 단일 서버의 부하를 낮춥니다.
flowchart LR App["애플리케이션"] -->|쓰기| Primary["Primary DB"] App -->|읽기| R1["Replica 1"] App -->|읽기| R2["Replica 2"] Primary -.->|비동기 복제| R1 Primary -.->|비동기 복제| R2
여기서 반드시 짚어야 하는 게 복제 지연(replication lag)입니다. Primary에 쓴 데이터가 Replica에 반영되기까지는 시간이 걸립니다. 보통 수십~수백 밀리초지만, 부하가 몰리면 몇 초까지 벌어지기도 합니다. 글을 쓴 사용자가 작성 직후 자기 글을 새로고침했는데 아직 복제가 안 된 Replica로 요청이 가면 글이 안 보입니다. 이건 버그가 아니라 복제를 쓰기로 한 순간부터 안고 가야 하는 트레이드오프입니다. 해결책은 "방금 쓴 데이터는 일정 시간 Primary에서 읽는다"처럼 읽기 경로를 상황에 따라 나누는 겁니다.
샤딩 — 데이터를 여러 서버에 쪼개는 이유
복제는 같은 데이터를 여러 벌 두는 거고, 샤딩(sharding)은 다른 데이터를 여러 서버에 나눠 담는 겁니다. 데이터 전체가 한 서버의 디스크·메모리·CPU 용량을 넘어설 때, 또는 쓰기 처리량 자체가 한 서버의 한계를 넘을 때 필요합니다. 복제로는 쓰기 처리량 문제를 못 풉니다 — 결국 모든 쓰기는 Primary 하나로 가니까요. 이 차이를 구분 못 하고 "복제하면 되죠"라고 답하면 감점 포인트입니다.
샤딩에서 가장 중요한 결정은 샤드 키(shard key) — 어떤 값을 기준으로 데이터를 나눌지입니다. 사용자 ID로 나눈다면 특정 사용자의 데이터는 항상 같은 샤드에 있으니 조회가 단순합니다. 문제는 특정 샤드로 트래픽이 쏠리는 핫스팟입니다. 유명인 계정 하나가 몰린 샤드는 다른 샤드보다 훨씬 바빠집니다.
# 단순 모듈러 샤딩: 샤드 개수가 바뀌면 거의 모든 키가 재배치된다
def shard_naive(user_id, num_shards):
return user_id % num_shards
# 일관된 해싱: 샤드 추가/제거 시 재배치되는 키의 비율을 최소화한다
class ConsistentHashRing:
def __init__(self, shards, virtual_nodes=150):
self.ring = {}
for shard in shards:
for i in range(virtual_nodes):
key = hash(f"{shard}-{i}")
self.ring[key] = shard
self.sorted_keys = sorted(self.ring.keys())
def get_shard(self, key):
h = hash(key)
for ring_key in self.sorted_keys:
if h <= ring_key:
return self.ring[ring_key]
return self.ring[self.sorted_keys[0]] # 링을 한 바퀴 돌아 처음으로단순 모듈러 샤딩(user_id % num_shards)은 이해하기 쉽지만 샤드를 하나 추가하는 순간 거의 모든 키의 나머지 값이 바뀌어 대규모 데이터 재배치가 일어납니다. 일관된 해싱은 가상 노드를 링 위에 흩뿌려서, 샤드가 늘거나 줄어도 그 근처 구간의 키만 이동하도록 만듭니다. 앞선 로드밸런싱 챕터에서 언급한 것과 같은 아이디어가 여기서도 그대로 쓰입니다 — "같은 키는 최대한 같은 곳으로, 구성이 바뀌어도 이동을 최소로"라는 원칙은 로드밸런서든 캐시든 DB 샤딩이든 반복됩니다.
조인이 사라지면 애플리케이션이 그 몫을 떠안는다
샤딩의 대가 중 잘 언급되지 않는 게 있습니다. 데이터가 여러 서버에 흩어지면 DB 레벨의 조인이 불가능해집니다. "사용자 A의 최근 주문과 그 주문의 상품 정보"를 한 번에 가져오는 쿼리가, 샤딩 이후에는 애플리케이션 코드에서 두 번 조회해 직접 합치는 형태로 바뀝니다. 이건 코드 복잡도가 늘어나는 것으로 끝나지 않고, 트랜잭션 정합성도 함께 잃습니다. 여러 샤드에 걸친 원자적 쓰기는 분산 트랜잭션이라는 훨씬 무거운 도구가 필요합니다. 그래서 저는 샤딩을 "정말 필요할 때만 쓰는 마지막 카드"라고 생각합니다. 복제와 인덱스 튜닝, 읽기 캐시로 버틸 수 있는 만큼 버틴 다음에 넘어가는 게 순서입니다.