fix(ecommerce): 관리자 카테고리 이미지 다운로드 전체 메모리 적재 제거

관리자 카테고리 이미지 다운로드가 streamDownload 콜백 안에서
Storage::get 으로 파일 전체를 메모리에 적재한 뒤 echo 하던 안티패턴을,
공개 서빙과 동일한 CategoryImageService::download
(StorageInterface::response 스트리밍) 위임으로 교체한다.

서빙 계층은 이미 종단간 스트리밍이며 이 지점이 유일한 전체 적재 경로였다.
규정(storage-driver.md)에 서빙 안티패턴을 명시하고 audit coverage 에 등재한다.
This commit is contained in:
HeuJung committed 2026-07-19 21:09:22 +09:00
1 parent 707919719c
commit 8522245137
4 files changed
+161 -34

No files matched your search

+21
View File
@@ -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()` 미사용).
@@ -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
{
@@ -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());
}
}