아직 완성 못했습니다...
기능 끝났고, 요번에 다이어 그램 그리면서 코드도 다시 한번 쓱 읽으면서 자잘한거 개선도 하고 했지만 아직 인프라가 하나도 되어있지 않아 몇번은 더 글을 쓸듯?? 하다...
지금까지 진행 된 과정 들이라도 정리한 겸사겸사 올려보도록 하겠습니다.
환경
프레임워크 - NestJS
언어 - Typescript
DB - Postgresql, Prisma(ORM)
Cache - Redis
IDE - Visual Studio Code
DB가 계속 사용해오던 MySQL에서 Postgresql로 바뀌었는데 사실 이전부터 넘어 오려고 했었고, 마침 Prisma가 매우매우 TypeORM보다 좋고 빠르고 쉽고 굿굿 하다는 말을 많이 들어서 연습용 프로젝트에서 계속 익히다가 요번에 써보게 되었다.
확실히 편하고 이 프로젝트 코드엔 없을 텐데 프리즈마 자체에서 내가 작성한 쿼리의 모델들의 타입을 모두 가지고 있어서 아마 다음에 작성 할때는 엔티티를 추가로 작성 안할거 같다!
(너무 늦게 알아서 적용 할 수 없었습니다...)
폴더 구조도 당당하게 보여줄 그날이 올까
항상 코드를 작성하면서 폴더 구성을 어떻게 하면 더 좋을까 깔끔해 보일까 고민을 많이 하는데 뭐 결국엔 혼자 하니까... 최소한만 지키고 내가 찾기 편하게끔 하는거 같다.
(서비스 끼리 묶는 다던지 레포지토리 끼리 묶는 다던지...)
아무튼 어떤 기술을 사용했으며 ERD, 다이어 그램 등등등 모두 깃허브에 올려두어서 이곳에는 그 사이사이에 루틴들을 정리해 볼까 한다.
(무슨 생각으로 내가 이렇게 했을까도 볼겸...)
로그인 & 회원가입
아마 모든 루틴을 뜯어 보려면 포스팅이 길어져 나누어서 하게 될텐데 우선 가장 기본적으로 로그인 회원가입 부터!
메일 인증을 이용한 회원가입을 구현해보고 싶어 정말로 "그냥" 넣어 본건데 처음엔 왜 이걸 쓸까? 했지만 생각해보니 쓸이유가 안쓸이유보다 많아서 넣은걸 후회하지 않는다.
이 루틴은 총 3단계로 나뉘며 코드발행 -> 코드인증 -> 회원등록 순으로 진행이 된다.
다음 코드는 코드 발행 루틴이다!
async publishCode(email: string, pass: string, nickname: string) :
Promise<boolean> {
const { salt, hash } = this.auth.encryption({ pass })
const code = this.auth.generateRandStr()
await this.redis.set(
code,
{ salt, hash, nickname, email },
UserService.name,
this.config.get<number>("EMAIL_TTL") ?? 60,
)
.then(async _=> await this.email.sendMail({
secret: code,
title: "Siren Order 회원가입 인증 코드",
to: email
}).catch(_=> {
Logger.log("메일 전송실패", UserService.name)
throw ERROR.FailedSendMail
}))
.catch(async err => {
this.redis.delete(code, UserService.name)
throw err
})
return true
}
우선 앱에서 보낸 데이터를 토대로 비밀번호를 암호화 하고(바로 DB에 넣게끔 순서는 상관없나?) 랜덤 코드를 생성해 메모리에 캐시를 저장하고 입력받은 이메일로 코드를 담은 메일을 전송하게 된다.
캐시저장, 메일 전송 둘 중 하나라도 실패하게 되면 캐시정보를 삭제하고 에러를 반환한다.
이게 노드 메일러가 하나 처리 할때마다 3초정도 소요가 되는데 내가 코드를 이상하게 써서 느린건가? 했는데 내가 있는 오픈 단톡방에서도 노드 메일러는 느리다고 aws꺼 쓰라고 한다.
(다행인건가?)
다음으로는 메일 인증코드 확인 루틴이다.
async verifyCode(code: string) :
Promise<boolean> {
const data = await this.redis.get<{ salt: string, hash: string, nickname: string, email: string }>(code, UserService.name)
if(data === null) {
var error = ERROR.NotFoundData
error.substatus = "NotValidCode"
throw error
}
this.redis.delete(code, UserService.name)
this.redis.set(
data.email,
data,
UserService.name,
600,
)
return true
}
사실 인증코드를 확인 할때 이 코드도 암호화를 걸까? 했지만 할 필요가 없어보여서 안넣었다.
그냥 보내온 코드 확인하고 그 코드가 캐시정보에 존재한다면, 캐시 정보를 삭제하고 유저가 생성하려는 이메일로 캐시를 10분동안 저장하고 성공을 반환한다.
지금 보기엔 그냥 이메일 인증과 동시에 회원 등록처리를 하는게 훨씬 좋아 보이는데 만들 당시에 그냥 있어보이려고 저랬나? 싶다.
마지막으로 유저 등록 루틴!
async registUser(email: string) :
Promise<boolean> {
const data = await this.redis.get<{ salt: string, hash: string, nickname: string, email: string }>(email, UserService.name)
if(data === null) {
var error = ERROR.NotFoundData
throw error
}
const createdUser = await this.userRepository.create({
...data,
uuid: this.auth.getRandUUID(),
wallet_uid: this.auth.getRandUUID(),
})
this._upsertCache(createdUser)
this.redis.delete(email, UserService.name)
return true
}
마찬가지로 보내온 이메일로 데이터를 찾고 없으면 에러를 반환하고, 그게 아니라면 유저 정보를 DB에 저장하고 캐시데이터를 모두 업데이트 한다.
진작 합쳤으면 10m/s는 빨라 졌을 텐데 쩝...
로그인은 크게 토큰 인증, 비밀번호 확인으로 이루어 지며, 비밀번호 확인만 이메일이 필요하다.
가져올 정보의 이메일과 비밀번호를 입력받아 처리하는데 이메일로 해당 정보가 DB에 존재하는지 판단하고, 단방향 암호화 인증방식으로 2차 확인 이후에 토큰을 발행하고 발행한 토큰을 정보와 함께 반환 해준다.
async loginByPass(email: string, pass: string) :
Promise<UserDto> {
const findUser : UserEntity = await this._findUser(email)
const isVerify = this.auth.verifyPass({ pass }, findUser.salt, findUser.pass)
if(isVerify) {
// accesstoken과 refreshtoken 발행
const { accesstoken, refreshtoken } = await this._publishTokens(findUser.email)
await this.userRepository.updateBy({
accesstoken: accesstoken,
refreshtoken: refreshtoken,
}, findUser.email)
this._upsertCache(findUser)
return { ... } as UserDto
}
var error = ERROR.UnAuthorized
error.substatus = "NotEqualPass"
throw error
}
로그인은 정말로 다양한 방식으로 처리가 가능한데 어떤 방식으로 최적화 할 수 있을까? 하면서 생각 해보지만 지금 내 머리로는 DB 업데이트 부분에서 await를 빼는거 밖에 생각이 나지를 않는다...
(근데 뺴도 되는지도 확실히 모르겠다. 아마 받아야 할 정보는 업데이트 이후 정보가 아니라 상관 없을거 같기는 한데...)
다음은 토큰 인증 로그인!
토큰을 사용하는 만큼 당연히 Guard를 거치고 바보 같이 가드에선 하나의 오류만 던질 수 있다고 알고 있던 나는 가드에서 오류쓰로잉 까지 처리하지 않고 나름 오류 핸들링 한다고 컨틀롤러 까지 내려 받아와 처리하는 엄청난 불상사를 일으켰다...
@UseGuards(AuthGuard)
async tokenLogin(
@AuthDecorator.GetTokenAndPayload() data:
| { payload: any, token: string }
| { payload: any }
) : Promise<TryCatch<
UserDto,
| typeof ERROR.ServerDatabaseError
| typeof ERROR.NotFoundData
| typeof ERROR.UnAuthorized
>> {
try {
if("email" in data.payload
&& "authorized" in data.payload) {
const authorized = data.payload.authorized as boolean
let result : UserDto
if(!authorized) {
const needCheckData = { ...data } as { payload: any, token: string }
result = await this.userService.checkRefresh(needCheckData.payload.email, needCheckData.token)
}
else result = await this.userService.loginByEmail(data.payload.email)
return {
data: result,
status: 201,
}
} else return data.payload
} catch(e) { return e }
}
지금보니 매우 처참해 보인다... 지금 수정하면 되지 않냐? 하지만 AuthGuard를 사용하는 루틴이 꽤나 있으며 모두 저런 식으로 처리 되어 있어서 생각보다 폼이 컸다...
(추후에 작성한 Guard는 그래도 내부에서 떤졌다...)
무튼! 어차피 로그인은 RefreshToken 때문에 어차피 컨트롤러에 데이터를 내려받고 체크를 해야하기 때문에 괜찮다.
(아마도)
먼저 AccessToken의 Payload로 유저를 탐색하며, 찾아 낸 유저의 RefreshToken을 검증한다.
검증받은 RefreshToken의 Payload와 만료된 AccessToken의 Payload 정보를 비교하고, 같다면 기존에 등록된 토큰들을 제거하고 새로 갱신한다.
그 외에 경우엔 모두 변조된 데이터로 간주하고 에러를 반환.
아래는 구현된 코드이다.
async checkRefresh(email: string) :
Promise<UserDto> {
const findUser = await this._findUser(email)
if(!findUser.refreshtoken) {
var error = ERROR.UnAuthorized
error.substatus = "ExpiredToken"
throw error
}
else {
const { payload } = await this.auth.verifyToken(findUser.refreshtoken, true)
.catch(err => {
Logger.error("만료된 토큰")
throw err
})
if(payload !== null
&& "email" in payload
&& payload.email === email
) {
// accesstoken과 refreshtoken 갱신
const { accesstoken, refreshtoken } = await this._publishTokens(findUser.email)
const user = await this.userRepository.updateBy({
accesstoken: accesstoken,
refreshtoken: refreshtoken,
}, findUser.email)
this._upsertCache(user)
return { ...user } as UserDto
}
// 저장되어 있는 토큰 폐기
const user = await this.userRepository.updateBy({
accesstoken: null,
refreshtoken: null,
}, findUser.email)
this._upsertCache(user)
// 디비에 저장되어 있는 RefreshToken이 오염될 경우에만
// 에러를 스로잉 함
var error = ERROR.UnAuthorized
error.substatus = "ForgeryData"
throw error
}
}
이렇게 로그인과 회원가입 루틴을 다시보며 왜 그랬을까... 하는 생각을 다시 가지고 또 보면서 당장에 수정가능한 부분은 수정해서 올렸다.
(아마 지금 레포코드랑 다를 수 도 있다)
마치며
이런 서비스 부분들이 6~7개 정도 되는데 포스팅이 많이 길어질거고 나는 더더욱 창피해하며 이제 이러지 말아야지... 하는 발전의 시간을 가지게 되겠지??...