프로젝트·파일 데이터 모델
프로젝트와 파일·폴더 엔티티를 정의하고 소유 관계와 계층 구조를 구성합니다.
앞 글에서는 데이터베이스 연결과 사용자 인증을 구현했습니다. 이 글에서는 로그인한 사용자가 다룰 프로젝트, 파일, 폴더의 엔티티를 정의합니다.
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 관계가 없습니다. 폴더 경로 삭제는 별도 서비스 로직입니다.
위 그림은 이 글에서 선언할 엔티티 사이의 관계를 보여 줍니다. User와 Project, Project와 File, Project와 Folder가 각각 일대다 관계이고, 소속은 ownerId와 projectId로 나타냅니다. 파일과 폴더의 path는 그냥 문자열이며, 파일과 폴더 사이나 폴더와 상위 폴더 사이의 관계는 선언하지 않는다는 점이 그림 아래에 적혀 있습니다.
id는 행을 식별하고, ownerId와 projectId는 어느 사용자와 프로젝트에 속하는지 나타냅니다. path는 화면에 파일의 위치를 보여 주거나 서비스에서 경로를 비교할 때 쓰는 문자열입니다. 경로가 /src/main.ts라고 해서 데이터베이스가 /src 폴더를 부모로 찾아 연결하지는 않습니다. 아래 엔티티에는 File.folder나 Folder.parent 같은 관계가 없습니다.
Project 엔티티
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../auth/entities/user.entity';
import { File } from '../../file/entities/file.entity';
import { Folder } from '../../folder/entities/folder.entity';
@Entity()
export class Project {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column()
ownerId: string; // 소유자 ID
@ManyToOne(() => User, (user) => user.projects)
@JoinColumn({ name: 'ownerId' }) // 위의 ownerId 컬럼을 외래 키로 씁니다.
owner: User;
@OneToMany(() => File, (file) => file.project)
files: File[];
@OneToMany(() => Folder, (folder) => folder.project)
folders: Folder[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}@JoinColumn({ name: 'ownerId' })은 owner 관계가 위에서 선언한 ownerId 컬럼을 외래 키로 쓴다는 것을 명시합니다. 이렇게 하면 관계를 불러오지 않고도 project.ownerId로 소유자를 비교할 수 있습니다.
Project.files와 Project.folders는 한 프로젝트에 속한 여러 파일과 폴더를 조회하는 반대편 관계입니다. 파일과 폴더 쪽에는 각각 project 관계가 있고 onDelete: 'CASCADE'가 붙어 있습니다. 이 설정은 프로젝트를 지울 때 연결된 파일과 폴더를 데이터베이스가 함께 지운다는 뜻입니다. 폴더 하나를 지울 때 그 경로 아래의 파일을 지우는 규칙은 다음 글의 서비스에서 따로 구현합니다.
File과 Folder 엔티티
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Project } from '../../project/entities/project.entity';
@Entity()
export class File {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column()
path: string; // 예: '/src/main.ts'
// DB의 NULL 가능성을 TypeScript 타입에도 드러냅니다.
@Column({ type: 'text', nullable: true })
content: string | null;
@Column({ type: 'varchar', nullable: true })
type: string | null; // 파일 타입(예: 'javascript', 'python')
@Column()
projectId: string;
// 프로젝트를 지우면 파일도 함께 지웁니다.
@ManyToOne(() => Project, (project) => project.files, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'projectId' })
project: Project;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Project } from '../../project/entities/project.entity';
@Entity()
export class Folder {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column()
path: string; // 예: '/src'
@Column()
projectId: string;
// 프로젝트를 지우면 폴더도 함께 지웁니다.
@ManyToOne(() => Project, (project) => project.folders, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'projectId' })
project: Project;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}File.content와 File.type은 데이터베이스에서 NULL일 수 있으므로 TypeScript 타입도 string | null로 적었습니다. 유니온 타입은 메타데이터로 컬럼 타입을 추론할 수 없으므로 type: 'text', type: 'varchar'를 명시합니다. 타입을 이렇게 맞춰 두면, 화면에서 null을 빈 문자열로 보여 줄지 같은 처리를 코드에서 빠뜨리지 않게 됩니다. 다만 타입 표기만으로 데이터베이스의 값이 바뀌거나 런타임 응답이 검증되지는 않습니다.
예를 들어 프로젝트 두 개에 각각 /src/main.ts가 있어도, 두 파일의 projectId가 다르면 서로 다른 프로젝트의 파일입니다. 첫 프로젝트의 /src 폴더를 지울 때 경로만 비교하면 다른 프로젝트의 파일까지 고를 위험이 있으므로, 다음 글의 삭제 쿼리는 projectId 조건과 경로 조건을 함께 씁니다.
더 깊이: onDelete와 cascade가 행을 지우는 방식의 차이
TypeORM의 관계 옵션에는 이름이 비슷한 두 가지가 있습니다. TypeORM 관계 문서에 따르면 onDelete는 참조되는 행이 지워질 때 외래 키가 어떻게 동작할지 정하는 옵션이고 기본값은 RESTRICT입니다. 이것은 외래 키 제약에 들어가는 설정이라서, 이 글처럼 CASCADE로 두면 프로젝트 행이 지워질 때 데이터베이스가 직접 파일과 폴더 행을 지웁니다. 반면 cascade는 기본값이 false인 ORM 쪽 옵션으로, save()나 remove()를 호출할 때 TypeORM이 연결된 엔티티까지 함께 삽입·수정·삭제하게 합니다.
이 차이 때문에 결과도 달라집니다. 문서는 cascade로 삭제를 전파할 때 엔티티 인스턴스에 실제로 불러와 둔 관계만 따라가며, 불러오지 않은 관계의 자식은 지우지 않는다고 경고합니다. 또 @BeforeRemove()와 @AfterRemove() 리스너는 저장소나 엔티티 매니저의 remove()로 지울 때 호출되므로, 데이터베이스의 ON DELETE CASCADE가 지운 파일 행에서는 실행되지 않습니다. 파일이 지워질 때 외부 저장소의 사본을 정리하는 것 같은 후처리가 필요하다면, 리스너에 기대지 말고 서비스의 삭제 흐름에 명시적으로 넣습니다.
컬럼 제약과 업무 규칙의 범위
엔티티 선언을 읽을 때는 “값이 있어야 한다”와 “허용한 값이어야 한다”를 구별합니다. @Column()의 기본값은 nullable: false로, SQL의 NULL을 막을 뿐 빈 문자열이나 공백만 있는 이름을 거부하지는 않습니다. 최소 길이나 공백 처리 정책이 필요하다면 입력 검증과 저장 규칙을 따로 정합니다.
또 이 코드에는 프로젝트별 path의 고유 제약이 없습니다. 서로 다른 id를 가진 두 File 행이 같은 projectId와 path를 쓰지 못하게 하려면, “프로젝트 안에서 정규화한 경로는 유일하다” 같은 업무 규칙을 정하고 데이터베이스의 복합 고유 제약으로도 표현해야 합니다. 저장하기 전에 반복된 슬래시, 대소문자, 끝의 슬래시를 어떻게 처리할지는 서비스가 정할 경로 규칙이며, 파일 시스템의 규칙이 저절로 적용되지는 않습니다. 고유 제약을 선언하는 방법은 TypeORM 공식 데코레이터 문서를 참고하되, 지금 예제에는 아직 추가하지 않았습니다.
정리
- User와 Project, Project와 File·Folder는 일대다 관계이며, 소속은
ownerId와projectId컬럼으로 나타냅니다. @JoinColumn({ name: ... })으로 관계가 쓸 외래 키 컬럼을 명시하면, 관계를 불러오지 않고도 ID로 소속을 비교할 수 있습니다.onDelete: 'CASCADE'는 프로젝트를 지울 때 파일과 폴더를 함께 지우며, 폴더 아래 파일을 지우는 규칙은 서비스에서 따로 구현합니다.path는 문자열일 뿐이므로 파일과 폴더의 계층이 데이터베이스 관계로 연결되지 않습니다.- DB에서
NULL일 수 있는 컬럼은 TypeScript에서도string | null로 적고 컬럼 타입을 명시하며, 빈 값 금지나 경로의 유일성은 별도 규칙과 제약으로 정합니다.
연습 문제
1. 폴더 없이 저장한 파일
path가 /src/main.ts인 File 행을 저장했는데, 같은 프로젝트에 /src 폴더 행이 없습니다. 이 코드의 엔티티 관계 때문에 반드시 저장이 실패할까요? 프로젝트 삭제와 폴더 삭제가 파일에 미치는 영향의 차이도 설명해 보세요.
해설 보기
실패하지 않습니다. File과 Folder 사이에는 관계가 없으므로, 폴더가 없다는 이유로 외래 키 검사가 일어나지 않습니다. 다만 프로젝트 관계나 다른 컬럼 제약은 따로 확인해야 하므로, 모든 저장이 성공한다는 뜻은 아닙니다.
프로젝트를 지우면 파일과 폴더의 project 관계에 선언한 CASCADE로 데이터베이스가 함께 지웁니다. 폴더를 지울 때는 그런 관계가 없으므로, 서비스가 같은 프로젝트에서 그 경로 아래의 파일을 골라 지우는 로직을 직접 실행해야 합니다.
2. 빈 이름의 프로젝트
name 컬럼은 nullable: false입니다. 이름이 ''이거나 ' '인 프로젝트도 이 설정 하나로 거부될까요?
해설 보기
거부되지 않습니다. 두 값은 NULL이 아닌 문자열이므로 NOT NULL 제약을 통과합니다.
의미 있는 이름만 받으려면 DTO에서 @Transform()으로 앞뒤 공백을 지우고 @IsNotEmpty()나 @MinLength(1)로 검사하는 입력 규칙을 따로 둡니다. 데이터베이스 쪽에서도 막고 싶다면 CHECK 제약을 추가할 수 있으며, 실제 저장 결과는 이런 추가 제약에 따라 달라집니다.
3. 같은 경로의 파일 두 개
서로 다른 File id 두 개가 같은 프로젝트의 /src/main.ts를 가리키도록 저장했습니다. 지금 코드에서 두 번째 저장이 실패할까요? 중복을 막으려면 무엇을 정해야 할까요?
해설 보기
실패하지 않습니다. 엔티티에 (projectId, path)의 고유 제약이 선언되어 있지 않기 때문입니다.
중복을 막으려면 먼저 같은 프로젝트 안에서만 막을지, 다른 프로젝트의 같은 경로까지 막을지를 정합니다. 그다음 @Unique(['projectId', 'path']) 같은 복합 고유 제약을 선언하고, 충돌했을 때 409로 응답하는 처리를 구현합니다. 경로 정규화 정책을 택했다면 /src//main.ts를 /src/main.ts로 바꾼 뒤에 같은 조건을 적용할지도 함께 정합니다.
다음 글 프로젝트·파일 API에서는 이 엔티티로 프로젝트와 파일을 만들고, 조회하고, 저장하는 서비스와 controller를 구현합니다.