# 3단계: gRPC 통신 구현 상세 가이드 ⬅ [학습 로드맵으로 돌아가기](MANUSCRIPT.md) 이 문서는 `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`는 변수에 특정 값을 집어넣는 대입 연산자가 아닙니다. * 통신 시 패킷에 매번 `DeviceId`나 `Temperature` 같은 구구절절한 문자열 이름표를 전부 태워 보내면 네트워크 대역폭이 낭비됩니다. 대신 데이터를 보낼 때 **"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](../lib/grpcentity/protoapi.proto) 명세서 코드는 앞서 기획한 `IoTService` 통신 구조를 수립하기 위해 다음과 같이 사양을 기재해 둡니다. ```proto 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 언어 통신용 변환 플러그인을 설치합니다. ```bash 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 플러그인을 환경에 바인딩합니다. ```bash 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 소스코드를 생성하기 위해 다음 명령어를 구동합니다: ```bash # 해당 실습 디렉터리로 이동 후 컴파일 실행 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](../lib/grpcentity/server.go)) * **서버 서비스 인터페이스 매핑 구조체 (`IoTServer`)**: ```go type IoTServer struct { protoapi.UnimplementedIoTServiceServer } ``` * **쉬운 설명**: 이 구조체는 '나는 IoTService가 약속한 기능들을 구현하는 서버입니다'라고 선언하는 역할을 합니다. **Go 언어의 gRPC 규칙상 이 구절(`Unimplemented...`)을 빼놓으면 서버가 아예 컴파일(빌드)되지 않고 에러가 발생하므로, '있으면 좋은 것'이 아니라 반드시 그대로 넣어주어야 하는 필수 구성 요소입니다.** (이렇게 넣어두면 부수적으로, 나중에 약속 장부에 새 기능이 추가되어도 기존 서버 코드가 빌드 오류 없이 구동되는 효과도 함께 얻습니다.) * **Q. 만약 이 줄(`protoapi.UnimplementedIoTServiceServer`)을 지우면 어떻게 되나요?** gRPC가 자동 생성한 인터페이스와의 호환성이 깨져 Go 컴파일러가 아래와 같은 에러를 내며 빌드를 거부합니다: ```text 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`)**: ```go 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`)**: ```go listen, _ := net.Listen("tcp", port) server.Serve(listen) ``` * **쉬운 설명**: `:8080` 포트로 통하는 소켓(전화선)을 개통하고, 기기들의 전화(접속 및 호출)가 오기를 기다리며 대기 상태로 들어가는 서버 구동 시작점입니다. * **상세 설명**: 지정된 포트(기본 포트 `:8080`)의 TCP 소켓 포트를 활성화하고, 클라이언트의 접속 및 RPC 서비스 호출에 대해 지속적으로 대기하는 리스너 구동의 진입점입니다. ### 5.2 gRPC 클라이언트 구현 분석 ([client.go](../lib/grpcentity/client.go)) * **원격 서비스 클라이언트 기동 (`NewIoTServiceClient`)**: ```go conn, _ := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) client := protoapi.NewIoTServiceClient(conn) ``` * **쉬운 설명**: 서버 주소로 전화를 거는 통신선(TCP 채널)을 안전 보안(TLS) 없이 개설한 뒤, 이 선을 통해 gRPC 약속 장부(`IoTService`)대로 서버에 원격 호출을 요청할 수 있는 전용 전화기(클라이언트 인스턴스)를 획득하는 과정입니다. * **상세 설명**: 서버와의 TCP 채널(`conn`)을 평문 전송(insecure) 기반으로 바인딩한 뒤, 해당 채널을 통해 원격 서비스를 호출할 수 있는 전송용 클라이언트 인스턴스를 확보합니다. * **패킷 구성 및 RPC 호출 실행 (`AskUpdateSensingData`)**: ```go 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](ASKING_DATETIME_WORKFLOW.md) 문서에서 상세한 동작 시퀀스 다이어그램과 라이프사이클 분석 자료를 선택적으로 확인할 수 있습니다. --- ## 6. 트러블슈팅 (Troubleshooting) 실습 구동 과정에서 마주할 수 있는 전형적인 에러 현상과 대처 방안입니다. ### 6.1 `bind: address already in use` (네트워크 소켓 포트 충돌) * **발생 원인**: gRPC 서버 기동 시 설정한 통신 포트 `:8080`이 이미 다른 네트워크 프로세스나 이전 실습 서버의 비정상 종료 등으로 인해 점유되어 바인딩에 실패한 상태입니다. * **조치 방법**: - `lib/main.go`의 `port` 변수 및 [server.go](../lib/grpcentity/server.go)의 `port` 전역 변수값을 동시에 다른 유휴 포트(예: `: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 메시지 규격을 명세합니다: ```proto 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](../lib/grpcentity/server.go)) 인메모리 파일 저장소(`fileStore`)를 구현하고 목록 조회(`ListFiles`) 및 서버 다운로드 스트리밍(`DownloadFile`) 핸들러를 정의합니다: ```go // 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](../lib/grpcentity/client.go)) 클라이언트는 업로드에 성공한 뒤, 서버에 파일 목록 조회를 요구하고, 다운로드 스트림을 개설해 조각 데이터를 재조립하여 무결성을 검사합니다. ```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)을 마친 후 반환합니다. ### 7.4 실시간 알림을 위한 Pub/Sub (발행/구독) 브로드캐스팅 구현 스마트 가전이나 센서 등 실시간 경보 통지가 필요한 AIoT 도메인에서는, 서버가 상시 대기하는 여러 디바이스(클라이언트)들에게 비동기로 이벤트를 밀어 넣어주는 **발행/구독(Publish/Subscribe)** 연동 구조가 필수적입니다. gRPC의 **서버 스트리밍(Server Streaming)** 채널을 응용하면, 다수의 클라이언트가 스트림 통로를 상시 유지한 채 대기하고, 서버가 특정 이벤트 발생 시 채널 리스트를 순회하며 실시간 이벤트를 **브로드캐스팅(Broadcasting)**하는 Pub/Sub 인프라를 단순하고 가볍게 완성할 수 있습니다. #### 1. 스키마 설계 (`protoapi.proto`) 구독 신청을 위한 파라미터(`AlertSubscription`)와 서버가 밀어 넣어줄 이벤트 규격(`AlertMessage`)을 IDL에 선언합니다: ```proto message AlertSubscription { string ClientId = 1; string Topic = 2; // 구독할 주제 (예: "temperature_warnings") } message AlertMessage { string AlertId = 1; string DeviceId = 2; string Message = 3; // 실시간 발생 경보 문자열 int64 Timestamp = 4; } ``` #### 2. 서버 사이드 구독자 관리 및 발행 구현 ([server.go](../lib/grpcentity/server.go)) 서버는 구독을 신청한 클라이언트들에게 메시지를 안전하게 분배하기 위해 스레드 세이프 맵과 고루틴 채널(`chan`) 구조를 구성합니다: ```go type AlertSubscriber struct { ClientId string Channel chan *protoapi.AlertMessage } var ( subscribers = make(map[string]*AlertSubscriber) subMu sync.Mutex ) // 실시간 모든 구독 채널에 알림 이벤트 분배 (Publish/Broadcast) func publishAlert(alert *protoapi.AlertMessage) { subMu.Lock() defer subMu.Unlock() for _, sub := range subscribers { select { case sub.Channel <- alert: default: // 채널 버퍼가 가득 찬 경우 병목 차단을 방지하기 위해 드롭 처리 fmt.Printf("Alert channel blocked for client %s, dropping event\n", sub.ClientId) } } } // 구독 채널 대기 핸들러 func (IoTServer) SubscribeAlerts(r *protoapi.AlertSubscription, stream protoapi.IoTService_SubscribeAlertsServer) error { clientId := r.GetClientId() ch := make(chan *protoapi.AlertMessage, 10) // 버퍼 10의 수신 채널 생성 sub := &AlertSubscriber{ ClientId: clientId, Channel: ch, } subMu.Lock() subscribers[clientId] = sub subMu.Unlock() fmt.Printf("Client %s subscribed to alerts on topic '%s'\n", clientId, r.GetTopic()) // 스트림 연결 유지 및 채널 대기 감시 루프 for { select { case alert := <-ch: err := stream.Send(alert) if err != nil { subMu.Lock() delete(subscribers, clientId) subMu.Unlock() return err } case <-stream.Context().Done(): subMu.Lock() delete(subscribers, clientId) subMu.Unlock() return nil } } } ``` * **쉬운 설명**: * **구독 신청**: 클라이언트가 전화를 걸면 서버는 그 선을 닫지 않고 메모장(`subscribers`)에 해당 전화번호와 연결된 통로(Go 채널)를 적어둡니다. 그리고 그 선을 계속 붙잡고 대기(`stream.Send`) 상태를 유지합니다. * **경보 발행**: 센서값 수신 핸들러(`UpdateSensingData`)에서 임계치(40도)를 초과하는 위험 열기가 감지되면, 메모장에 적힌 모든 연결된 통로에 경보 엽서(`AlertMessage`)를 휙 던져(Broadcast) 줍니다. * **상세 설명**: * **구독 신청**: `SubscribeAlerts` 엔드포인트는 호출과 동시에 전용 Go 비동기 버퍼 채널을 생성하고 전역 가입 맵에 등록합니다. `stream.Context().Done()` 채널 수신이나 스트림 유실 이벤트가 포착되기 전까지 루프 대기 상태를 안전하게 고정합니다. * **경보 발행**: 동시성 경쟁 방지 락(`subMu.Lock()`) 임계 구역 내에서 연결된 모든 채널에 데이터를 `select-default` 논블로킹 패턴으로 분배 기입하여, 특정 클라이언트의 수신 병목이 서버 전체 성능에 미치는 파급 효과를 예방합니다. #### 3. 클라이언트 비동기 청취 구현 ([client.go](../lib/grpcentity/client.go)) 클라이언트는 메인 흐름을 방해하지 않고 알림을 백그라운드에서 실시간으로 대기 청취할 수 있도록 별도의 독자적인 비동기 고루틴 구조로 가동합니다. ```go func AskSubscribeAlerts(ctx context.Context, m protoapi.IoTServiceClient, clientId string, topic string) { stream, err := m.SubscribeAlerts(ctx, &protoapi.AlertSubscription{ ClientId: clientId, Topic: topic, }) if err != nil { fmt.Println("Failed to subscribe alerts:", err) return } for { alert, err := stream.Recv() if err == io.EOF { break } if err != nil { break } fmt.Printf("\n🔔 [ALERT RECEIVED] ID: %s | Device: %s | Msg: %s | Time: %s\n\n", alert.GetAlertId(), alert.GetDeviceId(), alert.GetMessage(), time.Unix(alert.GetTimestamp(), 0).Format("15:04:05")) } } ``` * **쉬운 설명**: 클라이언트는 메인 로직이 다른 볼일(파일 업로드/다운로드 등)을 보러 간 동안, 옆방에서 전화를 붙잡고 계속 귀를 기울이는 전담 직원(비동기 고루틴)을 기동시킵니다. 서버에서 "벨(알림)"이 울릴 때마다 그 내용을 즉시 가로채 화면에 실시간 경보 창을 출력해 줍니다. * **상세 설명**: 메인 쓰레드의 블로킹을 방지하기 위해 Go의 경량 쓰레드 고루틴(`go AskSubscribeAlerts`)으로 리스너 루프를 위임 기동합니다. gRPC 스트림 클라이언트의 `stream.Recv()` 메서드는 서버로부터 메시지가 전달될 때까지 스레드 리소스를 낭비하지 않는 대기 상태로 머물며, 데이터 수령 시 콘솔 스트림에 이를 비동기 매핑 출력합니다. --- ## 8. 한 걸음 더 나아가기 (다음 단계) 본 기초 실습을 끝마치셨다면, 아래 과제를 해결해보세요: 1. **약속 스펙 확장해 보기**: [protoapi.proto](../lib/grpcentity/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)](https://aws.amazon.com/ko/compare/the-difference-between-grpc-and-rest/): 두 방식의 특징과 언제 어떤 기술을 선택해야 하는지 친절하게 정리된 공식 블로그 자료입니다.