Files
Gnuboard7/app/Providers/AuthServiceProvider.php
T
HeuJung 2ae1972a3b perf(core): 대용량 목록 상한 총 건수·커서 계약 신설 및 요청당 반복 실행 비용 정리
공개 이슈 gnuboard/g7 이 지목한 병목은 "같은 일을 반복 실행한다" 축이다.
응답·행의 무게를 다룬 · 과 겹치지 않는다.

공통 계약 — 총 건수 상한과 페이지 이동 범위는 별개 결정이다. 묶으면 필요 없이
기능이 깎인다. 총 건수만 상한을 받고(파생 테이블 COUNT), "다음" 이동은
per_page + 1 실측으로 끝까지 열어 둔다. 계산이 불가능해지는 것은 마지막 페이지
번호 하나뿐이며 그 사실은 last_page: null 이 알린다. 최신순처럼 실제 컬럼으로
정렬하는 목록은 커서로 전환해 깊이와 무관하게 일정 속도로 이동한다. 관련도순은
계산값 정렬이라 커서 키로 쓸 수 없어 offset 을 유지한다.

계약의 입구는 표준 paginate 와 같은 폭이어야 한다. 관계·쿼리 빌더를 받지 못하면
그 좁은 만큼이 그대로 운영 500 이 되고, 실제로 관리자 알림 목록에서 그렇게 터졌다.
응답 조립도 컬렉션마다 손으로 하면 형태가 늘어나는 순간 없는 값을 부르거나 새
필드를 흘리므로, 형태 판정을 paginationMeta 한 곳에 모았다. 표준 paginate
응답은 필드 단위로 이전과 동일하다.

요청당 반복 비용 — 훅 구독마다 남기던 로그 400줄, 요청당 스무 번 넘던 설정 파일
재읽기, 이미 캐시된 목록의 DB 재조회를 없앴다. 권한 판정은 요청 스코프 메모를 두어
화면 요소마다 나가던 조회를 한 번으로 줄였고, 크로스 요청 캐시는 두지 않아 권한
변경이 종전처럼 다음 요청에 반영된다.

검색 질의는 활성 엔진이 만든다. 저장소가 구체 엔진을 지목하면 플러그인이 등록한
엔진은 호출될 기회 자체를 잃고 오류 없이 다른 방식으로 동작한다. 해석기를 두어
활성 엔진에 위임하고, 전문검색이 없는 DBMS 의 부분일치 폴백도 드라이버명 하드코딩
대신 선언형 config 로 옮겼다.

총 건수가 잘린 목록에서 순번을 역산하면 0 과 음수가 나온다. 지어내지 않고 null 을
돌려주며, 그 원칙을 last_page 와 동일하게 적용했다.
2026-08-06 11:13:51 +09:00

279 lines
8.8 KiB
PHP

<?php
namespace App\Providers;
use App\Models\Role;
use App\Models\User;
use App\Support\GuestRoleResolver;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
class AuthServiceProvider extends ServiceProvider
{
/**
* Register any authentication / authorization services.
*/
public function boot(): void
{
// 회원 권한 체크 (비회원은 Gate::before가 호출되지 않으므로 checkPermission() 사용)
// admin 역할도 시더에서 모든 리프 권한을 명시 할당받으므로 별도 바이패스 불필요
Gate::before(function ($user, $ability) {
if ($user) {
// 회원: 기존 hasPermission() 메서드 활용
return $user->hasPermission($ability) ? true : null;
}
// 비회원: Laravel Gate는 guest에 대해 before 콜백을 호출하지 않으므로
// 여기에 도달하지 않음. 대신 checkPermission() 정적 메서드 사용
return null;
});
}
/**
* 회원/비회원 통합 권한 체크 메서드
*
* Laravel Gate::before가 guest에게 호출되지 않는 문제를 해결하기 위해
* Gate와 직접 guest 권한 체크를 조합합니다.
*
* @param string $ability 권한 식별자
* @param User|null $user 사용자 (null이면 현재 인증된 사용자 또는 guest)
* @return bool 권한 허용 여부
*/
public static function checkPermission(string $ability, ?User $user = null): bool
{
// 사용자 결정: 전달된 사용자 > 현재 인증 사용자 > guest
$user = $user ?? Auth::user();
if ($user) {
// 인증된 사용자: Gate 사용 (before 콜백 포함)
return Gate::forUser($user)->allows($ability);
}
// 비회원: 직접 guest role 권한 체크
return self::checkGuestPermission($ability) === true;
}
/**
* 여러 권한을 한 번에 체크합니다. (AND 조건)
*
* @param array<string> $abilities 권한 식별자 배열
* @param User|null $user 사용자 (null이면 현재 인증된 사용자 또는 guest)
* @return bool 모든 권한이 허용되면 true
*/
public static function checkPermissions(array $abilities, ?User $user = null): bool
{
foreach ($abilities as $ability) {
if (! self::checkPermission($ability, $user)) {
return false;
}
}
return true;
}
/**
* 구조화된 권한 로직(OR/AND)을 지원하는 권한 체크 메서드
*
* 지원 형식:
* - flat array: ["a", "b"] → AND (기존 호환)
* - OR 객체: {"or": ["a", "b"]} → a 또는 b
* - AND 객체: {"and": ["a", "b"]} → a 그리고 b
* - 중첩: {"and": ["a", {"or": ["b", "c"]}]} → a + (b 또는 c)
*
* @param array|object $permissions 권한 구조 (flat array 또는 구조화 객체)
* @param User|null $user 사용자 (null이면 현재 인증된 사용자 또는 guest)
* @param int $depth 현재 재귀 깊이 (내부용)
* @return bool 권한 충족 여부
*/
public static function checkPermissionsWithLogic(array|object $permissions, ?User $user = null, int $depth = 1): bool
{
// 최대 중첩 깊이 초과 시 거부
if ($depth > 3) {
Log::warning('권한 구조 최대 중첩 깊이(3) 초과', [
'permissions' => $permissions,
'depth' => $depth,
]);
return false;
}
// 배열 또는 객체를 배열로 변환
$permissions = (array) $permissions;
// 빈 배열 → 권한 불필요
if (empty($permissions)) {
return true;
}
// flat array (기존 호환): 모든 문자열 → AND 로직
if (array_is_list($permissions)) {
return self::evaluateList($permissions, $user, $depth);
}
// 구조화 객체: {"or": [...]} 또는 {"and": [...]}
$keys = array_keys($permissions);
if (count($keys) !== 1 || ! in_array($keys[0], ['or', 'and'])) {
Log::warning('잘못된 권한 구조 연산자', [
'keys' => $keys,
'permissions' => $permissions,
]);
return false;
}
$operator = $keys[0];
$items = $permissions[$operator];
if (! is_array($items) || ! array_is_list($items)) {
return false;
}
if ($operator === 'or') {
return self::evaluateOr($items, $user, $depth);
}
return self::evaluateAnd($items, $user, $depth);
}
/**
* 리스트 내 항목을 AND 로직으로 평가합니다.
*
* @param array $items 권한 항목 배열 (문자열 또는 중첩 구조)
* @param User|null $user 사용자
* @param int $depth 현재 재귀 깊이
* @return bool 모든 항목이 충족되면 true
*/
private static function evaluateList(array $items, ?User $user, int $depth): bool
{
foreach ($items as $item) {
if (! self::evaluateItem($item, $user, $depth)) {
return false;
}
}
return true;
}
/**
* OR 로직으로 평가합니다.
*
* @param array $items 권한 항목 배열
* @param User|null $user 사용자
* @param int $depth 현재 재귀 깊이
* @return bool 하나라도 충족되면 true
*/
private static function evaluateOr(array $items, ?User $user, int $depth): bool
{
foreach ($items as $item) {
if (self::evaluateItem($item, $user, $depth)) {
return true;
}
}
return false;
}
/**
* AND 로직으로 평가합니다.
*
* @param array $items 권한 항목 배열
* @param User|null $user 사용자
* @param int $depth 현재 재귀 깊이
* @return bool 모든 항목이 충족되면 true
*/
private static function evaluateAnd(array $items, ?User $user, int $depth): bool
{
foreach ($items as $item) {
if (! self::evaluateItem($item, $user, $depth)) {
return false;
}
}
return true;
}
/**
* 단일 항목을 평가합니다.
*
* 문자열이면 직접 권한 체크, 배열이면 재귀적으로 구조 평가.
*
* @param mixed $item 권한 항목 (문자열 또는 중첩 구조)
* @param User|null $user 사용자
* @param int $depth 현재 재귀 깊이
* @return bool 권한 충족 여부
*/
private static function evaluateItem(mixed $item, ?User $user, int $depth): bool
{
if (is_string($item)) {
return self::checkPermission($item, $user);
}
if (is_array($item)) {
return self::checkPermissionsWithLogic($item, $user, $depth + 1);
}
return false;
}
/**
* 비회원(guest) role의 권한을 확인합니다.
*
* @param string $ability 권한 식별자
* @return bool|null 권한 있으면 true, 없으면 null (다음 Gate로 위임)
*/
public static function checkGuestPermission(string $ability): ?bool
{
$guestRole = self::getGuestRole();
if (! $guestRole) {
return null; // guest role 없으면 다음 Gate 정의로 위임
}
// 적재해 둔 권한 컬렉션에서 판정한다. 빌더를 다시 열면 캐시가 무의미해져
// Gate 호출마다 쿼리가 나간다.
return GuestRoleResolver::hasPermission($ability) ? true : null;
}
/**
* guest role을 캐싱하여 조회합니다.
*/
protected static function getGuestRole(): ?Role
{
return GuestRoleResolver::resolve();
}
/**
* guest role 캐시를 초기화합니다. (테스트용)
*/
public static function clearGuestRoleCache(): void
{
GuestRoleResolver::flush();
}
/**
* 테스트용: 비회원 권한 체크 결과를 직접 확인합니다.
*
* @param string $ability 권한 식별자
* @return array{result: bool|null, guestRole: Role|null, hasPermission: bool}
*/
public static function debugCheckGuestPermission(string $ability): array
{
$guestRole = self::getGuestRole();
$hasPermission = false;
if ($guestRole) {
$hasPermission = GuestRoleResolver::hasPermission($ability);
}
return [
'result' => self::checkGuestPermission($ability),
'guestRole' => $guestRole,
'hasPermission' => $hasPermission,
'cachedRoleId' => $guestRole?->id,
];
}
}