docs: refine gRPC guide section numbers and add CloseAndRecv & fileStore explanations

This commit is contained in:
2026-07-17 22:15:58 +09:00
parent d90553ecdf
commit 50f63633ea
23 changed files with 2964 additions and 130 deletions
+36 -23
View File
@@ -40,7 +40,7 @@ gRPC는 HTTP/2를 기반으로 구축된 구글의 고성능 오픈소스 원격
1. **서버 시간 및 날짜 조회 (`GetDate`)**: 기기가 접속 상태를 확인하며 동기화를 위해 서버의 현재 날짜와 시간 포맷 문자열을 반환받습니다.
2. **센싱 데이터 업데이트 (`UpdateSensingData`)**: 센서 노드가 주기적으로 수집한 물리 데이터(온도, 습도) 및 기기 식별자(Device ID)를 전달하면, 서버는 데이터 정합성을 검증한 후 성공 여부를 반환합니다.
3. **일회성 보안 패스워드 발급 (`GetRandomPass`)**: 기기가 임시 통신 세션 수립을 위해 난수 생성 시드와 보안 문자열 길이를 전달하면, 무작위 ASCII 임시 패스워드를 연산하여 응답받습니다.
(참고: 여기서는 핵심 3종 Unary RPC를 우선 다루며, 대용량 파일 전송과 실시간 알림 기능은 §7에서 스트리밍 RPC 4종으로 이어서 확장합니다.)
(참고: 여기서는 핵심 3종 Unary RPC를 우선 다루며, 대용량 파일 전송과 실시간 알림 기능은 §6 및 §7에서 스트리밍 RPC 4종으로 이어서 확장합니다.)
### 2.2 학습 목표 및 진행 방법
이 유기적인 IoT 데이터 통신 모듈을 구축하는 실습을 통해 우리는 다음과 같은 gRPC의 핵심 개발 과정을 아주 쉽게 단계별로 마스터하게 됩니다:
@@ -85,7 +85,7 @@ gRPC는 HTTP/2를 기반으로 구축된 구글의 고성능 오픈소스 원격
실습 디렉토리 내에 선언된 [protoapi.proto](../lib/grpc/basic/protoapi.proto) 명세서 코드는 앞서 기획한 `IoTService` 통신 구조를 수립하기 위해 다음과 같이 사양을 기재해 둡니다.
※ 이 코드는 기본 Unary RPC 3종만 발췌한 것이며, 전체 스펙(스트리밍 4종 포함)은 §7.1에서 이어집니다.
※ 이 코드는 기본 Unary RPC 3종만 발췌한 것이며, 전체 스펙(스트리밍 4종 포함)은 §6.1에서 이어집니다.
```proto
syntax = "proto3";
@@ -207,7 +207,7 @@ protoc --go_out=. --go-grpc_out=. protoapi.proto
protoapi.UnimplementedIoTServiceServer
}
```
* **쉬운 설명**: 이 구조체는 '나는 IoTService가 약속한 기능들을 구현하는 서버입니다'라고 선언하는 역할을 합니다. **Go 언어의 gRPC 규칙상 이 구절(`Unimplemented...`)을 빼놓으면 서버가 아예 컴파일(빌드)되지 않고 에러가 발생하므로, '있으면 좋은 것'이 아니라 반드시 그대로 넣어주어야 하는 필수 구성 요소입니다.** (이렇게 넣어두면 부수적으로, 나중에 약속 장부에 새 기능이 추가되어도 기존 서버 코드가 빌드 오류 없이 구동되는 효과도 함께 얻습니다.)
* **간단 설명**: 이 구조체는 '나는 IoTService가 약속한 기능들을 구현하는 서버입니다'라고 선언하는 역할을 합니다. **Go 언어의 gRPC 규칙상 이 구절(`Unimplemented...`)을 빼놓으면 서버가 아예 컴파일(빌드)되지 않고 에러가 발생하므로, '있으면 좋은 것'이 아니라 반드시 그대로 넣어주어야 하는 필수 구성 요소입니다.** (이렇게 넣어두면 부수적으로, 나중에 약속 장부에 새 기능이 추가되어도 기존 서버 코드가 빌드 오류 없이 구동되는 효과도 함께 얻습니다.)
* **Q. 만약 이 줄(`protoapi.UnimplementedIoTServiceServer`)을 지우면 어떻게 되나요?**
gRPC가 자동 생성한 인터페이스와의 호환성이 깨져 Go 컴파일러가 아래와 같은 에러를 내며 빌드를 거부합니다:
```text
@@ -223,7 +223,7 @@ protoc --go_out=. --go-grpc_out=. protoapi.proto
return &protoapi.SensingResponse{Success: true, Message: "Sensing data updated successfully!"}, nil
}
```
* **쉬운 설명**: 기기(클라이언트)가 온습도 데이터를 전송해 왔을 때, 서버 콘솔 화면에 이를 예쁘게 출력한 뒤 "성공적으로 업데이트되었습니다"라는 확인 영수증(`SensingResponse`)을 만들어 돌려주는 실제 서비스 동작 부위입니다.
* **간단 설명**: 기기(클라이언트)가 온습도 데이터를 전송해 왔을 때, 서버 콘솔 화면에 이를 예쁘게 출력한 뒤 "성공적으로 업데이트되었습니다"라는 확인 영수증(`SensingResponse`)을 만들어 돌려주는 실제 서비스 동작 부위입니다.
* **상세 설명**: 클라이언트 디바이스로부터 센싱 패킷을 수신하면, 기기 식별 및 온습도 측정 매개변수를 포맷팅하여 표준 출력에 표시한 후 정상 처리 완료 플래그를 담은 응답 구조체(`SensingResponse`)를 반환합니다.
> [!NOTE]
@@ -240,7 +240,7 @@ protoc --go_out=. --go-grpc_out=. protoapi.proto
listen, _ := net.Listen("tcp", port)
server.Serve(listen)
```
* **쉬운 설명**: `:8080` 포트로 통하는 소켓(전화선)을 개통하고, 기기들의 전화(접속 및 호출)가 오기를 기다리며 대기 상태로 들어가는 서버 구동 시작점입니다.
* **간단 설명**: `:8080` 포트로 통하는 소켓(전화선)을 개통하고, 기기들의 전화(접속 및 호출)가 오기를 기다리며 대기 상태로 들어가는 서버 구동 시작점입니다.
* **상세 설명**: 지정된 포트(기본 포트 `:8080`)의 TCP 소켓 포트를 활성화하고, 클라이언트의 접속 및 RPC 서비스 호출에 대해 지속적으로 대기하는 리스너 구동의 진입점입니다.
### 5.2 gRPC 클라이언트 구현 분석 ([client.go](../lib/grpc/basic/client.go))
@@ -250,14 +250,14 @@ protoc --go_out=. --go-grpc_out=. protoapi.proto
conn, _ := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
client := protoapi.NewIoTServiceClient(conn)
```
* **쉬운 설명**: 서버 주소로 전화를 거는 통신선(TCP 채널)을 안전 보안(TLS) 없이 개설한 뒤, 이 선을 통해 gRPC 약속 장부(`IoTService`)대로 서버에 원격 호출을 요청할 수 있는 전용 전화기(클라이언트 인스턴스)를 획득하는 과정입니다.
* **간단 설명**: 서버 주소로 전화를 거는 통신선(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` 기능을 직접 원격 실행(호출)하는 부분입니다.
* **상세 설명**: 센서 측정값을 메시지 스펙 규격에 맞추어 `SensingData` 구조체 인스턴스로 바인딩한 후, 기설정된 클라이언트 인터페이스를 경유하여 서버의 `UpdateSensingData` 엔드포인트를 호출합니다.
### 5.3 통신 세션 동작 시퀀스 및 흐름
@@ -315,10 +315,24 @@ message DownloadRequest {
string FileName = 1;
}
```
* **설계 포인트**: `stream` 키워드가 들어간 위치에 주목합니다. `UploadFile`은 입력에 `stream`이 붙어 클라이언트 스트리밍을, `DownloadFile`은 반환(returns)에 `stream`이 붙어 서버 스트리밍 채널을 개설합니다.
* **설계 포인트**: `stream` 키워드가 들어간 위치에 주목합니다.
* **`stream` 키워드의 역할**: gRPC에서 `stream` 키워드는 단발성 요청/응답(Unary RPC) 방식과 달리, **하나의 HTTP/2 커넥션을 유지한 채 데이터를 연속적인 흐름(Stream)으로 쪼개서 전송하겠다**고 선언하는 지시어입니다. 이 키워드가 지정되면 컴파일러(`protoc`)는 데이터를 연속으로 송수신할 수 있는 스트림 파이프라인 형태의 Go 인터페이스와 스터브 코드를 생성합니다.
* **클라이언트 스트리밍 (`UploadFile`)**: 호출 매개변수 정의 앞부분에 `stream`(`stream FileChunk`)이 붙습니다. 클라이언트가 데이터를 여러 번에 걸쳐 청크로 송신하고, 서버는 최종 수신 완료 시점에 단 한 번 응답(`returns (UploadStatus)`)을 반환합니다.
* **서버 스트리밍 (`DownloadFile` 및 `SubscribeAlerts`)**: 반환형(`returns`) 정의의 괄호 내부에 `stream`(`returns (stream FileChunk)`)이 붙습니다. 클라이언트의 단일 호출 요청에 대해, 서버가 데이터를 여러 조각으로 쪼개어 연속적으로 클라이언트에게 푸시 전송합니다.
* **🚨 `stream` 키워드 없이 대용량 데이터를 전송할 때의 한계와 위험성**:
만약 대용량 파일나 수기가바이트(GB)에 달하는 대용량 데이터를 `stream` 키워드 없이 일반 Unary RPC(단발성 요청/응답)로 전송하려고 시도하면 다음과 같은 심각한 기술적 문제가 발생합니다:
1. **메모리 고갈 (OOM - Out Of Memory)**: 전송할 데이터 전체가 직렬화되기 전 단일 바이트 슬라이스(`[]byte`) 형태로 클라이언트와 서버 메모리에 한 번에 적재되어야 합니다. 이는 메모리 리소스가 극도로 제한된 IoT 임베디드 단말기나 동시 요청이 몰리는 서버 환경에서 즉각적인 OOM 에러 및 프로세스 강제 종료를 유발합니다.
2. **gRPC 기본 메시지 수신 한도 초과**: gRPC 엔진은 악의적인 디도스(DDoS) 공격 방지와 메모리 보호를 위해 **단일 RPC 호출당 최대 메시지 수신 크기를 기본 4MB**로 제한하고 있습니다. 따라서 4MB를 넘는 파일 데이터를 Unary로 전송할 경우 즉각 `ResourceExhausted` 에러가 발생하며 통신이 차단됩니다. (설정으로 한도를 늘릴 수 있으나 메모리 병목 우려로 권장되지 않습니다.)
3. **네트워크 유실 시 재전송 오버헤드**: 단 한 번의 네트워크 패킷 전송 오류(Glitch)가 발생해도 전체 대용량 데이터를 처음부터 완전히 다시 보내야 하므로 네트워크 비용 낭비가 심화됩니다. 스트리밍을 사용하면 청크 단위로 분할하여 안정적으로 주고받을 수 있습니다.
### 6.2 서버 저장 및 송수신 구현 ([server.go](../lib/grpc/basic/server.go))
인메모리 파일 저장소(`fileStore`)구현하고 목록 조회(`ListFiles`) 및 서버 다운로드 스트리밍(`DownloadFile`) 핸들러를 정의합니다:
클라이언트가 스트리밍으로 업로드한 파일의 메타데이터를 서버에서 관리하기 위해 인메모리 파일 저장소`fileStore`를 정의합니다. `fileStore`는 파일 이름을 키(Key)로 하고 파일 메타데이터 및 바이트 데이터를 포함하는 `UploadedFile` 구조체 포인터를 값(Value)으로 갖는 맵(`map[string]*UploadedFile`) 구조체입니다.
이 저장소를 활용하여 다음 3가지 핸들러를 구현합니다:
1. **파일 업로드 (`UploadFile` - Client Streaming)**: 클라이언트가 스트리밍을 통해 분할 전송하는 파일 데이터 조각(`FileChunk`)들을 수신해 병합한 후 `fileStore`에 등록합니다.
2. **파일 목록 조회 (`ListFiles` - Unary RPC)**: `fileStore`에 보관된 모든 파일들의 메타데이터(파일명, 크기, 타임스탬프) 목록을 빌드하여 일괄 반환합니다.
3. **파일 다운로드 (`DownloadFile` - Server Streaming)**: 요청된 파일 데이터를 `fileStore`에서 탐색한 후, 1KB 단위의 청크 조각으로 나누어 클라이언트에게 순차적으로 스트리밍 전송합니다.
```go
// 1. 인메모리 파일 보관소
@@ -418,7 +432,7 @@ func (IoTServer) DownloadFile(r *protoapi.DownloadRequest, stream protoapi.IoTSe
return nil
}
```
* **쉬운 설명**:
* **간단 설명**:
* **파일 업로드**: 클라이언트가 쪼개서 던지는 파일 조각 상자(`stream.Recv()`)들을 루프를 돌며 계속 수집하여 하나의 임시 보관 버퍼(`buffer`)에 합칩니다. 마지막 조각 전송 완료(`io.EOF`) 신호가 오면, 모인 바이트들을 파일 이름과 함께 서버의 보관함(`fileStore`)에 안전하게 저장하고 영수증을 클라이언트에게 발행합니다.
* **목록 조회**: 서버의 파일 보관함(`fileStore`)을 열고 그 안에 든 모든 파일의 메타데이터(이름, 크기, 업로드 시각)를 리스트로 포장해 한번에 리턴해 줍니다.
* **파일 다운로드**: 보관함에서 요청받은 파일을 찾은 뒤, 파일 내용 전체를 1KB 크기의 패킷 조각들로 잘라 통로를 타고 차례대로 연속 전송(`stream.Send()`)해 줍니다.
@@ -485,22 +499,23 @@ func AskDownloadFile(ctx context.Context, m protoapi.IoTServiceClient, fileName
return buffer, nil
}
```
* **쉬운 설명**:
* **간단 설명**:
* **파일 업로드**: 보낼 파일을 1KB 조각 크기로 나누어 준비한 뒤, gRPC 전용 파이프 스트림 통로에 차례대로 흘려보냅니다(`stream.Send()`). 모든 조각을 던진 후 채널을 끊고 영수증(`UploadStatus`)을 받습니다.
* **목록 조회**: 서버에게 "보관 중인 파일 이름 목록을 달라"고 요구하여 화면에 출력합니다.
* **파일 다운로드**: 다운로드 통로를 열어 서버가 던져주는 조각들을 계속 수령(`stream.Recv()`)하여 버퍼에 차곡차곡 합칩니다. 서버가 보내기를 끝마치면(`io.EOF`) 조립을 중단하고 최종 완성된 온전한 바이트 파일을 최종 사용처에 반환합니다.
* **상세 설명**:
* **파일 업로드**: `UploadFile` 채널을 기동하여 클라이언트 사이드 스트림 핸들을 획득합니다. 슬라이스 윈도우 방식으로 데이터를 순차 분할 송출하고, `CloseAndRecv` 메서드를 최종 호출해 스트림 종결 프레임을 송신한 뒤 서버의 단발성 최종 회신 상태를 획득합니다.
* **목록 조회**: 빈 메시지(`EmptyRequest`)를 동봉해 Unary RPC 채널을 트리거하고 메타데이터 배열 결과를 동기 획득합니다.
* **`CloseAndRecv()`의 역할**: 클라이언트에서 송신 스트림을 닫는(Half-close) 동시에, 서버가 전송 완료 후 최종 반환하는 응답 영수증(`UploadStatus`)을 수신할 때까지 블로킹 대기(Blocking wait)하여 최종 응답과 에러 객체를 받아오는 복합 기능을 수행합니다.
* **목록 조회**: 빈 메시지(`EmptyRequest`)를 동봉해 Unary RPC 채널을 트리거하고 메타데이터 배열 결과를 동기적으로 (Synchronously) 획득합니다.
* **파일 다운로드**: 서버 스트리밍 엔드포인트 기동 후, `stream.Recv()` 블로킹 수신 루프에 진입합니다. 채널 해제 지점(`io.EOF`)에 도달할 때까지 메모리 버퍼 슬라이스에 청크 바이트 배열을 병합 누적하여 재조립(Reassembly)을 마친 후 반환합니다.
### 6.4 실시간 알림을 위한 Pub/Sub (발행/구독) 브로드캐스팅 구현
## 7. 실시간 알림을 위한 Pub/Sub (발행/구독) 브로드캐스팅 구현
쉽게 말해 Pub/Sub은 '신문 구독'과 같습니다. 구독자(클라이언트)가 한 번 신청해 두면, 발행자(서버)는 새로운 소식(경보)이 생길 때마다 모든 구독자에게 알아서 배달해 줍니다. 클라이언트가 매번 '무슨 일 없어요?'라고 다시 물어볼 필요가 없다는 것이 핵심입니다.
스마트 가전이나 센서 등 실시간 경보 통지가 필요한 AIoT 도메인에서는, 서버가 상시 대기하는 여러 디바이스(클라이언트)들에게 비동기로 이벤트를 밀어 넣어주는 **발행/구독(Publish/Subscribe)** 연동 구조가 필수적입니다. gRPC의 **서버 스트리밍(Server Streaming)** 채널을 응용하면, 다수의 클라이언트가 스트림 통로를 상시 유지한 채 대기하고, 서버가 특정 이벤트 발생 시 채널 리스트를 순회하며 실시간 이벤트를 **브로드캐스팅(Broadcasting)**하는 Pub/Sub 인프라를 단순하고 가볍게 완성할 수 있습니다.
#### 1. 스키마 설계 (`protoapi.proto`)
### 7.1 스키마 설계 (protoapi.proto)
구독 신청을 위한 파라미터(`AlertSubscription`)와 서버가 밀어 넣어줄 이벤트 규격(`AlertMessage`)을 IDL에 선언합니다:
```proto
message AlertSubscription {
@@ -516,7 +531,7 @@ message AlertMessage {
}
```
#### 2. 서버 사이드 구독자 관리 및 발행 구현 ([server.go](../lib/grpc/basic/server.go))
### 7.2 서버 사이드 구독자 관리 및 발행 구현 ([server.go](../lib/grpc/basic/server.go))
서버는 구독을 신청한 클라이언트들에게 메시지를 안전하게 분배하기 위해 스레드 세이프 맵과 고루틴 채널(`chan`) 구조를 구성합니다:
```go
@@ -579,14 +594,14 @@ func (IoTServer) SubscribeAlerts(r *protoapi.AlertSubscription, stream protoapi.
}
}
```
* **쉬운 설명**:
* **간단 설명**:
* **구독 신청**: 클라이언트가 전화를 걸면 서버는 그 선을 닫지 않고 메모장(`subscribers`)에 해당 전화번호와 연결된 통로(Go 채널)를 적어둡니다. 그리고 그 선을 계속 붙잡고 대기(`stream.Send`) 상태를 유지합니다.
* **경보 발행**: 센서값 수신 핸들러(`UpdateSensingData`)에서 임계치(40도)를 초과하는 위험 열기가 감지되면, 메모장에 적힌 모든 연결된 통로에 경보 엽서(`AlertMessage`)를 휙 던져(Broadcast) 줍니다.
* **상세 설명**:
* **구독 신청**: `SubscribeAlerts` 엔드포인트는 호출과 동시에 전용 Go 비동기 버퍼 채널을 생성하고 전역 가입 맵에 등록합니다. `stream.Context().Done()` 채널 수신이나 스트림 유실 이벤트가 포착되기 전까지 루프 대기 상태를 안전하게 고정합니다.
* **경보 발행**: 동시성 경쟁 방지 락(`subMu.Lock()`) 임계 구역 내에서 연결된 모든 채널에 데이터를 `select-default` 논블로킹 패턴으로 분배 기입하여, 특정 클라이언트의 수신 병목이 서버 전체 성능에 미치는 파급 효과를 예방합니다.
#### 3. 클라이언트 비동기 청취 구현 ([client.go](../lib/grpc/basic/client.go))
### 7.3 클라이언트 비동기 청취 구현 ([client.go](../lib/grpc/basic/client.go))
클라이언트는 메인 흐름을 방해하지 않고 알림을 백그라운드에서 실시간으로 대기 청취할 수 있도록 별도의 독자적인 비동기 고루틴 구조로 가동합니다.
```go
@@ -614,22 +629,20 @@ func AskSubscribeAlerts(ctx context.Context, m protoapi.IoTServiceClient, client
}
}
```
* **쉬운 설명**: 클라이언트는 메인 로직이 다른 볼일(파일 업로드/다운로드 등)을 보러 간 동안, 옆방에서 전화를 붙잡고 계속 귀를 기울이는 전담 직원(비동기 고루틴)을 기동시킵니다. 서버에서 "벨(알림)"이 울릴 때마다 그 내용을 즉시 가로채 화면에 실시간 경보 창을 출력해 줍니다.
* **간단 설명**: 클라이언트는 메인 로직이 다른 볼일(파일 업로드/다운로드 등)을 보러 간 동안, 옆방에서 전화를 붙잡고 계속 귀를 기울이는 전담 직원(비동기 고루틴)을 기동시킵니다. 서버에서 "벨(알림)"이 울릴 때마다 그 내용을 즉시 가로채 화면에 실시간 경보 창을 출력해 줍니다.
* **상세 설명**: 메인 쓰레드의 블로킹을 방지하기 위해 Go의 경량 쓰레드 고루틴(`go AskSubscribeAlerts`)으로 리스너 루프를 위임 기동합니다. gRPC 스트림 클라이언트의 `stream.Recv()` 메서드는 서버로부터 메시지가 전달될 때까지 스레드 리소스를 낭비하지 않는 대기 상태로 머물며, 데이터 수령 시 콘솔 스트림에 이를 비동기 매핑 출력합니다.
---
## 7. 트러블슈팅 (Troubleshooting)
## 8. 트러블슈팅 (Troubleshooting)
실습 구동 과정에서 마주할 수 있는 전형적인 에러 현상과 대처 방안입니다.
### 7.1 `bind: address already in use` (네트워크 소켓 포트 충돌)
### 8.1 bind: address already in use (네트워크 소켓 포트 충돌)
* **발생 원인**: gRPC 서버 기동 시 설정한 통신 포트 `:8080`이 이미 다른 네트워크 프로세스나 이전 실습 서버의 비정상 종료 등으로 인해 점유되어 바인딩에 실패한 상태입니다.
* **조치 방법**:
- `lib/main.go`의 `port` 변수 값을 다른 유휴 포트(예: `:9090`)로 변경한 뒤 다시 실행하십시오. `ServerRun(addr)` 함수가 이 값을 메인 진입점으로부터 인자로 전달받아 TCP 리스너를 생성하므로, `main.go` 한 곳만 수정하면 서버와 클라이언트의 통신 포트가 동시에 성공적으로 변경됩니다.
---
## 8. 참고 자료
## 9. 참고 자료
* [gRPC와 REST의 차이점 (AWS)](https://aws.amazon.com/ko/compare/the-difference-between-grpc-and-rest/): 두 방식의 특징과 언제 어떤 기술을 선택해야 하는지 친절하게 정리된 공식 블로그 자료입니다.