2026년 10월 10일 Linux에서 VoiceLink 백엔드 테스트를 실행했다. Java 17.0.20.1·Spring Boot 3.2.5 환경에서 실패는 없었다.
Tests: 196
Passed: 195
Skipped: 1
Failures: 0
Errors: 0
건너뛴 하나는 PostgreSQL 16에서 Outbox 선점 경쟁을 확인하는 테스트였다. Docker도 실행 중이었다. 그런데 왜 빠졌을까?
설치 여부보다 로그 한 줄이 정확했다
해당 테스트만 로그를 켜고 다시 실행하니 Docker 클라이언트 요청이 거절되고 있었다.
client version 1.32 is too old. Minimum supported API version is 1.40
이 환경의 Testcontainers 클라이언트는 API 1.32로 요청했고, 서버는 최소 1.40을 요구했다. Docker 프로세스가 있다는 사실과 테스트가 Docker를 쓸 수 있다는 사실은 달랐다.
프로젝트 파일은 그대로 두고 임시 Gradle 초기화 스크립트에서 테스트 JVM에 다음 값을 지정했다. 이번 환경에서 원인을 좁히기 위한 설정이며, 모든 환경에 고정할 권장 버전은 아니다.
systemProperty 'api.version', '1.40'
같은 MatchOutboxRepositoryPostgresTest를 다시 실행하자 PostgreSQL 16 컨테이너가 시작됐고, 테스트 한 개가 통과했다. 이제 빠졌던 테스트는 채웠다. 다만 저장소의 기본 설정을 고친 것은 아니므로, 재현할 때도 이 추가 조건을 함께 적어야 한다.
잠긴 첫 행을 정말 건너뛰었나
이 테스트가 답하려는 질문은 작다. Outbox에 두 행이 있고 A가 첫 행을 잠갔을 때, B는 A를 기다리지 않고 다른 행을 가져올 수 있을까?
MatchOutboxRepository.lockBatchForClaim의 SQL은 다음 조건으로 처리할 행을 고른다. 조회할 개수를 1로 줄여 선점 경쟁을 만들었다.
SELECT * FROM match_outbox
WHERE sent_at IS NULL
AND status IN ('PENDING', 'PROCESSING')
AND (next_attempt_at IS NULL OR next_attempt_at <= :now)
AND (locked_until IS NULL OR locked_until <= :now)
ORDER BY COALESCE(next_attempt_at, created_at), id
LIMIT 1
FOR UPDATE SKIP LOCKED;
가짜 저장소가 다른 행을 반환하게 하면 그 뒤의 서비스 동작은 검사할 수 있다. 여기서는 DB가 잠긴 행을 실제로 건너뛰는지가 궁금하다. 그래서 실제 엔진과 별도 트랜잭션을 쓴다.
동시에 출발했다고 꼭 마주치는 건 아니다
두 작업을 동시에 제출해도 A가 먼저 끝나면 잠금 경쟁은 일어나지 않는다. 테스트는 A가 잠금을 잡았다는 신호를 받은 뒤, A를 멈춘 상태에서 B의 완료를 기다린다.
실제 검증 부분을 발췌했다. 트랜잭션 생성과 실패 시 정리 코드는 생략했다.
assertThat(firstLocked.await(5, TimeUnit.SECONDS)).isTrue();
Future<Long> secondClaim = workers.submit(() -> inNewTransaction(() ->
repository.lockBatchForClaim(LocalDateTime.now(), 1).get(0).getId()
));
Long secondClaimedId = secondClaim.get(5, TimeUnit.SECONDS);
releaseFirst.countDown();
Long firstClaimedId = firstClaim.get(5, TimeUnit.SECONDS);
assertThat(List.of(firstClaimedId, secondClaimedId))
.containsExactlyInAnyOrder(firstId, secondId);
secondClaim.get이 releaseFirst.countDown보다 앞에 있다. A를 풀기 전에 B의 결과를 받는 순서다. 단지 두 작업이 끝난 뒤 서로 다른 ID만 비교하는 것보다 확인 범위가 분명하다.
재실행은 이 순서와 ID 검증을 모두 통과했다. 테스트가 실패해도 첫 작업의 대기를 풀고 실행기를 닫도록 정리 코드를 둔다.
이번에는 실패가 아니라 건너뜀 한 줄에 원인이 숨어 있었다. 초록색 결과를 읽을 때도, 확인하려던 질문이 실행 대상에 들어갔는지부터 봐야 했다.
참고 자료
근거: VoiceLink 1704b46의 백엔드 실행 로그와 MatchOutboxRepositoryPostgresTest 재실행, 저장소 SQL·테스트 설정. 2026-10-12 기준 37109d5에서도 관련 저장소·테스트·빌드 설정은 동일하다. 조건부 재실행 환경은 Java 17.0.20.1·Spring Boot 3.2.5·Testcontainers 1.19.7이며 테스트 JVM의 Docker API 버전은 1.40이다. 브라우저·실기기 E2E 결과는 아니다.