# 그누보드7 데이터베이스 개발 가이드 > 이 문서는 그누보드7의 데이터베이스 마이그레이션, 시더, 다국어 처리 규칙을 상세히 설명합니다. --- ## TL;DR (5초 요약) ```text 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(): 테이블/컬럼/인덱스 존재 확인 후 삭제 (방어적 코딩 필수) ``` --- ## 목차 - [마이그레이션](#마이그레이션) - [시더](#시더) - [로케일 설정](#로케일-설정) - [다국어 (Backend)](#다국어-backend) - [코어 테이블 다국어 지원](#코어-테이블-다국어-지원) --- ## 마이그레이션 **규칙**: - 한국어 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` **예시**: ```bash # 단일 컬럼 추가 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 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 에 방출되지 않는다. ```php // ✅ 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 를 다루지 않는다. 그래서 **에러도 경고도 없이 조용히 사라진다.** ```sql -- 잘못된 순서로 생성된 테이블의 실측 결과 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: ...` 인라인 주석 (의도적 비-멱등 사유 기재) ``` #### 멱등 작성 예시 ```php // ✅ 올바른 패턴 — 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 빌더가 멱등 가드를 직접 제공하지 않으므로 다음 패턴 중 하나 적용: ```php // 방식 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 } } ``` #### `->change()` 패턴의 멱등성 `->change()` 는 이미 존재하는 컬럼의 타입/제약 변경이므로 컬럼 부재 fatal 위험은 없다. 단, MariaDB/MySQL 의 일부 ALTER 가 비-멱등인 경우 (예: enum 값 추가 후 재실행) 가 있으므로 마이그레이션 작성자가 의도 검증 필요. 정적 검사는 `->change()` 라인을 자동 제외하므로 false positive 미발생. #### 검증 절차 단일 마이그레이션의 dry-run 출력을 두 번 적용해도 동일 출력인지 확인: ```bash 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 에 넣는다. ```php $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)** ```php // ✅ DO: dropIfExists 사용 (기본 패턴) public function down(): void { Schema::dropIfExists('products'); } // ❌ DON'T: drop 사용 (테이블 미존재 시 오류) public function down(): void { Schema::drop('products'); // 금지 } ``` **2. 컬럼/인덱스 추가 마이그레이션 (add_*_to_*_table)** ```php // ✅ 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 변경 마이그레이션** ```php // ✅ 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 버전별 인덱스 확인 방법 ```php // 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 설정은 허용**되지만, **삭제 시에는 어플리케이션에서 명시적으로 처리**해야 합니다: ```php // ✅ 마이그레이션: CASCADE 설정 가능 (안전망 역할) $table->foreignId('product_id') ->comment('상품 ID') ->constrained('products') ->onDelete('cascade'); // DB 레벨 안전망 // ✅ 마이그레이션: RESTRICT 설정 (삭제 차단이 필요한 경우) $table->foreignId('product_id') ->comment('상품 ID') ->constrained('products') ->restrictOnDelete(); // 참조 중이면 삭제 불가 ``` #### Service 삭제 메서드 작성 규칙 **모든 연관 데이터는 Service에서 명시적으로 삭제**해야 합니다: ```php // ✅ 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`를 사용합니다: ```php // 주문 이력이 있는 상품은 삭제 불가 $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 패턴 사용 ```text ⚠️ CRITICAL: install --force, module:seed, 업그레이드 재실행 시 시더가 반복 실행됨. 사용자 수정 데이터나 counter 값이 리셋되면 안 된다. ``` **재실행 안전 패턴 선택 기준**: | 엔티티 유형 | 권장 패턴 | 비고 | |------------|----------|------| | 사용자 수정 가능한 마스터 데이터 (예: 배송사, 클레임 사유, 게시판 유형) | `GenericEntitySyncHelper::sync()` + `cleanupStale()` | 모델에 `HasUserOverrides` trait + `$trackableFields` 필요 | | Counter/상태 유지 데이터 (예: 시퀀스 current_value) | `firstOrCreate` | 재실행 시 기존 레코드 완전 보존 | | 단순 정적 참조 데이터 | `updateOrCreate` | 사용자 수정 개념이 없는 경우 | **❌ 금지 패턴**: ```php // 전체 삭제 후 재삽입 — 사용자 수정 / 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)**: ```php $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 엔티티)**: ```php 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 패턴**: ```php 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, ]); } } } ``` **실행 명령어**: ```bash # 설치 시더만 (기본) 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 '관리자', '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` ```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}/`)이 존재해야 함 **예시**: ```php // ✅ 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 데이터만) - 새 언어 추가 시 이 배열에 언어 코드 추가 **예시**: ```php // ✅ 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** 을 사용합니다. ```php // ✅ 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](extension/language-packs.md) 레거시 (config 직접 편집) 방식: 1. **`config/app.php` 업데이트**: ```php 'supported_locales' => ['ko', 'en', 'ja'], // 일본어 추가 'translatable_locales' => ['ko', 'en', 'ja'], ``` 2. **번역 파일 생성** (supported_locales에 추가한 경우): ```bash mkdir -p lang/ja # 모든 번역 파일 복사 및 번역 ``` 3. **템플릿 메타데이터 업데이트**: ```json // 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]::` 접두사 **예시**: ```php // 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 구조**: ```json { "ko": "한국어 텍스트", "en": "English text" } ``` **모델 사용법**: ```php // 현재 로케일의 이름 반환 $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" ``` **시더 작성 규칙**: ```php // ✅ 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 다국어 지원**: ```php // ✅ 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 역호환성 처리**: ```php // 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. 둘 다 없으면 빈 문자열 반환 **엣지 케이스**: ```php // 한국어만 있는 경우 $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(); // "" (빈 문자열 반환) ``` ---