Files
Gnuboard7/docs/extension/module-settings.md
T
HeuJung c77ddee03b fix(core,ecommerce): Windows 확장 업데이트 파일 잠금 해소 + 통화 삭제 영속화
Windows 는 하위 트리에 열린 핸들이 하나라도 있으면 디렉토리 rename 을 막는다.
잠금 프로세스를 찾아 종료하던 기존 대응은 디렉토리 핸들을 감지하지 못했고
사용 중인 편집기를 예고 없이 죽였다. rename 이 막히면 파일 단위 연산으로
폴백해 어떤 프로세스도 건드리지 않고 교체를 끝낸다. 커밋 전 점검에서
pint.json 이 코어 업데이트 대상에 빠져 있던 것도 함께 등재했다.

이커머스는 기본 제공 통화 삭제가 저장 응답에서 즉시 부활했다. 항목 단위
보충 병합이 "소실" 과 "의도적 삭제" 를 구분하지 못한 탓이라, 삭제 의도를
서버가 도출해 저장본에 기록하고 병합이 그 기록을 존중하게 했다. 그 통화로
결제된 과거 주문의 표기가 흔들리지 않도록 소수 자릿수 해석도 스냅샷 우선으로
바꿨다 — 금액은 원래 스냅샷 환율을 써 안전했으나 자릿수만 현재 설정을 봤다.
2026-08-12 10:10:01 +09:00

21 KiB

모듈 환경설정 시스템 개발 가이드

이 문서는 모듈에서 환경설정 시스템을 구현하는 방법을 설명합니다.


목차

  1. 개요
  2. 디렉토리 구조
  3. defaults.json 작성
  4. SettingsService 구현
  5. SettingsController 구현
  6. API 라우트 설정
  7. 레이아웃 연동
  8. 백엔드에서 설정 조회
  9. 카탈로그 병합 설정의 공개 응답
  10. 항목 단위 보충 병합과 삭제 의도
  11. 관련 문서

1. 개요

1.1 핵심 원칙

중요: 모듈 환경설정은 모듈의 책임
✅ 코어는 ModuleSettingsService + 헬퍼 함수(module_setting/module_settings)를 제공
✅ 모듈이 ModuleSettingsInterface를 구현하면 코어가 자동 검색하여 위임
✅ ModuleSettingsInterface 미구현 모듈은 코어 기본 구현(단일 setting.json) 사용
✅ 설정 파일은 모듈별 격리된 경로에 저장

1.2 설정 조회 흐름

module_setting('vendor-module', 'key')
  → ModuleSettingsService::get()
    → resolveModuleService() — 모듈별 설정 서비스 자동 검색
      → 1순위: Modules\{Vendor}\{Module}\Contracts\{Module}SettingsServiceInterface (바인딩)
      → 2순위: Modules\{Vendor}\{Module}\Services\{Module}SettingsService (클래스)
    → 모듈 서비스 발견 시: 위임 (getSetting/getAllSettings)
    → 미발견 시: 코어 기본 구현 (defaults.json + setting.json)

1.3 설계 원칙

  • 격리성: 모듈별로 독립적인 설정 저장 경로
  • 일관성: 코어 환경설정과 동일한 패턴
  • 유연성: 카테고리별 설정 분리 가능
  • 보안성: frontend_schema를 통한 민감정보 제어

2. 디렉토리 구조

2.1 모듈 설정 파일 위치

modules/{vendor-module}/
├── config/
│   └── settings/
│       └── defaults.json    ← 기본 설정값 정의
├── src/
│   ├── Services/
│   │   └── {Module}SettingsService.php    ← 설정 서비스
│   ├── Http/
│   │   └── Controllers/
│   │       └── Admin/
│   │           └── {Module}SettingsController.php    ← 설정 API
│   └── routes/
│       └── api.php    ← API 라우트
└── resources/
    └── layouts/
        └── admin/
            └── admin_{module}_settings.json    ← 설정 UI

2.2 설정 저장 경로

storage/app/modules/{vendor-module}/settings/
├── basic_info.json
├── language_currency.json
└── seo.json

3. defaults.json 작성

3.1 기본 구조

{
  "_meta": {
    "version": "1.0.0",
    "description": "모듈 설명",
    "categories": ["basic_info", "language_currency", "seo"]
  },
  "defaults": {
    "basic_info": {
      "field_name": "default_value"
    }
  },
  "frontend_schema": {
    "basic_info": {
      "expose": true,
      "fields": {
        "field_name": { "expose": true }
      }
    }
  }
}

3.2 _meta 섹션

필드 타입 설명
version string 설정 스키마 버전
description string 모듈 설명
categories array 설정 카테고리 목록

3.3 defaults 섹션

카테고리별 기본값을 정의합니다.

{
  "defaults": {
    "basic_info": {
      "shop_name": "",
      "route_path": "shop",
      "no_route": false
    },
    "seo": {
      "meta_main_title": "{site_name} - {commerce_name}",
      "seo_site_main": true
    }
  }
}

3.4 frontend_schema 섹션

프론트엔드에 노출할 필드를 제어합니다.

{
  "frontend_schema": {
    "basic_info": {
      "expose": true,
      "fields": {
        "shop_name": { "expose": true },
        "api_key": { "expose": false, "sensitive": true }
      }
    },
    "payment": {
      "expose": false
    }
  }
}
속성 설명
expose 프론트엔드 노출 여부
sensitive 민감 정보 여부

4. SettingsService 구현

4.1 ModuleSettingsInterface 구현

<?php

namespace Modules\Vendor\Module\Services;

use App\Contracts\Extension\ModuleSettingsInterface;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;

class ModuleSettingsService implements ModuleSettingsInterface
{
    private const MODULE_IDENTIFIER = 'vendor-module';
    private ?array $defaults = null;
    private ?array $settings = null;

    public function getSettingsDefaultsPath(): ?string
    {
        $path = $this->getModulePath().'/config/settings/defaults.json';
        return file_exists($path) ? $path : null;
    }

    public function getSetting(string $key, mixed $default = null): mixed
    {
        $settings = $this->getAllSettings();
        return Arr::get($settings, $key, $default);
    }

    public function setSetting(string $key, mixed $value): bool
    {
        $settings = $this->getAllSettings();
        Arr::set($settings, $key, $value);
        $parts = explode('.', $key);
        $category = $parts[0];
        return $this->saveCategorySettings($category, $settings[$category] ?? []);
    }

    public function getAllSettings(): array
    {
        if ($this->settings !== null) {
            return $this->settings;
        }

        $defaults = $this->getDefaults();
        $categories = $defaults['_meta']['categories'] ?? [];
        $defaultValues = $defaults['defaults'] ?? [];

        $settings = [];
        foreach ($categories as $category) {
            $categoryDefaults = $defaultValues[$category] ?? [];
            $savedSettings = $this->loadCategorySettings($category);
            $settings[$category] = array_merge($categoryDefaults, $savedSettings);
        }

        $this->settings = $settings;
        return $settings;
    }

    public function getSettings(string $category): array
    {
        $allSettings = $this->getAllSettings();
        return $allSettings[$category] ?? [];
    }

    public function saveSettings(array $settings): bool
    {
        $success = true;
        foreach ($settings as $category => $categorySettings) {
            if (str_starts_with($category, '_')) {
                continue;
            }
            if (!$this->saveCategorySettings($category, $categorySettings)) {
                $success = false;
            }
        }
        $this->settings = null;
        return $success;
    }

    public function getFrontendSettings(): array
    {
        $defaults = $this->getDefaults();
        $frontendSchema = $defaults['frontend_schema'] ?? [];
        $allSettings = $this->getAllSettings();

        $frontendSettings = [];
        foreach ($frontendSchema as $category => $schema) {
            if (!($schema['expose'] ?? false)) {
                continue;
            }
            $categorySettings = $allSettings[$category] ?? [];
            $fields = $schema['fields'] ?? [];

            if (empty($fields)) {
                $frontendSettings[$category] = $categorySettings;
                continue;
            }

            $exposedFields = [];
            foreach ($fields as $fieldName => $fieldSchema) {
                if ($fieldSchema['expose'] ?? false) {
                    $exposedFields[$fieldName] = $categorySettings[$fieldName] ?? null;
                }
            }
            if (!empty($exposedFields)) {
                $frontendSettings[$category] = $exposedFields;
            }
        }
        return $frontendSettings;
    }

    private function getModulePath(): string
    {
        return base_path('modules/'.self::MODULE_IDENTIFIER);
    }

    private function getStoragePath(): string
    {
        return storage_path('app/modules/'.self::MODULE_IDENTIFIER.'/settings');
    }

    // 나머지 private 메서드 구현...
}

4.2 분리 입력 필드 처리

전화번호, 사업자번호 등 분리 입력 필드 처리:

private function processSplitFields(string $category, array $settings): array
{
    if ($category !== 'basic_info') {
        return $settings;
    }

    // 사업자등록번호 병합
    if (isset($settings['business_number_1'])) {
        $parts = [
            $settings['business_number_1'] ?? '',
            $settings['business_number_2'] ?? '',
            $settings['business_number_3'] ?? '',
        ];
        $settings['business_number'] = implode('-', array_filter($parts));
        unset(
            $settings['business_number_1'],
            $settings['business_number_2'],
            $settings['business_number_3']
        );
    }

    return $settings;
}

5. SettingsController 구현

<?php

namespace Modules\Vendor\Module\Http\Controllers\Admin;

use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Vendor\Module\Services\ModuleSettingsService;

class ModuleSettingsController extends AdminBaseController
{
    public function __construct(
        private ModuleSettingsService $settingsService
    ) {
        parent::__construct();
    }

    public function index(): JsonResponse
    {
        $settings = $this->settingsService->getFrontendSettings();
        return $this->success('settings.fetch_success', $settings);
    }

    public function show(string $category): JsonResponse
    {
        $settings = $this->settingsService->getSettings($category);
        return $this->success('settings.fetch_success', [
            'category' => $category,
            'settings' => $settings,
        ]);
    }

    public function store(Request $request): JsonResponse
    {
        $result = $this->settingsService->saveSettings($request->all());

        if ($result) {
            $updatedSettings = $this->settingsService->getFrontendSettings();
            return $this->success('settings.save_success', $updatedSettings);
        }

        return $this->error('settings.save_failed');
    }
}

6. API 라우트 설정

6.1 라우트 파일 위치

modules/{vendor-module}/src/routes/api.php

6.2 라우트 정의

<?php

use Illuminate\Support\Facades\Route;
use Modules\Vendor\Module\Http\Controllers\Admin\ModuleSettingsController;

/*
|--------------------------------------------------------------------------
| Module API Routes
|--------------------------------------------------------------------------
|
| URL prefix: 'api/modules/{module-name}'
| Name prefix: 'api.modules.{module-name}.'
|
*/

Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function () {
    Route::get('settings', [ModuleSettingsController::class, 'index'])
        ->name('admin.settings.index');

    Route::put('settings', [ModuleSettingsController::class, 'store'])
        ->name('admin.settings.store');

    Route::get('settings/{category}', [ModuleSettingsController::class, 'show'])
        ->name('admin.settings.show');
});

6.3 최종 API 경로

Method 경로 설명
GET /api/modules/{id}/admin/settings 전체 설정 조회
PUT /api/modules/{id}/admin/settings 설정 저장
GET /api/modules/{id}/admin/settings/{category} 카테고리별 조회

7. 레이아웃 연동

7.1 data_sources 설정

{
  "data_sources": [
    {
      "id": "settings",
      "type": "api",
      "endpoint": "/api/modules/{module-id}/admin/settings",
      "method": "GET",
      "auto_fetch": true,
      "auth_required": true,
      "initLocal": "form",
      "refetchOnMount": true
    }
  ],
  "state": {
    "isSaving": false,
    "hasChanges": false,
    "form": {}
  }
}

7.2 저장 버튼 액션

{
  "handler": "apiCall",
  "auth_required": true,
  "target": "/api/modules/{module-id}/admin/settings",
  "params": {
    "method": "PUT",
    "body": "{{_local.form}}"
  }
}

8. 백엔드에서 설정 조회

8.1 헬퍼 함수 사용 (권장)

module_setting() / module_settings()는 내부적으로 ModuleSettingsService를 사용합니다. 모듈별 설정 서비스({Module}SettingsService)가 존재하면 자동으로 위임합니다.

// 단일 설정값 조회 (도트 노테이션 지원)
$shopName = module_setting('vendor-module', 'basic_info.shop_name', '기본값');

// 전체 설정 조회
$allSettings = module_setting('vendor-module');

// 카테고리 전체 조회
$seoSettings = module_settings('vendor-module', 'seo');

// 전체 설정 조회
$allSettings = module_settings('vendor-module');

8.2 ModuleSettingsService 직접 사용

use App\Services\ModuleSettingsService;

$service = app(ModuleSettingsService::class);

// 단일 설정값 조회
$value = $service->get('vendor-module', 'basic_info.shop_name', '기본값');

// 전체 설정 조회
$allSettings = $service->get('vendor-module');

8.3 모듈 내부에서 자체 서비스 사용

모듈 내부 Service/Controller에서는 자체 설정 서비스를 직접 주입하여 사용할 수 있습니다. 모듈 전용 메서드(예: getStockDeductionTiming())가 필요한 경우 이 방식을 사용합니다.

// 모듈 내부 — 모듈 전용 메서드 접근이 필요한 경우
use Modules\Vendor\Module\Services\ModuleSettingsService;

public function __construct(
    private ModuleSettingsService $settingsService
) {}

// ModuleSettingsInterface 메서드 + 모듈 전용 메서드 모두 사용 가능
$value = $this->settingsService->getSetting('basic_info.shop_name');
$timing = $this->settingsService->getStockDeductionTiming($method);

판단 기준: getSetting() 단일 호출만 필요하면 module_setting() 헬퍼 사용, 모듈 전용 메서드가 필요하면 서비스 직접 주입


9. 카탈로그 병합 설정의 공개 응답

설정 항목의 목록을 다른 확장이 훅으로 등록하는 경우(결제수단, 배송사, 발급 제공자 등), 저장된 값은 그 카탈로그와 병합되어 응답에 실린다. 운영자가 저장한 값(is_active, 정렬 순서 등)과 확장이 제공하는 정의(이름, 아이콘, 지원 범위)가 항목마다 합쳐지는 구조다.

9.1 고아 항목이 생기는 조건

병합의 두 입력은 생명주기가 다르다. 저장값은 파일에 남지만 카탈로그는 요청 시점에 훅으로 다시 만들어지므로, 다음 상황에서 저장값만 남고 카탈로그에서는 사라진 항목이 생긴다.

  • 공급 확장을 삭제했다
  • 공급 확장을 비활성화했다
  • 확장이 살아 있지만 자기 기능 토글을 꺼서 해당 항목 등록을 중단했다

병합부는 이런 항목에 _orphaned 표시를 붙여 돌려준다. 이때 저장값의 is_active 는 참 그대로 남아 있다 — 운영자가 끈 적이 없기 때문이다.

9.2 관리자 응답과 공개 응답의 처리가 다르다

응답 고아 항목 이유
관리자 설정 조회 포함 운영자가 그 항목이 남아 있음을 확인하고 지울 수 있어야 한다. 화면은 _orphaned 를 읽어 편집 불가로 표시한다
공개(소비) 조회 제거 공급 확장이 더 이상 제공하지 않는 항목이다. 내보내면 사용자가 선택할 수 있게 된다
public function getPublicPaymentSettings(): array
{
    $orderSettings = $this->getSettings('order_settings');

    if (isset($orderSettings['payment_methods']) && is_array($orderSettings['payment_methods'])) {
        $orderSettings['payment_methods'] = array_values(array_filter(
            $orderSettings['payment_methods'],
            fn ($method) => ! ($method['_orphaned'] ?? false)
        ));
    }

    return $orderSettings;
}

array_values() 로 인덱스를 재정렬한다. 중간 항목을 지우면 키가 비연속이 되고, 그 배열은 JSON 객체로 직렬화되어 화면의 반복 렌더가 깨진다.

9.3 차단 지점은 공개 API 한 곳이다

소비 화면(레이아웃 JSON)의 필터에 맡기지 않는다. 같은 데이터를 그리는 템플릿이 여러 개면 필터가 화면 수만큼 복제되고, 한 곳만 빠져도 같은 결함이 그 화면에서만 남는다.

같은 데이터를 내보내는 공개 엔드포인트가 둘 이상이면 전부 같은 공개 게터를 경유해야 한다. 한쪽이 raw getSettings() 를 쓰면 그 경로만 조용히 뚫린다.

// ❌ 엔드포인트마다 다른 게터 — checkout 경로만 고아 항목이 새어 나간다
$orderSettings = $this->settingsService->getSettings('order_settings');

// ✅ 공개 엔드포인트는 모두 공개 게터를 경유
$orderSettings = $this->settingsService->getPublicPaymentSettings();

9.4 진단

이 결함은 예외도 경고도 로그도 남기지 않는다. 관리자 화면은 고아 표시로 정상 차단하고 있어, 관리자 응답과 공개 응답을 나란히 놓고 항목 수를 비교하기 전에는 드러나지 않는다.

# 두 응답의 항목 id 목록이 다르면, 그 차이가 고아 항목이다
GET /api/modules/{id}/admin/settings      # 고아 포함 (정상)
GET /api/modules/{id}/settings/payment    # 고아 제외 (정상)

10. 항목 단위 보충 병합과 삭제 의도

defaults.json 의 목록 설정(정수키 리스트)은 저장 시 array_merge 가 병합이 아니라 통째 교체를 수행한다. 그래서 관리자가 목록에서 일부 항목을 빼고 저장하면 defaults 항목이 저장본에서 사라지고, 그 상태가 그대로 유지된다. 이 통째 교체가 "관리자가 편집한 목록이 그대로 남는다" 는 뜻이라 대부분의 목록에는 옳은 동작이다.

문제는 여기에 항목 단위 보충 병합(저장본에 없는 defaults 항목을 code/key 기준으로 다시 채워 넣는 처리)을 얹을 때다. 보충은 보통 "의도치 않은 소실 복구" 를 목적으로 도입되는데, 소실과 삭제는 저장본에서 똑같이 "그 항목이 없다" 로 보이기 때문에 구분할 근거가 없다. 그 결과 관리자의 의도적 삭제까지 되돌아간다.

❌ 금지 ✅ 올바른 사용
보충 병합이 저장본에 없는 defaults 항목을 무조건 다시 채움 저장 시점에 삭제 의도를 도출해 저장본에 기록하고, 병합이 그 기록을 존중
삭제 기록을 클라이언트 제출값에서 받음 서버가 defaults 항목 − 제출 항목 − 삭제 불가 항목 으로 재계산 (제출된 같은 키는 버린다)
그 목록 키가 제출되지 않은 저장에서 기록만 이월 목록 값과 기록을 함께 이월 — 저장이 파일 통째 교체라 목록 키가 사라지면 defaults 가 다시 들어와 삭제가 전부 부활한다
항상 살아 있어야 하는 항목(기본 통화 등)까지 기록 대상에 포함 그 항목은 기록에서 제외해 보충 경로로 항상 생존시킨다
삭제 기록을 공개 응답에 그대로 노출 frontend_schema 의 필드 화이트리스트로 차단 (관리자 응답에는 남겨도 무해)
기록 배열을 비연속 키로 저장 array_values() 로 재정렬 — 비연속 키는 JSON 객체로 직렬화되어 화면 반복이 깨진다

이 결함은 예외도 경고도 로그도 남기지 않는다. 저장 요청은 200 을 반환하고, 그 응답 본문에 이미 삭제한 항목이 되살아나 있다.

보충 병합과 통째 교체를 혼동하지 않도록, 목록 설정을 다룰 때는 다음 세 가지를 구분해 판정한다.

모델 조회 시 처리 화면 삭제 의미
통째 교체 저장본을 그대로 사용 항목 추가·삭제 삭제가 그대로 유지된다
항목 단위 보충 저장본에 없는 defaults 항목을 채움 항목 추가·삭제 삭제 기록이 없으면 되돌아간다
카탈로그 병합 확장이 등록한 카탈로그와 저장값을 합침 is_active 토글 삭제 개념이 없다 (9장 참조)

11. 관련 문서


체크리스트

모듈 환경설정 구현 시 확인 사항:

  • config/settings/defaults.json 생성
  • ModuleSettingsInterface 구현 서비스 생성
  • 설정 컨트롤러 생성
  • API 라우트 등록
  • 레이아웃에 data_sources 추가
  • 저장 버튼 액션 연결
  • frontend_schema로 민감정보 필터링 설정