AI 코딩에 확신을 더하다: TDD와 Playwright로 구축한 프론트엔드 통제권
도입: AI시대, 다시 TDD를 꺼내 든 이유
최근 claude, cursor 등 AI를 활용한 코딩이 일상이 되면서 생산성은 비약적으로 상승했습니다. 하지만 그 이면에는 새로운 종류의 불안함이 자리 잡기 시작했습니다. 바로 AI의 할루시네이션 입니다.
분명 "돌아가는 코드"를 내뱉어주지만, 때로는 내가 요구한 의도 그 이상으로 코드를 임의로 해석하거나 보이지 않는 곳에서 미세한 사이드 이펙트를 만들어내기도 합니다. AI가 짜준 복잡한 로직을 검증하기 위해 다시 수동으로 화면을 클릭하며 확인하는 "인적 테스트"에 시간을 쏟다 보면, "과연 이게 진정한 생산성 향상인가?"라는 의구심이 듭니다.
그러던 중, TDD(테스트 주도 개발)의 창시자인 **켄트 벡(Kent Beck)**이 최근 한 영상에서 한 말을 접하게 되었습니다.
"AI가 코드를 생성하는 시대에, 역설적으로 나는 다시 TDD를 적극적으로 도입하기 시작했다. AI가 내뱉는 결과물이 나의 의도를 정확히 반영하는지 확인하고, 통제권을 잃지 않기 위한 가장 강력한 가드레일이 바로 테스트이기 때문이다."
이 문장은 제가 이번 프로젝트에 TDD를 도입하기로 결정한 결정적인 계기가 되었습니다. AI라는 강력한 엔진을 제대로 다루기 위해서는, 그 엔진이 정해진 궤도를 벗어나지 않게 잡아줄 가이드라인이 절실했기 때문입니다.
우리의 목표: AI와 함께 만들되, "테스트로 주도권을 잡기"
AI에게 기능 구현을 맡기는 동시에 Playwright 테스트 코드를 먼저 작성하면, AI의 할루시네이션을 걸러내는 필터이자 정확한 요구사항 명세서가 됩니다. 이를 통해 다음 세 가지를 달성하고자 했습니다.
- 할루시네이션 방지: 테스트가 곧 스펙이므로, AI가 의도와 다른 코드를 생성하면 즉시 실패로 감지됩니다.
- 생산성 극대화: AI가 코드를 생성하고, 미리 작성된 테스트가 자동으로 검증하므로 수동 확인 시간을 최소화합니다.
- 과감한 리팩토링: 테스트라는 안전망이 있으니 AI가 제안한 대규모 변경도 두려움 없이 수용할 수 있습니다.
[RED] 실패의 미학: 코드를 짜기 전 "이야기"를 먼저 쓰다
TDD의 첫 번째 단계인 RED는 코드를 작성하기 전, 우리가 만들 기능이 사용자에게 어떤 가치를 주어야 하는지 스펙(Spec)을 정의하는 과정입니다.
Playwright로 정의하는 사용자 시나리오
프론트엔드에서 TDD를 수행할 때 가장 큰 장점은 "브라우저의 눈"으로 기능을 바라볼 수 있다는 점입니다.
기능을 구현하기 전, Given-When-Then 패턴을 사용해 마치 이야기처럼 시나리오를 먼저 작성했습니다.
💡 시나리오 예시: 챗봇 세션 진입 및 데이터 로드
- Given: 사용자가 챗봇 목록 페이지에 접속해 있다.
- When: 특정 "챗봇 세션" 카드를 클릭하여 상세 페이지로 진입한다.
- Then: 해당 세션에 맞는 히스토리 데이터가 화면에 정확히 렌더링되어야 한다.
코드를 한 줄도 짜기 전의 "의도된 실패"
이 단계에서는 실제 ChatBot.tsx 파일조차 존재하지 않을 수 있습니다.
하지만 테스트 코드는 먼저 실행됩니다.
test('사용자는 챗봇 세션에 진입하여 대화 내역을 볼 수 있다', async ({ page }) => {
// 1. [Given] 챗봇 목록 페이지 접속
await page.goto('/chatbots');
// 2. [When] 특정 세션 클릭
const sessionCard = page.getByRole('button', { name: 'AI 어시스턴트 세션' });
await sessionCard.click();
// 3. [Then] 데이터 로드 확인 (아직 구현되지 않았으므로 여기서 실패!)
const messageList = page.locator('.message-item');
await expect(messageList).toHaveCount(5); // 예상 데이터 5개
});30초의 타임아웃이 주는 교훈
테스트를 실행하면 당연히 빨간색 에러 메시지와 함께 30초의 타임아웃이 발생합니다. 하지만 이 "기다림"의 시간 동안 우리는 생각보다 많은 것을 결정하게 됩니다.
- Selector 정의: 유저가 클릭할 버튼의 이름은 무엇인가?
- 데이터 구조 파악: 화면을 그리려면 어떤 Mocking 데이터가 필요한가?
- 경로 설계: 상세 페이지의 URL 구조는
/chatbots/[id]로 할 것인가?
[GREEN] 최소한의 구현: 가상의 환경에서 빠르게 통과시키기
RED 단계에서 스펙을 정의했다면, 이제는 가장 빠르게 초록불을 켤 시간입니다. 핵심은 백엔드 의존성을 완전히 끊어내고 프론트엔드 로직에만 집중할 수 있는 환경을 만드는 것이었습니다.
API Mocking 전략: test-helpers.ts 설계 원칙
테스트마다 매번 Mock 데이터를 정의하면 코드가 비대해지고 유지보수가 어려워집니다. 따라서 이를 해결하기 위해 test-helpers.ts 파일을 생성하여 다음과 같은 원칙을 적용했습니다.
- 중앙 집중식 Route 관리: 모든 API 엔드포인트를 한 곳에서 관리하여, 실제 API 주소가 바뀌더라도 테스트 코드 수정은 한 곳으로 제한했습니다.
- 상태 주입형 Mock: 외부에서 데이터를 주입할 수 있는 헬퍼 함수를 만들어, 다양한 시나리오(데이터가 없을 때, 에러가 발생할 때 등)를 유연하게 테스트할 수 있도록 했습니다.
- 네트워크 레벨의 가로채기: Playwright의
route기능을 활용하여 Operation Name 기반의 데이터 매핑 구조를 설계했습니다.
🚀 트러블 슈팅: if-else 지옥 탈출하기
처음에는 모든 요청을 if문으로 분기 처리하는 방식을 사용했습니다. 하지만 API가 늘어날수록 관리가 어려워졌고, 이를 해결하기 위해 데이터와 로직을 분리했습니다.
- 가독성: 새로운 테스트 시나리오가 추가되어도 MOCK_DATA 객체에 한 줄만 추가하면 됩니다.
- 유연성: options를 통해 특정 상황(에러 발생, 빈 데이터 등)을 외부에서 주입해 시뮬레이션 할 수 있습니다.
- 격리: 백엔드 API가 아직 개발되지 않았어도, 프론트엔드 개발은 멈추지 않고 계속 진행할 수 있습니다.
// 1. 데이터와 응답값만 모아둔 맵
const MOCK_DATA = {
chatbotContentQuery: { chatbots: { edges: [...] } },
organizationProviderQuery: { currentUserId: 1, ... },
};
// 2. 요청을 가로채서 매핑해주는 핵심 함수
export async function mockCommonGraphQL(page: Page, options = {}) {
await page.route('**/graphql', async (route) => {
const body = route.request().postDataJSON();
const opName = body?.operationName; // GraphQL의 오퍼레이션 명 추출
// 매칭되는 Mock 데이터가 있으면 응답, 없으면 에러 혹은 통과
const responseData = MOCK_DATA[opName];
if (responseData) {
await route.fulfill({
status: 200,
body: JSON.stringify({ data: responseData }),
});
} else {
await route.continue();
}
});
}[REFACTOR] TDD로 잡아낸 성능 디테일
GREEN 단계에서 기능이 동작하는 것을 확인했다면, 이제는 "잘 동작하는가"를 검증할 차례입니다. TDD의 REFACTOR 단계에서 가장 큰 성과를 거둔 부분은 바로 중복 API 호출 디버깅이었습니다.
🚀 트러블 슈팅: 중복 네트워크 요청
실제 진행하는 프로젝트 중 대시보드 페이지 진입 시에 7일간의 데이터를 가져오는 tokenUsageDetailSectionQuery가 네트워크 탭에서 중복 호출되는 현상을 발견했습니다. Relay는 쿼리 + 변수 조합을 캐시 key로 사용하는데, 분명 같은 의도의 요청임에도 불구하고 캐시 미스가 발생하고 있었습니다.
원인은 new Date().toISOString()에 있었습니다.
// 수정 전: 매 호출마다 밀리초가 달라짐
const getInitialDateRange = () => {
const today = new Date();
return {
fromDate: sevenDaysAgo.toISOString(),
toDate: today.toISOString(), // "2026-02-19T05:30:12.347Z"
};
};React Strict Mode나 컴포넌트 리렌더링으로 인해 밀리초 차이로 함수가 다시 호출되면 toDate 값은 ...12.891Z 처럼 미세하게 변합니다. Relay 입장에서는 "전혀 다른 쿼리"가 들어온 것으로 새로운 요청을 보낼 수 밖에 없던 것 이었습니다.
🛠️ 해결: setMilliseconds(0) 정규화
해결책은 의외로 간단했습니다. 캐시 key로 사용될 날짜 값에서 "비결정적인 요소"인 밀리초만 제거하는 것입니다.
// 수정 후: 밀리초를 0으로 고정하여 결정성 확보
const getInitialDateRange = () => {
const today = new Date();
today.setMilliseconds(0); // 정규화의 핵심!
return {
fromDate: sevenDaysAgo.toISOString(), // "2026-02-19T05:30:12.000Z"
toDate: today.toISOString(), // 호출 시점이 같으면 항상 동일한 값
};
};이렇게 밀리초를 0으로 마추면, 같은 초 내에서 발생하는 재호출은 항상 동일한 문자열을 생성합니다. 결과적으로 Relay의 캐시 적중률이 극대화되었고, 불필요한 호출이 완벽하게 제거되었습니다.
AI시대, 통제권을 잃지 않는 유일한 방법
결국 AI 코딩 시대에 TDD는 단순히 버그를 잡는 도구가 아니라, AI가 내뱉는 폭발적인 코드를 개발자의 의도대로 제어할 수 있게 돕는 가장 강력한 핸들이자 가속 페달입니다.
AI의 할루시네이션을 두려워하며 결과물을 일일이 의심하는 대신, Playwright와 TDD라는 명확한 가드레일을 먼저 세워두고 AI의 빠른 속도를 온전히 활용하면서도 밀리초 단위의 미세한 성능까지 놓치지 않는 압도적인 개발 생산성을 경험할 수 있습니다!