코딩은 확률적이라도 배포는 확정적이어야 한다

한 웨비나에서 들은 “코딩은 확률적이라도 배포는 확정적이어야 한다”는 말이 계속 남았다.
AI로 코드를 빠르게 찍어내는 만큼 정확도와 보안 결점의 위험도 커지는데, 정작 우리 프로젝트에는 자동 테스트도, main 병합 전 게이트도 없었다.
CRUD가 되는지는 매번 손으로 확인했다.

그래서 병합부터 배포까지를 “병합 전 CI → 배포 전 통합 검증 → 배포”의 단계적 방어막으로 세우기로 했다.
우산 이슈 하나를 만들고 그 아래로 단계를 쪼갰다.
이 글은 그 과정을 순서대로 정리한 것이다.

전체 그림

파이프라인은 세 겹으로 나눴다.

  • 병합 전(CI): MR을 열면 유닛 테스트만 빠르게 돌려 깨진 코드가 main에 들어오는 걸 막는다.
  • 배포 전(CD 검증): main에 올라간 뒤, 실제 앱을 띄워 통합 E2E로 검증한다.
  • 배포: 검증을 통과했을 때만 개발 서버에 반영한다.

핵심 원칙은 “인프라 우선”이었다.
테스트 코드를 100% 다 짜두고 파이프라인을 한 번에 붙이려 하면 디버깅 지옥에 빠진다.
빈 도화지(파이프라인 도로망)를 먼저 뚫어두고 알맹이를 채우는 쪽이 훨씬 순탄했다.

실행 환경은 사내 개발 서버에 등록한 GitLab Runner(shell executor) 하나다.
Docker는 아직 도입하지 않았다.
이 “Docker 없음 + self-hosted 러너”라는 제약이 이후 거의 모든 결정을 좌우했다.

1. 병합 전 유닛 게이트

첫 단계는 유닛 테스트다.
우리 코드는 라우트, 서비스, 데이터 접근(3계층)이 깔끔히 나뉘어 있어서 서비스 계층의 순수 로직만 떼어 테스트하기 좋았다.
데이터 접근 함수는 mock으로 격리해 DB 없이 ms 단위로 돌게 했다.

  • 도구는 jest + ts-jest.
  • 결과는 jest-junit으로 뽑아 MR의 Test 탭에서 실패 원인을 바로 보게 했다.
  • 소스가 바뀌었는데 대응 테스트가 없으면 경고하는 잡을 하나 더 뒀다.
  • 이걸 위해 테스트 파일 명명 규칙을 <도메인>.<계층>.test.ts로 정하고, 변경된 서비스 파일마다 대응 테스트가 같이 바뀌었는지 1:1로 대조하게 했다.

여기서 정한 원칙 하나. MR 병합 게이트는 유닛만 담당한다.
매 MR마다 앱을 빌드하고 브라우저를 띄우는 건 과하니까 무거운 통합/E2E는 병합이 아니라 배포 흐름으로 미뤘다.

2. 통합 테스트용 일회용 DB

유닛이 mock이라면, 통합은 진짜 DB가 필요하다. “매 실행마다 생성되고 소멸하는 일회용 격리 DB”를 원했다.

처음엔 embedded-postgres(프로세스로 임시 PG를 띄우는 방식)를 붙여서 로컬·CI 양쪽에서 green까지 확인했다.
그런데 곱씹어 보니 러너가 개발 서버에 있고, 그 서버에 테스트 전용 PostgreSQL을 하나 두면 그만이었다.
그래서 개발 서버의 테스트 전용 PG에 localhost로 붙고, 매 실행 prisma db push --force-reset로 스키마를 초기화하는 방식으로 갈아탔다.

접속에 비밀번호가 필요 없도록 localhost trust 인증을 썼더니, CI 변수에 비밀 값을 둘 필요조차 없어졌다.

이 과정에서 오래 붙잡은 함정이 하나 있었다.
생성된 Prisma 클라이언트가 import.meta 기반 ESM이라, CommonJS로 도는 ts-jest에서 파싱이 안 됐다.
유닛 테스트는 데이터 계층을 mock해서 이 클라이언트를 안 탔기 때문에 안 걸렸는데, 통합은 실제 prisma를 임포트하니 정면으로 부딪혔다.
결국 통합 테스트만 ts-jest ESM 모드로 분리해서 풀었다.

3. 배포 전 통합 E2E

그다음은 Playwright로 실제 앱을 빌드·기동해서 핵심 화면이 크래시 없이 렌더되는지 보는 스모크 테스트다.
통합 DB 하네스를 그대로 재사용하고, jest와 Playwright가 같은 디렉토리에서 공존하도록 매처를 나눴다(jest는 *.test.ts, playwright는 *.spec.ts).

여기서도 현실의 벽이 몇 개 있었다.

  • 포트 충돌
    • 개발 서버에는 이미 다른 용도의 dev 서버가 3000을 상시 점유하고 있었다.
    • 그래서 E2E용 앱은 3100으로 띄웠다.
  • 브라우저 버전
    • 러너에 수동으로 깔아둔 chromium이 Playwright 버전과 안 맞아 실패했다.
    • 서버가 인터넷이 되는 걸 확인하고, CI가 프로젝트 버전에 맞춰 브라우저를 설치하도록 바꿨다(최초만 다운로드, 이후 캐시).
  • 인증 미구현
    • 원래는 “시드 계정으로 로그인 후 화면 확인”까지 하고 싶었지만, 앱에 로그인 기능 자체가 아직 없었다.
    • 그래서 이번엔 인증 없는 렌더 스모크까지만 하고, 로그인 검증은 인증 활성화 후속으로 분리했다.

이 E2E 잡은 매 push가 아니라 main에서 수동으로만 실행되게 뒀다.
배포하려 할 때 돌리는 관문이라는 뜻이다.

4. 개발 서버 배포 자동화

배포 단계에서 재밌는 점은, 러너와 배포 대상이 같은 서버라는 것이다.
처음엔 SSH가 필요 없다고 생각했다.
하지만 npm run dev 프로세스를 CI 잡이 끝난 뒤에도 살려두려면, 잡의 프로세스 트리 밖 별도 세션이 필요했다.
그래서 로컬이지만 SSH로 접속해 프로세스를 detach하는 구조를 택했다.

배포 잡은 소스를 rsync로 서비스 디렉토리에 미러하고, 원격에서 의존성 설치와 Prisma 클라이언트 생성을 거쳐 앱을 재기동한다.
그리고 앞의 통합 E2E를 needs로 걸어서, E2E가 통과해야만 배포 잡이 실행 가능하게 했다.

배포 대상이 사내 서버라 SSH는 비밀번호 대신 키 인증을 썼다.
개인키는 CI/CD의 File 타입 변수로 두면 로그에 안 찍히고, 마스킹 길이 제약도 우회된다.

5. 프로세스 관리: pm2에서 systemd로

재기동을 견고하게 하려고 처음엔 pm2를 붙였다.
설치부터 배포 연동, 서버 검증까지 다 마쳤는데, 문득 스스로에게 물었다.

“우리가 pm2의 핵심인 클러스터/무중단 리로드를 쓰나?”

안 썼다. 단일 dev 인스턴스만 필요했다.

이 프로젝트에 필요한 것은 지속·크래시 자동 복구·부팅 자동 기동·깨끗한 재시작·로그, 이 다섯이었다.
그건 OS에 이미 있는 systemd로 추가 의존성 없이 다 된다.
그래서 pm2를 걷어내고 systemd로 갈아탔다.
결정 근거는 이슈와 MR에 남겨뒀다.

배포 잡에 테스트를 합치지 않은 이유

한 번 고민했던 지점이다.
“배포 잡 안에서 E2E를 돌리고, 통과하면 이어서 배포”하는 단일 잡으로 갈까, 아니면 E2E 잡과 배포 잡을 나눌까.

결국 2단계로 나눴다.
E2E는 독립된 잡으로 두고, 배포 잡은 테스트를 품지 않고 needs로 그 성공에만 의존한다. 이유는 세 가지다.

  • E2E 리포트가 배포 로그에 섞이지 않고 별도 잡으로 깔끔하게 남는다.
  • 배포는 배포만, 검증은 검증 잡이 담당한다(관심사 분리).
  • 그러면서도 “E2E 통과 시에만 배포”라는 게이팅은 needs로 그대로 강제된다.

배포 파이프라인은 배포에만 집중하게 두는 편이 관리하기 편했다.

🪏 삽질 기록

돌이켜보면 파이프라인을 “짜는” 것보다 이 함정들을 하나씩 걷어내는 게 대부분의 시간이었다.

  • 러너가 잡을 안 가져감
    • UI에서 러너를 “생성”만 하면 서버의 설정 파일에 등록이 안 심긴다. 데몬이 폴링할 대상이 없어 잡이 무한 대기했다.
    • online 배지는 마지막 접속이 임계시간 내면 붙는 착시라, 실제로는 폴링을 안 하고 있었다.
  • 잡 실행 계정과 경로 권한
    • 시스템 서비스로 도는 러너의 잡은 러너 전용 계정으로 실행되는데, 빌드 경로를 다른 사용자 홈 아래로 잡아두는 바람에 접근 거부가 났다.
    • 게다가 그 빌드 경로가 배포 대상 디렉토리의 하위였어서, 배포의 rsync --delete가 러너 자기 작업 트리를 지우려다 자폭했다.
    • 빌드 경로를 옮기고, 배포 타깃이 러너 트리를 포함하면 중단하는 가드를 넣었다.
  • pkill 자기매칭
    • 기존 dev 프로세스를 정리하려고 pkill -f 'next dev'를 썼더니, 이 명령이 담긴 SSH 문자열 안에 next dev가 텍스트로 들어 있어서 배포 셸 자기 자신을 죽였다.
    • 애먼 exit 255에 한참 헤맸다.
    • 포트 기준으로 정리하는 쪽으로 바꿔서 자기매칭을 없앴고, 최종적으로는 systemd가 프로세스를 소유하면서 이 문제 자체가 사라졌다.
  • 사라진 .env
    • 배포 후 API가 500을 뱉었다
    • 원인은 .env가 gitignore라 체크아웃에 없었고, rsync --delete가 서버에 수동으로 놔뒀던 .env를 지워버린 것이었다
    • DB 설정이 통째로 날아갔다
    • 해결은 배포가 CI 변수로 .env를 생성하게 바꾼 것
      • 덤으로 자격증명을 git에 두던 부채도 같이 없어졌다.

회고

가장 뼈아팠던 건 마지막 부분이다.
모든 테스트가 green인데 실서버는 500이었다.

통합·E2E 테스트 DB는 커밋된 스키마로 만들어져서 자기들끼리 일관됐고, .env도 테스트용 값을 주입했으니 테스트는 아무 문제가 없었다.
정작 실서버는 실제 DB에 붙는데 .env가 없어서 죽은 것이다.

테스트가 통과했다는 사실은 “실서버가 정상”을 보장하지 않았다.
이건 결국 실서버에 직접 요청을 날려 실데이터가 나오는 걸 확인하고서야 잡혔다.
그래서 다음 과제로 “배포 후 실 DB를 대상으로 하는 스모크”를 남겨뒀다.
테스트 DB로는 실 DB 스키마 드리프트나 환경설정 누락 같은 걸 못 잡는다.

그리고 결정할 때마다 이슈와 MR에 근거를 남긴 게 나중에 크게 도움이 됐다(pm2를 왜 안 쓰는지, 배포 잡에 테스트를 왜 안 합치는지 같은 것들).

댓글남기기