diff --git a/docs/extension/storage-driver.md b/docs/extension/storage-driver.md index 537f6a47..e26e09df 100644 --- a/docs/extension/storage-driver.md +++ b/docs/extension/storage-driver.md @@ -1058,6 +1058,27 @@ $response = $this->storage->response( - 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 등)의 전체 읽기 전용이며, 서빙 본문에는 사용하지 않는다. + --- ## 트러블슈팅 diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md b/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md index 32468e36..71296329 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md @@ -55,4 +55,6 @@ Accept: application/json **설명** 공개 API로, 해시(`hash`)로 식별되는 카테고리 이미지 원본 파일을 스트리밍 서빙합니다. 인증이 필요 없으며(`PublicBaseController`), `CategoryImageController@download`가 `CategoryImageService::download()`를 호출해 리포지토리에서 해시로 이미지를 찾고 `StorageInterface::response()`로 `StreamedResponse`를 반환합니다. 응답에는 저장된 `mime_type`과 `Cache-Control: public, max-age=31536000`(1년) 헤더가 부여되어 브라우저/CDN 캐싱에 최적화됩니다. 해시에 해당하는 레코드가 없거나 스토리지에 실제 파일이 없으면 404를, 그 외 처리 오류 시 400 에러 응답을 반환합니다. +> **서빙 방식**: 이미지 본문은 `StorageInterface::response()`(내부 `readStream`) 로 청크 스트리밍되며 파일 전체를 메모리에 적재하지 않습니다. 관리자용 카테고리 이미지 서빙(`CategoryController::downloadImage`)도 동일하게 `CategoryImageService::download()` 스트리밍 경로를 공유합니다(전체 메모리 적재 `Storage::get()` 미사용). + diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CategoryController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CategoryController.php index 89d7e7c8..51602e09 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CategoryController.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CategoryController.php @@ -5,7 +5,6 @@ namespace Modules\Sirsoft\Ecommerce\Http\Controllers\Admin; use App\Helpers\ResponseHelper; use App\Http\Controllers\Api\Base\AdminBaseController; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\Storage; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\CategoryListRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\CreateCategoryRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ReorderCategoriesRequest; @@ -16,6 +15,7 @@ use Modules\Sirsoft\Ecommerce\Http\Resources\CategoryCollection; use Modules\Sirsoft\Ecommerce\Http\Resources\CategoryResource; use Modules\Sirsoft\Ecommerce\Services\CategoryImageService; use Modules\Sirsoft\Ecommerce\Services\CategoryService; +use Symfony\Component\HttpFoundation\StreamedResponse; /** * 카테고리 관리 컨트롤러 @@ -30,8 +30,8 @@ class CategoryController extends AdminBaseController /** * 카테고리 목록 조회 * - * @param CategoryListRequest $request - * @return JsonResponse + * @param CategoryListRequest $request 목록 조회 필터/정렬 조건 + * @return JsonResponse 카테고리 목록 응답 */ public function index(CategoryListRequest $request): JsonResponse { @@ -49,7 +49,7 @@ class CategoryController extends AdminBaseController * * 활성화된 카테고리만 트리 형태로 반환합니다. * - * @return JsonResponse + * @return JsonResponse 활성 카테고리 트리 응답 */ public function tree(): JsonResponse { @@ -68,8 +68,8 @@ class CategoryController extends AdminBaseController /** * 카테고리 생성 * - * @param CreateCategoryRequest $request - * @return JsonResponse + * @param CreateCategoryRequest $request 카테고리 생성 데이터 + * @return JsonResponse 생성된 카테고리 응답 */ public function store(CreateCategoryRequest $request): JsonResponse { @@ -86,8 +86,8 @@ class CategoryController extends AdminBaseController /** * 카테고리 상세 조회 * - * @param int $id - * @return JsonResponse + * @param int $id 카테고리 ID + * @return JsonResponse 카테고리 상세 응답 */ public function show(int $id): JsonResponse { @@ -111,9 +111,9 @@ class CategoryController extends AdminBaseController /** * 카테고리 수정 * - * @param UpdateCategoryRequest $request - * @param int $category - * @return JsonResponse + * @param UpdateCategoryRequest $request 카테고리 수정 데이터 + * @param int $category 카테고리 ID + * @return JsonResponse 수정된 카테고리 응답 */ public function update(UpdateCategoryRequest $request, int $category): JsonResponse { @@ -137,8 +137,8 @@ class CategoryController extends AdminBaseController /** * 카테고리 삭제 * - * @param int $category - * @return JsonResponse + * @param int $category 카테고리 ID + * @return JsonResponse 삭제 결과 응답 */ public function destroy(int $category): JsonResponse { @@ -162,9 +162,9 @@ class CategoryController extends AdminBaseController /** * 카테고리 이미지 업로드 * - * @param UploadCategoryImageRequest $request - * @param int|null $categoryId - * @return JsonResponse + * @param UploadCategoryImageRequest $request 이미지 업로드 데이터 + * @param int|null $categoryId 카테고리 ID (임시 업로드 시 null) + * @return JsonResponse 업로드된 이미지 정보 응답 */ public function uploadImage(UploadCategoryImageRequest $request, ?int $categoryId = null): JsonResponse { @@ -211,8 +211,8 @@ class CategoryController extends AdminBaseController /** * 카테고리 이미지 삭제 * - * @param int $id - * @return JsonResponse + * @param int $id 이미지 ID + * @return JsonResponse 삭제 결과 응답 */ public function deleteImage(int $id): JsonResponse { @@ -243,8 +243,8 @@ class CategoryController extends AdminBaseController /** * 카테고리 이미지 순서 변경 * - * @param ReorderCategoryImagesRequest $request - * @return JsonResponse + * @param ReorderCategoryImagesRequest $request 이미지 순서 데이터 + * @return JsonResponse 순서 변경 결과 응답 */ public function reorderImages(ReorderCategoryImagesRequest $request): JsonResponse { @@ -269,15 +269,17 @@ class CategoryController extends AdminBaseController /** * 카테고리 이미지 다운로드 * - * @param string $hash - * @return \Symfony\Component\HttpFoundation\StreamedResponse|JsonResponse + * @param string $hash 이미지 해시 + * @return StreamedResponse|JsonResponse 이미지 스트림 또는 404 응답 */ public function downloadImage(string $hash) { try { - $image = $this->categoryImageService->getByHash($hash); + // 서빙은 서비스의 StorageInterface::response() 스트리밍에 위임한다. + // (파일 전체를 메모리에 적재하는 streamDownload+Storage::get 안티패턴 금지) + $response = $this->categoryImageService->download($hash); - if (! $image) { + if (! $response) { return ResponseHelper::notFound( 'messages.category_images.not_found', [], @@ -285,11 +287,7 @@ class CategoryController extends AdminBaseController ); } - return response()->streamDownload(function () use ($image) { - echo Storage::disk($image->disk)->get($image->path); - }, $image->original_filename, [ - 'Content-Type' => $image->mime_type, - ]); + return $response; } catch (\Exception $e) { return ResponseHelper::moduleError( 'sirsoft-ecommerce', @@ -302,8 +300,8 @@ class CategoryController extends AdminBaseController /** * 카테고리 상태 토글 * - * @param int $id - * @return JsonResponse + * @param int $id 카테고리 ID + * @return JsonResponse 상태 변경 응답 */ public function toggleStatus(int $id): JsonResponse { @@ -327,7 +325,7 @@ class CategoryController extends AdminBaseController /** * 파일 크기를 사람이 읽기 쉬운 형식으로 변환 * - * @param int $bytes 바이트 단위 파일 크기 + * @param int $bytes 바이트 단위 파일 크기 * @return string 포맷된 파일 크기 (예: "1.5 MB") */ private function formatFileSize(int $bytes): string @@ -353,8 +351,8 @@ class CategoryController extends AdminBaseController * "child_menus": { "1": [{ "id": 2, "order": 1 }, ...] } * } * - * @param ReorderCategoriesRequest $request - * @return JsonResponse + * @param ReorderCategoriesRequest $request 카테고리 순서 데이터 + * @return JsonResponse 순서 변경 결과 응답 */ public function reorder(ReorderCategoriesRequest $request): JsonResponse { diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CategoryImageDownloadTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CategoryImageDownloadTest.php new file mode 100644 index 00000000..d4f7b219 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CategoryImageDownloadTest.php @@ -0,0 +1,106 @@ +streamDownload(fn => echo Storage::get()) + * 로 파일 전체를 메모리에 적재하는 안티패턴이었다. 서빙은 CategoryImageService::download() + * (StorageInterface::response() 기반 스트리밍)로 위임해야 한다. + * + * 이 테스트는 컨트롤러가 서비스의 스트리밍 경로로 위임하는지를 단언한다 — + * 전체 적재 경로(getByHash 조회 후 직접 Storage 접근)로 회귀하면 실패한다. + */ +class CategoryImageDownloadTest extends ModuleTestCase +{ + /** @var MockInterface&CategoryImageService */ + private $categoryImageService; + + private CategoryController $controller; + + protected function setUp(): void + { + parent::setUp(); + + $categoryService = Mockery::mock(CategoryService::class); + $this->categoryImageService = Mockery::mock(CategoryImageService::class); + + $this->controller = new CategoryController( + $categoryService, + $this->categoryImageService + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * 다운로드가 서비스의 스트리밍 응답으로 위임되는지 검증한다. + * + * 전체 적재 경로(getByHash + Storage::get)로 회귀하면 download() 미호출 + + * getByHash 호출로 이 테스트가 실패한다. + */ + #[Test] + public function test_download_delegates_to_streaming_service(): void + { + $hash = 'abcdef012345'; + $streamed = new StreamedResponse(function () {}, 200, [ + 'Content-Type' => 'image/jpeg', + ]); + + // 서비스의 스트리밍 다운로드가 호출되어야 한다. + $this->categoryImageService + ->shouldReceive('download') + ->once() + ->with($hash) + ->andReturn($streamed); + + // 전체 적재 경로(getByHash 후 직접 Storage 접근)는 사용하면 안 된다. + $this->categoryImageService + ->shouldReceive('getByHash') + ->never(); + + $result = $this->controller->downloadImage($hash); + + $this->assertInstanceOf( + StreamedResponse::class, + $result, + '관리자 다운로드는 스트리밍 응답을 반환해야 한다 (전체 메모리 적재 금지)' + ); + $this->assertSame($streamed, $result, '서비스가 생성한 스트리밍 응답을 그대로 반환해야 한다'); + } + + /** + * 이미지가 없으면 404 를 반환한다 (기존 동작 회귀 유지). + */ + #[Test] + public function test_download_returns_not_found_when_image_missing(): void + { + $hash = 'missinghash0'; + + $this->categoryImageService + ->shouldReceive('download') + ->once() + ->with($hash) + ->andReturn(null); + + $result = $this->controller->downloadImage($hash); + + $this->assertInstanceOf(JsonResponse::class, $result); + $this->assertSame(404, $result->getStatusCode()); + } +}