-
Notifications
You must be signed in to change notification settings - Fork 0
feat: NestJS Task Manager API 초기 구현 (코드리뷰 비교용) #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
5cf31ed
4b871a5
a367a6a
f495f6c
15ff861
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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 초기화 형태로 작성 |
| 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, | ||
| }), | ||
| UsersModule, | ||
| ProjectsModule, | ||
| TasksModule, | ||
| ], | ||
| }) | ||
| export class AppModule {} | ||
| 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); | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] GlobalValidationPipe 미등록 — 모든 DTO validator가 무력화됨
// 수정:
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(); | ||
| 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; | ||
| } |
| 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') | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] 존재하지 않는 Project 조회 시 404 대신 200 null 반환
// 수정: 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); | ||
| } | ||
| } | ||
| 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 {} |
| 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) | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Medium] N+1 쿼리 — 프로젝트 N개일 때 N+1번 DB 쿼리 발생
// 수정: 단일 쿼리로 처리
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); | ||
| } | ||
| } | ||
| 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() | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Medium] dueDate 타입이
// 수정:
@IsOptional()
@IsDateString()
dueDate?: string; |
||
| dueDate?: any; | ||
| } | ||
| 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는 변경 불가여야 하는데 수정 가능하도록 노출됨 | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [High] projectId 노출 — 무단 task 프로젝트 이동 가능
수정: DTO에서 const { projectId, ...safeDto } = dto;
await this.taskRepository.update(id, safeDto); |
||
| @IsOptional() | ||
| @IsNumber() | ||
| projectId?: number; | ||
|
|
||
| @IsOptional() | ||
| dueDate?: any; | ||
| } | ||
| 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; | ||
| } |
There was a problem hiding this comment.
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을 실행합니다. 프로덕션 배포 시 데이터 손실이 발생할 수 있습니다.