왜 녹색 CI가 시스템이 작동한다는 의미는 아닐까

발행: (2026년 5월 27일 PM 08:25 GMT+9)
5 분 소요
원문: Dev.to

Source: Dev.to

사례 연구: TypeScript 마이그레이션이 테스트 실행 시간을 두 배로 늘린 경우 — 실패는 전혀 없었습니다

CI는 초록색이었습니다. 테스트는 통과했습니다. PR은 머지되었습니다.
시스템은 깨졌지만 로그에는 아무것도 나타나지 않았습니다.

테스트 프로젝트를 JavaScript에서 TypeScript로 마이그레이션한 뒤, CI 실행 시간이 거의 두 배가 되었습니다. 실패도 없고 오류도 없었습니다. 단지… 느려졌을 뿐이죠. 저는 이것이 일반적인 TypeScript 컴파일 오버헤드라고 생각하고 넘어갔습니다—우연히 말이죠.

증상

마이그레이션이 거의 끝날 무렵, 원본 .js 파일들을 삭제하기 시작했을 때 테스트 개수가 거의 절반으로 줄어들었습니다:

  • 이전: 약 240개 테스트
  • 이후: 약 120개 테스트

그 숫자는 말이 안 되었습니다. 저는 테스트를 삭제한 것이 아니라 이미 없어야 할 오래된 JavaScript 파일만 삭제했을 뿐이었습니다.

원인

Playwright가 .spec.js .spec.ts 파일을 동시에 잡아들였습니다. 스위트에 있는 모든 테스트가 두 번씩 실행되었습니다—동일한 어설션, 동일한 설정, 동일한 정리—경고 하나 없이 조용히 중복 실행되었습니다.

가장 안 좋은 점은 낭비된 시간 자체가 아니라 CI가 상황을 개선되는 것처럼 보이게 만든 것이었습니다. 실행 시간이 점차 상승했는데, 이는 “마이그레이션 후 정상적인 속도 저하”로 해석되었습니다. 저는 증상에 대한 그럴듯한 이야기가 있었기 때문에 더 이상 살펴보지 않았습니다.

playwright.config.ts에 명시적인 testMatch가 없었습니다. Playwright의 기본 glob 패턴이 .js.ts 파일을 모두 매치하기 때문에 모든 파일을 잡아들인 것이었습니다.

해결 방법

// playwright.config.ts
export default defineConfig({
  // …
  testMatch: ['**/*.spec.ts'],
});

testMatch 라인을 추가함으로써 중복 실행을 멈출 수 있었습니다.

교훈

  • CI는 실행을 검증할 뿐, 정확성을 검증하지는 않는다. 초록색 CI는 실행 중에 충돌이 없었다는 의미일 뿐이며, 올바른 테스트가 올바른 수량과 가정으로 실행됐다는 것을 보장하지 않습니다.

  • CI에 간단한 테스트 카운터를 도입했다면 이 차이를 잡아낼 수 있었을 것입니다. 이제 파이프라인은 테스트 개수가 기대값과 다르면 명시적으로 실패합니다.

  • 테스트 시스템의 대부분 문제는 실패로 나타나지 않습니다. 대신 다음과 같이 나타납니다:

    • 중복 실행
    • 조용한 성능 저하
    • 테스트 변경 없이 러너 동작 변화

    그리고 이러한 문제들은 알림이 없습니다. 왜냐하면 우리는 이를 위해 설계하지 않았기 때문입니다.

실패 시그니처

  • CI 초록색
  • 실행 시간 두 배
  • 테스트 개수 두 배
  • 경고 없음

숨겨진 가정은 “CI 실행이 느려졌다면 마이그레이션 후 정상적인 오버헤드일 것”이라는 것이었습니다. 실제로는 러너가 몇 주 동안 두 배의 작업을 조용히 수행하고 있었던 것이었습니다—경고 하나 없이.

테스트 자동화에서의 조용한 실패 시리즈 중 일부

전체 프로젝트 (API + UI + E2E + CI + AI 엔드포인트): GitHub

0 조회
Back to Blog

관련 글

더 보기 »

엔티티 생성과 업데이트에 단일 Builder 패턴을 사용하는 것이 좋은 관행일까?

문제 Node.js TypeScript 백엔드에서 Builder 패턴을 구현하여 Permission 객체를 인스턴스화한 뒤 이를 repository 레이어에 전달하려고 합니다. 저는 Permission 객체가 여러 선택적 속성을 가질 수 있기 때문에 Builder를 사용해 가독성을 높이고 싶었습니다. 시도한 내용 ```typescript class PermissionBuilder { private permission: Permission; constructor() { this.permission = new Permission(); } setId(id: string): this { this.permission.id = id; return this; } setUserId(userId: string): this { this.permission.userId = userId; return this; } // … 기타 setter 메서드들 … build(): Permission { return this.permission; } } ``` 위와 같이 Builder를 만든 뒤, 서비스 레이어에서 다음과 같이 사용했습니다. ```typescript const permission = new PermissionBuilder() .setId('123') .setUserId('456') .setRead(true) .setWrite(false) .build(); await permissionRepository.save(permission); ``` 하지만 테스트를 실행하면 `permissionRepository.save`가 `undefined`를 반환하고, 저장된 레코드가 데이터베이스에 나타나지 않습니다. 또한, `PermissionBuilder`를 사용하지 않고 직접 `new Permission()`으로 객체를 만들면 정상적으로 저장됩니다. 원인 분석 1. **Builder가 실제로 새로운 인스턴스를 반환하지 않음** `build()` 메서드가 현재 내부에 보관하고 있는 `this.permission` 객체를 그대로 반환하고 있습니다. 이 객체는 `new Permission()`으로 만든 초기 객체이며, 이후 setter 메서드들이 원본 객체의 속성을 직접 수정합니다. TypeScript에서는 객체가 **참조에 의해 전달**되기 때문에, `PermissionBuilder` 인스턴스가 재사용될 경우 이전에 설정한 값이 남아 있을 수 있습니다. 2. **불변성을 보장하지 않음** Builder 패턴의 일반적인 구현은 `build()` 단계에서 **새로운 복사본**을 만들어 반환합니다. 이렇게 하면 Builder 자체는 재사용 가능하고, 이미 `build()`된 객체는 더 이상 변경되지 않게 됩니다. 현재 구현은 동일한 인스턴스를 계속 반환하므로, 테스트 환경에서 여러 번 `build()`를 호출하면 이전 테스트의 상태가 섞일 위험이 있습니다. 3. **Repository 레이어가 기대하는 형태와 불일치** `permissionRepository.save`는 **완전한 엔티티**(예: TypeORM 엔티티) 인스턴스를 기대합니다. Builder가 반환하는 객체가 엔티티 메타데이터(예: `@PrimaryGeneratedColumn`, `@Column` 데코레이터)와 연결되지 않은 **plain 객체**라면, ORM이 이를 무시하거나 `undefined`를 반환할 수 있습니다. 해결 방안 ### 1. `build()`에서 새로운 객체를 반환하도록 수정 ```typescript class PermissionBuilder { private readonly data: Partial<Permission> = {}; setId(id: string): this { this.data.id = id; return this; } setUserId(userId: string): this { this.data.userId = userId; return this; } // … 기타 setter 메서드들 … build(): Permission { // Permission 엔티티의 생성자를 사용하거나 Object.assign 로 복사 return Object.assign(new Permission(), this.data); } } ``` - `Partial<Permission>`을 사용해 아직 완전하지 않은 상태를 저장하고, `build()` 시점에 **새로운 Permission 인스턴스**를 만든 뒤 속성을 복사합니다. - 이렇게 하면 Builder 자체는 상태를 유지하지만, 반환된 객체는 독립적이며 ORM이 정상적으로 인식합니다. ### 2. Builder를 **불변**하게 설계 ```typescript class PermissionBuilder { private readonly data: Readonly<Partial<Permission>>; private constructor(data: Partial<Permission> = {}) { this.data = data; } static create(): PermissionBuilder { return new PermissionBuilder(); } setId(id: string): PermissionBuilder { return new PermissionBuilder({ ...this.data, id }); } setUserId(userId: string): PermissionBuilder { return new PermissionBuilder({ ...this.data, userId }); } // … 기타 setter 메서드들 … build(): Permission { return Object.assign(new Permission(), this.data); } } ``` - 각 setter가 새로운 Builder 인스턴스를 반환하므로 **동시성 문제**와 **테스트 간 상태 누수**를 방지합니다. ### 3. Repository에 전달하기 전에 엔티티 인스턴스인지 확인 ```typescript const permission = PermissionBuilder.create() .setId('123') .setUserId('456') .setRead(true) .setWrite(false) .build(); if (!(permission instanceof Permission)) { throw new Error('Built object is not a Permission entity'); } await permissionRepository.save(permission); ``` - 타입 가드로 잘못된 객체가 전달되는 것을 사전에 차단합니다. ### 4. 테스트 환경 정리 - 각 테스트 케이스 시작 전에 **Builder 인스턴스를 새로 생성**하거나 `PermissionBuilder.create()`를 호출합니다. - 테스트 후에는 데이터베이스를 **트랜잭션 롤백**하거나 `afterEach` 훅에서 `permissionRepository.clear()`를 호출해 상태를 초기화합니다. 예시 코드 (전체 흐름) ```typescript // permission.builder.ts export class PermissionBuilder { private readonly data: Partial<Permission> = {}; setId(id: string): this { this.data.id = id; return this; } setUserId(userId: string): this { this.data.userId = userId; return this; } setRead(read: boolean): this { this.data.read = read; return this; } setWrite(write: boolean): this { this.data.write = write; return this; } // … 추가 setter … build(): Permission { return Object.assign(new Permission(), this.data); } } // permission.service.ts async function createPermission(dto: CreatePermissionDto) { const permission = new PermissionBuilder() .setId(dto.id) .setUserId(dto.userId) .setRead(dto.read) .setWrite(dto.write) .build(); return await permissionRepository.save(permission); } ``` 결론 - 현재 Builder 구현은 **같은 인스턴스를 재사용**하고 있어 ORM이 기대하는 완전한 엔티티 객체를 제공하지 못합니다. - `build()` 단계에서 **새로운 Permission 인스턴스를 반환**하도록 수정하고, 가능하면 **불변 Builder** 패턴을 적용하면 테스트 간 상태 오염을 방지하면서도 가독성을 유지할 수 있습니다. - 마지막으로, Repository에 전달하기 전에 반환 객체가 실제 엔티티인지 검증하고, 테스트 환경을 깨끗하게 정리하면 `undefined` 반환 문제를 해결할 수 있습니다.