From 7ed3411abb3b4aa2c99775415cacf686661b754d Mon Sep 17 00:00:00 2001 From: ttipo Date: Wed, 11 Nov 2026 14:01:26 +0900 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=EB=B9=88=20DB=20=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=BD=94=EC=8A=A4=20=EC=83=9D=EC=84=B1=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=EA=B8=B0=EC=9D=98=20=EA=B8=B0=EB=8F=99=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=EA=B0=80=20e2e=20=EB=A5=BC=20=EB=A9=88=EC=B6=94?= =?UTF-8?q?=EA=B2=8C=20=ED=95=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 의 빈 DB 에서 e2e 가 앱을 띄운 뒤에 마이그레이션을 돌려, 실행기의 기동 정리가 아직 없는 테이블을 찾다가 기동이 실패했다. 앱을 닫지 못해 1분 주기 정리 작업이 남아 jest 가 끝나지 않았고 CI 가 러너 제한 시간까지 멈춰 있었다. - 기동 정리는 실패해도 기동을 막지 않고 error 로그만 남긴다 - 앱 전체를 띄우는 e2e 는 운영처럼 마이그레이션을 먼저 돌린다 - 준비가 실패해도 afterAll 에서 앱을 반드시 닫는다 Co-Authored-By: Claude Opus 5.5 --- .../course-generation.runner.ts | 25 +++++++++++++------ backend/test/course-generation.e2e-spec.ts | 14 +++++++---- backend/test/festival.e2e-spec.ts | 14 +++++++---- backend/test/place.e2e-spec.ts | 14 +++++++---- 4 files changed, 44 insertions(+), 23 deletions(-) diff --git a/backend/src/modules/course-generation/course-generation.runner.ts b/backend/src/modules/course-generation/course-generation.runner.ts index 077c8d9..0d6f11e 100644 --- a/backend/src/modules/course-generation/course-generation.runner.ts +++ b/backend/src/modules/course-generation/course-generation.runner.ts @@ -54,15 +54,24 @@ export class CourseGenerationRunner private readonly pipeline: CourseGenerationPipeline, ) {} - /** 서버가 한 대라 기동할 때 남아 있는 작업은 모두 지난 프로세스의 것이다 */ + /** + * 서버가 한 대라 기동할 때 남아 있는 작업은 모두 지난 프로세스의 것이다. + * 정리일 뿐이라 실패해도 기동은 막지 않는다 — 마이그레이션 전이라 테이블이 없을 때 서버 전체가 뜨지 않았다(2026-11-11) + */ async onApplicationBootstrap(): Promise { - const result = await this.generations.update( - { status: In([...ACTIVE_STATUSES]) }, - this.failedValues(), - ); - if (result.affected) { - this.logger.warn( - `지난 실행에서 끝나지 못한 코스 생성 ${result.affected}건을 실패로 바꿨다`, + try { + const result = await this.generations.update( + { status: In([...ACTIVE_STATUSES]) }, + this.failedValues(), + ); + if (result.affected) { + this.logger.warn( + `지난 실행에서 끝나지 못한 코스 생성 ${result.affected}건을 실패로 바꿨다`, + ); + } + } catch (error) { + this.logger.error( + `[SYS_001] 끝나지 못한 코스 생성을 정리하지 못했다: ${error instanceof Error ? error.message : String(error)}`, ); } } diff --git a/backend/test/course-generation.e2e-spec.ts b/backend/test/course-generation.e2e-spec.ts index 7ea6951..6200fb9 100644 --- a/backend/test/course-generation.e2e-spec.ts +++ b/backend/test/course-generation.e2e-spec.ts @@ -171,6 +171,9 @@ describe('AI 코스 생성 API (e2e)', () => { }), }) .compile(); + // 운영처럼 마이그레이션을 기동 훅보다 먼저 돌린다 — 빈 DB 에서 실행기의 기동 정리가 테이블을 찾는다 + ds = moduleFixture.get(DataSource); + await ds.runMigrations(); app = moduleFixture.createNestApplication(); app.setGlobalPrefix('api'); app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware())); @@ -184,9 +187,6 @@ describe('AI 코스 생성 API (e2e)', () => { app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector))); await app.init(); - - ds = app.get(DataSource); - await ds.runMigrations(); await cleanup(); const places = ds.getRepository(Place); @@ -216,8 +216,12 @@ describe('AI 코스 생성 API (e2e)', () => { }); afterAll(async () => { - await cleanup(); - await app.close(); + // 준비가 실패해도 앱은 닫는다 — 닫지 않으면 주기 작업이 남아 jest 가 끝나지 않는다 + try { + await cleanup(); + } finally { + await app.close(); + } }); afterEach(() => { diff --git a/backend/test/festival.e2e-spec.ts b/backend/test/festival.e2e-spec.ts index 78c2569..1fae675 100644 --- a/backend/test/festival.e2e-spec.ts +++ b/backend/test/festival.e2e-spec.ts @@ -66,6 +66,9 @@ describe('축제·행사 조회 API (e2e)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); + // 운영처럼 마이그레이션을 기동 훅보다 먼저 돌린다 — 빈 DB 에서 기동 훅(코스 생성 정리)이 테이블을 찾는다 + ds = moduleFixture.get(DataSource); + await ds.runMigrations(); app = moduleFixture.createNestApplication(); app.setGlobalPrefix('api'); app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware())); @@ -79,9 +82,6 @@ describe('축제·행사 조회 API (e2e)', () => { app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector))); await app.init(); - - ds = app.get(DataSource); - await ds.runMigrations(); await cleanup(); const repo = ds.getRepository(FestivalEvent); @@ -156,8 +156,12 @@ describe('축제·행사 조회 API (e2e)', () => { }); afterAll(async () => { - await cleanup(); - await app.close(); + // 준비가 실패해도 앱은 닫는다 — 닫지 않으면 주기 작업이 남아 jest 가 끝나지 않는다 + try { + await cleanup(); + } finally { + await app.close(); + } }); const get = (path: string) => request(app.getHttpServer()).get(path); diff --git a/backend/test/place.e2e-spec.ts b/backend/test/place.e2e-spec.ts index 3debd6c..92449e8 100644 --- a/backend/test/place.e2e-spec.ts +++ b/backend/test/place.e2e-spec.ts @@ -83,6 +83,9 @@ describe('장소 조회 API (e2e)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); + // 운영처럼 마이그레이션을 기동 훅보다 먼저 돌린다 — 빈 DB 에서 기동 훅(코스 생성 정리)이 테이블을 찾는다 + ds = moduleFixture.get(DataSource); + await ds.runMigrations(); app = moduleFixture.createNestApplication(); app.setGlobalPrefix('api'); app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware())); @@ -96,9 +99,6 @@ describe('장소 조회 API (e2e)', () => { app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector))); await app.init(); - - ds = app.get(DataSource); - await ds.runMigrations(); await cleanup(); const places = ds.getRepository(Place); @@ -245,8 +245,12 @@ describe('장소 조회 API (e2e)', () => { }); afterAll(async () => { - await cleanup(); - await app.close(); + // 준비가 실패해도 앱은 닫는다 — 닫지 않으면 주기 작업이 남아 jest 가 끝나지 않는다 + try { + await cleanup(); + } finally { + await app.close(); + } }); const get = (path: string) => request(app.getHttpServer()).get(path);