API 설계2026년 4월 30일5분 읽기

1건만 보냈는데 전체가 실행됐다 — 키 이름 한 글자 차이가 만든 폴백 함정

selectedTcIds 한 건만 보낸 요청이 버전 전체 TC를 실행시킨 사건 — 진짜 원인은 컨트롤러와 서비스 사이 키 이름 불일치였습니다.

#API 설계#NestJS#디버깅#방어적 검증

1건만 보냈는데 전체가 실행됐다 — 키 이름 한 글자 차이가 만든 폴백 함정


1. 사용자 보고

"이 케이스만 단독 실행 눌렀는데, 그 버전의 전체 TC가 실행되네요?"

QA 대시보드에서 테스트케이스 한 건만 골라 실행 버튼을 눌렀는데, 같은 버전에 묶인 TC 전부가 큐에 올라갔다는 보고였습니다. 프론트엔드 코드를 먼저 확인했습니다.

ts
// 프론트엔드 — 명백히 1건만 보냄
await fetch("/v1/runs", {
  method: "POST",
  body: JSON.stringify({
    versionId,
    selectedTcIds: ["TC-LOG-0001"],  // 1건
  }),
})

요청 페이로드는 깨끗합니다. 1건만 들어 있습니다. 그런데 백엔드는 그 버전의 모든 TC를 enqueue했습니다.


2. 컨트롤러를 먼저 의심하기

NestJS 컨트롤러를 봤습니다.

ts
// 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. 서비스 안에 숨어 있던 폴백

서비스 로직을 봤습니다.

ts
// 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. 수정된 코드

컨트롤러 — 두 키 이름 모두 받아 정규화

ts
// 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

ts
// 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. 마치며

이 버그에서 얻은 룰을 정리합니다.

  1. 클라이언트와 서버의 키 이름은 한 글자도 어긋나지 않게 맞춰라. OpenAPI/스키마 검증을 도입하면 이 사고는 발생 자체가 안 됩니다. 정 어렵다면 서버에서 두 키를 모두 받아 정규화하는 방어책이라도 둬야 합니다.
  2. 비어있는 입력은 명시적으로 거절하라. undefined[]를 같은 길로 보내면, 사용자가 의도하지 않은 동작이 조용히 실행됩니다.
  3. 폴백은 사이드이펙트 크기에 비례해서 보수적으로. 큐 발행·결제·메일 같은 되돌리기 어려운 동작에는 폴백을 두지 마세요. "전체 실행"이 진짜 필요하면 runAll: true 같은 명시적 플래그로.
  4. 검증을 모두 통과한 뒤에야 영속화하라. 좀비 row, 일부만 처리된 상태는 디버깅 비용이 폭발합니다.

사용자가 "이제 잘되네" 한 마디 하고 끝났지만, 이 한 줄짜리 키 이름 불일치가 며칠치 디버깅을 만들 수 있다는 걸 다시 한 번 배웠습니다.

#API 설계#NestJS#디버깅#방어적 검증

황호민

Backend Engineer · Java/Kotlin · Spring Boot · Next.js