소개
GitHub - bingual/sveltekit5: svelte5, 기타 라이브러리 테스트
svelte5, 기타 라이브러리 테스트. Contribute to bingual/sveltekit5 development by creating an account on GitHub.
github.com
해당 프로젝트는 최신 `Svelte 5`와 `SvelteKit`을 기반으로 구축된 프로젝트로, 클린 코드와 유지 보수를 중점적으로 고려한 웹 애플리케이션입니다.
성능 최적화, 재사용 가능한 컴포넌트 설계, 모듈화, 메모리 누수 관리, 중복 제거, 테스트 코드 작성을 목표로 하여 효율적인 개발 환경을 구성하는데 초점을 맞춘 기능 테스트가 목표입니다.
`Svelte 5` 는 2024년 10월 19일 정식으로 릴리즈 되었습니다.
한국에서는 `React`, `Vue`, `Next`와는 다르게 인지도가 낮지만 빠르고 편리하며 프레임워크 내에서 자체적으로 제공하는 기능이 많아 개발 경험이 좋기에 해외에서는 핫한 프레임워크 중 하나입니다.
현재 사용하는 `Svelte 5` 및 기타 라이브러리들은 베타 버전도 포함하고 있기에 버그가 있을 수 있습니다.
앞으로 연재될 Svelte 5 포스팅들은 단순히 기능을 구현하는 것만이 아니라 어떻게 해야 효율적으로 기능들을 구현하고 사용 및 관리하는지에 대해 설명을 진행할 예정입니다.
Sveltekit, Prisma, Tailwind, Auth 등등 해당 프로젝트에 사용되는 라이브러리에 대한 설명은 없으므로 공식문서를 참고 바랍니다.
프로젝트 구조
prisma/ # Prisma 관련 설정 및 파일 폴더
e2e/ # E2E 테스트 코드 폴더
src/ # 소스 코드 루트 폴더
├── lib/ # 재사용 가능한 라이브러리 코드 폴더
│ ├── auth.ts # Oauth 코드
│ ├── prisma.ts # Prisma 인스턴스 코드
│ ├── supabaseClient.ts # Supabase 관련 코드
│ ├── utils/ # 유틸리티 코드 폴더
│ └── components/ # Svelte 컴포넌트 폴더
├── routes/ # SvelteKit 라우팅 폴더
└── tests/ # 유닛 테스트 코드 폴더
명시되지 않은 코드 구조가 있을 수 있습니다.
내용 요약
설명은 간단하게 진행되니 자세한 설명은 참조하는 링크를 참고해 주세요.
- `database` 테이블 설명
- `remeda & prisma & faker` 라이브러리를 활용한 데이터 생성/제거 테스트 예제
https://www.prisma.io/docs/getting-started
라이브러리가 어떠한 용도로 사용되는지는 해당 링크를 참조해 주세요.
database 테이블 설명
`schema.prisma`
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime? @map("email_verified")
image String?
accounts Account[]
sessions Session[]
memo Memo[]
@@map("users")
}
model Memo {
id String @id @default(cuid())
author String
title String @db.VarChar(100)
content String
created_at DateTime @default(now()) @db.Timestamptz()
updated_at DateTime @updatedAt @db.Timestamptz()
user User @relation(fields: [author], references: [id], onDelete: Cascade)
images MemoImage[]
@@map("memos")
}
model MemoImage {
id String @id @default(cuid())
url String
memoId String @map("memo_id")
memo Memo @relation(fields: [memoId], references: [id], onDelete: Cascade)
@@map("memo_images")
}
`User`는 `session`, `account`와 관계를 형성하고 있는데 이 부분은 스킵하겠습니다.
- 사용자 개인의 한해서 자신만의 메모를 확인하고 수정하고 삭제할 수 있습니다.
- 메모에는 다수의 이미지를 삽입할 수 있어야 합니다.
그렇다면
- `User` 테이블과 `Memo` 테이블은 `1:N` 관계를 유지해야 합니다.
- `Memo` 테이블과 `MemoImage` 테이블은 `1:N` 관계를 유지해야 합니다.
DB 설계가 끝났으니 이제 테스트를 하기 위해서 가짜 데이터를 생성해 보도록 합시다.
Fake 데이터 생성/제거하기
우리가 메모 기능을 완성했습니다. 하지만 클라이언트단에서 원활히 작동하는지 테스트하려면 데이터가 많이 필요하겠죠? 그런 데이터들을 일일이 하나씩 생성하는 것은 고된 일이 될 겁니다.
그래서 이 부분을 원버튼 클릭으로 다수의 데이터를 생성/제거를 할 필요성이 있습니다.
`fake-data.server.ts`
import { prisma } from '$lib/prisma';
import { storageManager } from '$lib/utils/variables.server';
import { faker } from '@faker-js/faker/locale/en';
import { redirect } from '@sveltejs/kit';
import { flatMap, isEmpty, join, map, omit, pipe, range } from 'remeda';
import type { Actions } from './$types';
export const actions = {
memoCreate: async ({ locals, request }) => {
const session = await locals.auth();
if (!session?.user?.id) {
return redirect(302, '/');
}
const formData = Object.fromEntries(await request.formData());
const { count } = formData;
const totalCount = Number(count) || 1;
const now = new Date();
const generateRandomHtml = () => {
const headings = pipe(
range(0, faker.number.int({ min: 1, max: 3 })),
map(() => {
const headingLevel = faker.number.int({ min: 1, max: 3 });
return `<h${headingLevel}>${faker.lorem.sentence()}</h${headingLevel}>`;
}),
join(''),
);
const paragraphs = pipe(
range(0, faker.number.int({ min: 2, max: 5 })),
map(() => `<p>${faker.lorem.paragraph()}</p>`),
join(''),
);
const listItems = pipe(
range(0, faker.number.int({ min: 3, max: 7 })),
map(() => `<li>${faker.lorem.sentence()}</li>`),
join(''),
);
const lists = `<ul>${listItems}</ul>`;
const imageUrls = pipe(
range(0, faker.number.int({ min: 1, max: 8 })),
map(() => faker.image.url({ width: 400, height: 400 })),
);
const imgTags = pipe(
imageUrls,
map((imageUrl, index) => `<img src="${imageUrl}" alt="Image ${index + 1}" />`),
join('\n'),
);
// 조합된 HTML 반환
return {
content: `
${headings}
${paragraphs}
${lists}
${imgTags}
`,
imageUrls: imageUrls,
};
};
const fakeMemos = pipe(
range(0, totalCount),
map((index) => {
const { content, imageUrls } = generateRandomHtml();
return {
author: session?.user?.id,
title: faker.lorem.sentence(),
content: content,
created_at: new Date(now.getTime() - index * 1000),
memoImages: imageUrls,
};
}),
);
const [createdMemosCount] = await prisma.$transaction(async (prisma) => {
const createdMemosCount = await prisma.memo.createMany({
data: map(fakeMemos, (fakeMemo) => omit(fakeMemo, ['memoImages'])),
skipDuplicates: true,
});
const createdMemos = await prisma.memo.findMany({
select: { id: true },
where: { author: session?.user?.id },
orderBy: { created_at: 'desc' },
take: createdMemosCount.count,
});
const createdImages = pipe(
createdMemos,
flatMap((memo, memoIndex) =>
pipe(
fakeMemos[memoIndex].memoImages,
map((imageUrl) => ({
memoId: memo.id,
url: imageUrl,
})),
),
),
);
const fakeImagesCount = await prisma.memoImage.createMany({ data: createdImages });
return [createdMemosCount, createdImages, fakeImagesCount];
});
if (createdMemosCount.count > 0) {
return {
success: true,
action: 'create' as ActionType,
data: createdMemosCount.count,
};
}
},
memoDelete: async ({ locals }) => {
const session = await locals.auth();
if (!session?.user?.id) {
return redirect(302, '/');
}
const memoImages = await prisma.memoImage.findMany({
where: {
memo: {
author: session?.user?.id,
},
},
});
const { removePublicStorageFile } = storageManager();
const urlsToDelete = map(memoImages, (image) => image.url);
if (!isEmpty(urlsToDelete)) {
await removePublicStorageFile(urlsToDelete);
}
const memoDeleted = await prisma.memo.deleteMany({
where: {
author: session?.user?.id,
},
});
if (memoDeleted.count > 0) {
return {
success: true,
action: 'delete' as ActionType,
data: memoDeleted.count,
};
}
},
} satisfies Actions;
svelte에서는 폴더 내부의 `+page.svelte.ts`를 통해 서버 파일을 생성할 수 있습니다. `git`에서는 `fake-data/+page.server.ts` 으로 보이겠지만 포스팅에선 편하게 `fake-data.server.ts`라고 하겠습니다.
코드 자체를 설명하는 것이 아닌 어떻게 라이브러리를 활용해서 데이터를 생성/제거하는지를 설명하는 것이니 `faker`, `remeda`, `prisma`에 대해서 집중해 보도록 합시다.
`generateRandomHtml` 해당 함수는 `html tag`를 생성하는 함수입니다.
`remeda`는 기본 자바스크립트와는 다르게 커링과 파이프라인을 사용해서 좀 더 가독성 있는 코드를 작성할 수 있게 도와주는 라이브러리죠.
const headings = pipe(
range(0, faker.number.int({ min: 1, max: 3 })),
map(() => {
const headingLevel = faker.number.int({ min: 1, max: 3 });
return `<h${headingLevel}>${faker.lorem.sentence()}</h${headingLevel}>`;
}),
join(''),
);
const headings = Array.from({length: faker.number.int({ min: 1, max: 3 })}, () => {
const headingLevel = faker.number.int({ min: 1, max: 3 });
return `<h${headingLevel}>${faker.lorem.sentence()}</h${headingLevel}>`;
}).join()
두 가지다 동일한 기능을 하는 코드이지만 가독성은 `remeda`를 사용했을 때가 더 좋습니다. 더 복잡한 코드구조에서는 훨씬 더 효율적이겠죠?
`generateRandomHtml` 실행 결과
{
content: '\n' +
' <h3>Cultura civitas suasoria adopto creator paens terminatio.</h3><h3>Stella verumtamen annus itaque asper.</h3>\n' +
' <p>Aestus crinis infit communis angustus sperno adversus absorbeo quisquam. Campana aperte earum. Vox suppono comis acidus agnosco acies bene necessitatibus statua cena.</p><p>Tametsi adulatio vulgivagus vestrum abstergo. Venustas curo ambitus tondeo xiphias vulnus conitor. Vicinus custodia cena suppono totam blandior suspendo.</p>\n' +
' <ul><li>Cetera delibero avaritia tergum adeo defetiscor.</li><li>Audacia coepi annus degusto damno doloremque.</li><li>Dolorum rerum urbanus aveho aegrus.</li><li>Nisi sint auditor tantillus voluptatem depopulo.</li><li>Acsi placeat cogito caelum ex decor conspergo confido voluntarius tutis.</li><li>Tremo solitudo campana condico vaco pel adduco adulescens vinculum spargo.</li><li>Suscipit quia pecto spiritus totus amita quaerat cornu corporis.</li></ul>\n' +
' <img src="https://loremflickr.com/400/400?lock=4621350034807815" alt="Image 1" />\n' +
'<img src="https://picsum.photos/seed/wAouY2b/400/400" alt="Image 2" />\n' +
'<img src="https://loremflickr.com/400/400?lock=140257459668443" alt="Image 3" />\n' +
'<img src="https://loremflickr.com/400/400?lock=3689538162747238" alt="Image 4" />\n' +
'<img src="https://picsum.photos/seed/LJKHDF/400/400" alt="Image 5" />\n' +
'<img src="https://picsum.photos/seed/VC4SPT/400/400" alt="Image 6" />\n' +
' ',
imgUrls: [
{ url: 'https://loremflickr.com/400/400?lock=4621350034807815' },
{ url: 'https://picsum.photos/seed/wAouY2b/400/400' },
{ url: 'https://loremflickr.com/400/400?lock=140257459668443' },
{ url: 'https://loremflickr.com/400/400?lock=3689538162747238' },
{ url: 'https://picsum.photos/seed/LJKHDF/400/400' },
{ url: 'https://picsum.photos/seed/VC4SPT/400/400' }
]
}
`faker` 라이브러리를 사용하게 되면 위와 같이 상황에 맞는 데이터를 매 실행마다 랜덤 하게 생성할 수 있습니다. 직접 하나하나 데이터를 생성하는 거에 비하면 생산성이 극대화되죠.
`데이터 생성`
const fakeMemos = pipe(
range(0, totalCount),
map((index) => {
const { content, imageUrls } = generateRandomHtml();
return {
author: session?.user?.id,
title: faker.lorem.sentence(),
content: content,
created_at: new Date(now.getTime() - index * 1000),
images: imageUrls,
};
}),
);
const createdMemos = await Promise.all(
map(fakeMemos, async (memo) => {
return prisma.memo.create({
data: {
author: memo.author,
title: memo.title,
content: memo.content,
created_at: memo.created_at,
images: {
create: map(memo.images, (imageUrl) => ({ url: imageUrl })),
},
},
});
}),
);
`generateRandomHtml`으로 생성된 `content`와 `url`과 함께 DB에 삽입하기 위한 데이터 규격을 정해놓습니다.
그런 다음 createMemos에서 생성 작업을 병렬로 실행하도록 합니다.
현재 코드 자체는 병렬 수행이지만 쿼리는 `totalCount` 즉 fakeMemos의 개수만큼 매번 요청이 전달됩니다.
캐시와는 관계없는 부분이라 캐시 부분은 무시해도 됩니다. 기본적으로 데이터 베이스에 요청한 결과를 가져올 때에는 1번부터 7번까지의 스텝을 따릅니다.
만약 쿼리가 많아진다면 그만큼 커넥션도 많아지고 불러오는 속도가 저하되겠죠? 기본적으로 메모리에서 데이터를 가져오는 게 훨씬 빠르기 때문에 가능하다면 쿼리 요청을 더 보내는 것보다는 메모리를 더 많이 사용하는 방법이 효율적입니다.
`개선된 버전`
const fakeMemos = pipe(
range(0, totalCount),
map((index) => {
const { content, imageUrls } = generateRandomHtml();
return {
author: session?.user?.id,
title: faker.lorem.sentence(),
content: content,
created_at: new Date(now.getTime() - index * 1000),
memoImages: imageUrls,
};
}),
);
const [createdMemosCount] = await prisma.$transaction(async (prisma) => {
const createdMemosCount = await prisma.memo.createMany({
data: map(fakeMemos, (fakeMemo) => omit(fakeMemo, ['memoImages'])),
skipDuplicates: true,
});
const createdMemos = await prisma.memo.findMany({
select: { id: true },
where: { author: session?.user?.id },
orderBy: { created_at: 'desc' },
take: createdMemosCount.count,
});
const createdImages = pipe(
createdMemos,
flatMap((memo, memoIndex) =>
pipe(
fakeMemos[memoIndex].memoImages,
map((imageUrl) => ({
memoId: memo.id,
url: imageUrl,
})),
),
),
);
const fakeImagesCount = await prisma.memoImage.createMany({ data: createdImages });
return [createdMemosCount, createdImages, fakeImagesCount];
});
- 다수의 메모 데이터를 createMany로 생성
- 생성된 데이터의 개수만큼 findMany로 id 값 추출
- memoId를 이용해 createMany로 다수의 메모 이미지 데이터를 생성
이렇게 한다면 세 개의 쿼리 요청만으로 다수의 데이터를 효율적으로 생성할 수 있습니다.
단점으로는 의존성 주입 부분인데요.
첫 번째 방법으로 데이터를 생성하게 되면 항상 같은 memoId를 참조해서 데이터를 안전하게 삽입할 수 있습니다.
하지만 두 번째 방법은 중간에 누군가와 함께 동시에 데이터를 생성하게 된다면? 레이스 컨디션 문제가 발생하기에 트랜잭션을 사용할 필요성이 있죠.
또한 해당 코드는 기본적으로 `dev` 환경에서 실행됩니다. 프로덕션 환경에서 테스트를 위해 데이터를 프로덕션용 DB에 삽입하지는 않고 개발용 DB에 삽입할 테니 문제가 발생할 일도 크게 없어지죠.
memoDelete: async ({ locals }) => {
const session = await locals.auth();
if (!session?.user?.id) {
return redirect(302, '/');
}
const memoImages = await prisma.memoImage.findMany({
where: {
memo: {
author: session?.user?.id,
},
},
});
const { removePublicStorageFile } = storageManager();
const urlsToDelete = map(memoImages, (image) => image.url);
if (!isEmpty(urlsToDelete)) {
await removePublicStorageFile(urlsToDelete);
}
const memoDeleted = await prisma.memo.deleteMany({
where: {
author: session?.user?.id,
},
});
if (memoDeleted.count > 0) {
return {
success: true,
action: 'delete' as ActionType,
data: memoDeleted.count,
};
}
},
마찬 가지로 데이터 또한 제거할 수 있습니다.
마무리
다음 시간은 유닛 테스트 코드 작성 방법에 대해서 알아보겠습니다.
'프로젝트 > Fullstack' 카테고리의 다른 글
[Svelte] 유닛 테스트 코드 작성방법 (1) | 2025.02.03 |
---|---|
[Svelte] 효율적인 메모리 관리방법 (0) | 2024.11.29 |
[Svelte] 효율적인 컴포넌트 관리법 (0) | 2024.11.28 |
[Svelte] 전역에서 사용 가능한 모달 모듈 구현 (0) | 2024.11.27 |
[Svelte] 구독 기능을 이용한 토스트 메시지 구현 (0) | 2024.11.26 |