
우리가 회원가입을 하려고 할 때 거쳐야 할 단계를 먼저 생각해보려고 한다.
1 . username이 이미 존재하는지 확인
2 . email이 이미 사용중인지를 확인
3 . 이 두검사가 false로 통과한다면 -> 비밀번호를 hashing
4 . 마지막으로 사용자를 데이터베이스에 저장
5 . 사용자가 데이터베이스에 저장된다면 -> 사용자를 로그인을 시켜주고 -> redirect('/home')을 진행시킬 것이다.
username이 존재하는지를 확인하기 위해 cheakUniqueUsername() 함수를 생성한 후, formSchema에 refine()을 통해서 validation을 실행한다.
db에 저장된 user에서 findUnique()를 이용해서 username을 찾고, id만 데이터베이스에서 가져온다.
checkUniqueUsername() 함수는 주어진 username이 데이터베이스에 존재하는지 확인하게 된다. db.user.findUnique()를 이용하여 데이터베이스에서 username이 있는 사용자를 찾는다.
select: { id: true }를 사용하여, 해당 사용자의 ID만 가져오게 된다.
여기서, user가 있다면 -> false를 반환, user가 없다면 -> true를 반환해서 통과하게 된다.
이를 refine 메서드를 사용해 username이 데이터베이스에서 유니크한지를 검사하는 커스텀 유효성 검사를 추가한다.
checkUniqueUsername 함수는 주어진 username이 이미 존재하는 경우 false를 반환하며, 그럴 경우 'This username is already taken' 이라는 오류 메시지를 사용자에게 표시한다.
현재 ,
username이 seoyeon이라는 데이터가 존재할 때, seoyeon이라는 이름으로 회원가입을 진행한다면 ?
이렇게 내가 설정한 오류 메시지가 출력되는 것을 확인할 수 있다.
이메일도 데이터베이스에 존재하는지 확인하는 checkUniqueEmail() 함수를 생성해보려고 한다.
const checkUniqueEmail = async (email: string) => {
const user = await db.user.findUnique({
where: {
email,
},
select: {
id: true,
},
});
if (user) {
false;
} else {
true;
}
};

checkUniqueEmail() 함수 또한 checkUniqueUsername()과 비슷하기 때문에 함수에 대한 설명은 생략하도록 하겠다.
저장된 이메일을 입력하고, 회원가입을 시도하려고 한다면,
이렇게 내가 설정한 오류 메시지를 출력해주는 것을 확인할 수 있다.
여기서 중요한 것은, 지금 checkUniqueEmail, checkUniqueUsername 두개 모두 async, await을 사용해서 함수를 정의하고 있다.
왜 async와 await을 사용해야 하는지?
checkUniqueEmail과 checkUniqueUsername 함수는 데이터베이스와 상호작용하는 작업을 수행한다.
데이터베이스 조회는 네트워크 지연 등으로 인해 시간이 걸릴 수 있으므로 비동기 작업으로 처리하는 것이 일반적이다.
async와 await을 사용하면 코드가 동기적으로 실행되는 것처럼 보이지만, 실제로는 비동기적으로 동작하여 코드의 가독성과 유지보수성을 높일 수 있다.
그럼, checkUniqueEmail를 사용하는 refine()안에서도 await을 사용해야 하는거 아닐까? -->
refine() 안에서 checkUniqueEmail과 같은 비동기 함수를 호출하게 되면, 해당 검사가 완료될 때까지 기다려야 한다.
이를 위해서는 await을 사용해야 하지만, await은 비동기 함수(async) 안에서만 사용할 수 있다.
--> 이를 위해서는 safeParseAsync()를 사용하면 된다!
safeParseAsync(data)는 비동기적으로 validation을 수행하며, 비동기 작업이 완료될 때까지 기다리게 된다.
무슨 소리인가 하면,
refine()함수를 사용하는 formSchema를 우리는 safeParse(data)를 통해서 validation를 진행한다. 여기서 safeParse대신, safeParseAsync를 사용하게 되면,
formSchema에서 이루어지는 모든 validation 작업들이 비동기 적으로 이루어지게 된다.

이렇게 await formSchema.safeParseAsyne(data) 를 사용하게 되면, formSchema 내에서 정의된 모든 유효성 검사를 비동기적으로 수행할 수 있게 된다.
해싱은 기본적으로 유저가 보낸 비밀번호를 변환하는 것을 의미한다.
사용자가 보낸 비밀번호를 -> 해시 함수를 실행 -> 무작위로 보이는 문자열을 받게 됨 -> 그 무작위 문자열을 데이터베이스에 저장
하는 과정으로 이루어진다고 생각해주면 된다.
예를 들면, asd123! 비밀번호를 입력했는데, 데이터베이스에 그대로 저장이 된다면, 혹시 해킹이 되거나 하면 개인정보가 다 털리는 것이기 때문에
사용자가 asd123!로 비밀번호를 입력한다면, 우리는 해시 함수를 통해서 -> slkdfjasgjdg!dghksd와 같은 무작위의 문자열을 데이터베이스에 비밀번호로 저장하게 된다.
npm i bcrypt
npm i @types/bcrypt
로 설칠르 진행한 후, 
const hashedPassword = await bcrypt.hash(result.data.password, 12)
-> result.data.password의 값을 12번 알고리즘을 실행해서 hash한다 를 의미한다
12 -> saltRounds는 해시 생성 과정에서 추가적인 복잡도를 더하는 숫자로, 이 숫자가 클수록 해시 생성에 더 많은 시간이 걸리고, 그만큼 보안성이 높아진다.
result.data.password에 있는 비밀번호를 12번의 해시 알고리즘 복잡도(saltRounds)로 해시화하여 hashedPassword에 저장하는 과정이다.
이렇게 해시화된 비밀번호는 원본 비밀번호를 유추할 수 없게 하며, 보안성 있는 저장을 위해 사용된다.
위에 두개는 hashPassword를 거치지 않았고, 마지막은 hashPassword를 거쳤다.
저장되는 것을 살펴보면, 해시화되어서 저장된 것을 확인할 수 있다.
이제, 비밀번호 해시화도 끝났으니, 데이터를 저장해보도록 하겠다!
const user = await db.user.create({
data: {
username: result.data.username,
email: result.data.email,
password: hashedPassword,
},
select: {
id: true,
},
});
기본적으로 Prisma에서 create나 findUnique를 하게 되면, 모든 User의 정보를 넘겨주게 된다.
--> 하지만 그 모든 데이터는 필요가 없기 때문에 최소한의, 필요한 데이터만 가져와야 한다. 이때 select : {}를 사용해서 필요한 데이터만 가져올 수 있어야 한다!
이렇게 select : { id : true } 속성을 지정해 주었더니

사용자를 로그인 시킨다는 것은 뭘까 ? -> 사용자에게 쿠키를 준다는 것을 의미함(쿠키에 사용자 id가 포함되게 된다) -> id를 그대로 { id:8 } 로 저장하지 않고, 무작위로 text를 변경해서 저장을 도와주게 된다.
먼저 iron-session을 설치해보도록 하겠다
npm i iron-session
iron-session은 세션 데이터를 암호화하여 쿠키에 저장한다.
일반적으로 세션 데이터는 서버에 저장하고, 클라이언트에는 세션 ID만 쿠키에 저장하는 방식이지만, iron-session은 데이터를 안전하게 클라이언트에 저장할 수 있게 해준다.
--> 이는 서버에 별도의 세션 저장소를 필요로 하지 않으면서도, 중요한 데이터를 안전하게 다룰 수 있게 해준다.
iron-session을 사용하면, 사용자의 ID와 같은 세션 데이터를 서버에 저장하는 대신, 암호화된 형태로 클라이언트의 쿠키에 직접 저장할 수 있다
const cookie = await getIronSession(cookies(), {
cookieName: 'delicious -carrot',
password: process.env.COOKIE_PASSWORD!,
});
//@ts-expect-error
cookie.id = user.id;
await cookie.save();
redirect('/profile');
getIronSession(cookies(), { ... }):
! 는 TypeScript에서 해당 값이 null이나 undefined가 아님을 보장하기 위함이다.cookie.id = user.id; :
cookie.save() :
iron-session 정리를 잘 해둔 블로그를 발견했다. 이해하기 어렵다면 이 블로그 한번 들어가보는 것을 추천한다!
iron session이 nextjs로 부터 오는 쿠키를 받아서 delicious-carrot 이라는 쿠키가 존재하는지 검사를 함 --> 만약 존재하지 않으면 새로 만들고, 존재한다면 이 암호화를 사용해 복호화를 하게 된다.
그 쿠키의 내용을 수정한 뒤 저장하면, 내용을 암호화하는 데에도 같은 비밀번호를 사용하게 된다. --> 유저는 쿠키를 수정할 수 없고, 쿠키에 무슨 내용이 있는지도 알 수 없게 된다.

또한 타입스크립트에게 우리의 쿠키가 어떻게 생겼는지 알려주어야 한다.
interface SessionContan로 id가 number 형태임을 알려주었지만, id는 로그인 한 사용자만 가지고 있기 때문에, 쿠키가 있을수도 없을 수도 있기 때문에 ? 를 붙여주었다.
이제 로그인을 처리해보도록 하겠다.
로그인을 하기 위해서는
1 . 사용자가 입력한 이메일이 맞는지 확인하기
2 . 이메일이 맞다면, 입력한 비밀번호의 해시값이 일치하는지 확인
3 . 일치한다면, redirect('/profile')로 이동시킬 것임
이런 식으로 이루어진다 !
1. 사용자가 입력한 이메일이 데이터베이스에 있는지 확인하기

사용자가 입력한 이메일을 검증을 한 후, checkEmailExists()함수에 넣어서 db.user에 저장되어있는 email이 있는지 확인한 후, 있다면 true, 없다면 false를 반환하면서 에러메시지를 출력하게 된다.
데이터베이스에서 조회하거나 찾게 되면, 데이터베이스에 저장된 모든 것을 출력하게 되는데, 불필요한 데이터를 제외하고, select : { id :true } 를 통해서 id만들 반환하고 있다.
2 . 이메일이 맞다면, 입력한 비밀번호의 해시값이 일치하는지 확인하기

먼저, 이메일이 존재한다면, 비밀번호의 해시값이 일치하는지를 확인하기 때문에
where: { email: result.data.email }
이게 일치한다면, id와 password를 select를 통해서 가져온다.
그리고 가져온 값을 bcrypt.compare() 를 통해서 비교한다.
result.data.password = 사용자가 입력한 password와
user!.password = 데이터베이스에 해시화된 password를 비교해서 일치한지 확인한다.
bcrypt.compare(result.data.password, user!.password)를 호출하면, bcrypt 라이브러리가 사용자가 입력한 비밀번호를 해시화하여 데이터베이스에 저장된 해시화된 비밀번호와 비교합니다.
만약, 일치한다면-> ok (true) -> 로그인을 진행하기 위해서 getSession()을 가져온다. 그리고 session.id에 user.id를 저장한다.
그리고 redirect('/profile')를 통해 페이지를 이동시킨다.
만약 일치하지 않는다면, fieldErrors 객체를 반환
--> 이 객체는 password 필드에 "Wrong password"라는 에러 메시지를 포함하고 있다.
--> email 필드의 오류는 없으므로 빈 배열로 설정

이미 존재하는 username과 이미 존재하는 email을 입력하고, password또한 두개를 다르게 입력해 보았다.
그랬더니, 오류 메시지가 username, email, password 모두 생성되는 것을 확인할 수 있었다.
만약, username의 필드가 유효하지 않다면, 나머지 email, password의 유효성 검사를 하지 않아도 되지 않을까?
이미 username이 유효하지 않는데, email검사를 하기 위해서 데이터 베이스가 또 호출되고 있기 때문이다
(즉, username을 검증하기 위해서 1번, email를 검증하기 위해서 1번 총 2번 호출되고 있다)
username이 유효하지 않다면 나머지 email, password의 검증을 막아줄 수 있는게 필요하다
--> superRefine 메서드를 활용하면 됌

.superRefine()은 formSchema .object()의 검증이 끝난 후 진행이 된다.
먼저, superRefine()은 두가지 인수를 받는다
{ username } 는 입력 데이터에서 특정 필드(여기서는 username)만을 추출한 객체이다.사용자 이름 중복 검증이 끝난 후,
문제 기록 (중복된 사용자 이름) if(user) {}~
if (user) 조건문은 해당 username이 이미 존재하는지 확인한다.
ctx.addIssue: 이 메서드는 검증 컨텍스트에 문제(error) 를 추가하는 역할을 한다.
code: 'custom' 은 사용자 정의 검증 오류임을 나타내며, message는 에러 메시지를 설정한다. 여기서는 "This username is already taken"이라는 메시지가 사용자에게 표시된다. ..superRefine()은 formSchema 전체에서 사용하고 있기 때문에 에러 발생시 formError가 발생하게 된다. 에러메시지가 어디에 출력이 될 것인지 지정을 해주어야 한다. fatal: true는 이 오류가 치명적임을 나타내며, 다른 검증을 진행하지 않고 즉시 검증을 종료z.NEVER를 사용하여, 검증이 실패했음을 명확히 표시근데
fatal:true를 사용하면, 이미 검증이 종료되는데z.NEVER를 또 사용하는 이유가 뭐야?
정말 간단하게 설명하자면,
즉 username에서 검증이 실패했을 경우, email과 password의 검증이 실행되지 않는다.
실제로 검증이 이루어지지 않는 것을 확인할 수 있다.
username이 validation에 통과한다면?
요렇게 email검증으로 넘어갈 수 있게 된다!
로그인 성공 -> /profile 로 이동한 후
1 . session에서 id를 가져 DB에 저장되어있는 id와 비교 후, user에 대한 정보를 화면에 출력해보기
2 . logout 버튼 생성해보기
이렇게 두가지를 진행해볼 예정이다.
1 . session에서 id를 가져와 DD에 저장되어있는 id와 비교

getSession() : 이 함수는 현재 요청의 세션 정보를 가져온다.
이 세션에는 로그인한 사용자의 id가 포함되어 있을 수 있다.
session.id: 세션에서 가져온 사용자 id가 존재하는지 확인한다.
db.user.findUnique({ where: { id: session.id } }): 데이터베이스에서 session.id와 같은 id를 가진 사용자를 조회한다.
if (user) : 데이터베이스에서 사용자 정보가 존재하면 user 객체를 반환한다.
notFound() : 데이터베이스에서 사용자를 찾지 못하면 notFound를 호출하여 페이지를 찾을 수 없다는 응답을 보낸다. 일반적으로 404 페이지로 리다이렉트한다.
그럼 쿠키와 함께 화면에 {user.username}이 출력되는 것을 확인할 수 있다.
2 . logout 버튼 생성하기

logOut 함수는 사용자 로그아웃을 처리하는 비동기 함수
-> 이 함수는 사용자가 로그아웃 버튼을 클릭할 때 호출되며, 세션을 종료하고 사용자를 특정 페이지로 리다이렉트한다.
session.destroy() : 세션을 종료한다. 이 메서드는 현재 세션을 무효화하여 사용자 로그아웃을 처리한다. 세션 데이터는 삭제되며, 더 이상 유효하지 않게 된다.로그아웃 버튼: <form action={logOut}> 은 logOut 함수를 form의 action으로 설정한다.
사용자가 이 버튼을 클릭하면, logOut 함수가 호출되어 로그아웃 프로세스가 실행된다.
왜 logOut() 함수를 form의 action에 전달했는지?
action 속성에 함수를 설정하면, 폼이 제출될 때 해당 함수가 호출된다. 이 방식은 함수가 서버에서 실행되도록 하며, 클라이언트 측에서는 서버 측 로직을 호출하게 된다.
logOut 함수에 'use server' 지시어를 추가함으로써, 이 함수가 서버 측에서 실행된다는 것을 명시한다. 이 지시어는 서버 측 환경에서만 유효하며, 클라이언트 측에서는 처리되지 않는다.
결론은 logout을 하기 위해서 session을 destory를 해야하는데, 이러한 작업은 server에서 이루어기 때문에 'use server'를 작성해둔거고,
inline use server를 사용하기 위해서 form에 logOut()함수를 사용한 것이다. 라고 이해하면 될 것 같다!
logOut 함수를 클라이언트 컴포넌트에서 처리하려고 하면 세션 관리와 같은 서버 측 기능을 수행할 수 없기 때문에 use server를 사용하여 이를 명시적으로 서버에서 실행하도록 한 것이다. 클라이언트 컴포넌트로 변경하면 이러한 서버 측 기능이 동작하지 않으므로, 현재의 구조가 적절하게 된다.
따라서, 로그아웃 처리를 use server로 별도로 처리한 이유는 서버에서만 가능한 세션 파괴 작업을 안전하게 처리하기 위함이다. use client를 사용하면 클라이언트에서만 가능한 작업을 수행할 수 있지만, 이 경우 서버 측 세션 관리 기능을 잃게 된다.
미들웨어(Middleware)란?
미들웨어는 소프트웨어 개발에서 요청(Request)과 응답(Response) 사이에 위치하여, 요청이 처리되는 과정에서 특정 작업을 수행하거나, 응답이 반환되기 전에 추가적인 처리를 할 수 있도록 도와주는 소프트웨어 컴포넌트이다.
Next.js에서의 미들웨어(Middleware)
Next.js에서의 미들웨어는 HTTP 요청이 실제 페이지로 전달되기 전에 실행되는 코드로, 각 요청에 대해 추가적인 작업을 수행할 수 있다.
예를 들어, 인증, 리다이렉션, 로깅, 캐싱 등을 미들웨어를 통해 처리할 수 있다.
Next.js의 미들웨어 사용 방법
Next.js에서 미들웨어를 사용하려면, 프로젝트의 루트 디렉토리 또는 특정 경로에 middleware.ts 또는 middleware.js 파일을 생성한다.
--> 이 파일은 Next.js가 자동으로 감지하여 모든 요청에 대해 미들웨어로 실행됩니다.
만약 사용자가 /profile 페이지 로딩을 요청 -> <Profile/>로딩을 응답하기 전
사이에서 /profile ------> middleware ------> <Profile/> 가 이루어지는 것이라고 생각하면 될 것이다.
import { error } from 'console';
import { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname === '/profile') {
return Response.redirect(new URL('/', request.url));
}
}
export default function middleware(request: NextRequest) :
이 함수는 미들웨어로 사용된다. Next.js는 이 함수를 모든 요청에 대해 실행한다.
request 매개변수는 NextRequest 타입의 객체로, 클라이언트로부터 들어온 HTTP 요청을 나타낸다.
if (request.nextUrl.pathname === '/profile') :
이 조건문은 요청된 URL의 경로(pathname)가 /profile인지 확인한다.
request.nextUrl은 요청된 URL을 나타내며, pathname은 그 URL의 경로 부분 (/profile 등) 을 의미한다.
Response.redirect(new URL('/', request.url)) :
Response.redirect() 는 클라이언트를 다른 URL로 리다이렉트하는 응답을 생성한다.
new URL('/', request.url) 는 요청된 URL의 호스트를 유지한 채, 경로를 /로 변경한 새로운 URL을 생성한다. --> 즉, /profile에서 /로 리다이렉트한다.
middleware가 request를 가로채서 profile 페이지로 가려는 request를 완전히 중단시키게 된다. 대신에 response.redirect를 return 하고 있는 것이다.
middleware가 특정 페이지에서만 실행되도록 하는 방법과 쿠키들을 생성하는 방법도 배워볼 것이다.
user가 특정 행동을 하거나 특정 페이지로 이동할 때를 위해 cookie를 설정하고 싶을 수도 있기 때문이다!
cookie 생성해보기

if(request.nextUrl.pathname === '/') : 요청된 URL의 경로(pathname)가 루트 경로(/)인지 확인
NextResponse.next() 는 요청을 그대로 다음 단계로 넘기도록 설정하는 응답 객체를 생성한다.
response.cookies.set('middleware-cookies', 'helllllo!') :

/ 경로로 접근했더니 'middleware-cookies'라는 이름으로 'helllllo!' 값을 가진 쿠키가 생성된 것을 확인할 수 있다.
middleware가 특정 페이지에서만 실행되도록

export const config = {]를 Next.js에서 미들웨어의 동작 범위를 제한하거나 특정 경로에서만 미들웨어를 실행하도록 설정할 수 있다.
--> 이때, matcher 속성을 사용하여 미들웨어가 실행될 경로를 지정할 수 있습니다.
% middleware를 user로 시작하는 모든 단일 URL에서 실행하고 싶을 떄 ? -> /user/:path*
/ 경로로 접근해 보았더니

console에 "안녕 나는 middleware야"가 출력되는 것을 확인할 수 있다!
미들웨어의 제한점: Edge Runtime
Next.js의 미들웨어는 Edge Runtime 환경에서 실행된다. 이는 미들웨어가 일반적인 Node.js 환경과는 다른 환경에서 동작한다는 것을 의미하며, 일부 Node.js 기능을 사용할 수 없다는 제한이 있다.
그럼 Edge Runtime 이란?
Edge Runtime은 Next.js에서 사용하는 경량화된 런타임 환경으로, 성능 최적화 및 빠른 응답을 목표로 한다.
일종의 제한된 버전의 node.js라고 생각할 수 있다.
javascript도 실행할 수 있고, cookie도 볼 수 있음 하지만, nodejs가 할 수 있는 걸 모두 하지는 못한다.
왜 ? middleware가 모든 단일 request에 대해 실행되어야 하기 때문이다.
만약, 우리가 여기에 그냥 node.js code (데이터를 불러오고 사용하는) 를 작성할 수 있다면, middleware는 빠르게 실행하지 못하게 된다.
왜 ? 더 무거운 nodejs runtime 전체를 시작해야하기 때문
--> middleware가 처리하는 모든 request에 대해서 node.js runtime을 실행하기는 싫다 (middleware는 엄청나게 빨리 실행되어야 하기때문)
미들웨어는 빠른 실행과 짧은 응답 시간을 요구하는 작업에 적합하며, 복잡한 서버 사이드 로직은 미들웨어가 아닌 API Routes 또는 다른 Node.js 서버에서 처리하는 것이 올바르다.
인증된 사용자만 접근할 수 있도록 하는 미들웨어 만드는 것을 해볼 것이다.
로그아웃이 되었을 때 -> profile에 접근하면 안되지 않을까!
로그인이 되었을 때 -> create-account에 접근하면 안되지 않을까!
먼저, 세션을 가져오고, 사용자가 쿠키에 id를 가지고 있는지 확인하는 것부터 시작할 것임.(로그인 여부를 확인하는 것)
1. interface IRoutes:
이 인터페이스는 객체의 키로 문자열(string)을 사용하고, 값으로 불리언(boolean) 값을 가지는 객체의 구조를 정의한다.
예를 들어, { '/login': true } 와 같은 형태를 가지는 객체를 정의할 수 있다.
2. const publicOnlyUrl:
3. middleware 함수:
getSession() : 이 함수는 현재 세션을 가져온다. 세션에는 로그인된 사용자 정보가 포함되어 있으며, 이 예제에서는 session.id로 로그인 상태를 확인할 수 있다.4. 로그인 상태에 따른 리다이렉션 로직:
!exits : 요청된 경로가 publicOnlyUrl에 포함되지 않은 경우(즉, 로그인하지 않은 사용자가 접근하면 안 되는 경로인 경우),