- AuthService: 토큰 발급/갱신 통합 관리 - RefreshController: POST /api/v1/refresh 엔드포인트 추가 - 액세스 토큰 2시간, 리프레시 토큰 7일 (.env 설정) - TOKEN_EXPIRED 에러 코드로 프론트엔드 자동 리프레시 지원 - 리프레시 토큰 일회성 사용 (보안 강화) - Swagger 문서 Auth 태그로 통합
42 KiB
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):
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):
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 구현
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 토큰 만료 에러 처리
// 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)
# Sanctum 토큰 만료 설정 (분 단위, null이면 무제한)
SANCTUM_ACCESS_TOKEN_EXPIRATION=120 # 2시간 (운영 기준)
SANCTUM_REFRESH_TOKEN_EXPIRATION=10080 # 7일
5. Swagger 문서
POST /api/v1/refresh:
@OA\Post(
path="/api/v1/refresh",
tags={"Auth"},
summary="토큰 갱신 (리프레시 토큰으로 새로운 액세스 토큰 발급)",
description="리프레시 토큰을 사용하여 새로운 액세스 토큰과 리프레시 토큰을 발급받습니다. 리프레시 토큰은 사용 후 폐기되며 새로운 리프레시 토큰이 발급됩니다.",
security={{"ApiKeyAuth": {}}},
)
로그인 응답 업데이트:
@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만 허용
데이터베이스 영향
-- 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 테스트:
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 엔드포인트 테스트:
# 로그인
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"
}
데이터베이스 검증:
-- 최근 발급된 토큰 확인
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일 후
예상 효과:
- 보안 강화: 단기 액세스 토큰 + 장기 리프레시 토큰
- 세션 관리: 리프레시 토큰 갱신으로 지속적인 로그인 유지
- 에러 처리: TOKEN_EXPIRED 코드로 프론트엔드 자동 리프레시 구현 가능
- 유연성: 환경별 토큰 만료 시간 설정 (개발/운영 분리)
다음 작업:
- AuthService 구현
- RefreshController 구현
- Handler 에러 처리
- Swagger 문서 작성 (Auth 태그)
- i18n 메시지 추가
- Tinker 테스트
- API 엔드포인트 테스트
- 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):
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 + 지연 캐시):
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 (중복 권한 생성):
// 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 권한 재사용):
// 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 최적화
// Before: 7번의 개별 INSERT
Permission::firstOrCreate([...]); // × 7
// After: 1번의 Bulk INSERT
DB::table('permissions')->insertOrIgnore([
[...], // 7개의 레코드
[...],
// ...
]);
지연 캐시 삭제 (DB::afterCommit)
// 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)
예상 효과:
- 성능 향상: 회원가입 응답 속도 개선 (쿼리 78% 감소)
- 서버 부하 감소: DB 커넥션 사용량 대폭 감소
- 확장성 유지: 미래 메뉴 추가 시에도 최적화 효과 지속
- 유지보수성: 권한 패턴 통일로 코드 이해도 향상
테스트 결과:
php artisan tinker --execute="
DB::enableQueryLog();
\$result = App\Services\RegisterService::register([...]);
\$queries = DB::getQueryLog();
echo '쿼리 수: ' . count(\$queries) . '개';
"
# 결과: 58개 (이전 268개)
다음 작업:
- MenuObserver bulk insert 구현
- 지연 캐시 삭제 (DB::afterCommit)
- RegisterService 중복 권한 생성 제거
- Pint 포맷팅
- 회원가입 테스트 및 쿼리 수 검증
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 (잘못된 컬럼):
$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 스키마):
$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 컬럼:
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 수정:
default => [ // STANDARD
new CapabilityProfilesStep,
new CategoriesStep,
// new MenusStep, // Disabled: Use MenuBootstrapService in RegisterService instead
new SettingsStep,
],
4. ValidationException 처리 개선
문제:
// Before - 모든 validation 에러를 "필수 파라미터 누락"으로 변환
if (
$exception instanceof ValidationException ||
$exception instanceof BadRequestHttpException
) {
return response()->json([
'success' => false,
'message' => '필수 파라미터 누락', // ❌ 실제 에러 메시지 손실
'data' => null,
], 400);
}
증상:
- Slack: "이메일은(는) 이미 사용 중입니다" (실제 에러)
- API 응답: "필수 파라미터 누락" (잘못된 메시지)
수정:
// 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);
}
개선 효과:
// 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 상태 코드
- 실제 검증 에러 메시지 표시
예상 효과:
- 회원가입 정상 동작: SQL 에러 해결
- 쿼리 최적화: 272개 → 약 100개 (중복 제거)
- 유지보수 편의성: 코드 기반 메뉴 관리
- 명확한 에러 메시지: 사용자가 정확한 문제 파악 가능
다음 작업:
- MenusStep.php 컬럼 수정
- RecipeRegistry.php MenusStep 비활성화
- Handler.php ValidationException 처리 개선
- 캐시 클리어
- 회원가입 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단계 프로세스:
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}
권한 우선순위 로직:
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 적용:
->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() 응답 변경
기존 응답:
{
"message": "로그인 성공",
"user_token": "1|abc123xyz"
}
개선된 응답:
{
"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
}
]
}
테넌트 없는 경우:
{
"message": "로그인 성공",
"user_token": "1|abc123xyz",
"user": { ... },
"tenant": null,
"menus": []
}
3. Swagger 문서 업데이트
응답 스키마 (AuthApi.php):
- 200 응답: 테넌트 있는 경우 (완전한 정보)
- 200 (테넌트 없음): tenant=null, menus=[] 케이스
- 400: 필수 파라미터 누락
- 401: 비밀번호 불일치
- 404: 사용자를 찾을 수 없음
주요 변경사항:
@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 테이블 구조
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
);
권한 체크 세 가지 방법 (모두 사용)
- Spatie hasPermissionTo(): Role 기반 자동 상속
- permission_overrides: 명시적 allow/deny with 시간 제약
- 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회 로그인으로 모든 정보 획득
- 네트워크 최적화: 추가 API 호출 불필요 (/me 엔드포인트 미호출)
- 세밀한 권한 제어: Override 기능으로 일시적 권한 부여/차단
- 멀티테넌트 지원: 여러 테넌트 소속 시 전환 가능 정보 제공
다음 작업:
- 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 검증 규칙
사용자 필드:
'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에 저장
테넌트 필드:
'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 래핑):
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 구현
- 글로벌 메뉴가 복사되지 않음
개선 내용:
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 구현
패턴:
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)
요청 스키마:
required: user_id, name, email, password, password_confirmation, company_name, business_num
optional: phone, position, company_scale, industry
응답 스키마 (200):
{
"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):
{
"success": false,
"message": "유효성 검증에 실패했습니다",
"errors": {
"user_id": ["이미 사용 중인 아이디입니다"],
"business_num": ["사업자등록번호 형식이 올바르지 않습니다 (000-00-00000)"]
}
}
6. i18n 메시지 추가
lang/ko/message.php:
'registered' => '회원가입이 완료되었습니다.',
lang/ko/error.php:
'business_num_format' => '사업자등록번호 형식이 올바르지 않습니다 (000-00-00000)',
'business_num_duplicate_active' => '이미 등록된 사업자등록번호입니다 (정식 서비스 업체)',
'user_id_format' => '아이디는 영문, 숫자, _, - 만 사용할 수 있습니다',
'phone_format' => '전화번호 형식이 올바르지 않습니다',
7. Routes 등록
routes/api.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 제약
// trial/none 테넌트는 사업자번호 중복 허용
Rule::unique('tenants', 'business_num')->where(function ($query) {
return $query->where('tenant_st_code', 'active');
})
parent_id 매핑 알고리즘
// 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
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회 요청으로 모든 설정 완료
- 즉시 사용 가능: system_manager 권한으로 모든 메뉴 접근
- 멀티테넌트 격리: 각 테넌트별 독립적인 메뉴 구조
- 유연한 검증: trial 단계에서는 사업자번호 중복 허용
다음 작업:
- Swagger 재생성 (
php artisan l5-swagger:generate) - Postman/Swagger UI로 API 테스트
- Frontend 회원가입 화면 구현
- 이메일 인증 기능 추가 (선택)
- API 문서 최종 검토
Git 커밋 준비:
- 다음 커밋 예정:
feat: 회원가입 + 테넌트 생성 통합 API 추가 (/api/v1/register)
(이전 작업 내역은 그대로 유지...)