| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- jvm
- RDBMS
- monitoring
- CloudNative
- netty
- Kubernetes
- 동시성제어
- 성능최적화
- 트랜잭션
- spring boot
- Java
- grafana
- 백엔드개발
- helm
- JPA
- selector
- kafka
- GitOps
- DevOps
- SpringBoot
- Kotlin
- 성능 최적화
- prometheus
- redis
- mysql
- NIO
- 데이터베이스
- webflux
- docker
- 백엔드
- Today
- Total
유성
테스트 코드 작성 2편 동시성 이슈와 외부 의존성 제어 본문
지난 글에서는 '테스트하기 좋은 구조'를 위해 비즈니스 로직을 도메인 모델로 옮기고, 비결정적인 값(시간 등)을 외부에서 주입받는 방법을 살펴봤다. 덕분에 우리는 빠르고 독립적인 단위 테스트(Unit Test)를 가질 수 있게 되었다.
하지만 실제 서비스는 혼자 돌아가지 않는다. 수많은 사용자가 동시에 요청을 보내고(동시성), 실제 데이터베이스에 값이 정확히 들어가는지 확인해야 하는 순간이 온다. 오늘은 단위 테스트만으로는 검증하기 까다로운 '동시성 문제'와 '실제 인프라 의존성'을 제어하며 테스트하는 법을 알아보자.
1. 동시성 테스트: "동시에 100명이 주문하면 어떻게 될까?"
단일 스레드에서 돌아가는 단위 테스트는 동시성 버그를 절대 잡아낼 수 없다. 예를 들어, 재고를 1 감소시키는 로직을 테스트해 봅시다.
[문제 상황] 재고 감소 로직
@Service
class StockService(private val stockRepository: StockRepository) {
@Transactional
fun decrease(id: Long, quantity: Long) {
val stock = stockRepository.findById(id).orElseThrow()
stock.decrease(quantity) // 재고 감소 로직
stockRepository.saveAndFlush(stock)
}
}
이 코드를 단순히 100번 반복 실행하는 테스트는 성공할 것이다. 하지만 '동시에(Parallel)' 실행하면 이야기가 달라진다.
여러 스레드가 동시에 같은 재고 값을 읽어 고전적인 Race Condition이 발생하기 때문이다.
[해결책] CountDownLatch를 이용한 멀티스레드 테스트
자바의 CountDownLatch를 사용하면 여러 스레드를 대기시켰다가 동시에 출발시킬 수 있다.
@SpringBootTest
class StockServiceTest @Autowired constructor(
private val stockService: StockService,
private val stockRepository: StockRepository
) {
@Test
fun `동시에 100개의 요청이 올 때도 재고가 정확히 감소해야 한다`() {
// Given
val threadCount = 100
val executorService = Executors.newFixedThreadPool(32)
val latch = CountDownLatch(threadCount) // 100개의 작업이 끝날 때까지 대기할 래치
// When
for (i in 0 until threadCount) {
executorService.submit {
try {
stockService.decrease(1L, 1L)
} finally {
latch.countDown() // 작업 하나 완료 시 숫자 감소
}
}
}
latch.await() // 모든 작업(100개)이 끝날 때까지 메인 스레드 대기
// Then
val stock = stockRepository.findById(1L).get()
assertThat(stock.quantity).isEqualTo(0L)
}
}
- CountDownLatch: 스레드들이 작업을 마칠 때까지 기다리게 하거나, 특정 시점에 동시에 실행되도록 제어하는 역할을 한다.
- 주의점: 동시성 테스트가 실패한다면, 낙관적 락(Optimistic Lock), 비관적 락(Pessimistic Lock), 또는 Redis를 활용한 분산 락(Distributed Lock) 등의 해결책을 적용해야 한다는 신호이다.
테스트라는 것이 CPU 작동 상태를 계속 모니터링하는 게 아니다 보니, 당연하지만 동시성을 완벽하게 보장하지는 못한다.
꼭 필요한 곳에 적용하것이 비용 측면이나 유지보수 측면에서 바람직하다.
2. 외부 의존성 제어: "내 로컬에선 H2 쓰는데, 운영은 MySQL이라서 실패해요"
흔히 DB 테스트를 할 때 가벼운 H2 인 메모리 DB를 사용한다. 하지만 H2와 실제 운영 DB(MySQL, PostgreSQL 등)는 문법, 인덱스 작동 방식, 데이터 타입에서 차이가 있다. "로컬 테스트는 통과했는데 운영 서버에 배포하니 에러가 나는" 전형적인 상황이다.
한 가지 더 사례를 들면, 테스트를 테스트배드 DBMS에서 처리하는 경우가 존재한다. 간혹 TB DBMS에서 데이터를 조작해 놓고 복구를 하지 않아, 코드에 문제가 없음에도 다른 팀원의 통합 테스트가 깨지는 경우가 있는데, 이를 원천 차단하는 방법이 있다.
이때 필요한 것이 TestContainers이다.
[문제점] 가짜(Mock)와 실제의 간극
- Mocking: DB 호출을 Mock으로 대체하면 '쿼리 자체가 올바른지'는 알 수 없다. 정해진 결과만 표출된다.
- H2: MySQL 전용 문법이나 특정 DB 기능을 테스트할 수 없다. 물론 일정 수준의 쿼리테스트는 가능하다.
[해결책] TestContainers로 '진짜' 환경 구축하기
TestContainers는 Docker를 활용해 테스트 실행 시점에 실제 DB 컨테이너를 띄워주는 라이브러리이다.
@Testcontainers
@SpringBootTest
class IntegrationTest {
companion object {
@Container
val mysqlContainer = MySQLContainer<Nothing>("mysql:8.0").apply {
withDatabaseName("testdb")
withUsername("user")
withPassword("password")
}
@DynamicPropertySource
@JvmStatic
fun registerDynamicProperties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl)
registry.add("spring.datasource.username", mysqlContainer::getUsername)
registry.add("spring.datasource.password", mysqlContainer::getPassword)
}
}
@Test
fun `실제 MySQL 환경에서 복잡한 쿼리가 정상 작동한다`() {
// 실제 MySQL 컨테이너 위에서 테스트 수행
}
}
왜 TestContainers인가?
- 환경 일관성: 개발자 로컬, CI(Jenkins/GitHub Actions), 운영 환경이 모두 동일한 DB 엔진을 사용할 수 있다.
- 독립성: 테스트 시작 시 컨테이너가 뜨고 종료 시 사라지므로, 테스트 데이터 오염 걱정이 없다.
- No Mocking: 쿼리 튜닝이 필요한 복잡한 Native Query나 프로시저도 자신 있게 테스트할 수 있습니다.
모든 환경에서 동일하게 테스트 가 가능하며, 기존의 데이터를 조작하지 않는다는점에서 이점이 크다.
간혹 TB DBMS 장비에 테스트를 위해 데이터를 조작한경우 (코드에 문제가 없음에도) 통합테스트가 깨지는 경우가 있는데 이러한 문제들을 원천 차단할 수 있다.
마치며
지난 글에서 강조한 도메인 모델 중심의 설계가 '코드의 논리적 무결성'을 보장한다면, 오늘 다룬 CountDownLatch와 TestContainers는 '시스템의 물리적 안정성'을 보장한다.
하지만 중요한 사실이 있다. 어떤 아키텍처를 선택하느냐에 따라 테스트의 크기와 방식, 그리고 그에 따른 비용은 천차만별로 달라진다는 점이다. 예를 들어보면
- 트랜잭션 스크립트 패턴: 초기 개발 속도는 빠르지만 로직이 복잡해질수록 서비스 계층이 비대해져 단위테스트가 어려워진다.
- 헥사고날/클린 아키텍처: 외부 의존성을 철저히 분리하여 단위 테스트 비용이 낮지만, 초기 보일러플레이트 코드와 어댑터 계층을 검증하기 위한 통합 테스트 설계 비용이 커진다.
그래서 필자는 테스트 비용도 설계 비용에 포함된다고 생각한다.
서비스에 DMBS가 붙고 Redis, 혹은 MSA 환경에서의 동기/비동기 이벤트 처리 환경에서의 외부 연동 등 다루어야 할 환경이 복잡해질수록 테스트를 구축하고 유지하는 비용은 기하급수적으로 늘어난다.
무조건 100%의 테스트 커버리지를 목표로 개발보다 많은 시간을 할애하는 것은 정답이 아닐 수 있고, 현재 서비스가 추구하는 가치가 무엇인지, 제어해야 할 외부 서비스의 신뢰도가 어느 정도인지에 따라 '가장 효율적인 테스트 지점'을 찾아내는 눈이 필요하다고 생각한다.
'테스트코드 & 정적분석' 카테고리의 다른 글
| IntelliJ Profiler로 보는 JVM 힙 & 스레드 덤프로 장애 상황 분석 (0) | 2026.01.30 |
|---|---|
| Spring Boot 3.4+에서 @MockBean이 @MockitoBean으로 대체된 이유 (0) | 2026.01.18 |
| 테스트 코드 작성 1편 테스트가 고통스럽다면, 설계를 의심하라 (feat. POJO & Time) (0) | 2025.12.22 |
| 테스트 코드, '작동'을 넘어 '지속 가능한 시스템'으로 (0) | 2025.12.22 |
| 문제 발생시 커널을 조사하는 방법 (0) | 2025.12.08 |