홈시리즈멘토링

© 2026 정기창. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

Compiled successfully를 찍고 실패했습니다 — CI가 안 보고 있던 패키지

정기창·2026년 8월 26일

Node 버전을 올린 뒤 확인차 프론트 하나를 빌드해봤습니다. 그런데 빌드가 실패했습니다. 어제 깨진 게 아니라 그 전부터 깨져 있었고, main에 그대로 머물러 있었습니다. 아무도 몰랐던 이유는 단순합니다. 그 패키지는 CI에 빌드 잡이 없었습니다.

성공 메시지를 출력한 다음에 실패합니다

이 실패가 특히 눈에 걸렸던 것은 로그 모양 때문이었습니다.

✓ Compiled successfully
  Linting and checking validity of types ...
  Failed to compile.
  Type error: ...

컴파일과 타입체크는 다른 단계입니다. 앞 단계가 성공 메시지를 출력한 뒤에 뒤 단계가 실패하므로, 로그를 위에서부터 읽다 초록 체크를 보고 넘어가면 통과로 오독합니다. 사람이 눈으로 보든 스크립트가 문자열을 훑든 마찬가지입니다. 판정은 끝까지 돌린 exit code로만 해야 합니다.

같은 구멍이 세 번째였습니다

기록을 뒤져보니 이 레포에서 같은 계열의 사고가 이미 두 번 있었습니다.

사고무엇이 안 돌았나어떻게 드러났나
1배포 도구 테스트 137개가 경로 필터 밖한 번도 안 돌았다는 걸 나중에 발견
2e2e 163개가 CI 밖회귀를 놓치고 운영에서 발견
3프론트 패키지에 잡 자체가 없음다른 작업 중 우연히 빌드해봄

세 건의 공통점은 "테스트가 실패했는데 무시했다"가 아니라 "테스트가 아예 돌지 않았고 그 사실을 아무도 몰랐다"입니다. 실패는 시끄럽지만 부재는 조용합니다. 대시보드에는 초록만 보이고, 초록의 개수는 늘 그대로입니다.

넣기 싫었던 게 아니라 넣을 수 없었습니다

가장 배운 게 많았던 지점입니다. e2e를 CI에 왜 안 넣었을까 되짚어보니, 게으름이 아니라 구조 때문이었습니다. 그 설정 파일에는 테스트 실행 전에 서버를 띄우는 블록이 없었습니다. 즉 사람이 먼저 손으로 서버를 띄워둬야만 테스트가 돌았습니다. 그 상태에서는 CI에 넣는 것이 애초에 불가능합니다.

그러니까 순서가 이렇습니다. 로컬에서만 돌게 만들어졌고 → CI에 넣을 수 없었고 → 시간이 지나며 아무도 안 돌리게 되었고 → 깨진 지 언제부터인지도 모르게 되었습니다. 각 단계는 자연스럽고, 어느 시점에도 잘못된 결정을 내린 사람이 없습니다.

그래서 요즘은 CI에 없는 테스트를 볼 때 질문을 바꿉니다. "왜 안 넣었지"가 아니라 "넣을 수 있는 상태이긴 한가"를 먼저 봅니다. 대개는 넣기 싫었던 게 아니라 넣을 수 없게 되어 있습니다.

경계에서 나는 파손은 경계를 넘겨야 잡힙니다

이번에 깨져 있던 타입 에러는 공유 스키마 패키지와 그것을 쓰는 폼 사이에서 났습니다. 여기에 함정이 하나 있습니다. 스키마 패키지의 잡은 통과합니다. 자기 패키지 안에서는 아무 모순이 없기 때문입니다. 어긋남은 소비처에서만 드러납니다.

모노레포 CI는 보통 변경된 경로로 잡을 고릅니다. 그런데 그 필터 경계가 패키지 경계와 같으면, 경계에서 나는 결함은 구조적으로 잡히지 않습니다. 그래서 프론트 잡의 경로 필터에 공유 스키마 패키지를 함께 넣었습니다. 스키마가 바뀌면 소비처도 같이 빌드됩니다.

CI가 운영을 찌르지 않게

잡을 새로 만들면서 두 가지를 함께 막았습니다.

첫째, 빌드 타임에 API를 부르는 코드가 있어서 CI에서 그 주소를 어디로 둘지 정해야 했습니다. 운영 주소를 넣으면 편하지만 그건 CI가 매번 운영을 찌른다는 뜻입니다. 그래서 도달 불가능한 주소로 고정했습니다. 빌드 타임 fetch는 실패하면 fallback 경로로 넘어가 빌드가 정상 종료되므로, 이 방식이 "운영을 안 찌른다"를 구조적으로 보장합니다.

NEXT_PUBLIC_API_URL=http://127.0.0.1:1 pnpm --filter web build

둘째, e2e 설정의 주석이 운영 도메인을 baseURL 사용법으로 안내하고 있었습니다. 읽는 사람 입장에서는 그렇게 하라는 뜻입니다. 운영 사이트에 테스트를 쏘는 셈이라 지우고, 대신 허용 호스트 화이트리스트를 걸어 설정 로드 시점에 예외를 던지게 했습니다. 테스트가 하나라도 뜨기 전에 멈춥니다.

제외한 것을 코드에 적어둡니다

e2e 세 개 중 CI에 넣은 것은 하나뿐입니다. 나머지 둘은 실제 백엔드 응답을 그대로 소비해서, 요청을 가로채는 장치 없이는 CI에서 돌릴 수 없습니다.

중요한 것은 그 제외 사실을 설정 파일 주석에 적어둔 것입니다. 앞선 세 사고가 전부 "필터 밖에 있는 테스트를 아무도 모름"이었기 때문입니다. 제외 자체는 합리적일 수 있지만, 제외했다는 사실이 아무 데도 안 적혀 있으면 그것은 결정이 아니라 망각이 됩니다.

아직 남은 것

정직하게 적자면 이번 작업으로 다 메워진 게 아닙니다. 여전히 빌드 잡이 없는 패키지가 몇 개 남아 있고, 그중 하나는 얼마 뒤 실제로 운영 배포에서 터졌습니다. 그때 네 번째 잡을 만들었습니다.

그래서 CI 커버리지를 세는 기준을 바꿨습니다. 예전에는 "테스트가 몇 개 도는가"를 봤는데, 이 숫자는 사각지대에 대해 아무것도 말해주지 않습니다. 지금은 "어느 패키지가 한 번도 안 도는가"를 셉니다. 워크스페이스 목록과 CI 잡 목록을 나란히 놓고 빠진 이름을 찾는, 5분이면 끝나는 일입니다.

혼자 개발하면 CI는 자기가 자기에게 거는 안전장치라 느슨해지기 쉽습니다. 다만 느슨해지는 방식이 "실패를 무시하는" 형태가 아니라 "애초에 안 도는" 형태라는 걸 이번에 알았습니다. 앞의 것은 눈에 띄지만 뒤의 것은 몇 달을 갑니다.

CI/CDGitHub ActionsPlaywright모노레포테스트 전략트러블슈팅

관련 글

혼자 개발하는데 CI가 왜 필요했나 — 편의 기능인 줄 알았는데 임계 경로였습니다

1인 개발자에게 CI는 사치일까요. 어느 날 CI가 통째로 멈추고 나서야, 그것이 테스트를 대신 돌려주는 편의 기능이 아니라 머지 자체를 막는 임계 경로였다는 것을 알게 됐습니다.

관련도 96%

삭제한 기능이 남긴 참조가 새 빌드를 죽였습니다

서버를 옮기며 오래된 코드를 새로 빌드하자, 잘 돌던 화면이 첫 렌더부터 하얗게 죽었습니다. 5개월 동안 배포하지 않은 main에 숨어 있던, 삭제된 기능이 남긴 dangling 참조를 추적한 기록입니다.

관련도 94%

CI가 전부 멈췄고, 저는 원인을 확인하지 않았습니다 — 30분 만에 끝난 대응의 함정

GitHub Actions hosted 러너가 새벽에 전면 차단됐습니다. 변수 한 줄과 self-hosted 러너로 30분 만에 우회했지만, 에러 메시지를 원인으로 착각한 탓에 진짜 이유는 2주 뒤에야 알게 됐습니다.

관련도 94%