TL;DR
- 좋은 테스트 조건에 대한 생각과 상황에 맞는 테스트 전략(단위/통합/E2E) 선택의 중요성
- 통합테스트의 중요성과 테스트 더블의 SPY 학습
- TDD 어려웠던 점 회고
서론
안녕하세요. 백엔드 개발자 소프입니다.
지금까지 테스트 코드를 작성하면서 느꼈던 일부 개인적인 생각을 정리하려고 합니다.
저는 현재 5년차 개발자이며, 3년차부터 테스트 코드를 작성해왔습니다. "채용 사전 과제에 test 폴더가 없으면 떨어뜨린다"는 이야기가 크게 공감될 정도로, 테스트 코드 작성은 필수라고 생각합니다.
하지만 TDD 방식으로는 작성하지 않았습니다. 그 이유는 다음과 같습니다.
- "모로 가도 서울만 가면 된다." 결과적으로 테스트 코드를 작성하기만 하면 됩니다.
- 테스트 가능한 구조를 만들면 좋은 설계가 자연스럽게 도출되며, 이는 TDD가 아니어도 충분히 구현 가능합니다.
현재 저는 아래와 같은 사이클로 개발하고 있습니다.
기능 완성 -> 테스트 코드 작성 및 1차 검증 -> 리팩터링 -> 테스트 코드 2차 검증
문제는 제가 TDD를 많이 경험해보지 않았다는 점입니다. 이런 제가 "TDD가 굳이 필요한가?"라고 생각하는 것이 과연 올바른지 의구심이 들었습니다.
이번 백엔드 부트캠프 과정을 진행하면서, 최대한 TDD 방식으로 구현해보려고 합니다. 익숙하지 않은 것에 대한 도전이자, TDD를 적재적소에 활용할 수 있는 실력을 쌓고 싶습니다.
좋은 테스트란?
테스트란 무엇일까요?
테스트는 개발한 기능이 의도한 대로 동작하는지 검증하는 역할을 하며, 리팩터링의 안전망을 제공합니다. 또한 테스트 코드를 작성해두면 배포 시 불안감을 해소할 수 있습니다.
예를 들어, 하나의 도메인에 A와 B 기능이 있다고 가정해보겠습니다. A 기능의 테스트 코드를 먼저 작성해두었고, 이후 B 기능을 수정하면서 B 테스트 코드도 작성했습니다. 이때 전체 테스트를 실행하면 기존 A 테스트가 실패할 수 있습니다.
이처럼 테스트 코드는 의도치 않은 사이드 이펙트를 즉시 감지하여 버그를 사전에 방지할 수 있습니다.
그렇다면 좋은 테스트란 무엇일까요?
빠른 실행 속도, 적절한 테스트 커버리지 비율 등 좋은 테스트의 조건은 많습니다. 하지만 제가 가장 중요하게 생각하는 것은 두 가지입니다.
첫째, 코드가 변경되었을 때 관련 테스트가 즉시 실패해야 합니다.
둘째, 불필요한 것을 검증하지 않고 정확히 무엇을 검증하려는지 명확해야 합니다.
또한 테스트 코드는 요구사항 문서 역할도 할 수 있습니다. 테스트를 읽었을 때 그 의도가 명확히 드러난다면, 그것이 바로 좋은 테스트 코드라고 생각합니다.
통합 테스트도 중요하다
단위 테스트는 단순한 getter/setter 외에 모든 비즈니스 로직은 작성하는 편입니다. 단위 테스트를 작성하면 기능에 문제가 없겠지 생각하여 통합 테스트는 소홀히 했었는데, 최근 통합 테스트의 중요성을 실감한 사례가 있었습니다.
"통합 테스트를 작성하지 않으면 의도치 않은 동작이 발생할 수 있다"는 점을 뼈저리게 느꼈습니다.
실제 사례
PostgreSQL DB 필드에 소수점을 저장하고 있었고, DB 필드 타입은 double precision이었습니다. 이와 매핑되는 객체 필드는 BigDecimal이었습니다.
private fun calculateScore(
score: BigDecimal?,
scoreStandard: BigDecimal
): BigDecimal? = when {
score == null -> null
score > BigDecimal("11.0") -> BigDecimal("11.0")
score < BigDecimal.ZERO -> BigDecimal("0.0")
score.compareTo(scoreStandard) < 0 -> BigDecimal("0.0")
else -> score
}
@Test
fun `score가 scoreStandard와 같으면 score를 반환한다`() {
// given
val score = BigDecimal("4.0")
val scoreStandard = BigDecimal("4.0")
// when
val result = calculateScore(score, scoreStandard)
// then
assertThat(result).isEqualTo(BigDecimal("4.0"))
// 통합 테스트시 result값이 4.0이 아닌 0.0이 나옴
}
단위 테스트는 성공했는데, 실제 API를 호출해보니 의도한 대로 동작하지 않았습니다.
score가 4.0일때 result 결과값을 4.0이 나오길 기대했지만, 0.0이 나왔습니다.
문제의 원인
- scoreStandard는 DB에서 조회한 데이터
- DB 컬럼 타입이 double이었고, 프로덕션 코드에서는 BigDecimal 타입으로 조회
- scoreStandard의 기대한 값은 4.0이지만 코드 디버깅 결과 4.00000341로 조회됨
- score가 4.0이면 4.0을 반환해야 하는데, scoreStandard가 4.00000341로 조회되어 score.compareTo(scoreStandard) < 0 조건이 참이 됨
- 결과적으로 BigDecimal.ZERO를 반환하여 의도치 않은 동작 발생
- 단위 테스트는 scoreStandard를 정확히 4.0으로 세팅했기 때문에 통과
해피 케이스 하나만이라도 통합 테스트를 작성했다면 조기에 발견할 수 있었습니다.
DB 컬럼을 numeric(10, 1)으로 변경하여 해결했습니다.
이번 경험을 통해 TDD의 가치를 다시 생각해봤습니다. TDD로 작성했다면 통합 테스트를 먼저 작성하면서 이 문제를 더 빨리 발견할 수 있지 않았을까 하는 아쉬움이 남습니다.
통합 테스트의 중요성은 알았지만, 그렇다고 모든 기능에 통합 테스트를 작성하면 리소스가 많이 듭니다. 통합 테스트가 필요한 경우와 불필요한 경우를 잘 판단하여 최소한의 리소스로 최대한의 효율성을 찾는 것이 중요하다는 것을 배웠습니다.
통합 테스트가 불필요한 경우
- 프레임워크/라이브러리가 이미 보장하는 기능
- 단순히 책임 위임만 하는 로직
통합 테스트가 필요한 경우
- 여러 컴포넌트가 복잡하게 상호작용하는 경우
- 복잡한 쿼리를 사용하는 경우
- DB 타입 변환이 중요한 경우
- 외부 시스템과 통신하는 경우
E2E 테스트
테스트 피라미드를 보면 단위 테스트 70%, 통합 테스트 20%, E2E 테스트 10% 비율로 작성을 권고합니다.
단일 API에 대한 테스트는 단위 테스트와 통합 테스트로 충분히 커버됩니다. 컨트롤러 역할상 비즈니스 로직이 없어야 하고 Filter나 Interceptor는 별도의 API 테스트로 가능합니다.
예를 들어 단순 사용자 조회 API나 주문 생성 같은 독립적인 기능은 통합 테스트만으로 충분합니다.
E2E 테스트의 높은 비용
RestAssured로 E2E 테스트를 작성해본 경험이 있습니다. 다음과 같은 고충을 겪었습니다.
- 시나리오 설계 비용: 어떤 흐름을 검증할지 고민 필요
- 코드량 증가: 단위/통합 테스트에 비해 코드량이 많음
- 중복 제거 필요: Helper 메서드, 테스트 픽스처 관리가 복잡
- 실행 시간: 여러 API를 연쇄 호출하므로 느림
E2E 테스트가 필요한 경우
비용이 높은 만큼, 꼭 전체 흐름 검증이 필요한 경우에만 작성해야 합니다.
- 외부 시스템 연동이 있는 경우
- 결제 플로우처럼 외부 결제 게이트웨이를 호출하고 Webhook을 수신하는 과정
- 여러 API가 연계되는 복잡한 시나리오
- 회원가입 → 로그인 → 인증 → 게시글 작성 → 이벤트 발행 → 리워드 지급
테스트 더블 SPY
Mock과 Stub은 모킹 라이브러리로 많이 사용해봤지만, Spy는 처음 사용해봤습니다. Mock과의 차이는 무엇일까요?
Mock vs Spy 핵심 차이
Mock: 모든 메서드가 가짜로 동작합니다. 실제 구현을 호출하지 않습니다.
Spy: 실제 구현을 호출하되, 필요한 부분만 가짜로 바꿀 수 있습니다.
Mock과의 비교
| 구분 | Mock | Spy |
| 기본 동작 | 모든 메서드가 가짜 | 실제 구현 호출 |
| Stubbing | 필수 (안 하면 null 반환) | 선택적 (필요한 것만) |
| 실제 호출 | ❌ 호출 안 함 | ✅ 호출 함 |
| 사용 시점 | 외부 의존성 격리 | 실제 객체 + 부분 제어 |
산업 스파이처럼 실제 현장에 잠입해서 동작하지만, 필요할 때만 개입하는 것과 비슷합니다.
예시 코드
@Service
class UserService(
private val userRepository: UserRepository,
) {
@Transactional
fun signUp(command: UserCommand.SignUp): User {
if (userRepository.exist(command.userId)) {
throw CoreException(ErrorType.CONFLICT)
}
return userRepository.save(User.create(command))
}
@Transactional(readOnly = true)
fun getMyInfo(userId: String): User? {
return userRepository.getBy(userId)
}
}
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean
class UserServiceTest : IntegrationTest() {
@MockitoSpyBean
private lateinit var userRepository: UserRepository
@Autowired
private lateinit var userService: UserService
@DisplayName("UserService SingUp")
@Nested
inner class SignUp {
@Test
fun `회원 가입시 User 저장이 수행된다`() {
// given
val command = createSignUpCommand()
// when
val user = userService.signUp(command)
// then
assertSoftly { softly ->
softly.assertThat(user).isNotNull
softly.assertThat(user.userId.value).isEqualTo(command.userId)
softly.assertThat(user.email.value).isEqualTo(command.email)
softly.assertThat(user.birthDate.value).isEqualTo(command.birthDate)
softly.assertThat(user.gender).isEqualTo(command.gender)
}
verify(userRepository, times(1)).save(any())
verify(userRepository, times(1)).exist(any())
}
@Test
fun `이미 가입된 userId로 회원가입 시도시 실패한다`() {
// given
val command = createSignUpCommand()
// when
doReturn(true).`when`(userRepository).exist(command.userId)
// then
assertThatThrownBy {
userService.signUp(command)
}.isInstanceOfSatisfying(CoreException::class.java) { error ->
assertThat(error.errorType).isEqualTo(ErrorType.CONFLICT)
}
verify(userRepository, times(1)).exist(any())
}
}
}
@MockitoSpyBean
private lateinit var userRepository: UserRepository
@MockitoSpyBean은 실제 객체를 Spy로 감싸는 어노테이션입니다.
첫 번째 테스트: 실제 동작 + 검증
@Test
fun `회원 가입시 User 저장이 수행된다`() {
// when
val user = userService.signUp(command)
// then
verify(userRepository, times(1)).save(any())
verify(userRepository, times(1)).exist(any())
}
Spy의 동작:
- userRepository는 실제로 DB에 저장합니다.
- 동시에 호출 기록도 남깁니다.
- verify로 호출 여부를 검증 가능합니다.
두 번째 테스트: 부분 Stubbing
@Test
fun `이미 가입된 userId로 회원가입 시도시 실패한다`() {
// given
doReturn(true).`when`(userRepository).exist(command.userId)
// then
verify(userRepository, times(1)).exist(any())
}
Spy의 특징:
- exist() 메서드만 stubbing합니다. (가짜 응답 반환)
- 나머지 메서드는 실제 구현체를 사용합니다.
- verify로 호출 여부를 검증 가능합니다.
실무 관점
사실 위 요구사항은 통합 테스트가 더 어울립니다. 실제로 DB에 저장하는 것을 검증하는 거라면, 굳이 verify로 호출 횟수를 확인할 필요가 없기 때문입니다. 이번 요구사항은 Spy를 한번 사용해보라는 학습 목적인 것 같습니다. 복잡한 비즈니스에선 Spy 테스트를 활용할 수 있을것 같습니다.
예를 들어, 여러 메서드를 호출해서 하나의 결과를 만드는 로직에서 input값에 따른 분기문이 있다면 특정 메서드를 실제로 N번 호출하는지 지 적용할 수 있습니다.
중복된 테스트 검증
테스트 코드도 프로덕션 코드처럼 애지중지하면서 꾸준히 리팩터링해야 합니다.
그렇다면 테스트 코드가 많을수록 좋을까요?
저는 "아니다"라고 생각합니다. 중복되거나 불필요한 테스트는 오히려 유지보수 비용만 증가시킵니다.
다음 예시를 보겠습니다.
@Entity
@Table(name = "user")
class User(
@Embedded
val userId: UserId,
@Embedded
val email: Email,
@Embedded
val birthDate: BirthDate,
@Column(nullable = false)
@Enumerated(value = STRING)
val gender: Gender,
) : BaseEntity() {
companion object {
fun create(command: UserCommand.SignUp): User {
return User(
userId = UserId(command.userId),
email = Email(command.email),
birthDate = BirthDate(command.birthDate),
gender = command.gender,
)
}
}
}
@Embeddable
data class Email(
@Column(name = "email", nullable = false)
val value: String,
) {
init {
validate(value)
}
private fun validate(email: String) {
require(email.isNotBlank()) { ERROR_MESSAGE_BLANK }
require(!email.contains(" ")) { ERROR_MESSAGE_WHITESPACE }
require(email.matches(EMAIL_PATTERN)) { ERROR_MESSAGE_FORMAT }
}
override fun toString(): String = value
companion object {
private val EMAIL_PATTERN = Regex("^[^@]+@[^@]+\\.[^@]+$")
private const val ERROR_MESSAGE_BLANK = "email은 필수입니다"
private const val ERROR_MESSAGE_WHITESPACE = "email에 공백이 포함될 수 없습니다"
private const val ERROR_MESSAGE_FORMAT = "email은 xx@yy.zz 형식이어야 합니다"
}
}
class UserTest {
@ParameterizedTest(name = "[{index}] email={0} 일 때 생성 실패")
@MethodSource("invalidEmails")
fun `이메일 형식이 맞지 않으면 생성에 실패한다`(email: String) {
assertThatThrownBy {
createUser(email = email)
}.isInstanceOf(IllegalArgumentException::class.java).hasMessageContaining("email")
}
}
다음과 같은 상황을 가정해보겠습니다.
User의 userId, email, birthDate 필드를 각각 VO 객체로 추출했습니다. UserTest를 통해 이메일 유효성 검증을 하고 있습니다.
EmailTest를 별도로 만들어야 할까요?
저는 UserTest만으로 충분하다고 생각합니다. 그 이유는 다음과 같습니다:
- Email은 User를 통해서만 사용되므로, UserTest에서 이미 검증됨
- 동일한 검증 로직을 두 곳에서 테스트하면 중복 유지보수 발생
- Email의 검증 로직이 변경되면 두 테스트를 모두 수정해야 함
다만, Email VO를 다른 객체(예: Company, Newsletter 등)에서도 사용하게 된다면 그때 EmailTest를 만들어도 늦지 않습니다.
재사용되는 시점에 공통 검증을 독립적으로 테스트하는 것이 합리적입니다
물론 "VO는 항상 독립적으로 테스트해야 한다"는 의견도 있습니다.
- VO의 책임을 명확히 검증할 수 있다
- VO만 보고도 어떤 제약사항이 있는지 파악 가능
- 나중에 재사용될 때를 대비할 수 있다
하지만 저는 YAGNI(You Aren't Gonna Need It) 원칙에 따라, 실제로 필요해질 때 테스트를 추가하는 것이 더 실용적이라고 생각합니다.
핵심은 "테스트를 위한 테스트"를 작성하지 않는 것입니다.
TDD 어려웠던 점
이번 과제는 단일 클래스였기 때문에 TDD로 작성하면서 크게 어려웠던 점이 없었습니다. 하지만 클래스 간의 연관 관계로 엮여 있다면 생각만 해도 복잡해집니다. 이는 제가 TDD를 현업에서 시도하다가 포기한 이유이기도 합니다.
예를 들어 User - UserReview - Review와 같은 관계가 있다고 가정해봅시다.
User 객체의 테스트 코드를 작성하는데 UserReview가 필요합니다. 일단 UserReview를 껍데기 객체로만 만들어 놓았습니다. 그런데 곰곰이 생각해보니 UserReview가 비즈니스에 중요한 객체라고 판단되어, User를 멈추고 UserReview의 테스트 코드를 먼저 작성하게 됩니다.
직접 경험해보진 않았지만, 이런 상황이 반복되면 다음과 같은 문제가 발생할 것 같습니다:
- 한 기능을 완성하는 데 시간이 오래 걸림
- TDD의 빠른 피드백 사이클이 깨짐
- 집중력이 분산되어 생산성 저하
- 결국 "이럴 거면 구현부터 하고 테스트를 나중에 작성하는 게 낫지 않나?"는 생각이 듦
아직 명확한 해결책은 떠오르지 않습니다. 앞으로 직접 코드를 작성해가며 익혀나갈 예정입니다.
물론 이런 어려움에도 불구하고 이를 극복한다면, TDD의 장점을 크게 체감할 수 있을 것으로 기대됩니다.
- 설계를 먼저 고민하게 되어 불필요한 의존성을 줄일 수 있음
- 테스트 가능한 구조를 만들면서 자연스럽게 좋은 설계가 나옴
마무리: 학습 후 느낀 점
머릿속에 흩어져 있던 테스트 코드에 대한 생각을 정리 할 수 있었고, 좋은 테스트 코드에 대한 저만의 원칙을 세울 수 있었습니다.
특히 통합 테스트의 중요성과 Spy 같은 새로운 테스트 더블을 실제로 사용해보면서, 상황에 맞는 테스트 전략을 선택하는 것이 얼마나 중요한지 체감했습니다.
아직 테스트 코드를 작성해도 배포에 대한 두려움은 남아 있습니다. 자신감 있게 배포할 수 있는 그날까지 꾸준히 학습하고 경험을 쌓아가겠습니다.
루프백 3기 오픈
https://www.loopers.im/education
교육 과정 | Loop:Pak
Loop:Pak 부트캠프 교육 과정 상세 정보. NextNode Backend Edition을 포함한 전문 개발자 양성 커리큘럼과 현업 멘토링 프로그램을 만나보세요.
www.loopers.im
루퍼스 3기가 오픈되었습니다.
추천인 코드를 입력하면 할인가로 수강할 수 있습니다. 다만 결코 작은 금액은 아니기에, 신청 전 적지 않은 고민을 했는데요. AI 기술의 빠른 발전과 함께 연차가 쌓이면서, 스스로의 실력에 대한 의구심이 들던 시기였습니다.
며칠간 고민한 끝에 이번 기회를 마지막이라 생각하고 수강을 결정했습니다. 지금 돌이켜보면, 후회 없는 선택이었다고 자신 있게 말할 수 있습니다.
만약 저와 비슷한 고민을 하고 있거나, 루퍼스 과정에 대해 궁금한 점이 있다면 언제든지 편하게 연락 주셔도 좋습니다.
LinkedIn 또는 jikimee64@gmail.com으로 언제든 문의 부탁 드립니다. 감사합니다.
추천인 코드
TZFI0
'외부활동 > 루퍼스 2기' 카테고리의 다른 글
| [Loop:PAK] 3주차 WIL (0) | 2025.11.14 |
|---|---|
| [루퍼스/루프백 2기] 도메인을 보호하고 객체간 협력을 지키는 구현 방법 (0) | 2025.11.12 |
| [Loop:PAK] 2주차 WIL (0) | 2025.11.06 |
| [루퍼스/루프백 2기] 문서를 만들고 개발을 진행하시나요? (2) | 2025.11.01 |
| [Loop:PAK] 1주차 WIL (0) | 2025.10.30 |
