올해 10월 25일 Flutter Korea에서 주최하는 Flutter Korea 2025 : Transition 컨퍼런스에 연사자로 참여했습니다 :)
진행하고 있던 프로젝트에서 Flutter로 gRPC통신을 구현하고 있었고, 거기서 얻은 인사이트들을 나눌 수 있는 좋은 기회였습니다.
gRPC통신에 대해서는 대략적인 내용만 이해하고 있었고, 실제로 앱 개발자로서 사용해본 것은 처음이었습니다. 이런 과정에서 겪은 트러블 슈팅과 얻은 인사이트들을 컨퍼런스에서 공유할 수 있었는데, 이 내용을 더 늦지 않았을 때 글로 남겨야 겠다는 생각이 들어 블로그를 작성하게 됐습니다.
발표 자료와 예시 코드를 작성해둔 깃허브 레포 링크를 공유합니다 :)
gRPC 사용배경과 내용들은 발표자료에 있기에 블로그에서는 시간 상 공유하지 못했던 내용들을 작성합니다.
proto 컴파일러를 통해서 GeneratedMessage(*.pb.dart)를 자동생성할 수 있습니다. 이렇게 만들어진 클래스를 바로 앱에서 사용할 수 는 없는 다양한 이유가 있는데, 때문에 gRPC 통신이 구현되는 Data Layer에서는 GeneratedMessage를 사용해야하고 앱 내부(Domain-Presentation) 레이어 에서는 별도의 Entity 클래스를 만들어 기능을 구현해야 했습니다.
GeneratedMessage 클래스를 바로 앱에서 사용할 수 없다고 판단한 이유는 크게 4가지가 있습니다.
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
}
Freezed 모델은 ==가 필드 기반으로 동작하는 value equality(값 동등성)를 기본으로 제공합니다. 반면 GeneratedMessage는 이런 “도메인 모델 관점의 값 비교”를 전제로 설계된 타입이 아니라서, 상태 비교/캐싱/리빌드 판단 같은 영역에서 유지보수가 어려울 것이라고 판단했습니다.
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는 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에 대해서 공부하고 개선해야 할 것 들이 많기에, 위에 내용중 잘못되거나 수정이 필요한 내용이 있으면 댓글로 알려주세요 :)