편집기 헤더 「로고 이미지」에 파일을 올린 뒤 저장하면 「HTTPS 프로토콜 URL은 허용되지 않습니다」 422 로 거부되던 결함. 프록시 서빙 URL 이 절대 형태로 발급되어 NoExternalUrls 가 서버 자신의 주소를 외부로 차단했고, 배경 이미지는 style 로 들어가 스캔 밖이어서 로고(propValue)에서만 드러났다. - proxyUrl 을 사이트 상대 경로로 발급 (발급 시점 도메인·스킴에 묶이지 않는다) - SiteAssetHosts 신설: app.url host + 운영자가 선언한 공개 자산 디스크 host 를 브라우저와 같은 정규화 뒤 등가 비교. 요청 Host 헤더는 근거로 쓰지 않고, 흉내 host·protocol-relative·위험 스킴은 종전대로 차단 - 회귀 테스트 4클래스(71 passed) + 시나리오 매니페스트 + Playwright 2케이스, 공개 문서(SECURITY·security·storage-driver·api)와 규정 표 갱신 함께 담긴 릴리즈 정비: - 7.0.11 미출시 섹션의 릴리즈 날짜를 09-09 로 갱신 (코어·board·page·admin_basic·basic·ja 언어팩 3종) - 배포본에 실리는 주석·독블록·시나리오의 내부 호칭을 중립 표현으로 정리하고, 동봉 vendor 자산의 우연한 토큰은 누출 검사에서 경로 규칙으로만 제외 - 버전 bump 검사: github_url 없는 번들을 언어팩과 같은 origin/release 페이로드 기준선으로 판정(상시 경고 제거), 코어 버전 판독이 env 래핑을 읽도록 보정
55 KiB
스토리지 드라이버 시스템 (StorageInterface)
모듈/플러그인에서 파일을 저장하고 관리하기 위한 표준화된 인터페이스
TL;DR (5초 요약)
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 생성 (파일 다운로드/표시)
📋 목차
개요
목적
스토리지 드라이버 시스템은 모듈/플러그인에서 파일을 저장하고 관리하기 위한 표준화된 인터페이스를 제공합니다.
해결하는 문제
Before (❌ 문제점):
// 각 모듈마다 다른 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 (✅ 해결):
// 표준화된 인터페이스
private StorageInterface $storage;
// 일관된 경로 패턴
// modules/{identifier}/{category}/{path}
$this->storage->put('attachments', "{$slug}/{$date}/{$filename}", $contents);
핵심 장점
| 장점 | 설명 |
|---|---|
| 일관성 | 모든 모듈/플러그인이 동일한 API 사용 |
| 격리성 | 모듈별 디렉토리 자동 분리 (modules/{identifier}/) |
| 확장성 | S3, CDN 등 다른 백엔드로 전환 용이 |
| 테스트 용이 | Mock 인터페이스로 단위 테스트 작성 가능 |
| 표준 경로 | 예측 가능한 파일 경로 구조 |
주요 개념
1. StorageInterface
모든 파일 작업의 표준 인터페이스입니다.
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의 구현체로, 모듈별 격리된 저장소를 제공합니다.
class ModuleStorageDriver implements StorageInterface
{
public function __construct(
string $identifier, // 모듈 식별자
string $disk = 'modules' // 사용할 디스크 (기본: 'modules')
) {}
}
모듈에서 사용하기
STEP 1: ServiceProvider 설정
BaseModuleServiceProvider를 상속받고 $storageServices 배열에 Service 클래스를 등록합니다.
<?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
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):
return [
'attachment' => [
'disk' => env('SIRSOFT_BOARD_ATTACHMENT_DISK', 'modules'),
],
];
AbstractModule에서 오버라이드:
<?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 |
임시 파일 | 업로드 중인 파일 |
커스텀 카테고리
필요시 새로운 카테고리를 추가할 수 있습니다.
예시:
// 로그 파일 저장
$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
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
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
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
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
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
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
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. 테스트 실행 및 검증
주의 사항
경로 패턴 변경:
// ❌ Before
"attachments/modules/sirsoft/board/{$slug}/{$date}/{$filename}"
// ✅ After
"{$slug}/{$date}/{$filename}"
// → 실제 저장 경로: modules/sirsoft-board/attachments/{$slug}/{$date}/{$filename}
기존 파일 마이그레이션:
기존 파일이 있는 경우 마이그레이션 스크립트를 작성해야 합니다.
<?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()
파일을 저장합니다.
시그니처:
public function put(string $category, string $path, mixed $content): bool
파라미터:
$category(string): 카테고리 (attachments, images, settings 등)$path(string): 카테고리 하위 상대 경로$content(string|resource): 파일 내용
반환값:
bool: 저장 성공 여부
예시:
$this->storage->put('images', 'product/2024/01/19/uuid.jpg', $imageData);
get()
파일 내용을 가져옵니다.
시그니처:
public function get(string $category, string $path): ?string
파라미터:
$category(string): 카테고리$path(string): 카테고리 하위 상대 경로
반환값:
string|null: 파일 내용 (파일이 없으면 null)
예시:
$content = $this->storage->get('settings', 'setting.json');
if ($content) {
$settings = json_decode($content, true);
}
exists()
파일이 존재하는지 확인합니다.
시그니처:
public function exists(string $category, string $path): bool
파라미터:
$category(string): 카테고리$path(string): 카테고리 하위 상대 경로
반환값:
bool: 파일 존재 여부
예시:
if ($this->storage->exists('images', 'product/2024/01/19/uuid.jpg')) {
// 파일이 존재함
}
delete()
파일을 삭제합니다.
시그니처:
public function delete(string $category, string $path): bool
파라미터:
$category(string): 카테고리$path(string): 카테고리 하위 상대 경로
반환값:
bool: 삭제 성공 여부
예시:
$this->storage->delete('temp', 'session-123/upload.tmp');
url()
파일의 공개 URL을 반환합니다.
시그니처:
public function url(string $category, string $path): ?string
파라미터:
$category(string): 카테고리$path(string): 카테고리 하위 상대 경로
반환값:
string|null: 파일 URL (직접 URL 불가 디스크이고 훅 공급도 없으면 null)
직접 URL 판정: public 디스크이거나 filesystems.disks.{disk}.url 이 비어 있지 않은
문자열로 설정된 디스크(S3+CDN 등)면 직접 URL 을 생성합니다. url 설정이 없거나
빈 문자열/공백이면 null 입니다 (AWS_URL 미설정 방어).
필터 훅: 생성 결과는 디스크 종류와 무관하게 core.storage.filter_url 필터 훅을
항상 통과합니다 (null 도 발화 대상). 확장은 URL 을 공급(서명 URL 등)/수정(도메인
교체)/차단(빈 문자열 반환 → 호출측 스트리밍 폴백)할 수 있습니다. 컨텍스트 6키:
scope(core|module|plugin) / identifier(확장 식별자, 코어는 null) / disk /
category / path / full_path.
예시:
// 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
// url 설정 디스크(S3+CDN)인 경우
$url = $this->storage->url('images', 'product/2024/01/19/uuid.jpg');
// → https://cdn.example.com/modules/sirsoft-ecommerce/images/product/2024/01/19/uuid.jpg
// url 미설정 디스크(local 등)인 경우
$url = $this->storage->url('attachments', 'notice/2024/01/19/file.pdf');
// → null (별도 API 엔드포인트 사용해야 함)
files()
디렉토리 내 모든 파일 목록을 반환합니다.
시그니처:
public function files(string $category, string $directory = ''): array
파라미터:
$category(string): 카테고리$directory(string): 디렉토리 경로 (빈 문자열이면 카테고리 루트)
반환값:
array: 파일 경로 배열
예시:
$files = $this->storage->files('temp', 'session-123');
// → ['session-123/upload1.tmp', 'session-123/upload2.tmp']
deleteDirectory()
디렉토리와 그 하위의 모든 파일을 삭제합니다.
시그니처:
public function deleteDirectory(string $category, string $directory = ''): bool
파라미터:
$category(string): 카테고리$directory(string): 디렉토리 경로 (빈 문자열이면 카테고리 루트)
반환값:
bool: 삭제 성공 여부
예시:
// 특정 디렉토리 삭제
$this->storage->deleteDirectory('temp', 'session-123');
// 카테고리 전체 삭제
$this->storage->deleteDirectory('temp', '');
getBasePath()
카테고리의 전체 파일 시스템 경로를 반환합니다.
시그니처:
public function getBasePath(string $category): string
파라미터:
$category(string): 카테고리
반환값:
string: 전체 경로
예시:
$basePath = $this->storage->getBasePath('images');
// → /path/to/g7/storage/app/modules/sirsoft-ecommerce/images
제3자 라이브러리에 절대 경로를 넘길 때
제3자 라이브러리(HTML 정화기, PDF 생성기, 이미지 처리기 등)는 캐시·임시파일 경로를 설정하지 않으면 자기 설치 폴더(vendor 안)나 현재 작업 디렉토리에 쓴다. 표준 Laravel 배포는 웹서버에 storage/ 와 bootstrap/cache 만 쓰기 권한을 주므로 그 쓰기는 실패하는데, 실패가 예외가 아니라 PHP 경고라 Laravel HandleExceptions 가 ErrorException 으로 승격시켜 요청이 500 으로 끝난다. 설정 해시당 1회만 기록하는 라이브러리라면 캐시가 영영 생기지 않아 매 요청이 같은 실패를 반복한다. 개발 머신에서는 vendor 가 쓰기 가능해 한 번 성공하고 끝나므로 재현되지 않는다.
cache 카테고리의 절대 경로를 명시적으로 넘긴다.
$cacheDir = ExtensionStoragePath::module('sirsoft-ecommerce', 'cache/htmlpurifier');
if (! FilePermissionHelper::ensureWritableDirectory($cacheDir, 0775, $failure)) {
// 캐시만 끄고 정화는 그대로 수행한다 (아래 (c))
}
$config = \HTMLPurifier_Config::createDefault();
$config->set('Cache.SerializerPath', $cacheDir);
$config->set('Cache.SerializerPermissions', 0775);
세 가지 규율을 함께 지킨다.
(a) 디렉토리를 먼저 만든다. 경로 해석기는 경로를 계산할 뿐 만들지 않고, 라이브러리도 대개 지정한 base 아래의 하위 디렉토리만 만든다. base 가 없으면 경고 한 줄을 내고 끝나므로, 경로만 지정하면 실패 지점이 "vendor 쓰기 실패" 에서 "base 없음 실패" 로 옮겨갈 뿐이다.
확보는 코어 프리미티브 FilePermissionHelper::ensureWritableDirectory($path, $mode, $failure) 가 맡는다. 이 프리미티브는 예외도 PHP 경고도 내지 않고 bool 을 돌려준다 — File::ensureDirectoryExists() 는 mkdir() 을 억제 없이 부르므로 생성 실패가 E_WARNING 으로 나오고 Laravel 이 ErrorException 으로 승격시켜, 막으려던 500 이 다른 줄에서 그대로 난다. 직접 조립하지 않는다.
(b) 권한 정합화는 프리미티브가 함께 수행한다. 생성 API 의 mode 인자는 umask 로 깎이므로 명시 chmod 를 재적용하고, POSIX 에서는 setgid 도 함께 세운다 — 새 디렉토리의 그룹은 생성 프로세스의 egid 이므로, setgid 가 없으면 스케줄러(CLI)가 먼저 만든 하위 디렉토리를 웹 프로세스가 쓰지 못한다. 부모 소유권 상속까지 한 곳에 있으므로 호출부가 이 셋을 각자 복사하지 않는다. 이미 존재하는 디렉토리만 정합화하려면 FilePermissionHelper::hardenDirectory($path, $mode) 를 쓴다.
(c) 확보 실패는 기능 실패가 아니다. 프리미티브가 false 와 함께 사유(occupied_by_file / ancestor_not_writable / create_failed / not_writable)를 돌려주므로 통지에 그 사유를 싣는다 — 사유마다 운영자가 고쳐야 할 대상이 다르다. 디렉토리가 이미 있는데 쓰기 불가라면 요청 경로에서 권한과 싸우지 않는다. 캐시만 끄고(Cache.DefinitionImpl = null 같은 라이브러리별 비활성 스위치) 본래 기능은 계속 수행하며, 통지는 프로세스당 1회만 남긴다. 그 통지는 error 수준으로 남긴다 — 출하 기본 로그 수준이 error 라 warning 으로 남기면 기본 설치 상태에서 파일에 기록되지 않고, 기능은 성공하므로 운영자에게 도달하는 흔적이 그 통지 하나뿐이다. 정화·검증 자체를 건너뛰는 폴백은 금지한다 — 캐시는 성능 장치이고 정화는 보안 장치라, 캐시 실패가 보안 장치를 건너뛰게 만들어서는 안 된다.
디스크 주의: 경로를 getBasePath('cache') 로 얻지 말고 App\Support\ExtensionStoragePath::module($identifier, 'cache/…') 로 얻는다. 이 해석기는 modules/plugins 디스크의 root(config/filesystems.php)를 단일 출처로 삼아 로컬 절대 경로를 조립하며, 그 root 가 테스트 환경을 인지하므로 확장이 app()->runningUnitTests() 분기를 자기 안에 복사할 필요가 없다. getBasePath() 는 Storage::disk()->path() 위임이라 카테고리 디스크가 비로컬(S3 등)로 오버라이드되면 파일시스템 경로가 아니게 되고, 그러면 라이브러리가 그 값을 상대경로로 보고 현재 작업 디렉토리 기준으로 해석해 조용히 엉뚱한 곳에 쓴다. 대부분의 정의 캐시는 file_put_contents 로 쓰는 로컬 전용 장치다. 두 경로는 기본 설정에서 바이트 단위로 동일하므로 cache 카테고리 규약은 레이아웃 차원에서 그대로 지켜진다.
getDisk()
사용 중인 디스크 이름을 반환합니다.
시그니처:
public function getDisk(): string
반환값:
string: 디스크 이름 (local, public, s3 등)
예시:
$disk = $this->storage->getDisk();
// → 'local'
deleteAll()
카테고리의 모든 파일을 삭제합니다.
시그니처:
public function deleteAll(string $category): bool
파라미터:
$category(string): 카테고리
반환값:
bool: 삭제 성공 여부
예시:
$this->storage->deleteAll('temp');
response()
파일을 스트리밍 응답으로 반환합니다.
시그니처:
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)
예시:
// 이미지 다운로드
$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 하는 패턴은 대용량 파일에서 워커 메모리를 붕괴시킨다.
// ❌ 금지 — 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
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를 직접 포함했습니다.
해결:
// ❌ 잘못된 코드
$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
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
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이 반환됩니다.
원인:
- 사용 중인 디스크에
filesystems.disks.{disk}.url설정이 없습니다. url() 은public디스크이거나 url 이 설정된 디스크에서만 직접 URL 을 반환합니다. s3 디스크라도 url(AWS_URL/관리자 S3 URL 설정)이 비어 있으면 null 입니다.
해결:
// public disk 를 사용하거나
$module->getStorageDisk(); // → 'public'
// 디스크에 url 을 설정하거나 (config/filesystems.php 또는 관리자 S3 URL 설정)
'my_cdn' => ['driver' => 's3', ..., 'url' => 'https://cdn.example.com'],
// 또는 별도 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: 새로운 경로 패턴으로 파일을 저장하므로, 기존 파일은 마이그레이션 스크립트로 이동해야 합니다.
// 마이그레이션 예시
$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 (권장) — 관리자 > 환경설정 > 드라이버 > 파일 스토리지:
- 스토리지 드라이버를
Amazon S3로 선택 - 버킷 / 리전 / Access Key / Secret Key 입력
- S3 호환 스토리지(Cloudflare R2, MinIO, NCP 등)는 위 S3 호환 스토리지 연결 절의 두 항목(엔드포인트 URL·Path-style 주소)을 함께 설정
- 연결 테스트 성공 후 저장 — 코어 첨부 업로드 디스크가 s3 로 전환됩니다
(
ATTACHMENT_DISKenv 를 명시한 경우 env 가 항상 우선. 기존 파일은 저장 당시의 disk 로 계속 서빙되므로 혼재 안전)
참고: S3 URL(공개 URL) 칸은 파일 공개 URL 생성에 쓰는 CDN/커스텀 도메인이며, API 요청 주소가 아닙니다.
② .env (모듈별 커스텀):
-
.env파일에 S3 설정 추가: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 -
모듈 설정 변경:
// config/sirsoft-board.php return [ 'attachment' => [ 'disk' => env('SIRSOFT_BOARD_ATTACHMENT_DISK', 's3'), ], ]; -
AbstractModule에서 오버라이드:public function getStorageDisk(): string { return config('sirsoft-board.attachment.disk', 's3'); }
끝! Service 코드는 변경할 필요가 없습니다.
직접 URL(CDN) 서빙까지 원한다면: filesystems.disks.s3.url(관리자 환경설정의
S3 URL — CDN 도메인)이 반드시 설정되어야 합니다. url 이 비어 있으면 url() 은
null 을 반환해 스트리밍 경로가 유지됩니다.
Q3: 여러 disk를 동시에 사용할 수 있나요?
A: 네, getStorageDiskFor(string $category) 를 오버라이드하면 카테고리별로 다른
disk 를 사용할 수 있습니다. getStorageFor($category) 가 그 결정에 따라 디스크 단위로
memoize 된 인스턴스를 돌려주고, ServiceProvider 의 $storageCategoryServices 매핑이
해당 서비스에 카테고리 디스크 스토리지를 자동 주입합니다.
<?php
namespace Modules\Sirsoft\Ecommerce;
use App\Extension\AbstractModule;
class Module extends AbstractModule
{
/**
* 카테고리별 디스크 결정 — 기본 구현은 getStorageDisk() 와 동일.
*
* 주의: 'settings' 카테고리에서 모듈 설정을 조회하면 설정 로드와 재귀 고리가
* 생깁니다. 설정 조회는 설정 저장과 무관한 카테고리에서만 수행하세요.
*/
public function getStorageDiskFor(string $category): string
{
if ($category !== 'images') {
return $this->getStorageDisk();
}
// 공개 자산 디스크 해석 (확장 개별 설정 > 코어 전역 > 미설정 → 기본 디스크)
$override = module_setting('sirsoft-ecommerce', 'basic_info.public_asset_disk', '');
return $this->resolvePublicAssetDisk(is_string($override) ? $override : '')
?? $this->getStorageDisk();
}
}
// ServiceProvider — 카테고리 디스크가 필요한 서비스는 $storageCategoryServices 에 매핑
protected array $storageCategoryServices = [
ProductImageService::class => 'images',
];
Q4: 플러그인에서도 사용할 수 있나요?
A: 네, PluginStorageDriver를 사용하면 됩니다.
<?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
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: 다음 패턴을 권장합니다.
-
대용량 파일은 스트리밍 사용:
// ❌ 메모리에 모두 로드 $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); -
배치 삭제:
// ❌ 파일 하나씩 삭제 foreach ($files as $file) { $this->storage->delete('temp', $file); } // ✅ 디렉토리 전체 삭제 $this->storage->deleteDirectory('temp', $sessionId); -
URL 캐싱:
// ✅ URL을 DB에 캐시 $url = $this->storage->url('images', $image->path); $image->update(['cached_url' => $url]);
공개 자산 전용 디스크 분리 (직접 URL/CDN 서빙)
완전 공개 자산(상품/카테고리/리뷰/에디터 이미지, 레이아웃 배경 이미지 등)을 S3+CDN 등 원격 디스크에서 직접 URL 로 서빙하는 옵트인 기능입니다. 미설정 시 기존 PHP 스트리밍이 100% 보존됩니다.
설정 사슬
관리자 환경설정 > 드라이버 탭 > 공개 자산 스토리지 (drivers.public_asset_disk)
→ SettingsServiceProvider 가 core.storage.public_asset_disk 로 주입
→ 확장의 getStorageDiskFor('images') 가 resolvePublicAssetDisk() 로 해석
(우선순위: 확장 개별 설정 public_asset_disk > 코어 전역 > 미설정 → 기본 디스크)
→ ServiceProvider $storageCategoryServices 매핑으로 업로드 서비스에 주입
→ put() 이 그 디스크에 저장, 행 disk 컬럼에 기록
→ 모델 download_url accessor 가 행 disk 로 url() 시도 → null 이면 API 경로 폴백
- 선택지 카탈로그는 코어 3종(
none/public/s3) + 플러그인이core.settings.available_public_asset_drivers필터 훅으로 등록한 디스크입니다. 플러그인은 자기 ServiceProvider 에서Config::set('filesystems.disks.{id}', [... 'url' => CDN])로 디스크를 정의하고 훅에{id, label, provider}를 append 합니다. - 확장 개별 오버라이드 화면의 카탈로그는 그 확장의 설정 조회 응답에 부착해 내립니다
(
available_public_asset_disks키 — ecommerce 는 모듈 설정 컨트롤러가, 플러그인은 설정 스키마에public_asset_disk선언 시 코어 플러그인 설정 API 가 자동 부착). 화면이 코어 환경설정 API 를 교차 조회하면 화면 권한과 카탈로그 권한(core.settings.read)이 갈려 커스텀 역할에서 선택지만 조용히 비게 됩니다. none은 스트리밍 유지(확장 개별 설정에서는 전역이 CDN 이어도 강제 스트리밍)입니다.- 플러그인 비활성화로 디스크가 config 에서 사라지면(고아 디스크) 자동으로 스트리밍 폴백합니다 — 저장값은 보존되므로 재활성화 시 되살아납니다.
- 코어의 레이아웃 첨부(레이아웃 편집기에서 올리는 배경 이미지)도 이 배선 대상입니다.
이 카테고리의 서빙 라우트는 무인증 공개이고 검사가 템플릿 소속 확인 하나뿐이라
"완전 공개 자산" 에 해당합니다. 설정을 켠 뒤 업로드한 파일이 공개 자산 디스크에
저장되고, 그 행의 disk 가 지금 설정된 공개 자산 디스크와 일치할 때만 직접 URL 이
발급됩니다. 디스크에 공개 URL(
url) 설정이 있다는 사실만으로는 직접 URL 을 발급하지 않습니다 — 그 설정은 "URL 문자열을 만들 수 있는가" 일 뿐 "익명 읽기가 되는가" 가 아니어서, 비공개 버킷에 공개 URL 을 적어 둔 구성에서는 발급된 주소가 403 이 됩니다. - 발급된 주소는 레이아웃 저장의 외부 URL 차단 규칙을 통과해야 합니다. 편집기 image 위젯은
그 주소를 props 에 그대로 넣고, 헤더 「로고 이미지」처럼 값 슬롯이 하나뿐인 컨트롤은
style이 아니라props에 쓰므로 규칙의 스캔 대상입니다. 그래서 공개 서빙(프록시) URL 은 사이트 상대 경로(/api/templates/.../file)로 발급하고, 직접 URL 의 host(선언된 공개 자산 디스크)와 사이트 자기 host 는 규칙이 외부로 보지 않습니다(App\Support\SiteAssetHosts). 프록시 URL 을 절대 형태로 발급하면 그 저장이 422 로 거부될 뿐 아니라, 저장된 레이아웃이 발급 시점의 도메인·스킴에 묶여 주소가 바뀌면 그 이미지가 전부 깨집니다.
혼재 운용
디스크 전환 시 기존 파일 이동이 필요 없습니다. 행마다 기록된 disk 를 기준으로 서빙/삭제/이동이 동작하므로, 전환 이전 로컬 행은 스트리밍으로, 이후 원격 행은 직접 URL 로 각자 올바르게 서빙됩니다.
행에 기록된 disk 가 고아가 된 경우(그 디스크를 제공하던 플러그인을 비활성화한 뒤에도 그 disk 로 기록된 행이 남아 있는 경우)에도 서빙·삭제는 예외 없이 동작합니다. 행 disk 기준 스토리지를 만들 때 디스크 설정 존재 여부를 먼저 확인하고, 없으면 확장의 기본 디스크로 폴백하기 때문입니다. 파일 자체는 도달 불가이므로 그 이미지는 404 가 되지만, 목록·상세 화면과 삭제(상품/카테고리/리뷰 삭제 포함)는 정상 동작합니다. 이 검증을 생략하면 미등록 디스크 접근이 예외가 되어 서빙과 삭제가 모두 500 이 됩니다.
버킷/CDN 쪽 요구사항
직접 URL 서빙은 브라우저가 그 URL 을 인증 없이 읽는다는 전제 위에 있습니다. 따라서 대상 버킷/배포는 다음을 만족해야 합니다.
| 요구사항 | 미충족 시 증상 |
|---|---|
객체가 익명 읽기 가능 (버킷 정책 s3:GetObject 공개 또는 CDN 공개 배포) |
화면의 그 이미지들이 전부 깨짐 (S3 는 403 AccessDenied 를 XML 로 응답) |
공개 URL base(S3 URL)가 그 객체를 가리킴 |
url() 이 null → 스트리밍 폴백(기능은 정상, CDN 이점만 없음) |
켜기 전에 알아야 할 것
| 항목 | 내용 |
|---|---|
| 이미 올라간 파일에도 소급 적용될 수 있다 | 기존 첨부 디스크와 공개 자산 디스크가 같은 디스크(예: 둘 다 S3)라면, 설정을 켜는 순간 켜기 이전에 올라간 첨부까지 직접 URL 로 바뀝니다. 행에 기록된 disk 값이 같아 신·구를 구분할 수단이 없기 때문입니다. 두 디스크를 다르게 두면(예: 첨부는 로컬, 공개 자산은 S3) 켠 뒤 올린 파일만 전환됩니다 |
| 저장된 레이아웃에 주소가 그대로 남는다 | 설정을 켠 동안 배경 이미지를 지정해 저장하면 그 직접 URL 문자열이 레이아웃에 고정됩니다. 이후 설정을 끄거나 버킷을 비공개로 바꿔도 그 문자열은 자동으로 되돌아가지 않습니다(서버가 개입할 지점이 없습니다). 되돌리려면 편집기에서 이미지를 다시 선택해 저장해야 합니다 |
| 첨부를 지워도 직접 URL 은 살아 있을 수 있다 | 직접 URL 은 첨부 기록과 무관하게 저장소의 파일을 가리킵니다. 스트리밍 방식에서는 기록을 지우면 그 주소가 곧바로 404 가 되었지만, 직접 URL 은 파일이 남아 있는 한 계속 열립니다. 접근을 확실히 끊으려면 저장소에서 파일 자체를 지워야 합니다 |
| 확장 훅에 새로운 조합이 흘러간다 | core.storage.filter_url 훅을 scope 로 분기하는 확장은 이번부터 scope='core', identifier=null, category='template-layout-attachments' 조합을 받습니다. 지금까지 코어에서 이 훅으로 URL 을 만드는 호출부가 없었으므로 이 조합은 처음 등장합니다 |
관리자 화면의 썸네일은 교차 출처 공개 URL 을 <img> 로 직접 사용하므로 CORS 설정은
필요하지 않습니다. 다만 그 URL 을 자바스크립트로 읽는 커스텀 확장을 만든다면 그때는
버킷/CDN 에 CORS 규칙이 필요합니다.
교차 출처 URL 에는 코어 API 클라이언트가 인증 토큰을 붙이지 않습니다 — 공개 자산은 인증이 필요 없고, 세션 토큰이 제3자 origin 으로 나가서는 안 되기 때문입니다. 같은 이유로 첨부 썸네일·다운로드도 교차 출처 URL 이면 인증 요청 대신 URL 을 직접 씁니다.
서명 URL 등 커스텀 URL 공급
core.storage.filter_url 필터 훅이 디스크 무관 항상 발화하므로, 확장이 서명 URL 을
공급하거나 생성된 URL 을 수정/차단할 수 있습니다 (url() 절 참조).
보안 전제
권한 검사가 걸린 첨부파일(비밀글/회원 전용 게시판 첨부 등)은 배선 자체가 없습니다 — 직접 URL 은 서버 스트리밍 경로의 권한 검사를 우회하므로, 완전 공개 자산 카테고리에만 공개 자산 디스크를 적용합니다.
알려진 한계 — 상품 리뷰 이미지는 공개 자산으로 배선되어 있습니다. 리뷰를 노출에서 숨김으로 바꾸면 목록·상세 응답에서 이미지가 사라지지만, 공개 자산 디스크를 켠 상태에서 이미 발급된 직접 URL 을 알고 있는 사람은 그 주소로 파일에 계속 접근할 수 있습니다. 직접 URL 은 저장소의 파일을 직접 가리키므로 응답에서 감추는 것만으로는 회수되지 않습니다. 숨김을 접근 차단으로 쓰려면 그 카테고리에 공개 자산 디스크를 적용하지 않거나, 저장소에서 파일 자체를 지워야 합니다.
관련 문서
작성일: 2024-01-19 최종 수정: 2024-01-19 버전: 1.0.0