Architecture(2) - S3와 유사한 분산 객체 저장소



1. 저장소 시스템 101

아마존 S3(Simple Storage Service)와 같은 대규모 객체 저장소를 설계하기에 앞서, 3대 저장소 시스템의 기본 개념과 작동 원리를 파악해본다.

저장소 시스템은 물리적/논리적 구조에 따라 크게 블록(Block) 저장소, 파일(File) 저장소, 객체(Object) 저장소의 3가지 부류로 나뉜다.


1.1. 블록 저장소(Block Storage)

블록 저장소는 데이터를 가공되지 않은 정해진 크기의 원시 블록(Raw Block) 조각으로 나누어 관리하며, 서버에 Volume 형태로 직접 할당하는 가장 유연하고 최상위 성능을 제공하는 저장소 시스템이다.

HDD(Hard Disk Drive)나 SSD(Solid State Drive)처럼 서버에 물리적으로 직접 연결되는 드라이브가 대표적인 블록 저장소의 형태이다.

  • 동작 원리 및 접근 방식
    • 서버는 제공받은 원시 블록은 ext4, NTFS 등의 파일 시스템으로 포맷하여 사용하거나, DB나 VM 엔진 같은 고성능 애플리케이션에 블록 제어권을 직접 넘긴다.
    • 애플리케이션이 파일 시스템을 거치지 않고 디스크 블록에 직접 I/O를 수행할 수 있기 때문에 최상의 입출력 성능(IOPS)을 끌어낼 수 있다.
  • 네트워크 연결 프로토콜
    • 로컬 물리 드라이브에 국한되지 않고, 고속 전용 네트워크 프로토콜인 FC(Fibre Channel)iSCSI를 통해 SAN(Storage Area Network) 형태로 멀리 떨어진 서버에 결합될 수도 있다.
    • 서버 입장에서는 네트워크 전송 여부와 상관없이 내부에 장착된 물리 드라이브와 완전히 동일하게 인식된다.

1.2. 파일 저장소(File Storage)

파일 저장소는 블록 저장소 레이어 위에 계층적 디렉터리 구조를 구현하며, 파일과 폴더 단위로 데이터를 다룰 수 있도록 높은 수준의 추상화를 제공하는 파일 시스템 중심의 저장소이다.

파일 저장소는 우리가 일상적으로 사용하는 OS의 파일 탐색기와 가장 유사한 범용 저장소 솔루션이다.

  • 동작 원리 및 접근 방식
  • 특징
    • 클라이언트 서버가 원시 블록 제어나 볼륨 포맷 같은 까다로운 드라이버 레이어 작업을 신경 쓸 필요 없이 손쉽게 네트워크 망에서 파일과 폴더를 읽고 쓸 수 있다.
    • 조직 구성원 간의 공유 폴더나 중앙 집중식 문서 보관소로 적합하다.

1.3. 객체 저장소(Object Storage)

객체 저장소는 디렉터리 구조를 없앤 수평적(Flat) 구조 내에 데이터와 메타데이터를 하나의 객체(Object)로 묶어 보관하며, RESTful API를 통해 접근하는 무한 확장이 가능한 저비용 저장소이다.

객체 저장소는 데이터 영속성을 극대화하고 페타바이트(PB) 이상의 대규모 인프라를 지원하는 한편, 전체 구축 비용을 낮추기 위해 실시간 점진적 수직 성능을 의도적으로 희생한 시스템이다.

  • 동작 원리 및 접근 방식
    • 데이터 수정이 자주 발생하지 않는 Cold Data, 데이터 아카이빙, 백업, 미디어 파일 보관에 주로 쓰인다.
    • 계층적 디렉터리가 없으며, 오직 고유한 키(URI) 기반의 RESTful API(HTTP GET, PUT, DELETE 등)로 데이터를 교환한다.
  • 대표 제품
    • AWS S3(Simple Storage Service), MS Azure Blob Storage, Google Cloud Storage 등

1.4. Block vs File vs Object

저장소 유형

구분블록 저장소파일 저장소객체 저장소
저장 내용 변경 가능성Y (부분 덮어쓰기 가능)Y (부분 덮어쓰기 가능)N (직접 부분 수정 불가, 객체 버전을 통한 전체 대체만 가능)
비용고비용중~고비용저비용
성능중~고 혹은 최상 (최저 Latency)중~고저~중 (상대적 수십~수백ms 지연)
데이터 일관성강력한 일관성(Strong Consistency)강력한 일관성(Strong Consistency)강력한 일관성
데이터 접근 프로토콜SAS / iSCSI / FC표준 파일 접근, CIFS/SMB, NFSHTTP RESTful API
규모 확장성중 (단일 서버/SAN 한계)고 (NAS 노드 확장)최상 (무한 수평 확장 가능)
적합한 응용처VM 디스크, 데이터베이스(RDB)범용 파일 시스템, 조직 내 파일 공유이진 데이터, 대용량 비구조화 데이터

1.5. 객체 저장소 핵심 용어

객체 저장소의 아키텍처와 API를 이해하기 위한 기본 용어 5가지이다.

+-----------------------------------------------------------------------------------+
| 버킷 (Bucket: 전역 유일 논리 컨테이너)                                                 |
|  |                                                                                |
|  +--> 객체 (Object 1): URI = /bucket-name/image.png                               |
|  |     ├── 데이터 (Payload): Binary Data [01010011...]                             |
|  |     └── 메타데이터 (Metadata): Key-Value Pairs {Content-Type: image/png, ...}     |
|  |                                                                                |
|  +--> 객체 (Object 2): URI = /bucket-name/document.pdf (Version: v2)              |
+-----------------------------------------------------------------------------------+
  • 버킷(Bucket)
    • 객체들을 보관하는 최상위 논리적 컨테이너
    • 전 세계 모든 사용자가 공유하는 시스템 내에서 버킷 이름은 전역적으로 유일해야 함
  • 객체(Object)
    • 버킷 안에 저장되는 개별 데이터 단위
    • 실제 바이너리 데이터 파일인 페이로드와, 해당 객체의 속성/이름/권한 등을 설명하는 메타데이터(Key-Value 쌍)로 구성됨
  • 버전(Version)
    • 동일한 버킷 내에서 한 객체의 변경 이력을 여러 버전으로 동시 유지할 수 있는 기능
    • 버킷 단위로 활성화 여부를 설정하며, 실수로 인한 파일 삭제나 악의적인 덮어쓰기로부터 데이터를 안전하게 복구할 수 있는 방어 장치가 됨
  • URI(Uniform Resource Identifier)
    • 객체 저장소는 RESTful API를 기반으로 동작하므로 디렉터리 경로 대신 HTTP URL/URI 엔드포인트를 통해 각 객체를 고유하게 식별함
    • 예) GET /my-bucket/images/logo.png
  • SLA(Service-Level Agreement)
    • 서비스 제공자와 클라이언트 간 맺어지는 서비스 품질 보장 계약 수준
    • 예를 들어 아마존 S3 Standard-IA(Standard-Infrequent Access) 저장소 클래스의 SLA 규격은 다음과 같은 수치를 지킨다.
      • 데이터 내구성(Durability)
        • 여러 가용성 구역(AZ)에 걸쳐 99.999999999(11-nine) 수준의 객체 내구성 제공(1개 AZ 전체 소실 시에도 데이터 복원 보장)
      • 서비스 가용성
        • 연간 99.9% 이상의 업타임 보장

2. 문제 이해 및 설계 범위 확정

2.1. 요구사항 정의

실무 아키텍처 수립의 첫 단계는 요구사항을 명확히 하고 시스템의 규모를 가늠하는 것이다.

  • Q: 어떤 기능을 지원해야 하는가?
    • A: 아마존 S3와 유사한 객체 저장소로서 아래 핵심 기능을 지원해야 한다.
      • Bucket 생성
      • 객체 업로드 및 다운로드
      • 객체 버전 관리(Versioning)
      • 버킷 내 목록 출력(aws s3 ls CLI와 유사한 접두어 기반 조회)
  • Q: 데이터 크기는 어느 정도인가?
    • A: 수 GB 이상의 아주 큰 객체부터 수 KB 정도의 다량의 소형 객체까지 다양하게 저장할 수 있어야 한다.
  • Q: 매년 시스템에 추가되는 데이터의 양은 어느 정도인가?
    • A: 매년 약 100PB(Petabyte)의 데이터가 새롭게 추가된다.
  • Q: 99.9999%의 데이터 내구성과 99.99%의 서비스 가용성을 보장해야 하는가?
    • A: 그렇다. 높은 내구성과 가용성을 동시에 달성해야 한다.

비기능 요구사항

  • 데이터 규모
    • 연간 100PB 신규 데이터 저장
  • 데이터 내구성
    • 99.9999%(Six-Nine) 이상의 데이터 손실 방지
  • 서비스 가용성
    • 99.99%(Four-Nine) 이상의 시스템 정상 작동 보장
  • 저장소 효율성
    • 높은 수준의 안정성과 성능을 보장하되, 인프라 저장 비용(TCO, Total Cost of Ownership)을 최대한 낮추어야 함

(TCO, Total Cost of Ownership)

총소유비용
단순히 하드웨어나 소프트웨어를 구매할 때 드는 초기 도입 비용 뿐 아니라, 이를 구축/운영/유지보수/폐기할 때까지 전체 수명 주기 동안 발생하는 모든 직/간접 비용을 합산한 개념

여기서는 단순히 디스크나 스토리지의 구매 단가만 낮추는 것이 아니라, 전력 소모/데이터 압축 효율/관리 인력 공수/확장 시 발생하는 부대 비용 등 운영 전반의 모든 숨은 비용을 최적화해야 한다는 의미임


2.2. 대략적인 규모 추정

객체 저장소 시스템 설계에서는 디스크 용량과 초당 디스크 입출력 처리량(IOPS: Input/Output Operation Per Second)이 핵심 병목 구간이 된다.


디스크 용량 산정 조건

저장되는 객체의 크기가 아래의 분포를 따른다고 가정해보자.

  • 소형 객체(20%): 1MB 미만
  • 중형 객체(60%): 1MB~64MB
  • 대형 객체(20%): 64MB 이상

계산 편의성을 위해 중앙값(Median)을 사용한다.

  • 소형 객체(1MB 미만)
    • 0MB~1MB 대역의 중앙에 위치하는 0.5MB 적용
  • 중형 객체(1MB~64MB)
    • 1MB~64MB 대역의 중앙 부근인 32MB 적용
  • 대형 객체(64MB 이상)
    • 64MB부터 수 GB에 이르는 대형 객체 분포의 대표 중앙값으로 200MB 적용

중앙값(Median)

통계학에서 중앙값은 데이터를 크기 순으로 나열했을 때 정확히 가운데 위치하는 값이다.
파일 크기처럼 1KB짜리 극소형 파일부터 수십 GB짜리 극대형 파일까지 폭넓게 섞여 있는 치우친(Skewed) 분포에서는 단순 ‘평균(Mean)’을 사용하면 극단적으로 큰 파일 때문에 전체 결과가 왜곡된다.
따라서 범주의 대표 특성을 가장 잘 반영하는 중앙값을 기준 수치로 사용한다.


IOPS 가정

SATA 인터페이스 규격의 7,200 rpm HDD 1대는 초당 100~150회의 임의 데이터 탐색(Random I/O)을 지원한다. (= 100~150 IOPS)


수용 가능한 객체 수 산출 계산

안정적인 하드웨어 운용을 위해 디스크의 40% 저장 공간 사용률(Storage Utilization)을 유지한다고 가정할 때, 100PB 공간에 수용 가능한 객체 수를 계산해보자. \[100\text{ PB} = 100 \times \underbrace{1,000}_{(\text{PB } \rightarrow \text{ TB})} \times \underbrace{1,000}_{(\text{TB } \rightarrow \text{ GB})} \times \underbrace{1,000}_{(\text{GB } \rightarrow \text{ MB})} \text{ MB} = 10^{11}\text{ MB}\]


객체 1개당 평균 가중 용량 \[(0.2 \times 0.5\text{MB}) + (0.6 \times 32\text{MB}) + (0.2 \times 200\text{MB}) = 0.1\text{MB} + 19.2\text{MB} + 40\text{MB} = 59.3\text{MB}\]


수용 가능한 총 객체 수 \[\frac{10^{11}\text{MB} \times 0.4}{59.3\text{MB}} \approx 674,536,256 \approx \mathbf{6\text{억 } 8\text{천만 개}}\]

모든 객체의 메타데이터 크기를 평균 1KB로 가정하면, 약 6억 8천만 개 객체의 메타데이터를 저장하기 위해 약 0.68TB의 DB 저장 공간이 필요하다.


3. 개략적 설계안: 메타데이터와 데이터의 분리

3.1. 객체 저장소의 핵심 특성: 불변성 & Key-Value

설계에 들어가기 앞서 객체 저장소가 가진 고유한 4가지 속성을 이해해야 한다.

  • 객체 불변성(Object immutability)
    • 블록/파일 저장소와 달리 객체 저장소에 저장된 데이터는 부분 수정이 불가능하다. 데이터를 변경하려면 삭제 후 전체를 새 버전 객체로 대체해야 한다.
  • 키-값 저장소(Key-Value Store)
    • 객체의 URI가 Key가 되고, 실제 바이너리 데이터가 Value가 되는 키-값 대응 구조이다.
요청:
GET /bucket1/object1.txt HTTP/1.1

응답:
HTTP/1.1 200 OK
Content-Length: 1234

[해당 객체의 바이너리 데이터 1234 바이트]
  • WORM(Write Once, Read Many)
    • 데이터 접근 패턴 측면에서 쓰기는 1회 발생하지만 읽기는 수없이 반복적으로 발생한다.
  • 다양한 객체 크기 동시 지원
    • 수 KB 소형 파일부터 수 GB 대형 파일까지 문제없이 저장할 수 있어야 한다.

WORM(Write Once, Read Many)

  • Write Once: 데이터를 딱 한 번만 저장(생성)할 수 있다. 저장된 데이터는 지정된 보존 기간 동안 수정, 덮어쓰기, 삭제가 불가능하다.
    • 이를 데이터 불변성(Immutability)이라고 한다.
  • Read Many: 수정이나 삭제는 안 되지만, 저장된 데이터를 조회하고 읽는 것은 자유롭다.

객체 저장소에서 WORM을 쓰는 이유는?

  • 랜섬웨어 방어
    • 랜섬웨어가 시스템에 침투하더라도, WORM 설정이 적용된 객체 저장소의 데이터는 암호화하거나 강제 삭제 불가
  • 법적 보존 의무 준수
    • 금융, 의료, 법률 분야처럼 ‘거래 내역이나 진료 기록을 최소 5년간 원본 그대로 보존해야 한다’는 법적 규제가 있을 때 필수적으로 사용
      • (예: AWS S3의 Object Lock 기능이 대표적인 WORM 구현 형태)

랜섬웨어(Ransomware)

몸값(Ransom)과 소프트웨어(Software)의 합성어로, 컴퓨터 내의 데이터를 볼모로 잡고 돈을 요구하는 악성 프로그램


UNIX 파일 시스템과의 구조적 유사성

객체 저장소의 아키텍처 철학은 UNIX 파일 시스템의 아키텍처와 매우 유사하다.

UNIX 파일 시스템과 객체 저장소

  • UNIX 파일 시스템은 파일 이름과 메타데이터를 inode 구조체에 저장하고, 실제 데이터는 디스크의 물리 데이터 블록에 별도로 보관한다.
  • 아이노드에 저장된 파일 블록 포인터를 따라가면서 디스크를 읽는 구조이다.
  • 따라서 로컬 파일을 읽을 때 우선 아이노드에 기록된 메타데이터를 읽어서 파일 블록 포인터 목록을 확보한 후 그 포인터를 따라가면서 데이터를 읽어야 한다.

객체 저장소 역시 이와 동일하게 메타데이터 저장소(inode 역할)와 데이터 저장소(하드디스크 역할)를 물리적으로 엄격히 분리한다.
단, 메타데이터 저장소에는 파일 블록 포인터 대신 네트워크를 통해 데이터 저장소에 보관된 객체를 요청할 때 필요한 식별자(ID)가 보관된다.

버킷과 객체

메타데이터와 실제 데이터 분리의 장점
불변(Immutable) 객체 데이터와 변경 가능한(Mutable) 메타데이터를 분리하면, 데이터 저장소와 메타데이터 저장소를 서로 독립적인 기술 스택으로 확장하고 구현 및 최적화가 가능하다.


3.2. 전체 시스템 컴포넌트 구조

아래는 S3 시스템의 개략적인 전체 컴포넌트 구조도이다.

개략적 설계안

  • 로드밸런서
    • 사용자로부터 들어오는 RESTful API HTTP 요청을 여러 API 서버로 균등하게 분산
  • API 서비스
    • Stateless 서버 레이어로 쉽게 Scale-out이 가능하며, IAM 인증, 메타데이터 조회, 데이터 저장소 호출 등을 총괄 조율
  • IAM(Identity & Access Management)
    • 인증(Authentication), 접근 권한 부여(Authorization) 및 접근 제어(Access Control List) 등을 검증
  • 데이터 저장소
    • 실제 객체 바이너리 데이터를 저장하고 읽어오는 고성능 저장소 시스템
    • 모든 데이터 연산은 객체 고유 ID(UUID)를 통해 수행됨
  • 메타데이터 저장소
    • 버킷 정보 및 객체 이름과 ID 간의 매핑 메타데이터를 보관

3.3. 핵심 워크플로우

3.3.1. 객체 업로드

아래 그림은 bucket-to-share 라는 버킷 생성 후 script.txt를 업로드하는 흐름이다.

객체 업로드

① 클라이언트는 bucket-to-share 버킷을 생성하기 위해 HTTP PUT 요청을 보내며, 요청은 API 서비스로 전달된다.
② API 서비스는 IAM 서비스를 호출하여 사용자의 생성 권한을 확인한다.
③ 권한 검증 후 API 서비스는 메타데이터 저장소를 호출하여 버킷 정보를 생성 및 등록한다.
④ 버킷 생성 완료 후, 클라이언트는 script.txt 객체를 업로드하기 위해 HTTP PUT 요청을 보낸다.
⑤ API 서비스는 IAM을 통해 해당 사용자에게 WRITE 권한이 있는지 재확인한다.
⑥ 검증이 완료되면 API 서비스는 HTTP PUT Body에 실린 객체 데이터를 데이터 저장소로 전송한다.
데이터 저장소는 데이터를 영속적으로 저장한 후 고유한 object_id(UUID)를 반환한다.
⑦ API 서비스는 메타데이터 저장소를 호출하여 object_id(UUID), bucket_id, object_name(script.txt) 매핑 항목을 새롭게 등록한다.

[객체 업로드 HTTP 요청 예시]

PUT /bucket-to-share/script.txt HTTP/1.1
Host: foo.s3example.org
Date: Sun, 12 Sept 2021 17:51:00 GMT
Authorization: [권한 문자열]
Content-Type: text/plain
Content-Length: 4567
x-amz-meta-author: Alex

[객체 데이터 4567 바이트]

3.3.2. 객체 다운로드

객체 저장소는 디렉터리 계층을 지원하지 않지만, 객체 이름에 슬래시(/)를 포함하여 bucket-to-share/script.txt 처럼 저장하면 계층 구조를 논리적으로 흉내낼 수 있다.

[객체 다운로드 HTTP 요청 예시]

GET /bucket-to-share/script.txt HTTP/1.1
Host: foo.s3example.org
Date: Sun, 12 Sept 2021 18:30:01 GMT
Authorization: [권한 문자열]

객체 다운로드

① 클라이언트가 GET /bucket-to-share/script.txt 요청을 보내면 로드밸런서가 이를 API 서비스로 포워딩한다.
② API 서비스는 IAM을 통해 사용자의 READ 권한을 검증한다.
③ 권한 확인이 완료되면, API 서비스는 메타데이터 저장소에서 해당 객체 이름에 매칭된 UUID를 조회해온다.
④ API 서비스는 확보한 UUID를 가지고 데이터 저장소에 접근하여 실제 객체 데이터를 읽어온다.
⑤ API 서비스는 읽어온 데이터 페이로드를 HTTP GET 응답에 실어 클라이언트에 반환한다.


4. 상세 설계

앞서 살펴본 개략적 구조를 바탕으로, PB 데이터 규모에서 99.9999%의 내구성과 99.99%의 가용성을 달성하기 위한 데이터 저장소와 메타데이터 저장소의 상세 아키텍처에 대해 알아본다.


4.1. 데이터 저장소 아키텍처: Routing, Placement Service, Data Node

API 서비스가 사용자 요청을 받으면 객체 저장 및 조회 연산은 내부 데이터 저장소로 전달된다.

객체 업로드/다운로드

데이터 저장소 내부 핵심 컴포넌트는 다음과 같이 3가지로 나뉜다.

데이터 저장소 컴포넌트

  • 데이터 라우팅 서비스
    • API 서비스와 데이터 노드 사이에서 HTTP RESTful 또는 gRPC 프로토콜 인터페이스를 제공하는 Stateless 레이어
  • 배치 서비스(Placement Service)
    • 가상 클러스터 지도(Virtual Cluster Map)를 관리하며, 데이터를 어느 데이터 노드 다중화 그룹에 배치할지 결정하는 중앙 컨트롤러
  • 데이터 노드
    • 실제로 HDD/SSD를 장착하고 바이너리 데이터를 영속적으로 저장 및 복제하는 노드들

4.2. Placement Service 와 Raft 합의 프로토콜, 안정해시의 역할

Placement Service는 데이터 노드의 상태(Heartbeat)를 지속해서 모니터링하고 물리적 형상 정보인 가상 클러스터 지도를 최신 상태로 유지한다.

가상 클러스터 지도

Placement Service는 중요한 서비스이므로 Placement Service 클러스터는 Paxos 나 Raft 와 같은 합의 프로토콜을 사용할 것을 권장한다.


💡합의 프로토콜과 Raft란?

Raft의 개념 및 성격
Raft는 이론적 추상 개념이 아니라, 분산 시스템에서 여러 노드가 하나의 동일한 상태(State)에 대해 합의(Consensus)에 도달하도록 보장하기 위해 설계된 실제 동작 알고리즘(Consensus Algorithm)이다.
팩서스(Paxos) 알고리즘보다 이해 및 구현하기 쉽도록 개선된 방안이다.

다수결(Quorum) 기반의 과반수 합의 원리
5~7개의 노드로 Placement Service 클러스터를 구성하고 Raft 알고리즘을 적용하면, Leader 노드 선출과 클러스터 지도 변경 사항이 과반수(Quorum, \(\frac{N}{2} + 1\)) 노드의 동의를 거쳐 로그로 동기화된다.
7개 노드 중 최대 3개 노드에 장애가 발생하더라도 4개 노드가 살아있으므로 서비스는 중단 없이 정상 작동한다.


4.3. 데이터 다중화 흐름

데이터 저장소가 객체를 받아 영속적으로 보관하는 기본 동작 절차는 다음과 같다.

API 서비스 → 데이터 라우팅 → Placement Service 조회 → 주 노드 전송 → 부 노드 복제로 이어지는 다중화 메커니즘

데이터를 영속적으로 보관하는 흐름

  • ① API 서비스의 데이터 전달
    • API 서비스는 클라이언트로부터 받은 객체 데이터를 데이터 저장소 내의 데이터 라우팅 서비스로 포워딩
  • ② Placement Service 질의
    • 데이터 라우팅 서비스는 해당 객체에 UUID를 할당하고, Placement Service에 해당 객체를 보관할 데이터 노드 위치를 질의함
    • Placement Service는 가상 클러스터 지도를 확인하여 데이터를 보관할 Primary 데이터 노드 반환
  • ③ 주 데이터 노드로 데이터 전송
    • 데이터 라우팅 서비스는 저장할 데이터를 UUID와 함께 주 데이터 노드에 직접 전송
  • ④ 지역 저장 및 다중화 복제
    • 주 데이터 노드는 데이터를 자신의 노드에 지역적으로(Locally) 저장하는 한편, 2개의 Secondary 데이터 노드에 다중화 복제를 진행함
    • 주 데이터 노드는 모든 부 데이터 노드에 성공적으로 다중화가 완료되면 데이터 라우팅 서비스에 완료 응답 보냄
  • ⑤ UUID 반환
    • 데이터 라우팅 서비스는 객체의 UUID(객체 ID)를 API 서비스로 반환하며 연산을 마침

4.3.1. Placement Service와 안정 해시(Consistent Hashing)의 역할

위 흐름의 ② Placement Service 질의에서 Placement Service는 UUID를 입력으로 받아 해당 객체가 저장된 다중화 그룹(Primary + Secondary 노드들)을 도출해낸다.

이 계산 결과는 항상 결정적(Deterministic)이어야 하며, 노드가 새롭게 추가되거나 장애로 제거되더라도 전체 시스템에 미치는 영향을 최소화해야 한다.
이를 위해 안정 해시 기술을 활용한다.


💡노드 재배치를 줄이는 안정 해시를 왜 ‘다중화 그룹 계산’에 사용할까?

안정 해시 키 분배 방식은 단순 노드 추가/삭제 시 데이터 이동을 최소화하는 기능 외에도, 대규모 분산 저장소에서 다중화 그룹을 중앙 DB 없이 결정론적으로 찾기 위한 핵심 메커니즘으로 사용된다.

  • 결정론적 다중화 그룹 지정
    • UUID를 해시 링 위에 매핑한 뒤, 링을 따라 시계 방향으로 가장 먼저 만나는 노드를 Primary 노드로 지정
    • 그 후 계속 시계 방향으로 순회하며 서로 다른 물리적 Rack이나 가용성 구역(AZ)에 위치한 노드 2개를 추가로 선택하여 Secondary 노드로 지정
    • 이를 통해 별도의 거대한 위치 매핑 테이블 없이도 UUID 만으로 동일한 복제 노드 그룹을 항상 찾아낼 수 있음
  • 100PB 규모에서의 데이터 리밸런싱 폭탄 방지
    • 새로운 데이터 노드가 증설되거나 특정 노드가 다운되었을 때, 전통적인 Modulo 해싱을 사용하면 전체 100PB의 데이터 배치가 통째로 뒤흔들림
    • 안정 해시를 적용하면 노드가 변동되어도 오직 변동된 해시 구간에 속한 최소한의 데이터만 이동하면 되므로 전체 클러스터의 데이터 재배치(Rebalancing) 부하를 극도로 낮출 수 있음

4.3.2. 데이터 일관성과 Latency 간의 트레이드오프

④ 지역 저장 및 다중화 복제의 과정을 보면, 주 데이터 노드는 모든 부 데이터 노드에 복제 작업이 성공한 후에야 완료 응답을 반환한다.

[데이터 라우팅 서비스] ──(1. 데이터 전송)──> [주 데이터 노드]
                                                │
                                                ├──(2. 복제 전송)──> [부 데이터 노드 1] (완료)
                                                │
                                                └──(3. 복제 전송)──> [부 데이터 노드 2] (느림... 완료)[데이터 라우팅 서비스] <──(4. 최종 완료 응답)────┘ (가장 느린 부 노드가 끝날 때까지 대기)
  • 장점: 강력한 일관성
    • 응답을 받는 시점에 모든 데이터 노드가 동일한 최신 사본을 보유하므로, 언제 어디서 데이터를 읽어도 강력한 데이터 일관성이 보장됨
  • 단점: 높은 Latency
    • 복제 네트워크 전송 중 가장 느린 부 데이터 노드의 작업이 끝날 때까지 대기해야 하므로, 클라이언트가 체감하는 쓰기 응답 지연은 가장 길어짐

데이터를 여러 데이터 노드에 복제할 때, ‘클라이언트에 성공 완료 응답을 언제 보낼 것인가’에 따라 데이터 일관성과 응답 지연 시간 사이의 명확한 트레이드오프가 발생한다.

아래는 데이터 일관성과 지연 시간 사이의 트레이드오프를 보여준다. 데이터 일관성과 지연 시간 사이의 타협적 관계

  • 첫 번째 선택지: 3개 노드 전체 저장 완료 후 응답
    • 데이터를 주 데이터 노드 및 2개의 부 데이터 노드 전부(총 3개 노드)에 보관하고 나서야 성공적으로 저장되었다고 간주
    • 데이터 일관성 측면에서는 가장 강력하고 최선이지만, 복제 과정에서 가장 느린 부 노드의 작업이 끝날 때까지 대기해야 하므로 응답 지연 시간이 가장 높다.
  • 두 번째 선택지: 주 노드+부 노드 1개 저장 완료 후 응답
    • 데이터를 주 데이터 및 2개의 부 데이터 노드 중 최소 1개에 성공적으로 보관하면 성공으로 간주
    • 데이터 일관성과 응답 지연 시간 사이에서 중간 정도의 합리적인 성능을 제공
  • 세 번째 선택지: 주 노드 저장 완료 즉시 응답
    • 데이터를 주 데이터에만 보관하고 나면 즉시 성공했다고 간주
    • 응답 지연 시간이 가장 낮아 쓰기 속도는 가장 빠르지만, 비동기 복제가 완료되기 전에 주 노드에 장애가 생기면 데이터 손실 위험이 있어 데이터 일관성 측면에서는 가장 취약

두 번째와 세 번째 선택지 모두 궁극적 일관성(Eventual Consistency)의 한 형태로 볼 수 있다.
클라이언트에 응답이 전달된 직후 짧은 시간 동안은 노드 간 데이터 불일치가 존재할 수 있지만, 약간의 시차가 지난 후 백그라운드 복제 프로세스를 통해 결국(Eventually) 모든 데이터 노드가 동일한 최신 상태로 동기화 되는 특징을 가진다.


4.4. 데이터 노드의 동작 및 WAL 기반 순차 쓰기 구조

데이터 노드는 주기적으로 Placement Service에 Heartbeat 메시지를 전송한다. 15초 이상 응답이 없는 노드는 Down 상태로 표시된다.

데이터 저장 시 소형 객체를 개별 파일로 직접 디스크에 저장하면 두 가지 치명적 한계가 발생한다.

  • 디스크 블록 낭비
    • 파일 시스템의 기본 블록 크기(보통 4KB)보다 작은 파일(예: 1KB)을 저장해도 블록 1개를 온전히 차지하므로 디스크 공간이 낭비됨
  • inode 소진
    • OS 디스크 포맷 시 생성되는 아이노드 수량이 소진되어 공간이 남아도 더 이상 파일을 생성할 수 없게 되며, OS의 메타데이터 캐싱 성능이 급격히 저하된다.

이 문제를 해결하기 위해 여러 작은 객체를 하나의 큰 디스크 파일에 일렬로 연이어 기록(WAL(Write-Ahead Log) 방식)한다.

작은 객체들을 한 파일에 저장하는 방안

용량 임계치에 도달한 파일은 읽기 전용 파일로 변경하고 새로운 파일을 만들며, 읽기 전용 파일로 변경된 파일은 오직 읽기 요청만 처리한다.

읽기-쓰기 파일에 대한 쓰기 연산은 순차적으로 이루어져야 한다.
위 그림에서 보듯이 객체는 파일에 일렬로 저장되는데, 이를 유지하려면 여러 CPU 코어가 쓰기 연산을 병렬로 진행하더라도 객체 내용이 뒤섞이는 일은 없어야 한다.
파일에 객체를 기록하기 위해 자기 순서를 기다려야 한다는 뜻이기도 한데, 많은 코어를 가진 서버 시스템의 경우 대역폭이 심각하게 줄어든다는 문제가 있다.
즉, 여러 CPU 코어가 단 하나의 읽기-쓰기 파일에 동시에 데이터를 기록하려고 하면 디스크 Lock 경합이 발생하여 병렬 대역폭이 급격히 떨어진다.
따라서 CPU 코어마다 전담 읽기-쓰기 파일(예: Core-1 전용 /data/a, Core-2 전용 /data/b)을 각각 할당하여 병렬 디스크 I/O 처리 성능을 최대한으로 끌어올린다.


4.5. 객체 위치 검색 및 로컬 DB 선택 기준

하나의 큰 파일에 수만 개의 작은 객체가 묶여 있다면, 특정 UUID를 가진 객체의 위치는 어떻게 빠르게 찾을 수 있을까?

이를 위해 각 데이터 노드마다 디스크 내 객체 오프셋 정보를 관리하는 object_mapping 테이블을 유지한다.

필드설명
object_id객체의 고유 UUID (PK)
file_name객체가 보관된 읽기 전용/쓰기 데이터 파일 경로
start_offset파일 내 객체 데이터가 시작하는 바이트 위치
object_size객체의 바이트 단위 크기

위 정보를 저장할 때는 RocksDB 와 같은 파일 기반의 Key-Value Store 를 이용하는 방법과 RDBMS를 이용하는 방법이 있는데 RDBMS 가 더 나은 선택이다.
그 중에서도 SQLite가 이런 경우 안성맞춤이다.


💡B+ Tree 기반 RDB vs RocksDB(LSM-Tree) 비교

RocksDB는 LSM-Tree 구조 기반으로 쓰기 속도가 뛰어나지만 읽기 시 여러 SSTable(Sorted String Table)을 확인해야 하므로 읽기 지연이 발생할 수 있다.
반면, B+Tree 구조의 RDBMS는 인덱스 노드를 통한 랜덤 읽기 연산 성능이 압도적이다.

객체 저장소 데이터는 1회 작성 후 수없이 읽히는(WORM) 패턴이므로 읽기 성능이 우수한 RDB 구조가 훨씬 유리하다.


💡왜 하필 SQLite일까?

데이터 노드의 object_mapping 정보는 해당 데이터 노드가 자체 보유한 local 파일의 위치 정보에 불과하므로, 다른 데이터 노드와 네트워크를 통해 굳이 매핑 데이터를 공유할 필요가 전혀 없다.
따라서 별도의 heavyweight DB 서버(MySQL 등)를 띄우는 대신, 데이터 노드 내부 프로세스에 소켓 통신 오버헤드 없이 임베디드 형태로 가볍게 동작하는 파일 기반 SQLite RDB가 완벽한 최적의 솔루션이 된다.


4.6. 데이터 저장 흐름

주 데이터 노드가 데이터를 넘겨받았을 때, 디스크 파일 시스템 수준에서 데이터를 실제로 어떻게 보관하는지에 대해 알아본다.
4.3. 데이터 다중화 흐름에서 Primary 데이터 노드가 데이터를 전달받았을 때 디스크 내부에서 일어나는 과정(Step 3~4)을 좀 더 상세하게 본다.

주 데이터 노드가 1개의 객체를 할당받았을 때, 디스크 내 대용량 파일 Append 순차 쓰기와 SQLite object_mapping 인덱싱이 수행되는 노드 내부 단위

데이터 저장 흐름

  • ① 데이터 노드 서비스 수신
    • API 서비스(또는 라우팅 서비스)로부터 데이터를 받음
  • ② WAL 스타일 파일 Append
    • 디스크의 읽기-쓰기 전담 파일(예: /data/c) 맨 끝에 데이터를 순차적으로 연이어 덧붙임
  • ③ 로컬 DB 갱신
    • 노드 내부의 SQLite object_mapping 테이블에 (object_id, file_name, start_offset, object_size) 레코드 추가
  • ④ 결과 반환
    • 데이터 노드 서비스가 성공 응답 및 UUID를 상위 레이어로 반환

여기서 UUID는 데이터 노드가 데이터를 디스크에 저장하는 과정인 위 단계에서 생성되는 것이 아니라, 그 이전 단계인 API 서비스 또는 데이터 라우팅 서비스에서 미리 생성된다.
Placement Service에 ‘이 데이터를 어느 노드 그룹에 저장해야 할까?’라고 질의할 때 UUID의 해시값(안정 해시)을 기준으로 Primary/Secondary 데이터 노드를 결정해야 하기 때문이다.
따라서 데이터 노드가 데이터를 넘겨받은 시점에는 이미 UUID가 확정된 상태이며, 데이터 노드는 전달받은 UUID와 데이터 페이로드를 디스크 파일 및 SQLite 메타데이터에 기록하기만 한다.


4.7. 데이터 내구성: 다중화 vs 소거 코드

Six-Nine(99.9999%) 수준 이상의 데이터 내구성을 제공하는 저장소 시스템을 구축하려면 모든 장비 장애 시나리오를 살피고 데이터를 안전하게 보관해야 한다.


4.7.1. 하드웨어 장애와 장애 도메인

기록 매체의 종류와 관계없이 HDD나 SSD의 장애는 피할 수 없다.
내구성을 높이는 가장 검증된 방법은 데이터를 여러 대의 드라이브에 복제하여 특정 드라이브에서 장애가 생겨도 전체 데이터 가용성에 영향이 없도록 하는 것이다.
여기서는 기본적으로 데이터를 3중 복제한다.

회전식 HDD 드라이브의 연간 장애율(AFR, Annualized Failure Rate)을 0.81%라고 가정해보자.
데이터를 3중 복제하면 대략적인 내구성은 다음과 같이 계산된다. \[1 - 0.0081^3 \approx 0.999999 \quad (99.9999\%)\]

완전한 내구성 평가를 위해서는 여러 장애 도메인의 영향을 복합적으로 고려해야 한다.
장애 도메인이란 중요한 물리적/논리적 구획에 문제가 생겼을 때 부정적인 영향을 공유하는 범위를 말한다.

+-------------------------------------------------------------------------+
| Data Center (AZ 1)                                                      |
|  +-----------------------------------+  +----------------------------+  |
|  | Rack 1                            |  | Rack 2                     |  |
|  |  [Server 1] [Server 2] [Server 3] |  |  [Server 4] [Server 5]     |  |
|  |  (공유: 전원 supply, 네트워크 SW) |  |                            |  |
|  +-----------------------------------+  +----------------------------+  |
+-------------------------------------------------------------------------+
  • Rack 장애 도메인
    • 데이터 센터 내의 서버들은 보통 19인치 Rack에 설치된다.
    • 하나의 랙에 장착된 모든 서버는 전원 분배 장치(PDU, Power Distribution Unit)와 네트워크 스위치를 공유하므로 해당 랙 전체가 하나의 장애 도메인이 된다.
  • 서버 노드 장애 도메인
    • 서버 장비 내 컴포넌트들은 마더보드, CPU, RAM 등을 공유하므로 해당 서버 노드 자체가 독립된 장애 도메인에 속한다.
  • 가용성 구역(AZ)
    • 다른 데이터 센터와 물리적 전원 및 네트워크 인프라를 공유하지 않는 독립적인 데이터 센터 단위를 말한다.

여러 데이터센터를 활용한 데이터 다중화

데이터를 여러 가용성 구역과 다양한 랙에 분산 복제해 두어야 단일 AZ 소실 같은 대규모 재난 상황에서도 데이터 파손을 막을 수 있다.


4.7.2. 소거 코드(Erasure coding)

데이터를 3중 다중화하면 99.9999% 내구성을 달성할 수 있지만, 200%에 달하는 막대한 저장 공간 오버헤드가 생긴다.
원본 1GB에 더해 추가 복제본 2GB(1GB * 2)가 더 필요하므로, 원본 대비 추가로 드는 공간 오버헤드는 200%가 된다. (총 필요 용량은 300%)

이 비용을 획기적으로 낮추면서 내구성을 올리는 방안이 바로 소거 코드이다.
소거 코드는 원본 데이터를 작은 단위로 분할하여 서로 다른 서버에 배치하는 한편, 데이터 일부가 소실되었을 때 복구하기 위한 패리티(Parity) 정보를 수학적으로 계산하여 중복성을 확보한다.


4.7.2.1. 4+2 소거 코드의 데이터 복원 절차

소거 코드를 통한 데이터 복구

① 원본 데이터를 4개의 같은 크기 단위(\(d_1, d_2, d_3, d_4\))로 분할한다.
\(d_1, d_2, d_3, d_4\)의 데이터는 모두 다른 내용의 데이터이다.
원본 파일 바이너리를 동일한 바이트 크기로 4등분한 서로 다른 조각이다.
예를 들어 ‘일이삼사’라는 텍스트 파일이 있다면 \(d_1=\text{일}\), \(d_2=\text{이}\), \(d_3=\text{삼}\), \(d_4=\text{사}\) 형태로 분할된다.

리드-솔로몬 수학 공식(Reed-Solomon Code)을 적용하여 패리티 \(p_1, p_2\)를 계산한다.
이해를 돕기 위해 단순화된 선형 방정식 예시를 들어보면 아래와 같다.
\(p_1 = d_1 + 2d_2 - d_3 + 4d_4\)
\(p_2 = -d_1 + 5d_2 + d_3 - 3d_4\)
여기서 데이터는 ‘일,이, 삼,사’같은 문자나 파일 데이터인데 어떻게 +2, *4 같은 수학 수식이 가능한걸까?
컴퓨터 내의 모든 데이터는 결국 01로 구성된 바이너리 바이트(0~255 숫자) 값이다. 예를 들어 ‘일’이라는 글자의 UTF-8 바이트 표기값은 0xEC(236)와 같은 숫자 형태이다.
소거 코드는 갈루아 체(Galois Field \(GF(2^8)\))의 대수학 수식을 이용하여 각 바이트 숫자에 연산을 직접 적용한다.

③ 데이터 노드 장치 장애로 인해 \(d_3\)과 \(d_4\) 데이터 조각이 소실된다.

④ 남은 데이터 (\(d_1, d_2\)) 및 패리티(\(p_1, p_2\))값과 연립 방정식을 결합하면 소실된 \(d_3, d_4\)를 완벽히 복원할 수 있다.


💡남은 값들로 어떻게 \(d_3, d_4\)를 복원할까?

미지수가 \(d_3, d_4\) 2개이고, 알려진 수식 \(p_1, p_2\)가 2개인 2원 1차 연립방정식 문제가 된다.
\(d_3 - 4d_4 = d_1 + 2d_2 - p_1\)
\(-d_3 + 3d_4 = -d_1 + 5d_2 - p_2\)

알려진 값 \(d_1, d_2, p_1, p_2\)를 대입하여 두 식을 더하면 \(d_4\)가 도출되고, 순차적으로 \(d_3\)도 완벽히 역산하여 복원 가능하다.


4.7.2.2. 8+4 소거 코드와 장애 도메인 결합

(8+4) 소거 코드

위 그림의 각 요소는 아래를 의미한다.

  • 최상단 네모들
    • 분할 되기 전의 원본 객체 파일들
  • 중간 톱니바퀴
    • 패리티를 계산하는 수학적 인코더 엔진
  • 12개의 중간 도형들(마름모, 원)
    • 원본 데이터 8조각(\(d_1 ... d_8\)) + 패리티 4조각(\(p_1 ... p_4\)) = 총 12개의 데이터 블록
  • 화살표 및 장애 도메인
    • 만들어진 12개 조각을 서로 다른 12개의 독립된 장애 도메인(랙/AZ)에 각 1개씩 분산 배치하는 모습

8+4 소거코드를 사용하였으므로 원본 데이터는 8조각으로 분할하였고, 4개의 패리티를 계산한다.
그 결과 만들어진 12조각의 데이터는 전부 같은 크기로 12개의 장애 도메인에 분산한다.
소거 코드의 수식 덕에 최대 4개 노드에 장애가 동시에 발생하더라도 원본 데이터를 복원할 수 있다.


💡8+4 소거 코드에서 원본 데이터를 복원할 수 있는 최대 노드 장애 개수는 왜 4개일까?

패리티 블록이 4개라는 것은 수학적으로 독립된 4개의 방정식을 가지고 있다는 뜻이다.
연립방정식의 해를 구하려면 미지수의 개수가 방정식의 개수보다 작거나 같아야 하므로, 소실될 수 있는 최대 조각(미지수)의 수도 최대 4개로 제한된다.


데이터를 다중화할 경우 데이터 라우터는 객체 데이터를 하나의 건강한 노드에서 읽으면 충분했지만, 소거 코드를 사용하면 최대 8개의 건강한 노드에서 데이터를 가져와야 한다.
원본 데이터를 8조각으로 쪼개어 보관했기 때문이다.
패리티 연산 없이 정상적인 상황에서 원본 객체 하나를 조립해 내려면, 8개의 서로 다른 노드에 저장된 원본 조각 \(d_1 ... d_8\)을 전부 한꺼번에 읽어와야만 하나의 파일로 완성할 수 있다.


4.7.2.3. 저장 용량 오버헤드 비교

소거 코드의 구조적 단점이다.

응답 지연이 높아지는 대신 내구성은 향상되고, 저장소 비용은 낮아지지만, 객체 저장소는 저장 비용이 대부분이므로 이런 타협적 측면은 고려할 가치가 있다.

소거 코드를 사용하면 추가로 어느 정도의 저장 용량이 필요할까?
2개 데이터 블록에 하나의 패리티 블록이 필요하므로 오버헤드는 50%이고, 3중 복제 다중화 방안을 채택하는 경우에는 200% 이다.

다중화가 요구하는 추가 용량 vs 소거 코드가 요구하는 추가 용량

위 그림에서 다중화는 총 3GB, 소거 코드는 총 1.5GB가 필요하다.

  • 3중 복제
    • 원본 1GB + 복제본 2GB = 3GB
  • (4+2) 소거코드
    • 데이터 4 * 0.25GB(=1GB) + 패리티 2 * 0.25GB(=0.5GB) = 1.5GB (50% 오버헤드)

백블레이즈(Backblaze) 사의 통계에 따르면 8+4 소거 코드를 적용할 경우 무려 99.999999999%(11-nine) 수준의 내구성을 달성할 수 있다.

다중화 vs 소거 코드 비교

구분다중화 (Replication)소거 코드 (Erasure Coding)
내구성99.9999% (3중 복제)99.999999999% (8+4 소거 코드) [우월]
저장소 효율성200% 오버헤드50% 오버헤드 [우월]
계산 자원추가 연산 없음 [우월]패리티 계산 및 복원에 높은 CPU 자원 소모
쓰기 성능추가 계산 없어 쓰기 성능이 높고, 지연 시간 낮음 [우월]디스크 기록 전 패리티 연산으로 지연 시간 증가
읽기 성능단일 건강 노드에서 즉시 읽기 [우월]8개 노드 동시 조회 및 장애 시 복원 연산으로 지연 증가

요약하면 응답 지연이 중요한 애플리케이션은 다중화 방안을 권장하고, 저장소 비용이 중요하면 소거 코드를 권장한다.
소거 코드는 비용 효율과 내구성 측면에서는 매력적이지만 데이터 노드 설계 측면에서 까다로우므로 여기서는 다중화 방안에 중점을 두고 설명한다.


4.7.3. 정확성 검증: Checksum

소거 코드를 적용하면 적은 저장 공간 오버헤드로도 디스크 하드웨어 장애에 대비한 높은 데이터 내구성을 얻을 수 있다.
특정 디스크에 물리적 고장이 생기면 이를 노드 장애로 간주하고, 정상 상태인 다른 노드들의 패리티 데이터를 모아 연립방정식 계산으로 소실된 블록을 복구한다.

하지만 100PB 이상의 대규모 분산 저장소 환경에서는 디스크 단위의 완전한 정전이나 파손 외에도, 네트워크 전송 중의 비트 뒤틀림(Bit Flip)이나 메모리(RAM) 상의 데이터가 알 수 없는 이유로 오염되는 ‘무소음 데이터 오염(Silent Data Corruption / Bit Rot)’ 문제가 빈번하게 발생한다.


💡소거 코드는 메모리나 네트워크 상에서 발생한 데이터 훼손을 복구하지 못하는 걸까?

결론부터 말하면, 소거 코드는 ‘어느 블록이 훼손되었는지’ 위치를 미리 알려주지 않으면 메모리 오염을 스스로 감지하거나 복구할 수 없다.

  • 소거 코드의 한계
    • 소거 코드는 ‘3번 노드의 데이터 조각 \(d_3\)이 유실되었다’라는 명확한 불량 판정이 전제되어야 수학적 역산이 작동한다.
    • 데이터가 조용히 뒤틀린 상태에서 소거 코드가 이를 정상 데이터로 착각하고 읽어들이면, 오염된 데이터를 그대로 클라이언트에게 전달하거나 잘못된 패리티 연산을 수행하게 된다.
  • 체크섬과의 협업
    • 따라서 데이터를 디스크나 메모리에서 읽어올 때 체크섬을 대조하여 **‘3번 노드의 \(d_3\) 조각이 오염되었다’라고 손상 여부를 먼저 감지해주어야 한다.
    • 불량 블록임이 확인되어야 비로소 소거 코드 재구성 연산이 발동하여 다른 정상 노드의 패리티 조각들로부터 깨끗한 원본 \(d_3\)를 복원해낼 수 있다.

체크섬의 생성과 검증 원리

데이터 훼손 문제는 프로세스 경계 및 전송 구간마다 데이터 정합성 검증을 위한 체크섬(Checksum)을 두어 해결한다.
체크섬은 원본 데이터를 입력받아 에러 유무를 판별할 수 있도록 생성한 작고 고유한 해시 데이터 블록이다.

데이터를 저장하거나 전송할 때 원본 데이터 기반의 체크섬을 미리 계산해둔다.

체크섬 생성

이후 데이터를 가져오거나 네트워크로 수신할 때, 전달받은 데이터로 체크섬을 다시 계산하여 기존 체크섬과 대조한다.

  • 재계산한 체크섬이 원본 체크섬과 다를 경우
    • 메모리, 디스크, 혹은 네트워크 전송 과정에서 데이터 바이너리가 손상된 것으로 확정
  • 두 체크섬이 일치할 경우
    • 입력값이 1비트만 달라져도 완전히 다른 해시 결과를 내놓는 체크섬 알고리즘 특성상, 데이터가 온전히 보존되었다고 간주
      • (해시 충돌 가능성이 극도로 낮아 현실적으로 100% 온전하다고 판단)

체크섬 계산


체크섬 알고리즘과 데이터 노드 저장 위치

체크섬 계산 방식으로는 MD5, SHA-1, HMAC 등 다양한 해시 알고리즘이 존재한다.
여기서는 계산 방식이 간단하고 빠르게 동작하는 MD5를 예시로 사용한다.

본 설계안에서 체크섬은 데이터 파일의 맨 끝(Tail)에 덧붙여 보관한다.
데이터 노드가 디스크 내 전담 파일(/data/c)을 읽기 전용(Read-only) 상태로 전환하기 직전, 파일 전체 데이터에 대한 MD5 체크섬을 계산하여 파일의 마지막 위치에 덧붙여 기록한다.

데이터 노드에 체크섬 추가


🔥체크섬 알고리즘 최신 기술 트렌드

여기서는 이해하기 쉬운 MD5 알고리즘을 제시하지만, 실제 AWS S3나 Ceph 같은 최신 대규모 프로덕션 객체 저장소에서는 CPU 하드웨어 가속 명령어(SSE4.2/AVX)를 지원하는 CRC32CxxHash 알고리즘을 주로 사용한다.
MD5 대비 CPU 자원을 수 배 이상 절약하면서도 초당 수 GB의 데이터를 실시간으로 검증할 수 있기 때문이다.


(8+4) 소거 코드 + 체크섬 결합 읽기 파이프라인

(8+4) 소거 코드와 체크섬 검증 메커니즘을 동시 적용한 환경에서, 클라이언트가 객체 데이터를 요청할 때 처리되는 전체 읽기 흐름은 다음과 같다.

  • 데이터 및 체크섬 조회
    • 데이터 노드는 디스크 전담 파일에서 요청받은 데이터 조각과 함께 파일 끝에 저장되어 있던 원본 체크섬을 조회
  • 실시간 체크섬 검증
    • 읽어온 데이터 조각으로 체크섬을 즉시 다시 계산하여 저장된 원본 체크섬과 대조
    • 체크섬 일치: 데이터 오염이 없는 상태이므로 다음 단계로 진행
    • 체크섬 불일치: 비트 오염 등으로 데이터가 망가진 것이므로 해당 조각을 즉시 폐기하고, 다른 정상 장애 도메인(노드)들에서 패리티 조각들을 읽어와 소거 코드 역산 연산으로 원본 데이터 조각을 복구
  • 8개 조각 검증 완료
    • 원본 객체를 구성하는 8개 데이터 조각에 대해 1~2번 검증 및 복구 과정을 성공적으로 마칠 때까지 반복
  • 최종 원본 객체 전달
    • 정합성이 완벽히 검증된 8개 조각을 하나의 완성된 원본 파일로 재조립하여 클라이언트에게 최종 반환

4.8. 메타데이터 데이터 모델 및 규모 확장

객체 저장소의 메타데이터는 객체 이름, 크기, 생성일, 소유자 ID, 액세스 권한, 그리고 실제 객체 데이터 조각이 데이터 노드의 어느 파일/오프셋에 보관되어 있는지에 대한 위치 정보를 관리한다.


4.8.1. 스키마 설계

메타데이터 저장소는 객체 이름 기반의 ID 조회, 객체 생성/삭제, 그리고 특정 버킷 내의 객체 목록 조회를 효율적으로 처리할 수 있어야 한다.

  • bucket 테이블: 버킷 레코드는 사용자가 생성한 저장 영역 단위의 메타데이터를 보관한다.
    • bucket_name: 전역적으로 유일한 버킷 이름 문자열(PK)
    • bucket_id: 시스템 내부에서 버킷을 식별하기 위해 사용하는 UUID
    • owner_id: 해당 버킷을 소유한 사용자 또는 계정의 식별자(IAM 권한 검증용)
    • enable_versioning: 해당 버킷의 객체 버전 관리(Versioning) 기능 활성화 여부
  • object 테이블: 객체 메타 데이터를 보관한다.
    • bucket_id: 객체가 속한 버킷의 ID
    • object_name: 버킷 내에서 객체 경로/이름 문자열
    • object_id: 객체 고유의 UUID
    • object_version: 버전 관리를 위해 부여되는 버전을 식별하는 값(TIMEUUID)
    • file_name: 데이터 노드 내부의 디스크 파일명(예: /data/c)
    • start_offset: 해당 파일 내에서 객체 데이터가 시작되는 디스크 바이트 오프셋 위치
    • object_size: 객체의 바이트 단위 크기

4.8.2. bucket 테이블 규모 확장

버킷의 총개수는 전체 사용자 수에 비례하며, 사용자당 생성하는 버킷 수가 많지 않기 때문에 상대적으로 용량이 작다.

예를 들어 100만 명의 사용자가 각각 10개의 버킷을 생성하고 버킷 레코드당 1KB를 사용한다고 가정하면, 전체 데이터 용량은 약 1GB(1,000,000명 * 10개 버킷 * 1KB)에 불과하다.
따라서 단일 RDB 서버만으로도 전체 버킷 메타데이터 저장 공간을 충분히 커버할 수 있다.

다만, 버킷 정보 조회가 빈번히 일어나는 읽기 트래픽을 분산하기 위해 Primary DB - Read Replica DB 구조로 다중화하여 확장성을 확보한다.


4.8.3. object 테이블 규모 확장: sharding

object 테이블은 수십억 개 이상의 레코드를 보관해야 하므로 단일 DB 서버로는 용량과 I/O 성능을 감당할 수 없다.
따라서 여러 DB 노드로 데이터를 분산하는 샤딩이 필수적이다.

샤딩 키는 hash(bucket_name + object_name) 순서쌍으로 하는 것을 권장한다.

  • bucket_id로 샤딩할 경우의 문제
    • 특정 대형 기업 고객이 하나의 버킷에 수십억 개의 객체를 저장하면, 해당 bucket_id가 속한 단일 샤드 DB 서버에 트래픽과 용량이 쏠리는 핫스팟(Hotspot) 문제가 발생
  • object_id로 샤딩할 경우의 문제
    • 샤드는 고르게 분산되지만, REST API 요청인 GET /bucket-name/object-name 형태의 URI 조회 시 어떤 샤드에 해당 객체가 있는지 알 방법이 없어 모든 샤드를 전수 조사(Scatter-Gather)해야 함

따라서 버킷 이름과 객체 이름을 조합한 해시값을 샤딩 키로 쓰면, 특정 대용량 버킷 안의 객체들이 여러 샤드 서버로 아주 고르게 분산 저장될 뿐만 아니라, URI 조회 시에도 해시값만 계산하여 단 하나의 샤드 DB로 즉시 조회가 가능해진다.

Scatter-Gather

분산 시스템에서 일단 모두에게 다 물어보고(Scatter), 응답을 하나로 모으는(Gather) 쿼리 처리 방식


4.9. 버킷 내 객체 목록 확인

객체 저장소는 본질적으로 디렉터리라는 물리적 개념이 존재하지 않는 수평적 평면 구조(Flat Key-Value Store)이다.
모든 객체는 단지 고유한 Key 이름을 가질 뿐이다.

하지만 사용자는 파일 시스템의 디렉터리 구조에 익숙하므로, 객체 저장소는 접두어와 구분자(주로 /)를 활용하여 계층적 디렉터리 구조를 시뮬레이션하여 보여주는 기능을 제공한다.


4.9.1. 디렉터리 계층 구조 시뮬레이션 및 CLI 동작

AWS S3 CLI 명령어인 aws s3 ls는 평면적인 객체 키 목록을 디렉터리처럼 구조화하여 출력한다.

# 1. 버킷 최상위 목록 조회 (구분자 '/' 적용)
aws s3 ls s3://my-bucket/

# 2. 특정 접두어(Prefix) 하위의 목록 조회
aws s3 ls s3://my-bucket/a/

# 3. 특정 접두어 하위의 모든 객체를 재귀적으로 조회
aws s3 ls s3://my-bucket/a/ --recursive

이해를 돕기 위해 버킷 내에 다음과 같은 4개의 객체(Key)가 평면적으로 저장되어 있다고 가정해보자.

  • KR/cities/seoul.txt
  • KR/cities/suwon.txt
  • NY/cities/ny.txt
  • federal.txt

이 버킷을 대상으로 AWS S3 CLI 명령어를 수행하면 아래와 같이 표시된다.

  • aws s3 ls s3://my-bucket/
    • KR/
    • NY/
    • federal.txt
  • aws s3 ls s3://my-bucket/KR/
    • cities/
  • aws s3 ls s3://my-bucket/KR/ --recursive
    • KR/cities/seoul.txt
    • KR/cities/suwon.txt

4.9.2. 단일 데이터베이스 서버에서의 목록 조회 및 애플리케이션 파싱

단일 RDB 환경에서의 SQL은 LIKE 연산자와 ORDER BY를 사용하여 접두어 조회를 효율적으로 처리할 수 있다.

-- 버킷 '123' 내에서 'KR/' 접두어로 시작하는 객체 목록 조회
SELECT object_name, object_size, updated_at 
FROM object
WHERE bucket_id = '123' 
  AND object_name LIKE 'KR/%'
ORDER BY object_name ASC
LIMIT 10;

DB 질의 결과로 KR/cities/seoul.txtKR/cities/suwon.txt 가 반환되었을 때, 애플리케이션 레이어는 다음과 같은 가상 폴더를 만들어낸다.

  • 접두어 제거: 요청 조건이었던 KR/ 부분을 문자열에서 잘라냄
    • KR/cities/seoul.txt → cities/seoul.txt
    • KR/cities/suwon.txt → cities/suwon.txt
  • 구분자 스캔: 남은 문자열에서 첫 번째 슬래시까지의 위치를 찾음
    • cities/seoul.txt → 첫 슬래시까지 잘라내어 cities/ 추출
    • cities/suwon.txt → 첫 슬래시까지 잘라내어 cities/ 추출
  • 중복 제거: 추출된 동일한 문자열 cities/ 를 하나로 합쳐 클라이언트에게 PRE cities/(가상 디렉터리) 항목으로 최종 응답

4.9.3. 분산 데이터베이스(샤딩) 환경에서의 목록 조회 및 오프셋 추적의 난제

앞서 다루었듯, 메타데이터 DB는 단일 버킷에 트래픽이 쏠리는 핫스팟을 방지하기 위해 hash(bucket_name + object_name) 순서쌍을 샤딩 키로 활용하여 레코드를 여러 샤드 DB 서버에 분산 저장한다.

이로 인해 동일한 KR/ 접두어를 공유하는 객체들이라 하더라도 해시값에 의해 완전히 다른 샤드 DB 서버로 흩어지게 된다.

[샤드 DB 1] ──> 'KR/cities/seoul.txt'  (해시값: 0x2A...)
[샤드 DB 2] ──> 'NY/cities/ny.txt'     (해시값: 0x9B...)
[샤드 DB 3] ──> 'KR/cities/suwon.txt'  (해시값: 0x5F...)
[샤드 DB 4] ──> 'federal.txt'          (해시값: 0x1C...)

분산 DB에서 페이징 나눔이 어려운 이유(샤드별 오프셋 추적)

객체가 여러 샤드에 나뉘어 있으므로, 각 샤드가 조건에 맞아 반환하는 객체 수는 제각각이다.

  • 어떤 샤드에는 한 페이지를 꽉 채울 10개의 객체가 존재
  • 어떤 샤드에는 2~3개만 존재
  • 어떤 샤드에는 아예 조건에 맞는 객체가 없음

따라서 애플리케이션 코드는 모든 샤드에 질의를 보낸 후(Scatter), 반환된 결과를 받아 메모리에서 하나로 취합 및 정렬(Gather & Sort)한 뒤 10개만 추려내어 클라이언트에 반환해야 한다.

문제는 이번 페이지에 포함되지 못하고 남은 객체들은 다음 페이지 요청 시 다시 고려되어야 한다는 점이다.
즉, 샤드마다 읽어들인 위치(오프셋)가 서로 달라지게 된다.

[페이지 1 요청 결과]
- 샤드 1: 5개 읽음 (다음 시작 오프셋: 5)
- 샤드 2: 1개 읽음 (다음 시작 오프셋: 1)
- 샤드 3: 4개 읽음 (다음 시작 오프셋: 4)
- 샤드 4: 0개 읽음 (다음 시작 오프셋: 0)

Scatter-Gather 및 OFFSET 페이징의 성능 폭망 문제

특정 버킷이 전체 목록을 알파벳순(ORDER BY object_name)으로 나열하려면, 애플리케이션은 모든 샤드 DB에 흩어져 있는 결과값을 읽어온(Scatter-Gather) 후 병합 정렬(Merge Sort)해야만 한다.

이 분산 환경에서 전통적인 RDB의 OFFSET 100000 LIMIT 10 방식의 페이징을 적용할 경우 다음과 같은 치명적인 성능 저하가 발생한다.

  • Scatter-Gather 오버헤드
    • 모든 샤드 DB 각각에 OFFSET 100000 LIMIT 10 질의를 전송해야 함
  • 읽기 데이터의 기하급수적 증가
    • 샤드가 10개라면 각 샤드가 100,010개씩, 총 1,000,100개의 레코드를 메모리로 읽어와서 정렬해야 100,000번째 페이지의 10개 데이터를 건져낼 수 있음
  • 네트워크 및 CPU 병목
    • \(O(N \times (\text{OFFSET} + \text{LIMIT}))\) 형태의 컴퓨팅 비용이 발생하여 페이징 깊이가 깊어질수록 시스템 전체가 마비됨

4.9.4. 객체 저장소의 아키텍처 타협점(Trade-off)

대규모 객체 저장소 설계는 시스템의 무한한 규모 확장성과 데이터 내구성 최적화에 최우선 순위를 둔다.
상대적으로 객체 목록 출력 명령의 성능을 보장하는 것은 우선순위가 낮다.

실제로 AWS S3를 포함한 시중의 상용 객체 저장소가 제공하는 객체 목록 출력 기능의 반응 속도와 처리량은 단일 DB 조회 대비 최적과는 거리가 멀다.
데이터 보관 비용과 내구성을 극대화하는 대신, 목록 조회 성능을 일정 부분 양보하는 아키텍처적 타협을 선택했기 때문이다.


💡실무에서의 RDB 샤딩 시 페이징 기법은 무엇이 있을까?

실무에서는 샤드별 오프셋을 직접 추적하는 방식을 가급적 피하며, 아래 4가지 대안 기법을 사용한다.

  • 1. 커서(Keyset) 기반 페이징: 오프셋 대신 ‘마지막 읽은 키’ 조건절을 전달하여 인덱스 스캔 유도
  • 2. 샤딩 미들웨어 도입: 미들웨어를 두어서 DB 프록시 레이어에서 오프셋 추적을 자동화
  • 3. 검색 엔진(Elasticsearch) 연동: RDB는 CUD만 담당하고, 페이징 조회는 검색 엔진으로 이관
  • 4. 목록 전용 비정규화 테이블: 목록 전용 테이블을 bucket_id 기준으로 샤딩하여 단일 DB 샤드에서 정렬 및 페이징 일괄 처리

1. 커서 기반 페이징(Keyset / Cursor-based Pagination): 가장 보편적

샤드별 오프셋 숫자(OFFSET 100)로 추적하는 대신, ‘이전 페이지에서 마지막으로 읽은 객체 이름(Last Evaluated Key)’만을 커서로 전달

-- 애플리케이션은 각 샤드에 동일한 '커서 조건'만 던지면 됨
SELECT object_name, object_size 
FROM object
WHERE object_name > 'KR/cities/seoul.txt' -- 이전 페이지의 마지막 키
ORDER BY object_name ASC
LIMIT 10;

이 방법을 사용하면 애플리케이션은 샤드별 오프셋 숫자를 계산할 필요가 없다.
단지 클라이언트가 들고 온 ‘마지막 키 문자열’ 하나만 모든 샤드에 조건절(WHERE key > cursor)로 던진 후, 각 샤드에서 올라온 상위 10개만 정렬하면 된다.


2. 샤딩 미들웨어 도입: DB 레이어에 위임

애플리케이션 코드가 샤딩을 인식하지 않도록 Vitess, Citus, Apache ShardingSphere 같은 샤딩 미들웨어를 프록시로 둔다.

이 방법을 사용하면 애플리케이션은 마치 단일 RDB에 SELECT * FROM object ORDER BY name LIMIT 10 OFFSET 20을 던지는 것처럼 개발한다.
샤드 분산, 샤드별 오프셋 추적, 병합 정렬은 미들웨어가 내부 메모리에서 알아서 처리해준다.


3. CUD와 Read의 분리

RDB 샤딩은 서비스 데이터 저장 및 수정 용도로만 쓰고, 복잡한 페이징 및 목록 조회는 Elasticsearch / OpenSearch 같은 분산 검색 엔진으로 읽기(Read)를 이관한다.

이 방법은 CDC(Debezium 등)를 통해 RDB 데이터를 검색 엔진으로 비동기 동기화한다.
검색 엔진은 역인덱스(Inverted Index) 구조를 가지므로 샤딩 분산 환경에서도 수억 건의 페이징 조회를 수 ms 내에 처리한다.


4. 목록 전용 비정규화 테이블 운용

목록 출력이 빈번하게 일어나는 서비스라면, 목록 조회 전용 테이블을 별도로 만들어 단일 샤드로 모이도록 샤딩 키를 다르게 설계한다.

object 테이블은 핫스팟 방지를 위해 hash(bucket_name + object_name)로 샤딩하더라도, 목록 전용 비정규화 테이블은 bucket_id만을 샤딩 키로 설정한다.

[주 메타데이터 DB: hash(bucket_name + object_name) 샤딩]
- 샤드 1: KR/cities/seoul.txt
- 샤드 2: NY/cities/ny.txt
- 샤드 3: KR/cities/suwon.txt
(분산 저장되어 핫스팟 방지)

[목록 전용 비정규화 DB: bucket_id 샤딩]
- 샤드 1 (버킷 '123' 전용): 
   ├── KR/cities/seoul.txt
   ├── KR/cities/suwon.txt
   └── federal.txt
(단일 샤드에 모여 있어 한 번의 단일 DB 질의로 페이징 완료)
  • 장점: 구현 단순화 및 성능 극대화
    • 버킷 내 모든 객체 목록이 단 1대의 데이터베이스 샤드 서버에 집중 보관되므로, 샤드 간 병합 정렬이나 샤드별 오프셋 추적 로직 없이 단일 RDB의 일반적인 ORDER BY + LIMIT/OFFSET 문 만으로 목록 조회 처리 가능
  • 단점: 동기화 비용
    • 객체가 업로드되거나 삭제될 때 Primary 테이블과 비정규화 목록 테이블 두 곳 모두에 데이터를 반영해야 하므로 이중 쓰기나 이벤트 기반 최종 일관성 동기화 파이프라인을 추가로 구축해야 함

4.10. 객체 버전 관리(Versioning)

객체 버전 관리는 동일한 버킷 안에서 동일한 객체 이름(Key)을 가진 여러 버전의 사본을 보관하고 관리하는 기능이다.
실수로 객체를 삭제하거나 덮어썼을 때 이전 상태로 복구할 수 있는 무결성 장치이다.

버전 관리 기능이 활성화되면 객체 저장소는 해당 문서의 이전 버전을 메타데이터 저장소에 그대로 유지하며, 기존 레코드를 덮어쓰거나 이전 버전에 삭제 표시를 남기지 않고 독립된 레코드로 보존한다.


4.10.1. 객체 업로드(PUT) 흐름

버전 기능이 활성화된 클라이언트가 객체를 업로드(PUT)할 때, 시스템 내부에서는 데이터 오버라이드를 방지하기 위해 아래와 같은 5단계 절차가 진행된다.

객체 버전

  • ① 클라이언트 요청 전송
    • 클라이언트가 script.txt 객체를 업로드하기 위해 API 서비스로 HTTP PUT 요청을 보낸다.
  • ② 인증 및 권한 검증
    • API 서비스는 사용자 신원을 확인한 후, 해당 사용자가 대상 버킷에 Write 권한이 있는지 검증한다.
  • ③ 데이터 저장소 영속화 및 UUID 발급
    • 권한 검증 결과 이상이 없으면, API 서비스는 객체 데이터를 데이터 저장소에 보낸다.
    • 데이터 저장소는 새 객체를 생성하여 디스크에 영속적으로 저장한 후, API 서비스에 새 데이터 조각의 고유 UUID를 반환한다.
  • ④ 메타데이터 저장소 호출
    • API 서비스는 메타데이터 저장소를 호출하여 새 객체의 메타데이터 정보를 보관 요청한다.
  • TIMEUUID 기반 버전 레코드 추가
    • 메타데이터 저장소의 object 테이블에는 버전 기능을 지원하기 위한 object_version 컬럼이 존재한다.(이 컬럼은 버킷의 버전 기능이 활성화되어 있을 때 사용)
    • 기존 레코드를 덮어쓰는 대신, bucket_idobject_name은 동일하지만 object_id(3단계에서 반환받은 새 UUID)와 object_version은 새로운 값인 레코드를 테이블에 INSERT 한다.
    • 이 때, object_version은 새로운 레코드가 테이블에 추가될 때 자동으로 생성되는 TIMEUUID 값이다.
    • 동일한 object_name을 갖는 항목들 가운데 object_version에 기록된 TIMEUUID 값이 가장 큰 레코드가 해당 객체의 최신 버전이 된다.

메타데이터와 버전 정보


4.10.2. 삭제 표식(Delete Marker)을 통한 비파괴적 삭제 메커니즘

버전 관리 환경에서는 객체 삭제 요청이 들어오더라도 물리적 데이터나 메타데이터를 지우지 않는다.

삭제 표시 삽입을 통한 객체 삭제

  • ① Delete Marker 추가
    • 객체를 삭제할 때 버킷 안의 모든 기존 버전을 그대로 둔 채, 단순히 삭제 표식이라는 특별한 레코드를 최신 버전으로 추가한다.
  • ② 현재 버전 승격
    • 삭제 표식 또한 객체의 새로운 버전이므로, 테이블에 삽입되는 순간 해당 객체의 새로운 ‘현재 버전’이 된다.
  • ③ GET 요청 시 404 NOT FOUND 응답
    • 삭제 표식이 현재 버전인 상태에서 클라이언트가 해당 객체를 조회하면, 저장소 API 서비스는 삭제 표식을 확인하고 객체가 존재하지 않는 것으로 취급하여 404 NOT FOUND 오류를 반환한다.

4.11. 대용량 업로드 최적화: Multipart Upload와 ETag

대용량 객체를 단일 HTTP 요청으로 업로드하는 것은 네트워크 대역폭 및 시스템 안정성 측면에서 매우 큰 위험을 부담한다.

단일 스트림 업로드 방식은 네트워크에 순간적인 패킷 유실이나 타임아웃이 발생할 경우 수십 GB의 파일 전체를 처음부터 다시 업로드해야 하며, 단일 TCP 연결 대역폭의 한계로 인해 디스크 쓰기 속도를 속도감 있게 활용하지 못한다는 치명적인 단점이 존재한다.


4.11.1. 멀티파트 업로드 3단계 처리 파이프라인

이러한 문제를 해결하기 위해 대용량 객체를 작은 파트 단위로 나누어 병렬로 전송하는 멀티파트 업로드 메커니즘을 제공한다.

멀티 파트 업로드는 클라이언트와 저장소 API 간의 아래와 같은 3단계 상호작용을 통해 이루어진다.

[클라이언트] ───(1. InitiateMultipartUpload)───> [API 서비스]
            <───(UploadID 반환: "upload_89f2")───
                                
[클라이언트] ───(2. Part 1 Upload + UploadID)───> [API 서비스] (ETag 1 반환)
            ───(2. Part 2 Upload + UploadID)───> [API 서비스] (ETag 2 반환)
            ───(2. Part 3 Upload + UploadID)───> [API 서비스] (ETag 3 반환)
                        (병렬 전송)
                                
[클라이언트] ───(3. CompleteMultipartUpload)───> [API 서비스]
                 (UploadID + ETag 목록 전달)       (파일 결합 및 영속화)

멀티파트 업로드

  • 1단계: 업로드 시작(Initiate Multipart Upload)
    • ① 클라이언트가 대상 버킷과 객체 이름을 지정하여 멀티파트 업로드 개시 요청을 보낸다.
    • ② 서버는 이 세션을 식별하기 위한 고유 트랜잭션 키인 UploadID를 발급하여 클라이언트에 응답한다.
  • 2단계: 파트별 독립 및 병렬 업로드(Upload Parts)
    • 클라이언트는 원본 대용량 파일을 지정된 크기(예: 8MB~100MB)의 파트 조각으로 나눈다.
    • ③ 각 파트에 1,2,3.. 순서로 파트 번호를 부여하고, UploadID와 파트 번호를 포함하여 여러 파트를 독립적인 HTTP 요청으로 병렬로 전송한다.
    • ④ 서버는 파트 데이터를 수신하여 저장소에 영속화한 후, 해당 파트 바이너리인 MD5 해시값인 ETag를 생성하여 클라이언트에 응답한다.
    • 업로드 도중 특정 파트 전송이 실패하더라도, 전체 파일을 재전송할 필요 없이 실패한 해당 파트만 재전송하면 된다.
  • 3단계: 업로드 완료 및 객체 결합(Complete Multipart Upload)
    • ⑤ 모든 파트의 전송이 완료되면 클라이언트는 UploadID, 그리고 파트 번호와 수신한 ETag 목록을 배열 형태로 담아 CompleteMultipartUpload 요청을 전송한다.
    • ⑥ 서버는 클라이언트가 제출한 ETag 목록과 실제 저장된 파트 조각들의 정합성을 최종 검증한 후, 분산 저장된 조각들을 하나의 완성된 객체 메타데이터로 연결 조립한다.

⚠️멀티파트 업로드의 구조적 한계: 조립 후 버려지는 파트 조각 문제

멀티파트 업로드 접근법에서 반드시 고려해야 할 문제점은 ‘객체 조립이 완료된 후에는 디스크에 따로 남아있는 파트 조각들이 더 이상 쓸모가 없어진다’는 사실이다.

  • 독립 파일 저장
    • 2단계 진행 시 데이터 노드 디스크에는 Part 1, Part 2 … 가 각각 독립된 임시 파일 형태로 기록된다.
  • 조립 완료 후 참조 중단
    • 3단계에서 서버가 조각들을 하나의 원본 객체로 조립하고 나면, 디스크에 보관되어 있던 개별 파트 조각 파일들은 원본 객체 메타데이터와의 연결이 끊어지게 된다.
  • 용량 고갈 위험
    • 이 쓸모없어진 파트 조각들을 제때 지워주지 않으면 원본 객체 용량 외에 추가로 파트 조각 용량이 디스크를 중복 점유하게 된다.

따라서 객체 조립이 끝난 후 디스크 공간을 확보하기 위해, 쓸모없어진 파트 조각들을 백그라운드에서 주기적으로 삭제해 주는 GC(Garbage Collection) 메커니즘이 필수적으로 연동되어야 한다.


💡일반적인 백엔드의 multipart/form-data vs 객체 저장소의 Multipart Upload 차이점

결론적으로 말하자면, 둘은 이름만 비슷할 뿐 완전히 다른 개념이다.
백엔드 개발에서 일어나는 혼동을 바로잡기 위해 두 방식의 차이점을 다음과 같이 비교 정리할 수 있다.

구분HTTP Standard multipart/form-data (일반 웹 백엔드)S3 / 객체 저장소의 Multipart Upload (S3 API)
개념단일 HTTP 요청 바디 안에서 텍스트 데이터와 파일 데이터를 구분자(boundary)로 나누어 보내는 MIME 표준하나의 대용량 파일 자체를 여러 개(N개)의 독립된 HTTP 요청 조각으로 분할하여 전송하는 API 프로토콜
파일 분할 여부파일을 조각내지 않음 (1GB 파일도 단 1개의 HTTP 요청으로 통째로 전송)파일을 여러 개(예: 8MB 단위)의 청크(Chunk) 조각으로 실제로 분할하여 전송
전송 방식단일 TCP 연결 스트림 전송여러 HTTP 요청을 통한 병렬(Parallel) 전송
중단 시 복구실패 시 1GB 전체를 처음부터 다시 전송실패한 특정 파트 조각(8MB)만 재전송 가능 (Resumable Upload)
  • HTTP multipart/form-data
    • 단일 HTTP 요청 내부에서 Form 데이터와 파일 데이터를 구분자(boundary)로 나누어 보낼 뿐, 단일 파일 바이너리를 청크 단위로 분할하여 전송하지 않는다.
  • 객체 저장소 Multipart Upload
    • 대용량 파일 자체를 8MB 이상의 파트 조각들로 분할하여 독립적인 HTTP 요청으로 병렬 전송한다.
    • 실패 시 해당 파트만 재전송하면 되므로 대역폭 효율과 안정성이 뛰어나다.

💡일반 백엔드 개발에서 S3처럼 ‘진짜 분할 업로드’를 구현하려면?

일반 웹 백엔드(Spring, Node.js..)에서 S3처럼 끊어 올리기(Resumable Upload)나 대용량 파일 자동 분할 업로드를 구현하고 싶다면, multipart/form-data만으로는 불가능하며 다음과 같은 방법을 사용한다.

  • 프론트엔드 Javascript File.slice() 활용
    • JS의 Blob API인 file.slice(start, end)를 이용해 클라이언트 브라우저에서 직접 8MB 단위로 파일을 자른 뒤, 백엔드 REST API에 파트 번호와 함께 N번의 API 요청을 보낸다.
    • 백엔드는 임시 디스크나 DB에 쪼개진 파트를 저장해 두었다가 마지막에 하나로 결합(File Stream Append)한다.
  • 오픈소스 프로토콜 도입(TUS Protocol)
    • 대용량 분할/이어올리기 표준 오픈소스 프로토콜인 tus.io 같은 라이브러리를 프론트/백엔드에 도입하여 자동화한다.
  • AWS SDK 자동화
    • AWS SDK, Google Cloud SDK, S3 CLI 도구 등은 파일 크기가 특정 임계치(기본 8MB~16MB)를 초과하면 클라이언트단 라이브러리가 메모리상에서 파일을 청크(Chunk) 단위로 자동 분할하여 병렬 전송하고, 완료 결합 요청까지 자동으로 수행한다.

4.12. Garbage Collection

객체 저장소는 무한에 가까운 용량 확장을 목표로 하므로, 가용 저장 공간을 효율적으로 관리하는 것이 핵심 과제이다.

시간이 흐름에 따라 데이터 노드의 디스크에는 논리적으로 지워진 데이터, 더 이상 참조되지 않는 구버전 데이터, 그리고 조립이 끝났거나 중단된 멀티파트 파트 조각 등 쓸모없는 ‘쓰레기 데이터’가 지속적으로 누적된다.

이러한 유효하지 않은 데이터를 백그라운드에서 감지하고 회수하여 디스크 공간을 재확보하는 프로세스가 바로 GC(Garbage Collection)이다.


4.12.1. Garbage가 발생하는 주요 원인

대규모 객체 저장소 환경에서 GC의 처리 대상이 되는 쓰레기 데이터들은 주로 다음 경로를 통해 발생한다.

  • 논리적으로 삭제(Delete Marker)되거나 덮어써진 객체 및 구버전 사본
    • 사용자가 특정 버전 ID를 지정하여 영구 삭제 요청을 보내거나, 수명주기 규칙에 의해 보존기간이 만료된 데이터 조각들은 즉시 GC의 대상이 된다.
  • 멀티파트 조립 완료 후 남아있는 파트 조각들
  • 업로드 도중 취소되거나 중단된 채 방치된 멀티파트 업로드 파트 조각들
    • 완료되지 못하고 방치된 고아(Orphan) 파트 조각들 또한 주기적 GC 대상이 된다.
  • 다중 노드에 분산 보관 중인 사용되지 않는 데이터 사본들
    • 삭제된 객체에 대응하는 분산 복제본이나 패리티 조각들이 여러 노드에 남아있는 경우이다.

객체 저장소는 데이터 내구성을 위해 데이터를 여러 노드에 나누어 보관하므로, GC 시 모든 관련 노드의 사본을 동시 회수해야 하는 분산 동기화 과제가 존재한다.

[다중 노드 환경에서의 객체 삭제 / GC 범위]

1. 다중 복제(Replication) 방식
   [주 저장소 노드 (Primary)]  ──(GC 수행)──> 삭제 / 공간 회수
   [부 저장소 노드 (Secondary)] ──(GC 수행)──> 삭제 / 공간 회수 (동시 회수)

2. (8+4) 소거 코드(Erasure Coding) 방식
   [데이터 노드 1 ~ 8]   ──(GC 수행)──> 8개 원본 데이터 조각 삭제
   [패리티 노드 9 ~ 12]  ──(GC 수행)──> 4개 패리티 데이터 조각 삭제
   ------------------------------------------------------------------
   ★ 단 1개의 객체 삭제 시 -> 총 12개 노드 전체에서 데이터 회수 수행
  • 다중 복제 데이터의 사본 회수
    • 데이터를 다중화하여 저장하는 방식에서는 객체가 삭제 대상으로 판정되면, Primary Node뿐만 아니라 Secondary Nodes에 할당된 데이터 복사본까지 모두 찾아서 지워야 한다.
    • 백그라운드 GC 데몬은 메타데이터의 위치 정보를 조회하여 모든 Secondary 저장소 노드에 공간 회수 명령을 발행한다.
  • (8+4) 소거 코드 데이터의 사본 회수
    • (8+4) 소거 코드를 사용하는 환경에서는 객체 하나가 8개의 데이터 조각과 4개의 패리티 조각으로 쪼개져 총 12개의 서로 다른 장애 도메인(노드)에 분산 저장된다.
    • 따라서 객체 하나를 영구 삭제할 때, GC 데몬은 12개 노드 전체를 대상으로 해당 객체의 조각 파일들을 추적하여 지워야 완벽한 공간 회수가 이루어진다.

4.12.2. 데이터 노드 내부의 Compaction 메커니즘

데이터 노드는 디스크 I/O 성능 극대화를 위해 순차 쓰기 전용 Append-Only 구조로 파일(/data/c 등)을 관리한다.
파일 중간에 위치한 특정 객체나 파트 조각만 콕 집어서 물리적으로 지우는 것은 파일 시스템 특성상 불가능하다.

따라서 각 데이터 노드의 백그라운드 GC 데몬은 파일 압축(Compaction) 방식을 채택하여 쓰레기 데이터를 정리한다.

Garbage Collection 정리(Compaction) 메커니즘

  • 유효 객체 복사 및 불량 객체 건너뛰기
    • GC 프로세스는 기존 파일(/data/b)의 객체들을 새로운 파일(/data/d)로 순차 복사한다.
    • 이 과정에서 삭제된 객체임을 알리는 Flag 값이 true인 ‘객체 2’와 ‘객체 5’는 복사하지 않고 건너뛴다.
  • object_mapping 테이블 갱신
    • 유효한 모든 객체의 복사가 완료되면, GC는 데이터 노드 로컬 SQLite의 object_mapping 테이블(및 중앙 메타데이터 DB)을 갱신한다.
  • 속성 변경 유무
    • 예를 들어 복사되어 살아남은 ‘객체 3’의 경우 object_idobject_size 값은 변하지 않고 그대로 유지된다.
    • 그러나 디스크 상의 저장 위치가 바뀌었으므로 file_namestart_offset값은 새 위치(/data/d 및 신규 오프셋)를 가리키도록 수정된다.
      • file_name이 변경되는 이유는 객체의 디스크 내 물리적 저장 위치(파일)가 실제로 교체되었기 때문이다.
      • 원래 ‘객체 3’은 /data/b라는 파일 안의 start_offset = 5000 바이트 위치에 저장되어 있었다고 해보자.
      • 하지만 GC 프로세스가 쓰레기 데이터를 솎아내고 유효 객체들만 새로운 파일인 /data/d로 이관 복사하였다.
      • 따라서 ‘객체 3’이 실제로 존재하는 디스크 파일 경로가 /data/b에서 /data/d로 완전히 바뀌게 되었다.
  • 원자적 트랜잭션 보장
    • 데이터 일관성을 보장하기 위해 file_namestart_offset에 대한 갱신 연산은 반드시 동일한 단일 트랜잭션 안에서 원자적으로 수행하는 것이 바람직하다.

💡백그라운드 데몬(Daemon)이란?

데몬은 사용자가 직접 제어하지 않아도 보이지 않는 백그라운드에서 묵묵히 돌아가는 프로세스를 뜻한다.

리눅스나 서버 환경에서 이름 끝에 d가 붙어있는 프로그램들은 대부분 데몬이다.

  • httpd
    • 웹 요청을 기다렸다가 처리해주는 웹 서버 데몬
  • dockerd
    • 도커 컨테이너들을 백그라운드에서 관리해주는 도커 데몬
  • crond
    • 예약된 시간이 되면 알아서 작업을 실행해 주는 스케줄러 데몬

객체 저장소의 메인 프로세스(API 서비스)는 사용자들의 다운로드/업로드 요청을 처리하느라 바쁘다.
만일 메인 프로세스가 GC까지 직접 한다면 사용자 응답 속도가 크게 느려질 것이다.

따라서 메인 프로세스와 완전히 분리된 GC 데몬을 별도로 띄워 놓는다.

이 데몬 프로세스는 백그라운드에서 조용히 디스크를 스캔하며, 유효하지 않은 쓰레기 조각들을 주워 담아 폐기한다.


🔥객체 저장소의 발전 방향

  • NVMe-oF (NVMe over Fabrics) 및 캔자스/하드웨어 Offloading
    • 과거에는 데이터 노드의 CPU가 erasure coding 연산과 체크섬 계산을 부담했지만, 최근 AWS나 GCP 등의 최신 인프라에서는 SmartNIC 및 dedicated 인공지능/DSP 가속 칩을 통해 erasure coding 연산을 하드웨어 오프로딩하여 CPU 병목을 최소화한다.
  • S3 Express One Zone (초저지연 객체 저장소)
    • 2024년 이후 AWS가 선보인 S3 Express One Zone과 같이, 밀리초(ms) 단위의 지연 시간을 요구하는 AI/ML 학습 워크로드를 위해 초저지연 전용 싱글 AZ 객체 저장소 계층이 등장했다.
    • 기존의 3개 AZ 분산 복제 대신 디렉터리 노드(Directory Bucket) 구조와 고성능 NVMe 캐싱 층을 결합하여 초당 수만 건의 Small File Read/Write 성능을 제공한다.

💡오프로딩(Offloading)이란?

한 곳에 집중된 과부하나 작업 부담을 다른 곳으로 넘겨주는 행위를 말한다.

  • IT
    • CPU 같은 핵심 처리 장치의 과부하를 막기 위해 특정 연산이나 프로세스를 보조 장치(GPU, NPU 등)나 클라우드 서버로 이관하여 처리하는 기술
    • 예: 스마트폰의 복잡한 AI 연산을 전용 NPU나 클라우드로 보내 연산 속도를 높이고 배터리를 아끼는 것
  • 인지 심리학
    • 뇌의 기억 부담을 줄이기 위해 스마트폰 메모장, 달력 앱, 알람 등에 할 일을 기록해두는 행위
    • 뇌의 짐을 외부에 덜어냄으로써 더 중요한 생각에 집중할 수 있게 돕는다.

참고 사이트 & 함께 보면 좋은 사이트

본 포스트는 알렉스 쉬, 산 람 저자의 가상 면접 사례로 배우는 대규모 시스템 설계 기초 2를 기반으로 스터디하며 정리한 내용들입니다.






© 2020.08. by assu10

Powered by assu10