API 자동화 테스트: UI 테스트보다 빠르고 안정적이다
- 21 Dec, 2025
API 자동화 테스트: UI 테스트보다 빠르고 안정적이다
E2E 테스트가 또 깨졌다
월요일 아침 10시. 출근하자마자 슬랙 알림 30개.
“E2E 테스트 실패했어요.” “CI 막혔는데요?” “배포 못 하나요?”
밤새 돌린 자동화 테스트. 50개 중 12개 실패.
로그 확인했다. 버튼 클릭 타임아웃. 페이지 로딩 느림. 셀레니움 드라이버 오류.
진짜 버그는 하나도 없다.
E2E 테스트의 현실이다. Flaky 테스트. 불안정한 테스트. 실행할 때마다 결과가 다르다.
2년 전 내가 짠 UI 자동화 테스트. 커버리지 80% 목표로 만들었다.
지금은 유지보수가 더 큰 일이다.
개발자가 CSS 클래스 이름 바꾸면 테스트 10개 깨진다. 애니메이션 추가하면 대기 시간 조정해야 한다. 브라우저 업데이트되면 드라이버도 업데이트.
테스트 실행 시간도 문제다. 50개 테스트에 40분. CI에서 돌리면 배포가 느려진다.
“이거 자동화 의미 있나요?” 개발팀장이 물었다.
할 말이 없었다.

API 테스트로 갈아탔다
3개월 전. 테스트 전략을 바꿨다.
E2E는 최소화. 핵심 시나리오만 10개.
나머지는 API 테스트로 대체했다.
테스트 피라미드. 이론으로만 알던 걸 실전에 적용했다.
/\ UI (E2E) - 10개
/ \
/ API \ API - 150개
/______\
/ Unit \ Unit - 500개
/__________\
API 테스트가 중간 계층이다. 백엔드 로직 검증. UI 없이 빠르게.
requests 라이브러리. Python으로 간단하다.
import requests
response = requests.get('https://api.example.com/users/1')
assert response.status_code == 200
assert response.json()['name'] == '홍길동'
이게 전부다. 브라우저 띄울 필요 없다. 셀레니움 설치 안 해도 된다.
처음엔 개발자들이 의아해했다.
“유저는 API 안 보는데요?” “UI 테스트가 더 실제랑 비슷하지 않나요?”
맞는 말이다. 하지만 목적이 다르다.
E2E는 사용자 관점. 전체 플로우 확인. API 테스트는 비즈니스 로직 검증. 백엔드 동작 확인.
둘 다 필요하다. 비율이 문제다.
전에는 E2E 50개, API 20개. 지금은 E2E 10개, API 150개.
결과는 확실했다.

속도가 다르다
API 테스트는 빠르다.
E2E 1개 실행: 평균 48초 API 테스트 1개 실행: 평균 0.3초
160배 차이.
150개 API 테스트 실행 시간: 45초 전체 테스트 스위트 (E2E 10개 + API 150개): 9분
전에는 40분 걸렸다. 4배 빨라졌다.
CI 파이프라인이 막히지 않는다. 개발자들이 PR 올리고 10분 안에 결과 본다.
배포 속도도 올랐다. 하루 3번 배포에서 10번으로.
속도만 빠른 게 아니다. 안정성도 다르다.
E2E 테스트 성공률: 76% API 테스트 성공률: 99.2%
실패 원인도 명확하다. API 테스트가 실패하면 진짜 버그다.
- 200이어야 하는데 500
- JSON 필드 누락
- 비즈니스 로직 오류
UI 타임아웃 같은 거 없다. 네트워크는 가끔 느리지만 재시도하면 된다.
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
session = requests.Session()
retry = Retry(total=3, backoff_factor=0.5)
adapter = HTTPAdapter(max_retries=retry)
session.mount('https://', adapter)
response = session.get('https://api.example.com/users')
재시도 로직 3줄이면 된다. 셀레니움 암묵적 대기보다 확실하다.
개발자들 반응이 좋아졌다.
“테스트 실패 알림 오면 진짜 봐야 할 것만 남았어요.” “CI 빨라져서 피드백이 빠르네요.”
내가 원한 게 이거다. 신뢰받는 자동화.
시나리오 커버리지
API 테스트로 뭘 검증하나.
우리 서비스는 전자상거래 플랫폼이다. 핵심 기능들.
1. 인증/인가
def test_로그인_성공():
response = requests.post(
f'{BASE_URL}/auth/login',
json={'email': 'test@test.com', 'password': 'password123'}
)
assert response.status_code == 200
assert 'access_token' in response.json()
def test_잘못된_비밀번호():
response = requests.post(
f'{BASE_URL}/auth/login',
json={'email': 'test@test.com', 'password': 'wrong'}
)
assert response.status_code == 401
assert response.json()['error'] == 'Invalid credentials'
2. 상품 조회
def test_상품_목록_조회():
response = requests.get(f'{BASE_URL}/products')
assert response.status_code == 200
products = response.json()
assert len(products) > 0
assert 'id' in products[0]
assert 'name' in products[0]
assert 'price' in products[0]
def test_상품_상세_조회():
response = requests.get(f'{BASE_URL}/products/123')
assert response.status_code == 200
product = response.json()
assert product['id'] == 123
assert product['price'] > 0
3. 장바구니
def test_장바구니_추가():
token = get_auth_token()
response = requests.post(
f'{BASE_URL}/cart',
headers={'Authorization': f'Bearer {token}'},
json={'product_id': 123, 'quantity': 2}
)
assert response.status_code == 201
def test_장바구니_조회():
token = get_auth_token()
response = requests.get(
f'{BASE_URL}/cart',
headers={'Authorization': f'Bearer {token}'}
)
assert response.status_code == 200
cart = response.json()
assert cart['total_price'] > 0
4. 주문
def test_주문_생성():
token = get_auth_token()
response = requests.post(
f'{BASE_URL}/orders',
headers={'Authorization': f'Bearer {token}'},
json={
'items': [{'product_id': 123, 'quantity': 1}],
'address': '서울시 강남구',
'payment_method': 'card'
}
)
assert response.status_code == 201
order = response.json()
assert order['status'] == 'pending'
assert order['total_amount'] > 0
이런 테스트들. 비즈니스 로직 검증.
UI에서 하면 어떻게 되나.
- 로그인 페이지 접속 (5초)
- 아이디/비밀번호 입력 (3초)
- 로그인 버튼 클릭 (2초)
- 메인 페이지 로딩 (4초)
- 상품 검색 (6초)
- 상품 클릭 (3초)
- 상세 페이지 로딩 (4초)
- 장바구니 추가 버튼 클릭 (2초)
- 장바구니 페이지 이동 (3초)
- 주문하기 버튼 클릭 (2초)
- 주문 정보 입력 (10초)
- 결제 버튼 클릭 (3초)
총 47초. API로 하면 1.2초.
게다가 UI 테스트는 앞 단계가 실패하면 다음 검증을 못 한다. 로그인 실패하면 주문 테스트도 못 돌린다.
API 테스트는 독립적이다. 각 엔드포인트 따로 검증.

실전 구조
우리 API 테스트 구조.
tests/
├── api/
│ ├── auth/
│ │ ├── test_login.py
│ │ ├── test_signup.py
│ │ └── test_token.py
│ ├── products/
│ │ ├── test_list.py
│ │ ├── test_detail.py
│ │ └── test_search.py
│ ├── cart/
│ │ ├── test_add.py
│ │ ├── test_update.py
│ │ └── test_remove.py
│ └── orders/
│ ├── test_create.py
│ ├── test_list.py
│ └── test_cancel.py
├── fixtures/
│ ├── auth.py
│ └── data.py
└── utils/
├── api_client.py
└── helpers.py
fixtures는 pytest fixture다. 재사용 가능한 설정.
# fixtures/auth.py
import pytest
import requests
@pytest.fixture
def api_client():
return requests.Session()
@pytest.fixture
def auth_token(api_client):
response = api_client.post(
f'{BASE_URL}/auth/login',
json={'email': 'test@test.com', 'password': 'test123'}
)
return response.json()['access_token']
@pytest.fixture
def authorized_client(api_client, auth_token):
api_client.headers.update({'Authorization': f'Bearer {auth_token}'})
return api_client
이렇게 만들면 테스트가 간단해진다.
def test_장바구니_조회(authorized_client):
response = authorized_client.get(f'{BASE_URL}/cart')
assert response.status_code == 200
인증 로직 반복 안 해도 된다.
utils는 공통 함수들.
# utils/api_client.py
class APIClient:
def __init__(self, base_url):
self.base_url = base_url
self.session = requests.Session()
def login(self, email, password):
response = self.session.post(
f'{self.base_url}/auth/login',
json={'email': email, 'password': password}
)
if response.status_code == 200:
token = response.json()['access_token']
self.session.headers.update({'Authorization': f'Bearer {token}'})
return response
def get(self, endpoint):
return self.session.get(f'{self.base_url}{endpoint}')
def post(self, endpoint, data):
return self.session.post(f'{self.base_url}{endpoint}', json=data)
이렇게 래핑하면 테스트가 더 읽기 쉽다.
def test_주문_생성():
client = APIClient(BASE_URL)
client.login('test@test.com', 'test123')
response = client.post('/orders', {
'items': [{'product_id': 123, 'quantity': 1}],
'address': '서울시 강남구'
})
assert response.status_code == 201
중복 코드가 줄어든다.
데이터 관리
API 테스트의 어려움. 테스트 데이터.
DB에 있는 데이터에 의존한다. 테스트마다 상태가 달라질 수 있다.
우리는 두 가지 방식을 쓴다.
1. 테스트 DB 초기화
각 테스트 전에 DB 리셋.
@pytest.fixture(autouse=True)
def reset_database():
# 테스트 전에 DB 초기화
db.reset()
db.seed_test_data()
yield
# 테스트 후에 정리
db.cleanup()
장점: 깨끗한 상태. 예측 가능. 단점: 느리다. DB 작업이 많으면 시간 걸림.
2. 동적 데이터 생성
테스트마다 고유 데이터 만들기.
import uuid
def test_회원가입():
unique_email = f'test_{uuid.uuid4()}@test.com'
response = requests.post(
f'{BASE_URL}/auth/signup',
json={
'email': unique_email,
'password': 'test123',
'name': '테스터'
}
)
assert response.status_code == 201
장점: 빠르다. 병렬 실행 가능. 단점: 데이터 쌓임. 정리 필요.
우리는 섞어서 쓴다. 읽기 작업은 시드 데이터. 쓰기 작업은 동적 생성.
그리고 테스트 후 정리.
created_resources = []
def test_상품_생성():
response = requests.post(f'{BASE_URL}/products', json={'name': '테스트상품'})
product_id = response.json()['id']
created_resources.append(('products', product_id))
assert response.status_code == 201
@pytest.fixture(scope='session', autouse=True)
def cleanup_resources():
yield
# 모든 테스트 끝난 후
for resource_type, resource_id in created_resources:
requests.delete(f'{BASE_URL}/{resource_type}/{resource_id}')
완벽하진 않다. 하지만 관리 가능한 수준.
실패 시 디버깅
API 테스트가 실패하면 어떻게 하나.
로그가 중요하다.
import logging
logging.basicConfig(level=logging.DEBUG)
def test_주문_생성():
response = requests.post(f'{BASE_URL}/orders', json={'items': []})
logging.info(f'Request URL: {response.request.url}')
logging.info(f'Request Body: {response.request.body}')
logging.info(f'Response Status: {response.status_code}')
logging.info(f'Response Body: {response.text}')
assert response.status_code == 201
실패하면 전체 요청/응답이 보인다.
pytest는 실패한 테스트만 로그 보여준다. 성공하면 조용.
더 자세히 보려면 pytest 플러그인.
# conftest.py
import pytest
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == 'call' and report.failed:
# 실패한 테스트 정보 저장
if hasattr(item, 'funcargs'):
for name, value in item.funcargs.items():
if hasattr(value, 'last_response'):
print(f'\n마지막 응답: {value.last_response.text}')
CI에서 돌릴 때는 Allure 리포트.
pytest --alluredir=./allure-results
allure serve ./allure-results
예쁜 HTML 리포트. 요청/응답 전부. 스크린샷 대신 JSON.
디버깅이 E2E보다 쉽다. 브라우저 열 필요 없다. 로그만 보면 된다.
한계도 있다
API 테스트가 만능은 아니다.
못 잡는 버그들.
1. UI 버그
- 버튼이 안 보임
- CSS 깨짐
- 모바일 레이아웃 오류
API는 정상이어도 화면은 문제일 수 있다.
2. 통합 이슈
- API는 각각 정상
- 하지만 함께 쓰면 문제
예: 장바구니 API 정상. 결제 API 정상. 그런데 장바구니→결제 플로우에서 세션 유지 안 됨.
3. 타이밍 이슈
- 비동기 처리
- 웹소켓 통신
- 실시간 업데이트
API 테스트는 요청/응답만 본다. 실시간 상태 변화는 못 본다.
4. 브라우저 호환성
- Chrome은 되는데 Safari는 안 됨
- 이건 API 문제가 아니다
그래서 E2E를 완전히 없앨 순 없다.
우리 전략.
- API 테스트: 비즈니스 로직, 백엔드 검증
- E2E 테스트: 핵심 사용자 플로우 10개
- 시각 테스트: UI 변경 감지 (Percy 같은 툴)
- 모니터링: 실제 사용자 오류 추적 (Sentry)
계층별로 역할 분담.
CI 파이프라인 통합
Jenkins 파이프라인에 통합했다.
pipeline {
agent any
stages {
stage('Unit Tests') {
steps {
sh 'pytest tests/unit -v'
}
}
stage('API Tests') {
steps {
sh 'pytest tests/api -v --junit-xml=results.xml'
}
post {
always {
junit 'results.xml'
}
}
}
stage('E2E Tests') {
when {
branch 'main'
}
steps {
sh 'pytest tests/e2e -v'
}
}
}
}
Unit → API → E2E 순서.
API 실패하면 E2E 안 돌린다. 시간 절약.
PR에서는 API까지만. E2E는 메인 브랜치에만.
병렬 실행도 한다.
stage('API Tests') {
parallel {
stage('Auth Tests') {
steps {
sh 'pytest tests/api/auth -v'
}
}
stage('Products Tests') {
steps {
sh 'pytest tests/api/products -v'
}
}
stage('Orders Tests') {
steps {
sh 'pytest tests/api/orders -v'
}
}
}
}
독립적인 테스트들이니까 가능하다.
실행 시간이 반으로 줄었다.
팀 설득 과정
처음엔 반대가 있었다.
개발팀장: “유저는 API 안 보잖아요.” 프론트 개발자: “UI 테스트가 더 중요한 거 아닌가요?” QA 후배: “저는 셀레니움만 배웠는데…”
설득 방법.
-
데이터로 보여줬다.
- E2E 실패율 24%
- API 실패율 0.8%
- 실행 시간 비교
-
파일럿 프로젝트.
- 인증 API만 먼저 자동화
- 2주 후 결과 공유
- “이거 괜찮은데요?”
-
점진적 적용.
- E2E 줄이고 API 늘리기
- 한 번에 바꾸지 않음
-
교육.
- requests 라이브러리 세션
- API 테스트 작성 가이드
- 페어 프로그래밍
지금은 다들 만족한다.
개발자: “테스트 신뢰도 올라서 좋아요.” QA 후배: “생각보다 쉽네요.” 팀장: “배포 속도 빨라졌어요.”
변화는 서서히.
유지보수
API 테스트도 코드다. 유지보수 필요하다.
관리 포인트.
1. 엔드포인트 변경
백엔드가 API 수정하면 테스트도 수정.
# 전: /api/v1/products
# 후: /api/v2/products
# config.py에서 버전 관리
API_VERSION = 'v2'
BASE_URL = f'https://api.example.com/api/{API_VERSION}'
한 곳만 바꾸면 됨.
2. 응답 포맷 변경
JSON 구조 바뀌면 assertion 수정.
# 전: {'user': {'name': '홍길동'}}
# 후: {'data': {'user': {'name': '홍길동'}}}
# 헬퍼 함수로 추상화
def get_user_name(response):
return response.json()['data']['user']['name']
assert get_user_name(response) == '홍길동'
3. 인증 방식 변경
JWT에서 OAuth로 바꾸면 fixture 수정.
@pytest.fixture
def auth_token():
# JWT 방식
# response = login(email, password)
# return response.json()['token']
# OAuth 방식
oauth = OAuth2Session(client_id)
token = oauth.fetch_token(token_url)
return token['access_token']
4. 테스트 데이터 정리
쌓이는 데이터 주기적으로 삭제.
# 매일 새벽 3시
0 3 * * * python scripts/cleanup_test_data.py
월 1회 리뷰. 안 쓰는 테스트 삭제. 중복 테스트 합치기.
성과
숫자로 보는 변화.
전 (E2E 중심)
- 테스트 개수: 50개
- 실행 시간: 40분
- 성공률: 76%
- 배포 횟수: 하루 3회
- 버그 탈출: 월 8건
후 (API 중심)
- 테스트 개수: 160개 (E2E 10 + API 150)
- 실행 시간: 9분
- 성공률: 98.5%
- 배포 횟수: 하루 10회
- 버그 탈출: 월 2건
커버리지는 올리고 시간은 줄었다.
비용도 줄었다. CI 실행 시간 줄어서 인프라 비용 30% 감소.
개발자 만족도도 올랐다. 설문조사 결과.
“테스트 신뢰한다”: 45% → 89% “CI가 빠르다”: 32% → 91% “테스트가 도움된다”: 51% → 87%
내가 가장 만족하는 건. 야근이 줄었다.
전에는 E2E 실패 원인 찾느라 밤 늦게까지. 지금은 API 실패하면 5분 안에 원인 파악.
Flaky 테스트 스트레스가 없어졌다.
추천 사항
API 테스트 시작하려면.
1. 작게 시작
한 번에 다 바꾸지 마라. 한 모듈부터.
인증 API 5개 테스트. 2주 돌려보기.
2. 개발자와 협업
API 문서 같이 보기. 엔드포인트 이해하기.
Swagger 있으면 더 좋다.
3. 도구는 단순하게
requests만으로 충분하다. 복잡한 프레임워크 필요 없다.
pytest + requests + 약간의 fixture.
4. CI 먼저 통합
로컬에서만 돌리지 마라. CI에서 자동 실행.
빠른 피드백이 핵심.
5. 문서화
API 테스트 작성 가이드 만들기.
“이런 케이스는 이렇게” 예시.
6. 리팩토링 시간 확보
테스트도 코드다. 정리 시간 필요.
월 1회 리팩토링 데이.
E2E는 느리고 불안정하다. API 테스트는 빠르고 안정적이다. 완전히 대체는 못 한다. 하지만 대부분을 대체할 수 있다. 테스트 피라미드 중간 계층. 여기가 가장 효율적이다. 셀레니움 스트레스에서 해방됐다. requests 한 줄이면 된다. 고민하지 말고 시작하자. 작은 테스트 하나부터.
