- 14 Dec, 2025
새 기능이 나올 때마다 테스트 스크립트를 처음부터 짜야 할까
새 기능 나올 때마다 테스트 스크립트 처음부터 짜는 거 아니다 처음엔 나도 매번 새로 짰다 입사 첫 해. 새 기능 나오면 테스트 스크립트를 처음부터 작성했다. 로그인 테스트 짜면서 로그인 함수 또 만들고. 검색 기능 테스트하면서 검색 함수 또 만들고. 같은 코드를 계속 복붙했다. 어느 날 PM이 물었다. "새 기능 테스트 자동화 언제 돼요?" 2주 걸린다고 했더니 놀랐다. "자동화인데 왜 그렇게 오래 걸려요?" 그때 깨달았다. 내 자동화는 자동화가 아니었다. 테스트 케이스만 자동으로 돌아갈 뿐, 스크립트 작성은 수동이었다. 퇴근길에 생각했다. 개발자들은 라이브러리 쓰잖아. 매번 HTTP 요청 코드 새로 안 짜잖아. 나도 그렇게 해야 하는 거다.공통 함수부터 만들었다 다음 날부터 공통 함수를 모으기 시작했다. # common/auth.py 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() wait_for_dashboard(driver)간단했다. 로그인 기능을 함수 하나로. 이제 테스트마다 4줄이 1줄로 줄었다. login(driver, "test@test.com", "password123") 검색도 마찬가지였다. # common/search.py def search_product(driver, keyword): search_box = driver.find_element(By.ID, "search") search_box.clear() search_box.send_keys(keyword) search_box.send_keys(Keys.RETURN) wait_for_results(driver)처음엔 이것만으로도 신기했다. 테스트 작성 시간이 반으로 줄었다. 하지만 문제가 생겼다. ID가 "username"에서 "user-email"로 바뀌었다. 로그인 쓰는 테스트가 100개. 함수 하나만 고치면 끝났다. 그때 알았다. 재사용은 유지보수를 위한 거다.Page Object Model을 배웠다 선배가 코드 리뷰하면서 말했다. "POM 써봤어?" Page Object Model. 페이지를 클래스로 만드는 패턴이었다. # pages/login_page.py class LoginPage: def __init__(self, driver): self.driver = driver self.username_field = (By.ID, "username") self.password_field = (By.ID, "password") self.login_button = (By.ID, "login-btn") def login(self, username, password): self.driver.find_element(*self.username_field).send_keys(username) self.driver.find_element(*self.password_field).send_keys(password) self.driver.find_element(*self.login_button).click() return DashboardPage(self.driver)처음엔 복잡해 보였다. 함수 하나로 충분한데 왜 클래스로 만들어. 하지만 써보니 달랐다. # tests/test_login.py def test_successful_login(): login_page = LoginPage(driver) dashboard = login_page.login("test@test.com", "password123") assert dashboard.is_loaded()읽기 쉬웠다. 테스트가 뭘 하는지 한눈에 보였다. 로그인하고, 대시보드 뜨는지 확인하고. 더 중요한 건 확장성이었다. 로그인 페이지에 소셜 로그인 버튼이 추가됐다. def login_with_google(self): self.driver.find_element(*self.google_button).click() # OAuth 처리 return DashboardPage(self.driver)LoginPage 클래스에 메서드만 추가하면 됐다. 기존 테스트는 안 건드렸다. 컴포넌트를 더 작게 쪼갰다 POM도 중복이 있었다. 모든 페이지에 공통 헤더가 있었다. 로고, 검색창, 프로필 메뉴. 매 페이지마다 헤더 요소를 정의하는 건 비효율적이었다. # components/header.py class Header: def __init__(self, driver): self.driver = driver self.search_box = (By.ID, "global-search") self.profile_menu = (By.ID, "profile-menu") def search(self, keyword): element = self.driver.find_element(*self.search_box) element.send_keys(keyword) element.send_keys(Keys.RETURN) def open_profile(self): self.driver.find_element(*self.profile_menu).click() return ProfilePage(self.driver)페이지 클래스는 컴포넌트를 포함했다. # pages/dashboard_page.py class DashboardPage: def __init__(self, driver): self.driver = driver self.header = Header(driver) self.sidebar = Sidebar(driver) def search_from_dashboard(self, keyword): self.header.search(keyword) return SearchResultsPage(self.driver)이제 헤더가 바뀌어도 Header 클래스만 수정하면 됐다. 10개 페이지를 안 건드렸다. 컴포넌트 단위로 생각하니 UI도 다르게 보였다. 모달, 툴팁, 드롭다운. 전부 재사용 가능한 컴포넌트였다. # components/modal.py class Modal: def __init__(self, driver): self.driver = driver self.close_button = (By.CLASS_NAME, "modal-close") self.confirm_button = (By.CLASS_NAME, "modal-confirm") def close(self): self.driver.find_element(*self.close_button).click() def confirm(self): self.driver.find_element(*self.confirm_button).click()어떤 모달이든 이 클래스로 처리했다. 삭제 확인 모달, 저장 확인 모달, 에러 모달.공통 액션을 모듈화했다 테스트를 짜다 보면 패턴이 보였다. 기다리고, 클릭하고, 확인하고. 특히 기다리는 코드가 많았다. 로딩 끝날 때까지, 버튼 활성화될 때까지, 데이터 나올 때까지. # utils/waits.py class CustomWait: def __init__(self, driver, timeout=10): self.driver = driver self.wait = WebDriverWait(driver, timeout) def until_visible(self, locator): return self.wait.until(EC.visibility_of_element_located(locator)) def until_clickable(self, locator): return self.wait.until(EC.element_to_be_clickable(locator)) def until_text_present(self, locator, text): return self.wait.until(EC.text_to_be_present_in_element(locator, text)) def until_loading_done(self): self.wait.until(EC.invisibility_of_element_located((By.CLASS_NAME, "loading-spinner")))이제 테스트마다 WebDriverWait 안 써도 됐다. waiter = CustomWait(driver) waiter.until_loading_done() element = waiter.until_clickable(login_button)스크롤도 자주 썼다. 무한 스크롤, 특정 요소까지 스크롤, 맨 위로 스크롤. # utils/actions.py class Actions: def __init__(self, driver): self.driver = driver def scroll_to_element(self, element): self.driver.execute_script("arguments[0].scrollIntoView(true);", element) def scroll_to_bottom(self): self.driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") def infinite_scroll_until(self, target_count, item_locator): while True: items = self.driver.find_elements(*item_locator) if len(items) >= target_count: break self.scroll_to_bottom() time.sleep(1)테스트 코드가 깔끔해졌다. 의도가 명확했다. actions = Actions(driver) actions.infinite_scroll_until(50, (By.CLASS_NAME, "product-item"))"50개 아이템 나올 때까지 스크롤한다." 코드가 주석이 됐다. 데이터 생성도 라이브러리화했다 테스트마다 데이터가 필요했다. 유저, 상품, 주문. 처음엔 테스트 파일마다 데이터를 만들었다. test_user = { "email": "test@test.com", "password": "password123", "name": "테스터" }100개 테스트에 100개 유저가 있었다. 유저 스키마가 바뀌면 100군데를 고쳐야 했다. 팩토리 패턴을 적용했다. # fixtures/user_factory.py class UserFactory: @staticmethod def create_user(role="user", **kwargs): default_user = { "email": f"test_{uuid.uuid4()}@test.com", "password": "password123", "name": "테스트 유저", "role": role } default_user.update(kwargs) return default_user @staticmethod def create_admin(**kwargs): return UserFactory.create_user(role="admin", **kwargs)테스트에선 간단히 호출했다. user = UserFactory.create_user() admin = UserFactory.create_admin(name="관리자")이메일은 자동으로 유니크했다. 역할별로 쉽게 만들었다. 필요한 필드만 오버라이드했다. 상품도 마찬가지였다. # fixtures/product_factory.py class ProductFactory: @staticmethod def create_product(category="electronics", **kwargs): default_product = { "name": f"테스트 상품 {uuid.uuid4().hex[:8]}", "price": 10000, "stock": 100, "category": category, "description": "테스트용 상품입니다" } default_product.update(kwargs) return default_product재고 없는 상품, 할인 중인 상품, 카테고리별 상품. 필요한 데이터를 즉시 만들었다. out_of_stock = ProductFactory.create_product(stock=0) on_sale = ProductFactory.create_product(price=5000, original_price=10000)API 호출도 재사용했다 E2E 테스트 전에 데이터 세팅이 필요했다. UI로 만들면 너무 오래 걸렸다. API로 직접 데이터를 만들었다. # api/user_api.py class UserAPI: def __init__(self, base_url, token=None): self.base_url = base_url self.token = token def create_user(self, user_data): response = requests.post( f"{self.base_url}/api/users", json=user_data, headers={"Authorization": f"Bearer {self.token}"} ) return response.json() def delete_user(self, user_id): requests.delete( f"{self.base_url}/api/users/{user_id}", headers={"Authorization": f"Bearer {self.token}"} )테스트 전에 유저를 API로 만들고, 테스트 후에 삭제했다. @pytest.fixture def test_user(): user_api = UserAPI(BASE_URL, ADMIN_TOKEN) user_data = UserFactory.create_user() created_user = user_api.create_user(user_data) yield created_user user_api.delete_user(created_user["id"])테스트 시간이 70% 줄었다. UI로 회원가입하면 30초, API로 만들면 1초. 주문도 API로 만들었다. 장바구니 담고, 결제하고, 주문 완료까지. UI로 하면 2분, API로 하면 5초. 설정도 중앙화했다 환경별로 URL이 달랐다. 개발, 스테이징, 운영. 처음엔 테스트마다 URL을 하드코딩했다. 환경 바뀌면 전부 수정해야 했다. 설정 파일을 만들었다. # config/config.py class Config: ENVIRONMENTS = { "dev": { "base_url": "https://dev.example.com", "api_url": "https://api-dev.example.com", "db_host": "dev-db.example.com" }, "staging": { "base_url": "https://staging.example.com", "api_url": "https://api-staging.example.com", "db_host": "staging-db.example.com" }, "prod": { "base_url": "https://example.com", "api_url": "https://api.example.com", "db_host": "prod-db.example.com" } } @classmethod def get_config(cls, env="dev"): return cls.ENVIRONMENTS.get(env)테스트 실행할 때 환경만 지정했다. pytest --env=stagingconftest.py에서 설정을 로드했다. # conftest.py def pytest_addoption(parser): parser.addoption("--env", action="store", default="dev")@pytest.fixture(scope="session") def config(request): env = request.config.getoption("--env") return Config.get_config(env)@pytest.fixture def driver(config): driver = webdriver.Chrome() driver.get(config["base_url"]) yield driver driver.quit()이제 모든 테스트가 설정을 참조했다. 환경 바뀌어도 코드는 안 바뀌었다. 커스텀 어서션을 만들었다 Pytest 기본 assert도 좋지만, 반복되는 체크가 있었다. 요소가 화면에 보이는지, 특정 텍스트를 포함하는지, 특정 상태인지. # assertions/custom_assertions.py class CustomAssertions: def __init__(self, driver): self.driver = driver def assert_element_visible(self, locator, message=""): element = self.driver.find_element(*locator) assert element.is_displayed(), f"Element {locator} not visible. {message}" def assert_text_contains(self, locator, expected_text): element = self.driver.find_element(*locator) actual_text = element.text assert expected_text in actual_text, \ f"Expected '{expected_text}' in '{actual_text}'" def assert_element_count(self, locator, expected_count): elements = self.driver.find_elements(*locator) actual_count = len(elements) assert actual_count == expected_count, \ f"Expected {expected_count} elements, found {actual_count}"테스트가 읽기 쉬워졌다. assertions = CustomAssertions(driver) assertions.assert_element_visible((By.ID, "welcome-message")) assertions.assert_text_contains((By.CLASS_NAME, "username"), "테스터") assertions.assert_element_count((By.CLASS_NAME, "product-item"), 20)에러 메시지도 명확했다. 어떤 요소가, 뭘 기대했고, 실제론 뭐였는지. 베이스 테스트 클래스를 만들었다 모든 테스트가 공통으로 하는 일이 있었다. 드라이버 시작, 로그인, 종료. 베이스 클래스로 추상화했다. # tests/base_test.py class BaseTest: @pytest.fixture(autouse=True) def setup(self, driver, config): self.driver = driver self.config = config self.waiter = CustomWait(driver) self.actions = Actions(driver) self.assertions = CustomAssertions(driver) # 공통 페이지 객체 self.login_page = LoginPage(driver) yield # 스크린샷 저장 (실패 시) if hasattr(self, '_test_failed') and self._test_failed: self.save_screenshot() def save_screenshot(self): timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") self.driver.save_screenshot(f"screenshots/failed_{timestamp}.png") def login_as_user(self, user=None): if not user: user = UserFactory.create_user() self.login_page.login(user["email"], user["password"]) return DashboardPage(self.driver)테스트 클래스는 BaseTest를 상속했다. class TestDashboard(BaseTest): def test_dashboard_loads(self): self.login_as_user() self.assertions.assert_element_visible((By.ID, "dashboard")) def test_search_from_dashboard(self): dashboard = self.login_as_user() dashboard.header.search("테스트") self.waiter.until_loading_done() self.assertions.assert_element_count((By.CLASS_NAME, "result-item"), 10)setup, teardown, 공통 헬퍼가 자동으로 제공됐다. 테스트는 비즈니스 로직에만 집중했다. 라이브러리 구조를 정리했다 코드가 많아지니 구조가 중요해졌다. 어디에 뭐가 있는지 한눈에 봐야 했다. automation/ ├── pages/ │ ├── login_page.py │ ├── dashboard_page.py │ └── product_page.py ├── components/ │ ├── header.py │ ├── sidebar.py │ └── modal.py ├── api/ │ ├── user_api.py │ ├── product_api.py │ └── order_api.py ├── fixtures/ │ ├── user_factory.py │ └── product_factory.py ├── utils/ │ ├── waits.py │ ├── actions.py │ └── db_helper.py ├── assertions/ │ └── custom_assertions.py ├── config/ │ └── config.py └── tests/ ├── base_test.py ├── test_login.py └── test_dashboard.py폴더별로 역할이 명확했다. 새 기능 테스트 짤 때 어디서 가져올지 바로 알았다. 로그인 기능? pages/login_page.py. 헤더 검색? components/header.py. 유저 생성? api/user_api.py. import도 일관적이었다. from pages.login_page import LoginPage from components.header import Header from api.user_api import UserAPI from fixtures.user_factory import UserFactory from utils.waits import CustomWait문서화도 라이브러리의 일부다 코드만 있으면 안 됐다. 후배가 어떻게 쓰는지 알아야 했다. 각 모듈에 docstring을 추가했다. class LoginPage: """ 로그인 페이지 객체 사용법: login_page = LoginPage(driver) dashboard = login_page.login("test@test.com", "password123") 메서드: - login(email, password): 로그인 수행, DashboardPage 반환 - login_with_google(): 구글 로그인, DashboardPage 반환 - forgot_password(email): 비밀번호 재설정 메일 전송 """README.md도 작성했다. # 테스트 자동화 라이브러리## 빠른 시작1. 로그인 테스트 ```python from pages.login_page import LoginPagedef test_login(driver): login_page = LoginPage(driver) dashboard = login_page.login("test@test.com", "password123") assert dashboard.is_loaded()데이터 생성from fixtures.user_factory import UserFactory from api.user_api import UserAPIuser_data = UserFactory.create_user() user_api = UserAPI(BASE_URL, TOKEN) created_user = user_api.create_user(user_data)예제가 있으니 후배가 금방 따라했다. 질문도 줄었다.## 버전 관리도 했다라이브러리가 바뀌면 알려줘야 했다. 어떤 기능이 추가됐고, 뭐가 deprecated 됐는지.CHANGELOG.md를 만들었다.```markdown # Changelog## [2.1.0] - 2024-01-15 ### Added - Modal 컴포넌트에 `wait_until_closed()` 메서드 추가 - ProductFactory에 `create_bundle()` 메서드 추가### Changed - CustomWait의 기본 timeout을 10초에서 15초로 변경### Deprecated - `login_page.login_old()` 메서드는 v3.0.0에서 제거 예정## [2.0.0] - 2023-12-01 ### Breaking Changes - LoginPage의 `login()` 메서드가 DashboardPage 대신 HomePage 반환팀원들이 업데이트 전에 체크했다. Breaking change가 있으면 테스트 코드를 미리 수정했다. 실제로 얼마나 빨라졌나 숫자로 측정했다. 라이브러리 도입 전과 후. 새 기능 테스트 작성 시간전: 평균 2주 (10개 시나리오 기준) 후: 평균 3일테스트 유지보수 시간전: UI 변경 시 평균 5일 (영향받는 테스트 수정) 후: 평균 1일 (페이지 객체만 수정)코드 재사용률전: 30% (복붙이 대부분) 후: 80% (라이브러리 사용)테스트 코드 길이전: 평균 100줄 후: 평균 30줄수치가 명확하니 설득력이 있었다. PM도 인정했다. "자동화 투자가 빛을 보네요." 팀원들도 쓰기 시작했다 처음엔 나만 썼다. 후배는 여전히 매번 새로 짰다. 어느 날 후배가 물었다. "선배, 로그인 함수 어디 있어요?" "pages 폴더에 LoginPage 있어." "아 이거... 이거 쓰면 되는구나." 다음 날 후배 코드를 봤다. LoginPage, UserFactory, CustomWait를 썼다. 코드가 내 스타일과 비슷했다. 일주일 후 후배가 말했다. "선배, 저도 컴포넌트 하나 만들어도 돼요? Notification 자주 써서요." "당연하지. PR 올려봐." # components/notification.py class Notification: def __init__(self, driver): self.driver = driver self.toast = (By.CLASS_NAME, "toast-message") def get_message(self): element = self.driver.find_element(*self.toast) return element.text def wait_until_disappear(self): wait = WebDriverWait(self.driver, 10) wait.until(EC.invisibility_of_element_located(self.toast))좋았다. 머지했다. 이제 팀 전체가 Notification 컴포넌트를 쓴다. 라이브러리가 함께 자랐다. 혼자 만들 때보다 빨랐다. 신입이 와도 빨리 적응했다 3개월 전 신입이 들어왔다. QA 경험 1년, 자동화는 처음. "뭐부터 배워야 해요?" README 보여줬다. "여기 예제 따라해봐. 로그인 테스트부터." 2시간 뒤 신입이 로그인 테스트를 짰다. LoginPage 쓰고, UserFactory 쓰고, CustomAssertions 썼다. "이거 다 있네요. 편하다." 1주일 뒤 신입이 검색 기능 테스트를 완성했다. 혼자서. 예전 같았으면 한 달 걸렸을 거다. 라이브러리가 온보딩 시간을 줄였다. 한계도 있었다 라이브러리가 만능은 아니었다. 과도한 추상화 너무 범용적으로 만들
- 13 Dec, 2025
자동화 엔지니어 vs 개발자: 내 정체성은 뭘까
자동화 엔지니어 vs 개발자: 내 정체성은 뭘까 7년 차, 여전히 모르겠다 아침 10시. 출근해서 테스트 결과 확인했다. 밤새 돌린 E2E 테스트 327개 중 12개 실패. 로그 열어봤다. 8개는 타임아웃, 3개는 셀렉터 변경, 1개는 진짜 버그. 이 과정이 개발인지 QA인지 모르겠다. 7년 전 신입 때는 단순했다. 매뉴얼 QA. 클릭하고 확인하고 버그 리포트. 내 역할이 명확했다. QA는 QA였다. 4년 전 자동화로 넘어오면서 모호해졌다. 코드 짜고, 아키텍처 고민하고, 리팩토링하고. 이게 개발 아닌가? 어제 신입 개발자가 물었다. "J님은 개발자세요?" 잠깐 멈췄다. 답을 못 했다.매뉴얼 3년, 명확했던 시절 2018년. 첫 회사. QA팀 막내. 테스트 케이스 엑셀로 관리했다. 손으로 하나하나 클릭. 당시엔 단순했다. 기획서 보고 → 테스트 케이스 작성 → 실행 → 버그 리포트 → 회귀 테스트. 개발팀과 경계가 분명했다. 그들은 코드를 만들고, 우리는 검증했다. "QA는 품질의 파수꾼"이라는 말에 자부심 느꼈다. 버그 찾으면 뿌듯했다. 내 역할이 명확했다. 물론 힘들었다. 반복 작업. 야근. 회귀 테스트 지옥. 한 스프린트에 300개 테스트 케이스 손으로 돌렸다. 2년 차 되던 해, 팀장이 말했다. "자동화 배워볼래?" 그때는 몰랐다. 내 정체성이 흔들리기 시작할 줄. 자동화 시작, 코드와의 첫 만남 2021년. 새 회사로 이직. 자동화 포지션. 첫날 Selenium 설치했다. Python 기초 강의 들었다. 처음엔 간단했다. driver.find_element(By.ID, "login").click()"이거면 되네?" 싶었다. 3개월 후, 현실 직면했다.Flaky 테스트: 랜덤하게 실패하는 놈들 타임아웃 문제: Wait 조건 잡기 셀렉터 깨짐: UI 조금만 바뀌면 전부 수정 테스트 데이터 관리: DB 초기화는 어떻게?"이거 개발 아냐?" 생각했다. Page Object Model 배웠다. 디자인 패턴 공부했다. pytest fixture, conftest.py, 로그 관리, 리포트 생성. 6개월 후엔 프레임워크 설계했다. base_page.py 만들고, 공통 메서드 추출하고, config 관리하고. 동료 개발자가 코드 리뷰 달았다. "여기 중복 제거할 수 있어요." 그 순간 깨달았다. 나도 개발자처럼 일하고 있다는 걸.개발자인 듯 개발자 아닌 작년 봄. 개발팀 회의에 참석했다. 마이크로서비스 아키텍처 전환 논의. 프론트엔드 개발자: "API 스펙 바뀌면 통신 다시 짜야죠." 백엔드 개발자: "DB 마이그레이션 스크립트 필요해요." 나: "테스트 환경 구성은 어떻게 하죠?" 다들 고개 끄덕였다. 나도 의견 냈다. "서비스 간 통합 테스트가 복잡해질 텐데, 모킹 전략 필요합니다." 그 자리에선 동료였다. 개발자처럼. 회의 끝나고 슬랙 메시지 왔다. "J님, 회원가입 시나리오 손으로 한 번 테스트 부탁드려요." 순간 멈칫했다. 매뉴얼 테스트. 자동화했는데 왜 또 손으로? 물어봤다. "자동화 테스트로는 안 될까요?" "프로덕션 환경이라 자동화는 좀..." 아, 맞다. 나는 개발자가 아니구나. 급여는 개발자, 취급은... 연봉 협상 때. "자동화 엔지니어는 개발자급이니까 6500 드릴게요." 좋았다. 매뉴얼 QA 평균보다 1500 높았다. 근데 조직도를 보면 QA팀 소속. 팀명: "품질관리팀" 개발자 워크샵 있을 때. 초대 안 받았다. "개발 조직만 가는 거라서..." 컨퍼런스 지원 신청했다. "코드 짜시잖아요. 개발 컨퍼런스 가세요." 가서 발표했다. "E2E 테스트 자동화 프레임워크 구축기" 청중 질문: "근데 왜 개발팀에 안 계세요?" 답 못 했다. 사내 개발자 커뮤니티 있다. 가입 신청했다. "QA팀은... 음... 관심사가 다를 것 같아서..." 거절당했다. 급여는 개발자, 소속은 QA, 일은 둘 다. 나는 뭘까.SDET라는 새로운 선택지 6개월 전. 링크드인 헤드헌터 메시지. "SDET 포지션 관심 있으세요?" SDET. Software Development Engineer in Test. 처음 들어봤다. 찾아봤다.테스트 코드도 프로덕션 코드만큼 중요 개발팀 소속, 품질 책임 TDD, CI/CD 파이프라인 관리 테스트 인프라 개발"이거 나잖아?" JD 더 봤다.코딩 테스트 필수 자료구조, 알고리즘 능력 시스템 디자인 면접 프로덕션 코드 리뷰 참여긴장됐다. 내가 개발자 코딩 테스트를 통과할 수 있을까? LeetCode 시작했다. Easy 문제부터. Two Sum 풀었다. 30분 걸렸다. 개발자는 5분 만에 푸는 문제. "나는 아직 멀었구나." 코드는 짤 줄 알지만 내 GitHub 저장소.test-automation-framework: 스타 23개 api-testing-utils: 스타 8개 selenium-helper: 스타 15개전부 테스트 관련. 프로덕션 코드는? 없다. 기여한 오픈소스는? 테스트 툴만. 이력서 technical skills:Python, JavaScript (테스트용) Selenium, Appium, Pytest Jenkins, Docker (CI/CD) API Testing, E2E Testing개발자 이력서랑 비교했다.Python, JavaScript (프로덕션) Django, React AWS, Kubernetes RESTful API 설계, 마이크로서비스방향이 다르다. 작년에 프로덕션 코드 한 번 짰다. 테스트 환경 초기화 스크립트. 200줄. 시니어 개발자가 리뷰했다. "여기 에러 핸들링 약하네요." "로깅 레벨 잘못 잡았어요." "이건 유틸로 빼는 게 좋겠어요." 수정하는데 3일 걸렸다. 개발자들은 하루에 이런 코드 수백 줄 짠다. 나는 200줄에 3일. "나는 개발자가 아니구나." 다시 깨달았다. 정체성 혼란의 순간들 순간 1: 채용 공고 "QA 자동화 엔지니어 채용" 요구사항: 3년 이상 개발 경험 개발 경험? 나는 QA 경험 7년인데. 순간 2: 이직 면접 면접관: "본인은 QA 출신인가요, 개발 출신인가요?" 나: "QA로 시작했지만 지금은..." 면접관: "아, QA 출신이시네요." 탈락했다. 순간 3: 팀 회식 개발팀장: "J님은 뭐 하세요?" 나: "자동화 엔지니어요." 개발팀장: "아, 테스터?" 아니라고 하고 싶었다. 근데 맞는 말 같기도 하고. 순간 4: 연봉 협상 "QA는 올해 3% 인상입니다." "근데 저 코드 짜잖아요." "그래도 QA팀이니까요." 억울했다. 순간 5: 프로젝트 회고 PM: "개발은 잘 끝났고, QA는..." 나: "저도 개발했는데요. 테스트 인프라." PM: "아 네, QA 자동화 잘하셨어요." 개발으로 인정 안 받는 느낌. 양쪽에서 다 어중간한 QA 관점에서 보면: "J님은 매뉴얼 감각이 떨어져요." "요즘 손으로 안 해봐서 그래요." 손으로 안 하는 이유? 자동화했으니까. 근데 그게 단점이 된다. 후배 QA가 찾은 UI 버그. "이거 자동화 테스트에서 왜 안 잡았어요?" 시각적 요소. 픽셀 단위 레이아웃. 자동화로 잡기 어렵다. "자동화가 만능은 아니거든." "그럼 뭐 하러 자동화해요?" 할 말 없었다. 개발 관점에서 보면: "테스트 코드 품질이 낮아요." "프로덕션 코드처럼 관리해야죠." 노력했다. 리팩토링했다. 커버리지 올렸다. 근데 개발자가 보면 여전히 부족하다. "이런 건 디자인 패턴 쓰면 좋은데..." "성능 테스트는 k6가 낫지 않을까요?" 알고는 있다. 근데 시간이 없다. 테스트 케이스 늘리는 게 우선이니까. 양쪽에서 다 중간이다. QA 중에선 제일 코드 잘 짜는 사람. 개발자 중에선 제일 테스트만 하는 사람. 자동화의 함정 자동화 시작할 때 생각했다. "이거 다 자동화하면 나는 뭐 하지?" 4년 차. 답 나왔다. 자동화 유지보수. 개발팀이 UI 리뉴얼했다. 테스트 스크립트 380개 깨졌다. 2주 동안 고쳤다. 셀렉터 전부 수정. 끝나자마자 또 깨졌다. API 스펙 변경. Flaky 테스트. 랜덤 실패하는 놈들. 원인 찾는데 3일. 고치는데 1시간. "자동화하면 편할 줄 알았는데..." 유지보수가 개발보다 어렵다. 내가 짠 코드지만 6개월 후엔 낯설다. 주석 없으면 이해 못 한다. "이거 왜 이렇게 짰지?" 테스트 커버리지 80%. "나머지 20%는요?" "자동화 어려운 케이스예요." "그럼 손으로 해야죠." 결국 매뉴얼도 병행. 자동화 엔지니어인데 손으로 테스트. 이게 맞나 싶다. 커리어 고민, SDET로 갈까 링크드인 봤다. SDET 채용 공고.구글: SDET, L4, $180K 페이스북: Software Engineer, Testing, E5 넷플릭스: Senior SDET공통점: 개발팀 소속. 요구사항 봤다.코딩 테스트 (LeetCode Medium 이상) 시스템 디자인 테스트 전략 설계 프로덕션 코드 기여마지막이 관건이다. 프로덕션 코드. 내 경험:테스트 코드 4년 프로덕션 코드 0년JD에 "테스트 코드도 프로덕션 코드"라고 써있다. 위안 삼았다. 지원했다. 스타트업 SDET. 1차 코딩 테스트. Medium 2문제. 첫 문제: Binary Tree Level Order Traversal 45분 걸렸다. 제한 시간 30분. 탈락. "아직 멀었구나." LeetCode 매일 풀기 시작했다. 퇴근하고 2시간씩. 한 달 후 다시 지원. 다른 회사. 코딩 테스트 통과. 2차 기술 면접. "테스트 인프라 어떻게 설계하셨나요?" 대답했다. 내 경험 기반. 면접관이 고개 끄덕였다. "근데 프로덕션 API는 개발해보셨어요?" "...아니요." "SDET는 기능 개발도 하거든요." 또 탈락. 내가 원하는 건 뭘까 깊이 생각해봤다. 개발자가 되고 싶은가? 100% 아니다. QA로 남고 싶은가? 100% 아니다. 그럼 뭘 원하는가?코드로 문제 해결하고 싶다 품질에 대한 책임감을 유지하고 싶다 개발자와 동등하게 대우받고 싶다 테스트만 하는 사람은 되기 싫다 기능 개발만 하는 사람도 되기 싫다모순이다. SDET가 답일까? 어쩌면 맞다. 어쩌면 아니다. SDET 된다고 정체성 혼란 사라질까? 모르겠다. 결국 라벨 문제가 아닐 수도. "나는 무엇을 하는 사람인가"가 중요한 거지. 테스트 코드 짜는 개발자? 개발하는 QA? 둘 다 맞는 것 같다. 둘 다 틀린 것 같기도. 3개월 후, 작은 변화 포지션 타이틀 바꿨다. "QA 자동화 엔지니어" → "품질 엔지니어(Quality Engineer)" QA 빼니까 기분이 다르다. 팀 회의 때 말투 바꿨다. "이거 테스트해볼게요" → "이거 검증 로직 구현할게요" 사소하지만 차이 있다. 개발팀 코드 리뷰 참여 시작했다. "테스트 가능한 코드인가" 관점으로. "여기 의존성 주입하면 목킹 쉬울 것 같아요." 개발자: "오, 좋은데요?" PR 머지됐다. 내 리뷰로. 기여한 느낌. 처음이다. 사이드 프로젝트 시작했다. 간단한 웹앱. Todo 리스트. 프로덕션 코드 짜봤다. React, Node.js, MongoDB. 테스트 코드도 짰다. 당연히. 2주 만에 완성. 배포했다. "나도 개발할 수 있구나." 물론 프로 개발자 수준은 아니다. 근데 할 수 있다는 게 중요하다. 이력서 업데이트했다. "Full-stack 경험 있음 (사이드 프로젝트)" 거짓말은 아니다. 여전히 답은 모른다 지금도 모른다. 내가 뭔지. 출근해서 코드 짠다. 퇴근하고도 코드 짠다. 주말엔 LeetCode 푼다. 월요일엔 테스트 리포트 쓴다. 어떤 날은 개발자 같다. 어떤 날은 QA 같다. 근데 요즘은 괜찮다. 굳이 하나일 필요 있나? 하이브리드면 어때? 개발도 하고 테스트도 하는 사람. 품질도 책임지고 코드도 짜는 사람. 라벨이 뭐든 상관없다. 내가 하는 일이 중요하다. "자동화 엔지니어세요?" "네, 품질 엔지니어이기도 하고, 때론 개발자이기도 해요." 이제 이렇게 답한다. 더 이상 멈칫하지 않는다. SDET로 갈지 모른다. 안 갈 수도 있다. 중요한 건 계속 성장하는 것. 코드도, 테스트도, 품질도. 7년 차. 여전히 모르지만. 괜찮다. 계속 찾아가는 중이다.정체성은 명함이 아니라 내가 하는 일로 정의되는 거다.
- 12 Dec, 2025
테스트 리포트를 숫자가 아닌 스토리로 전달하기
테스트 리포트를 숫자가 아닌 스토리로 전달하기 7시 30분, 리포트 정리 시작 매일 저녁 7시. 내 업무 루틴은 리포트 정리로 끝난다. Jenkins에서 떨어진 테스트 결과. Pytest 리포트. Allure 대시보드. 숫자만 잔뜩이다. Pass 2,847개. Fail 23개. Skip 15개. 이걸 그대로 공유하면? 아무도 안 본다. 작년까지 나도 그랬다. 엑셀에 숫자 복붙. 컬러 코딩. 메일 발송. 다음 날 개발팀장이 물었다. "그래서 릴리즈 해도 돼요?" 멘탈이 나갔다. 2,847개 테스트를 통과했는데. 이걸 어떻게 설명하지. 그때부터 바꿨다. 리포트는 숫자가 아니라 이야기다.CTO가 물어본 질문 리포트 방식을 바꾸게 된 계기. 3개월 전 월례 회의. CTO가 내 리포트를 보더니 물었다. "테스트 커버리지 85%면 좋은 건가요? 릴리즈 해도 되나요?" 순간 막혔다. 기술적으론 좋은 수치다. 업계 평균도 70% 정도니까. 근데 "좋다"고 말할 수 없었다. 결제 모듈 테스트가 3개 실패 중이었거든. 85%라는 숫자는 의미 없었다. 나머지 15%가 어디냐가 중요했다. 그날 깨달았다. 임원진은 QA 전문가가 아니다. 숫자보다 리스크를 알고 싶어 한다. "릴리즈해도 안전한가"가 궁금한 거다. 다음 회의부터 접근을 바꿨다. "결제 모듈 테스트 3개 실패. 릴리즈 블로킹 이슈. 다른 건 문제없음." CTO 표정이 달라졌다. "결제는 고쳐야죠. 나머진 배포 가능?" 드디어 대화가 됐다. 숫자 대신 컨텍스트 리포트 구조를 완전히 뜯어고쳤다. Before:전체 테스트: 2,885개 Pass: 2,847개 (98.7%) Fail: 23개 (0.8%) Skip: 15개 (0.5%)After:릴리즈 블로킹: 3개 (결제 API 타임아웃) 주의 필요: 5개 (UI 깨짐, UX 영향) 알려진 이슈: 15개 (백로그 등록됨) 안전: 2,847개 기능 검증 완료같은 데이터다. 전달 방식만 바뀌었다. 근데 반응이 180도 달라졌다. 개발팀장: "결제는 내일 아침까지 고칠게요." PM: "UI 깨진 건 어느 화면이에요? 급한가요?" CTO: "알려진 이슈 15개는 언제 해결 예정?" 구체적인 액션이 나왔다. 이게 진짜 리포트다.시각화는 도구일 뿐 처음엔 시각화에 집착했다. Grafana 대시보드 만들었다. 그래프 10개. 실시간 업데이트. 컬러풀하고 멋있었다. 근데 아무도 안 봤다. 문제는 간단했다. 너무 많았다. 임원진은 대시보드 10분 볼 시간이 없다. 1분 안에 핵심만 알고 싶어 한다. 지금은 3가지만 보여준다. 1. 신호등 (Traffic Light)빨강: 릴리즈 블로킹. 절대 배포 금지. 노랑: 주의 필요. 비즈니스 판단 필요. 초록: 안전. 배포 가능.한눈에 본다. 오늘이 빨강인지 초록인지. 2. 트렌드 (Trend)최근 2주 테스트 실패율 그래프 갑자기 튄 날 있으면 설명 추가 "10일 - API 배포로 인한 일시적 실패"패턴을 본다. 점점 나아지는지 악화되는지. 3. 핫스팟 (Hotspot)가장 자주 실패하는 모듈 Top 3 테스트 불안정한 기능 표시 "로그인 모듈 - 이번 주 4번 실패"리스크 영역을 본다. 어디에 집중해야 하는지. 이 3개면 충분하다. 나머지는 디테일 페이지에 넣어뒀다. 궁금하면 클릭해서 보면 된다. 대부분은 안 본다. 그래도 된다. 이야기 구조로 전달 리포트를 이야기처럼 쓴다. 기승전결 구조: 기(상황): "오늘 2,885개 테스트 실행. 밤새 4시간 걸렸습니다." 승(문제): "결제 API 테스트 3개 실패. 타임아웃 에러. 원인은 DB 쿼리 성능 이슈." 전(영향): "결제 플로우 전체에 영향. 신용카드/계좌이체 모두 영향권. 릴리즈 블로킹." 결(해결): "백엔드팀 인덱스 추가 중. 내일 오전 재테스트 예정. 그 외 영역은 배포 가능." 누구나 이해한다. 기술 배경 없어도. 실제 예시 (지난주 금요일 리포트): 제목: [빨강] 결제 모듈 블로킹, 그 외 배포 가능 "오늘 릴리즈 준비 테스트 완료했습니다. 좋은 소식: 신규 검색 기능, 마이페이지 개편 모두 통과. 2주간 준비한 기능들 문제없습니다. 나쁜 소식: 결제 API 성능 이슈 발견. 1,000건 이상 주문 시 타임아웃. 실제 운영 데이터로 테스트하면서 잡혔습니다. 영향도: 블랙프라이데이 대비 필수. 대량 주문 처리 불가능. 해결: 백엔드팀 DB 최적화 중. 월요일 오전 재검증. 결론: 검색/마이페이지는 이번 주 배포 가능. 결제는 다음 주." CTO 답장: "결제는 다음 주 가능할까요? 블프 전까진 필수." 즉시 답했다: "월요일 오후면 확실히 결론 드릴게요." 이게 소통이다.인사이트는 숨어있다 리포트는 결과만 전달하는 게 아니다. 데이터 속에서 인사이트를 찾는다. 패턴을 본다. 예시 1 - 특정 시간대 실패: "로그인 테스트가 새벽 3시에만 실패. 조사해보니 이 시간에 DB 백업 돌아감. 백업 중 타임아웃 늘어남. 테스트 타이밍 조정 또는 백업 시간 변경 필요." → 운영 이슈 발견. DevOps팀이 백업 시간 옮김. 예시 2 - 특정 OS 실패: "iOS 테스트만 3주간 실패율 높음. Android는 정상. iOS 14.8 특정 버전 이슈. 애플 버그. 우리가 고칠 수 없음. 사용자한테 OS 업데이트 권장 필요." → CS팀이 공지 올림. 문의 감소. 예시 3 - 특정 개발자 커밋: "A 개발자 PR 후 테스트 실패율 증가 패턴. 악의는 없음. 테스트 코드 작성 습관 부족. 페어 프로그래밍 제안." → 조심스럽게 팀장한테 공유. 개선됨. 인사이트는 액션으로 이어진다. 단순 결과 나열과 차원이 다르다. 템플릿을 만들었다 매일 처음부터 쓸 순 없다. 템플릿 3개 만들었다. 1. 일일 리포트 (Daily)신호등 상태 블로킹 이슈 (있으면) 새로 발견된 버그 내일 테스트 계획5분 안에 작성. Slack에 공유. 2. 주간 리포트 (Weekly)이번 주 하이라이트 트렌드 분석 (좋아짐/나빠짐) 다음 주 리스크 예상 요청 사항20분 작성. 이메일 발송. 3. 릴리즈 리포트 (Release)전체 테스트 커버리지 릴리즈 가능 여부 (Go/No-Go) 알려진 이슈 목록 릴리즈 후 모니터링 계획1시간 작성. 회의에서 발표. 템플릿 있으니 일관성 생겼다. 누가 봐도 같은 구조다. 자동화할 건 자동화 리포트 생성도 반은 자동화했다. Python 스크립트 하나 만들었다. # 내가 만든 리포트 생성기 (간략 버전) def generate_daily_report(): results = parse_test_results() blocking = [t for t in results if t.severity == 'critical' and t.status == 'failed'] warnings = [t for t in results if t.severity == 'high' and t.status == 'failed'] if blocking: signal = "🔴 빨강" elif warnings: signal = "🟡 노랑" else: signal = "🟢 초록" report = f""" [{signal}] {date.today()} 테스트 리포트 블로킹: {len(blocking)}개 {format_test_list(blocking)} 주의: {len(warnings)}개 {format_test_list(warnings)} 안전: {len([t for t in results if t.status == 'passed'])}개 검증 완료 """ return report매일 저녁 7시 자동 실행. 초안을 만들어준다. 내가 할 일은 컨텍스트 추가. "왜 실패했는지", "언제 해결되는지". 시간이 절반으로 줄었다. 예전엔 1시간. 지금은 30분. 질문 받을 준비 리포트 보내면 질문 온다. 준비해둔다. 예상 질문과 답변. 자주 오는 질문: Q: "이번 주 배포 가능?" A: 준비됨. 신호등 색깔 + 조건부 배포 가능 여부. Q: "이 버그 언제 고쳐짐?" A: 담당자 확인 + 예상 일정. 모르면 "확인 후 알려드릴게요" 솔직히. Q: "커버리지 왜 떨어졌어?" A: 신규 기능 추가됨. 분모 늘어남. 테스트 추가 중. 다음 주 회복 예정. Q: "자동화 안 되는 건 어떻게 테스트?" A: 매뉴얼 테스트 계획 있음. 담당자 배정됨. 결과는 금요일 공유. 답 못하는 질문도 있다. 그땐 솔직하게. "확인해보고 알려드릴게요. 30분 드리면 될까요?" 모르는 척 안 한다. 추측도 안 한다. 확인 후 정확히 답한다. 신뢰는 일관성에서 3개월 했더니 달라진 게 있다. 사람들이 내 리포트를 기다린다. "자동화J 리포트 왔어?" "오늘 신호등 뭐야?" "이번 릴리즈 괜찮대?" 예전엔 읽으라고 태그 걸었다. 지금은 먼저 찾아본다. 이유는 간단하다. 일관성. 매일 같은 시간. 같은 형식. 같은 수준의 디테일. 한 번도 빠진 적 없다. 금요일 밤 배포 전에도. 월요일 아침 장애 후에도. 신뢰는 거기서 온다. "자동화J 리포트면 믿을 만해." 가끔 PM이 의사결정 회의에 내 리포트 캡처해서 쓴다. "QA팀 판단으론 릴리즈 가능합니다." 뿌듯하다. 리포트가 의사결정 자료가 됐다. 실패도 공유한다 좋은 것만 보고하지 않는다. 테스트 놓친 것도 쓴다. 자동화 실패도 쓴다. 지난달 장애: "로그인 장애 발생. 테스트에서 못 잡았습니다. 원인: 타사 OAuth 서버 다운. 우리 테스트는 Mock 사용. 실제 연동은 검증 안 됨. 개선: 주 1회 실제 OAuth 통합 테스트 추가." 숨기지 않았다. 솔직하게 썼다. CTO 반응: "개선 계획 좋습니다. 다음 주부터 시작하세요." 책망 없었다. 오히려 신뢰 올라갔다. 완벽한 척 안 한다. QA도 사람이다. 다 잡을 순 없다. 중요한 건 배우고 개선하는 것. 팀원도 가르친다 후배 QA 2명 있다. 이들도 리포트 쓴다. 가르쳐줬다. 처음엔 숫자만 나열했다. 당연하다. 나도 그랬으니까. "이 숫자가 말하는 게 뭐야? 릴리즈 해도 돼?" 질문하면서 가르쳤다. 숫자 뒤 컨텍스트를 보라고. 2주 지나니 좋아졌다. 한 달 지나니 제법이다. 이제 나 없어도 리포트 돈다. 휴가 갈 수 있다. 데이터는 이야기를 원한다 숫자는 사실이다. 이야기는 의미다. 2,847개 통과는 사실. "결제 빼고 다 안전합니다"는 의미. 의미를 전달해야 움직인다. 액션이 나온다. 리포트는 보고서가 아니다. 소통 도구다.오늘도 7시 30분. 리포트 쓴다. 신호등은 초록. 내일 배포 간다.
- 11 Dec, 2025
Selenium Grid로 병렬 테스트 실행: 8시간이 2시간으로
Selenium Grid로 병렬 테스트 실행: 8시간이 2시간으로 월요일 오전, 테스트는 아직 돌고 있다 월요일 아침 10시. 출근했다. 주말에 돌린 회귀 테스트가 아직도 실행 중이다. 진행률 67%. 시작한 지 6시간째. 전체 테스트 케이스 1,200개. 순차 실행. 예상 완료 시간은 오후 1시. "이거 언제 끝나요?" PM이 묻는다. 세 번째 질문이다. "1시요." "배포는 2시인데요?" "알아요." 테스트 결과 보고 버그 리포트 작성하면 시간 빠듯하다. 혹시 테스트 실패하면? 다시 돌린다. 4시간 더. 배포는 미뤄진다. 매번 이랬다.문제는 명확했다. 순차 실행. 1,200개 테스트가 한 줄로 서서 기다린다. 크롬 브라우저 하나에서. 내 노트북에서. 한 테스트 평균 20초. 1,200개 × 20초 = 24,000초 = 6.6시간. 수학적으로 맞다. 하지만 현실적으로 틀렸다. 금요일 오후에 시작해도 월요일 아침까지. 주말 내내 내 노트북은 일한다. 나는 쉬는데. 비효율이다. "Grid 알아봐야겠다." 혼잣말했다. Grid의 원리: 일을 나눠주는 것 Selenium Grid는 간단하다. Hub 하나. Node 여러 개. Hub는 사장. Node는 직원. 테스트 케이스가 들어온다. Hub가 받아서 Node에게 배분한다. 여러 Node가 동시에 일한다. 병렬 처리. 예를 들면 이렇다. 1,200개 테스트. Node 4개. 각 Node가 300개씩 맡는다. 동시 실행. 시간은 1/4. 6시간이 1.5시간 된다. 이론상으로는. 실제로 Grid 구축하면서 배웠다. 이론과 실전은 다르다는 걸. 첫 시도는 Docker로 했다. Hub 컨테이너 하나. Node 컨테이너 4개. docker-compose.yml 작성. version: '3' services: selenium-hub: image: selenium/hub:4.15.0 ports: - "4444:4444" chrome-node-1: image: selenium/node-chrome:4.15.0 environment: - SE_EVENT_BUS_HOST=selenium-hubNode 4개 띄웠다. Chrome Node 2개. Firefox Node 2개. 브라우저 호환성 테스트도 같이. 터미널에서 확인. docker ps컨테이너 5개 돌고 있다. Hub 1개, Node 4개. 정상이다. Grid Console 접속. http://localhost:4444/ui 화면에 Node 4개 보인다. 각각 브라우저 아이콘. 상태 표시. 처음엔 신기했다.테스트 코드 수정: 병렬화 준비 Grid 띄웠다고 끝이 아니다. 테스트 코드를 수정해야 한다. 병렬 실행 가능하게. 기존 코드는 이랬다. from selenium import webdriverdriver = webdriver.Chrome() driver.get("https://example.com")로컬 크롬 브라우저 실행. Grid에서는 안 된다. RemoteWebDriver로 바꿨다. from selenium import webdriver from selenium.webdriver.common.desired_capabilities import DesiredCapabilitiesdriver = webdriver.Remote( command_executor='http://localhost:4444', desired_capabilities=DesiredCapabilities.CHROME )Hub 주소 지정. 브라우저 타입 지정. 이제 Grid Node에서 실행된다. 하지만 문제가 있었다. 테스트들이 서로 간섭했다. 예를 들면 로그인 테스트. 여러 테스트가 동시에 같은 계정으로 로그인. 세션 충돌. 테스트 실패. 해결책: 테스트 독립성. 각 테스트마다 다른 계정 사용. 또는 테스트 데이터 격리. import pytest@pytest.fixture def driver(): driver = webdriver.Remote(...) yield driver driver.quit()def test_login_user1(driver): # user1 계정 사용 passdef test_login_user2(driver): # user2 계정 사용 passPytest의 fixture 활용. 각 테스트마다 독립적인 driver. 테스트 끝나면 driver 종료. 다음은 병렬 실행 설정. Pytest-xdist 플러그인 사용. pip install pytest-xdist실행 명령어. pytest -n 4 tests/-n 4: 4개 워커로 병렬 실행. Pytest가 테스트를 나눠서 실행한다. 각 워커가 Grid Node에 요청. 처음 돌렸을 때. 터미널에 로그 쏟아진다. 4개 테스트가 동시에. 신기했다. 그리고 무서웠다. 제대로 돌아가는 건지. 첫 실행: 2시간 11분 금요일 오후 4시. 첫 Grid 테스트 실행. 전체 1,200개 케이스. 4개 Node. pytest -n 4 --html=report.html tests/시작했다. Grid Console 보면서 모니터링. Node 4개 모두 바쁘게 움직인다. 브라우저 열리고 닫히고. 테스트 케이스 이름 스쳐 지나간다. 진행 상황 실시간 업데이트. 50개... 100개... 200개... 중간에 멈칫한다. Node 하나가 응답 없음. 타임아웃. 재시작. 다시 진행. 500개... 800개... 1,000개...오후 6시 11분. 완료. 결과 확인. 실행 시간: 2시간 11분. 기존: 6시간 30분. 시간 단축: 66%. 성공한 건가? 애매했다. 이론상 1.5시간 예상했는데 2시간 넘었다. 원인 분석했다.Node 재시작 시간 약 15분 테스트 간 대기 시간 (동기화) Hub-Node 통신 오버헤드 일부 테스트가 다른 것보다 훨씬 김그래도 만족했다. 배포 전날 밤에 시작하면 아침에 결과 본다. 충분하다. 브라우저 매트릭스: Chrome, Firefox, Safari Grid의 진짜 장점은 여기서 나왔다. 여러 브라우저 동시 테스트. 기존에는 이랬다. Chrome으로 전체 테스트 → 6시간. Firefox로 다시 전체 테스트 → 6시간. 총 12시간. 하루가 걸렸다. 크로스 브라우저 테스트는 사치였다. 중요한 기능만 Firefox 확인. Grid로 바꾼 후. Chrome Node 2개. Firefox Node 2개. 동시 실행. @pytest.fixture(params=['chrome', 'firefox']) def driver(request): browser = request.param if browser == 'chrome': caps = DesiredCapabilities.CHROME else: caps = DesiredCapabilities.FIREFOX driver = webdriver.Remote( command_executor='http://localhost:4444', desired_capabilities=caps ) yield driver driver.quit()Pytest의 parametrize 기능. 같은 테스트를 다른 브라우저로. 실행하면 자동으로 두 번 돌아간다. Chrome 버전, Firefox 버전. 실제 실행 시간. Chrome 1,200개 + Firefox 1,200개 = 2,400개. 4개 Node로 병렬 실행. 약 3시간 30분. 기존 12시간이 3.5시간 됐다. 시간 단축 70%. 더 나갔다. Safari도 추가하고 싶었다. 문제는 Safari는 macOS에서만 돌아간다. 해결책: AWS EC2 Mac 인스턴스. 비싸다. 하지만 필요했다. mac1.metal 인스턴스 띄웠다. Safari Node 설치. Grid Hub에 연결. 이제 Node 5개. Chrome 2개, Firefox 2개, Safari 1개. 테스트 코드에 Safari 추가. @pytest.fixture(params=['chrome', 'firefox', 'safari']) def driver(request): # ...3개 브라우저 동시 테스트. 총 3,600개 케이스. 5개 Node. 약 4시간 30분. 크로스 브라우저 호환성 문제 많이 잡혔다. Safari에서만 깨지는 CSS. Firefox에서만 안 되는 JavaScript. Grid 없었으면 못 찾았을 버그들이다. Node 관리: 죽고 살리고 Grid 운영하면서 배웠다. Node는 자주 죽는다. 메모리 부족. 브라우저 크래시. 네트워크 타임아웃. 알 수 없는 오류. 처음엔 당황했다. 테스트 중간에 Node 하나 죽으면? 전체가 멈춘다. 재시작해야 한다. 모니터링 스크립트 만들었다. import requestsdef check_grid_health(): response = requests.get('http://localhost:4444/status') data = response.json() for node in data['value']['nodes']: if not node['availability'] == 'UP': print(f"Node down: {node['id']}") restart_node(node['id'])5분마다 실행. cron으로. Node 상태 확인. 죽으면 재시작. 자동 복구 시스템이다. 밤새 테스트 돌려도 안심. Docker로 구성해서 재시작이 쉬웠다. docker restart chrome-node-110초면 다시 살아난다. Grid Hub에 자동 재연결. 하지만 근본 원인도 찾아야 했다. 왜 죽는가? 가장 흔한 원인: 메모리 누수. 브라우저 driver를 제대로 종료 안 하면. 메모리 계속 쌓인다. def teardown(): try: driver.quit() except: pass # 이미 죽었을 수도항상 quit() 호출. try-except로 안전하게. 두 번째 원인: 타임아웃. 느린 페이지 로딩. 무한 대기. driver.set_page_load_timeout(30) driver.implicitly_wait(10)타임아웃 설정 필수. 30초 넘으면 실패 처리. 세 번째 원인: Stale Element. DOM 변경되는데 이전 element 참조. from selenium.common.exceptions import StaleElementReferenceExceptiontry: element.click() except StaleElementReferenceException: element = driver.find_element(...) element.click()재탐색 로직 추가. 이런 개선들로 Node 안정성 올라갔다. 24시간 연속 실행 가능해졌다. 비용: 클라우드 vs 온프레미스 Grid 구축하면서 비용 고민했다. 두 가지 선택지.클라우드: AWS, Azure, BrowserStack 온프레미스: 자체 서버우리는 하이브리드로 갔다. 기본 Node는 온프레미스. 사무실 구석에 서버 3대. Ubuntu 설치. Docker 세팅. Chrome Node 6개 돌린다. 비용: 서버 구매비 약 600만원. 전기세 월 10만원 정도. Safari 테스트만 클라우드. AWS EC2 mac1.metal. 시간당 약 1.5달러. 하루 8시간만 켠다. 월 약 360달러 = 45만원. BrowserStack도 고려했다. 월 200달러부터. 병렬 5개 기준. 편하다. 모든 브라우저 지원. 하지만 우리는 온프레미스 선택. 이유: 테스트 데이터 보안. 내부 API 테스트가 많았다. 외부로 나가면 안 되는 데이터. BrowserStack은 퍼블릭 사이트 테스트용으로만. 중요 테스트는 내부 Grid. 비용 정리.초기 투자: 600만원 (서버) 월 고정: 10만원 (전기) 월 변동: 45만원 (AWS Mac) 총 월 55만원기존에 QA 인력 늘리려면? 1명 추가 = 월 400만원. Grid가 훨씬 싸다. 실전 팁: 내가 배운 것들 1. Node 수는 테스트 특성에 맞춰라 처음엔 무작정 Node 많이 띄웠다. 10개 띄우면 10배 빠를 줄 알았다. 아니다. 테스트가 API 호출 많으면? 서버가 병목이다. Node 10개가 동시에 API 때리면. 서버 죽는다. 적절한 수를 찾아야 한다. 우리는 4-6개가 최적이었다. 2. 테스트 그룹화 빠른 테스트와 느린 테스트 분리. 유닛 테스트: 5분. E2E 테스트: 2시간. 따로 돌린다. pytest -n 4 tests/unit/ # 빠른 것 pytest -n 2 tests/e2e/ # 느린 것느린 테스트는 Node 적게. 한 Node가 오래 걸리는 테스트 하나 맡으면. 다른 Node는 놀고 있다. 밸런싱이 중요하다. 3. 실패 재실행 Grid에서 테스트 실패하면. 원인이 두 가지다. 진짜 버그 vs Flaky 테스트. Flaky는 재실행하면 성공한다. Pytest에 옵션 있다. pytest --reruns 2 --reruns-delay 1실패하면 2번 재시도. 1초 대기 후. 이것만으로 false positive 많이 줄었다. 4. 리포트 통합 4개 Node에서 나온 결과. 따로따로 보면 혼란스럽다. 통합 리포트 필요하다. pytest-html 사용. pytest -n 4 --html=report.html --self-contained-html하나의 HTML 파일. 모든 테스트 결과 정리. 실패 원인, 스크린샷 포함. 이걸 Slack으로 자동 전송. import requestsdef send_to_slack(report_url): webhook_url = "https://hooks.slack.com/..." message = { "text": f"테스트 완료: {report_url}" } requests.post(webhook_url, json=message)아침에 출근하면 Slack에 결과 있다. 5. 캐시 활용 테스트마다 매번 로그인하면 느리다. 로그인 상태를 캐시한다. @pytest.fixture(scope='session') def auth_token(): # 한 번만 로그인 token = login() return tokendef test_something(driver, auth_token): driver.add_cookie({'name': 'auth', 'value': auth_token})session scope fixture. 전체 테스트에서 한 번만 실행. 시간 많이 절약된다. 2개월 후: 8시간이 1시간 45분으로 지금은 Grid 없이 못 산다. 통계를 냈다.전체 테스트: 1,500개 (늘었다) 3개 브라우저 (Chrome, Firefox, Safari) 총 4,500개 케이스 실행 시간: 1시간 45분기존 순차 실행이었으면? 약 25시간. 시간 단축: 93%. 금요일 오후 3시에 시작. 5시 전에 결과 나온다. 배포 여유 있게 진행. 더 좋은 점. PR마다 테스트 돌린다. GitHub Actions에 Grid 연동. 개발자가 코드 푸시하면. 자동으로 테스트 실행. 10분 안에 결과. 버그가 프로덕션 가기 전에 잡힌다. 이게 QA의 목표다. 팀장이 물었다. "Grid 투자 대비 효과는?" 계산해봤다.시간 절약: 주 40시간 인건비 환산: 월 약 200만원 Grid 운영비: 월 55만원 순이익: 월 145만원ROI 264%. 숫자로 증명됐다. 다음 목표: Kubernetes와 Auto-scaling Grid는 끝이 아니다. 다음 단계를 본다. 지금은 고정 Node. 6개 Node가 항상 돌아간다. 테스트 없을 때도. 비효율이다. Kubernetes로 옮기고 싶다. Auto-scaling 설정. 테스트 요청 들어오면. Pod 자동 생성. 필요한 만큼만 Node 띄운다. 테스트 끝나면. Pod 자동 종료. 비용 절감. 아직 공부 중이다. Helm Chart 보고 있다. 복잡하다. 하지만 재밌다.Grid 덕분에 주말이 편해졌다. 노트북도 쉰다. 나도 쉰다.
- 10 Dec, 2025
테스트 실패 분석: 버그인가, 스크립트 문제인가 판단하는 법
테스트 실패 분석: 버그인가, 스크립트 문제인가 판단하는 법 10시 출근, 빨간 불 출근했다. 슬랙에 알림 12개. Jenkins에서 온 거다. "Build #247 Failed - 18 test cases" 커피 먼저 탔다. 보고 나면 점심까지 못 마신다. 경험으로 안다. 모니터 3개 켰다. 왼쪽에 Jenkins, 가운데 로그, 오른쪽에 코드. 이게 내 전투 준비다. 18개 실패. 많은 편이다. 어제 배포 있었나 확인했다. Dev팀이 프론트엔드 UI 수정했다. 아. 첫 느낌으로 70%는 스크립트 문제다. 나머지 30%가 진짜 버그일 확률.로그부터 읽는다 로그 읽기가 제일 중요하다. 근데 로그 해석은 경력이다. 첫 번째 실패 케이스 열었다. ElementNotInteractableException: Element <button id="submit-btn"> is not clickable at point (450, 320). Other element would receive the click: <div class="modal-overlay">이건 명백하다. 모달이 버튼을 가린 거다. 개발자가 모달 로직 바꿨나 보다. 두 번째 케이스. TimeoutException: Message: Expected condition failed: waiting for visibility_of_element_located((By.ID, "user-profile"))타임아웃. 이것도 익숙하다. 두 가지 가능성.페이지 로딩이 느려졌다 (서버 문제) Element ID가 바뀌었다 (UI 변경)세 번째부터는 패턴이 보인다. 전부 같은 에러. NoSuchElementException: Unable to locate element: {"method":"css selector","selector":".btn-primary"}클래스명이 바뀐 거다. .btn-primary를 .primary-button로 바꿨나. 개발자한테 슬랙 날렸다. "어제 버튼 클래스명 바꿨어?" "아 맞다. 디자인 시스템 통일한다고" "..." 이래서 QA가 스트레스받는다. 알려주지도 않는다.스크린샷 체크 로그만으로 부족할 때 스크린샷 본다. Selenium은 실패 시 자동 캡처하게 설정해뒀다. /screenshots/test_login_flow_20240115_0342.png 열었다. 화면에 로딩 스피너가 있다. 아직 페이지가 다 안 떴는데 테스트가 실행된 거다. 이건 스크립트 문제다. Wait 조건이 부족해. 현재 코드 확인했다. 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()바로 찾아서 클릭한다. Wait 없다. 고쳐야 한다. wait = WebDriverWait(driver, 10) username_field = wait.until( EC.element_to_be_clickable((By.ID, "username")) ) username_field.send_keys("test@test.com")이렇게 바꿔야 안정적이다. 다른 스크린샷도 봤다. 결제 페이지 테스트 실패 건. 화면에 에러 메시지가 떠 있다. "Invalid payment method" 이건 진짜 버그다. 로그가 아니라 화면으로 확인됐다. JIRA 티켓 생성했다. "[BUG] Payment fails with valid test credit card" 재현 스텝 써서 Dev팀에 할당. 스크린샷 3장 첨부했다. 개발자가 바로 이해하게. 재현 테스트의 기술 의심스러운 케이스는 직접 돌려본다. 자동화 테스트 결과를 100% 믿으면 안 된다. Flaky test가 있다. 터미널 열고 단일 테스트 실행했다. pytest tests/test_checkout.py::test_guest_checkout -v1차: PASSED 2차: PASSED3차: FAILED 3번 중 1번 실패. 전형적인 Flaky test다. 로그 다시 봤다. 네트워크 타임아웃이 원인이다. API 응답이 가끔 느리다. 서버 문제일 수도 있다. 재현율 체크를 위해 10번 돌렸다. for i in {1..10}; do pytest tests/test_checkout.py::test_guest_checkout; done10번 중 3번 실패. 30% 재현율. 이 정도면 스크립트 문제가 아니다. 서버나 네트워크 이슈다. 백엔드 개발자한테 알렸다. "Checkout API 응답 시간 체크해줄 수 있어? 간헐적으로 타임아웃 나." 답장 왔다. "어제 DB 인덱스 작업했는데 그것 때문일 수도" 역시. 버그 맞다.판단 기준표 4년 하면서 만든 내 기준이다. 스크립트 문제일 확률 높음:NoSuchElementException (90%) ElementNotInteractableException (85%) 같은 에러가 여러 테스트에서 발생 (95%) 최근 UI 배포 후 발생 (90%) 재현율 100% (80%)버그일 확률 높음:AssertionError (70%) 화면에 에러 메시지 표시 (95%) 예전엔 통과했던 테스트 (60%) 재현율 낮음, 간헐적 (40%) 특정 환경에서만 발생 (75%)애매한 경우:TimeoutException (50/50) StaleElementReferenceException (60% 스크립트) Network error (70% 버그) Screenshot이 공백 (디버깅 필요)물론 절대적이지 않다. 맥락을 봐야 한다. 어제 배포 있었나, 환경 설정 바뀌었나, 외부 API 문제인가. 실전 프로세스 내가 실제로 하는 순서다. 1. 슬랙 알림 확인 (30초)Jenkins 빌드 번호 체크 몇 개 실패했나 파악 급한 건지 판단2. Jenkins 대시보드 훑기 (2분)실패 케이스 이름 쭉 본다 패턴 찾는다 (같은 모듈? 같은 에러?) 어느 브랜치인지 확인3. 첫 번째 실패 로그 정독 (5분)에러 타입 확인 스택 트레이스 읽기 실패한 코드 라인 찾기4. 스크린샷 체크 (3분)화면 상태 확인 의도한 화면인지 판단 UI 변경 여부 파악5. 코드 리뷰 (5분)해당 테스트 스크립트 열기 Wait 조건 있나 확인 Selector가 정확한가 체크6. 재현 테스트 (10분)로컬에서 직접 실행 3회 이상 돌려보기 재현율 계산7. 판단 및 액션 (5분)스크립트 문제면 바로 수정 버그면 JIRA 생성 애매하면 개발자와 논의총 30분 정도 걸린다. 18개 실패면 오전 내내 이거다. 자주 속는 케이스들 경험으로 배운 함정들이다. 1. 타임아웃인 줄 알았는데 버그 로딩이 느린 게 아니라, API가 아예 안 돌아오는 거였다. 백엔드 500 에러. Wait을 늘려봤자 소용없다. 에러 로그 끝까지 읽어야 한다. 2. Flaky test인 줄 알았는데 스크립트 문제 가끔 실패하니까 Flaky라고 생각했다. 근데 아니었다. 페이지에 애니메이션이 있었다. 클릭 타이밍을 못 잡은 거다. time.sleep(1) 넣으니까 통과. 근데 이건 나쁜 코드다. EC.element_to_be_clickable로 바꿔야 한다. 3. 로컬에선 되는데 CI에서 실패 환경 차이다. CI 서버가 느리거나, 해상도가 다르거나, 브라우저 버전이 다르거나. Headless 모드 문제일 수도 있다. Docker 컨테이너에서 직접 돌려봐야 안다. 4. 에러 메시지를 믿었다가 낭패 "Element not found" 에러인데, 사실 iframe 안에 있었다. 에러 메시지만 보고 셀렉터 바꿨다가 시간 낭비. HTML 구조 직접 확인해야 한다. 개발자와 소통법 이게 제일 중요하다. 판단 끝나면 전달이다. 스크립트 문제일 때: 슬랙에 조용히 쓴다. "어제 UI 변경 건 때문에 테스트 18개 깨짐. 오늘 중으로 고칠게." 개발자 멘션 안 한다. 내가 처리할 거니까. 버그일 때: JIRA 티켓 먼저 만든다. 슬랙에 링크 공유. "[BUG-1234] 결제 실패 이슈 확인됨. 우선순위 체크 부탁드려." 재현 스텝을 명확히 쓴다.게스트로 장바구니 담기 체크아웃 페이지 이동 테스트 카드 입력 (4242 4242 4242 4242) 결제 버튼 클릭 결과: "Invalid payment method" 에러스크린샷 3장 첨부. 로그도 같이. 개발자가 "재현 안 되는데요?" 하면 환경 정보 준다.Browser: Chrome 120.0.6099 OS: Ubuntu 20.04 (CI server) Test account: test+guest@company.com API endpoint: staging.api.company.com애매할 때: 개발자 불러서 같이 본다. "이거 좀 봐줄 수 있어? 버그인지 스크립트 문제인지 모르겠어." 화면 공유하면서 재현한다. 같이 디버깅. 이게 제일 빠르다. 혼자 고민하지 말기. 오늘의 결과 18개 실패 분석 끝났다. 결과는 이렇다.스크립트 문제: 14개 (UI 변경으로 셀렉터 깨짐) 진짜 버그: 3개 (결제 API, 장바구니 버그 2건) Flaky test: 1개 (네트워크 타임아웃, 무시)14개는 내가 고친다. 클래스명 업데이트하면 된다. # Before driver.find_element(By.CLASS_NAME, "btn-primary")# After driver.find_element(By.CLASS_NAME, "primary-button")30분이면 끝난다. 정규표현식으로 일괄 치환. 버그 3건은 JIRA 티켓 만들었다. Critical 2개, Medium 1개. 개발팀 스탠드업에서 공유 예정. Flaky test는 일단 Ignore 처리. 나중에 네트워크 Retry 로직 추가할 거다. 점심 먹고 스크립트 수정 시작한다. 오후에는 신규 기능 테스트 작성해야 한다. 결제 모듈 v2가 나온다. 끝나지 않는다. QA는 계속된다.분석하는 30분이 하루를 결정한다. 빨간 불을 초록으로 바꾸는 게 내 일이다.