Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
736 changes: 736 additions & 0 deletions docs/superpowers/plans/2026-06-10-task-manager-api.md

Large diffs are not rendered by default.

100 changes: 100 additions & 0 deletions docs/superpowers/specs/2026-06-10-task-manager-api-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Task Manager API — Design Spec

**Date:** 2026-06-10
**Purpose:** 코드리뷰 도구 3종 비교용 테스트 프로젝트 (실행 불필요, 리뷰 전용)

---

## 목적

Claude 코드리뷰, 자체 코드리뷰 플러그인, Alibaba 코드리뷰 기능을 토큰 소모량 및 탐지 품질 기준으로 비교하기 위한 NestJS 백엔드 샘플 프로젝트.

---

## 스택

- **Framework:** NestJS + TypeScript
- **ORM:** TypeORM (엔티티 정의만, 실제 DB 연결 불필요)
- **DB (참조용):** PostgreSQL

---

## 모듈 구조

```
src/
├── app.module.ts
├── main.ts
├── users/
│ ├── user.entity.ts
│ ├── users.controller.ts
│ ├── users.service.ts
│ └── users.module.ts
├── projects/
│ ├── project.entity.ts
│ ├── projects.controller.ts
│ ├── projects.service.ts
│ └── projects.module.ts
└── tasks/
├── task.entity.ts
├── dto/
│ ├── create-task.dto.ts
│ └── update-task.dto.ts
├── tasks.controller.ts
├── tasks.service.ts
└── tasks.module.ts
```

**관계:** User → (1:N) Project, Project → (1:N) Task, Task → (N:1) User (assignee)

---

## API 엔드포인트

| Method | Path | 설명 |
|--------|------|------|
| POST | /users | 유저 생성 |
| GET | /users/:id | 유저 조회 |
| POST | /projects | 프로젝트 생성 |
| GET | /projects | 전체 목록 |
| GET | /projects/:id | 단건 조회 |
| DELETE | /projects/:id | 삭제 |
| POST | /tasks | 태스크 생성 |
| GET | /tasks | 전체 목록 |
| GET | /tasks/:id | 단건 조회 |
| PATCH | /tasks/:id | 상태/담당자 수정 |
| DELETE | /tasks/:id | 삭제 |

---

## 의도적 결함 목록

### 컨트롤러 — 에러 핸들링 미흡
- `tasks.controller.ts`: try/catch 없이 서비스 직접 호출, 404 응답 없음
- `projects.controller.ts`: 존재하지 않는 프로젝트에 태스크 추가 시 에러 미처리

### 서비스 — N+1 쿼리
- `tasks.service.ts`: `findAll()`에서 태스크 루프 안에서 assignee를 별도 쿼리로 조회 (JOIN 미사용)
- `projects.service.ts`: 프로젝트 목록에서 각 task count를 루프로 조회

### DTO — 유효성 검사 미흡
- `create-task.dto.ts`: `@IsNotEmpty()` 누락, `dueDate` 타입이 `any`
- `update-task.dto.ts`: PartialType 미사용, 필드 중복 정의

### 엔티티 — 인덱스 누락
- `task.entity.ts`: 자주 쿼리되는 `status`, `assigneeId` 컬럼에 `@Index()` 없음

---

## 정상 구현 범위

- 엔티티 관계 정의 (`@OneToMany`, `@ManyToOne`)
- 서비스 레이어 비즈니스 로직 (CRUD)
- 모듈 간 의존성 주입

---

## 비고

- 실행을 목적으로 하지 않으므로 `DatabaseModule` 실제 연결 설정 생략
- `package.json`, `tsconfig.json`, `nest-cli.json` 은 표준 NestJS 초기화 형태로 작성
27 changes: 27 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersModule } from './users/users.module';
import { ProjectsModule } from './projects/projects.module';
import { TasksModule } from './tasks/tasks.module';
import { User } from './users/user.entity';
import { Project } from './projects/project.entity';
import { Task } from './tasks/task.entity';

@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'postgres',
password: 'password',
database: 'task_manager',
entities: [User, Project, Task],
synchronize: true,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] synchronize: true — 프로덕션에서 컬럼 DROP 위험

TypeORM의 synchronize: true는 앱 시작 시마다 엔티티 정의와 실제 DB 스키마를 비교해 자동으로 ALTER TABLE / DROP COLUMN을 실행합니다. 프로덕션 배포 시 데이터 손실이 발생할 수 있습니다.

// 수정: 환경변수로 제어
synchronize: process.env.NODE_ENV !== 'production',
// 또는 TypeORM Migration 사용

}),
UsersModule,
ProjectsModule,
TasksModule,
],
})
export class AppModule {}
8 changes: 8 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
const app = await NestFactory.create(AppModule);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] GlobalValidationPipe 미등록 — 모든 DTO validator가 무력화됨

app.useGlobalPipes(new ValidationPipe())가 없어서 CreateTaskDto, UpdateTaskDto의 모든 class-validator 데코레이터(@IsString, @IsEnum, @IsNumber 등)가 런타임에 전혀 동작하지 않습니다. 어떤 body라도 그대로 통과합니다.

// 수정:
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));
  await app.listen(3000);
}

await app.listen(3000);
}
bootstrap();
28 changes: 28 additions & 0 deletions src/projects/project.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
import { User } from '../users/user.entity';
import { Task } from '../tasks/task.entity';

@Entity('projects')
export class Project {
@PrimaryGeneratedColumn()
id: number;

@Column()
name: string;

@Column({ nullable: true })
description: string;

@Column()
ownerId: number;

@ManyToOne(() => User, (user) => user.projects)
@JoinColumn({ name: 'ownerId' })
owner: User;

@OneToMany(() => Task, (task) => task.project)
tasks: Task[];

@CreateDateColumn()
createdAt: Date;
}
30 changes: 30 additions & 0 deletions src/projects/projects.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Controller, Get, Post, Delete, Body, Param, ParseIntPipe } from '@nestjs/common';
import { ProjectsService } from './projects.service';

@Controller('projects')
export class ProjectsController {
constructor(private readonly projectsService: ProjectsService) {}

// 결함: 유효성 검사 없이 body를 그대로 사용
@Post()
create(@Body() body: { name: string; description: string; ownerId: number }) {
return this.projectsService.create(body.name, body.description, body.ownerId);
}

@Get()
findAll() {
return this.projectsService.findAll();
}

// 결함: findOne이 null을 반환해도 404를 던지지 않음
@Get(':id')

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] 존재하지 않는 Project 조회 시 404 대신 200 null 반환

projectsService.findOne()가 null을 반환해도 컨트롤러는 그대로 HTTP 200으로 응답합니다.

// 수정: service에서 처리
async findOne(id: number): Promise<Project> {
  const project = await this.projectRepository.findOne({ where: { id }, relations: ['tasks', 'owner'] });
  if (!project) throw new NotFoundException(`Project #${id} not found`);
  return project;
}

findOne(@Param('id', ParseIntPipe) id: number) {
return this.projectsService.findOne(id);
}

// 결함: 존재하지 않는 프로젝트 삭제 시도 시 에러 미처리
@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {
return this.projectsService.remove(id);
}
}
13 changes: 13 additions & 0 deletions src/projects/projects.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Project } from './project.entity';
import { ProjectsController } from './projects.controller';
import { ProjectsService } from './projects.service';

@Module({
imports: [TypeOrmModule.forFeature([Project])],
controllers: [ProjectsController],
providers: [ProjectsService],
exports: [ProjectsService],
})
export class ProjectsModule {}
47 changes: 47 additions & 0 deletions src/projects/projects.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Project } from './project.entity';

@Injectable()
export class ProjectsService {
constructor(
@InjectRepository(Project)
private readonly projectRepository: Repository<Project>,
) {}

async create(name: string, description: string, ownerId: number): Promise<Project> {
const project = this.projectRepository.create({ name, description, ownerId });
return this.projectRepository.save(project);
}

// 결함: 프로젝트 목록을 가져온 뒤 각 프로젝트의 task 수를 루프에서 별도 쿼리로 조회 (N+1)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] N+1 쿼리 — 프로젝트 N개일 때 N+1번 DB 쿼리 발생

projectRepository.find()로 전체 프로젝트를 가져온 뒤, 각 프로젝트마다 별도 쿼리로 task count를 조회합니다. 프로젝트 500개 → 501번 쿼리.

// 수정: 단일 쿼리로 처리
async findAll() {
  return this.projectRepository
    .createQueryBuilder('project')
    .loadRelationCountAndMap('project.taskCount', 'project.tasks')
    .getMany();
}

async findAll(): Promise<any[]> {
const projects = await this.projectRepository.find();

const result = [];
for (const project of projects) {
const taskCount = await this.projectRepository
.createQueryBuilder('project')
.leftJoin('project.tasks', 'task')
.where('project.id = :id', { id: project.id })
.select('COUNT(task.id)', 'count')
.getRawOne();

result.push({
...project,
taskCount: parseInt(taskCount.count, 10),
});
}

return result;
}

async findOne(id: number): Promise<Project> {
return this.projectRepository.findOne({ where: { id }, relations: ['tasks', 'owner'] });
}

async remove(id: number): Promise<void> {
await this.projectRepository.delete(id);
}
}
27 changes: 27 additions & 0 deletions src/tasks/dto/create-task.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { IsString, IsOptional, IsEnum, IsNumber } from 'class-validator';
import { TaskStatus } from '../task.entity';

export class CreateTaskDto {
// 결함: @IsNotEmpty() 누락 — 빈 문자열도 통과
@IsString()
title: string;

@IsOptional()
@IsString()
description?: string;

@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;

@IsNumber()
projectId: number;

@IsOptional()
@IsNumber()
assigneeId?: number;

// 결함: dueDate 타입이 any — Date 검증 없음
@IsOptional()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] dueDate 타입이 any — 잘못된 값이 DB에 그대로 저장됨

dueDate?: any"not-a-date", 0, {} 등 어떤 값도 통과시킵니다. DB DATE 컬럼 타입 불일치로 runtime 에러가 발생하거나 잘못된 데이터가 저장됩니다.

// 수정:
@IsOptional()
@IsDateString()
dueDate?: string;

dueDate?: any;
}
29 changes: 29 additions & 0 deletions src/tasks/dto/update-task.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { IsString, IsOptional, IsEnum, IsNumber } from 'class-validator';
import { TaskStatus } from '../task.entity';

// 결함: PartialType(CreateTaskDto)을 쓰면 되는데 필드를 직접 중복 정의
export class UpdateTaskDto {
@IsOptional()
@IsString()
title?: string;

@IsOptional()
@IsString()
description?: string;

@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;

@IsOptional()
@IsNumber()
assigneeId?: number;

// 결함: projectId는 변경 불가여야 하는데 수정 가능하도록 노출됨

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] projectId 노출 — 무단 task 프로젝트 이동 가능

UpdateTaskDtoprojectId가 optional 필드로 노출되어 있어 PATCH /tasks/:id { projectId: 99 }만으로 태스크를 다른 프로젝트로 이동할 수 있습니다. taskRepository.update(id, dto)는 DTO를 필터링 없이 그대로 전달합니다.

수정: DTO에서 projectId 제거하거나, 서비스에서 명시적으로 제외:

const { projectId, ...safeDto } = dto;
await this.taskRepository.update(id, safeDto);

@IsOptional()
@IsNumber()
projectId?: number;

@IsOptional()
dueDate?: any;
}
57 changes: 57 additions & 0 deletions src/tasks/task.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Project } from '../projects/project.entity';
import { User } from '../users/user.entity';

export enum TaskStatus {
TODO = 'todo',
IN_PROGRESS = 'in_progress',
DONE = 'done',
}

@Entity('tasks')
export class Task {
@PrimaryGeneratedColumn()
id: number;

@Column()
title: string;

@Column({ nullable: true })
description: string;

// 결함: 자주 필터링되는 컬럼인데 @Index() 없음
@Column({ type: 'enum', enum: TaskStatus, default: TaskStatus.TODO })
status: TaskStatus;

@Column()
projectId: number;

// 결함: 자주 JOIN되는 FK인데 @Index() 없음
@Column({ nullable: true })
assigneeId: number;

@Column({ nullable: true })
dueDate: Date;

@ManyToOne(() => Project, (project) => project.tasks)
@JoinColumn({ name: 'projectId' })
project: Project;

@ManyToOne(() => User, (user) => user.assignedTasks, { nullable: true })
@JoinColumn({ name: 'assigneeId' })
assignee: User;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
Loading