AI와 TDD 방법론으로 개발하는 과정에서 '좋은 테스트 코드'란 무엇인지 주관적인 고민을 공유합니다.
TDD(Test-Driven Development)는 테스트 코드를 먼저 작성하는 개발 방식입니다. 테스트가 실패하는 것을 확인한 뒤, 그 테스트를 통과시키는 최소한의 코드를 구현합니다. 이후 테스트와 구현 코드를 리팩토링하는 사이클을 반복하는 방식입니다.
이 글은 TDD 방법론을 기술적으로 깊게 다루기보다, AI 시대에서 '테스트' 가 어떤 가치를 갖는지 에 대한 주관적인 고민과 결론을 공유하는 데 무게를 둡니다.
이전에는 요구사항 명세를 직접 뜯어보며 예측 가능한 범주 안에서 꼼꼼하게 테스트를 설계하는 데 많은 에너지를 쏟았습니다. 어떤 경계값을 빠뜨렸는지, 어떤 실패 케이스가 누락됐는지를 손으로 채워가는 과정이 곧 설계의 일부였습니다.
하지만 Gen AI를 사용했을 때, 회원가입 기능의 예외 케이스를 TDD로 작성해줘 라는 프롬포트 입력 한 번에 수십 개의 정밀한 테스트 케이스가 쏟아집니다.
// AI 가 한 번에 생성해 준 테스트 목록 (일부)
@Test fun `loginId 가 비어 있으면 예외가 발생한다`() { /* ... */ }
@Test fun `loginId 가 20자를 초과하면 예외가 발생한다`() { /* ... */ }
@Test fun `password 에 특수문자가 없으면 예외가 발생한다`() { /* ... */ }
@Test fun `email 형식이 잘못되면 예외가 발생한다`() { /* ... */ }
@Test fun `이미 존재하는 loginId 면 예외가 발생한다`() { /* ... */ }
표면적인 완성도는 높을 수 있습니다. 문제는 그 다음입니다. AI가 작성한 테스트 코드와 구현 클래스를 검토하면서 이런 생각이 들었습니다.
케이스 도출이 쉬워지면서 역설적으로 '좋은 아키텍처'에 대한 갈증은 더 커졌습니다. TDD는 단순히 버그를 잡는 수단이 아니라, 설계의 적절성을 검증하고, 단일 책임 원칙을 끊임없이 의심하게 하는 시키는 강력한 설계 도구가 되기 때문입니다.
좋은 테스트의 기준을 다섯 글자로 요약한 것이 FIRST입니다.
다섯 글자 중 가장 자주 무너지는 건 마지막 T 입니다. "구현부터 하고 테스트는 나중에 붙이자" 는 패턴은 결국 이미 짠 코드를 통과시키기 위한 테스트를 양산합니다. AI가 코드를 먼저 뱉어주는 현재 개발 시장에서 T의 가치는 오히려 더 커집니다. Red 없이 Green 으로 시작된 테스트는 회귀 방지를 해 주지 못하기 때문입니다.
테스트 커버리지를 채우기 위해 모든 코드에 테스트를 강제하기 보다는 변경이 잦고 복잡한 비즈니스 로직 위주로 우선순위를 둡니다.
// 1) 단순 조회 — 분기 없음, 정책 변경의 영향 없음
fun getUserInfo(loginId: String): User {
return userRepository.findByLoginId(loginId)
?: throw CoreException(UserErrorType.NOT_FOUND)
}
// 2) 회원가입 — 조건 분기 + 예외 케이스 + 정책 변경 가능
fun signup(
loginId: String,
password: String,
name: String,
birthDate: LocalDate,
email: String,
): User {
if (userRepository.findByLoginId(loginId) != null) {
throw CoreException(UserErrorType.DUPLICATE_LOGIN_ID)
}
if (userRepository.findByEmail(email) != null) {
throw CoreException(UserErrorType.DUPLICATE_EMAIL)
}
val user = User.signUp(loginId, password, name, birthDate, email)
return userRepository.save(user)
}
모든 코드에 테스트를 강제하기보다는, 비즈니스 정책 로직이 들어간 코드에 테스트의 무게를 둡니다. 단순 CRUD 처럼 분기 없는 위임 코드는 깨질 일이 적고, 깨져도 영향이 작기 때문입니다.
TDD를 수행하며 제가 마주한 가장 큰 병목은 검증의 중복이었습니다. 기능적 요구사항을 테스트로 옮길 때, 어느 레이어까지 이 케이스들을 커버해야 할지 기준이 모호했습니다.
같은 비즈니스 요구사항을 API(Interfaces), Service, Domain 레이어 전체에서 반복적으로 테스트하다 보니, 정책 하나가 바뀔 때마다 모든 계층의 테스트 코드를 수정해야 하는 문제가 발생했습니다.
테스트가 안전망이 아니라 오히려 변화의 발목을 잡는 짐이 된 것입니다.
이를 해결하기 위해 요구사항을 기준으로 검증의 책임 레이어를 다시 정리해 보았습니다.
- 요구사항: 회원가입 시 ID는 중복될 수 없다
- 검증 포인트: 이미 존재하는 ID로 가입 요청 시 실패해야 함.
이 포인트를 마주했을 때, 저는 각 레이어의 역할을 다음과 같이 재정의하며 테스트 범위를 좁혔습니다.
| 레이어 | 검증 방식 | 핵심 포인트 |
|---|---|---|
| Domain (Domain Model, Domain Service) | 단위 테스트 | [설계 중심] 비즈니스 규칙이 도메인 객체에 응집되어 있는가? |
| Application(Facede, UseCase) | 통합 테스트 | [기능 중심] 실제 기능의 최종 결과와 흐름을 검증 |
| Interfaces (Controller) | E2E 테스트 | [규격 중심] 사용자 흐름에 따른 HTTP Spec이 지켜졌는가? |
특히 단위 테스트에서는 비즈니스 규칙을 검증하는 것과 더불어, 각 클래스의 역할과 책임을 검증하는, 클래스 설계 관점에서의 지도가 되는 것을 깨달았습니다.
이렇게 책임을 나누고 나니, 정책이 변경되었을 때 "이건 도메인 규칙의 변화니 단위 테스트만 수정하면 되겠구나", "흐름이 바뀌었으니 통합 테스트를 손봐야겠구나" 하는 명확한 판단 기준이 생겼습니다.
테스트 주도 개발 방법론 창시자인 켄트 백님의 CLAUDE.md를 참고해 TDD 사이클을 AI와 직접 경험해보았습니다.
무엇을 만들지 프롬포트로 작성합니다.
회원가입 기능을 TDD 로 구현한다.
요구사항:
- loginId: 영문/숫자, 4~20자
- password: 8~16자, 영문+숫자+특수문자, 생년월일 미포함
- email: 형식 검증
- birthDate: 만 14세 이상
- loginId / email 은 중복 불가
## 회원가입
### 1.1 도메인 검증
#### 1.1.1 정상 가입
- [ ] 유효한 입력으로 회원가입 시 가입자 id 가 발급되고, 동일 loginId 로 영속 조회가 가능하다
#### 1.1.2 보안 invariant
- [ ] 회원가입 시 비밀번호는 평문으로 저장되지 않는다 (해시 형태로 저장됨)
#### 1.1.3 중복 거부
- [ ] 동일 loginId 로 가입을 시도하면 `CoreException(DUPLICATE_LOGIN_ID)` 가 발생한다
- [ ] 동일 email 로 가입을 시도하면 `CoreException(DUPLICATE_EMAIL)` 가 발생한다
#### 1.1.4 입력 검증 거부
- [ ] 형식이 잘못된 loginId 로 가입을 시도하면 `CoreException(BAD_REQUEST)` 가 발생한다 (영문/숫자 외 문자 / 4자 미만 / 21자 이상)
- [ ] 형식이 잘못된 name 으로 가입을 시도하면 `CoreException(BAD_REQUEST)` 가 발생한다 (2자 미만 / 51자 이상 / 숫자·특수문자·공백 포함)
- [ ] 형식이 잘못된 email 로 가입을 시도하면 `CoreException(BAD_REQUEST)` 가 발생한다 (`@` 없음 / TLD 부족 / 255자 초과)
- [ ] 가입 자격이 없는 birthDate 로 가입을 시도하면 `CoreException(BAD_REQUEST)` 가 발생한다 (만 14세 미만 / 미래 일자)
... 등등
이 시점에 책임이 잘못 묶인 케이스를 골라낼 수 있습니다.
실패 테스트 → 최소 구현 → 통과 → AI와 리팩토링의 과정을 진행합니다.
모든 항목이 체크되면 마지막으로 AI에게 다시 묻습니다.
- 검증한 케이스 외에 엣지 케이스를 놓치지 않았는지 검증해줘
- 의도한 동작을 정확히 검증하는지 검토해줘
TDD는 결국 하나의 방법론입니다. 따라서 정답은 없습니다.
누군가는 Top-down을, 누군가는 Bottom-up을 선호합니다.
AI가 코드를 무한히 생성해 줄 수 있는 시대일 수록, 그 케이스들을 어디에 배치할지 결정하는 개발자의 설계 안목이 중요해지지 않을까 생각합니다.
이번 회고에서는 얻은 결론은,
좋은 테스트란 각자의 위치에서 명확한 이유(Why)를 가지고 존재하며, 변화가 생겼을 때 수정해야 할 곳을 정확히 알려주는 테스트라고 생각합니다.
AI가 쏟아내는 수많은 코드 사이에서, 어떤 테스트가 우리 서비스의 안정성을 지켜줄 '신뢰할 수 있는 코드'인지 선별할 수 있는 나름의 주관이 만드는 것이 중요하다고 생각합니다.