gRPC + Spring Boot 마스터 노트 시리즈 8편. gRPC Status Code 16종의 의미와 HTTP Status와의 매핑, StatusRuntimeException 처리, 에러 메시지·trailers·details, google.rpc.Status로 풍부한 에러 정보, ExceptionHandler 패턴, 비즈니스 에러 vs 시스템 에러 구분까지.
이 글은 gRPC + Spring Boot 마스터 노트 시리즈의 여덟 번째 편입니다. 1~7편이 정상 흐름이었다면, 이번엔 에러를 어떻게 표현하나 — Status Codes·예외 처리.
HTTP의 Status Code 같지만 16종으로 단순. 다만 풍부한 에러 정보를 어떻게 전달할지가 디자인 선택. ExceptionHandler 패턴으로 깔끔하게.
처음 에러 처리가 어렵게 느껴지는 이유
처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, Status Code 16종이 한 번에 등장합니다. 어느 게 어디? 둘째, 단순 에러 메시지 vs Rich Errors 차이가 막연합니다.
해결법은 한 가지예요. "Status Code = 분류 / Description = 메시지 / Trailers·Details = 풍부한 정보" 한 줄. 단순 에러는 Code+Description, 복잡 = google.rpc.Status로 details 첨부.
gRPC Status Code 16종
| Code | 번호 | HTTP 비교 | 의미 |
|---|---|---|---|
| OK | 0 | 200 | 정상 |
| CANCELLED | 1 | 499 | 클라이언트 취소 |
| UNKNOWN | 2 | 500 | 알 수 없음 |
| INVALID_ARGUMENT | 3 | 400 | 잘못된 인자 |
| DEADLINE_EXCEEDED | 4 | 504 | 타임아웃 |
| NOT_FOUND | 5 | 404 | 없음 |
| ALREADY_EXISTS | 6 | 409 | 중복 |
| PERMISSION_DENIED | 7 | 403 | 권한 없음 |
| RESOURCE_EXHAUSTED | 8 | 429 | Rate Limit·Quota |
| FAILED_PRECONDITION | 9 | 400 | 사전 조건 X |
| ABORTED | 10 | 409 | 중단 (동시성) |
| OUT_OF_RANGE | 11 | 400 | 범위 밖 |
| UNIMPLEMENTED | 12 | 501 | 미구현 |
| INTERNAL | 13 | 500 | 서버 에러 |
| UNAVAILABLE | 14 | 503 | 일시 불가 |
| DATA_LOSS | 15 | 500 | 데이터 손실 |
| UNAUTHENTICATED | 16 | 401 | 인증 X |
여기서 정말 중요한 시험 함정 — Status Code 신중히 선택. 클라이언트가 재시도 여부 결정. UNAVAILABLE = 재시도 OK / INVALID_ARGUMENT = 재시도 X.
서버 — 에러 전송
단순 에러
@Override
public void getUser(UserRequest request, StreamObserver<User> observer) {
try {
User user = userRepo.findById(request.getId())
.orElseThrow(() -> new NotFoundException(request.getId()));
observer.onNext(toProto(user));
observer.onCompleted();
} catch (NotFoundException e) {
observer.onError(
Status.NOT_FOUND
.withDescription("User not found: " + request.getId())
.asRuntimeException()
);
}
}
Status + Trailers (메타데이터)
Metadata trailers = new Metadata();
trailers.put(Metadata.Key.of("error-code", Metadata.ASCII_STRING_MARSHALLER), "USER_NOT_FOUND");
trailers.put(Metadata.Key.of("retry-after", Metadata.ASCII_STRING_MARSHALLER), "5");
observer.onError(
Status.NOT_FOUND
.withDescription("User not found")
.asRuntimeException(trailers)
);
추가 정보를 trailers로 전달.
클라이언트 — 에러 수신
try {
User user = blockingStub.getUser(request);
} catch (StatusRuntimeException e) {
Status.Code code = e.getStatus().getCode();
String description = e.getStatus().getDescription();
Metadata trailers = e.getTrailers();
String errorCode = trailers.get(Metadata.Key.of("error-code", Metadata.ASCII_STRING_MARSHALLER));
switch (code) {
case NOT_FOUND -> handleNotFound(description);
case PERMISSION_DENIED -> handleForbidden();
case UNAVAILABLE -> retry();
default -> handleGeneric(e);
}
}
여기서 시험 함정이 하나 있어요. StatusRuntimeException 또는 StatusException. RuntimeException이 일반적, checked exception은 특수 케이스만.
Rich Errors — google.rpc.Status
import "google/rpc/error_details.proto";
// 사용 안 보이지만 protoc-gen-grpc 자동 처리
import com.google.rpc.Status;
import com.google.rpc.BadRequest;
import com.google.rpc.BadRequest.FieldViolation;
BadRequest badRequest = BadRequest.newBuilder()
.addFieldViolations(FieldViolation.newBuilder()
.setField("email")
.setDescription("Invalid email format")
.build())
.addFieldViolations(FieldViolation.newBuilder()
.setField("age")
.setDescription("Must be positive")
.build())
.build();
Status richStatus = Status.newBuilder()
.setCode(Status.Code.INVALID_ARGUMENT.getNumber())
.setMessage("Validation failed")
.addDetails(Any.pack(badRequest))
.build();
throw StatusProto.toStatusRuntimeException(richStatus);
여러 필드 에러 한 번에 전달.
표준 Error Details
| Type | 용도 |
|---|---|
| BadRequest | 필드 검증 실패 |
| PreconditionFailure | 사전 조건 위반 |
| QuotaFailure | 할당량 초과 |
| RetryInfo | 재시도 정보 |
| Help | 도움 링크 |
| DebugInfo | 디버그 정보 |
| LocalizedMessage | 다국어 메시지 |
여기서 정말 중요한 시험 함정 — 표준 Error Details 사용 권장. 클라이언트 라이브러리·도구가 자동 인식. 커스텀 메시지 직접 만들기보다 효율.
ExceptionHandler 패턴 — 글로벌
@GrpcAdvice
public class GlobalExceptionHandler {
@GrpcExceptionHandler(NotFoundException.class)
public StatusException handleNotFound(NotFoundException e) {
return Status.NOT_FOUND
.withDescription(e.getMessage())
.asException();
}
@GrpcExceptionHandler(ValidationException.class)
public StatusRuntimeException handleValidation(ValidationException e) {
BadRequest details = BadRequest.newBuilder()
.addAllFieldViolations(e.getViolations())
.build();
return StatusProto.toStatusRuntimeException(
com.google.rpc.Status.newBuilder()
.setCode(Status.Code.INVALID_ARGUMENT.getNumber())
.setMessage("Validation failed")
.addDetails(Any.pack(details))
.build()
);
}
@GrpcExceptionHandler(Exception.class)
public StatusException handleGeneral(Exception e) {
log.error("Unhandled error", e);
return Status.INTERNAL
.withDescription("Internal server error")
.asException();
}
}
@GrpcAdvice + @GrpcExceptionHandler (grpc-spring-boot-starter). Spring @ControllerAdvice 비슷.
비즈니스 에러 vs 시스템 에러
시스템 에러:
- 네트워크 끊김
- 서버 다운
- 타임아웃
→ UNAVAILABLE / DEADLINE_EXCEEDED / INTERNAL
→ 재시도 가능
비즈니스 에러:
- 사용자 없음
- 잔액 부족
- 검증 실패
→ NOT_FOUND / FAILED_PRECONDITION / INVALID_ARGUMENT
→ 재시도 무의미, 응답으로 표현도 가능
여기서 시험 함정이 하나 있어요. 비즈니스 에러는 정상 응답으로도 OK. 예: OrderResult { status: SUCCESS or FAILURE_REASON }. Status Code는 시스템 에러에. 둘 혼용은 디자인 선택.
Streaming 에러
@Override
public void subscribe(Subscription req, StreamObserver<Notification> observer) {
Flux.interval(Duration.ofSeconds(1))
.take(10)
.doOnNext(i -> {
if (i == 5) throw new RuntimeException("Mid stream error");
})
.map(this::toNotification)
.subscribe(
observer::onNext,
t -> observer.onError(Status.INTERNAL.withCause(t).asRuntimeException()),
observer::onCompleted
);
}
스트림 중간 에러 → onError → 클라이언트 StatusRuntimeException.
여기서 시험 함정이 하나 있어요. 스트림 에러는 한 번. onError 후 더 보낼 수 X. 일부 메시지는 정상·일부는 에러 = 응답 안에 표현.
검증 — Bean Validation 통합
@GrpcService
public class UserServiceImpl extends UserServiceGrpc.UserServiceImplBase {
@Autowired
private Validator validator;
@Override
public void createUser(CreateUserRequest req, StreamObserver<User> observer) {
// Protobuf → Java DTO 변환
UserDto dto = UserDto.from(req);
Set<ConstraintViolation<UserDto>> violations = validator.validate(dto);
if (!violations.isEmpty()) {
BadRequest details = toBadRequest(violations);
observer.onError(StatusProto.toStatusRuntimeException(
com.google.rpc.Status.newBuilder()
.setCode(Status.Code.INVALID_ARGUMENT.getNumber())
.setMessage("Validation failed")
.addDetails(Any.pack(details))
.build()
));
return;
}
// 정상 처리
}
}
또는 @Valid + Spring Validation.
클라이언트 — Rich Errors 추출
try {
User user = blockingStub.createUser(req);
} catch (StatusRuntimeException e) {
com.google.rpc.Status status = StatusProto.fromThrowable(e);
if (status != null) {
for (Any detail : status.getDetailsList()) {
if (detail.is(BadRequest.class)) {
BadRequest br = detail.unpack(BadRequest.class);
br.getFieldViolationsList().forEach(fv -> {
log.warn("{}: {}", fv.getField(), fv.getDescription());
});
}
}
}
}
재시도 가능 vs 불가능
재시도 가능 (transient):
- UNAVAILABLE
- DEADLINE_EXCEEDED
- RESOURCE_EXHAUSTED (일정 시간 후)
재시도 불가능 (permanent):
- INVALID_ARGUMENT
- NOT_FOUND
- PERMISSION_DENIED
- UNAUTHENTICATED
- UNIMPLEMENTED
grpc:
client:
user-service:
method-config:
- service: UserService
retryPolicy:
maxAttempts: 3
retryableStatusCodes:
- UNAVAILABLE
- DEADLINE_EXCEEDED
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 8편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- gRPC Status Code 16종 (OK·CANCELLED·INVALID_ARGUMENT·NOT_FOUND·ALREADY_EXISTS·PERMISSION_DENIED·UNAVAILABLE·INTERNAL·UNAUTHENTICATED 등)
- HTTP Status와 매핑되지만 1:1 X
- Status Code 신중 선택 — 재시도 여부 결정
- 서버 —
Status.X.withDescription().asRuntimeException() - Trailers = 추가 메타데이터
- 클라이언트 —
StatusRuntimeException+getStatus().getCode()+getTrailers() - Rich Errors —
google.rpc.Status+Anydetails - 표준 Error Details — BadRequest·PreconditionFailure·QuotaFailure·RetryInfo·Help·DebugInfo·LocalizedMessage
@GrpcAdvice+@GrpcExceptionHandler= 글로벌 핸들러 (grpc-spring-boot-starter)- 비즈니스 vs 시스템 에러 구분
- 비즈니스 에러는 정상 응답으로 표현도 OK
- Streaming —
onError한 번, 더 보낼 수 X - Bean Validation — Protobuf 변환 후
validator.validate() - 클라이언트 Rich Errors —
StatusProto.fromThrowable+Any.unpack - 재시도 가능 — UNAVAILABLE·DEADLINE_EXCEEDED·RESOURCE_EXHAUSTED
- 재시도 불가능 — INVALID_ARGUMENT·NOT_FOUND·PERMISSION_DENIED·UNAUTHENTICATED·UNIMPLEMENTED
- yaml
retryPolicy.retryableStatusCodes로 자동 재시도
시리즈 다른 편
- 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 Status Codes / Rich Error Model 에서 더 깊이.
다음 글(9편)에서는 Security — TLS·mTLS·인증·인가·JWT·Spring Security 통합까지 풀어 갑니다.