무엇인가

모든 참여자가 클라이언트이면서 동시에 서버인 구조다. 중앙 서버가 없고, 각 피어가 자원을 내놓기도 하고 남의 자원을 쓰기도 한다.

클라이언트-서버에서 역할의 구분을 없앤 것이라고 보면 된다. 그 대신 “누가 무엇을 갖고 있는가”를 찾는 문제가 새로 생긴다.

구조

graph TD
    P1[피어 A<br/>가짐: x] <--> P2[피어 B<br/>가짐: y]
    P2 <--> P3[피어 C<br/>가짐: z]
    P1 <--> P3
    P3 <--> P4[피어 D<br/>가짐: x]
    P2 <--> P4

C++로 보기

각 피어가 자기 데이터를 갖고, 없으면 이웃에게 물어본다. 방문 표시를 안 하면 순환 구조에서 무한히 돈다 — 이 패턴의 첫 함정이다.

#include <iostream>
#include <memory>
#include <optional>
#include <set>
#include <string>
#include <unordered_map>
#include <vector>
 
class Peer {
    std::string name_;
    std::unordered_map<std::string, std::string> local_;
    std::vector<Peer*> neighbors_;
 
  public:
    explicit Peer(std::string name) : name_(std::move(name)) {}
 
    const std::string& name() const { return name_; }
    void store(const std::string& k, const std::string& v) { local_[k] = v; }
    void connect(Peer& other) {
        neighbors_.push_back(&other);
        other.neighbors_.push_back(this);
    }
 
    // 서버 역할: 내가 가졌으면 준다.
    // 클라이언트 역할: 없으면 이웃에게 물어본다.
    std::optional<std::string> lookup(const std::string& key,
                                      std::set<const Peer*>& visited) const {
        if (!visited.insert(this).second) return std::nullopt;   // 이미 왔던 곳
        auto it = local_.find(key);
        if (it != local_.end()) {
            std::cout << "  " << name_ << "가 가지고 있다\n";
            return it->second;
        }
        std::cout << "  " << name_ << "에 없음 → 이웃에게\n";
        for (const Peer* n : neighbors_) {
            if (auto found = n->lookup(key, visited)) return found;
        }
        return std::nullopt;
    }
 
    std::optional<std::string> find(const std::string& key) const {
        std::set<const Peer*> visited;
        return lookup(key, visited);
    }
};
 
int main() {
    Peer a{"A"}, b{"B"}, c{"C"}, d{"D"};
    a.connect(b);
    b.connect(c);
    c.connect(d);
    d.connect(a);          // 고리가 생긴다
 
    c.store("lang", "C++");
 
    std::cout << "A에서 lang 찾기:\n";
    if (auto v = a.find("lang")) std::cout << "찾음: " << *v << "\n\n";
 
    std::cout << "A에서 없는 키 찾기:\n";
    if (!a.find("nope")) std::cout << "못 찾음(모든 피어 방문 후 종료)\n";
}

visited 집합이 없으면 A→B→C→D→A로 영원히 돈다. 그리고 이 단순한 방식은 찾을 때마다 망 전체를 훑는다 — 피어가 늘면 그대로 못 쓴다. 실제 P2P가 DHT 같은 구조를 쓰는 이유가 이것이다.

언제 쓰나

  • 중앙 서버를 두기 어렵거나 두고 싶지 않을 때(비용, 검열 저항, 단일 장애점 제거).
  • 참여자가 늘수록 자원도 같이 느는 성질이 필요할 때(대역폭, 저장 공간).
  • 참여자들이 대등하고 서로 비슷한 일을 할 때.

대가

  • 탐색이 어렵다. 위 예제처럼 순진하게 하면 망 크기에 비례해 비용이 커진다.
  • 참여자가 언제든 사라진다. 가용성·일관성 보장이 서버 방식보다 훨씬 까다롭다.
  • 보안·신뢰 문제가 크다. 아무나 피어가 될 수 있기 때문이다.

실제로 만나는 곳

BitTorrent, 블록체인 네트워크, IPFS, 그리고 일부 게임의 P2P 매치.

P2P 넷코드가 왜 어려운지 — 치팅, 호스트 이점, NAT 통과 — 는 그 자체로 따로 다룰 주제다.