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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user