TDD(Test Driven Development)는 테스트 주도 개발로, 소프트웨어 개발 시 기능 구현 전에 테스트 코드를 작성하여 개발 전체적인 과정을 이끌어 가는 방법론이다.
이 접근법은 "Red(실패), Green(성공), Refactor(리팩토링)" 의 반복 사이클을 통해 코드를 작성하고 개선해 나간다.
Red 단계 : 아직 기능이 구현되지 않은 상태에서 테스트를 작성하면, 당연히 실패하는 결과를 확인할 수 있다. 이 단계에서 "무엇을 구현할 것인가" 에 대한 명확한 목표가 설정된다.
Green 단계 : 최소한의 코드를 작성해 테스트를 통과하게 한다. 이 때 기능이 실제로 동작하는지를 빠르게 확인 가능하다.
Refactor 단계 : 기능 구현 후, 코드의 가독성과 유지보수성을 개선하기 위한 리팩토링을 수행한다. 이 과정에서 작성된 테스트는 코드 수정 후에도 기능이 유지되고 있는지를 검증하는 역할을 한다.
이와 같이 반복적인 사이클은 개발자가 설계에 집중하고, 불필요한 복잡성을 줄이며, 안정적인 코드를 만드는 데 큰 도움을 준다.
이제부터 켄트 백이 TDD를 어떻게 정립했는지, TDD에 대해 비효율적이라는 비판이 나오는 이유에 대해 살펴보고, TDD의 확장 개념인 행동 주도 개발(BDD) 를 kotest를 통해 구현해보도록 하자.
TDD와 Kent Beck
TDD는 1990년대 말부터 2000년대 초반에 등장한 애자일 개발 방법론과 함께 주목받기 시작했다. 기존의 Waterfall 방식과는 달리, 빠른 피드백과 지속적인 개선을 목표로 하는 개발 방식이 각광받으면서 TDD 역시 그 중요성이 부각되었다.
초기 TDD의 개념은 테스트 코드를 작성한 후 실제 기능을 구현함으로써, 개발 과정에서 발생할 수 있는 버그를 조기에 발견하고 수정하는 것을 목표로 했다. 그 후, TDD는 단순히 "테스트 후 개발"이 아니라, 코드의 설계와 구조를 개선하고 문서화하는 수단으로도 활용 중이다.
켄트 백(Kent Beck)은 극단적인 프로그래밍(XP, Extreme Programming)의 창시자 중 한명으로, TDD를 구체화하고 대중화하는 데 큰 역할을 했다.
2003년 켄트 백이 출간한 책 "Test-Driven Development By Example" 에서는 TDD의 기본 원칙과 구체적인 예제을을 통해 TDD의 실천 방법을 상세히 설명하고, 많은 개발자들에게 이를 널리 알리기 시작했다. 이는 테스트를 먼저 작성하고, 최소한의 코드를 구현하여 테스트를 통과한 후, 리팩토링을 통해 코드를 개선하는 방식으로, 개발자들이 안정적이고 유지보수하기 쉬운 코드를 작성할 수 있도록 도와주었다.
그리고, XP 철학 속에 내재된 커뮤니케이션, 지속적인 피드백, 간결함 등의 가치가 TDD의 성공적인 적용에 큰 영향을 주었다.
TDD의 장점
TDD는 많은 장점을 제공하지만, 실제 적용 과정에서 몇 가지 도전 과제와 비판점도 존재한다.
- 버그 조기 발견 : 초기 단계에서 테스트를 작성함으로써, 코드 중에 발생할 수 있는 버그를 빠르게 발견할 수 있다.
- 코드의 유지보수성 향상 : 테스트 코드는 코드 변경 시 기존 기능이 제대로 동작하는지 확인할 수 있는 중요한 기준점이 된다.
- 안정적인 리팩토링 : 기존의 테스트가 존재하기 때문에, 개발자는 기능 개선이나 코드 정리를 할 때 안정감을 가질 수 있다.
TDD의 단점 및 비판
위와 같은 장점에도 불구하고, TDD는 여러 비판적인 목소리가 많이 있었다. 몇몇 업계 전문가들은 TDD가 항상 최선의 선택은 아니라는 점, 그리고 오히려 개발 과정에 제약을 가하거나 불필요한 오버헤드를 발생시킬 수 있다고 주장한다. 이에 대해 자세히 알아보도록 해보자.
과도한 테스트 코드와 유지보수 부담
TDD를 적용하면 기능마다 상세한 단위 테스트를 먼저 작성해야 하므로, 결과적으로 테스트 코드의 양이 매우 많아진다. 이는 초기 개발 속도를 매우 늦추고, 코드 변경 시 테스트 코드까지 모두 수정해야 하는 유지보수 부담으로 이어지게 된다.
일부 개발자들은 "테스트코드가 실제 비즈니스 로직보다 더 복잡해진다"고 하면서, TDD가 오히려 개발자에게 부담을 주고 유연성을 저해시킨다는 의견이 있다.
설계의 제약 및 창의성 저해
TDD는 "테스트 먼저" 라는 원칙에 따라 개발을 진행하기 때문에, 때로는 개발 초기의 불확실한 요구사항이나 설계 아이디어를 충분히 탐색하기보다는, 테스트를 만족시키기 위한 '안전한' 코드를 작성하게 만들 수 있다. 이는 창의적인 문제 해결이나 혁신적인 설계보다는, 이미 검증된 패턴에 의존하도록 만들 위험이 있다고 한다.
Ruby on Rails의 창시자인 David Heinemeier Hansson은 2014년에 TDD는 죽었다 라는 내용의 글을 작성하게 되고, "테스트가 모든 것을 규정하게 되면 개발자가 빠르게 프로토타입을 만들고 자유롭게 설계 방향을 모색하기 어렵다"고 비판한 바 있다. 그리고 그는 특히 초기 단계의 스타트업이나 빠른 시장 대응이 중요한 프로젝트에서, TDD가 지나치게 엄격한 제약을 가할 수 있다고 주장한다.
https://dhh.dk/2014/tdd-is-dead-long-live-testing.html
TDD is dead. Long live testing. (DHH)
TDD is dead. Long live testing. By David Heinemeier Hansson on April 23, 2014 Test-first fundamentalism is like abstinence-only sex ed: An unrealistic, ineffective morality campaign for self-loathing and shaming. It didn't start out like that. When I first
dhh.dk
단위 테스트의 한계와 시스템 통합의 문제
TDD는 주로 단위 테스트에 초점을 맞추기 때문에, 시스템 전체의 통합이나 사용자 경험같은 보다 큰 관점의 문제는 간과할 수 있다는 지적이 있다. 즉, 개별 모듈은 테스트를 통과하더라도, 이들 모듈이 함께 작동할 때 방생하는 문제를 포착하기 어렵다는 것이다.
설계 방향 전환에 대한 저항
TDD로 인해 한 번 작성된 테스트들은 코드의 변경에 대한 안전망 역할을 하지만, 동시에 기존 테스트에 묶여 새로운 설계로의 전환이나 대규모 리팩토링이 어려워지는 부작용도 발생할 수 있다. 이러한 문제는 TDD가 코드의 자연스러운 진화를 방해할 수 있다는 이야기가 많이 나온다.
이렇게 TDD는 단위 테스트를 통해 코드의 품질을 보장하고 설계를 명확하게 하는 데에 큰 도움이 되지만, 그 적용 방식이 항상 최적이라고 할 수는 없을 것 같다. DHH가 지적한 바와 같이 TDD는 지나친 테스트 코드 유지보수 부담, 설계의 제약, 시스템 전체 통합 문제 등 여러 도전 과제를 안고 있다.
따라서 TDD를 도입할 때는 프로젝트 특성과 팀의 문화, 그리고 개발 환경을 고려하여, 그 장점돠 단점을 균형 있게 검토하는게 중요할 것 같다.
이러한 장점을 갖고가면서, 단점을 보완할 수 있는 BDD라는 테스트 기법도 존재한다.
BDD란?
행동 주도 개발(Behavior-Driven Development, BDD)는 테스트 주도 개발(TDD)에서 파생된 개발 방법론으로서, 개발자, 테스터, 그리고 비즈니스 관리자 등 비개발자와 개발자 모두가 쉽게 이해할 수 있는 자연어 형식의 명세(Given-When-Then 형식, Gherkin문법 등)을 통해 소프트웨어의 기대 행동을 정의하는 방법론인데, 이는 DDD에서 차용된 개념이다.
- Given : 어떤 상황(환경)이 주어지고,
- When : 특정 행위(액션)가 발생하면,
- Then : 기대되는 결과가 나타난다.
위처럼 서술함으로써, 코드의 구현 세부 사항보다는 사용자 관점에서의 기능 동작과 요구 사항을 명확히 하게 된다.
Feature: 조회수 기능
Scenario: 게시글을 클릭하면 조회수가 올라간다.
Given 게시글을 하나 만들고
When 게시글을 한 번 조회하면
Then 조회수 결과는 1이 되어야 한다
BDD가 TDD의 단점을 보완하는 방법
테스트 코드의 명확성과 유지보수성 향상
TDD에서는 세세한 단위테스트들이 많아지면서 테스트 코드 관리가 복잡해질 수 있다.
BDD는 테스트를 보다 '행동'에 집중하여 작성하기 때문에, 테스트 명세가 자연어에 가깝고 이해하기가 쉽다. 이를 통해 비즈니스 요구사항을 그대로 반영하며, 테스트케이스 자체가 문서 역할을 하여 유지보수 시에도 가독성이 높다.
설계의 유연성 확보와 창의성 지원
TDD는 테스트를 통과하기 위한 안전한 구현에 집중하다 보면, 때로는 창의적인 설계나 초기 프로토타이핑에 제약이 될 수 있다.
BDD는 사용자 시나리오와 기대 행동을 명확히 하면서도, "어떻게" 보다는 "무엇을"' 해야하는 지에 집중한다. 이렇게 하면 개발자들은 비즈니스 로직에 맞는 다양한 구현 방법을 자유롭게 고민할 수 있으며, 설계 변경이나 혁신적인 접근을 더 쉽게 도입할 수 있다.
통합 테스트와 전체 시스템 동작의 보완
TDD의 단위 테스트는 개별 모듈의 동작은 잘 검증하지만, 시스템 전체의 통합 문제나 사용자 경험(UX)측면의 문제를 충분히 포착하지 못할 수 있다. BDD에서는 사용자 관점의 시나리오를 기반으로 테스트를 작성하기 때문에, 단순한 기능 검증을 넘어 시스템의 통합 동작과 실제 사용 시 발생할 수 있는 다양한 상황들을 커버하려는 경향이 있다.
이로 인해 단위 테스트와 통합 테스트 사이의 간극을 메꾸는데 도움이 된다.
협업과 커뮤니케이션 강화
TDD는 개발자 중심의 테스트 코드 작성으로 인해, 비개발자와의 소통에 문제가 있으르 수 있다.
BDD를 함으로서 자연어가 명세가 되어 비개발자인 비즈니스 담당자와 테스터와 함께 같은 언어로 소통 가능하며, 이는 팀 내 협업을 촉진하고, 요구사항 변경이나 기능 개선 시 빠르게 반영할 수 있는 장점을 제공한다.
BDD를 Kotest로 직접 해보자
BDD라 하면, 요즘은 Cucumber라는 테스트 도구로 많이 구현하고는 한다. 하지만 나는 Kotest를 선택했다.
그 이유는 Kotest가 Kotlin DSL을 통해 코드 내에서 바로 BDD 스타일의 테스트 시나리오를 작성할 수 있게 해주기 때문이다.
전통적인 Cucumber 방식은 Gherkin 문법을 사용하여 별도의 스펙 파일에 테스트 시나리오를 작성하고, 이를 실제 코드와 매핑하는 과정을 거친다. 이 방식은 비개발자도 테스트 명세를 쉽게 이해할 수 있다는 장점이 있지만, 테스트 코드와 스펙 파일이 분리되어 있어 유지보수와 동기화에 어려움이 있다고 한다.
반면 Kotest는 BehaviorSpec과 같은 DSL을 활용하여 Gherkin 형식의 테스트를 코드 내에 직접 작성할 수 있다.
이를 통해 IDE의 코드 완성 기능과 컴파일 타임 검증의 이점을 누릴 수 있으며, 테스트 시나리오와 실제 구현이 한 곳에 모여 있어 보다 간결하고 일관성 있는 테스트 환경을 제공한다.
사용 환경
Kotest 5.7.2
Kotlin
Coroutine
MongoDB 6
Spring-Data-reactiveMongo
kotest 플러그인 설치
조회하는 기능, 생성하는 기능 유스케이스(service-repository,impl)를 구현하고,
아래와 같이 테스트를 작성하면 된다.
package transactiontest.pleaseoperate.bdd.board
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.mockk
import transactiontest.pleaseoperate.application.BoardFacade
import transactiontest.pleaseoperate.domain.entity.board.Board
import transactiontest.pleaseoperate.domain.repo.board.BoardRepository
import transactiontest.pleaseoperate.domain.service.board.BoardService
class BoardFeatureTest : BehaviorSpec({
// 모의 객체(Repository)를 relaxed 모드로 생성.
val boardRepository = mockk<BoardRepository>(relaxed = true)
val boardService = BoardService(boardRepository)
val boardFacade = BoardFacade(boardService)
// boardRepository.save() 호출 시, 전달받은 Board에 id "board1"을 부여하여 반환하도록 스텁 설정
coEvery { boardRepository.save(any()) } answers {
val boardArg = firstArg<Board>()
boardArg.copy(id = "board1")
}
// boardRepository.findById("board1") 호출 시, 처음에는 조회수 0, 다음에는 1인 Board 객체를 순차적으로 반환하도록 설정
coEvery { boardRepository.findById("board1") } returnsMany listOf(
Board(id = "board1", name = "테스트게시글1", viewCount = 0),
Board(id = "board1", name = "테스트게시글1", viewCount = 1)
)
Given("게시글을 하나 만들고") {
// 게시글 생성 유스케이스 호출
val board1 = boardFacade.createBoard("테스트게시글1")
When("게시글을 한 번 조회하면") {
// 게시글 조회 유스케이스 호출 시 내부에서 조회수가 증가한다고 가정
val boardAfterView = boardFacade.getBoard(board1.id)
Then("조회수 결과는 1이 되어야 한다") {
boardAfterView.viewCount shouldBe 1
}
}
}
})
추가로 이렇게 확장해서 테스트 구현도 가능하다.
결론
BDD는 TDD에서 나타날 수 있는 테스트 코드의 과도한 복잡성, 설계의 경직성, 통합테스트의 한계, 그리고 협업의 어려움 등 여러 단점을 보완할 수 있는 접근법이다.
TDD가 코드의 품질을 보장하는 데 효과적이었다면, BDD는 그 품질을 사용자 관점의 '행동' 명세로 확장하여, 개발 프로세스 전반에 걸쳐 더 높은 수준의 소통과 협업, 그리고 유연성을 제공한다.
하지만 BDD가 TDD의 장점을 전부 갖고가지는 않는다는 것을 명심해야 한다.
이러한 해결책으로는 TDD와 BDD 둘을 상호 보완하도록 테스트를 구현하는 것인데, BDD 안에 TDD 사이클을 두는 것이다.
요구사항이 하나 주어질 때, BDD로 테스트를 작성하고, TDD를 하면서 개발하다가 마지막에 요구사항을 작성한 BDD의 테스트가 통과하는 것을 확인하는걸 하나의 사이클로 정할 수 있다. 물론 이러한 방식은 개발 비용에 있어서 효율적일지는 장담할 수 없다.
FE 진영에서는 UI 혹은 View 레이어에서 매우 강한 결합도를 가지는 설계를 하여 모듈의 유닛 테스트가 어려운 경우가 많이 보인다. 이런 설계를 가질 때, UI 혹은 View 레이어에서 모듈들은 BDD만 하는 것이 유리한 방법일 수 있다.
즉, 이 또한 프로젝트의 특성을 잘 고려해서 선택과 집중!을 해야 할 것이다.
'끄적끄적' 카테고리의 다른 글
MongoDB의 WiredTiger Engine과 Transaction에 대해서 알아보자 (2) | 2025.03.23 |
---|---|
이제 와서 고쳐보는 2024년의 내 코드 (4) | 2025.03.08 |