본문으로 건너뛰기

안동민 개발노트

본문 시작
13장 : 실전 프로젝트백엔드 API 구현

프로젝트·파일 API

프로젝트·파일·폴더 서비스와 컨트롤러 및 요청 DTO를 구현합니다.

Project Service, Controller, DTOs

src/project/dto/create-project.dto.ts
import { IsString, MinLength } from 'class-validator';

export class CreateProjectDto {
  @IsString()
  @MinLength(3)
  name: string;
}
src/project/project.service.ts
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를 구조화 로그로 남겨 장애 분석 시간을 줄입니다.
src/project/project.controller.ts
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.tssrc/folder/dto/folder.dto.ts에 유사한 방식으로 정의합니다.

이 페이지의 목차