13장 : 실전 프로젝트백엔드 API 구현
프로젝트·파일 API
프로젝트·파일·폴더 서비스와 컨트롤러 및 요청 DTO를 구현합니다.
Project Service, Controller, DTOs
import { IsString, MinLength } from 'class-validator';
export class CreateProjectDto {
@IsString()
@MinLength(3)
name: string;
}import { Injectable, InternalServerErrorException, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Project } from './entities/project.entity';
import { User } from '../auth/entities/user.entity';
import { CreateProjectDto } from './dto/create-project.dto';
import { File } from '../file/entities/file.entity';
import { Folder } from '../folder/entities/folder.entity';
@Injectable()
export class ProjectService {
constructor(
@InjectRepository(Project)
private projectsRepository: Repository<Project>,
@InjectRepository(File)
private filesRepository: Repository<File>,
@InjectRepository(Folder)
private foldersRepository: Repository<Folder>,
) {}
async createProject(createProjectDto: CreateProjectDto, user: User): Promise<Project> {
const newProject = this.projectsRepository.create({
name: createProjectDto.name,
owner: user,
ownerId: user.id,
});
return this.projectsRepository.save(newProject);
}
async findAllUserProjects(userId: string): Promise<Project[]> {
return this.projectsRepository.find({ where: { ownerId: userId }, order: { updatedAt: 'DESC' } });
}
async findProjectById(projectId: string, userId: string): Promise<Project> {
const project = await this.projectsRepository.findOne({
where: { id: projectId, ownerId: userId },
relations: ['files', 'folders'], // 관련 파일과 폴더를 함께 로드
});
if (!project) {
throw new NotFoundException('Project not found or you do not have access.');
}
return project;
}
async updateProject(projectId: string, userId: string, name: string): Promise<Project> {
const project = await this.findProjectById(projectId, userId); // 소유자 확인 포함
project.name = name;
return this.projectsRepository.save(project);
}
async deleteProject(projectId: string, userId: string): Promise<void> {
const project = await this.findProjectById(projectId, userId); // 소유자 확인 포함
await this.projectsRepository.remove(project); // cascade 삭제 설정으로 파일/폴더 함께 삭제
}
// --- 파일/폴더 관리 관련 메서드 (프로젝트 서비스 내에 함께 구현) ---
async createFile(projectId: string, userId: string, name: string, path: string, type?: string, content?: string): Promise<File> {
const project = await this.findProjectById(projectId, userId);
const newFile = this.filesRepository.create({
name,
path: path.startsWith('/') ? path : `/${path}`, // 경로 정규화
type,
content: content || '',
project,
projectId: project.id,
});
return this.filesRepository.save(newFile);
}
async updateFileContent(fileId: string, userId: string, newContent: string): Promise<File> {
const file = await this.filesRepository.findOne({ where: { id: fileId }, relations: ['project'] });
if (!file || file.project.ownerId !== userId) {
throw new UnauthorizedException('File not found or you do not have access.');
}
file.content = newContent;
return this.filesRepository.save(file);
}
async findFileById(fileId: string, userId: string): Promise<File> {
const file = await this.filesRepository.findOne({ where: { id: fileId }, relations: ['project'] });
if (!file || file.project.ownerId !== userId) {
throw new NotFoundException('File not found or you do not have access.');
}
return file;
}
async renameFile(fileId: string, userId: string, newName: string): Promise<File> {
const file = await this.findFileById(fileId, userId);
file.name = newName;
return this.filesRepository.save(file);
}
async deleteFile(fileId: string, userId: string): Promise<void> {
const file = await this.findFileById(fileId, userId);
await this.filesRepository.remove(file);
}
async createFolder(projectId: string, userId: string, name: string, path: string): Promise<Folder> {
const project = await this.findProjectById(projectId, userId);
const newFolder = this.foldersRepository.create({
name,
path: path.startsWith('/') ? path : `/${path}`,
project,
projectId: project.id,
});
return this.foldersRepository.save(newFolder);
}
async renameFolder(folderId: string, userId: string, newName: string): Promise<Folder> {
const folder = await this.foldersRepository.findOne({ where: { id: folderId }, relations: ['project'] });
if (!folder || folder.project.ownerId !== userId) {
throw new UnauthorizedException('Folder not found or you do not have access.');
}
folder.name = newName;
return this.foldersRepository.save(folder);
}
async deleteFolder(folderId: string, userId: string): Promise<void> {
const folder = await this.foldersRepository.findOne({ where: { id: folderId }, relations: ['project'] });
if (!folder || folder.project.ownerId !== userId) {
throw new UnauthorizedException('Folder not found or you do not have access.');
}
// 경로 기반 재귀 삭제: "/src" 삭제 시 "/src/*" 하위 리소스를 함께 정리
try {
await this.projectsRepository.manager.transaction(async (manager) => {
const descendantFolders = await manager
.createQueryBuilder(Folder, 'folder')
.where('folder.projectId = :projectId', { projectId: folder.projectId })
.andWhere('(folder.path = :exactPath OR folder.path LIKE :prefixPath)', {
exactPath: folder.path,
prefixPath: `${folder.path}/%`,
})
.getMany();
if (descendantFolders.length === 0) {
return;
}
const folderIds = descendantFolders.map((item) => item.id);
await manager
.createQueryBuilder()
.delete()
.from(File)
.where('projectId = :projectId', { projectId: folder.projectId })
.andWhere('(path = :exactPath OR path LIKE :prefixPath)', {
exactPath: folder.path,
prefixPath: `${folder.path}/%`,
})
.execute();
await manager
.createQueryBuilder()
.delete()
.from(Folder)
.where('id IN (:...folderIds)', { folderIds })
.execute();
});
} catch (error) {
// 트랜잭션 실패 시 전체 롤백되어 "파일만 지워지고 폴더는 남는" 상태를 방지
throw new InternalServerErrorException('Failed to delete folder tree. Please retry.');
}
}
}- 트랜잭션 롤백: 하위 파일/폴더 삭제 중 하나라도 실패하면 전체를 취소해 데이터 정합성을 유지합니다.
- 재시도 가능 에러 메시지: API 응답에서 재시도 가능한 메시지를 내려 프론트엔드가 사용자에게 안내할 수 있게 합니다.
- 운영 로그 분리:
folderId,projectId,userId를 구조화 로그로 남겨 장애 분석 시간을 줄입니다.
import { Controller, Post, Get, Patch, Delete, Body, Param, UseGuards, Req, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ProjectService } from './project.service';
import { CreateProjectDto } from './dto/create-project.dto';
import { User } from '../auth/entities/user.entity'; // 사용자 정보 타입 임포트
import { UpdateProjectDto } from './dto/update-project.dto'; // 추가할 DTO
import { CreateFileDto, UpdateFileContentDto, RenameFileDto } from '../file/dto/file.dto'; // 파일 DTO
import { CreateFolderDto, RenameFolderDto } from '../folder/dto/folder.dto'; // 폴더 DTO
@Controller('projects')
@UseGuards(AuthGuard('jwt')) // 모든 프로젝트 API에 JWT 인증 적용
export class ProjectController {
constructor(private readonly projectService: ProjectService) {}
@Post()
async createProject(@Body() createProjectDto: CreateProjectDto, @Req() req: any) {
const user: User = req.user;
return this.projectService.createProject(createProjectDto, user);
}
@Get()
async getAllUserProjects(@Req() req: any) {
const user: User = req.user;
return this.projectService.findAllUserProjects(user.id);
}
@Get(':projectId')
async getProjectDetails(@Param('projectId') projectId: string, @Req() req: any) {
const user: User = req.user;
return this.projectService.findProjectById(projectId, user.id);
}
@Patch(':projectId')
async updateProject(@Param('projectId') projectId: string, @Body() updateProjectDto: UpdateProjectDto, @Req() req: any) {
const user: User = req.user;
return this.projectService.updateProject(projectId, user.id, updateProjectDto.name);
}
@Delete(':projectId')
@HttpCode(HttpStatus.NO_CONTENT) // 204 No Content 반환
async deleteProject(@Param('projectId') projectId: string, @Req() req: any) {
const user: User = req.user;
await this.projectService.deleteProject(projectId, user.id);
}
// --- 파일 API ---
@Post(':projectId/files')
async createFile(
@Param('projectId') projectId: string,
@Body() createFileDto: CreateFileDto,
@Req() req: any,
) {
const user: User = req.user;
return this.projectService.createFile(projectId, user.id, createFileDto.name, createFileDto.path, createFileDto.type, createFileDto.content);
}
@Get(':projectId/files/:fileId')
async getFileContent(
@Param('projectId') projectId: string,
@Param('fileId') fileId: string,
@Req() req: any,
) {
const user: User = req.user;
// 실제로는 파일 내용만 반환하거나, DTO로 변환하여 필요한 정보만 반환
return this.projectService.findFileById(fileId, user.id);
}
@Patch(':projectId/files/:fileId/content')
async updateFileContent(
@Param('projectId') projectId: string,
@Param('fileId') fileId: string,
@Body() updateFileContentDto: UpdateFileContentDto,
@Req() req: any,
) {
const user: User = req.user;
return this.projectService.updateFileContent(fileId, user.id, updateFileContentDto.content);
}
@Patch(':projectId/files/:fileId/rename')
async renameFile(
@Param('projectId') projectId: string,
@Param('fileId') fileId: string,
@Body() renameFileDto: RenameFileDto,
@Req() req: any,
) {
const user: User = req.user;
return this.projectService.renameFile(fileId, user.id, renameFileDto.newName);
}
@Delete(':projectId/files/:fileId')
@HttpCode(HttpStatus.NO_CONTENT)
async deleteFile(
@Param('projectId') projectId: string,
@Param('fileId') fileId: string,
@Req() req: any,
) {
const user: User = req.user;
await this.projectService.deleteFile(fileId, user.id);
}
// --- 폴더 API ---
@Post(':projectId/folders')
async createFolder(
@Param('projectId') projectId: string,
@Body() createFolderDto: CreateFolderDto,
@Req() req: any,
) {
const user: User = req.user;
return this.projectService.createFolder(projectId, user.id, createFolderDto.name, createFolderDto.path);
}
@Patch(':projectId/folders/:folderId/rename')
async renameFolder(
@Param('projectId') projectId: string,
@Param('folderId') folderId: string,
@Body() renameFolderDto: RenameFolderDto,
@Req() req: any,
) {
const user: User = req.user;
return this.projectService.renameFolder(folderId, user.id, renameFolderDto.newName);
}
@Delete(':projectId/folders/:folderId')
@HttpCode(HttpStatus.NO_CONTENT)
async deleteFolder(
@Param('projectId') projectId: string,
@Param('folderId') folderId: string,
@Req() req: any,
) {
const user: User = req.user;
await this.projectService.deleteFolder(folderId, user.id);
}
}@UseGuards(AuthGuard('jwt')): 이 데코레이터를 사용하여 특정 컨트롤러 또는 특정 메서드에 JWT 인증 가드를 적용할 수 있습니다. 요청에 유효한 JWT가 없으면 401 Unauthorized 응답을 반환합니다.@Req() req: any: 요청 객체에서req.user를 통해 인증된 사용자 정보를 가져올 수 있습니다. (JwtStrategy에서 반환한 값)- 파일 및 폴더 DTO는
src/file/dto/file.dto.ts와src/folder/dto/folder.dto.ts에 유사한 방식으로 정의합니다.