Files
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

167 lines
5.0 KiB
PHP

<?php
namespace App\Services\LanguagePack;
use App\Contracts\Repositories\LanguagePackRepositoryInterface;
use App\Enums\LanguagePackScope;
use App\Models\LanguagePack;
use Illuminate\Support\Collection;
/**
* 언어팩 런타임 레지스트리.
*
* config('app.supported_locales') 가 본 레지스트리를 소스로 사용하며, 활성 언어팩 조회를 위해
* 부팅 후 전 시스템에서 참조됩니다. 메모리 캐시는 명시적 invalidate() 호출 시에만 만료됩니다.
*/
class LanguagePackRegistry
{
/**
* 활성 언어팩 캐시.
*
* @var Collection<int, LanguagePack>|null
*/
private ?Collection $activePacksCache = null;
/**
* 활성 코어 로케일 캐시.
*
* @var array<int, string>|null
*/
private ?array $activeCoreLocalesCache = null;
/**
* 항상 포함되어야 하는 번들 로케일 (코어 ko/en 하드 가드).
*
* @var array<int, string>
*/
private const BUNDLED_CORE_LOCALES = ['ko', 'en'];
/**
* 언어팩 Repository.
*/
public function __construct(
private readonly LanguagePackRepositoryInterface $repository
) {}
/**
* 활성 코어 로케일 목록을 반환합니다.
*
* 번들 ko/en 은 항상 포함되며, 추가로 active 상태인 코어 언어팩의 locale 이 합쳐집니다.
*
* @return array<int, string> 로케일 문자열 배열
*/
public function getActiveCoreLocales(): array
{
if ($this->activeCoreLocalesCache !== null) {
return $this->activeCoreLocalesCache;
}
// 활성 언어팩 전체는 getActivePacks() 가 이미 한 번 적재해 캐시한다. 코어 로케일은
// 그 컬렉션의 부분집합이므로 DB 를 다시 부르지 않고 여기서 걸러 낸다.
// (부팅 경로에서 두 메서드가 모두 호출되므로, 재조회하면 요청마다 쿼리가 하나 더 는다)
$fromPacks = $this->getActivePacks(LanguagePackScope::Core->value)
->pluck('locale')
->all();
$merged = array_values(array_unique(array_merge(self::BUNDLED_CORE_LOCALES, $fromPacks)));
return $this->activeCoreLocalesCache = $merged;
}
/**
* getActiveCoreLocales 의 별칭 (호환성).
*
* @return array<int, string> 로케일 문자열 배열
*/
public function getCoreInstalledLocales(): array
{
return $this->getActiveCoreLocales();
}
/**
* 설치된 모든 활성 언어팩의 locale → native_name 맵을 반환합니다.
*
* 번들 ko/en 의 native_name 은 코드 상수로 보장합니다.
*
* @return array<string, string> locale ⇒ native_name 맵
*/
public function getLocaleNames(): array
{
$names = [
'ko' => '한국어',
'en' => 'English',
];
foreach ($this->getActivePacks() as $pack) {
if ($pack->scope !== LanguagePackScope::Core->value) {
continue;
}
$names[$pack->locale] = $pack->locale_native_name ?: $pack->locale;
}
return $names;
}
/**
* 모든 활성 언어팩을 반환합니다 (옵션으로 scope 필터링).
*
* @param string|null $scope 필터링할 스코프 (null 이면 전체)
* @return Collection<int, LanguagePack> 활성 언어팩 컬렉션
*/
public function getActivePacks(?string $scope = null): Collection
{
if ($this->activePacksCache === null) {
$this->activePacksCache = $this->repository->getActivePacks();
}
if ($scope === null) {
return $this->activePacksCache;
}
return $this->activePacksCache->filter(
fn (LanguagePack $pack) => $pack->scope === $scope
)->values();
}
/**
* 특정 로케일의 코어 언어팩이 활성 상태인지 확인합니다.
*
* 번들 ko/en 은 항상 true 를 반환합니다.
*
* @param string $locale 로케일
* @return bool 활성 코어 언어팩 존재 여부
*/
public function hasActiveCoreLocale(string $locale): bool
{
if (in_array($locale, self::BUNDLED_CORE_LOCALES, true)) {
return true;
}
return in_array($locale, $this->getActiveCoreLocales(), true);
}
/**
* 특정 슬롯의 활성 언어팩을 반환합니다.
*
* @param string $scope 스코프
* @param string|null $target 대상 확장 식별자
* @param string $locale 로케일
* @return LanguagePack|null 활성 언어팩 또는 null
*/
public function getActivePackForSlot(string $scope, ?string $target, string $locale): ?LanguagePack
{
return $this->repository->findActiveForSlot($scope, $target, $locale);
}
/**
* 캐시를 만료시킵니다 (활성화/비활성화/제거 직후 호출).
*
* @return void
*/
public function invalidate(): void
{
$this->activePacksCache = null;
$this->activeCoreLocalesCache = null;
}
}