Go로 확장 가능한 키-값 저장소 구축하기
James Reed
Infrastructure Engineer · Leapcell

Go에서의 분산 키-값 저장소 소개
데이터의 기하급수적인 증가와 고가용성 및 확장형 시스템에 대한 요구 증가는 종종 기존의 모놀리식 데이터베이스로는 불충분하게 만들었습니다. 소셜 미디어 플랫폼부터 전자상거래 사이트까지 현대 애플리케이션은 여러 머신에 걸쳐 방대한 양의 데이터를 빠르고 안정적으로 저장하고 검색할 메커니즘을 필요로 합니다. 여기서 분산 키-값 저장소가 빛을 발합니다. 관계형 데이터베이스보다 단순한 데이터 모델을 제공하며 성능과 수평적 확장성에 중점을 두어 오늘날 클라우드 네이티브 아키텍처의 초석이 됩니다. Go는 뛰어난 동시성 기본 요소, 강력한 표준 라이브러리, 그리고 강력한 성능 특성을 갖추고 있어 이러한 시스템을 구축하는 데 이상적인 언어입니다. 이 글에서는 Go를 사용하여 간단한 분산 키-값 저장소를 개발하는 과정을 안내하며, 기본적인 원칙과 실용적인 구현을 다룹니다.
핵심 개념 및 구현 세부 정보
코드를 자세히 살펴보기 전에 분산 키-값 저장소를 이해하는 데 중심이 되는 몇 가지 필수 용어를 명확히 해 봅시다.
- 키-값 쌍: 데이터 저장의 가장 기본적인 단위입니다. 각 데이터 조각(값)은 키로 고유하게 식별됩니다. 사전이나 해시 맵과 같다고 생각하면 됩니다.
- 분산: 확장성과 장애 허용을 달성하기 위해 데이터가 클러스터 내의 여러 노드(서버)에 분산됩니다.
- 일관성: 모든 클라이언트가 동시에 동일한 데이터를 본다는 보장을 의미합니다. 다양한 분산 시스템은 다양한 일관성 모델(예: 강력한 일관성, 최종적 일관성)을 제공합니다. 저희의 간단한 저장소에서는 기본적인 수준의 일관성을 목표로 할 것입니다.
- 복제: 일부 노드가 실패하더라도 가용성이 보장되도록 여러 노드에 데이터의 여러 복사본을 저장합니다.
- 해싱/샤딩: 특정 키-값 쌍이 어떤 노드에 상주해야 하는지를 결정하는 데 사용되는 메커니즘입니다. 일관성 해싱은 노드가 추가되거나 제거될 때 데이터 이동을 최소화하는 데 널리 사용되는 기술입니다.
- RPC (원격 프로시저 호출): 한 컴퓨터의 프로그램이 마치 로컬 호출처럼 다른 컴퓨터의 코드를 실행할 수 있도록 하는 통신 프로토콜입니다. Go의
net/rpc
패키지 또는 gRPC가 일반적인 선택입니다.
저희의 간단한 분산 키-값 저장소는 다음과 같은 측면에 중점을 둘 것입니다: RPC를 사용한 클라이언트-서버 통신, 각 노드에서의 기본적인 키-값 저장, 그리고 원시적인 분산 전략.
기본 노드 구조
저희 분산 키-값 저장소의 각 서버는 "노드"가 될 것입니다. 각 노드는 자체 로컬 키-값 저장소를 유지합니다. 단순화를 위해 메모리 내 map[string]string
을 사용할 것입니다. 실제 시나리오에서는 RocksDB, LevelDB 또는 디스크 파일과 같은 영구 저장 메커니즘에 의해 백업될 것입니다.
RPC를 통해 노출할 Node
구조체와 해당 메서드를 정의해 봅시다:
// node.go package main import ( "fmt" "log" "net" "net/rpc" "sync" ) // KVStore는 노드에서 로컬 키-값 저장소를 나타냅니다. type KVStore struct { mu sync.RWMutex store map[string]string } // NewKVStore는 KVStore의 새 인스턴스를 생성합니다. func NewKVStore() *KVStore { return &KVStore{ store: make(map[string]string), } } // Get은 주어진 키에 대한 값을 검색합니다. func (kv *KVStore) Get(key string, reply *string) error { kv.mu.RLock() defer kv.mu.RUnlock() if val, ok := kv.store[key]; ok { *reply = val return nil } return fmt.Errorf("key '%s' not found", key) } // Put은 키-값 쌍을 저장합니다. func (kv *KVStore) Put(pair map[string]string, reply *bool) error { kv.mu.Lock() defer kv.mu.Unlock() for key, value := range pair { kv.store[key] = value } *reply = true return nil } // Node는 저희 분산 키-값 저장소의 노드를 나타냅니다. type Node struct { id string address string kvStore *KVStore } // NewNode는 새 Node 인스턴스를 생성합니다. func NewNode(id, address string) *Node { return &Node{ id: id, address: address, kvStore: NewKVStore(), } } // Serve는 노드의 RPC 서버를 시작합니다. func (n *Node) Serve() { rpc.Register(n.kvStore) // RPC를 통해 노출될 KVStore를 등록합니다. listener, err := net.Listen("tcp", n.address) if err != nil { log.Fatalf("Error listening on %s: %v", n.address, err) } log.Printf("Node %s listening on %s", n.id, n.address) rpc.Accept(listener) }
이 node.go
파일에서:
KVStore
는 동시 접근 안전성을 위해sync.RWMutex
를 사용하는 메모리 내 키-값 맵을 관리합니다.Get
및Put
은 RPC에 노출되는 메서드입니다.reply
인자가 결과를 다시 보내는 데 사용되는 방식을 확인하세요.Node
는 노드의 ID와KVStore
를 캡슐화합니다.Serve
는 RPC 서버를 설정하여KVStore
메서드를 원격으로 액세스 가능하게 합니다.
클라이언트 상호 작용
클라이언트는 Get
또는 Put
작업을 수행하기 위해 노드 중 하나에 연결해야 합니다. 단순화를 위해 저희 클라이언트는 특정 노드에 직접 연결됩니다. 더 고급 시스템에는 검색 서비스 또는 로드 밸런서가 있을 것입니다.
// client.go package main import ( "fmt" "log" "net/rpc" ) // Client는 키-값 저장소의 클라이언트를 나타냅니다. type Client struct { nodeAddress string rpcClient *rpc.Client } // NewClient는 특정 노드에 연결된 새 클라이언트를 생성합니다. func NewClient(nodeAddress string) (*Client, error) { client, err := rpc.DialHTTP("tcp", nodeAddress) if err != nil { return nil, fmt.Errorf("error dialing RPC server at %s: %v", nodeAddress, err) } return &Client{ nodeAddress: nodeAddress, rpcClient: client, }, } // Get은 노드에서 원격 Get 메서드를 호출합니다. func (c *Client) Get(key string) (string, error) { var reply string err := c.rpcClient.Call("KVStore.Get", key, &reply) if err != nil { return "", fmt.Errorf("error calling Get for key '%s': %v", key, err) } return reply, nil } // Put은 노드에서 원격 Put 메서드를 호출합니다. func (c *Client) Put(key, value string) error { args := map[string]string{key: value} var reply bool err := c.rpcClient.Call("KVStore.Put", args, &reply) if err != nil { return fmt.Errorf("error calling Put for key '%s': %v", key, err) } if !reply { return fmt.Errorf("put operation failed for key '%s'", key) } return nil } // Close는 RPC 클라이언트 연결을 닫습니다. func (c *Client) Close() error { return c.rpcClient.Close() }
client.go
에서:
NewClient
는 특정 노드 주소에 대한 RPC 연결을 설정합니다.Get
및Put
은 RPC 호출을 래핑하여 사용자로부터 원격 통신을 추상화합니다.
여러 노드 조정
"분산" 측면을 시연하려면 여러 노드를 실행하고 상호 작용하는 방법이 필요합니다. 이 예에서는 동일한 main
함수 내에서 별도의 고루틴으로 실행할 것입니다. 실제 배포에서는 이들이 다른 컴퓨터에 있는 별도의 프로세스가 될 것입니다.
// main.go package main import ( "log" "time" ) func main() { // 노드 1 시작 node1 := NewNode("node-1", ":8001") go node1.Serve() time.Sleep(time.Millisecond * 100) // 노드가 시작될 시간을 줍니다. // 노드 2 시작 (미래의 분산 로직을 시연하기 위해) node2 := NewNode("node-2", ":8002") go node2.Serve() time.Sleep(time.Millisecond * 100) // 노드가 시작될 시간을 줍니다. // --- 노드 1과의 클라이언트 상호 작용 --- log.Println("--- Client interacting with Node 1 ---") client1, err := NewClient(":8001") if err != nil { log.Fatalf("Failed to create client for Node 1: %v", err) } defer client1.Close() // 일부 데이터 넣기 err = client1.Put("name", "Alice") if err != nil { log.Printf("Error putting 'name': %v", err) } else { log.Println("Put 'name: Alice' successful on Node 1") } err = client1.Put("city", "New York") if err != nil { log.Printf("Error putting 'city': %v", err) } else { log.Println("Put 'city: New York' successful on Node 1") } // 데이터 가져오기 val, err := client1.Get("name") if err != nil { log.Printf("Error getting 'name': %v", err) } else { log.Printf("Got 'name': %s from Node 1", val) } val, err = client1.Get("country") if err != nil { log.Printf("Error getting 'country': %v", err) } else { log.Printf("Got 'country': %s from Node 1", val) } // --- 노드 2와의 클라이언트 상호 작용 (처음에는 비어 있음) --- log.Println("\n--- Client interacting with Node 2 ---") client2, err := NewClient(":8002") if err != nil { log.Fatalf("Failed to create client for Node 2: %v", err) } defer client2.Close() val, err = client2.Get("name") // 이 키는 노드 1에 넣었습니다. if err != nil { log.Printf("Error getting 'name' from Node 2 (expected): %v", err) } else { log.Printf("Got 'name': %s from Node 2", val) } err = client2.Put("language", "Go") if err != nil { log.Printf("Error putting 'language': %v", err) } else { log.Println("Put 'language: Go' successful on Node 2") } val, err = client2.Get("language") if err != nil { log.Printf("Error getting 'language' from Node 2: %v", err) } else { log.Printf("Got 'language': %s from Node 2", val) } log.Println("\n--- Operations complete. Press Ctrl+C to exit ---") select {} // 메인 고루틴을 계속 살아있게 합니다. }
main.go
에서:
- 다른 포트에서 수신 대기하는 두 개의 노드,
node-1
및node-2
를 시작합니다. - 그런 다음 각 노드와 개별적으로 상호 작용하기 위해 클라이언트를 생성합니다.
node-1
에 기록된 데이터가node-2
에서 자동으로 사용할 수 없다는 점에 유의하세요. 이는 분산 전략의 필요성을 강조합니다.
진정한 분산 저장소의 다음 단계
현재 설정은 기본적인 RPC 통신 및 로컬 저장을 시연합니다. 이것을 기능적인 분산 키-값 저장소로 만들려면 다음을 추가해야 합니다:
- 분산 계층: 특정 키를 어느 노드에 저장할지 결정하는 구성 요소(예: 코디네이터 또는 일관성 해싱 링). 클라이언트가
Put
또는Get
을 수행하면 이 계층이 요청을 올바른 노드로 전달합니다. - 복제: 데이터가 저장될 때 장애 허용을 보장하기 위해 여러 다른 노드로 복제되어야 합니다.
node-1
이 다운되어도 해당 데이터는 복제본에서 계속 액세스할 수 있어야 합니다. - 일관성 프로토콜: 복제된 데이터가 쓰기 및 장애 중에 여러 노드에 걸쳐 일관되게 유지됨을 보장하는 프로토콜(예: Raft 또는 Paxos)을 구현합니다.
- 장애 감지 및 복구: 노드가 오프라인 상태가 되는 시기를 감지하고 해당 데이터를 자동으로 복원하거나 클러스터를 재조정하는 메커니즘입니다.
- 영속성: 노드가 다시 시작될 때 데이터가 손실되지 않도록 키-값 쌍을 디스크에 저장합니다.
애플리케이션 시나리오
이와 같은 간단한 분산 키-값 저장소는 다양한 실제 애플리케이션의 기반을 형성합니다:
- 캐싱: 데이터베이스 부하를 줄이기 위해 자주 액세스하는 데이터를 저장합니다.
- 세션 관리: 여러 웹 서버에 걸쳐 사용자 세션 데이터를 저장합니다.
- 구성 관리: 다양한 서비스에 애플리케이션 구성을 분산합니다.
- 리더 선출: 분산 시스템에서 기본 노드를 선택하는 데 사용됩니다.
- 간단한 데이터 저장: 복잡한 트랜잭션 기능이나 쿼리 패턴이 필요하지 않은 높은 읽기/쓰기 처리량이 필요한 애플리케이션용.
결론
Go로 분산 키-값 저장소를 구축하는 것은 동시성 및 네트워킹에 적합한 언어를 사용하여 분산 시스템의 핵심 개념을 탐색할 수 있는 환상적인 기회를 제공합니다. 저희 예는 기본적인 것이지만 복잡성을 파악하기 위한 기반을 다집니다. RPC 및 동시성을 위한 Go의 강력한 표준 라이브러리를 활용하여 확장 가능하고 탄력적인 데이터 저장 솔루션을 만들 수 있습니다. 진정한 프로덕션급 분산 키-값 저장소는 분산, 일관성 및 장애 허용을 위한 정교한 기능을 추가하여 이러한 기초 위에 구축될 것입니다. 본질적으로 분산 키-값 저장소는 네트워크에 걸쳐 분산된 확장 가능하고 장애 허용적인 해시 테이블입니다.