티스토리 뷰
어쩌다보니 1편을 쓴지 거의 1년이 지나버렸다.. 다시 이어서 분석해보자.
클라이언트 라이브러리에서 데이터가 어떤 샤드에 있는지 찾는 기능을 캡슐화해서 구현했다면 그 다음은 흩어져 있는 새로운 소식들을 어떻게 찾고 모을지 고민해야 한다. 사실, 같은 게시판 DB를 여러 서버에 올려두고 그 속의 데이터만 사용자 고유 식별자의 해시 값에 따라 저장되는 서버가 달라지는 구성이기 때문에 기존의 단일 서버 구성과 다중 서버 구성상 서버별 검색은 다르지 않다. 하지만 다중 서버를 사용하는 경우에는 먼저 친구들의 데이터가 어떤 서버에 있는지 확인하고 병렬로 서버별 친구들을 묶어 검색한 후, 그 결과를 다시 모아 정렬하고 잘라내는 작업이 필요하다. 엎친 데 덮친 격으로 연산 비용이 커지지만 그나마 다행스럽게도 최신 데이터들이라면 앞서 적용한 캐시의 도움으로 빠르게 모아서 처리할 수 있을 것이다.
ok.
문제가 여기서 끝이라고 생각했다면 큰 오산이다. 소셜 피드의 수백 가지 형태에 대해서 고민해 보자. 근황, 사진, 비디오, 체크인, 좋아요, 공유, 댓글, 링크, 관계 맺음, 추천 등등. 게시판 관점에서 본다면, 하나의 게시물에는 족히 수백 개의 부가 필드가 필요할 것이다. 그리고 이 수백 개의 부가 필드를 위해 수천 대의 캐시와 DB 서버를 헤집고 다니며 구조를 변경하고 데이터를 구성해야 한다. 그렇다, 아홉 번째 지옥에 온 것을 환영한다. 어떻게 하면 최소한의 구조로 최대한의 표현력을 가지며 해당 정보들을 빠르게 읽을 수 있을까? 이 지옥을 탈출하기 위한 여러 선택지가 있겠지만, 기존의 게시판 관점에서 계속 생각을 확장해 보자.
ok.
먼저 게시물의 트랙백9 기능을 생각해 보자. 트랙백은 내부 게시물 관련 글을 외부 사이트에서 쓰면 정해진 프로토콜로 원래 글에 연결해 댓글처럼 표시되게 하는 것이다.
트랙백: 다른 사람의 글을 읽고 그 글에 직접 댓글을 올리는 대신에 자신의 블로그에 글을 올리고 글의 일정 부분이 다른 사람의 댓글로 보이도록 트랙백 핑을 보내는 것(https://terms.naver.com/entry.naver?docId=859634&cid=42346&categoryId=42346)
트랙백은 최근에는 사용하지 않는다. 그냥 개념만 알고 넘어가자.
| 옛날 (트랙백 시대) | 지금 |
| A글이 B글을 인용 → B에 트랙백 전송 | 그냥 링크 걸고 SNS/커뮤니티에 공유 |
| 블로그끼리 연결망 | 검색엔진 + SNS 알고리즘이 연결 |
만약, 트랙백을 외부 글에서 하는 것이 아니라 내부 간에 적용하면 어떻게 될까? 그리고 트랙백 종류별로 댓글, 위치, 첨부 파일 등으로 구분한다면 어떻게 될까? 내부 자료 구조는 매우 단순해진다. 원객체와 관계명 그리고 대상 객체 트리플10은 매우 단순한 구조이지만 'UserA friends UserB likes CarC likedBy UserD'와 같이 연결해 사용하면 표현력에 제한이 없다. 지금 설명하고 있는 것이 그래프 자료 구조라는 것을 이미 눈치챈 독자도 있을 듯하다. 이러한 그래프 자료 구조를 통해 최소한의 구조로 최대한의 표현력을 가질 수 있다.
바로 이해가 안됐다. 그리고 좀 보다보니 내가 이전에 학습한 내용과 관련이 있다는 것을 알게 되었다. 게시판 예시와 함께 살펴보자.
게시판 예시
유저가 글을 쓰고, 다른 유저가 좋아요 / 댓글 / 북마크를 다는 간단한 게시판이 있다. 나중에 신고 기능을 추가한다고 해보자. 그리고 기존의 일반적인 mysql 방식과 새로운 방식이 어떻게 다른지 비교해보자.
방식 A. 기존 방식 (관계마다 테이블 + FK)
스키마
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50)
);
CREATE TABLE posts (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT,
body TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 관계 종류마다 테이블을 하나씩 만든다
CREATE TABLE likes (
user_id BIGINT,
post_id BIGINT,
PRIMARY KEY (user_id, post_id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (post_id) REFERENCES posts(id)
);
CREATE TABLE comments (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT,
post_id BIGINT,
text TEXT, -- 댓글은 '값'이 있음
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (post_id) REFERENCES posts(id)
);
CREATE TABLE bookmarks (
user_id BIGINT,
post_id BIGINT,
PRIMARY KEY (user_id, post_id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (post_id) REFERENCES posts(id)
);
새 기능을 추가하려면
-- 새 테이블을 만들어야 한다 ← 스키마 변경(마이그레이션)
CREATE TABLE reports (
user_id BIGINT,
post_id BIGINT,
reason VARCHAR(200),
PRIMARY KEY (user_id, post_id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (post_id) REFERENCES posts(id)
);
이 방식은 명확하다. 인덱스/제약이 강력하고 쿼리가 직관적이다. 좋아요 수 세기도 쉽다. 일반적인 규모에선 이게 정답이다.
하지만 대규모 데이터를 처리해할 때는 얘기가 달라진다. 기능이 추가될 때마다 테이블을 추가하거나 마이그레이션이 필요하다. 서버가 수천 대로 샤딩되면 이 마이그레이션이 지옥이 된다.
방식 B. 글에서 말한 방식 (트리플 / 그래프)
관계 종류를 테이블(스키마)이 아니라 데이터(row 값)로 둔다.
스키마
-- 값(내용)을 가진 것 = 객체(노드). 종류는 type, 나머지는 JSON에 자유롭게
CREATE TABLE nodes (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
type VARCHAR(30), -- 'user' | 'post' | 'comment' ...
data JSON -- { "name": "..." } / { "body": "..." } / { "text": "..." }
);
-- 연결(관계) = 엣지. '관계 종류'는 relation 컬럼 값으로
CREATE TABLE edges (
from_id BIGINT,
relation VARCHAR(30), -- 'wrote' | 'likes' | 'commented' | 'bookmarks' ...
to_id BIGINT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (from_id, relation, to_id),
KEY idx_incoming (to_id, relation) -- '이 글에 달린 좋아요' 조회용
);
-- ※ FK 없음: 노드가 샤딩으로 흩어지면 어차피 못 걸기 때문
- 좋아요 (값 없음) → edges 한 줄
- 댓글 (내용 있음) → nodes에 comment 객체로 만들고, edges로 엮음
방식 A에서의 문제를 해결했다. 기능이 수백 개여도 스키마는 그대로다. 새로운 관계가 생기면 row를 추가하면 된다.
다만, 집계/조회가 번거롭고 느려질 수 있다. FK도 없어서 정합성을 db 레벨이 아니라 앱 레벨에서 맞춰야한다.
참고로 좋아요 수 집계 문제는 방식 A든 B든 스키마 설계와 무관하다. 데이터가 일정 규모를 넘으면 매번 COUNT(또는 GROUP BY)를 실시간으로 돌리는 것 자체가 몇십 초, 몇백 초씩 걸려서 현실적으로 불가능해진다. 그래서 결국 (1) 단건 숫자(좋아요 수 등)는 실시간으로 증감시키는 카운터 캐시, (2) 복잡한 집계(전체 통계, 여러 유저/게시물에 걸친 수치 등)는 배치로 미리 계산해두는 통계 테이블 등으로 따로 관리하게 된다.
즉 이 집계 문제는 A를 고수해도 어차피 별도로 풀어야 하는 숙제다. 그렇다면 B를 선택해서 얻는 "집계가 더 번거로워진다"는 손해는 생각보다 크지 않다. 애초에 대규모에서는 A도 카운터 캐시·통계 테이블 없이는 못 버티기 때문이다.
한 장 요약
| 방식 A (기존) | 방식 B (트리플/그래프) | |
| 관계 종류를 | 테이블(스키마)로 | row 값(relation)으로 |
| 값(댓글 내용 등) | 각 테이블 컬럼에 | 객체(node)의 JSON에 |
| 새 기능 추가 | 새 테이블 + 마이그레이션 | row 하나 |
| FK / JOIN | 강력하게 사용 | 안 씀 (분산이라 못 씀) |
| 언제 유리 | 일반 규모 (대부분) | 초대형 샤딩 / SNS 규모 |
(좋아요 수 집계·통계 테이블 필요 여부는 A/B 공통 이슈라 표에서 뺐다. 위에서 다뤘듯 규모의 문제지 스키마의 문제가 아니다.)
방식 B는 신기술이 아니라, "관계 종류를 스키마에서 데이터로 옮긴" 것뿐이다.
작은 규모면 방식 A가 명확해서 낫고, 관계가 수십억 개로 샤딩되면 스키마를 못 건드려서 어쩔 수 없이 방식 B로 간다.
관련 개념 1. DDD의 aggregate(집합체)
DDD를 공부하면 aggregate 라는 개념이 나온다. 가볍게만 알아보자.
- aggregate: 함께 변경되고 함께 일관성을 지켜야 하는 객체 묶음. 최상위 엔티티를 aggregate 루트라 부른다.
- 값 객체(VO)는 정체성이 없으니 루트 안에 통째로 embed 한다.
- 다른 aggregate는 객체가 아니라 id로만 참조한다.
즉 DDD의 규율은 한 줄로 "경계(aggregate) 안은 통째로 담고, 경계를 넘는 연결은 id로만 끊어라."
- Order 안의 OrderLine → 같은 aggregate → 통째로 저장 ✅
- Order 안의 Customer → 다른 aggregate → 통째 저장 ❌, id 참조 ✅
관련 개념 2. MSA에서 FK를 빼는 것
요즘 많은 서비스가 DB 외래 키(FK)를 안 쓴다. 나도 처음 들었을 때는 "왜??" 라는 생각이 들었다. 이것도 가볍게만 알아보자.
왜 FK를 못 쓰나
MSA는 서비스마다 DB가 물리적으로 분리된다 (database per service). FK와 JOIN은 같은 DB 안에서만 되니까, 다른 서버에 있는 유저 서비스 테이블은 아예 못 건다.
-- [주문 서비스 DB] ← 유저 서비스와 다른 서버
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT, -- 그냥 값으로만 들고 있음. FK 아님!
amount INT
-- FOREIGN KEY (user_id) REFERENCES users(id) ← 다른 DB라 불가능
);
그래서 user_id를 평범한 컬럼으로 들고, 유저 정보가 필요하면 유저 서비스에 따로 조회해서 앱에서 조합한다. (JOIN을 앱이 대신 하는 셈)
대신 치르는 비용
FK가 해주던 걸 이제 앱이 책임진다.
- 정합성: "없는 user_id는 못 넣게" 막아주던 걸 이제 앱 코드가 검증.
- 삭제 정합성: 유저를 지워도 주문의
user_id는 남는다 (dangling). 이벤트로 정리하거나 그냥 둔다. - 일관성 모델: 두 서비스를 한 트랜잭션으로 못 묶는다. 근데 사실 "항상 즉시 정합성이 맞아야 한다"는 건 개발자만의 강박에 가깝다. 쇼핑몰 주문도 다음날 취소되고 심지어 결제도 나중에 정정된다. 지금 딱 안 맞아도 나중에 맞추면 된다.
- 이걸 eventual consistency라고 부른다. 어느 순간 저절로 맞아떨어진다는 뜻이 아니라, 계속 맞춰나간다(continuous consistency)고 생각하면 된다.
사실, 셋은 다 같은 이야기다
방식 B(트리플/그래프), DDD aggregate, MSA FK 제거 — 출발점도 분야도 다르지만,
한 문장으로 전부 묶인다:
경계 안은 통째로 다루고, 경계를 넘는 연결은 id로만 끊는다.
| 이름 | 경계 안 | 경계 밖 |
| SNS 트리플/그래프 (방식 B) | node(JSON) 통째로 | edge로 연결 |
| DDD aggregate | aggregate 통째로 (VO embed) | aggregate 루트 id 참조 |
| MSA (FK 제거) | 서비스 내 정상 테이블 | id로만, FK 없음 |
세 줄 다 왼쪽은 "통째로", 오른쪽은 "id 참조"다. 부르는 이름만 node/aggregate/서비스로 다를 뿐,
경계를 긋고, 경계 밖은 id로 끊는다는 발상은 완전히 같다.
애초에 경계를 그을 수밖에 없는 이유도 같다 — 데이터가 분산돼 있어서다. (SNS는 샤딩으로, DDD는 도메인 별로, MSA는 서비스별로 쪼개진다.)
그리고 경계를 넘는 순간 공통으로 포기하는 것도 같다 — FK, JOIN, 단일 트랜잭션.
그 대가로 정합성·일관성을 앱이 떠안는 것까지 똑같다.
이름은 다양하지만 결국 서비스가 커졌을 때 적당한 단위의 추상화 블록을 만드는 것으로 통한다.
닳도록 익숙한 게시판이 여기까지 왔다. 주커버그가 기숙사 방에 틀어박혀 처음 Facebook을 만들기 시작했을 때, 바로 이렇게 시작했다. 지금까지 길고 번거롭게 설명하면서 진화시켜온 모델이 현재 Facebook 아키텍처가 발전해 온 모습인 것이다. 이 진화의 최종, 즉 현재 버전에서 가장 크게 발전한 부분이 캐시 계층이다. 기존에 memcached를 look-ahead 캐시로 사용했다면 이제는 TAO(The Associations and Objects)라는 그래프 자료 구조의 write-thru 캐시로 대체한 것이다. 기존에는 프로그램 로직에서 캐시도 관리하고 DB도 관리해야 했지만, 이제는 TAO에만 쓰면 내부적으로 알아서 영구 저장소(MySQL)에 저장한다. TAO 자체에 대한 더욱 상세한 내용은 "TAO: Facebook's Distributed Data Store for the Social Graph"에서 확인할 수 있다.
ok.
남은 부분은 3편에서..
원본링크
'기타' 카테고리의 다른 글
| 소셜 네트워크 서비스의 아키텍처에 대하여 분석하기 1편(feat. Range-based Partitioning, consistent hashing (14) | 2025.08.31 |
|---|
