perf: 활동 피드 무제한 렌더링 방지(상한 + 페이지네이션)
활발한 프로젝트/업무에서 활동이 한 번에 전부 내려와 렌더링되던 문제 보완. - 업무 상세 활동 탭: 최신 100건으로 조회 상한 추가(기존 무제한) - 프로젝트 활동 피드: 200건 통째 조회 → 오프셋 페이지네이션 전환 - 컨트롤러 page/size 쿼리 지원, 서비스 findAndCount - 프론트 스토어 누적 + hasMore/loadMore, 활동 페이지 "더 보기" 버튼 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
import {
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
ParseUUIDPipe,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import type { PaginatedResult } from '../../common/pagination/pagination';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { ActivityService, type ActivityResponse } from './activity.service';
|
||||
|
||||
@@ -17,11 +21,13 @@ export class ActivityController {
|
||||
constructor(private readonly activityService: ActivityService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '프로젝트 활동 피드 (최신순)' })
|
||||
@ApiOperation({ summary: '프로젝트 활동 피드 (최신순, 페이지네이션)' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
list(
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
): Promise<ActivityResponse[]> {
|
||||
return this.activityService.listForProject(projectId);
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
|
||||
): Promise<PaginatedResult<ActivityResponse>> {
|
||||
return this.activityService.listForProject(projectId, page, size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
paginated,
|
||||
resolvePage,
|
||||
type PaginatedResult,
|
||||
} from '../../common/pagination/pagination';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
import { Activity, type ActivityType } from './entities/activity.entity';
|
||||
|
||||
// 업무 상세 활동 탭 조회 상한 — 매우 활발한 업무에서 전량 렌더링을 방지(최신 N건)
|
||||
const TASK_ACTIVITY_LIMIT = 100;
|
||||
|
||||
// 활동 응답(원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립
|
||||
export interface ActivityResponse {
|
||||
id: string;
|
||||
@@ -54,18 +62,29 @@ export class ActivityService {
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 활동 피드 — projectId(uuid)로 조회(최신순, 최대 200건). 날짜 그룹핑은 프론트.
|
||||
async listForProject(projectId: string): Promise<ActivityResponse[]> {
|
||||
const rows = await this.activityRepo.find({
|
||||
// 프로젝트 활동 피드 — projectId(uuid)로 조회(최신순, 오프셋 페이지네이션). 날짜 그룹핑은 프론트.
|
||||
async listForProject(
|
||||
projectId: string,
|
||||
page?: number,
|
||||
size?: number,
|
||||
): Promise<PaginatedResult<ActivityResponse>> {
|
||||
const pg = resolvePage(page, size);
|
||||
const [rows, total] = await this.activityRepo.findAndCount({
|
||||
where: { project: { id: projectId } },
|
||||
relations: ['actor'],
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 200,
|
||||
skip: pg.skip,
|
||||
take: pg.take,
|
||||
});
|
||||
return rows.map((a) => this.toResponse(a));
|
||||
return paginated(
|
||||
rows.map((a) => this.toResponse(a)),
|
||||
total,
|
||||
pg,
|
||||
);
|
||||
}
|
||||
|
||||
// 업무 활동 — projectId(uuid) + 업무 순번(seq) 기준(최신순). 업무 상세 활동 탭용.
|
||||
// 전량 렌더링 방지를 위해 최신 TASK_ACTIVITY_LIMIT 건으로 제한한다.
|
||||
async listForTask(
|
||||
projectId: string,
|
||||
taskSeq: number,
|
||||
@@ -74,6 +93,7 @@ export class ActivityService {
|
||||
where: { project: { id: projectId }, taskSeq },
|
||||
relations: ['actor'],
|
||||
order: { createdAt: 'DESC' },
|
||||
take: TASK_ACTIVITY_LIMIT,
|
||||
});
|
||||
return rows.map((a) => this.toResponse(a));
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useApi } from './useApi'
|
||||
import { DEFAULT_PAGE_SIZE, type Paginated } from '@/types/pagination'
|
||||
import type { ApiActivity } from '@/types/activity'
|
||||
|
||||
// 활동 피드 API Composable — 인터셉터가 success/data 를 언래핑
|
||||
export function useActivity() {
|
||||
const api = useApi()
|
||||
|
||||
// 프로젝트 활동 피드(최신순)
|
||||
async function list(projectId: string): Promise<ApiActivity[]> {
|
||||
return (await api.get(`/projects/${projectId}/activity`)) as unknown as ApiActivity[]
|
||||
// 프로젝트 활동 피드(최신순, 페이지네이션)
|
||||
async function list(
|
||||
projectId: string,
|
||||
page = 1,
|
||||
size = DEFAULT_PAGE_SIZE,
|
||||
): Promise<Paginated<ApiActivity>> {
|
||||
return (await api.get(`/projects/${projectId}/activity`, {
|
||||
params: { page, size },
|
||||
})) as unknown as Paginated<ApiActivity>
|
||||
}
|
||||
|
||||
return { list }
|
||||
|
||||
@@ -242,6 +242,21 @@ const ActIcon = defineComponent({
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 더 보기 — 서버 페이지네이션(누적). 필터와 무관하게 다음 페이지를 불러온다 -->
|
||||
<div
|
||||
v-if="!loading && activityStore.hasMore"
|
||||
class="more-row"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="more-btn"
|
||||
:disabled="activityStore.loadingMore"
|
||||
@click="activityStore.loadMore()"
|
||||
>
|
||||
{{ activityStore.loadingMore ? '불러오는 중…' : '더 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
@@ -373,6 +388,30 @@ const ActIcon = defineComponent({
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
/* 더 보기 */
|
||||
.more-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
.more-btn {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-size: 0.813rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.more-btn:hover:not(:disabled) {
|
||||
background: #f4f5f7;
|
||||
}
|
||||
.more-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 로딩/빈 상태 */
|
||||
.feed-state {
|
||||
background: var(--panel);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useActivity } from '@/composables/useActivity'
|
||||
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
|
||||
import {
|
||||
dayGroupKo,
|
||||
escapeHtml,
|
||||
@@ -116,33 +117,68 @@ function toProjectActivityItem(api: ApiActivity): ProjectActivityItem {
|
||||
}
|
||||
}
|
||||
|
||||
// 활동 스토어 — 프로젝트 활동 피드(날짜 그룹)
|
||||
// 활동 스토어 — 프로젝트 활동 피드(페이지네이션 누적 → 날짜 그룹)
|
||||
export const useActivityStore = defineStore('activity', () => {
|
||||
const days = ref<ProjectActivityDay[]>([])
|
||||
const items = ref<ApiActivity[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(0)
|
||||
const currentProjectId = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
|
||||
const activityApi = useActivity()
|
||||
|
||||
// 프로젝트 활동 조회 → 날짜 그룹으로 묶기(목록은 최신순이라 같은 날은 연속)
|
||||
// 더 불러올 페이지가 남았는지(누적 건수 < 전체)
|
||||
const hasMore = computed(() => items.value.length < total.value)
|
||||
|
||||
// 누적 items → 날짜 그룹(최신순이라 같은 날은 연속)
|
||||
const days = computed<ProjectActivityDay[]>(() => {
|
||||
const groups: ProjectActivityDay[] = []
|
||||
let current: ProjectActivityDay | null = null
|
||||
for (const a of items.value) {
|
||||
const day = dayGroupKo(a.createdAt)
|
||||
if (!current || current.day !== day) {
|
||||
current = { day, items: [] }
|
||||
groups.push(current)
|
||||
}
|
||||
current.items.push(toProjectActivityItem(a))
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
// 첫 페이지(리셋) 로드
|
||||
async function fetch(projectId: string): Promise<void> {
|
||||
loading.value = true
|
||||
currentProjectId.value = projectId
|
||||
try {
|
||||
const list = await activityApi.list(projectId)
|
||||
const groups: ProjectActivityDay[] = []
|
||||
let current: ProjectActivityDay | null = null
|
||||
for (const a of list) {
|
||||
const day = dayGroupKo(a.createdAt)
|
||||
if (!current || current.day !== day) {
|
||||
current = { day, items: [] }
|
||||
groups.push(current)
|
||||
}
|
||||
current.items.push(toProjectActivityItem(a))
|
||||
}
|
||||
days.value = groups
|
||||
const res = await activityApi.list(projectId, 1, DEFAULT_PAGE_SIZE)
|
||||
items.value = res.items
|
||||
total.value = res.total
|
||||
page.value = 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { days, loading, fetch }
|
||||
// 다음 페이지 이어붙이기(더보기)
|
||||
async function loadMore(): Promise<void> {
|
||||
if (loadingMore.value || !hasMore.value || !currentProjectId.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const res = await activityApi.list(
|
||||
currentProjectId.value,
|
||||
page.value + 1,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
)
|
||||
// 그 사이 신규 적재로 중복될 수 있는 id 는 제외하고 append
|
||||
const known = new Set(items.value.map((x) => x.id))
|
||||
items.value.push(...res.items.filter((a) => !known.has(a.id)))
|
||||
total.value = res.total
|
||||
page.value += 1
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { days, loading, loadingMore, hasMore, fetch, loadMore }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user