1건만 보냈는데 전체가 실행됐다 — 키 이름 한 글자 차이가 만든 폴백 함정
selectedTcIds 한 건만 보낸 요청이 버전 전체 TC를 실행시킨 사건 — 진짜 원인은 컨트롤러와 서비스 사이 키 이름 불일치였습니다.
1건만 보냈는데 전체가 실행됐다 — 키 이름 한 글자 차이가 만든 폴백 함정
1. 사용자 보고
"이 케이스만 단독 실행 눌렀는데, 그 버전의 전체 TC가 실행되네요?"
QA 대시보드에서 테스트케이스 한 건만 골라 실행 버튼을 눌렀는데, 같은 버전에 묶인 TC 전부가 큐에 올라갔다는 보고였습니다. 프론트엔드 코드를 먼저 확인했습니다.
// 프론트엔드 — 명백히 1건만 보냄
await fetch("/v1/runs", {
method: "POST",
body: JSON.stringify({
versionId,
selectedTcIds: ["TC-LOG-0001"], // 1건
}),
})요청 페이로드는 깨끗합니다. 1건만 들어 있습니다. 그런데 백엔드는 그 버전의 모든 TC를 enqueue했습니다.
2. 컨트롤러를 먼저 의심하기
NestJS 컨트롤러를 봤습니다.
// runs.controller.ts (수정 전)
@Post()
async createRun(@Body() body: { versionId: number; tcIds?: string[] }) {
return this.runsService.create({
versionId: body.versionId,
tcIds: body.tcIds, // ← body.tcIds 만 읽음
})
}여기가 1차 함정입니다. 컨트롤러는 tcIds를 읽고, 프론트는 selectedTcIds를 보냈습니다. 키 이름이 다릅니다. 백엔드 입장에선 tcIds가 항상 undefined입니다.
그럼 이게 단순 오타라서 그냥 못 받은 걸까요? 그렇다면 "0건이 실행됨"이 정상입니다. 그런데 사용자가 본 결과는 "전체 실행"입니다. 무언가가 그 빈 값을 채우고 있습니다.
3. 서비스 안에 숨어 있던 폴백
서비스 로직을 봤습니다.
// runs.service.ts (수정 전)
async create({ versionId, tcIds }: CreateRunInput) {
const targets = tcIds && tcIds.length > 0
? tcIds
: await this.testCaseRepo.findAllIdsByVersion(versionId) // 폴백!
for (const tcId of targets) {
await this.queue.enqueue({ versionId, tcId })
}
await this.runsRepo.insert({ versionId, count: targets.length })
}이 폴백이 진짜 원인입니다. tcIds가 비어있으면 versionId 전체를 enqueue하도록 되어있었습니다. 처음 짤 때는 "tcIds 안 보내면 전체 실행이라는 의미겠지"라는 편의 기능이었을 겁니다. 그런데:
- 컨트롤러가 키 이름을 다르게 읽음 →
tcIds === undefined - 서비스의 폴백이 발동 → versionId 전체 enqueue
- 사용자는 1건만 골랐는데 전체가 돌아감
키 이름 불일치 자체보다, 그걸 조용히 삼킨 폴백이 진짜 범인이었습니다.
4. undefined와 빈 배열은 같은 게 아니다
수정하면서 가장 신경 쓴 건 이 둘을 분리하는 것이었습니다.
| 입력 | 의미 | 처리 |
|---|---|---|
tcIds가 키 자체에 없음 (undefined) | "필드 안 보냄" — 클라이언트 버그 가능성 | 400 에러 |
tcIds: [] (명시적 빈 배열) | "0건 실행하겠다" — 의미 모호한 요청 | 400 에러 |
tcIds: ["TC-1"] | "이 1건만 실행" | 1건 실행 |
tcIds: ["TC-1", "TC-NOT-EXIST"] | 미존재 ID 섞임 | 400 에러 |
폴백을 통째로 없앴습니다. "전체 실행"이라는 동작이 진짜 필요하다면 별도 명시 옵션(runAll: true 같은 것)으로 받아야 합니다. 비어있으니 알아서 채워주는 동작은 이번 사건처럼 키 이름 한 글자 어긋나기만 해도 사고로 이어집니다.
5. 수정된 코드
컨트롤러 — 두 키 이름 모두 받아 정규화
// runs.controller.ts (수정 후)
@Post()
async createRun(
@Body() body: {
versionId: number
tcIds?: string[]
selectedTcIds?: string[] // 프론트가 보내던 키도 받음
}
) {
const tcIds = body.tcIds ?? body.selectedTcIds
return this.runsService.create({ versionId: body.versionId, tcIds })
}서비스 — 검증을 모두 통과한 뒤에야 row INSERT
// runs.service.ts (수정 후)
async create({ versionId, tcIds }: CreateRunInput) {
if (tcIds === undefined) {
throw new BadRequestException("tcIds 필드가 누락되었습니다")
}
if (tcIds.length === 0) {
throw new BadRequestException("tcIds가 비어있습니다 — 최소 1건 필요")
}
// 중복 제거
const uniqueIds = [...new Set(tcIds)]
// 실제 존재하는 TC인지 검증
const existing = await this.testCaseRepo.findByIds(versionId, uniqueIds)
const missing = uniqueIds.filter(id => !existing.some(e => e.tcId === id))
if (missing.length > 0) {
throw new BadRequestException(`존재하지 않는 tcId: ${missing.join(", ")}`)
}
// 모든 검증 통과 후에만 runs row 생성
const run = await this.runsRepo.insert({ versionId, count: uniqueIds.length })
for (const tcId of uniqueIds) {
await this.queue.enqueue({ runId: run.id, versionId, tcId })
}
return run
}핵심 포인트가 두 가지 더 있습니다.
1. 좀비 row 방지
이전 코드는 enqueue를 먼저 시작하고 마지막에 runs row를 INSERT했습니다. 그러다 중간에 검증 실패가 나면 일부만 큐에 들어간 채 row는 안 만들어지는 — 또는 그 반대의 — 엇갈린 상태가 생깁니다. 모든 검증을 끝낸 뒤에야 row를 만들고 enqueue를 시작하면 이 문제가 사라집니다.
2. 명시적 거절
미존재 ID가 섞이면 폴백 발동도, 조용히 0건 실행도 아니고 400으로 거절합니다. 클라이언트가 자기 잘못을 알아야 고칠 수 있습니다.
6. 더 큰 그림 — 폴백은 안티패턴인가?
이번 일로 폴백을 무조건 나쁘게 보게 되진 않았습니다. 폴백이 가치 있는 경우도 분명 있습니다.
| 폴백이 OK인 경우 | 폴백이 위험한 경우 |
|---|---|
누락된 값을 무해한 기본값으로 채울 때 (페이지네이션 page=1) | 누락이 다른 의미로 해석될 때 (전체 실행 vs 0건 실행) |
| 호출자가 모를 수도 있는 보조 옵션 | 호출자가 반드시 의도해야 하는 핵심 입력 |
| 잘못 채워져도 사이드이펙트가 없을 때 | 큐 발행, 결제, 메일 발송 같은 되돌릴 수 없는 동작 |
이번 케이스의 tcIds는 어떤 것을 큐에 올릴지 결정하는 핵심 입력이었습니다. 사이드이펙트는 큐 발행 + 워커 자원 소모. 폴백으로 채울 자리가 아니었습니다.
7. 마치며
이 버그에서 얻은 룰을 정리합니다.
- 클라이언트와 서버의 키 이름은 한 글자도 어긋나지 않게 맞춰라. OpenAPI/스키마 검증을 도입하면 이 사고는 발생 자체가 안 됩니다. 정 어렵다면 서버에서 두 키를 모두 받아 정규화하는 방어책이라도 둬야 합니다.
- 비어있는 입력은 명시적으로 거절하라.
undefined와[]를 같은 길로 보내면, 사용자가 의도하지 않은 동작이 조용히 실행됩니다. - 폴백은 사이드이펙트 크기에 비례해서 보수적으로. 큐 발행·결제·메일 같은 되돌리기 어려운 동작에는 폴백을 두지 마세요. "전체 실행"이 진짜 필요하면
runAll: true같은 명시적 플래그로. - 검증을 모두 통과한 뒤에야 영속화하라. 좀비 row, 일부만 처리된 상태는 디버깅 비용이 폭발합니다.
사용자가 "이제 잘되네" 한 마디 하고 끝났지만, 이 한 줄짜리 키 이름 불일치가 며칠치 디버깅을 만들 수 있다는 걸 다시 한 번 배웠습니다.