AI 보조 테스트: 유닛 테스트 게이트로 AI 생성 코드에 대한 신뢰를 잡기
Categories:
AI 보조 테스트는 해결하려는 것이 테스트 커버리지가 아니라, 제가 AI가 생성한 코드에 대해 신뢰할 수 있는지에 관한 문제입니다. AI는 세 분 안에 엄청난 코드를 생성할 수 있고, 사람은 두 시간 동안 검토해야 합니다. 세 분 생성, 두 시간 검토는 과장되지 않습니다. 제 방법은 다른 검토 대상을 선택하는 것입니다. 코드를 한 줄씩 읽는 대신 테스트 문서를 읽고, 로컬 개발 환경에서 유닛 테스트로 문을 셋고, 실패한 테스트가 AI의 게으름과 오류를 감시하게 합니다.
소소한 버그가 AI 보조 테스트의 주요 전선입니다
버그는 두 종류입니다: 쉽게 해결되는 버그, 그리고 어렵게 해결되는 버그.
어렵게 해결되는 버그는 흔히 아키텍처나 설계상의 결함에서 비롯되거나, 점차 진화하는 역사적 부채로 이어질 수 있습니다. 하나에 영향이 있는 것 같으면 전체에 영향을 줍니다. 또는 제3자 라이브러리, 운영체제, 컴파일러로 인해 발생할 수 있으며, 더 전문적인 지식과 깊은 탐색이 필요합니다. 이런 복잡한 문제는 이 글의 탐구 범위에 속하지 않습니다.
이 글은 제품 수명주기 내의 소소한 버그에 관심을 두고, AI 보조로 테스트 케이스를 만들어 해결하는 문제를 다룹니다:
- 빠르게 발견할 수 있는 버그
- 수정에 아주 작은 변경만으로 해결되는 저급 버그
- 수정으로 인해 발생한 새로운 버그
- 긴급 수정 중 실수
- 대상 테스트의 비용과 효율성 균형
대부분의 버그는 소소한 버그이지만, 소소한 버그일지라도 심각한 문제를 초래할 수 있습니다. 소소한 버그를 로컬에서 해결하면 이후에 많은 노력을 절약할 수 있습니다.
검토 대상을 코드에서 테스트 문서로 전환하기
저는 가장 대담한 아이디어를 시도해 보았습니다. AI가 요구사항을 전적으로 처리하게 하고, 생성된 코드를 전혀 검토하지 않게 하는 것이었습니다. 마치 Copilot Agent 모드처럼 완전히 자유롭게 맡기는 것이었습니다. 나중에 포기했습니다. 결국 마음에 확신이 없었기 때문입니다. 논리적으로 엄격하고 명확한 이진 프로그램에 마음에 확신이 없다면, 그것은 위/아래 호환성에 대한 신뢰를 잃고 고객의 신뢰를 잃는 것을 의미합니다. 그래서 저는 vibe coding에 대한 사고를 익혔습니다. AI가 생성한 코드에는 반드시 마음에 확신이 있어야 합니다.
문제는 검토 비용이었습니다. AI가 때로는 생성하거나 수정한 코드가 너무 많아, 세 분 생성, 두 시간 검토는 과장되지 않습니다. 테스트 코드 작업량은 일반적으로 비즈니스 코드보다 더 많으며, 능동적인 개발자의 전달 성과 평가에 포함되지 않을 수 있습니다. 이것이 많은 사람이 테스트를 능동적으로 작성하지 않는 이유 중 하나입니다. 하지만 AI 보조자는 저보다 훨씬 똑똑합니다는 사실입니다. 거부하는 것보다 게이트를 설정하는 것이 낫습니다.
검토 대상을 코드에서 테스트 문서로 전환하는 것은 이 딜레마를 우회하는 핵심 방법입니다. 비즈니스 코드와 달리 AI가 생성한 테스트 코드는 일반적으로 매우 구체적인 시나리오를 설명하므로 검토하고 검증하기 쉽습니다. 많은 테스트는 한 줄씩 읽지 않아도 됩니다. 오류가 발생할 때까지 자세히 살펴보면 됩니다. 완전히 AI 코드를 검토하지 않는다고 말하는 것은 과장된 표현일 수 있지만, 잘 짜인 테스트 문서를 바탕으로 모든 코드 줄이 인간의 검토를 필요로 하지 않음을 증명할 수 있습니다.
---
title: 코드 검토에서 테스트 문서로
---
flowchart TB
A[AI가 대량의 코드 생성] e1@--> B[한 줄 검토 비용이 높음]
B e2@--> C{검토할 것은 무엇인가}
C e3@-->|코드| D[두 시간 검토, 마음에 확신 없음]
C e4@-->|테스트 문서| E[검토하기 쉽고 빠르게 검증 가능]
E e5@--> F[유닛 테스트 게이트]
F e6@--> G[실패한 테스트가 문제를 드러냄]
classDef risk fill:#FFF3E0,stroke:#E65100,color:#E65100;
classDef ok fill:#E8F5E9,stroke:#2E7D32,color:#1B5E20;
classDef animate stroke:#EF6C00,stroke-width:2px,stroke-dasharray: 9\,5,stroke-dashoffset: 900,animation: dash 25s linear infinite;
class D risk;
class E,F,G ok;
class e1,e2,e3,e4,e5,e6 animate;방법론 기반: Six Sigma와 DMAIC
Six Sigma는 데이터 기반 품질 개선 방법론이며, 핵심 목표는 프로세스 변이를 줄여 불량률을 백만 분의 3.4, 즉 6σ 수준으로 제어하는 것입니다1.
이 방법론은 실행 가능한 DMAIC 흐름을 제공합니다:
- Define 정의: 요구사항 분석, 데이터 모델로 변환, 데이터 구조와 인터페이스 설계
- Measure 측정: 테스트 모니터링, 커버리지, 성능, 보안
- Analyze 분석: 테스트와 비즈니스 코드 분석
- Improve 개선: 테스트와 비즈니스 코드 개선
- Control 통제: 자동화 테스트 게이트
함께 제공되는 기술 도구에는 linter, 정적 검사, 동적 검사 및 테스트 프레임워크가 포함됩니다. 코드 품질 지표도 많지만, 복잡도 지표(순환 복잡도, 인지 복잡도, 중첩 깊이, 클래스 결합도), 품질 지표(중복률, 함수 길이, 매직 넘버), 그리고 테스트 지표(단위 테스트 커버리지, 통합 테스트 통과율, 테스트 유효성, 예외 처리 커버리지)가 있습니다.
방법론과 도구는 문제를 발견하고 해결하는 데 도움이 되지만, 단기간 동안에는 전달 비용을 약간 높이고 효율을 낮출 수 있습니다. 제한된 자원에서 장기간 지속적으로 이익을 볼 수 있는 방법을 선택해야 합니다. 제 선택은 개발 과정에서 유닛 테스트를 보조 도구로 사용하는 것입니다.
AI가 서로 다른 역할 간 언어로의 변환에서 이점을 얻어, 개발자가 TDD, BDD, ATDD의 장점을 융합하고 빠르게 테스트 표와 테스트 코드를 생성하여 전달 품질을 높입니다.
테스트 주도 개발: TDD/BDD/ATDD와 테스트 가능한 코드
세 가지 테스트 주도 개발 방식의 차이는 다음과 같습니다:
| 차원 | TDD | BDD | ATDD |
|---|---|---|---|
| 구동 | 단위 테스트 | 사용자 행동 시나리오 | 인수 조건 |
| 참여자 | 개발자 | 비즈니스 + 개발 | 비즈니스 + 테스트 + 개발 |
| 테스트 계층 | 단위 테스트 | 통합/엔드투엔드 테스트 | 인수 테스트 |
| 도구 예시 | JUnit, pytest | Cucumber, SpecFlow | FitNesse, Robot Framework |
| 일반적인 산출물 | 코드 커버리지 보고서 | 실행 가능한 사용자 시나리오 문서 | 인수 테스트 통과율 |
| 언어 | assert add(2,3) == 5 | 자연어 시나리오 (Given-When-Then) | 비즈니스 언어 (인수 조건 표) |
테스트 가능한 코드에는 네 가지 핵심 원칙이 있습니다: 단일 책임(각 함수는 한 일만 함), 의존성 주입(의존성을 매개변수로 전달), 인터페이스 분리(입력/출력 타입이 명확), 상태 관찰 가능(내부 상태를 검증하는 쿼리 인터페이스 제공).
AI에게 전달하는 프롬프트에서 저는 확장 가능, 단일 책임, 의존성 주입을 직접 작성할 것을 권장합니다. 저는 AI가 함수 길이와 파일 길이를 제어하게 하려 했지만, 그 따름 여부는 좋지 않았습니다. 하지만 단일 책임과 의존성 주입으로 바꾸면 생성 코드가 훨씬 예상대로 나왔습니다. 지나친 최적화는 악의의 근원이며, 기본 데이터 구조와 인터페이스를 생성할 때는 AI가 확장성을 우선하도록 지시하면 됩니다.
테스트하기 쉬운 코드 예시:
// user.h
typedef struct {
int id;
char name[50];
int age;
} User;
// 内存存储接口抽象
typedef struct {
User* (*get_user)(int id);
int (*save_user)(const User* user);
} UserStorage;
// 创建用户 (依赖注入存储接口)
User* create_user(const char* name, int age, UserStorage* storage) {
if(strlen(name) >= 50 || age <= 0) return NULL;
User* new_user = malloc(sizeof(User));
new_user->id = generate_user_id();
strncpy(new_user->name, name, 49);
new_user->name[49] = '\0';
new_user->age = age;
if(storage->save_user(new_user) != 0) {
free(new_user);
return NULL;
}
return new_user;
}
테스트하기 어려운 코드 예시:
// 紧耦合数据库操作
void save_user_to_db(User* user) {
MYSQL conn;
mysql_init(&conn);
if(!mysql_real_connect(&conn, "localhost", "root", "pass", "test", 0, NULL, 0)) {
log_error("DB connection failed"); // 难以测试的日志依赖
exit(1); // 直接退出影响测试执行
}
// 拼接 SQL 并写入数据库
}
좋은 코드는 은은하게 작용하고, 나쁜 코드는 눈에 띕니다. 테스트하기 쉬운 함수는 반환값이 명확하고, 오류 처리가 표준화되어 있으며, 매개변수가 명확하고, 부작용이 격리되며, 의존성 주입을 지원합니다.
AI가 제공하는 코드는 때때로 최선의 모범 사례가 아니며, 스타일에도 문제가 있습니다. 비록 AI는 종종 문제를 해결하는 데 늘 힘을 쓰지만, 실제 프로젝트는 한 번에 하나의 상품을 사는 것이 아니므로 이후 유지 관리를 고려해야 합니다. AI에게 코드를 생성할 때도 마찬가지로 가독성 우선, 확장성, 단일 책임, 의존성 주입을 강조해야 합니다. 코드 스타일 문제에 대한 논의는 더 이상 진행하지 않으며, Google의 코드 스타일 가이드2를 참조하면 됩니다.
실제 개발의 함정과 균형
요구사항이 여러 역할 간에 전달될 때 여러 차례의 언어 변환을 거치며, 매번 의미 왜곡이 발생할 수 있습니다. 예를 들어 고객이 “중점적으로 확인"한다고 말했을 때 백엔드가 “단일 인터페이스가 반환한다"로 이해하여 페이지네이션을 수행하지 않았을 수 있습니다. AI가 여러 역할의 언어 템플릿을 생성하면 인간의 변환 중 정보 손실을 줄일 수 있습니다.
충분히 생각하지 못하는 것은 많은 버그의 근본 원인입니다. AI가 테스트 시나리오를 설계할 때 예상대로 작업을 수행할 뿐만 아니라, 의도치 않게 발전 아이디어를 제공하고 발표 담당자가 고려하지 못했던 세부 사항을 보완할 수 있습니다. 모든 역할 간 언어 변환 과정에서 AI와 대화하여 보완할 수 있습니다. Markdown은 AI가 가장 잘 이해하는 언어이므로, 입력과 출력은 가능한 한 Markdown 형식을 사용해야 합니다.
테스트 코드에는 몇 가지 주의할 점이 있습니다: 테스트 격리, 모의 의존성의 정확성, 단언의 유효성, 경계값, 예외 처리, 성능, 동시성. AI는 게으를 수 있으며, 모든 시나리오를 한 번에 생성하지 않을 것입니다. 한 번에 모든 테스트 코드를 생성하는 것은 모델에게 상당히 큰 요구이며, 여러 번 나누어 생성할 수 있습니다.
Cursor의 Claude 모델을 예로 들어, AI가 테스트 코드 쪽 주의사항을 상당히 잘 처리했지만, 때때로 테스트 격리와 예외 시나리오를 잊을 수 있습니다. 한 줄의 user rule을 추가하면 게으름을 약간 줄일 수 있습니다: 테스트 케이스를 생성할 때 테스트 문서를 준수하고, 테스트 격리를 준수하세요. 그러나 모든 시나리오를 한 번에 생성하지는 할 수 없습니다. 정상 경로, 예외 경로, 경계값, 성능, 동시성 시나리오는 여러 번 대화를 통해 보완해야 합니다.
실현 가능한 AI 보조 테스트 워크플로
설계 문서를 결합하면 시나리오에서 테스트로 이어지는 파이프라인을 구축할 수 있습니다:
---
title: AI 보조 테스트 워크플로
---
flowchart TB
A[설계 문서] e1@--> B[인수 테스트 시나리오 생성]
B e2@--> C[데이터 구조와 인터페이스 생성]
C e3@--> D[엔드투엔드 테스트 표 생성]
D e4@--> E[테스트 코드 생성]
D e5@--> F[비즈니스 코드 생성]
E e6@--> G[로컬 게이트: 테스트 통과]
F e7@--> G
G e8@--> H{실패한 테스트가 존재하는가}
H e9@-->|예| E
H e10@-->|아니오| I[제출]
classDef input fill:#E3F2FD,stroke:#1565C0,color:#0D47A1;
classDef gen fill:#E8F5E9,stroke:#2E7D32,color:#1B5E20;
classDef gate fill:#FFF3E0,stroke:#E65100,color:#E65100;
classDef animate stroke:#EF6C00,stroke-width:2px,stroke-dasharray: 9\,5,stroke-dashoffset: 900,animation: dash 25s linear infinite;
class A input;
class B,C,D,E,F gen;
class G,H,I gate;
class e1,e2,e3,e4,e5,e6,e7,e8,e9,e10 animate;각 단계의 프롬프트는 다음과 같이 구성할 수 있습니다: 제품 설계 문서를 기반으로 실행 가능한 인수 테스트 시나리오를 생성합니다. 테스트 시나리오를 기반으로 데이터 구조와 인터페이스를 생성합니다. 테스트 시나리오와 인터페이스를 기반으로 엔드투엔드 테스트 표를 생성합니다. 인터페이스와 테스트 표를 기반으로 테스트 코드를 생성합니다. 테스트 시나리오를 기반으로 비즈니스 코드를 생성하고 테스트가 통과하도록 합니다.
여기에는 잠재적 위험이 있습니다: AI는 테스트 코드를 변경할 때 비즈니스 코드를 변경할 수도 있고, 그 반대도 마찬가지입니다. 둘 다 오류가 있을 경우 AI는 혼란스러워하며, 대화가 늘어날수록 테스트 코드와 비즈니스 코드는 점점 더 혼란스러워질 것입니다. 이 경우 인간의 개입이 필요합니다.
과도하게 광범위한 프롬프트는 반대 사례입니다: AI가 정상, 예외, 경계값, 동시성을 한 번에 모두 다루고 코드를 생성하라고 요청하는 것입니다. 이러한 요청은 중단하기 쉽습니다. 올바른 방법은 Cursor에서 시나리오를 나누고, 여러 요청으로 나누어 생성하는 것입니다. 성능 테스트에는 많은 데이터가 필요하므로 시스템 테스트 단계로 옮기고, 먼저 AI가 임의 데이터 생성기를 작성하게 한 다음, 생성된 데이터를 사용하여 테스트를 구성합니다.
통합 테스트에는 테스트 환경을 구축해야 합니다. 일부 테스트는 외부 도구에 의존할 수 있습니다. 예를 들어 micro-isolation으로 iptables와 ipset에 의존하는 경우 Docker로 테스트 컨테이너를 구축할 수 있습니다. 원칙은: 사람이 테스트하는 방식대로 AI가 동일한 자동화 단계를 구현하도록 지시하세요. 보안 테스트에 대해서는 현재 경험과 분석에 의존하며, 아직 완성된 AI 보조 경험이 없습니다.
요약
이 워크플로는 AI를 사용하지 않을 때보다 더 많은 문서와 테스트 코드를 생성하므로, 모든 글자를 검토할 수는 없습니다. 하지만 문서가 코드보다 검토하기 쉽습니다. 특히 테스트 표는 테스트 생성에 정확한 지침을 제공합니다. 실제 개발에서 저는 때때로 실패한 테스트만 검토하고, 실패한 테스트를 디버깅하여 문제를 확인합니다.
개선 방향은 세 가지입니다: 설계 단계의 하위 흐름에 교차가 있습니다. 인수 테스트 시나리오는 데이터 구조와 인터페이스에 의존할 수 있습니다. 엔드투엔드 테스트 표는 개발자가 직접 관심을 가져야 합니다. 투표 전에 비즈니스 코드 세부 사항을 포함한 자세한 테스트 시나리오를 다시 생성해야 합니다.
최종적인 이익은 테스트 커버리지 숫자가 아니라 신뢰에 있습니다. 유닛 테스트라는 로컬 게이트를 통해 AI가 생성한 코드에 대해 마음에 확신을 품을 수 있게 되었고, 자의식 없이 vibing으로 생성된 코드를 프로덕션에 보내는 것을 멈출 수 있습니다.
Google Style Guide는 Google가 공개한 코드 스타일 가이드입니다. ↩︎