개인 프로젝트로 주문 처리량 6.4배 개선하기 (gRPC + Go)
개인 프로젝트로 주문 처리량 6.4배 개선하기 (gRPC + Go)
moseoh
python go

개인 프로젝트로 주문 처리량 6.4배 개선하기 (gRPC + Go)

moseoh · 2026년 06월 15일

실무에서 초당 수천 건씩 쏟아지는 트래픽을 다뤄볼 일은 흔치 않습니다. 저도 그래어요. 그래서 직접 만들어보기로 했습니다. 선착순 핫딜 시스템을 MSA로 짜고, k6로 부하를 걸어 일부러 병목을 만들어본 거죠.

그중 가장 끌금셨던 건 **주문 생성(POST /orders)**이었습니다. make load-order-stress로 50 VU만 걸어도 처리량이 46 req/s에서 막혔거든요. 이 글은 그 병목을 한 번에 하나씩 걷어낸 기록입니다. 흥미로웠던 건, 한 곣을 풀 때마다 병목이 다른 곣으로 옮겨 다녘다는 점이에요.

모든 단계는 추측이 아니라 측정으로 확인했습니다. 특히 CPU 사용량이 병목의 위치를 그대로 보여주는 지표였어요.

출발점: 어디서 막히는가

기존 시스템(HTTP/JSON 통신)의 측정값입니다.

지표
p95 응답시간867ms
평균 응답시간626ms
처리량46 req/s
에러율0%
컨테이너CPU
------
order86%
product40%

Order Service의 CPU가 86%로 거의 포화 상태였습니다. 구조를 보니 매 주문마다 Order가 Product Service를 HTTP로 호출해 재고를 확인·차감하고 있었어요. 50 VU에서 이미 천장에 닿은 거죠.

첫 가설은 단순했습니다. “HTTP 호출이 비싸다. 커넥션을 재사용하면 나아지지 않을까?”

첫 시도, 그리고 실패: 커넥션 풀

전역 httpx.AsyncClient를 만들어 커넥션 풀을 재사용했습니다. (max_connections=100, max_keepalive_connections=20)

지표BeforeAfter개선
p95 응답시간867ms912ms-5%
처리량46 req/s45 req/s-2%

효과가 없었습니다. 오히려 미세하게 나빠졌어요. 왜 안 됐을까 들여다보니 두 가지였습니다.

  • Order→Product 호출이 DB 트랜잭션과 함께 묶여 처리돼서, 커넥션을 아껴도 정작 대기는 트랜잭션 쪽에서 발생했습니다.
  • 더 근본적으로 HTTP/1.1의 Head-of-line blocking — 한 연결에서 앞 요청이 끝나야 다음이 진행됩니다. 커넥션 “개수”가 아니라 통신 프로토콜 자체가 문제였던 거죠. 실패였지만, 덕분에 다음 방향은 분명해졌습니다.

개선 1 — gRPC: 통신을 바꾸다

HTTP/1.1의 한계가 보였으니, 서비스 간 통신을 gRPC로 바꿨습니다.

  • HTTP/2 멀티플렉싱으로 단일 연결에서 다중 요청을 동시에 처리 (HOL blocking 해소)
  • Protobuf 바이너리 직렬화로 JSON 파싱 오버헤드 제거 JSON으로 주고받던 서비스 간 계약을 Protobuf로 다시 정의했습니다.
api/proto/product.proto
service ProductService {
rpc GetProduct(GetProductRequest) returns (Product);
rpc GetDeal(GetDealRequest) returns (Deal);
// 재고 변경 (delta: 양수면 증가, 음수면 감소)
rpc UpdateStock(UpdateStockRequest) returns (Product);
}
지표BeforeAfter
p95 응답시간867ms367ms
처리량46 req/s125 req/s
컨테이너BeforeAfter
---------
order86%64%
product40%79%

처리량이 2.7배 뛰었습니다. 그런데 더 중요한 신호는 CPU였어요. 병목이 Order(64%)에서 Product(79%)로 옮겨갔습니다. Order의 통신 부담이 줄자, 이번엔 Product가 요청을 받아내느라 바빨진 거죠.

개선 2 — Uvicorn 워커 2개: GIL을 우회하다

Product가 새 병목인데 CPU를 많이 씁니다. Python GIL 탓에 단일 프로세스가 한 코어만 쓰는 게 의심됐어요. Order/Product 둘 다 2워커로 띄워 별도 프로세스로 GIL을 우회했습니다.

지표BeforeAfter
p95 응답시간367ms224ms
처리량125 req/s208 req/s
컨테이너BeforeAfter
---------
order64%100%
product79%120%

처리량은 늘었지만, 여기서 트레이드오프가 분명해집니다. CPU가 order 100%, product 120%로 치솟았어요. 멀티코어를 쓰기 시작했다는 뜻이자, 처리량을 CPU로 산 셋입니다. 더 긁어낼 코어가 없으면 곱 천장이죠.

개선 3 — Product를 Go로: 런타임을 바꾸다

처리량을 CPU로 사는 방식은 한계가 명확하니, 같은 일을 더 적은 CPU로 하는 쪽으로 방향을 틀었습니다. 병목인 Product Service를 Go로 재작성했어요. (gRPC 서버로 GetProduct, GetDeal, UpdateStock 구현)

지표BeforeAfter
p95 응답시간224ms154ms
처리량208 req/s253 req/s
컨테이너BeforeAfter
---------
order100%100%
product120%20%

처리량 증가폭은 작았지만(22%), 숫자 하나가 눈에 띄니다. Product CPU가 120%에서 20%로. 같은 부하를 1/6 자원으로 처리한 거예요. 그리고 예상대로 병목은 다시 Order(100%)로 넘어갔습니다.

개선 4 — Order도 Go로: 마지막 병목

이제 패턴이 보입니다. 마지막 병목인 Order Service도 Go로 재작성했습니다. (Echo 프레임워크 기반 HTTP API + Product 호출용 gRPC 클라이언트)

Order는 Echo 프레임워크로, Product 호출은 gRPC 클라이언트로 정리했습니다. 여기서 한 가지 재미있는 점이 있어요. 도입부에서 실패했던 “커넥션 재사용”이 여기서는 자연스럽게 통합니다. gRPC 클라이언트는 sync.Once로 단일 연결을 만들어 HTTP/2 위에서 멀티플렉싱하거든요. 같은 “커넥션 재사용”이라도, 프로토콜이 받쳐줘야 효과가 난다는 걸 코드로 확인한 셋이죠.

services/order/go/internal/product/client.go
var (
client proto.ProductServiceClient
conn *grpc.ClientConn
clientOnce sync.Once
)
// 커넥션을 한 번만 생성해 재사용한다 (HTTP/2 멀티플렉싱)
func InitClient(addr string, otelEnabled bool) error {
var initErr error
clientOnce.Do(func() {
// ... DialOption 구성 ...
conn, initErr = grpc.NewClient(addr, opts...)
client = proto.NewProductServiceClient(conn)
})
return initErr
}
// 재고 차감: gRPC 호출. 재고 부족은 gRPC status code로 구분한다
func DecreaseStock(ctx context.Context, productID uuid.UUID, quantity int32) (*StockResult, error) {
resp, err := client.UpdateStock(ctx, &proto.UpdateStockRequest{
ProductId: productID.String(),
Delta: -quantity, // 음수 = 감소
})
if err != nil {
st, ok := status.FromError(err)
if ok && st.Code() == codes.FailedPrecondition {
return nil, &ClientError{Code: "INSUFFICIENT_STOCK", Message: "재고가 부족합니다.", Status: 400}
}
return nil, handleGRPCError(err, "Product", productID.String())
}
return &StockResult{ProductID: resp.Id, Stock: resp.Stock}, nil
}
지표BeforeAfter
p95 응답시간154ms20ms
처리량253 req/s295 req/s
컨테이너BeforeAfter
---------
order100%15%
product20%16%

p95가 154ms에서 20ms로, 마지막에 가장 큰 응답시간 점프가 나왔습니다. 그리고 두 서비스 CPU가 모두 15~16%로 내려앉았어요. 더 이상 어느 쪽도 포화가 아닙니다 — 병목이 사라진 거죠.

전체를 한 장으로

단계처리량p95 응답Product CPUOrder CPU
기존 (HTTP)46 req/s867ms40%86%
개선 1 (gRPC)125 req/s367ms79%64%
개선 2 (2워커)208 req/s224ms120%100%
개선 3 (Go Product)253 req/s154ms20%100%
개선 4 (Go Order)295 req/s20ms16%15%

표를 세로로 읽으면 이야기가 보입니다. 병목이 Order → Product → (둘 다 포화) → Order → 해소로 옮겨 다녘고, CPU 열이 그 위치를 매 단계 정확히 가리켰습니다.

같은 프로젝트의 다른 병목들

주문 처리량은 이 시스템에서 잡은 여러 병목 중 하나였을 뿐입니다. 같은 방식 — 부하로 재현하고, 측정하고, 한 단계씩 — 으로 다룬 다른 병목들도 간단히 소개할게요. (각 상세는 링크에 정리해뒀습니다.)

회고

이건 실무 장애 대응기가 아니라, 트래픽을 만질 기회가 없어 직접 만들어 부하를 걸어본 학습 프로젝트입니다. 그래서 오히려 마음껏 갈아엎으며 배울 수 있었어요. 남은 건 세 가지입니다.

  • CPU는 거짓말을 안 한다. “어디가 병목인가”를 두고 추측할 필요가 없었습니다. 매 단계 CPU 사용량이 다음에 손볼 곳을 가리켰거든요. 측정 가능한 부하 테스트 하나만 있으면, 개선은 추측이 아니라 추적이 됩니다.
  • 병목은 없애는 게 아니라 옮기는 것에 가깝다. 하나를 풀면 다음이 드러납니다. 그래서 “한 번에 하나씩 고치고 다시 측정”이 결국 가장 빨랐어요.
  • 언어·프로토콜 교체는 만병통치약이 아니라 트레이드오프다. gRPC는 통신 병목에, Go 전환은 CPU 병목에 정확히 들어맞았습니다. 가장 큰 한 방(gRPC, 2.7배)도 결국 “문제의 모양”을 먼저 본 덕분이었고요. 비슷한 시스템을 다루는 분이라면, 거창한 재설계 전에 재현 가능한 부하 테스트와 CPU 모니터링부터 붙여보길 권하고 싶어요. 병목은 결국 자기 위치를 드러내거든요.

전체 코드와 단계별 측정: https://github.com/moseoh/flash-deals/blob/main/docs/scenarios/order-tps-limit.md