* 댓글 기능 구현
- 일단 뷰부터 만들자.
<div class="comment-form-container">
<form class="new-comment-form">
<div class="user-info">
<%= post.member.nickname %>
</div>
<textarea placeholder="댓글을 입력하세요" required></textarea>
<div class="button-container">
<button type="submit">댓글 작성</button>
</div>
</form>
</div>
<div class="post-navigation">
<div class="nav-buttons">
<a href="/post/3" class="nav-button">이전글 : 미완성</a>
<a href="/post/4" class="nav-button">다음글 : 미완성</a>
</div>
<div class="list-button">
<a href="/index" class="nav-button">목록으로</a>
</div>
</div>
.comment-form-container {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 15px;
margin-top: 20px;
}
.new-comment-form {
display: flex;
flex-direction: column;
}
.user-info {
font-weight: bold;
margin-bottom: 10px;
}
.new-comment-form textarea {
width: 100%;
min-height: 80px;
padding: 10px;
margin-bottom: 10px;
border: 1px solid #ccc;
border-radius: 4px;
resize: vertical;
}
.button-container {
display: flex;
justify-content: flex-end;
}
.new-comment-form button {
padding: 8px 16px;
background-color: #4362D0;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.new-comment-form button:hover {
background-color: #3451A5;
}
.post-navigation {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
padding: 10px 0;
border-top: 1px solid #e0e0e0;
}
.nav-buttons {
display: flex;
gap: 10px;
}
.nav-button {
padding: 8px 16px;
background-color: #4362D0;
color: white;
text-decoration: none;
border-radius: 4px;
transition: background-color 0.3s;
}
.nav-button:hover {
background-color: #e0e0e0;
}
.list-button {
margin-left: auto;
}
- 이정도면 괜찮다?
- 댓글저장을 위해 필요한것 : 멤버, 게시글이 필요하다! (댓글이 1:N의 N쪽이므로)
- 해결 : ejs에서 히든레이어로 id를 넘겨주기!
- 컨트롤러 구현
import {Request} from "../../../was/request";
import {Response} from "../../../was/response";
import {PostRepository} from "../../../domain/post/PostRepository";
import {Post} from "../../../domain/post/Post";
import {ControllerV6} from "../ControllerV6";
import {Comment} from "../../../domain/comment/Comment";
import {CommentRepository} from "../../../domain/comment/CommentRepository";
import {MemberRepository} from "../../../domain/member/MemberRepository";
import {Repository} from "typeorm";
export class CommentSaveControllerV6 implements ControllerV6{
private commentRepository = CommentRepository.getInstance().getRepo();
private memberRepository = MemberRepository.getInstance().getRepo();
private postRepository = PostRepository.getInstance().getRepo();
async process(req:Request, res: Response, paramMap: Map<string, string>, model: Map<string, object>) {
const postId = +paramMap.get('postId');
const memberId= +paramMap.get('memberId');
const content = paramMap.get('content');
if(!postId || !content || !memberId){
return "redirect:error";
}
const findMember = await this.memberRepository.findOne({
where : {id : memberId},
});
const findPost = await this.postRepository.findOne({
where: { id: postId },
});
if(!findMember || !findPost){
return "redirect:error";
}
const comment = new Comment(findMember, findPost , content);
try{
await this.commentRepository.save(comment);
//해당 post의 상세 페이지로 이동
return "redirect:post/"+postId;
}
catch(e){
return "redirect:error";
}
}
version6() {
}
}
- 버그 : 댓글에는 게시글의 주인이 아니라, 로그인한 회원의 닉네임이 보여아한다.
- 댓글을 저장할때도 로그인한 회원이 댓글의 주인이다.
* fix : 회원가입후 보안 이슈
- 문제 : 쿼리스트링으로 id를 넘기게되면 다른회원의 정보 노출됨.
- 해결 : 쿼리스트링으로 회원가입한 멤버의 이메일, 닉네임을 넘기면됨!!
- 현재 내 was는 파라미터는 req.query로 접근해야함에 주의
* 배포
- 문제 : 로그인form의 링크가 localhost로 걸려있음(하드코딩) => 외부유저접속인데 localhost로 보내버림 ;;
- 해결 : 동적으로 form url 설정해야할듯
- 요청헤더의 host에 ip 주소가 담겨있다. 이를 활용하자.
- redirect method 수정
public redirect(path: string) {
if(!path.startsWith('/')){
path = '/'+path;
}
// 절대 URL인 경우 그대로 사용
if (path.startsWith('http://') || path.startsWith('https://')) {
this.header("Location", path);
} else {
// 상대 경로인 경우, 현재 호스트를 기반으로 URL 구성
const host = this.req.headers.get('host');
const fullUrl = `${PROTOCOL.HTTP}://${host}${path}`;
this.header("Location", fullUrl);
}
this.send();
}
- 일단 clone으로 리눅스에 떙겨와서 .git 파일을 만들어야함
- bug ? : git rev-parse origin/J160 이 작동을 안하는문제
- 일단 # 방법 3: ls-remote 사용 REMOTE=$(git ls-remote origin J160 | cut -f1) 원격저장소 직접조회로 임시 해결
- 문제2 : 현재 npm start가 아래와같이 되있는데, pm2로 시작하려니 다시 js로 빌드후 실행하는 방법밖에 없는듯함?? -> 즉, npm start를 직접 사용하는것은 불가능한듯. js로 빌드후 시작해야함.
- 일단 pm2 시작대신 npm start로 임시 해결
"scripts": {
"start": "tsc-watch --onSuccess \"node dist/main.js\""
},
#!/bin/bash
# 로그 파일 설정
LOG_FILE="/home/bisu/deployWas.log"
# 함수: 로그 메시지 출력
log_message() {
echo "$(date): $1" >> "$LOG_FILE"
}
echo "start auto deployWas"
log_message "배포 스크립트 시작"
# GitHub 토큰 설정
export GIT_ASKPASS="/home/bisu/script/git-pass.sh"
# 프로젝트 디렉토리로 이동
cd /home/bisu/boost/web-p2-was || { log_message "프로젝트 디렉토리로 이동 실패"; exit 1; }
# 원격 저장소에서 변경사항 가져오기
log_message "Git fetch 시작"
git fetch || { log_message "Git fetch 실패"; exit 1; }
# 원격 브랜치와 로컬 브랜치 비교
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git ls-remote origin J160 | cut -f1)
if [[ "$LOCAL" == "$REMOTE" ]]; then
log_message "변경사항 없음"
exit 0
fi
# 변경사항이 있으면 pull 실행
log_message "변경사항 감지. Git pull 시작"
git pull origin J160 || { log_message "Git pull 실패"; exit 1; }
# 3000번 포트 프로그램 종료
PID=$(lsof -t -i :3000)
if [[ -n "$PID" ]]; then
kill -15 "$PID" || { log_message "프로세스 종료 실패 (PID: $PID)"; exit 1; }
log_message "3000 포트 프로세스 종료 (PID: $PID)"
fi
# npm 패키지 설치
log_message "npm 패키지 설치 시작"
npm install || { log_message "npm 패키지 설치 실패"; exit 1; }
# PM2로 애플리케이션 재시작
log_message "npm 시작"
npm start || { log_message "npm 시작 실패"; exit 1; }
# Nginx 재시작
#log_message "Nginx 재시작 시작"
#sudo systemctl restart nginx || { log_message "Nginx 재시작 실패"; exit 1; }
log_message "배포 완료"
echo "배포 완료"
- 시스템 시작시 자동실행
# 파일: /etc/systemd/system/web-p2-was.service
[Unit]
Description=Web P2 WAS Application
After=network.target
[Service]
Type=simple
User=bisu
WorkingDirectory=/home/bisu/boost/web-p2-was
ExecStart=/usr/bin/npm start
Restart=on-failure
RestartSec=10
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=web-p2-was
[Install]
WantedBy=multi-user.target
# systemd 리로드
sudo systemctl daemon-reload
# 각 서비스 활성화
sudo systemctl enable pm2-nodejs.service
sudo systemctl enable web-p2-was.service
# 각 서비스 시작
sudo systemctl start pm2-nodejs.service
sudo systemctl start web-p2-was.service
# 각 서비스 상태 확인
sudo systemctl status pm2-nodejs.service
sudo systemctl status web-p2-was.service
# 모든 서비스 한 번에 재시작 (필요시)
sudo systemctl restart pm2-nodejs.service web-p2-was.service
- systemctl disable nodeapp-pm2.service => 기존 taskify 꺼두기
- 크론탭 => 배포자동화 (모든시간의 5분에)
* todo : 조회수 증가 구현,
- 의문 : 조회수가 +1 될때마다 db에 update 를 하는게 좋은가?
- 사용자가 100명이고 한게시물을 조회 -> 100개의 update 쿼리
- critical 한 정보가 아니기때문에 얻는 이득대비 db 비용이 크다고 생각한다.
- 개선 : 레디스캐시에 조회수를두고 여기에 업데이트한다.
- 5분마다 db에 업데이트 쿼리를 날린다.
- 5분에 1번만 db에 update 쿼리를 날린다.
sequenceDiagram
participant Client as 클라이언트
participant API as API 서버
participant Redis as Redis 캐시
participant DB as 데이터베이스
participant Job as 백그라운드 작업
Client->>API: 1. 게시글 요청
API->>Redis: 2. 조회수 증가 (INCR)
Redis-->>API: 3. 증가된 조회수 반환
API->>DB: 4. 게시글 정보 조회
DB-->>API: 5. 게시글 정보 반환
API-->>Client: 6. 게시글 및 조회수 반환
Note over Job: 주기적으로 실행 (예: 5분마다)
Job->>Redis: 7. 누적된 조회수 조회
Redis-->>Job: 8. 조회수 데이터 반환
Job->>DB: 9. 조회수 업데이트
Job->>Redis: 10. 처리된 조회수 데이터 삭제
- 우분투에 redis설치
- 알아보니까 쓰기에서는 redis를 잘 사용하지 않는다.
- 주로 조회가 많기때문에 그런것같다.
- 게시글 자체에 이미 조회수가 포함되있기때문에 인기글에 대해서 게시글 전체를 캐싱하는게 나을듯?
- write작업을 캐싱하려면 너무 복잡..
* local의 가상머신에 있는 redis 연결
- config
- 참고로 host에 192로 시작하는 내부ip를 입력해야 작동한다.
- db연결할때도 이렇게 했었네?
- 네트워크 공부필요
- dotenv.config해야 제대로 .env 파일의 내용을 불러온다.
import { createClient } from 'redis';
import * as dotenv from 'dotenv';
dotenv.config(); // env환경변수 파일 가져오기
export const RedisClient = createClient({
url: `redis://${process.env.DB_HOST}:${process.env.PORT_REDIS}`,
socket: {
reconnectStrategy: (retries) => {
if (retries > 10) {
console.error('Redis 연결 최대 재시도 횟수 초과');
return new Error('Redis 연결 실패');
}
return Math.min(retries * 100, 3000);
},
},
});
RedisClient.on('error', (err) => console.error('Redis 클라이언트 에러:', err));
RedisClient.on('connect', () => console.log('Redis에 연결되었습니다.'));
RedisClient.on('reconnecting', () => console.log('Redis에 재연결 중...'));
(async () => {
await RedisClient.connect();
})();
- 사용
export class HomeControllerV6 implements ControllerV6{
private sessionMgr : SessionManager = SessionManager.getInstance();
private postRepository = PostRepository.getInstance().getRepo();
private redisClient = RedisClient
async process(req: Request, res: Response, paramMap: Map<string, string>, model: Map<string, object>) {
const findCookieVal = this.sessionMgr.findCookie(req, this.sessionMgr.SESSION_COOKIE_NAME);
const findMember = this.sessionMgr.findMemberByCookieVal(findCookieVal);
// await this.redisClient.connect();
let posts;
const cachedPosts = await this.redisClient.get('posts');
const temp = await this.redisClient.get('a');
console.log(temp);
if (cachedPosts) {
posts = JSON.parse(cachedPosts);
}
- 우분투 설치방법
- sudo apt install redis-server
- etc/redis redis conf 수정
- 모든 ip 허용
- 비밀번호없이도 접근가능
- 어차피 방화벽에서 1차로 인증했다고 간주. (미들웨어랑 유사하다)
- 막상 설치는 했는데 활용할곳이 마땅치 않다.
- 게시글 조회시 조회수 증가로직 개선을 해보자.
// 주기적으로 동기화 작업 실행 (예: 5초마다)
setInterval(() => {
console.log('Syncing view counts to database...');
viewCountManager.syncViewCountsToDatabase()
.catch(console.error);
}, 5000);
- db의 조회수에 redis의 조회수를 더하는 방식이다. => redis값이 초기화 되더라도 상관없음!
* todo : 페이징
sequenceDiagram
participant Client
participant PaginationService
participant Repository
Client->>PaginationService: paginate(dto, repository, {}, 'posts')
Note over PaginationService: DTO: { page: 1, take: 10, order__createdAt: 'ASC' }
PaginationService->>PaginationService: pagePaginate()
PaginationService->>PaginationService: composeFindOptions()
Note over PaginationService: 생성된 FindOptions: { order: { createdAt: 'ASC' }, take: 10, skip: 0 }
PaginationService->>Repository: findAndCount(findOptions)
Repository-->>PaginationService: [data, count]
PaginationService-->>Client: { data, total: count }
- test용 data 넣기
import {Request} from "../../../was/request";
import {Response} from "../../../was/response";
import {PostRepository} from "../../../domain/post/PostRepository";
import {Post} from "../../../domain/post/Post";
import {ControllerV6} from "../ControllerV6";
export class PostSaveRandomControllerV6 implements ControllerV6{
private postRepository: PostRepository;
constructor() {
this.postRepository = PostRepository.getInstance();
}
async process(req:Request, res: Response, paramMap: Map<string, string>, model: Map<string, object>) {
for(let i=1; i<=100; i++){
const title = `title ${i}`;
const content = `content ${i}`;
const post = new Post(title, content, req.user);
await this.postRepository.save(post);
}
return "index";
}
version6() {
}
}
- 보통 게시글은 최신순이 기본이다. 바꿔보자.
- page번호를 1번으로 요청 //홈페이지 이므로
- DESC만 보내주면된다. -> db에서 정렬해서 take10 // less_than에 마지막 id 를 보내줄필요없음.
const data = await this.paginationService.paginate(
new BasePaginatePostDto(1, undefined,
undefined, 'DESC', 10),
this.postRepository,
{
relations:{
member: true,
}
},
'post',
);
- 이제 메인페이지 아래에 페이지번호를 달고 알맞은 링크를 생성해보자.
- 먼저 page기반 작동 컨트롤러를 만들자.
import {ControllerV6} from "../ControllerV6";
import {Request} from "../../../was/request";
import {Response} from "../../../was/response";
import {SessionManager} from "../../../utils/SessionManager";
import {PostRepository} from "../../../domain/post/PostRepository";
import {PaginationService} from "../../../domain/common/PaginationService";
import {BasePaginatePostDto} from "../../../domain/common/dto/BasePaginatePostDto";
export class GetAllPostControllerV6 implements ControllerV6{
private sessionMgr : SessionManager = SessionManager.getInstance();
private postRepository = PostRepository.getInstance().getRepo();
private paginationService =PaginationService.getInstance();
async process(req: Request, res: Response, paramMap: Map<string, string>, model: Map<string, object>) {
const findCookieVal = this.sessionMgr.findCookie(req, this.sessionMgr.SESSION_COOKIE_NAME);
const findMember = this.sessionMgr.findMemberByCookieVal(findCookieVal);
const data = await this.paginationService.paginate(
new BasePaginatePostDto(+req.query['page'], undefined,
undefined, 'DESC', 10),
this.postRepository,
{
relations:{
member: true,
}
},
'post',
);
const posts = data.data;
model.set("posts",posts);
model.set("data", data);
/**
* 로그인이 된경우, 동적 렌더링 필요
*/
if (findMember) {
model.set("member", findMember);
return "index";
} else {
return "index";
}
}
version6() {
}
sayHello() {
//say hello
}
}
- 이제 프론트 작업만 하면된다.
- index.ejs
<% if (typeof member !== 'undefined' && member !== null) { %>
<% for (let i = 1; i <= data.total/10 + 1; i++) { %>
<a href="/post?page=<%= i %>"><%= i %></a>
<% } %>
<% } else { %>
<% } %>
* dns 사용하기
- 원하는 도메인, 매칭될 ip 넣기
- nginx conf에서 도메인네임도 추가
- sudo systemctl restart nginx
* 멤버리스트도 페이징 적용하기
- 컨트롤러
import {ControllerV6} from "../ControllerV6";
import {Request} from "../../../was/request";
import {Response} from "../../../was/response";
import {PaginationService} from "../../../domain/common/PaginationService";
import {BasePaginatePostDto} from "../../../domain/common/dto/BasePaginatePostDto";
import {MemberRepository} from "../../../domain/member/MemberRepository";
export class UserListControllerV6 implements ControllerV6{
private userRepository = MemberRepository.getInstance().getRepo();
private paginationService =PaginationService.getInstance();
async process(req: Request, res: Response, paramMap: Map<string, string>, model: Map<string, object>) {
if(!req.query['page']){
req.query['page'] = "1";
}
const data = await this.paginationService.paginate(
new BasePaginatePostDto(+req.query['page'], undefined,
undefined, 'DESC', 10),
this.userRepository,
{
},
'user',
);
const members = data.data;
model.set("members",members);
model.set("data", data);
return "user/list";
}
version6() {
}
}
- ejs
<head>
<meta charset="UTF-8">
<title>Title</title>
<link rel="stylesheet" href="../css/memberList.css">
</head>
<body>
<nav class="navbar">
<a href="/">HELLO. WEB!</a>
<ul class="nav-item-box">
<li>
<button onclick="location.href='/user/list'" class="button BoldS">
<p>멤버리스트</p>
</button>
</li>
<li>
<button onclick="location.href='/user/mypage'" class="button BoldS">
<p>마이페이지</p>
</button>
</li>
<li>
<button onclick="location.href='/user/logout'" class="button BoldS">
<p>로그아웃</p>
</button>
</li>
</ul>
</nav>
<section class="hero-box">
<p class="hero-subtitle">부스트캠프 백엔드 교육용 페이지</h1>
<h1 class="hero-title">HELLO. WEB! 입니다.</p>
</section>
<section class="board-box">
<p class="board-count">전체멤버 <span><%=data.total%></span>명</p>
<ul class="board-list-member" >
<li class="board-header-member">
<p class="board-header-nickname BordS">닉네임</p>
<p class="board-header-email BordS">이메일</p>
<p class="board-header-createdAt BordS">회원가입일</p>
</li>
<% members.forEach(function(member) { %>
<li class="board-item-member">
<p class="board-header-nickname BordS"><%= member.nickname %></p>
<p class="board-header-email BordS"><%= member.email %></p>
<p class="board-header-createdAt BordS"><%= member.createdAt.toLocaleString() %></p>
</li>
<% }); %>
</ul>
<nav class="navbar">
<% for (let i = 1; i <= data.total/10 + 1; i++) { %>
<a href="/user/list?page=<%= i %>"><%= i %></a>
<% } %>
<ul class="nav-item-box">
<li>
<button onclick="location.href='/user/mypage'" class="button BoldS">
<p>검색어를 입력하세요</p>
</button>
</li>
<li>
<button onclick="location.href='/post/form'" class="button BoldS">
<p>글쓰기</p>
</button>
</li>
</ul>
</nav>
</body>
</html>
* todo : 비밀번호 암호화
- 회원저장 컨트롤러
export class UserSaveController implements ControllerV4{
private memberRepository: MemberRepository;
constructor() {
this.memberRepository = MemberRepository.getInstance();
}
async process(paramMap: Map<string, string>, model: Map<string, object>) {
const email: string = paramMap.get('email');
const nickname: string = paramMap.get('nickname');
const password: string = paramMap.get('password');
const salt = await bcrypt.genSalt();
const hashedPassword = await bcrypt.hash(password, salt);
const member = new Member(email, nickname , hashedPassword);
try{
await this.memberRepository.save(member);
return `redirect:/user/login-ok?email=${email}&nickname=${nickname}`;
}
catch(e){
return REDIRECT_ERROR.REDIRECT_URL;
}
}
}
- 로그인 컨트롤러
if(!await bcrypt.compare(password, findMember.password)){
console.log('비밀번호 불일치');
return "redirect:user/login_failed";
}
* todo : 에러핸들링
- 미들웨어통과후, 프론트컨트롤러에도 맞는 경로가없다 -> 404 error를 줘도된다고 판단!
if(!handler){
res.send404ErrorPage();
return;
}
- 하드코딩 이거맞나?
send404ErrorPage() {
this.status(404).header('Content-Type', 'text/html').send('<!DOCTYPE html>\n' +
'<html lang="ko">\n' +
'<head>\n' +
' <meta charset="UTF-8">\n' +
' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n' +
' <title>404 - 페이지를 찾을 수 없어요</title>\n' +
' <style>\n' +
' body {\n' +
' font-family: \'Arial\', sans-serif;\n' +
' background-color: #f0f8ff;\n' +
' display: flex;\n' +
' justify-content: center;\n' +
' align-items: center;\n' +
' height: 100vh;\n' +
' margin: 0;\n' +
' }\n' +
' .container {\n' +
' text-align: center;\n' +
' background-color: white;\n' +
' padding: 2rem;\n' +
' border-radius: 10px;\n' +
' box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);\n' +
' }\n' +
' h1 {\n' +
' color: #ff6b6b;\n' +
' font-size: 3rem;\n' +
' margin-bottom: 0.5rem;\n' +
' }\n' +
' p {\n' +
' color: #4a4a4a;\n' +
' font-size: 1.2rem;\n' +
' margin-bottom: 1.5rem;\n' +
' }\n' +
' .maple-icon {\n' +
' width: 150px;\n' +
' height: 150px;\n' +
' margin-bottom: 1rem;\n' +
' }\n' +
' .home-button {\n' +
' background-color: #4CAF50;\n' +
' border: none;\n' +
' color: white;\n' +
' padding: 15px 32px;\n' +
' text-align: center;\n' +
' text-decoration: none;\n' +
' display: inline-block;\n' +
' font-size: 16px;\n' +
' margin: 4px 2px;\n' +
' cursor: pointer;\n' +
' border-radius: 5px;\n' +
' transition: background-color 0.3s;\n' +
' }\n' +
' .home-button:hover {\n' +
' background-color: #45a049;\n' +
' }\n' +
' </style>\n' +
'</head>\n' +
'<body>\n' +
' <div class="container">\n' +
' <img src="data:image/jpeg;base64,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" alt="귀여운 메이플 아이콘" class="maple-icon">\n' +
' <h1>404</h1>\n' +
' <p>앗! 페이지를 찾을 수 없어요.</p>\n' +
' <p>찾으시는 페이지가 사라졌거나 잘못된 주소를 입력하셨어요.</p>\n' +
' <a href="/" class="home-button">홈으로 돌아가기</a>\n' +
' </div>\n' +
'</body>\n' +
'</html>');
}
- 하드코딩 개선 ㅋㅋ
- ejs에 error코드와 msg를 보낸다면 동적으로 만들수있겠지만 오늘은 여기까지
if(!handler){
const view = this.viewResolver("error404");
view.renderEjs(new Map(),req,res);
return;
}
'JS > boostCamp' 카테고리의 다른 글
24.10.15. 개발일지 // oauth fix, 이미지 업로드, Not allowed to load local resource error 해결 (0) | 2024.10.16 |
---|---|
24.10.14. 개발일지 // github oauth구현 (0) | 2024.10.14 |
24. 10. 10. 개발일지 // 글쓰기 구현, 동적 url 매핑 , n+1 문제 (0) | 2024.10.10 |
24. 10. 9. 개발일지 // redirectAttributes, 로그인이완료된후 원래페이지 이동, db, n+1 문제 (1) | 2024.10.10 |
24.10.8. 개발일지 // 쿠키파서, db연결 , typeorm (0) | 2024.10.08 |