[간병인 프로젝트 리팩토링] 회원가입 (NestJS)

윤학·2023년 7월 31일
0

간병인 프로젝트

목록 보기
3/8
post-thumbnail

이전 글들에서 휴대폰 인증을 통해 발송하고, 일치하는지 검사해서 로그인을 하는 과정까지 리팩토링 과정을 살펴보았다.

이번 글에서는 휴대폰 인증을 완료한 사용자가 간병인으로 회원가입을 하는 과정을 리팩토링 해볼 예정이다.(보호자로 가입하는 과정도 문항만 다를 뿐 전체 로직은 같다.)

가입 과정은 아래와 같이 필요한 문항들을 작성하며 이루어진다.

리팩토링하면서 느꼈지만 사용자들의 입력을 받지않고 보기를 제공하고 선택을 강제하는 방식이 훨씬 좋은 것 같다.

로직설명

큰 순서는 다음과 같다.
1. 역할(보호자, 간병인)에 맞게 회원가입 문항을 작성한다.
2. 첫 페이지는 공통 가입 내용이므로 계정 프로필로, 이후 페이지들은 역할에 맞게 역할 프로필로 저장된다.
3. 회원가입이 완료되고 앱 내에서 사용할 토큰을 발급한다.

그럼 얼른 살펴보자.

기존 코드

auth.controller.ts

    //회원가입 유저생성
    @Post('register')
    async createUser(@Body() createUserDto: CreateUserDto): Promise<{status: string, accessToken: string, user:UserDto}> {
        return await this.userService.createUser(createUserDto);
    }

회원가입을 위해 Controller에 회원가입 양식에 맞춘 Dto를 보내주었는데

create-user.dto.ts

export class CreateUserDto {
        
    firstRegister: object;
    
    secondRegister: object;
    
    lastRegister: object;
}

이렇게 object타입이라고만 작성을 해주었다.

회원가입을 진행하기 위해 UserService를 호출하자.

user.service.ts

    //회원가입
    async createUser(createUserDto: CreateUserDto): Promise<{ status: string, accessToken: string, user: UserDto }> {

        const token = new Token();

        //사용자 기본 정보 객체 생성
        const user = new User();
        user.id = createUserDto.firstRegister['id'];
        user.name = createUserDto.firstRegister['name'];
        user.birth = createUserDto.firstRegister['birth'];
        user.sex = createUserDto.firstRegister['sex'];
        user.purpose = createUserDto.firstRegister['purpose'];
        user.token = token;

        const purpose = createUserDto.firstRegister['purpose']; //가입 목적
        let eachPurposeObj: object = getPurposeObj(); //각 가입 목적별 객체 생성

        //각 목적별 객체 반환받기
        function getPurposeObj(): object {
            switch (purpose) {
                case '간병인':
                    let careGiverObj: object = createCareGiver(createUserDto, user);
                    return careGiverObj;
                case '활동보조사':
                    let assistantObj: object = createAssistant(createUserDto, user);
                    return assistantObj;
                case '보호자':
                    let protectorObj: object = createProtector(createUserDto, user);
                    return protectorObj;
            };
        };

        //목적별 테이블에 저장
        purpose === '간병인' ?
            await this.careGiverRepository.save(eachPurposeObj) :
            purpose === '활동보조사' ?
                await this.assistantRepository.save(eachPurposeObj) :
                await this.protectorRepository.save(eachPurposeObj)

        const accessToken = await this.setAccessToken(user.id); //회원가입과 동시에 로그인처리 위해
        const registerUser = await this.setRefreshToken(user.id); // accessToken과 refreshToken 발급하고 return
        return { status: 'success', accessToken: accessToken, user: registerUser }
    }

살펴보면 실제 화면에서 보여졌던 공통 회원가입문항(첫 페이지)의 데이터로 새로운 사용자를 생성한 이후 아직 발급이 되지 않은 토큰을 설정해주었다.

이후에 getPurposeObje() 함수를 통해 선택한 역할별로 작성한 문항들을 가지고 프로필을 생성하는데 간병인으로 진행했으니 간병인 프로필을 생성하는 코드를 살펴보자.

function createCareGiver(createUserDto: CreateUserDto, user: User): CareGiver {

    function getKeywords(): string {
        let keyWords = [];
        keyWords.push(createUserDto.lastRegister['careGiver']['keyWord1']);
        keyWords.push(createUserDto.lastRegister['careGiver']['keyWord2']);
        keyWords.push(createUserDto.lastRegister['careGiver']['keyWord3']);
        return keyWords.join();
    }

    const careGiver = new CareGiver();

    const strength1 = createUserDto.lastRegister['strength']['first'];
    const strength2 = createUserDto.lastRegister['strength']['second'];

    careGiver.weight = createUserDto.secondRegister['weight'];
    careGiver.career = createUserDto.secondRegister['career'];
    careGiver.pay = createUserDto.secondRegister['careGiver']['firstPay'];
    careGiver.startDate = createUserDto.secondRegister['startDate'];
    careGiver.nextHospital = createUserDto.secondRegister['careGiver']['nextHospital'];
    careGiver.possibleArea = createUserDto.secondRegister['possibleArea'].join();
    careGiver.license = createUserDto.secondRegister['license'].join();
    careGiver.suction = createUserDto.lastRegister['suction'];
    careGiver.toilet = createUserDto.lastRegister['toilet'];
    careGiver.bedsore = createUserDto.lastRegister['bedsore'];
    careGiver.washing = createUserDto.lastRegister['washing'];
    careGiver.strength = ({ first: strength1, second: strength2 });
    careGiver.keywords = getKeywords();
    careGiver.notice = createUserDto.lastRegister['careGiver']['notice'];
    careGiver.extraFee = createUserDto.lastRegister['careGiver']['extraFee'];
    careGiver.user = user;
    return careGiver;
}

getKeywords() 함수와 strength, license같은 필드를 보면 테이블을 정규화를 시키지 않고 JSON 형태로 저장하거나 배열들은 문자열로 변경해서 DB에 저장을 했었다.

그리고 생성한 Caregiver객체의 필드와 넘어온 Dto를 매핑시켜주어 프로필을 생성하여 반환해서 각 repository에 저장하였다.

마지막으로 토큰을 발급했던 과정을 살펴보자.

		/* 위의 service 코드의 마지막 부분 */
		const accessToken = await this.setAccessToken(user.id); //회원가입과 동시에 로그인처리 위해
        const registerUser = await this.setRefreshToken(user.id); // accessToken과 refreshToken 발급하고 return
        return { status: 'success', accessToken: accessToken, user: registerUser }

    async setAccessToken(id: string): Promise<string> {
        const accessPayload = { userid: id, date: new Date() };

        const accessToken = this.jwtService.sign(accessPayload, {
            secret: this.configService.get('jwt.accessToken.secretKey'),
            expiresIn: this.configService.get('jwt.accessToken.expireTime')
        });
        return accessToken;
    }
    
    async setRefreshToken(id: string, refresh?: boolean) {

        const refreshPayload = { userid: id, date: new Date() };
        const refreshToken: string = this.jwtService.sign(refreshPayload, {
            secret: this.configService.get('jwt.refreshToken.secretKey'),
            expiresIn: this.configService.get('jwt.refreshToken.expireTime')
        });

        //로그인에 성공하면 refreshToken 발급하고 유저정보를 넘겨준다.
        await this.dataSoucre.query(
            `UPDATE token TOKEN INNER JOIN user USER 
             ON TOKEN.index = USER.token_index 
             SET TOKEN.refreshToken = ?
             WHERE USER.id = ?`, [refreshToken, id]
        )

        //로그인에 성공시에만 유저 정보 넘겨주고, refreshToken 재발급시에는 업데이트만 해준다.
        if (refresh === undefined) {
            const user = await this.userRepository.findOne({
                select: ['id', 'email', 'name', 'purpose', 'isCertified', 'warning', 'token_index'],
                where: {
                    id: id
                }
            });
            return user;
        }
    };

AccessToken과 RefreshToken을 발급하는데 setRfreshToken() 메서드를 보면 토큰을 업데이트하고 파라미터로 refresh 여부를 받아 사용자를 조회해서 반환하는데,

해당 코드를 작성할 때 TypeORM의 save() 메서드의 작동법이나 반환 값을 잘 몰랐나보다.

읽기 조금 힘든 기존 코드를 얼른 리팩토링해보자.

리팩토링 코드

우선 전역으로 설정되어 있는 JwtGuard를 제외시키기 위해 @Public() 데코레이터를 달아주었고,

가입한 역할(보호자, 간병인)을 Body에 담아서 if문으로 검사하는 것 보단 path 파라미터로 구별해 각각의 라우터를 만드는 로직이 깔끔할 것 같아 아래와 같이 작성하였다.

user.controller.ts

    /* 보호자로 회원가입 */
    @Public()
    @Post('register/protector')
    async registerAsProtector(@Body() protectorRegisterDto: ProtectorRegisterDto): Promise<ClientDto> {
        return await this.userService.register(protectorRegisterDto);
    }

    @Public()
    @Post('register/caregiver')
    async registerAsCaregiver(@Body() caregiverReigsterDto: CaregiverRegisterDto): Promise<ClientDto> {
        return await this.userService.register(caregiverReigsterDto)
    }

그리고 해당 요청이 라우터에 도착하기 전 들어온 데이터들의 유효성 검사를 class-validator를 통해 수행하였다.(각 항목의 필드들에 대해서도 적용했지만 여기선 넘어가겠다.)

caregiver-register.dto.ts

export class CaregiverRegisterDto {
    @IsObject()
    @ValidateNested()
    @Type(() => CommonRegisterForm)
    firstRegister: CommonRegisterForm;

    @IsObject()
    @ValidateNested()
    @Type(() => CaregiverInfoForm)
    secondRegister: CaregiverInfoForm;

    @IsObject()
    @ValidateNested()
    @Type(() => CaregiverThirdRegisterDto)
    thirdRegister: CaregiverThirdRegisterDto;

    @IsObject()
    @ValidateNested()
    @Type(() => CaregiverLastRegisterDto)
    lastRegister: CaregiverLastRegisterDto;
}

그럼 이제 service를 호출하여 어떻게 로직이 진행되는지 살펴보자.

user.service.ts

    async register(registerDto: CaregiverRegisterDto | ProtectorRegisterDto): Promise<ClientDto> {
        const user = this.userMapper.mapFrom(registerDto.firstRegister); // 공통회원가입 양식으로부터 사용자 생성

        const savedUser = await this.userRepository.save(user); // DB에 저장하고 ID를 발급받음

        await this.addProfile(savedUser.getId(), registerDto); // 각자의 역할 프로필 저장

        return await this.authService.createAuthentication(savedUser); // 새로운 토큰들을 발급받아 저장
    }

일단 Dto들로부터 어떤 객체로 변환하는 로직들은 한 곳에서 관리하고 싶어 mapper를 만들어 내부에서 수행하였다.

user.mapper.ts

    mapFrom(commonRegisterDto: CommonRegisterForm): User {
        return new User(
            commonRegisterDto.name,
            commonRegisterDto.purpose,
            LOGIN_TYPE.PHONE,
            this.createEmailByLoginType(LOGIN_TYPE.PHONE),
            this.createPhoneByLoginType(LOGIN_TYPE.PHONE, commonRegisterDto.id),
            new UserProfile(commonRegisterDto.birth, commonRegisterDto.sex),
            null
        )
    };

로그인 타입이 휴대폰이냐 이메일이냐에 따라 객체가 둘 중 하나만 생성될 것을 생각하고 만들었는데 현재는 Email에 대한 로직은 리팩토링 하지 않아서 createEmailByLoginType()은 null이 반환되어 사실상 휴대폰 객체와 사용자 프로필 객체만 새로 생성된다.

그리고 토큰의 payload에 userId를 포함시킬 생각이여서 우선 null값으로 채운 후 반환한다.

mapper를 거쳐서 새로 생성된 사용자를 DB에 우선적으로 저장하여 id를 발급받고 역할 별 프로필을 추가한다.

	const savedUser = await this.userRepository.save(user); // DB에 저장하고 ID를 발급받음
	await this.addProfile(savedUser.getId(), registerDto); // 각자의 역할 프로필 저장

    /* 가입 목적별 프로필 추가 */
    private async addProfile(userId: number, registerDto: CaregiverRegisterDto | ProtectorRegisterDto) {
        registerDto.firstRegister.purpose == ROLE.CAREGIVER ?
            await this.caregiverProfileService.addProfile(userId, registerDto as CaregiverRegisterDto) : 
                await this.patientProfileService.addProfile(userId, registerDto as ProtectorRegisterDto);
    };

CaregiverProfileService에서도 역시 작성된 문항을 CaregiverProfile객체로 변환해주는데 mapper를 사용했다.

caregiver-profile.service.ts

    /* 회원가입시 새로운 프로필 추가 */
    async addProfile(userId: number, caregiverRegisterDto: CaregiverRegisterDto): Promise<void> {
        const caregiverProfile = this.caregiverProfileMapper.mapFrom(userId, caregiverRegisterDto);
        await this.caregiverProfileRepository.save(caregiverProfile);
    } 

caregiver-profile.mapper.ts

    mapFrom(userId: number, caregiverRegisterDto: CaregiverRegisterDto): CaregiverProfile {
        const { secondRegister, thirdRegister, lastRegister } = caregiverRegisterDto;
        return new CaregiverProfileBuilder( new ObjectId() )
            .userId(userId)
            .weight(secondRegister.weight)
            .career(secondRegister.career)
            .pay(secondRegister.pay)
            .possibleDate(secondRegister.possibleDate)
            .possibleAreaList(secondRegister.possibleAreaList)
            .licenseList(this.toLicenseList(secondRegister.licenseList))
            .nextHosptial(secondRegister.nextHospital)
            .helpExperience(thirdRegister.helpExperience)
            .strengthList(thirdRegister.strengthList)
            .tagList(thirdRegister.tagList)
            .notice(lastRegister.notice)
            .additionalChargeCase(lastRegister.additionalChargeCase)
            .isPrivate(false) // 처음 프로필 생성될 시 자동 공개 프로필
            .warningList()
            .build()
    };

    private toLicenseList(licenseList: string[]): License[] {
        /* 자격증을 증명전까지 false */
        return licenseList.map(license => new License(license, false))
    };

달라진게 있다면 Builder 패턴을 사용했는데 생성자 초기화를 하기엔 너무 많기에 가독성과 유지보수면에서 떨어질거라 생각했다.

근데 위의 이유보단 이후에 당장 테스트 코드를 작성할 때 필요한 필드만 설정하여 테스트 할 수 있어 굉장히 편했다.

Builder는 target 객체의 setter를 이용하여 간단하게 만들었다.

profile.builder.ts

export class CaregiverProfileBuilder {
    private caregiverProfile: CaregiverProfile;

    constructor(id: ObjectId) { 
        this.caregiverProfile = new CaregiverProfile(id);
    };

    userId(userId: number): this {
        this.caregiverProfile.setUserId(userId);
        return this;
    };

    weight(weight: number): this {
        this.caregiverProfile.setWeight(weight);
        return this;
    };

    career(career: number): this {
        this.caregiverProfile.setCareer(career);
        return this;
    };

    pay(pay: number): this {
        this.caregiverProfile.setPay((pay));
        return this;
    };

    possibleDate(date: PossibleDate): this {
        this.caregiverProfile.setPossibleDate(date);
        return this;
    };

    nextHosptial(description: string): this {
        this.caregiverProfile.setNextHosptail(description);
        return this;
    };

    notice(notice: string): this {
        this.caregiverProfile.setNotice(notice);
        return this;
    };
    
    helpExperience(list: CaregiverHelpExperience): this {
        this.caregiverProfile.setHelpExperience(list);
        return this;
    };

    additionalChargeCase(situation: string): this {
        this.caregiverProfile.setAdditionalChargeCase(situation);
        return this;
    };

    possibleAreaList(areaList: string []): this {
        this.caregiverProfile.setPossibleAreaList(areaList);
        return this;
    };

    licenseList(licenseList: License []): this {
        this.caregiverProfile.setLicenseList(licenseList);
        return this;
    };

    strengthList(strengthList: string []): this {
        this.caregiverProfile.setStrengthList(strengthList);
        return this;
    };

    tagList(tagList: string []): this {
        this.caregiverProfile.setTagList(tagList);
        return this;
    };

    isPrivate(isPrivate: boolean): this {
        this.caregiverProfile.setIsPrivate(isPrivate);
        return this;
    }

    warningList(warningList: Warning [] = []): this {
        this.caregiverProfile.setWarning(warningList);
        return this;
    }

    build(): CaregiverProfile { return this.caregiverProfile; };
};

근데 왜 id가 ObjectId 타입인가요?

리팩토링을 진행하면서 처음에 해당 데이터들도 MySQL을 이용하여 테이블을 설계했다.

근데 null값이 와도 되는 컬럼도 있고, 배열 값들도 있어 정규화를 진행 해보니 너무 많은 테이블로 나누어져 프로필 리스트에서 조회를 할 때를 생각하면 7~8개정도의 테이블을 조인해야 했다.

그래서 프로필에 대한 데이터들은 한번에 읽혀지는 것이 대부분이고, 컬럼마다 저장되는 데이터들도 일정하지 않아서 MongoDB에 저장하고, 해당 프로필의 사용자 데이터를 찾기 위해 userId만 같이 저장하였다.

역할 프로필 데이터까지 추가가 됐으면 토큰을 발급받고 DB에 업데이트하는 로직은 AuthService에게 위임하자.

auth.service.ts

    /* UserService의 마지막 코드*/
	return await this.authService.createAuthentication(savedUser); // 새로운 토큰들을 발급받아 저장

    /* 회원가입 시 새로운 인증을 발급 */
    async createAuthentication(user: User): Promise<ClientDto> {
        const newAuthentication = await this.generateAuthentication(user);
        user.setAuthentication(newAuthentication);
        await this.userRepository.save(user);
        return await this.addToSessionListAndMapToDto(user, newAuthentication.accessToken);
    }
    
    /* 새로운 전체 인증(AccessToken, RefreshToken)을 생성하는 메서드 */
    private async generateAuthentication(user: User): Promise<NewUserAuthentication> {
        return await this.tokenService.generateNewUsersToken(user);
    }

    /* 세션 리스트에 사용자를 추가하고 사용자에게 넘겨줄 Dto로 변환 */
    private async addToSessionListAndMapToDto(user: User, newAccessToken: string): Promise<ClientDto> {
        await this.sessionService.addUserToList(user.getId(), newAccessToken);
        return this.authMapper.toDto(user);
    }
}

AuthService에서는 TokenService의 힘을 빌려 실제 토큰을 생성한다.

token.service.ts

    /* 새로운 사용자의 인증(토큰) 발급 */
    async generateNewUsersToken(user: User): Promise<NewUserAuthentication> {
        const [accessToken, refreshToken] = await Promise.all([this.generateAccessToken(user), this.generateRefreshToken(user)])
        return new NewUserAuthentication(accessToken, refreshToken);
    };

    async generateAccessToken(user: User): Promise<string> {
        return await this.jwtService.signAsync( this.generateJwtPayload(user), {
            secret: this.accessTokenSecret,
            expiresIn: this.accessTokenExpiresIn
        });
    };

    async generateRefreshToken(user: User): Promise<RefreshToken> {
        const [uuid, refreshToken] = [
            UUIDUtil.generateOrderedUuid(),
            await this.jwtService.signAsync( this.generateJwtPayload(user), {
                secret: this.refreshTokenSecret,
                expiresIn: this.refreshTokenExpiresIn
            })
        ];
        return new RefreshToken(uuid, refreshToken);   
    };

    /* JwtToken의 Payload 생성 */
    private generateJwtPayload(user: User): JwtPayload {
        return {
            userId: user.getId(),
            role: user.getRole(),
            createdAt: new Date()
        };
    }

새로 생성된 토큰을 가지고 사용자는 앱 내에서 활동할 수 있게 세션정보에 해당 사용자의 유효 토큰을 업데이트 해주고 클라이언트에게는 mapper를 통해 꼭 필요한 데이터만 받을 수 있도록 한다.

    return await this.addToSessionListAndMapToDto(user, newAuthentication.accessToken);
	
	/* 세션 리스트에 사용자를 추가하고 사용자에게 넘겨줄 Dto로 변환 */
    private async addToSessionListAndMapToDto(user: User, newAccessToken: string): Promise<ClientDto> {
        await this.sessionService.addUserToList(user.getId(), newAccessToken);
        return this.authMapper.toDto(user);
    }

auth.mapper.ts

@Injectable()
export class AuthMapper extends UserAuthCommonMapper {}

user-auth-common.mapper.ts

export abstract class UserAuthCommonMapper {
    toDto(user: User): ClientDto {
        return {
            name: user.getName(),
            accessToken: user.getAuthentication().getAccessToken(),
            refreshKey: user.getAuthentication().getRefreshKey()
        };
    }
}

이렇게 해서 역할 별 프로필까지 추가하는 회원가입 로직이 완성되었다.

리팩토링을 진행하면서 만들어놨던 메서드가 재사용되는 빈도가 많아질수록 편해지는 것 같다.

추가로 개선할 점...

  1. save()
    TypeORM에서 자체적으로 save()를 수행 시 데이터가 있는지 조회하고 변경내용이 있으면 update를 하는데 현재까지는 save에 맡기고 있어 select query가 많이 나간다.
    특정 컬럼을 update하는 메서드나 upsert 메서드를 추후에 생각해보자.

  2. 트랜잭션
    분산 데이터베이스를 사용하고, 누락되면 안되는 데이터다 보니 2개의 데이터베이스에 대해 묶어서 트랜잭션을 처리해야 하는데 아직 구현을 하지 못했다.

profile
해결한 문제는 그때 기록하자

0개의 댓글