서비스 출시 준비 중 발견한 치명적인 문제점
지난 시간에 이어 sleuth의 백엔드 서버(FastAPI & Cloud Run) 로직을 프로덕션 환경을 위해 고도화하는 작업에 집중하고 있다. 평소처럼 로컬 환경에서 새로운 기능을 테스트해 보니 매끄럽게 잘 동작하기에, 큰 고민 없이 main 브랜치에 코드를 직접 푸시하고 배포를 진행했다. 팀에 개발을 담당하는 인력이 많이 없기도 하고, 백엔드 서버를 지금은 혼자 구축하고 있기에 편의를 위해 바로 main에 접근했었다.
하지만 결과는 심각했다. 클라우드에 배포된 서버를 테스트해 보니, 로컬에서는 단 한 번도 보지 못했던 500 에러(Gemini API 관련 400 INVALID_ARGUMENT)가 로그에 찍히고 있었다.
| 문제의 발견 |
순간 식은땀이 흘렀다... 내부 테스트를 앞두고 수정사항을 반영해서 배포했는데 안되다니...
만약 서비스가 정식 런칭된 상태에서 실제 사용자가 이 문제를 겪었다면 어땠을까? 로컬에서 잘 돌아간다고 프로덕션 환경도 안전하다는 것을 결코 보장하지 않는다는 원칙을 뼈저리게 체감한 순간이었다. (이번에는 Docker를 활용하고 있긴 하지만, 서버와 환경변수가 달라 발생한 문제였다)
아무튼 이 일로 배포 전에 잠재적인 버그와 환경 변수 누락 등을 차단할 수 있는 자동화된 테스트, 즉 CI(Continuous Integration) 파이프라인의 필요성을 절감하게 되었고, 곧바로 시스템 구축에 착수했다.
Phase 1: Testing 환경 구축
CI 파이프라인을 구축하기 위한 첫 번째 단계로, 코드가 내 의도대로 동작하는지 검증할 수 있는 '테스트 코드'를 작성하였다. 파이썬 생태계의 표준인 pytest와 비동기 처리를 위한 pytest-asyncio 프레임워크를 도입했다.
여기서 고민해야 할 핵심적인 문제는 '외부 의존성의 분리'였다. Sleuth 서버는 Firebase, Vertex AI 등 다양한 외부 API 서비스에 의존하는 형태로 구성되어 있다. 만약 테스트가 실행될 때마다 실제 API를 호출하게 된다면 비용 문제도 발생할뿐더러, 네트워크 상태에 따라 테스트 결과가 달라지는 불안정한 환경이 만들어진다. 특히 인증을 위해 Firebase Auth를 호출하는 걸 이전에는 로컬에서 테스트하기 위해 매번 test ID를 생성하는 귀찮은 과정을 수행해야 했었다.
| Firebase auth |
이를 해결하기 위해 unittest.mock을 적극적으로 활용하여 철저한 Mocking 설정을 진행했다.
더미 데이터를 반환하는 Fixture를 세팅하여 인증 과정을 우회하고, 비즈니스 로직 단위 테스트(app/domain/*)와 API 엔드포인트 통합 테스트(app/api/*) 시 외부 의존성의 개입을 차단했다.
import pytest
from typing import Generator
from fastapi.testclient import TestClient
from app.main import app
from app.core.auth import verify_firebase_token
@pytest.fixture(scope="module")
def client() -> Generator[TestClient, None, None]:
"""
Returns a TestClient instance with the Firebase auth dependency mocked.
"""
# 의존성 주입(DI)으로 인증 로직을 더미 함수로 교체
app.dependency_overrides[verify_firebase_token] = mock_verify_firebase_token
with TestClient(app) as c:
yield c
# 테스트 종료 후 오버라이드 설정 초기화
app.dependency_overrides.clear()이렇게 하면 외부 의존성에는 이상이 없다고 가정할 때, 순수히 우리 서버에서 발생한 문제를 간편하게 테스트할 수 있었다. 솔직히 말하면 이전에는 일일이 docker build를 해가면서 직접 API를 호출하는 방식이라 번거로웠었는데, 테스트 코드를 작성하니 그럴 필요가 없어 테스트하는 과정이 훨씬 효율적이면서도 누락될 걱정 없이 안전해졌다.
| Pytest |
또한, 코드의 가독성과 품질을 일관되게 유지하기 위해 black, isort, flake8을 린터와 포매터로 도입하여 코드 컨벤션을 강제하기로 했다. (gemini가 알려준 건데 덕분에 코드가 깔끔해진 느낌이다)
| Linter & Formatter |
Phase 2: GitHub Actions를 활용한 CI 파이프라인 연결
테스트 코드를 짰다면, 이제 이 테스트가 알아서 돌아가게 만들 차례다. 개발자가 코드를 수정할 때마다 매번 수동으로 터미널에 pytest를 입력하는 것은 비효율적일 뿐만 아니라, 실수로 누락할 가능성도 높다. 이전에 내가 미처 확인하지 못한 사례처럼... main branch에서 배포되기 전에 테스트가 통과하는지 확인하는 안전장치가 필요하다고 생각했다.
이를 위해 GitHub Actions를 활용하여, Pull Request(PR)가 생성되거나 업데이트될 때마다 자동으로 동작하는 CI 파이프라인을 작성했다. 동작의 흐름은 다음과 같이 설계했다.
환경 세팅 및 의존성 설치: Python 3.12 환경을 세팅하고
requirements.txt에 명시된 라이브러리를 설치한다.코드 퀄리티 검사:
black과isort,flake8을 차례로 실행하여 코드 컨벤션 위반 여부를 정적 분석한다.단위 및 통합 테스트 실행: Mocking된 환경 변수를 주입한 상태로
pytest를 실행하여 모든 비즈니스 로직의 무결성을 검증한다.Dockerfile 빌드 검사: 마지막으로
docker build명령어를 임시로 수행해 본다. 이는 배포 과정에서 컨테이너 이미지 빌드 자체가 실패하는 불상사를 사전에 막기 위함이다.
Phase 3: CD와 브랜치 보호 (Branch Protection) 규칙 적용
배포(CD)의 경우, GCP Cloud Run과 GitHub 레포지토리가 직접 연결되어 있어 main 브랜치에 코드가 병합되면 자동으로 새 리비전이 롤아웃되도록 설정되어 있었다. 하지만 앞선 실수처럼 누군가(내가) main 브랜치에 결함이 있는 코드를 직접 푸시해 버린다면, 자동화된 배포로 인해 프로덕션 환경에서 큰 문제가 발생할 수 있다.
이를 미연에 방지하기 위해 GitHub의 조직에서 Branch Protection Rules을 강제했다.
main브랜치로의 직접 푸시(Direct Push)를 전면 금지하고, 반드시 Pull Request를 생성하도록 만들었다.PR이 생성되면 앞서 구축한 CI 파이프라인의
test작업이 필수로 실행되며, 이 단계가 성공(✅)으로 끝나야만main으로 코드를 병합(Merge)할 수 있도록 잠금장치를 걸었다.
마치며
테스트 코드와 CI 환경을 구축하는 과정은 처음에는 번거롭기도 하고 지금까지 잘 하고 있었어서 도입의 필요성을 느끼지 못했다. 하지만 이를 실무에 적용해 보니, 그 생각이 완전히 틀렸음을 깨닫게 되었다. (특히나 Pytest... 함수 단위로 간편하게 테스트가 가능하다니... 이걸 모르고 있었다)
기능을 구현하기 전 실패하는 테스트를 먼저 작성하고(Red), 테스트를 통과할 최소한의 코드를 작성하는(Green) TDD 사이클을 CI와 결합하니, 개발 경험 자체가 달라졌다. 코드를 커밋할 때마다 CI 서버가 즉각적인 피드백을 주었고, 대대적인 리팩토링을 진행할 때도 기능이 망가지지 않았을까하는 막연한 두려움이 사라졌다. 혹여나 로직에 결함이 발생하면 파이프라인 단계에서 에러가 발생하며 배포를 막아주었기 때문이다.
결과적으로 자동화된 테스트 환경은 프로덕션 레벨의 치명적인 버그를 조기에 발견하게 해주었고, 수동 테스트에 쏟던 시간을 아껴 전체적인 배포 주기를 단축시킬 수 있었다.
개인적으로 지난 Vertex AI에 이어 이번 CI 파이프라인 구축 또한 sleuth가 상용 서비스로서 나아가기 위해 기반을 다지는 매우 의미 있는 한 걸음이었다.
추천글
[스타트업/기술] FastAPI와 클라우드 네이티브 | 클라이언트 LLM 호출의 한계와 백엔드 전환기 (Part 1)
[스타트업/기술] FastAPI와 클라우드 네이티브 | Cloud Run 도입과 백엔드 전환기 (Part 2)