Files
sam-api/CURRENT_WORKS.md
hskwon 798d5149ea feat: API 토큰 관리 시스템 구현 (액세스/리프레시 토큰 분리)
- AuthService: 토큰 발급/갱신 통합 관리
- RefreshController: POST /api/v1/refresh 엔드포인트 추가
- 액세스 토큰 2시간, 리프레시 토큰 7일 (.env 설정)
- TOKEN_EXPIRED 에러 코드로 프론트엔드 자동 리프레시 지원
- 리프레시 토큰 일회성 사용 (보안 강화)
- Swagger 문서 Auth 태그로 통합
2025-11-10 11:17:32 +09:00

1384 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SAM API 저장소 작업 현황
## 2025-11-10 (일) - API 토큰 관리 시스템 구현 (액세스/리프레시 토큰 분리)
### 주요 작업
- **액세스/리프레시 토큰 분리**: 액세스 토큰(2시간), 리프레시 토큰(7일) 독립 관리
- **환경별 설정**: .env 기반 토큰 만료 시간 설정 (설정 없으면 무제한)
- **토큰 갱신 엔드포인트**: POST /api/v1/refresh (리프레시 토큰으로 새 토큰 발급)
- **보안 강화**: 리프레시 토큰 일회성 사용, 사용자당 1개 리프레시 토큰만 유지
- **에러 처리**: TOKEN_EXPIRED 에러 코드로 프론트엔드 자동 리프레시 지원
### 추가된 파일:
- `app/Services/AuthService.php` - 토큰 발급/갱신 통합 서비스 (119줄)
- `app/Http/Controllers/Api/V1/RefreshController.php` - 토큰 갱신 컨트롤러 (32줄)
- `app/Http/Requests/Api/V1/RefreshRequest.php` - 리프레시 토큰 검증 (22줄)
- `app/Swagger/v1/RefreshApi.php` - 토큰 갱신 API 문서 (69줄)
### 수정된 파일:
- `.env` - 토큰 만료 설정 추가 (ACCESS: 120분, REFRESH: 10080분)
- `config/sanctum.php` - 토큰 만료 설정 키 추가
- `app/Http/Controllers/Api/V1/ApiController.php` - 로그인 시 AuthService 사용
- `app/Exceptions/Handler.php` - 토큰 만료 에러 처리 (TOKEN_EXPIRED)
- `app/Http/Middleware/ApiKeyMiddleware.php` - refresh 라우트 화이트리스트 추가
- `app/Swagger/v1/AuthApi.php` - 로그인 응답에 토큰 필드 추가
- `lang/ko/error.php` - 토큰 관련 에러 메시지 4개 추가
- `lang/ko/message.php` - token_refreshed 메시지 추가
- `routes/api.php` - POST /api/v1/refresh 라우트 추가
### 작업 내용:
#### 1. AuthService 구현
**토큰 발급 (issueTokens):**
```php
public static function issueTokens(User $user): array
{
// 기존 리프레시 토큰 삭제 (한 사용자당 하나만 유지)
$user->tokens()->where('name', 'refresh-token')->delete();
// 액세스 토큰 만료 시간 (분 단위, null이면 무제한)
$accessExpiration = Config::get('sanctum.access_token_expiration');
$accessExpiration = $accessExpiration ? (int) $accessExpiration : null;
$accessExpiresAt = $accessExpiration ? now()->addMinutes($accessExpiration) : null;
// 리프레시 토큰 만료 시간 (분 단위, null이면 무제한)
$refreshExpiration = Config::get('sanctum.refresh_token_expiration');
$refreshExpiration = $refreshExpiration ? (int) $refreshExpiration : null;
$refreshExpiresAt = $refreshExpiration ? now()->addMinutes($refreshExpiration) : null;
// 액세스 토큰 생성
$accessToken = $user->createToken('access-token', ['*'], $accessExpiresAt);
// 리프레시 토큰 생성
$refreshToken = $user->createToken('refresh-token', ['refresh'], $refreshExpiresAt);
return [
'access_token' => $accessToken->plainTextToken,
'refresh_token' => $refreshToken->plainTextToken,
'token_type' => 'Bearer',
'expires_in' => $accessExpiration ? $accessExpiration * 60 : null,
'expires_at' => $accessExpiresAt ? $accessExpiresAt->toDateTimeString() : null,
];
}
```
**토큰 갱신 (refreshTokens):**
```php
public static function refreshTokens(string $refreshToken): ?array
{
// 리프레시 토큰 검증
$token = \Laravel\Sanctum\PersonalAccessToken::findToken($refreshToken);
if (!$token || $token->name !== 'refresh-token') {
return null;
}
// 만료 확인
if ($token->expires_at && $token->expires_at->isPast()) {
$token->delete();
return null;
}
$user = $token->tokenable;
// 기존 리프레시 토큰 삭제 (사용 후 폐기)
$token->delete();
// 새로운 액세스 + 리프레시 토큰 발급
return self::issueTokens($user);
}
```
**핵심 특징:**
- ✅ 사용자당 1개의 리프레시 토큰만 유지
- ✅ 리프레시 토큰은 일회성 (사용 후 삭제)
- ✅ 토큰 갱신 시 액세스 + 리프레시 모두 새로 발급
- ✅ 타입 캐스팅 (.env 값은 문자열이므로 int 변환 필수)
#### 2. RefreshController 구현
```php
public function refresh(RefreshRequest $request): JsonResponse
{
$refreshToken = $request->validated()['refresh_token'];
// 리프레시 토큰으로 새로운 토큰 발급
$tokens = AuthService::refreshTokens($refreshToken);
if (!$tokens) {
return response()->json([
'error' => __('error.refresh_token_invalid_or_expired'),
'error_code' => 'TOKEN_EXPIRED',
], 401);
}
return response()->json([
'message' => __('message.token_refreshed'),
'access_token' => $tokens['access_token'],
'refresh_token' => $tokens['refresh_token'],
'token_type' => $tokens['token_type'],
'expires_in' => $tokens['expires_in'],
'expires_at' => $tokens['expires_at'],
]);
}
```
#### 3. Handler 토큰 만료 에러 처리
```php
// 401 Unauthorized
if ($exception instanceof AuthenticationException) {
// 토큰 만료 여부 확인
$errorCode = null;
$message = '인증 실패';
// Bearer 토큰이 있는 경우 만료 여부 확인
$bearerToken = $request->bearerToken();
if ($bearerToken) {
$token = \Laravel\Sanctum\PersonalAccessToken::findToken($bearerToken);
if ($token && $token->expires_at && $token->expires_at->isPast()) {
$errorCode = 'TOKEN_EXPIRED';
$message = __('error.token_expired');
}
}
return response()->json([
'success' => false,
'message' => $message,
'error_code' => $errorCode,
'data' => null,
], 401);
}
```
#### 4. 환경 설정 (.env)
```env
# Sanctum 토큰 만료 설정 (분 단위, null이면 무제한)
SANCTUM_ACCESS_TOKEN_EXPIRATION=120 # 2시간 (운영 기준)
SANCTUM_REFRESH_TOKEN_EXPIRATION=10080 # 7일
```
#### 5. Swagger 문서
**POST /api/v1/refresh:**
```php
@OA\Post(
path="/api/v1/refresh",
tags={"Auth"},
summary="토큰 갱신 (리프레시 토큰으로 새로운 액세스 토큰 발급)",
description="리프레시 토큰을 사용하여 새로운 액세스 토큰과 리프레시 토큰을 발급받습니다. 리프레시 토큰은 사용 후 폐기되며 새로운 리프레시 토큰이 발급됩니다.",
security={{"ApiKeyAuth": {}}},
)
```
**로그인 응답 업데이트:**
```php
@OA\Property(property="access_token", type="string", example="1|abc123xyz456", description="액세스 토큰 (API 호출에 사용)"),
@OA\Property(property="refresh_token", type="string", example="2|def456uvw789", description="리프레시 토큰 (액세스 토큰 갱신에 사용)"),
@OA\Property(property="token_type", type="string", example="Bearer", description="토큰 타입"),
@OA\Property(property="expires_in", type="integer", nullable=true, example=7200, description="액세스 토큰 만료 시간 (초 단위, null이면 무제한)"),
@OA\Property(property="expires_at", type="string", nullable=true, example="2025-11-10 16:00:00", description="액세스 토큰 만료 시각 (null이면 무제한)"),
```
### 기술 세부사항:
#### OAuth 2.0 표준 준수
- `token_type: "Bearer"` 포함 (RFC 6749 표준)
- 토큰 갱신 시 refresh token rotation (보안 강화)
- 만료 시간 명시 (expires_in, expires_at)
#### 보안 설계
```
1. 리프레시 토큰 일회성:
- 사용 시 즉시 삭제
- 새 리프레시 토큰 발급
- 도난 토큰 재사용 방지
2. 사용자당 1개 제한:
- 새 리프레시 토큰 발급 시 이전 것 삭제
- 멀티 디바이스 로그인 제한 (필요 시 변경 가능)
3. 타입 안전성:
- .env 값 타입 캐스팅 필수
- Carbon::addMinutes()는 int만 허용
```
#### 데이터베이스 영향
```sql
-- personal_access_tokens 테이블
SELECT id, name, expires_at, created_at
FROM personal_access_tokens
WHERE tokenable_id = 1
ORDER BY id DESC
LIMIT 5;
-- 결과:
ID: 184 | Name: refresh-token | Expires: 2025-11-17 11:06:28
ID: 183 | Name: access-token | Expires: 2025-11-10 13:06:28
```
### SAM API Development Rules 준수:
**Service-First 아키텍처:**
- AuthService에 모든 토큰 로직
- Controller는 DI + 응답만
**FormRequest 검증:**
- RefreshRequest로 리프레시 토큰 검증
**i18n 메시지 키:**
- __('message.token_refreshed'), __('error.xxx') 사용
**Swagger 문서:**
- 별도 파일 (app/Swagger/v1/RefreshApi.php)
- Auth 태그로 그룹화
**보안:**
- 토큰 일회성 사용
- 만료 시간 검증
- 에러 코드 명시 (TOKEN_EXPIRED)
**코드 품질:**
- 타입 안전성 (int 캐스팅)
- 명확한 주석
### 테스트 결과:
**Tinker 테스트:**
```bash
php artisan tinker --execute="
\$user = User::find(1);
\$tokens = \App\Services\AuthService::issueTokens(\$user);
echo 'Access Token: ' . substr(\$tokens['access_token'], 0, 20) . '...' . PHP_EOL;
echo 'Refresh Token: ' . substr(\$tokens['refresh_token'], 0, 20) . '...' . PHP_EOL;
echo 'Expires In: ' . \$tokens['expires_in'] . ' seconds' . PHP_EOL;
echo 'Expires At: ' . \$tokens['expires_at'] . PHP_EOL;
"
# 결과:
Access Token: 177|MtYCVI4XDqX5GXA...
Refresh Token: 178|rpoDdTsZ9orU2g3...
Expires In: 7200 seconds (120 minutes)
Expires At: 2025-11-10 13:01:21
```
**API 엔드포인트 테스트:**
```bash
# 로그인
curl -X POST "http://api.sam.kr/api/v1/login" \
-H "Content-Type: application/json" \
-H "X-API-KEY: 42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a" \
-d '{"email":"hamss@codebridge-x.com","password":"test1234"}'
# 토큰 갱신
curl -X POST "http://api.sam.kr/api/v1/refresh" \
-H "Content-Type: application/json" \
-H "X-API-KEY: 42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a" \
-d '{"refresh_token":"182|vsdUYz2WVaFxC05TWp4M0njVLhh833jPK6ilN5AB8ee106ad"}'
# 응답:
{
"message": "토큰이 갱신되었습니다",
"access_token": "183|pfbAqUvAZ2meTVKisDDC8MwnhBUCoMVsK7GXoh8aa1c832c5",
"refresh_token": "184|yNJJiqNF4GeH2u3YFAQr7mISYmLdEfiSdq9CdD00c1d7538d",
"token_type": "Bearer",
"expires_in": 7200,
"expires_at": "2025-11-10 13:06:28"
}
```
**데이터베이스 검증:**
```sql
-- 최근 발급된 토큰 확인
SELECT id, name, expires_at, created_at
FROM personal_access_tokens
WHERE tokenable_id = 1
ORDER BY id DESC LIMIT 5;
-- 결과:
토큰 발급: Access (ID: 183) + Refresh (ID: 184)
이전 리프레시 토큰 삭제: ID 182 삭제됨
만료 시간 설정: Access 2시간 , Refresh 7
```
### 예상 효과:
1. **보안 강화**: 단기 액세스 토큰 + 장기 리프레시 토큰
2. **세션 관리**: 리프레시 토큰 갱신으로 지속적인 로그인 유지
3. **에러 처리**: TOKEN_EXPIRED 코드로 프론트엔드 자동 리프레시 구현 가능
4. **유연성**: 환경별 토큰 만료 시간 설정 (개발/운영 분리)
### 다음 작업:
- [x] AuthService 구현
- [x] RefreshController 구현
- [x] Handler 에러 처리
- [x] Swagger 문서 작성 (Auth 태그)
- [x] i18n 메시지 추가
- [x] Tinker 테스트
- [x] API 엔드포인트 테스트
- [x] DB 검증
- [ ] Frontend 토큰 갱신 로직 구현
- [ ] 만료 토큰 정리 스케줄러 (선택)
### Git 커밋:
- 다음 커밋 예정: `feat: API 토큰 관리 시스템 구현 (액세스/리프레시 토큰 분리)`
---
## 2025-11-10 (일) - 회원가입 쿼리 최적화 (268개 → 58개, 78% 감소)
### 주요 작업
- **MenuObserver 성능 최적화**: Bulk insert + 지연 캐시 삭제로 메뉴당 28개 쿼리 → 3개 쿼리
- **RegisterService 중복 제거**: 권한 생성 로직 중복 제거 (27개 쿼리 감소)
- **캐시 삭제 최적화**: 126개 캐시 삭제 → 11개 (91% 감소)
- **확장성 유지**: 관리자의 메뉴 추가 시에도 동일한 최적화 적용
### 수정된 파일:
- `app/Observers/MenuObserver.php` - Bulk insert 및 DB::afterCommit() 활용
- `app/Services/RegisterService.php` - 중복 권한 생성 로직 제거
### 작업 내용:
#### 1. 문제 분석
**증상:**
```
회원가입 시 268개 쿼리 실행 (과다)
- MenuObserver: 9개 메뉴 × 28개 = 252개
- RegisterService 중복: 9개 × 3개 = 27개
- 기타: 19개
```
**원인:**
- MenuObserver가 메뉴 생성 시마다 7개 권한을 **개별 INSERT** (menu:{id}.view, create, update, delete, approve, export, manage)
- 각 권한 INSERT마다 **캐시 즉시 삭제** (배치 처리 안 됨)
- RegisterService가 **다른 패턴**으로 권한 중복 생성 (menu.{id})
**쿼리 분석:**
```
메뉴 1개당:
- SELECT 존재확인 × 7 = 7개
- INSERT 권한 × 7 = 7개
- DELETE 캐시 × 7 × 2 = 14개
총 28개 쿼리
9개 메뉴 × 28 = 252개 쿼리
```
#### 2. MenuObserver.php 최적화
**Before (개별 INSERT):**
```php
protected function ensurePermissions(Menu $menu): void
{
foreach ($this->actions() as $act) {
Permission::firstOrCreate([
'tenant_id' => (int) $menu->tenant_id,
'guard_name' => $this->guard,
'name' => "menu:{$menu->id}.{$act}",
]); // 7번 반복 = 28개 쿼리
}
}
```
**After (Bulk Insert + 지연 캐시):**
```php
protected function ensurePermissions(Menu $menu): void
{
$actions = $this->actions();
$permissionsData = [];
$now = now();
foreach ($actions as $act) {
$permissionsData[] = [
'tenant_id' => (int) $menu->tenant_id,
'guard_name' => $this->guard,
'name' => "menu:{$menu->id}.{$act}",
'created_at' => $now,
'updated_at' => $now,
];
}
// Bulk insert (7개를 1번에)
DB::table('permissions')->insertOrIgnore($permissionsData);
}
public function created(Menu $menu): void
{
// ...
$this->ensurePermissions($menu);
$this->forgetCacheAfterCommit(); // 트랜잭션 종료 후 1번만
}
protected function forgetCacheAfterCommit(): void
{
DB::afterCommit(function () {
app(PermissionRegistrar::class)->forgetCachedPermissions();
});
}
```
**개선 효과:**
- 메뉴 1개당: 28개 쿼리 → **3개 쿼리** (bulk insert + 지연 캐시)
- 9개 메뉴: 252개 → **27개 쿼리**
#### 3. RegisterService.php 중복 제거
**Before (중복 권한 생성):**
```php
// 8. Create permissions for each menu and assign to role
$permissions = [];
foreach ($menuIds as $menuId) {
$permName = "menu.{$menuId}"; // ❌ 다른 패턴 (menu.{id})
$perm = Permission::firstOrCreate([
'tenant_id' => $tenant->id,
'guard_name' => 'api',
'name' => $permName,
]); // 9개 × 3개 쿼리 = 27개 추가 쿼리
$permissions[] = $perm;
}
$role->syncPermissions($permissions);
```
**After (Observer 권한 재사용):**
```php
// 8. Get all permissions created by MenuObserver (menu:{id}.{action} pattern)
$permissionNames = [];
$actions = config('authz.menu_actions', ['view', 'create', 'update', 'delete', 'approve']);
foreach ($menuIds as $menuId) {
foreach ($actions as $action) {
$permissionNames[] = "menu:{$menuId}.{$action}";
}
}
$permissions = Permission::whereIn('name', $permissionNames)
->where('tenant_id', $tenant->id)
->where('guard_name', 'api')
->get(); // 1개 쿼리로 모든 권한 조회
// 9. Assign all menu permissions to system_manager role
$role->syncPermissions($permissions);
```
**개선 효과:**
- 중복 생성 제거: **27개 쿼리 감소**
- 권한 패턴 통일: `menu:{id}.{action}` 형식으로 일관성 유지
#### 4. 최종 결과
**쿼리 구성 (총 58개):**
```
- INSERT menus : 9개
- INSERT permissions (bulk) : 9개 (메뉴당 7개씩 일괄)
- DELETE cache : 11개 (이전 126개 → 91% 감소)
- INSERT tenants/users/roles : 5개
- INSERT tenant_bootstrap : 6개
- SELECT/기타 : 18개
──────────────────────────────────────
총합: 58개 (이전 268개 대비 78% 감소)
```
**데이터 검증:**
```
✅ 메뉴: 9개 생성
✅ 권한: 63개 생성 (9메뉴 × 7액션)
- 액션: view, create, update, delete, approve, export, manage
✅ 권한 패턴: menu:{id}.{action} (통일됨)
✅ Role 할당: system_manager에 모든 권한 부여
```
### 기술 세부사항:
#### Bulk Insert 최적화
```php
// Before: 7번의 개별 INSERT
Permission::firstOrCreate([...]); // × 7
// After: 1번의 Bulk INSERT
DB::table('permissions')->insertOrIgnore([
[...], // 7개의 레코드
[...],
// ...
]);
```
#### 지연 캐시 삭제 (DB::afterCommit)
```php
// Before: 권한마다 즉시 캐시 삭제
foreach ($actions as $act) {
Permission::firstOrCreate([...]);
app(PermissionRegistrar::class)->forgetCachedPermissions(); // × 7
}
// After: 트랜잭션 종료 후 1번만
DB::afterCommit(function () {
app(PermissionRegistrar::class)->forgetCachedPermissions(); // × 1
});
```
#### 권한 패턴 통일
```
Before:
- MenuObserver: menu:{id}.view, menu:{id}.create, ...
- RegisterService: menu.{id} (중복!)
After:
- MenuObserver: menu:{id}.view, menu:{id}.create, ...
- RegisterService: MenuObserver 권한 재사용 (중복 제거)
```
### SAM API Development Rules 준수:
**성능 최적화:**
- Bulk insert로 쿼리 횟수 최소화
- 캐시 삭제를 트랜잭션 단위로 배치 처리
**확장성 유지:**
- 관리자가 나중에 메뉴 추가 시에도 동일한 최적화 적용
- Role/Department/User별 세밀한 권한 제어 가능
**코드 일관성:**
- 권한 패턴 통일 (menu:{id}.{action})
- 중복 로직 제거
**코드 품질:**
- Laravel Pint 포맷팅 완료 (2 files)
### 예상 효과:
1. **성능 향상**: 회원가입 응답 속도 개선 (쿼리 78% 감소)
2. **서버 부하 감소**: DB 커넥션 사용량 대폭 감소
3. **확장성 유지**: 미래 메뉴 추가 시에도 최적화 효과 지속
4. **유지보수성**: 권한 패턴 통일로 코드 이해도 향상
### 테스트 결과:
```bash
php artisan tinker --execute="
DB::enableQueryLog();
\$result = App\Services\RegisterService::register([...]);
\$queries = DB::getQueryLog();
echo '쿼리 수: ' . count(\$queries) . '개';
"
# 결과: 58개 (이전 268개)
```
### 다음 작업:
- [x] MenuObserver bulk insert 구현
- [x] 지연 캐시 삭제 (DB::afterCommit)
- [x] RegisterService 중복 권한 생성 제거
- [x] Pint 포맷팅
- [x] 회원가입 테스트 및 쿼리 수 검증
### Git 커밋:
- 커밋 메시지: `perf: 회원가입 쿼리 최적화 (268개 → 58개, 78% 감소)`
---
## 2025-11-10 (일) - 회원가입 메뉴 생성 오류 수정 및 검증 에러 처리 개선
### 주요 작업
- **MenusStep 컬럼 오류 수정**: 존재하지 않는 컬럼(code, route_name, depth, description) 제거
- **하이브리드 메뉴 생성 방식 도입**: TenantBootstrapper에서 MenusStep 비활성화, MenuBootstrapService 활용
- **ValidationException 처리 개선**: 실제 검증 에러 메시지 표시 (422 상태 코드)
### 수정된 파일:
- `app/Services/TenantBootstrap/Steps/MenusStep.php` - 실제 DB 스키마에 맞게 컬럼 수정
- `app/Services/TenantBootstrap/RecipeRegistry.php` - MenusStep 비활성화 (주석 처리)
- `app/Exceptions/Handler.php` - ValidationException 처리 로직 개선
### 작업 내용:
#### 1. 문제 분석
**증상:**
```
SQLSTATE[42S22]: Column not found: 1054 Unknown column 'code' in 'field list'
SQL: insert into `menus` (..., `code`, `route_name`, `depth`, `description`, ...)
```
**원인:**
- `TenantObserver`가 Tenant 생성 시 자동으로 `TenantBootstrapper::bootstrap()` 호출
- `MenusStep.php`가 실제 DB에 없는 컬럼(`code`, `route_name`, `depth`, `description`) 사용 시도
- `RegisterService.php``MenuBootstrapService::createDefaultMenus()`와 중복 실행
**쿼리 과다 실행:**
- 메뉴 9개 생성 시 272개 쿼리 실행
- MenuObserver가 메뉴당 7개 권한 자동 생성 (view/create/update/delete/approve/export/manage)
- 중복 메뉴 생성 + 중복 권한 생성
#### 2. MenusStep.php 수정
**Before (잘못된 컬럼):**
```php
$newId = DB::table('menus')->insertGetId([
'tenant_id' => $tenantId,
'parent_id' => $newParentId,
'name' => $menu->name,
'code' => $menu->code ?? null, // ❌ 존재하지 않음
'icon' => $menu->icon ?? null,
'url' => $menu->url ?? null,
'route_name' => $menu->route_name ?? null, // ❌ 존재하지 않음
'sort_order' => $menu->sort_order ?? 0,
'is_active' => $menu->is_active ?? 1,
'depth' => $menu->depth ?? 0, // ❌ 존재하지 않음
'description' => $menu->description ?? null, // ❌ 존재하지 않음
'created_at' => now(),
'updated_at' => now(),
]);
```
**After (실제 DB 스키마):**
```php
$newId = DB::table('menus')->insertGetId([
'tenant_id' => $tenantId,
'parent_id' => $newParentId,
'name' => $menu->name,
'icon' => $menu->icon ?? null,
'url' => $menu->url ?? null,
'sort_order' => $menu->sort_order ?? 0,
'is_active' => $menu->is_active ?? 1,
'hidden' => $menu->hidden ?? 0, // ✅ 실제 컬럼
'is_external' => $menu->is_external ?? 0, // ✅ 실제 컬럼
'external_url' => $menu->external_url ?? null, // ✅ 실제 컬럼
'created_at' => now(),
'updated_at' => now(),
]);
```
**실제 DB 컬럼:**
```sql
id, tenant_id, parent_id, name, url, is_active, sort_order,
hidden, is_external, external_url, icon,
created_at, updated_at, created_by, updated_by, deleted_by, deleted_at
```
#### 3. 하이브리드 메뉴 생성 방식 도입
**배경:**
- **Option A**: TenantBootstrapper (글로벌 메뉴 복제, DB 의존)
- **Option B**: MenuBootstrapService (코드 기반, Git 버전 관리)
**선택: 하이브리드 접근** (Best Practice)
```
TenantObserver → TenantBootstrapper
├─ CapabilityProfilesStep ✅ (유지)
├─ CategoriesStep ✅ (유지)
├─ MenusStep ❌ (비활성화)
└─ SettingsStep ✅ (유지)
RegisterService → MenuBootstrapService ✅ (메뉴 생성)
```
**장점:**
- ✅ 메뉴 구조가 코드로 명확하게 정의됨 (`MenuBootstrapService.php`)
- ✅ Git으로 버전 관리 가능
- ✅ 새 메뉴 추가가 간단 (코드만 수정)
- ✅ 글로벌 메뉴 DB 데이터 불필요
- ✅ 부트스트랩 시스템 장점 유지 (CapabilityProfiles, Categories, Settings)
**RecipeRegistry.php 수정:**
```php
default => [ // STANDARD
new CapabilityProfilesStep,
new CategoriesStep,
// new MenusStep, // Disabled: Use MenuBootstrapService in RegisterService instead
new SettingsStep,
],
```
#### 4. ValidationException 처리 개선
**문제:**
```php
// Before - 모든 validation 에러를 "필수 파라미터 누락"으로 변환
if (
$exception instanceof ValidationException ||
$exception instanceof BadRequestHttpException
) {
return response()->json([
'success' => false,
'message' => '필수 파라미터 누락', // ❌ 실제 에러 메시지 손실
'data' => null,
], 400);
}
```
**증상:**
- Slack: "이메일은(는) 이미 사용 중입니다" (실제 에러)
- API 응답: "필수 파라미터 누락" (잘못된 메시지)
**수정:**
```php
// After - 실제 검증 에러 메시지 표시
if ($exception instanceof ValidationException) {
return response()->json([
'success' => false,
'message' => '입력값 검증 실패',
'data' => [
'errors' => $exception->errors(), // ✅ 실제 에러 정보
],
], 422); // ✅ 표준 validation 실패 코드
}
if ($exception instanceof BadRequestHttpException) {
return response()->json([
'success' => false,
'message' => '잘못된 요청',
'data' => null,
], 400);
}
```
**개선 효과:**
```json
// Before
{
"success": false,
"message": "필수 파라미터 누락",
"data": null
}
// After
{
"success": false,
"message": "입력값 검증 실패",
"data": {
"errors": {
"email": ["이메일은(는) 이미 사용 중입니다."],
"user_id": ["사용자 아이디은(는) 이미 사용 중입니다."]
}
}
}
```
### 기술 세부사항:
#### 메뉴 생성 방식 비교
**TenantBootstrapper + MenusStep (기존):**
- 장점: 체계적인 부트스트랩 시스템, 레시피 기반 확장
- 단점: 글로벌 메뉴 DB 데이터 필요, Git 버전 관리 불가, 메뉴 추가 시 DB 수정 필요
**MenuBootstrapService (새 방식):**
- 장점: 코드 기반, Git 버전 관리, 메뉴 추가 간단
- 단점: 부트스트랩 시스템과 분리
**하이브리드 (선택):**
- 데이터 부트스트랩(CapabilityProfiles, Categories, Settings)은 TenantBootstrapper 사용
- 메뉴 생성은 코드 기반 MenuBootstrapService 사용
- 양쪽 장점 활용
#### HTTP 상태 코드 표준화
- **422 Unprocessable Entity**: Validation 실패 (표준)
- **400 Bad Request**: 잘못된 요청 형식
- **401 Unauthorized**: 인증 실패
- **403 Forbidden**: 권한 없음
- **404 Not Found**: 리소스 없음
- **500 Internal Server Error**: 서버 에러
### SAM API Development Rules 준수:
**Service-First 아키텍처:**
- MenuBootstrapService에 메뉴 생성 로직
**멀티테넌시:**
- Tenant context 명시적 설정
- BelongsToTenant 스코프 활용
**코드 품질:**
- 실제 DB 스키마와 일치
- 명확한 주석 (비활성화 이유 설명)
**에러 처리:**
- 표준 HTTP 상태 코드
- 실제 검증 에러 메시지 표시
### 예상 효과:
1. **회원가입 정상 동작**: SQL 에러 해결
2. **쿼리 최적화**: 272개 → 약 100개 (중복 제거)
3. **유지보수 편의성**: 코드 기반 메뉴 관리
4. **명확한 에러 메시지**: 사용자가 정확한 문제 파악 가능
### 다음 작업:
- [x] MenusStep.php 컬럼 수정
- [x] RecipeRegistry.php MenusStep 비활성화
- [x] Handler.php ValidationException 처리 개선
- [x] 캐시 클리어
- [x] 회원가입 API 테스트 (성공/실패 케이스)
### Git 커밋:
- 커밋 메시지: `fix: 회원가입 메뉴 생성 오류 수정 및 검증 에러 처리 개선`
---
## 2025-11-06 (수) - Login API 응답 개선 (사용자/테넌트/메뉴 정보 포함)
### 주요 작업
- **Login API 응답 확장**: 토큰 외에 user, tenant, menus 정보 추가
- **테넌트 우선순위 로직**: is_default → is_active → null 순서로 선택
- **권한 기반 메뉴 필터링**: menu:{id}.view 권한 + override allow/deny 적용
- **Permission Overrides 활용**: 시간 기반 명시적 허용/차단 지원
- **메뉴 외부 링크 지원**: is_external, external_url 필드 추가
### 수정된 파일:
- `app/Services/MemberService.php` - getUserInfoForLogin() 메서드 추가 (130줄) + 외부 링크 필드 추가
- `app/Http/Controllers/Api/V1/ApiController.php` - login() 응답 구조 변경 (8줄)
- `app/Swagger/v1/AuthApi.php` - login() 엔드포인트 문서 업데이트 (80줄) + 외부 링크 스키마 추가
### 작업 내용:
#### 1. MemberService::getUserInfoForLogin() 구현
**5단계 프로세스:**
```php
1. 사용자 기본 정보 조회
- User::find($userId)
- 반환: {id, user_id, name, email, phone}
2. 활성 테넛트 조회 (우선순위)
- 1순위: is_default=1
- 2순위: is_active=1 ( 번째)
- 없으면: return {user, tenant: null, menus: []}
3. 테넛트 정보 구성
- 기본 테넌트: {id, company_name, business_num, tenant_st_code}
- 추가 테넌트 목록: other_tenants[]
4. 메뉴 권한 체크 (menu:{menu_id}.view 패턴)
- 4-1. 기본 Role 권한 (model_has_permissions 테이블)
- 4-2. Override 권한 (permission_overrides 테이블)
- 4-3. 최종 권한 계산: deny(-1) > allow(1) > base permission
5. 메뉴 목록 조회
- Menu::whereIn('id', $allowedMenuIds)
- 정렬: parent_id sort_order
- 반환: {id, parent_id, name, url, icon, sort_order, is_external, external_url}
```
**권한 우선순위 로직:**
```php
foreach ($allMenuPermissions as $permName) {
// 1. Override deny 체크
if (isset($overrides[$permName]) && $overrides[$permName]->effect === -1) {
continue; // 강제 차단
}
// 2. Override allow 또는 기본 Role 권한
if (
(isset($overrides[$permName]) && $overrides[$permName]->effect === 1) ||
in_array($permName, $rolePermissions, true)
) {
$allowedMenuIds[] = $menuId;
}
}
```
**시간 기반 Override 적용:**
```php
->where(function ($q) {
$q->whereNull('permission_overrides.effective_from')
->orWhere('permission_overrides.effective_from', '<=', now());
})
->where(function ($q) {
$q->whereNull('permission_overrides.effective_to')
->orWhere('permission_overrides.effective_to', '>=', now());
})
```
#### 2. ApiController::login() 응답 변경
**기존 응답:**
```json
{
"message": "로그인 성공",
"user_token": "1|abc123xyz"
}
```
**개선된 응답:**
```json
{
"message": "로그인 성공",
"user_token": "1|abc123xyz",
"user": {
"id": 1,
"user_id": "hamss",
"name": "홍길동",
"email": "hamss@example.com",
"phone": "010-1234-5678"
},
"tenant": {
"id": 1,
"company_name": "주식회사 코드브리지",
"business_num": "123-45-67890",
"tenant_st_code": "ACTIVE",
"other_tenants": [
{
"tenant_id": 2,
"company_name": "주식회사 샘플",
"business_num": "987-65-43210",
"tenant_st_code": "ACTIVE"
}
]
},
"menus": [
{
"id": 1,
"parent_id": null,
"name": "대시보드",
"url": "/dashboard",
"icon": "dashboard",
"sort_order": 1
}
]
}
```
**테넌트 없는 경우:**
```json
{
"message": "로그인 성공",
"user_token": "1|abc123xyz",
"user": { ... },
"tenant": null,
"menus": []
}
```
#### 3. Swagger 문서 업데이트
**응답 스키마 (AuthApi.php):**
- 200 응답: 테넌트 있는 경우 (완전한 정보)
- 200 (테넌트 없음): tenant=null, menus=[] 케이스
- 400: 필수 파라미터 누락
- 401: 비밀번호 불일치
- 404: 사용자를 찾을 수 없음
**주요 변경사항:**
```php
@OA\Property(
property="tenant",
type="object",
nullable=true,
description="활성 테넌트 정보 (is_default=1 우선, 없으면 is_active=1 중 첫 번째, 없으면 null)",
// ... 스키마 정의
)
@OA\Property(
property="menus",
type="array",
description="사용자가 접근 가능한 메뉴 목록 (menu:{menu_id}.view 권한 체크, override deny/allow 적용)",
// ... 스키마 정의
)
```
### 기술 세부사항:
#### Permission Overrides 테이블 구조
```sql
CREATE TABLE permission_overrides (
tenant_id BIGINT UNSIGNED,
model_type VARCHAR(255), -- User::class
model_id BIGINT UNSIGNED, -- User ID
permission_id BIGINT UNSIGNED,
effect TINYINT, -- 1=ALLOW, -1=DENY
effective_from TIMESTAMP NULL,
effective_to TIMESTAMP NULL
);
```
#### 권한 체크 세 가지 방법 (모두 사용)
1. **Spatie hasPermissionTo()**: Role 기반 자동 상속
2. **permission_overrides**: 명시적 allow/deny with 시간 제약
3. **Role-based inheritance**: Spatie 자동 처리
**우선순위:** override deny > override allow > base permission
#### 성능 특성
- **현재 방식**: 6-7 쿼리, 100-200ms
- **최적화 (캐싱 없음)**: 4 쿼리, 50-100ms
- **캐싱 적용 시**: 1 쿼리 (캐시 후), 10-20ms
**선택:** 세밀한 제어 우선 (로그인 시에만 실행되므로 성능 영향 최소)
### SAM API Development Rules 준수:
**Service-First 아키텍처:**
- MemberService에 모든 비즈니스 로직
- Controller는 DI + 호출만
**멀티테넌시:**
- BelongsToTenant 스코프 활용
- Tenant context 명시적 처리
**보안:**
- 민감 정보 제외 (password, remember_token, timestamps, audit columns)
- 권한 기반 메뉴 필터링
**Swagger 문서:**
- 별도 파일 (app/Swagger/v1/AuthApi.php)
- 완전한 응답 스키마 (테넌트 있음/없음 케이스)
**코드 품질:**
- Laravel Pint 포맷팅 완료 (3 files, 1 style issue fixed)
### 예상 효과:
1. **클라이언트 편의성**: 1회 로그인으로 모든 정보 획득
2. **네트워크 최적화**: 추가 API 호출 불필요 (/me 엔드포인트 미호출)
3. **세밀한 권한 제어**: Override 기능으로 일시적 권한 부여/차단
4. **멀티테넌트 지원**: 여러 테넌트 소속 시 전환 가능 정보 제공
### 다음 작업:
- [ ] Swagger 재생성 (`php artisan l5-swagger:generate`)
- [ ] Postman/Swagger UI로 API 테스트
- [ ] Frontend 로그인 화면에서 응답 데이터 처리
- [ ] 캐싱 전략 고려 (필요 시)
### Git 커밋 준비:
- 다음 커밋 예정: `feat: 로그인 응답에 사용자/테넌트/메뉴 정보 추가`
---
## 2025-11-06 (수) - Register API 개발 (/api/v1/register)
### 주요 작업
- **Register API 전체 구현**: 회원가입 + 테넌트 생성 + 시스템 관리자 권한 자동 부여
- **글로벌 메뉴 복제 로직**: 새 테넌트 생성 시 글로벌 메뉴 자동 복사 (parent_id 매핑)
- **사업자번호 조건부 유효성 검사**: 정식 서비스(active) 업체만 unique 제약
- **완전한 Swagger 문서**: 상세한 요청/응답 스키마 및 에러 케이스
### 추가된 파일:
- `app/Http/Requests/RegisterRequest.php` - 회원가입 요청 검증 (FormRequest)
- `app/Services/RegisterService.php` - 통합 비즈니스 로직 (DB 트랜잭션)
- `app/Http/Controllers/Api/V1/RegisterController.php` - 컨트롤러 (ApiResponse::handle)
- `app/Swagger/v1/RegisterApi.php` - Swagger 문서
### 수정된 파일:
- `app/Services/TenantBootstrap/Steps/MenusStep.php` - 글로벌 메뉴 복제 로직 구현
- `lang/ko/message.php` - `registered` 키 추가
- `lang/ko/error.php` - 4개 에러 메시지 추가 (business_num_format, business_num_duplicate_active, user_id_format, phone_format)
- `routes/api.php` - POST /api/v1/register 라우트 추가
### 작업 내용:
#### 1. RegisterRequest 검증 규칙
**사용자 필드:**
```php
'user_id' => 'required|string|max:255|regex:/^[a-zA-Z0-9_-]+$/|unique:users,user_id',
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users,email',
'phone' => 'nullable|string|max:20|regex:/^[0-9-]+$/',
'password' => 'required|string|min:8|confirmed',
'position' => 'nullable|string|max:100', // options JSON에 저장
```
**테넌트 필드:**
```php
'company_name' => 'required|string|max:255',
'business_num' => [
'required',
'string',
'regex:/^\d{3}-\d{2}-\d{5}$/',
Rule::unique('tenants', 'business_num')->where(function ($query) {
return $query->where('tenant_st_code', 'active'); // ⚠️ active만 unique
}),
],
'company_scale' => 'nullable|string|max:50', // options JSON에 저장
'industry' => 'nullable|string|max:100', // options JSON에 저장
```
**핵심 특징:**
- ✅ 사업자번호: `tenant_st_code='active'`인 경우만 unique (trial/none은 중복 허용)
- ✅ 비밀번호: confirmed 규칙 (password_confirmation 필요)
- ✅ 커스텀 에러 메시지: i18n 키 사용
#### 2. RegisterService 비즈니스 로직
**전체 프로세스 (DB::transaction 래핑):**
```php
1. Tenant 생성
- company_name, business_num
- tenant_st_code = 'trial' (데모 버전)
- options = {company_scale, industry}
2. TenantBootstrap 실행 (STANDARD 레시피)
- MenusStep: 글로벌 메뉴 복제 (parent_id 매핑)
- CategoriesStep, SettingsStep
3. User 생성
- user_id, name, email, phone
- password = Hash::make()
- options = {position}
4. TenantUserProfile 생성
- is_default = 1, is_active = 1
5. Tenant Context 설정
- app()->bind('tenant_id', $tenant->id)
- PermissionRegistrar::setPermissionsTeamId($tenant->id)
6. system_manager Role 생성
- guard_name = 'api'
- description = '시스템 관리자'
7. 모든 테넌트 메뉴 권한 생성 할당
- Menu::where('tenant_id', $tenant->id)->pluck('id')
- Permission::firstOrCreate(['name' => "menu.{menu_id}"])
- $role->syncPermissions($permissions)
8. User에게 system_manager Role 할당
- $user->assignRole($role)
9. 결과 반환
- user: {id, user_id, name, email, phone, options}
- tenant: {id, company_name, business_num, tenant_st_code, options}
```
**주의 사항 (자동 적용됨):**
- ⚠️ **트랜잭션 필수**: 실패 시 전체 롤백
- ⚠️ **멀티테넌시**: Tenant context 명시적 설정
- ⚠️ **보안**: Hash::make() 사용, 입력 검증
- ⚠️ **글로벌 메뉴 복제**: parent_id 매핑으로 계층 구조 유지
- ⚠️ **사업자번호 검증**: 조건부 unique (active만)
#### 3. MenusStep 글로벌 메뉴 복제 로직
**기존 문제:**
- ROOT 메뉴만 생성하는 stub 구현
- 글로벌 메뉴가 복사되지 않음
**개선 내용:**
```php
public function run(int $tenantId): void
{
// 1. 중복 실행 방지
if (Menu::where('tenant_id', $tenantId)->exists()) {
return;
}
// 2. 글로벌 메뉴 조회 (계층 순서로 정렬)
$globalMenus = DB::table('menus')
->whereNull('tenant_id')
->orderByRaw('parent_id IS NULL DESC, parent_id ASC, sort_order ASC')
->get();
// 3. parent_id 매핑 (old_id => new_id)
$parentIdMap = [];
foreach ($globalMenus as $menu) {
// 4. 부모 ID 매핑 확인
$newParentId = null;
if ($menu->parent_id !== null && isset($parentIdMap[$menu->parent_id])) {
$newParentId = $parentIdMap[$menu->parent_id];
}
// 5. 새 메뉴 생성
$newId = DB::table('menus')->insertGetId([
'tenant_id' => $tenantId,
'parent_id' => $newParentId, // ⚠️ 매핑된 parent_id 사용
'name' => $menu->name,
'code' => $menu->code ?? null,
// ... 모든 필드 복사
]);
// 6. 매핑 저장
$parentIdMap[$menu->id] = $newId;
}
}
```
**핵심:**
- ✅ 루트 메뉴 우선 처리 (`parent_id IS NULL DESC`)
- ✅ parent_id 매핑으로 계층 구조 정확히 유지
- ✅ 모든 메뉴 속성 보존 (name, code, icon, url, route_name 등)
#### 4. RegisterController 구현
**패턴:**
```php
public function register(RegisterRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return RegisterService::register($request->validated());
}, __('message.registered'));
}
```
**특징:**
- ✅ FormRequest 타입 힌트 (자동 검증)
- ✅ Service DI + ApiResponse::handle()
- ✅ i18n 메시지 키 사용
- ✅ Controller는 단순 래퍼 역할
#### 5. Swagger 문서 (RegisterApi.php)
**요청 스키마:**
```php
required: user_id, name, email, password, password_confirmation, company_name, business_num
optional: phone, position, company_scale, industry
```
**응답 스키마 (200):**
```php
{
"success": true,
"message": "회원가입이 완료되었습니다",
"data": {
"user": {
"id": 1,
"user_id": "john_doe",
"name": "홍길동",
"email": "john@example.com",
"phone": "010-1234-5678",
"options": {"position": "개발팀장"}
},
"tenant": {
"id": 1,
"company_name": "(주)테크컴퍼니",
"business_num": "123-45-67890",
"tenant_st_code": "trial",
"options": {
"company_scale": "중소기업",
"industry": "IT/소프트웨어"
}
}
}
}
```
**에러 응답 (422):**
```php
{
"success": false,
"message": "유효성 검증에 실패했습니다",
"errors": {
"user_id": ["이미 사용 중인 아이디입니다"],
"business_num": ["사업자등록번호 형식이 올바르지 않습니다 (000-00-00000)"]
}
}
```
#### 6. i18n 메시지 추가
**lang/ko/message.php:**
```php
'registered' => '회원가입이 완료되었습니다.',
```
**lang/ko/error.php:**
```php
'business_num_format' => '사업자등록번호 형식이 올바르지 않습니다 (000-00-00000)',
'business_num_duplicate_active' => '이미 등록된 사업자등록번호입니다 (정식 서비스 업체)',
'user_id_format' => '아이디는 영문, 숫자, _, - 만 사용할 수 있습니다',
'phone_format' => '전화번호 형식이 올바르지 않습니다',
```
#### 7. Routes 등록
**routes/api.php:**
```php
use App\Http\Controllers\Api\V1\RegisterController;
Route::middleware('auth.apikey')->group(function () {
Route::post('register', [RegisterController::class, 'register'])->name('v1.register');
});
```
**엔드포인트:**
- POST /api/v1/register (auth.apikey 미들웨어)
### SAM API Development Rules 준수:
**Service-First 아키텍처:**
- RegisterService에 모든 비즈니스 로직
- Controller는 DI + ApiResponse::handle()만
**FormRequest 검증:**
- RegisterRequest로 모든 검증 규칙 분리
**i18n 메시지 키:**
- __('message.registered'), __('error.xxx') 사용
**Swagger 문서:**
- 별도 파일 (app/Swagger/v1/RegisterApi.php)
- 완전한 요청/응답 스키마
**멀티테넌시:**
- BelongsToTenant 스코프 (Tenant, Role, Permission)
- Explicit tenant context 설정
**감사 로그:**
- created_by, updated_by 컬럼 포함
**SoftDeletes:**
- Tenant, User 모델에 적용
### 기술 세부사항:
#### 조건부 Unique 제약
```php
// trial/none 테넌트는 사업자번호 중복 허용
Rule::unique('tenants', 'business_num')->where(function ($query) {
return $query->where('tenant_st_code', 'active');
})
```
#### parent_id 매핑 알고리즘
```php
// 1. 루트 메뉴 먼저 처리 (parent_id IS NULL)
// 2. insertGetId로 새 ID 캡처
// 3. old_id => new_id 매핑 저장
// 4. 자식 메뉴 처리 시 매핑된 parent_id 사용
$parentIdMap[$oldId] = $newId;
$newParentId = $parentIdMap[$menu->parent_id] ?? null;
```
#### DB Transaction
```php
return DB::transaction(function () use ($params) {
// 모든 작업이 성공하거나 전체 롤백
$tenant = Tenant::create([...]);
app(RecipeRegistry::class)->bootstrap($tenant->id);
$user = User::create([...]);
// ...
return ['user' => $user->only([...]), 'tenant' => $tenant->only([...])];
});
```
### 예상 효과:
1. **원스톱 가입**: 1회 요청으로 모든 설정 완료
2. **즉시 사용 가능**: system_manager 권한으로 모든 메뉴 접근
3. **멀티테넌트 격리**: 각 테넌트별 독립적인 메뉴 구조
4. **유연한 검증**: trial 단계에서는 사업자번호 중복 허용
### 다음 작업:
- [ ] Swagger 재생성 (`php artisan l5-swagger:generate`)
- [ ] Postman/Swagger UI로 API 테스트
- [ ] Frontend 회원가입 화면 구현
- [ ] 이메일 인증 기능 추가 (선택)
- [ ] API 문서 최종 검토
### Git 커밋 준비:
- 다음 커밋 예정: `feat: 회원가입 + 테넌트 생성 통합 API 추가 (/api/v1/register)`
---
(이전 작업 내역은 그대로 유지...)