gRPC + Spring Boot — Error Handling·Status Codes

2026-05-03확률과 통계 마스터 노트

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 Errorsgoogle.rpc.Status + Any details
  • 표준 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로 자동 재시도

시리즈 다른 편

공식 문서: gRPC Status Codes / Rich Error Model 에서 더 깊이.

다음 글(9편)에서는 Security — TLS·mTLS·인증·인가·JWT·Spring Security 통합까지 풀어 갑니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!