gRPC를 웹 브라우저에서 쓰기
모바일 전용 gRPC 서버에 웹 프론트엔드를 추가하면서 겪은 실전 경험을 공유합니다.
1. 왜 이 글을 쓰게 되었나
우리 팀은 O2O 서비스 플랫폼을 운영하고 있습니다. 백엔드는 Kotlin gRPC 서버, 클라이언트는 Flutter 모바일 앱이었습니다. Flutter는 네이티브 gRPC를 완벽하게 지원하니까, 아무 문제 없이 잘 동작했습니다.
그런데 어느 날, 서비스 제공자를 위한 웹 대시보드가 필요해졌습니다.
브라우저는 네이티브 gRPC를 사용할 수 없습니다.
선택지는 두 가지였습니다:
- REST API를 새로 만든다 — gRPC 서비스 10개 이상, RPC 메서드 100개 이상을 REST로 다시 구현?
- 기존 gRPC 서버를 웹에서도 쓸 수 있게 만든다 — Envoy 프록시로 프로토콜만 번역하면?
당연히 2번이었습니다. 기존 서버 코드를 한 줄도 수정하지 않고, Envoy 설정 파일 하나로 웹 지원을 추가했습니다.
2. 브라우저가 gRPC를 못 쓰는 진짜 이유
"HTTP/2 지원하면 되는 거 아닌가?"라고 생각할 수 있습니다. 실제로 최신 브라우저는 HTTP/2를 사용합니다. 하지만 문제는 지원 여부가 아니라 제어 수준입니다.
graph TB
subgraph "네이티브 gRPC가 필요한 것"
A[HTTP/2 Trailers 접근] --> X[grpc-status, grpc-message 읽기]
B[Binary Frame 직접 제어] --> Y[Protobuf 바이너리 전송]
C[Bidirectional Streaming] --> Z[실시간 양방향 통신]
end
subgraph "브라우저가 제공하는 것"
D[Fetch API] --> |"Trailers 접근 불가"| A
E[XHR] --> |"HTTP/2 프레임 제어 불가"| B
F[Streams API] --> |"아직 불완전 구현"| C
end
핵심 문제는 HTTP/2 Trailers입니다. gRPC는 RPC 상태 코드(grpc-status, grpc-message)를 HTTP/2 trailing headers로 전송합니다. 그런데 브라우저의 Fetch API는 response trailers를 읽을 수 없습니다.
공식 gRPC 스펙에서도 이렇게 말합니다:
*"It is currently impossible to implement the HTTP/2 gRPC spec in the browser, as there is simply no browser API with enough fine-grained control over the requests."*
gRPC-Web 프로토콜은 이 문제를 우회합니다. Trailers를 response body의 마지막 프레임으로 인코딩하여, HTTP/1.1에서도 gRPC 통신이 가능하게 만듭니다.
| 항목 | gRPC (HTTP/2) | gRPC-Web |
|---|---|---|
| Transport | HTTP/2 전용 | HTTP/1.1 또는 HTTP/2 |
| Trailers | HTTP/2 trailing headers | Response body 마지막 프레임 |
| Content-Type | application/grpc+proto |
application/grpc-web-text+proto |
| Server Streaming | 지원 | 지원 (grpcwebtext 모드) |
| Bidirectional Streaming | 지원 | 미지원 |
3. 전체 아키텍처
모바일 앱은 gRPC 서버와 직접 통신하고, 웹 브라우저는 Envoy를 경유합니다. 서버는 동일하고, 접근 경로만 다릅니다.
graph LR
subgraph "Clients"
M[Flutter 모바일 앱]
W[Next.js 웹 대시보드]
end
subgraph "Proxy Layer"
E[Envoy Proxy<br/>Port 8080]
end
subgraph "Backend"
S[Kotlin gRPC Server<br/>Port 50051]
DB[(Database)]
end
M -->|"HTTP/2<br/>Native gRPC"| S
W -->|"HTTP/1.1<br/>gRPC-Web"| E
E -->|"HTTP/2<br/>Native gRPC"| S
S --> DB
세 가지 레이어가 각각의 역할을 합니다:
| 레이어 | 기술 | 역할 |
|---|---|---|
| Mobile | Flutter + gRPC | 네이티브 gRPC 직접 통신 |
| Web | Next.js + grpc-web | gRPC-Web 프로토콜로 Envoy에 요청 |
| Proxy | Envoy | gRPC-Web ↔ gRPC 프로토콜 변환, CORS 처리 |
| Backend | Kotlin + gRPC (port 50051) | 비즈니스 로직, 인터셉터 체인 |
핵심은 서버가 gRPC-Web의 존재를 모른다는 것입니다. Envoy가 프로토콜을 번역해주기 때문에, 서버 입장에서는 모바일이든 웹이든 동일한 gRPC 요청으로 보입니다.
4. Backend: Kotlin gRPC 서버
Proto 파일로 서비스 정의
모든 것은 .proto 파일에서 시작됩니다. 이 하나의 정의로 서버(Kotlin), 웹(TypeScript), 앱(Dart) 코드가 자동 생성됩니다.
// user_api.proto
syntax = "proto3";
package com.example.grpc;
service UserAPI {
rpc CreateAccount(AccountInfo) returns (CreateAccountResponse);
rpc GetAccountByUid(GetAccountByUidRequest) returns (GetAccountResponse);
rpc UpdateAccount(AccountInfo) returns (EmptyReply);
rpc GetContactByPhone(GetContactByPhoneRequest) returns (GetContactResponse);
// ...
}
// task_service.proto — 작업 관리
service TaskService {
// 고객 관리
rpc GetCustomers(GetCustomersRequest) returns (GetCustomersResponse);
rpc CreateCustomer(CreateCustomerRequest) returns (CreateCustomerResponse);
// 작업(Task) 관리
rpc GetTasks(GetTasksRequest) returns (GetTasksResponse);
rpc CreateTask(CreateTaskRequest) returns (CreateTaskResponse);
rpc UpdateTask(UpdateTaskRequest) returns (UpdateTaskResponse);
// 배치 작업
rpc BatchCreateTasks(BatchCreateTasksRequest) returns (BatchCreateTasksResponse);
// ...
}
gRPC 서버 시작
서버 시작 코드에서 여러 서비스를 등록하고, 인터셉터 체인을 연결합니다.
val apiServer = ServerBuilder.forPort(50051)
.executor(grpcExecutor)
.addService(UserAPIService())
.addService(TaskService())
.addService(CommonAPIService())
.addService(ChatAPIService())
.addService(NotificationService())
// ... 기타 서비스
// Interceptor chain (실행 순서: Auth → IP → Cache → Metrics)
.intercept(MetricsInterceptor()) // 메트릭 수집
.intercept(CacheInterceptor()) // 응답 캐싱
.intercept(IpAddressInterceptor()) // 클라이언트 IP 추출
.intercept(AuthInterceptor()) // 토큰 인증
.build()
.start()
logger.info("Server started, listening on 50051")
인터셉터 체인은 gRPC의 미들웨어 패턴입니다. 모든 요청이 이 체인을 통과합니다.
graph LR
A[Client Request] --> B[AuthInterceptor<br/>토큰 검증]
B --> C[IpAddressInterceptor<br/>IP 추출]
C --> D[CacheInterceptor<br/>캐시 확인]
D --> E[MetricsInterceptor<br/>메트릭 수집]
E --> F[Service Handler<br/>비즈니스 로직]
F --> G[Response]
Note: gRPC 인터셉터는
addService이후에intercept하므로, 코드에서 마지막에 선언된AuthInterceptor가 실제로는 가장 먼저 실행됩니다.
5. The Bridge: Envoy 프록시 설정
Envoy는 브라우저와 gRPC 서버 사이의 프로토콜 번역기입니다. 대부분의 HTTP 프록시(nginx 포함)는 HTTP/2 trailers를 올바르게 처리하지 못하지만, Envoy는 trailers를 first-class로 지원하는 몇 안 되는 프록시입니다.
Envoy 필터 체인
graph TB
subgraph "Envoy Proxy (Port 8080)"
direction TB
A[HTTP Connection Manager] --> B[CORS Filter<br/>Origin 검증, Preflight 처리]
B --> C[gRPC-Web Filter<br/>프로토콜 변환]
C --> D[Router Filter<br/>업스트림 라우팅]
end
E[Browser<br/>HTTP/1.1 gRPC-Web] --> A
D --> F[Kotlin gRPC Server<br/>HTTP/2 Native gRPC]
subgraph "응답 경로"
direction BT
G[gRPC Response] --> H[gRPC-Web Filter<br/>gRPC → gRPC-Web 변환]
H --> I[CORS Filter<br/>응답 헤더 추가]
I --> J[Browser]
end
Envoy 설정 파일
핵심만 추린 envoy.yaml입니다. 실제로 이 정도면 동작합니다.
admin:
address:
socket_address: { address: 0.0.0.0, port_value: 9901 }
static_resources:
listeners:
- name: listener_0
address:
socket_address: { address: 0.0.0.0, port_value: 8080 }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
codec_type: auto
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
cluster: grpc_service
timeout: 0s
max_stream_duration:
grpc_timeout_header_max: 0s
cors:
allow_origin_string_match:
- prefix: "*"
allow_methods: GET, PUT, DELETE, POST, OPTIONS
allow_headers: >-
keep-alive,user-agent,cache-control,content-type,
content-transfer-encoding,x-grpc-web,grpc-timeout,
authorization
max_age: "1728000"
expose_headers: grpc-status,grpc-message
http_filters:
- name: envoy.filters.http.grpc_web
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
- name: envoy.filters.http.cors
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: grpc_service
connect_timeout: 0.25s
type: logical_dns
http2_protocol_options: {} # 핵심: 백엔드는 HTTP/2 강제
lb_policy: round_robin
load_assignment:
cluster_name: grpc_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: localhost
port_value: 50051
설정에서 주의할 포인트
1. http2_protocol_options: {} — 이 한 줄이 없으면 Envoy가 백엔드에 HTTP/1.1로 요청을 보내고, gRPC 서버는 이를 이해하지 못합니다.
2. expose_headers: grpc-status,grpc-message — 브라우저는 기본적으로 커스텀 응답 헤더를 JavaScript에 노출하지 않습니다. 이 설정이 없으면 클라이언트가 gRPC 에러 코드를 읽지 못합니다.
3. allow_headers에 x-grpc-web과 authorization — gRPC-Web 클라이언트가 보내는 필수 헤더입니다. 여기서 빠지면 CORS preflight가 실패합니다.
6. Frontend: Next.js gRPC-Web 클라이언트
Proto 컴파일
서버의 .proto 파일에서 TypeScript 클라이언트 코드를 자동 생성합니다.
#!/bin/bash
# scripts/compile_proto.sh
PROTO_DIR="../server/src/main/proto"
OUT_DIR="src/generated"
PROTO_FILES=(
"common.proto"
"user_api.proto"
"task_service.proto"
# ... 기타 proto 파일
)
for proto_file in "${PROTO_FILES[@]}"; do
protoc \
--proto_path="$PROTO_DIR" \
--js_out=import_style=commonjs,binary:"$OUT_DIR" \
--grpc-web_out=import_style=typescript,mode=grpcwebtext:"$OUT_DIR" \
"$PROTO_DIR/$proto_file"
done
이 스크립트가 생성하는 파일은 세 종류입니다:
| 파일 패턴 | 역할 |
|---|---|
*_pb.js |
Protobuf 메시지 직렬화/역직렬화 |
*_pb.d.ts |
TypeScript 타입 정의 |
*ServiceClientPb.ts |
gRPC-Web 서비스 클라이언트 스텁 |
gRPC-Web 클라이언트 초기화
// src/services/api.ts
import { UserAPIClient } from "@/generated/User_apiServiceClientPb";
import { TaskServiceClient } from "@/generated/Task_serviceServiceClientPb";
import { config } from "@/config/env";
class ApiService {
private userClient: UserAPIClient;
private taskClient: TaskServiceClient;
constructor(serverUrl: string = config.grpcServerUrl) {
// serverUrl = Envoy 프록시 주소 (e.g., http://localhost:8080)
this.userClient = new UserAPIClient(serverUrl);
this.taskClient = new TaskServiceClient(serverUrl);
}
}
export const api = new ApiService();
config.grpcServerUrl은 환경변수 NEXT_PUBLIC_GRPC_SERVER_URL에서 읽어옵니다. 이 값이 가리키는 곳은 gRPC 서버가 아니라 Envoy 프록시입니다.
실제 API 호출 예시
async getAccountByUid(uid: string): Promise<AccountInfo | null> {
try {
// 1. Request 메시지 생성 (Proto 기반 타입 안전)
const request = new GetAccountByUidRequest();
request.setUid(uid);
// 2. 인증 메타데이터 가져오기
const metadata = await this.getAuthMetadata();
// 3. gRPC-Web 호출 (Envoy를 통해 백엔드에 도달)
const response = await this.userClient.getAccountByUid(
request, metadata
);
// 4. 응답 매핑
const account = response.getAccount();
if (!account) return null;
return {
id: account.getId(),
uid: account.getUid(),
fullName: account.getFullName(),
email: account.getEmail(),
phoneNumber: account.getPhoneNumber(),
isActive: account.getIsActive(),
};
} catch (error) {
console.error("gRPC getAccountByUid failed:", error);
return null;
}
}
인증 토큰을 gRPC Metadata로 전달
모든 gRPC 요청에 인증 토큰을 첨부합니다. 어떤 인증 방식이든(Firebase, JWT, OAuth 등) 동일한 패턴입니다.
// src/lib/grpcMetadata.ts
import * as grpcWeb from "grpc-web";
export async function getGrpcMetadata(): Promise<grpcWeb.Metadata> {
const metadata: grpcWeb.Metadata = {};
try {
const token = await getAuthToken(); // 프로젝트의 인증 방식에 맞게
if (token) {
metadata["authorization"] = `Bearer ${token}`;
}
} catch (error) {
console.error("Error getting auth token for gRPC:", error);
}
return metadata;
}
이 토큰은 Envoy를 그대로 통과하여(pass-through) 서버의 AuthInterceptor에서 검증됩니다. Envoy는 인증에 관여하지 않습니다.
7. 환경별 배포 전략
Local, Dev, Production 세 가지 환경에서 각각 다른 Envoy 설정을 사용합니다. 서버 코드는 동일하고, Envoy 설정 파일만 다릅니다.
graph TB
subgraph "Local Development"
LA[Next.js<br/>localhost:3000] -->|"http://localhost:8080"| LB[Envoy Native<br/>Port 8080]
LB -->|"localhost:50051"| LC[gRPC Server<br/>IDE에서 실행]
end
subgraph "Dev Environment"
DA[Next.js<br/>dev.example.com] -->|"https://envoy.dev.example.com"| DB[Envoy Sidecar<br/>Container]
DB -->|"grpc-server:50051"| DC[gRPC Server<br/>Container]
end
subgraph "Production"
PA[Next.js<br/>app.example.com] -->|"https://envoy.example.com"| PB[Envoy Sidecar<br/>Container]
PB -->|"grpc-server:50051"| PC[gRPC Server<br/>Container]
end
환경별 핵심 차이
| 항목 | Local | Dev | Production |
|---|---|---|---|
| Envoy 실행 | 네이티브 바이너리 | 사이드카 컨테이너 | 사이드카 컨테이너 |
| CORS Origin | .* (모두 허용) |
특정 dev 도메인 | 프로덕션 도메인만 |
| Health Check | 없음 | gRPC Health Check | gRPC Health Check |
| Timeout | 무제한 | 300s | 300s |
로컬 개발 시작
# 1. Envoy 프록시 시작
envoy -c envoy.yaml --log-level info &
# 2. gRPC 서버 시작 (IDE에서 Main.kt 실행 또는)
./gradlew run
# 3. Next.js 개발 서버 시작
cd web-dashboard && npm run dev
# → NEXT_PUBLIC_GRPC_SERVER_URL=http://localhost:8080
프로덕션 CORS: 보안을 위한 엄격한 설정
# envoy_prod.yaml (프로덕션)
cors:
allow_origin_string_match:
- exact: "https://app.example.com"
- exact: "https://www.example.com"
- safe_regex:
google_re2: {}
regex: "^https://.*\\.your-cdn\\.com$" # CDN/프리뷰 배포
allow_credentials: true # Authorization 헤더 사용시 필수
프로덕션에서는 와일드카드(*)를 절대 사용하지 않습니다. allow_credentials: true와 와일드카드는 함께 쓸 수 없습니다(브라우저가 거부합니다).
8. 요청의 여정: 전체 Request Lifecycle
사용자가 웹 대시보드에서 자신의 계정 정보를 조회할 때 일어나는 일을 추적해 봅시다.
sequenceDiagram
participant B as Browser (Next.js)
participant G as grpc-web Library
participant E as Envoy Proxy (8080)
participant K as gRPC Server (50051)
participant DB as Database
B->>G: api.getAccountByUid("uid_123")
Note over G: GetAccountByUidRequest 생성<br/>Protobuf 바이너리 직렬화
G->>G: Auth Token 획득
Note over G: metadata["authorization"]<br/>= "Bearer eyJ..."
G->>E: POST /com.example.grpc.UserAPI/GetAccountByUid<br/>Content-Type: application/grpc-web-text+proto<br/>X-Grpc-Web: 1<br/>Authorization: Bearer eyJ...
Note over E: CORS Filter<br/>→ Origin 검증 통과
Note over E: gRPC-Web Filter<br/>→ Content-Type 변환<br/>→ Base64 디코딩<br/>→ HTTP/1.1 → HTTP/2
E->>K: HTTP/2 gRPC 요청<br/>Content-Type: application/grpc+proto
Note over K: AuthInterceptor<br/>→ Token 검증
Note over K: CacheInterceptor<br/>→ 캐시 미스
K->>DB: SELECT * FROM accounts<br/>WHERE uid = 'uid_123'
DB-->>K: Account Data
K-->>E: gRPC Response + Trailers<br/>grpc-status: 0 (OK)
Note over E: gRPC-Web Filter<br/>→ Trailers를 Body 프레임으로<br/>→ HTTP/2 → HTTP/1.1
E-->>G: HTTP/1.1 gRPC-Web Response<br/>Content-Type: application/grpc-web-text+proto
Note over G: Protobuf 역직렬화<br/>→ GetAccountResponse
G-->>B: AccountInfo 객체 반환
9. 삽질 기록과 Tips
CORS 설정이 가장 흔한 문제
처음 셋업할 때 가장 많이 막히는 부분이 CORS입니다.
증상: 브라우저 콘솔에 CORS policy 에러가 나오고 gRPC 호출이 실패함
체크리스트:
allow_headers에x-grpc-web,authorization,content-type이 있는가?expose_headers에grpc-status,grpc-message가 있는가?allow_credentials: true일 때 origin에 와일드카드(*)를 쓰진 않았는가?
Envoy Admin UI로 디버깅
Envoy는 기본으로 9901 포트에 Admin UI를 제공합니다.
http://localhost:9901 → 대시보드
http://localhost:9901/clusters → 업스트림 클러스터 상태
http://localhost:9901/stats → 요청 통계
http://localhost:9901/config_dump → 현재 실행 설정 전체
문제가 생기면 /clusters에서 백엔드 연결 상태를 먼저 확인하세요.
간단한 연결 테스트
Envoy 설정을 바꾸고 나서 프론트엔드를 띄우기 전에, fetch로 빠르게 확인할 수 있습니다.
// 1. gRPC-Web 연결 테스트
fetch('http://localhost:8080/', {
method: 'POST',
headers: {
'Content-Type': 'application/grpc-web+proto',
'X-Grpc-Web': '1'
},
body: new Uint8Array([0, 0, 0, 0, 0])
});
// 200 또는 404 → Envoy 정상
// Connection refused → Envoy 미실행
// 2. CORS Preflight 테스트
fetch('http://localhost:8080/', {
method: 'OPTIONS',
headers: {
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'content-type,x-grpc-web'
}
});
// 200 → CORS 정상
Proto 변경 시 양쪽 재컴파일 필수
Proto 파일을 수정하면 서버와 클라이언트 모두 재컴파일해야 합니다. 한쪽만 하면 직렬화 불일치로 런타임 에러가 발생합니다.
# 서버 (Gradle이 자동 처리)
./gradlew build
# 웹 클라이언트 (수동 실행 필요)
./scripts/compile_proto.sh
gRPC-Web은 Server Streaming만 지원
Bidirectional streaming이 필요한 경우(예: 실시간 채팅)는 gRPC-Web으로는 불가합니다. 우리 팀은 채팅 같은 실시간 기능은 모바일 앱에서만 네이티브 gRPC streaming으로 제공하고, 웹에서는 polling 또는 WebSocket을 별도로 사용합니다.
10. 마치며
gRPC-Web + Envoy 조합으로 얻은 것들을 정리하면:
왜 REST 대신 gRPC-Web이었나: 이미 모바일 앱을 위한 gRPC 서비스가 10개 이상, RPC 메서드 100개 이상 운영 중이었습니다. 이걸 REST로 다시 만드는 것은 비용도 크고 두 벌의 API를 유지보수해야 하는 부담이 생깁니다. Envoy 설정 파일 하나(약 60줄)를 추가하는 것만으로 서버 코드 수정 없이 웹 지원이 가능했습니다.
Single Source of Truth: Proto 파일 하나로 서버(Kotlin), 웹(TypeScript), 앱(Dart) 세 플랫폼의 API 코드를 자동 생성합니다. REST API에서 흔한 "서버와 클라이언트의 스펙 불일치" 문제가 구조적으로 불가능합니다.
바이너리 효율성: JSON 대비 Protobuf는 페이로드 크기가 작고 직렬화/역직렬화가 빠릅니다.
물론 트레이드오프도 있습니다. Envoy라는 추가 인프라를 관리해야 하고, Bidirectional streaming을 쓸 수 없으며, Proto 컴파일이라는 빌드 단계가 추가됩니다. 하지만 이미 gRPC 서버가 있는 상황에서 웹 프론트엔드를 추가할 때, REST API를 새로 만드는 것보다 Envoy 설정 파일 하나를 추가하는 것이 훨씬 빠르고 안전한 선택이었습니다.
참고 링크