feat: 드라이브(파일 저장소/폴더 관리) 기능 추가

NCP Object Storage(S3 호환) 기반 파일 업로드/폴더 관리 기능을 백엔드에 추가한다.

- 스토리지 계층(common/storage): presign 기반 업로드/다운로드 URL 발급 래퍼
- 드라이브 모듈: 폴더 CRUD, 파일 presign 업로드→complete→다운로드, 이름변경/삭제
- 엔티티 3종: drive_folders(폴더 트리), drive_files(객체 메타), project_folder_links(프로젝트 공유)
- 마이그레이션 AddDriveTables: 3개 테이블 + 인덱스/외래키 생성
- stale pending 파일 정리 @Cron(매시) 추가
- env/docker-compose: OBJECT_STORAGE_* 주입(비시크릿은 루트, ACCESS/SECRET 키는 backend env)
- 의존성: @aws-sdk/client-s3, @aws-sdk/s3-request-presigner 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 21:11:08 +09:00
parent 101b1553b0
commit 22a02be937
24 changed files with 2020 additions and 0 deletions
@@ -0,0 +1,14 @@
import type { ValueTransformer } from 'typeorm';
// bigint 컬럼 변환기 — TypeORM 은 bigint 를 문자열로 반환하므로 number 로 복원한다.
// (파일 크기 등 Number.MAX_SAFE_INTEGER 이내 값에 사용)
export const numericColumnTransformer: ValueTransformer = {
// 저장: number → DB(bigint). null/undefined 는 그대로.
to(value: number | null | undefined): number | null | undefined {
return value;
},
// 조회: DB 문자열 → number. null 은 그대로.
from(value: string | null): number | null {
return value === null ? null : Number(value);
},
};
@@ -0,0 +1,168 @@
import { Injectable, Logger } from '@nestjs/common';
import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { StorageService } from './storage.service';
import type {
ObjectHead,
PresignDownloadInput,
PresignUploadInput,
PresignedUpload,
} from './storage.types';
// presigned URL 기본 유효 시간(초)
const DEFAULT_UPLOAD_EXPIRES = 300; // 업로드: 5분
const DEFAULT_DOWNLOAD_EXPIRES = 60; // 다운로드: 1분(단기 노출)
// NCP Object Storage 구현체 — S3 호환 API 를 그대로 사용한다.
// 환경변수(SSOT: 루트 .env → docker-compose 주입 / 시크릿은 backend 자체 env):
// OBJECT_STORAGE_ENDPOINT 예) https://kr.object.ncloudstorage.com
// OBJECT_STORAGE_REGION 예) kr-standard
// OBJECT_STORAGE_BUCKET 버킷명
// OBJECT_STORAGE_ACCESS_KEY 서브계정 Access Key (시크릿)
// OBJECT_STORAGE_SECRET_KEY 서브계정 Secret Key (시크릿)
@Injectable()
export class NcpObjectStorageService extends StorageService {
private readonly logger = new Logger(NcpObjectStorageService.name);
// 설정 누락 시에도 앱은 부팅되며(드라이브 외 기능 정상), 실제 사용 시점에 실패한다.
private readonly client: S3Client | null;
private readonly bucketName: string;
private readonly missing: string[];
constructor() {
super();
const endpoint = process.env.OBJECT_STORAGE_ENDPOINT;
const region = process.env.OBJECT_STORAGE_REGION || 'kr-standard';
const bucket = process.env.OBJECT_STORAGE_BUCKET;
const accessKeyId = process.env.OBJECT_STORAGE_ACCESS_KEY;
const secretAccessKey = process.env.OBJECT_STORAGE_SECRET_KEY;
this.bucketName = bucket || '';
this.missing = [
['OBJECT_STORAGE_ENDPOINT', endpoint],
['OBJECT_STORAGE_BUCKET', bucket],
['OBJECT_STORAGE_ACCESS_KEY', accessKeyId],
['OBJECT_STORAGE_SECRET_KEY', secretAccessKey],
]
.filter(([, v]) => !v)
.map(([k]) => k as string);
if (this.missing.length > 0) {
// 부팅은 통과시키되 경고를 남긴다 — 드라이브 API 호출 시 ensureConfigured() 가 막는다.
this.client = null;
this.logger.warn(
`오브젝트 스토리지 미설정(드라이브 비활성): ${this.missing.join(', ')} 누락`,
);
return;
}
this.client = new S3Client({
endpoint,
region,
// NCP/MinIO 등 S3 호환 스토리지는 path-style 접근이 필요(가상호스트 도메인 미지원)
forcePathStyle: true,
credentials: {
accessKeyId: accessKeyId as string,
secretAccessKey: secretAccessKey as string,
},
});
this.logger.log(
`오브젝트 스토리지 초기화: endpoint=${endpoint}, bucket=${this.bucketName}`,
);
}
get bucket(): string {
return this.bucketName;
}
// 설정이 완료되어 실제 호출 가능한지 보장 — 미설정 시 명확한 에러로 막는다.
private ensureConfigured(): S3Client {
if (!this.client) {
throw new Error(
`오브젝트 스토리지 설정 누락: ${this.missing.join(', ')} 를 환경변수로 지정해 주세요.`,
);
}
return this.client;
}
async presignUpload(input: PresignUploadInput): Promise<PresignedUpload> {
const client = this.ensureConfigured();
const expiresIn = input.expiresInSeconds ?? DEFAULT_UPLOAD_EXPIRES;
const command = new PutObjectCommand({
Bucket: this.bucketName,
Key: input.key,
// ContentType 을 서명에 포함 → 클라이언트는 동일 Content-Type 헤더로만 업로드 가능
ContentType: input.contentType,
});
const url = await getSignedUrl(client, command, { expiresIn });
return {
url,
method: 'PUT',
headers: { 'Content-Type': input.contentType },
expiresAt: new Date(Date.now() + expiresIn * 1000),
};
}
async presignDownload(input: PresignDownloadInput): Promise<string> {
const client = this.ensureConfigured();
const expiresIn = input.expiresInSeconds ?? DEFAULT_DOWNLOAD_EXPIRES;
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: input.key,
// 다운로드 강제 + 원본 파일명 복원(비ASCII 안전) + 타입 고정
ResponseContentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(
input.filename,
)}`,
ResponseContentType: input.contentType,
});
return getSignedUrl(client, command, { expiresIn });
}
async headObject(key: string): Promise<ObjectHead | null> {
const client = this.ensureConfigured();
try {
const res = await client.send(
new HeadObjectCommand({ Bucket: this.bucketName, Key: key }),
);
return {
size: res.ContentLength ?? 0,
contentType: res.ContentType ?? null,
};
} catch (err: unknown) {
// 미존재(404/NotFound)는 정상 흐름 → null. 그 외 오류는 상위로 전파.
if (this.isNotFound(err)) return null;
throw err;
}
}
async deleteObject(key: string): Promise<void> {
const client = this.ensureConfigured();
try {
await client.send(
new DeleteObjectCommand({ Bucket: this.bucketName, Key: key }),
);
} catch (err: unknown) {
// 이미 없으면 멱등 처리(삭제 목적은 달성). 그 외 오류는 전파.
if (this.isNotFound(err)) return;
throw err;
}
}
// AWS SDK v3 의 미존재 응답 판별(상태코드 404 또는 NotFound 계열 name)
private isNotFound(err: unknown): boolean {
const e = err as {
name?: string;
$metadata?: { httpStatusCode?: number };
};
return (
e?.$metadata?.httpStatusCode === 404 ||
e?.name === 'NotFound' ||
e?.name === 'NoSuchKey'
);
}
}
@@ -0,0 +1,18 @@
import { Global, Module } from '@nestjs/common';
import { StorageService } from './storage.service';
import { NcpObjectStorageService } from './ncp-object-storage.service';
// 스토리지 모듈 — StorageService 토큰을 NCP Object Storage 구현체에 바인딩한다.
// @Global 로 등록해 어느 모듈에서든 StorageService 를 주입받을 수 있다.
// (구현체 교체 시 useClass 만 변경하면 됨 — 예: 로컬 디스크/MinIO)
@Global()
@Module({
providers: [
{
provide: StorageService,
useClass: NcpObjectStorageService,
},
],
exports: [StorageService],
})
export class StorageModule {}
@@ -0,0 +1,26 @@
import type {
ObjectHead,
PresignDownloadInput,
PresignUploadInput,
PresignedUpload,
} from './storage.types';
// 오브젝트 스토리지 추상 인터페이스.
// abstract class 를 DI 토큰으로 사용한다(NestJS 권장 패턴) — 구현체는 storage.module 에서 주입.
// 바이너리는 백엔드를 거치지 않고 클라이언트가 presigned URL 로 직접 송수신한다.
export abstract class StorageService {
// 현재 사용 중인 버킷 이름(메타데이터에 함께 저장해 추후 버킷 이전 대비)
abstract get bucket(): string;
// 업로드용 presigned URL 발급(PUT). contentType 이 서명에 포함된다.
abstract presignUpload(input: PresignUploadInput): Promise<PresignedUpload>;
// 다운로드용 단기 presigned URL 발급(GET). 파일명/타입을 응답 헤더로 강제한다.
abstract presignDownload(input: PresignDownloadInput): Promise<string>;
// 객체 메타 조회 — 미존재 시 null. 업로드 완료(complete) 검증에 사용.
abstract headObject(key: string): Promise<ObjectHead | null>;
// 객체 삭제(파일/미완료 업로드 정리). 미존재여도 에러 없이 통과.
abstract deleteObject(key: string): Promise<void>;
}
@@ -0,0 +1,42 @@
// 스토리지 추상화 공용 타입 — 구현체(NCP Object Storage 등)와 호출부가 공유한다.
// Presigned 업로드 발급 입력
export interface PresignUploadInput {
// 버킷 내 객체 키 (예: development/drive/{userId}/{uuid}-name.pdf)
key: string;
// 업로드 시 강제할 MIME 타입 — 서명에 포함되어 클라이언트가 다른 타입으로 못 올림
contentType: string;
// URL 유효 시간(초). 미지정 시 구현체 기본값
expiresInSeconds?: number;
}
// Presigned 업로드 발급 결과
export interface PresignedUpload {
// 클라이언트가 직접 PUT 할 URL
url: string;
// HTTP 메서드 (PUT 고정)
method: 'PUT';
// 업로드 요청 시 반드시 포함해야 하는 헤더(서명 대상) — 예: { 'Content-Type': '...' }
headers: Record<string, string>;
// 만료 시각(클라이언트 표시/검증용)
expiresAt: Date;
}
// Presigned 다운로드 발급 입력
export interface PresignDownloadInput {
key: string;
// 다운로드 시 표시할 원본 파일명(Content-Disposition 에 반영)
filename: string;
// 응답 Content-Type
contentType: string;
// URL 유효 시간(초). 미지정 시 구현체 기본값
expiresInSeconds?: number;
}
// HEAD 조회 결과 — 업로드 완료 검증(실제 size/type 재확인)에 사용
export interface ObjectHead {
// 실제 저장된 바이트 크기
size: number;
// 실제 저장된 MIME 타입(없을 수 있음)
contentType: string | null;
}