잡담

TDD와 Flutter로 AI 주도 개발하기

Dev.Hansangwook 2026. 8. 25. 23:16

AI 시대에서 개발자

AI가 코드를 만드는 속도는 이미 사람이 직접 작성하는 속도를 크게 앞질렀다.
그런데, 코드 생산 속도가 빨라졌다고 해서 검증 속도까지 빨라진 것은 아니다. 오히려 병목은 구현에서 검증으로 이동한다.

 

AI는 잘못 이해한 요구사항도 놀라울 정도로 빠르게 구현한다. 그리고 테스트까지 AI에게 맡겨버리면, 잘못 이해한 요구사항을 테스트로 고정한 뒤 그 테스트를 완벽하게 통과시키는 코드까지 만들 수 있다.

 

그래서 나는 AI 시대의 TDD는 AI가 구현 과정에서 탈선하지 않게 만드는 피드백 루프로 보고 있다.

 

물론 위험도 있다. AI가 테스트와 구현을 둘 다 하면 오애한 대로 테스트를 쓰고 그 테스트를 통과시키는 일이 벌어진다. 테스트는 초록불인데 정작 의도가 틀린 것이다. 그래서 사람의 검토 중심은 구현 코드에서 요구사항과 테스트로 이동해야 한다.

 

좋지만 리소스가 큰 방법론

켄트 벡이 고안한 TDD는 이미 예전부터 존재하던 개념이었다. 특히, 테스트의 중요성은 최근들어 더 강조되고 있으나, 여러가지 이유로 실천하기 힘들다.

 

테스트를 먼저 쓰고, 그것을 계속 유지하는 데에는 사람과 시간이 든다. 많은 조직이 그 비용을 감당하지 못했다. 그래서 테스트라는 책임을 개발 단계 밖으로, 이를테면 QA 팀 쪽으로 밀어내는 식으로 우회했다. 개발자가 직접 짜는 TDD는 그렇게 조금씩 형해화됐다.

 

게다가 테스트 코드는 쓰기가 어렵다. 단위 테스트 정도는 괜찮지만 통합 테스트, E2E 테스트로 갈수록 셋업과 격리, 시나리오 구성의 난이도가 계단식으로 뛴다. 개발자가 검증하고 싶은 마음이 없어서가 아니라, 그걸 쓰는 일 자체가 너무 비싸다.

 

그런데 지금은 사정이 다르다. 바로 그 어려운 영역, 작성 난이도가 높은 통합·E2E 테스트를 에이전트가 대신 메워줄 수 있게 됐다. 테스트를 쓰는 비용이 낮아지면, TDD를 막아서던 "리소스 낭비"라는 논리도 무너진다.

 

그래서 나는 이렇게 정리하게 됐다. 이제 TDD는 새 프로젝트에서 처음부터 완벽하게 시작해야 하는 무언가가 아니라, 에이전트를 지렛대 삼아 기존 프로젝트에도 유연하게 얹어갈 수 있는 방법론이 됐다.

 

다만 그냥 "테스트 좀 써줘"라고 맡기면 안 된다. 무엇을, 어떤 순서로, 어떤 규칙 아래에서 쓰게 할지를 정해줘야 한다.

테스트란 무엇인가

테스트는 소프트웨어를 검증하는 방법이다.

 

검증은 사람이 눈과 손으로 하던 일이었다. 앱을 켜서 버튼을 눌러보고, 화면이 제대로 나오는지 확인한다. 그런데 이 방식은 느리고, 반복하기 어렵고, 무엇보다 놓치기 쉽다. 자동화된 테스트는 이 검증을 코드로 고정한다. 언제든 다시 돌릴 수 있고, 한 번 잡아둔 동작이 나중에 깨지면 곧바로 알려주는 안전망이 된다.

 

그래서 나는 테스트는 QA담당이란 것을 그다지 좋아하지 않는다. 테스트는 개발이 끝난 뒤에 붙이는 검사보다, 개발 행위 자체에 내장된 과정이다. 그리고 그것이 주는 것은 정확성만이 아니다. "바꿔도 안전하다"는 신뢰, 즉 변경 가능성이다.

테스트 가능한 구조

테스트를 하기 위해서는 테스트가 가능한 구조여야 한다.

 

예를 들어 이런 코드가 있다고 하자.

// Good: 의존성을 밖에서 주입받는다
class FetchHomeSummaryUseCase {
  FetchHomeSummaryUseCase({
    required UserRepository userRepository,
    required RewardRepository rewardRepository,
  })  : _userRepository = userRepository,
        _rewardRepository = rewardRepository;

  final UserRepository _userRepository;
  final RewardRepository _rewardRepository;

  Future<HomeSummary> call() async {
    final user = await _userRepository.fetchCurrentUser();
    final reward = await _rewardRepository.fetchSummary();
    return HomeSummary(user: user, reward: reward);
  }
}

이 UseCase는 필요한 의존성을 전부 생성자로 주입받는다. 그래서 테스트할 때 UserRepository 자리에 가짜(mock)를 끼워 넣으면 된다. 반대로 아래처럼 짜면 이야기가 달라진다.

// Bad: 하위 계층이 기본 구현체를 숨긴다
class FetchHomeSummaryUseCase {

  final UserRepository _userRepository = UserRepositoryImpl();
}

 

객체가 외부 의존성의 구체 구현을 내부에서 직접 생성하면 테스트에서 해당 경계를 교체하기 어려워진다. 그 결과 네트워크, 저장소, 시간 같은 외부 상태까지 테스트에 끌려 들어오기 쉽다. 이런 상황에서 테스트는 순수성을 잃는다

 

테스트 가능 구조는 좋은 구조의 결과이면서 동시에 좋은 구조를 유도한다.

구조가 없으면 어떤 테스트든 비싸지고, 비싸진 테스트는 결국 지워진다.

Flutter에서의 테스트

단위 테스트(unit)는 UseCase나 Bloc, 순수 로직을 검증한다.

 

test('given empty nickname then should be invalid', () {
  expect(NicknameValidator.validate(''), isFalse);
});

 

위젯 테스트(widget)는 위젯 트리의 렌더링과 상호작용을 검증한다.

 

testWidgets('when submit is tapped then should call onPressed', (tester) async {
  var pressed = false;
  await tester.pumpWidget(
    MaterialApp(home: SubmitButton(onPressed: () => pressed = true)),
  );

  await tester.tap(find.byType(SubmitButton));

  expect(pressed, isTrue);
});

 

통합 테스트(integration)는 여러 계층과 실제 화면 흐름을 엮어서 검증한다. 셋업 비용이 크고, 작성 난이도가 가장 높은 영역이다.

 

testWidgets('user completes login flow', (tester) async {
  await tester.pumpWidget(const App());
  await tester.pumpAndSettle();

  await tester.enterText(find.byKey(const ValueKey('email')), 'me@test.com');
  await tester.tap(find.byKey(const ValueKey('login_button')));
  await tester.pumpAndSettle();

  expect(find.byType(HomePage), findsOneWidget);
});

 

마지막으로 골든 테스트(golden)는 렌더링 결과를 이미지로 고정해서 UI 회귀를 잡는다.

testGoldens('ProfileView renders success state', (tester) async {
  await tester.pumpWidgetBuilder(const ProfileView());
  await screenMatchesGolden(tester, 'profile_view_success');
});

 

 

RED, GREEN, REFACTOR 그리고 TDD

TDD의 핵심은 테스트를 먼저 쓴다는 데 있다. 테스트가 사후 검증이 아니라, 무엇을 만들지 먼저 선언하는 명세가 된다.

이걸 세 단계로 나눈다.

 

먼저 RED. 구현하려는 동작을, 아직 존재하지 않는 코드에 대한 테스트로 먼저 쓴다. 당연히 실패한다. 나는 이 실패를 눈으로 확인하는 절차를 건너뛰지 않으려 한다. 테스트가 실제로 무언가를 검증하고 있다는 걸 보장해주는 게 바로 이 빨간불이기 때문이다. 그리고 이 단계에서 무엇이 올바른 동작인지를 못 박게 된다.

 

다음은 GREEN. 그 테스트를 통과시키는 데 필요한 최소한의 코드만 쓴다. 미리 만들어두는 것, 과하게 설계하는 것은 여기서 금지다. 지금 이 테스트를 통과시키는 것 외의 코드는 넣지 않는다. 이렇게 하면 구현 범위가 테스트로 경계 지어져서, 스코프가 제멋대로 번지지 않는다.

 

마지막이 REFACTOR. 테스트가 통과하는 범위 내에서 중복을 지우고 이름을 정리하고 구조를 다듬는다. 이때도 테스트는 계속 초록불이어야 한다. 그래야 동작은 그대로 두고 구조만 바꿨다는 게 증명된다. 테스트가 있어야 안심하고 구조를 바꿀 수 있다.

 

예를 들어, 닉네임이 비어 있으면 저장을 막는 규칙을 만든다고 하자. RED 단계에서는 아직 존재하지도 않는 함수에 대한 기대부터 적는다.

// RED: SaveNicknameUseCase 는 아직 없다. 당연히 컴파일도, 테스트도 실패한다.
test('given empty nickname then should throw EmptyNicknameException', () {
  final useCase = SaveNicknameUseCase(
    profileRepository: mockProfileRepository,
  );

  expect(
    () => useCase(nickname: ''),
    throwsA(isA<EmptyNicknameException>()),
  );
});

GREEN 단계에서는 이 테스트 하나를 통과시키는 데 딱 필요한 만큼만 짠다. 그 이상은 넣지 않는다.

// GREEN: 지금 이 테스트를 통과시키는 최소 구현
class SaveNicknameUseCase {
  SaveNicknameUseCase({required ProfileRepository profileRepository})
      : _profileRepository = profileRepository;

  final ProfileRepository _profileRepository;

  Future<void> call({required String nickname}) async {
    if (nickname.isEmpty) {
      throw const EmptyNicknameException();
    }
    await _profileRepository.saveNickname(nickname);
  }
}

그다음 REFACTOR에서 공백만 있는 닉네임 처리나 길이 제한 같은 걸 추가하고 싶어지면, 다시 실패 테스트부터 쓰고 이 사이클을 반복한다. 초록불을 유지한 채로 구조를 다듬는다.

 

이 RED → GREEN → REFACTOR를 작은 단위로 빠르게 반복하면서 기능을 쌓아 올린다. 사이클이 작을수록 실패 지점이 좁아지고 피드백이 빨라진다.

 

계층마다 다른 단위 테스트

같은 TDD라도 계층마다 검증하는 대상과 쓰는 테스트가 다르다. 나는 Clean Architecture의 세 계층을 기준으로 나눠서 생각한다.

Domain 계층은 UseCase와 도메인 규칙이 사는 곳이다. 프레임워크나 IO에 의존하지 않아서 가장 테스트하기 쉽다. 앞에서 본 FetchHomeSummaryUseCase처럼, 의존하는 repository를 mock으로 갈아 끼우고 결과만 검증하면 된다.

group(FetchHomeSummaryUseCase, () {
  group('when both repositories return data', () {
    test('then should combine them into HomeSummary', () async {
      when(() => mockUserRepository.fetchCurrentUser())
          .thenAnswer((_) async => user);
      when(() => mockRewardRepository.fetchSummary())
          .thenAnswer((_) async => reward);

      final useCase = FetchHomeSummaryUseCase(
        userRepository: mockUserRepository,
        rewardRepository: mockRewardRepository,
      );

      final result = await useCase();

      expect(result, HomeSummary(user: user, reward: reward));
    });
  });
});

실제 네트워크도, 실제 DB도 없다. mock 두 개와 순수한 조합 로직만 있다. 그래서 빠르고, 매번 같은 결과가 나온다. RED-GREEN-REFACTOR가 가장 매끄럽게 도는, TDD의 심장 같은 곳이다.

 

Data 계층은 경계에서의 변환이 핵심이다. repository 구현체, datasource, 그리고 서버 응답(DTO)을 도메인 엔티티로 바꾸는 부분이다. 여기서는 실제 네트워크를 쓰지 않는다. datasource를 mock으로 두거나 응답 fixture를 넣어서, 변환이 정확한지, 로컬 저장이 제대로 왕복하는지, 에러 응답이 도메인 에러로 잘 바뀌는지를 검증한다.

group(UserRepositoryImpl, () {
  group('when datasource returns a user dto', () {
    test('then should map dto into User entity', () async {
      when(() => mockUserDataSource.fetchCurrentUser()).thenAnswer(
        (_) async => const UserDto(id: 'u-1', nickName: 'hansw'),
      );

      final repository =
          UserRepositoryImpl(dataSource: mockUserDataSource);

      final user = await repository.fetchCurrentUser();

      // DTO 의 nickName 이 엔티티의 nickname 으로 옮겨졌는지
      expect(user, const User(id: 'u-1', nickname: 'hansw'));
    });
  });
});

검증하는 건 화려한 로직이 아니라 "필드가 제자리에 옮겨졌는가" 같은, 사소하지만 자주 틀리는 것들이다. 

 

Presentation 계층은 조금 나뉜다. 상태 로직인 Bloc은 bloc_testmocktail로 이벤트에 따른 상태 전이를 검증한다.

group(HomeBloc, () {
  group('when started event is added', () {
    blocTest<HomeBloc, HomeState>(
      'then should be loading and success',
      build: () {
        when(() => mockFetchHomeSummaryUseCase()).thenAnswer(
          (_) async => homeSummary,
        );
        return HomeBloc(
          fetchHomeSummaryUseCase: mockFetchHomeSummaryUseCase,
        );
      },
      act: (bloc) => bloc.add(const HomeStarted()),
      expect: () => [
        const HomeState(status: HomeStatus.loading),
        HomeState(status: HomeStatus.success, summary: homeSummary),
      ],
    );
  });
});

여기서 Bloc은 UseCase에만 의존한다. repository나 datasource를 직접 부르지 않기 때문에, mock으로 끊어야 할 경계가 UseCase 하나로 고정된다. 이 단순함이 테스트를 훨씬 편하게 만든다.

 

렌더링, 그러니까 View와 Widget 쪽은 다르게 접근한다. UI 회귀는 골든 테스트로 고정하고, 순수한 렌더링·상호작용 위젯 테스트는 남발하지 않는다. 위젯 테스트는 "비즈니스 로직과 UI의 연결 계약"을 확인할 때만 제한적으로 쓴다. 이를테면 재시도 버튼을 눌렀을 때 정말 이벤트가 추가되는지 같은 것이다.

testWidgets('when retry button is tapped then should add reload event', (
  tester,
) async {
  when(() => bloc.state).thenReturn(state);
  when(() => bloc.add(any())).thenReturn(null);

  await tester.pumpWidget(_buildView(bloc));
  await tester.tap(find.byKey(const ValueKey('retry_button')));

  verify(() => bloc.add(const HomeReloadRequested())).called(1);
});

화면 전체를 담당하는 View는 골든 테스트로 렌더링 결과를 통째로 고정한다. 이때 폰트와 테마를 고정하지 않으면 결과 이미지가 환경마다 달라져서 테스트가 의미를 잃는다.

setUpAll(() async {
  await GoldenTestUtils.ensureFontsLoaded();
});

testGoldens('ProfileView success state', (tester) async {
  await tester.pumpWidgetBuilder(
    const ProfileView(),
    wrapper: materialAppWrapper(theme: appTheme),
  );

  await screenMatchesGolden(tester, 'profile_view_success');
});

왜 이렇게 나누느냐고 물으면, 답은 하나다. 화면은 자주 바뀐다. 자주 바뀌는 화면에 렌더링 위젯 테스트를 촘촘히 붙여두면, 정작 로직은 멀쩡한데 테스트가 먼저 깨진다. 그게 반복되면 사람들은 테스트를 지운다. 그래서 각 계층은 자기 책임만 테스트하고, 옆 계층은 mock으로 끊는다. 그래야 테스트가 빠르고, 결정적이고, 깨질 이유가 좁아진다.

 

개인적으로 아래와 같이 계층별 테스트 전략을 잡는게 좋다고 생각한다.

 

Domain
- UseCase / 순수 비즈니스 규칙
- Repository mock

Data
- DTO mapping / error mapping / persistence
- Datasource stub

Presentation
- Bloc state transition
- UseCase mock

View
- Golden 중심
- interaction 계약만 Widget test

계층을 엮는 통합 테스트

단위 테스트만으로는 부족하다. 각 계층이 mock 위에서 전부 통과해도, 막상 실제로 연결하면 계약이 어긋나는 일이 생긴다. 잘못된 매핑이라든지, 예상하지 못한 상태 전이 같은 것들이다. 통합 테스트는 mock을 걷어내고 실제 구현끼리 연결해서, 이 경계들이 정말 맞물리는지를 확인한다.

 

나는 이걸 아래에서 위로 슬라이스를 넓혀가며 생각한다. Data 쪽에서는 repository 구현체와 실제 datasource를 연결하되 네트워크만 stub으로 대체해서, 단위 테스트에서 가정했던 응답이 실제 파싱 경로에서도 성립하는지를 본다.

 

(여기서 datasource를 stub으로 두었다는 것은 실제 서버와의 계약까지 검증했다는 뜻은 아니다. 서버의 schema 변경까지 잡으려면 별도의 contract test나 staging 환경 검증이 필요하다.)

 

거기서 한 단계 위로 올라가면 UseCase와 실제 repository를 엮어서, 유스케이스가 기대한 대로 도메인 결과를 조립하는지를 본다. 더 위로 가면 Bloc부터 datasource까지의 세로 슬라이스 전체를 연결해서, 이벤트 하나가 실제 유스케이스를 거쳐 상태 전이로 끝까지 이어지는지를 확인한다.

 

 

 

이 세로 슬라이스 통합은 단위 bloc_test와 겉모습은 비슷하지만, UseCase를 mock으로 끼우지 않고 진짜로 돌린다는 점이 다르다.

group('Home vertical slice', () {
  group('when HomeStarted is added', () {
    blocTest<HomeBloc, HomeState>(
      'then should load summary through real use case',
      build: () {
        // UseCase 는 진짜, 그 아래 datasource 만 stub 으로 대체한다.
        final repository = UserRepositoryImpl(dataSource: stubUserDataSource);
        return HomeBloc(
          fetchHomeSummaryUseCase: FetchHomeSummaryUseCase(
            userRepository: repository,
            rewardRepository: RewardRepositoryImpl(dataSource: stubRewardDataSource),
          ),
        );
      },
      act: (bloc) => bloc.add(const HomeStarted()),
      expect: () => [
        const HomeState(status: HomeStatus.loading),
        isA<HomeState>().having((s) => s.status, 'status', HomeStatus.success),
      ],
    );
  });
});

마지막으로 integration_test로 실제 위젯 트리를 띄우고, 화면 진입부터 데이터 로딩, 상태별 렌더링, 다음 화면 이동까지 사용자 흐름을 재생해본다.

testWidgets('user sees home summary after launching the app', (tester) async {
  await tester.pumpWidget(TestApp(overrides: stubbedNetwork));

  expect(find.byType(AppLoadingIndicator), findsOneWidget);

  await tester.pumpAndSettle();

  expect(find.byKey(const ValueKey('home_summary_card')), findsOneWidget);
});

이 통합·E2E 영역은 셋업과 격리의 난이도가 가장 높다. 그래서 전부 다 짜는 것보다는 핵심 흐름 위주로 선별해야 하고, 단위 테스트로 이미 잡을 수 있는 결함을 통합 테스트로 중복해서 검증하지도 않는다.

커버리지와 타협하기

계층마다 테스트를 갖추다 보면 자연스럽게 드는 질문이 있다. 그래서 이 테스트들이 코드를 충분히 덮고 있나?

이걸 재는 게 커버리지다. Flutter에서는 이렇게 뽑는다.

flutter test --coverage
# coverage/lcov.info 가 생성된다. HTML 리포트로 시각화하려면:
genhtml coverage/lcov.info -o coverage/html

이렇게 하면 어떤 파일과 라인이 아예 테스트를 거치지 않는지를 볼 수 있다. 사각지대를 찾는 데는 확실히 유용하다.

 

그런데 여기서 흔히 빠지는 함정이 있다. 라인 커버리지 100%가 안정성을 보장한다는 착각이다. 그렇지 않다. 커버리지는 그 라인을 지나갔다는 것만 말해줄 뿐, 그게 올바른지는 말해주지 않는다. 라인은 밟았지만 assertion이 허술하면, 숫자는 100%인데 실제로는 아무것도 지키지 못한다.

 

반대 방향의 문제도 있다. 100%를 채우겠다고 모든 곳에 테스트를 우겨넣는 것은 오히려 해롭다. 애니메이션, 네트워크 이미지 로딩, 단순한 스타일이나 레이아웃 같은 것들은 검증할 가치는 낮은데 유지 비용만 크다. 여기에 테스트를 강제하면 테스트가 개발의 병목이 된다.

골든 테스트도 마찬가지다. 남발하면 화면이 바뀔 때마다 계속 깨져서, 회귀를 막는 방패가 아니라 그냥 잡음이 된다.

 

그래서 나는 커버리지를 채워야 할 목표로 두지 않는다. 내가 권하는 건 100%가 아니라 "의미 있는 커버리지"다. 비즈니스 로직인 Domain과 Bloc은 촘촘하게 덮고, UI 표현이나 외부 SDK, 비결정적인 영역은 느슨하게 둔다. 계층마다 기대치를 다르게 두는 것이다. 숫자는 측정하되 맹신하지 않는다. 우리는 커버리지와 어느정도의 타협점을 찾아야한다.

 

TDD를 통한 ADD, 이를 위한 마크다운 Context 주입하기

내가 가장 하고 싶은 말은, 정해진 정답이 없다는 것이다. 다만, 내 경우에는 마크다운 문서를 꽤 잘게 나눴다.

core/       # 프로젝트 무관 공통 규칙 (파일 책임, 의존성 주입)
patterns/   # 반복되는 UI·상태·테스트 패턴
playbooks/  # 작업 종류별 실행 절차 (기능 추가/버그 수정/테스트 추가/리팩토링)
skills/     # Flutter 특화 규칙 (Page/View/Widget 구분 등)
project/    # 프로젝트 고유 명칭·예외 오버레이 (공통과 충돌 시 우선)

이렇게 나눈 이유는, 규칙을 한곳에 몰아넣지 않고 AI가 필요한 순간에 필요한 것만 집어들게 하고 싶어서였다. 문서 안에서도 규칙을 세 층으로 나눴다. 반드시 지켜야 할 Required, 그저 선호되는 Preferred, 절대 하면 안 되는 Forbidden이다. 예컨대 테스트 규칙은 이런 식으로 적혀 있다.

## Required
- 비즈니스 로직과 동작 변경 전에는 실패 테스트를 먼저 작성한다.
- UI 회귀 보호는 기본적으로 golden test 로 수행한다.

## Forbidden
- 테스트 없이 production code 부터 작성한다.
- 버그 수정 후 재현 테스트 없이 종료한다.

그리고 AI가 작업을 시작할 때는, 어떤 문서를 적용했고 핵심 규칙이 무엇인지를 먼저 요약해서 출력하게 했다.

적용 문서:
- ARCHITECTURE.md
- playbooks/add_feature.md
- patterns/screen.md

핵심 규칙:
- 비즈니스 로직은 TDD 먼저
- Bloc only, Cubit 금지
- 작업 중 테스트는 변경 범위 우선, 커밋 전 전체 테스트

 

작업 종류에 따라 어떤 문서를 읽어야 하는지도 라우팅으로 정해뒀다. 이렇게 해두면 AI가 "일단 코드부터" 짜는 대신, 매번 규칙을 먼저 읽고 TDD 단계를 밟도록 강제된다.

 

어디까지나 나의 경우다. 문서를 이렇게 잘게 쪼갤지 아니면 한 파일에 모을지, 강제의 수위를 어디까지 둘지는 팀과 프로젝트와, 심지어 쓰는 에이전트에 따라 다르다. 다른 팀은 훨씬 적은 규칙으로 충분할 수도 있고, 나와는 전혀 다른 축으로 나눌 수도 있다. 중요한 건 형식이 아니라 효과다. AI가 실제로 TDD 단계를 건너뛰지 않는가, 아키텍처를 무너뜨리지 않는가.

 

그리고 이런 하네스는 한 번에 완성되지 않는다. AI가 규칙을 어기는 지점을 발견할 때마다 문서를 고쳐가며 키워나가야 한다. 시작은 작아도 된다. 가장 자주 깨지는 규칙 하나를 문서로 강제하는 것부터 시작하면 된다. 그러니 이 글도 "이렇게 하라"는 정답으로 읽히지 않았으면 한다. 각자 자신의 팀에 맞는 하네스를 스스로 발견해 나가야 한다.

 

테스트를 언제 얼마나 돌리느냐의 문제도 짚어두고 싶다.

 

전체 테스트는 느리다. 그리고 느린 테스트는 TDD의 생명인 빠른 피드백 루프를 죽인다. 그래서 사이클을 도는 동안에는 지금 손댄 변경 범위, 그러니까 해당 feature나 package의 테스트만 돌린다. 즉각적인 빨간불과 초록불을 얻기 위해서다.

 

전체 테스트는 작업 단위가 끝났을 때 돌린다. 커밋하거나 완료를 보고하기 직전에, 전체 테스트와 정적 분석을 함께 실행해서 변경 범위 바깥에서 깨진 게 없는지를 확인한다. 이게 최종 안전망이다. 내 하네스에서는 이걸 아예 명령으로 못 박아뒀다.

# 작업 중 — 변경 범위만 빠르게
dart run tool/parallel_flutter_test.dart test/features/<domain>

# 커밋 또는 완료 보고 전 — 전체 테스트 + 정적 분석
dart run tool/parallel_flutter_test.dart
flutter analyze

 

문서나 주석만 고친 경우처럼 검증이 필요 없는 예외도 명시해뒀다.

원칙은 한 줄로 요약된다. 좁게 자주, 넓게 한 번. 이 루프가 있어야 에이전트가 빠르게 반복하면서도 전체 안정성을 잃지 않는다.

그러면, 사람은 무엇을 하는가

지금까지 이야기한 이 테스트 체계를 누가 돌리는가.

에이전트가 돌린다.

 

AI는 RED에서 GREEN, REFACTOR로 이어지는 사이클을 반복하면서 하나의 기능을 끝까지 조립한다.

계층마다 놓인 테스트가 각 단계에서 자기 채점 기준이 되어주기 때문에, 사이클이 엉뚱한 데로 발산하지 않고 수렴한다.

게다가 사람이 쓰기 어려워서 미뤄두던 통합·E2E 영역까지 메워주니, 실제로 테스트가 덮는 범위가 넓어진다.

 

그러면 사람은? 사람의 역할은 코드를 한 줄씩 짜는 데서 테스트와 결과를 검증하는 쪽으로 옮겨간다. 사람은 구현이 아니라 테스트를 본다. 이 테스트가 올바른 의도를 담고 있는지, 요구사항을 실제로 만족하는지, 놓친 시나리오는 없는지를 본다.

 

구현에 쓰던 시간이 줄면, 그 시간을 더 가치 있는 곳에 쓸 수 있다. 같은 기간에 더 많은 기능을 안정적으로 내보낼 수도 있고, man-month를 아낄 수도 있고, PM과 함께 "이 제품이 대체 무엇을 해야 하는가"를 더 오래 논의할 수도 있다. 음, 개발자가 AI로 대체된다? 개발자의 역할이 전환된다고 볼 수 있다.

 

물론 이 역할 분담이 성립하려면, 점진적으로 AI가 구현의 영역을 완벽하게 소화해야 한다.

 

마치며

AI가 코드를 만드는 속도는 앞으로도 더 빨라질 것이다. 그렇다고 우리가 더 빠르게 코드를 만드는 것만 고민해서는 안 된다. 더 많은 코드를 만들 수 있게 된 만큼, 무엇이 올바른 코드인지 판단하는 기준 역시 더 중요해진다.

 

나는 그 기준을 만드는 방법 중 하나로 TDD를 선택했다. 테스트를 먼저 작성하고, 실패를 확인하고, 최소한의 구현으로 통과시킨 뒤 다시 구조를 다듬는 과정은 AI에게도 꽤 좋은 피드백 루프가 된다. 여기에 프로젝트의 규칙과 작업 절차를 담은 하네스를 더하면, AI가 단순히 코드를 생성하는 것을 넘어 일정한 개발 과정 안에서 움직이게 만들 수 있다.

 

물론 테스트가 모든 것을 보장하지는 않는다. 잘못된 요구사항으로 작성된 테스트는 잘못된 구현을 오히려 단단하게 고정할 수도 있고, 테스트만으로 아키텍처나 성능, 플랫폼 특성까지 모두 판단할 수도 없다. 그래서 AI가 구현을 더 많이 담당할수록 무엇을 만들어야 하는지 정의하고, 그 결과가 정말 올바른지 판단하는 책임은 오히려 사람에게 더 크게 남는다.

 

결국 내가 생각하는 AI 시대의 TDD는 단순히 테스트 코드를 많이 작성하는 방법론이 아니다. 빠르게 움직이는 AI에게 방향과 종료 조건을 제공하는 개발의 피드백 루프다.

 

어떤 테스트를 작성하고, 어떤 규칙을 강제하고, 어디까지 AI에게 맡길지는 프로젝트마다 다를 것이다. 나 역시 지금의 하네스를 계속 고쳐나가고 있다.

 

AI가 코드를 얼마나 잘 작성하는가보다, 우리가 AI에게 무엇을 ‘맞다’고 가르칠 수 있는가. 앞으로는 그게 더 중요한 개발자의 역량이 되지 않을까.

'잡담' 카테고리의 다른 글

블로그 바꿀라구요  (0) 2026.08.06
경력 1년미만 개발자의 채용 프로세스 경험  (0) 2026.08.06