/* 제목 스타일 */
h1 {
text-align: center;
margin-top: 5%;
}
/* 테이블의 기본 스타일 설정 */
table {
width: 30%; /* 전체 너비를 차지하도록 설정 */
border-collapse: collapse; /* 테두리 겹치기 방지 */
margin: 20px auto; /* 마진 설정 */
font-family: "Arial", sans-serif; /* 폰트 스타일 설정 */
font-size: 16px; /* 글자 크기 설정 */
color: #333; /* 글자 색상 설정 */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); /* 그림자 효과 */
}
/* 테이블 셀 설정 */
th,
td {
padding: 12px 15px; /* 셀 내부 여백 설정 */
text-align: left; /* 텍스트 왼쪽 정렬 */
border-bottom: 1px solid #ddd; /* 하단 테두리 설정 */
}
/* 테이블 헤더 스타일 */
th {
background-color: #f4f4f4; /* 배경 색상 */
color: #333; /* 글자 색상 */
}
/* 마우스 오버 시 행 스타일 */
tr:hover {
background-color: #f9f9f9; /* 마우스 오버시 행 배경 색상 변경 */
}
/* 페이지네이션 스타일 */
/* 페이지네이션 컨테이너 */
#pagination-container {
display: flex;
justify-content: center; /* 버튼들을 가운데 정렬합니다 */
padding: 10px; /* 여백을 추가합니다 */
}
#pagination-container > button {
border: 1px solid #cccccc; /* 버튼의 테두리를 설정합니다 */
background-color: #f8f8f8; /* 배경색을 설정합니다 */
color: #333333; /* 글자색을 설정합니다 */
text-align: center;
padding: 5px 10px; /* 패딩을 설정합니다 */
margin-right: 4px; /* 버튼 사이의 간격을 설정합니다 */
cursor: pointer; /* 마우스 오버 시 커서를 포인터로 변경합니다 */
transition: background-color 0.3s; /* 배경색 변경에 대한 트랜지션을 추가합니다 */
}
#pagination-container > button:disabled {
color: #999999; /* 비활성화 상태의 글자색을 설정합니다 */
cursor: default; /* 비활성화 상태에서는 커서를 기본으로 설정합니다 */
background-color: #eeeeee; /* 비활성화 상태의 배경색을 설정합니다 */
}
#pagination-container > button:hover:not(:disabled) {
background-color: #dddddd; /* 버튼 호버 시 배경색을 변경합니다 */
}
#page-numbers-wrapper {
display: flex;
}
#page-numbers-wrapper > button {
border: 1px solid #cccccc; /* 페이지 번호 버튼의 테두리를 설정합니다 */
background-color: white; /* 페이지 번호 버튼의 배경색을 설정합니다 */
color: #333333; /* 페이지 번호 버튼의 글자색을 설정합니다 */
text-align: center;
padding: 5px 10px; /* 페이지 번호 버튼의 패딩을 설정합니다 */
margin: 0 2px; /* 페이지 번호 버튼 사이의 간격을 설정합니다 */
cursor: pointer; /* 페이지 번호 버튼의 커서를 포인터로 설정합니다 */
transition:
background-color 0.3s,
color 0.3s; /* 배경색과 글자색 변경에 대한 트랜지션을 추가합니다 */
}
#page-numbers-wrapper > button:hover:not(:disabled) {
background-color: #e0e0e0; /* 페이지 번호 버튼 호버 시 배경색을 변경합니다 */
color: #000000; /* 페이지 번호 버튼 호버 시 글자색을 변경합니다 */
}
#page-numbers-wrapper > button.active {
background-color: #007bff; /* 활성 페이지 번호 버튼의 배경색을 설정합니다 */
color: white; /* 활성 페이지 번호 버튼의 글자색을 설정합니다 */
border-color: #007bff; /* 활성 페이지 번호 버튼의 테두리 색을 설정합니다 */
}
#page-numbers-wrapper > button.current-page {
background-color: #007bff; /* 활성 페이지 번호 버튼의 배경색을 설정합니다 */
color: white; /* 활성 페이지 번호 버튼의 글자색을 설정합니다 */
border-color: #0056b3; /* 활성 페이지 번호 버튼의 테두리 색을 조금 더 진하게 설정합니다 */
font-weight: bold; /* 글자를 굵게 표시하여 더욱 눈에 띄게 합니다 */
cursor: default; /* 현재 페이지는 클릭할 필요가 없으므로 커서를 기본으로 설정합니다 */
pointer-events: none; /* 클릭이벤트를 비활성화하여 사용자가 현재 페이지를 클릭하지 못하도록 합니다 */
}
목표
도중에 취업이 되면 못할수도 있지만 나의 목표는 CRUD중 적어도 R을 마스터 하는 것이다.
그래서 바닐라 자바스크립트로 바닥부터 구현하고, React로 구현한 뒤, React Query, next.js로 점점 업그레이드 해나가고 싶다.
이 과정속에서 분명 배우는 것이 있을 것이다.
1. 개발 환경 구성하기.
모노레포로 한가지 레포안에 여러 프로젝트가 있는 형태를 구상중이다.
그래서 최종적으로는 폴더가 아래처럼 되도록 구상하고 있다.
/server
/vanilla-js
/react
/react-query
/nextjs
프론트엔드를 공부하는 것이니 백엔드가 크게 필요없다.
express로 간단히 백엔드 부터 프로젝트를 구성했다.
pnpm 설치 등은 생략하겠습니다!
2. 서버 구성하기
우선 server 폴더를 만들고
server.js 파일을 생성한다.
server를 공부할 것은 아니니 AI로 간단히 생성한 뒤에 API와 cors만 수정했다.
// /server/server.js
const express = require("express");
const bodyParser = require("body-parser");
const cors = require("cors");
const fs = require("fs");
const app = express();
const PORT = 3000;
app.use(bodyParser.json());
app.use(cors());
// 게시글 데이터 파일 경로
const DATA_FILE = "./posts.json";
app.get("/posts", (req, res) => {
const page = parseInt(req.query.page) || 1; // 페이지 번호, 기본값은 1
const limit = parseInt(req.query.limit) || 10; // 페이지 당 항목 수, 기본값은 10
fs.readFile(DATA_FILE, (err, data) => {
if (err) {
res.status(500).send("Error reading data file.");
return;
}
// 모든 게시물 파싱
const posts = JSON.parse(data);
const total = posts.length; // 전체 게시물 수
const startIndex = (page - 1) * limit; // 페이지 시작 인덱스
const endIndex = startIndex + limit; // 페이지 끝 인덱스
// 페이지에 해당하는 게시물 슬라이스
const paginatedPosts = posts.slice(startIndex, endIndex);
// 응답 데이터 구성
const response = {
totalItems: total,
totalPages: Math.ceil(total / limit),
currentPage: page,
pageSize: paginatedPosts.length,
posts: paginatedPosts,
};
res.json(response);
});
});
app.post("/posts", (req, res) => {
const newPost = req.body;
fs.readFile(DATA_FILE, (err, data) => {
if (err) {
res.status(500).send("Error reading data file.");
return;
}
const posts = JSON.parse(data);
posts.push(newPost);
fs.writeFile(DATA_FILE, JSON.stringify(posts, null, 2), (err) => {
if (err) {
res.status(500).send("Error writing data file.");
return;
}
res.status(201).send("Post added.");
});
});
});
app.delete("/posts/:id", (req, res) => {
const postId = parseInt(req.params.id);
fs.readFile(DATA_FILE, (err, data) => {
if (err) {
res.status(500).send("Error reading data file.");
return;
}
let posts = JSON.parse(data);
posts = posts.filter((post) => post.id !== postId);
fs.writeFile(DATA_FILE, JSON.stringify(posts, null, 2), (err) => {
if (err) {
res.status(500).send("Error writing data file.");
return;
}
res.send("Post deleted.");
});
});
});
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
DB도 필요하지 않다. 간단히 json 파일을 추가한다.
사실 이런 데모를 만들 때 ChatGPT와 함께라면 간단하게 만들 수 있다.
// /server/posts.json
[
{ "id": 1, "title": "Post 1", "liked": 34 },
{ "id": 2, "title": "Post 2", "liked": 7 },
{ "id": 3, "title": "Post 3", "liked": 23 },
{ "id": 4, "title": "Post 4", "liked": 94 },
{ "id": 5, "title": "Post 5", "liked": 58 },
{ "id": 6, "title": "Post 6", "liked": 12 },
{ "id": 7, "title": "Post 7", "liked": 45 },
{ "id": 8, "title": "Post 8", "liked": 76 },
{ "id": 9, "title": "Post 9", "liked": 13 },
{ "id": 10, "title": "Post 10", "liked": 28 },
{ "id": 11, "title": "Post 11", "liked": 53 },
{ "id": 12, "title": "Post 12", "liked": 29 },
{ "id": 13, "title": "Post 13", "liked": 8 },
{ "id": 14, "title": "Post 14", "liked": 36 },
{ "id": 15, "title": "Post 15", "liked": 88 },
{ "id": 16, "title": "Post 16", "liked": 5 },
{ "id": 17, "title": "Post 17", "liked": 11 },
{ "id": 18, "title": "Post 18", "liked": 55 },
{ "id": 19, "title": "Post 19", "liked": 97 },
{ "id": 20, "title": "Post 20", "liked": 66 },
{ "id": 21, "title": "Post 21", "liked": 0 },
{ "id": 22, "title": "Post 22", "liked": 18 },
{ "id": 23, "title": "Post 23", "liked": 46 },
{ "id": 24, "title": "Post 24", "liked": 33 },
{ "id": 25, "title": "Post 25", "liked": 42 },
{ "id": 26, "title": "Post 26", "liked": 12 },
{ "id": 27, "title": "Post 27", "liked": 75 },
{ "id": 28, "title": "Post 28", "liked": 48 },
{ "id": 29, "title": "Post 29", "liked": 21 },
{ "id": 30, "title": "Post 30", "liked": 63 },
{ "id": 31, "title": "Post 31", "liked": 14 },
{ "id": 32, "title": "Post 32", "liked": 56 },
{ "id": 33, "title": "Post 33", "liked": 87 },
{ "id": 34, "title": "Post 34", "liked": 22 },
{ "id": 35, "title": "Post 35", "liked": 41 },
{ "id": 36, "title": "Post 36", "liked": 67 },
{ "id": 37, "title": "Post 37", "liked": 55 },
{ "id": 38, "title": "Post 38", "liked": 64 },
{ "id": 39, "title": "Post 39", "liked": 10 },
{ "id": 40, "title": "Post 40", "liked": 36 },
{ "id": 41, "title": "Post 41", "liked": 50 },
{ "id": 42, "title": "Post 42", "liked": 71 }
]
이제 서버는 준비가 완료되었다!
3. vanilla-js 프로젝트 구성하기.
webpack으로 간단히 프로젝트를 생성했다.
webpack을 한 이유는 아직 webpack을 사용하는 곳이 많기도 하고, webpack에 대한 기본적인 이해가 있어야 vite도 잘 쓸 수 있어서다.
그리고 index.js와 app.js를 만들고
app.js에는 클래스와 내부 메서드들을 선언하고, server 테스트를 위한 간단한 코드만을 추가했다.
// /vanilla-js/index.js
import App from "./app.js";
new App();
// /vanilla-js/app.js
import "./styles/main.css";
class App {
constructor() {
this.page = 1;
this.limit = 10;
this.render();
}
async render() {
this.mainElement = document.createElement("main");
document.body.appendChild(this.mainElement);
await this.getArticles(this.page, this.limit);
this.renderTable();
this.renderPagination();
}
renderTable() {
if (this.tableElement == null) {
this.createTable();
}
this.renderRows();
}
createTable() {
this.tableElement = document.createElement("table");
const tableHead = document.createElement("thead");
const tableRow = document.createElement("tr"); // 테이블 헤더를 위한 행을 추가합니다.
// id 칼럼
const idCell = document.createElement("td");
idCell.innerText = "id";
tableRow.appendChild(idCell);
// title 칼럼
const titleCell = document.createElement("td");
titleCell.innerText = "title";
tableRow.appendChild(titleCell);
// liked 칼럼
const likedCell = document.createElement("td");
likedCell.innerText = "liked";
tableRow.appendChild(likedCell);
// 헤더 행을 thead 요소에 추가
tableHead.appendChild(tableRow);
// thead를 테이블에 추가
this.tableElement.appendChild(tableHead);
const tableBody = document.createElement("tbody");
tableBody.id = "tbody";
this.tableElement.appendChild(tableBody);
this.mainElement.appendChild(this.tableElement);
}
renderRows() {
const tableBody = document.getElementById("tbody");
tableBody.innerHTML = "";
if (this.articlesData) {
for (const post of this.articlesData.posts) {
const rowElement = document.createElement("tr");
const idElement = document.createElement("td");
idElement.innerHTML = post.id;
const titleElement = document.createElement("td");
titleElement.innerHTML = post.title;
const likedElement = document.createElement("td");
likedElement.innerHTML = post.liked;
rowElement.appendChild(idElement);
rowElement.appendChild(titleElement);
rowElement.appendChild(likedElement);
tableBody.appendChild(rowElement);
}
}
}
createPagination() {
const paginationContainer = document.createElement("div");
paginationContainer.id = "pagination-container";
const goFirstPageButton = document.createElement("button");
goFirstPageButton.id = "go-first-page-button";
goFirstPageButton.innerHTML = "|<";
const goPrevPageButton = document.createElement("button");
goPrevPageButton.id = "go-prev-page-button";
goPrevPageButton.innerHTML = "<";
const pageNumbersWrapper = document.createElement("div");
pageNumbersWrapper.id = "page-numbers-wrapper";
const goNextPageButton = document.createElement("button");
goNextPageButton.id = "go-next-page-button";
goNextPageButton.textContent = ">";
const goLastPageButton = document.createElement("button");
goLastPageButton.id = "go-last-page-button";
goLastPageButton.innerHTML = ">|";
paginationContainer.appendChild(goFirstPageButton);
paginationContainer.appendChild(goPrevPageButton);
paginationContainer.appendChild(pageNumbersWrapper);
paginationContainer.appendChild(goNextPageButton);
paginationContainer.appendChild(goLastPageButton);
this.mainElement.appendChild(paginationContainer);
}
renderPagination() {
if (document.getElementById("pagination-container") == null) {
this.createPagination();
}
if (!this.articlesData) return;
const pageNumbersWrapper = document.getElementById("page-numbers-wrapper");
pageNumbersWrapper.innerHTML = "";
let startPage = this.page > 3 ? this.page - 3 : 1;
const lastPage =
startPage + 4 <= this.articlesData.totalPages
? startPage + 4
: this.articlesData.totalPages;
if (lastPage == this.articlesData.totalPages) {
startPage = lastPage - 4;
}
for (let curtPage = startPage; curtPage <= lastPage; curtPage++) {
const pageButton = document.createElement("button");
pageButton.innerHTML = curtPage;
pageNumbersWrapper.appendChild(pageButton);
pageButton.id = `page-button-${curtPage}`;
pageButton.addEventListener("click", () => this.changePage(curtPage));
if (curtPage == this.page) {
pageButton.classList.add("current-page");
}
}
}
async changePage(targetPage) {
this.page = targetPage;
await this.getArticles(this.page);
this.renderTable();
this.renderPagination();
}
async getArticles(page) {
await this.fetchArticles(page);
}
async fetchArticles(page) {
try {
const response = await fetch(
`http://127.0.0.1:3000/posts?page=${page}&limit=${this.limit}`
);
this.articlesData = await response.json();
} catch (error) {
console.error("Error fetching data:", error);
}
}
}
export default App;
이걸 app과 index로 나눈 이유가 궁금할 수 있다.
단순히 index.js에서 하는 일은 고작 app을 불러와서 실행시키는 것이 끝이기 때문이다.
그렇다면 그냥 app.js의 하단부에 new App(); 을 하면 되기 때문이다.
이 모든 것언 맞지만, 이렇게 하면 유지보수성이 좋지 않아 분리했다.
혹시 나중에 app을 2개 만들고 싶을 수도 있고
또 테스트 코드 작성시에 app을 import만 했는데 바로 실행까지 되어버리는 참사가 발생하기도 한다.
그래서 이렇게 바꾸는 것이 좋다.
### CSS 추가하기
그래도 CSS도 빼놓을 수는 없으니 간단하게 CSS를 추가하였습니다.
4.테스트 코드 추가하기
jest로 테스트 환경을 구성하고 테스트 코드를 추가했다.
사실 TDD라면, 테스트코드를 먼저 작성해야했지만 어떤식으로 구현할지 정확히 설계가 되지 않은 상황이라서
기본 기능을 적당히 구현해놓은 후 테스트 코드를 추가적으로 작성했다.
import App from "../app.js";
describe("최초 렌더링 테스트", () => {
let app;
beforeEach(() => {
const mockData = {
totalItems: 42,
totalPages: 5,
currentPage: 1,
pageSize: 10,
posts: [
{ id: 1, title: "Post 1", liked: 34 },
{ id: 2, title: "Post 2", liked: 7 },
{ id: 3, title: "Post 3", liked: 23 },
{ id: 4, title: "Post 4", liked: 94 },
{ id: 5, title: "Post 5", liked: 58 },
{ id: 6, title: "Post 6", liked: 12 },
{ id: 7, title: "Post 7", liked: 45 },
{ id: 8, title: "Post 8", liked: 76 },
{ id: 9, title: "Post 9", liked: 13 },
{ id: 10, title: "Post 10", liked: 28 },
],
};
fetch.mockResponseOnce(JSON.stringify(mockData));
app = new App();
});
afterEach(() => {
fetch.resetMocks();
});
it("서버로부터 데이터를 가져와야 한다.", async () => {
expect(fetch).toHaveBeenCalledTimes(1);
});
it("페이지 사이즈에 맞게 테이블이 렌더링되어야 한다.", () => {
const tableBody = document.getElementById("tbody");
// tableBody의 tr이 app.articlesData.pageSize와 같은지 테스트
const trElements = tableBody.getElementsByTagName("tr");
expect(trElements.length).toBe(app.articlesData.pageSize);
});
it("첫번째 페이지가 선택되어 있어야 한다.", () => {
const firstPageButton = document.getElementById("page-button-1");
expect(firstPageButton.classList.contains("current-page")).toBe(true);
});
});
// /__tests__/pagination.test.js
import App from "../app.js";
describe("페이지네이션 테스트", () => {
let app;
let mockGetArticles;
let mockChangePage;
beforeEach(() => {
const mockData = {
totalItems: 42,
totalPages: 5,
currentPage: 1,
pageSize: 10,
posts: [
{ id: 1, title: "Post 1", liked: 34 },
{ id: 2, title: "Post 2", liked: 7 },
{ id: 3, title: "Post 3", liked: 23 },
{ id: 4, title: "Post 4", liked: 94 },
{ id: 5, title: "Post 5", liked: 58 },
{ id: 6, title: "Post 6", liked: 12 },
],
};
fetch.mockResponseOnce(JSON.stringify(mockData));
app = new App();
mockGetArticles = jest.spyOn(app, "getArticles");
mockChangePage = jest.spyOn(app, "changePage");
});
afterEach(() => {
fetch.resetMocks();
});
it("페이지 버튼을 누르면 알맞은 changePage 함수를 실행해야 한다.", async () => {
const seconedPageButton = document.getElementById("page-button-2");
await seconedPageButton.click();
expect(app.page).toBe(2);
});
it("changePage 함수가 비동기로 페이지를 변경한다.", async () => {
const seconedPageButton = document.getElementById("page-button-2");
// changePage 함수가 Promise를 반환하도록 가정
const seconedPageButton = document.getElementById("page-button-2");
await app.changePage(2); // 비동기 함수 호출과 완료를 기다림
expect(app.page).toBe(2); // 페이지 상태가 올바르게 변경되었는지 확인
expect(seconedPageButton.class).toBe("current-page"); // 버튼의 클래스가 바뀌었는지 확인
});
it("페이지에 따라 다른 데이터를 가져와야 한다.", async () => {
const seconedPageButton = document.getElementById("page-button-2");
seconedPageButton.click();
// 각 파라미터로 정확히 한 번씩 호출되었는지 검사
expect(mockGetArticles).toHaveBeenCalledWith(2);
const thirdPageButton = document.getElementById("page-button-3");
thirdPageButton.click();
// 각 파라미터로 정확히 한 번씩 호출되었는지 검사
expect(mockGetArticles).toHaveBeenCalledWith(3);
});
it("이미 선택된 버튼은 클릭할 수 없어야 한다.", async () => {
const seconedPageButton = document.getElementById("page-button-2");
seconedPageButton.click();
seconedPageButton.click();
// 페이지 교체 함수는 한번만 실행되었는지 검사
expect(mockChangePage).toHaveBeenCalledTimes(1);
});
it("마지막 페이지일 경우 다음, 마지막 페이지 버튼이 비활성화 되어야 한다.", async () => {
const nextPageButton = document.getElementById("go-next-page-button");
const lastPageButton = document.getElementById("go-last-page-button");
app.renderPagination();
// 페이지 상태 설정
app.articlesData.totalPages = 2; // 총 페이지 수
app.page = 2; // 현재 페이지가 마지막 페이지
// 페이지네이션 렌더링 함수 실행
app.renderPagination();
// nextPageButton이 disabled 상태인지 확인
expect(nextPageButton).toBeDisabled();
// lastPageButton이 disabled 상태인지 확인
expect(lastPageButton).toBeDisabled();
});
it("현재 첫번째 페이지일 경우 이전 페이지, 첫번째 페이지 가기 버튼이 비활성화 되어야 한다.", () => {
const prevPageButton = document.getElementById("go-prev-page-button");
const firstPageButton = document.getElementById("go-first-page-button");
app.renderPagination();
// 페이지 상태 설정
app.articlesData.totalPages = 2; // 총 페이지 수
app.page = 2; // 현재 페이지가 마지막 페이지
// 페이지네이션 렌더링 함수 실행
app.renderPagination();
// prevPageButton이 disabled 상태인지 확인
expect(prevPageButton).toBeDisabled();
// firstPageButton이 disabled 상태인지 확인
expect(firstPageButton).toBeDisabled();
});
});
어떤 테스트를 작성하면 좋을지는 GPT의 도움을 받아서 미처 빠트릴 수 있는 테스트 케이스도 꼼꼼하게 채워 넣었다.
그 결과, 초기 코드를 미리 작성해 놓은 덕에
render.test.js는 이미 다 통과하였다.
하지만 pagination.test.js는 거의 실패했다.
이제 다음 스탭은 테스트가 통과하도록 코드를 수정하기이다.
'Study > Frontend' 카테고리의 다른 글
완벽한 페이지네이션 바닥부터 개발하기 EP3 : 리팩토링 (0) | 2024.06.24 |
---|---|
완벽한 페이지네이션 바닥부터 개발하기 EP2 : 테스트 통과시키기 (1) | 2024.06.19 |
2024년에는 라이브러리를 위해 어떤 모듈 번들러를 선택해야 할까? (0) | 2024.04.12 |
TDD 테스트 주도 개발 방법론 (1) | 2023.10.28 |
REST API (0) | 2023.10.27 |