자동화 커버리지 80% vs 100%: 그 사이 어디가 현실일까

자동화 커버리지 80% vs 100%: 그 사이 어디가 현실일까

자동화 커버리지 80% vs 100%: 그 사이 어디가 현실일까 경영진의 질문 "자동화 커버리지 몇 퍼센트예요?" CTO가 물었다. 월요일 아침 10시, 스탠드업 미팅. "현재 73%입니다." "100% 언제쯤 가능할까요?" 순간 멈칫했다. 이 질문, 매번 나온다. "현실적으로 어렵습니다." "왜요? 다른 회사는 90% 넘는다는데." 설명해야 한다. 또.자동화 커버리지. 숫자 하나로 모든 걸 판단한다. 80%면 좋은 거다. 90%면 훌륭하다. 100%면 완벽하다. 정말 그럴까. 4년 동안 자동화 했다. 처음엔 나도 100%를 꿈꿨다. 지금은 안다. 불가능하다는 걸. 80%의 벽 우리 서비스, 테스트 케이스 2,400개. 자동화된 케이스 1,752개. 정확히 73%. 남은 648개. 왜 자동화 안 했을까. 이유는 명확하다. 첫째, UI 테스트의 함정. 로그인 페이지 자동화. 쉽다. driver.find_element(By.ID, "username").send_keys("test") driver.find_element(By.ID, "password").send_keys("1234") driver.find_element(By.ID, "login-btn").click()3줄이면 끝. 그런데 메인 페이지는 다르다. 애니메이션 들어간 대시보드. 차트가 동적으로 그려진다. 무한 스크롤. 레이지 로딩. 실시간 업데이트. 어떻게 자동화하나. # 차트 로딩 대기 WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CLASS, "chart-loaded")) )# 아니다, 10초론 부족하다 # 15초? 20초? # 네트워크 느리면 30초도 모자라다대기 시간을 늘리면 테스트가 느려진다. 줄이면 Flaky Test가 된다. 실패했다가 다시 돌리면 성공한다. 이게 버그인가 타이밍 문제인가.둘째, 써드파티 API. 결제 시스템. PG사 API 쓴다. 테스트 서버 있다. 좋다. 그런데 불안정하다. def test_payment(): response = requests.post( "https://testpay.pg.com/api/v1/payment", json={"amount": 10000} ) assert response.status_code == 200오전엔 성공한다. 오후엔 타임아웃. 저녁엔 502 Bad Gateway. 우리 코드 문제가 아니다. PG사 테스트 서버가 그렇다. 이걸 자동화에 넣으면 CI가 계속 깨진다. 개발자들이 묻는다. "이거 우리 버그예요?" "아니요, PG사 서버 문제예요." "그럼 테스트를 왜 돌려요?" 할 말이 없다. 결국 Mocking한다. @mock.patch('requests.post') def test_payment(mock_post): mock_post.return_value.status_code = 200 # 테스트 진행이게 진짜 테스트일까. 실제 PG사 API는 안 건드린다. 우리가 만든 가짜 응답만 테스트한다. 커버리지는 올라간다. 100개 케이스 추가. 실제 품질은? 모른다. 73%의 의미 우리 자동화 커버리지 73%. 이게 낮은 걸까. 계산해봤다. E2E 테스트: 120개 (자동화 48개, 40%) 풀 사용자 플로우. 회원가입부터 결제까지. 시나리오가 길다. 15분짜리도 있다. 중간에 하나만 깨져도 전체가 실패. 네트워크, 써드파티, 브라우저 버전, OS 차이. 변수가 너무 많다. 40% 자동화도 선방한 거다. 통합 테스트: 680개 (자동화 612개, 90%) API 테스트. 이건 쉽다. def test_user_api(): response = client.get("/api/users/1") assert response.status_code == 200 assert response.json()["name"] == "테스트"서버만 있으면 된다. UI 없다. 브라우저 없다. 빠르다. 안정적이다. 90% 자동화. 만족스럽다. 단위 테스트: 1,600개 (자동화 1,092개, 68%) 함수 단위 테스트. 개발자가 짜야 하는데 안 짠다. "QA가 테스트하잖아요." 이런 케이스는 우리가 자동화해도 의미 없다. 코드 레벨 테스트는 개발자 몫이다.전체 73%. E2E 40%, 통합 90%, 단위 68%. 피라미드 구조다. 정상이다. 그런데 경영진은 하나의 숫자만 본다. "73%요. 낮네요." 100%의 환상 전 직장 동료 H. 대기업 QA였다. 자동화 커버리지 95% 달성했다고 자랑했다. "어떻게 했어?" "UI 테스트 다 넣었지." "Flaky Test는?" "많아. 그냥 3번 돌려서 1번 성공하면 pass 처리." 이게 95%의 실체다. 불안정한 테스트를 숫자에 포함시킨다. 의미 없는 테스트도 넣는다. def test_homepage_loads(): driver.get("https://example.com") assert "Example" in driver.title이런 테스트. 10초 걸린다. 확인하는 건 타이틀뿐. 이게 1개 케이스로 카운트된다. 커버리지는 올라간다. 실효성은 낮다. 또 다른 회사 K. 자동화 커버리지 100% 달성. 비결이 뭘까. 테스트 케이스를 줄였다. 2,000개 → 800개. 자동화 어려운 케이스는 아예 삭제. "이건 매뉴얼로 하면 돼요." 남은 800개만 자동화. 800/800, 100%. 숫자는 예쁘다. 실제 커버리지는? 40%. 현실적인 목표 그래서 내 결론. 80%가 현실이다. 100%는 환상이다. 자동화 가능한 것: 80% 불가능한 것: 20% 20%는 이렇다.복잡한 UI 인터랙션드래그 앤 드롭 멀티터치 제스처 캔버스 기반 에디터 실시간 화상 채팅불안정한 외부 의존성써드파티 API 테스트 서버 결제 시스템 샌드박스 SNS 로그인 연동 이메일/SMS 발송 확인시각적 검증디자인 QA 애니메이션 자연스러움 반응형 레이아웃 미세 조정 다국어 텍스트 overflow탐색적 테스트예상 못 한 버그 찾기 사용자 관점 UX 검증 경계값 실험 직관적 이상함 감지이 20%는 사람이 한다. 비효율적이지 않다. 필수다. 80%를 지키는 법 그럼 80%는 어떻게 달성할까. 내 원칙 5가지. 1. 테스트 피라미드 따르기 E2E: 최소한만 (10%) 통합: 핵심 기능 (40%) 단위: 최대한 많이 (50%) E2E 많이 만들면 커버리지 빨리 오른다. 하지만 느리고 불안정하다. 단위 테스트가 기반이어야 한다. 2. Flaky Test 절대 용납 안 함 테스트가 불안정하면 즉시 수정하거나 삭제. "가끔 실패해요"는 없다. 실패하는 테스트는 신뢰를 깎는다. CI가 빨간색이면 모두가 무시한다. 3. Mocking의 한계 인정 Mocking은 도구다. 만능이 아니다. 외부 API는 Mock한다. 하지만 통합 테스트는 따로 둔다. 주 1회, 실제 테스트 서버 대상으로 돌린다. 느려도 괜찮다. 정확도가 중요하다. 4. ROI 계산하기 이 테스트 자동화하는 시간: 3시간 매뉴얼로 하는 시간: 5분 실행 빈도: 월 1회 3시간 투자해서 월 5분 절약. 의미 없다. 자주 실행하는 것만 자동화한다. CI에 들어갈 테스트, 회귀 테스트, 스모크 테스트. 5. 유지보수 고려 자동화 코드도 코드다. 관리해야 한다. UI 바뀌면 스크립트도 바뀐다. Page Object Pattern 쓴다. class LoginPage: def __init__(self, driver): self.driver = driver def enter_username(self, username): self.driver.find_element(By.ID, "username").send_keys(username) def enter_password(self, password): self.driver.find_element(By.ID, "password").send_keys(password)UI 변경되면 이 클래스만 수정. 테스트 코드 100개 건드릴 필요 없다. 경영진 설득하기 다시 CTO에게 설명했다. "100% 불가능한 이유 3가지 말씀드리겠습니다." 회의실 화이트보드. 피라미드 그렸다. "첫째, 테스트 피라미드 구조상 E2E는 소수여야 합니다. 우리는 40% 자동화했고, 이는 업계 평균 이상입니다." "둘째, 써드파티 의존성. 결제, 인증, 알림 시스템. 이들의 테스트 환경은 불안정합니다. Mock으로 대체 가능하지만, 실제 커버리지는 낮아집니다." "셋째, 시각적 요소와 탐색적 테스트. 이는 자동화로 대체 불가능합니다. 사람의 눈과 직관이 필요합니다." CTO가 고개를 끄덕였다. "그럼 현재 73%는?" "안정적으로 유지 가능한 최대치입니다. 80%까지는 올릴 수 있지만, 3개월 소요됩니다. 우선순위 조정 필요합니다." "신규 기능 개발이 우선인가요, 자동화 커버리지 증가가 우선인가요?" 침묵. "신규 기능이 우선입니다." "그렇다면 73% 유지하면서, 신규 기능에 대한 자동화는 즉시 추가하겠습니다. 분기마다 5%씩 증가 목표로 하겠습니다." 현실적인 제안. 승인됐다. 숫자의 함정 자동화 커버리지는 숫자다. 숫자는 거짓말을 한다. 95%인데 Flaky Test 투성이인 팀. 70%인데 모든 테스트가 안정적인 팀. 어느 쪽이 나을까. 당연히 후자다. 커버리지 높이기는 쉽다. 의미 없는 테스트 추가하면 된다. 케이스 줄이면 된다. Mock 남발하면 된다. 하지만 품질은 안 올라간다. 진짜 중요한 건 이거다. "이 자동화가 버그를 잡아낼 수 있나?" 73%의 테스트가 버그 100개 찾는다. 95%의 테스트가 버그 50개 찾는다. 나는 73%를 선택한다. 3개월 후 현재 커버리지: 77%. 4% 올렸다. 추가한 테스트: 96개. 모두 통합 테스트. API 위주. 안정적이다. CI에서 한 번도 안 깨졌다. E2E는 2개만 추가. 핵심 시나리오만. 회원가입-구매-환불. UI 변경 적은 부분만 골랐다. CTO가 물었다. "80% 언제 되나요?" "다음 분기요." "100%는?" "현실적으로 어렵습니다. 대신 77% 테스트가 모두 의미 있습니다. 한 번도 False Positive 없었습니다." 고개를 끄덕인다. "좋습니다. 품질이 더 중요하죠." 드디어 이해했다. 자동화의 본질 자동화는 수단이다. 목적이 아니다. 목적은 품질이다. 100% 자동화해도 버그 못 잡으면 무용지물. 50% 자동화해도 치명적 버그 잡으면 성공. 숫자에 집착하지 않는다. 대신 이것만 지킨다.모든 자동화 테스트는 안정적이어야 한다. 실패는 항상 실제 버그여야 한다. 빠르게 실행되어야 한다. 유지보수가 쉬워야 한다. 비용 대비 효과가 있어야 한다.이 5가지를 지키는 게 80%다. 100%는 욕심이다.오늘도 CI 빌드 통과. 77% 자동화, 0% Flaky. 이게 진짜 성공이다.

CI에서 깨졌는데 로컬에서는 왜 된다고 나와요?

CI에서 깨졌는데 로컬에서는 왜 된다고 나와요?

CI에서 깨졌는데 로컬에서는 왜 된다고 나와요? 새벽 2시의 슬랙 알람 슬랙이 울렸다. 새벽 2시. "Jenkins Build #847 Failed" 침대에서 폰 켰다. 빨간 불. 또다. 로컬에서는 분명 다 통과했다. 커밋 푸시 전에 세 번 돌렸다. 전부 초록불. 근데 CI에서는 깨졌다. 이게 벌써 이번 주만 다섯 번째다. "환경 차이요? 그게 뭔데요?" 신입이 물었다. 어제. 설명했다. 30분. 근데 오늘 또 똑같은 문제로 슬랙이 왔다. 그래서 쓴다. 이 글. 로컬은 네 컴퓨터, CI는 남의 집 로컬에서 테스트 돌린다. MacBook Pro. 16GB 램. Python 3.9.7. Chrome 119. Jenkins에서 테스트 돌린다. Ubuntu 20.04. 8GB 램. Python 3.9.2. Chrome 118. 다르다. 당연히 다르다. 근데 개발자들은 모른다. "똑같은 코드잖아요." 코드는 같다. 환경이 다르다.Python 버전부터 다르다 로컬: Python 3.9.7 Jenkins: Python 3.9.2 "마이너 버전 차이인데요?" 맞다. 근데 dict 순서 보장이 3.9.2에서 미묘하게 달랐다. 테스트가 dict key 순서에 의존하고 있었다. 로컬에서는 통과. CI에서는 실패. 원인 찾는 데 4시간 걸렸다. 크롬 버전도 다르다 Selenium으로 E2E 테스트 돌린다. 로컬에서는 크롬 자동 업데이트 됐다. 119. Jenkins에서는 Docker 이미지에 박혀 있다. 118. 버튼 클릭 타이밍이 달라졌다. WebDriverWait 타임아웃이 로컬에서는 충분했다. 3초. Jenkins에서는 부족했다. 5초 필요. 타임존이 다르다 로컬: Asia/Seoul (UTC+9) Jenkins: UTC 날짜 테스트가 깨졌다. datetime.now() 썼다. 로컬에서는 오늘. Jenkins에서는 어제. assert 실패. 가장 흔한 5가지 원인 이제 패턴이 보인다. 7년 하니까. 1. 환경 변수가 없다 로컬에는 .env 파일 있다. API_KEY=abc123 DB_HOST=localhost TIMEOUT=30Jenkins에는 없다. 당연히 없다. 깃에 안 올렸으니까. 테스트가 환경 변수 읽는다. None 나온다. 터진다. 해결책: Jenkins 환경 변수 설정. Credentials Plugin 쓴다. 시크릿 관리. 근데 매번 까먹는다. 새 변수 추가하면. 체크리스트 만들었다. 커밋 전에 확인.2. 파일 경로가 절대경로다 테스트 코드에 이렇게 썼다. test_data = '/Users/jiyeon/project/test_data.json'로컬에서는 된다. 내 맥북 경로니까. Jenkins에서는 안 된다. Jenkins 서버에 그 경로 없으니까. FileNotFoundError. 상대경로 써야 한다. test_data = os.path.join(os.path.dirname(__file__), 'test_data.json')이것도 자주 실수한다. 후배들이. 코드 리뷰 때마다 지적한다. 3. 네트워크가 다르다 로컬에서는 회사 내부망 접속 된다. VPN 연결돼 있어서. Jenkins는 AWS에 있다. 내부망 접속 안 된다. 스테이징 서버 API 호출이 안 된다. 타임아웃 난다. 60초 기다리다가 실패. 해결책: Jenkins에서도 VPN 연결 설정. 또는 테스트용 API 엔드포인트 따로 만들기. 근데 이거 DevOps팀이랑 협의 필요하다. 귀찮다. 4. 캐시가 남아있다 로컬에서는 pytest 캐시 쌓인다. .pytest_cache/ 폴더. 이전 테스트 결과 기억한다. Jenkins에서는 매번 clean build. 캐시 없다. 테스트가 캐시에 의존하고 있었다. 몰랐다. Jenkins에서만 실패. pytest --cache-clear 로컬에서 돌려봤다. 재현됐다. 5. Docker 컨테이너 리소스 부족 Jenkins에서 Docker 컨테이너로 테스트 돌린다. 메모리 제한 걸려있다. 2GB. 로컬에서는 제한 없다. 16GB 다 쓴다. Selenium 테스트 10개 동시 실행. 로컬: 문제없음. Jenkins: OOMKilled. 메모리 터졌다. 해결책: 병렬 실행 수 줄이기. 또는 컨테이너 메모리 늘리기. 디버깅 방법론 패턴 알았으니 대응한다. 1단계: 로컬에서 CI 환경 재현 Jenkins에서 쓰는 Docker 이미지 똑같이 받는다. docker pull jenkins/jenkins:lts로컬에서 같은 이미지로 컨테이너 띄운다. docker run -it jenkins/jenkins:lts /bin/bash그 안에서 테스트 돌린다. 재현되면 환경 문제 확정.2단계: 환경 변수 출력 테스트 시작할 때 환경 정보 전부 출력한다. import os import sys import platformprint(f"Python: {sys.version}") print(f"OS: {platform.system()} {platform.release()}") print(f"ENV: {os.environ}")로컬 출력이랑 Jenkins 출력 비교한다. 차이 찾는다. 보통 여기서 원인 나온다. 3단계: 단계별 로그 추가 테스트 실패하는 부분 찾는다. 그 앞뒤로 로그 추가한다. logger.info("Before API call") response = api.get('/endpoint') logger.info(f"Response: {response.status_code}") logger.info(f"Body: {response.text}")Jenkins 콘솔 로그 본다. 어디서 멈췄는지 안다. 타임아웃인지, 에러인지, 응답이 다른지. 4단계: 환경 통일 자동화 매번 수동으로 맞추기 귀찮다. 자동화한다. Docker Compose 쓴다. version: '3' services: test: image: python:3.9.7 environment: - TZ=Asia/Seoul - API_KEY=${API_KEY} volumes: - .:/app command: pytest tests/로컬에서도 이걸로 돌린다. Jenkins에서도 이걸로 돌린다. 환경 일치. 환경 변수 관리하는 법 제일 골치 아픈 부분이다. .env 파일 vs Jenkins Credentials 로컬 개발: .env 파일 CI/CD: Jenkins Credentials Plugin 문제: 변수 추가할 때 두 곳 다 업데이트해야 함. 자주 까먹는다. 해결책: 변수 목록 문서화. README에 필수 환경 변수 리스트 적는다. ## Required Environment Variables - API_KEY: API 인증 키 - DB_HOST: 데이터베이스 호스트 - TIMEOUT: 테스트 타임아웃 (초)신입이 보고 설정할 수 있게. 민감 정보 관리 API 키, DB 비밀번호. 깃에 올리면 안 된다. 로컬: .env (gitignore에 추가) Jenkins: Credentials 저장 테스트 코드에서는 환경 변수로만 접근. api_key = os.getenv('API_KEY') if not api_key: raise ValueError("API_KEY not set")없으면 즉시 실패. 명확한 에러 메시지. "근데 Jenkins에서는 어떻게 주입하나요?" Jenkinsfile에서. withCredentials([string(credentialsId: 'api-key', variable: 'API_KEY')]) { sh 'pytest tests/' }이렇게. Docker로 환경 통일하기 가장 확실한 방법. Dockerfile 작성 테스트 전용 이미지 만든다. FROM python:3.9.7# 타임존 설정 ENV TZ=Asia/Seoul RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime# 크롬 설치 RUN apt-get update && apt-get install -y \ wget \ gnupg \ && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ && echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list \ && apt-get update \ && apt-get install -y google-chrome-stable# 의존성 설치 COPY requirements.txt . RUN pip install -r requirements.txtWORKDIR /app로컬에서도 이걸로 돌린다. docker build -t test-env . docker run -v $(pwd):/app test-env pytestJenkins에서도 이걸로 돌린다. docker.build('test-env').inside { sh 'pytest tests/' }환경 일치. 문제 사라짐. Docker Compose로 전체 스택 띄우기 DB, Redis 필요한 테스트 있다. 로컬에서 직접 설치 귀찮다. Docker Compose 쓴다. version: '3' services: db: image: postgres:13 environment: POSTGRES_PASSWORD: test redis: image: redis:6 test: build: . depends_on: - db - redis environment: DB_HOST: db REDIS_HOST: redis volumes: - .:/app실행. docker-compose up --abort-on-container-exit test전체 환경 일관성 있게 띄워짐. 로컬이든 Jenkins든 똑같이. 타임존과 날짜 테스트 자주 무시되는 부분. 근데 자주 터진다. 문제 상황 def test_today(): today = datetime.now().date() assert get_today_logs() == today로컬: Asia/Seoul. 2024-01-15 14:00. Jenkins: UTC. 2024-01-15 05:00. datetime.now() 결과 다르다. assert 실패. 해결책 1: UTC로 통일 모든 시간을 UTC로. from datetime import datetime, timezonedef test_today(): today = datetime.now(timezone.utc).date() assert get_today_logs() == today서버도 UTC. 테스트도 UTC. 통일. 해결책 2: 환경 변수로 타임존 설정 Docker에서 타임존 주입. environment: - TZ=Asia/Seoul이러면 컨테이너 내부 시간이 서울 시간. 근데 권장은 UTC 통일. 서버 시간은 항상 UTC가 표준이니까. 네트워크 이슈 대응 API 테스트할 때 자주 나온다. 문제: 내부망 접근 불가 로컬: 회사 네트워크. 내부 API 접근 가능. Jenkins: AWS. 내부망 차단. 테스트 실패. 해결책 1: VPN 설정 Jenkins 서버에서 VPN 연결. 근데 복잡하다. DevOps 도움 필요. 해결책 2: 테스트용 공개 엔드포인트 스테이징 서버를 외부 접근 가능하게. 보안팀 승인 받아야 한다. 귀찮다. 해결책 3: Mock 사용 외부 API는 Mock으로. @patch('requests.get') def test_api_call(mock_get): mock_get.return_value.status_code = 200 mock_get.return_value.json.return_value = {'data': 'test'} result = fetch_data() assert result == {'data': 'test'}네트워크 의존성 제거. 가장 안정적. 근데 실제 API 동작은 못 테스트한다. Trade-off. 실전 체크리스트 커밋 전에 확인한다. 환경 독립성 체크 절대경로 사용 안 함 환경 변수 의존성 문서화 .env.example 파일 업데이트 로컬 캐시에 의존 안 함CI 재현 테스트 Docker로 로컬 재현 성공 환경 변수 누락 없음 타임존 영향 확인 네트워크 의존성 명확화로그 및 디버깅 실패 시 충분한 로그 출력 환경 정보 출력 코드 추가 타임아웃 값 명시적 설정이거 지키면 90% 해결된다. 나머지 10%는 경험. 결론 "CI에서 깨졌는데 로컬에서는 돼요." 이제 안 무섭다. 체계적으로 접근한다.환경 차이 파악 Docker로 재현 로그로 원인 찾기 환경 통일 자동화시간은 걸린다. 근데 한 번 세팅하면 끝. 그 다음부터는 편하다. 신입 후배한테 이 글 링크 보낸다. 다음에 또 물어보면.새벽 2시 슬랙. 이제 덜 무섭다. 체크리스트 있으니까.

개발자가 UI 한 줄 바꿀 때마다 내 스크립트는 왜 폭탄이 될까

개발자가 UI 한 줄 바꿀 때마다 내 스크립트는 왜 폭탄이 될까

개발자가 UI 한 줄 바꿀 때마다 내 스크립트는 왜 폭탄이 될까 새벽 2시의 슬랙 알람 새벽 2시에 핸드폰이 울렸다. Jenkins 알람. 또 깨졌다. "Nightly build failed. 67 tests broken." 눈을 비비고 노트북을 켰다. 테스트 리포트를 열었다. 로그인 버튼을 못 찾는다고 한다. 전부. "ElementNotInteractableException: element not found: #login-btn" 어제 저녁까지 멀쩡했다. 67개 테스트가 동시에 깨질 리 없다. 무언가 바뀐 거다. 개발팀 슬랙을 확인했다. 밤 11시에 프론트엔드 개발자가 올린 커밋. "refactor: button ID 네이밍 컨벤션 변경 (#login-btn → #btn-login)" 이 한 줄. 이 한 줄 때문에 67개. 다시 잤다. 아침에 고치기로 했다.출근해서 본 현실 9시 50분 출근. 커피부터. 테스트 결과를 다시 봤다. 67개가 아니었다. 89개. 로그인 버튼만 바뀐 게 아니었다. 메뉴 버튼, 검색창, 확인 버튼. ID가 전부 바뀌었다. 프론트 개발자한테 물었다. "형, ID 바꿨어?" "응. 네이밍 규칙 통일했어. 코드 리뷰에서 지적받아서." "QA팀한테 얘기 없었는데." "아 미안. 그냥 ID만 바꾼 거라." 그냥 ID만. 그 말이 제일 무섭다. 테스트 스크립트 89개를 열었다. find_element_by_id를 찾았다. 247개. 하나씩 고쳐야 한다.왜 매번 깨지는가 이게 처음이 아니다. 지난달에는 class name이 바뀌었다. "btn-primary"가 "primary-button"이 됐다. 스크립트 52개 수정. 그 전 달에는 div 구조가 바뀌었다. XPath가 전부 틀어졌다. 스크립트 38개 수정. 매달 이런다. 개발자는 UI를 개선한다. 나는 스크립트를 고친다. 왜 이렇게 취약한가. 로케이터 전략이 문제다. 나는 ID로 찾고, class로 찾고, XPath로 찾는다. 개발자는 그걸 바꾼다. 끝. Selenium 코드를 봤다. driver.find_element_by_id("login-btn").click() driver.find_element_by_class_name("btn-primary").click() driver.find_element_by_xpath("//div[@class='container']/button[1]").click()이게 89개 파일에 흩어져 있다. 하나 바뀌면 전부 바꿔야 한다. 문제는 결합도다. 테스트 코드가 UI 구현에 직접 의존한다. UI가 바뀌면 테스트가 깨진다. 필연이다. 해결책은 간단하다. 추상화.페이지 객체 모델이 답이다 POM. Page Object Model. 들어는 봤다. 써본 적은 없었다. 개념은 단순하다. UI를 클래스로 감싼다. 테스트는 클래스를 쓴다. UI가 바뀌면 클래스만 고친다. LoginPage 클래스를 만들었다. class LoginPage: def __init__(self, driver): self.driver = driver self.login_button = (By.ID, "login-btn") self.username_input = (By.ID, "username") self.password_input = (By.ID, "password") def click_login(self): self.driver.find_element(*self.login_button).click() def enter_username(self, username): self.driver.find_element(*self.username_input).send_keys(username)테스트 코드가 바뀌었다. # 전 driver.find_element_by_id("username").send_keys("test@test.com") driver.find_element_by_id("password").send_keys("password123") driver.find_element_by_id("login-btn").click()# 후 login_page = LoginPage(driver) login_page.enter_username("test@test.com") login_page.enter_password("password123") login_page.click_login()ID가 바뀌면 어떻게 되나. LoginPage만 고친다. 한 군데. 89개 테스트를 LoginPage를 쓰게 바꿨다. 이틀 걸렸다. 다음 날 ID가 또 바뀌었다. "#btn-login"이 "#submit-login"이 됐다. LoginPage 한 줄만 고쳤다. 30초. 효과는 즉시 나타났다. 로케이터 전략의 우선순위 POM을 쓰기 시작하면서 로케이터 전략을 다시 생각했다. 무엇으로 요소를 찾을 것인가. ID? Class? XPath? 우선순위를 정했다. 1순위: data 속성 가장 안정적이다. UI용이 아니라 테스트용이다. 개발자한테 부탁했다. "테스트용 속성 좀 넣어줘." <button id="btn-login" data-testid="login-button">로그인</button>data-testid는 바뀔 이유가 없다. UI 디자인과 무관하다. 테스트만을 위한 속성이다. self.login_button = (By.CSS_SELECTOR, "[data-testid='login-button']")처음엔 개발자가 귀찮아했다. "매번 넣어야 돼?" 설득했다. "한 번만 넣으면 됩니다. 안 바뀌잖아요." 점점 늘었다. 지금은 주요 버튼에 다 들어간다. 2순위: ID ID는 유일하다. 빠르다. 하지만 바뀐다. 프론트 개발자가 네이밍 규칙을 바꾸면 ID가 바뀐다. 리팩토링하면 ID가 바뀐다. ID를 쓸 땐 POM 안에만 쓴다. 바뀌면 POM만 고친다. 3순위: CSS Selector class는 자주 바뀐다. 디자인 시스템이 바뀌면 class가 바뀐다. 대신 구조로 찾는다. self.submit_button = (By.CSS_SELECTOR, "form.login-form button[type='submit']")form 안의 submit 버튼. 구조는 잘 안 바뀐다. 꼴찌: XPath XPath는 최후의 수단이다. 취약하다. 느리다. # 나쁜 예 driver.find_element_by_xpath("//div[@class='container']/div[2]/button[1]")div 하나만 추가돼도 깨진다. 순서가 바뀌면 깨진다. XPath를 써야 하면 상대 경로로. # 조금 나은 예 driver.find_element_by_xpath("//button[contains(text(), '로그인')]")텍스트로 찾는다. 텍스트는 안 바뀐다. (국제화하면 바뀐다. 그것도 문제다.) 우선순위를 정하니 스크립트가 안정적이 됐다. 깨지는 빈도가 70% 줄었다. 대기 전략도 중요하다 로케이터만 문제가 아니었다. 가끔 요소를 찾는데 "ElementNotInteractableException"이 뜬다. 요소는 있다. 근데 클릭이 안 된다. 왜냐. 아직 로딩 중이다. 옛날 코드를 봤다. driver.find_element_by_id("login-btn").click()요소가 나타날 때까지 안 기다린다. 바로 찾는다. 없으면 실패. time.sleep(3)을 넣었다. 3초 기다린다. time.sleep(3) driver.find_element_by_id("login-btn").click()문제가 생긴다.요소가 1초에 뜨면 2초를 낭비한다. 요소가 5초 걸리면 실패한다. 테스트가 느려진다. 전체 런타임 30분이 1시간이 됐다.해결책은 명시적 대기. Explicit Wait. from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as ECwait = WebDriverWait(driver, 10) element = wait.until(EC.element_to_be_clickable((By.ID, "login-btn"))) element.click()최대 10초 기다린다. 요소가 클릭 가능해지면 즉시 진행한다. 1초에 뜨면 1초만 기다린다. POM에 넣었다. class LoginPage: def __init__(self, driver): self.driver = driver self.wait = WebDriverWait(driver, 10) def click_login(self): button = self.wait.until( EC.element_to_be_clickable((By.CSS_SELECTOR, "[data-testid='login-button']")) ) button.click()Flaky 테스트가 80% 줄었다. 런타임은 원래대로 돌아왔다. 개발팀과의 커뮤니케이션 기술만으로는 부족하다. 사람이 문제다. 개발자는 QA를 모른다. 테스트 스크립트가 있는지도 모른다. 버튼 ID 바꾸면 테스트가 깨지는지 모른다. 몰라서 그런다. 알려줘야 한다. 1. PR에 QA 태그 요청 프론트 개발자한테 부탁했다. "UI 바꾸는 PR엔 저 태그해주세요." 처음엔 깜빡했다. 그래도 계속 말했다. 지금은 습관이 됐다. 태그 받으면 PR을 본다. data-testid가 바뀌나 확인한다. 바뀌면 댓글 단다. "이 속성 테스트에서 쓰고 있어요. 바꾸면 스크립트 수정해야 합니다." 대부분은 안 바꾸는 쪽으로 간다. 가끔은 같이 수정한다. 2. 테스트 커버리지 공유 월요일 스탠드업 때 공유한다. "로그인 플로우 자동화 완료했습니다. 이제 이 부분 건드리면 자동으로 테스트됩니다." 개발자가 안다. 어느 부분이 자동화됐는지. 조심하게 된다. 3. 깨진 테스트 즉시 알림 Jenkins가 실패하면 슬랙에 알람 간다. 개발팀 채널에. "[E2E Test Failed] Login flow broken by commit abc123" 커밋 해시까지 보인다. 누가 깨트렸는지 바로 안다. 처음엔 싫어했다. "버그도 아닌데 왜 알람 와요?" 설명했다. "스크립트가 깨진 것도 비용입니다. 고치는 데 시간이 듭니다." 지금은 자기가 커밋하면 테스트 결과를 확인한다. 깨지면 바로 연락 온다. 4. 테스트용 속성 가이드 문서 Confluence에 문서를 만들었다. "E2E 테스트 친화적인 프론트엔드 개발 가이드"data-testid 네이밍 규칙 동적 ID 피하기 (타임스탬프, 랜덤 문자열) 테스트에서 사용 중인 셀렉터 목록신입 프론트 개발자 온보딩 때 읽게 했다. 리드 개발자가 코드 리뷰할 때 체크한다. 문화가 바뀌었다. 개발자가 먼저 물어본다. "이거 테스트에서 쓰는 거죠?" 그래도 깨질 때 모든 걸 해도 깨진다. 리팩토링. 디자인 시스템 전면 개편. 프레임워크 마이그레이션. 지난달에 React 16에서 18로 올렸다. 렌더링 방식이 바뀌었다. 타이밍이 달라졌다. 테스트 34개가 깨졌다. 어쩔 수 없다. 고친다. 하지만 POM 덕분에 빠르다. 34개 테스트를 고쳤지만 실제로는 5개 페이지 클래스만 수정했다. 전엔 34개 파일을 열어서 일일이 고쳤다. 지금은 5개. 시간이 1/7로 줄었다. 회고 개발자가 UI 바꿀 때마다 스크립트가 폭탄 되는 이유.로케이터가 취약하다. (ID, class 직접 의존) 중복이 많다. (같은 셀렉터가 여러 파일에) 추상화가 없다. (UI와 테스트가 직접 결합) 개발팀이 모른다. (테스트 영향을 생각 안 함)해결책.POM으로 추상화한다. 로케이터 우선순위를 정한다. (data-testid > ID > CSS > XPath) 명시적 대기를 쓴다. 개발팀과 소통한다.완벽할 순 없다. UI는 계속 바뀐다. 테스트도 계속 고쳐야 한다. 하지만 구조를 잘 만들면 유지보수가 쉽다. 한 군데만 고치면 된다. 자동화는 코드다. 코드는 설계가 중요하다. 설계 없이 짠 코드는 레거시가 된다. 테스트 코드도 마찬가지다. 지금 LoginPage 클래스를 본다. 깔끔하다. ID가 바뀌어도 여기만 고치면 된다. 89개 파일을 열던 시절이 기억난다. 지금은 1개만 연다. POM이 답이었다.새벽 알람은 여전히 온다. 하지만 이젠 30초면 고친다. 다시 잔다.

테스트 피라미드: 왜 E2E 테스트만 100개 돌리는 팀은 실패하는가

테스트 피라미드: 왜 E2E 테스트만 100개 돌리는 팀은 실패하는가

테스트 피라미드: 왜 E2E 테스트만 100개 돌리는 팀은 실패하는가 월요일 아침, 빌드가 깨졌다 출근했다. 슬랙에 알림 37개. "빌드 실패", "테스트 타임아웃", "CI 30분째 돌아가는 중". 아침 9시. 젠킨스 보니까 E2E 테스트가 2시간째 돌고 있다. 103개 테스트 중 47번째에서 멈췄다. 셀레니움이 로딩 스피너를 못 찾는다. 또. 옆자리 개발자가 말했다. "배포 언제 돼요? 오전 회의에서 발표해야 하는데." 나도 모른다. 테스트가 끝나봐야 안다. 이게 3일째다.작년 이맘때, 우리는 E2E를 사랑했다 입사 1년 전 이야기다. 그때 우리 팀은 E2E 테스트를 막 도입했다. PM이 말했다. "실제 사용자 시나리오를 테스트해야죠." 맞는 말이었다. 첫 E2E 테스트를 짰다. 로그인 → 상품 검색 → 장바구니 → 결제. 완벽했다. 실제 브라우저에서 돌아갔다. 버그를 3개 찾았다. 개발팀이 감탄했다. "역시 E2E네요!" 그래서 더 짰다. 회원가입 시나리오. 10개 케이스. 결제 시나리오. 15개 케이스. 마이페이지. 8개 케이스. 6개월 후, E2E 테스트가 103개였다. 실행 시간은 2시간 30분. 이때부터 문제가 시작됐다. 금요일 오후, 아무도 배포 안 하려는 이유 금요일 4시. 개발자가 PR 올렸다. "간단한 버그 픽스입니다. 머지 부탁드려요." 나도 간단해 보였다. CSS 한 줄 수정. 그런데 규칙이 있었다. "모든 PR은 E2E 테스트 통과 후 머지." 버튼 눌렀다. 젠킨스가 103개 테스트를 돌리기 시작했다. 2시간 기다렸다. 92번째 테스트에서 실패. "Element not found: 로그아웃 버튼." CSS 수정인데 왜 로그아웃 테스트가 깨지나. 로그 봤다. 타임아웃이었다. 페이지 로딩이 평소보다 2초 늦었다. 재실행했다. 이번엔 통과했다. 시계 봤다. 저녁 7시. 개발자는 퇴근했다. 금요일 배포는 없었다. 다음 주 월요일에 머지했다. CSS 한 줄 때문에 3일 걸렸다.화요일 점심, CTO가 물었다 회의실. CTO가 들어왔다. "배포 속도가 너무 느립니다. 경쟁사는 하루 10번 배포하는데." 개발 리드가 말했다. "테스트 때문입니다. 2시간 넘게 걸려요." CTO가 나를 봤다. "테스트를 줄일 수 있나요?" 나는 답했다. "어떤 테스트를요? 다 중요합니다." CTO가 물었다. "정말 103개가 다 필요한가요?" 솔직히 모르겠다. 6개월 동안 쌓다 보니 103개가 됐다. 어느 게 중요한지 구분 못 했다. 회의 끝나고 책상 돌아왔다. 구글링했다. "E2E test too slow". 테스트 피라미드가 나왔다. 마틴 파울러의 글이었다. 그때 깨달았다. 우리는 피라미드를 거꾸로 세우고 있었다. 테스트 피라미드, 내가 이해한 것 그림을 그렸다. 삼각형. 피라미드. 밑에서부터:유닛 테스트 (70%) 통합 테스트 (20%) E2E 테스트 (10%)우리 팀 현실:유닛 테스트 (5%) 통합 테스트 (5%) E2E 테스트 (90%)완전 거꾸로였다. 왜 피라미드 모양이어야 하나. 이유는 간단했다. 속도. 유닛 테스트: 0.1초. 통합 테스트: 2초. E2E 테스트: 90초. 안정성. 유닛 테스트: 깨지면 코드 문제. 통합 테스트: 깨지면 연동 문제. E2E 테스트: 깨지면... 네트워크? 타임아웃? 셀레니움? 모름. 유지보수. 유닛 테스트: 함수 이름 바꾸면 끝. 통합 테스트: API 스펙 바꾸면 수정. E2E 테스트: UI 바뀌면 셀렉터 다 깨짐. 계산해봤다. 103개 E2E를 70개 유닛 + 20개 통합 + 13개 E2E로 바꾸면. 실행 시간:기존: 2시간 30분 변경 후: 12분10배 빨라진다.수요일 오전, 설득 시작 개발팀 회의. 화이트보드에 피라미드 그렸다. "우리는 지금 이렇게 테스트합니다." 거꾸로 된 피라미드. "이래야 합니다." 정상 피라미드. 개발자 한 명이 물었다. "유닛 테스트로 사용자 시나리오를 어떻게 검증해요?" 좋은 질문이었다. "검증 안 합니다. 유닛 테스트는 함수를 테스트해요." 예시 들었다. 로그인 기능. 기존 E2E 테스트:브라우저 열기 로그인 페이지 가기 아이디 입력 비밀번호 입력 로그인 버튼 클릭 메인 페이지 확인 프로필 메뉴 확인 로그아웃 버튼 확인90초 걸렸다. 새로운 방식: 유닛 테스트 (3개):비밀번호 검증 함수 테스트 (0.1초) 토큰 생성 함수 테스트 (0.1초) 세션 저장 함수 테스트 (0.1초)통합 테스트 (2개):로그인 API 테스트 (2초) 토큰 검증 API 테스트 (2초)E2E 테스트 (1개):실제 로그인 시나리오 (30초)총 실행 시간: 34.3초. 기존 대비 62% 빠르다. 더 중요한 건 안정성이었다. E2E가 깨지면 원인 찾기 어렵다. 유닛이 깨지면 정확히 어느 함수가 문제인지 안다. 개발 리드가 말했다. "좋은데, 누가 유닛 테스트 짜죠?" "개발자들이요." 분위기가 싸해졌다. 목요일 오후, 반발 개발자 세 명이 찾아왔다. "유닛 테스트 짜는 시간 있으면 기능 개발하겠습니다." 예상했던 반응이다. 물었다. "지난주 버그 기억나요? 결제 금액 계산 오류." 기억한다는 표정이었다. "그거 유닛 테스트 있었으면 5분 만에 찾았어요. 우리는 2시간 걸렸죠." 계산해줬다. 한 달 버그 수정 시간: 40시간. 유닛 테스트 작성 시간: 주당 2시간, 한 달 8시간. 이득: 32시간. "기능 개발 시간 더 생깁니다." 한 명이 고개 끄덕였다. 다른 개발자가 물었다. "QA가 도와줄 건가요?" "유닛은 어렵습니다. 코드를 제가 다 모르니까. 대신 통합 테스트는 제가 짜겠습니다." 역할 분담했다.개발자: 유닛 테스트 (함수, 클래스) 나: 통합 테스트 (API, 데이터베이스) 나: E2E 테스트 (핵심 시나리오만)일주일 시범 운영 제안했다. CTO가 승인했다. 2주 후, 숫자로 말하기 스프레드시트 만들었다. 비교표. 기존 방식 (2주):총 테스트: 103개 (E2E 100개, 통합 3개) 실행 시간: 평균 2시간 18분 빌드 실패율: 23% (Flaky 테스트) 평균 배포 시간: PR 머지부터 4.2시간 버그 발견: 17개 프로덕션 버그: 5개새 방식 (2주):총 테스트: 187개 (유닛 132개, 통합 38개, E2E 17개) 실행 시간: 평균 14분 빌드 실패율: 8% 평균 배포 시간: 28분 버그 발견: 31개 프로덕션 버그: 2개숫자가 말해줬다. 테스트는 81% 늘었다. 시간은 90% 줄었다. 버그는 더 많이 찾았다. 프로덕션 버그는 60% 줄었다. 개발자들이 놀랐다. "유닛 테스트가 버그를 이렇게 많이 찾네요." 당연했다. 유닛 테스트는 엣지 케이스를 확인한다. E2E는 해피 패스만 확인한다. 예시. 결제 금액 계산 함수. E2E로는 1개 케이스만 확인했다. "10,000원 상품 + 2,500원 배송비 = 12,500원" 유닛으로는 12개 케이스 확인했다.음수 금액 0원 천억 원 소수점 null 값 할인 적용 쿠폰 중복 등등실제로 버그 3개 찾았다. 음수 금액 처리 안 됨. 쿠폰 중복 적용됨. 천억 원 넘으면 오버플로우. E2E로는 절대 못 찾을 버그들이었다. 한 달 후, E2E 17개 선택 기준 회의. "E2E 103개 중 17개만 남기기." 기준 세웠다. 남길 E2E:매출 직결 시나리오 (결제, 환불) 유닛/통합으로 커버 안 되는 것 (브라우저 특성) 여러 시스템 연동 필수 (결제사, 배송사) 사용자 경험 크리티컬 (회원가입, 로그인) 법적 필수 기능 (개인정보 처리)버릴 E2E:유닛으로 커버 가능 (계산, 검증) 통합으로 충분 (API 호출) UI만 다른 중복 케이스 (버튼 위치만 다름) Flaky한 테스트 (타임아웃 자주 남) 비즈니스 임팩트 낮음 (어드민 페이지 필터)103개 리스트 출력했다. 하나씩 분류했다. 결과:유닛으로 이동: 61개 통합으로 이동: 25개 E2E 유지: 17개17개 E2E 리스트:회원가입 (이메일 인증 포함) 로그인 (소셜 로그인 포함) 상품 검색 → 상세 → 장바구니 결제 (카드, 계좌이체, 카카오페이) 주문 조회 환불 신청 리뷰 작성 1:1 문의 배송지 변경 쿠폰 적용 포인트 사용 정기 구독 신청 구독 해지 비밀번호 찾기 회원 탈퇴 장바구니 비우기 최근 본 상품이 17개만 돌리면 35분 걸렸다. 충분했다. 핵심 사용자 플로우는 다 커버했다. 3개월 후, 새로운 문제 평화로웠다. 배포는 빨라졌다. 하루 평균 8번 배포했다. 그런데 새로운 문제가 생겼다. 유닛 테스트가 너무 많아졌다. 418개. 개발자들이 열심히 짰다. 너무 열심히 짰다. 실행 시간이 3분으로 늘었다. 예전엔 30초였다. 왜? 봤더니 중복 테스트가 많았다. 예시. 회원가입 API. 유닛 테스트:이메일 형식 검증 (유닛) 비밀번호 길이 검증 (유닛) 닉네임 특수문자 검증 (유닛)통합 테스트:이메일 형식 검증 (통합) 비밀번호 길이 검증 (통합) 닉네임 특수문자 검증 (통합)중복이었다. 개발자한테 말했다. "유닛에서 검증했으면 통합에서 또 할 필요 없어요." "그럼 통합에서는 뭘 테스트해요?" "연동이요. 데이터베이스에 제대로 저장되는지. 이메일이 발송되는지." 정리했다. 유닛 테스트 역할:비즈니스 로직 계산, 검증, 변환 빠른 피드백통합 테스트 역할:시스템 연동 데이터베이스, API, 외부 서비스 실제 환경 검증E2E 테스트 역할:사용자 시나리오 여러 시스템 통합 최종 확인역할이 명확해지니까 중복이 줄었다. 418개 유닛 → 312개. 실행 시간 3분 → 1분 20초. 6개월 후, 숫자 보고 CTO 보고. 반년 데이터. 테스트 현황:유닛: 312개 통합: 94개 E2E: 17개 총: 423개실행 시간:전체: 평균 16분 유닛만: 1분 20초 통합까지: 9분 E2E 포함: 16분배포 속도:하루 평균: 11.3번 6개월 전: 1.4번 증가율: 707%품질:프로덕션 버그: 월 2.1개 6개월 전: 월 8.7개 감소율: 76%개발 생산성:기능 개발 시간: 37% 감소 (버그 수정 시간 줄어서) 핫픽스: 월 1.2회 (기존 5.8회)CTO가 만족했다. "다른 팀에도 적용합시다." 복잡했다. 지금, 다른 팀 코칭 중 지금은 백엔드팀 코칭 중이다. 그들도 비슷했다. 통합 테스트 200개. 실행 시간 40분. 피라미드 그려줬다. "유닛 테스트 먼저 짜세요." 그들이 물었다. "API 테스트가 있는데 왜 유닛이 필요해요?" 설명했다. API 테스트는 느리다. 서버 띄우고, DB 연결하고, 요청 보내고. 한 테스트에 2초. 유닛 테스트는 빠르다. 함수만 호출. 한 테스트에 0.01초. 200배 차이. 더 중요한 건 디버깅이다. API 테스트가 깨지면:라우팅 문제? DB 문제? 비즈니스 로직 문제? 권한 문제? 네트워크 문제?모른다. 다 확인해야 한다. 유닛 테스트가 깨지면:정확히 이 함수가 문제.끝. 백엔드팀이 2주째 시도 중이다. 유닛 78개 추가했다. 통합 200개 → 143개로 줄였다. 실행 시간 40분 → 18분. 잘되고 있다. 내가 배운 것들 6개월간 배웠다. 1. E2E는 마지막 방어선이다. 모든 걸 E2E로 테스트하려고 하면 망한다. E2E는 비싸다. 느리고, 불안정하고, 유지보수 어렵다. 핵심만 남겨라. 2. 유닛 테스트가 제일 중요하다. 처음엔 안 믿었다. "사용자 시나리오를 어떻게 유닛으로 테스트해?" 하지만 버그의 80%는 로직 문제다. 로직은 유닛으로 잡는다. 3. 피라미드는 속도다. 10분 안에 피드백 받아야 한다. 그래야 개발자가 컨텍스트 유지한다. 2시간 후 피드백은 의미 없다. 4. 테스트도 코드다. 중복 제거해야 한다. 리팩토링 해야 한다. 유지보수 해야 한다. 테스트가 많다고 좋은 게 아니다. 5. 역할 분담이 명확해야 한다. 유닛, 통합, E2E의 역할을 모두가 이해해야 한다. 안 그러면 중복 테스트 천지가 된다. 6. 숫자로 말해야 한다. "E2E 줄입시다"는 설득 안 된다. "실행 시간 90% 줄고, 버그 60% 줄어듭니다"는 설득된다. 7. 점진적으로 바꿔야 한다. 한 번에 103개 다 못 바꾼다. 일주일 시범, 한 달 확대, 석 달 정착. 이게 현실이다. 여전히 어려운 것 완벽하진 않다. E2E 선택 기준이 주관적이다. "이건 E2E로 해야 해요" vs "통합으로 충분해요" 여전히 의견 갈린다. 내 기준:돈이 오가는가? → E2E UI 인터랙션이 복잡한가? → E2E API만 호출하는가? → 통합 함수 호출인가? → 유닛하지만 애매한 경우가 많다. 개발자 설득이 계속 필요하다. 신입이 들어온다. 또 설명해야 한다. "왜 이런 걸 유닛으로 짜요?" 피라미드 그림 그리는 게 일상이 됐다. Flaky 테스트는 여전하다. E2E 17개 중 3개는 가끔 깨진다. 네트워크 타임아웃. 타이밍 이슈. 재시도 로직 넣었다. 3번 재시도. 2번 성공하면 통과. 완벽한 해결은 아니다. 커버리지 압박. PM이 물어본다. "커버리지 몇 퍼센트예요?" "유닛 78%, 통합까지 합치면 86%." "왜 100% 아니에요?" 설명한다. "100% 커버리지가 목표가 아니에요. 중요한 로직 커버가 목표예요." 이해 안 할 때도 있다. E2E 유지보수는 여전히 힘들다. UI 바뀌면 E2E 깨진다. 버튼 ID 바뀜. 셀렉터 수정. CSS 클래스 바뀜. 다시 수정. 17개로 줄었지만 여전히 일이다. 금요일 저녁, 배포 금요일 5시. 개발자가 PR 올렸다. "새 기능: 위시리스트 공유" 리뷰했다. 머지 승인. CI 돌아간다. 유닛 312개: 1분 20초. 통과. 통합 94개: 7분 40초. 통과. E2E 17개: 6분 30초. 통과. 총 15분 30초. 배포 버튼 눌렀다. 프로덕션 올라갔다. 5시 17분에 시작해서 5시 33분에 끝났다. 개발자한테 슬랙 보냈다. "배포 완료. 확인해보세요." "벌써요? 감사합니다!" 퇴근했다. 6시 10분. 작년 같았으면 9시까지 E2E 돌아가는 걸 지켜봤을 것이다. 지금은 정시 퇴근한다. 테스트 피라미드 덕분이다.E2E 100개 돌리는 팀은 느리고, 불안정하고, 결국 무너진다. 피라미드는 거꾸로 세우는 게 아니다. 밑에서부터 차곡차곡 쌓는 거다. 그게 빠르고, 안정적이고, 지속 가능한 방법이다.

매뉴얼 QA 후배에게 Selenium 가르치다 깨달은 것

매뉴얼 QA 후배에게 Selenium 가르치다 깨달은 것

매뉴얼 QA 후배에게 Selenium 가르치다 깨달은 것 시작은 HR 전화 "J님, 신입 한 명 들어와요. 자동화 가르쳐주세요." 3년 매뉴얼 QA 경력자. 이름은 민지. 28살. 나는 고민했다. 뭘 먼저 가르치지. Python? HTML? Git? 결론은 "일단 Selenium 돌려보자"였다. 첫날, 내 자동화 프레임워크 보여줬다. 민지 표정이 굳었다. "선배, 이게 다 뭐예요?" Page Object Model. Config 파일. Fixture. Decorator. 설명하는데 1시간. 민지는 계속 끄덕였다. 하지만 눈이 멍했다. 그때 깨달았다. 내 코드가 생각보다 복잡하다. 첫 번째 질문: "왜 find_element 안 써요?" 민지 첫 과제. 로그인 테스트 스크립트 짜기. Selenium 공식 문서 보고 짰다. 코드 리뷰 요청 왔다. driver.find_element(By.ID, "username").send_keys("test") driver.find_element(By.ID, "password").send_keys("1234") driver.find_element(By.XPATH, "//button[@type='submit']").click()내가 짠 코드는 이랬다. self.login_page.enter_username("test") self.login_page.enter_password("1234") self.login_page.click_login_button()민지가 물었다. "선배 코드엔 find_element가 없는데요?" 나는 설명했다. Page Object Model. Locator 추상화. 유지보수성. 민지는 또 끄덕였다. 근데 다음 날 코드는 여전히 find_element 투성이. 화가 나려다 멈췄다. 민지가 이해 못 한 게 아니다. 내가 "왜"를 안 알려줬다.엘리먼트가 안 잡힐 때 민지 두 번째 과제. 검색 기능 테스트. 2시간 뒤 민지가 왔다. "선배, 이거 계속 에러나요." NoSuchElementException 나는 물었다. "wait 넣었어?" "wait요?" WebDriverWait. Explicit Wait. Implicit Wait. 설명했다. 민지는 코드에 time.sleep(3) 박았다. "아니 그게 아니라..." 나는 다시 설명했다. sleep은 나쁜 습관. 테스트 느려짐. Flaky 테스트 원인. 민지가 물었다. "그럼 왜 선배 코드엔 wait이 안 보여요?" 내 Base Page 클래스 열어봤다. 모든 메소드에 wait 내장. def _wait_and_find(self, locator, timeout=10): return WebDriverWait(self.driver, timeout).until( EC.presence_of_element_located(locator) )민지는 내 코드만 봤으니 wait을 몰랐다. 나는 당연하다고 생각한 것들. 민지에겐 보이지 않았다. 프레임워크 안에 숨어있었으니까. 세 번째 질문: "이건 왜 깨져요?" 민지 자동화 스크립트 10개 짰다. CI에 올렸다. 다음 날 아침. Jenkins 빨간불. 민지 테스트 5개 실패. 민지가 당황했다. "제 컴퓨터에선 됐는데요?" Headless 모드. 크롬 버전. 타임아웃 설정. 환경변수. 나는 하나씩 체크했다. 민지는 옆에서 봤다. "선배, 이런 거 어떻게 다 알아요?" "다 겪어봐서." 실패 원인은 타임아웃이었다. CI 서버가 느렸다. 민지 코드는 타임아웃 3초 하드코딩. 내 코드는 환경변수로 관리. TIMEOUT = os.getenv('TEST_TIMEOUT', 10)민지가 물었다. "왜 이렇게 해요?" "CI는 로컬보다 느리거든." "그럼 전부 이렇게 해야 돼요?" "응." 민지 표정이 어두워졌다. "자동화 어렵네요." 나도 그랬다고 말해줬다. 4년 전 나도 헤맸다고.내 코드 다시 보기 민지 질문이 계속됐다. "왜 fixture를 이렇게 써요?" "conftest.py는 뭐예요?" "이 decorator는 왜 만든 거예요?" 질문마다 내 코드 다시 봤다. 4년간 쌓인 코드. 나한테는 당연했다. 근데 민지 눈으로 보니 복잡했다. 주석 없는 함수. 이름만으로는 모호한 변수. 왜 이렇게 짰는지 기억 안 나는 로직. 민지는 내 코드 리뷰어가 됐다. "선배, 이 함수 이름이 뭘 하는 건지 모르겠어요." def _handle_alert(self): ...민지 말이 맞았다. alert 뜨면 accept? dismiss? 코드 봐야 안다. 리팩토링했다. def accept_alert_if_present(self): """Alert이 있으면 accept, 없으면 무시""" ...민지가 물었다. "이 try-except는 왜 있어요?" try: element.click() except ElementClickInterceptedException: self.driver.execute_script("arguments[0].click();", element)"어... 가끔 엘리먼트가 다른 거에 가려져서." "그럼 주석 달면 안 돼요?" 또 맞았다. 주석 추가했다. 민지 덕분에 내 코드가 나아졌다. 온보딩 방법 바꾸기 3주 뒤. 민지는 여전히 헤맸다. 내 접근이 틀렸다. "일단 프레임워크 써봐" 방식. 민지는 프레임워크 구조를 이해 못 했다. 왜 이렇게 짜야 하는지. 방법을 바꿨다. 온보딩 문서 만들기. 1단계: 날것의 Selenium 가장 기본부터. driver 띄우고 find_element. from selenium import webdriverdriver = webdriver.Chrome() driver.get("https://example.com") driver.find_element(By.ID, "username").send_keys("test")"이게 자동화의 시작이야. 이것만 알아도 테스트 짤 수 있어." 민지가 직접 짰다. 로그인. 검색. 장바구니. 코드는 지저분했다. 반복도 많았다. 하지만 돌아갔다. 민지 표정이 밝아졌다. "선배, 이거 재밌어요!" 2단계: 반복의 고통 민지한테 과제 줬다. "로그인 테스트 10개 짜봐." 다음 날 민지가 왔다. "선배, 코드가 너무 길어요." find_element 코드 100번 반복. Copy-paste 지옥. "개발자가 ID 바꾸면?" "다... 다 고쳐야죠." "그래서 함수로 빼는 거야." 민지는 함수를 만들었다. def login(driver, username, password): driver.find_element(By.ID, "username").send_keys(username) driver.find_element(By.ID, "password").send_keys(password) driver.find_element(By.ID, "login-btn").click()"훨씬 낫다. 이게 추상화의 시작이야."3단계: Page Object Model 민지 함수가 늘어났다. 50개. "선배, 이거 어떻게 관리해요?" "Page Object Model." 페이지별로 클래스 만들기. 로케이터 분리. 메소드로 액션 정의. class LoginPage: def __init__(self, driver): self.driver = driver self.username_input = (By.ID, "username") self.password_input = (By.ID, "password") def login(self, username, password): self.driver.find_element(*self.username_input).send_keys(username) ...이제 민지가 내 코드를 이해했다. "선배, 선배 코드가 이렇게 된 거였구나!" "응. 고통 받다보면 자연스럽게 이렇게 돼." 4단계: 프레임워크 pytest. fixture. conftest.py. Base Page. 민지는 이제 질문이 구체적이었다. "왜 fixture를 session scope로 해요?" "Base Page에 wait을 넣는 게 좋은 이유가 뭐예요?" 3주 전 질문과 달랐다. 문맥을 이해했다. 민지는 직접 Base Page 만들었다. 내 것과 비슷했다. "선배, 제가 짠 거 리뷰해주세요." 코드 봤다. 생각보다 괜찮았다. "민지야, 너 이제 자동화 엔지니어 같은데?" 민지가 웃었다. "아직 멀었어요." 민지의 역습 6주 뒤. 민지가 PR 날렸다. "선배, 이거 개선했어요." 내 retry 로직. 민지가 리팩토링했다. 원래 코드: def click_with_retry(self, locator, max_attempts=3): for i in range(max_attempts): try: element = self._wait_and_find(locator) element.click() return except: if i == max_attempts - 1: raise time.sleep(1)민지 코드: def click_with_retry(self, locator, max_attempts=3, retry_delay=1): """엘리먼트 클릭을 재시도. Stale element 대응.""" for attempt in range(max_attempts): try: element = self._wait_and_find(locator) element.click() logger.info(f"Click succeeded on attempt {attempt + 1}") return except (StaleElementReferenceException, ElementClickInterceptedException) as e: if attempt == max_attempts - 1: logger.error(f"Click failed after {max_attempts} attempts") raise logger.warning(f"Click failed, retrying... ({attempt + 1}/{max_attempts})") time.sleep(retry_delay)로깅 추가. Exception 구체화. Docstring. 내 코드보다 나았다. "Merge 할게." 민지가 좋아했다. "제 PR 첫 머지예요!" 깨달은 것들 민지 가르치면서 배웠다. 1. 내 코드는 내가 생각한 것보다 복잡하다 4년간 쌓인 코드. 당연하다고 생각한 패턴들. 초보자 눈엔 복잡한 미로. 2. "왜"를 알려줘야 한다 "이렇게 해" 방식은 안 먹힌다. 왜 Page Object Model? 왜 fixture? 왜 wait? 고통을 먼저 겪게 하고, 해결책을 제시하기. 3. 단계적 학습이 중요하다 처음부터 프레임워크 보여주기 = 실패 날것 Selenium → 반복의 고통 → 추상화 → 프레임워크 순서가 있다. 4. 질문은 코드 리뷰다 민지 질문은 내 코드의 문제점이었다. 주석 없는 함수. 모호한 네이밍. 불필요한 복잡도. 민지 덕분에 리팩토링했다. 5. 가르치면서 성장한다 민지한테 설명하려니 내가 제대로 이해 못 한 게 보였다. "이거 왜 이렇게 짰지?" 기억 안 나는 코드들. 다시 공부했다. Python decorator. Pytest fixture scope. Selenium wait 전략. 민지 때문에 내가 나아졌다. 지금 민지는 민지는 이제 자동화 스크립트 30개 관리한다. CI 빨간불 나면 혼자 고친다. PR 리뷰도 한다. 어제 민지가 말했다. "선배, 저도 후배 가르칠 수 있을 것 같아요." "그래? 뭘 먼저 가르칠 건데?" "당연히 날것 Selenium이죠. 고통부터 겪게 해야죠." 민지가 웃었다. 나도 웃었다. 민지가 진짜 이해했다. 다음 신입한테 다음 달 신입 또 온다. 이번엔 개발자 출신. 준비는 됐다. 온보딩 문서 있다. 민지한테 멘티 맡길 수도 있다. 근데 또 다를 거다. 개발자는 질문이 다를 테니까. "왜 unittest 안 쓰고 pytest 써요?" "이 구조는 왜 이렇게 짰어요?" 또 내 코드 다시 볼 거다. 또 리팩토링할 거다. 가르치는 게 배우는 거다. 민지가 증명했다.민지 질문이 내 코드를 고쳤다. 다음 신입도 기대된다.