관리 메뉴

Mini

24. 10. 10. 개발일지 // 글쓰기 구현, 동적 url 매핑 , n+1 문제 본문

JS/boostCamp

24. 10. 10. 개발일지 // 글쓰기 구현, 동적 url 매핑 , n+1 문제

Mini_96 2024. 10. 10. 23:06

* 글쓰기 구현

  • 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;
}

결과. 마음에 안들지만 일단 pass?
엥 memberId가 안들어간다.

  • 직접 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() {
    }
}

3번 유저로 로그인
외래키가 잘들어감.
실제 쿼리는 member id만 넣어서 이렇게 나간다.

* 동적 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)을 이용해서 해결하고자 함.

post를 누가썻는지, 댓글과 누가썻는지가 나온다.

  • 쿼리 분석 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 문제 해결 여부:

  1. 쿼리 구조:
    • 두 개의 쿼리가 실행되고 있습니다.
    • 첫 번째 쿼리는 특정 Post ID를 선택합니다.
    • 두 번째 쿼리는 실제 데이터를 조회합니다.
  2. JOIN 사용:
    • posts, members, comment 테이블이 LEFT JOIN으로 연결되어 있습니다.
    • Post와 관련된 Member, Comment, 그리고 Comment의 작성자(Member)까지 한 번에 조회하고 있습니다.
  3. N+1 문제 해결:
    • 예, 이 쿼리는 N+1 문제를 해결했습니다.
    • 모든 관련 데이터(Post, Member, Comment, Comment의 Member)를 단일 쿼리로 가져오고 있어, 추가적인 쿼리 실행이 필요 없습니다.
  4. 성능 고려사항:
    • 단일 쿼리로 모든 데이터를 가져오므로 데이터베이스 호출 횟수가 줄어듭니다.
    • 그러나 LEFT JOIN을 많이 사용하여 쿼리가 복잡해졌으므로, 대량의 데이터를 다룰 때 성능 이슈가 발생할 수 있습니다.
  5. 최적화 가능성:
    • 필요한 필드만 선택하여 쿼리 결과의 크기를 줄일 수 있습니다.
    • 페이지네이션을 적용하여 한 번에 가져오는 데이터의 양을 제한할 수 있습니다.

결론: 이 쿼리는 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());

주요 차이점:

  1. 쿼리 실행 방식:
    • 일반 relations: 모든 관계를 포함한 하나의 큰 JOIN 쿼리를 실행합니다.
    • Batch size: 메인 엔티티(여기서는 Post)를 먼저 로드하고, 관련 엔티티(User, Comment)를 별도의 쿼리로 로드합니다.
  2. 성능:
    • 일반 relations: 데이터가 적을 때는 빠를 수 있지만, 데이터량이 많아지면 성능이 저하될 수 있습니다.
    • Batch size: 대량의 데이터를 처리할 때 더 효율적일 수 있으며, 메모리 사용량을 제어하기 쉽습니다.
  3. 쿼리 수:
    • 일반 relations: 단일 쿼리로 모든 데이터를 가져옵니다.
    • Batch size: 여러 개의 작은 쿼리로 나누어 실행합니다.
  4. 유연성:
    • 일반 relations: 간단하고 직관적이지만, 대량의 데이터에 대해 최적화하기 어려울 수 있습니다.
    • Batch size: 데이터 로딩을 더 세밀하게 제어할 수 있어, 대규모 데이터셋에 대해 더 유연합니다.
  5. 메모리 사용:
    • 일반 relations: 모든 데이터를 한 번에 메모리에 로드하므로 메모리 사용량이 높을 수 있습니다.
    • Batch size: 데이터를 청크 단위로 로드하므로 메모리 사용을 제어하기 쉽습니다.
  6. N+1 문제 해결:
    • 일반 relations: 단일 쿼리로 모든 것을 가져오므로 N+1 문제를 피할 수 있습니다.
    • Batch size: N+1 문제를 효과적으로 해결하면서도 성능을 최적화합니다.

사용 시나리오:

  • 일반 relations:
    • 데이터 양이 적을 때
    • 관계가 단순할 때
    • 빠른 개발이 필요할 때
  • Batch size:
    • 대량의 데이터를 다룰 때
    • 메모리 사용을 최적화해야 할 때
    • 복잡한 관계를 가진 엔티티를 다룰 때

결론:
일반 relations는 간단하고 직관적이지만, 대규모 데이터셋에서는 성능 문제가 발생할 수 있습니다. 반면에 batch size를 이용한 방법은 더 복잡하지만, 대규모 데이터셋에서 더 나은 성능과 메모리 효율성을 제공할 수 있습니다. 프로젝트의 요구사항과 데이터의 특성에 따라 적절한 방법을 선택해야 합니다.

  • 구체적인 예시를 들어달라고 요청.

네, 게시글에 댓글이 10,000개 있는 상황에서 일반 relations와 batch size 방법의 실행 과정을 비교해 드리겠습니다.

이제 두 방법의 실행 과정을 비교해 보겠습니다:

  1. 일반 relations 방법: 실행 과정:
    1. 단일 대형 SQL 쿼리가 생성됩니다. 이 쿼리는 게시글, 모든 댓글, 각 댓글의 사용자 정보를 한 번에 JOIN합니다.
    2. 데이터베이스는 이 대형 쿼리를 실행하여 10,000개의 댓글과 그에 해당하는 사용자 정보를 모두 포함한 결과를 반환합니다.
    3. ORM은 이 큰 결과 세트를 메모리에 로드하고 객체로 매핑합니다.
    장단점:
    • 장점: 단일 쿼리로 모든 데이터를 가져오므로 데이터베이스 왕복 횟수가 적습니다.
    • 단점:
      • 메모리 사용량이 매우 높습니다 (10,000개의 댓글 객체를 한 번에 생성).
      • 쿼리 실행 시간이 길어질 수 있습니다.
      • 데이터베이스 서버에 큰 부하를 줄 수 있습니다.
  2. Batch size를 이용한 방법: 실행 과정:
    1. 먼저 게시글 정보만 가져오는 작은 쿼리를 실행합니다.
    2. 그 다음, 댓글을 100개씩 가져오는 쿼리를 여러 번 실행합니다. (예: 100개씩 100번의 쿼리 실행)
    3. 각 배치마다 댓글과 관련 사용자 정보를 함께 가져옵니다.
    장단점:
    • 장점:
      • 메모리 사용량을 제어할 수 있습니다 (한 번에 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. 처리된 조회수 데이터 삭제