23.06.15
DEVOTEE를 활성화 시키면
지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.
버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.
| 컨텐츠 유형 | 제목 | 저장일 | 삭제 |
|---|
본인인증 로그인에 실패하였습니다.
회원이 아니시거나 본인인증 등록이
완료되지 않은 사용자입니다.
안녕하세요. SK텔레콤 Auto서비스개발팀 신철민입니다.

차량 내에서도 에이닷 서비스를 더욱 편리하게 경험할 수 있는 시대가 다가오고 있습니다.
이를 실현하기 위해 다양한 OEM 차량에서 에이닷 서비스를 제공하는 것이 무엇보다 중요했습니다.
그러나 많은 OEM 차량에서는 저희가 개발한 SDK를 직접 탑재할 수 없는 한계가 있었습니다.
이 문제를 해결하기 위해, SDK가 없는 차량에도 에이닷 서비스를 제공할 수 있는 방법을 고민한 끝에 서버 대 서버(Server-to-Server) 방식의 연동을 도입하게 되었습니다.
이에 따라 OEM 서버와 에이닷 플랫폼 간의 원활한 차량 서비스 연동을 지원하는 Auto Proxy 서버를 개발하게 되었고 그 과정을 공유드립니다.

초기 컴퓨팅 시대
대형 메인프레임 컴퓨터가 주를 이뤘습니다. 이 거대한 기계들은 모든 기능을 단일 시스템 내에서 처리했고, 외부와의 통신 필요성이 거의 없었습니다.
데이터 교환은 주로 테이프나 카드와 같은 물리적 매체를 통해 이루어졌기 때문에, 현재와 같은 복잡한 네트워크 통신의 필요성이 크지 않았습니다.
분산 컴퓨팅의 시작
기술 발전으로 PC, 워크스테이션, 소형 서버 등이 등장하면서, 컴퓨팅 환경이 크게 변화했습니다.
고가의 메인프레임의 기능을 여러 소형 서버로 분산시키고 네트워크로 연결하는 방식이 도입되었습니다.
이는 서버와 클라이언트 간의 통신을 기반으로 하는 새로운 모델을 탄생시켰고, 네트워크 기술의 중요성이 부각되었습니다.
프로세스 간 통신(IPC)의 발전
프로세스 간 정보 교환이 활성화되면서 IPC(Inter Process Communication) 기술이 발전했습니다.
다양한 IPC 방식 중에서 소켓(Socket)은 네트워크를 통해 프로세스 간 통신을 가능하게 하는 중요한 수단이 되었습니다.
이를 통해 컴퓨터 간의 더욱 효율적인 통신이 가능해졌습니다.
RPC 기술의 등장
소켓은 유용했지만 서비스가 확장하면서 더욱 다양한 데이터 종류를 송수신하게 되며 이를 매핑하는 과정이 복잡해졌습니다.
이러한 한계를 극복하기 위해 RPC 기술이 등장했습니다.
RPC는 네트워크로 연결된 서버의 함수를 마치 로컬 함수처럼 호출할 수 있게 해주어 데이터 교환 방식을 단순화했습니다.

API 설계에 있어 REST와 gRPC는 각각 고유한 장점을 가진 두 가지 주요 접근 방식입니다. 이들의 특징을 비교해보면 다음과 같습니다.
REST(Representational State Transfer)는 널리 사용되는 API 설계 방식으로, 몇 가지 주목할 만한 장점이 있습니다.
REST는 단순성과 사용 용이성이 뛰어납니다. HTTP 메서드와 URL을 이용한 직관적인 설계로 개발자들이 빠르게 이해하고 구현할 수 있습니다.
REST는 가독성과 디버깅이 용이합니다. JSON이나 XML 같은 사람이 읽을 수 있는 형식을 사용하기 때문입니다.
REST의 stateless 특성과 HTTP 프로토콜 사용으로 캐싱 메커니즘을 쉽게 구현할 수 있어, 성능 향상과 서버 부하 감소에 도움이 됩니다.
이러한 특징으로 REST는 개발자와 외부 사용자 모두에게 친화적인 API로 자리잡았습니다.
그러나 REST에도 한계가 있습니다.
클라이언트의 요청이 있어야 서버가 응답할 수 있는 구조이며, JSON의 key-value 형식은 이해하기 쉽지만 데이터 형식이 제한적이어서 parsing과 mapping에 추가 작업이 필요합니다.
gRPC(gRPC Remote Procedure Call)는 REST와는 다른 접근 방식으로 몇 가지 중요한 이점을 제공합니다.
gRPC는 뛰어난 성능을 자랑합니다.
HTTP/2를 기반으로 하며, Protocol Buffers를 사용한 메시지 정의로 JSON보다 30-70% 작은 데이터 크기를 가집니다.
이로 인해 REST+JSON 통신보다 5-8배 빠른 속도를 달성할 수 있습니다.
gRPC는 다양한 통신 방식을 지원합니다.
서버 스트리밍, 클라이언트 스트리밍, 양방향 스트리밍 등 다양한 스트리밍 방식을 기본적으로 제공합니다.
gRPC는 개발 효율성을 높일 수 있습니다.
Protocol Compiler(protoc)를 통해 서버와 클라이언트의 핵심 코드가 자동 생성되어, 개발자가 이 패턴에 익숙해지면 개발 효율성을 크게 높일 수 있습니다.
결론적으로, REST와 gRPC는 각각의 장단점을 가지고 있으며, 프로젝트의 요구사항과 개발 환경에 따라 적절한 선택이 필요합니다.
REST는 범용성과 이해의 용이성이 뛰어나고, gRPC는 고성능과 효율적인 리소스 사용이 필요한 경우에 적합합니다.
gRPC에서 사용하는 Protocol Buffers(proto) 파일은 서비스 인터페이스와 메시지 구조를 정의하는 핵심 요소입니다.
다음은 sample.proto 파일의 예시와 주요 구성 요소에 대한 설명입니다.
syntax = "proto3";
option java_package = "com.sktelecom.sample";
option java_outer_classname = "SampleProto";
service SampleService {
rpc CreateSample (SampleRequest) returns (SampleResponse);
}
message SampleRequest {
int64 id = 1;
string email = 2;
string password = 3;
string name = 4;
}
message SampleResponse {
int64 id = 1;
string email = 2;
string password = 3;
string name = 4;
}service: gRPC 서비스를 정의합니다. 클라이언트가 호출할 수 있는 메서드들의 집합입니다.
rpc: 원격으로 호출 가능한 함수를 정의합니다. 함수명, 요청 객체, 응답 객체를 지정합니다.
message: RPC 메서드에서 주고받는 데이터 구조를 정의합니다.
메시지 이름은 PascalCase, 필드 이름은 snake_case 사용을 권장합니다.
각 필드 뒤의 숫자는 필드 태그로, 바이너리 인코딩 후 필드 식별에 사용됩니다.
1-15 범위의 태그는 1바이트, 16-2047은 2바이트를 사용합니다.
자주 사용하는 필드에는 작은 태그 값을 할당하여 효율성을 높일 수 있습니다.
19000-19999 범위는 예약되어 있어 사용할 수 없습니다.
proto 파일을 통해 gRPC는 강력한 타입 체크, 효율적인 직렬화, 그리고 다양한 프로그래밍 언어에 대한 코드 생성을 지원합니다.
이는 개발 생산성과 런타임 성능을 모두 향상시키는 gRPC의 핵심 장점 중 하나 입니다.
gRPC가 REST에 비해 더 작은 데이터 크기를 달성하는 방법을 이해하기 위해, SampleRequest 메시지를 예로 들어 살펴보겠습니다.
Protocol Buffers는 각 필드를 key-value 형식으로 인코딩하지만, REST와는 달리 key에 데이터 타입(WireType)과 필드 태그를 포함시켜 더욱 효율적인 인코딩을 가능하게 합니다.
WireType은 다양한 데이터 타입을 나타내는 숫자로 정의됩니다.
예를 들어, int64 타입은 VARINT(0)로 표현되며, string 타입은 Length-delimited(2)로 표현됩니다. 이러한 방식으로 각 필드의 특성에 맞는 최적화된 인코딩이 가능해집니다.

(reference: https://protobuf.dev/programming-guides/encoding/)
VARINT(Variable-length Integer) 인코딩은 Protocol Buffers에서 사용하는 효율적인 정수 직렬화 방식입니다.
이 방식의 작동 원리를 259라는 숫자를 예로 들어 설명하겠습니다.
이진수 변환: 259를 이진수로 변환합니다.
259 (10진수) = 100000011 (2진수)
7비트 단위로 분할: 이진수를 오른쪽에서부터 7비트씩 나눕니다.
0000010 | 0000011
분할된 비트 그룹 역순 정렬: 7비트 그룹들을 역순으로 배열합니다.
0000011 | 0000010
MSB(Most Significant Bit) 추가: 각 바이트의 맨 앞에 MSB를 추가합니다. MSB는 뒤에 더 이어지는 바이트가 있으면 1, 없으면 0입니다.
1|0000011 (첫번째 바이트, MSB=1)
0|0000010 (마지막 바이트, MSB=0)
최종 인코딩 결과:
10000011 00000010 (2바이트 바이너리)
Protocol Buffers에서 필드 태그 인코딩은 VARINT 방식을 따르며, 이는 메시지 구조의 효율성과 확장성에 중요한 역할을 합니다.
필드 태그 인코딩은 다음 세 부분으로 구성됩니다:
MSB (Most Significant Bit): 1비트
필드 태그: 4비트 (1-15 범위)
WireType: 3비트 (1-5 범위)
위 구성 때문에 필드태그 값이 15보다 크면 키를 엔코딩 하는데 더 많은 byte가 필요하게 됩니다.
Protocol Buffers에서 문자열과 임베디드 메시지는 LEN(Length-delimited) 와이어 타입을 사용합니다.
이 인코딩 방식을 SampleRequest의 'name' 필드를 예로 들어 설명하겠습니다.
키 인코딩:
필드 태그: 4 (0100 이진수)
와이어 타입: 2 (010 이진수)
결합: 0 0100 010 (이진수) = 22 (16진수)
길이 인코딩:
예: "conan" (5바이트 길이)
길이 인코딩: 05 (16진수)
값 인코딩:
"conan"을 16진수로 변환: 63 6F 6E 61 6E
최종 인코딩 결과: 22 05 63 6F 6E 61 6E
LEN 인코딩은 가변 길이 데이터를 효율적으로 처리할 수 있게 해주며, 데이터의 시작과 끝을 명확히 구분할 수 있어 파싱이 용이합니다.
이는 Protocol Buffers가 다양한 데이터 타입을 효과적으로 지원할 수 있게 하는 핵심 메커니즘 중 하나입니다.
Java Spring 환경에서 gRPC와 REST 응답 크기를 비교해 봅시다.
@PostMapping("/compareSize")
public String compareSize(@RequestBody RequestSampleDTO requestSampleDTO) throws JsonProcessingException, InvalidProtocolBufferException {
SampleProto.SampleResponse grpcResponse = SampleProto.SampleResponse.newBuilder()
.setId(Math.abs(new Random().nextLong()))
.setEmail(requestSampleDTO.getEmail())
.setPassword(requestSampleDTO.getPassword())
.setName(requestSampleDTO.getName())
.build();
ResponseSampleDTO restResponse = ResponseSampleDTO.builder()
.id(grpcResponse.getId())
.email(grpcResponse.getEmail())
.password(grpcResponse.getPassword())
.name(grpcResponse.getName())
.build();
return String.format("gRPC size: %d, REST size: %d",
grpcResponse.getSerializedSize(),
new ObjectMapper().writeValueAsBytes(restResponse).length);
}테스트 결과, gRPC 응답이 REST 응답보다 상당히 작은 크기를 가짐을 확인할 수 있습니다.
이러한 효율적인 인코딩 방식으로 인해 gRPC는 네트워크 대역폭 사용을 최적화하고, 특히 대량의 데이터를 처리하는 마이크로서비스 환경에서 뛰어난 성능을 발휘합니다.
AI 서비스인 에이닷은 방대한 양의 데이터 트래픽을 주고받을 필요가 있기 때문에, 높은 효율성과 적은 대기 시간을 제공하는 gRPC의 선택은 필수적이었습니다.

gRPC는 Java, Python, Go, C++, Node.js 등 다양한 언어로 구현이 가능하지만, 저희팀은 상용 환경에서 안정적으로 운영하는 것을 최우선으로 고려했습니다.
이에 따라, 모두가 익숙한 Java + Spring Boot 프레임워크를 채택하게 되었습니다.
Spring Boot는 gRPC의 높은 성능을 활용하면서도 간편한 개발 환경을 제공해 팀원들이 효율적으로 협업할 수 있도록 돕습니다.
이번 과정을 통해 Spring Boot에서 gRPC 서버를 구현하는 데 필요한 설정과 라이브러리를 자세히 살펴보겠습니다.
build.gradle 파일에 다음과 같은 플러그인과 의존성을 추가합니다:
plugins {
...
id("com.google.protobuf") version "0.9.4"
}
dependencies {
...
// protobuf
implementation("com.google.protobuf:protobuf-java:$protobufVersion")
implementation("com.google.protobuf:protobuf-java-util:$protobufVersion")
// gRPC
implementation("io.grpc:grpc-protobuf:$grpcVersion")
implementation("io.grpc:grpc-netty-shaded:$grpcVersion")
implementation("io.grpc:grpc-stub:$grpcVersion") // proto 인터페이스를 객체화
// gRPC utils
implementation("net.devh:grpc-server-spring-boot-starter:3.1.0.RELEASE")
compileOnly("org.apache.tomcat:annotations-api:6.0.53") // 자동생성된 gRPC 코드 컴파일 오류 방지
}protobuf-java & protobuf-java-util: Protobuf 메시지와 JSON 간의 변환을 지원합니다.
SampleProto.SampleResponse grpcResponse = SampleProto.SampleResponse.newBuilder()
.setId(Math.abs(new Random().nextLong()))
.setEmail(requestSampleDTO.getEmail())
.setPassword(requestSampleDTO.getPassword())
.setName(requestSampleDTO.getName())
.build();
String json = JsonFormat.printer().print(grpcResponse);
System.out.println(json);
/*
{
"id": "3992536559824466452",
"email": "conan.shin@sk.com",
"password": "password",
"name": "conan shin"
}
*/grpc-protobuf: .proto 파일을 기반으로 gRPC 서버를 구동합니다.
grpc-netty-shaded: gRPC 클라이언트 생성에 사용되는 Netty 기반 네트워킹 라이브러리입니다.
public GrpcSampleClient() {
ManagedChannel channel = NettyChannelBuilder.forAddress("localhost", 50051)
.usePlaintext()
.build();
blockingStub = SampleServiceGrpc.newBlockingStub(channel);
}grpc-stub: 서버의 gRPC 함수 인터페이스를 자동 생성하여 클라이언트에서 직접 호출할 수 있게 합니다.
Protobuf 코드 생성 설정
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:$protobufVersion"
}
plugins {
create("grpc") {
artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion"
}
}
generateProtoTasks {
all().forEach {
it.plugins {
create("grpc")
}
}
}
}이 설정으로 ./gradlew generateProto 명령을 실행하면 .proto 파일을 기반으로 Java 서비스 및 메시지 클래스가 자동 생성됩니다.
이러한 설정을 통해 Spring Boot 환경에서 효율적으로 gRPC 서버를 구성하고 관리할 수 있습니다.
자동 생성된 코드를 활용하여 개발 생산성을 높이고, 강력한 타입 안정성을 가진 gRPC 서비스를 구현할 수 있습니다.
gRPC는 서비스 정의 시 네 가지 주요 통신 패턴을 제공합니다
Unary
가장 기본적인 형태로, 클라이언트가 하나의 요청을 보내면 서버가 하나의 응답을 반환합니다.
흔히 HTTP의 REST API와 비슷한 1:1 요청-응답 구조라고 생각할 수 있습니다.
예를 들어, “로그인 요청”에 대한 “로그인 결과”를 받는 상황이 여기에 해당합니다.
public void unaryExample(
RequestType request,
StreamObserver<ResponseType> responseObserver)Server-streaming
클라이언트가 하나의 요청을 보내면, 서버가 여러 개의 응답을 스트림 형태로 순차적으로 반환합니다.
예를 들어, 클라이언트가 “뉴스 목록 요청”을 보내면, 서버가 여러 개의 뉴스 아이템을 차례대로 스트림으로 보내주는 경우가 이에 해당합니다.
public void serverStreamingExample(
RequestType request,
StreamObserver<ResponseType> responseObserver)Client-streming
클라이언트가 여러 개의 요청을 스트림으로 보내고, 서버는 모두 받은 뒤 하나의 응답을 반환합니다.
예를 들어, 클라이언트가 여러 파일 조각을 순차적으로 보내고, 서버가 모두 받은 뒤 “업로드 완료”와 같은 응답을 주는 상황에서 사용됩니다.
public StreamObserver<RequestType> clientStreamingExample(
StreamObserver<ResponseType> responseObserver)Bidirectional-streaming
클라이언트와 서버가 모두 여러 개의 메시지를 스트림으로 주고받을 수 있습니다.
채팅 서비스, 실시간 데이터 동기화 등 양방향 실시간 통신이 필요한 서비스에서 주로 활용됩니다.
public StreamObserver<RequestType> bidirectionalStreamingExample(
StreamObserver<ResponseType> responseObserver)Asynchronous Stub
비동기 스텁은 메서드 호출 시 즉시 반환되며, 응답은 콜백(리스너) 방식으로 처리합니다.
모든 스트리밍 패턴을 지원하며, 대량의 동시 요청이나 실시간 데이터 처리에 적합합니다.
예를 들어, 실시간 채팅 메시지를 주고받거나, 대용량 데이터를 스트림으로 처리할 때 사용합니다.
Blocking Stub
이 방식은 메서드를 호출하면 서버의 응답이 올 때까지 현재 스레드가 대기합니다.
가장 직관적이며, 단순한 요청-응답이나 서버 스트리밍을 동기적으로 처리할 때 적합합니다.
Unary(1:1)과 Server-streaming 호출을 지원합니다.
Future Stub
이 방식은 Unary(1:1) 호출에서만 사용할 수 있으며, Guava의 ListenableFuture를 반환합니다.
비동기적으로 결과를 받고 싶거나, Future 기반의 코드와 연동할 때 유용합니다.
예를 들어, 여러 요청을 동시에 보내고, 각 요청의 결과가 준비될 때까지 기다리거나 콜백을 등록할 수 있습니다.
SampleServiceGrpc.java 파일에서 생성된 주요 함수들과 그 설명은 다음과 같습니다.
getCreateSampleMethod()
CreateSample RPC 메소드의 설명자를 생성하고 반환합니다.
메소드의 이름, 요청 및 응답 타입, 직렬화/역직렬화 방식 등 메소드에 대한 모든 메타데이터를 포함하는 설명자를 생성합니다.
gRPC 런타임은 이 정보를 사용하여 클라이언트와 서버 간의 통신을 올바르게 처리합니다.
newStub(io.grpc.Channel channel)
비동기 클라이언트 스텁을 생성합니다.
생성된 스텁은 모든 유형의 gRPC 호출을 지원하며, 개발자가 비동기 방식으로 서버와 통신할 수 있게 해줍니다.
이는 높은 동시성이 필요한 애플리케이션에 적합합니다.
newBlockingStub(io.grpc.Channel channel)
동기식 블로킹 스타일의 클라이언트 스텁을 생성합니다.
이 스텁은 단일 호출과 서버 스트리밍 호출을 지원하며, 간단한 요청-응답 패턴에 적합합니다.
호출이 완료될 때까지 스레드가 블로킹되므로 동기적 프로그래밍 모델에 적합합니다.
newFutureStub(io.grpc.Channel channel)
ListenableFuture 스타일의 클라이언트 스텁을 생성합니다.
이 스텁은 단일 호출만을 지원하며, 비동기 호출의 결과를 Future 객체로 반환합니다.
이는 비동기 프로그래밍 모델을 선호하지만 스트리밍은 필요하지 않은 경우에 유용합니다.
bindService(AsyncService service)
.proto 파일에 정의 되어있는 서비스의 구현체를 gRPC 런타임과 연결하여 실제 RPC 호출이 해당 구현체로 라우팅되도록 합니다.
예를 들어 .proto 파일에 정의 되어있는 서비스의 rpc 함수 CreateSample은 getCreateSampleMethod()를 통해 로드되며 해당 함수를 추상 클래스에 바인딩 하는 부분이 bindService이고 해당 함수가 정의 되어있는 추상 클래스 SampleServiceImplBase를 상속받아 rpc 함수를 override해서 서비스 구현체를 완성하게 됩니다.
getServiceDescriptor()
서비스에 대한 전체적인 설명을 제공하는 ServiceDescriptor를 반환합니다.
반환된 설명자는 서비스 이름, 포함된 모든 메소드, 그리고 관련된 메타데이터를 포함합니다.
gRPC 런타임은 이 정보를 사용하여 서비스를 올바르게 처리하고 관리합니다.
스텁(Stub)이란 분산 시스템에서 클라이언트와 서버 간의 통신을 추상화하는 객체입니다.
gRPC에서 스텁은 클라이언트 측에서 서버의 원격 메소드를 로컬 메소드처럼 호출할 수 있게 해주는 프록시 역할을 합니다.
스텁은 네트워크 통신의 복잡성을 숨기고, 개발자가 마치 로컬 객체를 다루는 것처럼 원격 서비스와 상호작용할 수 있게 해줍니다.
gRPC는 다양한 유형의 스텁(비동기, 블로킹, Future)을 제공하여 개발자가 애플리케이션의 요구사항에 맞는 통신 방식을 선택할 수 있게 합니다.
Auto Proxy 서버는 OEM사와의 데이터 교환이 실시간 스트리밍 방식으로 이루어져야 했기 때문에, Auto Proxy와 OEM 간 통신에 Bidirectional-streaming gRPC를 적용하였습니다.
또한, 플랫폼 서버와도 데이터를 연속적으로 주고받아야 하는 요구사항이 있어, Auto Proxy와 플랫폼 서버 간에도 동일하게 양방향 Bidirectional-streaming gRPC 통신을 구현하였습니다.
이처럼 gRPC의 Bidirectional-streaming 기능을 제공하기 위해서는,
newStub(io.grpc.Channel channel) 메서드를 활용하여 Asynchronous Stub을 사용해야 합니다.
Asynchronous Stub은 스트리밍 데이터의 송수신을 비동기적으로 처리할 수 있어,
실시간 양방향 통신이 필요한 환경에서 최적의 선택이었습니다.

OEM사와 Auto proxy 서버간 공유하는 .proto 파일을 기반으로 generateProto를 실행하면 이전 장에서 언급한 Abstract class가 생성되고 해당 클래스를 구현하면 되겠습니다.
예를 들어, OEM과 통신하는 양방향 stream rpc를 포함한 proto 파일이 다음과 같다면,
service OemService {
rpc oemRpc(stream req) returns (stream res){}
}
message req{
oneof mRequest{
bytes voice = 1;
string command = 2;
}
}
message res{
oneof mResponse{
string command = 1;
string asr_text = 2;
string result_text = 3;
}
}oemRpc는 Abstract class인 OemServiceImplBase에 bind되고 해당 클래스를 상속받아 oemRpc 함수를 구현합니다.
public class OemServiceImpl extends OemServiceGrpc.OemServiceImplBase {
...
@Override
public StreamObserver<OemProto.req> oemRpc(
StreamObserver<OemProto.res> oemResponseObserver) {
return new StreamObserver<>() {
@Override
public void onNext(OemProto.req request) {위 함수에서 OemProto.req는 proto에 정의된 것 처럼 아래 2가지 유형 중 하나입니다.
voice: bytes 형식의 음성 데이터를 포함합니다.
command: string 형식의 요청 명령문을 담고있습니다. ("ARE_YOU_READY")
그리고 OemProto.res는 다음 3가지 유형 중 하나입니다.
command: string 형식의 응답 명령문을 담고있습니다. ("I_AM_READY")
asr_text: 음성 데이터인 질의를 텍스트 형으로 변환하여 응답합니다.
result_text: 텍스트 형식의 질의를 기반으로 응답을 생성하여 텍스트로 전달합니다.
여기서 voice는 차량으로부터 들어오는 음성 데이터입니다.
예를 들어, “오늘 날씨 어때?“와 같은 운전자의 질의가 음성 데이터에 담겨 전송됩니다.
이 음성 데이터를 플랫폼으로 전달하면, 플랫폼에서는 asrText(음성을 텍스트로 변환한 결과)나 resultText(질의에 대한 답변 텍스트)로 응답을 내려주며, 이 응답들은 다시 OEM으로 전달됩니다.
@Override
public void onNext(OemProto.req request) {
if (request.hasVoice()) {
ByteString voiceContent = request.getVoice();
sendVoiceContent(
platformRequestObserver,
voiceMeta,
voiceContent
);
}
}sendVoiceContent 함수는 voiceData를 기반으로 플랫폼에 gRPC 요청을 보내는 역할을 합니다.
이때 platformRequestObserver를 사용하여 플랫폼에 음성 데이터를 전달합니다.
또한, platformResponseObserver는 platformRequestObserver의 생성자에 포함되어 있으며, 플랫폼에서 받은 응답을 처리하는 역할을 담당합니다.
voiceMeta와 platformRequestObserver에 대한 자세한 설명은 이후에 이어서 다루겠습니다.
OEM과 Auto Proxy 서버 간에 요청을 받을 준비가 되었는지 확인하는 용도로 사용됩니다.
@Override
public void onNext(OemProto.req request) {
if (request.hasCommand()) {
if (request.getCommand.equals("ARE_YOU_READY")) {
String token = tokenService.getAccessTokenFromPlatform();
StreamObserver<PlatformResponse> platformResponseObserver = getResponseObserver(
asrText -> oemResponseObserver.onNext(
OemProto.res.newBuilder().setAsrText(asrText).build()
),
resultText -> oemResponseObserver.onNext(
OemProto.res.newBuilder().setResultText(resultText).build()
)
)
StreamObserver<PlatformRequest> platformRequestObserver = getRequestObserver(token, platformResponseObserver);
oemResponseObserver.onNext(
OemProto.res.newBuilder().setCommand("I_AM_READY").build()
);
}
}
}“ARE_YOU_READY”라는 명령이 들어오면, OEM의 음성을 받아 플랫폼에 전달할 준비를 시작합니다.
이를 위해 먼저 플랫폼과 통신할 때 필요한 access token을 발급받고, 해당 토큰을 기반으로 gRPC stub을 생성합니다.
이 stub은 플랫폼에 요청을 보내기 위한 객체이며, 플랫폼에서 내려오는 응답을 처리할 수 있도록 platformResponseObserver도 함께 준비합니다.
responseObserver는 asrText와 resultText를 전달받으면, oemResponseObserver를 통해 OEM에게 응답해 주는 역할을 합니다.
이렇게 준비된 platformRequestObserver는 앞서 언급했던 sendVoiceContent 함수에서 활용됩니다.
이처럼 gRPC 서버는 proto 파일로 자동 생성된 abstract 클래스를 상속받아,
요청 메시지를 처리하는 로직만 구현하면 손쉽게 완성할 수 있습니다.
앞서 언급했던 것처럼 OEM에게는 gRPC 서버 역할이었지만 플랫폼에는 gRPC 클라이언트 역할이 됩니다.
따라서 Auto proxy와 플랫폼간 통신을 위한 .proto도 존재합니다.
service PlarformService {
rpc streamVoice(stream PlarformRequest) returns (stream PlatformResponse);
}
message PlatformRequest {
oneof message {
VoiceMeta voice_meta = 1;
VoiceContent voice_content = 2;
}
}
message VoiceContent {
VoiceMeta voice_meta = 1;
bytes contents = 2;
}
message PlatformResponse {
oneof message {
String asr_text = 1;
String result_text = 2;
}
}streamVoice: 플랫폼으로 음성 데이터를 요청하고 응답을 받기 위한 rpc 입니다.
PlatformRequest
voice_meta: 해당 음성 요청의 식별자, 음성의 시작시간, 음성을 발생시킨 디바이스 정보가 담겨있습니다.
voice_content: 음성 데이터는 여러 chunk로 스트리밍 방식으로 인입되는데, 해당 chunk의 데이터와 voice meta 정보가 담겨있습니다.
PlatformResponse
asr_text: 음성 데이터인 질의를 텍스트 형으로 변환하여 응답합니다.
result_text: 텍스트 형식의 질의를 기반으로 응답을 생성하여 텍스트로 전달합니다.
gRPC 클라이언트를 구성하는 핵심 요소에 대해 살펴보겠습니다.
Channel
gRPC에서 클라이언트와 서버 간의 논리적 연결을 나타냅니다.
Channel은 네트워크 연결을 추상화한 객체로, 클라이언트가 서버와 통신하는 데 사용하는 장기적인 연결입니다.
이 Channel은 여러 RPC 호출에서 재사용되며, 로드 밸런싱, 연결 관리, 연결 실패 시 재시도와 같은 기능을 제공합니다.
Event Loop Group
gRPC의 비동기 통신을 관리하는 핵심 요소로, Netty 프레임워크의 일부입니다.
I/O 작업과 이벤트 처리를 위해 스레드 풀을 제공하며, 네트워크 이벤트를 효율적으로 처리해 gRPC 클라이언트에서 높은 성능과 확장성을 가능하게 합니다.
Stub
Stub은 gRPC 클라이언트에서 서버의 원격 메서드를 호출하기 위한 객체입니다.
이는 .proto 파일에서 정의된 서비스로부터 자동으로 생성되며, 클라이언트가 서버의 원격 서비스를 마치 로컬 메서드처럼 호출할 수 있게 해줍니다.
Stub은 네트워크 통신의 복잡성을 추상화하고, 함수 호출을 적절한 gRPC 메시지로 변환하는 역할을 합니다.

Stub은 고객이 메뉴를 선택하고 주문하는 태블릿과 같습니다.
Event Loop Group은 주문된 음식을 전달하거나 회수하는 역할을 하는 웨이터와 같으며, 비동기적으로 여러 요청을 처리합니다.
Channel은 음식 주문서에 적혀 있는 정확한 테이블 번호와 음식 정보처럼, 서버와 클라이언트를 연결하고 올바르게 데이터를 전달하는 역할을 합니다.
이러한 요소들을 활용해 gRPC 클라이언트는 높은 성능과 유연성을 제공하면서 서버와의 통신을 효율적으로 관리할 수 있습니다.
int poolSize = 10;
EventLoopGroup eventLoopGroup = new NioEventLoopGroup(poolSize);
ManagedChannel channel = NettyChannelBuilder.forAddress(host, port)
.eventLoopGroup(eventLoopGroup)
.channelType(NioSocketChannel.class)
.build();채널 생성을 할때 eventLoopGroup을 통해 스레드 풀 크기를 제어할 수 있습니다.
위 예제에서는 10개의 스레드가 네트워크 이벤트를 균등하게 처리하기 때문에 단일 스레드에 대한 과부하를 방지할 수 있고, 과도한 스레드 생성을 방지해서 시스템의 안정성을 높일 수 있습니다.
public StreamObserver<PlarformRequest> getRequestObserver(String accessToken, StreamObserver<PlatformResponse> responseObserver) {
Metadata header = new Metadata();
Metadata.Key<String> accessTokenMeta = Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER);
header.put(accessTokenMeta, "Bearer " + accessToken);
return VoiceServiceGrpc.newStub(channel)
.withCallCredentials(new CustomCallCredentials(header))
.streamVoice(responseObserver);
}gRPC 통신에서 metadata는 RPC 호출과 관련된 부가적인 정보를 전달하는 중요한 메커니즘입니다. 이는 HTTP/2 헤더와 유사한 역할을 하며, 키-값 쌍의 형태로 구성됩니다.
metadata의 주요 특징과 용도는 다음과 같습니다:
인증 정보 전달: 토큰이나 인증 키와 같은 보안 관련 정보를 전송할 수 있습니다.
트레이싱 및 로깅: 요청의 추적 ID나 로그 레벨 등을 포함하여 분산 시스템에서의 모니터링을 용이하게 합니다.
컨텍스트 전파: 마이크로서비스 아키텍처에서 서비스 간 컨텍스트 정보를 전달할 수 있습니다.
커스텀 헤더: 애플리케이션 특정 데이터를 전송할 수 있어 유연성이 높습니다.
언어 중립적: 다양한 프로그래밍 언어에서 일관되게 사용할 수 있습니다.
저는 metadata를 통해 플랫폼과 인증 정보를 전달받도록 구성했습니다.
그리고 streamVoice rpc를 통해 들어오는 Platform의 응답은 OEM사로 전달할 수 있도록 responseObserver로 핸들링 하였습니다.
여기서 Bidirectional-streaming을 제공하기위해 newStub함수를 사용한것을 확인할 수 있습니다.
public StreamObserver<PlatformResponse> getResponseObserver(
Consumer<String> asrTextCallback,
Consumer<String> resultTextCallback
) {
return new StreamObserver<>() {
@Override
public void onNext(PlatformResponse response) {
...
// 현실은 directive를 전달받아 복잡한 directive parsing 로직이 들어있음
...
if (response.hasAsrText()) {
asrTextCallback(response.getAsrText());
} else if (response.hasTextCallback()) {
resultTextCallback(response.getResultText());
}
}
};
}위 처럼 간단히 플랫폼에서 응답을 받아 응답에 대한 callback만 호출해서, 실제 응답에 대한 처리는 각 oem service 핸들러에서 구현하도록 하였습니다.
(OEM 마다 응답을 어떤 형식으로 받을지 proto 규격이 상이할 수 있음)
이제 platformRequestObserver를 사용해 플랫폼으로 gRPC 요청을 스트리밍하는 코드를 살펴보겠습니다.
public VoiceMeta sendVoiceMeta(StreamObserver<PlatformRequest> requestObserver) {
VoiceMeta voiceMeta = VoiceMeta.newBuilder()
.setRequestId("Some Id")
.setDeviceId("Some Device Id")
.setStartTime(new Date())
.build();
PlatformRequest request = PlatformRequest.newBuilder()
.setVoiceMeta(voiceMeta)
.build();
requestObserver.onNext(request);
return voiceMeta;
}실제 음성 데이터를 전달하기 전에, 우선 해당 음성 데이터의 식별자 및 부가 정보를 담은 메타 정보를 플랫폼에 먼저 전송합니다.
이후 동일한 메타 정보를 포함하여 음성 데이터를 전달하면, 플랫폼에서는 이 메타 정보를 기반으로 음성 데이터를 올바르게 조합해 결과를 반환할 수 있습니다.
public void sendVoiceContent(
StreamObserver<PlatformRequest> requestObserver,
VoiceMeta meta,
ByteString contents) {
VoiceContent voiceContent = VoiceContent.newBuilder()
.setVoiceMeta(voiceMeta)
.setContents(contents)
.build();
PlatformRequest request = PlatformRequest.newBuilder()
.setVoiceContent(voiceContent)
.build();
requestObserver.onNext(request);
}음성 데이터를 전달할 때는 반드시 메타 정보를 함께 포함하여 요청을 전송합니다.
OEM과 Platform 간에 음성 데이터 포맷을 동일한 규격으로 맞추는 것이 매우 중요하지만, 이 글에서는 해당 세부 내용은 다루지 않겠습니다.
앞서 구현한 gRPC 서버 코드를 다시 살펴보면 다음과 같습니다.
@Override
public void onNext(OemProto.req request) {
if (request.hasVoice()) {
ByteString voiceContent = request.getVoice();
/**
StreamObserver<PlatformResponse> platformResponseObserver = getResponseObserver(
asrText -> oemResponseObserver.onNext(
OemProto.res.newBuilder().setAsrText(asrText).build()
),
resultText -> oemResponseObserver.onNext(
OemProto.res.newBuilder().setResultText(resultText).build()
)
)
StreamObserver<PlatformRequest> platformRequestObserver = getRequestObserver(token, platformResponseObserver);
**/
sendVoiceContent(
platformRequestObserver,
voiceMeta,
voiceContent
);
}
}sendVoiceContent함수를 통해 platformRequestObserver가 Platform으로 음성 요청을 보내고 platformRequestObserver에 포함되어있는 platformResponseObserver가 asrText와 resultText에 대한 응답을 받으면 oemResponseObserver로 OEM사에 최종결과를 streaming하는 방식으로 구현되었습니다.
여기까지 Bidirectional-streaming gRPC 기반 Auto Proxy 서버를 구축한 경험기였습니다.
DEVOTEE를 활성화 시키면
지금 작성한 댓글에 AI가 댓글을 달아줍니다.