[TDD] TDD와 AI
테스트
소프트웨어에서의 테스트
소프트웨어서 테스트는 코드가 내가 의도한 대로 동작하는지를 확인하는 작업이다. 가령 어떤 메소드가 존재하고, 메소드의 인자(input)에 값을 채워주면 결과(output)을 반환한다. 개발자는 이 메소드가 어떤 input을 넣으면 output이 반환되는지를 알기에 이를 기반으로 테스트를 수행한다.
만약 내부 구현이 잘못되었다면 의도하지 않은 결과를 받게 될 것이다. 잘못된 결과가 나오기 때문에 개발자는 내부 구현을 수정하게 되고, 올바른 결과를 도출해낸다. 올바르게 동작함을 통해서 검증이 완성되고, 이 과정이 소프트웨어에서 테스트를 하는 목적이다.
코드가 올바르게 동작하는지 검증하는 것은 소프트웨어의 품질을 향상시키는 것이고, 균일한 품질의 소프트웨어는 신뢰성이 올라가기 때문에 시장에서 유저들이 찾게 되는 요소 중 하나이다.
TDD가 풀려는 문제
테스트는 내가 만든 코드가 올바르게 동작하는지를 검증하는 과정이다. 만약 이 과정이 없다면 어떨까? 일단 동작하는 코드를 만들고, 시간이 되면 테스트를 작성하고, 시간이 안 되면 그대로 배포한다. 당장은 잘 돌아가겠지만 고객 문의, 버그 발견 등이 발생하고 계속해서 핫픽스 코드를 생산하게 된다. 안정적이지 못한 코드는 고객 신뢰도가 떨어지게 된다.
TDD는 이 불안정한 흐름을 없애기 위한 방법론이다. 우선 구현하는 것이 아닌 PRD, 요구사항 등에서의 명세를 먼저 정의하고, 명세를 기반으로 한 구현을 한다. 우선 내부적으로 구현이 어떻게 되든 관계없이 명세를 통과시키는 것만으로 안정성이 보장된다.
이미 테스트 코드 명세라는 변하지 않는 인터페이스가 있기 때문에 리팩토링을 통해서 개발자는 내부 구현을 쉽게 변경할 수 있다.
TDD는 이런 흐름을 통해 안정적인 소프트웨어를 만드는 목적을 가지고 있다.
Red-Green-Refactor
TDD에서는 3단계의 사이클을 거치며, 주로 사용되는 용어는 다음과 같다.
| 용어 | 정의 |
|---|---|
| TDD | 테스트를 먼저 작성한 뒤 구현으로 통과시키는 개발 방법론 |
| Red | 실패하는 테스트가 작성된 상태. 컴파일 실패도 포함한다 |
| Green | 테스트가 통과하는 상태. 코드 품질은 무관 |
| Refactor | 테스트 통과를 유지하며 내부 구조를 개선하는 단계 |
| Production code | 실제 동작을 담당하는 코드. 테스트 코드와 구분된다 |
Red
테스트가 반드시 실패해야 한다. 컴파일 에러도 실패의 한 형태로 보며, 미구현된 객체를 테스트 코드에 사용함으로써 컴파일 에러를 발생시킬 수 있다. 객체들의 협력과 조립을 통해 비즈니스 로직을 구성할 수 있고, 객체들은 각각의 역할과 책임을 가진다.
Red 단계에선 어떤 행동(단일 객체)들이 있는지를 정의하는 과정이다. 구현은 하지않고, 행동만을 기술함으로써 테스트 코드를 시작해나간다.
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
class CalculatorTest : FunSpec({
test("1 + 2 = 3") {
Calculator().add(1, 2) shouldBe 3
}
})Green
테스트를 빠르게 통과시키는 방법을 택한다. 내부 구현에 초점을 맞추지 말고, 우선 클래스만 정의하고 테스트를 통과시키는데 주 목표를 둔다.
class Calculator {
fun add(a: Int, b: Int): Int = 3
}Refactor
Green 단계에서 모든 테스트를 통과시키도록 하였고, 테스트 명세는 완성되었다. 이 시점에서 테스트 명세는 변하지 않는 인터페이스가 되며, 개발자는 내부 구현을 완성시킬 수 있다.
class Calculator {
fun add(a: Int, b: Int): Int = a + b
}즉, 기존 동작을 망가뜨리지 않음을 보장하며 내부 구조 개선을 통해 안정적인 테스트 과정을 완성해나갈 수 있다.
TDD와 AI
AI 코딩 에이전트(Claude Code, Cursor, Copilot 등)가 실제 코드를 작성하는 시대가 되면서 AI가 짠 코드를 어떻게 믿을 것인가가 새로운 품질 문제로 떠올랐다. 이미 TDD 가 있기 때문에 이를 이용한다면, AI 코딩 에이전트 도구도 동일하게 움직이게 시킴으로써 안정적인 코드를 작성할 수 있다.
AI의 안정성
AI는 항상 균일한 품질을 내지 않는다. 같은 프롬프트를 두 번 보내도 출력이 다를 수 있고, 모델이 한 번 업데이트되면 어제까지 잘 동작하던 코드 생성 패턴이 오늘 다를 수도 있다. 이런 비결정성은 LLM(Large Language Model) 의 본질적 특성에서 비롯되며, 다음 네 가지 문제점으로 구체화된다.
| 문제점 | 의미 | 사례 |
|---|---|---|
| 확률적 출력 | 같은 입력에도 출력이 미세하게 달라짐 | 메서드 이름이 한 번은 findById, 다음 번은 getById 로 바뀌며 시그니처가 흔들림 |
| 환각(Hallucination) | 존재하지 않는 API/라이브러리/시그니처를 그럴듯하게 생성 | Optional.orElseThrowIfEmpty() 같은 가짜 메서드 호출 |
| 컨텍스트 손실 | 모델이 본 코드만 안다. 시스템 전체를 모름 | 도메인 규칙을 무시한 채 일반적인 구현으로 채워넣음 |
| 모델 회귀 | 모델/프롬프트가 업데이트되면 같은 요청에도 다른 결과 | claude-sonnet-4-6 에서 4-7 로 업데이트 후 코드 스타일/구조 변화 |
이 문제점들엔 다음의 공통점이 있다. AI가 짠 코드가 의도대로 동작하는지 사람이 일일이 눈으로 확인해야 한다는 점이다. 검증이 사람에게 의존되면 AI 도입의 효과는 빠르게 사라지며, 병목이 생긴다.
전통적인 개발 과정에서 이 문제를 풀어온 도구가 바로 자동화된 테스트다. AI가 만든 코드를 사람이 판단하는 대신 테스트라는 수단으로 검증해야 한다. AI 는 비결정적이지만, 테스트는 결정적이므로 균일한 코드를 생산해낼 수 있으며, 사람에 대한 의존도가 떨어지게 된다.
하지만 개발자가 의도한 도메인 규칙에 맞는 코드를 작성한다는 보장은 여전히 없다.
// 안티 패턴: AI 가 짜준 코드를 그대로 받고 테스트 없이 운영에 올린다
class CouponService(
private val couponRepository: CouponRepository,
private val userRepository: UserRepository,
) {
// AI 에게 쿠폰 사용 처리해달라고 한 줄 요청한 결과
fun useCoupon(userId: Long, couponId: Long) {
val coupon = couponRepository.findById(couponId).get()
val user = userRepository.findById(userId).get()
// AI 는 사용한 쿠폰을 USED 로 바꾸고 유저에게 포인트를 준다고 추정
// 하지만 도메인에서는 사용된 쿠폰이면 예외, 만료된 쿠폰이면 거부가 규칙
// AI 는 이 컨텍스트를 모른 채 일반적인 구현을 생성함
coupon.status = CouponStatus.USED
user.point += coupon.discountAmount
}
}이 코드는 컴파일도 통과하고 겉보기에는 합리적이지만, 이미 사용된 쿠폰을 다시 사용해도 통과하고 만료된 쿠폰도 그냥 적용된다. AI 는 단순히 흔히 쓰는 쿠폰 사용 로직을 생성했을 뿐, 우리 도메인의 불변식을 알지 못한다. 테스트 없이 이 코드를 받아들이면 버그는 운영 환경에서 사용자 클레임을 통해 발견된다.
TDD로 AI 통제
AI에게 코드를 작성시킬 때 가장 강력한 통제 수단은 테스트를 먼저 정의하는 것이다. TDD의 Red 단계가 이 역할을 한다. 사람이 도메인 규칙을 테스트로 기술하고, AI는 그 테스트를 통과시키는 구현만 만들면 된다. 테스트는 변하지 않는 명세이고, AI 의 출력은 그 명세 위에서 로직을 구성한다.
각 단계의 책임은 다음과 같이 명확히 갈린다.
| 단계 | 담당 | 무엇을 |
|---|---|---|
| Red | 사람 | 도메인 규칙·불변식·엣지 케이스를 테스트 코드로 표현 |
| Green | AI | 그 테스트만 통과시키는 최소 구현 생성 |
| Refactor | 사람과 AI | 테스트가 보호하는 상태에서 내부 구조 개선 |
같은 쿠폰 사용 예제에 TDD 를 적용하면 흐름이 완전히 달라진다. 먼저 사람이 도메인 규칙을 테스트로 적는다.
class CouponServiceTest : FunSpec({
test("사용된 쿠폰을 다시 사용하면 예외가 발생한다") {
val coupon = Coupon(id = 1L, status = CouponStatus.USED, discountAmount = 1000)
val service = CouponService(FakeCouponRepository(coupon), FakeUserRepository())
shouldThrow<AlreadyUsedCouponException> {
service.useCoupon(userId = 1L, couponId = 1L)
}
}
test("만료된 쿠폰은 사용할 수 없다") {
val expired = Coupon(
id = 1L,
status = CouponStatus.ACTIVE,
discountAmount = 1000,
expiresAt = ZonedDateTime.now().minusDays(1),
)
val service = CouponService(FakeCouponRepository(expired), FakeUserRepository())
shouldThrow<ExpiredCouponException> {
service.useCoupon(userId = 1L, couponId = 1L)
}
}
test("정상 쿠폰을 사용하면 상태가 USED 로 바뀌고 유저에게 포인트가 적립된다") {
val active = Coupon(id = 1L, status = CouponStatus.ACTIVE, discountAmount = 1000)
val user = User(id = 1L, point = 0)
val service = CouponService(FakeCouponRepository(active), FakeUserRepository(user))
service.useCoupon(userId = 1L, couponId = 1L)
active.status shouldBe CouponStatus.USED
user.point shouldBe 1000
}
})이 테스트 세 개를 AI 에게 통과시키라고 요청하면, AI 는 다음과 같이 도메인 규칙을 반영한 구현을 만들어낸다.
class CouponService(
private val couponRepository: CouponRepository,
private val userRepository: UserRepository,
) {
fun useCoupon(userId: Long, couponId: Long) {
val coupon = couponRepository.findById(couponId)
?: throw CouponNotFoundException(couponId)
// 테스트가 강제한 도메인 규칙: 이미 사용된 쿠폰은 거부
if (coupon.status == CouponStatus.USED) {
throw AlreadyUsedCouponException(couponId)
}
// 테스트가 강제한 도메인 규칙: 만료된 쿠폰은 거부
if (coupon.isExpired()) {
throw ExpiredCouponException(couponId)
}
val user = userRepository.findById(userId)
?: throw UserNotFoundException(userId)
coupon.status = CouponStatus.USED
user.point += coupon.discountAmount
}
}처음 AI 에게 그냥 한 줄로 시켰을 때 빠졌던 도메인 로직들이 전부 채워졌다. 테스트가 명세 역할을 했기 때문이다.