본문 바로가기

프로젝트·파일 API

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

앞 글에서 정의한 엔티티로 프로젝트, 파일, 폴더를 다루는 API를 만듭니다. 코드를 읽을 때는 세 가지 값을 구별합니다. URL 경로에 들어 있는 projectId와 fileId, JWT 인증으로 확인한 req.user.id, 그리고 데이터베이스에 저장된 ownerId와 projectId입니다. 서비스는 앞의 두 값을 받아 세 번째 값과 비교한 뒤에만 데이터를 읽거나 바꿉니다.


요청 DTO

요청 본문은 DTO로 검증합니다. 다음 글에서 main.ts에 등록할 ValidationPipe가 이 데코레이터를 읽어 형식이 맞지 않는 요청을 400 Bad Request로 거부합니다.

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

export class CreateProjectDto {
  @IsString()
  @MinLength(3)
  name: string;
}
src/project/dto/update-project.dto.ts
import { CreateProjectDto } from './create-project.dto';

// 이름만 바꾸므로 생성 DTO의 검증 규칙을 그대로 물려받습니다.
export class UpdateProjectDto extends CreateProjectDto {}
src/file/dto/file.dto.ts
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';

export class CreateFileDto {
  @IsString()
  @MinLength(1)
  name: string;

  @IsString()
  @MinLength(1)
  path: string; // 예: '/src/main.ts'

  @IsOptional()
  @IsString()
  type?: string; // 예: 'typescript'

  @IsOptional()
  @IsString()
  @MaxLength(100_000)
  content?: string;
}

export class UpdateFileContentDto {
  @IsString()
  @MaxLength(100_000)
  content: string;
}

export class RenameFileDto {
  @IsString()
  @MinLength(1)
  newName: string;
}
src/folder/dto/folder.dto.ts
import { IsString, MinLength } from 'class-validator';

export class CreateFolderDto {
  @IsString()
  @MinLength(1)
  name: string;

  @IsString()
  @MinLength(1)
  path: string; // 예: '/src'
}

export class RenameFolderDto {
  @IsString()
  @MinLength(1)
  newName: string;
}

class-validator의 데코레이터는 클래스 상속으로 물려받으므로, UpdateProjectDto도 name에 같은 규칙을 적용합니다. 파일 내용의 @MaxLength(100_000)는 문자 수 제한입니다. 이와 별개로 Express의 JSON 본문 기본 한도는 100kb여서, 한글처럼 한 글자가 여러 바이트인 내용은 문자 수 제한보다 먼저 이 한도에 걸려 413 Payload Too Large가 될 수 있습니다.


ProjectService

src/project/project.service.ts
import {
  Injectable,
  InternalServerErrorException,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../auth/entities/user.entity';
import { CreateFileDto } from '../file/dto/file.dto';
import { File } from '../file/entities/file.entity';
import { CreateFolderDto } from '../folder/dto/folder.dto';
import { Folder } from '../folder/entities/folder.entity';
import { CreateProjectDto } from './dto/create-project.dto';
import { Project } from './entities/project.entity';

@Injectable()
export class ProjectService {
  private readonly logger = new Logger(ProjectService.name);

  constructor(
    @InjectRepository(Project)
    private readonly projectsRepository: Repository<Project>,
    @InjectRepository(File)
    private readonly filesRepository: Repository<File>,
    @InjectRepository(Folder)
    private readonly foldersRepository: Repository<Folder>,
  ) {}

  // --- 프로젝트 ---

  async createProject(createProjectDto: CreateProjectDto, user: User): Promise<Project> {
    // owner에 user 객체를 넣으면 응답에 비밀번호 해시까지 실리므로 ID만 저장합니다.
    const newProject = this.projectsRepository.create({
      name: createProjectDto.name,
      ownerId: user.id,
    });
    return this.projectsRepository.save(newProject);
  }

  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: true, folders: true }, // 파일과 폴더를 함께 불러옵니다.
    });
    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.findOwnedProject(projectId, userId);
    project.name = name;
    return this.projectsRepository.save(project);
  }

  async deleteProject(projectId: string, userId: string): Promise<void> {
    // 파일과 폴더는 엔티티의 onDelete: 'CASCADE'에 따라 DB가 함께 지웁니다.
    const result = await this.projectsRepository.delete({ id: projectId, ownerId: userId });
    if (!result.affected) {
      throw new NotFoundException('Project not found or you do not have access.');
    }
  }

  // --- 파일 ---

  async createFile(projectId: string, userId: string, dto: CreateFileDto): Promise<File> {
    await this.findOwnedProject(projectId, userId);
    const newFile = this.filesRepository.create({
      name: dto.name,
      path: this.normalizePath(dto.path),
      type: dto.type ?? null,
      content: dto.content ?? '',
      projectId,
    });
    return this.filesRepository.save(newFile);
  }

  async findFileById(projectId: string, fileId: string, userId: string): Promise<File> {
    await this.findOwnedProject(projectId, userId);
    // URL의 프로젝트 안에서만 파일을 찾습니다.
    const file = await this.filesRepository.findOneBy({ id: fileId, projectId });
    if (!file) {
      throw new NotFoundException('File not found or you do not have access.');
    }
    return file;
  }

  async updateFileContent(
    projectId: string,
    fileId: string,
    userId: string,
    content: string,
  ): Promise<File> {
    const file = await this.findFileById(projectId, fileId, userId);
    file.content = content;
    return this.filesRepository.save(file);
  }

  async renameFile(
    projectId: string,
    fileId: string,
    userId: string,
    newName: string,
  ): Promise<File> {
    const file = await this.findFileById(projectId, fileId, userId);
    file.name = newName;
    return this.filesRepository.save(file);
  }

  async deleteFile(projectId: string, fileId: string, userId: string): Promise<void> {
    const file = await this.findFileById(projectId, fileId, userId);
    await this.filesRepository.remove(file);
  }

  // --- 폴더 ---

  async createFolder(projectId: string, userId: string, dto: CreateFolderDto): Promise<Folder> {
    await this.findOwnedProject(projectId, userId);
    const newFolder = this.foldersRepository.create({
      name: dto.name,
      path: this.normalizePath(dto.path),
      projectId,
    });
    return this.foldersRepository.save(newFolder);
  }

  async renameFolder(
    projectId: string,
    folderId: string,
    userId: string,
    newName: string,
  ): Promise<Folder> {
    const folder = await this.findFolderById(projectId, folderId, userId);
    folder.name = newName;
    return this.foldersRepository.save(folder);
  }

  async deleteFolder(projectId: string, folderId: string, userId: string): Promise<void> {
    const folder = await this.findFolderById(projectId, folderId, userId);
    // "/src"를 지우면 "/src" 자체와 "/src/"로 시작하는 경로를 함께 지웁니다.
    const paths = { exactPath: folder.path, prefixPath: `${folder.path}/%` };

    try {
      await this.projectsRepository.manager.transaction(async (manager) => {
        await manager
          .createQueryBuilder()
          .delete()
          .from(File)
          .where('projectId = :projectId', { projectId })
          .andWhere('(path = :exactPath OR path LIKE :prefixPath)', paths)
          .execute();

        await manager
          .createQueryBuilder()
          .delete()
          .from(Folder)
          .where('projectId = :projectId', { projectId })
          .andWhere('(path = :exactPath OR path LIKE :prefixPath)', paths)
          .execute();
      });
    } catch (error) {
      // 트랜잭션이 실패하면 두 삭제가 모두 취소됩니다.
      const reason = error instanceof Error ? error.message : String(error);
      this.logger.error(
        `Failed to delete folder tree (projectId=${projectId}, folderId=${folderId}): ${reason}`,
      );
      throw new InternalServerErrorException('Failed to delete folder tree. Please retry.');
    }
  }

  // --- 내부 도우미 ---

  private async findOwnedProject(projectId: string, userId: string): Promise<Project> {
    const project = await this.projectsRepository.findOneBy({ id: projectId, ownerId: userId });
    if (!project) {
      throw new NotFoundException('Project not found or you do not have access.');
    }
    return project;
  }

  private async findFolderById(projectId: string, folderId: string, userId: string): Promise<Folder> {
    await this.findOwnedProject(projectId, userId);
    const folder = await this.foldersRepository.findOneBy({ id: folderId, projectId });
    if (!folder) {
      throw new NotFoundException('Folder not found or you do not have access.');
    }
    return folder;
  }

  private normalizePath(path: string): string {
    return path.startsWith('/') ? path : `/${path}`;
  }
}

소유 확인과 응답 코드

모든 메서드는 findOwnedProject()나 findProjectById()로 시작합니다. 두 메서드는 id와 ownerId를 같은 where 조건에 넣어 조회하므로, 다른 사용자의 프로젝트는 처음부터 결과에 나오지 않습니다. 파일과 폴더는 그다음 { id, projectId }로 찾습니다. 그래서 내 소유의 파일이라도 URL에 적힌 프로젝트에 속하지 않으면 찾지 못합니다.

조회에 실패하면 프로젝트가 없는 경우와 다른 사용자의 프로젝트인 경우 모두 404 Not Found로 응답합니다. 이렇게 하면 응답만 보고 다른 사람의 프로젝트 ID가 존재하는지 알아낼 수 없습니다. 401 Unauthorized는 인증이 없거나 토큰이 잘못되었다는 뜻이므로 이 자리에는 쓰지 않습니다. 13장 3절의 프런트엔드는 401을 받으면 로그인 화면으로 보내므로, 소유자가 아니라는 이유로 401을 돌려주면 로그인한 사용자가 로그아웃되는 문제도 생깁니다.

createProject()는 owner에 user 객체를 넣지 않고 ownerId만 저장합니다. save()는 넘겨받은 엔티티를 그대로 돌려주므로, owner: user를 넣으면 controller의 응답에 사용자의 password 해시가 함께 실립니다. 파일을 만들 때도 같은 이유로 project 객체 대신 projectId만 넣습니다.

deleteProject()는 엔티티를 먼저 불러오지 않고 delete({ id, ownerId })를 실행합니다. 지운 행이 없으면 affected가 0이므로 404로 응답합니다. 프로젝트의 파일과 폴더는 앞 글에서 선언한 onDelete: 'CASCADE'에 따라 데이터베이스가 지웁니다.

폴더 삭제와 트랜잭션

폴더 /src를 지울 때는 같은 프로젝트에서 경로가 정확히 /src이거나 /src/로 시작하는 파일과 폴더를 지웁니다. 접두사에 슬래시를 붙였으므로 /src2/main.ts는 대상에 들어가지 않고, 다른 프로젝트의 /src/main.ts는 projectId 조건으로 제외됩니다.

파일 삭제와 폴더 삭제는 manager.transaction() 안에서 같은 manager로 실행합니다. 두 번째 삭제가 실패하면 첫 번째 삭제도 함께 rollback되므로, 파일만 지워지고 폴더는 남는 상태가 생기지 않습니다. 실패한 요청에는 다시 시도하라는 메시지와 함께 500을 돌려주고, 원인을 찾을 수 있도록 프로젝트와 폴더의 ID를 로그에 남깁니다. 운영 환경에서는 10장의 JSON 로거로 이 값을 필드로 나눠 기록합니다.

파라미터 바인딩은 SQL 주입을 막지만, LIKE에서 %와 _는 바인딩된 값 안에서도 패턴 문자로 해석됩니다. 폴더 경로에 이 문자를 허용한다면 경로를 저장할 때 막거나, 쿼리에서 이 문자를 escape해 글자 그대로 비교하도록 따로 정해야 합니다.

인증 주체로 프로젝트 범위를 좁히고 폴더 삭제를 함께 확정합니다

소유 검사·프로젝트 경로 선택·File/Folder 동일 TX 삭제·함께 commit/rollback으로 상태가 변한다.

Nest 프로젝트 API · 소유권과 트랜잭션

인증 주체로 프로젝트 범위를 좁히고 폴더 삭제를 함께 확정합니다

JWT 인증은 사용자를 정합니다. 서비스는 그 사용자의 프로젝트·리소스만 처리해야 합니다. 삭제 트랜잭션의 성공·롤백과 현재 예제에 추가할 URL·경로 검증을 구분합니다.

소유자 거부와 삭제 거래의 두 결과 폴더 소유자 검사가 실패하면 삭제하지 않는다. 같은 프로젝트의 경로를 선택한 뒤 거래 안에서 File과 Folder를 삭제하여 함께 확정하거나 오류 시 함께 되돌린다. URL 프로젝트 소속과 wildcard 검증은 별도 추가 계약이다. 삭제 트랜잭션 아니요 예 성공 오류 JWT에서 사용자 확인 req.user.id 폴더 소유자? 인증 사용자와 비교 거부 · 삭제 없음 UnauthorizedException 같은 project 경로 선택 exactPath 또는 prefixPath File → Folder 삭제 같은 transaction manager 삭제 완료? 오류 발생 여부 함께 commit 삭제 확정 함께 rollback 실패 응답 500
소유자 거부와 삭제 거래의 두 결과 폴더 소유자 검사가 실패하면 삭제하지 않는다. 같은 프로젝트의 경로를 선택한 뒤 거래 안에서 File과 Folder를 삭제하여 함께 확정하거나 오류 시 함께 되돌린다. URL 프로젝트 소속과 wildcard 검증은 별도 추가 계약이다. 삭제 거래 예 아니요 성공 오류 JWT 사용자 확인 req.user.id 폴더 소유자? 사용자와 비교 같은 project 경로 선택 exact / prefix File → Folder 삭제 같은 manager 삭제 완료? 오류 발생 여부 함께 commit 삭제 확정 거부 · 삭제 없음 Unauthorized Exception 함께 rollback 실패 응답 500

그림은 거래 안에서 대상·하위 폴더 조회 결과가 비어 있지 않은 삭제 경로입니다. 조회 결과가 비면 파일·폴더 삭제 없이 반환합니다.

소유권

요청부터 소유권 확인까지

  1. 인증된 사용자

    AuthGuard(jwt)와 JwtStrategy가 정한 req.user.id를 사용합니다. 본문의 사용자 ID를 신원으로 믿지 않습니다.

  2. 프로젝트 조회·생성 경계

    목록은 ownerId로 제한하고 개별 프로젝트는 id + ownerId로 찾습니다. 파일·폴더 생성도 이 조회를 먼저 통과합니다.

  3. 기존 파일·폴더 경계

    리소스의 project.ownerId를 인증 주체와 비교합니다. JWT가 유효해도 다른 소유자의 리소스는 허용하지 않습니다.

삭제 단위

폴더 트리 삭제의 현재 순서

  1. 대상 폴더와 소유자 확인

    현재 예제는 트랜잭션 전에 폴더와 project를 읽어 소유자를 확인합니다.

  2. 같은 프로젝트의 경로 선택

    트랜잭션 manager로 projectId와 정확한 경로 또는 하위 prefix에 맞는 폴더를 찾습니다.

  3. 파일 → 폴더 삭제

    같은 프로젝트의 대상 경로 파일을 지운 뒤 조회한 폴더 ID들을 지웁니다.

  4. 함께 commit하거나 rollback

    삭제 중 오류가 나면 트랜잭션을 취소하고 500으로 실패합니다. rollback은 앞선 소유자 검증이나 외부 작업까지 포함하지 않습니다.

/src

경로 범위의 구체적 예

일반 경로 /src는 정확히 같은 /src와 /src/… 하위를 선택합니다. /src2는 이 prefix에 속하지 않습니다. 같은 경로라도 다른 projectId는 제외합니다.

추가할 계약

현재 코드와 추가 검증의 경계

기존 리소스 API는 소유자를 검사하지만 URL의 projectId를 서비스에 전달하지 않습니다. 중첩 route 계약을 완성하려면 리소스의 실제 프로젝트와 URL도 일치시켜야 합니다.

현재 경로 처리는 선행 /를 붙이는 수준입니다. ..·중복 구분자·루트의 규칙을 정하고 SQL LIKE의 %·_가 사용자 경로에서 wildcard가 되지 않도록 검증·escaping을 추가해야 합니다. 바인딩 parameter만으로 wildcard 의미가 사라지지는 않습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 폴더 소유자 검사가 실패하면 삭제하지 않는다. 같은 프로젝트의 경로를 선택한 뒤 트랜잭션 안에서 File과 Folder를 삭제하여 함께 확정하거나 오류 시 함께 되돌린다. URL 프로젝트 소속과 wildcard 검증은 별도 추가 계약이다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
4소유자 거부와 삭제 트랜잭션의 두 결과
5폴더 소유자 검사가 실패하면 삭제하지 않는다. 같은 프로젝트의 경로를 선택한 뒤 트랜잭션 안에서 File과 Folder를 삭제하여 함께 확정하거나 오류 시 함께 되돌린다. URL 프로젝트 소속과 wildcard 검증은 별도 추가 계약이다.
36그림은 트랜잭션 안에서 대상·하위 폴더 조회 결과가 비어 있지 않은 삭제 경로입니다. 조회 결과가 비면 파일·폴더 삭제 없이 반환합니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
소유자와 프로젝트

폴더 소유를 확인하고 같은 프로젝트 경로를 선택합니다.

소유 실패: 삭제 없음 · 경로: 같은 프로젝트허용된 범위만 삭제 후보가 됩니다.
같은 TX 삭제

File·Folder를 같은 transaction 안에서 삭제합니다.

변경: File·Folder · 범위: 같은 TX연관 삭제의 원자적 범위를 묶습니다.
함께 완료·rollback

성공은 함께 확정하고 오류는 함께 되돌립니다.

성공: 둘 다 commit · 실패: 둘 다 rollback · wildcard: 추가 계약두 데이터 변경이 같은 완료 결과를 가집니다.

인증 주체로 프로젝트 범위를 좁히고 폴더 삭제를 함께 확정합니다

소유 검사·프로젝트 경로 선택·File/Folder 동일 TX 삭제·함께 commit/rollback으로 상태가 변한다.

Nest 프로젝트 API · 소유권과 트랜잭션

인증 주체로 프로젝트 범위를 좁히고 폴더 삭제를 함께 확정합니다

JWT 인증은 사용자를 정합니다. 서비스는 그 사용자의 프로젝트·리소스만 처리해야 합니다. 삭제 트랜잭션의 성공·롤백과 현재 예제에 추가할 URL·경로 검증을 구분합니다.

소유자 거부와 삭제 거래의 두 결과 폴더 소유자 검사가 실패하면 삭제하지 않는다. 같은 프로젝트의 경로를 선택한 뒤 거래 안에서 File과 Folder를 삭제하여 함께 확정하거나 오류 시 함께 되돌린다. URL 프로젝트 소속과 wildcard 검증은 별도 추가 계약이다. 삭제 트랜잭션 아니요 예 성공 오류 JWT에서 사용자 확인 req.user.id 폴더 소유자? 인증 사용자와 비교 거부 · 삭제 없음 UnauthorizedException 같은 project 경로 선택 exactPath 또는 prefixPath File → Folder 삭제 같은 transaction manager 삭제 완료? 오류 발생 여부 함께 commit 삭제 확정 함께 rollback 실패 응답 500
소유자 거부와 삭제 거래의 두 결과 폴더 소유자 검사가 실패하면 삭제하지 않는다. 같은 프로젝트의 경로를 선택한 뒤 거래 안에서 File과 Folder를 삭제하여 함께 확정하거나 오류 시 함께 되돌린다. URL 프로젝트 소속과 wildcard 검증은 별도 추가 계약이다. 삭제 거래 예 아니요 성공 오류 JWT 사용자 확인 req.user.id 폴더 소유자? 사용자와 비교 같은 project 경로 선택 exact / prefix File → Folder 삭제 같은 manager 삭제 완료? 오류 발생 여부 함께 commit 삭제 확정 거부 · 삭제 없음 Unauthorized Exception 함께 rollback 실패 응답 500

그림은 거래 안에서 대상·하위 폴더 조회 결과가 비어 있지 않은 삭제 경로입니다. 조회 결과가 비면 파일·폴더 삭제 없이 반환합니다.

소유권

요청부터 소유권 확인까지

  1. 인증된 사용자

    AuthGuard(jwt)와 JwtStrategy가 정한 req.user.id를 사용합니다. 본문의 사용자 ID를 신원으로 믿지 않습니다.

  2. 프로젝트 조회·생성 경계

    목록은 ownerId로 제한하고 개별 프로젝트는 id + ownerId로 찾습니다. 파일·폴더 생성도 이 조회를 먼저 통과합니다.

  3. 기존 파일·폴더 경계

    리소스의 project.ownerId를 인증 주체와 비교합니다. JWT가 유효해도 다른 소유자의 리소스는 허용하지 않습니다.

삭제 단위

폴더 트리 삭제의 현재 순서

  1. 대상 폴더와 소유자 확인

    현재 예제는 트랜잭션 전에 폴더와 project를 읽어 소유자를 확인합니다.

  2. 같은 프로젝트의 경로 선택

    트랜잭션 manager로 projectId와 정확한 경로 또는 하위 prefix에 맞는 폴더를 찾습니다.

  3. 파일 → 폴더 삭제

    같은 프로젝트의 대상 경로 파일을 지운 뒤 조회한 폴더 ID들을 지웁니다.

  4. 함께 commit하거나 rollback

    삭제 중 오류가 나면 트랜잭션을 취소하고 500으로 실패합니다. rollback은 앞선 소유자 검증이나 외부 작업까지 포함하지 않습니다.

/src

경로 범위의 구체적 예

일반 경로 /src는 정확히 같은 /src와 /src/… 하위를 선택합니다. /src2는 이 prefix에 속하지 않습니다. 같은 경로라도 다른 projectId는 제외합니다.

추가할 계약

현재 코드와 추가 검증의 경계

기존 리소스 API는 소유자를 검사하지만 URL의 projectId를 서비스에 전달하지 않습니다. 중첩 route 계약을 완성하려면 리소스의 실제 프로젝트와 URL도 일치시켜야 합니다.

현재 경로 처리는 선행 /를 붙이는 수준입니다. ..·중복 구분자·루트의 규칙을 정하고 SQL LIKE의 %·_가 사용자 경로에서 wildcard가 되지 않도록 검증·escaping을 추가해야 합니다. 바인딩 parameter만으로 wildcard 의미가 사라지지는 않습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 폴더 소유자 검사가 실패하면 삭제하지 않는다. 같은 프로젝트의 경로를 선택한 뒤 트랜잭션 안에서 File과 Folder를 삭제하여 함께 확정하거나 오류 시 함께 되돌린다. URL 프로젝트 소속과 wildcard 검증은 별도 추가 계약이다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
4소유자 거부와 삭제 트랜잭션의 두 결과
5폴더 소유자 검사가 실패하면 삭제하지 않는다. 같은 프로젝트의 경로를 선택한 뒤 트랜잭션 안에서 File과 Folder를 삭제하여 함께 확정하거나 오류 시 함께 되돌린다. URL 프로젝트 소속과 wildcard 검증은 별도 추가 계약이다.
36그림은 트랜잭션 안에서 대상·하위 폴더 조회 결과가 비어 있지 않은 삭제 경로입니다. 조회 결과가 비면 파일·폴더 삭제 없이 반환합니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
소유자와 프로젝트

폴더 소유를 확인하고 같은 프로젝트 경로를 선택합니다.

소유 실패: 삭제 없음 · 경로: 같은 프로젝트허용된 범위만 삭제 후보가 됩니다.
같은 TX 삭제

File·Folder를 같은 transaction 안에서 삭제합니다.

변경: File·Folder · 범위: 같은 TX연관 삭제의 원자적 범위를 묶습니다.
함께 완료·rollback

성공은 함께 확정하고 오류는 함께 되돌립니다.

성공: 둘 다 commit · 실패: 둘 다 rollback · wildcard: 추가 계약두 데이터 변경이 같은 완료 결과를 가집니다.

위 그림은 폴더 삭제 요청이 세 단계로 처리되는 과정을 보여 줍니다. 먼저 JWT로 확인한 사용자가 폴더의 소유자인지 검사하고, 실패하면 아무것도 지우지 않습니다. 다음으로 같은 프로젝트에서 고른 경로의 File과 Folder를 하나의 트랜잭션에서 지웁니다. 마지막으로 두 삭제가 모두 성공하면 함께 commit하고, 오류가 나면 함께 rollback한 뒤 500으로 응답합니다.

그림의 거부 단계에는 UnauthorizedException이 적혀 있지만, 이 글의 코드는 앞에서 설명한 이유로 같은 자리에서 NotFoundException을 던집니다. 또 그림이 별도로 추가할 검사로 남겨 둔 URL의 프로젝트 소속 확인은 findFolderById()가 이미 하고 있으며, wildcard 문자 처리만 남은 작업입니다. 그림의 상세 패널은 트랜잭션 안에서 대상 폴더와 하위 폴더를 먼저 조회한 뒤 그 ID들을 지운다고 설명하지만, 이 글의 deleteFolder()는 폴더를 따로 조회하지 않고 같은 projectId와 경로 조건을 쓴 DELETE 두 개로 File과 Folder를 바로 지웁니다.


ProjectController

src/project/project.controller.ts
import {
  Body,
  Controller,
  Delete,
  Get,
  HttpCode,
  HttpStatus,
  Param,
  ParseUUIDPipe,
  Patch,
  Post,
  Req,
  UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { User } from '../auth/entities/user.entity';
import { CreateFileDto, RenameFileDto, UpdateFileContentDto } from '../file/dto/file.dto';
import { CreateFolderDto, RenameFolderDto } from '../folder/dto/folder.dto';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
import { ProjectService } from './project.service';

type AuthRequest = { user: User };

@Controller('projects')
@UseGuards(AuthGuard('jwt')) // 이 controller의 모든 API에 JWT 인증을 적용합니다.
export class ProjectController {
  constructor(private readonly projectService: ProjectService) {}

  @Post()
  createProject(@Body() createProjectDto: CreateProjectDto, @Req() req: AuthRequest) {
    return this.projectService.createProject(createProjectDto, req.user);
  }

  @Get()
  getAllUserProjects(@Req() req: AuthRequest) {
    return this.projectService.findAllUserProjects(req.user.id);
  }

  @Get(':projectId')
  getProjectDetails(
    @Param('projectId', ParseUUIDPipe) projectId: string,
    @Req() req: AuthRequest,
  ) {
    return this.projectService.findProjectById(projectId, req.user.id);
  }

  @Patch(':projectId')
  updateProject(
    @Param('projectId', ParseUUIDPipe) projectId: string,
    @Body() updateProjectDto: UpdateProjectDto,
    @Req() req: AuthRequest,
  ) {
    return this.projectService.updateProject(projectId, req.user.id, updateProjectDto.name);
  }

  @Delete(':projectId')
  @HttpCode(HttpStatus.NO_CONTENT) // 204 No Content
  async deleteProject(
    @Param('projectId', ParseUUIDPipe) projectId: string,
    @Req() req: AuthRequest,
  ) {
    await this.projectService.deleteProject(projectId, req.user.id);
  }

  // --- 파일 API ---

  @Post(':projectId/files')
  createFile(
    @Param('projectId', ParseUUIDPipe) projectId: string,
    @Body() createFileDto: CreateFileDto,
    @Req() req: AuthRequest,
  ) {
    return this.projectService.createFile(projectId, req.user.id, createFileDto);
  }

  @Get(':projectId/files/:fileId')
  getFile(
    @Param('projectId', ParseUUIDPipe) projectId: string,
    @Param('fileId', ParseUUIDPipe) fileId: string,
    @Req() req: AuthRequest,
  ) {
    return this.projectService.findFileById(projectId, fileId, req.user.id);
  }

  @Patch(':projectId/files/:fileId/content')
  updateFileContent(
    @Param('projectId', ParseUUIDPipe) projectId: string,
    @Param('fileId', ParseUUIDPipe) fileId: string,
    @Body() updateFileContentDto: UpdateFileContentDto,
    @Req() req: AuthRequest,
  ) {
    return this.projectService.updateFileContent(
      projectId,
      fileId,
      req.user.id,
      updateFileContentDto.content,
    );
  }

  @Patch(':projectId/files/:fileId/rename')
  renameFile(
    @Param('projectId', ParseUUIDPipe) projectId: string,
    @Param('fileId', ParseUUIDPipe) fileId: string,
    @Body() renameFileDto: RenameFileDto,
    @Req() req: AuthRequest,
  ) {
    return this.projectService.renameFile(projectId, fileId, req.user.id, renameFileDto.newName);
  }

  @Delete(':projectId/files/:fileId')
  @HttpCode(HttpStatus.NO_CONTENT)
  async deleteFile(
    @Param('projectId', ParseUUIDPipe) projectId: string,
    @Param('fileId', ParseUUIDPipe) fileId: string,
    @Req() req: AuthRequest,
  ) {
    await this.projectService.deleteFile(projectId, fileId, req.user.id);
  }

  // --- 폴더 API ---

  @Post(':projectId/folders')
  createFolder(
    @Param('projectId', ParseUUIDPipe) projectId: string,
    @Body() createFolderDto: CreateFolderDto,
    @Req() req: AuthRequest,
  ) {
    return this.projectService.createFolder(projectId, req.user.id, createFolderDto);
  }

  @Patch(':projectId/folders/:folderId/rename')
  renameFolder(
    @Param('projectId', ParseUUIDPipe) projectId: string,
    @Param('folderId', ParseUUIDPipe) folderId: string,
    @Body() renameFolderDto: RenameFolderDto,
    @Req() req: AuthRequest,
  ) {
    return this.projectService.renameFolder(
      projectId,
      folderId,
      req.user.id,
      renameFolderDto.newName,
    );
  }

  @Delete(':projectId/folders/:folderId')
  @HttpCode(HttpStatus.NO_CONTENT)
  async deleteFolder(
    @Param('projectId', ParseUUIDPipe) projectId: string,
    @Param('folderId', ParseUUIDPipe) folderId: string,
    @Req() req: AuthRequest,
  ) {
    await this.projectService.deleteFolder(projectId, folderId, req.user.id);
  }
}

클래스에 붙인 @UseGuards(AuthGuard('jwt'))는 이 controller의 모든 메서드에 JWT 인증 guard를 적용합니다. 유효한 토큰이 없으면 controller 메서드에 들어가기 전에 401 Unauthorized로 응답합니다. 인증을 통과하면 백엔드 API 구현에서 만든 JwtStrategy.validate()가 반환한 사용자가 req.user에 들어 있으므로, 이 형태를 AuthRequest 타입으로 한 번 정의해 모든 메서드에서 씁니다.

경로 매개변수에는 ParseUUIDPipe를 붙였습니다. 엔티티의 ID는 UUID 컬럼이어서, abc 같은 값을 그대로 쿼리에 넘기면 PostgreSQL이 형식 오류를 내고 500이 됩니다. 이 pipe는 UUID가 아닌 값을 서비스에 닿기 전에 400 Bad Request로 거부합니다.

controller는 URL의 projectId를 파일과 폴더 메서드에도 빠짐없이 넘깁니다. 서비스가 이 값으로 소속을 확인하므로, /projects/:projectId/files/:fileId라는 URL 구조가 실제 접근 범위와 일치합니다.


정리

  • 서비스는 URL의 ID와 JWT의 사용자 ID를 받아, 데이터베이스의 ownerId와 projectId와 비교한 뒤에만 데이터를 읽거나 바꿉니다.
  • 파일과 폴더는 { id, projectId }로 찾으므로, 내 소유라도 URL의 프로젝트에 속하지 않으면 404가 됩니다.
  • 소유하지 않은 리소스에는 401이 아니라 404로 응답해, 다른 사람의 ID가 존재하는지 드러내지 않고 프런트엔드의 로그아웃 처리와도 섞이지 않게 합니다.
  • 엔티티를 만들 때 관계 객체 대신 ID만 넣으면, 응답에 비밀번호 해시 같은 관계 데이터가 실리지 않습니다.
  • 폴더 삭제는 같은 프로젝트의 정확한 경로와 경로/ 접두사로 대상을 고르고, 파일과 폴더 삭제를 한 트랜잭션으로 묶습니다.
  • ParseUUIDPipe는 형식이 잘못된 ID를 400으로 거부해, 데이터베이스 오류가 500으로 새어 나가지 않게 합니다.

연습 문제

1. 다른 프로젝트의 파일 ID

한 사용자가 프로젝트 A와 B를 소유하고 있습니다. B에 속한 파일의 ID를 넣어 GET /projects/A/files/:fileId를 요청하면 어떤 응답을 받을까요? 서비스가 fileId와 사용자 ID만으로 파일을 찾고 file.project.ownerId만 비교했다면 결과가 어떻게 달라질까요?

해설 보기

404 Not Found를 받습니다. findOwnedProject()는 A의 소유자가 맞으므로 통과하지만, findOneBy({ id: fileId, projectId: A })가 B의 파일을 찾지 못합니다.

fileId와 사용자 ID만 쓰는 방식이었다면 B의 소유자도 같은 사용자이므로 검사를 통과하고, A의 URL로 B의 파일을 읽게 됩니다. “내 파일이다”와 “이 URL의 프로젝트에 속한다”는 서로 다른 조건이므로 둘 다 확인해야 합니다.

2. 폴더 삭제의 범위

같은 프로젝트에 경로가 /src/main.ts, /src2/main.ts, /src인 파일이 있고, 다른 프로젝트에도 /src/main.ts가 있습니다. 폴더 /src를 지우면 어떤 파일이 지워질까요? 파일 삭제 뒤 폴더 삭제 쿼리가 실패하면 파일은 어떻게 될까요?

해설 보기

같은 프로젝트의 /src/main.ts와 정확히 /src인 파일이 지워집니다. /src2/main.ts는 /src/%와 일치하지 않고, 다른 프로젝트의 파일은 projectId 조건으로 제외됩니다. 경로에 %나 _가 없다는 전제의 답입니다.

폴더 삭제가 실패하면 같은 트랜잭션 안에서 실행한 파일 삭제도 rollback되므로 파일은 그대로 남고, 클라이언트는 500을 받습니다. 다만 rollback되는 것은 트랜잭션 안의 쿼리뿐이며, 그 전에 실행한 소유 확인 조회나 다른 시스템에서 한 작업까지 되돌리지는 않습니다.

3. owner 객체를 넣은 프로젝트 생성

createProject()에서 create({ name, owner: user, ownerId: user.id })처럼 owner에 req.user를 넣었습니다. 프로젝트는 정상적으로 만들어지지만 응답 본문에 어떤 문제가 생길까요?

해설 보기

save()가 돌려준 엔티티에 owner 속성이 그대로 남아, 응답에 사용자의 email과 password 해시가 함께 실립니다. req.user는 JwtStrategy.validate()가 데이터베이스에서 읽은 User 엔티티 전체이기 때문입니다.

ownerId만 넣으면 소유 관계는 똑같이 저장되면서 응답에는 프로젝트의 컬럼만 담깁니다. 관계 객체를 응답에 포함해야 한다면, 백엔드 API 구현의 프로필 API처럼 password를 뺀 형태로 바꾼 뒤에 돌려줍니다.

다음 글 모듈 연결과 실행에서는 이 서비스와 controller를 모듈로 묶어 AppModule에 등록하고, 서버를 실행해 API를 차례로 호출해 봅니다.