관리 메뉴

Mini

24. 10. 9. 개발일지 // redirectAttributes, 로그인이완료된후 원래페이지 이동, db, n+1 문제 본문

JS/boostCamp

24. 10. 9. 개발일지 // redirectAttributes, 로그인이완료된후 원래페이지 이동, db, n+1 문제

Mini_96 2024. 10. 10. 00:00

* 홈페이지(index) 구현

  • 로그인 안된경우, 아래와 같이 보여준다.

  • 로그인 된경우, 회원 닉네임, 멤버리스트, 마이페이지, 로그아웃, 검색어, 글쓰기 를 보여준다.

  • 방법1 : redirect주소에 쿼리파라미터로 dfjsfkljf?status=true 이런식으로 넘기기
    • 쿼리 파라미터를 템플릿엔진에서 접근해서 if(status) 이런식으로 구현
    • ejs는 지원하지 않는것같다. 타임리프에서는 가능
  • 방법2 : index는 현재 white list에 등록되있다. -> 현 사용자가 로그인 될수도있고 안될수도 있다. -> index 접근전에 사용자의 상태를 알아야 한다.
    • 음... 이왕 auth filter를 구현했으므로 이를 활용해보자
    • index를 블랙리스트에 넣고,
    • 요청이 index인경우 && 미인증 사용자인경우 -> 첫번째 사진을 렌더링한다.
    • 요청이 index인경우 && 인증 사용자인경우 -> 두번째 사진을 렌더링한다.
  • 방법3 : express 에서 했던 방식 모방
    • 홈 컨트롤러에서 쿠키를 가지고 맴버를 찾아도 되겠다.
    • 방법 3으로 진행!
    • 이럴려면 req, res를 지원하는 V6 Controller로 해야겠다.

  • 홈컨트롤러 V6
import {ControllerV6} from "../ControllerV6";
import {Request} from "../../../was/request";
import {Response} from "../../../was/response";
import {SessionManager} from "../../../utils/SessionManager";

export class HomeControllerV6 implements ControllerV6{

    private sessionMgr : SessionManager = SessionManager.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);

        /**
         * 로그인이 된경우, 동적 렌더링 필요
         */
        if(findMember){
            model.set("member",findMember);
            return "index";
        }
        return "index";
    }

    version6() {
    }
}
  • ejs
    • 로그인된경우, 닉네임출력
    • 안된경우 GUEST 출력
<section class="hero-box">
    <p class="hero-subtitle">부스트캠프 백엔드 교육용 페이지
    <h1 class="hero-title">HELLO.
        <% if (typeof member !== 'undefined' && member !== null) { %>
            <%= member.nickname %>
        <% } else { %>
            GUEST
        <% } %>
        !</h1>
    </p>
</section>
  • 로그인된경우, 멤버리스트, 마이페이지, 로그아웃 버튼
<nav class="navbar">
    <a href="/public">HELLO. WEB!</a>
    <ul class="nav-item-box">
        <% if (typeof member !== 'undefined' && member !== null) { %>
            <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>
        <% } else { %>
            <li>
                <button onclick="location.href='/user/login/form'" class="button BoldS">
                    <p>로그인/회원가입</p>
                </button>
            </li>
        <% } %>
    </ul>
</nav>

로그인 된경우
로그인 안된경우

* 로그인하지 않은 상태에서 로그인이 필요한 페이지 접근시 로그인이 완료된 후 원래 페이지로 이동할 수 있도록 구현한다.

  • 먼저 authFilter에서 사용자가 요청한 url 를 같이 넘겨준다.

  • .그결과 path parameter에 들어간다.

  • 이제 로그인 컨트롤러에서 이를 파싱하여 사용하면된다!!
  • 먼저, url을 쿼리파라미터로 보내면 req객체에서 어떻게 처리하는지 살펴보자.
  • query안에 객체로 저장되어있다.

  • 그런데 내코드는 login-form.html에서 form action으로 로그인 버튼을 누르면 보낼 요청을 제어한다.
  • 따라서 이를 동적으로 바꿔야한다.
  • 로그인 폼 컨트롤러에서 모델에 redirectURL을 담아서 ejs로 넘긴다.
import {ControllerV6} from "../ControllerV6";
import {Request} from "../../../was/request";
import {Response} from "../../../was/response";
import {SessionManager} from "../../../utils/SessionManager";

export class LoginFormControllerV6 implements ControllerV6{

    async process(req: Request, res: Response, paramMap: Map<string, string>, model: Map<string, object>) {
        const redirectURL = req.query['redirectURL'];
        model.set("redirectURL", {redirectURL : redirectURL});
        return "login-form";
    }

    version6() {
    }
}
  • ejs에서는 로그인 버튼을 누르면 보낼 요청(url)을 분기한다.
<% if (typeof redirectURL !== 'undefined' && redirectURL !== null) { %>
    <form action="//localhost:3000/user/login?redirectURL=<%= redirectURL.redirectURL %>" method="POST">
<% } else { %>
    <form action="//localhost:3000/user/login" method="POST">
<% } %>
  • login 요청을 처리하는 컨트롤러에서도 redirect 쿼리 파라미터가 있는지에 따라 분기한다.

  • flow chart
    • user/list 요청 (사용자)
    • 미인증사용자이므로 user/login/form?redicrtURL=user/list로 재요청 (AuthFilter)
    • 쿼리파라미터를 같이 ejs로 보냄 (로그인폼 컨트롤러)
    • 사용자가 아이디, 비번 입력후 로그인 버튼 누르면 /user/login?redirectURL=user/list로 재요청 (ejs form)
    • req에 쿼리파라미터가 존재 -> redirect:user/list (로그인 컨트롤러)
    • user/list 렌더링 (UserList 컨트롤러)

1. 미인증 사용자가 list에 접근시 redirectURL을 넘겨줌 by auth filter
2. 로그인 성공후 원래 사용자가 원하던 list 페이지로 리다이렉션.
로그

* 프론트 컨트롤러 redirect fix

  • 기존 : redirect인경우, 302로 응답을 보내고 끝내야하는데, return 문이 없어 그대로 진행되어 bug 발생.
  • 버그 : user/list를 그대로 요청 -> model에 member가 없음 -> ejs 에러
if(viewName.startsWith("redirect:")){
    const [_, temp ] = viewName.split(":");
    viewName = temp;
    view = this.viewResolver(viewName);
    // res.status(302).header("Location","http://localhost:3000/" + viewName);
    res.status(302).redirect(viewName);
    req.isEnd=true;
    return;
}
  • 해결 : 프론트 컨트롤러에서 리다이렉션시 302응답후 종료. -> 자동으로 브라우저에서 새 요청을 보내준다.

* 쿠키가 없는경우 예외처리

  • 로그인 작업중 버그발견
  • 쿠키가 없는경우 undefiend에 split 해서 서버가 터짐방지.

* 회원가입후 구현

  • user save controller에서 redirect를 해당 view로 해주면 될듯함.
  • model을 set 해준후에.
  • 그런데 redirect 문제가 생겼다.
    • 이전코드에서 프론트 컨트롤러가 redirect후에 응답을 끝낸후 브라우저에서 새 요청을 보내도록 수정하였다.
    • 그런데, save controller에서 바로 저장된 member를 set후 바로 렌더링 하는게 효율이 좋다고 생각되었다.
    • 새로운 요청을 보낸다면, after-register url 컨트롤러에서는 방금 저장된 회원이 누군지 알 수 없다!

  • 따라서 프론트 컨트롤러를 rollback 하였다.

  • 그리고 login 후 model도 set 해주고 ejs로 넘겼다.
  • 이러면 일단 user/list는 잘 렌더링된다.
  • 좋은 구조인지는 의문이다.
  • 이러면 앞으로 login후 게시글을 보여줘야한다면, 게시글도 find해서 model에 set 해줘야 할듯하다..

  • 그렇다면 회원저장후 쿼리파라미터로 방금 생성된 회원의 id만 넘기면 어떤가? -> 시도해보자!
  • 우선, paramMap은 body의 정보만 담고있다. -> 따라서 req에 접근가능한 V6가 필요. (paramMap에서 쿼리스트링도 파싱하도록 개선필요)
  • 회원가입버튼 클릭시 user/save로 이동하므로 이쪽 컨트롤러를 만져야겠다.
    • 방금 저장된 회원의 id를 쿼리스트링으로 넘긴후 리다이렉트 시킨다.

  • 그럼 url이 이런형태가 되고

  • after 컨트롤러에서 id를 가지고 멤버를찾은후, 동적 렌더링을 하면된다.
import {ControllerV6} from "../ControllerV6";
import {Request} from "../../../was/request";
import {Response} from "../../../was/response";
import {MemberRepository} from "../../../domain/member/MemberRepository";

export class UserSaveAfterControllerV6 implements ControllerV6{
    private memberRepository: MemberRepository = MemberRepository.getInstance();

    async process(req: Request, res: Response, paramMap: Map<string, string>, model: Map<string, object>) {
        const id = req.query['id'];

        const findMember = await this.memberRepository.findById(+id);
        model.set("member", findMember);
        return "login-ok";
    }

    version6() {
    }
}

결과

  • 그런데 이방식을 취약점이 있다. 바로 id 에 1,2,3등 값을 넣으면 내가 아닌 회원의 정보를 얻을수 있다.

* 쿼리스트링 취약점 해결 시도

  • 쿼리스트링의 id와 현재 로그인된 멤버의 id가 다르면 error page로 이동시킨다.

  • 그런데 회원가입을 한것이지 로그인을 한것이 아니다 -> 세션에 값이 없다. -> 못찾는다 (currentMember가 없음)
  • 음.. 클라이언트 사이드 렌더링으로 해결해야할듯? -> 나중에...

* 로그인 url bug

  • 로그인을 누르면 뒤에 빈 쿼리스트링이 생기는 버그가 있다.

  • login-form.ejs 수정
    • {redirectURL : redirectURL} 객체로 넘기다보니, key만 존재하고 값은 빈경우에 error가 터졌다.
<% if (typeof redirectURL !== 'undefined' && redirectURL !== null && typeof redirectURL.redirectURL !== 'undefined' && redirectURL.redirectURL !== null) { %>
    <form action="//localhost:3000/user/login?redirectURL=<%= redirectURL.redirectURL %>" method="POST">
<% } else { %>
    <form action="//localhost:3000/user/login" method="POST">
<% } %>
  • redirect path 수정
    • 디버깅도중에 path가 3000/path 이런경우와
    • 3000path 붙여서 온경우가 섞여있었다.
    • 이에 대한 임시 해결책
public redirect(path: string) {
    if(!path.startsWith('/')){
        path = '/'+path;
    }
    this.header("Location","http://localhost:3000" + path);
    this.send();
}

* 로그아웃 구현

  • 컨트롤러만 구현을 잘하면된다.
  • 세션매니저에 req를 넘기면, 쿠키의 value에 해당하는 member를 지운다.
import {ControllerV6} from "../ControllerV6";
import {Request} from "../../../was/request";
import {Response} from "../../../was/response";
import {SessionManager} from "../../../utils/SessionManager";

export class LogOutControllerV6 implements ControllerV6{

    private sessionMgr : SessionManager = SessionManager.getInstance();

    async process(req: Request, res: Response, paramMap: Map<string, string>, model: Map<string, object>) {
        this.sessionMgr.expire(req);
        return "index";
    }

    version6() {
    }
}

* 게시글 , 댓글 테이블

  • 사용자는 게시글 여러개를 가진다.
  • 사용자는 댓글 여러개를 가진다.
  • 게시글은 댓글 여러개를 가진다.

@Entity("members")
export class Member {

    @PrimaryGeneratedColumn()
    id: number; // DB의 AutoIncrement Id

    @Column()
    nickname: string;

    @Column()
    password: string;

    @Column({ unique: true })
    email: string;

    @CreateDateColumn() //밀리초 저장안함.
    createdAt: Date;

    @UpdateDateColumn()
    updatedAt: Date;

    @OneToMany(() => Post, post => post.member)
    posts: Post[];
import {Entity, PrimaryGeneratedColumn, Column, UpdateDateColumn, CreateDateColumn, ManyToOne} from "typeorm";
import {Member} from "../member/Member";
import {Post} from "../post/Post";

@Entity()
export class Comment {
    @PrimaryGeneratedColumn()
    id: number;

    @Column("text")
    content: string;

    @CreateDateColumn()
    createdAt: Date;

    @UpdateDateColumn()
    updatedAt: Date;

    @ManyToOne(() => Member, member => member.posts)
    member: Member;

    @ManyToOne(() => Post, post => post.comments)
    post: Post;
}
import {
    Entity,
    PrimaryGeneratedColumn,
    Column,
    UpdateDateColumn,
    CreateDateColumn,
    ManyToOne,
    OneToMany
} from "typeorm";
import {Member} from "../member/Member";
import {Comment} from "../comment/Comment";

@Entity("posts")
export class Post {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    title: string;

    @Column("text")
    content: string;

    @CreateDateColumn()
    createdAt: Date;

    @UpdateDateColumn()
    updatedAt: Date;

    @ManyToOne(() => Member, member => member.posts)
    member: Member;

    @OneToMany(() => Comment, comment => comment.post)
    comments: Comment[];
}

여기도 엔티티 추가 해줘야함
N 쪽에 외래키가 잘 생성된 모습.

  • 그런데 유저 , 게시글, 댓글이 1: n: m 관계여서 1+ n*m의 쿼리가 나가는 문제가 있을수 있겠다는 생각이 들었다. 

 

네, 좋은 질문입니다. 1:N:N 관계에서 유저 1명을 조회할 때 N^2개의 쿼리가 나가는 문제는 실제로 발생할 수 있는 중요한 성능 이슈입니다. 이를 "N+1 문제"의 확장된 형태로 볼 수 있습니다.

먼저, 문제 상황을 설명하겠습니다:

  1. 유저 1명을 조회하는 쿼리 1개
  2. 해당 유저의 게시글을 조회하는 쿼리 N개
  3. 각 게시글의 댓글을 조회하는 쿼리 N*M개 (M은 각 게시글의 댓글 수)

따라서 총 1 + N + N*M 개의 쿼리가 실행될 수 있습니다.

이 문제를 시각화해보겠습니다:

sequenceDiagram
    participant A as Application
    participant D as Database
    A->>D: 유저 조회 (1 쿼리)
    D-->>A: 유저 데이터 반환
    loop 각 게시글에 대해 (N번)
        A->>D: 게시글 조회 (N 쿼리)
        D-->>A: 게시글 데이터 반환
        loop 각 댓글에 대해 (M번)
            A->>D: 댓글 조회 (N*M 쿼리)
            D-->>A: 댓글 데이터 반환
        end
    end

이 문제를 해결하기 위한 주요 방법은 다음과 같습니다:

  1. Eager Loading (Join 사용):
    TypeORM에서는 relations 옵션이나 쿼리 빌더를 사용하여 한 번의 쿼리로 관련 데이터를 모두 가져올 수 있습니다.
async getUserWithPostsAndComments(userId: number): Promise<User> {
  return this.userRepository.findOne({
    where: { id: userId },
    relations: ['posts', 'posts.comments']
  });
}
  1. Query Builder 사용:
    더 복잡한 조인이 필요한 경우, 쿼리 빌더를 사용하여 세밀한 제어가 가능합니다.
async getUserWithPostsAndComments(userId: number): Promise<User> {
  return this.userRepository.createQueryBuilder('user')
    .leftJoinAndSelect('user.posts', 'post')
    .leftJoinAndSelect('post.comments', 'comment')
    .where('user.id = :userId', { userId })
    .getOne();
}
  1. DataLoader 사용:
    GraphQL 환경에서는 DataLoader를 사용하여 N+1 문제를 효과적으로 해결할 수 있습니다.
import DataLoader from 'dataloader';

const postLoader = new DataLoader(async (userIds: number[]) => {
  const posts = await postRepository.find({
    where: { userId: In(userIds) }
  });
  const postsByUserId = groupBy(posts, 'userId');
  return userIds.map(userId => postsByUserId[userId] || []);
});

// 사용 예
const user = await userRepository.findOne(userId);
const posts = await postLoader.load(user.id);
  1. 페이지네이션 적용:
    모든 데이터를 한 번에 가져오는 대신, 필요한 만큼만 가져오도록 페이지네이션을 적용할 수 있습니다.
async getUserWithPaginatedPostsAndComments(userId: number, page: number, limit: number): Promise<User> {
  return this.userRepository.createQueryBuilder('user')
    .leftJoinAndSelect('user.posts', 'post')
    .leftJoinAndSelect('post.comments', 'comment')
    .where('user.id = :userId', { userId })
    .skip((page - 1) * limit)
    .take(limit)
    .getOne();
}

Best Practices:

  1. 필요한 데이터만 조회: 항상 모든 관계를 로드하는 것이 아니라, 필요한 데이터만 선택적으로 로드하세요.
  2. 쿼리 최적화: EXPLAIN 명령어를 사용하여 실행 계획을 분석하고 인덱스를 적절히 사용하세요.
  3. 캐싱 전략: 자주 조회되는 데이터는 Redis와 같은 캐시 시스템을 활용하세요.
  4. 비동기 로딩: 일부 데이터는 비동기적으로 로드하여 초기 로딩 시간을 줄일 수 있습니다.
  5. 모니터링: 실제 운영 환경에서 쿼리 성능을 지속적으로 모니터링하고 최적화하세요.

이러한 방법들을 적절히 조합하여 사용하면, 1:N:N 관계에서 발생할 수 있는 N^2 쿼리 문제를 효과적으로 해결할 수 있습니다. 특정 상황에 대한 추가 질문이나 더 자세한 설명이 필요하시다면 말씀해 주세요.

* index에 로그인된경우 , 글쓰기 버튼 만들기

<nav class="navbar">
    <a ><1, 2, 3, 4, 5></a>
    <ul class="nav-item-box">
    <% if (typeof member !== 'undefined' && member !== null) { %>
        <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>
    <% } else { %>
    <% } %>
    </ul>
</nav>