티스토리 뷰

728x90
SMALL

배경

이런 저런 이유로 백엔드 공부를 시작했다. 사실 예전부터 미루고 미뤄왔던 일이기도 하다. 개발자가 왜 되었냐고 한다면 명확히 대답할 수 있지만 프론트 개발자가 왜 되었냐고 묻는다면 약간 대답하기가 애매해진다. 프론트가 싫었던 적도 지금 싫지도 않다. 다만 내가 프론트로 취업한 이유는 프론트를 더 잘하기 때문이다. 애초에 취직전까지는 풀스택으로 공부했었다. 보통 비전공자가 혼자서 풀스택으로 공부하다보면 자연스럽게 프론트를 더 많이 공부하고 잘하게 된다. 그리고 그건 나도 예외가 아니였다. 그리고 그렇게 취직한지 2년 4개월이라는 시간이 흘렀다. 쓸 얘기는 많지만 이정도에서 배경 설명은 마무리 하겠다. 중요한건 백엔드 공부를 시작했다!

 

공부 방법

회사다니는 동안 nestjs 강의를 하나 듣고 블로그에 글을 정리한 적이 있다. 그 때 강의가 괜찮았어서 해당 강의를 들으면서 공부를 하기로 마음먹었다. 해당 강의는 nestjs, typeorm으로 게시판을 만들면서 기본적인 nestjs에 대한 설명을 해준다. 강의는 완전 초보자용으로 만들어졌기 때문에 사용법 익히는 느낌으로만 보고 코드는 내가 다시 작성했다. 내 개인적인 목표는 nestjs에 대한 이해, typeorm에 대한 기본 사용법 익히기, 단위 테스트(계층별 테스트 포함), e2e 테스트, 서비스가 가능한 수준의 보일러 플레이트 만들기였다. 

 

nestjs

딱히 기술을 정리하는 글은 아니라서 세부적인 설명은 생략한다. 확실히 계층이 잡혀져 있어서 좋긴 하다. DI, IOC같은 개념은 nest 개념이 아니라서 어렵지 않은데 module 구조는 처음에 약간 헷갈리긴 했다. 근데 이것저것 라이브러리 모듈도 붙여보고 공식문서도 읽어서 이제 헷갈리지는 않는다.(모든 공식문서를 정독하지는 못했다.)

 

typeorm

아무래도 db테이블이 2개다보니 아쉽지만 복잡한 쿼리를 짤 일은 없었다. active record와 data mapper패턴을 jojoldu님의 글을 보고 알게 되었는데 해당 글에 공감해서 나는 data mapper 패턴을 사용했다. 그리고 자연스럽게 서비스 계층과 레포지토리 계층의 에러 핸들링 위치도 고민하게 되었다. 다행히 같이 고민해주는 gpt 친구가 있어서 도움을 많이 받았다.

Active Record와 Data Mapper 패턴 모두를 지원하는 TypeORM을 사용할때도 Active Record 패턴은 금지하고, Data Mapper 패턴을 쓰도록 권장하는 편이다.

출처: https://jojoldu.tistory.com/680

 

테스트코드

가장 중점적으로 공부한 부분이다. 테스트 관련 글도 많이 보고 여기저기 물어보고 다녔는데 프로젝트가 너무 간단한 탓에 엄청 유의미한 단위테스트를 작성하지는 못했다. 그대신 계층별 테스트, 모킹, 모킹없이 테스트짜기 등등은 학습했다. 

 

레포지토리 계층 테스트

레포지토리 테스트는 우선순위가 낮은 테스트같다. 결국 db 혹은 orm을 검증하는 느낌이 있는 것 같다. 물론 복잡한 api의 경우에는 여전히 의미가 있다고 생각하기는 한다.

 

서비스 계층 테스트

가장 애를 먹은 부분이다. 서비스 계층에는 사실 db의 관여가 크다. 모킹이 많은 테스트는 좋은 테스트가 아니라고 생각한다. 그래서 프론트에서도 데이터는 될수있다면 격리시키고 테스트를 진행한다. 그런데 서비스 계층은 db에 대한 접근을 격리시키기가 어렵다. 그래서 복잡한 로직을 함수로 분리해서 독립적인 테스트를 해야하나 생각했었다. 이것저것 찾아보다가 jojoldu님의 글을 보게 되었다. 테스트하기 좋은 코드 시리즈가 전부 좋긴 하지만 3편의 글이 도움이 많이 되었다. 핵심은 도메인 로직을 서비스 로직과 분리하자 인 것 같다. 애초에 서비스 계층은 모킹이 필요한게 맞고 테스트하기가 어려운게 맞다. 그래도 내가 처음에 생각한 것과 방향성정도는 맞았다는데에 위안을 느꼈다 ㅎㅎ.. 생각해보니 리팩터링2 스터디 했을 때 이런 내용이 나왔던 것 같기도하다. 코드스피츠 강의를 들을 때도 도메인 패턴 얘기를 했는데 테스트코드에 대한 얘기를 한 건 아니지만 결국 지향점은 같다는 생각이 들었다.

 

컨트롤러 계층 테스트

사실 컨트롤러의 계층이 깊어지면 테스트할 필요가 있을수도 있다고 생각하기는 한다. 근데 그걸 지금 학습해야되는지는 잘 모르겠다. 컨트롤러 최상단에 Guard가 있다면 Guard테스트를 하는 것만으로도 충분하지 않나 생각이 들었다. 결국 e2e와 컨트롤러 테스트는 크게 다르지 않으니 말이다.(중첩 컨트롤러가 많지 않을때)

 

e2e 테스트

단위 테스트도 중요하지만 역시 e2e 테스트도 중요하다. 사실 join된 테이블이 적을때는 테스트가 어렵지 않다. 그냥 하나하나 생성하면 되니까 말이다. 문제는 여러 테이블들사이에 join문이 생기고 이걸 테스트하기 위한 seed 데이터를 생성하는 방법일 것 같다. 

유저에 대한 인증을 어떻게 해줄것인지도 있을 것 같다. 나는 지금 그냥 토큰만 주입해주면 돼서 auth정보가 필요한 api에서는 다음과 같이 인증 api를 구현했다.

  describe('boards', () => {
    let accessToken: string;

    const GET = (endPoint: string) => request.get(endPoint).set('Authorization', `Bearer ${accessToken}`);
    const POST = (endPoint: string) => request.post(endPoint).set('Authorization', `Bearer ${accessToken}`);
    const PATCH = (endPoint: string) => request.patch(endPoint).set('Authorization', `Bearer ${accessToken}`);
    const DELETE = (endPoint: string) => request.delete(endPoint).set('Authorization', `Bearer ${accessToken}`);

    beforeEach(async () => {
      const { accessToken: accessTokenByCreateUser } = await createUser();
      accessToken = accessTokenByCreateUser;
    });

 

프로젝트가 커질수록 e2e는 한계가 있다는 생각도 든다. 하나의 api에 예외케이스가 20개씩 있다면 이건 e2e로 해결하기 좀 힘들다. 또한 api에 대한 변경사항이 생겼을 때 e2e로는 세부적인 상황까지 검증하기는 힘들다. 그래서 결국은 단위 테스트가 필요해진다.

 

로그

나는 nestjs에서 제공하는 로그와 winston을 결합했다. 그리고 winston-daily-rotate-file라는 라이브러리로 날짜별로 로그를 관리했다. 나는 서민이라서 cloud watch같은걸 도입하기보다는 일단 github action으로 s3에 올리는 정도로 관리할 것 같다. 그리고 크리티컬한 에러가 발생했을 때는 슬렉 메세지 정도만 연결하면 될 것 같다.

 

보통 express에서는 morgan을 이용해서 api별 로그를 찍는다. 그런데 nest morgan은 지금 관리되고 있지 않는 것 같아서 그냥 직접 만들었다. 디버깅을 위해서 body도 필요할 것 같아서 넣었는데 비밀번호 같은게 찍히는건 좀 큰 문제다. 그래서 그냥 마스킹처리를 했다. 근데 실제 운영하다보면 password를 다른 변수명으로 받을수도있을 것 같은데 이런건 어떻게 방지하지... 싶기도 하다.

import { IncomingHttpHeaders } from 'http';

import { Inject, Injectable, Logger, LoggerService, NestMiddleware } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import { NextFunction, Request, Response } from 'express';
import * as jwt from 'jsonwebtoken';

import { appConfig } from '@/configs/app.config';
import { AccessTokenPayload } from '@/modules/auth/auth.dto';

/** morgan과 유사하게 기본적인 api 정보를 로그로 남깁니다. */
@Injectable()
export class LoggerContextMiddleware implements NestMiddleware {
  constructor(
    @Inject(Logger) private readonly logger: LoggerService,

    @Inject(appConfig.KEY)
    private env: ConfigType<typeof appConfig>
  ) {}

  use(req: Request, res: Response, next: NextFunction) {
    const { ip, method, originalUrl, headers, body } = req;
    const userAgent = req.get('user-agent');
    const maskedBody = maskSensitiveInfo(body);

    const userId = getUserId(headers, this.env.jwtSecret);

    const startTime = Date.now();

    res.on('finish', () => {
      const { statusCode } = res;
      const endTime = Date.now();
      const duration = endTime - startTime;
      this.logger.log(
        `USER-${userId} ${method} ${originalUrl} ${JSON.stringify(maskedBody)} ${statusCode} ${ip} ${userAgent} - ${duration}ms`
      );
    });

    next();
  }
}

const getUserId = (headers: IncomingHttpHeaders, jwtSecret: string) => {
  const authorizationHeader = headers.authorization;
  if (authorizationHeader) {
    const token = authorizationHeader.split(' ')[1];
    try {
      const secret = jwtSecret;
      const decoded = jwt.verify(token, secret);
      if (typeof decoded !== 'string') {
        return (decoded as AccessTokenPayload).id;
      }
    } catch (error) {}
  }
  return null;
};

const maskSensitiveInfo = (body: any) => {
  const clonedBody = { ...body };
  if (clonedBody.password) {
    clonedBody.password = '******';
  }
  return clonedBody;
};

 

에러가 발생했을 때도 세부적인 로그를 찍고 싶어서 다음과 같이 exception filter를 구현했다.

import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { Request, Response } from 'express';

import { AccessTokenPayload } from '@/modules/auth/auth.dto';

@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
  private readonly logger: Logger = new Logger();

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const res = ctx.getResponse<Response>();
    const req = ctx.getRequest<Request>();
    const { body, user, originalUrl, method, ip } = req;
    const userAgent = req.get('user-agent');
    const userId = (user as AccessTokenPayload)?.id;
    if (exception instanceof HttpException) {
      const statusCode = exception.getStatus();

      const message = `USER-${userId} ${method} ${originalUrl} ${JSON.stringify(body)} ${statusCode} ${ip} ${userAgent}`;
      if (statusCode >= HttpStatus.INTERNAL_SERVER_ERROR) {
        this.logger.error(message, { exception: this.formatError(exception) });
      } else {
        this.logger.warn(message, { exception: this.formatError(exception) });
      }

      res.status(statusCode).json(exception.getResponse());
    } else {
      const message = `로직 에러. USER-${userId} ${method} ${originalUrl} ${JSON.stringify(body)} ${HttpStatus.INTERNAL_SERVER_ERROR} ${ip} ${userAgent}`;
      this.logger.error(message, { exception: this.formatError(exception) });

      res.status(500).json({ statusCode: HttpStatus.INTERNAL_SERVER_ERROR, message: 'Internal server error' });
    }
  }

  private formatError(exception: unknown): object {
    if (exception instanceof Error) {
      return {
        message: exception.message,
        stack: exception.stack,
      };
    }
    return { exception };
  }
}

 

커스텀에러

지금보니까 exception filter에 커스텀에러 적용을 안했다.. 나중에 추가해야겠다.

특별한 내용은 없다. 그냥 이런 형식으로 에러 관리를 할 생각이다. 일단 BaseExceptionCode는 nestjs 기본 에러중 당장 사용할 것 같은 몇개의 코드만 가져왔다.(지금보니 각각의 message에는 기본적인 메세지를 넣어주면 더 좋을것같다)

import { HttpException, HttpStatus } from '@nestjs/common';

import { RestExceptionCode } from '@/api-interfaces/exception';

export class ApiError<T> {
  code?: RestExceptionCode;
  data?: T;
  message?: string | string[];
}

class RestException<T = undefined> extends HttpException {
  constructor(httpStatus: HttpStatus, error?: ApiError<T>) {
    super(error ?? {}, httpStatus);
  }
}

class BadRequestException<T = undefined> extends RestException<T> {
  constructor({ data, message }: { data?: T; message?: ApiError<T>['message'] } = {}) {
    super(HttpStatus.BAD_REQUEST, {
      code: RestExceptionCode.BaseExceptionCode.BadRequest,
      data,
      message,
    });
  }
}

class UnauthorizedException<T = undefined> extends RestException<T> {
  constructor({ data, message }: { data?: T; message?: ApiError<T>['message'] } = {}) {
    super(HttpStatus.UNAUTHORIZED, {
      code: RestExceptionCode.BaseExceptionCode.Unauthorized,
      data,
      message,
    });
  }
}

class NotFoundException<T = undefined> extends RestException<T> {
  constructor({ data, message }: { data?: T; message?: ApiError<T>['message'] } = {}) {
    super(HttpStatus.NOT_FOUND, {
      code: RestExceptionCode.BaseExceptionCode.NotFound,
      data,
      message,
    });
  }
}

class ForbiddenException<T = undefined> extends RestException<T> {
  constructor({ data, message }: { data?: T; message?: ApiError<T>['message'] } = {}) {
    super(HttpStatus.FORBIDDEN, {
      code: RestExceptionCode.BaseExceptionCode.Forbidden,
      data,
      message,
    });
  }
}

class NotAcceptableException<T = undefined> extends RestException<T> {
  constructor({ data, message }: { data?: T; message?: ApiError<T>['message'] } = {}) {
    super(HttpStatus.NOT_ACCEPTABLE, {
      code: RestExceptionCode.BaseExceptionCode.NotAcceptable,
      data,
      message,
    });
  }
}

class TimeoutException<T = undefined> extends RestException<T> {
  constructor({ data, message }: { data?: T; message?: ApiError<T>['message'] } = {}) {
    super(HttpStatus.REQUEST_TIMEOUT, {
      code: RestExceptionCode.BaseExceptionCode.Timeout,
      data,
      message,
    });
  }
}

class ConflictException<T = undefined> extends RestException<T> {
  constructor({ data, message }: { data?: T; message?: ApiError<T>['message'] } = {}) {
    super(HttpStatus.CONFLICT, {
      code: RestExceptionCode.BaseExceptionCode.Conflict,
      data,
      message,
    });
  }
}

class InternalServerErrorException<T = undefined> extends RestException<T> {
  constructor({ data, message }: { data?: T; message?: ApiError<T>['message'] } = {}) {
    super(HttpStatus.INTERNAL_SERVER_ERROR, {
      code: RestExceptionCode.BaseExceptionCode.InternalServerError,
      data,
      message,
    });
  }
}

export const CustomError = {
  RestException,
  BadRequestException,
  UnauthorizedException,
  NotFoundException,
  ForbiddenException,
  NotAcceptableException,
  TimeoutException,
  ConflictException,
  InternalServerErrorException,
} as const;

 

후기

요즘 맨날 새벽까지 공부했는데 재밌다. 커밋으로 날짜를 확인해보니 10일동안 매일 했다. 당연히 쉬는 날이 있을 줄알았는데 없었다.. 아마 주말에 약속있는날은 전날 새벽이나 당일 밤에 공부를 해서 커밋이 남았나보다. 

회사에서 일하면서 항상 프론트에만 국한해서 생각하지는 않는다. ux, 백엔드는 느낌이라도 알고 있어야 된다고 생각해서 동료들에게 이것저것 물어보기도 하고 궁금한게 있으면 찾아보기도 했다. 그런 사소한 것들이 쌓여서 지금 백엔드를 공부할때도 도움이 되는 것 같다. 하지만 그와 동시에 참 막막하다. 도메인 로직 분리, 계층 분리, 테스트 코드 작성 이런건 기대가 되면 기대가 되지 막막하지는 않다. 그런데 db, 인프라쪽으로 가면 좀 막막하다... 열심히 해보자(참고로 지금 당장 백엔드 개발자로 진로를 바꾸려는 것은 아니다. 그냥 이런저런 이유가 있을뿐..)

 

다음은?

firebase, multi tenancy, 복잡한 mysql query 사용해보기, mysql 성능 테스트, 대용량 데이터 다루기 w/캐싱(redis, local cache 등등), real mysql, 운영체제 등을 공부할 예정이다. 좀 거창해보이는데... 엄청 딥하게 하지는 않을거다.(할수도 없고) 

 

 

깃헙pr링크

https://github.com/yoonminsang/TIL/pull/20

 

nestjs board 연습 by yoonminsang · Pull Request #20 · yoonminsang/TIL

 

github.com

 

728x90
LIST

'생각정리' 카테고리의 다른 글

코딩 강사가 되었다  (0) 2024.06.26
1년차 개발자 회고(웹 프론트)  (7) 2023.03.21
간단한 내년 상반기(2022 상반기) 계획  (0) 2021.12.29
댓글
공지사항