* 홈페이지(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 컨트롤러)
* 프론트 컨트롤러 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[];
}
- 그런데 유저 , 게시글, 댓글이 1: n: m 관계여서 1+ n*m의 쿼리가 나가는 문제가 있을수 있겠다는 생각이 들었다.
네, 좋은 질문입니다. 1:N:N 관계에서 유저 1명을 조회할 때 N^2개의 쿼리가 나가는 문제는 실제로 발생할 수 있는 중요한 성능 이슈입니다. 이를 "N+1 문제"의 확장된 형태로 볼 수 있습니다.
먼저, 문제 상황을 설명하겠습니다:
- 유저 1명을 조회하는 쿼리 1개
- 해당 유저의 게시글을 조회하는 쿼리 N개
- 각 게시글의 댓글을 조회하는 쿼리 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
이 문제를 해결하기 위한 주요 방법은 다음과 같습니다:
- Eager Loading (Join 사용):
TypeORM에서는relations
옵션이나 쿼리 빌더를 사용하여 한 번의 쿼리로 관련 데이터를 모두 가져올 수 있습니다.
async getUserWithPostsAndComments(userId: number): Promise<User> {
return this.userRepository.findOne({
where: { id: userId },
relations: ['posts', 'posts.comments']
});
}
- 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();
}
- 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);
- 페이지네이션 적용:
모든 데이터를 한 번에 가져오는 대신, 필요한 만큼만 가져오도록 페이지네이션을 적용할 수 있습니다.
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:
- 필요한 데이터만 조회: 항상 모든 관계를 로드하는 것이 아니라, 필요한 데이터만 선택적으로 로드하세요.
- 쿼리 최적화: EXPLAIN 명령어를 사용하여 실행 계획을 분석하고 인덱스를 적절히 사용하세요.
- 캐싱 전략: 자주 조회되는 데이터는 Redis와 같은 캐시 시스템을 활용하세요.
- 비동기 로딩: 일부 데이터는 비동기적으로 로드하여 초기 로딩 시간을 줄일 수 있습니다.
- 모니터링: 실제 운영 환경에서 쿼리 성능을 지속적으로 모니터링하고 최적화하세요.
이러한 방법들을 적절히 조합하여 사용하면, 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>
'JS > boostCamp' 카테고리의 다른 글
24.10.11. 개발일지 // 배포, redis, 댓글, 조회수증가, 페이징, dns, 에러페이지 (3) | 2024.10.14 |
---|---|
24. 10. 10. 개발일지 // 글쓰기 구현, 동적 url 매핑 , n+1 문제 (0) | 2024.10.10 |
24.10.8. 개발일지 // 쿠키파서, db연결 , typeorm (0) | 2024.10.08 |
24.10.5~7. 개발일지 // 회원가입구현, redirect, cookie, session, 인증 (0) | 2024.10.07 |
24. 10. 4. 개발일지 // 정적서빙버그 fix (2) | 2024.10.05 |