MSA 는 왜 gRPC 를 쓰는가: gRPC vs REST 실측

MSA 는 왜 gRPC 를 쓰는가: gRPC vs REST 실측

Java 21 · Spring Boot 3.5 에서 같은 도메인 코어에 전송 방식만 바꿔 끼우고 측정했습니다. 단건 조회에서 gRPC 가 더 느렸고, 조건과 무관하게 남는 것은 커넥션과 계약이었습니다.

MSA 는 왜 gRPC 를 쓰는가: gRPC vs REST 실측

문제
MSA 에서 서비스 간 통신을 gRPC 로 두는 이유는 보통 속도로 설명됩니다.
그런데 그 근거로 “7~10배 빠르다”와 “작은 페이로드에서는 차이가 없다”가 함께 돌아다닙니다.

결과
단건 조회에서 gRPC 가 더 느렸습니다. REST 의 0.88배, 가상 스레드 REST 대비 0.76배입니다.
빨라지는 구간은 다건뿐이고 1,000건에서 1.56배입니다.
조건과 무관하게 남은 것은 커넥션 50배 절감과 스키마 계약이었습니다.

범위
측정 환경은 Java 21 · Spring Boot 3.5 입니다. 기준선으로 쓴 가상 스레드가 Java 21 기능이라 스레드 모델 비교는 이 스택에 묶입니다.
위 배수는 DB 를 뺀 인메모리 구성에서 나온 값입니다. 프로토콜 오버헤드의 상한이고 실제 서비스에서는 DB 시간에 묻힙니다.
그래서 gRPC 를 쓸지가 아니라 어떤 조건에서 이득이 남는지를 다룹니다.

1. 속도 때문일까?

MSA 에서 서비스 간 통신을 gRPC 로 두는 이유를 찾으면 대개 속도가 먼저 나옵니다. 그런데 그 근거로 붙는 수치가 “REST 보다 7~10배 빠르다” 와 “작은 페이로드에서는 차이가 없다” 로 나뉩니다. 둘 다 맞는 말이라면 각각 맞는 조건이 서로 다른 것입니다. 그 조건을 모르면 도입 판단이 먼저 읽은 글에 끌려갑니다.

찾아본 결과가 예상과 달랐습니다. gRPC 는 2015년 기술이고 Kubernetes 는 2022년부터 1급으로 다뤘습니다. 그렇게 다룰 이유가 있다는 뜻인데, 10년이 지나도 채택 근거의 수치가 정리되어 있지 않다는 것은 그 이유가 속도만은 아닐 가능성을 뜻합니다. 속도라고 짚을 근거도 헤더와 필드명을 덜 실으니 전송 바이트가 줄어 빠를 것이라는 추측 수준이었습니다.

그래서 두 구성이 어디서 다른지부터 봤습니다.

2. 두 구성의 차이

전송과 직렬화가 짝을 이룬 두 구성입니다. 항목별로 비교해 두면 뒤의 결과가 어디서 오는지 읽힙니다.

 REST 구성gRPC 구성
전송HTTP/1.1HTTP/2
직렬화JSON (자기 기술적)Protobuf (스키마 의존)
커넥션당 동시 요청1건스트림으로 다중화
페이로드에 실리는 것필드명 + 구조 + 값태그 번호 + 값
계약 위반 검출 시점런타임컴파일 (스텁 재생성)
브라우저 직접 호출가능불가 (trailer 제약)

커넥션당 1건이 뒤에서 결론을 뒤집는 조건입니다. HTTP/1.1 은 한 커넥션에 요청 하나를 실어 보내고 응답을 기다립니다. 그래서 동시 요청 수만큼 커넥션이 필요합니다. HTTP/2 는 한 커넥션 안에서 스트림을 나눠 동시에 실어 보냅니다.

HTTP/1.1   동시 요청 3건 = 커넥션 3개
  conn 1   [req1] -> [res1]
  conn 2   [req2] -> [res2]
  conn 3   [req3] -> [res3]

HTTP/2     동시 요청 3건 = 커넥션 1개
  conn 1   [req1] -> [res1]   stream 1
           [req2] -> [res2]   stream 2
           [req3] -> [res3]   stream 3

브라우저 직접 호출 불가는 프런트엔드 대면 구간의 제약입니다. gRPC 는 호출 상태(grpc-status 성공·오류 코드)를 HTTP/2 trailer 에 싣는데, trailer 는 본문 뒤에 오는 헤더라 브라우저 fetch 로 읽을 수 없습니다. 그래서 항상 중간 계층(grpc-gateway 같은 변환 계층)이 필요합니다.

확인 대상은 표에서 나왔습니다.

  1. 얼마나 빠른가
    페이로드 크기별로 나눠 차이가 커지는 구간과 사라지는 구간을 찾습니다.
  2. 무엇이 빠르게 만드는가
    Protobuf 직렬화인지 HTTP/2 다중화인지 구분합니다. 둘을 묶어 두면 “넣었는데 안 빨라졌다”의 원인을 짚을 수 없습니다.

3. 어떻게 측정할까?

측정 조건을 후보와 함께 적습니다. 선택하지 않은 쪽을 밝히지 않으면 결과가 조건에 얼마나 매여 있는지 알 수 없습니다.

항목후보선택사유
기준선플랫폼 스레드 REST 만 가상 스레드를 켠 REST 가 오늘의 현실적 기준선인데 그것과 비교하지 않으면 gRPC 이득이 부풀려집니다
 플랫폼 + 가상 둘 다같은 빌드에서 세 구성을 측정합니다
측정 지점프로토콜 직접 호출만 부하 도구의 클라이언트 구현이 결과를 지배합니다
 직접 + 애플리케이션 경유인용 값은 전부 경유 측정입니다
데이터 계층실제 DB DB 시간이 프로토콜 차이를 덮습니다
 인메모리 고정프로토콜 오버헤드의 상한을 봅니다
부하 모델동시 사용자 고정 서버가 느려지면 부하도 줄어 과부하 구간을 못 봅니다
 요청률 고정커넥션 회차에 적용

두 번째 항목은 실제로 사고가 났던 자리입니다. 부하 도구가 Protobuf 를 동적으로 파싱하는 비용 때문에 “gRPC 가 3.3배 느리다”는 잘못된 결론이 먼저 나왔습니다.

전송만 바꿨다는 근거

헥사고날 아키텍처라 도메인과 유스케이스는 그대로 두고 아웃포트 어댑터만 바꿔 측정했습니다.

public interface ProfileLookupPort {
  Transport transport();          // 이 구현이 담당하는 전송 방식
  LookupResult list(int size);
}
// REST 구현: 선언형 HTTP 클라이언트에 위임
public class ProfileLookupRestPortImpl implements ProfileLookupPort {
  private final ProfileHttpClient profileHttpClient;
  public Transport transport() { return Transport.REST; }
}

// gRPC 구현: 생성된 스텁에 위임
public class ProfileLookupGrpcPortImpl implements ProfileLookupPort {
  private final ProfileServiceGrpc.ProfileServiceBlockingStub profileStub;
  public Transport transport() { return Transport.GRPC; }
}

커넥션 조건을 맞춘 근거

gRPC 채널은 커넥션과 스트림 다중화를 스스로 관리합니다. REST 쪽을 기본 설정으로 두면 gRPC 에만 재사용 이득이 생깁니다. 그래서 양쪽 다 명시했습니다.

// REST: 커넥션 풀 상한을 설정값으로 고정
PoolingHttpClientConnectionManagerBuilder.create()
    .setMaxConnTotal(properties.maxConnections())
    .setMaxConnPerRoute(properties.maxConnections())
    .build();

// gRPC: 채널 하나를 애플리케이션 수명 동안 재사용
ManagedChannelBuilder.forAddress(properties.grpcHost(), properties.grpcPort())
    .usePlaintext()   // 양쪽 모두 평문. TLS 핸드셰이크가 차이에 섞이지 않게
    .build();

4. 페이로드 크기별 결과

Java 21 · 50 VU · 30초 · 단일 호스트(M2 Pro 12코어 · 16GB)입니다. 상류는 호출 측이 측정한 피호출 서비스 왕복 시간입니다. 워밍업은 별도로 실행하고 회차마다 서버를 새로 띄웠습니다. 앞선 부하가 늘려놓은 스레드가 남은 JVM 에서 측정하면 같은 조건이 12% 낮게 나옵니다.

가상 스레드는 OS 스레드를 점유하지 않고 JVM 이 스케줄하는 경량 실행 단위입니다. 같은 동시 요청 50에서 플랫폼 스레드는 38~50개까지 늘어나는데 가상 스레드는 캐리어 12개로 고정됐습니다. 그래서 스레드 수가 병목인 구간에서만 이득이 나옵니다.

지표REST 플랫폼REST 가상gRPC우위
처리량 (응답 1건)27,030 req/s31,136 req/s23,698 req/sREST 가상
처리량 (응답 1,000건)8,153 req/s8,085 req/s12,685 req/sgRPC
상류 p95 (응답 1건)1.33 ms1.18 ms2.48 msREST 가상
상류 p95 (응답 1,000건)8.36 ms9.84 ms4.96 msgRPC
상류 max (응답 1건)119.4 ms35.5 ms125.0 msREST 가상
상류 max (응답 1,000건)335.7 ms239.6 ms50.8 msgRPC
TCP 커넥션50개50개1개gRPC
요청당 CPU 시간 (응답 1,000건)360.0 µs337.9 µs203.5 µsgRPC
요청당 전송량기준기준약 2.5배 절감gRPC

9개 지표 중 gRPC 가 6개, 가상 스레드 REST 가 3개에서 우위입니다. 가상 스레드가 우위인 3개는 모두 단건이고, gRPC 가 우위인 6개는 다건 3개와 페이로드 크기에 무관한 3개(커넥션·CPU·전송량)입니다. 플랫폼 스레드 REST 는 한 항목도 없습니다.

배수로 보면 단건은 플랫폼 대비 0.88배, 가상 대비 0.76배입니다. 기준선을 플랫폼으로 잡으면 gRPC 이득이 커 보입니다. 1,000건은 1.56배와 1.57배로 기준선과 무관한데, 직렬화 비용은 스레드 모델로 줄지 않기 때문입니다.

가상 스레드는 단건에서만 이득입니다. 1건에서 처리량 1.15배, 최대 지연 3.4배 개선인데 1,000건에서는 0.99배로 사라집니다. 병목이 스레드 모델에서 직렬화 CPU 로 옮겨가면 손댈 곳이 없습니다.

5. 커넥션이 모자라면 뒤집힌다

앞 회차는 풀 200 에 동시 요청 50 이라 커넥션이 병목에서 멀어서, 50개 대 1개라는 차이가 지연에도 처리량에도 나타나지 않았습니다. 그래서 이번에는 커넥션을 제한한 상태로 비교했습니다. 부하를 올리면 CPU 포화와 도구 경합이 섞이므로 부하는 그대로 두고 커넥션 예산만 양쪽 1개로 낮췄습니다.

부하는 k6 로 걸었습니다. 실행 모델을 요청률 고정으로 바꿨고, 버린 요청이 과부하 지표가 됩니다.

scenarios: {
  openLoop: {
    executor: 'constant-arrival-rate',   // 응답과 무관하게 초당 rate 만큼 요청
    rate: RATE,
    timeUnit: '1s',
    duration: __ENV.DURATION || '15s',
    preAllocatedVUs: Number(__ENV.PRE_VUS || 50),
    // 응답이 밀려 VU 가 모두 점유되면 k6 가 요청을 버리고
    // dropped_iterations 로 센다. 그 값이 과부하 지표가 된다.
    maxVUs: Number(__ENV.MAX_VUS || 400),
  },
}

15초 회차이므로 버린 요청의 모수는 요청률 × 15 입니다. 회차가 짧아 p99 이상은 표본이 부족하므로 p95 까지만 봅니다.

요청률 목표전송달성 요청률상류 p50상류 p95버린 요청
6,4001REST6,316 req/s72 µs1,795 µs1,259
6,4001gRPC6,373 req/s79 µs245 µs402
9,6001REST8,812 req/s33,394 µs61,987 µs11,631
9,6001gRPC9,494 req/s129 µs1,728 µs1,582
12,8001REST9,043 req/s40,396 µs54,690 µs56,008
12,8001gRPC12,625 req/s156 µs3,806 µs2,615
12,8004REST12,328 req/s118 µs20,862 µs6,747

커넥션 1개당 REST 상한은 약 9,000 req/s 입니다. 12,800 을 줘도 9,043 에서 멈추고 56,008건(목표 대비 29%)을 버립니다. 같은 목표에서 gRPC 가 버린 요청은 2,615건(1.4%)입니다.

gRPC 쪽 상한은 이 회차에서 찾지 못했습니다. 12,800 구간에서 피호출 서비스 CPU 가 15초 부하에 15.7초, 코어 하나를 채우는 수준이었습니다. 그 위는 커넥션이 아니라 CPU 에 먼저 막히므로 gRPC 의 커넥션 한계는 이 조건에서 관측되지 않습니다.

차이는 꼬리 지연에서 먼저 나타납니다. 6,400 구간에서 p50 은 거의 같은데(72 대 79 µs) p90 에서 2.5배, p95 에서 7.3배로 뒤로 갈수록 벌어집니다. 중앙값으로는 커넥션 포화를 판정할 수 없습니다.

REST 는 커넥션을 늘려 되찾지만 꼬리는 남습니다. 풀 4개로 12,328 req/s 까지 회복하고 p50 도 118 µs 로 정상인데 p90 이 10.3 ms 로 87배입니다. 중앙값만 걸린 대시보드에서는 정상으로 읽히는 상태입니다. p95 는 gRPC 커넥션 1개의 3.8 ms 대 20.9 ms 로 5.5배 차이입니다.

결론은 우열이 아니라 교환 조건입니다. 같은 처리량을 REST 는 커넥션 4개로, gRPC 는 1개로 냅니다. 커넥션이 싼 환경에서는 풀을 키우면 됩니다. 다만 여러 호출자가 한 서비스로 몰리는 팬인 구조에서는 호출자 인스턴스 수만큼 이 배수가 곱해집니다. 인스턴스가 수백 개면 피호출 쪽 파일 디스크립터와 소켓 메모리와 로드밸런서 커넥션 한계에 그대로 걸립니다.

풀 1개는 실무 설정이 아니므로 절대 처리량보다 배수를 봐야 합니다. 그리고 크기 조건과 커넥션 조건은 독립입니다. 응답이 작아도 커넥션이 부족하면 gRPC 가 이깁니다.

6. 크기 이득은 직렬화에서 나온다

4장의 전송량 2.5배 절감이 어디서 나오는지 봅니다. JSON 은 필드명과 구조를 데이터마다 함께 싣고, Protobuf 는 스키마를 양쪽이 공유하니 와이어에 태그 번호와 값만 남습니다.

직렬화가 원인인지 확인하려면 전송 계층이 같고 직렬화만 다른 두 경로가 필요합니다. 2장에서 말한 중간 계층 두 방식이 그 조합입니다. grpc-gateway 는 JSON 으로 변환해 넘기고, grpc-web 은 브라우저가 Protobuf 를 그대로 주고받습니다.

경로전송직렬화100건 크기
REST 직접HTTP/1.1JSON13,086 B
grpc-gatewayHTTP/1.1JSON13,286 B (1.02배)
grpc-webHTTP/1.1Protobuf5,086 B (0.39배)

HTTP/2 와 Protobuf 로 통신하고도 마지막에 JSON 으로 직렬화하면 크기 이득이 0 입니다. grpc-gateway 가 그 경우이고, 브라우저 데브툴에서는 REST 와 구별되지 않습니다. protojson 이 int64 를 문자열로 인코딩하기 때문입니다. grpc-gateway 를 쓰는 이유는 성능이 아니라 호환성입니다.

확인한 것은 크기까지입니다. 지연이 HTTP/2 때문인지 Protobuf 때문인지는 구분하지 못했고, 구분하려면 REST 를 h2c(TLS 없는 평문 HTTP/2)로 올린 구성이 하나 더 필요합니다.

7. 그래서 무엇을 고를까?

상황선택근거
단건 조회 + 커넥션 여유REST + 가상 스레드gRPC 가 0.76배로 더 느림
단건 조회 + 커넥션 부족gRPC커넥션 1개당 상한 1.4배, 포화 시 지연 260배
다건·대용량gRPC1.56배, 요청당 CPU 40% 절감
대역폭 비용이 병목gRPC크기 무관 2.5배 절감
API 명세가 자주 바뀐다gRPC스텁을 다시 생성하면 깨진 호출부가 컴파일에서 드러남

REST 가 이기는 구간은 하나입니다. 단건 조회에 커넥션이 넉넉할 때이고, 그때는 가상 스레드 설정 한 줄이 gRPC 도입보다 큰 이득을 냅니다. 계약도 배포 단위도 그대로 두고 얻습니다.

나머지 조건은 전부 gRPC 입니다. 응답이 커지거나 커넥션이 부족하거나 대역폭이 비용이 되거나 명세가 자주 바뀌면 그렇습니다. 규모가 커지면 이 조건들이 대체로 함께 오므로, 판단은 도입 여부가 아니라 순서가 됩니다. 단건 위주면 가상 스레드부터 켜고, 호출자가 수백 개로 늘어 커넥션이 병목이 되는 시점에 gRPC 로 옮깁니다.

8. 마치며

MSA 가 서비스 간 통신에 gRPC 를 쓰는 이유는 속도가 아니었습니다. 속도는 조건부고 단건 조회에서는 REST 가 더 빠릅니다. 조건과 무관하게 남은 것은 커넥션 1개가 나르는 양과 컴파일에서 깨지는 계약이었고, 둘 다 서비스가 많고 서로를 부르는 구조에서만 비용으로 드러납니다. gRPC 를 부르는 것은 속도가 아니라 MSA 라는 조건 자체입니다.

남은 한계. 단일 호스트라 왕복 지연이 거의 0 이고 이 조건은 gRPC 에 불리합니다. DB 가 없어 프로토콜 오버헤드의 상한을 본 셈입니다. 커넥션 회차의 풀 1개는 실무 설정이 아닙니다. 그래서 절대 처리량을 인용하지 않고 배수만 씁니다.

더 자세한 내용은 grpc-vs-rest-lab 에 정리해 두었습니다.

이 글은 Claude와 함께 작업했습니다.

This post is licensed under CC BY 4.0 by the author.