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));
}