Files
Gnuboard7/app/Repositories/NotificationRepository.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

166 lines
5.4 KiB
PHP

<?php
namespace App\Repositories;
use App\Contracts\Repositories\NotificationRepositoryInterface;
use App\Models\User;
use App\Support\Query\BoundedPaginator;
use App\Support\Query\PaginationLimits;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Pagination\LengthAwarePaginator;
class NotificationRepository implements NotificationRepositoryInterface
{
/**
* 사용자의 알림 목록을 페이지네이션으로 조회합니다.
*
* @param User $user 대상 사용자
* @param array $filters 필터 조건 (read: unread|read)
* @param int $perPage 페이지당 항목 수
* @return LengthAwarePaginator 페이지네이션된 알림 목록
*/
public function getByUser(User $user, array $filters = [], int $perPage = 20): LengthAwarePaginator
{
$query = $user->notifications();
if (isset($filters['read']) && $filters['read'] === 'unread') {
$query->whereNull('read_at');
} elseif (isset($filters['read']) && $filters['read'] === 'read') {
$query->whereNotNull('read_at');
}
// audit:allow repository-paginate-column-pruning reason: 사용자 1명에 종속된 알림함 —
// $user->notifications() 로 이미 좁혀지고, 목록이 표시하는 본문 자체가 data(JSON) 라
// 걷어낼 넓은 컬럼이 없다
// 정렬 마지막의 기본키는 전순서 보장용이다 — created_at 동률에서 페이지 경계가
// 흔들려 인접 페이지가 같은 알림을 중복 노출하고 다른 알림을 누락하는 것을 막는다.
//
// 알림함은 한 사용자에 묶이지만 시간이 지날수록 계속 쌓인다(설정성 테이블이 아니다).
// 총 건수는 상한까지만 세고, 뒤쪽 페이지 이동은 실측으로 끝까지 열어 둔다.
return BoundedPaginator::paginate(
$query->orderBy('created_at', 'desc')->orderBy('id', 'desc'),
perPage: $perPage,
resultCap: PaginationLimits::resultCap('user.notifications'),
);
}
/**
* 사용자의 미읽음 알림 수를 반환합니다.
*
* @param User $user 대상 사용자
* @return int 미읽음 알림 수
*/
public function getUnreadCount(User $user): int
{
return $user->unreadNotifications()->count();
}
/**
* 특정 알림을 읽음 처리합니다.
*
* @param User $user 대상 사용자
* @param string $notificationId 알림 ID
* @return DatabaseNotification|null 읽음 처리된 알림 (없으면 null)
*/
public function markAsRead(User $user, string $notificationId): ?DatabaseNotification
{
$notification = $user->notifications()->find($notificationId);
if (! $notification) {
return null;
}
$notification->markAsRead();
return $notification;
}
/**
* 지정된 알림들을 일괄 읽음 처리합니다.
*
* @param User $user 대상 사용자
* @param array $ids 알림 ID 배열
* @return int 읽음 처리된 알림 수
*/
public function markBatchAsRead(User $user, array $ids): int
{
return $user->unreadNotifications()
->whereIn('id', $ids)
->update(['read_at' => now()]);
}
/**
* 사용자의 모든 미읽음 알림을 읽음 처리합니다.
*
* @param User $user 대상 사용자
* @return int 읽음 처리된 알림 수
*/
public function markAllAsRead(User $user): int
{
$count = $user->unreadNotifications()->count();
$user->unreadNotifications->markAsRead();
return $count;
}
/**
* 알림을 삭제합니다.
*
* @param User $user 대상 사용자
* @param string $notificationId 알림 ID
* @return bool 삭제 성공 여부 (알림이 없으면 false)
*/
public function delete(User $user, string $notificationId): bool
{
$notification = $user->notifications()->find($notificationId);
if (! $notification) {
return false;
}
return (bool) $notification->delete();
}
/**
* 사용자의 모든 알림을 삭제합니다.
*
* @param User $user
* @return int 삭제된 알림 수
*/
public function deleteAll(User $user): int
{
return $user->notifications()->delete();
}
/**
* 오래된 알림을 정리합니다.
*
* @param int $readRetentionDays 읽은 알림 보관 일수 (0 이하면 정리하지 않음)
* @param int $unreadRetentionDays 미읽음 알림 보관 일수 (0 이하면 정리하지 않음)
* @return array{deleted_read: int, deleted_unread: int}
*/
public function cleanup(int $readRetentionDays, int $unreadRetentionDays): array
{
$deletedRead = 0;
$deletedUnread = 0;
if ($readRetentionDays > 0) {
$deletedRead = DatabaseNotification::whereNotNull('read_at')
->where('read_at', '<', now()->subDays($readRetentionDays))
->delete();
}
if ($unreadRetentionDays > 0) {
$deletedUnread = DatabaseNotification::whereNull('read_at')
->where('created_at', '<', now()->subDays($unreadRetentionDays))
->delete();
}
return [
'deleted_read' => $deletedRead,
'deleted_unread' => $deletedUnread,
];
}
}