Skip to content

Latest commit

 

History

History
1089 lines (859 loc) · 36.3 KB

File metadata and controls

1089 lines (859 loc) · 36.3 KB

그누보드7 데이터베이스 개발 가이드

이 문서는 그누보드7의 데이터베이스 마이그레이션, 시더, 다국어 처리 규칙을 상세히 설명합니다.


TL;DR (5초 요약)

1. 마이그레이션: 한국어 comment 필수, down() 구현 필수
2. 네이밍: create_[table]_table, add_[col]_to_[table]_table
3. 로케일: config('app.supported_locales') 사용, 하드코딩 금지
4. 다국어 필드: JSON 구조 {"ko": "한글", "en": "English"}
5. 시더: 콘솔 메시지 필수, truncate() 대신 delete() 사용
6. 모듈/플러그인 down(): 테이블/컬럼/인덱스 존재 확인 후 삭제 (방어적 코딩 필수)

목차


마이그레이션

규칙:

  • 한국어 comment 필수
  • enum/boolean은 값 설명 포함
  • 외래키 제약조건 명시
  • down() 메서드 구현

파일명 네이밍 규칙:

  • 테이블 생성: create_[table_name]_table
  • 컬럼 추가: add_[column1]_and_[column2]_to_[table_name]_table
    • 단일 컬럼: add_[column_name]_to_[table_name]_table
    • 다중 컬럼: 모든 컬럼명을 _and_로 연결
  • 컬럼 삭제: remove_[column1]_and_[column2]_from_[table_name]_table
    • 단일 컬럼: remove_[column_name]_from_[table_name]_table
    • 다중 컬럼: 모든 컬럼명을 _and_로 연결
  • 컬럼 변경: modify_[column1]_and_[column2]_in_[table_name]_table
    • 단일 컬럼: modify_[column_name]_in_[table_name]_table
    • 다중 컬럼: 모든 컬럼명을 _and_로 연결
  • 복합 변경 (추가+삭제+변경): update_[main_feature]_fields_in_[table_name]_table
    • 예: update_user_profile_fields_in_users_table

예시:

# 단일 컬럼 추가
add_email_to_users_table

# 다중 컬럼 추가
add_first_name_and_last_name_and_phone_to_users_table

# 단일 컬럼 삭제
remove_nickname_from_users_table

# 다중 컬럼 삭제
remove_old_status_and_legacy_flag_from_products_table

# 단일 컬럼 변경
modify_price_in_products_table

# 다중 컬럼 변경
modify_name_and_description_in_categories_table

# 복합 변경 (3개 이상의 컬럼 또는 추가/삭제/변경 혼합)
update_template_version_fields_in_templates_table

패턴:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * 마이그레이션 실행
     */
    public function up(): void
    {
        Schema::create('products', function (Blueprint $table) {
            $table->id()->comment('상품 ID');
            $table->string('name')->comment('상품명');
            $table->text('description')->comment('상품 설명');
            $table->decimal('price', 10, 2)->comment('상품 가격');
            $table->foreignId('category_id')
                ->comment('카테고리 ID')
                ->constrained('product_categories')
                ->onDelete('cascade');
            $table->boolean('is_active')
                ->default(true)
                ->comment('활성화 여부 (1: 활성화, 0: 비활성화)');
            $table->enum('status', ['draft', 'published', 'archived'])
                ->default('draft')
                ->comment('상품 상태 (draft: 임시저장, published: 게시, archived: 보관)');
            $table->foreignId('created_by')->nullable()->comment('생성자 ID');
            $table->foreignId('updated_by')->nullable()->comment('수정자 ID');
            $table->timestamps();
            $table->softDeletes();

            // 인덱스
            $table->index('category_id');
            $table->index('is_active');
        });
    }

    /**
     * 마이그레이션 롤백
     */
    public function down(): void
    {
        Schema::dropIfExists('products');
    }
};

컬럼 comment 와 FK 체인 순서

->comment() 는 ->constrained() / ->references() / ->on() 앞에 둔다. 뒤에 체인하면 comment 가 컬럼이 아니라 외래키 정의에 부착되어 DB 에 방출되지 않는다.

// ✅ comment 가 컬럼에 부착된다
$table->foreignId('user_id')
    ->nullable()
    ->comment('사용자 ID')
    ->constrained('users')
    ->nullOnDelete();

// ❌ comment 가 사라진다 — COLUMN_COMMENT 가 빈 문자열로 생성된다
$table->foreignId('user_id')
    ->nullable()
    ->constrained('users')
    ->nullOnDelete()
    ->comment('사용자 ID');

원인은 반환 타입 전이다. ForeignIdColumnDefinition::constrained() 는 $this->references(...)->on(...) 을 반환하며 이것은 ColumnDefinition 이 아니라 ForeignKeyDefinition 이다. ForeignKeyDefinition 은 Fluent 라서 ->comment() 호출이 예외 없이 통과하지만, 그 값은 외래키 커맨드의 속성으로만 남고 MySQL grammar 의 compileForeign 은 comment 를 다루지 않는다. 그래서 에러도 경고도 없이 조용히 사라진다.

-- 잘못된 순서로 생성된 테이블의 실측 결과
SELECT COLUMN_NAME, COLUMN_COMMENT FROM information_schema.COLUMNS ...
g7_menus  name        [메뉴 이름 (다국어 JSON)]
g7_menus  created_by  []          ← 소스에는 comment 가 적혀 있다

이미 migrate 를 마친 설치본은 마이그레이션을 다시 실행하지 않으므로, 소스를 교정해도 기존 DB 의 comment 는 비어 있는 채로 남는다. 소스 교정과 업그레이드 스텝 백필을 함께 수행한다. 백필은 comment 가 비어 있을 때만 채워 운영자가 직접 넣은 값을 보존하고, 자료형·NULL 허용·기본값을 현재 스키마에서 읽어 그대로 재적용해 설명 외에는 아무 것도 바꾸지 않는다.

면제: // audit:allow migration-fk-comment-order <사유> 인라인 주석 (해당 구문에 부착)

마이그레이션 멱등성

필수: Schema::table 안의 컬럼 추가 (`$table->{type}('{column}')`) 는 Schema::hasColumn 가드와 함께 작성
배경: 코어 업그레이드 도중 마이그레이션이 부분 적용된 채 fatal 한 경우, `core:update --force` 재실행 시 이미 적용된 컬럼이 재시도되어 "Column already exists" SQL error 가 발생
면제: `// audit:allow migration-idempotency-guard reason: ...` 인라인 주석 (의도적 비-멱등 사유 기재)

멱등 작성 예시

// ✅ 올바른 패턴 — Schema::hasColumn 가드
public function up(): void
{
    Schema::table('users', function (Blueprint $table): void {
        if (! Schema::hasColumn('users', 'locked_until')) {
            $table->timestamp('locked_until')->nullable()->after('email');
        }
        if (! Schema::hasColumn('users', 'last_failed_login_at')) {
            $table->timestamp('last_failed_login_at')->nullable();
        }
    });
}

// ❌ 잘못된 패턴 — 가드 부재
public function up(): void
{
    Schema::table('users', function (Blueprint $table): void {
        $table->timestamp('locked_until')->nullable();      // partial migrate 후 재실행 시 fatal
        $table->timestamp('last_failed_login_at')->nullable();
    });
}

인덱스 추가의 멱등성

인덱스 추가는 Laravel 의 Schema 빌더가 멱등 가드를 직접 제공하지 않으므로 다음 패턴 중 하나 적용:

// 방식 1 — Schema::hasIndex (Laravel 11+ Schema::getIndexes 기반)
public function up(): void
{
    Schema::table('users', function (Blueprint $table): void {
        if (! collect(Schema::getIndexes('users'))->pluck('name')->contains('users_email_idx')) {
            $table->index('email', 'users_email_idx');
        }
    });
}

// 방식 2 — try/catch (인덱스명이 명시되지 않은 경우)
public function up(): void
{
    try {
        Schema::table('users', function (Blueprint $table): void {
            $table->index('email');
        });
    } catch (\Throwable $e) {
        // 이미 존재 — 멱등 skip
    }
}

정렬을 덮는 복합 인덱스의 선행 컬럼

인덱스가 ORDER BY 를 덮으려면 선행 컬럼이 모두 등치로 고정되고 그 다음 컬럼이 정렬 컬럼이어야 한다. 선택적 필터 컬럼을 선행에 두면, 그 필터를 걸지 않은 기본 진입에서 정렬을 못 덮어 filesort 가 그대로 남는다.

// ❌ status 는 선택적 필터 — 필터 없는 기본 목록에서 created_at 정렬을 못 덮는다
$table->index(['status', 'created_at']);

// ✅ SoftDeletes 가 항상 `deleted_at IS NULL` 을 붙이므로 선행 컬럼이 고정된다
$table->index(['deleted_at', 'created_at']);

필터 조합이 다양한 목록은 단일 최적 인덱스가 없다. 항상 걸리는 조건을 선행에 두고 하나만 추가한다 — 조합마다 인덱스를 추가하면 쓰기 비용만 늘어난다.

카디널리티가 극히 낮은 컬럼(deleted_at 처럼 대부분 NULL)의 단독 인덱스는 옵티마이저가 좁은 range 로 오판해 index_merge 후보로 올리므로 거의 항상 해롭다. 다만 기존 단독 인덱스를 제거할 때는 다른 쿼리가 그것에 의존할 수 있으므로, 제거 전 해당 컬럼을 쓰는 쿼리를 전수 확인한다.

후속 검토 항목: g7_board_posts_deleted_at_index(단독) — 지연 조인 전환으로 index_merge 는 사라졌으나 인덱스 자체는 남아 있다. 의존 쿼리 전수 확인 후 제거 여부를 별도로 판단한다. board_posts 에 정렬 전용 복합 인덱스를 추가하지 않기로 판정한 근거: 지연 조인 적용 후 재측정에서 index_merge 가 소멸했고 inner 가 기존 idx_board_posts_list_count 를 커버링으로 사용한다. 후보 인덱스 (board_id, is_notice, parent_id, deleted_at, id) 를 실제로 만들어 강제 비교했을 때의 이득이 1.6배에 그쳐 쓰기 비용 대비 근거가 부족하다. 계측 데이터가 단일 게시판·공지 없음·답글 없음의 균일 분포라 두 후보의 선택도가 같아지는 한계가 있어, 실데이터 분포에서 재확인이 필요하다.

->change() 패턴의 멱등성

->change() 는 이미 존재하는 컬럼의 타입/제약 변경이므로 컬럼 부재 fatal 위험은 없다. 단, MariaDB/MySQL 의 일부 ALTER 가 비-멱등인 경우 (예: enum 값 추가 후 재실행) 가 있으므로 마이그레이션 작성자가 의도 검증 필요. 정적 검사는 ->change() 라인을 자동 제외하므로 false positive 미발생.

검증 절차

단일 마이그레이션의 dry-run 출력을 두 번 적용해도 동일 출력인지 확인:

php artisan migrate --pretend --path=database/migrations/2026_05_05_172526_add_login_attempt_columns_to_users_table.php

신규 마이그레이션 작성 후에는 같은 마이그레이션을 두 번 실행하는 시나리오를 PHPUnit Feature 테스트로 작성해 SQL error 미발생을 검증한다 (tests/Feature/Upgrades/MultiVersionUpgradePathTest.php 의 hasColumn_가드된_마이그레이션은_두_번_적용해도_SQL_error_없다 사례 참조).


긴 문자열 컬럼의 unique — 해시 컬럼 패턴 (sitemap_urls 사례)

utf8mb4(4 byte/char) 긴 문자열 컬럼을 unique 인덱스에 직접 넣으면 InnoDB 키 길이 제한(3072 byte)을 넘겨 마이그레이션이 실패한다. 예: sitemap_urls 의 identity 는 (resource_type, resource_id, loc) 인데 loc string(2048) 을 넣으면 256+256+8192 = 8704 byte 로 초과한다.

해결: identity 문자열의 고정 길이 해시 컬럼을 두고 그것을 unique 에 넣는다.

$table->string('loc', 2048);                 // 실제 값 (utf8mb4)
$table->char('loc_hash', 64)->charset('ascii'); // sha256(loc) — ascii 64 byte
$table->unique(['resource_type', 'resource_id', 'loc_hash']); // 64+256+256 = 576 byte
  • 해시는 app 레벨(hash('sha256', $loc))에서 계산한다 — raw SQL/DB 함수 비의존(다국어·DB 이식성 규율).
  • loc 는 실제 서빙/표시에 쓰고, loc_hash 는 오직 identity 매칭용이다.
  • upsert 는 delete-후-insert 로 멱등을 보장하고, 이 unique 가 중복 삽입(1062)을 차단한다.

sitemap_urls 스키마: resource_type(64,index) · resource_id(64,nullable) · loc(2048) · loc_hash(64,ascii) · lastmod · changefreq(16) · priority(2,1) · contributor(64,index) · is_visible(bool,index) + unique(resource_type, resource_id, loc_hash) + index(contributor, is_visible, id)(리빌드 스트리밍 커서). 모든 컬럼 한국어 ->comment(), down() 구현, Schema::hasTable 멱등 가드.


모듈/플러그인 마이그레이션 down() 메서드

주의: 모듈/플러그인은 삭제 시 마이그레이션 롤백이 빈번하게 발생
필수: down() 메서드에서 테이블/컬럼/인덱스 존재 여부 확인 후 삭제
✅ 필수: Laravel 11+ 에서는 Schema::getIndexes() 사용 (getDoctrineSchemaManager 미지원)
✅ 필수: NOT NULL 변경 시 NULL 데이터 존재 여부 확인

왜 방어적 코딩이 필요한가?

모듈/플러그인 삭제 시 delete_data: true 옵션으로 마이그레이션 롤백이 실행됩니다. 이때:

문제 상황 발생 원인 결과
테이블 미존재 이전 롤백 실패, 수동 삭제 SQL 오류 발생
인덱스 미존재 부분 롤백, 스키마 불일치 SQL 오류 발생
NULL 데이터 존재 NOT NULL 변경 시도 SQL 오류 발생

오류 발생 시 해당 마이그레이션 롤백이 실패하고, 이후 마이그레이션도 롤백되지 않습니다.

down() 메서드 작성 규칙

1. 테이블 생성 마이그레이션 (create_*_table)

// ✅ DO: dropIfExists 사용 (기본 패턴)
public function down(): void
{
    Schema::dropIfExists('products');
}

// ❌ DON'T: drop 사용 (테이블 미존재 시 오류)
public function down(): void
{
    Schema::drop('products');  // 금지
}

2. 컬럼/인덱스 추가 마이그레이션 (add_to_table)

// ✅ DO: 테이블, 인덱스, 컬럼 존재 여부 확인 후 삭제
public function down(): void
{
    // 1. 테이블 존재 여부 확인
    if (! Schema::hasTable('ecommerce_product_images')) {
        return;
    }

    // 2. 인덱스 존재 여부 확인 (Laravel 11+)
    $tableName = Schema::getConnection()->getTablePrefix().'ecommerce_product_images';
    $indexes = Schema::getIndexes('ecommerce_product_images');
    $indexNames = array_column($indexes, 'name');

    Schema::table('ecommerce_product_images', function (Blueprint $table) use ($tableName, $indexNames) {
        // 인덱스 존재 시에만 삭제
        if (in_array($tableName.'_temp_key_index', $indexNames)) {
            $table->dropIndex(['temp_key']);
        }

        // 컬럼 존재 시에만 삭제
        if (Schema::hasColumn('ecommerce_product_images', 'temp_key')) {
            $table->dropColumn('temp_key');
        }
    });
}

// ❌ DON'T: 존재 여부 확인 없이 삭제 (금지)
public function down(): void
{
    Schema::table('ecommerce_product_images', function (Blueprint $table) {
        $table->dropIndex(['temp_key']);  // 인덱스 미존재 시 오류
        $table->dropColumn('temp_key');   // 컬럼 미존재 시 오류
    });
}

3. nullable 변경 마이그레이션

// ✅ DO: NULL 데이터 존재 여부 확인 후 NOT NULL 변경
public function down(): void
{
    if (! Schema::hasColumn('products', 'category_id')) {
        return;
    }

    // NULL 데이터가 있으면 NOT NULL 변경 불가
    $hasNullData = DB::table('products')
        ->whereNull('category_id')
        ->exists();

    if (! $hasNullData) {
        Schema::table('products', function (Blueprint $table) {
            $table->unsignedBigInteger('category_id')
                ->nullable(false)
                ->comment('카테고리 ID')
                ->change();
        });
    }
}

// ❌ DON'T: NULL 데이터 확인 없이 NOT NULL 변경 (금지)
public function down(): void
{
    Schema::table('products', function (Blueprint $table) {
        // NULL 데이터 존재 시 SQL 오류 발생
        $table->unsignedBigInteger('category_id')->nullable(false)->change();
    });
}

down() 메서드 체크리스트

모듈/플러그인 마이그레이션 down() 작성 시 반드시 확인:

□ 1. 테이블 존재 여부: Schema::hasTable() 사용
□ 2. 컬럼 존재 여부: Schema::hasColumn() 사용
□ 3. 인덱스 존재 여부: Schema::getIndexes() 사용 (Laravel 11+)
□ 4. NOT NULL 변경 시: NULL 데이터 존재 여부 확인
□ 5. 외래키 삭제 시: 외래키 존재 여부 확인
□ 6. dropIfExists() 사용: 테이블 삭제 시

Laravel 버전별 인덱스 확인 방법

// Laravel 11+ (권장)
$indexes = Schema::getIndexes('table_name');
$indexNames = array_column($indexes, 'name');
if (in_array('index_name', $indexNames)) {
    // 인덱스 존재
}

// Laravel 10 이하 (Doctrine DBAL 필요)
$sm = Schema::getConnection()->getDoctrineSchemaManager();
$indexes = $sm->listTableIndexes($tableName);
if (isset($indexes['index_name'])) {
    // 인덱스 존재
}

외래 키 및 삭제 정책

필수: Service에서 명시적 삭제 (DB CASCADE에 의존한 삭제 금지)
필수: 어플리케이션(Service)에서 명시적으로 연관 데이터 삭제
✅ 필수: 삭제 순서 보장, 훅 실행, 로깅, 파일 삭제 등 어플리케이션 로직 처리

왜 DB CASCADE에 의존하면 안 되는가?

DB CASCADE를 사용하면 다음 문제가 발생합니다:

문제 설명
훅 미실행 before_delete, after_delete 훅이 실행되지 않음
파일 미삭제 Storage에 저장된 파일이 남음 (이미지, 첨부파일 등)
로깅 불가 삭제 이력을 추적할 수 없음
순서 미보장 삭제 순서를 제어할 수 없음
디버깅 어려움 어떤 데이터가 삭제되었는지 파악 어려움

마이그레이션 작성 규칙

마이그레이션에서 CASCADE 설정은 허용되지만, 삭제 시에는 어플리케이션에서 명시적으로 처리해야 합니다:

// ✅ 마이그레이션: CASCADE 설정 가능 (안전망 역할)
$table->foreignId('product_id')
    ->comment('상품 ID')
    ->constrained('products')
    ->onDelete('cascade');  // DB 레벨 안전망

// ✅ 마이그레이션: RESTRICT 설정 (삭제 차단이 필요한 경우)
$table->foreignId('product_id')
    ->comment('상품 ID')
    ->constrained('products')
    ->restrictOnDelete();  // 참조 중이면 삭제 불가

Service 삭제 메서드 작성 규칙

모든 연관 데이터는 Service에서 명시적으로 삭제해야 합니다:

// ✅ DO: 명시적 삭제 (필수)
public function delete(Product $product): bool
{
    HookManager::doAction('module.entity.before_delete', $product);

    return DB::transaction(function () use ($product) {
        // 1. 파일 물리적 삭제 (Storage)
        $this->deleteProductImageFiles($product);

        // 2. 연관 데이터 명시적 삭제 (순서 중요)
        $product->images()->delete();
        $product->options()->delete();
        $product->additionalOptions()->delete();
        $product->labelAssignments()->delete();
        $product->logs()->delete();
        $product->notice()->delete();
        $product->categories()->detach();  // 중간 테이블

        // 3. 메인 레코드 삭제
        $result = $this->repository->delete($product);

        HookManager::doAction('module.entity.after_delete', $product);

        return $result;
    });
}

// ❌ DON'T: CASCADE에 의존 (금지)
public function delete(Product $product): bool
{
    // 연관 데이터 삭제 없이 바로 삭제 - 금지!
    return $this->repository->delete($product);
}

삭제 메서드 체크리스트

Service에서 delete() 메서드 작성 시 반드시 확인:

□ 1. 훅 실행 (before_delete, after_delete)
□ 2. DB 트랜잭션 사용
□ 3. 파일 삭제 (Storage에 저장된 이미지, 첨부파일 등)
□ 4. 모든 HasMany 관계 명시적 삭제
□ 5. 모든 HasOne 관계 명시적 삭제
□ 6. 모든 BelongsToMany 중간 테이블 detach
□ 7. TODO 주석으로 미구현 연관 데이터 명시
□ 8. 메인 레코드 삭제

예외: RESTRICT 사용 케이스

비즈니스 규칙상 삭제를 차단해야 하는 경우 RESTRICT를 사용합니다:

// 주문 이력이 있는 상품은 삭제 불가
$table->foreignId('product_id')
    ->constrained('products')
    ->restrictOnDelete();  // ✅ 적절한 사용

// Service에서 삭제 전 체크
public function checkCanDelete(Product $product): array
{
    $ordersCount = OrderOption::where('product_id', $product->id)->count();

    return [
        'canDelete' => $ordersCount === 0,
        'reason' => $ordersCount > 0
            ? __('messages.has_order_history', ['count' => $ordersCount])
            : null,
    ];
}

시더

규칙:

  • 콘솔 메시지 (시작/완료/삭제 건수) 필수
  • 메서드 분리 (delete*, create*)
  • truncate() 대신 delete() 사용
  • PHPDoc 주석 한국어
  • 재실행 안전성 필수: delete + insert 패턴 금지 → upsert 패턴 사용
⚠️ CRITICAL: install --force, module:seed, 업그레이드 재실행 시 시더가 반복 실행됨.
사용자 수정 데이터나 counter 값이 리셋되면 안 된다.

재실행 안전 패턴 선택 기준:

엔티티 유형 권장 패턴 비고
사용자 수정 가능한 마스터 데이터 (예: 배송사, 클레임 사유, 게시판 유형) GenericEntitySyncHelper::sync() + cleanupStale() 모델에 HasUserOverrides trait + $trackableFields 필요
Counter/상태 유지 데이터 (예: 시퀀스 current_value) firstOrCreate 재실행 시 기존 레코드 완전 보존
단순 정적 참조 데이터 updateOrCreate 사용자 수정 개념이 없는 경우

❌ 금지 패턴:

// 전체 삭제 후 재삽입 — 사용자 수정 / counter 손실
Model::query()->delete();
foreach ($items as $item) {
    Model::create($item);
}

// 특정 type 범위 삭제 후 재삽입 — 동일 문제
Model::where('type', $type)->delete();
foreach ($items as $item) {
    Model::create($item);
}

✅ 안전 패턴 (GenericEntitySyncHelper):

$helper = app(GenericEntitySyncHelper::class);
$codes = [];
foreach ($items as $item) {
    $helper->sync(Model::class, ['code' => $item['code']], $item);
    $codes[] = $item['code'];
}
// 시더 정의에서 제거된 row 만 정리
$helper->cleanupStale(Model::class, ['type' => 'foo'], 'code', $codes);

✅ 안전 패턴 (firstOrCreate — counter 엔티티):

Model::firstOrCreate(
    ['type' => $type->value],  // unique 조건
    [ /* 최초 생성 시에만 사용될 초기값 */ ],
);

시더 디렉토리 구조

설치 필수 시더와 샘플(개발용) 시더는 반드시 분리
✅ 설치 시더: database/seeders/ 루트에 배치
✅ 샘플 시더: database/seeders/Sample/ 하위에 배치
✅ 기본 실행(db:seed, module:seed, plugin:seed)은 설치 시더만 실행
✅ --sample 옵션 시에만 샘플 시더 추가 실행

디렉토리 구조:

database/seeders/
├── DatabaseSeeder.php         # 설치 시더 호출 + 조건부 샘플 호출
├── AdminUserSeeder.php        # 설치 필수
├── RolePermissionSeeder.php   # 설치 필수
└── Sample/                    # 샘플(개발용) 시더
    ├── DummyUserSeeder.php
    └── TemplateSeeder.php

HasSampleSeeders 트레이트 (app/Traits/HasSampleSeeders.php):

  • DatabaseSeeder에서 use HasSampleSeeders; 사용
  • shouldIncludeSample(): --sample 옵션 여부 확인
  • 커맨드에서 setIncludeSample() 호출로 전파

DatabaseSeeder 패턴:

use App\Traits\HasSampleSeeders;

class DatabaseSeeder extends Seeder
{
    use HasSampleSeeders;

    public function run(): void
    {
        // 설치 필수 시더 (항상 실행)
        $this->call([
            AdminUserSeeder::class,
        ]);

        // 샘플 시더 (--sample 옵션 시에만 실행)
        if ($this->shouldIncludeSample()) {
            $this->call([
                Sample\DummyUserSeeder::class,
            ]);
        }
    }
}

실행 명령어:

# 설치 시더만 (기본)
php artisan db:seed
php artisan module:seed sirsoft-ecommerce

# 설치 + 샘플
php artisan db:seed --sample
php artisan module:seed sirsoft-ecommerce --sample

# 개별 샘플 시더
php artisan db:seed --class="Database\Seeders\Sample\DummyUserSeeder"
php artisan module:seed sirsoft-ecommerce --class=Sample\\ProductSeeder

패턴:

<?php

namespace Database\Seeders\Core;

use Illuminate\Database\Seeder;
use App\Models\User;
use App\Models\Role;

class AdminUserSeeder extends Seeder
{
    /**
     * 기본 관리자 정보
     */
    private array $defaultAdminUser = [
        'name' => '관리자',
        'email' => 'admin@example.com',
        'password' => 'password',
    ];

    /**
     * 시더 실행
     */
    public function run(): void
    {
        $this->command->info('기본 관리자 사용자 생성을 시작합니다.');

        // 기존 데이터 삭제
        $this->deleteExistingUsers();

        // 새 데이터 생성
        $this->createAdminUser($this->defaultAdminUser);

        $this->command->info('기본 관리자 사용자가 성공적으로 생성되었습니다.');
    }

    /**
     * 기존 사용자 삭제
     *
     * @return void
     */
    private function deleteExistingUsers(): void
    {
        $deletedCount = User::where('email', $this->defaultAdminUser['email'])->delete();

        if ($deletedCount > 0) {
            $this->command->warn("기존 관리자 사용자 {$deletedCount}건을 삭제했습니다.");
        }
    }

    /**
     * 관리자 사용자 생성
     *
     * @param array $userData 사용자 데이터
     * @return void
     */
    private function createAdminUser(array $userData): void
    {
        $adminRole = Role::where('name', 'admin')->first();

        $user = User::create([
            'name' => $userData['name'],
            'email' => $userData['email'],
            'password' => bcrypt($userData['password']),
            'email_verified_at' => now(),
        ]);

        $user->roles()->attach($adminRole->id);

        $this->command->info("관리자 사용자가 생성되었습니다: {$user->email}");
    }
}

로케일 설정

MANDATORY: supported_locales와 translatable_locales는 config/app.php에서 관리
✅ 필수: 하드코딩 금지, config() 함수 사용 필수

supported_locales vs translatable_locales

위치: config/app.php

// config/app.php
'supported_locales' => ['ko', 'en'],        // UI 언어 전환에 사용
'translatable_locales' => ['ko', 'en'],     // 다국어 필드 데이터 저장에 사용

supported_locales

용도: 시스템에서 지원하는 모든 UI 언어 목록

사용처:

  • SetLocale 미들웨어: 사용자 언어 검증
  • 템플릿 엔진: 언어 선택자 옵션 생성 ($locales 전역 변수)
  • 프론트엔드: template.json의 locales 필드 (템플릿별 지원 언어)

규칙:

  • 반드시 config('app.supported_locales')로 접근
  • 하드코딩(['ko', 'en']) 금지
  • 번역 파일(/lang/{locale}/)이 존재해야 함

예시:

// ✅ DO: config 함수 사용
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
if (in_array($locale, $supportedLocales)) {
    // ...
}

// ❌ DON'T: 하드코딩
if (in_array($locale, ['ko', 'en'])) {  // 금지
    // ...
}

translatable_locales

용도: 다국어 필드(JSON 타입)에서 허용하는 언어 목록

사용처:

  • 다국어 테이블 필드 (permissions.name, roles.description 등)
  • FormRequest 검증 (예: UpdateModuleRequest)
  • Model Accessor/Mutator (getLocalizedName() 등)

규칙:

  • 반드시 config('app.translatable_locales')로 접근
  • 번역 파일이 없어도 데이터 저장은 허용 (DB 데이터만)
  • 새 언어 추가 시 이 배열에 언어 코드 추가

예시:

// ✅ DO: FormRequest에서 사용
public function rules(): array
{
    $locales = config('app.translatable_locales', ['ko', 'en']);
    $rules = [];

    foreach ($locales as $locale) {
        $rules["name.{$locale}"] = ['required', 'string'];
    }

    return $rules;
}

// ✅ DO: Model에서 사용
public function getLocalizedName(?string $locale = null): string
{
    $locale = $locale ?? app()->getLocale();
    $translatable = config('app.translatable_locales', ['ko', 'en']);

    // Locale별 폴백 처리
    // ...
}

두 설정의 차이점

구분 supported_locales translatable_locales
용도 UI 언어 전환 다국어 필드 데이터
번역 파일 필수 (/lang/{locale}/) 선택 (없어도 됨)
사용 예 언어 선택자, 미들웨어 DB JSON 필드, FormRequest
검증 위치 런타임 (미들웨어) 데이터 입력 시 (FormRequest)

언어팩 시스템과의 관계

config/app.php 의 supported_locales / translatable_locales / locale_names 는 boot 시점에 LanguagePackServiceProvider::refreshSupportedLocales() 가 활성 코어 언어팩의 locale 을 합쳐 동적으로 갱신합니다. 따라서:

  • 새 locale 은 config/app.php 편집이 아니라 언어팩 설치/활성화 로 추가합니다.
  • 코드에서 config('app.supported_locales') / config('app.translatable_locales') 를 호출하면 이미 활성 언어팩이 반영된 결과가 반환됩니다.
  • 두 config 는 항상 동기화됩니다. supported_locales 에는 ja 가 있는데 translatable_locales 에는 없는 상태가 되지 않도록 provider 가 함께 갱신합니다.

다국어 fallback chain 정책

다국어 JSON 필드 (permissions.name, module.name, 모듈 모델의 name/description 등) 에서 현재 locale 의 값을 반환할 때는 config('app.fallback_locale', 'ko') 기반 fallback chain 을 사용합니다.

// ✅ DO: app.fallback_locale config 기반
return $name[$locale]
    ?? $name[config('app.fallback_locale', 'ko')]
    ?? (! empty($name) ? array_values($name)[0] : '')
    ?? '';

// ❌ DON'T: ko / en 하드코딩
return $name[$locale]
    ?? $name['ko']
    ?? $name['en']
    ?? '';

운영자가 APP_FALLBACK_LOCALE 환경변수로 폴백 locale 을 ko 외 값으로 변경할 수 있도록 하기 위함입니다. ko 가 fallback 인 환경에서는 두 패턴이 동일 결과지만, 환경 변경 시 하드코딩 패턴은 의도와 어긋난 ko 폴백을 보입니다.

새 언어 추가 절차

권장: 언어팩 시스템 사용

  1. 번들 언어팩 패키지 준비 (lang-packs/_bundled/g7-core-{locale}/ 디렉토리에 매니페스트와 backend/frontend 파일 작성)
  2. 설치 + 활성화: php artisan language-pack:install g7-core-{locale} --source=bundled
  3. provider 가 boot 시 자동으로 supported_locales / translatable_locales / locale_names 갱신

상세: extension/language-packs.md

레거시 (config 직접 편집) 방식:

  1. config/app.php 업데이트:

    'supported_locales' => ['ko', 'en', 'ja'],  // 일본어 추가
    'translatable_locales' => ['ko', 'en', 'ja'],
  2. 번역 파일 생성 (supported_locales에 추가한 경우):

    mkdir -p lang/ja
    # 모든 번역 파일 복사 및 번역
  3. 템플릿 메타데이터 업데이트:

    // templates/sirsoft-admin_basic/template.json
    {
      "locales": ["ko", "en", "ja"]
    }

다국어 (Backend)

기본 지원 언어:

  • 한국어 (ko): 기본 언어
  • 영어 (en): 필수 지원 언어

규칙:

  • 모든 다국어 파일은 ko, en 두 언어를 필수로 제공
  • 새로운 기능 추가 시 반드시 두 언어 모두 작성
  • 언어 목록은 config('app.supported_locales')에서 관리

위치:

  • 코어: /lang/{ko,en}/
  • 모듈: /modules/[vendor-module]/src/lang/{ko,en}/
  • 플러그인: /plugins/[vendor-plugin]/src/lang/{ko,en}/

네이밍:

  • 코어: 접두사 없음
  • 모듈: [vendor-module]:: 접두사 (예: sirsoft-ecommerce::)
  • 플러그인: [vendor-plugin]:: 접두사

예시:

// modules/_bundled/sirsoft-ecommerce/src/lang/ko/products.php

return [
    'title' => '상품 관리',
    'create' => '상품 생성',
    'edit' => '상품 수정',
    'delete_confirm' => '이 상품을 삭제하시겠습니까?',
    'messages' => [
        'created' => '상품이 생성되었습니다.',
        'updated' => '상품이 수정되었습니다.',
        'deleted' => '상품이 삭제되었습니다.',
    ],
];

// 사용
__('sirsoft-ecommerce::products.title');
__('sirsoft-ecommerce::products.messages.created');

코어 테이블 다국어 지원

MANDATORY: permissions, roles, menus, modules, plugins 테이블 다국어 필수
✅ 필수: JSON 구조 사용 {"ko": "한국어", "en": "English"}

지원 테이블 및 필드:

테이블 다국어 필드
permissions name, description
roles name, description
menus name
modules name, description
plugins name, description

JSON 구조:

{
  "ko": "한국어 텍스트",
  "en": "English text"
}

모델 사용법:

// 현재 로케일의 이름 반환
$permission->getLocalizedName();      // 현재 로케일
$permission->getLocalizedName('ko');  // 한국어
$permission->getLocalizedName('en');  // 영어

// Accessor 사용
$permission->localized_name;          // 현재 로케일
$permission->localized_description;    // 현재 로케일

// 예시
app()->setLocale('ko');
echo $permission->getLocalizedName();  // "사용자 조회"

app()->setLocale('en');
echo $permission->getLocalizedName();  // "View Users"

시더 작성 규칙:

// ✅ DO: 다국어 배열 구조
Permission::create([
    'identifier' => 'users.read',
    'name' => [
        'ko' => '사용자 조회',
        'en' => 'View Users',
    ],
    'description' => [
        'ko' => '사용자 목록을 조회할 수 있습니다.',
        'en' => 'Can view user list.',
    ],
]);

Menu::create([
    'name' => [
        'ko' => '대시보드',
        'en' => 'Dashboard',
    ],
    'slug' => 'dashboard',
]);

// ❌ DON'T: 문자열 사용 (레거시, 권장하지 않음)
Permission::create([
    'identifier' => 'users.read',
    'name' => '사용자 조회',  // 지양
]);

ModuleInterface, PluginInterface 다국어 지원:

// ✅ DO: 다국어 배열 반환 (권장)
public function getName(): array
{
    return [
        'ko' => '이커머스 모듈',
        'en' => 'Ecommerce Module',
    ];
}

public function getDescription(): array
{
    return [
        'ko' => '온라인 쇼핑몰 기능을 제공합니다.',
        'en' => 'Provides online shopping features.',
    ];
}

// 역호환: 문자열 반환 (자동 변환됨)
public function getName(): string
{
    return 'Ecommerce Module';  // 자동으로 ['ko' => '...', 'en' => '...']로 변환
}

FormRequest 역호환성 처리:

// UpdateModuleRequest.php 예시
protected function prepareForValidation(): void
{
    $locales = config('app.translatable_locales', ['ko', 'en']);

    // name이 문자열로 들어온 경우 배열로 자동 변환
    if ($this->has('name') && is_string($this->name)) {
        $nameArray = [];
        foreach ($locales as $locale) {
            $nameArray[$locale] = $this->name;
        }
        $this->merge(['name' => $nameArray]);
    }
}

폴백 동작:

  1. 요청한 로케일에 번역이 있으면 반환
  2. 없으면 'ko' (기본 로케일)로 폴백
  3. 'ko'도 없으면 'en'으로 폴백
  4. 둘 다 없으면 빈 문자열 반환

엣지 케이스:

// 한국어만 있는 경우
$permission->name = ['ko' => '테스트'];
$permission->getLocalizedName('en');  // "테스트" (ko로 폴백)

// 영어만 있는 경우
$role->name = ['en' => 'Test'];
$role->getLocalizedName('ko');  // "Test" (en으로 폴백)

// 빈 번역
$menu->name = ['ko' => '메뉴', 'en' => ''];
$menu->getLocalizedName('en');  // "" (빈 문자열 반환)

// NULL 값
$module->description = null;
$module->getLocalizedDescription();  // "" (빈 문자열 반환)