Files
grpccanary/docs/GRPC.md
T

446 lines
36 KiB
Markdown

# 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](../examples/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 examples/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`)**가 결합되어 `examples/grpcentity/protoapi/` 경로가 자동 생성되며, 그 하위에 다음 소스코드들이 정상 배치됩니다:
* `protoapi.pb.go`: 명세에 정의된 메시지(Message) 규격을 Go 구조체로 변환한 파일입니다.
* `protoapi_grpc.pb.go`: 클라이언트와 서버 통신을 위한 원격 호출(Service) 규격을 구현한 파일입니다.
---
## 5. 실습 소스코드 상세 구현 분석
컴파일러를 통해 통신을 위한 스터브(Stub) 코드가 확보되었으므로, 이를 기반으로 서버와 클라이언트의 비즈니스 로직을 연결하는 상세 코드를 검토합니다.
### 5.1 gRPC 서버 구현 분석 ([server.go](../examples/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](../examples/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`이 이미 다른 네트워크 프로세스나 이전 실습 서버의 비정상 종료 등으로 인해 점유되어 바인딩에 실패한 상태입니다.
* **조치 방법**:
- `examples/main.go`의 `port` 변수 및 [server.go](../examples/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](../examples/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](../examples/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)을 마친 후 반환합니다.
---
## 8. 한 걸음 더 나아가기 (다음 단계)
본 기초 실습을 끝마치셨다면, 아래 과제를 해결해보세요:
1. **약속 스펙 확장해 보기**: [protoapi.proto](../examples/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/): 두 방식의 특징과 언제 어떤 기술을 선택해야 하는지 친절하게 정리된 공식 블로그 자료입니다.