로컬 테스트는 성공하는데 CI에서만 실패하는 미스터리
- 23 Dec, 2025
로컬 테스트는 성공하는데 CI에서만 실패하는 미스터리
오전 10시. 출근해서 Jenkins 확인했다. 빨간 불 15개. 어젯밤 배포 파이프라인 전부 터졌다.
슬랙에 개발자들 멘션 폭격. “J님, 테스트 왜 깨진 거예요?”
난 아무것도 안 건드렸다. 어제 퇴근 전에 PR 머지만 했다. 로컬에선 다 초록불이었는데.
로컬은 천국, CI는 지옥
# 로컬에서 100번 돌려도 성공
pytest tests/test_checkout.py
# ✓ 20 passed in 45.23s
근데 CI에서 돌리면.
FAILED tests/test_checkout.py::test_payment_processing
ElementNotInteractableException: element not interactable
다시 로컬에서 돌린다. 성공한다. 10번 연속 성공.
CI 다시 돌린다. 실패한다. 5번 중 3번 실패.
이게 뭐냐고.

개발자들은 이런 거 처음 겪나 보다. “제 로컬에선 되는데요?” 알아. 나도 그래.
근데 CI에서 실패하면 배포를 못 한다. 그게 문제다.
첫 번째 용의자: 네트워크 타이밍
로컬에선 API 응답이 빠르다. 개발 서버가 내 컴퓨터 바로 옆이니까.
CI는 다르다. 컨테이너끼리 네트워크 통신한다. 지연이 생긴다.
# 내가 짠 코드
def test_user_login():
driver.get("https://dev.example.com/login")
email_input = driver.find_element(By.ID, "email")
email_input.send_keys("test@test.com")
# 바로 submit
submit_btn = driver.find_element(By.ID, "submit")
submit_btn.click()
로컬에선 찰나다. 페이지 로드 0.5초.
CI에선 2초 걸린다. 그 사이 내 스크립트는 이미 element 찾으러 갔다. 당연히 못 찾는다.
해결법.
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
def test_user_login():
driver.get("https://dev.example.com/login")
# 명시적 대기 추가
email_input = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.ID, "email"))
)
email_input.send_keys("test@test.com")
submit_btn = WebDriverWait(driver, 10).until(
EC.element_to_be_clickable((By.ID, "submit"))
)
submit_btn.click()
이거 하나 바꾸는 데 오전 다 갔다. 테스트 케이스가 300개인데 하나하나 고쳐야 한다.

두 번째 용의자: 병렬 실행 순서
로컬에선 테스트를 순차로 돌린다. 한 번에 하나씩.
CI는 시간이 금이다. 병렬로 돌린다. 6개 워커.
# .gitlab-ci.yml
test:
script:
- pytest -n 6 # 6개 병렬
문제는 테스트끼리 간섭한다는 거다.
# test_cart.py
def test_add_to_cart():
login("test@test.com")
add_item("MacBook Pro")
assert cart_count() == 1
def test_remove_from_cart():
login("test@test.com")
remove_item("MacBook Pro")
assert cart_count() == 0
순차 실행하면 문제없다. 1번 테스트가 끝나고 2번 시작.
병렬 실행하면 꼬인다.
- 워커 1: add_to_cart 실행 중
- 워커 2: remove_from_cart 동시 실행
- 둘 다 같은 계정 사용
- 장바구니 카운트가 왔다갔다
결과: 랜덤 실패.
해결법은 격리다.
import pytest
import uuid
@pytest.fixture
def unique_user():
email = f"test_{uuid.uuid4()}@test.com"
create_test_user(email)
return email
def test_add_to_cart(unique_user):
login(unique_user)
add_item("MacBook Pro")
assert cart_count() == 1
각 테스트가 독립적인 데이터를 쓴다. 이제 병렬로 돌려도 안 꼬인다.
근데 이것도 문제다. 테스트 계정 300개가 생성된다. DB cleanup이 필요하다.
@pytest.fixture
def unique_user():
email = f"test_{uuid.uuid4()}@test.com"
user_id = create_test_user(email)
yield email
delete_test_user(user_id) # teardown
이제 CI 실행 시간이 늘었다. cleanup 때문에 1분 추가.
트레이드오프다.
세 번째 용의자: 환경 변수
제일 짜증 나는 케이스.
로컬 .env 파일.
API_BASE_URL=http://localhost:3000
DB_HOST=localhost
REDIS_URL=redis://localhost:6379
CI 환경 변수.
variables:
API_BASE_URL: http://api-service:3000
DB_HOST: postgres-service
REDIS_URL: redis://redis-service:6379
URL이 다르다. 당연히 테스트가 깨진다.
더 미묘한 건 이런 거다.
import os
# 내 코드
def get_api_url():
return os.getenv("API_BASE_URL", "http://localhost:3000")
# CI에서는 환경변수가 안 들어간다
# 왜? Docker 컨테이너 안에서 pytest 실행
# 환경변수가 컨테이너 안으로 안 들어감
해결법.
# .gitlab-ci.yml
test:
script:
- export API_BASE_URL=http://api-service:3000
- pytest
또는 docker-compose에서.
services:
test:
environment:
- API_BASE_URL=http://api-service:3000
이것도 찾는 데 2시간 걸렸다. print 디버깅으로 겨우 찾았다.
def test_api_connection():
print(f"API_BASE_URL: {os.getenv('API_BASE_URL')}")
# CI 로그 보니까 None이 찍혔다

Docker로 환경 동일하게 만들기
결론은 하나다. 로컬 환경을 CI 환경과 똑같이 만든다.
Docker Compose 파일 작성.
# docker-compose.test.yml
version: '3.8'
services:
app:
build: .
environment:
- API_BASE_URL=http://api:3000
- DB_HOST=postgres
depends_on:
- postgres
- redis
- api
postgres:
image: postgres:14
environment:
- POSTGRES_PASSWORD=testpass
redis:
image: redis:7
api:
image: our-api:latest
environment:
- DB_HOST=postgres
이제 로컬에서도 똑같이 돌린다.
docker-compose -f docker-compose.test.yml up --build
docker-compose -f docker-compose.test.yml run app pytest
CI에서도 똑같이.
# .gitlab-ci.yml
test:
script:
- docker-compose -f docker-compose.test.yml up -d
- docker-compose -f docker-compose.test.yml run app pytest
이제 로컬이랑 CI랑 환경이 동일하다. 같은 네트워크 설정. 같은 서비스 이름. 같은 환경 변수.
차이가 없으니 결과도 같다.
그래도 가끔 깨진다
환경을 맞춰도 100% 해결은 아니다.
Flaky 테스트는 남는다.
- CI 서버 CPU가 바쁠 때 타임아웃
- 외부 API 호출하는 테스트 (네이버 로그인 등)
- 랜덤 데이터 쓰는 테스트
이런 건 다른 방법이 필요하다.
외부 API는 mocking.
@patch('requests.get')
def test_naver_login(mock_get):
mock_get.return_value.json.return_value = {
"email": "test@naver.com"
}
# 실제 네이버 API 안 부름
타임아웃은 여유 있게.
# Before
WebDriverWait(driver, 5) # 5초는 짧다
# After
WebDriverWait(driver, 15) # 15초로 늘림
랜덤은 시드 고정.
import random
random.seed(42) # 매번 같은 랜덤 값
완벽한 해결책은 없다. 그냥 확률을 낮추는 거다.
현재 상태
오후 6시. Jenkins 다시 돌렸다. 초록불 20개.
개발자들 슬랙 반응. “오 고쳤네요!” “ㄳㄳ”
나는 PR에 코멘트 남겼다.
변경사항:
- 명시적 대기 추가 (WebDriverWait)
- 테스트별 unique user fixture 적용
- docker-compose로 로컬/CI 환경 통일
- 환경변수 주입 확인
테스트 시간: 45초 → 1분 20초 (cleanup 때문)
성공률: 60% → 95%
완벽하진 않다. 95%. 가끔 1-2개는 여전히 깬다.
근데 이 정도면 된다. Flaky 테스트는 retry로 처리.
# .gitlab-ci.yml
test:
retry: 2 # 2번까지 재시도
배운 것
로컬과 CI가 다르다는 걸 인정해야 한다. “내 컴퓨터에선 되는데”는 핑계다.
CI 환경을 로컬에서 재현할 수 있어야 한다. Docker가 답이다.
네트워크 타이밍은 항상 고려. 명시적 대기를 쓴다.
테스트는 독립적이어야 한다. 순서에 의존하면 병렬 실행에서 터진다.
환경 변수는 명확하게. print 디버깅으로 값 확인.
100% 안정적인 자동화는 없다. 95%면 성공이다. 나머지 5%는 retry와 monitoring으로.
퇴근 7시. 오늘은 정시다. CI 초록불 보니까 기분이 좋다.
