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:
2026-06-22 18:03:19 +09:00
parent 2b534743d6
commit 2c02c4afc1
5 changed files with 136 additions and 28 deletions
@@ -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));
}
+10 -3
View File
@@ -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);
+53 -17
View File
@@ -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 }
})