서버 정리3

뜨루루루·2024년 1월 13일

sirenorder

목록 보기
8/9

쿠폰

어제 상점 & 상인 부분을 정리하다 쿠폰을 정리 안하고 하다보니 부가적으로 들어가 쿠폰을 빠르게 정리 해야겠다고 생각해서 오늘은 쿠폰을 정리 해보겠다!

컨트롤러

여태까지 정리에서 이 부분을 한 번도 적은 적이 없는데 쿠폰은 유저에서도 사용되는 AuthGuard와 쿠폰에서만 사용되는 CouponGuard가 모두 사용되어 한 번 적어보려한다.

사실 두 가드가 서로 해주는 역할이 크게 다른 점이 없어서 그냥 넘어가려고 했지만 뭐 한 번더 읽을 껀덕지라도 있는게 좋고 컨트롤러에서 결과, 에러를 모두 여기서 떤져주기 때문에 매우매우 중요한 부분 이기도 하다!

먼저 AuthGuard!

글로벌하게 사용되는 토큰 검증을 위한 가드이다.

가드에서 반환 해줄 수 있는 값은 boolean으로 false가 반환되면 자동적으로 오류를 떤져주는데 여기서 그냥 내가 임의로 오류를 떤져도 된다는걸 모르고 Request에 담아 컨트롤러에 내려 거기서 처리해버린 치욕스러운 부분이 있다... 참고

그래서 AuthGuard에서는 일부로 페이로드에 결과를 담아 내려보내는 부분도 있지만 굳이 넘기지 않고 바로 오류를 떤져줘야하는 부분 또한 존재한다.

async canActivate(context: ExecutionContext) : Promise<boolean> {
        const req = context.switchToHttp().getRequest()
        const reqAddress = req.headers['x-forwarded-for'] ||  req.connection.remoteAddress
        const token = this._extractTokenFromHeader(req)
        if(token === null) {
            req.user = ERROR.UnAuthorized
            return true
        }
        try {
            const payload = await this._getPayload(token)
            if("email" in payload) {
                if(!payload.email || !/^[0-9a-zA-Z]+@[a-zA-Z]+.[a-zA-Z]{2,3}$/g.test(`${payload.email}`)) {
                    Logger.error(`[이메일 형식이 아님] 요청 아이피: ${reqAddress}`, AuthGuard.name)
                    req.user = ERROR.UnAuthorized
                    return true
                }
                req.user = payload
            } else req.user = ERROR.UnAuthorized
            return true
        } catch(e) {
            if(typeof e === typeof ERROR) {
                Logger.error(`[검증 할 수 없는 토큰] 요청 아이피: ${reqAddress}\n위조데이터 가능성 있음`, AuthGuard.name)
            }
            req.user = ERROR.UnAuthorized
            return true
        }
    }

위는 그 문제의 AuthGuard 코드인데... 그래도 고치기 전에 코드를 한번쯤 올려두어야 창피를 알고 다시 하지않겠지 하는 마음으로 수정전 코드를 올렸다.

흐름을 보자면 요청을 가져와 요청자의 아이피와 토큰정보를 가져오고 토큰 자체가 없다면 오류를 담아 흘려보낸다.

그 이후에는 토큰 검증과 동시에 페이로드를 가져오고, 페이로드에 담겨있는 이메일 정보를 가져와 데이터가 변조 되어었는지 확인하고 결과와 함께 흘려보낸다.

요기서 추후에 적용된 부분중 다른것도 중요하지만 이 부분이 굉장히 중요해 보여서 가져왔다.

if(context.getType() !== "http") {
	Logger.log(`올바르지 않은 요청이 들어왔습니다\n프로토콜: ${context.getType()}`, logPath)
    throw new HttpException("올바르지 않은 요청입니다.", HttpStatus.BAD_GATEWAY)
}

바로 요청 프로토콜 타입 체크

이게 있는 줄 도 몰랐고 우연히 컨텍스트 인터페이스 뒤져보다가 http,rpc,ws(웹 소켓)으로 프로토콜 타입별로 받아온다는 걸 알게 되었다.

단순히 내가 모두 설계한다고 여기로는 모두 http 요청만 올거 같지만 만약에 라도 기타 환경변화 혹은 외부접근 시도 등등 이 예외 상황에서 원인을 알 수 없는 요청 실패를 겪게 된다면 생각만 해도 끔찍하다...

CouponGuard

우선 쿠폰발행은 서버의 시크릿 코드를 권한으로 사용된다는 점을 미리 알고 봐야한다.

async canActivate(context: ExecutionContext): Promise<boolean> {
        if(context.getType() !== "http") {
            Logger.log(`올바르지 않은 요청이 들어왔습니다\n프로토콜: ${context.getType()}`, logPath)
            throw new HttpException("올바르지 않은 요청입니다.", HttpStatus.BAD_GATEWAY)
        }
        const req = context.switchToHttp().getRequest()
        const reqAddress = req.headers['x-forwarded-for'] ||  req.connection.remoteAddress
        const secret = this._extractSecretFromHeader(req)
        if(server_secret === null) {
            Logger.log(`서버 시크릿 코드가 로드 되지 않았습니다\n환경변수를 확인해주세요`, logPath)
            throw new HttpException("서버에서 검증을 위한 준비가 되지 않아 요청이 취소됩니다.", HttpStatus.ACCEPTED)
        } else if(secret !== server_secret) {
            Logger.log(`정상적이지 않은 쿠폰발급 요청이 왔습니다\n요청 아이피: ${reqAddress}`, logPath)
            throw new HttpException("해당 요청에 필요한 자격 증명에 실패 했습니다.", HttpStatus.UNAUTHORIZED)
        }
        
        req.user = true
        return true
    }

아주 단순하게 서버가 가지고 있는 코드와 요청자가 보낸 코드가 일치하는지 판별하고 결과를 떤져주는 가드이다.

조금 허술하게 되어있지만 서버에서 자체적으로 가지고 있는 시크릿 코드가 털리지만 않으면 상관없지 않을까 하는 편한 생각을 가지고 만든 루틴인데 아마 배포를 한다고 했으면 권한을 부여하는 식으로 검증루틴을 넣지 않았을까...

쿠폰발행과 등록

앞서 설명한 가드들을 뚫고 들어오게 된다면 쿠폰발행을 위한 함수에 접근이 가능하다!

우선 소제목과 같이 쿠폰은 발행과 등록으로 나뉘며, 발행받은 쿠폰코드를 유저가 등록해서 사용하는 방식으로 나뉘었다.

우선은 발행코드

async publishCoupon(coupon_info: CouponInfo) 
    : Promise<{ code: string, expiration_period: Date }> {
        const code = this.auth.generateRandStr(12)
        const { hash } = this.auth.encryption(code, coupon_secret, 32)
        const expiration_period = this._createExpirationPeriod(coupon_info.expiration_day)
        const published = await this.couponRepoistory.publishCoupon({
            coupon: {
                code: hash,
                expiration_period,
                menuinfo: coupon_info.menuinfo,
            },
        })
        if(!published) throw ERROR.ServiceUnavailableException
        return { code, expiration_period }
    }

쿠폰코드는 12자의 랜덤한 문자열로 발행이 되며 단방향 암호화를 통해 검증한다.

이렇게 만들어진 쿠폰 데이터는 DB에 등록되고 트랜잭션의 성공여부를 통해 결과를 반환해주는 방식!

이 함수는 여러곳에 사용되며 사용하는 루틴은 반드시 가드를 통한 검증을 마친 이후에서야 호출이 가능한곳 에서만 사용된다.

예를 들어 스탬프 6장을 모으면 아메리카노 쿠폰을 지급해주는 코드이다.

async publishAndRegisterStampCoupon(current_user_email: string)
    : Promise<SimpleCouponEntity> {
        const { code } = await this.publishCoupon({
            menuinfo: {
                "name": "아메리카노",
                "en_name": "Americano",
                ...,
            },
        })
        const coupon = await this.registerCoupon(
            current_user_email, 
            code,
            true,
        )
        return coupon
    }

당연히 가드를 통한 검증을 마친 이후에 호출이 되는 함수이며 위에 올려둔 발행 루틴을 사용하여 코드를 받아와 바로 뒤에 설명할 등록루틴을 통해 결과를 반환해주는 루틴이다.

등록 루틴은 가드를 거치지 않고 호출이 가능한 함수인데
이유는 코드 자체가 이미 가드를 거쳐 발행되었고, 내부에 발행된 코드를 검증하는 루틴 또한 갖추어 있다.

그리고 코드번호를 알고있는데 무슨 검증이 필요하겠는가?

무튼 아래는 등록 루틴 코드이다.

async registerCoupon(
        current_user_email: string,
        code: string,
        isStamp?: boolean
    ) : Promise<SimpleCouponEntity> {
        const data = await this._validate(code)
        const simple_data = {...} as SimpleCouponEntity
        try {
            const registered = await this.couponRepoistory.registerCoupon({
                current_user_email,
                coupon: simple_data,
                isStamp: isStamp
            })
            if(!registered) {
                if(isStamp !== undefined && isStamp) {
                    await this.deleteCoupon({
                        user_email: current_user_email,
                        code,
                        message: "스탬프의 개수가 모자라 쿠폰발행이 취소됩니다",
                        title: "스탬프 개수 부족",
                    })
                    throw ERROR.Accepted
                }
                throw ERROR.Accepted
            }

            let message = "쿠폰을 지급 받으셨습니다\n쿠폰함을 확인 해주세요!"
            if(isStamp !== undefined && isStamp) {
                message += "스탬프를 사용해"
            }
            this.sseService.pushMessage({
                notify_type: "user-notify",
                subject: {...} satisfies UserNotifySubject
            } satisfies SSESubject)
            
            this._updateCouponFromUser(
                current_user_email,
                code,
                data.coupon,
            )
            return simple_data
        } catch(e) {
            if(typeof ERROR.Accepted !== typeof e) {
                await this.deleteCoupon({
                    user_email: current_user_email,
                    code,
                    message: "쿠폰등록에 실패했습니다"
                })
            }
            throw e
        }
    }

위 설명과 같이 코드번호가 서버에서 발행한 코드가 맞는지 검증을 하고, 해당 정보를 통해 쿠폰데이터를 만들어 등록 트랜잭션을 날린다.

isStamp 인자는 이름과 같이 이게 스탬프를 이용했는가?를
판별하고 그에 따른 메세지를 다르게 처리하는데 쓰인다.

근데 또또또 작성할때 어디가 아팟는지 중복도 심하고 쓸모 없는 처리와 하면 안될 처리까지 해주고 있다.

이거는 수정된 코드도 같이 올릴텐데 진짜 창피하다...

async registerCoupon(
        current_user_email: string,
        code: string,
        isStamp?: boolean
    ) : Promise<SimpleCouponEntity> {
        const data = await this._validate(code)
        const simple_data = {...} as SimpleCouponEntity
        let message = "쿠폰을 지급 받으셨습니다\n쿠폰함을 확인 해주세요!"
        let title
        try {
            const registered = await this.couponRepoistory.registerCoupon({...})
            if(!registered) {
                if(isStamp !== undefined && isStamp) {
                    title = "스탬프 개수 부족"
                    message = "스탬프의 개수가 모자라 쿠폰발행이 취소됩니다"
                }
                throw ERROR.Accepted
            }
            if(isStamp !== undefined && isStamp) {
                message = "스탬프를 사용해" + message
            }
            this.sseService.pushMessage({
                notify_type: "user-notify",
                subject: {...} satisfies UserNotifySubject
            } satisfies SSESubject)
            
            this._updateCouponFromUser(
                current_user_email,
                code,
                data.coupon,
            )
            return simple_data
        } catch(e) {
            await this.deleteCoupon({
                user_email: current_user_email,
                code,
                message: "쿠폰등록에 실패했습니다",
                title,
            })
            throw e
        }
    }

메세지 처리랍시고 해뒀는데 전혀 처리도 안됐고, 에러 캐치 부분에서는 한 가지 에러에 대해서만 쿠폰등록 취소를 하고 있다.

그리고 스탬프를 이용한 요청일 경우 쿠폰삭제 함수를 호출하고 에러를 던지는데 또 에러 캐치부분에서 쿠폰삭제 함수를 호출한다 이게 무슨... 참담하다.

마치며

아니 무슨놈에 코드가 정리 할때마다 수정할게 보이는지
가면 갈 수록 점점 감당이 안될지경인데... 아무튼 이렇게 해놓고 다른거 또 할 생각중이던 나를 반성하며 정리 끝날때까지 새로운 프로젝트는 미뤄두고 후에 할 생각이다...

profile
개발 블로그보단 개발 일기 입...껄요?

0개의 댓글