gRPC 실전 및 REST와 같이 쓰며 배운 것들
이 글은 REST API의 역사에서 출발하여, gRPC가 왜 탄생했는지, 그리고 실제 프로덕션에서 REST와 gRPC를 함께 운영하며 경험한 것들을 다룹니다.
REST가 지배하던 시대
2000년, Roy Fielding이 박사 논문에서 REST(Representational State Transfer)라는 아키텍처 스타일을 제안했습니다. 당시 웹 서비스 통신의 주류는 SOAP(Simple Object Access Protocol)이었습니다. SOAP은 XML 기반의 프로토콜로, 엄격한 스키마(WSDL)를 요구했고, 메시지 하나를 보내기 위해 수십 줄의 XML 엔벨로프를 작성해야 했습니다.
<!-- SOAP 요청 예시 -->
<soap:Envelope xmlns:soap="<http://schemas.xmlsoap.org/soap/envelope/>">
<soap:Body>
<GetUser xmlns="<http://example.com/users>">
<UserId>42</UserId>
</GetUser>
</soap:Body>
</soap:Envelope>
REST는 이 복잡함에 대한 반론이었습니다. HTTP라는 이미 존재하는 프로토콜의 메서드(GET, POST, PUT, DELETE)를 그대로 활용하고, 리소스를 URI로 표현하자는 것. 별도의 프로토콜 없이, 웹 그 자체가 API가 되는 구조였습니다.
GET /users/42
한 줄이면 충분했습니다. 이 단순함이 REST가 승리한 이유입니다. 2005년경 AJAX의 등장, 2007년경 모바일 시대의 개막과 함께 REST + JSON 조합은 사실상의 표준이 되었습니다. 2010년대에 이르러서는 "API = REST API"라는 등식이 성립할 정도였습니다.
REST의 한계가 드러나기 시작하다
REST는 클라이언트와 서버 사이의 통신에서는 충분했습니다. 브라우저에서 서버로 요청을 보내고, JSON으로 응답을 받는 패턴은 직관적이었고, 누구나 이해할 수 있었습니다. 하지만 서버와 서버 사이의 통신에서는 이야기가 달라지기 시작했습니다. 마이크로서비스 아키텍처가 보편화되면서, 하나의 요청이 처리되기 위해 내부적으로 수십 개의 서비스가 통신해야 하는 상황이 일상이 되었습니다. 이 환경에서 REST는 몇 가지 구조적인 한계를 드러냈습니다.
첫째, 텍스트 기반 직렬화의 비효율. JSON은 사람이 읽기 좋지만, 기계가 파싱하기에는 비용이 큽니다. 서비스 간 초당 수천 건의 통신이 오갈 때, 매번 JSON을 문자열로 직렬화하고 다시 파싱하는 오버헤드는 무시할 수 없습니다.
둘째, 스키마의 부재. REST에는 요청과 응답의 형태를 강제하는 메커니즘이 없습니다. Swagger/OpenAPI로 문서화할 수는 있지만, 그것은 "약속"이지 "계약"이 아닙니다. 서버 A가 응답 필드를 하나 바꿔도, 서버 B는 런타임에 깨질 때까지 알 수 있는 방법이 없습니다.
셋째, HTTP/1.1의 구조적 제약. 하나의 커넥션에서 하나의 요청 ↔ 응답만 처리할 수 있는 구조입니다. 서버 간 대량 통신에서 커넥션 풀 관리는 그 자체로 복잡도를 만들어 냈습니다.
gRPC의 탄생
2015년, Google이 gRPC를 오픈소스로 공개했습니다. gRPC의 "g"가 무엇의 약자인지는 공식적으로 정해져 있지 않습니다. "Google"이라는 설이 유력하지만, gRPC 팀은 매 릴리즈마다 g의 의미를 바꾸는 장난을 치기도 합니다 (good, green, glorious...). gRPC는 Google 내부에서 10년 이상 사용해 온 Stubby라는 RPC 프레임워크의 후속작입니다. Google은 수만 개의 마이크로서비스가 초당 수십억 건의 RPC를 처리하는 환경을 운영하고 있었고, Stubby는 그 환경에서 검증된 시스템이었습니다. Stubby를 외부에 공개하기 위해 표준 기술 위에 재설계한 결과물이 gRPC입니다. gRPC의 핵심 설계 결정은 세 가지입니다.
1. Protocol Buffers (protobuf)
gRPC의 직렬화 포맷은 JSON이 아니라 Protocol Buffers(protobuf)입니다. 2008년에 Google이 공개한 바이너리 직렬화 포맷으로, JSON 대비 메시지 크기는 약 60~80% 작고, 직렬화/역직렬화 속도는 수 배 빠릅니다. 하지만 protobuf의 진짜 가치는 성능이 아닙니다. 스키마가 곧 코드라는 점입니다.
syntax = "proto3";
service StudyMaterialService {
rpc Generate (StudyMaterialRequest) returns (StudyMaterialResponse);
}
message StudyMaterialRequest {
repeated ArticleChange changes = 1;
}
message ArticleChange {
string articleNo = 1;
string changeType = 2;
string before = 3;
string after = 4;
}
이 .proto 파일 하나로부터 TypeScript, Go, Kotlin, Python 등 원하는 언어의 클라이언트/서버 코드가 자동 생성됩니다. 서버가 proto 파일을 변경하면, 클라이언트는 컴파일 타임에 변경을 감지합니다. REST에서는 런타임에 터지던 문제가, gRPC에서는 빌드 타임에 잡힙니다. 이는 REST API 방식에 비해 꽤 안전할지도 모릅니다.
2. HTTP/2
gRPC는 HTTP/2 위에서 동작합니다. HTTP/2의 핵심 기능인 멀티플렉싱(하나의 커넥션에서 여러 요청을 동시에 처리), 헤더 압축, 서버 푸시를 기본으로 활용합니다. 이로 인해 하나의 TCP 커넥션으로 수백 개의 동시 RPC를 처리할 수 있습니다. HTTP/1.1에서 커넥션 풀 크기를 고민하던 것과는 차원이 다른 효율입니다.
3. 4가지 통신 패턴
REST는 단방향 요청-응답(Unary)만 지원합니다. gRPC는 네 가지 패턴을 지원합니다. 다만, 솔직히 말하면 실무에서 Unary 이외의 패턴을 사용하는 경우는 매우 드뭅니다. HoBom 시스템에서도 모든 gRPC 통신은 Unary입니다. Streaming이 필요한 시점이 오면 그때 도입해도 늦지 않습니다.
HoBom에서의 선택 → REST + gRPC 혼용
HoBom 시스템은 REST와 gRPC를 동시에 운영합니다. 같은 서버에서, 같은 포트가 아닌 두 개의 포트로 운영하는 형태입니다.
[클라이언트 (앱/웹)]
↓ REST (포트 8080)
[for-hobom-backend] ←── gRPC (포트 50051) ── [hobom-event-processor (Go)]
│
└── REST (포트 3000) ──→ [hobom-llm-service-backend]
↑ gRPC (포트 50052)
[hobom-event-processor (Go)]
왜 이런 구조가 되었는지 지금부터 설명해 보도록 하겠습니다.
외부 통신은 REST
클라이언트(앱, 웹)와의 통신은 REST를 유지했습니다. 이유는 단순합니다.
- 브라우저에서 gRPC를 네이티브로 호출할 수 없습니다. (gRPC-Web이라는 프록시 레이어가 필요합니다)
- React 에서 gRPC 클라이언트 생태계가 아직 성숙하지 않다고 생각합니다.
- Swagger UI를 통한 API 문서화와 테스트가 REST에서 훨씬 간편합니다.
- API Gateway의 라우팅, 인증, 로깅이 HTTP 기반으로 이미 구축되어 있습니다.
외부 통신에 gRPC를 도입하면 얻는 성능 이점보다, 클라이언트 팀의 개발 복잡도가 더 크게 증가합니다. 이 트레이드오프에서 REST가 합리적이었습니다.
내부 통신은 gRPC
반면, 서버 간 통신에서 gRPC를 선택한 이유는 명확합니다. HoBom은 NestJS(TypeScript), Go, Spring Boot(Kotlin), C# 이렇게 네 가지 언어로 구성된 멀티 런타임 시스템입니다. 이 환경에서 서비스 간 계약을 어떻게 관리할 것인가가 핵심 문제입니다.. REST API로 서비스 간 통신을 하면, 계약은 암묵적이고 변화에 유동적이라는 장점은 있으나, 이를 정기적으로 추적하기 어렵습니다. "이 엔드포인트에 이런 JSON을 보내면 이런 JSON이 온다"는 약속이 문서로만 존재합니다. 서버 A가 응답 형태를 바꿔도, 서버 B는 배포해서 오류가 나야 그제서야 알 수 있습니다. 하지만 gRPC에서는 .proto 파일이 명시적인 계약서 역할을 합니다.
Proto 파일 관리 → 단일 소스 전략
HoBom에서는 모든 proto 파일을 hobom-buf-proto라는 별도의 Git 저장소에서 관리합니다. 각 서비스는 이 저장소를 Git submodule로 참조합니다. 사실 Git submodule 외에 더 편리하고 좋은 관리 방식이 있을 수 있겠으나, 개인 프로젝트에 다른 솔루션을 도입하기엔 오버엔지니어링 이라는 판안이 들어 submodule을 유지하기로 했습니다.
hobom-buf-proto/
├── law/
│ ├── outbox/v1/
│ │ └── find-hobom-law-outbox.proto
│ └── v1/
│ └── save-study-material.proto
├── llm/
│ └── v1/
│ ├── generate-study-material.proto
│ └── ask-question.proto
└── message/
└── outbox/v1/
├── find-hobom-message-outbox.proto
└── patch-hobom-message-outbox.proto
이 구조의 핵심은 proto 파일을 변경하면, 모든 서비스가 동시에 영향을 받는다는 것입니다. 예를 들어, 학습자료 생성 요청에 필드를 추가한다고 가정해 보겠습니다.
message StudyMaterialRequest {
repeated ArticleChange changes = 1;
string lawId = 2; // 새 필드 추가
}
이 변경이 hobom-buf-proto에 커밋되면, 이 proto를 참조하는 Go 서비스(hobom-event-processor)와 NestJS 서비스(hobom-llm-service-backend) 모두 submodule을 업데이트해야 합니다. 업데이트하지 않으면 빌드가 실패합니다. 사실 빌드 단계까지 가지 않는 것이 더욱 안전하고 좋은 방법일 수 있겠으나, 런타임에 올라간 뒤 에러를 맞이하게 되는 REST API 방식 보다는 빌드 단계에서 깨지는 것이 더 좋을 수도 있겠습니다. REST에서는 이 문제를 Swagger 문서 업데이트로 해결하려 하지만, 많은 양의 API 문서를 모두 읽는 다는 보장이 없습니다. 또한 서비스 규모가 클수록 이 가능성은 비례하여 증가할 것으로 생각합니다. 하지만 gRPC에서는 컴파일러가 강제합니다. 다만, 이 방식에도 분명한 비용이 있습니다. proto 파일을 수정할 때마다 모든 서비스의 submodule을 업데이트하고 재빌드해야 한다는 것. 2인 팀인 HoBom에서는 이 비용이 관리 가능하지만, 팀이 커지면 proto 변경의 호환성 관리(breaking change 방지)가 별도의 프로세스로 필요해질 수 있겠습니다.
NestJS에서 gRPC 서버 구성하기
NestJS는 하나의 애플리케이션에서 REST와 gRPC를 동시에 운영하는 하이브리드 모드를 공식 지원합니다. HoBom의 메인 서버(for-hobom-backend)가 바로 이 구조입니다.
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
// REST 설정 (Swagger, CORS, ValidationPipe 등)
app.useGlobalPipes(new ValidationPipe({ ... }));
app.useGlobalInterceptors(new ResponseWrapInterceptor());
// gRPC 마이크로서비스 연결
app.connectMicroservice(grpcOptions);
await app.startAllMicroservices(); // gRPC 서버 시작
await app.listen(8080); // REST 서버 시작
}
connectMicroservice에 전달하는 gRPC 옵션은 다음과 같습니다.
// options.grpc.ts
export const grpcOptions: MicroserviceOptions = {
transport: Transport.GRPC,
options: {
url: "0.0.0.0:50051",
package: ["outbox.message", "outbox.log", "outbox.law", "law"],
protoPath: [
join(__dirname, "../../../hobom-buf-proto/message/outbox/v1/find-hobom-message-outbox.proto"),
join(__dirname, "../../../hobom-buf-proto/law/outbox/v1/find-hobom-law-outbox.proto"),
join(__dirname, "../../../hobom-buf-proto/law/v1/save-study-material.proto"),
// ...
],
},
};
package에 지정한 이름이 proto 파일의 package 선언과 일치해야 합니다. 하나라도 어긋나면 서비스 바인딩이 실패합니다. 이 부분은 처음 설정할 때 꽤 헤맸던 기억이 있습니다. gRPC 컨트롤러는 REST 컨트롤러와 거의 동일한 형태로 작성합니다.
@Controller()
@UseGuards(GrpcApiKeyGuard)
export class FindLawOutboxController {
constructor(
@Inject(DIToken.OutboxModule.FindLawOutboxByEventTypeAndStatusUseCase)
private readonly useCase: FindLawOutboxByEventTypeAndStatusUseCase,
) {}
@GrpcMethod("FindHoBomLawOutboxController", "FindOutboxByEventTypeAndStatusUseCase")
public async findBy(request: {
eventType: string;
status: string;
}): Promise<{ items: FindLawOutboxResultDto[] }> {
const outbox = await this.useCase.invoke(
request.eventType as EventType,
request.status as OutboxStatus,
);
return { items: outbox.map(FindLawOutboxResultDto.from) };
}
}
@GrpcMethod 데코레이터의 첫 번째 인자는 proto에 정의된 service 이름, 두 번째는 rpc 메서드 이름입니다. REST의 @Get, @Post가 HTTP 메서드와 경로를 매핑하듯, @GrpcMethod는 proto 정의와 핸들러를 매핑합니다.
gRPC 인증 → Metadata vs HTTP Header
REST에서 인증은 HTTP 헤더로 처리합니다. Authorization: Bearer 혹은 x-api-key: 형태로. gRPC에서는 Metadata라는 개념이 이 역할을 대신합니다. Metadata는 HTTP/2 헤더의 추상화로, Key ↔ Value 쌍으로 구성됩니다. HoBom에서는 서비스 간 gRPC 인증에 API 키를 사용합니다. 클라이언트(Go 서비스)가 Metadata에 x-api-key를 담아 보내고, 서버(NestJS)의 Guard가 이를 검증합니다.
// gRPC API Key Guard
@Injectable()
export class GrpcApiKeyGuard implements CanActivate {
constructor(private readonly configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
// REST에서는 context.switchToHttp().getRequest()
// gRPC에서는 context.switchToRpc().getContext<Metadata>()
const metadata = context.switchToRpc().getContext<Metadata>();
const apiKey = metadata.get("x-api-key")[0] as string | undefined;
const expected = this.configService.getOrThrow<string>("HOBOM_GRPC_API_KEY");
if (
apiKey == null ||
apiKey.length !== expected.length ||
!timingSafeEqual(Buffer.from(apiKey), Buffer.from(expected))
) {
throw new UnauthorizedException("Invalid gRPC API key");
}
return true;
}
}
여기서 주목할 점은 timingSafeEqual의 사용입니다. 단순 문자열 비교(===)는 타이밍 공격(timing attack)에 취약합니다. 문자열을 앞에서부터 비교하다가 불일치가 발견되는 시점에 즉시 반환하기 때문에, 공격자가 응답 시간을 측정하여 키를 한 글자씩 추론할 수 있습니다. timingSafeEqual은 입력의 모든 바이트를 항상 비교하여 이를 방지합니다. 이것은 REST든 gRPC든 동일하게 적용해야 하는 보안 원칙이지만, gRPC의 Metadata 처리에서 특히 놓치기 쉬운 부분입니다. REST Guard와 gRPC Guard를 비교하면 구조적 차이가 명확합니다.
// REST: HTTP Request에서 헤더 추출
const request = context.switchToHttp().getRequest();
const apiKey = request.headers["x-api-key"];
// gRPC: Metadata에서 키 추출
const metadata = context.switchToRpc().getContext<Metadata>();
const apiKey = metadata.get("x-api-key")[0];
NestJS의 ExecutionContext가 이 차이를 추상화해 주지만, Guard를 작성할 때는 어떤 transport인지 명시적으로 구분해야 합니다. 하나의 Guard로 REST와 gRPC를 동시에 처리하려는 시도는 코드를 복잡하게 만들 뿐, 실질적인 이점이 없었습니다.
에러 핸들링: HTTP Status vs gRPC Status
REST와 gRPC의 에러 처리 방식은 근본적으로 다릅니다. REST는 HTTP 상태 코드를 사용합니다. 400, 401, 404, 500 등 수십 개의 상태 코드가 존재하고, 각각의 의미가 HTTP 스펙에 정의되어 있습니다. 하지만 실무에서는 200에 에러 메시지를 담아 보내는 경우도 흔하고, 같은 400이라도 팀마다 의미가 다릅니다. gRPC는 자체 상태 코드 체계를 가지고 있습니다. 17개의 명확한 상태 코드가 전부입니다. HoBom에서 gRPC 에러는 RpcException으로 던집니다.
// gRPC 컨트롤러에서의 에러 처리
@GrpcMethod("SaveStudyMaterialController", "SaveStudyMaterial")
public async saveStudyMaterial(request: SaveStudyMaterialRequest): Promise<void> {
if (!request.diffId || !request.summary) {
throw new RpcException({
code: status.INVALID_ARGUMENT,
message: "diffId와 summary는 필수입니다.",
});
}
try {
await this.studyMaterialPersistencePort.save({ ... });
} catch (error) {
throw new RpcException({
code: status.INTERNAL,
message: "학습자료 저장에 실패했어요.",
});
}
}
REST에서는 HttpException과 그 서브클래스들(BadRequestException, NotFoundException 등)을 사용합니다.
// REST 컨트롤러에서의 에러 처리
throw new BadRequestException("잘못된 요청입니다.");
여기서 실수하기 쉬운 부분이 있습니다. gRPC 컨트롤러에서 HttpException을 던지면, 클라이언트는 의미 있는 에러를 받지 못합니다. NestJS의 gRPC transport는 RpcException만 정상적으로 처리합니다. 이것은 처음 gRPC를 도입할 때 실제로 겪었던 문제입니다. 반대로, REST 컨트롤러에서 RpcException을 던져도 마찬가지입니다. 각 transport에 맞는 예외 타입을 사용해야 합니다. 이 차이를 인지하지 못하면, 에러 로그에는 찍히는데 클라이언트에게는 UNKNOWN 에러만 전달되는 상황에 빠질 수 있습니다.
실전에서 느낀 차이들
디버깅
REST API는 cURL이나 Postman으로 바로 테스트할 수 있습니다. 하지만 gRPC는 그렇지 않습니다. 바이너리 프로토콜이기 때문에 일반적인 HTTP 도구로는 호출할 수 없고, grpcurl이나 Postman의 gRPC 지원, 혹은 BloomRPC 같은 전용 도구가 필요합니다.
# REST는 이렇게 간단하게
curl -X POST <http://localhost:8080/api/v1/privacy-law/ask> \\
-H "Content-Type: application/json" \\
-d '{"question": "개인정보 수집 동의 기준은?"}'
# gRPC는 별도 도구가 필요
grpcurl -plaintext \\
-d '{"eventType": "LAW_CHANGED", "status": "PENDING"}' \\
-H 'x-api-key: hobom-grpc-internal-key-2024!' \\
localhost:50051 outbox.law.FindHoBomLawOutboxController/FindOutboxByEventTypeAndStatusUseCase
개발 초기에 이 불편함이 꽤 컸습니다. 하지만 시간이 지나면서 깨달은 것은, gRPC 통신을 직접 디버깅해야 하는 상황 자체가 적어야 정상이라는 것이었습니다. proto 파일이 계약을 보장하고, 타입이 맞으면 대부분 정상 동작합니다. 디버깅이 필요한 시점은 비즈니스 로직의 문제이지, 통신의 문제가 아닌 경우가 대부분이었습니다.
브라우저 호환성
gRPC는 HTTP/2의 트레일러(trailer)에 의존하는데, 브라우저는 이를 지원하지 않습니다. 따라서 브라우저에서 gRPC를 직접 호출하려면 gRPC-Web이라는 프록시 레이어가 필요합니다. HoBom에서는 이 문제를 단순하게 해결했습니다. 브라우저가 닿는 곳은 REST, 서버끼리만 통신하는 곳은 gRPC. 경계를 명확히 나눈 것입니다. gRPC-Web을 도입하면 브라우저에서도 gRPC를 쓸 수 있지만, 그로 인한 인프라 복잡도 증가가 정당화될 만큼의 이점이 현 시점에서는 보이지 않았습니다.
버전 관리
REST API의 버전 관리는 /api/v1/, /api/v2/ 처럼 URI에 버전을 넣거나, Accept 헤더에 버전을 명시하는 방식이 일반적입니다. 하지만 실무에서 v2를 깔끔하게 운영하는 팀은 드뭅니다. 대부분 v1에 필드를 계속 추가하다가, 더 이상 수습이 안 되면 v2를 만듭니다. 혹은 v2를 만들지 않고, v1을 유지한채 엔드 포인트만 바꾸는 경우가 대다수입니다. gRPC(protobuf)는 필드 번호 기반의 하위 호환성을 제공합니다.
message StudyMaterialRequest {
repeated ArticleChange changes = 1; // 기존 필드
string lawId = 2; // 새 필드 (기존 클라이언트는 무시)
}
필드 번호 1은 절대 바뀌지 않습니다. 새 필드를 추가할 때는 새로운 번호를 부여하면 됩니다. 기존 클라이언트는 모르는 필드를 자동으로 무시합니다. 이 규칙만 지키면, 대부분의 변경이 하위 호환을 유지합니다. 다만 필드를 삭제하거나 타입을 변경하면 호환이 깨집니다. 이때는 reserved 키워드로 해당 번호를 예약하여 재사용을 방지해야 합니다. 이 부분은 팀 규모가 커질수록 중요해질 것 같습니다.
gRPC를 도입해야 하는가?
이 글을 읽고 "gRPC를 도입해야겠다"고 생각했다면, 한 가지 질문을 먼저 던져야 합니다.
서비스 간 계약이 현재 문제인가?
모놀리스 서비스이거나, 서비스가 2~3개이고 모두 같은 언어로 되어 있다면, gRPC의 이점은 제한적입니다. REST + OpenAPI 스펙으로 충분할 수 있습니다. 반면, 다음 조건 중 하나라도 해당한다면 gRPC를 고려할 가치가 있습니다.
- 다중 언어 환경: 서비스마다 런타임이 다를 때, proto 파일이 언어 중립적 계약서 역할을 합니다.
- 높은 내부 통신 빈도: 서비스 간 초당 수백~수천 건의 호출이 발생할 때.
- 엄격한 스키마 관리: 서비스 간 계약 변경이 빌드 타임에 검증되어야 할 때.
HoBom은 세 가지 모두에 해당했습니다. NestJS, Go, Kotlin, C#이라는 네 가지 언어를 사용하고 있었고, Outbox 패턴의 polling으로 높은 빈도의 내부 통신이 발생했으며, 2인 팀이기에 오히려 암묵적 계약이 아닌 명시적 계약이 필요했습니다.
회고
HoBom의 gRPC 도입이 모든 면에서 성공적이었다고 말하기는 어렵습니다.
gRPC Guard에서 HttpException을 던져서 클라이언트가 UNKNOWN 에러만 받았던 적도 있습니다. 이 실수는 REST에 익숙한 상태에서 gRPC를 작성했기 때문에 발생한, 꽤나 흔한 실수입니다.
그리고 무엇보다, 사이드 프로젝트에 REST + gRPC 이중 transport가 정말 필요한가?라는 질문에는 여전히 확실한 답을 가지고 있지 않습니다. REST만으로 충분히 동작했을 수 있고, 오히려 gRPC 때문에 발생한 디버깅 복잡도가 더 컸을 수도 있습니다.
하지만 이 경험을 통해 확실히 깨달은 점이 있습니다. REST와 gRPC의 차이는 단순한 성능이나 프로토콜의 문제가 아니라, 서비스 간 소통을 어떻게 설계할 것인가라는 아키텍처 문제라는 것.
그리고 그 설계는 팀의 규모, 서비스의 복잡도, 운영 환경에 따라 달라져야 한다는 것.
기술 선택에 정답은 없지만, 그 선택의 이유를 설명할 수 있는가는 엔지니어의 몫이라고 생각합니다.