User module(1)
목표
<본격적으로 User 모듈 부터 만들어 보자>
🍔 핵심 내용
🥑 schema.prisma 셋팅
이제 다시 처음부터 셋팅을 진행 해보자. npm prisma init 으로 시작.
🥑 users폴더 생성 및 typeDefs, queries, mutations 파일 생성 (하단 코드 리뷰 참조)
앞 서, movies와 동일하게 진행 된다. 이제 본격 실습!
🥑 hash에 대한 이해
npm install bcrypt 설치 및 hash 암호화 사용 (하단 유투브 영상 참조)
쉽게 설명하면, 회원이 넣는 암호 그대로를 db에 저장하는 것이 아니라 한번 더 암호화해서 저장하는거라고 보면 된다.
(아무도 모르게)
🍔 코드 리뷰
🥑 prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
firstName String
lastName String?
userName String @unique
email String @unique
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
이전에 생성 했던 model movie와 마찬가지로, 같은 로직으로 model User를 생성해주자. 그리고 model에 값이 변경 될때 마다 npm run migrate를 꼭 해주자.
🥑 users.typeDefs.js
import { gql } from "apollo-server";
export default gql`
type User {
id: String!
firstName: String!
lastName: String
userName: String!
email: String!
createdAt: String!
updatedAt: String!
}
type Mutation {
createAccount(
firstName: String!
lastName: String
userName: String!
email: String!
password: String!
): User
}
type Query {
seeProfile(userName: String!): User
}
`;
type을 정의해주자. mutation부분은 일단 계정 생성 부터 진행해볼 것이다.
🥑 users.mutations.js
import bcrypt from "bcrypt"
import client from "../client";
export default {
Mutation: {
createAccount: async (_, {
firstName,
lastName,
userName,
email,
password,
}) => {
const existingUser = await client.user.findFirst({
where: {
OR: [
{
userName,
},
{
email,
}
],
},
}); // db에 userName와 email가 이미 있는지 확인하는 로직
const uglyPassword = await bcrypt.hash(password, 10);
console.log(uglyPassword);
return client.user.create({
data: {
userName,
email,
firstName,
lastName,
password: uglyPassword,
}
})
}
}
};
계정만드는 로직을 진행해보자. 먼저 userName와 email은 unique한 것이기 때문에 신규 유저가 가입할 때 기존 db에 값이 있으면 안된다. 이부분을 확인을 먼저 해주고(이 부분은 추가로 더 할 예정), 문제가 없다면 받은 password를 hash화 하여 db에 저장해준다.
console.log(uglyPassword)의 값을 보자면, 이렇게 랜덤하게 되어 있다. 비밀번호 넣은 값은 1234였다.
아래와 같이 mutation - createAccount 값을 넣어주면 계정이 생성 된다.
이제 npm run studio를 통해서 실제 값이 들어갔는지 확인해보자. (아래)
db 연동부터 계정생성 및 hash까지 진짜 심플하게 진행 되었다.
🍔 핵심 내용
🥑 try&catch 구문에 대한 이해 / throw new Error(mutation - createAccount 마무리)
async await 구문에서 사용하는 try&catch를 사용해보자.
🥑 Query - seeProfile 마무리
seeProfile부분을 마무리 짓자.
🍔 코드 리뷰
🥑 users.mutations.js
import bcrypt from "bcrypt"
import client from "../client";
export default {
Mutation: {
createAccount: async (_, {
firstName,
lastName,
userName,
email,
password,
}) => {
try {
const existingUser = await client.user.findFirst({
where: {
OR: [
{
userName,
},
{
email,
}
],
},
});
if (existingUser) {
throw new Error("이미 계정이 있습니다.")
}
// db에 userName와 email가 이미 있는지 확인하는 로직
const uglyPassword = await bcrypt.hash(password, 10);
return client.user.create({
data: {
userName,
email,
firstName,
lastName,
password: uglyPassword,
}
})
} catch (e) {
return e;
}
}
}
};
async await 구문에서는 try catch 구문을 사용하는 것이 좋다. 어떤 데이터를 가지고 올 때 에러가 있는 경우가(위 케이스에서는 기존 계정이 있을 경우 에러를 던지는 로직으로 만들어 놨음.) 있기 때문 이다.
에러를 던지는 문법은 throw new Error("원하는 에러 메시지")
🍔 코드 리뷰
import client from "../client";
export default {
Query: {
seeProfile: (_, { userName }) => client.user.findUnique({
where: {
userName,
}
})
},
};
크게 어려울 것은 없다. 다만, 여기서 findUnique와 findFirst의 차이점을 얘기하자면, findUnique는 아래와 같이 model 정의 시에, unique되어 있는 부분만 조회를 하게 된다.
🍔 핵심 내용
🥑jwt(jsonwebtoken)사용법 (mutation - login 부분 마무리)
토큰을 사용하여, login부분을 끝내 보자.
🥑refactoring 진행
typeDefs와 resolvers 부분이 점점 길어 지고 있다.. 조금 더 분할해서 정복해보자.
아래와 구조와 같이, users안에 세부폴더를 만들어서 관리하는 것이 효율적이다. 다음 내용에 이어서...
🍔 코드 리뷰
🥑 users.typeDefs.js
import { gql } from "apollo-server-core";
export default gql`
type User {
id: String!
firstName: String!
lastName: String
userName: String!
email: String!
createdAt: String!
updatedAt: String!
}
type LoginResult {
ok: Boolean!
token: String
error: String
}
type Mutation {
createAccount(
firstName: String!
lastName: String
userName: String!
email: String!
password: String!
): User
login(
userName: String!
password: String!
) : LoginResult
}
type Query {
seeProfile(userName: String!): User
}
`;
mutation부분에 login을 추가 해주었다. return 역시 새로 LoginResult type을 생성 해주었음.
login을 위해, userName과 password를 인자값으로 받게 된다.
🥑 users.mutations.js
require("dotenv").config();
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import client from "../client";
export default {
Mutation: {
createAccount: async (_, {
firstName,
lastName,
userName,
email,
password,
}) => {
try {
const existingUser = await client.user.findFirst({
where: {
OR: [
{
userName,
},
{
email,
}
],
},
});
if (existingUser) {
throw new Error("이미 계정이 있습니다.")
}
const uglyPassword = await bcrypt.hash(password, 10);
return client.user.create({
data: {
userName,
email,
firstName,
lastName,
password: uglyPassword,
}
})
} catch (e) {
return e;
}
},
login: async (_, { userName, password }) => {
const user = await client.user.findFirst({ where: { userName } })
if (!user) {
return {
ok: false,
error: "존재 하지 않는 유저네임 입니다"
}
}
const passwordOk = await bcrypt.compare(password, user.password)
if (!passwordOk) {
return {
ok: false,
error: "비밀번호가 틀립니다."
}
}
const token = jwt.sign({ id: user.id }, process.env.SECRET_KEY)
return {
ok: true,
token: token
}
}
}
};
login을 하기 위한 3가지 프로세스는 아래와 같다.
① 입력한 userName이 DB에 존재한지 확인
② userName이 존재한다면, 입력한 password가 DB에 있는 PW와 일치 한지 확인 (bcrypt.compare 이용)
③ userName과 password가 모두 문제 없으면 토큰 발행
(중간에 에러가 있으면, return을 반환함으로써 그 다음 내용으로 가지 못하고 값을 반환하게 된다.)
jwt.sign 부분은, 토큰값을 발행하는 로직이다. 첫번째 인자값에는 payload 값이 들어가고 두번째 인자값에는 secret키 값이 들어 간다. secret 값은 노출이 되면 안되기 때문에, .env 파일안에서 값을 끌어 온다.
위 토큰 값을 jwt.io 사이트에 넣어보면, payload에 넣었던 id값 을 확인할 수 있다. (토큰값으로 식별할 수 있는 것임)
jsonwebtoken 자세한 내용은 구글링해보면 나와 있으니, 나는 큰 그림만 잡고 가겠다.
🍔 핵심 내용
🥑refactoring 마무리
기존 mutations, queries, typeDefs 3종의 파일을 아래와 같이 resolvers, typesDefs로 묶자. schema 파일 변경
🍔 코드 리뷰
🥑 schema.js
import { loadFilesSync, makeExecutableSchema, mergeResolvers, mergeTypeDefs } from "graphql-tools";
const loadedTypes = loadFilesSync(`${__dirname}/**/*.typeDefs.js`);
const loadedResolvers = loadFilesSync(`${__dirname}/**/*.resolvers.js`);
const typeDefs = mergeTypeDefs(loadedTypes);
const resolvers = mergeResolvers(loadedResolvers);
const schema = makeExecutableSchema({ typeDefs, resolvers });
export default schema;
{mutations, queries} --> resolvers로 변경하였다.
그리고 대표로, createAccount 파일만 봐보자.
🥑 createAccount.typeDefs.js
import { gql } from "apollo-server-core";
export default gql`
type EditProfileResult{
ok: Boolean!
error: String
}
type Mutation {
editProfile(
firstName: String
lastName: String
userName: String
email: String
password: String
): EditProfileResult!
}
`;
result값을 모두 통일 시켜줬다.
🥑 createAccount.resolvers.js
require("dotenv").config();
import bcrypt from "bcrypt";
import client from "../../client";
export default {
Mutation: {
createAccount: async (_, {
firstName,
lastName,
userName,
email,
password,
}) => {
try {
const existingUser = await client.user.findFirst({
where: {
OR: [
{
userName,
},
{
email,
}
],
},
});
if (existingUser) {
throw new Error("이미 계정이 있습니다.")
}
const uglyPassword = await bcrypt.hash(password, 10);
return client.user.create({
data: {
userName,
email,
firstName,
lastName,
password: uglyPassword,
}
})
} catch (e) {
return e;
}
},
}
};
아직 리턴값은 수정 전임. (나중에 수정할 예정) 리팩토링 큰 흐름만 잡자.
🍔 핵심 내용
🥑 editProfle 작업
Profile 변경시에, password를 수정하려면 어떻게 해야 할까?
🍔 코드 리뷰
🥑 editProfile.typeDefs.js
import { gql } from "apollo-server-core";
export default gql`
type EditProfileResult{
ok: Boolean!
error: String
}
type Mutation {
editProfile(
firstName: String
lastName: String
userName: String
email: String
password: String
): EditProfileResult!
}
`;
🥑 editProfile.resolvers.js
import client from "../../client";
import bcrypt from "bcrypt";
export default {
Mutation: {
editProfile: async (_, {
firstName,
lastName,
userName,
email,
password: newPassword }) => {
let uglyPassword = null;
if (newPassword) {
uglyPassword = await bcrypt.hash(newPassword, 10);
}
const updatedUser = await client.user.update({
where: { id: 1 },
data: {
firstName,
lastName,
userName,
email,
...(uglyPassword && { password: uglyPassword })
}
})
if (updatedUser.id) {
return {
ok: true
}
} else {
return {
ok: false,
error: "프로필을 업데이트 할 수 없습니다."
}
}
}
},
};
프로필을 수정하기 위해서는 몇 가지 조건이 있다.
① 로그인 한 사람이 누군지?
-->이건 토큰으로 해결, 다음 내용에 다룰 예정 (위 코드에는 id: 1로 임의로 정의)
② 사용자가 넣지 않은 값(undefine)을 넣었을 때, 해당 값이 db에 들어가면 안됨
-->prisma에서 알아서 처리해줌.
③ 수정될 password를 넣었을 때, 다시 hash화된 값이 db에 들어가야 함.
--> 먼저 hash화된 password (uglyPassword) 는 기본적으로 null로 넣어 둔다. (password를 바꾸지 않을수도 있기 때문에) 만약, password를 변경하려면 bcrypt.hash를 통해 uglypassword에 해당 값을 담아 둔 후, client.user.update의 data부분에 조건문에 사용한다. 이부분이 약간 헷갈릴 수 있으나 천천히 따라가보자.
data부분에는 받은 인자 값이 db에 적용이 되는데, password부분에는 만약 uglypassword가 true면 db의 password값에 생성된 uglypassword 값을 넣어주는 로직이다. es6 문법으로 ...(~~&&{}) 이렇게 사용이 된다.
최종적으로. return값은 2개가 나온다 성공시 or 실패시
🍔 핵심 내용
🥑 editProfle 토큰 넘기기
위에서는, 임의로 id를 강제로 넣어서 진행해 보았다. 이제는 token으로 해보자. (이것도 이해를 돕기 위해, 매뉴얼로 진행하는 부분임)
🍔 코드 리뷰
🥑 editProfile.typeDefs.js
import { gql } from "apollo-server-core";
export default gql`
type EditProfileResult{
ok: Boolean!
error: String
}
type Mutation {
editProfile(
firstName: String
lastName: String
userName: String
email: String
password: String
token: String!
): EditProfileResult!
}
`;
token 값을 인자값으로 넣어두고,,,
🥑 editProfile.resolvers.js
require("dotenv").config();
import client from "../../client";
import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";
export default {
Mutation: {
editProfile: async (_, {
firstName,
lastName,
userName,
email,
password: newPassword,
token }) => {
const { id } = await jwt.verify(token, process.env.SECRET_KEY);
let uglyPassword = null;
if (newPassword) {
uglyPassword = await bcrypt.hash(newPassword, 10);
}
const updatedUser = await client.user.update({
where: { id, },
data: {
firstName,
lastName,
userName,
email,
...(uglyPassword && { password: uglyPassword })
}
})
if (updatedUser.id) {
return {
ok: true
}
} else {
return {
ok: false,
error: "프로필을 업데이트 할 수 없습니다."
}
}
}
},
};
token값을 받은 후, jwt.verify를 통해 id값을 확인한다. ( 여기서 { id } 는 es6 문법으로, 저기에 const A 를 넣었으면
A = { id: xx } 이렇게 나왔을 거고, 해당 id를 바로 가져다 쓰는 방법이다. )
해당 id 값은, client.user의 where : { id } 값으로 들어간다.
따라서, 로그인 된 사람이 누구인지 토큰값으로 알고 있기 때문에, 해당 id값에 맞는 데이터를 업데이트 할 수 있는 원리 이다. 이 프로세스의 문제는, 직접 mutation에 token값을 넣어줘야 하고, 여러가지로 번거롭다. 이제 깔끔하게 진행해보자.
🍔 핵심 내용
🥑 context 개념 이해
resolvers의 인자에는 총 4개 인자가 들어간다. 이중에 3번째인자인 context에 대해서 알아보자.
🥑 header를 이용하여 token값 전달하기
매뉴얼로 token값을 넣는게 아니라, 발급 받은 token값이 header에 자동으로 들어가는 로직을 만들어 주자.
🍔 코드 리뷰
먼저, HEADERS에 아래와 같이 token값을 넣어주자. 해당 token은 id : 10 인 유저 이다. 해당 데이터는, request 데이터안에 값이 들어가 있다. (req.headers.token)
🥑 users/utils.js
import jwt from "jsonwebtoken";
import client from "../client";
export const getUser = async (token) => {
try {
if (!token) {
return null;
}
const { id } = await jwt.verify(token, process.env.SECRET_KEY);
const user = await client.user.findUnique({ where: { id } })
if (user) {
return user;
} else {
return null;
}
} catch {
return null;
}
}
token 값을 인자로 받고,
① jwt을 사용하여 id을 추출해 낸 후,
② 해당 id로 client.user에서 유저를 찾아내는
getUser 함수를 별도로 만들어 주었다. 그 이유는, 글 작성, 사진 업로드, 프로필 수정 등 관련 로직이 필요한 부분이 여러곳에 있기 때문이다.
여기서, token값이 없거나 user가 없을 수 있기 때문에 try & catch 사용
🥑 server.js
require("dotenv").config();
import { ApolloServer } from "apollo-server";
import schema from "./schema"
import { getUser } from "./users/users.utils";
const server = new ApolloServer({
schema,
context: async ({ req }) => {
return {
loggedInUser: await getUser(req.headers.token)
}
}
});
const PORT = process.env.PORT;
server
.listen()
.then(() => console.log(`🚀 Server is running http://localhost:${PORT} ✅`));
context는 모든 resolvers의 3번째 인자에 전역변수 식으로 쓰일 수 있다. ApolloServer 값으로 context를 쓸수 있고 여기에는 함수 역시 사용 될 수 있다. 이를 이용 하여, resolvers 전역에 token값을 전달 할 수 있는 것이다.
위에 만든 gerUser함수를 사용하여, token값을 전달 및 유저를 loggedInUser값에 담는다. 해당 값이 context 값이 되는 것이다.
🥑 editProfile.resolvers.js
require("dotenv").config();
import client from "../../client";
import bcrypt from "bcrypt";
export default {
Mutation: {
editProfile: async (_, {
firstName,
lastName,
userName,
email,
password: newPassword,
}, { loggedInUser }) => {
let uglyPassword = null;
if (newPassword) {
uglyPassword = await bcrypt.hash(newPassword, 10);
}
const updatedUser = await client.user.update({
where: { id: loggedInUser.id },
data: {
firstName,
lastName,
userName,
email,
...(uglyPassword && { password: uglyPassword })
}
})
if (updatedUser.id) {
return {
ok: true
}
} else {
return {
ok: false,
error: "프로필을 업데이트 할 수 없습니다."
}
}
}
},
};
{ loggedInUser } 값이 3번째 인자, 즉 context값으로 들어가 있다. 해당 loggedUser에는 User의 모든 값들이 들어가 있다. console.log 해서 보면 아래와 같다.
해당 id 값이 where의 id값으로 들어가게 된다.
위 전체 프로세스에서 아직 허점이 남아 있다. 만약 아직 로그인이 안된 경우, 토큰값이 없는데 그럼 어떻게 해야 되나?
로그인으로 유도해야 겠지? 이 부분을 보완해보자.
🍔 핵심 내용
🥑 currying 사용법 (잘 이해 안됨)
resolver를 보호 할 수 있도록 currying 함수를 만들어 보자. resolver를 보호한다는 뜻은, 바로 전 내용처럼, 토큰 값이 없는 경우 현재 resolver 상태에서는 에러만 뜨게 된다. 이때 에러가 아니라, 로그인을 해달라는 식으로 만들어 보자. (보호 기능)
🍔 코드 리뷰
🥑 users.utils.js
import jwt from "jsonwebtoken";
import client from "../client";
export const getUser = async (token) => {
try {
if (!token) {
return null;
}
const { id } = await jwt.verify(token, process.env.SECRET_KEY);
const user = await client.user.findUnique({ where: { id } })
if (user) {
return user;
} else {
return null;
}
} catch {
return null;
}
};
export const protectedResolver = (ourResolver) => (
root,
args,
context,
info
) => {
if (!context.loggedInUser) {
return {
ok: false,
error: "로그인이 필요합니다. 로그인 해주세요!",
}
}
return ourResolver(root, args, context, info)
}
protectedResolver 함수를 만들어 주었다. 이 부분이 솔직히 잘 이해가 되지는 않는다. gql의 resolver를 감싸주는 역할을 맡는다. ( loggedInUser --> 즉, token 값 여부에 따라 token값이 있으면 resolver 내용 대로 진행 or token 값이 없으면 로그인 요청 )
따라서, 로그인이 필요한 (개인 프로필 수정, 사진 등록 등 등) 모든 resolver에 해당 currying 함수를 넣어줌으로써 보호를 해줄 수 있다.
잘 이해는 안되지만, 큰 맥락으로 짚어 보자면 resolver안에는 (root, args, context, info) 4개 인자를 기본적으로 받는다.
특정 resolver (ex : mutation : editProfile)를 예로 들면 gql에서 request를 보내면 해당 resolver가 실행 된다. 그리고,
editProfile이라는 resolver에는 위 4개의 인자를 받게 되고, 해당 인자에 있는 context 값중 loggedInUser 값의 여부를 파악 한 후 loggedInUser가 null 값이면 로그인 요청 / 존재 한다면 우리의 resolver ( editProfile ) 이 진행 된다.
🥑 editProfile.resolvers.js
import client from "../../client";
import bcrypt from "bcrypt";
import { protectedResolver } from "../users.utils";
const resolverFn = async (_, {
firstName,
lastName,
userName,
email,
password: newPassword,
}, { loggedInUser }) => {
let uglyPassword = null;
if (newPassword) {
uglyPassword = await bcrypt.hash(newPassword, 10);
}
const updatedUser = await client.user.update({
where: { id: loggedInUser.id },
data: {
firstName,
lastName,
userName,
email,
...(uglyPassword && { password: uglyPassword })
}
})
if (updatedUser.id) {
return {
ok: true
}
} else {
return {
ok: false,
error: "프로필을 업데이트 할 수 없습니다."
}
}
}
export default {
Mutation: {
editProfile: protectedResolver(resolverFn)
},
};
protectedResolver로 editProfile을 감싸준 상태 이다.