이 문서는 그누보드7의 데이터베이스 마이그레이션, 시더, 다국어 처리 규칙을 상세히 설명합니다.
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() 는 ->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() 는 이미 존재하는 컬럼의 타입/제약 변경이므로 컬럼 부재 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_없다 사례 참조).
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() 메서드에서 테이블/컬럼/인덱스 존재 여부 확인 후 삭제
✅ 필수: Laravel 11+ 에서는 Schema::getIndexes() 사용 (getDoctrineSchemaManager 미지원)
✅ 필수: NOT NULL 변경 시 NULL 데이터 존재 여부 확인
모듈/플러그인 삭제 시 delete_data: true 옵션으로 마이그레이션 롤백이 실행됩니다. 이때:
| 문제 상황 | 발생 원인 | 결과 |
|---|---|---|
| 테이블 미존재 | 이전 롤백 실패, 수동 삭제 | SQL 오류 발생 |
| 인덱스 미존재 | 부분 롤백, 스키마 불일치 | SQL 오류 발생 |
| NULL 데이터 존재 | NOT NULL 변경 시도 | SQL 오류 발생 |
오류 발생 시 해당 마이그레이션 롤백이 실패하고, 이후 마이그레이션도 롤백되지 않습니다.
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() 작성 시 반드시 확인:
□ 1. 테이블 존재 여부: Schema::hasTable() 사용
□ 2. 컬럼 존재 여부: Schema::hasColumn() 사용
□ 3. 인덱스 존재 여부: Schema::getIndexes() 사용 (Laravel 11+)
□ 4. NOT NULL 변경 시: NULL 데이터 존재 여부 확인
□ 5. 외래키 삭제 시: 외래키 존재 여부 확인
□ 6. dropIfExists() 사용: 테이블 삭제 시
// 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를 사용하면 다음 문제가 발생합니다:
| 문제 | 설명 |
|---|---|
| 훅 미실행 | 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에서 명시적으로 삭제해야 합니다:
// ✅ 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를 사용합니다:
// 주문 이력이 있는 상품은 삭제 불가
$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() 함수 사용 필수
위치: config/app.php
// config/app.php
'supported_locales' => ['ko', 'en'], // UI 언어 전환에 사용
'translatable_locales' => ['ko', 'en'], // 다국어 필드 데이터 저장에 사용용도: 시스템에서 지원하는 모든 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'])) { // 금지
// ...
}용도: 다국어 필드(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 가 함께 갱신합니다.
다국어 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 폴백을 보입니다.
권장: 언어팩 시스템 사용
- 번들 언어팩 패키지 준비 (
lang-packs/_bundled/g7-core-{locale}/디렉토리에 매니페스트와 backend/frontend 파일 작성) - 설치 + 활성화:
php artisan language-pack:install g7-core-{locale} --source=bundled - provider 가 boot 시 자동으로 supported_locales / translatable_locales / locale_names 갱신
상세: extension/language-packs.md
레거시 (config 직접 편집) 방식:
-
config/app.php업데이트:'supported_locales' => ['ko', 'en', 'ja'], // 일본어 추가 'translatable_locales' => ['ko', 'en', 'ja'],
-
번역 파일 생성 (supported_locales에 추가한 경우):
mkdir -p lang/ja # 모든 번역 파일 복사 및 번역 -
템플릿 메타데이터 업데이트:
// templates/sirsoft-admin_basic/template.json { "locales": ["ko", "en", "ja"] }
기본 지원 언어:
- 한국어 (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]);
}
}폴백 동작:
- 요청한 로케일에 번역이 있으면 반환
- 없으면 'ko' (기본 로케일)로 폴백
- 'ko'도 없으면 'en'으로 폴백
- 둘 다 없으면 빈 문자열 반환
엣지 케이스:
// 한국어만 있는 경우
$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(); // "" (빈 문자열 반환)