-
Notifications
You must be signed in to change notification settings - Fork 1
[재하] 1205(화) 개발기록
어드민 페이지 Full Stack, 전체 글 조회기능 및 시스템 정보 조회기능 구현
- emotion 설치
- Button, Table 컴포넌트 생성
- 전체 글 조회하기
- 전체 글 조회 API 구현
- Board 컴포넌트 생성, 전체 글 조회
- 트러블 슈팅: Vite dev/prod 환경변수 설정
- 테스트 (동작 화면)
- 시스템 정보 조회하기
- 시스템 정보 조회 API 구현 (GET /admin/system-info)
- SystemInfo 페이지 구현, 라우팅 (/admin/system-info)
- 테스트 (동작 화면)
- disk usage 조회, 글 상세조회
- disk usage 조회
- 글 상세조회
- 테스트 (동작 화면)
yarn workspace admin add @emotion/styled @emotion/react
프론트 프로젝트와 의존성을 동일하게 가기 위해 @emotion/styled, @emotion/react를 설치해준다.
버튼, 테이블 등 공유해서 사용할 수 있는 베이스 컴포넌트들을 생성해준다. 학습메모 2를 참고하여 프론트와 동일한 코드 컨벤션으로 구현을 해보았다.
// Button.tsx
import styled from '@emotion/styled';
interface PropsType extends React.ButtonHTMLAttributes<HTMLButtonElement> {
onClick?: () => void;
children: React.ReactNode;
}
export default function Button({ children, ...args }: PropsType) {
return <CustomButton {...args}>{children}</CustomButton>;
}
const CustomButton = styled.button<PropsType>`
background-color: #fff;
border: 1px solid #ddd;
border-radius: 4px;
color: #212121;
font-size: 14px;
font-weight: 600;
padding: 6px 12px;
cursor: pointer;
outline: none;
&:hover {
background-color: #ddd;
}
`;
// Table.tsx
import styled from '@emotion/styled';
interface PropsType extends React.TableHTMLAttributes<HTMLTableElement> {
children: React.ReactNode;
}
export default function Table({ children, ...args }: PropsType) {
return <CustomTable {...args}>{children}</CustomTable>;
}
const CustomTable = styled.table<PropsType>`
border-collapse: collapse;
width: 100%;
border: 1px solid #ddd;
`;
스타일은 추후에 또 적당히 수정해보자.
백엔드에 전체 글 조회 API가 없기 때문에 만들어둔 admin 모듈에 새로운 API를 만들어준다.
// admin.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AdminService } from './admin.service';
@Controller('admin')
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Get('post')
getAllPosts() {
return this.adminService.getAllPosts();
}
}
// admin.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from 'src/auth/entities/user.entity';
import { Board } from 'src/board/entities/board.entity';
import { Repository } from 'typeorm';
@Injectable()
export class AdminService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(Board)
private readonly boardRepository: Repository<Board>,
) {}
async getAllPosts() {
const posts = await this.boardRepository.find();
return posts;
}
}
board 메뉴에 보여줄 페이지를 컴포넌트로 만들어준다. 마찬가지로 학습메모 5, 프론트 분들의 코드를 참고하여 동일한 컨벤션으로 구현 시도.
import { useState } from 'react';
import Button from '../shared/Button';
import Table from '../shared/Table';
const baseUrl = import.meta.env.VITE_API_BASE_URL;
export default function Board() {
const [boardList, setBoardList] = useState([]);
const getBoardList = async () => {
const response = await fetch(baseUrl + '/admin/post');
const data = await response.json();
setBoardList(data);
};
return (
<div>
<Button onClick={getBoardList}>게시글 불러오기</Button>
<Table>
<thead>
<tr>
<th>번호</th>
<th>제목</th>
<th>작성자</th>
<th>좋아요</th>
<th>이미지 수</th>
<th>작성일시</th>
<th>수정일시</th>
</tr>
</thead>
<tbody>
{boardList.map((board: any) => (
<tr key={board.id}>
<td>{board.id}</td>
<td>{board.title}</td>
<td>{board.user.nickname}</td>
<td>{board.like_cnt}</td>
<td>{board.images.length}</td>
<td>{board.created_at}</td>
<td>{board.updated_at}</td>
</tr>
))}
</tbody>
</Table>
</div>
);
}
완성된 페이지는 라우트와 Nav바에 등록해줘야 한다.
Nav바는 아래와 같이 리팩토링해서 App.tsx에서 메뉴를 한번에 편집이 가능하도록 변경했다.
// Nav.tsx
import styled from '@emotion/styled';
interface PropsType extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
export default function Nav({ children, ...args }: PropsType) {
return <CustomNav {...args}>{children}</CustomNav>;
}
// 최상단에 위치하도록 설정
const CustomNav = styled.nav<PropsType>`
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 20px;
display: flex;
align-items: center;
justify-content: space-around;
background-color: #fff;
border-bottom: 1px solid #ddd;
padding: 12px 12px;
& > a {
margin-right: 16px;
text-decoration: none;
color: #212121;
font-size: 14px;
font-weight: 600;
}
& > a:hover {
text-decoration: underline;
}
`;
이제 최종적으로 board 페이지 등록.
// App.tsx
import { Route, Routes } from 'react-router-dom';
import './App.css';
import { TestComponent } from './components/TestComponent/TestComponent.tsx';
import Board from './components/Board/Board.tsx';
import Nav from './components/Nav.tsx';
function App() {
return (
<>
<Nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/abc">Test</a>
<a href="/board">Board</a>
</Nav>
<Routes>
<Route path="/" element={<div>Home</div>} />
<Route path="/about" element={<div>About</div>} />
<Route path="/abc" element={<TestComponent />} />
<Route path="/board" element={<Board />} />
</Routes>
</>
);
}
export default App;
board 페이지에서 게시글 전체 조회를 위한 fetch 시
baseUrl을 개발 환경에서는 http://localhost:3000
,
배포 환경에서는 https://www.별글.site/api
로 해줘야 하는데,
기존에 알고 있던 REACT 환경변수가 먹히지 않아 애를 좀 먹었다. 결국 본 프로젝트에서는 Vite를 이용하여 dev서버 실행과 build를 진행하므로, Vite에서 제공하는 환경변수 import 형태를 준수해야 했다.
학습메모 4를 참고하여 admin workspace 루트인 /packages/admin/
에
.env.development
, .env.production
두 파일을 추가해준다.
VITE_API_BASE_URL=http://localhost:3000
VITE_API_BASE_URL=https://www.별글.site/api
불러올 땐 아래와 같이 불러오면 된다.
const baseUrl = import.meta.env.VITE_API_BASE_URL;
yarn workspace admin dev
이제 dev서버에서 요청하면 http://localhost:3000
로 잘 가고
yarn workspace admin build
빌드해보면 https://www.별글.site/api
이 잘 등록되어 있음을 확인할 수 있다.
전체적인 실행 화면은 위와 같다.
yarn workspace server add os-utils
다양한 방법이 있지만, os-utils 패키지를 이용해 조회하는 것이 가장 직관적이라 판단되어 해당 솔루션을 활용함
@Get('system-info')
getSystemInfo() {
return this.adminService.getSystemInfo();
}
컨트롤러 단에 GET /admin/system-info
를 추가해주고,
async getSystemInfo() {
// 플랫폼 정보
const platform = osUtils.platform();
// CPU 개수
const cpuCount = osUtils.cpuCount();
// cpu 사용량 (비동기로 동작하여 Promise로 감싸줌)
const usedCpu: number = await new Promise((resolve) => {
osUtils.cpuUsage((cpuUsage) => {
resolve(cpuUsage);
});
});
const freeCpu: number = await new Promise((resolve) => {
osUtils.cpuFree((cpuFree) => {
resolve(cpuFree);
});
});
const cpuUsage = `${Math.floor(usedCpu * 100 * 100) / 100}% (free ${
Math.floor(freeCpu * 100 * 100) / 100
}%)`;
// 메모리 사용량
const totalMem = osUtils.totalmem();
const freeMem = osUtils.freemem();
const usedMem = totalMem - freeMem;
const memUsagePercent = usedMem / totalMem;
const memUsage = `${Math.floor(memUsagePercent * 100 * 100) / 100}%`;
// 시스템 정보 객체로 반환
const systemInfo = {
platform,
cpuCount,
cpuUsage,
memUsage,
};
return systemInfo;
}
서비스 단에서 로직을 구현해줬다.
cpuUsage, cpuFree 메소드는 콜백 형태로만 반환이 되기 때문에, 기다린 후에 값을 넣어주기 위해 Promise로 감싸서 await 시켜줬다.
이 부분에서 조금 시간이 쓰였음
import { useState } from 'react';
import Button from '../shared/Button';
import Table from '../shared/Table';
const baseUrl = import.meta.env.VITE_API_BASE_URL;
export default function SystemInfo() {
const [systemInfo, setSystemInfo]: any = useState([]);
const getSystemInfo = async () => {
const response = await fetch(baseUrl + '/admin/system-info');
const data = await response.json();
setSystemInfo(data);
};
return (
<div>
<Button onClick={getSystemInfo}>시스템 정보 불러오기</Button>
<Table>
<thead>
<tr>
<th>플랫폼</th>
<th>CPU 수</th>
<th>CPU 사용량</th>
<th>메모리 사용량</th>
</tr>
</thead>
<tbody>
<tr>
<td>{systemInfo.platform}</td>
<td>{systemInfo.cpuCount}</td>
<td>{systemInfo.cpuUsage}</td>
<td>{systemInfo.memUsage}</td>
</tr>
</tbody>
</Table>
</div>
);
}
Board와 유사하게 페이지 컴포넌트를 만들어 줬다. 클릭 시마다 현재 시스템 상태정보를 다시 조회할 수 있도록!
function App() {
return (
<>
<Nav>
<a href="/admin">Home</a>
<a href="/admin/about">About</a>
<a href="/admin/abc">Test</a>
<a href="/admin/board">Board</a>
<a href="/admin/system-info">System Info</a>
</Nav>
<Routes>
<Route path="/admin" element={<div>Home</div>} />
<Route path="/admin/about" element={<div>About</div>} />
<Route path="/admin/abc" element={<TestComponent />} />
<Route path="/admin/board" element={<Board />} />
<Route path="/admin/system-info" element={<SystemInfo />} />
</Routes>
</>
);
}
마지막으로 라우트와 Nav바에 등록해주면 완성
클릭할 때마다 정상적으로 값을 받아옴을 확인할 수 있다.
간략히만 기록하겠다. 일단 메모리 말고 디스크 사용량 확인이 없어서 찝찝해서 추가해봤다.
별도로 패키지 추가는 필요없고 child_process
내장모듈만으로 구현 가능했다.
// admin.service.ts
// 디스크 사용량
const diskUsageString: string = await new Promise((resolve) => {
exec('df -h', (error, stdout, stderr) => {
resolve(stdout);
});
});
const diskUsageRows = diskUsageString.split('\n');
const diskUsage = [];
diskUsageRows.forEach((row) => {
const rowSplit = row.split(' ');
const rowSplitFiltered = rowSplit.filter((item) => item !== '');
diskUsage.push(rowSplitFiltered);
});
// header는 따로 전송
const diskUsageHead = diskUsage.shift();
좋고~ 이걸 출력해주기 위해 프론트 단에선 테이블을 하나 더 만들어 출력했다.
// SystemInfo.tsx
...
export default function SystemInfo() {
...
return (
<div>
...
<Table>
<thead>
<tr>
{systemInfo.diskUsageHead &&
(systemInfo.diskUsageHead as any).map(
(head: string, index: number) => <th key={index}>{head}</th>,
)}
</tr>
</thead>
<tbody>
{systemInfo.diskUsage &&
(systemInfo.diskUsage as any).map(
(line: string[], index: number) => {
return (
<tr key={index}>
{line.map((item: string, index: number) => (
<td key={index}>{item}</td>
))}
</tr>
);
},
)}
</tbody>
</Table>
</div>
);
}
글 상세조회도 만들어보자.
async getAllPosts() {
const posts = await this.boardRepository.find();
// 컨텐츠 복호화
posts.forEach((post) => {
post.content = decryptAes(post.content);
});
// 이미지 있는 경우 이미지 경로 추가
posts.forEach((post: any) => {
if (post.images.length > 0) {
post.images = post.images.map(
(image) =>
`${awsConfig.endpoint.href}${bucketName}/${image.filename}`,
);
}
});
// console.log(posts);
return posts;
}
상세조회를 위해 본문은 복호화하고, 이미지는 링크정보를 가져오도록 변환했다.
다음으로 프론트
원래 파라미터가 없는 button 컴포넌트를 event 파라미터 넣도록 만들고, closest를 사용해 tr의 id값을 입력받도록 개선했다. id값 하나 받아오려고 고생 쫌 했다...
// Board.tsx
...
export default function Board() {
...
const getBoardDetail = async (e: React.MouseEvent<HTMLButtonElement>) => {
// 이벤트 위임으로 선택한 tr의 id값을 가져온다.
const id = Number((e.target as any).closest('tr').id);
const data = boardList.find((board: any) => board.id === id);
setBoardDetail(data as any);
console.log(boardDetail);
};
return (
<div>
<Button onClick={getBoardList}>게시글 불러오기</Button>
<Table>
<thead>
...
</thead>
<tbody>
{boardList.map((board: any) => (
<tr key={board.id} id={board.id}>
...
<td>
<Button onClick={(e) => getBoardDetail(e)}>상세 보기</Button>
</td>
</tr>
))}
</tbody>
</Table>
<div>
{boardDetail &&
(Object.keys(boardDetail) as any).map((detail: any) => {
return (
<div>
<div>{detail + ' | ' + boardDetail[detail]}</div>
</div>
);
})}
</div>
</div>
);
}
시간나면 상세보기는 카드나 모달 형태로 만들고, 이미지 태그로 이미지도 직접 보여주도록 변경해봐도 좋을듯.
© 2023 debussysanjang
- 🐙 [가은] Three.js와의 설레는 첫만남
- 🐙 [가은] JS로 자전과 공전을 구현할 수 있다고?
- ⚽️ [준섭] NestJS 강의 정리본
- 🐧 [동민] R3F Material 간단 정리
- 👾 [재하] 만들면서 배우는 NestJS 기초
- 👾 [재하] GitHub Actions을 이용한 자동 배포
- ⚽️ [준섭] 테스트 코드 작성 이유
- ⚽️ [준섭] TypeScript의 type? interface?
- 🐙 [가은] 우리 팀이 Zustand를 쓰는 이유
- 👾 [재하] NestJS, TDD로 개발하기
- 👾 [재하] AWS와 NCP의 주요 서비스
- 🐰 [백범] Emotion 선택시 고려사항
- 🐧 [동민] Yarn berry로 모노레포 구성하기
- 🐧 [동민] Vite, 왜 쓰는거지?
- ⚽️ [준섭] 동시성 제어
- 👾 [재하] NestJS에 Swagger 적용하기
- 🐙 [가은] 너와의 추억을 우주의 별로 띄울게
- 🐧 [동민] React로 멋진 3D 은하 만들기(feat. R3F)
- ⚽️ [준섭] NGINX 설정
- 👾 [재하] Transaction (트랜잭션)
- 👾 [재하] SSH 보안: Key Forwarding, Tunneling, 포트 변경
- ⚽️ [준섭] MySQL의 검색 - LIKE, FULLTEXT SEARCH(전문검색)
- 👾 [재하] Kubernetes 기초(minikube), docker image 최적화(멀티스테이징)
- 👾 [재하] NestJS, 유닛 테스트 각종 mocking, e2e 테스트 폼데이터 및 파일첨부
- 2주차(화) - git, monorepo, yarn berry, TDD
- 2주차(수) - TDD, e2e 테스트
- 2주차(목) - git merge, TDD
- 2주차(일) - NCP 배포환경 구성, MySQL, nginx, docker, docker-compose
- 3주차(화) - Redis, Multer 파일 업로드, Validation
- 3주차(수) - AES 암복호화, TypeORM Entity Relation
- 3주차(목) - NCP Object Storage, HTTPS, GitHub Actions
- 3주차(토) - Sharp(이미지 최적화)
- 3주차(일) - MongoDB
- 4주차(화) - 플랫폼 종속성 문제 해결(Sharp), 쿼리 최적화
- 4주차(수) - 코드 개선, 트랜잭션 제어
- 4주차(목) - 트랜잭션 제어
- 4주차(일) - docker 이미지 최적화
- 5주차(화) - 어드민 페이지(전체 글, 시스템 정보)
- 5주차(목) - 감정분석 API, e2e 테스트
- 5주차(토) - 유닛 테스트(+ mocking), e2e 테스트(+ 파일 첨부)
- 6주차(화) - ERD
- 2주차(화) - auth, board 모듈 생성 및 테스트 코드 환경 설정
- 2주차(목) - Board, Auth 테스트 코드 작성 및 API 완성
- 3주차(월) - Redis 연결 후 RedisRepository 작성
- 3주차(화) - SignUpUserDto에 ClassValidator 적용
- 3주차(화) - SignIn시 RefreshToken 발급 및 Redis에 저장
- 3주차(화) - 커스텀 AuthGuard 작성
- 3주차(수) - SignOut시 토큰 제거
- 3주차(수) - 깃헙 로그인 구현
- 3주차(토) - OAuth 코드 통합 및 재사용
- 4주차(수) - NestJS + TypeORM으로 MySQL 전문검색 구현
- 4주차(목) - NestJS Interceptor와 로거
- [전체] 10/12(목)
- [전체] 10/15(일)
- [전체] 10/30(월)
- [FE] 11/01(수)~11/03(금)
- [전체] 11/06(월)
- [전체] 11/07(화)
- [전체] 11/09(목)
- [전체] 11/11(토)
- [전체] 11/13(월)
- [BE] 11/14(화)
- [BE] 11/15(수)
- [FE] 11/16(목)
- [FE] 11/19(일)
- [BE] 11/19(일)
- [FE] 11/20(월)
- [BE] 11/20(월)
- [BE] 11/27(월)
- [FE] 12/04(월)
- [BE] 12/04(월)
- [FE] 12/09(금)
- [전체] 12/10(일)
- [FE] 12/11(월)
- [전체] 12/11(월)
- [전체] 12/12(화)