[Flutter] gRPC 통신 (1)

고랭지참치·2025년 12월 16일

Flutter

목록 보기
25/25
post-thumbnail

올해 10월 25일 Flutter Korea에서 주최하는 Flutter Korea 2025 : Transition 컨퍼런스에 연사자로 참여했습니다 :)

진행하고 있던 프로젝트에서 Flutter로 gRPC통신을 구현하고 있었고, 거기서 얻은 인사이트들을 나눌 수 있는 좋은 기회였습니다.

gRPC통신에 대해서는 대략적인 내용만 이해하고 있었고, 실제로 앱 개발자로서 사용해본 것은 처음이었습니다. 이런 과정에서 겪은 트러블 슈팅과 얻은 인사이트들을 컨퍼런스에서 공유할 수 있었는데, 이 내용을 더 늦지 않았을 때 글로 남겨야 겠다는 생각이 들어 블로그를 작성하게 됐습니다.

발표 자료와 예시 코드를 작성해둔 깃허브 레포 링크를 공유합니다 :)

피그마 링크
깃헙 레포

gRPC 사용배경과 내용들은 발표자료에 있기에 블로그에서는 시간 상 공유하지 못했던 내용들을 작성합니다.


GeneratedMessage와 Entity

proto 컴파일러를 통해서 GeneratedMessage(*.pb.dart)를 자동생성할 수 있습니다. 이렇게 만들어진 클래스를 바로 앱에서 사용할 수 는 없는 다양한 이유가 있는데, 때문에 gRPC 통신이 구현되는 Data Layer에서는 GeneratedMessage를 사용해야하고 앱 내부(Domain-Presentation) 레이어 에서는 별도의 Entity 클래스를 만들어 기능을 구현해야 했습니다.

GeneratedMessage 클래스를 바로 앱에서 사용할 수 없다고 판단한 이유는 크게 4가지가 있습니다.

mutable(가변 객체) + builder 성격

GeneratedMessage는 전송용 DTO에 최적화된 타입이라, 생성 후에도 필드를 set 하며 값을 채워 넣는 mutable 구조입니다.
하지만 앱 내부 상태는 보통 immutable(불변) 모델을 기준으로 관리하게 만들어져 있기에 변경 추적과 디버깅을 위해서는 사용할 수 없다고 판단했습니다.

// Proto (GeneratedMessage)
void protoMutableExample() {
  final meta = MetaData();

  // 1) 생성 후 set 으로 채움 (mutable)
  meta.pagination = SliceResponse()
    ..page = 1
    ..size = 20;

  // 2) ensureXxx(): 없으면 만들고 반환 -> 반환값을 계속 수정 (builder-like)
  meta.ensurePagination().page = 2;

  print(meta.pagination.page); // 2
}

value equality(값 동등성) 중심이 아님

Freezed 모델은 ==가 필드 기반으로 동작하는 value equality(값 동등성)를 기본으로 제공합니다. 반면 GeneratedMessage는 이런 “도메인 모델 관점의 값 비교”를 전제로 설계된 타입이 아니라서, 상태 비교/캐싱/리빌드 판단 같은 영역에서 유지보수가 어려울 것이라고 판단했습니다.

copyWith가 field-based가 아니라 closure-based update

Freezed의 copyWith(data: …) 같은 필드 기반(field-based) 방식이 아니라, copyWith((m) { … }) 형태의 업데이트 클로저(closure-based update) 방식입니다.
사용 자체는 가능하지만, 앱 모델로 쓰기엔 가독성과 안전성(특히 중첩 메시지 수정)이 떨어지고, “불변 모델을 다룬다”는 느낌도 약해집니다.

// Proto (GeneratedMessage)
void protoCopyWithClosureExample() {
  final a = MetaData()
    ..pagination = (SliceResponse()
      ..page = 1
      ..size = 20);

  // copyWith + 내부 mutate(업데이트 클로저)
  final b = a.copyWith((m) {
    // 중첩 메시지는 ensureXxx + mutate 체인이 자주 등장
    m.ensurePagination().page = 2;
  });

  print(a.pagination.page); // 1
  print(b.pagination.page); // 2
}

결론

Data Layer에서는 Proto(GeneratedMessage) 를 그대로 사용하고, 앱 내부 로직이 돌아가는 Domain/Presentation 레이어에서는 별도의 Entity(Freezed) 를 정의해 사용했습니다. 그리고 이 둘의 경계를 명확히 하기 위해 fromProto() / toProto() 변환 책임을 gRPCMapper 클래스로 분리했고, 변환은 각 기능의 Repository에서 일관되게 관리하도록 구성했습니다.

class SomeGrpcMapper{

  UpsertInfoRequest toUpsertProto({
    required UserEntity entity,
  }) {
    final UpsertInfoRequest request = UpsertInfoRequest(
      passport: userGrpcMapper.toProto(entity: entity),
    );

    return request;
  }
  }

gRPC 에러(Exception)처리

gRPC는 UNAVAILABLE, UNAUTHENTICATED 같은 표준 상태 코드(1~16) 를 제공하기 때문에, 이 코드만으로도 에러 처리가 가능합니다.
하지만 서버가 내려주는 trailing metadata(trailers) 에 커스텀 에러코드를 통해 기능을 구현해야 했기에 클라이언트에서는 이를 파싱해 공통 예외로 맵핑하는 구조를 만들었습니다.


// Data Layer Base DataSource
} on GrpcError catch (error, _) {
  final base = CustomGrpcException(
    error.message ?? 'Unknown gRPC error',
    code: error.code,                // gRPC 레벨 상태 코드(transport)
    rawResponse: error.rawResponse,
    trailers: error.trailers,        // 여기서 커스텀 에러코드 추출
    details: error.details,
  );

  final mapped = onGrpcError?.call(base) ?? base;

  if (debugLabel != null) {
    logger.e('[$runtimeType][$debugLabel] gRPC error: ${mapped.message}');
  }
  return Result.error(mapped);
}

CustomGrpcException은 gRPC의 상태 코드(code)는 그대로 보존하면서, trailers에 포함된 error-code를 파싱해 도메인에서 쓰는 커스텀 에러코드를 제공하도록 했습니다.

int? get errorCode {
  return trailers != null && trailers!.containsKey('custom-code')
      ? int.tryParse(trailers!['custom-code']!) ?? -1
      : -1;
}

이렇게 하여 “네트워크/프로토콜 문제인지(gRPC code)”와 “서비스 정책/비즈니스 문제인지(custom error-code)”를 분리할 수 있고, 화면에서는 커스텀 에러코드 기준으로 UX(로그아웃, 다이얼로그, 특정 화면 이동 등)를 일관되게 유지할 수 있었습니다.


gRPC 통신 아키텍처를 구현하고, 기능과 연결시키는 것은 Dio와 Retrofit조합을 통한 REST 통신만 하던 저에게 고민할 수 있는 다양한 이슈들을 만나게 해준 값진 경험이었습니다. 아직 더 gRPC에 대해서 공부하고 개선해야 할 것 들이 많기에, 위에 내용중 잘못되거나 수정이 필요한 내용이 있으면 댓글로 알려주세요 :)

profile
소프트웨어 엔지니어 / Flutter 개발자

0개의 댓글