Error Handling

gRPC는 분산 시스템에서 서비스 간 통신을 위한 강력한 프레임워크이다. 복잡한 분산 환경에서는 네트워크 문제, 서버 장애, 비즈니스 로직 오류 등 다양한 오류 상황이 발생할 수 있다. gRPC는 이러한 다양한 오류 상황을 일관되고 체계적으로 처리하기 위한 포괄적인 오류 처리 메커니즘을 제공한다.

gRPC의 오류 처리는 HTTP/2 상태 코드, gRPC 상태 코드, 그리고 상세 오류 메시지를 조합하여 클라이언트에게 오류 정보를 전달한다. 이 시스템은 모든 지원 언어에서 일관되게 작동하며, 개발자가 다양한 오류 상황에 적절히 대응할 수 있도록 돕는다.

gRPC 상태 코드(Status Codes)

gRPC 오류 처리의 핵심은 상태 코드 시스템이다. 각 gRPC 호출은 성공 또는 실패를 나타내는 상태 코드와 함께 완료된다. 이 상태 코드는 오류의 유형과 심각도를 나타낸다.

주요 gRPC 상태 코드

gRPC는 다음과 같은 주요 상태 코드를 정의한다:

상태 코드설명
OK0성공적인 호출
CANCELLED1클라이언트에 의해 작업이 취소됨
UNKNOWN2알 수 없는 오류가 발생함
INVALID_ARGUMENT3클라이언트가 잘못된 인수를 제공함
DEADLINE_EXCEEDED4작업 완료 전에 데드라인이 만료됨
NOT_FOUND5요청된 엔티티를 찾을 수 없음
ALREADY_EXISTS6클라이언트가 생성하려는 엔티티가 이미 존재함
PERMISSION_DENIED7클라이언트가 작업을 수행할 권한이 없음
RESOURCE_EXHAUSTED8리소스가 소진됨(할당량 초과 등)
FAILED_PRECONDITION9시스템이 작업을 수행할 상태가 아님
ABORTED10작업이 중단됨(일반적으로 동시성 문제로 인해)
OUT_OF_RANGE11작업이 유효 범위를 벗어남
UNIMPLEMENTED12요청된 작업이 구현되지 않음
INTERNAL13내부 서버 오류
UNAVAILABLE14서비스를 일시적으로 사용할 수 없음
DATA_LOSS15복구할 수 없는 데이터 손실이 발생함
UNAUTHENTICATED16클라이언트가 인증되지 않음

상태 코드 선택 가이드라인

올바른 상태 코드를 선택하는 것은 클라이언트가 오류에 적절히 대응하는 데 중요하다.

다음은 상태 코드 선택에 대한 가이드라인이다:

오류 세부 정보(Error Details)

gRPC는 기본 상태 코드 외에도 더 상세한 오류 정보를 제공할 수 있는 메커니즘을 제공한다.

오류 메시지

각 gRPC 상태 코드는 문자열 메시지를 포함할 수 있다. 이 메시지는 디버깅 목적으로 사용되며, 최종 사용자에게 직접 표시하도록 설계되지 않았다.

1
2
// 서버 측 구현 예시 (Go)
return nil, status.Errorf(codes.InvalidArgument, "필드 값 %s이(가) 유효하지 않습니다", value)

오류 상세 정보 (Error Details)

gRPC는 google.rpc.Status 메시지를 확장하여 더 구조화된 오류 정보를 제공할 수 있다. 이를 통해 클라이언트에 다음과 같은 추가 정보를 전달할 수 있다:

이러한 상세 정보는 google.rpc.ErrorInfo 프로토콜 버퍼 메시지를 사용하여 정의된다.

1
2
3
4
5
6
7
8
// 오류 상세 정보 예시 (proto 정의)
message BadRequest {
  message FieldViolation {
    string field = 1;
    string description = 2;
  }
  repeated FieldViolation field_violations = 1;
}

구현 예시

Java에서 오류 상세 정보를 포함한 상태 생성 예시:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 필드 유효성 검사 오류에 대한 상세 정보 추가
Metadata.Key<BadRequest> badRequestKey = 
    ProtoUtils.keyForProto(BadRequest.getDefaultInstance());
Metadata trailers = new Metadata();

BadRequest badRequest = BadRequest.newBuilder()
    .addFieldViolations(FieldViolation.newBuilder()
        .setField("email")
        .setDescription("올바른 이메일 형식이 아닙니다")
        .build())
    .build();

trailers.put(badRequestKey, badRequest);

return Status.INVALID_ARGUMENT
    .withDescription("요청에 유효하지 않은 필드가 있습니다")
    .asRuntimeException(trailers);

언어별 오류 처리 구현

gRPC는 다양한 프로그래밍 언어를 지원하며, 각 언어별로 오류 처리 메커니즘을 제공한다.

Go 언어에서의 오류 처리

Go 언어에서 gRPC 오류 처리는 status 패키지를 사용한다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import (
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

// 서버 측에서 오류 반환
func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
    if req.Id <= 0 {
        return nil, status.Errorf(codes.InvalidArgument, "사용자 ID는 양수여야 합니다: %v", req.Id)
    }
    
    user, err := s.repository.FindUser(req.Id)
    if err != nil {
        return nil, status.Errorf(codes.Internal, "내부 오류: %v", err)
    }
    
    if user == nil {
        return nil, status.Errorf(codes.NotFound, "ID가 %d인 사용자를 찾을 수 없습니다", req.Id)
    }
    
    return user, nil
}

// 클라이언트 측에서 오류 처리
resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: -1})
if err != nil {
    st, ok := status.FromError(err)
    if ok {
        switch st.Code() {
        case codes.InvalidArgument:
            log.Printf("잘못된 인수: %v", st.Message())
        case codes.NotFound:
            log.Printf("사용자를 찾을 수 없음: %v", st.Message())
        default:
            log.Printf("gRPC 오류: %v", st)
        }
    } else {
        log.Printf("일반 오류: %v", err)
    }
    return
}

Java 언어에서의 오류 처리

Java에서는 StatusStatusRuntimeException을 사용한다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 서버 측에서 오류 반환
@Override
public void getUser(GetUserRequest request, StreamObserver<User> responseObserver) {
    try {
        if (request.getId() <= 0) {
            responseObserver.onError(
                Status.INVALID_ARGUMENT
                    .withDescription("사용자 ID는 양수여야 합니다: " + request.getId())
                    .asRuntimeException());
            return;
        }
        
        User user = repository.findUser(request.getId());
        if (user == null) {
            responseObserver.onError(
                Status.NOT_FOUND
                    .withDescription("ID가 " + request.getId() + "인 사용자를 찾을 수 없습니다")
                    .asRuntimeException());
            return;
        }
        
        responseObserver.onNext(user);
        responseObserver.onCompleted();
    } catch (Exception e) {
        responseObserver.onError(
            Status.INTERNAL
                .withDescription("내부 오류: " + e.getMessage())
                .withCause(e)  // 원인 예외 추가 (전송되지 않음, 로깅용)
                .asRuntimeException());
    }
}

// 클라이언트 측에서 오류 처리
try {
    User user = blockingStub.getUser(GetUserRequest.newBuilder().setId(-1).build());
    // 성공적인 응답 처리
} catch (StatusRuntimeException e) {
    Status status = e.getStatus();
    switch (status.getCode()) {
        case INVALID_ARGUMENT:
            logger.log(Level.WARNING, "잘못된 인수: {0}", status.getDescription());
            break;
        case NOT_FOUND:
            logger.log(Level.INFO, "사용자를 찾을 수 없음: {0}", status.getDescription());
            break;
        default:
            logger.log(Level.SEVERE, "gRPC 오류: {0}", status);
    }
}

Python 언어에서의 오류 처리

Python에서는 grpc.StatusCode와 예외를 사용한다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 서버 측에서 오류 반환
def GetUser(self, request, context):
    if request.id <= 0:
        context.set_code(grpc.StatusCode.INVALID_ARGUMENT)
        context.set_details(f"사용자 ID는 양수여야 합니다: {request.id}")
        return pb.User()  # 더미 응답
    
    try:
        user = self.repository.find_user(request.id)
        if not user:
            context.set_code(grpc.StatusCode.NOT_FOUND)
            context.set_details(f"ID가 {request.id}인 사용자를 찾을 수 없습니다")
            return pb.User()  # 더미 응답
        return user
    except Exception as e:
        context.set_code(grpc.StatusCode.INTERNAL)
        context.set_details(f"내부 오류: {str(e)}")
        return pb.User()  # 더미 응답

# 클라이언트 측에서 오류 처리
try:
    response = stub.GetUser(pb.GetUserRequest(id=-1))
    # 성공적인 응답 처리
except grpc.RpcError as e:
    status_code = e.code()
    if status_code == grpc.StatusCode.INVALID_ARGUMENT:
        print(f"잘못된 인수: {e.details()}")
    elif status_code == grpc.StatusCode.NOT_FOUND:
        print(f"사용자를 찾을 수 없음: {e.details()}")
    else:
        print(f"gRPC 오류: {status_code} - {e.details()}")

고급 오류 처리 패턴

실제 프로덕션 환경에서는 더 복잡한 오류 처리 패턴이 필요하다.

다음은 gRPC 애플리케이션에서 사용할 수 있는 고급 오류 처리 패턴이다.

오류 인터셉터(Error Interceptors)

인터셉터를 사용하면 오류 처리 로직을 중앙화하고 일관성 있게 적용할 수 있다.

Java 서버 측 인터셉터 예시:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class ErrorInterceptor implements ServerInterceptor {
    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call, 
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {
        
        ServerCall.Listener<ReqT> listener = next.startCall(call, headers);
        
        return new ForwardingServerCallListener.SimpleForwardingServerCallListener<ReqT>(listener) {
            @Override
            public void onHalfClose() {
                try {
                    super.onHalfClose();
                } catch (RuntimeException ex) {
                    handleException(call, ex);
                    throw ex;
                }
            }
            
            private void handleException(ServerCall<ReqT, RespT> call, RuntimeException ex) {
                Status status;
                if (ex instanceof IllegalArgumentException) {
                    status = Status.INVALID_ARGUMENT.withDescription(ex.getMessage());
                } else if (ex instanceof ResourceNotFoundException) {
                    status = Status.NOT_FOUND.withDescription(ex.getMessage());
                } else {
                    status = Status.INTERNAL.withDescription("내부 서버 오류");
                }
                
                call.close(status, new Metadata());
            }
        };
    }
}

오류 매핑(Error Mapping)

비즈니스 로직 예외를 적절한 gRPC 상태 코드로 매핑하는 전략은 모든 gRPC 서비스에서 중요하다.

Go 언어에서의 오류 매핑 예시:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 오류 매핑 함수
func mapToStatusError(err error) error {
    switch {
    case errors.Is(err, ErrNotFound):
        return status.Error(codes.NotFound, err.Error())
    case errors.Is(err, ErrInvalidInput):
        return status.Error(codes.InvalidArgument, err.Error())
    case errors.Is(err, ErrPermissionDenied):
        return status.Error(codes.PermissionDenied, err.Error())
    case errors.Is(err, ErrUnauthenticated):
        return status.Error(codes.Unauthenticated, err.Error())
    case errors.Is(err, ErrAlreadyExists):
        return status.Error(codes.AlreadyExists, err.Error())
    default:
        // 내부 오류는 자세한 정보를 노출하지 않음
        return status.Error(codes.Internal, "내부 서버 오류가 발생했습니다")
    }
}

// 서비스 구현에서 사용
func (s *server) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.User, error) {
    user, err := s.service.CreateUser(req.Name, req.Email)
    if err != nil {
        return nil, mapToStatusError(err)
    }
    return user, nil
}

오류 처리 모범 사례

gRPC 애플리케이션에서 오류를 처리할 때 다음 모범 사례를 고려한다:

  1. 일관된 오류 코드 사용: 애플리케이션 전체에서 오류 코드를 일관되게 사용한다.
  2. 적절한 상태 코드 선택: 오류 상황에 가장 적합한 상태 코드를 선택한다.
  3. 유용한 오류 메시지 제공: 디버깅에 도움이 되는 명확한 오류 메시지를 제공한다.
  4. 민감한 정보 노출 방지: 오류 메시지에 민감한 정보가 포함되지 않도록 주의한다.
  5. 구조화된 오류 상세 정보 사용: 가능한 경우 구조화된 오류 세부 정보를 사용하여 클라이언트에게 더 많은 컨텍스트를 제공한다.
  6. 오류 처리 중앙화: 인터셉터나 미들웨어를 사용하여 오류 처리 로직을 중앙화한다.
  7. 재시도 가능한 오류와 재시도 불가능한 오류 구분: 클라이언트가 적절한 재시도 전략을 선택할 수 있도록 오류를 명확히 구분한다.

오류 전파와 처리 전략

분산 시스템에서는 오류가 서비스 체인을 통해 전파되는 방식이 중요하다.

오류 전파 패턴

분산 시스템에서 오류를 처리하는 몇 가지 일반적인 패턴이 있다:

  1. 직접 전파(Direct Propagation): 하위 서비스의 오류를 상위 서비스로 그대로 전달한다.

    1
    2
    3
    4
    
    resp, err := downstreamService.Call(ctx, req)
    if err != nil {
        return nil, err  // 오류를 그대로 상위 서비스로 전달
    }
    
  2. 오류 변환(Error Translation): 하위 서비스의 오류를 상위 서비스 컨텍스트에 맞게 변환한다.

    1
    2
    3
    4
    5
    6
    7
    8
    
    resp, err := downstreamService.Call(ctx, req)
    if err != nil {
        st, ok := status.FromError(err)
        if ok && st.Code() == codes.NotFound {
            return nil, status.Errorf(codes.NotFound, "사용자 프로필을 찾을 수 없습니다: %v", st.Message())
        }
        return nil, status.Errorf(codes.Internal, "프로필 서비스 오류")
    }
    
  3. 오류 래핑(Error Wrapping): 오류 컨텍스트를 추가하면서 원래 오류를 유지한다.

    1
    2
    3
    4
    
    resp, err := downstreamService.Call(ctx, req)
    if err != nil {
        return nil, fmt.Errorf("프로필 서비스 호출 중 오류: %w", err)
    }
    
  4. 오류 흡수(Error Absorption): 특정 오류를 처리하고 대체 로직을 실행한다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    resp, err := downstreamService.Call(ctx, req)
    if err != nil {
        st, ok := status.FromError(err)
        if ok && st.Code() == codes.NotFound {
            // 대체 로직: 기본 프로필 반환
            return getDefaultProfile(), nil
        }
        return nil, err
    }
    

클라이언트 측 오류 처리 전략

gRPC 클라이언트는 다양한 오류 상황에 효과적으로 대응하기 위한 전략이 필요하다.

재시도 전략

일시적인 오류가 발생할 경우 자동 재시도를 구현할 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Java에서의 재시도 예시
public Response callWithRetry(Request request) {
    int maxRetries = 3;
    int retryCount = 0;
    long retryDelayMs = 100;
    
    while (true) {
        try {
            return stub.serviceMethod(request);
        } catch (StatusRuntimeException e) {
            retryCount++;
            if (retryCount > maxRetries || !isRetryable(e.getStatus().getCode())) {
                throw e;  // 최대 재시도 횟수 초과 또는 재시도 불가능한 오류
            }
            
            logger.warning("재시도 가능한 오류 발생, 재시도 중… (" + retryCount + "/" + maxRetries + ")");
            try {
                Thread.sleep(retryDelayMs * (1 << (retryCount - 1)));  // 지수 백오프
            } catch (InterruptedException ie) {
                Thread.currentThread().interrupt();
                throw e;
            }
        }
    }
}

private boolean isRetryable(Status.Code code) {
    return code == Status.Code.UNAVAILABLE || 
           code == Status.Code.RESOURCE_EXHAUSTED ||
           code == Status.Code.DEADLINE_EXCEEDED;
}
서킷 브레이커(Circuit Breaker) 패턴

연속적인 오류가 발생할 경우 서비스 호출을 일시적으로 중단하는 서킷 브레이커 패턴을 구현할 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 간단한 서킷 브레이커 구현 예시
public class CircuitBreaker {
    private final int failureThreshold;
    private final long resetTimeout;
    private int failureCount;
    private long lastFailureTime;
    private State state;
    
    public enum State {
        CLOSED,    // 정상 작동
        OPEN,      // 서킷 열림 (호출 중단)
        HALF_OPEN  // 시험 작동
    }
    
    public CircuitBreaker(int failureThreshold, long resetTimeoutMs) {
        this.failureThreshold = failureThreshold;
        this.resetTimeout = resetTimeoutMs;
        this.state = State.CLOSED;
        this.failureCount = 0;
    }
    
    public synchronized <T> T execute(Supplier<T> operation) throws Exception {
        if (state == State.OPEN) {
            if (System.currentTimeMillis() - lastFailureTime > resetTimeout) {
                state = State.HALF_OPEN;
            } else {
                throw new CircuitBreakerOpenException("서킷 브레이커가 열려 있습니다");
            }
        }
        
        try {
            T result = operation.get();
            if (state == State.HALF_OPEN) {
                reset();  // 성공 시 상태 재설정
            }
            return result;
        } catch (Exception e) {
            recordFailure(e);
            throw e;
        }
    }
    
    private synchronized void recordFailure(Exception e) {
        failureCount++;
        lastFailureTime = System.currentTimeMillis();
        
        if (state == State.HALF_OPEN || failureCount >= failureThreshold) {
            state = State.OPEN;
        }
    }
    
    private synchronized void reset() {
        failureCount = 0;
        state = State.CLOSED;
    }
}
타임아웃 설정

모든 gRPC 호출에 적절한 타임아웃을 설정하는 것이 중요하다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Java에서의 타임아웃 설정 예시
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 50051)
    .usePlaintext()
    .build();

// 클라이언트 스텁 생성 시 데드라인 설정
UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub(channel)
    .withDeadlineAfter(5, TimeUnit.SECONDS);

// 개별 호출에 대한 타임아웃 설정도 가능
try {
    // 기본 클라이언트 스텁에서 메서드별 타임아웃 설정
    User user = stub.withDeadlineAfter(1, TimeUnit.SECONDS)
        .getUser(GetUserRequest.newBuilder().setId(123).build());
} catch (StatusRuntimeException e) {
    if (e.getStatus().getCode() == Status.Code.DEADLINE_EXCEEDED) {
        logger.severe("호출 타임아웃");
    }
}

서버 측 오류 처리 전략

서버 측에서도 오류를 효과적으로 처리하기 위한 전략이 필요하다.

요청 검증(Validation)

요청을 처리하기 전에 철저히 검증하여 많은 오류를 조기에 방지할 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func (s *server) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.User, error) {
    // 입력 검증
    if req.Name == "" {
        return nil, status.Error(codes.InvalidArgument, "이름은 필수 필드입니다")
    }
    
    if !isValidEmail(req.Email) {
        return nil, status.Error(codes.InvalidArgument, "유효하지 않은 이메일 형식입니다")
    }
    
    // 비즈니스 로직 실행
    user, err := s.service.CreateUser(req.Name, req.Email)
    if err != nil {
        return nil, mapToStatusError(err)
    }
    
    return user, nil
}
교착 상태 방지(Deadlock Prevention)

gRPC 서버에서는 교착 상태를 방지하기 위해 모든 요청에 대해 적절한 타임아웃을 설정하는 것이 중요하다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func (s *server) ProcessData(ctx context.Context, req *pb.ProcessDataRequest) (*pb.ProcessDataResponse, error) {
    // 컨텍스트에서 데드라인 확인
    deadline, ok := ctx.Deadline()
    if !ok {
        // 기본 타임아웃 설정
        var cancel context.CancelFunc
        ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
        defer cancel()
    } else {
        // 남은 처리 시간 계산
        remaining := time.Until(deadline) - 100*time.Millisecond
        if remaining <= 0 {
            return nil, status.Error(codes.DeadlineExceeded, "데드라인이 너무 짧습니다")
        }
    }
    
    // 하위 서비스 호출 시 컨텍스트 전파
    result, err := s.dataProcessor.Process(ctx, req.Data)
    if err != nil {
        return nil, err
    }
    
    return &pb.ProcessDataResponse{Result: result}, nil
}
점진적 성능 저하(Graceful Degradation)

서비스 일부에 문제가 발생하더라도 전체 기능을 유지하는 전략을 구현할 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public User getUser(GetUserRequest request) {
    User user = new User();
    
    // 기본 사용자 정보 조회
    try {
        UserInfo info = userInfoService.getUserInfo(request.getId());
        user.setId(info.getId());
        user.setName(info.getName());
        user.setEmail(info.getEmail());
    } catch (StatusRuntimeException e) {
        logger.severe("사용자 기본 정보 조회 실패: " + e.getMessage());
        if (e.getStatus().getCode() != Status.Code.NOT_FOUND) {
            throw e;  // NOT_FOUND가 아닌 경우에만 예외 전파
        }
        // NOT_FOUND인 경우, 기본값으로 계속 진행
        user.setId(request.getId());
        user.setName("Unknown");
    }
    
    // 사용자 프로필 조회 (선택적 기능)
    try {
        UserProfile profile = profileService.getProfile(request.getId());
        user.setProfilePicture(profile.getPictureUrl());
        user.setBio(profile.getBio());
    } catch (StatusRuntimeException e) {
        // 프로필 정보가 없어도 기본 사용자 정보는 반환
        logger.warning("사용자 프로필 조회 실패, 기본값 사용: " + e.getMessage());
        user.setProfilePicture("default.png");
    }
    
    return user;
}

메타데이터를 통한 오류 정보 전달

gRPC는 메타데이터(Metadata)를 통해 추가적인 오류 정보를 전달할 수 있다.

트레일러 메타데이터(Trailer Metadata)

gRPC는 응답 트레일러(trailers)를 통해 오류에 대한 추가 정보를 전달할 수 있다. 이는 특히 오류 상세 정보를 전달하는 데 유용하다.

Java 서버 예시:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Override
public void processRequest(Request request, StreamObserver<Response> responseObserver) {
    try {
        // 비즈니스 로직 처리
        Response response = processBusinessLogic(request);
        responseObserver.onNext(response);
        
        // 트레일러 메타데이터 추가
        Metadata trailers = new Metadata();
        Metadata.Key<String> processingTimeKey = 
            Metadata.Key.of("processing-time-ms", Metadata.ASCII_STRING_MARSHALLER);
        trailers.put(processingTimeKey, String.valueOf(processingTime));
        
        // 완료 시 트레일러 전송
        ((ServerCallStreamObserver<Response>) responseObserver).setTrailers(trailers);
        
        responseObserver.onCompleted();
    } catch (Exception e) {
        // 오류 처리 및 트레일러 메타데이터 추가
        Metadata trailers = new Metadata();
        Metadata.Key<String> errorDetailsKey = 
            Metadata.Key.of("error-details", Metadata.ASCII_STRING_MARSHALLER);
        trailers.put(errorDetailsKey, "오류 세부 정보: " + e.getMessage());
        
        Status status = Status.INTERNAL
            .withDescription("처리 중 오류 발생")
            .withCause(e);
        
        responseObserver.onError(status.asRuntimeException(trailers));
    }
}

Java 클라이언트 예시:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
ClientCall<Request, Response> call = channel.newCall(
    ServiceGrpc.getProcessRequestMethod(), CallOptions.DEFAULT);

call.start(new ClientCall.Listener<Response>() {
    @Override
    public void onClose(Status status, Metadata trailers) {
        if (!status.isOk()) {
            String errorDetails = trailers.get(
                Metadata.Key.of("error-details", Metadata.ASCII_STRING_MARSHALLER));
            logger.severe("오류 발생: " + status + ", 세부 정보: " + errorDetails);
        }
    }
    
    @Override
    public void onMessage(Response response) {
        // 응답 처리
    }
}, new Metadata());

call.sendMessage(request);
call.halfClose();
call.request(1);

바이너리 메타데이터를 사용한 구조화된 오류 정보

Protocol Buffers로 정의된 구조화된 오류 정보를 바이너리 메타데이터로 전송할 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 서버 측 구현
public void processRequest(Request request, StreamObserver<Response> responseObserver) {
    try {
        // 비즈니스 로직 처리
    } catch (ValidationException e) {
        // 구조화된 오류 정보 생성
        ErrorDetails errorDetails = ErrorDetails.newBuilder()
            .setCode("VALIDATION_ERROR")
            .setMessage(e.getMessage())
            .addFields(FieldError.newBuilder()
                .setField(e.getField())
                .setDescription(e.getDescription())
                .build())
            .build();
        
        // 바이너리 메타데이터로 변환
        Metadata.Key<byte[]> errorDetailsKey = 
            Metadata.Key.of("error-details-bin", Metadata.BINARY_BYTE_MARSHALLER);
        Metadata trailers = new Metadata();
        trailers.put(errorDetailsKey, errorDetails.toByteArray());
        
        // 오류 상태와 함께 트레일러 전송
        responseObserver.onError(
            Status.INVALID_ARGUMENT
                .withDescription("유효성 검사 오류")
                .asRuntimeException(trailers));
    }
}

// 클라이언트 측 처리
try {
    Response response = stub.processRequest(request);
    // 성공적인 응답 처리
} catch (StatusRuntimeException e) {
    if (e.getStatus().getCode() == Status.Code.INVALID_ARGUMENT) {
        Metadata trailers = e.getTrailers();
        if (trailers != null) {
            Metadata.Key<byte[]> errorDetailsKey = 
                Metadata.Key.of("error-details-bin", Metadata.BINARY_BYTE_MARSHALLER);
            byte[] errorDetailsBytes = trailers.get(errorDetailsKey);
            
            if (errorDetailsBytes != null) {
                try {
                    ErrorDetails errorDetails = ErrorDetails.parseFrom(errorDetailsBytes);
                    logger.severe("유효성 검사 오류: " + errorDetails.getMessage());
                    for (FieldError field : errorDetails.getFieldsList()) {
                        logger.severe("필드 오류: " + field.getField() + " - " + field.getDescription());
                    }
                } catch (InvalidProtocolBufferException ipbe) {
                    logger.severe("오류 세부 정보 파싱 실패");
                }
            }
        }
    }
}

스트리밍 RPC에서의 오류 처리

gRPC는 단일 요청/응답 외에도 스트리밍 RPC를 지원한다.

스트리밍 RPC에서의 오류 처리는 몇 가지 추가적인 고려 사항이 필요하다.

서버 스트리밍 RPC

서버 스트리밍 RPC에서 서버는 여러 응답을 클라이언트에게 전송한다. 오류가 발생하면 스트림이 종료된다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 서버 측 구현
@Override
public void listUsers(ListUsersRequest request, StreamObserver<User> responseObserver) {
    try {
        // 페이지네이션 파라미터 검증
        if (request.getPageSize() <= 0) {
            responseObserver.onError(
                Status.INVALID_ARGUMENT
                    .withDescription("페이지 크기는 양수여야 합니다")
                    .asRuntimeException());
            return;
        }
        
        // 사용자 목록 조회 및 스트리밍
        List<User> users = userRepository.findUsers(request.getPageToken(), request.getPageSize());
        
        for (User user : users) {
            // 각 사용자를 스트림으로 전송
            responseObserver.onNext(user);
            
            // 컨텍스트 취소 여부 확인
            if (Context.current().isCancelled()) {
                logger.info("클라이언트가 스트림을 취소했습니다");
                return;
            }
        }
        
        responseObserver.onCompleted();
    } catch (Exception e) {
        logger.severe("사용자 목록 조회 중 오류: " + e.getMessage());
        responseObserver.onError(
            Status.INTERNAL
                .withDescription("내부 서버 오류")
                .asRuntimeException());
    }
}

// 클라이언트 측 구현
ListUsersRequest request = ListUsersRequest.newBuilder()
    .setPageSize(10)
    .build();

Iterator<User> users = blockingStub.listUsers(request);

try {
    while (users.hasNext()) {
        User user = users.next();
        // 사용자 처리
    }
} catch (StatusRuntimeException e) {
    if (e.getStatus().getCode() == Status.Code.INTERNAL) {
        logger.severe("서버 내부 오류: " + e.getStatus().getDescription());
    } else {
        logger.severe("스트리밍 오류: " + e.getStatus());
    }
}

클라이언트 스트리밍 RPC

클라이언트 스트리밍 RPC에서는 클라이언트가 여러 요청을 서버에 전송한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// 서버 측 구현
@Override
public StreamObserver<User> createUsers(final StreamObserver<CreateUsersResponse> responseObserver) {
    return new StreamObserver<User>() {
        private int successCount = 0;
        private final List<String> failedUsers = new ArrayList<>();
        
        @Override
        public void onNext(User user) {
            try {
                // 사용자 유효성 검사
                if (user.getName().isEmpty()) {
                    failedUsers.add("ID: " + user.getId() + " - 이름은 필수 필드입니다");
                    return;
                }
                
                // 사용자 생성
                userRepository.createUser(user);
                successCount++;
            } catch (Exception e) {
                failedUsers.add("ID: " + user.getId() + " - " + e.getMessage());
            }
        }
        
        @Override
        public void onError(Throwable t) {
            logger.severe("클라이언트 스트림 오류: " + t.getMessage());
            Status status = Status.fromThrowable(t);
            responseObserver.onError(status.asRuntimeException());
        }
        
        @Override
        public void onCompleted() {
            // 모든 결과 요약하여 응답
            CreateUsersResponse response = CreateUsersResponse.newBuilder()
                .setSuccessCount(successCount)
                .addAllFailedUsers(failedUsers)
                .build();
            
            responseObserver.onNext(response);
            responseObserver.onCompleted();
        }
    };
}

// 클라이언트 측 구현
StreamObserver<CreateUsersResponse> responseObserver = new StreamObserver<CreateUsersResponse>() {
    @Override
    public void onNext(CreateUsersResponse response) {
        logger.info("성공적으로 생성된 사용자 수: " + response.getSuccessCount());
        for (String failure : response.getFailedUsersList()) {
            logger.warning("사용자 생성 실패: " + failure);
        }
    }
    
    @Override
    public void onError(Throwable t) {
        Status status = Status.fromThrowable(t);
        logger.severe("스트림 오류: " + status);
    }
    
    @Override
    public void onCompleted() {
        logger.info("스트림 완료");
    }
};

StreamObserver<User> requestObserver = asyncStub.createUsers(responseObserver);

try {
    for (User user : users) {
        requestObserver.onNext(user);
        
        // 일시 중단을 요청받았는지 확인
        if (Thread.currentThread().isInterrupted()) {
            throw new InterruptedException();
        }
    }
    
    requestObserver.onCompleted();
} catch (RuntimeException e) {
    requestObserver.onError(e);
    throw e;
} catch (InterruptedException e) {
    requestObserver.onError(Status.CANCELLED.withDescription("취소됨").asRuntimeException());
    Thread.currentThread().interrupt();
}

양방향 스트리밍 RPC

양방향 스트리밍 RPC에서는 클라이언트와 서버 모두 스트림을 통해 메시지를 주고받는다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
// 서버 측 구현
@Override
public StreamObserver<ChatMessage> chat(final StreamObserver<ChatMessage> responseObserver) {
    return new StreamObserver<ChatMessage>() {
        @Override
        public void onNext(ChatMessage message) {
            try {
                // 메시지 유효성 검사
                if (message.getContent().isEmpty()) {
                    responseObserver.onError(
                        Status.INVALID_ARGUMENT
                            .withDescription("빈 메시지는 허용되지 않습니다")
                            .asRuntimeException());
                    return;
                }
                
                // 금지된 단어 검사
                if (containsProfanity(message.getContent())) {
                    responseObserver.onError(
                        Status.PERMISSION_DENIED
                            .withDescription("부적절한 콘텐츠가 포함되어 있습니다")
                            .asRuntimeException());
                    return;
                }
                
                // 메시지 처리 및 응답
                ChatMessage response = processAndCreateResponse(message);
                responseObserver.onNext(response);
            } catch (Exception e) {
                logger.severe("채팅 메시지 처리 중 오류: " + e.getMessage());
                responseObserver.onError(
                    Status.INTERNAL
                        .withDescription("메시지 처리 중 오류가 발생했습니다")
                        .asRuntimeException());
            }
        }
        
        @Override
        public void onError(Throwable t) {
            logger.warning("클라이언트 오류: " + t.getMessage());
            // 리소스 정리
        }
        
        @Override
        public void onCompleted() {
            logger.info("클라이언트가 채팅을 완료했습니다");
            responseObserver.onCompleted();
        }
    };
}

// 클라이언트 측 구현
StreamObserver<ChatMessage> requestObserver = asyncStub.chat(
    new StreamObserver<ChatMessage>() {
        @Override
        public void onNext(ChatMessage response) {
            // 서버 응답 처리
            processServerResponse(response);
        }
        
        @Override
        public void onError(Throwable t) {
            Status status = Status.fromThrowable(t);
            if (status.getCode() == Status.Code.INVALID_ARGUMENT) {
                logger.warning("잘못된 메시지: " + status.getDescription());
            } else if (status.getCode() == Status.Code.PERMISSION_DENIED) {
                logger.warning("권한 거부: " + status.getDescription());
            } else {
                logger.severe("채팅 오류: " + status);
            }
            
            // UI에 오류 표시
            showErrorToUser(status.getDescription());
        }
        
        @Override
        public void onCompleted() {
            logger.info("채팅이 완료되었습니다");
        }
    }
);

try {
    // 사용자 메시지 전송
    for (ChatMessage message : userMessages) {
        requestObserver.onNext(message);
    }
    
    // 채팅 완료
    requestObserver.onCompleted();
} catch (RuntimeException e) {
    requestObserver.onError(e);
    throw e;
}

gRPC 오류 처리와 모니터링

분산 시스템에서는 오류를 모니터링하고 추적하는 것이 중요하다.

로깅과 추적(Logging and Tracing)

모든 서비스에서 일관된 로깅 및 추적을 구현하는 것이 중요하다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// 인터셉터를 통한 로깅 및 추적 구현
public class LoggingInterceptor implements ServerInterceptor {
    private static final Logger logger = Logger.getLogger(LoggingInterceptor.class.getName());
    
    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call, 
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {
        
        String methodName = call.getMethodDescriptor().getFullMethodName();
        String traceId = extractTraceId(headers);
        
        logger.info(String.format("[%s] 메서드 호출 시작: %s", traceId, methodName));
        
        return new ForwardingServerCallListener.SimpleForwardingServerCallListener<ReqT>(
                next.startCall(
                    new ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT>(call) {
                        @Override
                        public void close(Status status, Metadata trailers) {
                            logger.info(String.format(
                                "[%s] 메서드 완료: %s, 상태: %s", 
                                traceId, methodName, status.getCode()));
                            
                            if (!status.isOk()) {
                                logger.warning(String.format(
                                    "[%s] 오류 발생: %s, 설명: %s", 
                                    traceId, status.getCode(), status.getDescription()));
                            }
                            
                            super.close(status, trailers);
                        }
                    }, 
                    headers)) {
                    
            @Override
            public void onMessage(ReqT message) {
                logger.info(String.format("[%s] 메시지 수신: %s", traceId, methodName));
                super.onMessage(message);
            }
            
            @Override
            public void onHalfClose() {
                logger.info(String.format("[%s] 클라이언트 스트림 종료: %s", traceId, methodName));
                super.onHalfClose();
            }
            
            @Override
            public void onCancel() {
                logger.warning(String.format("[%s] 호출 취소됨: %s", traceId, methodName));
                super.onCancel();
            }
            
            @Override
            public void onComplete() {
                logger.info(String.format("[%s] 호출 완료: %s", traceId, methodName));
                super.onComplete();
            }
        };
    }
    
    private String extractTraceId(Metadata headers) {
        // 헤더에서 추적 ID 추출 또는 생성
        Metadata.Key<String> traceIdKey = 
            Metadata.Key.of("x-trace-id", Metadata.ASCII_STRING_MARSHALLER);
        String traceId = headers.get(traceIdKey);
        
        if (traceId == null) {
            traceId = UUID.randomUUID().toString();
            // 새로운 추적 ID를 헤더에 추가
            headers.put(traceIdKey, traceId);
        }
        
        return traceId;
    }
}

메트릭 수집(Metrics Collection)

gRPC 호출 성공률, 지연 시간, 오류율 등의 메트릭을 수집하여 서비스 품질을 모니터링할 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 메트릭 수집 인터셉터 예시
public class MetricsInterceptor implements ServerInterceptor {
    private final MeterRegistry registry;
    
    public MetricsInterceptor(MeterRegistry registry) {
        this.registry = registry;
    }
    
    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call, 
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {
        
        String methodName = call.getMethodDescriptor().getFullMethodName();
        Timer.Sample timer = Timer.start(registry);
        
        // 요청 카운터 증가
        registry.counter("grpc.requests.total", 
                "method", methodName).increment();
        
        return new ForwardingServerCallListener.SimpleForwardingServerCallListener<ReqT>(
                next.startCall(
                    new ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT>(call) {
                        @Override
                        public void close(Status status, Metadata trailers) {
                            // 타이머 정지 및 레이턴시 기록
                            timer.stop(Timer.builder("grpc.requests.latency")
                                    .tag("method", methodName)
                                    .tag("status", status.getCode().toString())
                                    .register(registry));
                            
                            // 상태 코드별 카운터 증가
                            registry.counter("grpc.requests.status", 
                                    "method", methodName,
                                    "status", status.getCode().toString()).increment();
                            
                            // 오류인 경우 오류 카운터 증가
                            if (!status.isOk()) {
                                registry.counter("grpc.requests.errors", 
                                        "method", methodName,
                                        "status", status.getCode().toString()).increment();
                            }
                            
                            super.close(status, trailers);
                        }
                    }, 
                    headers)) {
            // 리스너 구현
        };
    }
}

분산 추적(Distributed Tracing)

OpenTelemetry와 같은 분산 추적 시스템을 사용하여 서비스 간 호출을 추적할 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// OpenTelemetry를 사용한 분산 추적 인터셉터 예시
public class TracingInterceptor implements ServerInterceptor {
    private final Tracer tracer;
    
    public TracingInterceptor(Tracer tracer) {
        this.tracer = tracer;
    }
    
    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call, 
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {
        
        String methodName = call.getMethodDescriptor().getFullMethodName();
        
        // 헤더에서 상위 컨텍스트 추출
        Context parentContext = OpenTelemetry.getPropagators().getTextMapPropagator()
                .extract(Context.current(), headers, new HeadersExtractor());
        
        // 새 스팬 생성
        Span span = tracer.spanBuilder(methodName)
                .setParent(parentContext)
                .setSpanKind(SpanKind.SERVER)
                .startSpan();
        
        // 스팬에 메서드 정보 추가
        span.setAttribute("rpc.service", extractService(methodName));
        span.setAttribute("rpc.method", extractMethod(methodName));
        
        try (Scope scope = span.makeCurrent()) {
            return new ForwardingServerCallListener.SimpleForwardingServerCallListener<ReqT>(
                    next.startCall(
                        new ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT>(call) {
                            @Override
                            public void close(Status status, Metadata trailers) {
                                // 스팬에 상태 정보 추가
                                span.setAttribute("rpc.status_code", status.getCode().toString());
                                
                                if (!status.isOk()) {
                                    // 오류 정보 기록
                                    span.setStatus(StatusCode.ERROR);
                                    span.recordException(status.asRuntimeException());
                                    span.setAttribute("error.message", status.getDescription());
                                }
                                
                                super.close(status, trailers);
                                span.end();
                            }
                        }, 
                        headers)) {
                // 리스너 구현
            };
        }
    }
    
    private String extractService(String fullMethodName) {
        int index = fullMethodName.indexOf('/');
        return index > 0 ? fullMethodName.substring(0, index) : fullMethodName;
    }
    
    private String extractMethod(String fullMethodName) {
        int index = fullMethodName.indexOf('/');
        return index > 0 ? fullMethodName.substring(index + 1) : "";
    }
    
    // 헤더에서 컨텍스트 추출을 위한 도우미 클래스
    private static class HeadersExtractor implements TextMapGetter<Metadata> {
        @Override
        public Iterable<String> keys(Metadata carrier) {
            // 메타데이터 키 추출 로직
            return Collections.emptyList();  // 간단한 예시용
        }

        @Override
        public String get(Metadata carrier, String key) {
            // 메타데이터에서 값 추출
            Metadata.Key<String> k = Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER);
            return carrier.get(k);
        }
    }
}

보안 관련 오류 처리

gRPC 서비스에서 보안 관련 오류는 특별한 주의가 필요하다.

인증 및 인가 오류

인증 및 인가 오류를 적절하게 처리하는 것이 중요하다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// 인증 인터셉터 예시
public class AuthInterceptor implements ServerInterceptor {
    
    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call, 
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {
        
        // … 앞부분 생략 …
        
        try {
            // 토큰 검증
            UserContext userContext = authService.validateToken(token);
            
            // 접근 권한 확인
            if (!hasPermission(userContext, methodName)) {
                call.close(
                    Status.PERMISSION_DENIED
                        .withDescription("이 작업을 수행할 권한이 없습니다"),
                    new Metadata());
                return new ServerCall.Listener<ReqT>() {};
            }
            
            // 유효한 인증 컨텍스트를 다음 인터셉터 또는 서비스로 전달
            Context ctx = Context.current().withValue(USER_CONTEXT_KEY, userContext);
            return Contexts.interceptCall(ctx, call, headers, next);
            
        } catch (ExpiredTokenException e) {
            // 만료된 토큰 처리
            Metadata trailers = new Metadata();
            trailers.put(
                Metadata.Key.of("token-expired", Metadata.ASCII_STRING_MARSHALLER), 
                "true");
            
            call.close(
                Status.UNAUTHENTICATED
                    .withDescription("인증 토큰이 만료되었습니다"),
                trailers);
            return new ServerCall.Listener<ReqT>() {};
            
        } catch (InvalidTokenException e) {
            // 유효하지 않은 토큰 처리
            call.close(
                Status.UNAUTHENTICATED
                    .withDescription("유효하지 않은 인증 토큰입니다"),
                new Metadata());
            return new ServerCall.Listener<ReqT>() {};
        }
    }
    
    private String extractToken(Metadata headers) {
        return headers.get(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER));
    }
    
    private boolean isPublicMethod(String methodName) {
        // 공개 메서드 확인 로직
        return methodName.endsWith("/Login") || methodName.endsWith("/Register");
    }
    
    private boolean hasPermission(UserContext userContext, String methodName) {
        // 권한 확인 로직
        if (methodName.contains("Admin") && !userContext.hasRole("ADMIN")) {
            return false;
        }
        return true;
    }
}

// 클라이언트 측 처리
try {
    Response response = stub.protectedMethod(request);
    // 성공적인 응답 처리
} catch (StatusRuntimeException e) {
    Status status = e.getStatus();
    if (status.getCode() == Status.Code.UNAUTHENTICATED) {
        Metadata trailers = e.getTrailers();
        if (trailers != null && 
            "true".equals(trailers.get(
                Metadata.Key.of("token-expired", Metadata.ASCII_STRING_MARSHALLER)))) {
            // 토큰 재발급 시도
            refreshToken();
            // 재시도 로직
        } else {
            // 로그인 화면으로 리디렉션
            redirectToLogin();
        }
    } else if (status.getCode() == Status.Code.PERMISSION_DENIED) {
        // 접근 거부 화면 표시
        showAccessDenied(status.getDescription());
    }
}

속도 제한(Rate Limiting) 오류

서비스 남용을 방지하기 위한 속도 제한과 관련된 오류를 처리하는 방법:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// 속도 제한 인터셉터 예시
public class RateLimitInterceptor implements ServerInterceptor {
    private final RateLimiter rateLimiter;
    
    public RateLimitInterceptor(RateLimiter rateLimiter) {
        this.rateLimiter = rateLimiter;
    }
    
    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call, 
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {
        
        String methodName = call.getMethodDescriptor().getFullMethodName();
        String clientId = extractClientId(headers);
        
        // 속도 제한 확인
        if (!rateLimiter.allowRequest(clientId, methodName)) {
            // 재시도 정보 추가
            Metadata trailers = new Metadata();
            trailers.put(
                Metadata.Key.of("retry-after-ms", Metadata.ASCII_STRING_MARSHALLER),
                String.valueOf(rateLimiter.getRetryAfterMs(clientId, methodName)));
            
            call.close(
                Status.RESOURCE_EXHAUSTED
                    .withDescription("요청 한도 초과. 나중에 다시 시도하세요"),
                trailers);
            return new ServerCall.Listener<ReqT>() {};
        }
        
        return next.startCall(call, headers);
    }
    
    private String extractClientId(Metadata headers) {
        // 클라이언트 ID 추출 로직
        return headers.get(Metadata.Key.of("client-id", Metadata.ASCII_STRING_MARSHALLER));
    }
}

// 클라이언트 측 처리
try {
    Response response = stub.frequentMethod(request);
    // 성공적인 응답 처리
} catch (StatusRuntimeException e) {
    if (e.getStatus().getCode() == Status.Code.RESOURCE_EXHAUSTED) {
        // 재시도 지연 정보 추출
        Metadata trailers = e.getTrailers();
        String retryAfterMs = trailers.get(
            Metadata.Key.of("retry-after-ms", Metadata.ASCII_STRING_MARSHALLER));
        
        if (retryAfterMs != null) {
            long delayMs = Long.parseLong(retryAfterMs);
            logger.info("속도 제한 초과, " + delayMs + "ms 후 재시도 예정");
            
            // 지연 후 재시도 로직
            scheduler.schedule(() -> retryRequest(request), delayMs, TimeUnit.MILLISECONDS);
        }
    }
}

민감한 정보 처리

오류 메시지에서 민감한 정보가 노출되지 않도록 주의해야 한다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 민감한 정보 필터링 예시
public class ErrorSanitizingInterceptor implements ServerInterceptor {
    private static final Logger logger = Logger.getLogger(ErrorSanitizingInterceptor.class.getName());
    
    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call, 
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {
        
        return next.startCall(
            new ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT>(call) {
                @Override
                public void close(Status status, Metadata trailers) {
                    if (!status.isOk()) {
                        // 내부 오류의 경우 상세 정보 로깅 후 일반적인 메시지로 대체
                        if (status.getCode() == Status.Code.INTERNAL) {
                            logger.severe("내부 오류: " + status.getDescription());
                            
                            // 민감한 정보가 포함된 원래 메시지 대신 일반적인 메시지 사용
                            status = Status.INTERNAL.withDescription("내부 서버 오류가 발생했습니다");
                        }
                        
                        // SQL 주입, 스택 트레이스 등의 패턴 제거
                        String description = status.getDescription();
                        if (description != null) {
                            description = sanitizeErrorMessage(description);
                            status = status.withDescription(description);
                        }
                    }
                    
                    super.close(status, trailers);
                }
                
                private String sanitizeErrorMessage(String message) {
                    // SQL 오류 패턴 제거
                    message = message.replaceAll("(SQL|sql)\\s+error.*", "데이터베이스 오류");
                    
                    // 스택 트레이스 제거
                    message = message.replaceAll("at\\s+[\\w\\.\\$]+\\([^)]*\\).*", "");
                    
                    // 파일 경로 제거
                    message = message.replaceAll("/(home|usr|var|etc)/[\\w/]*", "[PATH]");
                    
                    // IP 주소 제거
                    message = message.replaceAll("\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}", "[IP]");
                    
                    return message;
                }
            }, 
            headers);
    }
}

다국어 지원과 오류 메시지 지역화

국제적인 사용자를 위한 애플리케이션에서는 오류 메시지의 지역화가 중요하다.

지역화된 오류 메시지 제공

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
// 오류 메시지 지역화 인터셉터 예시
public class LocalizedErrorInterceptor implements ServerInterceptor {
    private final ResourceBundle.Control bundleControl;
    private final Map<Locale, ResourceBundle> errorMessageBundles;
    
    public LocalizedErrorInterceptor() {
        bundleControl = ResourceBundle.Control.getControl(ResourceBundle.Control.FORMAT_PROPERTIES);
        errorMessageBundles = new ConcurrentHashMap<>();
    }
    
    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call, 
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {
        
        // 클라이언트 로케일 추출
        Locale clientLocale = extractLocale(headers);
        
        return next.startCall(
            new ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT>(call) {
                @Override
                public void close(Status status, Metadata trailers) {
                    if (!status.isOk()) {
                        // 오류 코드 추출
                        String errorCode = extractErrorCode(status.getDescription());
                        
                        if (errorCode != null) {
                            // 지역화된 오류 메시지 찾기
                            String localizedMessage = getLocalizedErrorMessage(clientLocale, errorCode);
                            
                            if (localizedMessage != null) {
                                // 지역화된 메시지로 상태 업데이트
                                status = status.withDescription(localizedMessage);
                                
                                // 원래 오류 코드를 메타데이터에 포함
                                trailers.put(
                                    Metadata.Key.of("error-code", Metadata.ASCII_STRING_MARSHALLER),
                                    errorCode);
                            }
                        }
                    }
                    
                    super.close(status, trailers);
                }
                
                private String extractErrorCode(String description) {
                    // 오류 설명에서 코드 추출 (형식: [CODE:xxxx] 메시지)
                    if (description != null) {
                        Matcher matcher = Pattern.compile("\\[CODE:(\\w+)\\].*").matcher(description);
                        if (matcher.matches()) {
                            return matcher.group(1);
                        }
                    }
                    return null;
                }
                
                private String getLocalizedErrorMessage(Locale locale, String errorCode) {
                    try {
                        ResourceBundle bundle = errorMessageBundles.computeIfAbsent(
                            locale, 
                            l -> ResourceBundle.getBundle("errors", l, bundleControl));
                        
                        return bundle.getString(errorCode);
                    } catch (MissingResourceException e) {
                        // 번역이 없는 경우 원래 메시지 사용
                        return null;
                    }
                }
            }, 
            headers);
    }
    
    private Locale extractLocale(Metadata headers) {
        String localeStr = headers.get(
            Metadata.Key.of("accept-language", Metadata.ASCII_STRING_MARSHALLER));
        
        if (localeStr != null) {
            String[] parts = localeStr.split("[-_]", 2);
            if (parts.length == 2) {
                return new Locale(parts[0], parts[1]);
            } else {
                return new Locale(parts[0]);
            }
        }
        
        // 기본 로케일
        return Locale.US;
    }
}

// 서비스 구현에서 사용
@Override
public void createUser(CreateUserRequest request, StreamObserver<User> responseObserver) {
    if (request.getEmail() == null || !isValidEmail(request.getEmail())) {
        responseObserver.onError(
            Status.INVALID_ARGUMENT
                .withDescription("[CODE:INVALID_EMAIL] 유효하지 않은 이메일 주소입니다")
                .asRuntimeException());
        return;
    }
    
    // 나머지 처리 로직
}

다양한 언어로 오류 메시지 정의

각 언어별 속성 파일에 오류 메시지를 정의한다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# errors_en.properties (영어)
INVALID_EMAIL=Invalid email address
USER_NOT_FOUND=User not found
PERMISSION_DENIED=You do not have permission to perform this action

# errors_ko.properties (한국어)
INVALID_EMAIL=유효하지 않은 이메일 주소입니다
USER_NOT_FOUND=사용자를 찾을 수 없습니다
PERMISSION_DENIED=이 작업을 수행할 권한이 없습니다

# errors_ja.properties (일본어)
INVALID_EMAIL=無効なメールアドレスです
USER_NOT_FOUND=ユーザーが見つかりません
PERMISSION_DENIED=この操作を実行する権限がありません

확장성을 고려한 오류 처리 설계

대규모 마이크로서비스 환경에서는 확장 가능한 오류 처리 시스템이 필요하다.

오류 처리 라이브러리

모든 서비스가 공통된 오류 처리 패턴을 사용할 수 있도록 라이브러리화할 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// 공통 오류 처리 라이브러리 예시
public class ErrorLibrary {
    // 공통 오류 코드 정의
    public enum ErrorCode {
        INVALID_INPUT("INVALID_INPUT", Status.Code.INVALID_ARGUMENT),
        NOT_FOUND("NOT_FOUND", Status.Code.NOT_FOUND),
        ALREADY_EXISTS("ALREADY_EXISTS", Status.Code.ALREADY_EXISTS),
        PERMISSION_DENIED("PERMISSION_DENIED", Status.Code.PERMISSION_DENIED),
        UNAUTHENTICATED("UNAUTHENTICATED", Status.Code.UNAUTHENTICATED),
        INTERNAL("INTERNAL", Status.Code.INTERNAL);
        
        private final String code;
        private final Status.Code statusCode;
        
        ErrorCode(String code, Status.Code statusCode) {
            this.code = code;
            this.statusCode = statusCode;
        }
        
        public String getCode() {
            return code;
        }
        
        public Status.Code getStatusCode() {
            return statusCode;
        }
    }
    
    // 오류 상태 생성 헬퍼 메서드
    public static Status createStatus(ErrorCode errorCode, String message) {
        return Status.fromCode(errorCode.getStatusCode())
            .withDescription(String.format("[CODE:%s] %s", errorCode.getCode(), message));
    }
    
    // 오류 상태와 상세 정보를 포함한 예외 생성
    public static StatusRuntimeException createException(ErrorCode errorCode, String message) {
        return createStatus(errorCode, message).asRuntimeException();
    }
    
    public static StatusRuntimeException createException(
            ErrorCode errorCode, String message, Metadata trailers) {
        return createStatus(errorCode, message).asRuntimeException(trailers);
    }
    
    // 필드 오류 상세 정보 생성
    public static Metadata createFieldErrorMetadata(String fieldName, String errorMessage) {
        Metadata metadata = new Metadata();
        
        BadRequest badRequest = BadRequest.newBuilder()
            .addFieldViolations(FieldViolation.newBuilder()
                .setField(fieldName)
                .setDescription(errorMessage)
                .build())
            .build();
        
        metadata.put(
            ProtoUtils.keyForProto(BadRequest.getDefaultInstance()),
            badRequest);
        
        return metadata;
    }
    
    // 일반적인 비즈니스 예외를 gRPC 상태로 변환
    public static StatusRuntimeException mapBusinessException(BusinessException e) {
        // 비즈니스 예외 유형에 따라 적절한 gRPC 상태 코드 매핑
        switch (e.getType()) {
            case VALIDATION:
                return createException(ErrorCode.INVALID_INPUT, e.getMessage());
            case NOT_FOUND:
                return createException(ErrorCode.NOT_FOUND, e.getMessage());
            case DUPLICATE:
                return createException(ErrorCode.ALREADY_EXISTS, e.getMessage());
            case AUTHORIZATION:
                return createException(ErrorCode.PERMISSION_DENIED, e.getMessage());
            case AUTHENTICATION:
                return createException(ErrorCode.UNAUTHENTICATED, e.getMessage());
            default:
                // 내부 오류는 로깅하고 일반적인 메시지 반환
                logger.severe("내부 오류: " + e.getMessage());
                return createException(ErrorCode.INTERNAL, "내부 서버 오류가 발생했습니다");
        }
    }
}

서비스 경계에서의 일관된 오류 변환

분산 시스템의 각 계층에서 일관된 오류 변환을 적용한다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 서비스 계층에서의 오류 변환 예시
@GrpcService
public class UserServiceImpl extends UserServiceGrpc.UserServiceImplBase {
    private final UserManager userManager;
    
    @Override
    public void getUser(GetUserRequest request, StreamObserver<User> responseObserver) {
        try {
            // 비즈니스 로직 호출
            User user = userManager.getUser(request.getId());
            responseObserver.onNext(user);
            responseObserver.onCompleted();
        } catch (UserNotFoundException e) {
            // 도메인 예외를 gRPC 상태로 변환
            responseObserver.onError(
                ErrorLibrary.createException(
                    ErrorLibrary.ErrorCode.NOT_FOUND,
                    e.getMessage()));
        } catch (InvalidInputException e) {
            responseObserver.onError(
                ErrorLibrary.createException(
                    ErrorLibrary.ErrorCode.INVALID_INPUT,
                    e.getMessage()));
        } catch (Exception e) {
            // 예상치 못한 오류는 로깅하고 일반적인 메시지 반환
            logger.severe("사용자 조회 중 예상치 못한 오류: " + e.getMessage());
            responseObserver.onError(
                ErrorLibrary.createException(
                    ErrorLibrary.ErrorCode.INTERNAL,
                    "내부 서버 오류가 발생했습니다"));
        }
    }
}

마이크로서비스 간 오류 전파 표준화

마이크로서비스 간 통신에서 오류 전파를 표준화하는 것은 매우 중요하다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 마이크로서비스 간 오류 전파 예시
public class UserProfileServiceImpl extends UserProfileServiceGrpc.UserProfileServiceImplBase {
    private final UserServiceClient userServiceClient;
    private final ProfileStore profileStore;
    
    @Override
    public void getUserProfile(GetUserProfileRequest request, 
                              StreamObserver<UserProfile> responseObserver) {
        try {
            // 다른 서비스 호출
            User user = userServiceClient.getUser(request.getUserId());
            
            // 프로필 정보 조회
            ProfileData profileData = profileStore.getProfileData(request.getUserId());
            
            // 응답 생성
            UserProfile profile = UserProfile.newBuilder()
                .setUserId(user.getId())
                .setName(user.getName())
                .setEmail(user.getEmail())
                .setBio(profileData.getBio())
                .setPictureUrl(profileData.getPictureUrl())
                .build();
            
            responseObserver.onNext(profile);
            responseObserver.onCompleted();
        } catch (StatusRuntimeException e) {
            // 다른 서비스에서 발생한 gRPC 오류 처리
            Status status = e.getStatus();
            
            if (status.getCode() == Status.Code.NOT_FOUND) {
                // 사용자 서비스에서 사용자를 찾지 못한 경우
                responseObserver.onError(
                    Status.NOT_FOUND
                        .withDescription("사용자 프로필을 찾을 수 없습니다")
                        .asRuntimeException());
            } else if (status.getCode() == Status.Code.PERMISSION_DENIED) {
                // 권한 오류 전파
                responseObserver.onError(e);
            } else {
                // 다른 오류의 경우 로깅 후 내부 오류로 변환
                logger.severe("사용자 서비스 오류: " + status);
                responseObserver.onError(
                    Status.INTERNAL
                        .withDescription("프로필을 처리하는 중 오류가 발생했습니다")
                        .asRuntimeException());
            }
        } catch (Exception e) {
            // 예상치 못한 오류 처리
            logger.severe("프로필 처리 중 예상치 못한 오류: " + e.getMessage());
            responseObserver.onError(
                Status.INTERNAL
                    .withDescription("내부 서버 오류가 발생했습니다")
                    .asRuntimeException());
        }
    }
}

테스트와 오류 시뮬레이션

오류 처리 메커니즘을 철저히 테스트하는 것은 신뢰성 있는 gRPC 서비스를 제공하는 데 필수적이다.

단위 테스트와 오류 시나리오

다양한 오류 시나리오에 대한 단위 테스트를 작성한다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 오류 처리 단위 테스트 예시
@Test
public void testInvalidArgumentError() {
    // 테스트 설정
    UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub(channel);
    
    // 잘못된 입력으로 테스트
    GetUserRequest request = GetUserRequest.newBuilder()
        .setId(-1)  // 음수 ID는 유효하지 않음
        .build();
    
    // 예외 검증
    StatusRuntimeException exception = assertThrows(
        StatusRuntimeException.class,
        () -> stub.getUser(request));
    
    // 상태 코드 검증
    assertEquals(Status.Code.INVALID_ARGUMENT, exception.getStatus().getCode());
    assertTrue(exception.getStatus().getDescription().contains("양수여야 합니다"));
}

@Test
public void testUserNotFoundError() {
    // 테스트 설정
    UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub(channel);
    
    // 존재하지 않는 사용자 ID로 테스트
    GetUserRequest request = GetUserRequest.newBuilder()
        .setId(9999)  // 존재하지 않는 ID
        .build();
    
    // 예외 검증
    StatusRuntimeException exception = assertThrows(
        StatusRuntimeException.class,
        () -> stub.getUser(request));
    
    // 상태 코드 검증
    assertEquals(Status.Code.NOT_FOUND, exception.getStatus().getCode());
    assertTrue(exception.getStatus().getDescription().contains("찾을 수 없습니다"));
}

통합 테스트와 오류 전파 검증

여러 서비스 간의 오류 전파를 검증하는 통합 테스트를 작성한다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 오류 전파 통합 테스트 예시
@Test
public void testErrorPropagationAcrossServices() {
    // 테스트 설정
    UserProfileServiceGrpc.UserProfileServiceBlockingStub profileStub = 
        UserProfileServiceGrpc.newBlockingStub(profileChannel);
    
    // 존재하지 않는 사용자로 프로필 요청
    GetUserProfileRequest request = GetUserProfileRequest.newBuilder()
        .setUserId(9999)  // 존재하지 않는 ID
        .build();
    
    // 예외 검증
    StatusRuntimeException exception = assertThrows(
        StatusRuntimeException.class,
        () -> profileStub.getUserProfile(request));
    
    // 프로필 서비스에서 NOT_FOUND로 변환되었는지 확인
    assertEquals(Status.Code.NOT_FOUND, exception.getStatus().getCode());
    assertTrue(exception.getStatus().getDescription().contains("프로필을 찾을 수 없습니다"));
}

오류 시뮬레이션 테스트

다양한 오류 상황을 시뮬레이션하여 시스템의 견고성을 테스트한다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// 오류 시뮬레이션 테스트 예시
@Test
public void testRetryMechanismOnUnavailable() throws Exception {
    // 일시적으로 사용할 수 없는 서비스 모의 객체 생성
    Server flakyServer = createFlakyServer();
    try {
        flakyServer.start();
        
        // 재시도 메커니즘이 있는 스텁 생성
        UserServiceGrpc.UserServiceBlockingStub retryingStub = 
            UserServiceGrpc.newBlockingStub(ManagedChannelBuilder.forAddress("localhost", flakyServer.getPort())
                .usePlaintext()
                .build())
            .withDeadlineAfter(10, TimeUnit.SECONDS);
        
        // 테스트 요청
        GetUserRequest request = GetUserRequest.newBuilder()
            .setId(123)
            .build();
        
        // 재시도 후 성공적으로 응답을 받아야 함
        User user = retryingStub.getUser(request);
        
        // 응답 검증
        assertEquals(123, user.getId());
        assertEquals("Test User", user.getName());
    } finally {
        flakyServer.shutdownNow();
    }
}

private Server createFlakyServer() {
    // 처음 두 번은 UNAVAILABLE 오류를 반환하고, 세 번째 요청부터는 성공하는 서버
    AtomicInteger requestCount = new AtomicInteger(0);
    
    return ServerBuilder.forPort(0)
        .addService(new UserServiceGrpc.UserServiceImplBase() {
            @Override
            public void getUser(GetUserRequest request, StreamObserver<User> responseObserver) {
                int count = requestCount.incrementAndGet();
                
                if (count <= 2) {
                    // 처음 두 번은 UNAVAILABLE 오류
                    responseObserver.onError(
                        Status.UNAVAILABLE
                            .withDescription("서비스를 일시적으로 사용할 수 없습니다")
                            .asRuntimeException());
                } else {
                    // 세 번째부터는 정상 응답
                    User user = User.newBuilder()
                        .setId(request.getId())
                        .setName("Test User")
                        .setEmail("test@example.com")
                        .build();
                    
                    responseObserver.onNext(user);
                    responseObserver.onCompleted();
                }
            }
        })
        .build();
}

주요 원칙

  1. 일관성: 모든 서비스에서 일관된 오류 코드와 형식을 사용하여 클라이언트가 쉽게 예측하고 대응할 수 있도록 한다.
  2. 명확성: 오류 메시지와 코드는 오류의 원인과 가능한 해결 방법을 명확하게 전달해야 한다.
  3. 보안성: 오류 메시지에 민감한 정보가 포함되지 않도록 하고, 인증 및 인가 오류를 적절하게 처리해야 한다.
  4. 확장성: 오류 처리 메커니즘은 새로운 오류 유형을 추가하고 여러 서비스 간에 오류를 전파할 수 있도록 확장 가능해야 한다.
  5. 재사용성: 공통 오류 처리 로직을 라이브러리나 인터셉터로 구현하여 코드 중복을 줄이고 일관성을 향상시킨다.

모범 사례 요약

서버 측 모범 사례

  1. 적절한 상태 코드 선택: 오류 상황에 가장 적합한 gRPC 상태 코드를 선택한다.
    • 클라이언트 오류에는 INVALID_ARGUMENT, NOT_FOUND, ALREADY_EXISTS
    • 서버 오류에는 INTERNAL, UNAVAILABLE
    • 권한 관련 오류에는 PERMISSION_DENIED, UNAUTHENTICATED
  2. 유용한 오류 메시지 제공: 디버깅에 도움이 되는 명확한 오류 메시지를 제공하되, 민감한 정보는 노출하지 않는다.
  3. 구조화된 오류 상세 정보: 가능한 경우 google.rpc.Status 확장이나 메타데이터를 사용하여 구조화된 오류 정보를 제공한다.
  4. 오류 처리 중앙화: 인터셉터나 공통 라이브러리를 사용하여 오류 처리 로직을 중앙화한다.
  5. 오류 매핑 표준화: 비즈니스 로직 예외를 gRPC 상태 코드로 매핑하는 일관된 전략을 사용한다.
  6. 적절한 로깅: 오류 발생 시 디버깅에 필요한 정보를 로깅하되, 민감한 정보는 제외한다.
  7. 스트리밍 RPC에서의 적절한 오류 처리: 스트리밍 RPC에서 오류가 발생하면 스트림을 적절하게 종료한다.

클라이언트 측 모범 사례

  1. 오류 상태 코드 확인: 오류 응답에서 상태 코드를 확인하고 상황에 맞게 처리한다.
  2. 재시도 전략 구현: 일시적인 오류(UNAVAILABLE, RESOURCE_EXHAUSTED 등)에 대한 적절한 재시도 전략을 구현한다.
  3. 적절한 타임아웃 설정: 모든 RPC 호출에 적절한 타임아웃을 설정하여 응답을 무한정 기다리는 상황을 방지한다.
  4. 점진적 성능 저하 처리: 일부 서비스에 문제가 발생하더라도 가능한 기능은 정상 작동하도록 설계한다.
  5. 오류 메타데이터 활용: 서버에서 제공하는 추가 오류 정보(트레일러 메타데이터)를 활용한다.

언어별 고려 사항

각 프로그래밍 언어는 gRPC 오류 처리에 대한 특정 패턴과 관용구를 가지고 있다:

  1. Java: Status, StatusRuntimeException, StreamObserver.onError()를 사용한다.
  2. Go: status 패키지와 codes 패키지를 사용하여 오류를 처리한다.
  3. Python: grpc.StatusCodecontext.set_code(), context.set_details()를 사용한다.
  4. C#: RpcExceptionStatusCode 열거형을 사용한다.
  5. JavaScript/TypeScript: status 객체와 콜백 패턴 또는 Promise를 사용한다.

전체 시스템 수준 고려 사항

  1. 일관된 모니터링: 모든 서비스에서 오류 발생률, 유형, 심각도를 모니터링한다.
  2. 서비스 수준 목표(SLO) 정의: 허용 가능한 오류율과 응답 시간을 정의하고 모니터링한다.
  3. 문서화: 모든 서비스의 가능한 오류 코드와 처리 방법을 문서화한다.
  4. 카오스 테스트: 다양한 오류 시나리오를 시뮬레이션하여 시스템의 견고성을 테스트한다.
  5. 오류 처리 방식 개선: 생산 환경에서 발생하는 실제 오류를 분석하고 오류 처리 메커니즘을 지속적으로 개선한다.

용어 정리

용어설명

참고 및 출처