Flutter에서 GraphQL 사용에 대해 설명해주세요
질문
Flutter 애플리케이션에서 GraphQL을 통합하고 사용하는 방법과 장점에 대해 설명해주세요.
답변
GraphQL은 REST API의 대안으로 등장한 쿼리 언어 및 런타임으로, 클라이언트가 필요한 데이터만 정확히 요청할 수 있어 효율적인 데이터 통신을 가능하게 합니다. Flutter 애플리케이션에서 GraphQL을 사용하면 네트워크 요청을 최적화하고 타입 안전성을 향상시킬 수 있습니다.
1. Flutter에서 GraphQL 적용을 위한 주요 패키지
1.1 graphql_flutter 패키지
가장 널리 사용되는 Flutter용 GraphQL 패키지로, 웹소켓 지원 및 캐싱 기능을 제공합니다.
dependencies:
graphql_flutter: ^5.1.2
1.2 artemis 패키지
GraphQL 스키마를 기반으로 Dart 코드를 자동 생성하는 코드 제너레이터입니다.
dependencies:
artemis: ^7.12.0-beta
dev_dependencies:
build_runner: ^2.3.3
json_serializable: ^6.6.1
2. GraphQL 클라이언트 설정
2.1 기본 클라이언트 설정
import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
void main() async {
// Flutter 초기화
WidgetsFlutterBinding.ensureInitialized();
// Hive 데이터베이스 초기화 (캐싱에 사용)
await initHiveForFlutter();
final HttpLink httpLink = HttpLink(
'https://api.example.com/graphql',
);
// 인증 토큰 추가
final AuthLink authLink = AuthLink(
getToken: () async => 'Bearer <YOUR_TOKEN>',
);
// 링크 결합
final Link link = authLink.concat(httpLink);
// 클라이언트 생성
ValueNotifier<GraphQLClient> client = ValueNotifier(
GraphQLClient(
link: link,
cache: GraphQLCache(store: HiveStore()),
),
);
runApp(
GraphQLProvider(
client: client,
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter GraphQL Demo',
home: HomePage(),
);
}
}
2.2 다양한 링크 유형과 결합
// 에러 처리를 위한 에러 링크
final ErrorLink errorLink = ErrorLink(
onGraphQLError: (request, forward, response) {
print('GraphQL Error: ${response.errors}');
return forward(request);
},
onException: (request, forward, exception) {
print('Exception: $exception');
return forward(request);
},
);
// HTTP 및 웹소켓 링크 결합
final WebSocketLink webSocketLink = WebSocketLink(
'wss://api.example.com/graphql',
config: SocketClientConfig(
autoReconnect: true,
inactivityTimeout: Duration(seconds: 30),
),
);
// 링크 구성 (쿼리와 뮤테이션은 HTTP, 구독은 WebSocket 사용)
final Link link = Link.split(
(request) => request.isSubscription,
webSocketLink,
authLink.concat(errorLink).concat(httpLink),
);
3. 쿼리(Query) 실행
3.1 Query 위젯 사용
class ProductsPage extends StatelessWidget {
// GraphQL 쿼리 정의
final String getProductsQuery = '''
query GetProducts {
products {
id
name
price
imageUrl
category {
id
name
}
}
}
''';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('상품 목록')),
body: Query(
options: QueryOptions(
document: gql(getProductsQuery),
fetchPolicy: FetchPolicy.cacheAndNetwork,
),
builder: (QueryResult result, {VoidCallback? refetch, FetchMore? fetchMore}) {
if (result.hasException) {
return Center(child: Text('오류 발생: ${result.exception.toString()}'));
}
if (result.isLoading) {
return Center(child: CircularProgressIndicator());
}
final List<dynamic> products = result.data?['products'] ?? [];
return ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
title: Text(product['name']),
subtitle: Text('₩${product['price']}'),
leading: Image.network(product['imageUrl']),
trailing: Text(product['category']['name']),
);
},
);
},
),
);
}
}
3.2 GraphQLClient 직접 사용
Future<List<Product>> fetchProducts() async {
final GraphQLClient client = GraphQLProvider.of(context).value;
final QueryOptions options = QueryOptions(
document: gql(getProductsQuery),
fetchPolicy: FetchPolicy.cacheAndNetwork,
);
final QueryResult result = await client.query(options);
if (result.hasException) {
throw Exception(result.exception.toString());
}
final List<dynamic> productsData = result.data?['products'] ?? [];
// JSON 데이터를 Product 객체로 변환
return productsData.map((productData) => Product.fromJson(productData)).toList();
}
3.3 변수가 있는 쿼리
String getProductByIdQuery = '''
query GetProduct(\$id: ID!) {
product(id: \$id) {
id
name
description
price
inStock
}
}
''';
// Query 위젯 사용
Query(
options: QueryOptions(
document: gql(getProductByIdQuery),
variables: {'id': productId},
),
builder: (result, {refetch, fetchMore}) {
// 결과 처리 로직
},
);
4. 뮤테이션(Mutation) 실행
4.1 Mutation 위젯 사용
class AddProductPage extends StatefulWidget {
@override
_AddProductPageState createState() => _AddProductPageState();
}
class _AddProductPageState extends State<AddProductPage> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _priceController = TextEditingController();
final String addProductMutation = '''
mutation AddProduct(\$name: String!, \$price: Float!) {
addProduct(input: {name: \$name, price: \$price}) {
id
name
price
}
}
''';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('상품 추가')),
body: Mutation(
options: MutationOptions(
document: gql(addProductMutation),
onCompleted: (dynamic resultData) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('상품이 추가되었습니다.')),
);
Navigator.of(context).pop();
},
),
builder: (RunMutation runMutation, QueryResult? result) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _nameController,
decoration: InputDecoration(labelText: '상품명'),
validator: (value) => value!.isEmpty ? '상품명을 입력하세요' : null,
),
TextFormField(
controller: _priceController,
decoration: InputDecoration(labelText: '가격'),
keyboardType: TextInputType.number,
validator: (value) => value!.isEmpty ? '가격을 입력하세요' : null,
),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
runMutation({
'name': _nameController.text,
'price': double.parse(_priceController.text),
});
}
},
child: Text('저장'),
),
if (result != null && result.isLoading)
CircularProgressIndicator(),
if (result != null && result.hasException)
Text('오류: ${result.exception.toString()}'),
],
),
);
},
),
);
}
}
4.2 GraphQLClient를 사용한 뮤테이션
Future<Product> addProduct(String name, double price) async {
final GraphQLClient client = GraphQLProvider.of(context).value;
final MutationOptions options = MutationOptions(
document: gql(addProductMutation),
variables: {
'name': name,
'price': price,
},
);
final QueryResult result = await client.mutate(options);
if (result.hasException) {
throw Exception(result.exception.toString());
}
return Product.fromJson(result.data!['addProduct']);
}
5. 구독(Subscription) 사용
실시간 데이터 업데이트를 위한 구독 설정:
class ChatPage extends StatelessWidget {
final String messagesSubscription = '''
subscription OnNewMessage(\$roomId: ID!) {
messageAdded(roomId: \$roomId) {
id
text
sender {
id
name
}
createdAt
}
}
''';
final String roomId;
ChatPage({required this.roomId});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('채팅방')),
body: Subscription(
options: SubscriptionOptions(
document: gql(messagesSubscription),
variables: {'roomId': roomId},
),
builder: (QueryResult result) {
if (result.hasException) {
return Center(child: Text('구독 오류: ${result.exception.toString()}'));
}
if (result.isLoading) {
return Center(child: CircularProgressIndicator());
}
final messageData = result.data?['messageAdded'];
if (messageData == null) {
return Center(child: Text('메시지를 기다리는 중...'));
}
// 새 메시지 처리
final message = Message.fromJson(messageData);
// 메시지 목록에 추가하는 로직
// ...
return MessageList(messages: messages);
},
),
);
}
}
6. 캐싱 및 오프라인 지원
6.1 캐시 정책 설정
final options = QueryOptions(
document: gql(query),
fetchPolicy: FetchPolicy.cacheAndNetwork, // 캐시 우선, 네트워크 요청도 수행
);
// 다른 캐시 정책 옵션:
// FetchPolicy.cacheFirst - 캐시에 있으면 반환, 없으면 네트워크 요청
// FetchPolicy.cacheOnly - 캐시만 확인, 네트워크 요청 안 함
// FetchPolicy.networkOnly - 네트워크에서만 데이터 가져오기
// FetchPolicy.noCache - 네트워크에서 가져오고 캐시에 저장 안 함
6.2 캐시 업데이트
// 캐시에서 쿼리 결과 직접 갱신
client.cache.writeQuery(
QueryOptions(
document: gql(getProductsQuery),
),
data: {
'products': [
// 업데이트된 제품 목록
]
},
);
// 뮤테이션 후 쿼리 캐시 업데이트
MutationOptions options = MutationOptions(
document: gql(addProductMutation),
variables: variables,
update: (GraphQLDataProxy cache, QueryResult? result) {
if (result == null || result.hasException || result.data == null) return;
// 쿼리로 얻은 이전 데이터 읽기
final cachedData = cache.readQuery(
QueryOptions(document: gql(getProductsQuery)),
);
if (cachedData == null) return;
final List<dynamic> previousProducts = cachedData['products'];
final newProduct = result.data!['addProduct'];
// 새 제품을 목록에 추가
cache.writeQuery(
QueryOptions(document: gql(getProductsQuery)),
data: {
'products': [...previousProducts, newProduct],
},
);
},
);
6.3 낙관적 응답(Optimistic Response)
final MutationOptions options = MutationOptions(
document: gql(addToCartMutation),
variables: {
'productId': productId,
'quantity': quantity,
},
optimisticResult: {
'addToCart': {
'__typename': 'CartItem',
'id': 'temp-id-${DateTime.now().millisecondsSinceEpoch}',
'product': {
'__typename': 'Product',
'id': productId,
'name': productName,
'price': productPrice,
},
'quantity': quantity,
}
},
);
7. 코드 생성을 통한 타입 안전성 (Artemis 사용)
7.1 스키마 파일 설정
먼저 GraphQL 스키마 파일이 필요합니다. schema.graphql
파일을 프로젝트 루트에 생성하거나 서버에서 다운로드합니다.
7.2 Artemis 설정
build.yaml
파일 생성:
targets:
$default:
builders:
artemis:
options:
schema_mapping:
- schema: schema.graphql
queries_glob: lib/graphql/*.graphql
output: lib/graphql/generated/graphql_api.dart
fragment_imports:
- "package:my_app/fragment_models.dart"
7.3 GraphQL 쿼리 파일 작성
lib/graphql/products.graphql
파일 생성:
query GetProducts {
products {
id
name
price
imageUrl
}
}
mutation AddProduct($name: String!, $price: Float!) {
addProduct(input: { name: $name, price: $price }) {
id
name
price
}
}
7.4 코드 생성 실행
flutter pub run build_runner build
7.5 생성된 코드 사용
import 'package:my_app/graphql/generated/graphql_api.dart';
// 쿼리 실행
Future<List<ProductsData_products>> fetchProducts() async {
final client = GraphQLProvider.of(context).value;
final options = GetProductsOptions();
final result = await client.query(options);
if (result.hasException) {
throw Exception(result.exception.toString());
}
final data = GetProducts$Query.fromJson(result.data!);
return data.products;
}
// 뮤테이션 실행
Future<AddProductData_addProduct> addProduct(String name, double price) async {
final client = GraphQLProvider.of(context).value;
final options = AddProductOptions(
variables: AddProductArguments(
name: name,
price: price,
),
);
final result = await client.mutate(options);
if (result.hasException) {
throw Exception(result.exception.toString());
}
final data = AddProduct$Mutation.fromJson(result.data!);
return data.addProduct;
}
8. GraphQL 프래그먼트 활용
프래그먼트는 재사용 가능한 GraphQL 쿼리 부분으로, 코드 중복을 줄이는 데 도움이 됩니다.
8.1 프래그먼트 정의
fragment ProductBasic on Product {
id
name
price
}
fragment ProductDetail on Product {
...ProductBasic
description
imageUrl
category {
id
name
}
reviews {
id
rating
comment
}
}
query GetProductDetails($id: ID!) {
product(id: $id) {
...ProductDetail
}
}
query GetProductsList {
products {
...ProductBasic
}
}
9. GraphQL vs REST API 비교
9.1 장점
- Over-fetching 해결: 필요한 데이터만 정확히 요청할 수 있어 데이터 전송량 감소
- Under-fetching 해결: 여러 API 엔드포인트를 호출할 필요 없이 하나의 요청으로 필요한 모든 데이터 검색
- 타입 안전성: 스키마 기반 시스템으로 런타임 오류 감소
- 유연한 API 진화: 새 필드를 추가해도 기존 클라이언트 코드에 영향 없음
- 실시간 데이터 처리: 구독을 통한 실시간 업데이트 지원
9.2 단점
- 복잡성: 설정과 학습이 REST API보다 더 복잡할 수 있음
- 캐싱 복잡도: REST보다 캐싱 전략이 더 복잡함
- 서버 부하: 복잡한 쿼리의 경우 서버에 추가 부하를 줄 수 있음
- 파일 업로드: 표준 명세에 파일 업로드가 없어 별도 구현 필요
10. 실제 Flutter 프로젝트에서의 아키텍처
10.1 리포지토리 패턴 적용
// ProductRepository 인터페이스
abstract class ProductRepository {
Future<List<Product>> getProducts();
Future<Product> getProductById(String id);
Future<Product> addProduct(String name, double price);
Future<bool> deleteProduct(String id);
}
// GraphQL 구현
class GraphQLProductRepository implements ProductRepository {
final GraphQLClient client;
GraphQLProductRepository(this.client);
@override
Future<List<Product>> getProducts() async {
final options = QueryOptions(
document: gql(getProductsQuery),
);
final result = await client.query(options);
if (result.hasException) {
throw RepositoryException(result.exception.toString());
}
final List<dynamic> productsData = result.data?['products'] ?? [];
return productsData.map((data) => Product.fromJson(data)).toList();
}
// 다른 메서드 구현...
}
10.2 종속성 주입과 함께 사용
// GetIt 사용 예시
final getIt = GetIt.instance;
void setupDependencies() {
// GraphQL 클라이언트 등록
getIt.registerSingleton<GraphQLClient>(
GraphQLClient(
link: httpLink,
cache: GraphQLCache(store: HiveStore()),
),
);
// 리포지토리 등록
getIt.registerSingleton<ProductRepository>(
GraphQLProductRepository(getIt<GraphQLClient>()),
);
// 비즈니스 로직 서비스 등록
getIt.registerSingleton<ProductService>(
ProductService(getIt<ProductRepository>()),
);
}
// 서비스 클래스
class ProductService {
final ProductRepository repository;
ProductService(this.repository);
Future<List<Product>> getProductsByCategory(String categoryId) async {
final products = await repository.getProducts();
return products.where((p) => p.categoryId == categoryId).toList();
}
// 비즈니스 로직 메서드
}
결론
Flutter에서 GraphQL을 사용하면 네트워크 통신의 효율성을 높이고, 타입 안전성을 확보할 수 있어 대규모 애플리케이션 개발에 많은 이점을 제공합니다. graphql_flutter
패키지는 쿼리, 뮤테이션, 구독을 위한 직관적인 위젯을 제공하며, Artemis와 같은 코드 생성 도구를 함께 사용하면 타입 안전성을 더욱 강화할 수 있습니다.
REST API보다 초기 설정이 복잡할 수 있지만, 복잡한 데이터 요구사항이 있는 앱에서는 장기적으로 개발 및 유지보수 비용을 절감할 수 있습니다. 특히 데이터 요구사항이 자주 변경되거나 클라이언트가 데이터를 유연하게 요청해야 하는 경우 GraphQL은 탁월한 선택이 될 수 있습니다.