본문 바로가기
Back-end/nest.js

Nest.js : gRPC 통신 (+Python gRPC)

by hsloth 2023. 7. 25.

2023년 7월 25일 기준 작성된 글입니다.

 

해당 포스팅은 SKT FLY AI 교육과정 중, 내가 AI 모델을 위한 서버와 REST api 서버를 따로 구축할까 생각하던 와중 시도해본 GRPC통신에 대한 포스팅이다.

 

Nest.js는 고맙게도 프레임워크 자체에서 gRPC 통신을 지원해준다. (파이썬 FastApi로 gRPC통신이 되는지 찾아봤는데... 파이썬은 따로 grpcio-tools 라는 걸 다운받고, grpc.server메소드를 사용해야 grpc 서버가 만들어지는 것 같다. 즉, 프레임워크 자체에서 GRPC지원은 안되는 것 같다)

 

RPC란?


Remote Procedure Call의 약자로 원격 프로시저 호출이라는 의미이다.

RPC를 통해 다른 서버에 존재하는 함수나 프로시저를 호출하고 응답을 받을 수 있다.

다른 컴퓨터에 존재하는 프로그램의 프로시저를 실행할 수 있도록 허용하는 프로토콜이라고 생각하면 된다.

 

동작 방식

음... 자세한 설명은 머리아프니 생략하겠다. (솔직히 잘 모르겠다. 아래는 그냥 내가 사용해본 경험상의 동작 방식을 적은 것이니 참고만 하자)

Nest.js 서버가 두 개가 있다. 하나는 Client Server이고, 다른 하나는 RPC Server라고 하겠다.

사용자가 Client Server로 API를 호출하면, Client Server는 컨트롤러의 함수 내부에 존재하는 RPC Server를 호출하는 로직을 부르고, RPC Server에 해당 함수의 인자를 넘겨준다. 그리고 RPC Server는 Client Server로 부터 함수의 인자(파라미터)를 받아서 자신의 함수를 실행시킨 결과 값을 다시 Client Server로 넘겨준다. 그 후, Client Server는 해당 함수의 결과 값을 받아와서 나머지 부가적인 로직을 처리한 뒤에 최종적으로 사용자에게 API호출에 대한 결과를 넘겨준다.

 

 

RPC는 왜 사용할까?

간단하게 말하면, MSA때문에 사용한다. HTTP보다 가벼워서 성능상으로 이점이 있다고 한다.

MSA 방식이 등장함에 따라 RestAPI는 속도 측면에서 많은 부담이 되어 RPC방식을 다시 사용하기 시작했다고 한다.

솔직히 나는, 그냥 공부하려고 사용해봤다.

 

 

그렇다면 gRPC란 무엇일까?

gRPC는 Google에서 만든 오픈소스 RPC Framework 이다.

Service간의 통신을 Proto라는 언어를 통해 Request/Response가 이루어진다.

자세한 RPC 통신은 구글링해서 찾아보자.

 


Nest.js에서 gRPC 통신으로 파이썬 서버의 데이터 받아오기


여기서는 Nest.js는 클라이언트, Python은 gRPC server의 역할을 한다.

일단, 파이썬 gRPC 서버를 만들어보자.

 

Python

일단, 프로젝트 폴더로 이동해서 파이썬의 가상환경을 먼저 설치 해주자.

python3 -m venv venv

# 혹은
python -m venv venv

만약 위의 명령어가 먹히지 않는다면, pip install virtualenv를 해보길 바란다.

 

위의 명령어가 성공적으로 실행되었다면, 프로젝트 폴더에 venv라는 폴더가 생겼을 것이다.

그러면 그 가상환경을 실행시켜주면 된다.

# Mac, Linux
source ./venv/bin/activate

# Window
.\venv\Scripts\activate

# 가상환경 탈출 방법
deactivate

가상환경이 무엇이고 왜 쓰는지는 구글링해보자!

간단히 설명하면, 그냥 pip install <패키지명>을 하면 npm i -g <패키지명> 으로 실행하는 느낌이다.

가상환경에서 pip install을 하면, 그냥 npm install을 하는 느낌이라고 보면 된다.

 

가상환경에 접속했다면, 이제 필요한 라이브러리를 다운받아보자.

pip install grpcio-tools

 

그리고 proto파일을 작성하자.

// /proto/greeter.proto
syntax = "proto3"; // 해당 proto파일의 문법은 proto3 버전이다.

package greeter; // 해당 파일의 패키지명은 user이다.

message HelloRequest {  // HelloRequest라는 타입을 정의할건데, 
    string name = 1; // HelloRequest라는 타입은 string을 value로 가지는 name속성을 가진다.
}

message HelloReply {  // HelloReply 타입을 정의할건데,
    string message = 1;  // HelloReply 타입은 string을 value로 가지는 message 가진다.
}

// 참고로 = 1은 잘 모르겠다.

service GreeterService {  // GreeterService라는 서비스를 정의할건데,
	// rpc통신을 하며, sayHello이라는 함수명을 가진다.
	// 그리고 함수의 인자는 HelloRequest타입이며, 리턴타입은 HelloReply이다.
    rpc sayHello (HelloRequest) returns (HelloReply) {}  
}

//service도 결국 interface에서 함수를 정의한 것과 같다.

 

그러면, 해당 proto파일을 python으로 변환시켜주는 명령어를 입력해보자.

# outDir경로는 proto파일 기준인 듯 하다.
python -m grpc_tools.protoc -I .
	--python_out=<outDir> --grpc_python_out=<outDir>
	<proto file path/proto file>

# ex) 
python -m grpc_tools.protoc -I .
	--python_out=./src/proto --grpc_python_out=./src/proto
	./src/proto/greeter.proto

 

그러면, main.ts

import asyncio
from grpc import aio

from proto.helloworld_pb2 import HelloRequest, HelloReply
from proto import helloworld_pb2_grpc

class Greeter(helloworld_pb2_grpc.GreeterServicer):
    
    # .proto에서 지정한 메서드를 구현하는데, request, context를 인자로 받는다.
    # 요청하는 데이터를 활용하기 이ㅜ해서는 request.{메시지 형식 이름}으로 호출한다.
    # 응답시에는 메서드 return에 proto buffer 형태로 메시지 형식에 내용을 적어서 반환한다.
    async def SayHello(self, request, context):
        return HelloReply(message="Hello, %s!" % request.name)


async def serve():
    print("Server start...")
    
    # 서버를 정의할 때, future의 멀티 스레딩을 이용하여 서버 가동
    server = aio.server()
    
    # 위에서 정의한 서버를 지정
    helloworld_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
    
    listen_addr = '[::]:50051'
    
    # 불안정한 포트 50051로 연결한다.
    server.add_insecure_port(listen_addr)
    await server.start()
    await server.wait_for_termination()
    
if __name__ == '__main__':
    asyncio.run(serve())

위의 코드는 비동기를 이용해서 코드를 작성한 것이다.

 

만약 비동기를 이용하지 않을 생각이라면, 아래 코드로 작성하자.

from concurrent.futures import ThreadPoolExecutor
import grpc

from proto.helloworld_pb2 import HelloRequest, HelloReply
from proto import helloworld_pb2_grpc

class Greeter(helloworld_pb2_grpc.GreeterServicer):
    
    # .proto에서 지정한 메서드를 구현하는데, request, context를 인자로 받는다.
    # 요청하는 데이터를 활용하기 이ㅜ해서는 request.{메시지 형식 이름}으로 호출한다.
    # 응답시에는 메서드 return에 proto buffer 형태로 메시지 형식에 내용을 적어서 반환한다.
    def SayHello(self, request, context):
        return HelloReply(message="Hello, %s!" % request.name)


def serve():
    print("Server start...")
    
    # 서버를 정의할 때, future의 멀티 스레딩을 이용하여 서버 가동
    server = grpc.server(ThreadPoolExecutor(max_workers=10))
    
    # 위에서 정의한 서버를 지정
    helloworld_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
    
    # 불안정한 포트 50051로 연결한다.
    server.add_insecure_port('[::]:50051')
    server.start()
    server.wait_for_termination()
    
if __name__ == '__main__':
    serve()

Greeter라는 클래스를 따로 파일로 분리해서 구조를 나눌 수도 있다.

 

여기까지 작성하면 거의 다 끝났다.

파이썬은 절대 경로 문제가 있기 때문에 환경변수를 설정해주자.

# window
set PYTHONPATH=.

# linux, mac
export PYTHONPATH=.

 

마지막으로 다음 명령어로 파이썬 gRPC서버를 실행시킬 수 있다.

python main.py

# 혹은
python3 main.py

 

 

그리고 Nest.js의 클라이언트 서버를 설정해보자.

Nest.js

먼저, gRPC통신을 위해 필요한 패키지들을 다운받자.

npm i --save @nestjs/microservices

npm i --save @grpc/grpc-js @grpc/proto-loader

 

Nest.js를 클라이언트 용도로 사용한다면, main.ts파일에서 수정할 것은 없다.

 

그러면, gRPC 서버에 정의된 proto파일을 가져와서 복사하여 생성하고, 그에 대한 인터페이스를 생성하자.

// greeter.interface.ts

export interface HelloRequest {
	name: string;
}

export interface HelloReply {
	message: string;
}

export interface IHelloService {
	sayHello(param: HelloRequest): Promise<HelloReply>;
}

해당 인터페이스는 proto에 정의된 타입들을 ts로 정의한 것이다. 컴파일하는 것보다 직접 생성하는게 빠르다.

 

그리고 gRPC서버와 Nest.js Client Server를 연결하는 작업을 해주자.

// greeter-svc.options.ts (파일명 알아서 짓자)
import { ClientOptions, Transport } from '@nestjs/microservices';
import * as path from 'path';

export const GreeterServiceClientOptions: ClientOptions = {
  transport: Transport.GRPC,
  options: {
    url: '127.0.0.1:5000',  // 나의 경우 로컬에서 5000번 포트에 grpc서버를 열 생각이다.
    package: 'greeter',  // 아까 proto파일에서 설정했던 패키지명
    protoPath: path.join(__dirname, '../../proto/greeter.proto'),  // 프로토파일 경로
    loader: {  // 이거는... 아마도 해당 타입들을 어떤 형식으로 받을지에 대한 정보같다.
      enums: String,   // 일단 이렇게 설정하자.
      objects: true,
      arrays: true,
    },
  },
};

 

 

인터페이스를 정의해 주었다면, 컨트롤러에서 gRPC통신을 할 수 있도록 함수를 작성하자.

컨트롤러에서 gRPC서버의 정보를 이용해 gRPC서버의 Service를 가져온다.

// greeter.controller.ts
import { Controller, Get, OnModuleInit } from '@nestjs/common';
import { Client, ClientGrpc } from '@nestjs/microservices';
import { GreeterServiceClientOptions } from './config/greeter-svc.options';
import { IGreeterService } from './config/greeter.interface';

@Controller('greeter')
export class GreeterController implements OnModuleInit {
  constructor() {}

  @Client(GreeterServiceClientOptions)  // GRPC서버의 정보를 주입한다.
  private readonly greeterServiceClient: ClientGrpc;

  private greeterService: IGreeterService;

  onModuleInit() {
		// GRPC서버의 정보를 이용하여 Service를 받아온다.
		// 'GreeterService'는 proto파일에 정의되어있는 함수의 타입이다.
    this.greeterService =
      this.greeterServiceClient.getService<IGreeterService>('GreeterService');
  }

	// 타입은 아까 정의한 인터페이스들로 정의를 해주면 된다. (이때 내가 왜 타입을 정의 안했을까..)
	// /greeter/:id로 Get요청이 들어오면 greeterService(GRPC서버의 함수)가 실행된다.
  @Get(':id')
  async f(): Promise<any> {
    const res = await this.greeterService.sayHello({ name: 'name' });

    return res;
  }
}

 

마지막으로, AppModule에 GreeterModule을 등록해주면 된다.

 

 

위의 예제는 다음 레포를 참고해보자.

https://github.com/8471919/python-nest-grpc-docker-example

 

GitHub - 8471919/python-nest-grpc-docker-example

Contribute to 8471919/python-nest-grpc-docker-example development by creating an account on GitHub.

github.com

 

 


 Nest.js를 gRPC 서버로 사용하기


npm i --save @nestjs/microservices

npm i --save @grpc/grpc-js @grpc/proto-loader

위의 명령어로 패키지들을 설치하자.

 

위의 proto파일이랑은 조금 다르게 설정해보겠다.

// user.proto
syntax = "proto3";   // proto버전이 3임을 의미한다. 디폴트는 2.

package user;    // 패키지명을 명시해준다.

message User {  
    string id = 1;
}

message UserName {
    string username = 1;
}


service UserService {
    rpc findOne (User) returns (UserName) {}
}

// user.interface.ts
// interface를 꼭 service 대상으로 만들지 않아도 될 것 같다.
// 컨트롤러 대상으로 만들어도 될듯.
export interface User {
  id: string;
}

export interface FindUserName {
  username: string;
}

export interface IUserService {
  findOne(param: User): Promise<string>;
}

 

그 다음, main.ts 파일에서 app을 Microservice용으로 create한다.

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import * as path from 'path';

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(
    AppModule,
    {
      transport: Transport.GRPC,
      options: {
        url: '127.0.0.1:5000',  // 해당 url로 서버를 연다.
        package: 'user',  // proto파일의 패키지명
        protoPath: path.join(__dirname, '../src/proto/user.proto'), // 프로토 파일 위치
        loader: {  // 이거는... 아마도 해당 타입들을 어떤 형식으로 받을지에 대한 정보같다. 
          enums: String,  // 일단 이렇게 적자.
          objects: true,
          arrays: true,
        },
      },
    },
  );

  await app.listen();
}
bootstrap();

 

설정을 해줬다면, 그에 맞는 Controller와 Service를 만들어주자

// user.service.ts
import { Injectable } from '@nestjs/common';
import { FindUserName, IUserService, User } from './config/user.interface';

@Injectable()
export class UserService implements IUserService { // 컨트롤러에 implement하면 더 좋을거같음.
  constructor() {}                               // 결국 컨트롤러의 리턴타입이 클라이언트 쪽에서는 service의 리턴타입이기 때문.

  async findOne(param: User): Promise<string> {
    return 'hansu';
  }
}

// user.controller.ts
import { Controller } from '@nestjs/common';
import { UserService } from './user.sevice';
import { GrpcMethod } from '@nestjs/microservices';
import { FindUserName, User } from './config/user.interface';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

	// GrpcMethod(클라이언트의 proto파일의 서비스명, 해당 서비스의 함수명)
	// 함수명은 클라이언트측에서 호출한 함수명과 일치해야한다.
  @GrpcMethod('UserService', 'findOne')
  async f(param: User): Promise<FindUserName> {
    const res = await this.userService.findOne(param);

    return { username: res };  // 타입을 클라이언트의 proto파일의 리턴타입과 일치시켜줘야한다.
  }
}

 

 

위의 코드는 다음의 레포를 참고하자.

https://github.com/8471919/nestjs-grpc-example/tree/main

 

GitHub - 8471919/nestjs-grpc-example

Contribute to 8471919/nestjs-grpc-example development by creating an account on GitHub.

github.com

 

 


번외. 하이브리드 어플리케이션 설정


// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import * as path from 'path';

async function bootstrap() {
  const app = await NestFactory.create(AppModule)

	app.connectMicroservice({
    transport: Transport.GRPC,
    options: {
      url: '127.0.0.1:5000',  // 해당 url로 서버를 연다.
      package: 'user',  // proto파일의 패키지명
      protoPath: path.join(__dirname, '../src/proto/user.proto'), // 프로토 파일 위치
      loader: {  // 이거는... 아마도 해당 타입들을 어떤 형식으로 받을지에 대한 정보같다. 
        enums: String,  // 일단 이렇게 적자.
        objects: true,
        arrays: true,
      },
    },
  });

	await app.startAllMicroservices(); //

  await app.listen();
}
bootstrap();