gRPC + Spring Boot 마스터 노트 시리즈 3편. Unary RPC가 1:1 요청-응답으로 가장 기본 패턴인 이유, StreamObserver의 onNext·onCompleted·onError 콜백, BlockingStub·AsyncStub·FutureStub 3 종 차이, Deadline으로 타임아웃 제어, Metadata로 헤더 전달, Reactive Stub(grpc-spring-boot-starter)으로 Mono/Flux 통합까지.
이 글은 gRPC + Spring Boot 마스터 노트 시리즈의 세 번째 편입니다. 2편(Protobuf)에서 스키마를 다졌다면, 이번엔 첫 RPC 모드 — Unary.
가장 기본. HTTP의 요청-응답과 같음. 다만 Stub 3종·StreamObserver·Deadline·Metadata 디테일이 gRPC 고유.
처음 Unary RPC가 어렵게 느껴지는 이유
처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, Stub 3종(Blocking·Async·Future) 차이가 막연합니다. 둘째, StreamObserver의 onNext·onCompleted·onError 패턴이 익숙하지 않습니다.
해결법은 한 가지예요. "Stub 선택 = 코드 스타일". Blocking = 단순, Async = 콜백, Future = CompletableFuture 친화. 모두 같은 RPC, 표현만 다름.
Unary RPC 흐름
Client → Request → Server
← Response ←
HTTP GET/POST와 동일. 1:1.
Proto 정의
service UserService {
rpc GetUser (UserRequest) returns (User);
}
message UserRequest {
string id = 1;
}
message User {
string id = 1;
string name = 2;
}
서버 구현
@GrpcService
public class UserServiceImpl extends UserServiceGrpc.UserServiceImplBase {
@Autowired
private UserRepository repo;
@Override
public void getUser(UserRequest request, StreamObserver<User> responseObserver) {
try {
User user = repo.findById(request.getId())
.map(this::toProto)
.orElseThrow(() -> new NotFoundException(request.getId()));
responseObserver.onNext(user); // 응답 전송
responseObserver.onCompleted(); // 종료
} catch (NotFoundException e) {
responseObserver.onError(
Status.NOT_FOUND.withDescription("User not found").asRuntimeException()
);
}
}
}
StreamObserver 메서드
| 메서드 | 의미 |
|---|---|
onNext(T) |
응답 전송 (Unary는 1번) |
onCompleted() |
정상 종료 |
onError(Throwable) |
에러 |
여기서 정말 중요한 시험 함정 — onNext 후 반드시 onCompleted 또는 onError. 안 부르면 클라이언트 영원히 대기 → 메모리 누수.
클라이언트 — 3 Stub
1. BlockingStub — 동기 (가장 단순)
@Service
public class UserClient {
@GrpcClient("user-service")
private UserServiceGrpc.UserServiceBlockingStub blockingStub;
public User getUser(String id) {
UserRequest request = UserRequest.newBuilder().setId(id).build();
return blockingStub.getUser(request);
}
}
장점 — 단순·익숙. 단점 — 블로킹·WebFlux 부적합.
2. AsyncStub — 비동기 콜백
@GrpcClient("user-service")
private UserServiceGrpc.UserServiceStub asyncStub;
public void getUserAsync(String id, Consumer<User> callback) {
UserRequest request = UserRequest.newBuilder().setId(id).build();
asyncStub.getUser(request, new StreamObserver<User>() {
@Override
public void onNext(User user) {
callback.accept(user);
}
@Override
public void onCompleted() {
log.info("Done");
}
@Override
public void onError(Throwable t) {
log.error("Failed", t);
}
});
}
장점 — 논블로킹. 단점 — 콜백 지옥.
3. FutureStub — Future·CompletableFuture
@GrpcClient("user-service")
private UserServiceGrpc.UserServiceFutureStub futureStub;
public CompletableFuture<User> getUser(String id) {
UserRequest request = UserRequest.newBuilder().setId(id).build();
ListenableFuture<User> future = futureStub.getUser(request);
// ListenableFuture → CompletableFuture
CompletableFuture<User> cf = new CompletableFuture<>();
Futures.addCallback(future, new FutureCallback<User>() {
@Override
public void onSuccess(User result) { cf.complete(result); }
@Override
public void onFailure(Throwable t) { cf.completeExceptionally(t); }
}, MoreExecutors.directExecutor());
return cf;
}
장점 — CompletableFuture 합성.
단점 — 변환 코드 필요.
여기서 시험 함정이 하나 있어요. 3 Stub 모두 같은 RPC. 선택은 코드 스타일. 일반 = Blocking, WebFlux·CompletableFuture = Async/Future.
Reactive Stub — Mono/Flux 통합
grpc-spring-boot-starter는 Reactive 어댑터 제공:
implementation 'com.salesforce.servicelibs:reactor-grpc-stub:1.x'
// reactor-grpc plugin으로 컴파일
service UserService {
rpc GetUser (UserRequest) returns (User);
}
public Mono<User> getUser(String id) {
UserRequest request = UserRequest.newBuilder().setId(id).build();
return reactorStub.getUser(request); // Mono<User>
}
WebFlux 환경에 자연스럽게 통합.
Deadline — 타임아웃
// 클라이언트
User user = blockingStub
.withDeadlineAfter(5, TimeUnit.SECONDS)
.getUser(request);
// 서버 — 클라이언트가 설정한 deadline 확인
@Override
public void getUser(UserRequest request, StreamObserver<User> observer) {
if (Context.current().getDeadline() != null
&& Context.current().getDeadline().isExpired()) {
observer.onError(Status.DEADLINE_EXCEEDED.asRuntimeException());
return;
}
// 정상 처리
}
여기서 정말 중요한 시험 함정 — gRPC Deadline은 호출 체인 전체로 전파. 서비스 A → B → C 호출 시, A의 deadline이 자동으로 B·C까지. 한 곳 timeout = 전체 timeout.
Metadata — 헤더
클라이언트 — 메타데이터 전송
Metadata metadata = new Metadata();
Metadata.Key<String> traceKey = Metadata.Key.of("trace-id", Metadata.ASCII_STRING_MARSHALLER);
metadata.put(traceKey, "trace-123");
User user = blockingStub
.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata))
.getUser(request);
서버 — 메타데이터 받기
@Override
public void getUser(UserRequest request, StreamObserver<User> observer) {
String traceId = ServerInterceptors.getMetadata().get(
Metadata.Key.of("trace-id", Metadata.ASCII_STRING_MARSHALLER)
);
log.info("Trace: {}", traceId);
// ...
}
또는 Interceptor로. 7편에서 자세히.
ChannelBuilder — 클라이언트 직접 생성
ManagedChannel channel = ManagedChannelBuilder
.forAddress("localhost", 9090)
.usePlaintext()
.keepAliveTime(30, TimeUnit.SECONDS)
.keepAliveTimeout(5, TimeUnit.SECONDS)
.build();
UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub(channel);
// 사용 후
channel.shutdown();
@GrpcClient 자동 생성과 동일. 명시 제어 시 사용.
에러 처리 (8편 미리보기)
try {
User user = blockingStub.getUser(request);
} catch (StatusRuntimeException e) {
Status.Code code = e.getStatus().getCode();
if (code == Status.Code.NOT_FOUND) {
// 404 처리
} else if (code == Status.Code.DEADLINE_EXCEEDED) {
// 타임아웃
}
}
자세한 건 8편.
Cancel — 호출 취소
// 클라이언트
ClientCall<UserRequest, User> call = ...;
call.cancel("User cancelled", null);
// 서버 — 취소 감지
@Override
public void getUser(UserRequest request, StreamObserver<User> observer) {
Context context = Context.current();
context.addListener(ctx -> {
if (ctx.isCancelled()) {
log.info("Client cancelled");
// 정리 작업
}
}, MoreExecutors.directExecutor());
// 처리
}
긴 작업 시 클라이언트 취소 감지 → 자원 회수.
Bean 자동 등록 (Spring Boot)
grpc:
client:
user-service:
address: static://localhost:9090
negotiation-type: plaintext
enable-keep-alive: true
keep-alive-time: 30s
@GrpcClient("user-service") 자동 매칭.
Service Discovery 통합
grpc:
client:
user-service:
address: discovery:///user-service # Eureka·Consul·Nacos
헬스 체크
@Bean
public HealthStatusManager healthStatusManager() {
HealthStatusManager manager = new HealthStatusManager();
manager.setStatus("UserService", ServingStatus.SERVING);
return manager;
}
grpcurl -plaintext localhost:9090 grpc.health.v1.Health/Check
# {"status": "SERVING"}
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 3편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- Unary RPC = 1:1 요청-응답 (HTTP 같음)
- 서버 —
@GrpcService+extends ...ImplBase StreamObserver—onNext/onCompleted/onErroronNext후 onCompleted 또는 onError 필수 (안 그러면 영원 대기)- 클라이언트 Stub 3종 — BlockingStub (동기) / AsyncStub (콜백) / FutureStub (Future)
- 모두 같은 RPC, 코드 스타일 차이
- 일반 = Blocking / WebFlux·Future = Async/Future
- Reactive Stub (
reactor-grpc-stub) = Mono/Flux 통합 - WebFlux 친화
- Deadline =
withDeadlineAfter(N, TimeUnit) - Deadline 자동 전파 (서비스 체인 전체)
- Metadata = HTTP 헤더 비슷
- 클라이언트 —
MetadataUtils.newAttachHeadersInterceptor - 서버 — Interceptor 또는 직접 추출
ManagedChannelBuilder= 클라이언트 직접 생성- 에러 —
StatusRuntimeException+Status.Code - Cancel =
call.cancel()/ 서버Context.current().addListener - 긴 작업 시 취소 감지·정리
- Spring Boot
@GrpcClient("name")+ yamlgrpc.client.<name>.address - Service Discovery —
discovery:///service-name - 헬스 체크 —
grpc.health.v1.Health/Check
시리즈 다른 편
- 1편 — 기본 개념·HTTP/2·4 RPC 모드
- 2편 — Protocol Buffers
- 3편 — Unary RPC (현재 글)
- 4편 — Server Streaming
- 5편 — Client Streaming
- 6편 — Bidirectional Streaming
- 7편 — Interceptors
- 8편 — Error Handling
- 9편 — Security
- 10편 — 고급 (Reflection·Health·LB·gRPC-Web)
공식 문서: gRPC Java Basics / grpc-spring-boot-starter 에서 더 깊이.
다음 글(4편)에서는 Server Streaming — 1 요청 → N 응답 패턴, 무한 스트림·페이징·실시간 알림까지 풀어 갑니다.