[Nest.js]05. 예외 처리

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

1. 개요

서버에서의 예외 처리는 정말 중요하다. 일단 서버가 24시간 계속 돌아가려면 예상치 못한 오류에서 예외 처리가 되어있어야 한다. 그리고 클라이언트가 request를 보냈을때 각각 다른 상황에서 똑같은 오류 응답을 한다면 개발자 입장에서 무슨 오류인지도 모를 것이다. 그렇기 때문에 예외 처리는 서비스를 이용하는 사용자, 개발하는 개발자 모두의 입장에서 중요하다.

nest에서는 기본적으로 예외 필터가 내장되어 있기 때문에 express 처럼 직접 에러를 처리 할 필요없이 자동으로 처리된다. 하지만 기본적으로 내장된 필터 외에 다른 로직이 필요한 경우 custom으로 작성할 수도 있다. 오늘은 그 두개를 다 다뤄볼려고 한다.

2. 내장 필터

- Exception Filter

기본적으로 내장된 필터는 따로 UseInterceptors를 통해 따로 필터를 달아두지 않아도 자동으로 필터 처리된다.

- 내장 HTTP 예외

필터가 기본적으로 내장되어 있는 것처럼 HTTP 예외들도 내장되어 있다. 지난 포스트에서 했던 User, Webtoon 모듈에서 예외처리를 해줄려고 한다.

[webtoon.service.ts]
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { ConflictException, HttpException, Inject, Injectable, NotFoundException } 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 NotFoundException(`webtoonId ${id} 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 new NotFoundException(`webtoon's day ${day} is wrong.`);
        }

        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: "완" }
            }
        );

        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) {
            throw new NotFoundException("option is wrong");
        }

        return webtoonList;
    }


    // insert
    // insert

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

        if (webtoon) {
            throw new ConflictException(`webtoonId ${webtoonId} is already exist.`);
        }
        
        await this.webtoonModel.create({
            ...insertWebtoonDto,
        });

        return true;
    }


    // Patch
    // Patch

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

        await this.webtoonModel.update(
            { ...updateWebtoonDto },
            { where: { webtoonId } },
        );
        
        // 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);

        await this.webtoonModel.destroy(
            { where: { webtoonId: id }
        });

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

        return true;
    }
}

기본 내장 예외는 따로 구현이 필요 없이 필요한 상황에 필요한 예외를 던지면 되기 때문에 아주 간편하다.

이때 주의 하면서 작성한건 getWebtoonForId 메서드를 작성한 후에
update, delete, create 등에 같은 예외 처리 코드를 반복하지 않기 위해 getWebtoonForId를 재사용해 주었다.

[user.service.ts]
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/caching/cache.constants';
import { CreateUserDataDto, UpdateUserDataDto } from 'src/dto/user.dto';
import { User } from 'src/sequelize/entity/user.model';

import * as bcrypt from "bcrypt";
import { Webtoon } from 'src/sequelize/entity/webtoon.model';

@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;
        }

        // password를 제외한 사용자 정보를 id를 통해 가져오기
        const exUser: User = await this.userModel.findOne(
            {
                where: { userId },
                attributes: { exclude: ["password", "currentRefreshToken"] }
            }
        );
        // 사용자가 없다면 에러 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 },
        });

        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 },
        });

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

        return true;
    }
}

3. Custom Exception Filter

사용자 지정 오류가 필요하게 된 것은 DTO 때문이다. DTO를 통해서 데이터 검증을 하지만 클라이언트에서 응답하는 에러에서는 어떤 것이 잘못되었는지의 정보가 부족했다.

  1. 프로퍼티가 존재하는지 않는지에 대한 정보가 없다
  2. 문장 형태로 되어 있기에 클라이언트에서 사용이 번거롭다.
<exception filter 적용 >
"property people should not be exist"
"updateDay must be a string"
"category must be a string"
"genres must be a string"
"genreCount must be a number conforming to the specified constraints"
"description must be a string" 
"fanCount must be a number conforming to the specified constraints"

그래서 다음과 같은 형태로 변환을 할려고 한다.

<exception filter 적용 >
people: "exclude" // 제외 시켜야 한다는 의미
category: null
description: null
fanCount: null
genreCount: null
genres: null
updateDay: "string"

애초에 요청 데이터에도 존재하지 않아 값이 필요함을 알려 줄때 null을 요청에 요청 데이터에는 들어있긴 하지만 데이터 타입이 잘못되었을 때는 올바른 데이터 타입을 값에 넣은 형태이다. 그리고 요청 데이터에 제외되어야 하는 데이터는 "exclude"로 표시한다.

- Exception Filter

[dtoException.filter.ts]
import { ArgumentsHost, BadRequestException, Catch, ExceptionFilter } from "@nestjs/common";
import { Request, Response } from "express";

@Catch(BadRequestException)
export class DtoExceptionFilter implements ExceptionFilter {
    catch(exception: BadRequestException, host: ArgumentsHost) {
        const ctx = host.switchToHttp();
        const req = ctx.getRequest<Request>();
        const res = ctx.getResponse<Response>();

        // 상태 코드 및 예외 응답 가져오기
        const status = exception.getStatus();
        const exceptionResonse = exception.getResponse() as any;

        // 요청했던 프로퍼티 및 응답 오류 메시지 변환 -> [["updateDay", "string"], ["fanCount"]: "number"]
        const messages: string[] = exceptionResonse.message;
        const requestPropertys: Record<string, any> = req.body;
        let errorPropertys: Array<string[]>;
        if (messages instanceof Array) {

            errorPropertys = messages.map((message) => {
                const first = message.split(" ")[0];
                const property = first !== "property" ? first : message.split(" ")[1];
                const dtoType = first !== "property" ? message.split(" ")[4] : "exclude";
                return [ property, dtoType ];
            });
    
            // 요청 했던 프로퍼티와 오류 비교 후 존재 여부 도출 -> [["updateDay", "string"], ["fanCount", "null"]
            errorPropertys = errorPropertys.map((errorProperty) => {
                const transformed = [...errorProperty];
    
                if (transformed[0] in requestPropertys) {
                    return transformed;
                } else {
                    transformed[1] = null;
                    return transformed;
                }
            });
        }
        
        // [["updateDay", "string"], ["fanCount", "null"]] 이중 키, 값 배열 객체 리터럴로 변환
        // => { "updateDay": "string", "fanCount": "null" }
        const property: Record<string, string | null> = (
            errorPropertys ?
            Object.fromEntries(errorPropertys) :
            {}
        );

        res
        .status(status)
        .json({
            statusCode: status,
            timeStamp: new Date().toISOString(),
            path: req.url,
            message: messages,
            property
        });
    }
}

dto는 모든 모듈에서 사용하기에 전역으로 설정해두었다.

import { APP_FILTER } from "@nestjs/core";
import { DtoExceptionFilter } from "src/exception-filter/dtoException.filter";

export const DtoFilterProvider = {
    provide: APP_FILTER,
    useClass: DtoExceptionFilter,
};

글을 마치며

리팩토링 전에는 무작정 커스텀 예외, 예외 필터가 좋은 줄 알고 모든 모듈에 모두 다른 사용자 지정 예외를 만들었다. 결과적으로 매우 안타까운 선택이었다...

  1. 재사용성 떨어짐
    각 모듈마다 커스텀 예외를 만든 만큼 재사용을 해봤자 1~2회였다. (필요없음)
  2. 가독성 그냥 저냥
    가독성도 공통된 Http 내장 예외를 쓰는 것과 별반 차이가 없다.
  3. 생산성 매우 떨어짐
    중간부터는 너무 귀찮아서 나중에 다 만들어야지 하고 새로운 모듈은 생겨나는데 그에 해당하는 예외는 안 만들고 미뤘다. 시간도 너무 오래 걸리고 정말 번거로웠다.

결국 리팩토링 때는 왠만하면 공통된 내장 예외로 사용하고 추가적으로 내가 필요한 로직이 있을때에만 커스텀 예외를 적용하기로 하였다.

참고

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

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

0개의 댓글