본문 바로가기
Frontend

gRPC를 웹 브라우저에서 쓰기

by GODOLs 2026. 2. 27.

목차

    모바일 전용 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 설정 파일 하나를 추가하는 것이 훨씬 빠르고 안전한 선택이었습니다.


    참고 링크

    반응형