[Nest.js]04. User, Webtoon 모듈

김지엽·2023년 10월 9일
0
post-custom-banner

1. 개요

이번 프로젝트인 "웹툰 추천 사이트"를 만들기 위해 User와 Webtoon 모듈을 구성한다.
User: 사용자 CRUD 및 사용자 정보 인증을 수행
Webtoon: 웹툰 CRUD 및 다양한 정보들로 Webtoon 검색

2. 프로젝트 구조

│  main.ts
│
├─app
│      app.controller.ts
│      app.module.ts
│      app.service.ts
│
├─caching
│      caching.module.ts
│
├─config-project
│      config-project.module.ts
│
├─constatns
│      redis.constants.ts
│
├─custom-provider
│      model.provider.ts
│
├─dto
├─sequelize
│  │  mysql_sequelize.module.ts
│  │
│  ├─config
│  │      config.json
│  │
│  ├─entity
│  │      user.model.ts
│  │      userWebtoon.model.ts
│  │      webtoon.model.ts
│  │
│  ├─migrations
│  ├─models
│  │      index.js
│  │
│  └─seeders
├─user
│      user.controller.ts
│      user.module.ts
│      user.service.ts
│
└─webtoon
        webtoon.controller.ts
        webtoon.module.ts
        webtoon.service.ts

터미널에서 "tree /f 폴더이름" 명령어로 파일을 포함한 해당 폴더의 구조를 출력할 수 있다.

3. User

- Module

[user.module.ts]
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';

import { SequelizeModule } from '@nestjs/sequelize';
import { User } from 'src/sequelize/entity/user.model';
import { userProvider } from 'src/custom-provider/model.provider';

@Module({
  imports: [SequelizeModule.forFeature([User])],
  exports: [UserService, userProvider],
  controllers: [UserController],
  providers: [UserService, userProvider]
})
export class UserModule {}

exports: [UserService, userProvider]을 통해 User모듈에서 sequelize를 통해 mysql의 User 모델을 주입할 수 있다.
exports: [UserService]를 하지 않으면 다른 모듈에서 UserService를 사용할 때 User모듈을 import 하더라도 providers에 UserService를 넣어줘야 한다. (넣지 않으면 DI 에러가 발생) 번거러운 과정을 막기위해 export 해주었다.

- DTO

[user.dto.ts]
import { applyDecorators } from "@nestjs/common";
import { PartialType } from "@nestjs/mapped-types";
import { IsDate, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator";

// 모든 프로퍼티에 @IsNotEmpty()를 적용
function IsNotEmptyOnAllProperties() {
    return applyDecorators(
        IsNotEmpty({
            message: "This field should not be empty",
        }),
    );
}

@IsNotEmptyOnAllProperties()
export class CreateUserDataDto {
    @IsString()
    userId: string;

    @IsString()
    password: string;

    @IsString()
    name: string;

    @IsNumber()
    age: number;

    @IsString()
    sex: string;

    @IsString()
    address: string;
}

// CreateUserDataDto의 속성들을 partial(선택적)으로 해서 확장한다.
export class UpdateUserDataDto extends PartialType(CreateUserDataDto) {
    @IsNotEmpty()
    @IsString()
    userId: string; 
}

CreateUserDataDto와 UpdateUserDataDto는 속성들은 일치하기에 똑같은 코드를 한번 더 작성하는 것보다 PartialType를 이용해
프로퍼티들을 IsOptional로만 바꿔주고 userId는 필수 프로퍼티이니 다시 IsNotEmpty를 붙여놓는다.

PartialType는 nestjs의 mapped-types의 헬퍼 메서드로 typescript의 유틸리티 타입들과 비슷한 역할을 한다.
$ npm i @nestjs/mapped-types - 설치

- Controller

[user.controller.ts]
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDataDto, UpdateUserDataDto } from 'src/dto/user.dto';
import { JwtAccessTokenGuard } from 'src/auth/guard/accessToken.guard';

@Controller('user')
export class UserController {

    constructor(private readonly userService: UserService) {}

    @UseGuards(JwtAccessTokenGuard)
    @Get(":id")
    async getUser(@Param("id") userId: string) {
        const user = await this.userService.getUser(userId);
        const transformedUser = {
            userId: user.userId,
            name: user.name,
            age: user.age,
            sex: user.sex,
            address: user.address
        };
        return transformedUser;
    }

    @Get("/:id/readWebtoons")
    getUserReadWebtoons(@Param("id") userId: string) {
        return this.userService.getUserReadWebtoonIds(userId);
    }

    @Post("newUser")
    createUser(@Body() createUserDataDto: CreateUserDataDto) {
        return this.userService.createUser(createUserDataDto);
    }

    @Patch("updateUser")
    updateUser(@Body() updateUserDataDto: UpdateUserDataDto) {
        return this.userService.updateUser(updateUserDataDto);
    }

    @Delete(":id")
    deleteUser(@Param("id") userId: string) {
        return this.userService.deleteUser(userId);
    }
}

createUser, updateUser는 다른 메서드들과 달리 Body의 프로퍼티의 개수가 많고 프로퍼티의 유효성 검사 및 코드가 복잡해지지 않도록 DTO로 바꿔주었다.

- Service

import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { UserCacheTTL, UserReadCacheTTL } from 'src/constatns/cache.constants';
import { CreateUserDataDto, UpdateUserDataDto } from 'src/dto/user.dto';
import { User } from 'src/sequelize/entity/user.model';
import { Webtoon } from 'src/sequelize/entity/webtoon.model';

import * as bcrypt from "bcrypt";

@Injectable()
export class UserService {

    constructor(
        @Inject("USER") private readonly userModel: typeof User,
        @Inject(CACHE_MANAGER) private readonly cacheManager: Cache
    ) {}

    async getUser(userId: string): Promise<User> {
        const userCacheKey: string = `userCache-${userId}`;

        const userCache: string = await this.cacheManager.get(userCacheKey);
        if (userCache) {
            const userInfo = JSON.parse(userCache) as User;
            return userInfo;
        }

        // currentRefreshToken을 제외한 사용자 정보를 id를 통해 가져오기
        const exUser: User = await this.userModel.findOne({
            where: { userId },
        });
        // 사용자가 없다면 에러 throw
        if (!exUser) { 
            throw new NotFoundException(`userId ${userId} is not exist.`);
        }

        await this.cacheManager.set(
            userCacheKey,
            JSON.stringify(exUser),
            UserCacheTTL
        );
        
        return exUser;
    }

    async getUserReadWebtoonIds(userId: string): Promise<string[]> {
        const userReadCacheKey: string = `userReadCache-${userId}`;

        const userReadCache: string = await this.cacheManager.get(userReadCacheKey);
        if (userReadCache) {
            return JSON.parse(userReadCache);
        }

        const exUser: User = await this.getUser(userId);

        // 사용자가 이미 읽은 웹툰 목록 
        const readWebtoons: Webtoon[] = await exUser.$get("readWebtoons", {
            attributes: ["webtoonId"],
        });

        // 웹툰 목록을 id 배열로 바꾸기
        const readwebtoonIds: string[] = readWebtoons.map((webtoon) => {
            return webtoon.webtoonId;
        });

        await this.cacheManager.set(
            userReadCacheKey,
            JSON.stringify(readwebtoonIds),
            UserReadCacheTTL,
        );

        return readwebtoonIds;
    }

    async createUser(createUserData: CreateUserDataDto): Promise<boolean> {
        const { userId, password } = createUserData;
        // 비밀번호 암호화
        const hashPassword = await bcrypt.hash(password, 10);

        const exUser = await this.userModel.findOne({ where: { userId } });
        if (exUser) {
            throw new ConflictException(`userId ${userId} is already exist.`);
        }

        await this.userModel.create({
            ...createUserData,
            password: hashPassword, // 데이터베이스에는 암호환된 비밀번호 저장
        });
        
        console.log(`[Info]userId ${userId} is created.`);

        return true;
    }

    async deleteUser(userId: string): Promise<boolean> {
        await this.getUser(userId);

        await this.userModel.destroy({
            where: { userId },
        });

        const userCacheKey: string = `userCache-${userId}`;
        await this.cacheManager.del(userCacheKey);

        console.log(`[Info]userId ${userId} is removed.`);

        return true;
    }

    async updateUser(updateUserData: UpdateUserDataDto): Promise<boolean> {
        const { userId }: { userId: string } = updateUserData;
        await this.getUser(userId);

        // updateUserData의 있는 변경된 사항들만 update
        this.userModel.update({
            ...updateUserData,
        }, {
            where: { userId },
        });

        const userCacheKey: string = `userCache-${userId}`;
        await this.cacheManager.del(userCacheKey);

        console.log(`[Info]userId ${userId} is changed.`);

        return true;
    }
}

userService 클래스를 작성하면서 가장 주의한건 보안메서드 호출 횟수이다.

예를 들어 클라이언트에서 사용자 정보를 요청하는 횟수는 상당히 많을 것이고, 그래서 caching을 적용했다. 하지만 redis에 저장됨은 물론이고 클라이언트에도 사용자 정보를 넘기기 때문에 password를 제외한 나머지 정보를 넘기기로 했다.

4. Webtoon

- Module

[webtoon.module.ts]
import { Module } from '@nestjs/common';
import { WebtoonController } from './webtoon.controller';
import { WebtoonService } from './webtoon.service';
import { webtoonProvider } from 'src/custom-provider/model.provider';
import { SequelizeModule } from '@nestjs/sequelize';
import { Webtoon } from 'src/sequelize/entity/webtoon.model';

@Module({
    imports: [SequelizeModule.forFeature([Webtoon])],
    exports: [WebtoonService, webtoonProvider],
    controllers: [WebtoonController],
    providers: [WebtoonService, webtoonProvider],
})
export class WebtoonModule {}

- DTO

[webtoon.dto.ts]
import { IsNotEmpty, IsNumber, IsString } from "class-validator";
import { IsNotEmptyOnAllProperties } from "./function.dto";
import { PartialType } from "@nestjs/mapped-types";

@IsNotEmptyOnAllProperties()
export class InsertWebtoonDto {
    
    @IsString()
    webtoonId: string;

    @IsString()
    title: string;

    @IsString()
    author: string;
    
    @IsNumber()
    episodeLength: number;

    @IsString()
    thumbnail: string;

    @IsString()
    service: string;

    @IsString()
    updateDay: string;

    @IsString()
    category: string;

    @IsString()
    genres: string;

    @IsNumber()
    genreCount: number;

    @IsString()
    description: string;

    @IsNumber()
    fanCount: number;

}

export class UpdateWebtoonDto extends PartialType(InsertWebtoonDto) {
    @IsNotEmpty()
    @IsString()
    webtoonId: string;
}

- Controller

[webtoon.controller.ts]
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { WebtoonService } from './webtoon.service';
import { InsertWebtoonDto, UpdateWebtoonDto } from 'src/dto/webtoon.dto';

@Controller('webtoon')
export class WebtoonController {

    constructor(private webtoonService: WebtoonService) {}

    @Get("content/:id")
    getWebtoon(@Param("id") webtoonId: string) {
        return this.webtoonService.getWebtoonForId(webtoonId);
    }

    @Get("day/:day")
    getAllWebtoonForDay(@Param("day") day: string) {
        return this.webtoonService.getAllWebtoonForDay(day);
    }

    @Get("finished")
    getAllFinishedWebtoon() {
        return this.webtoonService.getAllFinishedWebtoon();
    }

    @Post("insertWebtoon")
    insertWebtoon(@Body() insertWebtoonDto: InsertWebtoonDto) {
        return this.webtoonService.insertWebtoon(insertWebtoonDto);
    }

    @Patch("updateWebtoon")
    updateWebtoon(@Body() updateWebtoonDto: UpdateWebtoonDto) {
        return this.webtoonService.updateWebtoonForOption(updateWebtoonDto);
    }

    @Delete("deleteWebtoon/:id")
    deleteWebtoon(@Param("id") webtoonId: string) {
        return this.webtoonService.deleteWebtoon(webtoonId);
    }

}

- Service

[webtoon.service.ts]
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { webtoonCacheTTL } from 'src/constatns/redis.constants';
import { InsertWebtoonDto, UpdateWebtoonDto } from 'src/dto/webtoon.dto';
import { Webtoon } from 'src/sequelize/entity/webtoon.model';
import { SelectOption } from 'src/types/webtoon.type';

@Injectable()
export class WebtoonService {

    constructor(
        @Inject("WEBTOON") private webtoonModel: typeof Webtoon,
        @Inject(CACHE_MANAGER) private cacheManager: Cache
    ) {}

    async getAllWebtoon(): Promise<Webtoon[]> {
        return this.webtoonModel.findAll();
    }


    async getAllWebtoonForIds(ids: string[]): Promise<Webtoon[]> {
        const webtoons: Webtoon[] = [];
        for (const id of ids) {
            const webtoon: Webtoon = await this.getWebtoonForId(id);
            if (webtoon) webtoons.push(webtoon);
        }
        return webtoons;
    }


    async getWebtoonForId(id: string): Promise<Webtoon> {

        // cache-key
        const webtoonCacheKey: string = `webtoonCache-${id}`; 

        // cache 값이 있다면 바로 값을 반환
        const webtoonCache: string = await this.cacheManager.get(webtoonCacheKey);
        if (webtoonCache) {
            const webtoonInfo = JSON.parse(webtoonCache);
            return new Webtoon(webtoonInfo);
        }

        // database에서 해당 id의 웹툰 가져오기
        const webtoon: Webtoon =  await this.webtoonModel.findOne({ where: { webtoonId: id }});
        if (!webtoon) {
            throw new Error("webtoonId is not exsit..");
        }

        // cache 값 저장
        this.cacheManager.set(
            webtoonCacheKey,
            JSON.stringify(webtoon),
            webtoonCacheTTL, // cache 만료 시간
        );

        return webtoon;
    }


    // 요일별 웹툰 가져오기(캐시)
    async getAllWebtoonForDay(day: string): Promise<Webtoon[]> {
        const daywebtoonCacheKey: string = `webtoonCache-${day}`;

        const dayWebtoonCache: string = await this.cacheManager.get(daywebtoonCacheKey);
        if (dayWebtoonCache) {
            const dayWebtoon = JSON.parse(dayWebtoonCache);
            return dayWebtoon;
        }

        const webtoons: Webtoon[] =  await this.webtoonModel.findAll(
            {
                attributes: { exclude: ["embVector"] },
                where: { updateDay: day }
            }
        );
        if (!webtoons) {
            // throw WebtoonNotFoundException(`webtoonDay(${day}) not found`);
            return null;
        }

        this.cacheManager.set(daywebtoonCacheKey, JSON.stringify(webtoons));

        return webtoons;
    }


    // 완결 웹툰 모두 가져오기(캐시)
    async getAllFinishedWebtoon(): Promise<Webtoon[]> {
        const finishedWebtoonCacheKey: string = `webtoonCache-finished`;

        const finishedWebtoonCache: string = await this.cacheManager.get(finishedWebtoonCacheKey);
        if (finishedWebtoonCache) {
            const finishedWebtoon = JSON.parse(finishedWebtoonCache);
            return finishedWebtoon;
        }

        const webtoons: Webtoon[] =  await this.webtoonModel.findAll(
            {
                attributes: { exclude: ["embVector"] },
                where: { updateDay: "완" }
            }
        );
        if (!webtoons) {
            // throw WebtoonNotFoundException(`webtoonDay(finished) not found`);
            return null;
        }

        this.cacheManager.set(finishedWebtoonCacheKey, JSON.stringify(webtoons));

        return webtoons;
    };
    
	
    // 옵션에 맞는 웹툰 가져오기
    async getAllWebtoonForOption(option: SelectOption): Promise<Webtoon[]> {
        let selectQeury: string = "SELECT * FROM Webtoons WHERE ";

        // 초기 조건 추가(AND를 쓰기위한 문법에 필요)

        selectQeury += `fanCount > 0 `;

        /// 조건 여부에 따른 쿼리 문자열 추가

        if (option.genreUpCount) {
            selectQeury += `(LENGTH(genres) - LENGTH(REPLACE(genres, '"', ''))) / 2 > ${option.genreUpCount} `;
        }

        if (option.genreDownCount) {
            selectQeury += `(LENGTH(genres) - LENGTH(REPLACE(genres, '"', ''))) / 2 < ${option.genreDownCount} `;
        }

        if (option.service) {
            selectQeury += `AND service=\"${option.service}\" `;
        }

        if (option.category) {
            selectQeury += `AND category=\"${option.category}\" `;
        }

        if (option.fanCount) {
            selectQeury += `AND fanCount > ${option.fanCount} `;
        }

        if (option.updateDay) {
            selectQeury += `AND updateDay=${option.updateDay} `;
        }

        if (option.descriptionLength) {
            selectQeury += `AND LENGTH(description) >= ${option.descriptionLength} `;
        }

        ///

        let data, webtoonList: Webtoon[];
        try {
            data = (await this.webtoonModel.sequelize.query(selectQeury)) as (Webtoon[])[];
            webtoonList = data[0];
        } catch (e) {
            console.log(e);
            return null;
        }

        return webtoonList;
    }


    // insert
    // insert

    async insertWebtoon(insertWebtoonDto: InsertWebtoonDto): Promise<boolean> {
        const { webtoonId } = insertWebtoonDto;
        const webtoon = await this.webtoonModel.findOne({ where: { webtoonId } })

        if (webtoon) {
            // throw WebtoonAlreadyExistException();
            console.log("webtoon Id is exist");
            return false;
        }
        
        try {
            await this.webtoonModel.create({
                ...insertWebtoonDto,
            });
        } catch (e) {
            console.log("webtoon data is wrong");
            return false;
        }

        return true;
    }


    // Patch
    // Patch

    async updateWebtoonForOption(updateWebtoonDto: UpdateWebtoonDto): Promise<boolean> {
        const { webtoonId }: { webtoonId: string } = updateWebtoonDto;
        await this.getWebtoonForId(webtoonId);

        try {
            await this.webtoonModel.update(
                { ...updateWebtoonDto },
                { where: { webtoonId } },
            );
        } catch (e) {
            console.log(e);
            return false;
        }

        // category 변경시에는 genres의 첫번쨰 값도 같이 변경
        if (updateWebtoonDto.category) {
            const webtoon = await this.webtoonModel.findOne({ where: { webtoonId } });
            const genres: string[] = JSON.parse(webtoon.genres);
            const { category } = updateWebtoonDto;

            // 변경된 category와 genres의 첫번째 값이 다를 경우에만 변경
            if (genres[0] !== category) {
                genres[0] = category;
                const genresText: string = JSON.stringify(genres);

                await this.updateWebtoonForOption({ webtoonId, genres: genresText });
            }
        }

        // 웹툰 수정, 삭제시 캐시된 값도 삭제
        const webtoonCacheKey: string = `webtoonCache-${webtoonId}`;
        await this.cacheManager.del(webtoonCacheKey);

        return true;
    }


    /// delete
    /// delete

    async deleteWebtoon(id: string): Promise<boolean> {
        await this.getWebtoonForId(id);

        try {
            this.webtoonModel.destroy(
                { where: { webtoonId: id }
            });
        } catch (e) {
            return false;
        }

        // 웹툰 수정, 삭제시 캐시된 값도 삭제
        const webtoonCacheKey: string = `webtoonCache-${id}`;
        await this.cacheManager.del(webtoonCacheKey);

        return true;
    }
}

webtoonService를 작성하면서 주의한건 마찬가지로 메서드 호출 횟수, 데이터, 캐싱이었다.

웹툰 데이터는 중복되거나 프로퍼티가 null인 채로 데이터가 저장되면 정말 안타까운 상황이 발생하기에... 데이터의 확인과 데이터를 삭제하면 캐싱된 값도 같이 삭제하는지 같은 데이터 처리를 주의하면 작성하였다.

발전한 점

- 보안 문제

리팩토링 전에는 클라이언트에서 user 정보를 호출했을때 password, refreshToken등을 전부 그대로 넘겨주었다. 지나고 보니 이는 매우 위험한 행동인 것 같다. 그래서 controller-level에서 민감한 정보를 제외한 user 정보를 반환하도록 변경했다.

controller-level에서 수정한 이유는 타 모듈에서 userService의 getUser를 사용할 때 password, refreshToken을 필요로 하기 때문이다.

- 캐싱 데이터 문제

리팩토링 전에는 그냥 이상한건가 싶다하던 문제를 드디어 발견했다.
바로 캐싱 데이터의 문제였다. service-level의 getUser 메서드를 호출시 캐싱을 진행한다. 하지만 그 후에 해당 userId의 user 정보를 update, delete 했다면? 데이터베이스는 변했지만 그 후 얼마동안은 캐싱 데이터가 그대로 호출된다.

이를 발견하고 이제까지 그냥 삭제랑 수정이 늦게 반영되는건가 싶던 문제를 드디어 깨닫고 delete, update 메서들를 호출시에 캐싱 데이터도 삭제되도록 변경했다.

- 불필요한 메서드 통합 및 삭제

리팩토링 전에는 타입스크립트 문법에 지금보다 덜 익숙한 시절이라 아주 비효율적으로 메서드를 작성하고 코드는 난잡해졌다.

예를 들어, 리팩토링 후의 updateUser 메서드 처럼 거의 모든 변경을 하나의 메서드로 처리 할 수 있지만 리팩토링 전에는 변경사항 하나 하나 마다 update 메서드를 만들었다.

그래도 그 시절보다 성장했다는게 느껴져서 기분이 썩 나쁘지만도 않다.

글을 마치며..

코드를 리팩토링 하다보니 예전에 짯던 코드가 얼마나 재사용성이 떨어지고.. 난잡했는지 알게되었다. updateWebtoonForOption 처럼 하나의 함수로 작성이 가능한 코드를 예전에는 옵션별로 전부 다 다른 함수를 작성하였다.

그리고 이전에 짰던 코드는 웹툰을 삭제하거나 수정했는데도 캐시된 값이 남아있는 등 데이터의 처리도 불완전했다.

앞으로도 코드를 리팩토링 하면서 내가 부족했던 부분들을 많이 채워나갈 생각을 하니 가슴이 두근거린다.

참고

https://docs.nestjs.com/ - nest 공식 문서

profile
욕심 많은 개발자
post-custom-banner

0개의 댓글