← 글 목록

GitHub Actions CI 실패 원인 추적하기 - PR 타입체크 에러 디버깅 (연재 2/3)

/ 7분 분량

오픈소스 프로젝트 PR에서 프론트엔드 CI가 실패하는 걸 발견하고, 원인을 끝까지 추적해봤습니다. 단순히 에러 로그만 보고 끝내지 않고 "왜 하필 지금 이 에러가 나는가"까지 파고들어본 기록입니다.

학습 주제

  • 주제: GitHub Actions CI 실패 원인 분석, paths-filter 기반 CI 분리 구조, lint/build의 역할
  • 날짜: 2026-07-21

탐구 과정

특정 PR의 CI가 실패했다는 걸 확인하고 처음엔 단순히 "어디서 뭐가 깨졌지?" 정도로 접근했습니다. 로그를 보니 npm run build 단계에서 타입 에러가 나고 있었어요.

./src/lib/feedApi.ts:8:3
Type error: Module '"@shared/types/feed"' has no exported member 'MagicLinkTokenStatusResponse'.

여기까지는 단순해 보였는데, 파고들수록 생각할 거리가 많아졌습니다. 특히 두 가지 질문이 꼬리를 물었어요.

첫 번째는 **"이전엔 문제없이 머지됐는데 갑자기 왜 에러가 나지?"**였습니다. 코드가 갑자기 나빠진 것도 아닌데 CI가 갑자기 화를 내니 이상했죠. 두 번째는 **"프론트는 어차피 Vercel이 배포하는데, CI에서 굳이 lint랑 build를 또 돌릴 필요가 있나?"**였습니다. 배포 파이프라인이 따로 있으면 CI는 뭘 해야 하는 건지 헷갈렸어요.

이 두 질문을 하나씩 풀어가면서 CI의 역할에 대한 이해가 꽤 명확해졌습니다.

핵심 학습 내용

1. "갑자기 에러난 게 아니라, 처음으로 검사받은 것"

PR의 CI 실행 이력을 확인해보니, 해당 브랜치에서 CI가 딱 두 번 돌았는데 둘 다 프론트 CI 잡이 새로 생긴 커밋 이후였습니다. 그 전 ci.yml을 뜯어보니:

on:
  pull_request:
    paths: ['backend/**', 'shared/**', 'frontend/**']
jobs:
  build-and-test:   # 이름과 달리 backend에서 gradle 빌드만 함

frontend/** 변경이 CI를 트리거는 했지만, 실제로 npm run build를 실행하는 잡 자체가 없었습니다. 즉 이 저장소 역사상 프론트 코드는 CI에서 한 번도 실제로 빌드된 적이 없었던 거예요. 어제 백/프론트 잡을 분리하면서 프론트 잡에 npm run build가 처음 생겼고, 그 이후 처음 돈 CI가 하필 이 PR이었던 겁니다.

여기서 배운 게 컸습니다. **"CI가 갑자기 문제를 만든 게 아니라, 원래 있던 문제를 처음으로 잡아낸 것"**이라는 점이요. 안전장치가 새로 생긴 시점과 버그가 발견된 시점이 겹치면, 안전장치 탓처럼 보이기 쉽다는 걸 실감했습니다.

2. 실제 원인: shared 타입 변경의 반쪽짜리 반영

근본 원인을 파보니, 해당 PR이 글쓰기 인증 방식을 매직링크 토큰 → 로그인 쿠키 기반으로 바꾸면서 백엔드의 MagicLinkToken* 관련 코드와 shared/types/feed.ts의 관련 타입을 전부 지웠는데, 프론트의 feedApi.ts는 안 건드려서 여전히 삭제된 타입을 import하고 삭제된 엔드포인트를 호출하고 있었습니다.

이걸 보면서 "shared 타입은 프론트/백엔드가 공유하는 계약이니, 한쪽만 바꾸면 반드시 다른 쪽도 깨진다"는 당연하지만 실감 나는 교훈을 얻었습니다.

3. Vercel과 GitHub Actions CI, 왜 둘 다 필요한가

"어차피 Vercel이 배포하는데 CI에서 build를 또 돌릴 필요가 있나?" 하는 의문이 있었는데, 확인해보니 Vercel도 같은 커밋에서 같은 타입 에러로 배포가 실패하고 있었습니다. 즉 GH Actions의 frontend 잡이 없었어도 Vercel 배포 자체가 이 코드로는 실패했을 거예요. 두 체크는 같은 버그를 서로 다른 위치에서 보고하고 있었을 뿐이었습니다.

4. lint와 build를 CI에 넣는 이유

이 부분이 가장 명확하게 정리된 개념이었습니다.

  • Lint: 문법 실수, 안 쓰는 변수, hooks 규칙 위반 같은 걸 리뷰어가 눈으로 보기 전에 기계가 먼저 걸러줌
  • Build (next build): TypeScript 타입 에러, import 깨짐처럼 컴파일 자체가 안 되는 문제를 머지 전에 잡아줌

특히 Next.js는 tsc --noEmit 같은 순수 타입체크 전용 커맨드가 따로 없어서, next build를 돌리는 게 사실상 가장 빠른 타입체크 방법이라는 걸 알게 됐습니다. 배포용이 아니라 타입체크 겸용으로 빌드를 돌리는 구조였던 거죠.

이게 없었다면 이번 버그는 dev 브랜치에 그냥 머지됐다가, 나중에 프로덕션 배포 때나 다른 사람이 로컬에서 빌드할 때 발견됐을 겁니다. lint/build 스텝은 "발견 시점을 최대한 앞당기는" 역할을 한다는 걸 이번 케이스로 체감했습니다.

5. paths-filter로 CI 분리하기

백/프론트 CI가 이미 paths-filter 액션으로 분리돼 있던 구조도 확인했습니다. backend/나 shared/가 바뀌면 backend 잡만, frontend/나 shared/가 바뀌면 frontend 잡만 독립적으로 도는 방식입니다. 불필요한 잡을 안 돌려서 CI 시간도 아끼고, 실패 원인도 어느 영역인지 바로 구분되는 구조라 합리적이라고 느꼈습니다.

이해한 내용

  • CI 실패는 "설정이 갑자기 잘못됐다"보다 "숨어있던 버그를 이제야 잡았다"인 경우가 많다는 것
  • shared 타입처럼 여러 쪽이 함께 참조하는 계약을 바꿀 때는, 참조하는 모든 쪽을 같이 확인해야 한다는 것
  • 배포 도구(Vercel)와 CI(GitHub Actions)가 겹쳐 보여도, CI의 build/lint는 배포 성공 여부와 별개로 "머지 전 검증" 목적이 따로 있다는 것
  • Next.js에서는 build 자체가 타입체크 수단으로 쓰인다는 것

실전 적용

이번에 확인한 원인과 수정해야 할 파일(어떤 타입을 왜 못 쓰는지)을 정리해서 PR에 리뷰 코멘트로 남겨봤습니다. 담당자를 정확히 짚어 멘션하니 왔다갔다 설명하는 과정 없이 바로 소통이 됐어요. 앞으로 CI 실패를 마주치면:

  1. 실패 로그의 표면적 에러만 보지 말고, CI 실행 이력을 먼저 확인해서 "원래 통과하던 게 최근에 깨진 건지, 처음 걸린 건지" 구분하기
  2. shared 타입처럼 여러 모듈이 참조하는 걸 바꿀 때는 참조하는 곳을 grep으로 미리 훑어보기
  3. CI 설계 자체를 의심하기 전에, 실제 코드 버그인지 CI 설정 문제인지 먼저 구분하기

추가 학습 계획

  • GitHub Actions의 paths-filter 액션 옵션을 더 자세히 살펴보고, 모노레포 구조에서 CI 분리 패턴 정리해보기
  • 프론트엔드 테스트 프레임워크(vitest 등) 도입 시 CI에 테스트 스텝을 어떻게 추가하는 게 좋을지 조사
  • tsc --noEmit과 next build의 타입체크 범위 차이가 실제로 있는지 확인해보기
  • 인증 방식(매직링크 토큰 vs 세션 쿠키)의 보안 트레이드오프를 좀 더 깊게 공부하기