Files
Gnuboard7/docs/database-guide.md
T
2026-04-01 10:30:52 +09:00

24 KiB

그누보드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');
    }
};

모듈/플러그인 마이그레이션 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 주석 한국어

시더 디렉토리 구조

설치 필수 시더와 샘플(개발용) 시더는 반드시 분리
✅ 설치 시더: 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)

새 언어 추가 절차

  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();  // "" (빈 문자열 반환)