공개 제보 — 파일 스토리지에서 S3 를 선택해 저장해도 실제 파일 저장이 동작하지 않던 결함의 전면 수정. - S3 어댑터(league/flysystem-aws-s3-v3)·predis 를 코어 기본 의존성으로 포함 — 어댑터 부재 즉사, phpredis 확장 없는 서버의 redis 선택 전면 다운 차단 (부트 시 확장 부재 감지 → predis 자동 폴백) - storage_driver=s3 저장 시 코어 첨부 업로드 디스크를 s3 로 전환 (ATTACHMENT_DISK env 명시가 항상 우선, 기존 행은 저장 당시 disk 로 서빙) - 첨부·템플릿 레이아웃 첨부 서빙을 행 disk 를 따르는 스토리지 스트림으로 교체 — 로컬 절대 경로 전제 fileResponse 는 S3 행에서 filemtime stat 500 (streamedFileResponse: 행 메타 기반 ETag/304/Cache-Control) - S3 호환 스토리지(R2/MinIO/NCP) 연결 지원: 엔드포인트 URL·path-style 설정 신설, 리전 목록 선택 → 자유 입력 전환, 연결 테스트를 실제 저장 경로와 동일 설정(endpoint/path-style)으로 정렬 - 사용 불능 드라이버(어댑터·PHP 확장 부재)의 저장/테스트 요청을 사유와 함께 422 로 차단하는 서버 게이트 신설 (DriverRegistryService 능력 판정) - 웹소켓 연결 테스트에 서버(백엔드 발송용) endpoint 검사 추가 — 클라이언트만 검사해 테스트 성공 + 실제 발송 실패가 가능하던 비대칭 해소 - env 빈 값(`KEY=`) 함정 정규화: AWS_URL/AWS_ENDPOINT/ATTACHMENT_DISK 빈 문자열을 미설정으로 취급 (config 정규화 + 예시 파일 주석 처리) - 플러그인 드라이버 폴백의 log 카테고리 죽은 키(logging.default) 정정 및 websocket 유령 설정 키 제거 - 실 AWS S3 종단 검증 완료 (설정 저장 → 업로드 S3 실저장 → 서빙 200/304)
1540 lines
40 KiB
Markdown
1540 lines
40 KiB
Markdown
# 스토리지 드라이버 시스템 (StorageInterface)
|
|
|
|
> **모듈/플러그인에서 파일을 저장하고 관리하기 위한 표준화된 인터페이스**
|
|
|
|
## TL;DR (5초 요약)
|
|
|
|
```text
|
|
1. 모든 파일 저장은 StorageInterface 사용 (Storage::disk() 직접 호출 금지)
|
|
2. BaseModuleServiceProvider에서 storageServices 배열에 Service 클래스 등록
|
|
3. Category로 파일 분류 (attachments, images, settings, cache, temp)
|
|
4. 경로: modules/{identifier}/{category}/{path} (기본 disk: 'modules')
|
|
5. Service 생성자에서 StorageInterface 타입힌트하면 자동 주입
|
|
6. response() 메서드로 StreamedResponse 생성 (파일 다운로드/표시)
|
|
```
|
|
|
|
---
|
|
|
|
## 📋 목차
|
|
|
|
- [개요](#개요)
|
|
- [주요 개념](#주요-개념)
|
|
- [모듈에서 사용하기](#모듈에서-사용하기)
|
|
- [카테고리 네이밍 규칙](#카테고리-네이밍-규칙)
|
|
- [예제 코드](#예제-코드)
|
|
- [마이그레이션 가이드](#마이그레이션-가이드)
|
|
- [API 레퍼런스](#api-레퍼런스)
|
|
- [트러블슈팅](#트러블슈팅)
|
|
- [S3 호환 스토리지 연결](#s3-호환-스토리지-연결)
|
|
- [FAQ](#faq)
|
|
|
|
---
|
|
|
|
## 개요
|
|
|
|
### 목적
|
|
|
|
스토리지 드라이버 시스템은 모듈/플러그인에서 파일을 저장하고 관리하기 위한 **표준화된 인터페이스**를 제공합니다.
|
|
|
|
### 해결하는 문제
|
|
|
|
**Before (❌ 문제점)**:
|
|
```php
|
|
// 각 모듈마다 다른 disk 사용
|
|
$disk = config('sirsoft-board.attachment.disk', 'local');
|
|
|
|
// 불일치한 경로 패턴
|
|
$path = "attachments/modules/sirsoft/board/{$slug}/{$date}/{$filename}";
|
|
$path = "attachments/modules/sirsoft/ecommerce/category-images/{$date}/{$filename}";
|
|
|
|
// Storage Facade 직접 호출
|
|
Storage::disk($disk)->put($path, $contents);
|
|
```
|
|
|
|
**After (✅ 해결)**:
|
|
```php
|
|
// 표준화된 인터페이스
|
|
private StorageInterface $storage;
|
|
|
|
// 일관된 경로 패턴
|
|
// modules/{identifier}/{category}/{path}
|
|
$this->storage->put('attachments', "{$slug}/{$date}/{$filename}", $contents);
|
|
```
|
|
|
|
### 핵심 장점
|
|
|
|
| 장점 | 설명 |
|
|
|------|------|
|
|
| **일관성** | 모든 모듈/플러그인이 동일한 API 사용 |
|
|
| **격리성** | 모듈별 디렉토리 자동 분리 (`modules/{identifier}/`) |
|
|
| **확장성** | S3, CDN 등 다른 백엔드로 전환 용이 |
|
|
| **테스트 용이** | Mock 인터페이스로 단위 테스트 작성 가능 |
|
|
| **표준 경로** | 예측 가능한 파일 경로 구조 |
|
|
|
|
---
|
|
|
|
## 주요 개념
|
|
|
|
### 1. StorageInterface
|
|
|
|
모든 파일 작업의 표준 인터페이스입니다.
|
|
|
|
```php
|
|
interface StorageInterface
|
|
{
|
|
public function put(string $category, string $path, mixed $content): bool;
|
|
public function get(string $category, string $path): ?string;
|
|
public function exists(string $category, string $path): bool;
|
|
public function delete(string $category, string $path): bool;
|
|
public function url(string $category, string $path): ?string;
|
|
public function files(string $category, string $directory = ''): array;
|
|
public function deleteDirectory(string $category, string $directory = ''): bool;
|
|
public function getBasePath(string $category): string;
|
|
public function getDisk(): string;
|
|
public function deleteAll(string $category): bool;
|
|
public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse;
|
|
}
|
|
```
|
|
|
|
### 2. 카테고리 시스템
|
|
|
|
파일을 **용도별로 분류**하여 관리합니다.
|
|
|
|
```
|
|
modules/{identifier}/
|
|
├── attachments/ # 첨부파일 (게시글, 댓글 등)
|
|
├── images/ # 이미지 파일 (상품, 카테고리 등)
|
|
├── settings/ # 환경설정 파일 (JSON, INI 등)
|
|
├── cache/ # 캐시 데이터
|
|
└── temp/ # 임시 파일
|
|
```
|
|
|
|
### 3. 경로 패턴
|
|
|
|
**표준 경로 구조**:
|
|
```
|
|
storage/app/modules/{identifier}/{category}/{path}
|
|
```
|
|
|
|
**예시**:
|
|
```
|
|
storage/app/modules/sirsoft-board/attachments/notice/2024/01/19/uuid.pdf
|
|
storage/app/modules/sirsoft-ecommerce/images/category/2024/01/19/uuid.jpg
|
|
storage/app/modules/sirsoft-ecommerce/settings/setting.json
|
|
```
|
|
|
|
### 4. ModuleStorageDriver
|
|
|
|
`StorageInterface`의 구현체로, 모듈별 격리된 저장소를 제공합니다.
|
|
|
|
```php
|
|
class ModuleStorageDriver implements StorageInterface
|
|
{
|
|
public function __construct(
|
|
string $identifier, // 모듈 식별자
|
|
string $disk = 'modules' // 사용할 디스크 (기본: 'modules')
|
|
) {}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 모듈에서 사용하기
|
|
|
|
### STEP 1: ServiceProvider 설정
|
|
|
|
`BaseModuleServiceProvider`를 상속받고 `$storageServices` 배열에 Service 클래스를 등록합니다.
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace Modules\Sirsoft\Board\Providers;
|
|
|
|
use App\Extension\BaseModuleServiceProvider;
|
|
use Modules\Sirsoft\Board\Services\AttachmentService;
|
|
|
|
class BoardServiceProvider extends BaseModuleServiceProvider
|
|
{
|
|
/**
|
|
* 모듈 식별자
|
|
*/
|
|
protected string $moduleIdentifier = 'sirsoft-board';
|
|
|
|
/**
|
|
* StorageInterface가 필요한 서비스 목록
|
|
*
|
|
* 이 배열에 추가된 서비스는 자동으로 StorageInterface를 주입받습니다.
|
|
*/
|
|
protected array $storageServices = [
|
|
AttachmentService::class,
|
|
];
|
|
|
|
/**
|
|
* Repository 인터페이스와 구현체 매핑
|
|
*/
|
|
protected array $repositories = [
|
|
// ...
|
|
];
|
|
}
|
|
```
|
|
|
|
### STEP 2: Service에서 주입받기
|
|
|
|
Service 생성자에서 `StorageInterface`를 타입힌트하면 자동으로 주입됩니다.
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace Modules\Sirsoft\Board\Services;
|
|
|
|
use App\Contracts\Extension\StorageInterface;
|
|
use Illuminate\Http\UploadedFile;
|
|
|
|
class AttachmentService
|
|
{
|
|
/**
|
|
* AttachmentService 생성자
|
|
*/
|
|
public function __construct(
|
|
private AttachmentRepositoryInterface $repository,
|
|
private StorageInterface $storage // 자동 주입
|
|
) {}
|
|
|
|
/**
|
|
* 파일 업로드
|
|
*/
|
|
public function upload(string $slug, UploadedFile $file, ?int $postId = null): DynamicAttachment
|
|
{
|
|
// 경로 생성
|
|
$storedFilename = Str::uuid() . '.' . $file->getClientOriginalExtension();
|
|
$datePath = date('Y/m/d');
|
|
$path = "{$slug}/{$datePath}/{$storedFilename}";
|
|
|
|
// 스토리지에 저장 (category: 'attachments')
|
|
$this->storage->put('attachments', $path, file_get_contents($file->getRealPath()));
|
|
|
|
// Disk 정보 가져오기
|
|
$disk = $this->storage->getDisk();
|
|
|
|
// DB에 레코드 생성
|
|
return $this->repository->create($slug, [
|
|
'post_id' => $postId,
|
|
'path' => $path,
|
|
'original_filename' => $file->getClientOriginalName(),
|
|
'disk' => $disk,
|
|
'mime_type' => $file->getMimeType(),
|
|
'size' => $file->getSize(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 파일 삭제
|
|
*/
|
|
public function delete(string $slug, int $id): bool
|
|
{
|
|
$attachment = $this->repository->findById($slug, $id);
|
|
|
|
// 스토리지에서 파일 삭제
|
|
if ($this->storage->exists('attachments', $attachment->path)) {
|
|
$this->storage->delete('attachments', $attachment->path);
|
|
}
|
|
|
|
// DB에서 삭제
|
|
return $this->repository->delete($slug, $id);
|
|
}
|
|
}
|
|
```
|
|
|
|
### STEP 3: Disk 설정 (선택)
|
|
|
|
기본값은 `modules` disk를 사용하지만, 모듈 설정 파일에서 변경 가능합니다.
|
|
|
|
**모듈 설정 파일** (`config/sirsoft-board.php`):
|
|
```php
|
|
return [
|
|
'attachment' => [
|
|
'disk' => env('SIRSOFT_BOARD_ATTACHMENT_DISK', 'modules'),
|
|
],
|
|
];
|
|
```
|
|
|
|
**AbstractModule에서 오버라이드**:
|
|
```php
|
|
<?php
|
|
|
|
namespace Modules\Sirsoft\Board;
|
|
|
|
use App\Extension\AbstractModule;
|
|
|
|
class BoardModule extends AbstractModule
|
|
{
|
|
/**
|
|
* 스토리지 디스크를 반환합니다.
|
|
*/
|
|
public function getStorageDisk(): string
|
|
{
|
|
return config('sirsoft-board.attachment.disk', 'modules');
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 카테고리 네이밍 규칙
|
|
|
|
### 표준 카테고리
|
|
|
|
| 카테고리 | 용도 | 예시 |
|
|
|----------|------|------|
|
|
| `attachments` | 첨부파일 (게시글, 댓글 등) | PDF, DOCX, ZIP 등 |
|
|
| `images` | 이미지 파일 | 상품 이미지, 카테고리 이미지 |
|
|
| `settings` | 환경설정 파일 | JSON, INI 설정 |
|
|
| `cache` | 캐시 데이터 | 임시 계산 결과 |
|
|
| `temp` | 임시 파일 | 업로드 중인 파일 |
|
|
|
|
### 커스텀 카테고리
|
|
|
|
필요시 새로운 카테고리를 추가할 수 있습니다.
|
|
|
|
**예시**:
|
|
```php
|
|
// 로그 파일 저장
|
|
$this->storage->put('logs', 'error.log', $logContent);
|
|
|
|
// 백업 파일 저장
|
|
$this->storage->put('backups', 'backup-2024-01-19.sql', $backupData);
|
|
```
|
|
|
|
**네이밍 규칙**:
|
|
- **소문자**: 모두 소문자 사용
|
|
- **복수형**: 복수형 명사 사용 (logs, backups)
|
|
- **단어 구분**: 하이픈 사용 (user-uploads, product-images)
|
|
|
|
---
|
|
|
|
## 예제 코드
|
|
|
|
### 예제 1: 이미지 업로드 및 썸네일 생성
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace Modules\Sirsoft\Ecommerce\Services;
|
|
|
|
use App\Contracts\Extension\StorageInterface;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Intervention\Image\Facades\Image;
|
|
|
|
class CategoryImageService
|
|
{
|
|
public function __construct(
|
|
private CategoryImageRepositoryInterface $repository,
|
|
private StorageInterface $storage
|
|
) {}
|
|
|
|
/**
|
|
* 이미지 업로드 및 썸네일 생성
|
|
*/
|
|
public function upload(UploadedFile $file, int $categoryId): CategoryImage
|
|
{
|
|
// 원본 이미지 저장
|
|
$storedFilename = Str::uuid() . '.' . $file->getClientOriginalExtension();
|
|
$datePath = date('Y/m/d');
|
|
$path = "category/{$datePath}/{$storedFilename}";
|
|
|
|
$this->storage->put('images', $path, file_get_contents($file->getRealPath()));
|
|
|
|
// 썸네일 생성
|
|
$thumbnail = Image::make($file)->fit(300, 300)->encode('jpg', 80);
|
|
$thumbnailPath = "category/{$datePath}/thumb_{$storedFilename}";
|
|
$this->storage->put('images', $thumbnailPath, (string) $thumbnail);
|
|
|
|
// DB 레코드 생성
|
|
$disk = $this->storage->getDisk();
|
|
|
|
return $this->repository->create([
|
|
'category_id' => $categoryId,
|
|
'path' => $path,
|
|
'thumbnail_path' => $thumbnailPath,
|
|
'disk' => $disk,
|
|
'original_filename' => $file->getClientOriginalName(),
|
|
'width' => getimagesize($file->getRealPath())[0],
|
|
'height' => getimagesize($file->getRealPath())[1],
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 예제 2: 환경설정 파일 저장
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Contracts\Extension\StorageInterface;
|
|
|
|
class ModuleSettingsService
|
|
{
|
|
private const SETTINGS_FILENAME = 'setting.json';
|
|
|
|
/**
|
|
* 환경설정 저장
|
|
*/
|
|
public function save(string $identifier, array $settings): bool
|
|
{
|
|
// 모듈 인스턴스 가져오기
|
|
$module = $this->moduleManager->getModule($identifier);
|
|
if (!$module) {
|
|
return false;
|
|
}
|
|
|
|
$storage = $module->getStorage();
|
|
|
|
// JSON 인코딩
|
|
$content = json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
|
|
// 저장
|
|
return $storage->put('settings', self::SETTINGS_FILENAME, $content);
|
|
}
|
|
|
|
/**
|
|
* 환경설정 로드
|
|
*/
|
|
public function load(string $identifier): array
|
|
{
|
|
$module = $this->moduleManager->getModule($identifier);
|
|
if (!$module) {
|
|
return [];
|
|
}
|
|
|
|
$storage = $module->getStorage();
|
|
|
|
// 파일 존재 여부 확인
|
|
if (!$storage->exists('settings', self::SETTINGS_FILENAME)) {
|
|
return [];
|
|
}
|
|
|
|
// 파일 읽기
|
|
$content = $storage->get('settings', self::SETTINGS_FILENAME);
|
|
|
|
return json_decode($content, true) ?? [];
|
|
}
|
|
}
|
|
```
|
|
|
|
### 예제 3: 임시 파일 관리
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace Modules\Sirsoft\Board\Services;
|
|
|
|
use App\Contracts\Extension\StorageInterface;
|
|
|
|
class TempFileService
|
|
{
|
|
public function __construct(
|
|
private StorageInterface $storage
|
|
) {}
|
|
|
|
/**
|
|
* 임시 파일 저장
|
|
*/
|
|
public function storeTempFile(string $sessionId, UploadedFile $file): string
|
|
{
|
|
$filename = Str::uuid() . '.' . $file->getClientOriginalExtension();
|
|
$path = "{$sessionId}/{$filename}";
|
|
|
|
$this->storage->put('temp', $path, file_get_contents($file->getRealPath()));
|
|
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* 세션의 모든 임시 파일 삭제
|
|
*/
|
|
public function cleanupSession(string $sessionId): bool
|
|
{
|
|
return $this->storage->deleteDirectory('temp', $sessionId);
|
|
}
|
|
|
|
/**
|
|
* 24시간 이상 된 임시 파일 정리
|
|
*/
|
|
public function cleanupOldFiles(): int
|
|
{
|
|
$files = $this->storage->files('temp', '');
|
|
$deletedCount = 0;
|
|
$cutoffTime = now()->subDay()->timestamp;
|
|
|
|
foreach ($files as $file) {
|
|
$fullPath = $this->storage->getBasePath('temp') . '/' . $file;
|
|
if (filemtime($fullPath) < $cutoffTime) {
|
|
$this->storage->delete('temp', $file);
|
|
$deletedCount++;
|
|
}
|
|
}
|
|
|
|
return $deletedCount;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 예제 4: 파일 다운로드 컨트롤러
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace Modules\Sirsoft\Board\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\BaseApiController;
|
|
use App\Contracts\Extension\StorageInterface;
|
|
use Illuminate\Http\Response;
|
|
|
|
class AttachmentController extends BaseApiController
|
|
{
|
|
public function __construct(
|
|
private AttachmentService $attachmentService,
|
|
private StorageInterface $storage
|
|
) {}
|
|
|
|
/**
|
|
* 첨부파일 다운로드
|
|
*/
|
|
public function download(string $slug, int $id): Response
|
|
{
|
|
$attachment = $this->attachmentService->getById($slug, $id);
|
|
|
|
// 권한 확인 로직...
|
|
|
|
// 파일 존재 여부 확인
|
|
if (!$this->storage->exists('attachments', $attachment->path)) {
|
|
return response()->json(['message' => '파일을 찾을 수 없습니다.'], 404);
|
|
}
|
|
|
|
// 파일 내용 가져오기
|
|
$content = $this->storage->get('attachments', $attachment->path);
|
|
|
|
// 다운로드 응답
|
|
return response($content, 200)
|
|
->header('Content-Type', $attachment->mime_type)
|
|
->header('Content-Disposition', 'attachment; filename="' . $attachment->original_filename . '"');
|
|
}
|
|
}
|
|
```
|
|
|
|
### 예제 5: StreamedResponse를 사용한 이미지 다운로드
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace Modules\Sirsoft\Ecommerce\Services;
|
|
|
|
use App\Contracts\Extension\StorageInterface;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Modules\Sirsoft\Ecommerce\Models\ProductImage;
|
|
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductImageRepositoryInterface;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
/**
|
|
* 상품 이미지 서비스
|
|
*/
|
|
class ProductImageService
|
|
{
|
|
public function __construct(
|
|
protected ProductImageRepositoryInterface $repository,
|
|
protected StorageInterface $storage
|
|
) {
|
|
// StorageInterface는 EcommerceServiceProvider에서 자동 주입됨
|
|
}
|
|
|
|
/**
|
|
* 해시로 이미지 조회
|
|
*/
|
|
public function findByHash(string $hash): ?ProductImage
|
|
{
|
|
return $this->repository->findByHash($hash);
|
|
}
|
|
|
|
/**
|
|
* 이미지 다운로드 응답 생성
|
|
*
|
|
* @param string $hash 이미지 해시 (12자)
|
|
* @return StreamedResponse|null 이미지 스트림 또는 없을 경우 null
|
|
*/
|
|
public function download(string $hash): ?StreamedResponse
|
|
{
|
|
$image = $this->repository->findByHash($hash);
|
|
|
|
if (! $image) {
|
|
return null;
|
|
}
|
|
|
|
// ModuleStorageDriver가 자동으로 경로를 해결함
|
|
// DB path: products/{id}/{filename}.jpg
|
|
// 실제 경로: modules/sirsoft-ecommerce/images/products/{id}/{filename}.jpg
|
|
$response = $this->storage->response(
|
|
'images',
|
|
$image->path,
|
|
$image->original_filename,
|
|
[
|
|
'Content-Type' => $image->mime_type,
|
|
'Cache-Control' => 'public, max-age=31536000',
|
|
]
|
|
);
|
|
|
|
if (! $response) {
|
|
Log::error('상품 이미지 스토리지에 없음', [
|
|
'product_image_id' => $image->id,
|
|
'path' => $image->path,
|
|
'disk' => $this->storage->getDisk(),
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 마이그레이션 가이드
|
|
|
|
### 기존 코드 전환하기
|
|
|
|
#### Before (기존 패턴)
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace Modules\Sirsoft\Board\Services;
|
|
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
class AttachmentService
|
|
{
|
|
public function upload(string $slug, UploadedFile $file, ?int $postId = null): DynamicAttachment
|
|
{
|
|
// Config에서 disk 가져오기
|
|
$disk = config('sirsoft-board.attachment.disk', 'local');
|
|
|
|
// 경로 생성
|
|
$storedFilename = Str::uuid() . '.' . $file->getClientOriginalExtension();
|
|
$datePath = date('Y/m/d');
|
|
$path = "attachments/modules/sirsoft/board/{$slug}/{$datePath}/{$storedFilename}";
|
|
|
|
// Storage Facade 직접 호출
|
|
Storage::disk($disk)->put($path, file_get_contents($file->getRealPath()));
|
|
|
|
// DB 저장
|
|
return $this->repository->create($slug, [
|
|
'path' => $path,
|
|
'disk' => $disk,
|
|
// ...
|
|
]);
|
|
}
|
|
|
|
public function delete(string $slug, int $id): bool
|
|
{
|
|
$attachment = $this->repository->findById($slug, $id);
|
|
|
|
// Storage Facade 직접 호출
|
|
if (Storage::disk($attachment->disk)->exists($attachment->path)) {
|
|
Storage::disk($attachment->disk)->delete($attachment->path);
|
|
}
|
|
|
|
return $this->repository->delete($slug, $id);
|
|
}
|
|
}
|
|
```
|
|
|
|
#### After (StorageInterface 패턴)
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace Modules\Sirsoft\Board\Services;
|
|
|
|
use App\Contracts\Extension\StorageInterface;
|
|
|
|
class AttachmentService
|
|
{
|
|
/**
|
|
* StorageInterface 주입
|
|
*/
|
|
public function __construct(
|
|
private AttachmentRepositoryInterface $repository,
|
|
private StorageInterface $storage // 추가
|
|
) {}
|
|
|
|
public function upload(string $slug, UploadedFile $file, ?int $postId = null): DynamicAttachment
|
|
{
|
|
// 경로 생성 (모듈 prefix 제거)
|
|
$storedFilename = Str::uuid() . '.' . $file->getClientOriginalExtension();
|
|
$datePath = date('Y/m/d');
|
|
$path = "{$slug}/{$datePath}/{$storedFilename}"; // 변경됨
|
|
|
|
// StorageInterface 사용 (category 추가)
|
|
$this->storage->put('attachments', $path, file_get_contents($file->getRealPath()));
|
|
|
|
// Disk 정보 가져오기
|
|
$disk = $this->storage->getDisk();
|
|
|
|
// DB 저장
|
|
return $this->repository->create($slug, [
|
|
'path' => $path,
|
|
'disk' => $disk,
|
|
// ...
|
|
]);
|
|
}
|
|
|
|
public function delete(string $slug, int $id): bool
|
|
{
|
|
$attachment = $this->repository->findById($slug, $id);
|
|
|
|
// StorageInterface 사용
|
|
if ($this->storage->exists('attachments', $attachment->path)) {
|
|
$this->storage->delete('attachments', $attachment->path);
|
|
}
|
|
|
|
return $this->repository->delete($slug, $id);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 전환 체크리스트
|
|
|
|
```
|
|
□ 1. ServiceProvider를 BaseModuleServiceProvider 상속으로 변경
|
|
□ 2. storageServices 배열에 Service 클래스 추가
|
|
□ 3. Service 생성자에 StorageInterface 파라미터 추가
|
|
□ 4. Storage::disk() 호출을 $this->storage-> 호출로 변경
|
|
□ 5. 경로에서 모듈 prefix 제거 (자동으로 추가됨)
|
|
□ 6. Category 파라미터 추가 (attachments, images 등)
|
|
□ 7. config()로 disk 가져오는 코드를 $this->storage->getDisk()으로 변경
|
|
□ 8. 단위 테스트 작성/수정 (StorageInterface Mock 사용)
|
|
□ 9. 테스트 실행 및 검증
|
|
```
|
|
|
|
### 주의 사항
|
|
|
|
**경로 패턴 변경**:
|
|
```php
|
|
// ❌ Before
|
|
"attachments/modules/sirsoft/board/{$slug}/{$date}/{$filename}"
|
|
|
|
// ✅ After
|
|
"{$slug}/{$date}/{$filename}"
|
|
// → 실제 저장 경로: modules/sirsoft-board/attachments/{$slug}/{$date}/{$filename}
|
|
```
|
|
|
|
**기존 파일 마이그레이션**:
|
|
|
|
기존 파일이 있는 경우 마이그레이션 스크립트를 작성해야 합니다.
|
|
|
|
```php
|
|
<?php
|
|
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
// 기존 경로: attachments/modules/sirsoft/board/notice/2024/01/19/file.pdf
|
|
// 새 경로: modules/sirsoft-board/attachments/notice/2024/01/19/file.pdf
|
|
|
|
$oldPath = "attachments/modules/sirsoft/board/notice/2024/01/19/file.pdf";
|
|
$newPath = "modules/sirsoft-board/attachments/notice/2024/01/19/file.pdf";
|
|
|
|
if (Storage::disk('local')->exists($oldPath)) {
|
|
Storage::disk('local')->move($oldPath, $newPath);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## API 레퍼런스
|
|
|
|
### put()
|
|
|
|
파일을 저장합니다.
|
|
|
|
**시그니처**:
|
|
```php
|
|
public function put(string $category, string $path, mixed $content): bool
|
|
```
|
|
|
|
**파라미터**:
|
|
- `$category` (string): 카테고리 (attachments, images, settings 등)
|
|
- `$path` (string): 카테고리 하위 상대 경로
|
|
- `$content` (string|resource): 파일 내용
|
|
|
|
**반환값**:
|
|
- `bool`: 저장 성공 여부
|
|
|
|
**예시**:
|
|
```php
|
|
$this->storage->put('images', 'product/2024/01/19/uuid.jpg', $imageData);
|
|
```
|
|
|
|
---
|
|
|
|
### get()
|
|
|
|
파일 내용을 가져옵니다.
|
|
|
|
**시그니처**:
|
|
```php
|
|
public function get(string $category, string $path): ?string
|
|
```
|
|
|
|
**파라미터**:
|
|
- `$category` (string): 카테고리
|
|
- `$path` (string): 카테고리 하위 상대 경로
|
|
|
|
**반환값**:
|
|
- `string|null`: 파일 내용 (파일이 없으면 null)
|
|
|
|
**예시**:
|
|
```php
|
|
$content = $this->storage->get('settings', 'setting.json');
|
|
if ($content) {
|
|
$settings = json_decode($content, true);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### exists()
|
|
|
|
파일이 존재하는지 확인합니다.
|
|
|
|
**시그니처**:
|
|
```php
|
|
public function exists(string $category, string $path): bool
|
|
```
|
|
|
|
**파라미터**:
|
|
- `$category` (string): 카테고리
|
|
- `$path` (string): 카테고리 하위 상대 경로
|
|
|
|
**반환값**:
|
|
- `bool`: 파일 존재 여부
|
|
|
|
**예시**:
|
|
```php
|
|
if ($this->storage->exists('images', 'product/2024/01/19/uuid.jpg')) {
|
|
// 파일이 존재함
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### delete()
|
|
|
|
파일을 삭제합니다.
|
|
|
|
**시그니처**:
|
|
```php
|
|
public function delete(string $category, string $path): bool
|
|
```
|
|
|
|
**파라미터**:
|
|
- `$category` (string): 카테고리
|
|
- `$path` (string): 카테고리 하위 상대 경로
|
|
|
|
**반환값**:
|
|
- `bool`: 삭제 성공 여부
|
|
|
|
**예시**:
|
|
```php
|
|
$this->storage->delete('temp', 'session-123/upload.tmp');
|
|
```
|
|
|
|
---
|
|
|
|
### url()
|
|
|
|
파일의 공개 URL을 반환합니다.
|
|
|
|
**시그니처**:
|
|
```php
|
|
public function url(string $category, string $path): ?string
|
|
```
|
|
|
|
**파라미터**:
|
|
- `$category` (string): 카테고리
|
|
- `$path` (string): 카테고리 하위 상대 경로
|
|
|
|
**반환값**:
|
|
- `string|null`: 파일 URL (private disk인 경우 null)
|
|
|
|
**예시**:
|
|
```php
|
|
// public disk인 경우
|
|
$url = $this->storage->url('images', 'product/2024/01/19/uuid.jpg');
|
|
// → http://example.com/storage/modules/sirsoft-ecommerce/images/product/2024/01/19/uuid.jpg
|
|
|
|
// local (private) disk인 경우
|
|
$url = $this->storage->url('attachments', 'notice/2024/01/19/file.pdf');
|
|
// → null (별도 API 엔드포인트 사용해야 함)
|
|
```
|
|
|
|
---
|
|
|
|
### files()
|
|
|
|
디렉토리 내 모든 파일 목록을 반환합니다.
|
|
|
|
**시그니처**:
|
|
```php
|
|
public function files(string $category, string $directory = ''): array
|
|
```
|
|
|
|
**파라미터**:
|
|
- `$category` (string): 카테고리
|
|
- `$directory` (string): 디렉토리 경로 (빈 문자열이면 카테고리 루트)
|
|
|
|
**반환값**:
|
|
- `array`: 파일 경로 배열
|
|
|
|
**예시**:
|
|
```php
|
|
$files = $this->storage->files('temp', 'session-123');
|
|
// → ['session-123/upload1.tmp', 'session-123/upload2.tmp']
|
|
```
|
|
|
|
---
|
|
|
|
### deleteDirectory()
|
|
|
|
디렉토리와 그 하위의 모든 파일을 삭제합니다.
|
|
|
|
**시그니처**:
|
|
```php
|
|
public function deleteDirectory(string $category, string $directory = ''): bool
|
|
```
|
|
|
|
**파라미터**:
|
|
- `$category` (string): 카테고리
|
|
- `$directory` (string): 디렉토리 경로 (빈 문자열이면 카테고리 루트)
|
|
|
|
**반환값**:
|
|
- `bool`: 삭제 성공 여부
|
|
|
|
**예시**:
|
|
```php
|
|
// 특정 디렉토리 삭제
|
|
$this->storage->deleteDirectory('temp', 'session-123');
|
|
|
|
// 카테고리 전체 삭제
|
|
$this->storage->deleteDirectory('temp', '');
|
|
```
|
|
|
|
---
|
|
|
|
### getBasePath()
|
|
|
|
카테고리의 전체 파일 시스템 경로를 반환합니다.
|
|
|
|
**시그니처**:
|
|
```php
|
|
public function getBasePath(string $category): string
|
|
```
|
|
|
|
**파라미터**:
|
|
- `$category` (string): 카테고리
|
|
|
|
**반환값**:
|
|
- `string`: 전체 경로
|
|
|
|
**예시**:
|
|
```php
|
|
$basePath = $this->storage->getBasePath('images');
|
|
// → /path/to/g7/storage/app/modules/sirsoft-ecommerce/images
|
|
```
|
|
|
|
---
|
|
|
|
### getDisk()
|
|
|
|
사용 중인 디스크 이름을 반환합니다.
|
|
|
|
**시그니처**:
|
|
```php
|
|
public function getDisk(): string
|
|
```
|
|
|
|
**반환값**:
|
|
- `string`: 디스크 이름 (local, public, s3 등)
|
|
|
|
**예시**:
|
|
```php
|
|
$disk = $this->storage->getDisk();
|
|
// → 'local'
|
|
```
|
|
|
|
---
|
|
|
|
### deleteAll()
|
|
|
|
카테고리의 모든 파일을 삭제합니다.
|
|
|
|
**시그니처**:
|
|
```php
|
|
public function deleteAll(string $category): bool
|
|
```
|
|
|
|
**파라미터**:
|
|
- `$category` (string): 카테고리
|
|
|
|
**반환값**:
|
|
- `bool`: 삭제 성공 여부
|
|
|
|
**예시**:
|
|
```php
|
|
$this->storage->deleteAll('temp');
|
|
```
|
|
|
|
---
|
|
|
|
### response()
|
|
|
|
파일을 스트리밍 응답으로 반환합니다.
|
|
|
|
**시그니처**:
|
|
```php
|
|
public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
|
|
```
|
|
|
|
**파라미터**:
|
|
|
|
- `$category` (string): 카테고리
|
|
- `$path` (string): 카테고리 하위 상대 경로
|
|
- `$filename` (string): 다운로드 시 표시될 파일명
|
|
- `$headers` (array): 추가 HTTP 헤더 (Content-Type, Cache-Control 등)
|
|
|
|
**반환값**:
|
|
|
|
- `StreamedResponse|null`: 파일 스트림 (파일이 없으면 null)
|
|
|
|
**예시**:
|
|
```php
|
|
// 이미지 다운로드
|
|
$response = $this->storage->response(
|
|
'images',
|
|
'products/123/image.jpg',
|
|
'product-image.jpg',
|
|
[
|
|
'Content-Type' => 'image/jpeg',
|
|
'Cache-Control' => 'public, max-age=31536000',
|
|
]
|
|
);
|
|
|
|
if ($response) {
|
|
return $response; // StreamedResponse 반환
|
|
}
|
|
|
|
// 첨부파일 다운로드
|
|
$response = $this->storage->response(
|
|
'attachments',
|
|
'notice/2024/01/19/document.pdf',
|
|
'important-document.pdf',
|
|
[
|
|
'Content-Type' => 'application/pdf',
|
|
'Content-Disposition' => 'attachment',
|
|
]
|
|
);
|
|
```
|
|
|
|
**장점**:
|
|
|
|
- 메모리 효율적 (대용량 파일도 스트리밍)
|
|
- Content-Type, Cache-Control 등 헤더 자동 설정 가능
|
|
- Laravel의 `Storage::response()` 메서드 활용
|
|
|
|
**주의사항**:
|
|
|
|
- 파일이 존재하지 않으면 `null` 반환
|
|
- null 체크 후 404 응답 처리 필요
|
|
- 권한 체크는 컨트롤러/서비스에서 별도 구현
|
|
|
|
### 파일 서빙 안티패턴 — 전체 메모리 적재 금지
|
|
|
|
파일 본문을 응답으로 내려줄 때는 항상 `response()` / `download()` 스트리밍(내부 `readStream`)을 사용한다. 아래처럼 파일 전체를 문자열로 읽어 `echo` 하는 패턴은 대용량 파일에서 워커 메모리를 붕괴시킨다.
|
|
|
|
```php
|
|
// ❌ 금지 — streamDownload 로 감쌌지만 콜백 안에서 파일 전체를 메모리에 적재
|
|
return response()->streamDownload(function () use ($image) {
|
|
echo Storage::disk($image->disk)->get($image->path); // get() = 전체 문자열 로드
|
|
}, $image->original_filename, ['Content-Type' => $image->mime_type]);
|
|
|
|
// ✅ 올바름 — StorageInterface::response() 로 청크 스트리밍 (파일 전체 미적재)
|
|
$response = $this->storage->response('images', $image->path, $image->original_filename, [
|
|
'Content-Type' => $image->mime_type,
|
|
'Cache-Control' => 'public, max-age=31536000',
|
|
]);
|
|
|
|
return $response ?? ResponseHelper::notFound(...);
|
|
```
|
|
|
|
`StorageInterface::get()` 은 소형 메타 파일(manifest.json 등)의 전체 읽기 전용이며, 서빙 본문에는 사용하지 않는다.
|
|
|
|
---
|
|
|
|
## 트러블슈팅
|
|
|
|
### 문제 1: Service에서 StorageInterface가 주입되지 않음
|
|
|
|
**증상**:
|
|
```
|
|
Target [App\Contracts\Extension\StorageInterface] is not instantiable.
|
|
```
|
|
|
|
**원인**:
|
|
- `BaseModuleServiceProvider`의 `$storageServices` 배열에 Service 클래스가 등록되지 않았습니다.
|
|
|
|
**해결**:
|
|
```php
|
|
<?php
|
|
|
|
namespace Modules\Sirsoft\Board\Providers;
|
|
|
|
use App\Extension\BaseModuleServiceProvider;
|
|
use Modules\Sirsoft\Board\Services\AttachmentService;
|
|
|
|
class BoardServiceProvider extends BaseModuleServiceProvider
|
|
{
|
|
protected string $moduleIdentifier = 'sirsoft-board';
|
|
|
|
protected array $storageServices = [
|
|
AttachmentService::class, // 추가 필수
|
|
];
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 문제 2: 파일 경로가 잘못됨
|
|
|
|
**증상**:
|
|
- 파일이 `storage/app/modules/sirsoft-board/attachments/modules/sirsoft-board/...` 같은 중복된 경로에 저장됩니다.
|
|
|
|
**원인**:
|
|
- 경로에 모듈 prefix를 직접 포함했습니다.
|
|
|
|
**해결**:
|
|
```php
|
|
// ❌ 잘못된 코드
|
|
$path = "modules/sirsoft-board/attachments/{$slug}/{$date}/{$filename}";
|
|
$this->storage->put('attachments', $path, $content);
|
|
|
|
// ✅ 올바른 코드
|
|
$path = "{$slug}/{$date}/{$filename}"; // 모듈 prefix 제거
|
|
$this->storage->put('attachments', $path, $content);
|
|
```
|
|
|
|
---
|
|
|
|
### 문제 3: 테스트에서 Mock이 동작하지 않음
|
|
|
|
**증상**:
|
|
- 테스트 실행 시 실제 파일 시스템에 저장됩니다.
|
|
|
|
**원인**:
|
|
- StorageInterface를 Mock하지 않았습니다.
|
|
|
|
**해결**:
|
|
```php
|
|
<?php
|
|
|
|
namespace Modules\Sirsoft\Board\Tests\Unit;
|
|
|
|
use App\Contracts\Extension\StorageInterface;
|
|
use Mockery;
|
|
use Tests\TestCase;
|
|
|
|
class AttachmentServiceTest extends TestCase
|
|
{
|
|
private AttachmentService $service;
|
|
private $storage;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// StorageInterface Mock 생성
|
|
$this->storage = Mockery::mock(StorageInterface::class);
|
|
|
|
// Service 생성
|
|
$this->service = new AttachmentService(
|
|
$this->repository,
|
|
$this->storage // Mock 주입
|
|
);
|
|
}
|
|
|
|
public function test_upload(): void
|
|
{
|
|
// Mock 동작 정의
|
|
$this->storage
|
|
->shouldReceive('put')
|
|
->once()
|
|
->withArgs(function ($category, $path, $contents) {
|
|
return $category === 'attachments'
|
|
&& str_contains($path, 'notice/')
|
|
&& str_ends_with($path, '.pdf');
|
|
})
|
|
->andReturn(true);
|
|
|
|
$this->storage
|
|
->shouldReceive('getDisk')
|
|
->andReturn('local');
|
|
|
|
// 테스트 실행...
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 문제 4: disk 설정이 적용되지 않음
|
|
|
|
**증상**:
|
|
- config 파일에서 disk를 변경했지만 항상 'local'을 사용합니다.
|
|
|
|
**원인**:
|
|
- `AbstractModule`에서 `getStorageDisk()` 메서드를 오버라이드하지 않았습니다.
|
|
|
|
**해결**:
|
|
```php
|
|
<?php
|
|
|
|
namespace Modules\Sirsoft\Board;
|
|
|
|
use App\Extension\AbstractModule;
|
|
|
|
class BoardModule extends AbstractModule
|
|
{
|
|
/**
|
|
* 스토리지 디스크를 반환합니다.
|
|
*/
|
|
public function getStorageDisk(): string
|
|
{
|
|
return config('sirsoft-board.attachment.disk', 'local');
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 문제 5: URL이 null을 반환함
|
|
|
|
**증상**:
|
|
- `$this->storage->url()` 호출 시 항상 null이 반환됩니다.
|
|
|
|
**원인**:
|
|
- private disk (local, s3 등)를 사용 중입니다. URL 메서드는 public disk에서만 직접 URL을 반환합니다.
|
|
|
|
**해결**:
|
|
```php
|
|
// public disk를 사용하거나
|
|
$module->getStorageDisk(); // → 'public'
|
|
|
|
// 또는 별도 API 엔드포인트를 사용
|
|
Route::get('/api/attachments/{id}/download', [AttachmentController::class, 'download']);
|
|
```
|
|
|
|
---
|
|
|
|
## S3 호환 스토리지 연결
|
|
|
|
코어 7.0.7+ 의 S3 드라이버는 AWS 뿐 아니라 S3 API 를 제공하는 호환 스토리지(Cloudflare R2, MinIO, 네이버 클라우드 등)에도 연결할 수 있다. 관리자 > 환경설정 > 드라이버 > 파일 스토리지의 두 항목이 이를 담당한다.
|
|
|
|
| 항목 | 의미 | 예시 |
|
|
| --- | --- | --- |
|
|
| 엔드포인트 URL | SDK 가 요청을 보내는 API 주소. 비워 두면 AWS 리전 도메인을 사용 | `https://<account-id>.r2.cloudflarestorage.com`, `http://minio.internal:9000` |
|
|
| Path-style 주소 사용 | 버킷을 호스트가 아닌 경로에 두는 주소 형식(`endpoint/bucket/...`). MinIO 등 path-style 전용 스토리지에서 켠다 | — |
|
|
|
|
- 리전은 자유 입력이다 — Cloudflare R2 는 `auto`, MinIO 는 관례상 `us-east-1` 을 쓴다.
|
|
- **S3 URL(공개 URL)** 은 파일 공개 URL 생성에 쓰는 CDN/커스텀 도메인이며 API 요청 주소가 아니다 — API 주소는 엔드포인트 URL 에만 넣는다.
|
|
- IP 주소 엔드포인트는 SDK 가 path-style 을 자동 적용하므로 토글과 무관하게 동작한다. 호스트명 엔드포인트에서는 토글이 주소 형식을 결정한다.
|
|
- 연결 테스트는 실제 저장 경로와 같은 설정(엔드포인트·path-style 포함)을 사용한다.
|
|
|
|
`.env` 로 설정하는 경우 대응 키는 `AWS_ENDPOINT` / `AWS_USE_PATH_STYLE_ENDPOINT` 다 (아래 FAQ Q2 의 ② 경로 참조).
|
|
|
|
---
|
|
|
|
## FAQ
|
|
|
|
### Q1: 기존 파일은 어떻게 되나요?
|
|
|
|
**A**: 새로운 경로 패턴으로 파일을 저장하므로, 기존 파일은 **마이그레이션 스크립트**로 이동해야 합니다.
|
|
|
|
```php
|
|
// 마이그레이션 예시
|
|
$oldPath = "attachments/modules/sirsoft/board/{$slug}/{$date}/{$filename}";
|
|
$newPath = "modules/sirsoft-board/attachments/{$slug}/{$date}/{$filename}";
|
|
|
|
if (Storage::disk('local')->exists($oldPath)) {
|
|
Storage::disk('local')->move($oldPath, $newPath);
|
|
|
|
// DB 업데이트
|
|
DB::table('board_notice_attachments')->update([
|
|
'path' => "{$slug}/{$date}/{$filename}",
|
|
]);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Q2: S3로 전환하려면 어떻게 하나요?
|
|
|
|
**A**: 코어 7.0.7+ 는 S3 어댑터(`league/flysystem-aws-s3-v3`)를 기본 포함하므로 별도 패키지 설치가 필요 없습니다. 전환 경로는 두 가지입니다.
|
|
|
|
**① 관리자 설정 UI (권장)** — 관리자 > 환경설정 > 드라이버 > 파일 스토리지:
|
|
|
|
1. 스토리지 드라이버를 `Amazon S3` 로 선택
|
|
2. 버킷 / 리전 / Access Key / Secret Key 입력
|
|
3. S3 호환 스토리지(Cloudflare R2, MinIO, NCP 등)는 위 [S3 호환 스토리지 연결](#s3-호환-스토리지-연결) 절의 두 항목(엔드포인트 URL·Path-style 주소)을 함께 설정
|
|
4. 연결 테스트 성공 후 저장 — 코어 첨부 업로드 디스크가 s3 로 전환됩니다
|
|
(`ATTACHMENT_DISK` env 를 명시한 경우 env 가 항상 우선. 기존 파일은 저장 당시의 disk 로 계속 서빙되므로 혼재 안전)
|
|
|
|
참고: **S3 URL(공개 URL)** 칸은 파일 공개 URL 생성에 쓰는 CDN/커스텀 도메인이며, API 요청 주소가 아닙니다.
|
|
|
|
**② `.env` (모듈별 커스텀)**:
|
|
|
|
1. `.env` 파일에 S3 설정 추가:
|
|
```env
|
|
AWS_ACCESS_KEY_ID=your-key
|
|
AWS_SECRET_ACCESS_KEY=your-secret
|
|
AWS_DEFAULT_REGION=ap-northeast-2
|
|
AWS_BUCKET=your-bucket
|
|
# S3 호환 스토리지(R2/MinIO 등)만:
|
|
AWS_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
|
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
|
```
|
|
|
|
2. 모듈 설정 변경:
|
|
```php
|
|
// config/sirsoft-board.php
|
|
return [
|
|
'attachment' => [
|
|
'disk' => env('SIRSOFT_BOARD_ATTACHMENT_DISK', 's3'),
|
|
],
|
|
];
|
|
```
|
|
|
|
3. `AbstractModule`에서 오버라이드:
|
|
```php
|
|
public function getStorageDisk(): string
|
|
{
|
|
return config('sirsoft-board.attachment.disk', 's3');
|
|
}
|
|
```
|
|
|
|
**끝!** Service 코드는 변경할 필요가 없습니다.
|
|
|
|
---
|
|
|
|
### Q3: 여러 disk를 동시에 사용할 수 있나요?
|
|
|
|
**A**: 네, 카테고리별로 다른 disk를 사용할 수 있습니다.
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace Modules\Sirsoft\Ecommerce;
|
|
|
|
use App\Extension\AbstractModule;
|
|
use App\Contracts\Extension\StorageInterface;
|
|
use App\Extension\Storage\ModuleStorageDriver;
|
|
|
|
class EcommerceModule extends AbstractModule
|
|
{
|
|
/**
|
|
* 카테고리별 Storage 인스턴스 캐시
|
|
*/
|
|
private array $storageCache = [];
|
|
|
|
/**
|
|
* 카테고리별로 다른 Storage를 반환합니다.
|
|
*/
|
|
public function getStorage(string $category = 'default'): StorageInterface
|
|
{
|
|
if (isset($this->storageCache[$category])) {
|
|
return $this->storageCache[$category];
|
|
}
|
|
|
|
// 카테고리별 disk 설정
|
|
$disk = match ($category) {
|
|
'images' => config('sirsoft-ecommerce.image_disk', 's3'),
|
|
'attachments' => config('sirsoft-ecommerce.attachment_disk', 'local'),
|
|
default => $this->getStorageDisk(),
|
|
};
|
|
|
|
$this->storageCache[$category] = new ModuleStorageDriver($this->getIdentifier(), $disk);
|
|
|
|
return $this->storageCache[$category];
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Q4: 플러그인에서도 사용할 수 있나요?
|
|
|
|
**A**: 네, `PluginStorageDriver`를 사용하면 됩니다.
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace Plugins\Sirsoft\Payment;
|
|
|
|
use App\Extension\AbstractPlugin;
|
|
use App\Contracts\Extension\StorageInterface;
|
|
use App\Extension\Storage\PluginStorageDriver;
|
|
|
|
class PaymentPlugin extends AbstractPlugin
|
|
{
|
|
public function getStorage(): StorageInterface
|
|
{
|
|
return new PluginStorageDriver($this->getIdentifier(), $this->getStorageDisk());
|
|
}
|
|
|
|
public function getStorageDisk(): string
|
|
{
|
|
return config('sirsoft-payment.storage_disk', 'local');
|
|
}
|
|
}
|
|
```
|
|
|
|
경로 패턴만 다릅니다:
|
|
```
|
|
plugins/{identifier}/{category}/{path}
|
|
```
|
|
|
|
---
|
|
|
|
### Q5: 단위 테스트는 어떻게 작성하나요?
|
|
|
|
**A**: `StorageInterface`를 Mock하여 테스트합니다.
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace Modules\Sirsoft\Board\Tests\Unit;
|
|
|
|
use App\Contracts\Extension\StorageInterface;
|
|
use Mockery;
|
|
use Tests\TestCase;
|
|
|
|
class AttachmentServiceTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private AttachmentService $service;
|
|
private $repository;
|
|
private $storage;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// Mock 생성
|
|
$this->repository = Mockery::mock(AttachmentRepositoryInterface::class);
|
|
$this->storage = Mockery::mock(StorageInterface::class);
|
|
|
|
// Service 생성
|
|
$this->service = new AttachmentService($this->repository, $this->storage);
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
Mockery::close();
|
|
parent::tearDown();
|
|
}
|
|
|
|
public function test_upload_stores_file_and_creates_record(): void
|
|
{
|
|
// Arrange
|
|
$file = UploadedFile::fake()->create('document.pdf', 100);
|
|
|
|
// Storage Mock 설정
|
|
$this->storage
|
|
->shouldReceive('put')
|
|
->once()
|
|
->withArgs(function ($category, $path, $contents) {
|
|
return $category === 'attachments'
|
|
&& str_contains($path, 'notice/')
|
|
&& str_ends_with($path, '.pdf');
|
|
})
|
|
->andReturn(true);
|
|
|
|
$this->storage
|
|
->shouldReceive('getDisk')
|
|
->andReturn('local');
|
|
|
|
// Repository Mock 설정
|
|
$expectedAttachment = new DynamicAttachment(['id' => 1]);
|
|
$this->repository->shouldReceive('create')->andReturn($expectedAttachment);
|
|
|
|
// Act
|
|
$result = $this->service->upload('notice', $file, 1);
|
|
|
|
// Assert
|
|
$this->assertEquals(1, $result->id);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Q6: 성능 최적화 팁이 있나요?
|
|
|
|
**A**: 다음 패턴을 권장합니다.
|
|
|
|
1. **대용량 파일은 스트리밍 사용**:
|
|
```php
|
|
// ❌ 메모리에 모두 로드
|
|
$content = file_get_contents($file->getRealPath());
|
|
$this->storage->put('attachments', $path, $content);
|
|
|
|
// ✅ 스트리밍 사용
|
|
$resource = fopen($file->getRealPath(), 'r');
|
|
$this->storage->put('attachments', $path, $resource);
|
|
fclose($resource);
|
|
```
|
|
|
|
2. **배치 삭제**:
|
|
```php
|
|
// ❌ 파일 하나씩 삭제
|
|
foreach ($files as $file) {
|
|
$this->storage->delete('temp', $file);
|
|
}
|
|
|
|
// ✅ 디렉토리 전체 삭제
|
|
$this->storage->deleteDirectory('temp', $sessionId);
|
|
```
|
|
|
|
3. **URL 캐싱**:
|
|
```php
|
|
// ✅ URL을 DB에 캐시
|
|
$url = $this->storage->url('images', $image->path);
|
|
$image->update(['cached_url' => $url]);
|
|
```
|
|
|
|
---
|
|
|
|
## 관련 문서
|
|
|
|
- [모듈 개발 가이드](module-basics.md)
|
|
- [플러그인 개발 가이드](plugin-development.md)
|
|
- [Service-Repository 패턴](../backend/service-repository.md)
|
|
- [테스트 작성 가이드](../testing-guide.md)
|
|
|
|
---
|
|
|
|
**작성일**: 2024-01-19
|
|
**최종 수정**: 2024-01-19
|
|
**버전**: 1.0.0
|