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 장점

  1. Over-fetching 해결: 필요한 데이터만 정확히 요청할 수 있어 데이터 전송량 감소
  2. Under-fetching 해결: 여러 API 엔드포인트를 호출할 필요 없이 하나의 요청으로 필요한 모든 데이터 검색
  3. 타입 안전성: 스키마 기반 시스템으로 런타임 오류 감소
  4. 유연한 API 진화: 새 필드를 추가해도 기존 클라이언트 코드에 영향 없음
  5. 실시간 데이터 처리: 구독을 통한 실시간 업데이트 지원

9.2 단점

  1. 복잡성: 설정과 학습이 REST API보다 더 복잡할 수 있음
  2. 캐싱 복잡도: REST보다 캐싱 전략이 더 복잡함
  3. 서버 부하: 복잡한 쿼리의 경우 서버에 추가 부하를 줄 수 있음
  4. 파일 업로드: 표준 명세에 파일 업로드가 없어 별도 구현 필요

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은 탁월한 선택이 될 수 있습니다.

results matching ""

    No results matching ""