프로젝트·파일 데이터 모델
프로젝트와 파일·폴더 엔티티를 정의하고 소유 관계와 계층 구조를 구성합니다.
프로젝트 및 파일 관리 API 구현
Nest · Project data relationships
소유자·프로젝트·파일의 관계
User–Project, Project–File, Project–Folder의 일대다 관계입니다. ownerId·projectId로 소속을 표현하며 path는 문자열입니다. 관계에 필요한 필드만 표시합니다.
- User — 앞 문서의 외부참조
id- Project
id,ownerId- File
id,projectId, 문자열path예:/src/main.ts- Folder
id,projectId, 문자열path예:/src
- User 1 : Project N —
User.projects와Project.owner - Project 1 : File N —
Project.files와File.project - Project 1 : Folder N —
Project.folders와Folder.project
1:N은 일대다 관계를 나타냅니다. 필드는 관계 식별에 필요한 부분만 표시했습니다.
- Project 삭제 → 해당 File·Folder 삭제.
File.project와Folder.project에onDelete: 'CASCADE'가 설정되어 있습니다. - path는 경로 문자열입니다. 이 엔티티에는 폴더의 부모 FK나 File–Folder 관계가 없습니다. 폴더 경로 삭제는 별도 서비스 로직입니다.
이제 사용자 인증이 완료된 후 접근할 수 있는 프로젝트 및 파일 관리 API를 구현합니다.
id는 행을 식별하고, ownerId와 projectId는 어느 사용자·프로젝트에 속하는지 나타냅니다. path는 화면에서 파일의 위치를 표시하거나 서비스에서 경로를 비교할 때 쓰는 문자열입니다. 경로가 /src/main.ts라고 해서 데이터베이스가 /src 폴더를 부모로 찾아 연결하는 것은 아닙니다. 아래 엔티티에는 File.folder나 Folder.parent 관계가 없습니다.
Project Entity
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { User } from '../../auth/entities/user.entity'; // User 엔티티 임포트
import { File } from '../../file/entities/file.entity'; // File 엔티티 임포트 (미리 정의했다고 가정)
import { Folder } from '../../folder/entities/folder.entity'; // Folder 엔티티 임포트 (미리 정의했다고 가정)
@Entity()
export class Project {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false })
name: string;
@Column({ nullable: false })
ownerId: string; // 소유자 ID
@ManyToOne(() => User, user => user.projects)
owner: User; // User 엔티티와의 관계
@OneToMany(() => File, file => file.project)
files: File[];
@OneToMany(() => Folder, folder => folder.project)
folders: Folder[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}Project.files와 Project.folders는 한 프로젝트에서 여러 파일·폴더를 조회하는 반대편 관계입니다. 파일과 폴더 쪽에는 각각 project 관계가 있고 onDelete: 'CASCADE'가 있습니다. 이 설정은 프로젝트 삭제에 연결된 자식 삭제를 뜻합니다. 폴더 하나의 삭제가 경로상의 파일을 지우는 규칙은 다음 서비스의 별도 로직으로 읽습니다.
File & Folder Entities
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { Project } from '../../project/entities/project.entity';
@Entity()
export class File {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false })
name: string;
@Column({ nullable: false })
path: string; // 예: '/src/main.ts'
@Column({ type: 'text', nullable: true })
content: string; // 파일 내용 (TEXT 타입)
@Column({ nullable: true })
type: string; // 파일 타입 (예: 'javascript', 'python')
@Column({ nullable: false })
projectId: string;
@ManyToOne(() => Project, project => project.files, { onDelete: 'CASCADE' }) // 프로젝트 삭제 시 파일도 삭제
project: Project;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { Project } from '../../project/entities/project.entity';
@Entity()
export class Folder {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false })
name: string;
@Column({ nullable: false })
path: string; // 예: '/src'
@Column({ nullable: false })
projectId: string;
@ManyToOne(() => Project, project => project.folders, { onDelete: 'CASCADE' }) // 프로젝트 삭제 시 폴더도 삭제
project: Project;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}예시
프로젝트 두 개에 각각 /src/main.ts가 있어도 두 파일의 projectId가 다르면 소속이 다릅니다. 첫 프로젝트의 /src 폴더를 삭제할 때 경로만 비교하면 다른 프로젝트의 파일까지 선택할 위험이 있습니다. 따라서 다음 절의 삭제 쿼리는 projectId 조건과 경로 조건을 함께 사용합니다.
관계 확인 문제
File.path가 /src/main.ts인 행을 저장했지만 같은 프로젝트에 /src 폴더 행이 없습니다. 이 코드에 정의된 File–Folder 관계 때문에 반드시 저장이 실패하나요? 프로젝트 삭제와 폴더 삭제의 차이도 설명해 보세요.
엔티티 선언으로 답 확인
아니요. 이 엔티티에는 File–Folder 관계가 없으므로 그 폴더가 없다는 이유만으로 두 행 사이의 FK 검사가 수행되지는 않습니다. 그렇다고 모든 저장이 성공한다는 뜻은 아닙니다. 프로젝트 관계, 열 제약, 실제 DB 설정은 별도로 확인해야 합니다. 프로젝트 삭제에는 파일·폴더의 project 관계에 선언된 CASCADE가 쓰이고, 폴더 삭제에는 서비스가 같은 프로젝트의 경로를 골라 지우는 로직이 쓰입니다.
열 제약과 업무 규칙의 범위
엔티티의 선언을 읽을 때는 “값이 있어야 한다”와 “허용한 값이어야 한다”를 구별합니다. name의 nullable: false는 SQL NULL을 금지하는 설정이며, 빈 문자열이나 공백만 있는 이름을 거절하는 업무 규칙을 이 선언만으로 표현하지는 않습니다. 최소 길이와 공백 처리 정책이 필요하면 입력 검증과 저장 계약을 따로 정합니다. 실제 DB에 추가 CHECK나 다른 제약이 있다면 그 조건도 함께 확인해야 합니다.
반대로 File.content와 type은 nullable: true인데 TypeScript 필드는 string으로 적혀 있습니다. DB의 NULL 가능성과 TypeScript 정적 타입이 일치하도록 string | null을 쓰는 방식을 검토하고, 화면에서 null을 빈 문자열로 표시할지 계약을 정해야 합니다. 타입 표기만으로 DB 값이 변환되거나 런타임 응답이 검증되지는 않습니다. 다음 에디터 절의 content ?? ''는 표시 값의 기본값이지 원래 DB 값이 빈 문자열이었다는 증거가 아닙니다.
또 이 코드에는 프로젝트별 path의 고유 제약이 없습니다. 서로 다른 id를 가진 두 File 행이 같은 projectId와 path를 사용하지 못하게 하려면, “프로젝트 안에서 정규화한 경로가 유일하다” 같은 업무 규칙을 정하고 DB의 복합 고유 제약으로도 표현해야 합니다. path를 저장하기 전에 반복 슬래시·대소문자·끝 슬래시 등을 어떻게 처리할지는 서비스의 경로 계약입니다. 파일 시스템의 규칙이 자동 적용되는 것은 아닙니다. 고유 제약을 선언하는 방법은 TypeORM 공식 decorator 문서를 참고하되, 현재 예제에 이미 추가된 제약으로 읽지는 않습니다.
데이터 모델의 빈틈을 확인하는 문제
문제 1. name이 nullable: false이면 ''와 ' '도 이 옵션 하나로 거절되나요?
풀이. 아닙니다. 두 값은 NULL이 아닌 문자열입니다. 의미 있는 이름만 허용하려면 공백 정리와 최소 길이 등의 입력 규칙을 따로 검증해야 합니다. 실제 저장 결과는 추가 DB 제약에 따라 달라질 수 있으므로 엔티티의 nullable 옵션만으로 모든 결과를 단정하지 않습니다.
문제 2. 서로 다른 File id 두 개가 같은 프로젝트의 /src/main.ts를 가리킵니다. 현재 코드에서 path가 문자열이라는 이유만으로 반드시 두 번째 저장이 실패하나요?
풀이. 아닙니다. 앞 엔티티에는 (projectId, path)의 고유 제약이 선언되어 있지 않습니다. 중복을 금지할 계약을 정한 뒤 복합 고유 제약과 충돌 응답을 구현·검수해야 합니다. 서로 다른 프로젝트의 같은 경로까지 금지할지와도 구별합니다. 정규화 정책을 택했다면 /src//main.ts를 /src/main.ts로 바꾼 뒤 같은 고유성 조건을 적용할지도 명시합니다.
문제 3. content가 NULL인 응답을 에디터에서 ?? ''로 표시했습니다. 저장된 값도 ''로 바뀌었으며 TypeScript string이 NULL을 검증했다고 설명해도 되나요?
풀이. 안 됩니다. null 병합 연산은 그 표현식의 표시 값을 정할 뿐 DB 쓰기를 수행하지 않습니다. TypeScript 타입 역시 응답의 런타임 검증을 대신하지 않습니다. 조회 값, 표시 값, 수정 후 저장 값을 각각 구분하고 nullable 타입과 입력·응답 계약을 맞춰야 합니다.