"given이 저장 안 된다"는 보고의 진짜 원인은 priority CHECK 위반이었다
BDD 필드 매핑 버그를 찾으려고 한참을 헤맸는데, 실제로는 같은 INSERT 안의 priority enum 한 글자가 트랜잭션을 통째로 깨고 있었습니다.
"given이 저장 안 된다"는 보고의 진짜 원인은 priority CHECK 위반이었다
1. 사용자 보고
"TC 에디터에서 Given 절 입력하고 저장했는데, 다시 열면 빈값이에요."
QA 대시보드의 테스트케이스 에디터에서 BDD 필드(Given/When/Then)가 저장 안 된다는 보고가 들어왔습니다. 처음엔 너무 명확해 보였습니다. 저장 흐름의 어딘가에서 given 필드가 누락되고 있다는 가설을 세우고 추적을 시작했습니다.
2. 1차 의심 — 컬럼 매핑
// test-cases.service.ts:95
mapWriteToEntityPartial(dto: TestCaseWriteDto) {
return {
name: dto.name,
priority: dto.priority,
givenCondition: dto.given, // ← 여기?
whenAction: dto.when,
thenResult: dto.then,
}
}// test-case.entity.ts:57
@Column({ name: "given_condition", type: "text", nullable: true })
givenCondition?: string매핑은 정상이었습니다. DTO given → 엔티티 givenCondition → DB 컬럼 given_condition. 한 줄도 빠지는 데가 없습니다.
3. 2차 의심 — 클라이언트가 다른 키로 보내나?
DTO를 손봤습니다. 클라이언트가 given 대신 givenCondition이나 given_condition으로 보낼 수도 있다고 가정하고, 세 키를 모두 받도록 보강했습니다.
// dto/test-case-write.dto.ts (방어적 보강)
export class TestCaseWriteDto {
@IsOptional() given?: string
@IsOptional() givenCondition?: string
@IsOptional() given_condition?: string
@IsOptional() when?: string
@IsOptional() whenAction?: string
@IsOptional() when_action?: string
@IsOptional() then?: string
@IsOptional() thenResult?: string
@IsOptional() then_result?: string
// ...
}// 어느 키로 들어오든 엔티티 필드에 매핑
mapWriteToEntityPartial(dto: TestCaseWriteDto) {
return {
givenCondition: dto.given ?? dto.givenCondition ?? dto.given_condition,
whenAction: dto.when ?? dto.whenAction ?? dto.when_action,
thenResult: dto.then ?? dto.thenResult ?? dto.then_result,
// ...
}
}이 정도면 어느 클라이언트가 어떤 컨벤션으로 보내든 받아낼 수 있습니다. 그런데 — 여전히 저장이 안 됐습니다.
4. 진짜 원인이 보인 순간
서버 로그를 다시 봤습니다. 그런데 이상한 게 한 줄 박혀 있었습니다.
QueryFailedError: new row for relation "test_cases"
violates check constraint "test_cases_priority_check"
DETAIL: Failing row contains (..., ready, ..., test, ...).
SQL state: 23514
23514는 PostgreSQL의 CHECK 제약 위반 에러 코드입니다. INSERT 자체가 통째로 거절되고 있었던 겁니다. given이 누락된 게 아니라, 트랜잭션이 통째로 롤백되어서 아무것도 저장이 안 된 상태였던 거죠.
원인을 추적해보니 코드 enum과 DB CHECK 제약이 어긋나 있었습니다.
// 코드 쪽 enum
export enum Priority {
P0 = "P0",
P1 = "P1",
P2 = "P2",
P3 = "P3",
READY = "ready", // ← 최근에 추가됨
}-- DB 쪽 CHECK 제약
CHECK (priority IN ('P0', 'P1', 'P2', 'P3'))
-- 'ready' 가 없음코드에 ready 값이 추가되면서 마이그레이션이 빠진 상태였습니다. 클라이언트가 새 케이스를 만들 때 priority를 ready로 보내면, INSERT 단계에서 CHECK가 거절 → 트랜잭션 롤백 → given/when/then이 같은 트랜잭션 안에 있었으니 다 같이 사라짐.
사용자 입장에선 given만 안 보였을 수도 있고(에러 메시지를 못 읽었거나, UI가 priority 필드를 자동 채워서 인지 못 했거나), 결과적으로 "given 저장 안 됨"이라고 보고된 겁니다.
5. 수정 — DB CHECK에 enum 값 추가
마이그레이션 한 줄로 해결됐습니다.
-- sql/test_cases_priority_check.sql
ALTER TABLE test_cases
DROP CONSTRAINT IF EXISTS test_cases_priority_check;
ALTER TABLE test_cases
ADD CONSTRAINT test_cases_priority_check
CHECK (priority IN ('P0', 'P1', 'P2', 'P3', 'ready'));DB에 적용한 직후 다시 시도해보니 given_condition='test'가 정상 INSERT 되었습니다. 사실 "given 저장 안 됨"이라는 증상의 진짜 원인은 priority 위반으로 INSERT 자체가 실패하던 것이었습니다.
6. 왜 이걸 빨리 못 잡았나
세 가지 이유가 있었습니다.
1. 보고된 증상이 너무 구체적이었다.
"given이 안 들어간다"는 보고를 받으면 자연스럽게 "given 필드 처리 어딘가가 망가졌다"고 가정합니다. 그리고 컬럼 매핑·DTO·ORM 설정만 들여다봅니다. 트랜잭션 전체가 깨졌다는 가능성은 후순위로 밀립니다.
2. CHECK 위반 에러가 상위로 잘 올라오지 않았다.
NestJS + TypeORM 조합에서 CHECK 위반은 QueryFailedError로 던져집니다. 이걸 별도로 매핑하지 않으면 클라이언트엔 그냥 500이 가거나, 로그에만 SQL state 23514로 남습니다. 사용자 측 UI는 "저장 실패" 토스트조차 안 떴을 수 있습니다 — 그래서 "저장은 됐는데 일부만 안 보인다"라는 잘못된 모델이 만들어졌습니다.
3. 코드 enum과 DB CHECK가 서로 모르는 채 살았다.
이게 진짜 구조적 문제입니다. 같은 enum을 코드와 DB 양쪽에서 정의하면, 한 쪽만 바뀌었을 때 다른 쪽은 모릅니다. 마이그레이션 파일이 PR에 들어가지 않으면 npm install 같은 자동 트리거도 없어서 그냥 방치됩니다.
7. 재발 방지 — 코드 enum과 DB CHECK 동기화
선택지가 몇 개 있습니다.
| 전략 | 장점 | 단점 |
|---|---|---|
enum 테이블 만들기 (priorities 룩업 테이블 + FK) | 추가/제거가 INSERT/DELETE | 단순 라벨에 테이블 1개 더 |
| DB CHECK 유지 + 마이그레이션 강제 리뷰 | 변경 비용 적음 | 사람이 까먹을 수 있음 |
| CHECK 빼고 애플리케이션 레벨 검증만 | DB 마이그레이션 필요 없음 | DB 직접 조작 시 무방비 |
PostgreSQL ENUM 타입 (CREATE TYPE) | DB 레벨에서 강제 | ALTER TYPE ... ADD VALUE가 트랜잭션 안에서 제약 있음 |
이번에는 DB CHECK 유지 + 코드 PR에 마이그레이션 SQL 동봉을 강제하는 PR 템플릿 으로 정리했습니다. enum 추가는 자주 일어나는 일이 아니니 룩업 테이블까지 도입할 가치는 적었고, 한편으로 CHECK는 DB 직접 접근이나 다른 서비스(ETL, BI)가 INSERT할 때까지 일관되게 보호해주기 때문입니다.
8. CHECK 위반 에러를 사용자에게 보여주기
부수적으로 한 가지 더 손봤습니다. CHECK 위반 같은 DB 에러가 사용자에게 그냥 500으로 가버리면 다음 사람도 같은 함정에 빠집니다. 글로벌 예외 필터에서 SQL state로 매핑을 추가했습니다.
@Catch(QueryFailedError)
export class DbErrorFilter implements ExceptionFilter {
catch(err: any, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse()
if (err.code === "23514") {
// CHECK violation
return res.status(400).json({
message: `허용되지 않는 값: ${err.detail}`,
statusCode: 400,
})
}
if (err.code === "23505") {
// UNIQUE violation
return res.status(409).json({
message: "이미 존재하는 값입니다",
statusCode: 409,
})
}
return res.status(500).json({ message: "데이터베이스 오류" })
}
}이제 같은 일이 또 일어나면 클라이언트 토스트에 "허용되지 않는 값: priority='ready'"가 뜹니다. 다음 사람은 5초 만에 원인을 알 수 있습니다.
9. 마치며
이번 사건에서 얻은 룰을 정리합니다.
- 보고된 증상 = 실제 원인이 아닐 수 있다. "X가 저장 안 됨"은 "X 처리 로직이 깨짐"이 아니라 "X가 포함된 트랜잭션이 통째로 롤백 중"일 수 있습니다. 같은 INSERT/UPDATE에 묶인 다른 컬럼들을 함께 의심하세요.
- DB 에러를 삼키지 말고 사용자에게 보여줘라. CHECK 위반·UNIQUE 위반은 4xx로 매핑해서 토스트에 띄우면, 같은 함정의 재발생을 막을 수 있습니다.
- enum을 코드와 DB 양쪽에 둔다면 동기화 절차를 명문화해라. 마이그레이션 파일이 PR에 안 들어가면 빌드가 깨지든 배포가 막히든, 사람이 까먹을 자리를 자동화로 메워야 합니다.
가장 인상 깊었던 건, 두 시간 동안 컬럼 매핑을 의심하던 제가 결국 봤던 게 한 줄짜리 SQL 에러 로그였다는 점입니다. 디버깅의 절반은 "내가 지금 어떤 가정 위에서 움직이고 있는지" 를 의심하는 일이었습니다.