1. gRPC-Web 서비스 소개

gRPC-Web이란?

gRPC-Web은 웹 클라이언트에서 gRPC 서비스를 직접 호출할 수 있게 해주는 JavaScript 기반 라이브러리입니다.

이를 통해 브라우저에서 바로 gRPC 요청이 가능해져 웹 개발에서 통신을 단순화하고, 서버 리소스를 효율적으로 사용할 수 있습니다.

gRPC-Web의 주요 특징

  • 직접적인 gRPC 서비스 호출: 브라우저에서 바로 gRPC 서버를 호출할 수 있습니다.
  • 브라우저 호환성: 대부분의 현대 브라우저에서 작동합니다.
  • 프로토콜 버퍼 사용: 효율적인 데이터 직렬화를 위해 Protocol Buffers를 사용합니다.
  • HTTP/2 기반의 효율적인 통신: 기반 프로토콜로 HTTP/2를 활용하여 성능을 향상시킵니다.

아키텍처 개요

┌─────────────┐      HTTP/1.1      ┌─────────────┐      HTTP/2      ┌─────────────┐
│  브라우저   │ ──────────────────▶│  Proxy 서버 │ ───────────────▶│  gRPC 서버  │
│ (gRPC-Web)  │ ◀──────────────── │  (Envoy)    │ ◀────────────── │             │
└─────────────┘                    └─────────────┘                  └─────────────┘

gRPC-Web 구현 과정

1. Protocol Buffer (.proto) 정의

syntax = "proto3";
package helloworld;

message HelloRequest {
  string name = 1;
}

message HelloResponse {
  string message = 1;
}

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloResponse);
}

2. .proto 파일 컴파일 (클라이언트용)

protoc -I=$DIR helloworld.proto \
  --js_out=import_style=commonjs:$OUT_DIR \
  --grpc-web_out=import_style=commonjs,mode=grpcwebtext:$OUT_DIR

컴파일 모드 옵션:

  • mode=grpcwebtext:
    • Content-type: application/grpc-web-text
    • 페이로드는 base64로 인코딩
    • 단방향, 스트리밍 모두 지원
  • mode=grpcweb:
    • Content-type: application/grpc-web+proto
    • 페이로드는 바이너리 protobuf 형식
    • 단방향 호출만 지원

3. JavaScript 클라이언트 구현

var helloService = new proto.helloworld.GreeterServiceClient("http://localhost:8080");
var request = new proto.helloworld.HelloRequest();
request.setMessage(msg);
var metadata = {"custom-header-1": "value1"};

helloService.SayHello(request, metadata, function(err, response) {
  if (err) {
    console.log(err.code);
    console.log(err.message);
  } else {
    console.log(response.getMessage());
  }
});

4. .proto 파일 컴파일 (서버용)

protoc --java_out=generated src/main/proto/helloworld.proto

5. Java 서버 구현

public class HelloWorldServer {
  public static void main(String[] args) throws Exception {
    Server server = ServerBuilder.forPort(8080)
      .addService(new GreeterImpl())
      .build();
    server.start();
    server.awaitTermination();
  }

  static class GreeterImpl extends GreeterGrpc.GreeterImplBase {
    @Override
    public void sayHello(HelloRequest request, StreamObserver<HelloResponse> responseObserver) {
      String name = request.getName();
      HelloResponse response = HelloResponse.newBuilder()
        .setMessage("Hello, " + name + "!")
        .build();
      responseObserver.onNext(response);
      responseObserver.onCompleted();
    }
  }
}

6. Proxy 서버 설정 (Envoy)

docker-compose up -d node-server envoy commonjs-client

Envoy Proxy를 사용하는 이유

gRPC-Web 클라이언트가 보낸 HTTP/1.1 요청을 gRPC 서버가 이해할 수 있는 HTTP/2 gRPC 요청으로 변환합니다.

일반적인 Java gRPC 서버는 gRPC-Web 프로토콜을 직접 지원하지 않기 때문에, Envoy 프록시를 사용하거나 Armeria(JVM) 기반의 서버를 사용하는 것이 필요합니다.

gRPC-Web의 장점과 한계

장점

  • 브라우저 레벨에서 gRPC, Protocol Buffers를 사용 가능
  • 브라우저에서 바로 gRPC 서버 호출 가능
  • Protocol Buffers를 통한 파라미터 규약 사용 가능

한계

  • gRPC-Web 지원이 되지 않는 서버에서는 추가 Proxy 서버 구축 필요

gRPC 사용 사례

  • 마이크로서비스 간 통신
  • 클라우드 기반 애플리케이션
  • 멀티 플랫폼 서비스(웹, 모바일 등)
  • 실시간 데이터 처리 및 스트리밍
  • 처리 시간이 오래 걸리거나, 외부 리소스 응답을 기다려야 하는 비동기 작업
  • 보안이 중요한 시스템 (여러 공격 유형에 대응하는 적절한 보안조치와 정기적인 보안검사)

2. RESTful API와 gRPC 간의 변환 방법

gRPC를 RESTful API로 변환하는 장점

  • 기존 REST 클라이언트 지원
  • 플랫폼 및 언어 독립성
  • 방화벽 및 인터넷 친화적
  • 간단한 디버깅과 모니터링

gRPC-to-REST 변환 방법

1. google.api.http 옵션 사용

Google에서 제공하는 REST API 자동 변환 도구를 활용합니다.

syntax = "proto3";
import "google/api/annotations.proto";

service MyService {
  rpc GetItem(GetItemRequest) returns (GetItemResponse) {
    option (google.api.http) = {
      get: "/v1/items/{id}"
    };
  }
}

message GetItemRequest {
  string id = 1;
}

message GetItemResponse {
  string name = 1;
  string description = 2;
}

구현 과정

// java gRPC 서버 생성
protoc --java_out=${output_path} \
  --grpc-java_out=${output_path} \
  --proto_path=${proto_file_path} \
  your_service.proto

// gRPC Gateway 생성
protoc --go_out=${output_path} \
  --go-grpc_out=${output_path} \
  --grpc-gateway_out=${output_path} \
  --proto_path=${proto_file_path} \
  your_service.proto

주의 사항

  • Java 서버는 google.api.http 옵션을 직접 처리하지 못함
  • Go gRPC Gateway를 사용해야 함

2. Spring으로 Gateway 직접 구현

@Configuration
public class GrpcClientConfig {
  @Bean
  public ItemServiceGrpc.ItemServiceBlockingStub itemServiceStub() {
    ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9090)
      .usePlaintext()
      .build();
    return ItemServiceGrpc.newBlockingStub(channel);
  }
}

@RestController
@RequestMapping("/api/items")
public class ItemController {
  private final ItemServiceGrpc.ItemServiceBlockingStub itemServiceStub;
  
  public ItemController(ItemServiceGrpc.ItemServiceBlockingStub itemServiceStub) {
    this.itemServiceStub = itemServiceStub;
  }
  
  @GetMapping("/{id}")
  public ResponseEntity<?> getItem(@PathVariable String id) {
    GetItemRequest request = GetItemRequest.newBuilder()
      .setId(id)
      .build();
    
    try {
      GetItemResponse response = itemServiceStub.getItem(request);
      return ResponseEntity.ok(
        Map.of("name", response.getName(), 
              "description", response.getDescription())
      );
    } catch (StatusRuntimeException e) {
      return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
        .body(e.getStatus().getDescription());
    }
  }
}

3. Armeria Framework 사용

Armeria는 다음과 같은 장점을 제공합니다:

  • JVM 기반의 Framework
  • Spring Integration
  • HTTP/2 지원
  • gRPC-Web 지원
  • 다양한 프로토콜과 통합

변환 방법 비교

방법장점단점
google.api.httpGoogle에서 공식 지원
자동화된 변환
Java에서 사용하려면 gRPC Gateway 필요
설정 복잡
Spring Gateway자바 개발자에게 친숙
높은 자유도
기존 Spring 생태계 활용
직접 구현 필요
추가 코드 작성
ArmeriaJVM 기반에서 gRPC 프로토콜을 자연스럽게 지원
HTTP/1.x, HTTP/2, gRPC, Thrift 등 다양한 프로토콜 동시 지원
새로운 프레임워크 학습 필요

3. 클라이언트 및 서버에서 gRPC 사용 예제

클라이언트 구현 예제 (JavaScript)

// gRPC-Web 클라이언트 생성
const {HelloRequest, HelloResponse} = require('./helloworld_pb.js');
const {GreeterClient} = require('./helloworld_grpc_web_pb.js');

// 클라이언트 인스턴스 생성
const client = new GreeterClient('http://localhost:8080');

// 요청 메시지 생성
const request = new HelloRequest();
request.setName('Dobby');

// 메타데이터 설정 (선택사항)
const metadata = {'custom-header': 'value'};

// 서버에 요청 보내기
client.sayHello(request, metadata, (err, response) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log('Response:', response.getMessage());
});

서버 구현(Java)

기본 gRPC 서버

import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.stub.StreamObserver;

public class HelloGrpcServer {
  public static void main(String[] args) throws Exception {
    // 서버 인스턴스 생성 및 포트 설정
    Server server = ServerBuilder.forPort(9090)
        .addService(new GreeterImpl())
        .build();
    
    // 서버 시작
    server.start();
    System.out.println("Server started on port 9090");
    
    // 서버 종료 전까지 대기
    server.awaitTermination();
  }
  
  // 서비스 구현
  static class GreeterImpl extends GreeterGrpc.GreeterImplBase {
    @Override
    public void sayHello(HelloRequest request, StreamObserver<HelloResponse> responseObserver) {
      // 요청 처리
      String name = request.getName();
      System.out.println("Received request from: " + name);
      
      // 응답 생성
      HelloResponse response = HelloResponse.newBuilder()
          .setMessage("Hello, " + name + "!")
          .build();
      
      // 응답 전송
      responseObserver.onNext(response);
      responseObserver.onCompleted();
    }
  }
}

Armeria를 사용한 gRPC 서버

import com.linecorp.armeria.common.grpc.GrpcSerializationFormats;
import com.linecorp.armeria.server.Server;
import com.linecorp.armeria.server.docs.DocService;
import com.linecorp.armeria.server.grpc.GrpcService;

public class ArmeriaGrpcServer {
  public static void main(String[] args) {
    // gRPC 서비스 생성
    GrpcService grpcService = GrpcService.builder()
        .addService(new GreeterImpl())
        .supportedSerializationFormats(GrpcSerializationFormats.values())
        .enableUnframedRequests(true)
        .build();
    
    // Armeria 서버 생성
    Server server = Server.builder()
        .http(8080)
        .service(grpcService)
        .service("/docs", DocService.builder().build())
        .build();
    
    // 서버 시작
    server.start().join();
    System.out.println("Server started on port 8080");
    
    // 서버 종료 전까지 대기
    server.closeOnJvmShutdown();
  }
  
  // 서비스 구현 (위의 GreeterImpl과 동일)
}

스트리밍

서버 스트리밍 (Server-side Streaming)

  • Proto 정의
service WeatherService {
  rpc GetWeatherUpdates(LocationRequest) returns (stream WeatherUpdate);
}
  • 서버 구현
@Override
public void getWeatherUpdates(LocationRequest request, 
                             StreamObserver<WeatherUpdate> responseObserver) {
  String location = request.getLocation();
  
  // 여러 개의 응답을 스트리밍
  for (int i = 0; i < 10; i++) {
    WeatherUpdate update = WeatherUpdate.newBuilder()
        .setLocation(location)
        .setTemperature(20 + new Random().nextInt(10))
        .setTimestamp(System.currentTimeMillis())
        .build();
    
    responseObserver.onNext(update);
    
    try {
      Thread.sleep(1000); // 1초마다 업데이트
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
  
  responseObserver.onCompleted();
}
  • 클라이언트 구현
const stream = client.getWeatherUpdates(request);

stream.on('data', (response) => {
  console.log('Update:', response.getTemperature());
});

stream.on('end', () => {
  console.log('Stream ended');
});

stream.on('error', (err) => {
  console.error('Error:', err);
});

보안 및 인증 예제

TLS/SSL 설정

Server server = ServerBuilder.forPort(9090)
    .addService(new GreeterImpl())
    .useTransportSecurity(certChainFile, privateKeyFile)
    .build();

JWT 인증 추가

// 인증 인터셉터
public class AuthInterceptor implements ServerInterceptor {
  @Override
  public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
      ServerCall<ReqT, RespT> call, Metadata headers, ServerCallHandler<ReqT, RespT> next) {
    
    String token = headers.get(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER));
    
    if (token == null || !validateToken(token)) {
      call.close(Status.UNAUTHENTICATED.withDescription("Invalid token"), new Metadata());
      return new ServerCall.Listener<ReqT>() {};
    }
    
    return next.startCall(call, headers);
  }
  
  private boolean validateToken(String token) {
    // JWT 토큰 검증 로직
    return true; // 실제 구현에서는 토큰 검증
  }
}

// 서버에 인터셉터 추가
Server server = ServerBuilder.forPort(9090)
    .addService(new GreeterImpl())
    .intercept(new AuthInterceptor())
    .build();

오류 처리 예제

@Override
public void getItem(GetItemRequest request, StreamObserver<GetItemResponse> responseObserver) {
  String id = request.getId();
  
  // 검증
  if (id == null || id.isEmpty()) {
    responseObserver.onError(
        Status.INVALID_ARGUMENT
            .withDescription("Item ID cannot be empty")
            .asRuntimeException());
    return;
  }
  
  // 아이템 조회
  Item item = itemRepository.findById(id);
  
  if (item == null) {
    responseObserver.onError(
        Status.NOT_FOUND
            .withDescription("Item not found: " + id)
            .asRuntimeException());
    return;
  }
  
  // 정상 응답
  GetItemResponse response = GetItemResponse.newBuilder()
      .setName(item.getName())
      .setDescription(item.getDescription())
      .build();
  
  responseObserver.onNext(response);
  responseObserver.onCompleted();
}

결론

gRPC와 gRPC-Web은 현대적인 분산 시스템 개발에 강력한 도구입니다.
Protocol Buffers를 통한 명확한 API 계약, 효율적인 바이너리 직렬화, 그리고 HTTP/2의 장점을 활용할 수 있습니다.
웹 환경에서는 gRPC-Web을 통해 브라우저에서도 이러한 이점을 활용할 수 있으며, 기존 REST API와의 통합도 다양한 방법으로 가능합니다.
각 시스템의 특성과 팀의 익숙함에 따라 적절한 구현 방법을 선택하는 것이 중요합니다.
마이크로서비스 아키텍처, 실시간 애플리케이션, 멀티 플랫폼 환경에서 특히 gRPC의 장점이 두드러집니다.

profile
에러가 나도 괜찮아 — 그건 내가 배우고 있다는 증거야.

0개의 댓글