Distributed P2P: From Gnutella to IPFS
모든 사용자가 동등하게 연결되고, 정보를 자유롭게 주고받는 세상.
Distributed P2P: From Gnutella to IPFS

모든 사용자가 동등하게 연결되고, 정보를 자유롭게 주고받는 세상.
초창기 인터넷의 철학은 단순했다. 인터넷은 처음부터 중앙 통제 없이 작동하는 개방적이고 자율적인 네트워크로 설계되었다. 이러한 철학은 시간이 지나며 다양한 형태로 구현되었고, 그중에서도 2000년대 초반의 P2P(Peer-to-Peer) 기술은 그 철학을 구현한 대표적인 네트워크 모델이었다.
P2P는 “서버 없이도 모두가 동시에 server이자 client가 된다”라는 발상에서 출발했다. 이는 당시 웹 브라우저와 전자상거래 사이트 중심의 읽기 전용 웹(Web 1.0) 시대에서 사용자가 단순 소비자가 아닌 참여자로 전환되는 계기를 만들었다. 특히 Napster(1999), Gnutella(2000), eDonkey(2000), BitTorrent(2001)와 같은 서비스는 중앙 기관 없이도 전 세계 수백만 사용자가 음악과 영상을 공유할 수 있음을 보여주며 “인터넷은 모두의 네트워크다”라는 철학을 기술로 구현했다. 이번 글에서는 이러한 철학을 구현한 대표적인 Distributed P2P 시스템들을 살펴본다.
Gnutella
Gnutella는 중앙 서버 없이 피어들이 서로 직접 연결되어 질의와 응답을 교환하는 초창기 P2P 네트워크이다. “모든 클라이언트가 동시에 서버 역할도 수행한다”라는 점을 강조하며, 이런 양면적 역할을 하는 노드를 servent(server + client)라고 부른다. 구조가 분산되어 있어서 일부 서벤트가 오프라인이 되어도 네트워크 전체가 멈추지 않는 고장 허용성(fault tolerance)을 가진다. 아래 설명은 초창기의 Gnutella Protocol 0.4 스펙 문서를 참고하여 작성하였다. 이후 버전에서는 파일 검색 방식이 단순 Flooding에서 Bloom Filter 기반의 Query Routing Protocol로 개선되었으며, SHA-1 해시, URN, GGEP(Gnutella Generic Extension Protocol) 등 추가 필드가 도입되었다.
네트워크 합류 과정
네트워크 합류는 다른 servent의 주소를 먼저 알아내 TCP 연결을 맺는 것(Handshake)으로 시작된다. 부트스트랩 주소(최초 진입을 위한 주소)를 얻는 방식은 프로토콜의 범위 밖으로 두고, 연결이 열리면 문자열 *GNUTELLA CONNECT/0.4\\n\\n을 보내고, 상대가 이를 수락하면 `GNUTELLA OK\n\n`*로 응답한다. 이후부터는 Gnutella descriptor를 주고받으며 통신이 이루어진다.
모든 메시지는 공통된 descriptor header를 가지고 있으며, 이 헤더에는 16바이트의 ID, 1바이트 타입, 1바이트 TTL(Time-To-Live), 1바이트 Hops, 4바이트 Payload Length가 포함된다. 각 홉을 지날 때마다 TTL은 1 감소하고 Hops는 1 증가하여, 항상 초기 TTL = 현재 TTL + 현재 Hops 관계를 유지한다. 파싱은 오직 Payload Length를 기준으로 다음 descriptor의 경계를 판단하도록 규정되어 있다.
스펙이 정의한 descriptor 타입은 다섯 가지이다.
- Ping(0x00): 페이로드가 없으며 새 이웃을 찾기 위한 능동 탐색용 메시지이다.
- Pong(0x01): Ping에 대한 응답으로, 응답 서벤트의 수신 포트, IPv4 주소, 공유 파일 수, 총 공유 용량(KB)을 담는다.
- Query(0x80): 파일을 검색하기 위한 질의 메시지로, 최소 전송속도(kbps)와 널(0x00)로 끝나는 검색 문자열로 구성된다.
- QueryHit(0x81): Query에 대한 응답으로, 다운로드 가능한 포트, IP, 응답 측 속도 등 여러 결과 항목을 담는다. 각 항목은 File Index(로컬 식별자), File Size(바이트), File Name(더블 널 종료)로 구성된다.
- Push(0x40): 방화벽 등으로 직접 수신이 불가능한 피어를 위한 역방향 파일 전송 요청용 메시지로, Servent Identifier(16바이트)를 사용해 대상을 식별한다.
파일 검색과 메시지 라우팅
네트워크에 합류한 노드는 주변 피어와 TCP 연결을 맺고, 파일을 찾을 때 “이 파일 가진 사람?”이라는 Query descriptor를 인접 노드로 전파한다. 이 질의는 TTL(Time-To-Live) 값이 하락하는 한 여러 hop을 거치며 플러딩(flooding) 방식으로 퍼진다. 파일을 보유한 피어는 QueryHit 메시지를 생성해 질의가 지나온 역경로(reverse path)를 따라 응답을 보낸다.

Gnutella: File Searching
이 방식의 장점은 완전한 탈중앙성과 단순한 동작 원리이다. 중앙 인덱스 서버가 필요 없고, 누구나 자유롭게 네트워크에 들어와 파일을 검색하고 공유할 수 있다. 그러나 네트워크 규모가 커질수록 플러딩 트래픽이 기하급수적으로 증가하여 효율이 급격히 떨어진다. TTL로 전파 범위를 제한하면 부하는 줄지만, 반대로 검색의 재현율(recall)이 낮아져 원하는 파일을 찾지 못하거나 시간이 오래 걸릴 수 있다.
또한, 노드의 이탈 및 합류(churn)가 잦은 환경에서는 “누가 어떤 파일을 갖고 있는가?”에 대한 최신 정보를 안정적으로 유지하기 어렵다. 이러한 한계 때문에 Gnutella의 디스크립터 라우팅은 “응답은 역경로로만 돌아간다”는 규칙을 따른다. Ping에 대한 Pong, Query에 대한 QueryHit, 그리고 QueryHit에 연동된 Push는 모두 요청이 지나온 동일한 경로를 역방향으로만 전달되며, 이미 본 (Payload Descriptor, Descriptor ID) 조합의 중복 디스크립터는 다시 퍼뜨리지 않는다. 매 홉마다 TTL을 감소시켜 TTL이 0이 되면 폐기함으로써, 루프나 중복 확산을 방지한다.
Gnutella 네트워크의 역할은 검색과 발견(discovery)까지만 담당한다. 검색 이후의 파일 다운로드는 Gnutella 오버레이 네트워크 바깥에서, 즉 직접 연결을 통한 HTTP 전송으로 수행된다. 이러한 설계는 검색/발견과 데이터 전송을 분리하여, 오버레이 상에 대용량 파일 데이터가 흐르지 않도록 하기 위한 것이다. 이처럼 Gnutella는 완전한 탈중앙형 구조와 단순한 브로드캐스트 기반 탐색으로 초창기 P2P 네트워크의 원형을 제시했지만, 네트워크 규모가 커질수록 트래픽 비효율이 심화되고 검색의 확장성이 떨어졌다. 이 근본적인 한계를 해결하기 위한 시도로 DHT(Distributed Hash Table) 개념이 등장하였고, 이후의 대규모 P2P 시스템들은 점차 DHT를 핵심 라우팅 및 조회 메커니즘으로 채택하게 된다.
Chord 기반 DHT
Chord는 “어떤 노드가 특정 키(key)에 대한 데이터를 가지고 있는가?”라는 문제를 효율적으로 해결하기 위해 제안된 DHT(Distributed Hash Table) 프로토콜이다. Gnutella처럼 무차별 브로드캐스트로 검색을 수행하는 대신, Chord는 키 공간을 해시로 균등하게 분산시키고, O(log N)의 홉만으로 원하는 데이터를 찾을 수 있도록 설계되었다.
Chord의 핵심 아이디어는 모든 노드와 키를 동일한 해시 공간에서 ring 형태로 배치하는 것이다. 각 노드와 키는 해시 함수(보통 SHA-1)를 통해 0부터 2^m−1 범위의 식별자(identifier)를 부여받으며, 각 키는 그 키보다 같거나 큰 식별자를 가진 첫 번째 노드(successor node)에 저장된다. 이렇게 구성된 식별자 원형 링(identifier circle)은 새로운 노드가 네트워크에 합류하거나 떠날 때도 간단한 포인터 조정만으로, 안정적으로 유지된다. Chord는 노드가 자신의 후속 노드(Successor)와 선행 노드(Predecessor)를 알고 있으며, 여기에 더해 finger table이라 불리는 routing table을 유지한다. finger table의 i번째 엔트리는 현재 노드 ID + 2^(i−1) 위치의 노드를 가리키며, 이 구조 덕분에 검색은 선형이 아닌 로그 단위로 빠르게 수렴한다.
검색 과정
Chord의 검색 과정은 다음과 같다. 먼저 검색자는 찾고자 하는 키(key)를 해시 함수에 입력해 고유한 ID를 얻는다. 현재 노드는 이 키 ID를 기준으로 자신의 finger table을 참조해, 자신보다 키 ID에 더 가깝지만, 아직 도달하지 않은 노드 중 가장 먼 노드를 선택한다. 그런 다음 요청을 해당 노드로 전달하며, 그 노드는 다시 동일한 절차를 반복한다. 이 과정이 계속 이어지면서 요청은 매 홉(hop)마다 검색 공간을 절반씩 줄여 나가며, 결국 해당 키를 담당하는 successor 노드에 도달하게 된다.

Chord: Finger — Table Routing
이 알고리즘의 핵심은 모든 노드가 네트워크의 global topology를 알 필요가 없다는 점이다. 각 노드는 오직 자신의 finger table이라는 부분적 정보만으로도 효율적인 탐색이 가능하다. 이러한 설계 덕분에 Chord의 검색 복잡도는 검색 복잡도는 네트워크 규모 N에 대해 O(log N)으로 수렴한다. 검색에 대한 Pseudo code는 다음과 같다.
// find the node responsible for a given key-id
Node.Lookup(key-id)
look in local finger table for
highest node n such that self.id < n.id < key-id
if n exists
return n.Lookup(key-id) // next hop
else
return self.successor // found responsible node
노드 추가
네트워크 생성 초기에는 하나의 서버가 모든 키를 담당하지만, 새 노드가 합류하면 자신의 해시 구간에 속하는 키들만 successor로부터 이양받는다. 새 노드가 들어오는 과정은 다음과 같다.
- 새 노드
*n이 들어오면, 먼저 기존 노드 중 하나에게 자신의 successor는 누구인지를 묻는다. 이를 위해 `find_successor(n.id)`* 함수를 사용해 링에서 자신의 위치를 찾고, 그 결과로 successor와 predecessor를 설정한다. - 노드
*n은 successor에게서 자신이 맡게 될 키 구간(예: `[predecessor(n), n)`*)을 넘겨받아 저장한다. 이때 전체 키의 1/N 정도만 재분배된다. - 이 과정에서 기존 노드들의 일부 finger table 정보가 변경되며, 각 노드는 주기적으로 stabilize 함수를 실행해 자신의 successor와 predecessor를 다시 확인하고 필요시 수정한다.
노드 추가에 대한 Pseudo code는 다음과 같다.
// new node n joins an existing ring using any known node
Node.JoinBase(knownNode)
// find my immediate successor on the identifier circle
self.successor := knownNode.FindSuccessor(self.id)
self.predecessor := nil
// initialize my finger table (all entries start from my successor)
InitFingerTableFromSuccessor()
// tell other nodes so they can update their fingers if I fall on their routes
UpdateOthers()
// migrate keys I am now responsible for from my successor
MoveKeysFromSuccessor()
// find first node whose id >= id (clockwise), using fingers
Node.FindSuccessor(id)
p := FindPredecessor(id)
return p.successor
Node.FindPredecessor(id)
n := self
while id ∉ (n.id, n.successor.id]
n := n.ClosestPrecedingFinger(id)
return n
Node.ClosestPrecedingFinger(id)
for i := m downTo 1
if finger[i].id ∈ (self.id, id)
return finger[i]
return self
InitFingerTableFromSuccessor()
for i := 1..m
finger[i] := self.successor
UpdateOthers()
// for each i, locate the node p whose i-th finger might now point to me
for i := 1..m
p := FindPredecessor(self.id - 2^(i-1)) // modulo on the ring
p.UpdateFinger(self, i)
UpdateFinger(candidate, i)
// if candidate is a better i-th finger than current, accept it
if candidate.id ∈ (self.id, finger[i].id]
finger[i] := candidate
MoveKeysFromSuccessor()
// ask successor for keys in (predecessor(self), self]
// application layer moves the actual values associated with those keys
유지와 안정화
노드가 네트워크에서 이탈할 때는 자신이 보유한 키를 successor에게 넘겨주고, 이후 finger table과 successor 리스트를 갱신하면 된다. 각 노드는 여러 개의 successor를 리스트 형태로 유지하는데, 이는 신뢰성을 위한 예비 노드 목록으로, 바로 다음 successor가 고장 나더라도 다음 후보 노드로 즉시 대체할 수 있도록 해준다. 이러한 구조 덕분에 일부 노드가 장애를 일으켜도 검색(lookup)은 계속 유지된다. 다만 모든 successor가 동시에 장애를 일으키면 lookup이 실패할 수 있으며, finger table의 갱신이 지연되면 탐색 홉이 늘어나거나 잘못된 경로를 따를 위험이 있다. 이러한 문제를 완화하기 위해 Chord는 stabilization과 finger refresh 절차를 주기적으로 수행해 네트워크의 일관성과 검색 효율성을 유지한다. stabilization을 적용한 Pseudo code는 다음과 같다.
// create a new Chord ring
Node.Create()
self.predecessor := nil
self.successor := self
// join a Chord ring containing an existing node
Node.Join(knownNode)
self.predecessor := nil
self.successor := knownNode.Lookup(self.id) // start lookup at known node
// periodically executed to verify successor and notify it
Node.Stabilize()
x := self.successor.predecessor
if x ∈ (self.id, self.successor.id)
self.successor := x
self.successor.Notify(self)
// called by a node that thinks it may be our predecessor
Node.Notify(candidate)
if self.predecessor == nil or candidate.id ∈ (self.predecessor.id, self.id)
self.predecessor := candidate
// periodically refresh finger table entries
Node.FixFingers()
self.next := self.next + 1
if self.next > m
self.next := 1
start := self.id + 2^(self.next - 1)
self.finger[self.next] := self.Lookup(start)
실제 인터넷 환경에서는 여러 제약으로 인해 완전한 동작이 어렵다. 노드 간의 물리적 지연(latency)과 네트워크 topology가 ring의 논리적 거리와 일치하지 않아 비효율적인 경로 탐색이 발생했고, 노드의 불안정한 접속이 잦은 실제 환경에서는 안정화(stabilization) 주기가 이를 충분히 따라가지 못했다. 또한 Chord는 보안 공격, NAT 환경, 대규모 노드 신뢰 관리 문제에 대한 고려가 부족해, 상용 P2P 시스템에서는 직접 채택되지 못했다.
Kademlia 기반 DHT
Kademlia는 라우팅과 유지 관리에 XOR 거리를 도입한 DHT 설계이다. XOR 거리는 거리를 A ⊕ B(비트 단위 XOR)로 정의한다. XOR 연산은 두 ID 간의 비트 차이를 나타내며, 공통 접두(prefix)가 길수록 XOR 결과가 작아진다. 즉, “가깝다”라는 것을 “두 ID가 더 많은 상위 비트를 공유한다”로 해석할 수 있다. 이를 tree 형태로 표현하면 다음과 같다.

Kademlia binary tree
XOR은 자기 자신과의 거리가 0이고 symmetry(A가 B에게 가까우면 B에게 A도 가까움)하고, 삼각부등식(dis(A, C) ≤ dis(A, B) + dis(B, C))을 만족한다. 이를 통해 모든 노드가 같은 방식으로 거리를 계산할 수 있어 라우팅 의사결정과 테이블 구조가 깔끔해진다. 또한, 연산이 저렴하다는 장점이 있다. 이 공간 위에서 각 노드는 자신의 주변을 더 잘 알고(근접 노드에 대해 더 조밀한 정보), 멀수록 듬성듬성 아는 구조를 유지한다. 결과적으로 키에 가장 가까운 k개 노드를 찾아가는 경로가 비트 단위로 수렴해 O(log N) 홉 내에 목적지에 도달하도록 설계되어 있다.
Kademlia의 라우팅 테이블은 k-bucket이라는 버킷 배열을 참조한다. 각 버킷 배열은 (IP address, UDP port, Node ID) 튜플을 저장한다. 각 버킷은 내 ID와의 XOR 거리 범위 (2^i, 2^{i+1})를 담당하며, 그 범위 안에서 최대 k개의 노드 연락처를 가장 오래전에 본 노드가 끝으로 가는 리스트(least-recently seen, LRU)를 유지한다. 새로운 노드를 알게 되었을 때 해당 버킷이 가득 차 있으면 가장 오래 못 본 노드(버킷의 Tail)에 *PING을 보내 살아 있지 않으면 교체, 살아 있으면 신규 노드는 버린다. 이 정책은 오래 살아남는 노드(ping을 오래 주고 받은 노드)가 앞으로도 살아 있을 확률이 높다*는 추정을 기반으로, 안정적인 노드를 선호해 잦은 접속과 이탈이 있는 환경에서도 라우팅 품질을 높이는 효과가 있다.
Kademlia가 제공하는 네 가지 RPC를 제공한다. 이 RPC가 각종 동작의 기반이 된다.
- PING: 상대 노드의 생존 여부를 확인한다.
- FIND_NODE: 특정 ID에 가까운 k개 노드 반환한다. 가까운 버킷이 이미 충분히 채워져 있다면 한 버킷에서만 모두 반환하지만, 그 버킷이 비어 있거나 일부만 채워져 있다면, 인접한 여러 bucket에서 노드를 모아서 k개를 채워서 반환한다.
- FIND_VALUE: 키의 값이 있으면 값을 반환한다. 없으면 가까운 k개 노드(IP address, UDP port, Node ID)를 반환한다.
- STORE: (key, value)를 저장하라고 지시한다.
조회(lookup)는 보통 DHT에서 키를 따라가 가까운 노드(또는 값을 가진 노드)를 찾아가는 절차이다. 조회자는 자신의 테이블에서 목표 키에 가장 가까운 α개의 노드(보통 α=3)를 고르고 *FIND_NODE(혹은 `FIND_VALUE*) RPC를 병렬로 보낸다. 응답으로 돌아온 후보 중 더 가까운 k개를 추려 다시 α개에게 묻는 식으로 **가까워지는 방향으로 탐색을 반복**한다. 더 이상 이전보다 가까운 후보가 나오지 않으면 지금까지 알게 된 **가장 가까운 k개 노드 중** 아직 질의하지 않은 모든 노드에게FIND_NODE`를 다시 보내고 응답받은 후 종료한다.
저장하기 위해 먼저 lookup으로 그 key에 가장 가까운 k개 노드를 찾는다. 찾은 노드에 *STORE(key, value) RPC를 보내서 각 노드가 로컬 DB에 저장하게 한다. 결과적으로 데이터를 복제(replication)하게 되며, `FIND_VALUE`* 경로 중간 노드에 캐싱을 허용해 인기있는 키의 핫스팟을 완화하는 효과를 가진다. 또한 모든 노드는 자신이 보유한 key-value 쌍을 1시간마다 다시 퍼블리시(republish) 하여 만약 다른 노드가 죽었거나 네트워크가 변했어도, 복제본이 다시 채워진다. 만약 원본 발행자가 24시간 이내에 publish하지 않으면 만료된 것으로 취급한다.
Kademlia 네트워크에 새 노드가 합류하려면, 먼저 이미 참여 중인 노드 하나(부트스트랩 노드)의 주소를 알아야 한다. 새 노드 *u는 이 노드를 자신의 k-bucket에 추가한 뒤, 자신의 Node ID로 `FIND_NODE* lookup을 수행한다. 이를 통해 네트워크상에서 자신에게 **가장 가까운 k개의 노드**를 알아내고, 그들과의 연결을 확립한다. 그 후u`는 자신보다 더 먼 거리 구간의 k-bucket을 순차적으로 갱신하며, 각 범위에 대해 무작위 ID를 선택해 *FIND_NODE 요청을 보내 테이블을 채운다. 이 과정에서 `u`*는 자신의 라우팅 테이블을 풍부하게 만들 뿐 아니라, 다른 노드들의 테이블에도 자신이 삽입될 수 있도록 상호 갱신을 유도하여 네트워크에 합류한다.
일반적인 트래픽이 존재할 때는 lookup이나 store 요청이 오가며 버킷이 자동으로 최신 상태로 유지된다. 그러나 트래픽이 적은 경우를 대비해 각 노드는 1시간 동안 lookup이 수행되지 않은 버킷을 임의의 ID로 지정해 *FIND_NODE*를 실행함으로써 스스로 갱신한다. 이러한 주기적 self-refresh 덕분에 네트워크의 연결성과 탐색 효율이 유지되며, 일부 노드의 이탈(churn)이나 일시적인 장애에도 전체 DHT의 구조적 안정성이 보장된다.
요약하면, Kademlia는 XOR 거리 기반의 k-bucket 라우팅과 α개 동시 탐색으로 간단한 프로토콜에 강한 churn 내성과 효율적인 조회 성능(O(log N))을 보장한다. 이러한 장점을 바탕으로 오늘날의 많은 P2P 시스템(예: BitTorrent DHT, IPFS의 Kademlia 변형, 이더리움의 Discovery v5 등)이 Kademlia에서 영감받거나 직접 변형해 사용한다.
BitTorrent의 Mainline DHT
BitTorrent는 대용량 파일을 효율적으로 배포하기 위해 고안된 P2P 파일 전송 프로토콜이다. 기존의 중앙 서버 기반 다운로드 방식이 서버 한 곳에 모든 부하를 집중시키는 반면, BitTorrent는 참여자(peer)들이 서로 파일 조각(piece)을 교환함으로써 전체 전송 속도와 확장성을 획기적으로 높였다. BitTorrent 네트워크에서 파일은 여러 조각으로 나뉘며, 각 조각의 해시가 포함된 토렌트 메타파일(.torrent)이 배포된다. 이 메타파일에는 파일 전체의 무결성을 검증하기 위한 해시 트리 정보와, 파일을 처음 배포한 노드(seeder) 혹은 피어들을 찾기 위한 트래커(tracker) 주소가 담겨 있다. 이 트래커의 역할을 Mainline DHT가 대체한다. 즉, 트래커의 역할을 네트워크 전체로 분산시켜, 중앙 서버 없이도 피어 검색(peer discovery)이 가능하게 했다.
Mainline DHT는 BitTorrent 클라이언트가 BitTorrent 프로토콜을 통해 피어를 찾는 데 사용하는 Kademlia 기반 DHT이다. 기본적으로 Kademlia가 제공하는 XOR 거리(metric)와 k-bucket 기반 라우팅 테이블을 그대로 계승하지만, 토렌트의 info_hash(특정 토렌트 데이터를 가리키는 식별자)를 키로 두고 그 파일을 소유한 피어(peer)의 정보를 빠르게 찾는 것에 맞게 여러 부분이 수정되었다.
가장 큰 차이점은 저장되는 값(value)의 형태다. 기존 Kademlia가 *(key, value) 쌍으로 일반 데이터를 저장한다면, Mainline DHT의 값은 피어 목록(peer list) 그 자체다. 각 노드는 자신이 가진 토렌트의 info_hash에 대해 `announce_peer* RPC를 통해 *“나도 이 파일을 가지고 있다”*는 사실을 DHT 네트워크에 등록한다. 조회자는get_peers`를 호출하여 파일을 가진 피어들의 주소(IP, Port)를 얻는다.
Mainline DHT는 UDP 위의 KRPC 프로토콜을 사용하며, 표준 RPC로 *ping, `find_node*,get_peers`, *announce_peer 네 가지를 정의한다. 특히, get_peers 응답 시 함께 제공되는 token을 이용해야만 `announce_peer`* 요청을 할 수 있도록 하여, 임의의 노드가 거짓된 피어 정보를 등록하지 못하게 한다. 이는 Sybil 공격과 IP 스푸핑을 막기 위한 안전장치이다.

Mainline DHT: Rounting
또한 Kademlia의 고정 버킷 구조를 그대로 쓰지 않고, 필요할 때만 버킷을 분할(split)하는 적응형 라우팅 테이블을 사용한다. 즉, 처음엔 하나의 버킷으로 시작해, 그 버킷이 가득 차 있고 그 범위가 자신의 Node ID를 포함할 때만 나눈다. 이 방식은 메모리 사용을 줄이고, 가까운 거리의 피어를 세밀하게 관리할 수 있도록 해준다.
BitSwap과 IPFS DHT
BitSwap은 IPFS(InterPlanetary File System)의 핵심 전송 계층으로, BitTorrent의 아이디어를 계승하면서도 Content Addressing에 맞게 재설계된 데이터 교환 프로토콜이다. BitTorrent가 파일을 여러 조각으로 나누어 동시에 교환했다면, BitSwap은 IPFS 네트워크에서 특정 콘텐츠 블록(Content Block)을 가진 피어를 찾아 그 데이터를 교환한다. IPFS에서 파일은 수많은 블록으로 쪼개지고, 각 블록은 해시 기반의 고유한 콘텐츠 ID(CID)로 식별된다. BitSwap은 이 CID를 키로 하여 “누가 이 블록을 가지고 있는가?”를 찾아내고, 필요한 데이터를 여러 피어로부터 동시에 요청 및 수신한다. 이 과정에서 BitSwap은 단순히 데이터를 교환하는 데 그치지 않고, 노드 간의 교환 기록(ledger)을 추적하여 주고받은 양을 계산한다.
흥미로운 점은, BitSwap이 BitTorrent처럼 “특정 토렌트 그룹”에 묶이지 않고, IPFS 전체 네트워크를 통틀어 블록을 주고받는다는 점이다. 이로 인해 BitSwap은 전 세계적으로 분산된 콘텐츠 네트워크(IPFS)에서 누구나 데이터 제공자(provider)가 되고, 누구나 데이터 소비자가 될 수 있는 persistent marketplace(지속적인 교환 시장)으로 작동한다. 결국 BitSwap은 “파일 교환(P2P)”이라는 BitTorrent의 전통적인 모델을, 콘텐츠 중심(content-centric) 네트워크 시대에 맞게 확장한 프로토콜이라 할 수 있다.
IPFS DHT
이 위에서 동작하는 Kademlia 기반 IPFS DHT는, “누가 특정 CID를 제공할 수 있는가?”를 빠르게 찾기 위한 콘텐츠 라우팅 인덱스 역할을 수행한다. DHT는 파일 데이터를 직접 저장하지 않는다. 대신, “이 콘텐츠 블록(CID)을 가지고 있는 노드가 누구인가?”를 빠르게 찾아주는 콘텐츠 라우팅(content routing) 시스템으로 동작한다. 즉, IPFS의 DHT는 단순한 키-값 저장소가 아니라, 콘텐츠 제공자를 찾는 탐색 엔진이다.
이 구조에서 key는 IPFS 콘텐츠의 해시(CID), value는 그 콘텐츠를 제공할 수 있는 노드의 목록이다. 노드가 새로운 데이터를 퍼블리시할 때, IPFS 노드는 *Provide(CID) RPC를 통해 DHT에 provider record를 등록한다. 이때 CID와 해시 공간상에서 가장 가까운 k개의 노드가 그 provider 정보를 저장한다. 다른 노드가 데이터를 찾을 때는 `FindProviders(CID)`* RPC를 통해 DHT를 탐색한다. 요청 노드는 CID에 가장 가까운 노드들을 탐색하며, 이들로부터 해당 CID를 보유한 provider 목록을 반환받는다. 이 정보를 바탕으로 실제 데이터 전송은 BitSwap 프로토콜을 통해 이루어진다. 즉, IPFS DHT는 데이터를 직접 보관하지 않고, BitSwap이 데이터를 전송하기 위해 필요한 경로를 만들어주는 인덱스 역할을 한다.
BitSwap은 단순한 블록 교환을 넘어서, 피어 간의 공정한 교환을 유지하기 위해 크레딧 시스템(credit system)을 도입한다. 각 노드는 피어별로 주고받은 데이터의 양을 기록하며, 이 기록을 기반으로 부채 비율(debt ratio, r), 송신 확률(P(send|r))을 계산한다.

즉, 빚이 많을수록 데이터를 받을 확률이 낮아지고, 과거에 데이터 교환이 활발했던 신뢰할 수 있는 피어일수록 관대하게 유지된다.
이 구조는 세 가지 효과를 낸다:
- Sybil 공격 저항성: 새로운 노드를 무한히 만들어 공짜로 받는 행위를 방지한다.
- 신뢰 관계 유지성: 이전에 성실하게 교환했던 피어는 일시적인 부채 상태라도 연결 유지한다.
- 기여 유도성: 공유하지 않는 피어(leecher)는 점차 연결에서 배제한다.
모든 BitSwap 노드는 피어별로 ledger를 유지한다. ledger에는 송신량, 수신량, 마지막 교환 시각 등이 기록되며, 피어 간 교환 내역을 추적하고 신용을 계산한다.
type Ledger struct {
owner NodeId
partner NodeId
bytes_sent int
bytes_recv int
timestamp Timestamp
}
피어가 연결될 때 ledger 정보를 교환해 동기화하며, 만약 두 ledger가 불일치하면 새로 초기화되어 신용이 0으로 돌아간다. 악의적인 노드가 ledger를 잃어버린 척할 수도 있지만, 그 경우 신뢰도까지 함께 사라지므로 실질적으로 손해가 된다. 즉, BitSwap ledger는 네트워크의 투명한 신용 장치로 작동한다.
또한 IPFS는 전통적인 Kademlia와 달리 모든 노드가 동일한 역할을 하지 않는다. 일부 노드는 DHT 클라이언트 모드로 동작하여 탐색 요청만 수행하고, 다른 노드는 DHT 서버(라우팅 노드)로서 provider record를 저장하거나 질의에 응답한다. 이는 대규모 네트워크에서 불필요한 라우팅 부하를 줄이고, 트래픽이 집중되는 노드를 최적화하기 위한 구조적 선택이다. IPFS는 DHT를 통해서만 조회하는 것이 아니다. 단일 경로 탐색 대신 다중 경로(multi-path) 탐색, 캐시 기반 응답, 로컬 provider 브로드캐스트 등 다양한 보조 전략을 함께 사용하여 실제 네트워크 latency을 줄인다.
P2P의 철학과 책임
2000년대 초반의 P2P는 기술적으로 매우 앞서 있었지만, 오늘날에는 과거만큼 널리 사용되지 않는다. 그 이유는 몇 가지로 정리된다. 첫째, 인터넷 대역폭과 서버 인프라 비용이 과거보다 훨씬 낮아졌고, 클라우드 기반의 콘텐츠 전송 네트워크(CDN) 구조가 보편화되면서 중앙 서버 모델도 충분히 경제적으로 되었다. 둘째, 저작권 침해와 검열 문제 등 법적, 사회적 규제 환경이 강화되면서, 공개형 P2P 네트워크는 다양한 제약을 안게 되었다. 셋째, 참여자 간의 직접 파일 공유는 악성 코드 유포나 불법 콘텐츠 확산 같은 윤리적 위험을 수반했다.
이처럼 P2P는 주류 인터넷에서 한발 물러났지만, 그 철학은 여전히 의미를 지닌다. P2P가 제시한 철학은 “네트워크의 책임과 자원을 사용자 스스로가 분담한다.”라는 개념에 있다. 모든 노드가 동등한 권한을 가지며, 동시에 client이자 server로 작동한다. 이러한 구조적 특성 덕분에 네트워크는 참여자가 많아질수록 함께 확장되고, 자연스럽게 자율성과 장애 내성을 높이는 방향으로 발전했다. 나아가 P2P는 기술적 분담뿐 아니라 법적, 윤리적 책임의 분담까지도 내포한다. 각 사용자는 자신이 전송, 저장, 공유하는 데이터에 대해 직접적인 책임을 져야 하며, 이러한 개인의 선택과 책임이 모여 생태계 전체의 신뢰와 지속성을 형성한다.
출처
The Gnutella Protocol Specification v0.4
Chord: A Scalable Peer-to-peer Lookup Service for Internet Applications
Kademlia: A Peer-to-peer Information System Based on the XOR Metric
BEP (BitTorrent Enhancement Proposal) — 0005
IPFS Academic Papers: BitTorrent’s Mainline DHT Security Assessment
이미지 출처
https://pages.cs.wisc.edu/~akella/CS640/F07/slides/F07_Lecture19_p2p.pdf
https://courses.cs.washington.edu/courses/cse550/22au/slides/14-large-storage-discussion.pdf
메타데이터
- post_id
- b04d09ab6eee
- slug
- distributed-p2p-from-gnutella-to-ipfs-b04d09ab6eee
- url
- https://medium.com/@sineta01/distributed-p2p-from-gnutella-to-ipfs-b04d09ab6eee
- canonical_url
- https://medium.com/@sineta01/distributed-p2p-from-gnutella-to-ipfs-b04d09ab6eee
- author_url
- https://medium.com/@sineta01
- status
- ok
- fetched_at
- 2026-08-20 12:19:53