Frontend

gRPC를 웹 브라우저에서 쓰기

GODOLs 2026. 2. 27. 12:00

모바일 전용 gRPC 서버에 웹 프론트엔드를 추가하면서 겪은 실전 경험을 공유합니다.


1. 왜 이 글을 쓰게 되었나

우리 팀은 O2O 서비스 플랫폼을 운영하고 있습니다. 백엔드는 Kotlin gRPC 서버, 클라이언트는 Flutter 모바일 앱이었습니다. Flutter는 네이티브 gRPC를 완벽하게 지원하니까, 아무 문제 없이 잘 동작했습니다.

그런데 어느 날, 서비스 제공자를 위한 웹 대시보드가 필요해졌습니다.

브라우저는 네이티브 gRPC를 사용할 수 없습니다.

선택지는 두 가지였습니다:

  1. REST API를 새로 만든다 — gRPC 서비스 10개 이상, RPC 메서드 100개 이상을 REST로 다시 구현?
  2. 기존 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_headersx-grpc-webauthorization — 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_headersx-grpc-web, authorization, content-type이 있는가?
  • expose_headersgrpc-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 설정 파일 하나를 추가하는 것이 훨씬 빠르고 안전한 선택이었습니다.


참고 링크

반응형