* 글쓰기 구현
- html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>글쓰기</title>
<link rel="stylesheet" href="../css/post-form.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">멤버리스트</button></li>
<li><button onclick="location.href='/user/mypage'" class="button">마이페이지</button></li>
<li><button onclick="location.href='/user/logout'" class="button">로그아웃</button></li>
</ul>
</nav>
<div class="title-block">
<h1 class="title">글쓰기</h1>
</div>
<form action="/post/save" method="POST" class="writing-form" id="writing-form">
<div class="input-form">
<div class="input-block">
<label for="title" class="input-label">제목</label>
<input
class="input-data title-input"
type="text"
id="title"
name="title"
required
placeholder="글의 제목을 입력하세요"
/>
</div>
<div class="input-block">
<label for="content" class="input-label">내용</label>
<textarea
class="input-data content-input"
id="content"
name="content"
required
placeholder="글의 내용을 입력하세요"
></textarea>
</div>
<div class="submit-button-block">
<button type="submit" class="button-long submit">작성완료</button>
</div>
</div>
</form>
</body>
</html>
- css
body {
font-family: Arial, sans-serif;
line-height: 1.6;
margin: 0;
padding: 0;
}
/* 네비게이션 바 스타일 */
.navbar {
background-color: white;
color: black;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar a {
color: black;
text-decoration: none;
}
.nav-item-box {
list-style-type: none;
display: flex;
margin: 0;
padding: 0;
}
.nav-item-box li {
margin-left: 1rem;
}
.button {
background: #4362D0;
border: none;
color: white;
padding: 10px 20px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
margin: 4px 2px;
cursor: pointer;
border-radius: 4px;
}
/* 타이틀 블록 스타일 */
.title-block {
background-color: #f4f4f4;
padding: 1rem;
text-align: center;
}
.title {
margin: 0;
font-size: 2rem;
}
/* 글쓰기 폼 스타일 */
.writing-form {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.input-form {
display: flex;
flex-direction: column;
}
.input-block {
margin-bottom: 20px;
}
.input-label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.input-data {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.title-input {
font-size: 1.2em;
}
.content-input {
height: 300px;
resize: vertical;
}
.submit-button-block {
text-align: right;
}
.button-long.submit {
padding: 10px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.button-long.submit:hover {
background-color: #0056b3;
}
.button-long.submit:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
- 직접 post객체를 만들때 연관된 user 객체를 넣어줘야한다!!
- 음, auth guard를 통과한 이후 (쿠키 : member가 있음이 보장됨. ), req.user에 user객체를 넣어주면 활용성이 높겠군. -> 미들웨어를 만들자.
import {Request} from "../was/request";
import {Response} from "../was/response";
import {SessionManager} from "../utils/SessionManager";
const sessionManager: SessionManager = SessionManager.getInstance();
export const MakeUser2Req = (req : Request, res : Response, next) => {
const findCookie = sessionManager.findCookie(req, sessionManager.SESSION_COOKIE_NAME);
const findMember = sessionManager.findMemberByCookieVal(findCookie);
if(!findMember){
next();
}
else{
req.user = findMember;
next();
}
}
- 컨트롤러도 만들자.
- req에 접근해야하니 V6로 만들자.
- new Post 생성시 member도 넣어줘야 외래키가 들어간다!
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 PostSaveControllerV6 implements ControllerV6{
private postRepository: PostRepository;
constructor() {
this.postRepository = PostRepository.getInstance();
}
async process(req:Request, res: Response, paramMap: Map<string, string>, model: Map<string, object>) {
const title = paramMap.get('title');
const content= paramMap.get('content');
if(!title || !content || !req.user){
return "redirect:error";
}
const post = new Post(title, content , req.user);
try{
const savedPost = await this.postRepository.save(post);
//todo : 해당 post의 상세 페이지로 이동
return "redirect:index"
}
catch(e){
return "redirect:error";
}
}
version6() {
}
}
* 동적 url 매핑 구현
- api : post/:id
- 그런데 post/save등 다른 api 와 안겹치려면?
- 이를 처리하도록 구현해야함.
- 프론트 컨트롤러수정
- url과 정확히 매핑 -> 해당 컨트롤러 반환
- else -> handlerMap 완탐하면서 정적 부분이 매핑되는 핸들러를 찾음.
- ex : post / asdf / :id vs post / asdf / 1
/**
* 정확히 매핑됨 -> 그 컨트롤러 호출
* 정확한 매핑이없음 -> 패턴이 매칭되는게 있는지 탐색
* @param reqURI
* @private
*/
private findHandler(reqURI: string): any {
// 정확한 매치 먼저 확인
if (this.handlerMappingMap.has(reqURI)) {
return this.handlerMappingMap.get(reqURI);
}
// 동적 라우트 매칭
for (const [pattern, handler] of this.handlerMappingMap.entries()) {
if (this.isPatternMatch(pattern, reqURI)) {
return handler;
}
}
return null;
}
/**
*
* @param pattern post/:id
* @param uri post/1
* @private
*/
private isPatternMatch(pattern: string, uri: string): boolean {
const patternParts = pattern.split('/'); // [ post , :id ]
const uriParts = uri.split('/'); // [ post, 1 ]
if (patternParts.length !== uriParts.length) {
return false;
}
for (let i = 0; i < patternParts.length; i++) {
if (patternParts[i].startsWith(':')) {
continue; // 동적 부분은 항상 매치
}
if (patternParts[i] !== uriParts[i]) { //post, post와 같이 정적 부분이 매칭안되는경우 -> false ( 지금 컨트롤러와 매칭 x )
return false;
}
}
// 동적부분제외, 정적부분이 모두 매칭되는경우 매칭되는 컨트롤러이다.
return true;
}
- 잘 찍히나 보려했더니 , member 가 비어있음 연관된 member, comments 객체도 한번에 가져오기를 원함. (게시글에 작성자 정보 넣어야함 && 연관된 댓글도 보여줘야함)
- fetch join (typeorm의 relation)을 이용해서 해결하고자 함.
- 쿼리 분석 with gpt
-- 첫 번째 쿼리: 특정 Post ID 선택
SELECT DISTINCT `distinctAlias`.`Post_id` AS `ids_Post_id`
FROM (
SELECT `Post`.`id` AS `Post_id`, /* 다른 필드들 생략 */
FROM `posts` `Post`
LEFT JOIN `members` `Post__Post_member` ON `Post__Post_member`.`id` = `Post`.`memberId`
LEFT JOIN `comment` `Post__Post_comments` ON `Post__Post_comments`.`postId` = `Post`.`id`
LEFT JOIN `members` `Post__Post_comments__Post__Post_comments_member`
ON `Post__Post_comments__Post__Post_comments_member`.`id` = `Post__Post_comments`.`memberId`
WHERE (`Post`.`id` = ?)
) `distinctAlias`
ORDER BY `Post_id` ASC
LIMIT 1
-- 두 번째 쿼리: 실제 데이터 조회
SELECT `Post`.`id` AS `Post_id`, `Post`.`title` AS `Post_title`, /* 다른 필드들 생략 */
FROM `posts` `Post`
LEFT JOIN `members` `Post__Post_member` ON `Post__Post_member`.`id` = `Post`.`memberId`
LEFT JOIN `comment` `Post__Post_comments` ON `Post__Post_comments`.`postId` = `Post`.`id`
LEFT JOIN `members` `Post__Post_comments__Post__Post_comments_member`
ON `Post__Post_comments__Post__Post_comments_member`.`id` = `Post__Post_comments`.`memberId`
WHERE (`Post`.`id` = ?) AND (`Post`.`id` IN (3))
쿼리 분석 및 N+1 문제 해결 여부:
- 쿼리 구조:
- 두 개의 쿼리가 실행되고 있습니다.
- 첫 번째 쿼리는 특정 Post ID를 선택합니다.
- 두 번째 쿼리는 실제 데이터를 조회합니다.
- JOIN 사용:
- posts, members, comment 테이블이 LEFT JOIN으로 연결되어 있습니다.
- Post와 관련된 Member, Comment, 그리고 Comment의 작성자(Member)까지 한 번에 조회하고 있습니다.
- N+1 문제 해결:
- 예, 이 쿼리는 N+1 문제를 해결했습니다.
- 모든 관련 데이터(Post, Member, Comment, Comment의 Member)를 단일 쿼리로 가져오고 있어, 추가적인 쿼리 실행이 필요 없습니다.
- 성능 고려사항:
- 단일 쿼리로 모든 데이터를 가져오므로 데이터베이스 호출 횟수가 줄어듭니다.
- 그러나 LEFT JOIN을 많이 사용하여 쿼리가 복잡해졌으므로, 대량의 데이터를 다룰 때 성능 이슈가 발생할 수 있습니다.
- 최적화 가능성:
- 필요한 필드만 선택하여 쿼리 결과의 크기를 줄일 수 있습니다.
- 페이지네이션을 적용하여 한 번에 가져오는 데이터의 양을 제한할 수 있습니다.
결론: 이 쿼리는 N+1 문제를 효과적으로 해결했습니다. 단일 쿼리로 Post와 관련된 모든 데이터를 가져오고 있어, 추가적인 쿼리 실행이 필요 없습니다. 그러나 쿼리의 복잡성으로 인해 대규모 데이터셋에서는 성능 최적화가 필요할 수 있습니다.
추가적인 최적화나 다른 측면에서의 분석이 필요하다면 말씀해 주세요.
* relation(fetch-join) vs batch-size
네, 좋은 질문입니다. 일반적인 relations
옵션과 batch size를 이용한 방법 사이에는 몇 가지 중요한 차이점이 있습니다. 이를 비교해 보겠습니다.
// 일반적인 relations 사용
async function getPostsWithRelations() {
return await postRepository.find({
relations: ['user', 'comments', 'comments.user']
});
}
// Batch size를 이용한 방법
async function getPostsWithBatchSize() {
return await postRepository.find({
relations: ['user', 'comments', 'comments.user'],
relationLoadStrategy: "query",
take: 100 // 예시 batch size
});
}
// 실행 결과 비교
console.log("Relations method:", await getPostsWithRelations());
console.log("Batch size method:", await getPostsWithBatchSize());
주요 차이점:
- 쿼리 실행 방식:
- 일반
relations
: 모든 관계를 포함한 하나의 큰 JOIN 쿼리를 실행합니다. - Batch size: 메인 엔티티(여기서는 Post)를 먼저 로드하고, 관련 엔티티(User, Comment)를 별도의 쿼리로 로드합니다.
- 일반
- 성능:
- 일반
relations
: 데이터가 적을 때는 빠를 수 있지만, 데이터량이 많아지면 성능이 저하될 수 있습니다. - Batch size: 대량의 데이터를 처리할 때 더 효율적일 수 있으며, 메모리 사용량을 제어하기 쉽습니다.
- 일반
- 쿼리 수:
- 일반
relations
: 단일 쿼리로 모든 데이터를 가져옵니다. - Batch size: 여러 개의 작은 쿼리로 나누어 실행합니다.
- 일반
- 유연성:
- 일반
relations
: 간단하고 직관적이지만, 대량의 데이터에 대해 최적화하기 어려울 수 있습니다. - Batch size: 데이터 로딩을 더 세밀하게 제어할 수 있어, 대규모 데이터셋에 대해 더 유연합니다.
- 일반
- 메모리 사용:
- 일반
relations
: 모든 데이터를 한 번에 메모리에 로드하므로 메모리 사용량이 높을 수 있습니다. - Batch size: 데이터를 청크 단위로 로드하므로 메모리 사용을 제어하기 쉽습니다.
- 일반
- N+1 문제 해결:
- 일반
relations
: 단일 쿼리로 모든 것을 가져오므로 N+1 문제를 피할 수 있습니다. - Batch size: N+1 문제를 효과적으로 해결하면서도 성능을 최적화합니다.
- 일반
사용 시나리오:
- 일반
relations
:- 데이터 양이 적을 때
- 관계가 단순할 때
- 빠른 개발이 필요할 때
- Batch size:
- 대량의 데이터를 다룰 때
- 메모리 사용을 최적화해야 할 때
- 복잡한 관계를 가진 엔티티를 다룰 때
결론:
일반 relations
는 간단하고 직관적이지만, 대규모 데이터셋에서는 성능 문제가 발생할 수 있습니다. 반면에 batch size를 이용한 방법은 더 복잡하지만, 대규모 데이터셋에서 더 나은 성능과 메모리 효율성을 제공할 수 있습니다. 프로젝트의 요구사항과 데이터의 특성에 따라 적절한 방법을 선택해야 합니다.
- 구체적인 예시를 들어달라고 요청.
네, 게시글에 댓글이 10,000개 있는 상황에서 일반 relations와 batch size 방법의 실행 과정을 비교해 드리겠습니다.
이제 두 방법의 실행 과정을 비교해 보겠습니다:
- 일반 relations 방법: 실행 과정:
- 단일 대형 SQL 쿼리가 생성됩니다. 이 쿼리는 게시글, 모든 댓글, 각 댓글의 사용자 정보를 한 번에 JOIN합니다.
- 데이터베이스는 이 대형 쿼리를 실행하여 10,000개의 댓글과 그에 해당하는 사용자 정보를 모두 포함한 결과를 반환합니다.
- ORM은 이 큰 결과 세트를 메모리에 로드하고 객체로 매핑합니다.
- 장점: 단일 쿼리로 모든 데이터를 가져오므로 데이터베이스 왕복 횟수가 적습니다.
- 단점:
- 메모리 사용량이 매우 높습니다 (10,000개의 댓글 객체를 한 번에 생성).
- 쿼리 실행 시간이 길어질 수 있습니다.
- 데이터베이스 서버에 큰 부하를 줄 수 있습니다.
- Batch size를 이용한 방법: 실행 과정:
- 먼저 게시글 정보만 가져오는 작은 쿼리를 실행합니다.
- 그 다음, 댓글을 100개씩 가져오는 쿼리를 여러 번 실행합니다. (예: 100개씩 100번의 쿼리 실행)
- 각 배치마다 댓글과 관련 사용자 정보를 함께 가져옵니다.
- 장점:
- 메모리 사용량을 제어할 수 있습니다 (한 번에 100개의 댓글만 처리).
- 데이터베이스 서버의 부하를 분산시킬 수 있습니다.
- 첫 번째 배치의 데이터를 빠르게 사용할 수 있습니다 (지연 로딩).
- 단점:
- 데이터베이스 왕복 횟수가 증가합니다 (약 101번의 쿼리 실행).
- 전체 데이터를 가져오는 데 시간이 더 걸릴 수 있습니다.
실제 성능 비교:
- 일반 relations 방법: 초기 로딩 시간이 매우 길지만, 모든 데이터를 한 번에 사용할 수 있습니다.
- Batch size 방법: 초기 로딩은 빠르지만, 모든 댓글을 로드하는 데 시간이 걸립니다. 그러나 사용자에게 점진적으로 데이터를 보여줄 수 있습니다.
결론: 10,000개의 댓글이 있는 상황에서는 일반적으로 batch size 방법이 더 적합합니다. 이 방법을 사용하면 메모리 사용을 제어하고, 사용자에게 점진적으로 데이터를 제공할 수 있습니다. 또한, 필요한 만큼만 데이터를 로드할 수 있어 효율적입니다.
실제 애플리케이션에서는 페이지네이션을 구현하여 한 번에 모든 댓글을 로드하지 않고, 사용자가 요청할 때마다 일부분씩 로드하는 방식을 고려해볼 수 있습니다.
* 이어서 게시글 상세 개발
- 아래와 같은 객체를 orm이 뽑아준다.
Post {
title: '1',
content: '1',
member: Member {
nickname: 'chovy',
password: 'a',
email: 'a@a',
id: 3,
createdAt: 2024-10-08T13:22:25.238Z,
updatedAt: 2024-10-08T13:17:21.573Z
},
id: 3,
views: 0,
createdAt: 2024-10-10T11:15:26.943Z,
updatedAt: 2024-10-10T11:15:26.943Z,
comments: []
}
- ejs에서 사용만 하면 된다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>게시글 상세</title>
<link rel="stylesheet" href="../css/post-detail.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">멤버리스트</button></li>
<li><button onclick="location.href='/user/mypage'" class="button">마이페이지</button></li>
<li><button onclick="location.href='/user/logout'" class="button">로그아웃</button></li>
</ul>
</nav>
<div class="post-container">
<h1 class="post-title"><%=post.title%></h1>
<div class="post-info">
<span class="author">작성자: <%= post.member.nickname %></span>
<span class="date">작성일: <%=post.createdAt%></span>
<span class="views">조회수: <%=post.views%></span>
</div>
<div class="post-content">
<%=post.content%>
</div>
<div class="comment-count">
댓글 수: 3
</div>
<div class="comments-container">
<h2>댓글</h2>
<div class="comment">
<div class="comment-info">
<span class="comment-author">댓글 작성자1</span>
<span class="comment-date">2023-10-11</span>
</div>
<div class="comment-content">
첫 번째 댓글 내용입니다.
</div>
</div>
<div class="comment">
<div class="comment-info">
<span class="comment-author">댓글 작성자2</span>
<span class="comment-date">2023-10-12</span>
</div>
<div class="comment-content">
두 번째 댓글 내용입니다.
</div>
</div>
<!-- 추가 댓글들... -->
</div>
<!-- 새 댓글 작성 폼 -->
<form class="new-comment-form">
<textarea placeholder="새 댓글을 작성하세요" required></textarea>
<button type="submit">댓글 작성</button>
</form>
</div>
</body>
</html>
* 이제 index에 실제 게시글이 표시되도록 구현
- 홈컨트롤러 수정
- posts는 로그인여부와 상관없이 항상 보여준다.
- ejs에 보내주고싶은 객체를 model.set만 해주면 된다.
export class HomeControllerV6 implements ControllerV6{
private sessionMgr : SessionManager = SessionManager.getInstance();
private postRepository = PostRepository.getInstance().getRepo();
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 posts = await this.postRepository.find({
relations: ['member', 'comments', 'comments.member'],
});
model.set("posts",posts);
/**
* 로그인이 된경우, 동적 렌더링 필요
*/
if(findMember){
model.set("member",findMember);
return "index";
}
else{
return "index";
}
}
- ejs 반복문 추가
<section class="board-box">
<p class="board-count">전체글<span>N</span>개</p>
<ul class="board-list">
<li class="board-header">
<div>
<p class="board-header-title BordS">제목</p>
<p class="board-header-author BordS">작성자</p>
<p class="board-header-date BordS">작성일자</p>
<p class="board-header-views BordS">조회수</p>
</div>
<li class="board-item">
<% posts.forEach(function(post) { %>
<a href="/post/<%= post.id %>" class="board-link">
<p class="board-item-title BordR"><%= post.title%></p>
<p class="board-item-author BordR"><%= post.member.nickname%></p>
<p class="board-item-date BordR"><%= post.createdAt.toLocaleString()%></p>
<p class="board-item-views BordR"><%= post.views%></p>
</a>
<% }); %>
</li>
</ul>
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. 처리된 조회수 데이터 삭제
'JS > boostCamp' 카테고리의 다른 글
24.10.14. 개발일지 // github oauth구현 (0) | 2024.10.14 |
---|---|
24.10.11. 개발일지 // 배포, redis, 댓글, 조회수증가, 페이징, dns, 에러페이지 (3) | 2024.10.14 |
24. 10. 9. 개발일지 // redirectAttributes, 로그인이완료된후 원래페이지 이동, db, n+1 문제 (1) | 2024.10.10 |
24.10.8. 개발일지 // 쿠키파서, db연결 , typeorm (0) | 2024.10.08 |
24.10.5~7. 개발일지 // 회원가입구현, redirect, cookie, session, 인증 (0) | 2024.10.07 |