From 4ce57eaf27fde67c77b167640e3759313266df06 Mon Sep 17 00:00:00 2001 From: aiden30015 Date: Sat, 19 Sep 2026 17:12:58 +0900 Subject: [PATCH 1/4] =?UTF-8?q?:green=5Fheart:=20::=20perf=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=EC=B8=A1=EC=A0=95=20CI=EA=B0=80=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=EB=A1=9C=20=EB=8F=99=EC=9E=91=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B9=8C=EB=93=9C=20=ED=99=98=EA=B2=BD=20=EB=B0=8F?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gradle 8.14.3, AGP 8.11.1, Kotlin 2.2.20으로 상향 (Flutter 최소 요구사항) - main()의 초기화를 bootstrap(overrides)로 분리 - 온보딩 로그인 버튼, 멤버 리스트에 테스트용 Key 추가 - perf_test 재작성 및 fake repository 추가 Co-Authored-By: Claude Sonnet 5 --- .../gradle/wrapper/gradle-wrapper.properties | 2 +- android/settings.gradle.kts | 4 +- integration_test/fakes/fake_repositories.dart | 131 ++++++++++++++++++ integration_test/perf_test.dart | 116 ++++++++++++++-- .../widgets/member_list_section.dart | 1 + .../screens/onboarding_screen.dart | 1 + lib/main.dart | 10 +- pubspec.lock | 2 +- pubspec.yaml | 1 + 9 files changed, 250 insertions(+), 18 deletions(-) create mode 100644 integration_test/fakes/fake_repositories.dart diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index ac3b4792..74b269f3 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ff284ff6..174f4082 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -19,11 +19,11 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.9.1" apply false + id("com.android.application") version "8.11.1" apply false // START: FlutterFire Configuration id("com.google.gms.google-services") version("4.3.15") apply false // END: FlutterFire Configuration - id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false } include(":app") diff --git a/integration_test/fakes/fake_repositories.dart b/integration_test/fakes/fake_repositories.dart new file mode 100644 index 00000000..7b8f88e4 --- /dev/null +++ b/integration_test/fakes/fake_repositories.dart @@ -0,0 +1,131 @@ +import 'package:goms/features/member/data/request/student_council_filter_request.dart'; +import 'package:goms/features/member/domain/entities/current_member_entity.dart'; +import 'package:goms/features/member/domain/entities/member_entity.dart'; +import 'package:goms/features/member/domain/entities/student_council_student_entity.dart'; +import 'package:goms/features/member/domain/repositories/member_repository.dart'; +import 'package:goms/core/enums/role_enum.dart'; +import 'package:goms/features/outing/domain/entities/my_outing_status_entity.dart'; +import 'package:goms/features/outing/domain/entities/outing_qr_result_entity.dart'; +import 'package:goms/features/outing/domain/entities/outing_student_entity.dart'; +import 'package:goms/features/outing/domain/repositories/outing_repository.dart'; + +/// CI 테스트 계정은 실제 외출 데이터가 비어있어서 outing_state_scroll perf +/// 시나리오가 스크롤할 대상이 없다. 측정용으로만 스크롤할 데이터를 채워주는 +/// fake — 이 시나리오에서 쓰는 메서드만 구현하고 나머지는 UnimplementedError. +class FakeOutingRepository implements OutingRepository { + static final List _students = List.generate( + 30, + (i) => OutingStudentEntity( + memberId: i, + name: '외출학생$i', + grade: 1 + (i % 3), + department: i % 2 == 0 ? 'SW' : 'AI', + outingAt: DateTime(2026, 9, 18, 8, i % 60), + ), + ); + + @override + Future> getCurrentOutingStudents() async => + _students; + + @override + Future> searchOutingStudents({ + required String name, + }) async => + _students.where((s) => s.name.contains(name)).toList(); + + @override + Future getMyOutingStatus() => + throw UnimplementedError('perf 테스트에서 안 씀'); + + @override + Future processOutingByQr({ + required String uuid, + required int exp, + }) => + throw UnimplementedError('perf 테스트에서 안 씀'); + + @override + Future processComingByQr({ + required String uuid, + required int exp, + }) => + throw UnimplementedError('perf 테스트에서 안 씀'); + + @override + Future forceOutStudent({required int memberId}) => + throw UnimplementedError('perf 테스트에서 안 씀'); + + @override + Future forceInStudent({ + required int memberId, + }) => + throw UnimplementedError('perf 테스트에서 안 씀'); +} + +/// member_list_scroll perf 시나리오용 — CI 테스트 계정이 보는 멤버 목록이 +/// 비어있어서 스크롤할 데이터를 채워준다. +class FakeMemberRepository implements MemberRepository { + static final List _members = List.generate( + 30, + (i) => MemberEntity( + id: i, + name: '멤버$i', + studentNumber: '${10 + i % 3}기 s${24000 + i}', + role: 'ROLE_STUDENT', + profileImageUrl: '', + ), + ); + + @override + Future> getMembers() async => _members; + + @override + Future getMyProfile() async => const CurrentMemberEntity( + memberId: 24068, + email: 's24068@gsm.hs.kr', + name: '테스트계정', + role: RoleEnum.user, + ); + + @override + Future getMyRole() async => RoleEnum.user; + + @override + Future> getStudentCouncilMembers({ + String? query, + }) => + throw UnimplementedError('perf 테스트에서 안 씀'); + + @override + Future> getFilteredStudentCouncilMembers({ + required StudentCouncilFilterRequest filter, + }) => + throw UnimplementedError('perf 테스트에서 안 씀'); + + @override + Future updateStudentCouncilRole({ + required int memberId, + required bool isCouncil, + }) => + throw UnimplementedError('perf 테스트에서 안 씀'); + + @override + Future updateStudentCouncilOutingAllowed({ + required int memberId, + required bool isAllowed, + }) => + throw UnimplementedError('perf 테스트에서 안 씀'); + + @override + Future updateProfileImage({required String imagePath}) => + throw UnimplementedError('perf 테스트에서 안 씀'); + + @override + Future deleteProfileImage() => + throw UnimplementedError('perf 테스트에서 안 씀'); + + @override + Future withdrawMember({required String password}) => + throw UnimplementedError('perf 테스트에서 안 씀'); +} diff --git a/integration_test/perf_test.dart b/integration_test/perf_test.dart index f2da27da..9bc80be2 100644 --- a/integration_test/perf_test.dart +++ b/integration_test/perf_test.dart @@ -4,12 +4,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:riverpod/misc.dart' show Override; import 'package:goms/app/router/app_router.dart' as app_router; +import 'package:goms/features/member/data/providers/member_providers.dart'; import 'package:goms/features/member/presentation/routes/member_route_path.dart'; +import 'package:goms/features/outing/data/providers/outing_data_providers.dart'; import 'package:goms/features/outing/presentation/routes/outing_route_path.dart'; import 'package:goms/main.dart' as app; +import 'fakes/fake_repositories.dart'; + late IntegrationTestWidgetsFlutterBinding binding; /// 특정 시나리오만 돌리고 싶을 때: --dart-define=PERF_SCENARIOS=home_scroll,login @@ -25,29 +30,41 @@ void main() { // 따로 잰다 (integration_test 안에서는 이미 떠 있는 프로세스의 위젯 빌드 // 시간(수 ms)일 뿐이라 콜드 스타트가 아니다). - scenario('login', (tester) async { - await tester.enterText(find.byKey(const Key('login_id')), _testEmail); - await tester.enterText(find.byKey(const Key('login_pw')), _testPassword); - await tester.tap(find.byKey(const Key('login_submit'))); - // 로그인은 비동기 응답을 기다린다. pumpAndSettle 은 "예약된 프레임이 없으면" - // 바로 반환하므로 화면 전환 전에 끝나버린다. 목표 위젯이 뜰 때까지 편다. - await _pumpUntil(tester, find.byKey(const Key('home_list'))); - }); + scenario( + 'login', + (tester) async { + await _enterText(tester, const Key('login_id'), _testEmail); + await _enterText(tester, const Key('login_pw'), _testPassword); + await tester.tap(find.byKey(const Key('login_submit'))); + // 로그인은 비동기 응답을 기다린다. pumpAndSettle 은 "예약된 프레임이 없으면" + // 바로 반환하므로 화면 전환 전에 끝나버린다. 목표 위젯이 뜰 때까지 편다. + await _pumpUntil(tester, find.byKey(const Key('home_list'))); + }, + // 스플래시 → 온보딩 → 로그인 화면 진입은 측정 대상(로그인 자체 성능)이 + // 아니라 setUp 에서 끝낸다. + setUp: _goToLoginScreen, + ); scrollScenario('home_scroll', const Key('home_list'), setUp: _login); + // CI 테스트 계정은 실제 외출 데이터가 없다 — 스크롤 성능만 재는 시나리오라 + // outingRepositoryProvider 를 가짜 데이터로 갈아끼운다(로그인 자체는 여전히 + // 실제 백엔드로 한다). scrollScenario( 'outing_state_scroll', const Key('outing_state_list'), + overrides: [outingRepositoryProvider.overrideWithValue(FakeOutingRepository())], setUp: (tester) async { await _login(tester); await _goTo(tester, OutingRoutePath.outingState); }, ); + // 마찬가지로 CI 테스트 계정이 보는 멤버 목록이 비어있어서 가짜로 채운다. scrollScenario( 'member_list_scroll', const Key('member_list'), + overrides: [memberRepositoryProvider.overrideWithValue(FakeMemberRepository())], setUp: (tester) async { await _login(tester); await _goTo(tester, MemberRoutePath.members); @@ -61,6 +78,7 @@ void scrollScenario( String name, Key list, { Future Function(WidgetTester)? setUp, + List overrides = const [], }) { scenario( name, @@ -69,6 +87,7 @@ void scrollScenario( await setUp?.call(tester); await _fling(tester, list, rounds: 2); }, + overrides: overrides, ); } @@ -78,11 +97,12 @@ void scenario( String name, Future Function(WidgetTester) body, { Future Function(WidgetTester)? setUp, + List overrides = const [], }) { if (_only.isNotEmpty && !_only.split(',').contains(name)) return; testWidgets(name, (tester) async { - app.main(); + app.bootstrap(overrides: overrides); await tester.pumpAndSettle(); // setUp 은 측정 밖이다. 로그인/화면 진입과 워밍업이 여기서 끝난다. await setUp?.call(tester); @@ -108,6 +128,9 @@ void scenario( /// 측정 구간이 짧으면 p95/p99 가 노이즈로 크게 흔들린다. 왕복 10회로 표본을 확보. Future _fling(WidgetTester tester, Key key, {required int rounds}) async { final target = find.byKey(key); + // 화면 전환 직후엔 pumpAndSettle 이 끝났어도 타겟이 아직 트리에 없는 + // 찰나가 있을 수 있다 — 뜰 때까지 명시적으로 기다린다. + await _pumpUntil(tester, target); for (var i = 0; i < rounds; i++) { await tester.fling(target, const Offset(0, -600), 4000); await tester.pumpAndSettle(); @@ -116,11 +139,50 @@ Future _fling(WidgetTester tester, Key key, {required int rounds}) async { } } +/// 실기기(flutter drive)에서는 tester.enterText() 가 신뢰할 수 없다 — tap 으로 +/// 먼저 포커스해도 컨트롤러가 계속 비어있는 채로 남는 경우가 있었다(테스트용 +/// mock text input 채널과 실제 기기의 IME 가 서로 다른 대상을 보는 것으로 +/// 보임). 컨트롤러를 직접 갈아끼우는 쪽이 훨씬 안정적이다. +Future _enterText(WidgetTester tester, Key key, String text) async { + final field = tester.widget( + find.descendant(of: find.byKey(key), matching: find.byType(TextFormField)), + ); + field.controller!.text = text; + await tester.pump(); +} + +/// 앱은 스플래시 뒤 곧장 로그인 화면으로 가지 않고 온보딩을 먼저 보여준다 +/// (토큰이 없으면 항상 온보딩). 온보딩의 "로그인" 버튼을 눌러야 login_id 가 +/// 있는 화면에 도달한다. +Future _goToLoginScreen(WidgetTester tester) async { + await _pumpUntil(tester, find.byKey(const Key('onboarding_login_button'))); + await tester.tap(find.byKey(const Key('onboarding_login_button'))); + await tester.pumpAndSettle(); + await _pumpUntil(tester, find.byKey(const Key('login_id'))); +} + +/// app.main() 은 시나리오마다 같은 프로세스 안에서 다시 호출된다 — 직전 +/// 시나리오에서 로그인해 저장된 토큰이 그대로 남아있으면 스플래시가 온보딩을 +/// 건너뛰고 곧장 홈으로 간다. 이미 로그인돼 있으면 다시 로그인하지 않는다. Future _login(WidgetTester tester) async { - await tester.enterText(find.byKey(const Key('login_id')), _testEmail); - await tester.enterText(find.byKey(const Key('login_pw')), _testPassword); + // app_router.router 는 전역 싱글턴이라 app.main() 을 다시 불러도 이전 + // 시나리오에서 _goTo 로 이동해둔 위치(예: 외출 현황)가 그대로 남아있다. + // 확인하기 전에 먼저 홈으로 되돌려서 시작점을 고정한다. + app_router.router.go(OutingRoutePath.home); + await tester.pumpAndSettle(); + + final homeList = find.byKey(const Key('home_list')); + final onboardingLoginButton = find.byKey(const Key('onboarding_login_button')); + await _pumpUntilAny(tester, [homeList, onboardingLoginButton]); + if (homeList.evaluate().isNotEmpty) return; + + await tester.tap(onboardingLoginButton); + await tester.pumpAndSettle(); + await _pumpUntil(tester, find.byKey(const Key('login_id'))); + await _enterText(tester, const Key('login_id'), _testEmail); + await _enterText(tester, const Key('login_pw'), _testPassword); await tester.tap(find.byKey(const Key('login_submit'))); - await _pumpUntil(tester, find.byKey(const Key('home_list'))); + await _pumpUntil(tester, homeList); await tester.pumpAndSettle(); } @@ -144,5 +206,35 @@ Future _pumpUntil( await tester.pump(const Duration(milliseconds: 16)); if (finder.evaluate().isNotEmpty) return; } + // profile 빌드에서는 앱 로거가 꺼져 있어(kDebugMode 게이팅) 실패 원인을 + // 알 방법이 없다 — 화면에 실제로 뜬 텍스트를 그대로 찍어서 CI 로그에서 + // 바로 원인을 좁힐 수 있게 한다. + final visibleTexts = tester + .widgetList(find.byType(Text)) + .map((t) => t.data) + .whereType() + .toList(); + debugPrint('[perfkit] timeout waiting for $finder, visible texts: $visibleTexts'); throw StateError('timeout: $finder 를 기다리다 실패'); } + +/// finder 여러 개 중 하나라도 나타나면 반환한다 — 이미 로그인돼 있어 온보딩을 +/// 건너뛰는 경우와 그렇지 않은 경우를 하나의 대기로 같이 처리할 때 쓴다. +Future _pumpUntilAny( + WidgetTester tester, + List finders, { + Duration timeout = const Duration(seconds: 20), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 16)); + if (finders.any((f) => f.evaluate().isNotEmpty)) return; + } + final visibleTexts = tester + .widgetList(find.byType(Text)) + .map((t) => t.data) + .whereType() + .toList(); + debugPrint('[perfkit] timeout waiting for any of $finders, visible texts: $visibleTexts'); + throw StateError('timeout: $finders 중 어느 것도 기다리다 실패'); +} diff --git a/lib/features/member/presentation/widgets/member_list_section.dart b/lib/features/member/presentation/widgets/member_list_section.dart index baf9353b..a6b275e0 100644 --- a/lib/features/member/presentation/widgets/member_list_section.dart +++ b/lib/features/member/presentation/widgets/member_list_section.dart @@ -19,6 +19,7 @@ class MemberListSection extends StatelessWidget { } return ListView.separated( + key: const Key('member_list'), padding: const EdgeInsets.all(16), itemCount: members.length, separatorBuilder: (_, __) => const SizedBox(height: 8), diff --git a/lib/features/splash/presentation/screens/onboarding_screen.dart b/lib/features/splash/presentation/screens/onboarding_screen.dart index b809bc85..1cf1b88d 100644 --- a/lib/features/splash/presentation/screens/onboarding_screen.dart +++ b/lib/features/splash/presentation/screens/onboarding_screen.dart @@ -56,6 +56,7 @@ class OnboardingScreen extends StatelessWidget { const Spacer(flex: 2), // 로그인 버튼 ConfirmButton( + key: const Key('onboarding_login_button'), text: '로그인', onPressed: () => context.push(AuthRoutePath.login), ), diff --git a/lib/main.dart b/lib/main.dart index d2b570f3..163a651a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:riverpod/misc.dart' show Override; import 'package:goms/core/config/app_env.dart'; import 'package:goms/app/router/app_router.dart'; import 'package:goms_design_system/goms_design_system.dart'; @@ -40,7 +41,12 @@ Future _initKakaoMap() async { } } -Future main() async { +Future main() async => bootstrap(); + +/// perf 통합 테스트가 특정 repository provider 를 mock 으로 갈아끼워 돌릴 수 +/// 있도록 초기화 로직을 분리했다. 프로덕션 진입점(main())은 overrides 없이 +/// 그대로 호출한다. +Future bootstrap({List overrides = const []}) async { WidgetsFlutterBinding.ensureInitialized(); const appEnvValue = String.fromEnvironment('APP_ENV', defaultValue: 'dev'); final appEnv = AppEnv.fromValue(appEnvValue); @@ -50,7 +56,7 @@ Future main() async { _initFirebase(), ]); - runApp(const ProviderScope(child: MyApp())); + runApp(ProviderScope(overrides: overrides, child: const MyApp())); unawaited(_initKakaoMap()); } diff --git a/pubspec.lock b/pubspec.lock index 48302325..f0c5c55c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1087,7 +1087,7 @@ packages: source: hosted version: "10.2.1" riverpod: - dependency: transitive + dependency: "direct main" description: name: riverpod sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83" diff --git a/pubspec.yaml b/pubspec.yaml index fcf68757..ba3a4f02 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,6 +36,7 @@ dependencies: flutter_svg: ^2.2.3 intl: ^0.20.2 flutter_riverpod: ^3.2.1 + riverpod: ^3.2.1 go_router: ^17.1.0 dio: ^5.9.0 retrofit: ^4.7.2 From 79e5fce08aa740c38376b440bff94c60300af553 Mon Sep 17 00:00:00 2001 From: aiden30015 Date: Sat, 19 Sep 2026 19:32:09 +0900 Subject: [PATCH 2/4] =?UTF-8?q?:green=5Fheart:=20::=20PR=EC=97=90=EC=84=9C?= =?UTF-8?q?=EB=8A=94=20perf=20=EB=9D=BC=EB=B2=A8=EC=9D=B4=20=EC=9E=88?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=EB=A7=8C=20=EC=84=B1=EB=8A=A5=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=95=20=EC=8B=A4=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- .github/workflows/perf.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index f96ef0a5..37690ad6 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -9,8 +9,12 @@ name: perf # 이 repo 의 모든 시크릿이 외부(perfkit) 워크플로우 실행 컨텍스트에 그대로 # 노출되는데, 여기선 앱이 dotenv 로 읽는 값 몇 개만 필요하기 때문이다. on: + # PR 에서는 에뮬레이터 측정이 12~15분 걸리므로 기본으론 건너뛴다. 성능에 영향 + # 주는 변경(화면/리스트/시작 흐름 등)이면 PR 에 `perf` 라벨을 붙이면 돈다. + # (main/develop push 는 baseline·히스토리 때문에 항상 돈다) pull_request: branches: [main, develop] + types: [opened, synchronize, reopened, labeled] push: branches: [main, develop] workflow_dispatch: @@ -31,6 +35,7 @@ permissions: jobs: perf: + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'perf') uses: aiden30015/Ci-App-Performance-Measurement/.github/workflows/perf-reusable.yml@main with: rebaseline: ${{ inputs.rebaseline == true }} From 8a7af33f924b8bc09029149c19c4f4617c31433d Mon Sep 17 00:00:00 2001 From: aiden30015 Date: Sat, 19 Sep 2026 20:40:21 +0900 Subject: [PATCH 3/4] =?UTF-8?q?:green=5Fheart:=20::=20PR=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=ED=8C=8C=EC=9D=BC=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B4=80=EB=A0=A8=20=EC=8B=9C=EB=82=98?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=EB=A7=8C=20=EC=84=B1=EB=8A=A5=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - perf.yaml에 selection(시나리오별 관련 경로) 추가 - perf.yml: select-changed 활성화, 수동 실행용 scenarios 입력 추가 - 기존 perf 라벨 조건은 selection이 대체 Co-Authored-By: Claude Sonnet 5 --- .github/workflows/perf.yml | 13 ++++++++----- perf.yaml | 13 +++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index 37690ad6..4bc615eb 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -9,12 +9,11 @@ name: perf # 이 repo 의 모든 시크릿이 외부(perfkit) 워크플로우 실행 컨텍스트에 그대로 # 노출되는데, 여기선 앱이 dotenv 로 읽는 값 몇 개만 필요하기 때문이다. on: - # PR 에서는 에뮬레이터 측정이 12~15분 걸리므로 기본으론 건너뛴다. 성능에 영향 - # 주는 변경(화면/리스트/시작 흐름 등)이면 PR 에 `perf` 라벨을 붙이면 돈다. - # (main/develop push 는 baseline·히스토리 때문에 항상 돈다) + # PR 에서는 에뮬레이터 측정이 12~15분 걸리므로, 변경된 파일과 관련된 시나리오만 + # 돈다 (perf.yaml 의 selection). 성능과 무관한 변경뿐이면 측정을 건너뛴다. + # main/develop push 는 baseline·히스토리 때문에 항상 전체를 돈다. pull_request: branches: [main, develop] - types: [opened, synchronize, reopened, labeled] push: branches: [main, develop] workflow_dispatch: @@ -26,6 +25,9 @@ on: repeats: description: '반복 측정 횟수 (기본값은 perf.yaml)' default: '' + scenarios: + description: '일부 시나리오만 측정 (쉼표 구분, 예: home_scroll,login). 비우면 전체' + default: '' permissions: contents: write @@ -35,11 +37,12 @@ permissions: jobs: perf: - if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'perf') uses: aiden30015/Ci-App-Performance-Measurement/.github/workflows/perf-reusable.yml@main with: rebaseline: ${{ inputs.rebaseline == true }} repeats: ${{ inputs.repeats || '' }} + scenarios: ${{ inputs.scenarios || '' }} + select-changed: true # main·develop 둘 다 push 트리거 대상이라, 둘 다 히스토리에 쌓고 대시보드도 # 갱신한다 (perfkit 기본값은 main 하나뿐이라 명시적으로 넘겨야 한다). history-refs: '["main","develop"]' diff --git a/perf.yaml b/perf.yaml index 3c70586e..51996808 100644 --- a/perf.yaml +++ b/perf.yaml @@ -37,3 +37,16 @@ headline_metrics: [fps, jank_rate, p95_frame_time_ms, memory_mb, startup_ms] # baseline 이 이보다 오래되면 리포트에 경고를 띄운다. baseline_max_age_days: 30 + +# PR 에서 변경된 파일과 겹치는 시나리오만 측정한다 (perfkit select). +# 여기 없는 경로(lib/core, main.dart, pubspec, android/, integration_test/ 등 공통·빌드 +# 변경)가 하나라도 바뀌면 안전하게 전체를 측정한다. +selection: + ignore: ["**/*.md", "docs/**"] + scenarios: + app_startup: ["lib/features/splash/**"] + login: ["lib/features/auth/**", "lib/features/splash/**"] + # 홈(home_list)은 outing 의 대기 화면이다. + home_scroll: ["lib/features/outing/**"] + outing_state_scroll: ["lib/features/outing/**"] + member_list_scroll: ["lib/features/member/**"] From 7c04b5ffaac5ba4275ea96d4c09603bf476a35cb Mon Sep 17 00:00:00 2001 From: aiden30015 Date: Sat, 19 Sep 2026 22:09:29 +0900 Subject: [PATCH 4/4] =?UTF-8?q?:green=5Fheart:=20::=20=EC=84=B1=EB=8A=A5?= =?UTF-8?q?=20=EC=B8=A1=EC=A0=95=20baseline=20=EC=B6=94=EA=B0=80=20(PR=20#?= =?UTF-8?q?145=20CI=20run=20=EA=B8=B0=EC=A4=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run 35440756399 (commit 8a7af33, ci-emulator-api34) 의 측정값 Co-Authored-By: Claude Sonnet 5 --- baseline/performance_baseline.json | 181 +++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 baseline/performance_baseline.json diff --git a/baseline/performance_baseline.json b/baseline/performance_baseline.json new file mode 100644 index 00000000..0f15bda6 --- /dev/null +++ b/baseline/performance_baseline.json @@ -0,0 +1,181 @@ +{ + "schema": 1, + "updated_at": "2026-09-19T13:09:24+00:00", + "commit": "8a7af33", + "device_profile": "ci-emulator-api34", + "repeats": 3, + "scenarios": { + "app_startup": { + "metrics": { + "startup_ms": { + "value": 855.107, + "noise": 27.3643 + }, + "startup_rasterized_ms": { + "value": 970.564, + "noise": 6.35 + } + } + }, + "home_scroll": { + "metrics": { + "fps": { + "value": 60.0, + "noise": 0.0 + }, + "jank_rate": { + "value": 17.0543, + "noise": 14.9409 + }, + "dropped_frames": { + "value": 22.0, + "noise": 19.2738 + }, + "p50_frame_time_ms": { + "value": 11.857, + "noise": 1.6457 + }, + "p90_frame_time_ms": { + "value": 18.0196, + "noise": 1.0787 + }, + "p95_frame_time_ms": { + "value": 19.7028, + "noise": 1.6771 + }, + "p99_frame_time_ms": { + "value": 21.1342, + "noise": 4.9716 + }, + "memory_mb": { + "value": 328.0391, + "noise": 0.2259 + }, + "duration_ms": { + "value": 16066.0, + "noise": 2.9652 + } + } + }, + "login": { + "metrics": { + "fps": { + "value": 60.0, + "noise": 0.0 + }, + "jank_rate": { + "value": 3.5714, + "noise": 1.6789 + }, + "dropped_frames": { + "value": 2.0, + "noise": 0.5 + }, + "p50_frame_time_ms": { + "value": 3.183, + "noise": 0.2276 + }, + "p90_frame_time_ms": { + "value": 6.126, + "noise": 0.771 + }, + "p95_frame_time_ms": { + "value": 9.2103, + "noise": 0.8639 + }, + "p99_frame_time_ms": { + "value": 34.7187, + "noise": 3.0699 + }, + "memory_mb": { + "value": 296.6289, + "noise": 6.1621 + }, + "duration_ms": { + "value": 1462.0, + "noise": 37.065 + } + } + }, + "member_list_scroll": { + "metrics": { + "fps": { + "value": 60.0, + "noise": 0.0 + }, + "jank_rate": { + "value": 0.0, + "noise": 0.7692 + }, + "dropped_frames": { + "value": 0.0, + "noise": 0.5 + }, + "p50_frame_time_ms": { + "value": 10.042, + "noise": 0.2535 + }, + "p90_frame_time_ms": { + "value": 12.591, + "noise": 0.1272 + }, + "p95_frame_time_ms": { + "value": 13.032, + "noise": 0.0433 + }, + "p99_frame_time_ms": { + "value": 13.5335, + "noise": 0.1865 + }, + "memory_mb": { + "value": 388.3906, + "noise": 1.8822 + }, + "duration_ms": { + "value": 31263.0, + "noise": 1.4826 + } + } + }, + "outing_state_scroll": { + "metrics": { + "fps": { + "value": 60.0, + "noise": 0.0 + }, + "jank_rate": { + "value": 1.1494, + "noise": 1.7041 + }, + "dropped_frames": { + "value": 1.0, + "noise": 1.4826 + }, + "p50_frame_time_ms": { + "value": 9.6455, + "noise": 0.1505 + }, + "p90_frame_time_ms": { + "value": 12.21, + "noise": 0.8765 + }, + "p95_frame_time_ms": { + "value": 13.4152, + "noise": 0.1985 + }, + "p99_frame_time_ms": { + "value": 15.6536, + "noise": 1.4187 + }, + "memory_mb": { + "value": 362.832, + "noise": 1.2683 + }, + "duration_ms": { + "value": 28928.0, + "noise": 17.7912 + } + } + } + } +}