895 lines
21 KiB
Markdown
895 lines
21 KiB
Markdown
|
|
# MNG 기술 표준 문서
|
||
|
|
|
||
|
|
**목적:** 모든 Phase에서 일관되게 적용할 기술 표준 및 개발 규칙 정의 (SAM API Rules 기반)
|
||
|
|
|
||
|
|
## 📋 목차
|
||
|
|
|
||
|
|
1. [아키텍처 패턴 (SAM API Rules)](#1-아키텍처-패턴-sam-api-rules)
|
||
|
|
2. [코딩 컨벤션](#2-코딩-컨벤션)
|
||
|
|
3. [데이터베이스 설계 원칙](#3-데이터베이스-설계-원칙)
|
||
|
|
4. [보안 정책](#4-보안-정책)
|
||
|
|
5. [테스트 전략](#5-테스트-전략)
|
||
|
|
6. [성능 최적화](#6-성능-최적화)
|
||
|
|
7. [배포 프로세스](#7-배포-프로세스)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 1. 아키텍처 패턴 (SAM API Rules)
|
||
|
|
|
||
|
|
### 1.1 Service-First Pattern (필수)
|
||
|
|
|
||
|
|
**원칙:** 모든 비즈니스 로직은 Service 클래스에 위치 (SAM API Rule #1)
|
||
|
|
|
||
|
|
```
|
||
|
|
[Request]
|
||
|
|
↓
|
||
|
|
[Route]
|
||
|
|
↓
|
||
|
|
[Controller] (DI 주입, Service 호출만)
|
||
|
|
↓
|
||
|
|
[FormRequest] (유효성 검증 - SAM API Rule #8)
|
||
|
|
↓
|
||
|
|
[Service] (비즈니스 로직, tenantId()/apiUserId() 필수)
|
||
|
|
↓
|
||
|
|
[Model] (Eloquent ORM, BelongsToTenant)
|
||
|
|
↓
|
||
|
|
[Database]
|
||
|
|
```
|
||
|
|
|
||
|
|
**Controller 예시:** (SAM API Rule #5)
|
||
|
|
```php
|
||
|
|
class UserController extends Controller
|
||
|
|
{
|
||
|
|
public function __construct(private UserService $userService) {}
|
||
|
|
|
||
|
|
public function index(Request $request)
|
||
|
|
{
|
||
|
|
// Controller는 Service 호출만
|
||
|
|
$users = $this->userService->list($request->all());
|
||
|
|
return view('users.index', compact('users'));
|
||
|
|
}
|
||
|
|
|
||
|
|
public function store(StoreUserRequest $request)
|
||
|
|
{
|
||
|
|
// FormRequest 검증 완료 데이터만 Service로 전달
|
||
|
|
$user = $this->userService->create($request->validated());
|
||
|
|
return redirect()->route('users.index')
|
||
|
|
->with('success', __('message.user.created')); // i18n 키 필수
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Service 예시:** (SAM API Rule #5)
|
||
|
|
```php
|
||
|
|
namespace App\Services;
|
||
|
|
|
||
|
|
use App\Services\Service as BaseService; // Base Service 상속 필수
|
||
|
|
|
||
|
|
class UserService extends BaseService
|
||
|
|
{
|
||
|
|
// tenantId(), apiUserId() 필수 설정 (SAM API Rule #1)
|
||
|
|
public function list(array $filters): LengthAwarePaginator
|
||
|
|
{
|
||
|
|
$query = User::query();
|
||
|
|
|
||
|
|
// Multi-tenant 필터링 (BelongsToTenant global scope 자동 적용)
|
||
|
|
if (isset($filters['search'])) {
|
||
|
|
$query->where(function($q) use ($filters) {
|
||
|
|
$q->where('name', 'like', "%{$filters['search']}%")
|
||
|
|
->orWhere('email', 'like', "%{$filters['search']}%");
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return $query->paginate(20);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function create(array $data): User
|
||
|
|
{
|
||
|
|
DB::beginTransaction();
|
||
|
|
try {
|
||
|
|
$user = User::create([
|
||
|
|
'tenant_id' => $this->tenantId(), // Base Service에서 제공
|
||
|
|
'name' => $data['name'],
|
||
|
|
'email' => $data['email'],
|
||
|
|
'password' => Hash::make($data['password']),
|
||
|
|
'created_by' => $this->apiUserId(), // Base Service에서 제공
|
||
|
|
]);
|
||
|
|
|
||
|
|
if (isset($data['roles'])) {
|
||
|
|
$user->assignRole($data['roles']);
|
||
|
|
}
|
||
|
|
|
||
|
|
DB::commit();
|
||
|
|
return $user;
|
||
|
|
} catch (\Exception $e) {
|
||
|
|
DB::rollBack();
|
||
|
|
throw $e;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 1.2 FormRequest Pattern (필수)
|
||
|
|
|
||
|
|
**원칙:** Controller에서 검증 금지, FormRequest 사용 (SAM API Rule #8)
|
||
|
|
|
||
|
|
```php
|
||
|
|
namespace App\Http\Requests;
|
||
|
|
|
||
|
|
use Illuminate\Foundation\Http\FormRequest;
|
||
|
|
|
||
|
|
class StoreUserRequest extends FormRequest
|
||
|
|
{
|
||
|
|
public function authorize(): bool
|
||
|
|
{
|
||
|
|
// 권한 체크 (RBAC)
|
||
|
|
return $this->user()->can('users.create');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function rules(): array
|
||
|
|
{
|
||
|
|
return [
|
||
|
|
'name' => 'required|string|max:255',
|
||
|
|
'email' => 'required|email|unique:users,email',
|
||
|
|
'password' => 'required|min:8|confirmed',
|
||
|
|
'roles' => 'array',
|
||
|
|
'roles.*' => 'exists:roles,id',
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
public function messages(): array
|
||
|
|
{
|
||
|
|
// i18n 키 사용 (SAM API Rule #6)
|
||
|
|
return [
|
||
|
|
'name.required' => __('validation.required', ['attribute' => __('users.name')]),
|
||
|
|
'email.unique' => __('validation.unique', ['attribute' => __('users.email')]),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**공통 Request 재사용:** (SAM API Rule #8)
|
||
|
|
```php
|
||
|
|
// app/Http/Requests/PaginateRequest.php
|
||
|
|
class PaginateRequest extends FormRequest
|
||
|
|
{
|
||
|
|
public function rules(): array
|
||
|
|
{
|
||
|
|
return [
|
||
|
|
'page' => 'integer|min:1',
|
||
|
|
'per_page' => 'integer|min:1|max:100',
|
||
|
|
'sort' => 'string',
|
||
|
|
'order' => 'in:asc,desc',
|
||
|
|
];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 사용 예시
|
||
|
|
public function index(PaginateRequest $request)
|
||
|
|
{
|
||
|
|
$users = $this->userService->list($request->validated());
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 1.3 Repository Pattern (선택적)
|
||
|
|
|
||
|
|
**사용 시기:** 복잡한 쿼리, 여러 모델 조인, 재사용성 높은 쿼리
|
||
|
|
|
||
|
|
```php
|
||
|
|
namespace App\Repositories;
|
||
|
|
|
||
|
|
class UserRepository
|
||
|
|
{
|
||
|
|
public function findWithRolesAndDepartment(int $id): ?User
|
||
|
|
{
|
||
|
|
return User::with(['roles', 'department'])
|
||
|
|
->find($id);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getActiveUsersWithRecentActivity(int $days = 30): Collection
|
||
|
|
{
|
||
|
|
return User::where('is_active', true)
|
||
|
|
->where('last_login_at', '>=', now()->subDays($days))
|
||
|
|
->orderBy('last_login_at', 'desc')
|
||
|
|
->get();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 2. 코딩 컨벤션
|
||
|
|
|
||
|
|
### 2.1 네이밍 규칙
|
||
|
|
|
||
|
|
```php
|
||
|
|
// 클래스: PascalCase
|
||
|
|
class UserService {}
|
||
|
|
class StoreUserRequest {}
|
||
|
|
|
||
|
|
// 메서드: camelCase
|
||
|
|
public function createUser() {}
|
||
|
|
public function getUserById() {}
|
||
|
|
|
||
|
|
// 변수: camelCase
|
||
|
|
$userName = 'John';
|
||
|
|
$isActive = true;
|
||
|
|
|
||
|
|
// 상수: UPPER_SNAKE_CASE
|
||
|
|
const MAX_USERS = 100;
|
||
|
|
const DEFAULT_ROLE = 'user';
|
||
|
|
|
||
|
|
// 데이터베이스: snake_case
|
||
|
|
// 테이블: 복수형
|
||
|
|
users, sales_opportunities, quotation_items
|
||
|
|
|
||
|
|
// 컬럼: snake_case
|
||
|
|
user_id, created_at, is_active, tenant_id
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2.2 i18n (국제화) - SAM API Rule #6
|
||
|
|
|
||
|
|
**원칙:** 한글 직접 사용 금지, 언어 키 사용 필수
|
||
|
|
|
||
|
|
```php
|
||
|
|
// ❌ 잘못된 예
|
||
|
|
return redirect()->back()->with('success', '사용자가 생성되었습니다.');
|
||
|
|
|
||
|
|
// ✅ 올바른 예 (SAM API Rule #6)
|
||
|
|
return redirect()->back()->with('success', __('message.user.created'));
|
||
|
|
```
|
||
|
|
|
||
|
|
**언어 파일 구조:**
|
||
|
|
```
|
||
|
|
lang/
|
||
|
|
├── ko/
|
||
|
|
│ ├── message.php // 성공 메시지
|
||
|
|
│ ├── error.php // 에러 메시지
|
||
|
|
│ ├── validation.php // 검증 메시지
|
||
|
|
│ └── users.php // 도메인별 메시지
|
||
|
|
└── en/
|
||
|
|
├── message.php
|
||
|
|
├── error.php
|
||
|
|
├── validation.php
|
||
|
|
└── users.php
|
||
|
|
```
|
||
|
|
|
||
|
|
**`lang/ko/message.php` 예시:** (SAM API Rule #6)
|
||
|
|
```php
|
||
|
|
return [
|
||
|
|
// 공통 메시지
|
||
|
|
'fetched' => '조회되었습니다.',
|
||
|
|
'created' => '생성되었습니다.',
|
||
|
|
'updated' => '수정되었습니다.',
|
||
|
|
'deleted' => '삭제되었습니다.',
|
||
|
|
'bulk_upsert' => '일괄 저장되었습니다.',
|
||
|
|
'reordered' => '순서가 변경되었습니다.',
|
||
|
|
|
||
|
|
// 도메인별 메시지 (선택적)
|
||
|
|
'user' => [
|
||
|
|
'created' => '사용자가 생성되었습니다.',
|
||
|
|
'updated' => '사용자 정보가 수정되었습니다.',
|
||
|
|
],
|
||
|
|
];
|
||
|
|
```
|
||
|
|
|
||
|
|
**`lang/ko/error.php` 예시:**
|
||
|
|
```php
|
||
|
|
return [
|
||
|
|
'not_found' => '데이터를 찾을 수 없습니다.',
|
||
|
|
'unauthorized' => '권한이 없습니다.',
|
||
|
|
'validation_failed' => '입력값 검증에 실패했습니다.',
|
||
|
|
];
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2.3 코드 스타일 (SAM API Rule #7)
|
||
|
|
|
||
|
|
**Laravel Pint 사용 (자동 포맷팅):**
|
||
|
|
```bash
|
||
|
|
./vendor/bin/pint
|
||
|
|
```
|
||
|
|
|
||
|
|
**주석 규칙:**
|
||
|
|
```php
|
||
|
|
/**
|
||
|
|
* 사용자를 생성합니다.
|
||
|
|
*
|
||
|
|
* @param array $data 사용자 데이터
|
||
|
|
* @return User 생성된 사용자
|
||
|
|
* @throws \Exception 생성 실패 시
|
||
|
|
*/
|
||
|
|
public function create(array $data): User
|
||
|
|
{
|
||
|
|
// 복잡한 로직 설명이 필요한 경우에만 인라인 주석
|
||
|
|
// 단순 코드는 주석 없이 자명하게 작성
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 3. 데이터베이스 설계 원칙
|
||
|
|
|
||
|
|
### 3.1 Multi-tenant 필수 컬럼 (SAM API Rule #2)
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE example_table (
|
||
|
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||
|
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '소속 테넌트 (필수)',
|
||
|
|
-- 기타 컬럼...
|
||
|
|
created_by BIGINT UNSIGNED NULL COMMENT '생성자',
|
||
|
|
updated_by BIGINT UNSIGNED NULL COMMENT '수정자',
|
||
|
|
deleted_by BIGINT UNSIGNED NULL COMMENT '삭제자',
|
||
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
|
|
deleted_at TIMESTAMP NULL COMMENT 'Soft Delete',
|
||
|
|
|
||
|
|
INDEX idx_tenant_id (tenant_id),
|
||
|
|
INDEX idx_deleted_at (deleted_at),
|
||
|
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||
|
|
```
|
||
|
|
|
||
|
|
**Model에 BelongsToTenant trait 적용:** (SAM API Rule #2)
|
||
|
|
```php
|
||
|
|
namespace App\Models;
|
||
|
|
|
||
|
|
use App\Traits\BelongsToTenant;
|
||
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||
|
|
|
||
|
|
class ExampleModel extends Model
|
||
|
|
{
|
||
|
|
use BelongsToTenant, SoftDeletes;
|
||
|
|
|
||
|
|
protected $fillable = ['tenant_id', 'name', ...];
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**BelongsToTenant trait:**
|
||
|
|
```php
|
||
|
|
namespace App\Traits;
|
||
|
|
|
||
|
|
use App\Scopes\TenantScope;
|
||
|
|
|
||
|
|
trait BelongsToTenant
|
||
|
|
{
|
||
|
|
protected static function bootBelongsToTenant()
|
||
|
|
{
|
||
|
|
// Global Scope 적용 (자동 tenant_id 필터링)
|
||
|
|
static::addGlobalScope(new TenantScope);
|
||
|
|
|
||
|
|
// Model 생성 시 자동으로 tenant_id 설정
|
||
|
|
static::creating(function ($model) {
|
||
|
|
if (!$model->tenant_id && auth()->check()) {
|
||
|
|
$model->tenant_id = auth()->user()->tenant_id;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
public function tenant()
|
||
|
|
{
|
||
|
|
return $this->belongsTo(Tenant::class);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3.2 Soft Delete 기본 정책 (SAM API Rule #2)
|
||
|
|
|
||
|
|
**원칙:** 모든 테넌트 데이터는 Soft Delete 적용
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||
|
|
|
||
|
|
class User extends Model
|
||
|
|
{
|
||
|
|
use SoftDeletes;
|
||
|
|
|
||
|
|
protected $dates = ['deleted_at'];
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3.3 Audit Log (감사 로그) - SAM API Rule #9
|
||
|
|
|
||
|
|
**원칙:** 모든 CUD 작업 자동 기록 (13개월 보관)
|
||
|
|
|
||
|
|
**audit_logs 테이블:**
|
||
|
|
```sql
|
||
|
|
CREATE TABLE audit_logs (
|
||
|
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||
|
|
tenant_id BIGINT UNSIGNED NULL COMMENT '소속 테넌트',
|
||
|
|
target_type VARCHAR(255) NOT NULL COMMENT '대상 모델',
|
||
|
|
target_id BIGINT UNSIGNED NOT NULL COMMENT '대상 ID',
|
||
|
|
action VARCHAR(50) NOT NULL COMMENT 'created, updated, deleted 등',
|
||
|
|
before_values JSON NULL COMMENT '변경 전 데이터',
|
||
|
|
after_values JSON NULL COMMENT '변경 후 데이터',
|
||
|
|
actor_id BIGINT UNSIGNED NULL COMMENT '작업자 ID',
|
||
|
|
ip_address VARCHAR(45) NULL,
|
||
|
|
user_agent TEXT NULL,
|
||
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
|
||
|
|
INDEX idx_tenant_id (tenant_id),
|
||
|
|
INDEX idx_target (target_type, target_id),
|
||
|
|
INDEX idx_created_at (created_at)
|
||
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||
|
|
```
|
||
|
|
|
||
|
|
**Observer 패턴 사용:**
|
||
|
|
```php
|
||
|
|
namespace App\Observers;
|
||
|
|
|
||
|
|
use App\Models\AuditLog;
|
||
|
|
|
||
|
|
class UserObserver
|
||
|
|
{
|
||
|
|
public function created(User $user)
|
||
|
|
{
|
||
|
|
AuditLog::create([
|
||
|
|
'tenant_id' => $user->tenant_id,
|
||
|
|
'target_type' => User::class,
|
||
|
|
'target_id' => $user->id,
|
||
|
|
'action' => 'created',
|
||
|
|
'before_values' => null,
|
||
|
|
'after_values' => $user->toArray(),
|
||
|
|
'actor_id' => auth()->id(),
|
||
|
|
'ip_address' => request()->ip(),
|
||
|
|
'user_agent' => request()->userAgent(),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function updated(User $user)
|
||
|
|
{
|
||
|
|
AuditLog::create([
|
||
|
|
'tenant_id' => $user->tenant_id,
|
||
|
|
'target_type' => User::class,
|
||
|
|
'target_id' => $user->id,
|
||
|
|
'action' => 'updated',
|
||
|
|
'before_values' => $user->getOriginal(),
|
||
|
|
'after_values' => $user->getChanges(),
|
||
|
|
'actor_id' => auth()->id(),
|
||
|
|
'ip_address' => request()->ip(),
|
||
|
|
'user_agent' => request()->userAgent(),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function deleted(User $user)
|
||
|
|
{
|
||
|
|
AuditLog::create([
|
||
|
|
'tenant_id' => $user->tenant_id,
|
||
|
|
'target_type' => User::class,
|
||
|
|
'target_id' => $user->id,
|
||
|
|
'action' => 'deleted',
|
||
|
|
'before_values' => $user->toArray(),
|
||
|
|
'after_values' => null,
|
||
|
|
'actor_id' => auth()->id(),
|
||
|
|
'ip_address' => request()->ip(),
|
||
|
|
'user_agent' => request()->userAgent(),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**AppServiceProvider에 등록:**
|
||
|
|
```php
|
||
|
|
public function boot()
|
||
|
|
{
|
||
|
|
User::observe(UserObserver::class);
|
||
|
|
Client::observe(ClientObserver::class);
|
||
|
|
// 기타 모델...
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**13개월 보관 정책 (Scheduler):**
|
||
|
|
```php
|
||
|
|
// app/Console/Kernel.php
|
||
|
|
protected function schedule(Schedule $schedule)
|
||
|
|
{
|
||
|
|
// 13개월 이전 감사 로그 삭제
|
||
|
|
$schedule->command('audit:prune')->monthlyOn(1, '02:00');
|
||
|
|
}
|
||
|
|
|
||
|
|
// app/Console/Commands/PruneAuditLogs.php
|
||
|
|
public function handle()
|
||
|
|
{
|
||
|
|
$retentionMonths = 13;
|
||
|
|
$cutoffDate = now()->subMonths($retentionMonths);
|
||
|
|
|
||
|
|
$deleted = AuditLog::where('created_at', '<', $cutoffDate)->delete();
|
||
|
|
|
||
|
|
$this->info("Pruned {$deleted} audit log records older than {$retentionMonths} months.");
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3.4 인덱스 전략
|
||
|
|
|
||
|
|
```sql
|
||
|
|
-- 필수 인덱스
|
||
|
|
INDEX idx_tenant_id (tenant_id) -- Multi-tenant 필터링
|
||
|
|
INDEX idx_created_at (created_at) -- 날짜 정렬
|
||
|
|
INDEX idx_deleted_at (deleted_at) -- Soft Delete 필터링
|
||
|
|
|
||
|
|
-- 검색 인덱스
|
||
|
|
INDEX idx_email (email) -- 이메일 검색
|
||
|
|
FULLTEXT idx_search (title, content) -- 전문 검색
|
||
|
|
|
||
|
|
-- 복합 인덱스
|
||
|
|
INDEX idx_tenant_status (tenant_id, status) -- 테넌트 + 상태 필터
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 4. 보안 정책
|
||
|
|
|
||
|
|
### 4.1 인증 (Authentication)
|
||
|
|
|
||
|
|
**Laravel Sanctum 사용:**
|
||
|
|
```php
|
||
|
|
// config/sanctum.php
|
||
|
|
'expiration' => 60 * 24, // 24시간
|
||
|
|
|
||
|
|
// 로그인 (MNG는 세션 기반, API는 토큰 기반)
|
||
|
|
public function login(Request $request)
|
||
|
|
{
|
||
|
|
$credentials = $request->validate([
|
||
|
|
'email' => 'required|email',
|
||
|
|
'password' => 'required',
|
||
|
|
]);
|
||
|
|
|
||
|
|
if (!Auth::attempt($credentials)) {
|
||
|
|
throw ValidationException::withMessages([
|
||
|
|
'email' => [__('error.auth.failed')],
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
$user = Auth::user();
|
||
|
|
|
||
|
|
// 세션 기반 로그인 (MNG)
|
||
|
|
return redirect()->route('dashboard');
|
||
|
|
|
||
|
|
// 또는 API 토큰 발급 (필요시)
|
||
|
|
// $token = $user->createToken('mng-token')->plainTextToken;
|
||
|
|
// return response()->json(['token' => $token, 'user' => $user]);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.2 권한 (Authorization) - RBAC
|
||
|
|
|
||
|
|
**Spatie Laravel Permission 사용:**
|
||
|
|
```php
|
||
|
|
// Permission 체크
|
||
|
|
if ($user->can('users.create')) {
|
||
|
|
// 권한 있음
|
||
|
|
}
|
||
|
|
|
||
|
|
// Middleware
|
||
|
|
Route::group(['middleware' => ['auth', 'can:users.index']], function () {
|
||
|
|
Route::get('/users', [UserController::class, 'index']);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Blade
|
||
|
|
@can('users.create')
|
||
|
|
<a href="{{ route('users.create') }}">새 사용자</a>
|
||
|
|
@endcan
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.3 XSS 방지
|
||
|
|
|
||
|
|
**Blade 자동 이스케이프:**
|
||
|
|
```blade
|
||
|
|
{{-- 자동 이스케이프 (안전) --}}
|
||
|
|
{{ $user->name }}
|
||
|
|
|
||
|
|
{{-- 이스케이프 없음 (주의) --}}
|
||
|
|
{!! $htmlContent !!}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.4 CSRF 보호
|
||
|
|
|
||
|
|
**모든 POST/PUT/DELETE 요청에 @csrf:**
|
||
|
|
```blade
|
||
|
|
<form method="POST" action="{{ route('users.store') }}">
|
||
|
|
@csrf
|
||
|
|
<!-- 폼 필드 -->
|
||
|
|
</form>
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.5 SQL Injection 방지
|
||
|
|
|
||
|
|
**Eloquent 또는 Query Builder 사용 (Raw 쿼리 금지):**
|
||
|
|
```php
|
||
|
|
// ✅ 올바른 예
|
||
|
|
User::where('email', $email)->first();
|
||
|
|
|
||
|
|
// ❌ 잘못된 예
|
||
|
|
DB::select("SELECT * FROM users WHERE email = '$email'");
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.6 민감 정보 암호화
|
||
|
|
|
||
|
|
```php
|
||
|
|
// 비밀번호
|
||
|
|
use Illuminate\Support\Facades\Hash;
|
||
|
|
Hash::make($password);
|
||
|
|
|
||
|
|
// API 키, 토큰
|
||
|
|
use Illuminate\Support\Facades\Crypt;
|
||
|
|
$encrypted = Crypt::encryptString($apiKey);
|
||
|
|
$decrypted = Crypt::decryptString($encrypted);
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 5. 테스트 전략
|
||
|
|
|
||
|
|
### 5.1 테스트 레벨
|
||
|
|
|
||
|
|
```
|
||
|
|
┌─────────────────────────────────┐
|
||
|
|
│ Feature Tests (통합 테스트) │ ← 주력
|
||
|
|
├─────────────────────────────────┤
|
||
|
|
│ Unit Tests (단위 테스트) │ ← Service 계층
|
||
|
|
├─────────────────────────────────┤
|
||
|
|
│ Browser Tests (E2E) │ ← 선택적 (Playwright MCP)
|
||
|
|
└─────────────────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5.2 Unit Test 예시
|
||
|
|
|
||
|
|
```php
|
||
|
|
// tests/Unit/Services/UserServiceTest.php
|
||
|
|
namespace Tests\Unit\Services;
|
||
|
|
|
||
|
|
use Tests\TestCase;
|
||
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
|
|
|
||
|
|
class UserServiceTest extends TestCase
|
||
|
|
{
|
||
|
|
use RefreshDatabase;
|
||
|
|
|
||
|
|
public function test_create_user()
|
||
|
|
{
|
||
|
|
$service = new UserService();
|
||
|
|
$service->setTenantId(1); // Base Service tenantId 설정
|
||
|
|
$service->setApiUserId(1); // Base Service apiUserId 설정
|
||
|
|
|
||
|
|
$data = [
|
||
|
|
'name' => 'Test User',
|
||
|
|
'email' => 'test@example.com',
|
||
|
|
'password' => 'password123',
|
||
|
|
];
|
||
|
|
|
||
|
|
$user = $service->create($data);
|
||
|
|
|
||
|
|
$this->assertDatabaseHas('users', [
|
||
|
|
'email' => 'test@example.com',
|
||
|
|
]);
|
||
|
|
$this->assertTrue(Hash::check('password123', $user->password));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5.3 Feature Test 예시
|
||
|
|
|
||
|
|
```php
|
||
|
|
// tests/Feature/UserControllerTest.php
|
||
|
|
namespace Tests\Feature;
|
||
|
|
|
||
|
|
use Tests\TestCase;
|
||
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
|
|
|
||
|
|
class UserControllerTest extends TestCase
|
||
|
|
{
|
||
|
|
use RefreshDatabase;
|
||
|
|
|
||
|
|
public function test_user_can_view_users_list()
|
||
|
|
{
|
||
|
|
$user = User::factory()->create();
|
||
|
|
$this->actingAs($user);
|
||
|
|
|
||
|
|
$response = $this->get(route('users.index'));
|
||
|
|
|
||
|
|
$response->assertStatus(200);
|
||
|
|
$response->assertViewIs('users.index');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_user_can_create_new_user()
|
||
|
|
{
|
||
|
|
$admin = User::factory()->create();
|
||
|
|
$admin->givePermissionTo('users.create');
|
||
|
|
$this->actingAs($admin);
|
||
|
|
|
||
|
|
$data = [
|
||
|
|
'name' => 'New User',
|
||
|
|
'email' => 'new@example.com',
|
||
|
|
'password' => 'password123',
|
||
|
|
'password_confirmation' => 'password123',
|
||
|
|
];
|
||
|
|
|
||
|
|
$response = $this->post(route('users.store'), $data);
|
||
|
|
|
||
|
|
$response->assertRedirect(route('users.index'));
|
||
|
|
$this->assertDatabaseHas('users', ['email' => 'new@example.com']);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5.4 테스트 실행
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# 전체 테스트
|
||
|
|
php artisan test
|
||
|
|
|
||
|
|
# 특정 테스트
|
||
|
|
php artisan test --filter UserServiceTest
|
||
|
|
|
||
|
|
# 커버리지 (Xdebug 필요)
|
||
|
|
php artisan test --coverage
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 6. 성능 최적화
|
||
|
|
|
||
|
|
### 6.1 Eager Loading
|
||
|
|
|
||
|
|
**N+1 쿼리 방지:**
|
||
|
|
```php
|
||
|
|
// ❌ N+1 문제
|
||
|
|
$users = User::all();
|
||
|
|
foreach ($users as $user) {
|
||
|
|
echo $user->department->name; // N번 쿼리
|
||
|
|
}
|
||
|
|
|
||
|
|
// ✅ Eager Loading
|
||
|
|
$users = User::with('department')->get();
|
||
|
|
foreach ($users as $user) {
|
||
|
|
echo $user->department->name; // 1번 쿼리
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 6.2 쿼리 최적화
|
||
|
|
|
||
|
|
```php
|
||
|
|
// ✅ select로 필요한 컬럼만
|
||
|
|
User::select('id', 'name', 'email')->get();
|
||
|
|
|
||
|
|
// ✅ chunk로 대량 데이터 처리
|
||
|
|
User::chunk(100, function ($users) {
|
||
|
|
foreach ($users as $user) {
|
||
|
|
// 처리
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// ✅ 카운트 최적화
|
||
|
|
$count = User::count(); // SELECT COUNT(*) (빠름)
|
||
|
|
// ❌ $count = User::all()->count(); (느림)
|
||
|
|
```
|
||
|
|
|
||
|
|
### 6.3 캐싱
|
||
|
|
|
||
|
|
```php
|
||
|
|
// 캐시 저장 (60분)
|
||
|
|
Cache::put('users.all', User::all(), now()->addMinutes(60));
|
||
|
|
|
||
|
|
// 캐시 조회 (없으면 생성)
|
||
|
|
$users = Cache::remember('users.all', 60 * 60, function () {
|
||
|
|
return User::all();
|
||
|
|
});
|
||
|
|
|
||
|
|
// 캐시 삭제
|
||
|
|
Cache::forget('users.all');
|
||
|
|
```
|
||
|
|
|
||
|
|
### 6.4 큐 (Queue)
|
||
|
|
|
||
|
|
**무거운 작업은 큐로 처리:**
|
||
|
|
```php
|
||
|
|
// Job 생성
|
||
|
|
php artisan make:job SendWelcomeEmail
|
||
|
|
|
||
|
|
// Job 클래스
|
||
|
|
namespace App\Jobs;
|
||
|
|
|
||
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||
|
|
|
||
|
|
class SendWelcomeEmail implements ShouldQueue
|
||
|
|
{
|
||
|
|
public function __construct(public User $user) {}
|
||
|
|
|
||
|
|
public function handle()
|
||
|
|
{
|
||
|
|
Mail::to($this->user->email)->send(new WelcomeEmail($this->user));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Job 디스패치
|
||
|
|
SendWelcomeEmail::dispatch($user);
|
||
|
|
|
||
|
|
// 큐 워커 실행
|
||
|
|
php artisan queue:work
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 7. 배포 프로세스
|
||
|
|
|
||
|
|
### 7.1 환경 설정
|
||
|
|
|
||
|
|
```
|
||
|
|
로컬 (sam.kr) → 개발 서버 (codebridge-x.com) → 운영 서버 (TBD)
|
||
|
|
```
|
||
|
|
|
||
|
|
### 7.2 배포 체크리스트
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# 1. Git 푸시
|
||
|
|
git add .
|
||
|
|
git commit -m "feat: 사용자 관리 구현"
|
||
|
|
git push origin main
|
||
|
|
|
||
|
|
# 2. 서버 접속 (개발 서버)
|
||
|
|
ssh user@codebridge-x.com
|
||
|
|
|
||
|
|
# 3. 코드 Pull
|
||
|
|
cd /var/www/mng
|
||
|
|
git pull origin main
|
||
|
|
|
||
|
|
# 4. 의존성 업데이트
|
||
|
|
composer install --no-dev --optimize-autoloader
|
||
|
|
|
||
|
|
# 5. 마이그레이션
|
||
|
|
php artisan migrate --force
|
||
|
|
|
||
|
|
# 6. 캐시 클리어
|
||
|
|
php artisan config:cache
|
||
|
|
php artisan route:cache
|
||
|
|
php artisan view:cache
|
||
|
|
|
||
|
|
# 7. 권한 설정
|
||
|
|
chown -R www-data:www-data storage bootstrap/cache
|
||
|
|
|
||
|
|
# 8. 큐 재시작
|
||
|
|
php artisan queue:restart
|
||
|
|
|
||
|
|
# 9. Supervisor 재시작 (큐 워커)
|
||
|
|
sudo supervisorctl restart mng-worker:*
|
||
|
|
```
|
||
|
|
|
||
|
|
### 7.3 롤백 프로세스
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# 1. Git 롤백
|
||
|
|
git revert HEAD
|
||
|
|
git push origin main
|
||
|
|
|
||
|
|
# 2. 서버에서 Pull
|
||
|
|
git pull origin main
|
||
|
|
|
||
|
|
# 3. 마이그레이션 롤백 (필요 시)
|
||
|
|
php artisan migrate:rollback --step=1
|
||
|
|
|
||
|
|
# 4. 캐시 클리어
|
||
|
|
php artisan cache:clear
|
||
|
|
php artisan config:clear
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📚 참고 자료
|
||
|
|
|
||
|
|
### SAM 프로젝트 문서
|
||
|
|
- **SAM CLAUDE.md:** `/SAM/CLAUDE.md` - 전체 프로젝트 구조
|
||
|
|
- **API CLAUDE.md:** `/SAM/api/CLAUDE.md` - SAM API Development Rules 상세
|
||
|
|
- **MNG CLAUDE.md:** `/SAM/mng/CLAUDE.md` - MNG 프로젝트 특화 가이드
|
||
|
|
- **현재 작업:** `CURRENT_WORKS.md` (각 저장소별)
|
||
|
|
|
||
|
|
### 외부 문서
|
||
|
|
- **Laravel 공식 문서:** https://laravel.com/docs
|
||
|
|
- **Tailwind CSS:** https://tailwindcss.com/docs
|
||
|
|
- **DaisyUI:** https://daisyui.com/
|
||
|
|
- **HTMX:** https://htmx.org/
|
||
|
|
- **Spatie Permission:** https://spatie.be/docs/laravel-permission
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
**최종 업데이트:** 2025-11-21
|
||
|
|
**작성자:** Claude Code
|
||
|
|
**버전:** 2.0.0 (SAM API Rules 기반)
|