fix(ecommerce): 관리자 카테고리 이미지 다운로드 전체 메모리 적재 제거
관리자 카테고리 이미지 다운로드가 streamDownload 콜백 안에서 Storage::get 으로 파일 전체를 메모리에 적재한 뒤 echo 하던 안티패턴을, 공개 서빙과 동일한 CategoryImageService::download (StorageInterface::response 스트리밍) 위임으로 교체한다. 서빙 계층은 이미 종단간 스트리밍이며 이 지점이 유일한 전체 적재 경로였다. 규정(storage-driver.md)에 서빙 안티패턴을 명시하고 audit coverage 에 등재한다.
This commit is contained in:
1 parent
707919719c
commit
8522245137
4 files changed
+161
-34
No files matched your search
@@ -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 등)의 전체 읽기 전용이며, 서빙 본문에는 사용하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
@@ -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()` 미사용).
|
||||
|
||||
|
||||
+32
-34
@@ -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
|
||||
{
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Admin;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Mockery;
|
||||
use Mockery\MockInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CategoryController;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CategoryImageService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CategoryService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* 관리자 카테고리 이미지 다운로드 서빙 회귀 테스트
|
||||
*
|
||||
* 배경: downloadImage() 가 과거 response()->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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user