Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c258e1b
feat: 블로그 서비스의 비동기 메서드 추가 및 인덱스 파일 처리 로직 개선
sunub Dec 10, 2025
78eac87
feat: BlogService 성능 벤치마크 테스트 추가 및 지연 평가와 즉시 평가 비교
sunub Dec 10, 2025
be03c5a
fix: BlogPostListViewTrigger에서 조건문 포맷 개선
sunub Dec 10, 2025
8777f2e
fix: tsconfig.json에서 JSX 설정을 'react-jsx'에서 'preserve'로 변경
sunub Dec 10, 2025
4c6db2b
fix: packageManager 버전을 pnpm@10.25.0으로 업데이트
sunub Dec 10, 2025
71627be
chore : 일부 패키지의 버전 변경
sunub Dec 10, 2025
ab2aa0d
feat: 테스트 스크립트에 성능 벤치마크 추가
sunub Dec 10, 2025
2fd7cb6
fix: test:bench 스크립트에 --rootDir 옵션 추가
sunub Dec 10, 2025
d5b3291
fix: package.json에서 next 및 playwright 의존성 버전 수정
sunub Dec 10, 2025
8de9561
fix: logger 접근 제어자를 private에서 public으로 변경 및 코드 정리
sunub Dec 10, 2025
a837a64
fix: 주석 처리된 로딩 화면 테스트 코드 제거
sunub Dec 10, 2025
d26f974
refactor: 코드 정리 및 임포트 순서 변경
sunub Dec 10, 2025
0ed5fb2
feat: 성능 벤치마크 테스트에 메모리 사용량 측정 추가 및 출력 형식 개선
sunub Dec 10, 2025
ece3255
chore : React, Next.js 버전을 보안 주의사항에 맞춰 업그레이드
sunub Dec 10, 2025
b593667
fix: tsconfig.json에서 JSX 설정을 'preserve'에서 'react-jsx'로 변경
sunub Dec 10, 2025
6a42948
feat: 새로운 포스트 데이터 추가
sunub Dec 10, 2025
8aa851d
feat: Lazy vs Eager evaluation 성능 비교를 위한 벤치마크 테스트 개선
sunub Dec 10, 2025
e89f69f
feat: 비동기 검색 기능 추가 및 결과 반환 방식 개선
sunub Dec 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"test:bench": "jest --rootDir . test/bench/performance.spec.ts"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
Expand Down
122 changes: 80 additions & 42 deletions backend/src/instances/blog/blog.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { createReadStream } from "node:fs";
import { opendir } from "node:fs/promises";
import { opendir, readFile, writeFile } from "node:fs/promises";
import { cpus } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { concurrent, filter, map, pipe, toArray } from "fx_utils";
import { concurrent, filter, map, pipe, take, toArray } from "fx_utils";
import * as matter from "gray-matter";
import { FileProcessor } from "./FileProcessor";
import {
Expand All @@ -16,24 +16,20 @@ import {

@Injectable()
export class BlogService implements OnModuleInit {
private readonly logger = new Logger(BlogService.name);
private readonly POSTS_ROOT_PATH = join(process.cwd(), "../posts");
private readonly INDEX_FILE_PATH = join(this.POSTS_ROOT_PATH, "posts.jsonl");
public readonly logger = new Logger(BlogService.name);

private allPosts: PostFrontMatter[] = [];
private postsByCategory: Record<string, PostFrontMatter[]> = {
web: [],
algorithm: [],
code: [],
cs: [],
};
private totalPostCount = 0;
private fileProcessor = new FileProcessor();

async onModuleInit() {
this.logger.log("BlogService 초기화를 진행합니다...");
try {
await this.processAndCacheAllPosts();
await this.ensureIndex();
this.totalPostCount = await this.countPosts();
this.logger.log(
`블로그 서비스가 성공적으로 초기화되었습니다. 총 게시물 수: ${this.allPosts.length}`,
`블로그 서비스가 성공적으로 초기화되었습니다. 총 게시물 수: ${this.totalPostCount}`,
);
} catch (error: unknown) {
this.logger.error(
Expand All @@ -44,30 +40,51 @@ export class BlogService implements OnModuleInit {
}

public getTotalPostCount(): number {
return this.allPosts.length;
return this.totalPostCount;
}

public getLatestPosts(count: number): PostFrontMatter[] {
return this.allPosts.slice(0, count);
public async getLatestPosts(count: number): Promise<PostFrontMatter[]> {
return pipe(this.readIndexLines(), (iter) => take(count, iter), toArray);
}

public getPostsInRange(start: number, end: number): PostFrontMatter[] {
return this.allPosts.slice(start, end);
public async getPostsInRange(
start: number,
end: number,
): Promise<PostFrontMatter[]> {
return pipe(
this.readIndexLines(),
(iter) => {
let index = 0;
return filter(() => {
const keep = index >= start && index < end;
index++;
return keep;
}, iter);
},
(iter) => take(end - start, iter),
toArray,
);
}

public getAllPosts(): PostFrontMatter[] {
return this.allPosts;
public async getAllPosts(): Promise<PostFrontMatter[]> {
return pipe(this.readIndexLines(), toArray);
}

public getPostsByCategory(category: PostCategory): PostFrontMatter[] {
return this.postsByCategory[category] || [];
public async getPostsByCategory(
category: PostCategory,
): Promise<PostFrontMatter[]> {
return pipe(
this.readIndexLines(),
filter((post) => post.frontmatter.category === category),
toArray,
);
}

public getPostBySlug(
public async getPostBySlug(
category: PostCategory,
slug: string,
): PostFrontMatter | undefined {
const posts = this.getPostsByCategory(category);
): Promise<PostFrontMatter | undefined> {
const posts = await this.getPostsByCategory(category);
return posts.find((p) => p.frontmatter.slug === slug);
}

Expand All @@ -79,30 +96,50 @@ export class BlogService implements OnModuleInit {
return this.fileProcessor.processFile(filePath);
}

async processAndCacheAllPosts(category: PostCategory | "." = ".") {
const iterator = await this.createFrontMatterIterator(category);
const allFrontMatters = await toArray(iterator);
private async *readIndexLines(): AsyncGenerator<PostFrontMatter> {
const stream = createReadStream(this.INDEX_FILE_PATH);
const rl = createInterface({ input: stream, crlfDelay: Infinity });

for await (const line of rl) {
if (line.trim()) {
yield JSON.parse(line);
}
}
}

private async countPosts(): Promise<number> {
let count = 0;
const stream = createReadStream(this.INDEX_FILE_PATH);
const rl = createInterface({ input: stream, crlfDelay: Infinity });
for await (const _ of rl) {
count++;
}
return count;
}

private async ensureIndex() {
try {
await readFile(this.INDEX_FILE_PATH);
this.logger.log("인덱스 파일(NDJSON)을 확인했습니다.");
} catch {
this.logger.warn(
"인덱스 파일을 찾을 수 없습니다. 파일 시스템에서 생성합니다...",
);
await this.buildIndexFromFiles();
}
}

async buildIndexFromFiles(category: PostCategory | "." = ".") {
const allFrontMatters = await this.createFrontMatterIterator(category);
const sortedPosts = allFrontMatters.sort((a, b) =>
a.frontmatter.date > b.frontmatter.date ? -1 : 1,
);

const categorizedPosts = sortedPosts.reduce(
(acc, post) => {
const { category } = post.frontmatter;
if (acc[category]) {
acc[category].push(post);
}
return acc;
},
{ web: [], algorithm: [], code: [], cs: [] } as Record<
PostCategory,
PostFrontMatter[]
>,
const content = sortedPosts.map((p) => JSON.stringify(p)).join("\n");
await writeFile(this.INDEX_FILE_PATH, content);
this.logger.log(
`인덱스 파일(NDJSON)을 생성했습니다: ${this.INDEX_FILE_PATH}`,
);

this.allPosts = sortedPosts;
this.postsByCategory = categorizedPosts;
}

private async createFrontMatterIterator(category: PostCategory | ".") {
Expand Down Expand Up @@ -131,6 +168,7 @@ export class BlogService implements OnModuleInit {
}),
concurrent(maxConcurrency),
filter((data): data is PostFrontMatter => data !== null),
toArray,
);
} catch (error) {
throw new Error(
Expand Down
22 changes: 10 additions & 12 deletions backend/src/posts/posts.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,32 @@ import type { PostCategory } from "../instances/blog/Schema";
export class PostsService {
constructor(private readonly blogService: BlogService) {}

findAll() {
async findAll() {
return this.blogService.getAllPosts();
}

findLatest(count: number = 10) {
async findLatest(count: number = 10) {
const totalCount = this.blogService.getTotalPostCount();
const latestFrontmatters = this.blogService
.getLatestPosts(count)
.map((post) => post.frontmatter);
const posts = await this.blogService.getLatestPosts(count);
const latestFrontmatters = posts.map((post) => post.frontmatter);
return {
totalCount,
frontmatters: latestFrontmatters,
};
}

findLatestInRange(start: number, end: number) {
async findLatestInRange(start: number, end: number) {
const totalCount = this.blogService.getTotalPostCount();
const latestFrontmatters = this.blogService
.getPostsInRange(start, end)
.map((post) => post.frontmatter);
const posts = await this.blogService.getPostsInRange(start, end);
const latestFrontmatters = posts.map((post) => post.frontmatter);
return {
totalCount,
frontmatters: latestFrontmatters,
};
}

findByCategory(category: PostCategory) {
const posts = this.blogService.getPostsByCategory(category);
async findByCategory(category: PostCategory) {
const posts = await this.blogService.getPostsByCategory(category);

if (!posts || posts.length === 0) {
throw new Error(`${category} 카테고리에 해당하는 게시물이 없습니다.`);
Expand All @@ -42,7 +40,7 @@ export class PostsService {
}

async findOne(cateogry: PostCategory, slug: string) {
const { frontmatter } = this.blogService.getPostBySlug(cateogry, slug);
const frontmatter = await this.blogService.getPostBySlug(cateogry, slug);
const { content } = await this.blogService.getPostContent(cateogry, slug);
if (!frontmatter || !content) {
throw new Error(
Expand Down
5 changes: 3 additions & 2 deletions backend/src/search/search.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export class SearchController {
constructor(private readonly searchService: SearchService) {}

@Get()
search(@Query("query") query: string) {
return { results: this.searchService.search(query) };
async search(@Query("query") query: string) {
const searchResults = await this.searchService.search(query);
return { results: searchResults };
}
}
4 changes: 2 additions & 2 deletions backend/src/search/search.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import { findMatches } from "./utils/findMatches";
export class SearchService {
constructor(private readonly blogService: BlogService) {}

search(query: string) {
async search(query: string) {
if (!query) {
return [];
}

const posts = this.blogService.getAllPosts();
const posts = await this.blogService.getAllPosts();
const results = [];
for (const post of posts) {
const { title, summary } = post.frontmatter;
Expand Down
123 changes: 123 additions & 0 deletions backend/test/bench/performance.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { constants } from "node:fs";
import { access, readFile, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Test, TestingModule } from "@nestjs/testing";
import { BlogService } from "../../src/instances/blog/blog.service";

describe("BlogService Performance Benchmark", () => {
let service: BlogService;
const POSTS_DIR = join(process.cwd(), "../posts");
const MOCK_DB_PATH = join(POSTS_DIR, "posts.jsonl");
const ORIGINAL_DB_PATH = join(POSTS_DIR, "posts.jsonl.bak");

const TARGET_COUNT = 10000;
const DUMMY_DATA = Array.from({ length: TARGET_COUNT }, (_, i) => ({
frontmatter: {
title: `Post ${i}`,
date: new Date().toISOString(),
category: "code",
slug: `post-${i}`,
},
filePath: `/path/to/post-${i}.mdx`,
}))
.map((p) => JSON.stringify(p))
.join("\n");

beforeAll(async () => {
try {
await access(MOCK_DB_PATH, constants.F_OK);
const existing = await readFile(MOCK_DB_PATH, "utf-8");
await writeFile(ORIGINAL_DB_PATH, existing);
} catch (_e) {}

await writeFile(MOCK_DB_PATH, DUMMY_DATA);

const module: TestingModule = await Test.createTestingModule({
providers: [BlogService],
}).compile();

service = module.get<BlogService>(BlogService);
// Suppress logs
jest.spyOn(service.logger, "log").mockImplementation(() => {});
jest.spyOn(service.logger, "warn").mockImplementation(() => {});
await service.onModuleInit();
});

afterAll(async () => {
try {
await unlink(MOCK_DB_PATH);
try {
await access(ORIGINAL_DB_PATH, constants.F_OK);
const backup = await readFile(ORIGINAL_DB_PATH, "utf-8");
await writeFile(MOCK_DB_PATH, backup);
await unlink(ORIGINAL_DB_PATH);
} catch {}
} catch (_e) {}
});

it("should compare Lazy vs Eager evaluation across multiple fetch sizes", async () => {
const FETCH_COUNTS = [10, 100, 1000, 5000, 10000];

// Helper to measure memory
const getMemoryUsage = () => {
if (global.gc) global.gc();
return process.memoryUsage().heapUsed / 1024 / 1024; // MB
};

console.log("\n========================================================================================");
console.log(`Benchmark Results (Total Posts: ${TARGET_COUNT})`);
console.log("========================================================================================");
console.log("| Fetch Count | Lazy Time (ms) | Eager Time (ms) | Speedup (x) | Lazy Mem (MB) | Eager Mem (MB) | Mem Reduction (x) |");
console.log("|------------:|---------------:|----------------:|------------:|--------------:|---------------:|------------------:|");

for (const count of FETCH_COUNTS) {
// Force GC before each run to get clean baseline
if (global.gc) global.gc();

// 1. Measure Lazy Evaluation
const startMemLazy = getMemoryUsage();
const startLazy = process.hrtime.bigint();
const lazyResult = await service.getLatestPosts(count);
const endLazy = process.hrtime.bigint();
const endMemLazy = getMemoryUsage();

const lazyTime = Number(endLazy - startLazy) / 1e6;
const lazyMemory = Math.max(0, endMemLazy - startMemLazy);

// Force GC between tests
if (global.gc) global.gc();

// 2. Measure Eager Evaluation
const startMemEager = getMemoryUsage();
const startEager = process.hrtime.bigint();
// Simulate old way: read all, parse all, then slice
const fileContent = await readFile(MOCK_DB_PATH, "utf-8");
const allPosts = fileContent
.split("\n")
.filter((line) => line.trim())
.map((line) => JSON.parse(line));
const eagerResult = allPosts.slice(0, count);
const endEager = process.hrtime.bigint();
const endMemEager = getMemoryUsage();

const eagerTime = Number(endEager - startEager) / 1e6;
const eagerMemory = Math.max(0, endMemEager - startMemEager);

const speedup = (eagerTime / lazyTime).toFixed(2);
const memReduction = (eagerMemory / (lazyMemory || 0.0001)).toFixed(2);

console.log(
`| ${count.toString().padEnd(11)} | ${lazyTime.toFixed(4).padStart(14)} | ${eagerTime.toFixed(4).padStart(15)} | ${speedup.padStart(11)} | ${lazyMemory.toFixed(4).padStart(13)} | ${eagerMemory.toFixed(4).padStart(14)} | ${memReduction.padStart(17)} |`
);

expect(lazyResult.length).toBe(count);
expect(eagerResult.length).toBe(count);

// Lazy should generally be faster for smaller subsets
if (count < 5000) {
expect(lazyTime).toBeLessThan(eagerTime);
}
}
console.log("========================================================================================\n");
});
});
Loading
Loading