Files
grpccanary/docs/GRPC.md
T

36 KiB

3단계: gRPC 통신 구현 상세 가이드

학습 로드맵으로 돌아가기

이 문서는 grpccanary 프로젝트의 3단계: gRPC 통신 구현에 대한 이론적 배경, 스키마 명세, 컴파일 기법 및 구체적인 소스코드 분석을 설명합니다.

gRPC는 HTTP/2를 기반으로 구축된 구글의 고성능 오픈소스 원격 프로시저 호출(RPC) 시스템입니다. 사물인터넷(IoT) 장비나 에이전트 간의 데이터 통신 시 가볍고 구조화된 데이터 통신을 유지하는 데에 가장 적합한 프레임워크입니다.


1. gRPC 개요 및 기술 배경

쉽게 말해 gRPC는 다른 컴퓨터에 있는 함수를, 마치 내 코드 안에 있는 함수처럼 그냥 호출할 수 있게 해주는 기술입니다. 평소 sum(1, 2)처럼 내 프로그램 안의 함수를 부르듯, client.GetDate()라고 쓰면 실제로는 저 멀리 다른 컴퓨터(서버)의 함수가 실행되고 그 결과를 돌려받는 것입니다. 이런 '원격에 있는 함수를 부르는 방식'을 원격 프로시저 호출(RPC)이라고 부릅니다.

1.1 gRPC의 핵심 차별점

  • 강력한 스키마 계약: .proto 파일 하나로 서비스 통신 규약을 명확히 선언하고, 컴파일 단계에서 이를 바탕으로 여러 언어의 클라이언트/서버 코드를 자동 생성합니다. 따라서 런타임 단계에서의 통신 필드 누락이나 타입 불일치 버그를 완벽하게 방지합니다.
  • 이진 프로토콜 (바이너리 포맷): 텍스트가 아닌 컴팩트한 이진 형식을 사용하므로 데이터 크기가 매우 작고 네트워크 대역폭 리소스 효율이 뛰어납니다.
  • HTTP/2 기반: 하나의 네트워크 커넥션을 재사용해 다중화(Multiplexing) 전송이 가능하고, 실시간 스트리밍(양방향 스트리밍 포함) 서비스에 탁월한 환경을 제공합니다.

1.2 Protobuf (프로토콜 버퍼)의 장단점

  • 장점

    • 높은 전송 효율성: 데이터 교환 시 텍스트가 아닌 바이너리 인코딩 형식을 사용하므로 JSON에 비해 직렬화/역직렬화 속도가 매우 빠르고 크기도 가볍습니다.
    • 일관성 있는 코드 생성 (Stub): 동일한 정의서로부터 다국어 API 클라이언트를 빌드하여 언어별 클라이언트 코드를 중복 작성해야 하는 오버헤드를 제거합니다.
    • 하위 호환성: 고유 필드 번호 매핑 방식을 사용하므로 스키마가 개정되어도 이전 시스템과의 통신 호환을 보장합니다.
  • 단점

    • 가독성 부재: 패킷이 암호화는 아니지만 바이너리로 전달되므로 사람이 브라우저 개발자 도구 등으로 바로 읽어 디버깅하기 곤란합니다.
    • 빌드 종속성: 명세 변경 시마다 Stub 코드를 컴파일하여 빌드에 바인딩하는 과정이 요구됩니다.

2. 실습 프로젝트 소개: IoT 센싱 데이터 수집 서비스

본 튜토리얼에서는 gRPC 분산 통신 기법을 실증적으로 학습하기 위해 현업에서 가장 범용적으로 쓰이는 가상의 IoT 센싱 데이터 수집 및 기기 관리 서비스 프로젝트를 직접 설계하고 구현해 나갑니다.

2.1 프로젝트 시나리오

사물인터넷(IoT) 센서 노드나 분산 멀티 에이전트 환경에서는 엣지 기기들이 중앙 서버에 접속해 통신 가능 여부를 검증하고 상태 정보(날짜/시간)를 수집하거나, 임시 보안 인증을 위한 비밀번호 발급을 요청하고, 실시간으로 센싱한 환경 정보(온도, 습도 등)를 지속적으로 업데이트해야 하는 현실적인 시나리오가 요구됩니다. 우리가 개발할 IoTService는 이에 대응하는 다음 3가지 핵심 원격 프로시저(RPC)를 수행합니다:

  1. 서버 시간 및 날짜 조회 (GetDate): 기기가 접속 상태를 확인하며 동기화를 위해 서버의 현재 날짜와 시간 포맷 문자열을 반환받습니다.
  2. 센싱 데이터 업데이트 (UpdateSensingData): 센서 노드가 주기적으로 수집한 물리 데이터(온도, 습도) 및 기기 식별자(Device ID)를 전달하면, 서버는 데이터 정합성을 검증한 후 성공 여부를 반환합니다.
  3. 일회성 보안 패스워드 발급 (GetRandomPass): 기기가 임시 통신 세션 수립을 위해 난수 생성 시드와 보안 문자열 길이를 전달하면, 무작위 ASCII 임시 패스워드를 연산하여 응답받습니다.

2.2 학습 목표 및 진행 방법

이 유기적인 IoT 데이터 통신 모듈을 구축하는 실습을 통해 우리는 다음과 같은 gRPC의 핵심 개발 과정을 아주 쉽게 단계별로 마스터하게 됩니다:

  • 1단계 - 통신 약속 문서 작성하기 (스키마 설계): 기기와 서버가 서로 어떤 형태로 데이터를 주고받을지 .proto라는 명세서 파일에 미리 정의해 둡니다. 이 약속을 바탕으로 데이터의 이름과 형태(예: 기기 ID는 문자열, 온도는 실수 등)를 컴파일 전에 확실하게 강제합니다.
  • 2단계 - 말을 알아듣는 코드 자동으로 만들기 (Stub 컴파일): 작성한 약속 문서를 컴파일러(protoc)에 넣어주면, 네트워크 통신과 데이터 변환 처리를 담당하는 Go 언어 코드를 컴퓨터가 자동으로 만들어 줍니다. 이를 통해 개발자가 직접 복잡한 JSON 변환이나 소켓 통신 코드를 일일이 짤 필요가 없어집니다.
  • 3단계 - 실제로 서버와 기기를 연결하여 대화하기 (네트워크 구현): 자동으로 만들어진 코드를 바탕으로 실제 gRPC 서버를 실행하고, 클라이언트 기기가 서버에 접속하여 데이터(날짜 요청, 센싱 값 전송 등)를 실시간으로 주고받는 엔드투엔드(End-to-End) 통신 흐름을 완성합니다.

3. 인터페이스 명세서 (protoapi.proto)

3.1 직관적인 비유로 이해하는 명세서 동작 원리

인터페이스 명세서의 다소 생소한 기술적 개념들은 일상적인 요소에 빗대어 다음과 같이 쉽게 해석할 수 있습니다:

  1. 인터페이스 명세서의 실체 (통합 약속 장부):
    • 센서 단말기는 저수준 임베디드 언어(예: C언어)를 사용하고, 중앙 서버는 고수준 언어(예: Go 언어)를 사용할 때, 두 기기는 첫인사부터 말이 통하지 않습니다. 이때 통신 규격이 적힌 약속 장부(.proto 파일)를 가운데에 공유함으로써, 기기와 서버가 동일한 기준으로 대화할 수 있는 소통 채널을 구축하게 됩니다.
  2. 명세서를 선언해야 하는 이유 (오타 예방과 자동 코드 생성):
    • 데이터 이름 불일치 차단: 송신 측은 기기 ID를 device_id로 보내고, 수신 측은 deviceId로 처리하여 시스템이 오작동하는 사소한 오타 버그를 컴파일(빌드) 단계에서 완전히 차단합니다.
    • 자동 코드 생성: 명세서 장부 하나만 기재해 두면 Go, Python 등 원하는 언어의 네트워크 연동 소스코드를 번역기(protoc)가 스스로 작성해 줍니다. 개발자가 일일이 수백 줄의 네트워크 통신 코드를 손으로 직접 짤 필요가 없어집니다.
  3. 메시지(Message)와 서비스(Service)의 구분 (규격 포장 상자와 기능 메뉴판):
    • service (기능 메뉴판): "본 서버 관제소에서는 시간 조회(GetDate)와 센서 보고(UpdateSensingData) 두 가지 기능 메뉴를 접수합니다" 하고 처리 가능한 통신 API 목록을 공시하는 것과 같습니다.
    • message (규격 포장 상자): 전송할 데이터를 알맞게 담는 전용 포장 박스입니다. 상자 안에는 기기 ID, 온도, 습도라는 세 종류의 알맹이가 들어가도록 크기와 형식을 고정해 둡니다.
  4. = 1, = 2 식별자의 의미 (데이터의 고유 번호표):
    • 프로토콜 버퍼에서 필드 이름 뒤에 붙는 = 1, = 2는 변수에 특정 값을 집어넣는 대입 연산자가 아닙니다.
    • 통신 시 패킷에 매번 DeviceIdTemperature 같은 구구절절한 문자열 이름표를 전부 태워 보내면 네트워크 대역폭이 낭비됩니다. 대신 데이터를 보낼 때 "1번 칸에는 기기 ID가 들어있고, 2번 칸에는 온도가 들어있다" 하고 짧은 번호표만 달아서 이진화(바이너리)하여 보냅니다. 수신 측 컴퓨터는 이 번호표만 보고 순서대로 데이터를 꺼내 해석하기 때문에 통신 오버헤드가 극적으로 줄어듭니다. 이미 가동 중인 이전 단말 기기들이 헷갈리지 않게, 한 번 지정한 번호표는 절대로 바꾸지 않고 유지하는 것이 설계의 절대 법칙입니다.

3.2 인터페이스 명세서의 개념 및 도입 목적

인터페이스 명세서(Interface Description Document)란 시스템을 구성하는 상이한 노드나 기기들이 데이터를 어떠한 규격과 규약으로 상호 교환할지 사전에 조율하고 합의하여 기술해 둔 설계 문서입니다. gRPC 환경에서는 이를 프로토콜 버퍼(Protocol Buffers)의 .proto 파일 형식을 활용해 데이터 명세와 서비스를 통합 기술하는 IDL(Interface Description Language)로 구체화합니다.

인터페이스 명세서를 강제하여 설계하는 목적은 다음과 같습니다:

  1. 강력한 스키마 계약 강제(Schema Contract): 송수신할 데이터의 이름, 형태(타입) 및 지원 함수를 코드 레벨에서 선언적으로 봉인하여, 런타임 단계가 아닌 컴파일 단계에서 데이터 구조 불일치 오류를 완전히 원천 차단합니다.
  2. 이종 스택/다국어 간의 원활한 호환성: 하나의 명세서만을 공유하면, 번역 도구 체인을 통하여 Go, C++, Python, Java 등 서로 다른 소스 언어로 동작하는 클라이언트와 서버 기기들이 상호 간에 기술적 문맥 충돌 없이 원활하게 패킷 데이터를 해석하고 소통할 수 있게 유도합니다.
  3. 효율적인 패킹을 통한 대역폭 절약: JSON 등 텍스트 기반 통신 규약에 비해 바이너리 압축 포맷을 생성하는 메커니즘을 명세 단계에서 조율함으로써 패킷 용량을 극대화하여 낮추고, 분산 에이전트와 센싱 노드 간 통신 오버헤드를 경감합니다.

3.3 인터페이스 명세서 설계 가이드 (작성 요령)

  • 사양 정의: 파일 최상단에 syntax = "proto3"; 스펙 버전을 선언하고, 대상 플랫폼에 맞는 패키지 출력 경로(go_package 옵션 등)를 제공합니다.
  • 원격 프로시저 선언: service 블록을 구성하여 외부 단말이 호출을 제기할 수 있는 진입 함수군(RPC 메서드)을 정의하며, 입력과 반환값으로 매핑될 데이터 규격을 선언합니다.
  • 메시지 직렬화 필드 정의: message 블록을 선언하여 구조체 데이터를 정의합니다. 이때 필드값 뒤에 정의하는 식별자 번호(예: = 1, = 2)는 값을 대입하는 대입 연산자가 아니라, 컴퓨터가 데이터 직렬화 및 역직렬화 시 순서를 식별하는 **필드 번호 태그(Field Number Tag)**입니다. 한 번 릴리즈된 인터페이스의 태그 번호는 과거 하위 호환성을 보장하기 위하여 임의로 수정하거나 재사용해서는 안 되며, 확장 시 새로운 번호를 꼬리에 덧붙이는 전방향 호환성 설계를 준수해야 합니다.

3.4 명세서 소스코드 및 구체적 분석

실습 디렉토리 내에 선언된 protoapi.proto 명세서 코드는 앞서 기획한 IoTService 통신 구조를 수립하기 위해 다음과 같이 사양을 기재해 둡니다.

syntax = "proto3";

option go_package = "./protoapi/;protoapi";

// 엣지 센싱 단말과 중앙 관리 서버가 연동할 원격 호출(RPC) 창구 정의
service IoTService {
    rpc GetDate (RequestDateTime) returns (DateTime);
    rpc UpdateSensingData (SensingData) returns (SensingResponse);
    rpc GetRandomPass (RequestPass) returns (RandomPass);
}

// 센싱 디바이스가 측정하여 전송할 환경 데이터 구조
message SensingData {
    string DeviceId = 1;      // 기기 식별 고유 일련번호
    double Temperature = 2;   // 수집된 대기 온도 센싱값
    double Humidity = 3;      // 수집된 상대 습도 센싱값
}

// 데이터 수신 완료 및 가공 결과를 통보하는 응답 영수증 구조
message SensingResponse {
    bool Success = 1;         // 트랜잭션 정상 반영 성공 여부
    string Message = 2;       // 상태 상세 정보 안내 메시지
}

message DateTime {
    string Value = 1;
}

message RequestDateTime {
    string Value = 2;
}

message RequestPass {
    int64 Seed = 1;
    int64 Length = 8;
}

message RandomPass {
    string Password = 1;
}

명세 구조의 구성 분석

  • IoTService: 센서 단말이 중앙 관제소(서버)에 접근하여 실행 가능한 세 가지 원격 서비스의 인터페이스 스펙을 나타냅니다. 기기는 서버 시계를 연계 조회(GetDate)하거나, 측정값을 안전하게 전달(UpdateSensingData)하고, 암호 세션 비밀번호 발급(GetRandomPass)을 동기식으로 호출할 수 있습니다.
  • SensingData: 실제 환경에 노출된 IoT 단말 기기의 정보를 포장하여 송신하기 위한 데이터 모델입니다. 기기 식별을 위한 식별자(DeviceId) 및 물리 센서 측정 변수(Temperature, Humidity)를 순서 번호 태그 1, 2, 3으로 매핑하여 순서가 흐트러지지 않도록 보장합니다.
  • SensingResponse: 서버가 수신 데이터를 트랜잭션 처리한 결과를 다시 단말로 되돌려주기 위한 수신 회신 메커니즘입니다. 통신 처리 및 갱신의 안전한 성패 지표(Success) 및 원격 디버깅을 위한 가시적인 스트링 로그(Message)를 캡슐화해 줍니다.

4. Go Stub 컴파일 및 도구 체인

작성된 .proto 명세 파일을 Go 언어 소스코드로 변환하기 위해 프로토콜 버퍼 컴파일러(protoc)와 Go 전용 플러그인을 로컬 개발 환경에 구성하는 가이드라인입니다.

4.1 핵심 도구 체인 구성과 기능 개요

.proto 파일이 '약속 장부'라면, 이 3가지 도구는 그 장부를 실제 프로그램이 알아듣는 Go 언어 코드로 옮겨주는 '번역팀'입니다. protoc은 장부를 읽고 이해하는 통역사, protoc-gen-go는 장부 속 데이터 모양(message)을 Go 구조체로 옮겨 적는 필경사, protoc-gen-go-grpc는 장부 속 기능 목록(service)을 실제로 호출 가능한 Go 함수 뼈대로 옮겨 적는 필경사입니다.

gRPC 빌드 및 코드 생성 환경을 구축하기 위해 사용되는 세 가지 핵심 바이너리의 세부 역할은 다음과 같습니다:

  1. protobuf-compiler (또는 protoc 본체):
    • 역할: 프로토콜 버퍼 코어 컴파일러 엔진
    • 목적: .proto 파일의 스키마 명세를 구문 분석하는 역할을 수행합니다. 특정 프로그래밍 언어에 의존하지 않는 공통 파서 역할을 하며, 생성된 구문 분석 정보를 아래의 언어별 플러그인 모듈에 위임하여 타깃 코드를 출력하도록 제어합니다.
  2. protoc-gen-go (Go 데이터 직렬화 플러그인):
    • 역할: Go 구조체 및 직렬화 소스코드 생성 플러그인
    • 목적: 명세서 내에 선언된 message 정의를 Go 언어의 구조체(struct) 코드로 자동 생성합니다. 이를 통해 바이너리 직렬화/역직렬화 인터페이스 및 필드 매핑 코드가 구현된 *.pb.go 파일을 얻을 수 있습니다.
  3. protoc-gen-go-grpc (Go gRPC 서비스 플러그인):
    • 역할: Go gRPC 통신 인터페이스 소스코드 생성 플러그인
    • 목적: 명세서 내에 선언된 service 정의를 해석하여 Go 언어 환경의 gRPC 서버 핸들러 뼈대와 클라이언트 송수신용 채널 인터페이스를 제공하는 *_grpc.pb.go 파일을 출력합니다.

4.2 컴파일 도구 체인 설치

  • macOS (Homebrew 사용): 터미널에 아래 명령어를 입력해 컴파일러와 Go 언어 통신용 변환 플러그인을 설치합니다.

    brew install protobuf
    go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
    go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
    
  • Linux (Ubuntu 기준): 패키지 관리자를 통해 컴파일러를 다운로드하고 마찬가지로 Go 플러그인을 환경에 바인딩합니다.

    sudo apt install -y protobuf-compiler
    go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
    go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
    

4.3 프로토콜 버퍼 컴파일 수행

작성된 스키마 명세를 빌드하여 Go 소스코드를 생성하기 위해 다음 명령어를 구동합니다:

# 해당 실습 디렉터리로 이동 후 컴파일 실행
cd lib/grpcentity
protoc --go_out=. --go-grpc_out=. protoapi.proto

컴파일 옵션의 출력 경로 지정 동작 방식

명령어 실행 시 주입하는 각 옵션은 빌드 파일이 생성될 **기준 디렉터리(Base Directory)**를 개별 정의합니다:

  • --go_out=.: 데이터 명세 구현체인 protoapi.pb.go가 출력될 기준 경로를 현재 컴파일 실행 디렉터리(.)로 선언합니다.
  • --go-grpc_out=.: gRPC 통신 구현체인 protoapi_grpc.pb.go가 출력될 기준 경로를 현재 실행 디렉터리(.)로 선언합니다.

Go 패키지 지정 옵션과의 결합 규칙: 해당 출력 경로 옵션들은 단독으로 파일의 최종 위치를 고정하지 않습니다. 컴파일러는 지정된 기준 경로(예: .)에 .proto 스펙 내부의 option go_package = "./protoapi;protoapi" 설정값을 조합하여 최종 디렉터리 경로를 생성합니다. 이에 따라 **컴파일 대상 디렉터리(.)**와 **상세 패키지 주소(./protoapi)**가 결합되어 lib/grpcentity/protoapi/ 경로가 자동 생성되며, 그 하위에 다음 소스코드들이 정상 배치됩니다:

  • protoapi.pb.go: 명세에 정의된 메시지(Message) 규격을 Go 구조체로 변환한 파일입니다.
  • protoapi_grpc.pb.go: 클라이언트와 서버 통신을 위한 원격 호출(Service) 규격을 구현한 파일입니다.

5. 실습 소스코드 상세 구현 분석

컴파일러를 통해 통신을 위한 스터브(Stub) 코드가 확보되었으므로, 이를 기반으로 서버와 클라이언트의 비즈니스 로직을 연결하는 상세 코드를 검토합니다.

5.1 gRPC 서버 구현 분석 (server.go)

  • 서버 서비스 인터페이스 매핑 구조체 (IoTServer):

    type IoTServer struct {
    	protoapi.UnimplementedIoTServiceServer
    }
    
    • 쉬운 설명: 이 구조체는 '나는 IoTService가 약속한 기능들을 구현하는 서버입니다'라고 선언하는 역할을 합니다. Go 언어의 gRPC 규칙상 이 구절(Unimplemented...)을 빼놓으면 서버가 아예 컴파일(빌드)되지 않고 에러가 발생하므로, '있으면 좋은 것'이 아니라 반드시 그대로 넣어주어야 하는 필수 구성 요소입니다. (이렇게 넣어두면 부수적으로, 나중에 약속 장부에 새 기능이 추가되어도 기존 서버 코드가 빌드 오류 없이 구동되는 효과도 함께 얻습니다.)
    • Q. 만약 이 줄(protoapi.UnimplementedIoTServiceServer)을 지우면 어떻게 되나요? gRPC가 자동 생성한 인터페이스와의 호환성이 깨져 Go 컴파일러가 아래와 같은 에러를 내며 빌드를 거부합니다:
      cannot use IoTServer{} (value of type IoTServer) as protoapi.IoTServiceServer in argument to protoapi.RegisterIoTServiceServer:
      	IoTServer does not implement protoapi.IoTServiceServer (missing method mustEmbedUnimplementedIoTServiceServer)
      
      따라서 나중에 메서드를 더 추가할 일이 없더라도, gRPC 서버의 정상적인 구동을 위해 반드시 포함시켜야 합니다.
    • 상세 설명: UnimplementedIoTServiceServer를 임베딩하여 정의합니다. 이는 향후 새로운 메서드가 프로토콜 스펙에 추가되더라도 기존 서버가 빌드 오류 없이 구동 호환성(미구현 메서드 호출 시 unimplemented 에러 반환)을 안전하게 유지하도록 제약하기 위한 설정입니다.
  • 센싱 데이터 업데이트 수신 처리 (UpdateSensingData):

    func (IoTServer) UpdateSensingData(ctx context.Context, r *protoapi.SensingData) (*protoapi.SensingResponse, error) {
    	fmt.Printf("Received sensing data - Device: %s, Temp: %.2f°C, Humid: %.2f%%\n", r.GetDeviceId(), r.GetTemperature(), r.GetHumidity())
    	return &protoapi.SensingResponse{Success: true, Message: "Sensing data updated successfully!"}, nil
    }
    
    • 쉬운 설명: 기기(클라이언트)가 온습도 데이터를 전송해 왔을 때, 서버 콘솔 화면에 이를 예쁘게 출력한 뒤 "성공적으로 업데이트되었습니다"라는 확인 영수증(SensingResponse)을 만들어 돌려주는 실제 서비스 동작 부위입니다.
    • 상세 설명: 클라이언트 디바이스로부터 센싱 패킷을 수신하면, 기기 식별 및 온습도 측정 매개변수를 포맷팅하여 표준 출력에 표시한 후 정상 처리 완료 플래그를 담은 응답 구조체(SensingResponse)를 반환합니다.

    Note

    (심화) 기술 분석: 왜 Set 메서드는 없고 Get 메서드만 제공되는가? (Go와 Protobuf의 설계 철학)

    자동으로 생성된 Go 소스코드를 들여다보면, 필드 데이터를 추출하는 r.GetDeviceId() 메서드는 존재하나 값을 쓰는 r.SetDeviceId() 메서드는 설계 단계에서 배제되어 있습니다. 이 비대칭 구조에는 아래와 같은 언어 및 아키텍처적 지향점이 투영되어 있습니다:

    1. Go 언어의 구조적 단순함 (Simplicity): Go 언어는 불필요한 은닉용 Getter/Setter 작성을 권장하지 않습니다. 외부 공유가 필요한 구조체 필드는 대문자(DeviceId)로 시작하도록 설계되어 외부에서 직접 대입(r.DeviceId = ...)하여 제어하는 것이 보편적인 방식이기 때문입니다.
    2. 포인터 예외(Nil-Safety) 방지를 위한 안전장치: 수신한 구조체 변수가 초기화되지 않은 nil 포인터일 때 필드에 직접 접근하면 프로그램이 즉시 종료(Segmentation Fault)됩니다. 하지만 자동 생성된 GetDeviceId() 메서드는 내부적으로 수신 객체의 nil 검사를 사전에 집행하여 에러를 막고, 객체가 비어있을 경우 해당 필드 타입의 안전한 기본값(Zero-value, 예: "" 또는 0)을 반환하도록 설계된 안전 읽기 인터페이스입니다.
    3. 데이터 불변성 (Immutability): 분산 환경에서 통신 객체(DTO)는 한 번 직렬화되어 전송되기 시작한 시점부터 중간 변경이 억제되는 불변 객체로 취급하는 것이 데이터 정합성에 유리합니다. 인스턴스 중간 변조를 허용하는 세터(Setter)의 무분별한 사용을 차단하여 통신의 신뢰성을 고정하고자 하는 아키텍처적 의도도 포함되어 있습니다.
  • gRPC 리스너 구동 및 서빙 (ServerRun):

    listen, _ := net.Listen("tcp", port)
    server.Serve(listen)
    
    • 쉬운 설명: :8080 포트로 통하는 소켓(전화선)을 개통하고, 기기들의 전화(접속 및 호출)가 오기를 기다리며 대기 상태로 들어가는 서버 구동 시작점입니다.
    • 상세 설명: 지정된 포트(기본 포트 :8080)의 TCP 소켓 포트를 활성화하고, 클라이언트의 접속 및 RPC 서비스 호출에 대해 지속적으로 대기하는 리스너 구동의 진입점입니다.

5.2 gRPC 클라이언트 구현 분석 (client.go)

  • 원격 서비스 클라이언트 기동 (NewIoTServiceClient):
    conn, _ := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
    client := protoapi.NewIoTServiceClient(conn)
    
    • 쉬운 설명: 서버 주소로 전화를 거는 통신선(TCP 채널)을 안전 보안(TLS) 없이 개설한 뒤, 이 선을 통해 gRPC 약속 장부(IoTService)대로 서버에 원격 호출을 요청할 수 있는 전용 전화기(클라이언트 인스턴스)를 획득하는 과정입니다.
    • 상세 설명: 서버와의 TCP 채널(conn)을 평문 전송(insecure) 기반으로 바인딩한 뒤, 해당 채널을 통해 원격 서비스를 호출할 수 있는 전송용 클라이언트 인스턴스를 확보합니다.
  • 패킷 구성 및 RPC 호출 실행 (AskUpdateSensingData):
    request := &protoapi.SensingData{DeviceId: deviceId, Temperature: temp, Humidity: humid}
    return m.UpdateSensingData(ctx, request)
    
    • 쉬운 설명: 온습도 데이터 상자(SensingData)를 접어서 기기 번호와 센서값을 가지런히 담은 뒤, 전용 전화기(클라이언트 인터페이스)를 통해 서버의 UpdateSensingData 기능을 직접 원격 실행(호출)하는 부분입니다.
    • 상세 설명: 센서 측정값을 메시지 스펙 규격에 맞추어 SensingData 구조체 인스턴스로 바인딩한 후, 기설정된 클라이언트 인터페이스를 경유하여 서버의 UpdateSensingData 엔드포인트를 호출합니다.

5.3 통신 세션 동작 시퀀스 및 흐름

실습 예제를 기동하면 클라이언트와 서버 간에 아래와 같은 동작 흐름이 순차적으로 실행됩니다:

  1. 서버 날짜/시간 조회: 클라이언트가 GetDate를 호출하여 서버의 현재 가동 시계 정보 문자열을 회신받아 표준 출력에 노출합니다.
  2. 보안 토큰용 임시 암호 발급: 클라이언트가 암호화 연산 시드와 길이를 전달하여 GetRandomPass를 호출하고, 서버가 생성한 일회성 보안 패스워드를 응답받습니다.
  3. 환경 수집 센싱 데이터 동기화: 클라이언트가 기기 식별값("sensor-room-01")과 가상의 온습도 변수를 실어 UpdateSensingData를 기동하면, 서버는 수신 값을 화면에 검증 및 로깅한 후 완료 보고 영수증을 반환합니다.

Tip

(선택 학습) 상세 네트워크 시퀀스 및 엔진 내부 동작 분석: 기본 실습 과정을 모두 마치고 gRPC 내부에서 네트워크 패킷이 구체적으로 어떻게 직렬화되고 HTTP/2 프레임으로 오가는지 깊이 알고 싶다면, ASKING_DATETIME_WORKFLOW.md 문서에서 상세한 동작 시퀀스 다이어그램과 라이프사이클 분석 자료를 선택적으로 확인할 수 있습니다.


6. 트러블슈팅 (Troubleshooting)

실습 구동 과정에서 마주할 수 있는 전형적인 에러 현상과 대처 방안입니다.

6.1 bind: address already in use (네트워크 소켓 포트 충돌)

  • 발생 원인: gRPC 서버 기동 시 설정한 통신 포트 :8080이 이미 다른 네트워크 프로세스나 이전 실습 서버의 비정상 종료 등으로 인해 점유되어 바인딩에 실패한 상태입니다.
  • 조치 방법:
    • lib/main.goport 변수 및 server.goport 전역 변수값을 동시에 다른 유휴 포트(예: :9090)로 변경하여 구동을 시도합니다.
    • 🚨 소스코드 설계 제약: server.go에 기재된 ServerRun(addr string) 함수는 메인 환경으로부터 주입받은 addr 인자값을 사용하지 않고, 패키지 내부 전역 변수인 port (하드코딩된 값 :8080)를 소켓 리슨 파라미터로 직접 참조하도록 되어 있습니다. 따라서 바인딩 충돌 회피를 위해서는 main.go뿐만 아니라 반드시 server.go 파일 내부의 전역 변수인 port 역시 함께 갱신해 주어야 실제 바인딩 포트 변경이 유효하게 처리됩니다.

7. 대용량 데이터 전송을 위한 스트리밍(Streaming) 구현

일반적인 단발성 요청/응답(Unary) 통신은 전송할 전체 데이터를 단일 메모리에 전부 올려 적재한 상태에서 동작하므로, 펌웨어나 대형 이미지 같은 대용량 데이터를 다룰 때 메모리 고갈(OOM)이나 네트워크 대역폭 병목을 초래하기 쉽습니다. gRPC는 HTTP/2 프로토콜의 스트림(Stream) 채널을 기본 가용하므로, 데이터를 일정 크기(Chunk) 단위로 쪼개 연속적으로 전송할 수 있는 강력한 스트리밍(Streaming) 기법을 지원합니다. 본 예제에서는 클라이언트가 파일을 조각내어 보내는 클라이언트 스트리밍(Client Streaming), 업로드된 파일 메타 정보를 모아 한 번에 내려주는 단일 조회(Unary RPC), 그리고 서버가 데이터를 쪼개어 클라이언트에게 보내는 **서버 스트리밍(Server Streaming)**까지 모두 종합 설계하여 탑재했습니다.

7.1 스키마 설계 (protoapi.proto)

업로드와 리스트 조회, 그리고 다운로드를 위한 gRPC 메시지 규격을 명세합니다:

service IoTService {
    // ... 기존 RPC ...
    rpc UploadFile (stream FileChunk) returns (UploadStatus);
    rpc ListFiles (EmptyRequest) returns (FileList);
    rpc DownloadFile (DownloadRequest) returns (stream FileChunk);
}

message FileChunk {
    string FileName = 1;
    bytes Content = 2; // 쪼개진 바이너리 데이터 조각
}

message UploadStatus {
    bool Success = 1;
    string Message = 2;
    int64 BytesUploaded = 3;
}

message EmptyRequest {}

message FileMetadata {
    string FileName = 1;
    int64 FileSize = 2;
    int64 UploadedAt = 3;
}

message FileList {
    repeated FileMetadata Files = 1;
}

message DownloadRequest {
    string FileName = 1;
}
  • 설계 포인트: stream 키워드가 들어간 위치에 주목합니다. UploadFile은 입력에 stream이 붙어 클라이언트 스트리밍을, DownloadFile은 반환(returns)에 stream이 붙어 서버 스트리밍 채널을 개설합니다.

7.2 서버 저장 및 송수신 구현 (server.go)

인메모리 파일 저장소(fileStore)를 구현하고 목록 조회(ListFiles) 및 서버 다운로드 스트리밍(DownloadFile) 핸들러를 정의합니다:

// 1. 인메모리 파일 보관소
type UploadedFile struct {
	FileName   string
	Content    []byte
	UploadedAt int64
}

var (
	fileStore = make(map[string]*UploadedFile)
	storeMu   sync.RWMutex
)

// 2. 파일 리스트 조회 Unary RPC
func (IoTServer) ListFiles(ctx context.Context, r *protoapi.EmptyRequest) (*protoapi.FileList, error) {
	storeMu.RLock()
	defer storeMu.RUnlock()

	var files []*protoapi.FileMetadata
	for _, f := range fileStore {
		files = append(files, &protoapi.FileMetadata{
			FileName:   f.FileName,
			FileSize:   int64(len(f.Content)),
			UploadedAt: f.UploadedAt,
		})
	}
	return &protoapi.FileList{Files: files}, nil
}

// 3. 파일 다운로드 Server Streaming RPC
func (IoTServer) DownloadFile(r *protoapi.DownloadRequest, stream protoapi.IoTService_DownloadFileServer) error {
	storeMu.RLock()
	f, exists := fileStore[r.GetFileName()]
	storeMu.RUnlock()

	if !exists {
		return status.Errorf(codes.NotFound, "file %s not found", r.GetFileName())
	}

	chunkSize := 1024 // 1KB 단위 분할 송출
	totalBytes := len(f.Content)

	for i := 0; i < totalBytes; i += chunkSize {
		end := i + chunkSize
		if end > totalBytes {
			end = totalBytes
		}
		err := stream.Send(&protoapi.FileChunk{
			FileName: f.FileName,
			Content:  f.Content[i:end],
		})
		if err != nil {
			return err
		}
	}
	return nil
}
  • 쉬운 설명:
    • 목록 조회: 서버의 파일 보관함(fileStore)을 열고 그 안에 든 모든 파일의 메타데이터(이름, 크기, 업로드 시각)를 리스트로 포장해 한번에 리턴해 줍니다.
    • 파일 다운로드: 보관함에서 요청받은 파일을 찾은 뒤, 파일 내용 전체를 1KB 크기의 패킷 조각들로 잘라 통로를 타고 차례대로 연속 전송(stream.Send())해 줍니다.
  • 상세 설명:
    • 목록 조회: 동시 접근 보호(Race condition 방지)를 위해 읽기 전용 락(RLock)을 획득한 후 인메모리 맵을 순회하며 메타데이터 구조체 목록을 집계해 반환합니다.
    • 파일 다운로드: 대상 파일 쿼리 실패 시 gRPC 표준 에러(codes.NotFound)를 반환합니다. 검증 통과 시 루프 내에서 가상 윈도우 슬라이싱을 집행해 청크 구조체를 구성하고, stream.Send()로 직렬화 패킷을 클라이언트 버퍼 큐에 기입합니다.

7.3 클라이언트 송수신 기동 (client.go)

클라이언트는 업로드에 성공한 뒤, 서버에 파일 목록 조회를 요구하고, 다운로드 스트림을 개설해 조각 데이터를 재조립하여 무결성을 검사합니다.

// 1. 파일 목록 조회 호출
func AskListFiles(ctx context.Context, m protoapi.IoTServiceClient) (*protoapi.FileList, error) {
	return m.ListFiles(ctx, &protoapi.EmptyRequest{})
}

// 2. 파일 다운로드 수신 및 조립
func AskDownloadFile(ctx context.Context, m protoapi.IoTServiceClient, fileName string) ([]byte, error) {
	stream, err := m.DownloadFile(ctx, &protoapi.DownloadRequest{FileName: fileName})
	if err != nil {
		return nil, err
	}

	var buffer []byte
	for {
		chunk, err := stream.Recv()
		if err == io.EOF {
			break // 서버가 송신 완료하고 채널을 닫음
		}
		if err != nil {
			return nil, err
		}
		buffer = append(buffer, chunk.GetContent()...)
	}
	return buffer, nil
}
  • 쉬운 설명:
    • 목록 조회: 서버에게 "보관 중인 파일 이름 목록을 달라"고 요구하여 화면에 출력합니다.
    • 파일 다운로드: 다운로드 통로를 열어 서버가 던져주는 조각들을 계속 수령(stream.Recv())하여 버퍼에 차곡차곡 합칩니다. 서버가 보내기를 끝마치면(io.EOF) 조립을 중단하고 최종 완성된 온전한 바이트 파일을 최종 사용처에 반환합니다.
  • 상세 설명:
    • 목록 조회: 빈 메시지(EmptyRequest)를 동봉해 Unary RPC 채널을 트리거하고 메타데이터 배열 결과를 동기 획득합니다.
    • 파일 다운로드: 서버 스트리밍 엔드포인트 기동 후, stream.Recv() 블로킹 수신 루프에 진입합니다. 채널 해제 지점(io.EOF)에 도달할 때까지 메모리 버퍼 슬라이스에 청크 바이트 배열을 병합 누적하여 재조립(Reassembly)을 마친 후 반환합니다.

8. 한 걸음 더 나아가기 (다음 단계)

본 기초 실습을 끝마치셨다면, 아래 과제를 해결해보세요:

  1. 약속 스펙 확장해 보기: protoapi.proto 파일에 새로운 환경 데이터(예: 미세먼지 수치 double Dust = 4;)를 슬쩍 얹어본 뒤, 직접 번역기를 새로 돌리고 Go 소스코드를 고치며 확장해 봅니다.
  2. proto 명세서 물리 분할하기 (실전 모듈화): 현재 단일 파일로 구성된 protoapi.proto를 기능 성격에 맞춰 iot_service.proto(Unary 제어 메시지)와 file_transfer.proto(스트리밍 전송 메시지) 2개의 파일로 쪼개어 구성해 봅니다. 이때 두 파일의 option go_package 공유 방식과 번역기(protoc) 명령어를 다중 타겟(또는 *.proto)으로 조율하여 정상 빌드해 보세요.

9. 참고 자료

  • gRPC와 REST의 차이점 (AWS): 두 방식의 특징과 언제 어떤 기술을 선택해야 하는지 친절하게 정리된 공식 블로그 자료입니다.