1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)
혼자서 BE·FE·QA·PM 네 역할을 갈아끼우며 학습 클라이언트 웹 프로토타입의 QA 라운드 3~7을 연속으로 돌렸다. 라운드 3의 타이머 NaN·시드 부족부터 라운드 7의 BundleContent nullable·다음 번들 자동 생성까지, 5라운드 동안 버그가 BE·시드·스키마를 차례로 옮긴 흐름과 혼자서 여러 역할을 맡는 QA의 회고.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 환경: 혼자서 BE·FE·QA·PM 네 역할을 동시에 맡는 1인 개발 구조에서 학습 클라이언트 웹 프로토타입의 QA 라운드를 5번 연속으로 돌렸다
- 5라운드 흐름: 라운드 3은 BE 버그(타이머 NaN)와 시드 부족, 라운드 4는 한 커밋으로 동시 수정, 라운드 5는 QA가 코드를 직접 고치고 PM이 머지, 라운드 6은 시드 추가로 드러난 500, 라운드 7은
BundleContent.contentIdnullable 스키마 변경으로 종결- 메타 패턴: 5라운드 동안 회귀 누적 0건 — 같은 사람이 라운드 사이마다 PM 리뷰·BE 수정·QA 재테스트를 순서대로 굴리면 회귀가 안 쌓이는 대신 결정이 가벼워진다
- 잔존: 슬라이더 값 미반영 1건 — FE 단독 작업이 필요한 항목은 5라운드 안에서 안 풀린다, 별도 큐로 분리해야 한다
- 교훈: 라운드를 5번 굴리려면 시드를 먼저 고정해야 한다. 시드를 그대로 두면 라운드마다 같은 흐름이 다른 결과를 낸다
🎯 동기 — 라운드 1·2의 끝에서 왜 5라운드를 더 돌렸나
라운드 1은 워크트리 미동기화·만료 토큰 잔존·검증 충실도 부족 세 함정을 잡았다. 라운드 2는 그 직후 발견된 배치고사 새로고침 버그와 번들 상세 API 미구현을 처리하며 끝났다. 두 라운드 모두 “UI 스모크” 수준이었고, 비즈니스 로직 검증은 다음 라운드의 숙제로 남겨졌다.
라운드 3부터는 처음으로 비즈니스 로직 검증으로 들어갔다. 화면이 뜬다·버튼이 눌린다 수준을 넘어서, 번들 안의 콘텐츠 후보가 정확히 어떤 순서로 채워지는지·타이머가 어떻게 표시되는지·번들이 끝나면 다음 번들이 자동으로 만들어지는지를 확인하는 단계다.
📌 핵심: 라운드 3은 같은 시나리오를 한 번 더 도는 라운드가 아니다. 검증 충실도 한 단계를 올리는 라운드다. UI 스모크에서 비즈니스 로직으로 넘어가면, 같은 화면을 봐도 다른 버그가 보인다.
여기서부터 라운드 7까지 5번을 연속으로 돌렸다. 한 번에 끝낼 줄 알았던 비즈니스 로직 검증이 5라운드로 늘어난 이유는 단순했다. 버그가 한 곳에서 끝나지 않고 옮겨다녔다. BE에서 잡으면 시드에서 다시 났고, 시드를 채우면 스키마에서 났다. 그래서 라운드를 5번 돌렸다.
이 글은 그 5라운드 동안 버그가 어디로 어떻게 옮겨다녔는지의 회고다. 같은 사람이 PM·BE·QA를 갈아끼우며 5라운드를 돌리면 무엇이 가벼워지고 무엇이 무거워지는지를 정리했다.
🛠️ 설계 — 라운드 우선순위와 QA 직접 수정 권한
라운드 3을 시작하기 전에 라운드 1·2와 달라진 셋업이 있다. 그 셋업을 먼저 정리해 둬야 라운드 5에서 QA가 코드를 직접 고치는 장면이 왜 정상인지 읽힌다.
우선순위 3단계 — Critical · High · Medium
라운드를 굴릴 때마다 시나리오 24건을 다 도는 게 아니라, 우선순위를 셋으로 잘라서 차례로 도는 방식으로 바꿨다.
| 단계 | 시나리오 | 통과 조건 |
|---|---|---|
| Critical | 번들 완료 → 다음 번들 자동 생성 | 같은 회원이 번들 끝나면 다음 번들 ID 응답 |
| High | 결과 등급 하향/상승 흐름 | 슬라이더 값에 따라 다음 번들 콘텐츠 후보가 달라짐 |
| Medium | 결과 분기·합류 흐름 | 분기 진입 후 합류 위치까지 자동 진행 |
이 우선순위는 비즈니스 가치 기준이 아니라 검증 의존성 기준이다. Critical이 안 통과하면 High·Medium은 의미가 없다. 다음 번들이 안 만들어지면 등급 하향도, 분기·합류도 검증할 단계가 없으니까.
라운드 3은 Critical만 도는 라운드로 시작했다.
시드 5개 회원 — 테스트 흐름 분리
비즈니스 로직 검증으로 들어가면서 시드 회원 수를 늘렸다. 라운드 1·2까지는 회원 1명(TEST001)으로 흐름만 봤지만, 등급 하향·상승·분기·합류는 한 회원 한 흐름에서 동시에 검증되지 않는다.
| 계정 | 용도 |
|---|---|
| TEST001 | UI 스모크 (기존) |
| TEST002 | Critical — 번들 완료 → 다음 번들 자동 생성 |
| TEST004 | High — 결과 등급 하향 흐름 |
| TEST005 | High — 결과 등급 상승 흐름 |
| TEST006 | Medium — 등급 원복 흐름 |
| TEST007 | Medium — 분기 진입 |
| TEST008 | Medium — 합류 도착 |
시드는 하나의 진입점에서 회원·과제·번들·콘텐츠 후보·진행 이력을 같이 만들어 두는 구조로 바꿨다. 회원 한 명에 흐름 한 줄이 시드 설계의 기본이 됐다.
QA 직접 수정 권한 — 라운드 5의 셋업
라운드 5부터는 셋업을 한 번 더 바꿨다. QA가 BE/FE 코드를 직접 수정할 수 있게 권한을 풀었다.
- QA가 라운드 중 작은 버그를 발견하면 그 위치에서 수정 가능
- 수정한 코드는 별도 PR이 아니라 QA 워크트리 안에 커밋만 두고 PM에게 코드 리뷰 요청
- PM이 리뷰 후 본 브랜치에 머지
다른 사람과 일했다면 절대 풀지 않았을 권한이지만, 1인 환경에서 컨텍스트 스위칭 비용이 너무 크기 때문에 의도적으로 풀었다. 같은 사람이 코드를 쓴 위치를 다시 찾아가서 다시 컨텍스트를 로드하는 비용보다 그 위치에서 한 줄 고치는 비용이 훨씬 작다.
⚠️ 주의: 이 권한은 다른 사람과 일할 때는 절대 풀면 안 된다. QA가 코드를 고친다는 건 QA가 BE/FE의 의도를 본다는 뜻이고, 그건 그 자체로 회귀 책임이 흐려진다. 1인 환경의 이점은 의도 위치와 검증 위치가 같은 머릿속에 있다는 점이고, 그게 깨질 환경에서는 이 권한이 곧 사고의 시발점이 된다.
PM 역할이 리뷰 게이트로 들어와 있다는 점이 중요하다. 같은 사람이지만 PM 모드로 전환된 시점에 코드를 다시 읽는다. 이 두 번째 읽기가 QA가 짠 한 줄이 BE 설계와 정합한지 검증한다.
라운드 5에서 이 권한을 한 번 썼다. 그 사례가 5라운드 회고의 중심이다.
📝 실전 사례 — 5라운드 동안 버그가 옮겨다닌 위치들
라운드 3·4 — 타이머 NaN과 시드 50건, 한 라운드에 두 함정
라운드 3은 2026-01-19 21:00에 시작했다. 우선 Critical 흐름의 첫 번째인 번들 상세 API부터 검증했다.
GET /api/v1/student/assignment/10 → 200 OK ✅
POST /api/v1/student/bundle/10/start → 200 OK ✅
GET /api/v1/student/bundle/10 → 200 OK ✅
GET /api/v1/student/bundle/10/content-candidates → 200 OK ✅
여기까지는 정상. 번들 안의 지표 순서도 정확히 들어갔다.
| 순서 | 지표 | 결과 |
|---|---|---|
| 1 | 강점 | TOP1 ✅ |
| 2 | 최약점 | TOP5 ✅ |
| 3 | 약점 | TOP4 ✅ |
| 4 | 최약점 | TOP5 ✅ |
| 5 | 중간 | TOP3 ✅ |
엣지 케이스 EC-4(재접속 후 이어하기)도 PASSED. 여기서 두 가지 신호가 동시에 떴다.
- 타이머 표시: NaN:NaN
- 콘텐츠 후보: 빈 배열 [ ]
타이머가 NaN:NaN으로 뜨는 건 라운드 4의 타이머 NaN 트러블슈팅에서 자세히 다뤘다. 같은 도메인을 노출하는 두 API가 응답을 손으로 매핑하다 한 컨트롤러가 remainingTimeMs 필드를 빼먹었고, FE의 formatTime이 undefined를 받아 NaN:NaN을 그렸다.
콘텐츠 후보 빈 배열은 별개의 함정이었다. API는 정상 응답인데, 시드 데이터에 그 번들의 콘텐츠 후보가 안 들어가 있었다. 정확히는 새 회원·새 레벨 케이스에서 BundleContent 매핑이 빈 배열인 패턴이었다.
라운드 3에서 두 함정을 같이 발견한 게 라운드 운영의 핵심 신호다. 한 라운드 안에서 BE 버그와 시드 부족이 동시에 보이는 건 우연이 아니다. 검증 충실도가 한 단계 올라가면 그동안 가려져 있던 두 종류의 미흡이 한 번에 드러난다.
라운드 4(01-19 22:00)는 한 시간 만에 시작했다. PM 역할로 전환해서 두 함정을 한 커밋(324d060)으로 묶었다.
// apps/api/src/application/dto/bundle-detail-response.dto.ts — ❌ 누락 패턴
export interface BundleDetailResponse {
bundleId: string;
status: BundleStatus;
contentItems: ContentItem[];
// remainingTimeMs 누락
}
// apps/api/src/application/dto/bundle-detail-response.dto.ts — ✅ DTO 클래스화 + 필수 필드
export class BundleDetailResponseDto {
@ApiProperty()
bundleId: string;
@ApiProperty({ enum: BundleStatus })
status: BundleStatus;
@ApiProperty({ type: [ContentItemDto] })
contentItems: ContentItemDto[];
@ApiProperty({ description: '남은 시간(ms) — 0 미만이면 종료' })
remainingTimeMs: number;
}
시드 부족은 같은 커밋에 묶었다. prisma/seed.ts의 BundleContent 생성 블록에 누락된 50건을 채웠다. 한 커밋에 BE 응답 DTO 클래스화 + 도메인 서비스 분리 + FE Number.isFinite 가드 + 시드 50건 재생성이 같이 들어갔다.
라운드 4 결과는 깔끔했다.
| 이슈 | 이전 | 현재 | 결과 |
|---|---|---|---|
| 타이머 표시 | NaN:NaN | 23:13 | ✅ FIXED |
| 콘텐츠 후보 | 빈 배열 | 2개 후보 | ✅ FIXED |
🔍 단서: 한 라운드 안에 BE 버그와 시드 부족이 동시에 보이면 한 커밋으로 묶는다. 따로 나누면 시드 변경이 BE 변경 뒤에 머지되는 동안 검증 단위가 분리되고, 라운드를 한 번 더 돌리게 된다. 같은 사람이 굴리는 1인 환경에서는 한 커밋이 정답이다.
이 한 커밋의 의미는 라운드 4 타이머 NaN 트러블슈팅에서 같은 위치를 코드 관점으로 한 번 더 다뤘다. 본 글은 라운드 운영 관점에서, 그 글은 코드 관점에서 같은 한 커밋을 본다.
라운드 5 — QA가 직접 BE/FE 코드를 수정한 라운드
라운드 5(01-19 17:50)부터 본격적인 비즈니스 로직 검증으로 들어갔다. 셋업한 시드 회원 TEST002로 Critical 흐름을 돌렸다.
번들 한 개를 끝까지 진행했고, 마지막 콘텐츠 결과를 제출했다. 그런데 다음 번들이 안 만들어졌다.
POST /api/v1/student/bundle/8/complete → 200 OK
{
"success": true,
"data": {
"completedBundleId": "8",
"hasNextBundle": false
}
}
hasNextBundle: false. 비즈니스 로직상 다음 번들이 만들어져야 하는 흐름이었지만 응답이 명시적으로 “다음 번들 없음”으로 왔다. 코드를 까보니 다음 번들 자동 생성 로직 자체가 미구현이었다.
QA가 그 위치에서 한 줄 고친 게 아니라, 일단 라운드를 멈췄다. 다음 번들 자동 생성은 작은 변경이 아니다. 도메인 서비스에서 다음 번들의 콘텐츠 후보 선정 알고리즘을 호출해야 하고, 트랜잭션 안에서 번들 엔티티를 만들어야 한다. QA가 손댈 단위가 아니다.
대신 QA는 두 가지 작은 버그를 직접 고쳤다. 둘 다 다음 번들 자동 생성과는 무관한 잔존 결함이었다.
// apps/api/src/application/services/student-assignment.application.service.ts — ❌
async getCurrentContentOrder(bundleId: string): Promise<number> {
const bundle = await this.bundleRepository.findById(bundleId);
return bundle.currentContentOrder; // BE에서 저장된 값을 그대로 반환
}
문제는 currentContentOrder가 콘텐츠 완료 이벤트와 동기화가 안 되는 상황에서 stale 값을 돌려준다는 점이었다. QA는 그 위치를 동적 계산으로 바꿨다.
// apps/api/src/application/services/student-assignment.application.service.ts — ✅
async getCurrentContentOrder(bundleId: string): Promise<number> {
const completedCount = await this.bundleContentRepository.countCompleted(bundleId);
return completedCount; // 완료된 콘텐츠 수를 기반으로 동적 계산
}
두 번째는 FE의 응답 구조 파싱 + 404 방어 코드였다.
// learning-client/src/pages/bundle/index.tsx — ❌
const { data: bundle } = useQuery(['bundle', bundleId], () =>
axios.get(`/bundle/${bundleId}`).then(res => res.data)
);
// bundle.contentItems가 undefined일 때 .map() 호출 → 크래시
// learning-client/src/pages/bundle/index.tsx — ✅
const { data: bundle } = useQuery(['bundle', bundleId], async () => {
try {
const res = await axios.get(`/bundle/${bundleId}`);
return res.data?.data ?? null; // 응답 표준 unwrap + null 폴백
} catch (err) {
if (err.response?.status === 404) return null;
throw err;
}
});
if (!bundle) return <EmptyState />;
두 수정을 QA 워크트리에 커밋 두 개로 두고, PM 역할로 전환해서 코드 리뷰를 했다.
PM 코드 리뷰 결과: 둘 다 PASS. QA가 짠 한 줄이 BE 도메인 의도와 정합했고, FE의 응답 unwrap 패턴은 응답 표준 unwrap 패턴을 그대로 따랐다. 본 브랜치에 머지.
같은 라운드 안에서 PM이 다음 번들 자동 생성을 한 시간 만에 구현했다.
// apps/api/src/application/services/student-assignment.application.service.ts
async completeBundle(bundleId: string, ctx: TransactionContext) {
// 1) 현재 번들 완료 처리
const completedBundle = await this.bundleRepository.complete(bundleId, ctx);
// 2) 다음 번들 메타 생성 (콘텐츠 후보 선정 알고리즘 호출)
const bundleMeta = this.bundleGenerationService.generateBundleMeta({
memberId: completedBundle.memberId,
assignmentId: completedBundle.assignmentId,
previousBundleResults: await this.collectPreviousResults(completedBundle, ctx),
});
// 3) 트랜잭션 안에서 다음 번들 엔티티 생성
const nextBundle = await this.bundleRepository.create(bundleMeta, ctx);
return {
completedBundleId: completedBundle.id.toString(),
hasNextBundle: true,
nextBundleId: nextBundle.id.toString(),
};
}
PM 입장에서 한 커밋으로 머지했고, QA로 전환해서 다시 라운드를 돌렸다. 같은 회원 흐름이 깔끔하게 다음 번들로 넘어갔다.
QA의 두 수정 + PM의 자동 생성 구현이 같은 라운드 안에 모두 끝났다. 다른 사람과 일했다면 이 흐름이 3개의 PR + 3번의 리뷰 + 3번의 머지로 늘어났을 것이다. 1인 환경의 의도된 이점이 정확히 이 한 라운드 안에서 발휘됐다.
📌 핵심: QA 직접 수정 권한은 PM 리뷰 게이트가 살아 있을 때만 정상이다. PM 모드로 전환된 시점의 두 번째 읽기가 QA 코드의 정합성을 검증한다. 이 두 번째 읽기가 빠지면 QA가 짠 한 줄이 BE 의도와 어긋난 채로 들어가고, 그게 곧 다음 라운드의 버그가 된다.

라운드 6 — 시드 추가로 드러난 500, 그리고 ContentItem connect 실패
라운드 5에서 PM이 시드를 한 번 더 확장했다. 라운드 1·2까지 사용했던 TEST001 외에 TEST004~008을 추가했다.
// prisma/seed.ts — 시드 확장 블록
const testMembers = [
{ loginId: 'TEST004', levelKey: 'POOR_FLOW' },
{ loginId: 'TEST005', levelKey: 'EXCELLENT_FLOW' },
{ loginId: 'TEST006', levelKey: 'NORMAL_FLOW' },
{ loginId: 'TEST007', levelKey: 'BRANCH_FLOW' },
{ loginId: 'TEST008', levelKey: 'MERGE_FLOW' },
];
for (const tm of testMembers) {
const member = await prisma.member.upsert({ ... });
const assignment = await prisma.assignment.create({ ... });
const bundle = await prisma.bundle.create({ ... });
// 5명 모두 과제·번들·콘텐츠 후보까지 한 진입점에서 생성
}
라운드 6(01-20 13:50)은 TEST004로 결과 등급 하향 흐름을 검증했다. 로그인·번들 구조·콘텐츠 선택까지 깔끔하게 진행됐다. 그런데 마지막 단계에서 500이 떴다.
POST /api/v1/student/bundle/16/complete → 500 Internal Server Error
{
"statusCode": 500,
"message": "PrismaClientKnownRequestError: An operation failed because it depends on one or more records that were required but not found"
}
로그를 까보니 원인이 분명했다.
Invalid `prisma.bundle.update()` invocation:
{
data: {
bundleContents: {
create: [
{
order: 1,
content: { connect: { id: undefined } }
// ^^^^^^^^^ undefined
}
]
}
}
}
다음 번들 자동 생성 로직이 BundleContent를 create할 때, 콘텐츠 후보가 비어 있는 경우 content: { connect: { id: undefined } }를 시도하고 있었다. TEST004의 결과 등급 하향 흐름은 다음 번들의 콘텐츠 후보가 비어 있는 케이스를 만들어 냈고, 그 패턴이 이 버그를 드러냈다.
라운드 5에서 짠 PM의 다음 번들 자동 생성 구현은 콘텐츠 후보가 비어 있는 케이스를 가정 안 했다. 시드를 늘리지 않았다면 그 케이스가 라운드에서 안 보였을 것이다.
이 지점에서 결정이 갈렸다. 두 옵션이 있었다.
- 옵션 A: 콘텐츠 후보가 비어 있으면 다음 번들을 안 만든다 (
hasNextBundle: false) - 옵션 B: 콘텐츠 후보가 비어 있어도 다음 번들은 만들고
BundleContent.contentId를 nullable로 둔다
PM 모드로 30분 정도 검토했다. 옵션 A는 비즈니스 로직과 어긋났다 — 결과 등급 하향 흐름은 콘텐츠 후보가 없어도 다음 번들을 진행해야 하는 케이스가 있었다. 옵션 B를 선택했다.
라운드 7 — BundleContent.contentId nullable, 그리고 5라운드의 끝
라운드 7(01-20 14:55) 직전에 PM이 한 커밋(836e76e)으로 세 파일을 묶었다.
// prisma/schema.prisma — BundleContent.contentId nullable화
model BundleContent {
id String @id @default(cuid())
bundleId String
bundle Bundle @relation(fields: [bundleId], references: [id])
contentId String? // ← nullable
content Content? @relation(fields: [contentId], references: [id])
order Int
status BundleContentStatus @default(PENDING)
completedAt DateTime?
// ...
}
// apps/api/src/infrastructure/repositories/bundle.repository.ts — 조건부 connect
async createBundleContent(
bundleId: string,
order: number,
contentId: string | null,
ctx: TransactionContext,
) {
return ctx.prisma.bundleContent.create({
data: {
bundle: { connect: { id: bundleId } },
order,
...(contentId
? { content: { connect: { id: contentId } } }
: {}),
// contentId가 null이면 connect 자체를 안 보낸다
},
});
}
// apps/api/src/application/services/bundle.application.service.ts — null 체크
async getContentItem(bundleContentId: string): Promise<ContentItemDto | null> {
const bc = await this.bundleContentRepository.findById(bundleContentId);
if (!bc.contentId) {
return null; // 콘텐츠 후보가 없는 BundleContent는 null로 응답
}
const content = await this.contentRepository.findById(bc.contentId);
return ContentItemDto.from(bc, content);
}
이 커밋 안에 schema 변경 + Prisma migrate + 리포지토리 조건부 connect + 서비스 null 체크가 같이 들어갔다.
이전 편(라운드 1 회고)에서 정리한 “혼자서 여러 역할을 맡는 환경에서 결정이 가볍다”가 정확히 이 지점에서 한 번 더 드러난다. schema nullable화는 평시 PR review에서 절대 한 라운드 안에 안 끝나는 결정이다. 비즈니스 로직상 의미·NULL 처리 정책·migration 영향 범위·기존 데이터 호환성 검증이 다 필요하다. 다른 팀과 일했다면 이 결정 한 건에 PR 리뷰 1~2일을 쓴다.
1인 환경에서는 30분 만에 옵션 A·B를 비교하고 옵션 B를 선택했다. 같은 사람이 BE 의도·DB 영향·FE 영향을 다 머릿속에 들고 있어서 빠른 결정이 가능했다. 다만 그 결정의 무게는 그대로 남는다 — 6개월 뒤 NULL인 contentId 케이스가 어디서 드러날지 모른다.
⚠️ 주의: 1인 환경에서 schema 결정을 한 라운드 안에 끝내는 게 가능하다는 사실은 그 결정의 무게가 가벼워진다는 뜻이 아니다. 결정 속도가 빨라질 뿐, 결정이 시스템에 남기는 흔적은 똑같이 크다. 평시 PR 리뷰에서 brake가 걸렸을 결정이 1인 환경에서 그대로 들어가면, 6개월 뒤 그 NULL 케이스가 어디서 터질지를 본인이 책임진다.
라운드 7 결과는 깔끔했다.
POST /api/v1/student/bundle/8/complete [success - 200] ✅
GET /api/v1/student/bundle/13? [success - 200] ✅
POST /api/v1/student/bundle/13/complete [success - 200] ✅
GET /api/v1/student/bundle/14? [success - 200] ✅
같은 회원이 번들 8 → 13 → 14로 연속 3개를 깔끔하게 진행했다. Critical 흐름은 통과 처리.
BLOCKED 잔존 — 슬라이더 값 미반영, 5라운드 안에서 안 풀렸다
라운드 7 끝에 High·Medium 흐름 검증으로 넘어갔다. TEST004의 결과 등급 하향 흐름·TEST005의 상승 흐름·TEST006의 원복 흐름·TEST007/008의 분기·합류 흐름이 차례로 남아 있었다.
여기서 슬라이더 값 미반영 버그가 한 번에 4건 모두를 막았다. 콘텐츠 결과를 제출할 때 FE의 슬라이더 값이 75%로 고정돼서 BE로 전송되는 패턴이었다.
POST /api/v1/student/bundle-content/:id/result
{
"score": 75, // ← 슬라이더를 0으로 옮겨도, 100으로 옮겨도 항상 75
"answerKey": "ANSWER_KEY_A"
}
QA가 직접 수정 시도했다. FE 코드를 까봤더니 슬라이더 컴포넌트의 onChange 핸들러가 state 업데이트만 하고 submit 시점에 state가 아닌 초기값을 보내는 패턴이었다. 한 줄 수정으로 끝날 것 같았지만, 그 컴포넌트가 react-hook-form과 얽혀 있었고, controller 패턴으로 다시 짜야 했다. 30분 안에 못 끝낸다.
라운드 7 끝에서 결정했다. 이 버그는 5라운드 안에서 안 풀린다. 별도 FE 작업 큐로 분리하고 라운드 7 자체는 Critical 통과로 종결.
| 흐름 | 결과 |
|---|---|
| Critical (다음 번들 자동 생성) | ✅ PASSED |
| High (결과 등급 하향/상승) | ❌ BLOCKED — 슬라이더 |
| Medium (원복/분기/합류) | ❌ BLOCKED — 슬라이더 |
라운드를 5번 돌렸지만 슬라이더 1건은 안 풀렸다. FE 단독 작업이 필요한 항목은 라운드 안에서 푸는 게 비효율적이다 — QA 모드의 시간 박스를 깨고 30분 이상 들어가면 그 라운드의 다른 시나리오가 다 멎는다.

💡 교훈 — 5라운드 릴레이의 패턴
5라운드를 돌리면서 알게 된 패턴 셋이 있다. 같은 사람이 PM·BE·QA를 갈아끼우며 라운드를 5번 굴리면 무엇이 가벼워지고 무엇이 무거워지는지의 정리다.
회귀 누적 0건 — 라운드 사이마다 PM·BE·QA가 순서대로 굴리면 회귀가 안 쌓인다
5라운드 동안 회귀 버그가 0건이었다. 라운드 3에서 잡은 타이머 NaN이 라운드 5에서 다시 안 났고, 라운드 5에서 짠 다음 번들 자동 생성이 라운드 7에서 안 깨졌다.
회귀가 안 쌓인 이유는 단순했다. 라운드 사이에 PM 리뷰가 매번 들어갔다. 같은 사람이 QA → PM → BE → PM → QA로 모드를 바꾸면서 매번 두 번째 읽기를 했고, 그 두 번째 읽기가 회귀를 차단했다.
다른 팀과 일했다면 같은 두 번째 읽기를 PR 리뷰가 담당한다. 그런데 PR 리뷰는 시간 박스 안에서 작동한다 — 리뷰어가 30분 이상 한 PR에 못 머문다. 1인 환경에서는 PM 모드의 두 번째 읽기가 시간 박스 없이 작동한다. 본인이 짠 BE 코드를 PM 모드에서 다시 읽으면 의도와 구현의 차이가 그 순간에 보인다. 다른 사람의 코드를 30분 동안 읽는 것보다 본인 코드를 5분 동안 다시 읽는 게 회귀 차단력이 더 강하다.
다만 이 메커니즘은 같은 사람이 라운드 사이마다 PM 모드로 전환할 의지가 있을 때만 작동한다. 라운드 5처럼 QA가 직접 코드를 고친 뒤 PM 모드로 전환하지 않고 그대로 머지했다면, 그게 곧 라운드 6의 회귀가 됐을 것이다.
결정이 가벼워진다 — schema nullable화가 한 라운드 안에 끝났다
라운드 7의 BundleContent.contentId nullable화는 다른 환경이었다면 한 라운드 안에 절대 못 끝낼 결정이었다. 1인 환경에서 30분 만에 끝났다.
가벼워지는 이유는 결정 단위 안의 정보가 다 같은 사람의 머릿속에 있다는 점이다. 옵션 A·B 비교, NULL 처리 정책, FE 영향, migration 영향이 다 같은 머릿속에 들어와 있어서 동기화 비용이 0이다.
문제는 결정의 무게는 그대로 남는다는 점이다. 6개월 뒤 다른 흐름에서 NULL인 contentId 케이스가 드러나면, 그게 라운드 7의 결정을 다시 펴는 지점이 된다. 결정 속도가 빠르다고 결정의 영향 범위가 작아지지 않는다.
| 측면 | 다른 팀과 일할 때 | 1인 환경 |
|---|---|---|
| schema 변경 결정 시간 | 1~2일 (PR 리뷰) | 30분 |
| 결정 정보 동기화 비용 | 회의·문서·코멘트 | 0 (같은 머릿속) |
| 결정의 시스템 영향 | 동일 | 동일 |
| 결정의 6개월 뒤 추적 | PR 코멘트 + ADR | 본인 기억 + 시드 변경 흔적 |
마지막 행이 1인 환경의 진짜 비용이다. 6개월 뒤 결정의 맥락을 추적할 자료가 PR 리뷰 코멘트가 아니라 본인의 기억과 시드 변경 흔적뿐이다. 이게 무거워진다. 그래서 라운드를 5번 돌릴 때마다 결정 흔적을 LESSONS·세션 아카이브에 명시적으로 남겨야 한다. 1인 환경의 결정 속도는 그 자체로 위험 신호다.
FE 단독 작업은 라운드 안에서 안 풀린다
라운드 7의 슬라이더 버그가 5라운드 안에서 안 풀린 이유는 단순했다. FE 단독 작업은 BE/시드/스키마와 결정 의존성이 없어서 라운드의 시간 박스 안에 안 들어간다.
라운드의 시간 박스는 BE·시드·스키마가 같이 움직일 때만 효율적이다. 한 라운드 안에 BE 수정 + 시드 수정 + 라운드 재실행이 30분 안에 끝나는 게 이상적이다. FE 단독 작업은 그 30분을 깨버린다 — react-hook-form controller 재설계가 30분 안에 안 끝나니까.
이 패턴은 다음 라운드 셋업에 반영했다.
- 라운드 안에서 푸는 버그: BE 단위 변경 + 시드 변경 + 30분 이내 FE 한 줄 수정
- 라운드 밖으로 빼는 버그: FE 컴포넌트 재설계, react-hook-form 패턴 변경, UI/UX 변경
- 큐:
FE-DEBT.md별도 파일로 라운드 후 정리
라운드의 효율은 무엇을 라운드 안에 넣고 무엇을 밖으로 빼느냐로 결정된다. 라운드 안에 다 넣으려고 하면 한 라운드가 4시간씩 늘어지고, 그러면 5라운드를 절대 못 굴린다.
🚀 권장 — 라운드를 5번 굴리려면
5라운드 회고를 한 장 체크리스트로 정리했다. 다음 작업의 비즈니스 로직 검증 단계에서 그대로 쓰는 운영 가이드다.
라운드 0 — 시드를 먼저 고정한다
라운드 1·2까지는 회원 1명으로 충분했지만, 비즈니스 로직 검증으로 들어가면 흐름마다 회원 1명씩 시드에 박아둬야 한다. 라운드를 5번 굴린 뒤 알게 된 시드 설계 원칙이다.
- 한 회원에 흐름 한 줄
- 한 진입점에서 회원·과제·번들·콘텐츠 후보·진행 이력을 같이 생성
- 시드를 변경하면 라운드를 처음부터 다시 — 라운드 중간 시드 변경 금지
// prisma/seed.ts — 흐름 한 줄 시드 패턴
const flowSeeds = [
{ loginId: 'TEST002', flow: 'NEXT_BUNDLE_AUTO' },
{ loginId: 'TEST004', flow: 'GRADE_DOWN' },
{ loginId: 'TEST005', flow: 'GRADE_UP' },
{ loginId: 'TEST006', flow: 'GRADE_REVERT' },
{ loginId: 'TEST007', flow: 'BRANCH_ENTRY' },
{ loginId: 'TEST008', flow: 'MERGE_ARRIVAL' },
];
for (const fs of flowSeeds) {
await seedFullFlow(fs.loginId, fs.flow);
// 회원·과제·번들·콘텐츠 후보·이전 결과까지 한 함수에서 처리
}
라운드 우선순위 — Critical → High → Medium 순서
24건을 다 도는 게 아니라 우선순위 3단계로 잘라서 도는 방식이 5라운드를 가능하게 했다.
- Critical: 의존성 사슬의 첫 흐름 — 통과 안 되면 다음 흐름 검증 불가
- High: 비즈니스 가치 흐름 — 통과해야 출시 가능
- Medium: 엣지 케이스 흐름 — 통과 안 돼도 출시 가능, 별도 작업으로 분리
라운드 3은 Critical만, 라운드 5는 Critical 통과 후 High로, 라운드 7은 High BLOCKED 시 Medium 보류 — 우선순위가 라운드의 진입점을 결정한다.
QA 직접 수정 + PM 리뷰 게이트
1인 환경에서만 정상인 권한이다. 다른 사람과 일할 때는 절대 풀면 안 된다.
- QA가 30분 이내 수정 가능한 버그만 직접 수정
- 수정 후 PM 모드로 전환해서 본인 코드를 다시 읽기 — 의도와 구현의 차이가 그 순간에 보인다
- PM 리뷰 후 본 브랜치 머지
PM 리뷰 게이트가 살아 있어야 QA 직접 수정이 정상이다. 게이트가 빠지면 그게 곧 다음 라운드의 회귀가 된다.
결정 흔적 명시 — 1인 환경의 빠른 결정 속도는 위험 신호
schema 변경·NULL 처리 정책·기본값 변경처럼 시스템에 흔적을 남기는 결정은 빠르게 끝나도 명시적으로 기록해야 한다.
- 세션 아카이브에 옵션 A·B 비교 + 선택 사유 명시
- 시드 변경 흔적은
prisma/seed.ts의 주석에 라운드 번호 명시 - 6개월 뒤 본인이 다시 읽었을 때 결정의 맥락을 복원할 수 있어야 한다
1인 환경의 결정 속도는 PR 리뷰 코멘트가 빠진 지점에서 나온다. 그 지점을 본인의 기억에 의존하는 건 6개월 뒤 본인을 위험하게 만든다.
FE 단독 작업은 별도 큐
라운드 안에서 안 풀리는 작업은 빠르게 라운드 밖으로 뺀다.
- 30분 안에 안 끝나는 FE 변경은 즉시
FE-DEBT.md로 이동 - 라운드 종료 시 우선순위 재평가 — Medium 흐름의 BLOCKED는 출시 후 큐로 빼도 OK
- FE 단독 작업은 별도 시간 박스(예: 다음 날 오전 3시간)로 분리
라운드의 효율은 무엇을 빼느냐로 결정된다. 다 넣으려 하면 5라운드를 못 굴린다.
axios interceptor의 응답 unwrap 패턴은 이전에 정리한 응답 표준에서 같은 패턴을 한 번 더 다뤘다.
Prisma의 nullable 필드 처리와 connect API의 동작은 공식 문서가 정확히 정리해 둔다. connect는 필수 필드가 없으면 트랜잭션을 실패시키지만, connect를 조건부로 빼면 nullable 필드를 그대로 둘 수 있다.
📋 정리 — 핵심 요약
5라운드 동안 버그가 옮겨다닌 위치
| 라운드 | 발견 위치 | 수정 위치 | 종결 커밋 |
|---|---|---|---|
| 3 | BE 응답 DTO (remainingTimeMs 누락) + 시드 부족 | BE DTO + seed.ts | 324d060 (라운드 4) |
| 5 | FE 미구현 (다음 번들 자동 생성) | BE 도메인 서비스 + 시드 확장 | (라운드 5 내 머지) |
| 6 | BE 트랜잭션 (콘텐츠 후보 빈 케이스) | schema + 리포지토리 + 서비스 | 836e76e (라운드 7) |
| 7 (BLOCKED) | FE 슬라이더 (react-hook-form controller 패턴) | FE 컴포넌트 재설계 | (별도 큐로 분리) |
라운드 사이 PM·BE·QA 모드 전환 흐름
| 라운드 | QA 시간 | PM 시간 | BE 시간 | 결과 |
|---|---|---|---|---|
| 3 | 60분 | 30분 | 60분 | 두 함정 발견 → 라운드 4로 |
| 4 | 30분 | 15분 | (라운드 3에서 진행) | FIXED 확인 |
| 5 | 90분 | 60분 | 60분 | QA 직접 수정 + PM 자동 생성 구현 |
| 6 | 60분 | 30분 | 30분 | 500 발견 → 옵션 A·B 검토 |
| 7 | 30분 | 30분 | 60분 | nullable 결정 + 머지 + Critical 통과 |
5라운드 운영의 안티패턴과 권장 패턴
| 측면 | 안티패턴 | 권장 |
|---|---|---|
| 라운드 진입 | 시나리오 24건 다 돌기 | Critical → High → Medium 우선순위 |
| 시드 운영 | 라운드 중간 시드 변경 | 라운드 0에 시드 고정, 변경 시 라운드 재시작 |
| QA 권한 | QA가 코드 고치고 그대로 머지 | QA 수정 + PM 모드 전환 + 두 번째 읽기 |
| 결정 속도 | 빠른 결정 = 좋은 결정 | 빠른 결정 + 명시적 기록 (세션 아카이브) |
| FE 단독 작업 | 라운드 안에서 30분 이상 잡고 있기 | 별도 FE 큐로 즉시 분리 |
| 회귀 차단 | PR 리뷰 1번 | PM 모드 두 번째 읽기 (시간 박스 없음) |
숫자로 보는 5라운드
- 라운드 수: 5 (라운드 3 → 7)
- 실 작업 시간: 약 11시간 (01-19 21:00 ~ 01-20 15:15)
- 수정 커밋: 5건 (
6573039324d06028980c9라운드 5 머지 2건836e76e) - 회귀 버그: 0건
- schema 변경: 1건 (
BundleContent.contentIdnullable) - 시드 확장: 1건 (TEST004~008, 5명 추가)
- BLOCKED 잔존: 1건 (슬라이더 값 미반영, FE 단독 작업으로 분리)
- QA 직접 수정: 2건 (
student-assignment.application.service.tsbundle/index.tsx)

다음 편에서는 5라운드 동안 BLOCKED로 남겨둔 Unity Lobby + 배치고사 씬 통합을 다룬다. 1인 환경에서 Unity와 웹 클라이언트를 한 빌드 안에서 운영하는 방식의 회고다.
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)
- 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
- 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
- 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
- 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
- 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
- 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
- 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
- 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
- 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
- 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
- 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
- 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
- 13. 번들 구조를 통째로 바꿔야 했던 이유
- 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
- 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
- 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
- 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
- 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
- 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
- 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
- 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
- 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
- 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
- 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
- 25. 멀티테넌트 누수 — tenantId 3계층 강제
- 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
- 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
- 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
- 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
- 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
- 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
- 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
- 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
- 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
- 35. Unity ↔ 웹 PostMessage 브릿지 설계기
- 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
- 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
- 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
- 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
- 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
- 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
- 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
- 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
- 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
- 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
- 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날