AI가 생성한 Flutter 코드를 검토하는 방법 (프로덕션 환경이 망가지기 전에)
요약
AI 에이전트가 생성한 Flutter 코드에서 발생하는 7가지 구조적 결함과 이를 검토하는 방법을 다룹니다. 통계적 패턴에 의존하는 AI의 한계를 지적하며, 상태 관리 오류와 테스트 누락 등 실무적인 문제 해결 방안을 제시합니다.
핵심 포인트
- AI는 관용적인 방식보다 학습 데이터의 통계적 빈도에 따라 코드를 작성함
- 파생된 상태(Derived State)를 재계산하지 말고 단일 소스(Stream)를 사용해야 함
- AI 에이전트는 테스트 및 벤치마크 작성을 누락하는 경향이 있음
- 잘못된 상태 관리와 테스트 부재는 코드베이스의 유지보수성을 저해함
우리가 검토한 Flutter 코드를 작성하는 모든 비지도 AI 에이전트(unsupervised AI agent)는 동일한 7가지 실수를 저질렀습니다.
이것들은 단순한 오타나 스타일의 차이가 아닙니다. 이는 연쇄적으로 발생하는 구조적 결함입니다. 잘못된 상태 관리(state management)에 테스트 누락, 하드코딩된 색상(hardcoded colors)이 더해지면 코드베이스는 테마를 적용하기 비싸지고, 테스트하기 어려워지며, 대규모 확장 시 유지보수가 불가능해집니다.
분위기를 파악하기 위해 작은 사례 하나를 들어보겠습니다. 한 개발자가 에이전트에게 Dart 프로젝트에서 외부 서비스로의 GET 요청을 구현해 달라고 요청했습니다. 에이전트의 해결책은 Process.run을 통해 curl을 실행하고 stdout을 파싱하는 것이었습니다.
package:http도 아니었습니다. dio도 아니었습니다. 심지어 dart:io 자체의 HttpClient조차 아니었습니다. Dart 1.0부터 일급 객체(first-class) HTTP 클라이언트를 지원해 온 언어 안에서, CLI 도구에 대한 서브프로세스(subprocess) 호출을 사용한 것입니다.
이 사례는 깊이 생각해 볼 가치가 있습니다. 왜냐하면 이것은 진정한 Flutter의 문제라기보다, 전체적인 패턴을 축소해 놓은 것이기 때문입니다. 에이전트는 curl이 GET 요청을 보낼 수 있다는 점에서는 "틀리지" 않았습니다. 다만 에이전트는 "현재 작성 중인 언어에서 이것이 관용적인(idiomatic) 방식이다"라는 점보다 "이 패턴은 학습 데이터에 끊임없이 등장한다"라는 점에 최적화되었습니다. Bash와 curl은 지금까지 작성된 거의 모든 튜토리얼, README, Stack Overflow 답변에 등장합니다. 반면 package:http는 Dart 전용 문서에 등장합니다. 다른 제약 조건이 없다면, 에이전트는 문맥적으로 올바른 패턴이 아닌 통계적으로 지배적인 패턴을 선택한 것입니다.
아래의 7가지 격차(gaps)는 "curl을 실행하는 것"만큼 명확하지는 않지만, 동일한 실패 모드입니다. 실제 코드 예시와 작동하는 해결책과 함께 우리가 발견한 내용을 소개합니다.
1. 파생된 상태(Derived State)의 재계산
문제점: 에이전트들은 단일 진실 공급원(source of truth)을 유지하는 대신, 여러 위치에서 동일한 값을 재계산합니다.
장바구니 총액이 세 가지 별도의 방식으로 계산되는 결제 흐름을 상상해 보십시오:
- 결제 페이지에서:
(items.sum + tax) - discount - 푸터(footer)에서:
items.sum - discount + tax - 주문 요약에서:
(items.sum - discount) * (1 + taxRate)
계산 방식은 다르지만 의미론적(semantic) 의미는 같습니다. 이 중 하나는 반드시 먼저 깨지게 될 것입니다.
해결책: 스트림 (streams)을 사용하여 상태 계층 (state layer)에서 값을 한 번만 도출하세요. 모든 위젯이 그 단일 소스(single source)로부터 읽도록 합니다.
// 호출 지점(call sites)에서 계산하는 대신:
final total = items.fold(0, (sum, item) => sum + item.price);
...
원칙: 상태(state)로부터 계산될 수 있는 것이라면, 그것은 상태가 아닙니다.
모든 재계산은 발생하기를 기다리고 있는 동기화 버그 (sync bug)입니다.
2. 누락된 테스트 및 벤치마크 (Benchmarks)
문제점: "기능을 배포하고 끝냅니다. 위젯 테스트 (widget tests), 골든 테스트 (goldens), 벤치마크 (benchmark)가 없습니다."
에이전트 (Agents)는 독립적으로 테스트를 실행할 수 없기 때문에 테스트를 작성하지 않습니다. 에이전트는 코드를 생성하고, 사용자가 이를 통합하면, 그제서야 다음과 같은 문제들을 발견하게 됩니다:
- 제출 중 버튼이 비활성화되지 않음 (상태 테스트 누락)
- 사용자가 다시 입력할 때 에러 메시지가 지워지지 않음 (위젯 테스트 누락)
- 너비 320pt에서 레이아웃 오버플로 (layout overflow) 발생 (골든 테스트 누락)
- 실제 기기에서 프레임 레이트 (frame rate)가 60fps에서 12fps로 하락 (벤치마크 누락)
테스트는 에이전트가 반복 개선 (iterate)할 수 있는 피드백 루프가 됩니다. 골든 파일 (Golden files)은 시각적 회귀 (visual regression) 탐지를 제공하여 오버플로, 다크 모드 대비 실패, 200% 배율에서의 텍스트 잘림 등을 자동으로 잡아냅니다.
해결책: 테스트를 먼저 작성하세요. 그러면 테스트가 곧 명세 (specification)가 됩니다:
group('SearchPage', () {
testWidgets('loading 중 스피너를 보여줌', (tester) async {
await tester.pumpWidget(SearchPage(state: const SearchState.loading()));
...
3. 상태 머신 (State Machines) 대신 플래그 피라미드 (Flag Pyramids) 사용
문제점: 에이전트는 느슨하게 병렬된 불리언 (boolean) 필드들을 사용하고 이를 기준으로 분기 처리를 하여, 유지보수가 불가능한 조건문들을 만들어냅니다.
bool _isLoading = false;
String? _error;
List<Hit> _items = [];
...
UI 코드는 실제 상태 머신 (state machine)을 표현하지 못합니다. 단순히 플래그 조합을 바탕으로 추측할 뿐입니다. 만약 에러를 지우기 전에 로딩이 완료된다면, UI는 혼란에 빠지게 됩니다.
해결책: 봉인된 상태 계층 구조 (sealed state hierarchies)를 사용하세요. 모든 유효한 상태를 명시적으로 표현하세요:
sealed class SearchState {}
final class Idle extends SearchState {}
final class Loading extends SearchState {}
...
잘못된 조합이 없습니다. if-문 피라미드(pyramids of if-statements)도 없습니다. 컴파일러가 완전성(exhaustiveness)을 강제합니다.
질문 하나: PR(Pull Request)에서 플래그 피라미드(flag-pyramid) 안티 패턴을 발견한 적이 있나요? 이는 보통 AI가 생성한 Flutter 코드가 잘못되는 첫 번째 지점입니다. 아래에 네 가지 사례가 더 있습니다.
4. constants.dart의 디자인 토큰 (Design Tokens)
문제점: 에이전트(Agents)가 ThemeData를 사용하는 대신, 상수 파일(constant files)을 생성하고 모든 호출 지점(call site)에 값을 하드코딩합니다.
// constants.dart
const primaryColor = Color(0xFF1F77D2);
const lightGrey = Color(0xFFF5F5F5);
...
이는 네 가지 문제로 이어집니다:
- 다크 모드 구현 시 200개 이상의 삼항 연산자 필요: 모든 호출 지점에서
color: isDark ? darkGrey : lightGrey를 작성해야 함 - 디자인 리브랜딩 시 방대한 변경 사항(diffs) 발생: 기본 색상(primary color) 변경 → 300개의 파일 수정
- 접근성(Accessibility) 파괴: 하드코딩된 16pt 텍스트는 200% 텍스트 크기 조절 시 깨짐
- 테마 오버라이드(Theme overrides) 작동 불능: 아무도
Theme.of(context)를 호출하지 않음
해결책: 디자인 시스템을 ThemeData, TextTheme, 그리고 컴포넌트 테마(component themes)에 단 한 번만 정의하세요:
MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Color(0xFF1F77D2)),
...
리브랜딩은 한 번만 수행하면 됩니다. 모든 위젯이 자동으로 업데이트됩니다.
5. 각 화면마다 고유한 UX 어휘를 가짐
문제점: 개별적으로는 방어 가능한 40개의 화면이 모이면 마치
- 320pt에서의 오버플로 (가로 모드의 좁은 휴대폰)
- 44/48dp 미만의 탭 대상 (접근성 실패)
- 입력 필드를 가리는 키보드 (화면 내 감지 불가)
- 200% 배율에서의 텍스트 잘림 (가독성 문제)
- 노치 아래 또는 홈 인디케이터 뒤의 콘텐츠 (Safe Area 문제)
- 다크 모드에서의 대비 실패 (WCAG A/AA)
해결책 (The Fix): 상호작용 어휘(interaction vocabulary)를 단 한 번 결정하세요. 로딩 / 에러 / 확인 / 유효성 검사 피드백 / 빈 상태(empty states)가 어떻게 작동해야 할까요? 그 패턴을 모든 곳에 구현하세요. 실제 기기에서 테스트하세요:
// 한 번만 정의:
class AppLoadingOverlay extends StatelessWidget {
const AppLoadingOverlay({Key? key}) : super(key: key);
...
6. 로컬라이제이션 (Localization) 대신 문자열 연결 (String Concatenation) 사용
문제점 (The Problem): 에이전트(Agents)는 코드에 영어 기반의 가정을 심어버립니다.
Text('$count items'); // 출력: "1 items" (문법적으로 틀림)
Text('$count ${count == 1 ? "item" : "items"}');
...
이것들 중 그 어떤 것도 국제화(Internationalization)가 아닙니다. 변수가 포함된 영어일 뿐입니다.
해결책 (The Fix): gen_l10n 및 intl 패키지와 함께 ARB 파일을 사용하세요:
# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
...
// lib/l10n/app_en.arb
{
"unreadMessages": "{count, plural, =0{No new messages} one{{count} message} other{{count} messages}}"
...
}
Text(l10n.unreadMessages(count))
// 통화를 위해 NumberFormat 사용
...
각 언어에 대한 단일 진실 공급원(One source of truth)을 확보하세요. 복수형(Plurals), 성별(Gender), 어순(Word order) 등은 모두 ICU에 의해 처리됩니다.
7. 균일한 단순화 (잘못된 위치에서의 단순화)
문제점 (The Problem): 에이전트는 복잡성을 균일하게 평탄화합니다. 즉, 핵심적인 로직을 무너뜨리는 동시에 사소한 코드는 과도하게 추상화(over-abstracting)합니다.
과도하게 단순화됨 (위험함):
try {
await api.charge(order);
} catch (_) {
...
이 방식은 완전히 다른 세 가지 실패를 하나로 삼켜버립니다:
- 네트워크 타임아웃 (재시도 가능)
- 카드 승인 거절 (사용자 문제, 재시도 금지)
- 멱등성(Idempotency) 충돌 (재시도 시 중복 결제 발생)
이제 이 세 가지는 모두 동일해졌습니다. 재시도 로직이 망가진 것입니다.
과도하게 추상화됨 (낭비):
// 5가지 버튼 스타일을 위한 14개의 파라미터를 가진 위젯
class AppButton extends StatelessWidget {
final String label;
...
범용적인 버전은 사용하기 더 어렵고 논리적으로 파악하기(reason about) 힘듭니다.
해결책 (The Fix): 복잡성은 예산입니다. 진정으로 어려운 도메인에 그 예산을 사용하세요.
하중을 견디는 부분 (Load-bearing, 명시적이고 장황하게):
- 결제 및 금융 트랜잭션 (Financial transactions)
- 오프라인 동기화 (Offline sync) 및 충돌 해결 (Conflict resolution)
- 인증 (Authentication) 및 토큰 갱신 (Token refresh)
- 멱등성 (Idempotency) 및 중복 제거 (Deduplication)
- 복구 전략을 포함한 에러 핸들링 (Error handling)
사소한 부분 (Trivial, 공격적으로 축소):
- 다섯 개의 유사한 리스트 타일 (List tiles) → 하나의 위젯으로 통합
- 10개의 토글이 있는 설정 화면 → 추상화하지 않음
- 버튼 변형 (Button variants) → 파라미터가 아닌 구체적인 하위 클래스 (Concrete subclasses)로 구현
- 유틸리티 함수 → BaseRepository를 만들지 말 것
// 명시적인 결제 에러 핸들링
sealed class PaymentError {}
final class NetworkTimeout extends PaymentError {}
...
💡 핵심 원칙: 복잡성은 예산입니다. 결제, 오프라인 동기화, 인증, 멱등성과 같이 진정으로 어려운 도메인에 그 예산을 사용하세요. 그 외의 모든 곳에서는 공격적으로 축소(Collapse)하세요.
Flutter가 이러한 문제를 증폭시키는 이유
Flutter가 다른 프레임워크보다 이러한 문제를 더 크게 만드는 네 가지 이유:
- UI가 곧 코드임: 일관성을 강제할 스타일시트 (Stylesheet) 레이어가 없습니다. 린터 (Linter)가 검증할 수 있는 중앙 집중식 테마 (Theme)도 없습니다.
- 모든 것이 컴파일됨: "여기서는
Theme.of(context)를 사용해야 합니다"와 같은 컴파일러 에러가 발생하지 않습니다. 하드코딩된 색상들은 50일 차가 되기 전까지는 괜찮아 보입니다. - 에이전트가 프레임을 볼 수 없음: 레이아웃이 실제로 320pt에서 렌더링되는지, 텍스처 크기가 200%일 때 텍스트를 읽을 수 있는지, 또는 키보드가 입력창을 가리는지에 대한 가시성이 없습니다.
- 오래된 학습 데이터: 모델들이 자신 있게 지원 중단된 (Deprecated) API (
RaisedButton,WillPopScope,MaterialStateProperty)를 생성합니다.
핵심 통찰: 오케스트레이션 (Orchestration) vs. 비감독 에이전트 (Unsupervised Agents)
이러한 모든 실패 사례는 모든 언어에서 나타납니다.
Flutter는 단지 그것들을 더 크게 들리게 할 뿐입니다.
차이점은 오케스트레이션 (Orchestration)에 있습니다:
- 불변량(Invariants)을 먼저 설정하세요: 테마(Theme), 상태 모델(State model), 지역화(Localization), 폴더 구조, 에러 분류 체계(Error taxonomy)—사람이든 에이전트(Agent)든 코드를 작성하기 전에 결정해야 합니다.
- 피드백 루프(Feedback loops)를 구축하세요: 엄격한 린팅(Linting) (하드코딩된 색상 금지), 테스트 커버리지 강제, 프레임 타임 예산(Frame-time budgets), CI 체크.
- 코드 라인이 아닌 결정을 검토하세요: 개별적인 차이점(Diffs)이 아니라 상태 일관성(State consistency), 테마 사용, 유도 소스(Derivation sources), 지역화 등을 검증하세요.
- 앱을 직접 실행하세요: 실제 기기에서 텍스트 크기 200%, 다크 모드, 네트워크 제한(Throttled network), 두 가지 언어 환경에서 테스트하세요.
AI 에이전트가 강력한 불변량과 피드백 루프가 갖춰진 코드베이스 내에서 작동할 때, 그들은 빠르게 움직입니다. 하지만 그러한 제약 조건 없이 처음부터 코드베이스를 생성할 때, 그들은 잘못된 방향으로 빠르게 움직입니다.
본질적으로 영리한 것보다 익숙한 것이 승리합니다. 그리고 에이전트의 속도가 부채(Liability)가 아닌 자산(Asset)이 되는 유일한 방법은 코딩이 시작되기 전에 결정된 구조적 제약(Structural constraints)을 통해서뿐입니다.
핵심 요약 (Key Takeaways)
- 한 번 유도하고, 어디서든 읽으세요 (Derive once, read everywhere) — 상태(State)로부터 계산될 수 있는 것이라면, 그것은 상태가 아닙니다.
- 테스트가 곧 명세(Spec)입니다 — 테스트를 먼저 작성하여 사후 고려 사항이 아닌 피드백 루프가 되도록 하세요.
- 상태를 명시적으로 모델링하세요 — 불리언 플래그(Boolean flags) 대신 봉인된 클래스(Sealed classes)를 사용하세요. 컴파일러가 잘못된 조합을 잡아낼 수 있도록 하세요.
- 디자인 시스템을 중앙 집중화하세요 — 200개의 호출 지점에 값을 하드코딩하지 말고,
ThemeData를 한 번만 정의하세요. - 단일한 상호작용 어휘(Interaction vocabulary)를 선택하세요 — 로딩, 에러, 확인(Confirmations) 과정은 모든 화면에서 동일하게 느껴져야 합니다.
- 첫날부터 지역화(Localization)를 적용하세요 — 문자열 연결(String concatenation)이 아닌 ARB 파일과 ICU 복수형(Plurals)을 사용하세요.
- 복잡성을 예산(Budget)으로 취급하세요 — 결제/인증/동기화(Sync)에 복잡성을 사용하고, 사소한 모든 것은 단순화하세요.
토론: 리뷰 중에 발견한 AI 생성 코드의 가장 대표적인 냄새(Code smell)는 무엇인가요? 댓글로 남겨주세요. 후속 포스트를 위해 최악의 사례들을 수집하고 있습니다.
더 많은 예시가 포함된 심층 분석을 원하시나요? nerdy.pro에서 전체 기사를 읽어보세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기