| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
- grafana
- Kubernetes
- helm
- NIO
- RDBMS
- 성능 최적화
- 백엔드개발
- GitOps
- Kotlin
- jvm
- redis
- JPA
- netty
- 성능최적화
- monitoring
- SpringBoot
- 트랜잭션
- kafka
- prometheus
- CloudNative
- webflux
- Java
- 동시성제어
- docker
- 데이터베이스
- spring boot
- mysql
- selector
- DevOps
- 백엔드
- Today
- Total
유성
테스트 코드 작성 1편 테스트가 고통스럽다면, 설계를 의심하라 (feat. POJO & Time) 본문
"테스트 코드를 작성하는 데 시간이 너무 오래 걸려요." "어제는 통과했는데 오늘은 실패해요"
이런 식으로 테스트 코드 작성이 어렵다면, 그건 테스트 실력이 부족해서가 아니라 '테스트하기 어려운 구조'로 코드를 짰을 가능성이 크다.
오늘은 도구 사용법이 아니라 "테스트하기 좋은 구조"에 대해 두가지만 이야기 하려 한다.
1. 로직의 위치를 의심하라 (Service vs Domain)
문제의 시작: 트랜잭션 스크립트 패턴
흔히 'Service 계층에 모든 비즈니스 로직을 몰아넣는 방식'을 말한다. 예를 들어 유저를 생성할 때 로그인 타입(Social, General)에 따라 ID를 다르게 저장해야 한다고 가정해보자.
@Service
class UserService(
private val userRepository: UserRepository
) {
fun createUser(name: String, id: String, loginType: LoginType) {
val user = User()
user.name = name
if (loginType == LoginType.SOCIAL) {
user.socialId = id
} else if (loginType == LoginType.GENERAL) {
user.generalId = id
}
userRepository.save(user)
}
}
이 코드는 기능상으로는 아무런 문제가 없다. 하지만 테스트 관점에서는 몇 가지 불편한 점이 생긴다.
무엇이 문제인가?
- Mocking의 필요성: createUser 메서드를 테스트하려면 UserRepository를 반드시 Mocking 해야 한다.
- 무거운 테스트: 단순한 if-else 분기 처리를 검증하고 싶을 뿐인데, 스프링 컨텍스트를 띄우거나 Mock 객체를 주입받는 준비 과정(Setup)이 필요하다.
- POJO가 아님: User 객체는 데이터만 담고 있는 껍데기(Anemic Domain Model)일 뿐이고, 진짜 로직은 UserService에 있다.
만약 로직이 복잡해져서 "이름 유효성 검사", "ID 중복 체크", "가입 환영 이메일 발송" 등이 추가된다면? UserServiceTest는 수십 줄의 given(), when()으로 뒤덮인 거대한 테스트가 될 것이다.
2. 해결책: 도메인 모델 패턴
해결 방법은 간단하다. 데이터를 가진 객체가 로직을 수행하도록 책임을 이동시키는 것이다. User 객체 스스로가 자신의 상태를 결정하도록 리팩터링 해보자.
리팩터링 된 User 객체 (POJO)
class User(
var name: String,
var socialId: String? = null,
var generalId: String? = null
) {
companion object {
fun create(name: String, id: String, loginType: LoginType): User {
val user = User(name = name)
when (loginType) {
LoginType.SOCIAL -> user.socialId = id
LoginType.GENERAL -> user.generalId = id
}
return user
}
}
}
리팩터링 된 UserService
@Service
class UserService(
private val userRepository: UserRepository
) {
fun createUser(name: String, id: String, loginType: LoginType) {
val user = User.create(name, id, loginType)
userRepository.save(user)
}
}
3. 테스트가 얼마나 쉬워졌을까?
이제 우리는 UserService를 테스트할 필요가 없다. (단순 위임만 하니까) 대신 User 객체만 테스트하면 된다.
class UserTest {
@Test
fun `소셜 로그인 타입으로 유저를 생성하면 socialId가 세팅된다`() {
// Given & When
val user = User.create("홍길동", "12345", LoginType.SOCIAL)
// Then
assertThat(user.name).isEqualTo("홍길동")
assertThat(user.socialId).isEqualTo("12345")
assertThat(user.generalId).isNull()
}
}
변화된 점
- 속도: 외부 의존성(DB, Repository)이 전혀 없으므로 테스트 실행 속도가 ms 단위로 끝난다.
- 가독성: setup 과정이 사라지고 비즈니스 로직 검증에만 집중할 수 있다.
- 유지보수: 로직이 변경되면 User 클래스와 UserTest만 수정하면 된다. UserService는 건드릴 필요가 없다.
제어할 수 없는 값 처리하기
테스트하기 좋은 구조의 또 다른 핵심은 '비결정적인 값'을 다루는 방식이다. 예를 들어 "영엽 시간(09:00~22:00)에만 주문 가능"하다는 로직을 추가한다고 해보자.
테스트하기 나쁜 예
class Order {
fun place() {
val now = LocalDateTime.now() // 여기서 시간을 직접 호출하면 테스트 불가능
if (now.hour < 9 || now.hour >= 22) {
throw IllegalStateException("영업 시간이 아닙니다.")
}
}
}
내가 밤 11시에 야근을 하고 있으면, 이 테스트는 무조건 실패한다. (야근이 없으면 통과할지도 모른다는 게 더 최악이다.)
테스트하기 좋은 예(파라미터로 받기)
class Order {
// 시간을 외부에서 주입받음
fun place(currentTime: LocalDateTime) {
if (currentTime.hour < 9 || currentTime.hour >= 22) {
throw IllegalStateException("영업 시간이 아닙니다.")
}
}
}
이제 우리는 테스트 코드에서 원하는 시간(currentTime)을 마음대로 넣어볼 수 있다.
테스트하기 좋은 예(Clock Bean 의존성 주입)
만약 스프링 빈(Service) 내부에서 시간을 써야 한다면, Clock을 주입받는 것이 가장 깔끔한 방법이다.
@Service
class OrderService(
private val clock: Clock // 빈 등록
) {
fun placeOrder() {
val now = LocalDateTime.now(clock) // Clock을 사용하여 현재 시간 조회
val hour = now.hour
if (hour < 9 || hour >= 22) {
throw IllegalStateException("영업 시간이 아닙니다.")
}
}
}
@Test
@DisplayName("영업 시간(13:00)에는 주문이 정상적으로 생성된다")
fun createOrder_success() {
// Given: 시간을 2025-12-25 오후 1시로 고정
val fixedTime = Instant.parse("2025-12-25T13:00:00Z")
// Clock.fixed()를 사용해 멈춰있는 시계 생성
val fixedClock = Clock.fixed(fixedTime, ZoneId.systemDefault())
// 가짜 시계를 넣어서 서비스 생성 (DI)
val orderService = OrderService(fixedClock)
}
Clock.fixed() 를 사용하여 시간을 멈출 수 있다.
마치며: 테스트는 설계의 거울이다
"테스트 코드를 짜기 어렵다"는 느낌이 든다면, 잠깐 키보드에서 손을 떼고 코드를 바라볼 필요가 있다.
- 모든 로직이 Service에 몰려있지 않은가?
- Entity가 단순한 Getter/Setter 만을 수행하고 있지는 않는가?
- 메서드 내부에서 now()나 random()을 직접 호출하고 있지 않은가?
테스트하기 좋은 구조(POJO, 도메인 모델 패턴)로 코드를 옮기는 순간, 테스트는 고통이 아니라 가장 강력한 아군이 되어줄 것이다.
물론 트랜잭션 스크립트 패턴이 무조건 나쁜 것은 아니다. 단순한 CRUD나 로직이 거의 없는 코드에서는 오히려 효율적일 수 있다.
하지만 시스템이 성장하고 로직이 복잡해질수록, '테스트 가능한 구조'에 대한 고민은 필수적이다.
다음 글에서는 이렇게 단위 테스트로 검증하기 어려운 '동시성 문제'와 '외부 의존성(DB) 제어'를 어떻게 해결하는지, CountDownLatch와 TestContainers를 통해 알아보겠다.
'테스트코드 & 정적분석' 카테고리의 다른 글
| Spring Boot 3.4+에서 @MockBean이 @MockitoBean으로 대체된 이유 (0) | 2026.01.18 |
|---|---|
| 테스트 코드 작성 2편 동시성 이슈와 외부 의존성 제어 (0) | 2025.12.23 |
| 테스트 코드, '작동'을 넘어 '지속 가능한 시스템'으로 (0) | 2025.12.22 |
| 문제 발생시 커널을 조사하는 방법 (0) | 2025.12.08 |
| 캐시와 메모리 모델: 자바 멀티스레드 핵심 3가지 이슈 (3) | 2025.07.21 |