Google Drive 유형의 파일 저장소 및 공유 시스템 설계
1. 개요
이 글에서는 Google Drive와 유사한 파일 저장소 및 공유 시스템을 설계합니다. 내용은 다음 순서를 따릅니다: 요구사항 확정, 데이터 모델 구축, API 정의, 업로드 및 동기화 흐름 설명. 이후 글은 쓰기 충돌, 일관성 및 트래픽 급증이라는 세 가지 어려운 부분에 중점을 둡니다.
글을 읽고 나면 시스템 설계 면접에 사용할 수 있는 설계안을 갖게 될 것입니다. 또한 핵심 부분을 설명하는 Spring Boot 코드도 제공됩니다.
2. 요구사항
2.1. 기능 요구사항
- 파일 업로드 및 다운로드, 최대 크기 10GB.
- 파일을 디렉터리 트리로 구성, 이름 바꾸기 및 이동 지원.
VIEWER또는EDITOR권한으로 파일 또는 폴더를 다른 사람과 공유. 공개 링크도 지원.- 버전 기록을 저장하고 이전 버전으로의 복원 허용.
- 같은 사용자의 여러 기기 간의 변경사항 동기화.
2.2. 규모 및 제약사항
계산을 위한 가정:
사용자: 5천만, DAU: 1천만
평균 업로드: 2 파일/사람/일, ~500KB/파일
=> ~2천만 파일/일, ~230 파일/초(피크 x5 ≈ 1,200/초)
=> ~10TB 신규 데이터/일, ~3.6PB/년(복제본 제외)
읽기:쓰기 비율 ≈ 5:1
이 수치로부터 세 가지 제약을 도출할 수 있습니다:
- 데이터 손실 없음. 내구성은 “11개의 9”(eleven nines) 수준에 도달해야 합니다. 따라서 파일 콘텐츠는 S3 같은 object storage에 저장되어야 하며, 앱 서버 디스크에 저장되면 안 됩니다.
- 메타데이터는 강한 일관성이 필요합니다. 한 파일이 두 개의 ’최신 버전’을 가질 수 없습니다. 이를 위해 관계형 데이터베이스를 사용합니다.
- 앱 서버가 대역폭의 병목이 되면 안 됩니다. 파일의 바이트는 클라이언트에서 스토리지로 직접 이동해야 합니다.
3. 전체 아키텍처
Client ──► API Gateway (인증, rate limit) ──► Metadata Service ──► PostgreSQL (owner_id로 샤드됨)
│ │
│ presigned URL ├──► Redis (권한 캐시, rate limit)
▼ └──► Outbox ──► Kafka ──► Notification / 검색 / 썸네일
Object Storage (S3) ◄── CDN (공유 파일 다운로드)
핵심 아이디어는 메타데이터를 콘텐츠와 분리하는 것입니다. 파일은 4MB 청크로 나뉩니다. 각 청크는 내용의 SHA-256 해시로 식별됩니다(content-addressed). 이 접근 방식은 세 가지 이점을 제공합니다:
- 무료 중복 제거. 동일한 두 청크는 한 번만 저장됩니다.
- 업로드 재개. 네트워크가 끊기면 클라이언트는 누락된 청크만 다시 보내면 됩니다.
- Delta 동기화. 1GB 파일의 작은 부분을 수정하면 몇 개의 청크만 업로드하면 됩니다.
4. 데이터 모델
CREATE TABLE files (
id UUID PRIMARY KEY,
owner_id BIGINT NOT NULL,
parent_id UUID REFERENCES files(id),
name VARCHAR(255) NOT NULL,
is_folder BOOLEAN NOT NULL,
head_version INT NOT NULL DEFAULT 0,
size_bytes BIGINT NOT NULL DEFAULT 0,
deleted_at TIMESTAMPTZ,
UNIQUE (parent_id, name)
);
CREATE TABLE file_versions (
file_id UUID NOT NULL REFERENCES files(id),
version INT NOT NULL,
block_list TEXT[] NOT NULL, -- 해시 목록(순서 유지)
created_by BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (file_id, version)
);
CREATE TABLE blocks (
hash CHAR(64) PRIMARY KEY, -- SHA-256 hex
size_bytes INT NOT NULL,
ref_count BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE shares (
file_id UUID NOT NULL REFERENCES files(id),
grantee_id BIGINT NOT NULL,
role VARCHAR(10) NOT NULL, -- VIEWER | EDITOR
PRIMARY KEY (file_id, grantee_id)
);
CREATE TABLE change_journal (
seq BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
file_id UUID NOT NULL,
action VARCHAR(20) NOT NULL
);
스키마의 각 부분의 역할:
head_version은 각 파일의 낙관적 잠금 열입니다(6.1 섹션 참조).file_versions.block_list는 각 버전을 해시 목록으로 변환합니다. 따라서 이전 버전으로의 복원은 단순히 이전 block_list를 가리키는 새 버전을 만드는 것이며, 바이트를 복사할 필요가 없습니다.blocks.ref_count는 garbage collector가 사용 중인 청크가 없는 청크를 S3에서 삭제할 시기를 알 수 있도록 합니다.change_journal은 사용자별 변경 로그입니다. 기기는 seq(커서)부터의 항목을 읽어 동기화합니다.UNIQUE (parent_id, name)은 DB 계층에서 한 디렉터리 내에 중복된 이름의 파일이 두 개 있는 것을 방지합니다.
참고: ’부모 폴더가 공유되면 자식 파일을 볼 수 있음’과 같이 디렉터리 트리에 따라 권한을 확인하는 것은 재귀 쿼리이며 비용이 많이 듭니다. Redis에서 key
perm:{userId}:{fileId}형식의 권한 결과를 짧은 TTL로 캐시합니다. 공유가 변경될 때 캐시를 삭제합니다.
5. API 및 업로드 흐름
업로드는 세 단계로 진행됩니다:
POST /api/files/{fileId}/uploads {baseVersion, blocks:[hash...]} -> {missing:[{hash,url}]}
PUT <presigned-url> (클라이언트가 청크를 S3로 직접 전송)
POST /api/files/{fileId}/commit {baseVersion, blocks:[hash...]} -> {version}
GET /api/changes?cursor=1234 -> {changes:[...], nextCursor}
필요한 Maven 의존성:
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<version>2.28.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
5.1. 누락된 청크 식별
중복 제거 로직은 테스트하기 쉽도록 순수 Java로 작성해야 합니다:
public class ChunkPlanner {
public List<String> missingBlocks(List<String> requested, Set<String> existing) {
return requested.stream()
.distinct()
.filter(hash -> !existing.contains(hash))
.toList();
}
}
클라이언트는 순서대로 해시 목록을 보냅니다. 서버는 blocks 테이블을 조회하여 기존 집합을 가져옵니다. 아직 없는 해시만 업로드하면 됩니다. distinct()는 파일에 동일한 여러 청크가 포함된 경우(예: 모두 0인 영역)를 처리합니다.
5.2. Presigned URL 발급
@RestController
@RequestMapping("/api/files")
public class UploadController {
private final S3Presigner presigner;
private final BlockRepository blockRepository;
private final ChunkPlanner planner = new ChunkPlanner();
public UploadController(S3Presigner presigner, BlockRepository blockRepository) {
this.presigner = presigner;
this.blockRepository = blockRepository;
}
@PostMapping("/{fileId}/uploads")
public UploadPlan initiate(@PathVariable UUID fileId, @RequestBody UploadRequest req) {
Set<String> existing = blockRepository.findExisting(req.blocks());
List<BlockUrl> urls = planner.missingBlocks(req.blocks(), existing).stream()
.map(hash -> new BlockUrl(hash, presign(hash)))
.toList();
return new UploadPlan(urls);
}
private String presign(String hash) {
return presigner.presignPutObject(r -> r
.signatureDuration(Duration.ofMinutes(15))
.putObjectRequest(o -> o.bucket("drive-blocks").key("blocks/" + hash)))
.url().toString();
}
}
record UploadRequest(int baseVersion, List<String> blocks) {}
record BlockUrl(String hash, String url) {}
record UploadPlan(List<BlockUrl> missing) {}
누락된 각 청크에 대해 서버는 15분 동안 유효한 PUT URL에 서명합니다. 클라이언트는 바이트를 S3로 직접 보내므로 앱 서버는 수십MB 데이터 대신 몇KB의 JSON만 처리합니다. 응답 예시:
{"missing":[{"hash":"9f86d0...","url":"https://drive-blocks.s3.amazonaws.com/blocks/9f86d0...?X-Amz-Signature=..."}]}
참고: 키가 해시이므로 클라이언트는 선언된 해시와 일치하지 않는 콘텐츠를 보낼 수 있습니다. 커밋할 때 서버는 S3에서 지원하는 x-amz-checksum-sha256 체크섬 같은 방식으로 다시 확인해야 합니다. 이 단계를 건너뛰면 사용자가 다른 사람이 공유하는 청크를 ‘손상시킬’ 수 있습니다.
5.3. 중복 제거 로직의 테스트
class ChunkPlannerTest {
private final ChunkPlanner planner = new ChunkPlanner();
@Test
void shouldReturnOnlyBlocksNotYetStored() {
List<String> requested = List.of("a1", "b2", "a1", "c3");
Set<String> existing = Set.of("b2");
assertThat(planner.missingBlocks(requested, existing))
.containsExactly("a1", "c3");
}
@Test
void shouldReturnEmptyWhenEverythingIsDeduplicated() {
assertThat(planner.missingBlocks(List.of("a1"), Set.of("a1"))).isEmpty();
}
}
두 번째 테스트는 두 명이 동일한 설치 파일을 업로드하는 경우를 시뮬레이션합니다. 두 번째 사람은 바이트를 보낼 필요가 없으며 업로드가 ‘즉시’ 완료됩니다.
6. 어려운 부분과 해결책
6.1. 충돌: 두 기기가 동일 파일을 동시에 수정할 때
노트북과 휴대폰이 모두 버전 7을 수정합니다. ’마지막에 온 것이 덮어쓴다’는 규칙을 적용하면 한쪽의 변경사항이 손실됩니다. 해결책은 낙관적 잠금을 사용하는 것입니다: 클라이언트는 baseVersion을 함께 보내며, head_version이 해당 값과 동일할 때만 커밋이 성공합니다.
@Service
public class CommitService {
private final JdbcTemplate jdbc;
public CommitService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public int commit(UUID fileId, long userId, int baseVersion, List<String> blocks, long size) {
int updated = jdbc.update("""
UPDATE files SET head_version = head_version + 1, size_bytes = ?
WHERE id = ? AND head_version = ? AND deleted_at IS NULL
""", size, fileId, baseVersion);
if (updated == 0) {
throw new VersionConflictException(fileId, baseVersion);
}
int newVersion = baseVersion + 1;
jdbc.update("INSERT INTO file_versions(file_id, version, block_list, created_by) VALUES (?,?,?,?)",
fileId, newVersion, blocks.toArray(String[]::new), userId);
jdbc.update("UPDATE blocks SET ref_count = ref_count + 1 WHERE hash = ANY(?)",
(Object) blocks.toArray(String[]::new));
jdbc.update("INSERT INTO change_journal(user_id, file_id, action) VALUES (?,?,'MODIFIED')",
userId, fileId);
return newVersion;
}
}
UPDATE … WHERE head_version = ? 문은 한 번의 원자적 작업으로 확인과 버전 증가를 수행합니다. 나중에 커밋하는 쪽은 updated == 0을 받습니다. 이때 API는 409 Conflict를 반환합니다. 클라이언트는 덮어쓰지 않고 자신의 버전을 새 파일로 저장합니다(예: report (Lan’s Laptop의 충돌 버전).docx). Dropbox와 Google Drive도 이진 파일을 이렇게 처리합니다.
비관적 잠금을 사용하면 안 됩니다(SELECT … FOR UPDATE로 업로드 전체 과정 동안 잠금 유지). 업로드는 수 분이 걸릴 수 있으며 이렇게 오래 잠금을 유지하면 DB가 정체됩니다. Google Docs 같은 실시간 협력 편집은 다른 문제이며 OT 또는 CRDT가 필요하고 이 글의 범위를 벗어납니다.
6.2. DB, 스토리지 및 기타 서비스 간의 일관성
두 가지 질문에 답해야 합니다.
첫째: 청크가 S3에 올라갔지만 커밋이 실패하면 어떻게 됩니까? 괜찮습니다. 메타데이터는 커밋이 성공한 후에만 청크를 가리킵니다. ref_count = 0인 ‘고아’ 청크는 24시간 후 GC 작업으로 삭제됩니다. 순서는 항상 바이트를 먼저 쓴 후 메타데이터를 쓰므로 아직 존재하지 않는 데이터를 가리키는 메타데이터는 절대 없습니다.
둘째: 다른 기기, 검색 인덱스 및 썸네일 서비스에 어떻게 알립니까? 트랜잭션 내에서 kafkaTemplate.send()를 직접 호출하면 dual-write 문제가 발생합니다: DB 롤백되지만 메시지는 전송되었거나 그 반대의 경우입니다. 해결책은 트랜잭션 아웃박스입니다. 커밋과 동일한 트랜잭션에서 outbox 테이블에 이벤트를 기록한 다음 릴레이가 이벤트를 Kafka로 푸시합니다:
@Component
public class OutboxRelay {
private final JdbcTemplate jdbc;
private final KafkaTemplate<String, String> kafka;
public OutboxRelay(JdbcTemplate jdbc, KafkaTemplate<String, String> kafka) {
this.jdbc = jdbc;
this.kafka = kafka;
}
@Scheduled(fixedDelay = 500)
@Transactional
public void publish() {
List<Map<String, Object>> rows = jdbc.queryForList("""
SELECT id, owner_id, payload FROM outbox
ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED
""");
for (Map<String, Object> row : rows) {
kafka.send("file-events", row.get("owner_id").toString(), (String) row.get("payload")).join();
jdbc.update("DELETE FROM outbox WHERE id = ?", row.get("id"));
}
}
}
주요 사항 설명:
FOR UPDATE SKIP LOCKED는 여러 릴레이 인스턴스를 동일한 배치를 중복으로 보내지 않고 실행할 수 있게 합니다.- Kafka 키가 owner_id이므로 한 사용자의 모든 이벤트가 동일 파티션으로 들어가고 올바른 순서를 유지합니다.
- 릴레이는 여전히 중복으로 보낼 수 있습니다(예: send 후에 충돌하지만 DELETE 전). 따라서 컨슈머는 멱등해야 합니다: 처리된 (file_id, version)을 저장하고 중복된 것을 무시합니다.
기기는 Kafka를 통해 콘텐츠를 받을 필요가 없습니다. 알림 서비스는 WebSocket을 통해 ‘변경 있음’ 신호만 푸시합니다. 기기는 그 후 GET /api/changes?cursor=…를 호출하여 change_journal을 읽습니다. 기기가 3일간 오프라인 상태였다면 이전 커서부터 계속 읽기만 하면 되므로 이벤트가 손실되지 않습니다.
6.3. 트래픽 급증
세 가지 일반적인 시나리오가 있습니다.
공개적으로 공유된 파일이 확산되는 경우(예: 시험 문제가 수십만 명과 공유됨). 다운로드는 서명된 URL을 통해 CDN을 거칩니다. 청크는 키가 해시이므로 불변이며 무한정 캐시할 수 있습니다(Cache-Control: immutable). 결과적으로 원본은 거의 부하를 받지 않습니다.
아침 시간에 수백만 대의 기기가 동시에 동기화합니다. 클라이언트는 jitter가 있는 지수 백오프를 사용합니다. 서버는 Redis를 사용하여 사용자별 속도 제한을 합니다:
@Component
public class RateLimiter {
private final StringRedisTemplate redis;
public RateLimiter(StringRedisTemplate redis) {
this.redis = redis;
}
public boolean tryAcquire(long userId, int limitPerMinute) {
String key = "rl:%d:%d".formatted(userId, Instant.now().getEpochSecond() / 60);
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1) {
redis.expire(key, Duration.ofSeconds(70));
}
return count != null && count <= limitPerMinute;
}
}
각 사용자는 분마다 카운터를 가집니다. INCR은 Redis의 원자적 작업이므로 여러 앱 서버가 동일 카운터를 공유해도 올바르게 계산됩니다. 한계를 초과하면 API는 429 Too Many Requests를 Retry-After 헤더와 함께 반환합니다. 고정 윈도우는 두 분의 경계에서 최대 2배의 버스트를 허용합니다. 더 정확함이 필요하면 슬라이딩 윈도우를 사용하거나 Lua 스크립트로 작성한 토큰 버킷을 사용합니다.
한 계정에 수백만 개의 파일이 있는 경우. owner_id로 PostgreSQL을 샤드하여 한 사람의 전체 디렉터리 트리가 동일 샤드에 있도록 합니다. 이렇게 하면 이름 바꾸기나 폴더 이동이 로컬 트랜잭션일 뿐입니다. 이동은 한 행의 parent_id만 변경하며 자식 파일을 건드리지 않습니다.
참고: 계정 간에 공유된 폴더는 소유자의 샤드에 있게 됩니다. 공유받은 사람은 Metadata Service를 통해 샤드를 교차 액세스하며 DB 간 직접 조인을 하지 않습니다.
6.4. 피해야 할 것들
- 파일을 BYTEA/BLOB 열에 저장. 백업이 비대해지고 복제가 느리며 DB가 병목입니다.
- 파일을 앱 서버를 거치게 함. 서버의 대역폭과 메모리가 파일 크기에 따라 선형으로 증가합니다.
- 파일 삭제 시 즉시 청크를 삭제. 청크는 다른 파일이나 다른 버전에서 사용 중일 수 있습니다. 항상 ref_count를 기반으로 GC를 수행하고 삭제 휴지통을 지원하도록 deleted_at으로 soft delete를 사용합니다.
- 좋은 delta 동기화가 필요할 때 모든 파일 유형에 대해 고정 크기 청크를 사용. 파일 시작에 바이트를 삽입하면 뒤의 모든 청크가 오프셋됩니다. Content-defined chunking(rolling hash)으로 해결할 수 있지만 더 복잡합니다. 실제 데이터가 명확한 이점을 보여줄 때만 적용합니다.
7. 결론
Google Drive 유형의 시스템 설계는 한 가지 핵심 결정을 중심으로 회전합니다: 메타데이터(관계형 데이터베이스, 강한 일관성)를 콘텐츠(object storage, 해시로 식별되는 불변 청크)와 분리하는 것입니다. 이로부터 중복 제거, 업로드 재개, 버전 기록 및 CDN 캐시가 거의 자동으로 따라옵니다.
어려운 부분은 꽤 표준적인 해결책을 가지고 있습니다:
- 쓰기 충돌: head_version을 사용한 낙관적 잠금과 덮어쓰기 대신 충돌 버전 생성.
- 일관성: 바이트를 먼저 쓴 후 메타데이터를 커밋하며 트랜잭션 아웃박스와 멱등 컨슈머를 결합합니다.
- 트래픽 급증: presigned URL, CDN, Redis를 통한 속도 제한 및 소유자별 샤딩.
이 글을 면접 또는 실제 프로젝트의 템플릿으로 사용할 때는 섹션 2의 규모 추정부터 시작합니다. 거기의 각 수치는 뒤따르는 아키텍처 선택을 결정합니다.