[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사람도메인 규칙·불변식·엣지 케이스를 테스트 코드로 표현
GreenAI그 테스트만 통과시키는 최소 구현 생성
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 에게 그냥 한 줄로 시켰을 때 빠졌던 도메인 로직들이 전부 채워졌다. 테스트가 명세 역할을 했기 때문이다.