Flutter 앱 - Lambda - RDS MySQL 데이터 교환 구현 & 패턴 정리

2025. 5. 7. 06:24Project Log/학부 졸업프로젝트

 

     

     

    💛 송수신 데이터 목록 정리

    Flutter 앱에서 Lambda 함수로 전달할 데이터, DB 조회 후 람다 함수가 Flutter 앱으로 전달할 데이터 목록을 엑셀로 정리하였다. 아래는 초안 파일이고, 개발하면서 변동이 조금씩 있을 것이다.

     

    플러터 앱 화면, 람다 함수명, 교환할 데이터 종류, 람다 함수에 연결된 API Gateway URL 등 목록을 빠짐없이 잘 정리하기 위해, 문서화하기로 했다. 본인용 앱과 보호자용 앱 각각에 20개 이상(총 40개 이상)의 람다 함수를 만들어야 한다. 데이터가 많이 중복되는 케이스이면 람다 함수를 몇 개씩 통합할 계획도 있다.

     

    구현하기 전에 DB를 생성하는 SQL 문을 싹 점검하고 수정했다. 그리고 모든 테이블에 mock data를 INSERT 하는 쿼리도 실행했다. 개발하면서 필요한 부분은 틈틈이 수정하고 커밋 중이다.

     

    💛 건강 리포트: Flutter - Lambda - DB 연결

    여러 개의 람다 함수를 만들기 전에, 코드 패턴을 한 번 정리하는 게 좋겠다고 생각했다.

     

    화면마다 람다 함수에 전달해야 하는 데이터, 람다 함수로부터 전달받는 DB 데이터에 차이가 있다.

    Flutter - Lambda - DB 연동 시나리오는 다음과 같다.

     

    --------

    1. 건강 리포트 화면의 경우, flutter secure storage에 저장된 현재 사용자의 Fitbit 고유 아이디인 'encodedId'와 캘린더에서 선택한 날짜인 'report_date'를 람다 함수에 전달한다. 

    2. Lambda 함수는 'mysql2/promise' 라이브러리를 사용하여 RDS MySQL과 상호작용을 한다. 전달받은 encodedId와 일치하는 사용자의 id를 users 테이블에서 찾고, daily_health_reports 테이블의 user_id가 방금 찾은 id와 일치하면서, report_date를 갖는 리포트 데이터를 검색한다.

    3. 람다 함수는 Flutter 앱에 JSON 형태로 검색된 데이터를 전송한다.

    4. Flutter 앱 화면에 3에서 받은 데이터를 반영한다.

    --------

     

    DB값이 업데이트되었을 때, 이를 Flutter 앱에 반영하는 방법은 두 가지이다.

     

    1. 특정 화면에 진입한다.

    2. 사용자가 앱 화면을 드래그해서 새로고침한다.

     

    Flutter 앱의 건강 리포트 화면 코드이다. 리포트 메인 화면과 상세 화면들이 여러 개 있는데, 연동 방법을 정리하는 게 목적이므로 생략했다. 앞으로 패턴을 변형해서 여러 개의 람다 함수를 만들어야 하므로, 주요 코드 구성을 정리해 보겠다.

     

    Future 객체인 reportData를 생성한다. 화면에 진입하자마자 fetchReportData 함수를 호출하여 데이터를 불러오도록 한다.

     

    FitbitAuthService의 getUserId 함수를 통해 Flutter Secure Storage에 저장해놓은 Fitbit 고유 ID를 불러온다. 사용자가 선택한 리포트 날짜 쿼리 파라미터를 가져온다. parseReportDate 함수를 통해 날짜 형식을 변환한다. 

     

    람다 함수가 연결된 API Gateway URL로 POST 요청을 보낸다. POST 요청을 할 때는 encodedId와 report_date를 json 형태로 보낸다. 람다 함수로부터 response (화면에서 보여줄 데이터 json)를 받는다.

     

    쿼리 파라미터의 날짜는 '2025 / 05 / 07' 형식이다. parseReportDate는 이 날짜를  '2025-05-07' 형식으로 바꿔주는 함수이다.

     

    refreshData 함수는 fetchReportData 함수를 호출하는 함수로, 화면을 새로고침 할 때 활용된다.

     

    RefreshIndicator를 Column의 상위 위젯으로 배치한다. RefreshIndicator에 onRefresh 이벤트가 발생하면 refreshData 함수를 호출하여 데이터를 업데이트하도록 했다. RefreshIndicator 색상은 앱의 이미지에 어울리게 초록색으로 설정했다. 

     

    중요한 부분이 있다. FutureBuilder 위젯을 배치하고 future 파라미터의 값을 reportData로 설정했다. FutureBuilder를 사용하면 비동기 연산의 결과를 기다렸다가, 결과에 따라 UI를 동적으로 구성할 수 있다. snapshot의 ConnectionState에 따라 화면 중앙에 CircularProgressIndicator 또는 에러 메시지가 나오도록 했다. snapshot의 data로부터 화면에 필요한 overallHealthScore와 stressScore 값을 추출한다. 값이 없는 경우 기본값은 0으로 설정했다.

     

    람다 함수 코드이다.

     

    mysql2/promise 라이브러리를 layer에 추가해서 사용한다. Flutter 앱에서 보낸 event.body (JSON 문자열)에서 encodedId와 report_date를 추출한다. getUserId 함수를 호출해서 userId를 얻고, getHealthReport 함수를 호출해서 리포트 데이터를 얻는다. 리포트 데이터를 리턴한다.

     

    parseRequestBody는 encodedId와 report_date 중 하나라도 없으면 예외를 발생시킨다.

     

    createDbConnection은 RDS MySQL과의 커넥션을 생성한다. DB 관련 변수들은 람다 함수의 설정에 따로 저장해놓았다.

     

    getUserId 함수는 encodedId를 사용해 users 테이블에서 사용자의 id를 조회한다.

     

    getHealthReport 함수는 user_id와 report_date를 사용해 daily_health_reports 테이블로부터 원하는 건강 리포트 데이터를 조회한다. 이때 SELECT * 을 해서 전체 컬럼을 조회하도록 했다. 이는 리포트 카테고리 화면 뿐만 아니라, 리포트 상세 화면에도 동일한 람다 함수를 사용하기 위해서이다.

     

    buildResponse 함수는 JSON 형식으로 클라이언트에게 응답할 결과를 만드는 것이다.

     

    import 'dart:convert';
    import 'package:flutter/material.dart';
    import 'package:http/http.dart' as http;
    import 'package:go_router/go_router.dart';
    import 'package:intl/intl.dart';
    import 'package:day_in_bloom_v1/features/authentication/service/fitbit_auth_service.dart';
    import 'package:day_in_bloom_v1/features/healthreport/screen/pdf_download_modal.dart';
    import 'package:day_in_bloom_v1/widgets/app_bar.dart';
    
    class ReportCategoryScreen extends StatefulWidget {
      const ReportCategoryScreen({super.key});
    
      @override
      State<ReportCategoryScreen> createState() => _ReportCategoryScreenState();
    }
    
    class _ReportCategoryScreenState extends State<ReportCategoryScreen> {
      late Future<Map<String, dynamic>> _reportData;
    
      @override
      void initState() {
        super.initState();
        _reportData = fetchReportData();
      }
    
      Future<Map<String, dynamic>> fetchReportData() async {
        final encodedId = await FitbitAuthService.getUserId();
        final reportDateRaw = GoRouterState.of(context).uri.queryParameters['date'];
    
        if (encodedId == null || reportDateRaw == null) {
          throw Exception('사용자 정보 또는 날짜가 없습니다.');
        }
    
        final formattedDate = _parseReportDate(reportDateRaw);
    
        final response = await http.post(
          Uri.parse('API_GATEWAY_URL'),
          headers: {'Content-Type': 'application/json'},
          body: jsonEncode({
            'encodedId': encodedId,
            'report_date': formattedDate,
          }),
        );
    
        if (response.statusCode != 200) {
          throw Exception('데이터 로드 실패: ${response.body}');
        }
    
        return json.decode(response.body);
      }
    
      String _parseReportDate(String rawDate) {
        try {
          final sanitized = rawDate.replaceAll('/', '-').replaceAll(' ', '');
          final parsedDate = DateTime.parse(sanitized);
          return DateFormat('yyyy-MM-dd').format(parsedDate);
        } catch (_) {
          throw Exception('날짜 파싱 실패: $rawDate');
        }
      }
    
      Future<void> _refreshData() async {
        setState(() {
          _reportData = fetchReportData();
        });
      }
    
      @override
      Widget build(BuildContext context) {
        final selectedDate = GoRouterState.of(context).uri.queryParameters['date'] ?? '날짜가 선택되지 않았습니다.';
    
        return Scaffold(
          appBar: const CustomAppBar(title: '건강 리포트', showBackButton: true),
          body: Padding(
            padding: const EdgeInsets.all(35.0),
            child: RefreshIndicator(
              onRefresh: _refreshData,
              color: Colors.green,
              backgroundColor: Colors.white,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.center,
                children: [
                  Text(
                    selectedDate,
                    style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 20),
                  Expanded(
                    child: FutureBuilder<Map<String, dynamic>>(
                      future: _reportData,
                      builder: (context, snapshot) {
                        if (snapshot.connectionState == ConnectionState.waiting) {
                          return const Center(child: CircularProgressIndicator(color: Colors.green));
                        } else if (snapshot.hasError) {
                          return Center(child: Text('Error: ${snapshot.error}'));
                        } else if (!snapshot.hasData) {
                          return const Center(child: Text('No data found.'));
                        }
    
                        final data = snapshot.data!;
                        final overallHealthScore = data['overall_health_score'] ?? 0;
                        final stressScore = data['stress_score'] ?? 0;
    
                        return GridView.builder(
                          gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                            crossAxisCount: 2,
                            crossAxisSpacing: 16,
                            mainAxisSpacing: 16,
                            childAspectRatio: 1,
                          ),
                          itemCount: _categories.length,
                          itemBuilder: (context, index) {
                            final category = _categories[index];
                            if (index == 0 || index == 1) {
                              return ScoreReportCategoryTile(
                                category: category.copyWith(
                                  score: index == 0 ? overallHealthScore : stressScore,
                                ),
                                color: index == 0 ? Colors.yellow.shade100 : Colors.grey.shade200,
                              );
                            }
                            return ReportCategoryTile(category: category);
                          },
                        );
                      },
                    ),
                  ),
                  const SizedBox(height: 16),
                  const DownloadReportButton(),
                  const SizedBox(height: 16),
                ],
              ),
            ),
          ),
        );
      }
    }
    
    class ReportCategoryTile extends StatelessWidget {
      final ReportCategory category;
    
      const ReportCategoryTile({super.key, required this.category});
    
      @override
      Widget build(BuildContext context) {
        final selectedDate = GoRouterState.of(context).uri.queryParameters['date'] ?? '';
    
        return GestureDetector(
          onTap: () => context.go('${category.route}?date=$selectedDate'),
          child: Container(
            padding: const EdgeInsets.all(12),
            decoration: BoxDecoration(
              color: Colors.grey.shade200,
              borderRadius: BorderRadius.circular(16),
            ),
            child: Stack(
              children: [
                Align(
                  alignment: Alignment.topLeft,
                  child: Text(
                    category.title,
                    style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold),
                  ),
                ),
                Align(
                  alignment: Alignment.bottomRight,
                  child: Image.asset(category.imagePath, width: 60, height: 60),
                ),
              ],
            ),
          ),
        );
      }
    }
    
    class ScoreReportCategoryTile extends StatelessWidget {
      final ReportCategory category;
      final Color color;
    
      const ScoreReportCategoryTile({
        super.key,
        required this.category,
        required this.color,
      });
    
      @override
      Widget build(BuildContext context) {
        final selectedDate = GoRouterState.of(context).uri.queryParameters['date'] ?? '';
    
        return GestureDetector(
          onTap: () => context.go('${category.route}?date=$selectedDate'),
          child: Container(
            padding: const EdgeInsets.all(12),
            decoration: BoxDecoration(
              color: color,
              borderRadius: BorderRadius.circular(16),
            ),
            child: Stack(
              children: [
                Align(
                  alignment: Alignment.topLeft,
                  child: Text(
                    category.title,
                    style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold),
                  ),
                ),
                Align(
                  alignment: Alignment.bottomRight,
                  child: Text(
                    category.score.toString(),
                    style: Theme.of(context).textTheme.displayMedium?.copyWith(
                          fontWeight: FontWeight.bold,
                          color: category.color ?? Colors.black,
                        ),
                  ),
                ),
              ],
            ),
          ),
        );
      }
    }
    
    class DownloadReportButton extends StatelessWidget {
      const DownloadReportButton({super.key});
    
      @override
      Widget build(BuildContext context) {
        return GestureDetector(
          onTap: () => PdfDownloadModal.show(context),
          child: Container(
            padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
            decoration: BoxDecoration(
              color: Colors.green.shade100,
              borderRadius: BorderRadius.circular(16),
            ),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(
                  '리포트 PDF 다운로드 (본인용)',
                  style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold, fontSize: 12),
                ),
                const SizedBox(width: 14),
                Image.asset('assets/report_icon/green_pdf.png', width: 40, height: 40),
              ],
            ),
          ),
        );
      }
    }
    
    class ReportCategory {
      final String title;
      final String imagePath;
      final int score;
      final Color? color;
      final String route;
    
      const ReportCategory({
        required this.title,
        required this.imagePath,
        this.score = 0,
        this.color,
        required this.route,
      });
    
      ReportCategory copyWith({int? score}) {
        return ReportCategory(
          title: title,
          imagePath: imagePath,
          score: score ?? this.score,
          color: color,
          route: route,
        );
      }
    }
    
    const List<ReportCategory> _categories = [
      ReportCategory(title: '전체 종합 점수', imagePath: '', score: 0, route: '/homeCalendar/report/totalScore'),
      ReportCategory(title: '스트레스 점수', imagePath: '', score: 0, color: Colors.red, route: '/homeCalendar/report/stressScore'),
      ReportCategory(title: '운동', imagePath: 'assets/report_icon/dumbell.png', route: '/homeCalendar/report/exercise'),
      ReportCategory(title: '수면', imagePath: 'assets/report_icon/pillow.png', route: '/homeCalendar/report/sleep'),
      ReportCategory(title: '보호자님\n조언', imagePath: 'assets/report_icon/family_talk.png', route: '/homeCalendar/report/familyAdvice'),
      ReportCategory(title: '의사 선생님\n조언', imagePath: 'assets/report_icon/doctor_talk.png', route: '/homeCalendar/report/doctorAdvice'),
    ];
    import mysql from 'mysql2/promise';
    
    export const handler = async (event) => {
      let connection;
    
      try {
        const { encodedId, report_date } = parseRequestBody(event.body);
    
        connection = await createDbConnection();
    
        const userId = await getUserId(connection, encodedId);
        const report = await getHealthReport(connection, userId, report_date);
    
        return buildResponse(200, report || {});
      } catch (error) {
        console.error('Lambda error:', error);
        return buildResponse(500, { error: 'Internal Server Error', detail: error.message });
      } finally {
        if (connection) await connection.end();
      }
    };
    
    function parseRequestBody(body) {
      if (!body) throw new Error('Request body is missing');
      const parsed = JSON.parse(body);
    
      if (!parsed.encodedId || !parsed.report_date) {
        throw new Error('Missing required fields: encodedId or report_date');
      }
    
      return parsed;
    }
    
    async function createDbConnection() {
      return await mysql.createConnection({
        host: process.env.DB_HOST,
        user: process.env.DB_USER,
        password: process.env.DB_PASSWORD,
        database: process.env.DB_NAME,
        charset: 'utf8mb4',
      });
    }
    
    async function getUserId(connection, encodedId) {
      const [rows] = await connection.execute(
        'SELECT id FROM users WHERE encodedId = ?',
        [encodedId]
      );
    
      if (rows.length === 0) {
        throw new Error('User not found');
      }
    
      return rows[0].id;
    }
    
    async function getHealthReport(connection, userId, reportDate) {
      const [reports] = await connection.execute(
        'SELECT * FROM daily_health_reports WHERE user_id = ? AND report_date = ?',
        [userId, reportDate]
      );
    
      return reports[0];
    }
    
    function buildResponse(statusCode, body) {
      return {
        statusCode,
        headers: {
          'Content-Type': 'application/json; charset=utf-8',
        },
        body: JSON.stringify(body),
      };
    }

     

    💛 운동 / 수면 추천: Flutter - Lambda (with GPT) - DB 연결

    Flutter - Lambda (GPT API 호출) - DB 연동 시나리오는 다음과 같다.

     

    --------

    1. 운동 / 수면 추천 화면의 경우, flutter secure storage에 저장된 현재 사용자의 Fitbit 고유 아이디인 'encodedId'와 오늘 날짜인 'date'를 람다 함수에 전달한다.

     

    2. Lambda 함수는 'mysql2/promise' 라이브러리를 통해 RDS MySQL과 상호작용한다. 전달받은 encodedId와 일치하는 사용자의 id를 users 테이블에서 찾는다. user_id가 방금 찾은 id와 일치하면서, 추가 조건을 만족하는 fitbit 데이터를 DB에서 검색한다. DB에 저장된 날짜 형식들은 '2025-05-07 00:00:00' 형식임에 주의한다.

       * 추가 조건

       2-1. 한 달간 fitbit 운동 데이터:

             fitbit_average_history 테이블에서 recorded_at 컬럼이 Flutter에서 받은 date이면서

             period_type 컬럼의 값이 '30D'인 행의 모든 컬럼.

       2-2. 어제 fitbit 운동 데이터:

             fitbit_activity_summary 테이블에서 date 컬럼의 값이 Flutter에서 받은 date 기준으로 어제 날짜인 행의 모든 컬럼.

     

    3. 세 종류의 프롬프트에 2에서 추출한 데이터를 함께 넣어, GPT API를 호출한다.

       * 세 종류의 프롬프트

       3-1. 한 달간 운동 분석: 2-1 데이터를 넣고 한 달간 운동 분석을 요청.

       3-2. 어제의 운동 분석: 2-2 데이터를 넣고 어제 운동 분석을 요청.

       3-3. 추천하는 운동/주의사항: 2-1과 2-2 데이터를 모두 넣고 추천하는 운동 및 주의사항 분석을 요청.

     

    4. 람다 함수는 Flutter 앱에 JSON 형태로 GPT의 3가지 분석 항목에 대한 응답을 전송한다.

     

    5. Flutter 앱 화면에 4에서 받은 데이터를 반영한다.

    --------

     

    Flutter 앱의 운동 추천 화면 코드이다. 운동 추천 화면과 수면 추천 화면이 있는데, 연동 방법을 정리하는 게 목적이므로 하나는 생략했다. 리포트 화면 코드와 비슷한 흐름으로 구성된다.

     

    Future 객체인 exerciseData를 선언한다. 데이터 불러오기에 실패할 때를 대비해 default 메시지를 설정한다.

     

    현재 날짜를 2025-05-07 형식으로 반환하는 부분이다.

     

    화면이 로드될 때 fetchExerciseData 함수를 호출하여 데이터를 불러온다.

     

    fetchExerciseData는 데이터를 불러오는 함수이다. FitbitAuthService의 getUserId 함수를 통해 로그인한 사용자의 Fitbit 고유 ID를 얻는다. 람다 함수가 연결된 API Gateway URL에 POST 요청을 보낸다. 이때, encodedId와 date를 JSON 형식으로 묶어서 전송하며, response로부터 3가지 데이터를 파싱한다.

     

    refreshExerciseData 함수는 fetchExerciseData 함수를 호출한다. 플러터 앱에서 화면 새로고침을 할 때, 활용된다.

     

    RefreshIndicator에 onRefresh 이벤트가 발생하면 refreshExerciseData 함수를 호출한다. FutureBuilder 위젯을 사용하여 비동기 연산의 결과를 기다렸다가, 결과에 따라 UI를 동적으로 구성한다. ConnectionState에 따라 CircularProgressIndicator를 보여준다. snapshot data를 UI에 보여준다.

     

    람다 함수 코드이다.

     

    mysql과 openai 라이브러리를 layer에 추가하고 사용한다. OPENAI_API_KEY는 람다 함수 환경 변수 설정에 추가했다.

     

    event.body에서 encodedId와 date를 추출한다. handler는 DB 연결, 사용자 조회, 데이터 조회, GPT API 호출, 응답 반환 관련 함수들을 순차적으로 호출하는 역할을 한다.

     

    connectToDatabase 함수는 DB에 연결하는 함수이다. DB 관련 설정은 람다 환경변수에 등록했다. getUserId 함수는 encodedId를 사용하여 users 테이블에서 id를 조회한다.

    '

    getMonthData 함수와 getYesterdayData 함수는 DB로부터 한 달간의 운동 데이터와 어제 운동 데이터를 추출하는 역할을 한다.

     

    getGptResults 함수는 monthData와 yesterdayData를 포함하여 프롬프트를 구성한다. 그리고 GPT API를 호출한다.

    (아래 프롬프트는 실제 서비스에 사용할 프롬프트가 아닌 테스트용이다. 프롬프트 고도화를 위해, 따로 GPT와 여러 가지 대화를 시도 중이다.)

     

    import 'dart:convert';
    import 'package:flutter/material.dart';
    import 'package:http/http.dart' as http;
    import 'package:day_in_bloom_v1/features/authentication/service/fitbit_auth_service.dart';
    import 'package:day_in_bloom_v1/widgets/app_bar.dart';
    
    class ExerciseRecommendationScreen extends StatefulWidget {
      const ExerciseRecommendationScreen({super.key});
    
      @override
      State<ExerciseRecommendationScreen> createState() =>
          _ExerciseRecommendationScreenState();
    }
    
    class _ExerciseRecommendationScreenState
        extends State<ExerciseRecommendationScreen> {
      late Future<Map<String, String>> _exerciseData;
      final String _selectedDate = _formattedToday;
    
      static const String _defaultMessage = "데이터를 로딩할 수 없습니다. 네트워크 연결을 확인하세요.";
      static const String _apiUrl =
          "{API_GATEWAY_URL 입력하시오.}";
    
      static const Map<String, String> _defaultExerciseData = {
        "monthly": _defaultMessage,
        "yesterday": _defaultMessage,
        "recommendation": _defaultMessage,
      };
    
      static String get _formattedToday {
        final now = DateTime.now();
        return "${now.year.toString().padLeft(4, '0')}-"
            "${now.month.toString().padLeft(2, '0')}-"
            "${now.day.toString().padLeft(2, '0')}";
      }
    
      @override
      void initState() {
        super.initState();
        _exerciseData = _fetchExerciseData();
      }
    
      Future<Map<String, String>> _fetchExerciseData() async {
        try {
          final encodedId = await FitbitAuthService.getUserId();
          if (encodedId == null) throw Exception("User ID is null");
    
          final response = await http.post(
            Uri.parse(_apiUrl),
            headers: {"Content-Type": "application/json"},
            body: json.encode({
              "encodedId": encodedId,
              "date": _selectedDate,
            }),
          );
    
          if (response.statusCode != 200) {
            throw Exception("Failed to fetch exercise data.");
          }
    
          final jsonData = json.decode(response.body);
    
          return {
            "monthly": jsonData['exercise_month_analysis'] ?? _defaultMessage,
            "yesterday": jsonData['exercise_yesterday_analysis'] ?? _defaultMessage,
            "recommendation": jsonData['exercise_recommendation'] ?? _defaultMessage,
          };
        } catch (_) {
          return _defaultExerciseData;
        }
      }
    
      Future<void> _refreshExerciseData() async {
        setState(() {
          _exerciseData = _fetchExerciseData();
        });
      }
    
      Widget _buildExerciseBox(String title, String description) {
        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              title,
              style: const TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.bold,
                color: Colors.black87,
              ),
            ),
            const SizedBox(height: 8),
            Container(
              width: double.infinity,
              padding: const EdgeInsets.all(16),
              decoration: BoxDecoration(
                color: Colors.white,
                borderRadius: BorderRadius.circular(12),
                border: Border.all(color: Colors.green, width: 1.5),
              ),
              child: Text(
                description,
                style: const TextStyle(fontSize: 16, color: Colors.black54),
              ),
            ),
          ],
        );
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: const CustomAppBar(title: "맞춤 운동 추천"),
          body: RefreshIndicator(
            onRefresh: _refreshExerciseData,
            color: Colors.green,
            child: FutureBuilder<Map<String, String>>(
              future: _exerciseData,
              builder: (context, snapshot) {
                if (snapshot.connectionState == ConnectionState.waiting) {
                  return const Center(child: CircularProgressIndicator(color: Colors.green));
                }
    
                final data = snapshot.data ?? _defaultExerciseData;
    
                return SingleChildScrollView(
                  physics: const AlwaysScrollableScrollPhysics(),
                  padding: const EdgeInsets.all(28.0),
                  child: Column(
                    children: [
                      _buildExerciseBox("✅ 한 달 간 운동 분석", data["monthly"]!),
                      const SizedBox(height: 20),
                      _buildExerciseBox("✅ 어제의 운동 분석", data["yesterday"]!),
                      const SizedBox(height: 20),
                      _buildExerciseBox("✅ 추천하는 운동 / 주의사항", data["recommendation"]!),
                    ],
                  ),
                );
              },
            ),
          ),
        );
      }
    }
    import mysql from 'mysql2/promise';
    import OpenAI from 'openai';
    
    const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
    
    const FALLBACK_MESSAGE = "네트워크 연결을 확인하세요.";
    const DEFAULT_RESULTS = {
      exercise_month_analysis: FALLBACK_MESSAGE,
      exercise_yesterday_analysis: FALLBACK_MESSAGE,
      exercise_recommendation: FALLBACK_MESSAGE,
    };
    
    export const handler = async (event) => {
      let connection;
    
      try {
        const { encodedId, date } = JSON.parse(event.body);
        connection = await connectToDatabase();
    
        const userId = await getUserId(connection, encodedId);
        if (!userId) {
          return buildResponse(404, { ...DEFAULT_RESULTS, message: 'User not found' });
        }
    
        const monthData = await getMonthData(connection, userId, date);
        const yesterdayData = await getYesterdayData(connection, userId, getYesterdayDateString(date));
    
        const results = await getGptResults(monthData, yesterdayData);
    
        return buildResponse(200, results);
      } catch (error) {
        return buildResponse(500, {
          ...DEFAULT_RESULTS,
          message: 'Internal server error',
          error: error.message,
        });
      } finally {
        if (connection) await connection.end();
      }
    };
    
    async function connectToDatabase() {
      return mysql.createConnection({
        host: process.env.DB_HOST,
        user: process.env.DB_USER,
        password: process.env.DB_PASSWORD,
        database: process.env.DB_NAME,
        charset: 'utf8mb4',
      });
    }
    
    async function getUserId(conn, encodedId) {
      const [users] = await conn.execute(
        'SELECT id FROM users WHERE encodedId = ?',
        [encodedId]
      );
      return users[0]?.id || null;
    }
    
    async function getMonthData(conn, userId, date) {
      const [rows] = await conn.execute(
        `SELECT * FROM fitbit_average_history 
         WHERE user_id = ? AND recorded_at = ? AND period_type = '30D'`,
        [userId, date]
      );
      return rows[0] || {};
    }
    
    async function getYesterdayData(conn, userId, date) {
      const [rows] = await conn.execute(
        `SELECT * FROM fitbit_activity_summary 
         WHERE user_id = ? AND date = ?`,
        [userId, date]
      );
      return rows[0] || {};
    }
    
    function getYesterdayDateString(dateStr) {
      const date = new Date(dateStr);
      date.setDate(date.getDate() - 1);
      return date.toISOString().split('T')[0];
    }
    
    async function getGptResults(monthData, yesterdayData) {
      const prompts = [
        {
          key: 'exercise_month_analysis',
          prompt: `
    당신은 노인 헬스케어 전문가입니다.
    [한 달 평균 운동 데이터] ${JSON.stringify(monthData)}
    이 데이터를 기반으로 사용자의 한 달간 운동 습관과 건강 상태를 분석해주세요 (200자 이내).
          `.trim()
        },
        {
          key: 'exercise_yesterday_analysis',
          prompt: `
    당신은 노인 헬스케어 전문가입니다.
    [어제 운동 데이터] ${JSON.stringify(yesterdayData)}
    이 데이터를 기반으로 어제 하루의 운동 분석해주세요 (200자 이내).
          `.trim()
        },
        {
          key: 'exercise_recommendation',
          prompt: `
    당신은 노인 헬스케어 전문가입니다.
    [한 달 평균 운동 데이터] ${JSON.stringify(monthData)}
    [어제 운동 데이터] ${JSON.stringify(yesterdayData)}
    이 정보를 기반으로 다음을 알려주세요:
    1. 추천 운동 2~3가지 (이유 포함)
    2. 운동 시 주의사항
    3. 전반적인 피드백
    모든 응답은 200자 이내 요약문으로 제공해주세요.
          `.trim()
        },
      ];
    
      const results = { ...DEFAULT_RESULTS };
    
      const responses = await Promise.all(prompts.map(({ key, prompt }) =>
        openai.chat.completions.create({
          model: 'gpt-4',
          messages: [{ role: 'user', content: prompt }],
        }).then(res => ({ key, text: res.choices[0].message.content.trim() }))
          .catch(() => ({ key, text: 'AI 응답을 가져오지 못했습니다.' }))
      ));
    
      responses.forEach(({ key, text }) => {
        results[key] = text;
      });
    
      return results;
    }
    
    function buildResponse(statusCode, body) {
      return {
        statusCode,
        headers: { 'Content-Type': 'application/json; charset=utf-8' },
        body: JSON.stringify(body),
      };
    }

     

    💛 To do: 노인 본인용 앱 람다 함수 구현

    위에 정리한 것은 DB와 바로 연결되는 간단한 케이스, DB의 특정 유저 데이터를 프롬프트에 넣어 GPT 분석 결과를 앱에 제공하는 조금 더 복잡한 케이스이다. 위와 비슷한 방식으로 노인 본인용 앱에 필요한 람다 함수를 추가하고 있다.

     

    에러도 많이 보고 시간도 많이 들어가지만, 앱이 잘 완성될 모습을 상상하며! 화이팅!