ET네 만물상 프로젝트 복기 - NestJS

DD·2021년 9월 6일
0

우아한 테크캠프

목록 보기
10/14
post-thumbnail

📣 이 시리즈는...

  • 우아한 테크캠프 4기에서 마지막으로 진행한 프로젝트 전체를 복기해본 문서 시리즈입니다.
  • 제가 작성하지 않은 코드도 포함해서 복기했기에, 오류가 있을 수도 있는 개인적인 학습 기록을 위한 문서입니다!

ET네 만물상 - GitHub Repository / 배포 링크

  • 현재 배포 링크는 내부 문제로 API서버가 동작하지 않습니다.. 조만간 해결할 예정..



NestJS

  • API 서버를 구성하기 위해 사용한 NestJS 프레임워크.
  • DDD 아키텍처를 구성하려 했으나 도메인끼리 의존성이 생겼기 때문에 제대로 DDD를 해내진 못 했다.

package.json

"dependencies": {
    "@nestjs/common": "^8.0.0",
    "@nestjs/config": "^1.0.1",
    "@nestjs/core": "^8.0.0",
    "@nestjs/elasticsearch": "^8.0.0",
    "@nestjs/jwt": "^8.0.0",
    "@nestjs/platform-express": "^8.0.0",
    "@nestjs/typeorm": "^8.0.2",
  },
  "devDependencies": {
    "@nestjs/cli": "^8.0.0",
    "@nestjs/schematics": "^8.0.0",
    "@nestjs/testing": "^8.0.0",
  },
  • elasticsearch라던가 typeorm이라던가 nest가 내부적으로 지원하는 것들이 많은 거 같다.

  • 설정은 내가 한게 아니기도 하고, 주로 다룰 내용은 nest를 사용한 구조와 방법?들 이기 때문이 요정도만하고 넘어가자




디렉토리 구조 및 도메인 구조

src
|-- cart
|   |-- application
|   |-- domain
|   |-- dto
|   |-- entity
|   `-- presentation
|-- config
|   |-- filter
|   `-- properties
|-- destination
|   |-- application
|   |-- domain
|   |-- dto
|   |-- entity
|   `-- presentation
|-- infra
|   `-- mysql
|-- order
|   |-- application
|   |-- domain
|   |-- dto
|   |-- entity
|   `-- presentation
|-- payment
|   `-- presentation
|-- product
|   |-- application
|   |-- domain
|   |-- dto
|   |-- entity
|   |-- infrastructure
|   `-- presentation
`-- user
    |-- application
    |-- domain
    |-- dto
    |-- entity
    |-- infrastructure
    `-- presentation
core-module.ts
jwt-middleware.ts
main.ts
  • 이 프로젝트에는 DDD(Domain Driven Design) 패턴을 적용했기 때문에 각 디렉토리가 하나의 도메인 단위로 구분된다. 물론 완전한 DDD는 아니다. 각 도메인이 서로 연결되어 있기 때문에...

  • 각 도메인은 디렉토리는 5~6개의 하위 리덱토리로 구성되는데, 하나씩 살펴보자




core.ts

const jwtConfig = properties.auth;

@Module({
  imports: [
    MysqlConfig,
    JwtModule.register({
      secret: jwtConfig.secret,
      signOptions: { expiresIn: jwtConfig.expiresIn },
    }),
    ProductModule,
    DestinationModule,
    CartModule,
    OrderModule,
    UserModule,
    PaymentModule,
  ],
})
export class AppModule {
  configure(consumer: MiddlewareConsumer): any {
    consumer
      .apply(LoggerMiddleware)
      .exclude("/auth")
      .exclude("/auth/*")
      .exclude("/users")
      .exclude("/users/*")
      .forRoutes("*");
  }
}
  • Module 데코레이터로 NestJs로 만들 전체 APP 모듈을 만든다.

  • 각 도메인별 모듈과 MySQL, jwt관련 모듈을 import해서 합친다

  • 로그인 관련 처리를 하는 jwt middleware도 apply한다.




jwt-middleware.ts

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  constructor(private readonly jwtService: JwtService) {}
  use(@Req() req: Request, @Res() res: Response, @Next() next: NextFunction) {
    try {
      const token = req.cookies[properties.auth.tokenKey];
      if (token) {
        const result = this.jwtService.verify(token)["userId"];
        if (!result) throw Error("token expired");
        req.body.userId = this.jwtService.decode(token)["userId"];
      }
      next();
    } catch (e) {
      res.clearCookie(properties.auth.tokenKey);
      res.status(HttpStatus.PRECONDITION_FAILED);
      res.send(messages.failed.EXPIRED_TOKEN);
    }
  }
}
  • Injectable데코레이터로 Provider를 만들고, NestMiddleware를 implements해서 NestJS에서 사용할 미들웨어로 만든다

  • 우리 프로젝트는 쿠키에 accessToken이 담겨있기 때문에 이 미들웨에를 거치면 해당 토큰의 유효성을 검사해서 Body에 userId를 넣어주거나, 에러처리를 한다.

  • 이후 컨트롤러에서 Body 데코레이터를 사용한 파라미터에서 userId를 추출할 수 있다!




main.ts

const serverPort = properties.server.port;

const nestApplication = async () => {
  const app = await NestFactory.create(AppModule);
  app.enableCors({
    origin: [properties.client],
    methods: "GET,HEAD,PUT,PATCH,POST,DELETE",
    preflightContinue: false,
    optionsSuccessStatus: 204,
    credentials: true,
  });
  app.useGlobalFilters(new HttpExceptionFilter());
  app.use(cookieParser());
  await app.listen(serverPort);
};

nestApplication();
  • 서버 앱을 만드는 메인 파일.

  • cors 옵션, 필터, 각 미들웨어 등을 등록한다!




decorator

  • 디렉토리 구조를 살펴보기 전 데코레이터에 대해 간략히 알아보자

  • 데코레이터란 새 함수를 반환해서, 전달된 함수/메서드 동작을 수정하는 함수이다.

  • @ 접두사와 함께 사용하면 decorating 하고자 하는 class, 함수 등의 위에 적용해서 nestJS에서 사용하는 형태로 변경시킬 수 있다.

  • 자세한 내용은 더 깊게 따로 아티클로 작성해보도록 하자. 지금 이 단계에서는 내가 정의할 함수/ 클래스를 nestJS라는 프레임워크의 형태에 맞게 변형시켜준다 정도로만 이해하자




entity

  • DB 테이블 구조를 정의한다.

  • typeorm 모듈에서 필요한 데코레이터 함수를 import해서 사용한다.

    • Entity : 엔티티 class 선언부 위에 @Entity() 형태로 사용한다. 테이블 네임의 두 단어 이상이라 띄어쓰기가 있는 경우, 보통의 DB는 스네이크 케이스(_)를 사용한다.
    • 하지만 자바스크립트의 클래스는 주로 파스켈 케이스를 사용하므로, 이 둘을 맞춰주기 위해 아래처럼 DB에서 사용하는 이름을 전달한다.
    @Entity(product_option)
    class ProductOption

    - PrimaryGeneratedColumn : int 타입의 PK를 자동 생성한다.
    @PrimaryGeneratedColumn()
    id: number;

    - PrimaryColumn : PK 컬럼.
    @PrimaryColumn({ type: "char", length: 32 })
    id: string;

    - ManyToOne : 1:n관계의 n인 테이블에서 1인 테이블에 대한 칼럼 데코레이터
    @ManyToOne(() => User, (user) => user.wishes, { lazy: true })

    - OneToMany : 1:n 관계의 1인 테이블에서 n인 테이블에 대한 칼럼 데코레이터
    @OneToMany(() => Wish, (wish) => wish.user)
    wishes: Wish[];

    - OneToOne : 1:1 관계
    @OneToOne(() => Review, (review) => review.order)
    review: Review;

    - JoinColumn : 칼럼명, 참조 칼럼명을 **합친** 칼럼을 만든다. 내가 이해한 바로는, foreign key 관계일 때 ManyToOne/OneToMany 와 더불어 사용한다.
    @ManyToOne(() => Product, (product) => product.images, {
      nullable: false,
      onDelete: "CASCADE",
    })
    @JoinColumn({ name: "product_id" })
    product: Product;

    - Column : 일반적인 컬럼을 만들 때 사용한다.
    @Column({ type: "char", length: 32, nullable: true })
    image: string;

    - CreateDateColumn : 생성시각, 업데이트 시각 등 자동 생성되는 시간 컬럼에 대해 사용한다.
    @CreateDateColumn({
      type: "timestamp",
      name: "created_at",
      default: () => "CURRENT_TIMESTAMP(6)",
    })
    createdAt: Date;



dto

  • DTO(Data Transfer Object)사전적 정의 프로세스 간 데이터를 전송하는 객체이다.
  • 여기서는 서버의 데이터를 클라이언트로 전달하기 위해 사용한다.
  • DTO 없이 그냥 전달해도 문제는 없겠지만 디비 데이터를 바로 응답하는 건 좋지 않다는 용성님 의견이 있었다.. 따라서 복사를 위해서 of 메소드가 필요하다
  • 개인적으로 DTO의 역할은 해당 응답값의 형태를 정해 놓는 것이다. 따라서 interface/type 만으로도 충분하지만 여기에 더해서 DB조회로 가져온 데이터를 DTO의 형태로 변환하기 위해서도 of 메소드가 필요하다.
  • 따라서 아래와 같은 형태가 된다.
export class ReviewResponse {
  averageRate: number;
  rates: ReviewRate[];
  reviews: ReviewDTO[];

  static of(productReviews: Review[]): ReviewResponse {
    const RATES: ReviewRate[] = [
      { rate: 1, count: 0 },
      { rate: 2, count: 0 },
      { rate: 3, count: 0 },
      { rate: 4, count: 0 },
      { rate: 5, count: 0 },
    ];

    const averageRate = Number(
      (
        productReviews.reduce((result, review) => {
          return result + review.rate;
        }, 0) / productReviews.length
      ).toFixed(1)
    );

    const rates = productReviews.reduce(
      (result: ReviewRate[], review): ReviewRate[] => {
        result[review.rate - 1].count++;
        return result;
      },
      RATES
    );

    const reviews: ReviewDTO[] = productReviews.map((review): ReviewDTO => {
      return {
        id: review.id,
        rate: review.rate,
        content: review.content,
        image: review.image,
        authorName: review.order.user.name,
        createdAt: review.createdAt,
      };
    });

    return {
      averageRate,
      rates,
      reviews,
    };
  }
}
  • select문으로 테이블의 특정 컬럼만 가져오기 보다, 테이블 전체를 가져온 후 DTO에서 필요한 데이터만 골라서 / 변형해서 원하는 형태의 응답 값을 만들어낸다.

presentation

@Controller("/my")
export class MyController {
  constructor(private readonly myService: MyService) {}

  @Get("/info")
  async getMyInfo(@Body("userId") userId: number): Promise<MyInfoResponse> {
    return await this.myService.getMyInfo(userId);
  }
  ...
}
  • 3 layer architecture에서 controller의 역할을 한다.

  • @Controller 데코레이터를 사용한다. 해당 컨트롤러가 사용될 path를 입력한다.

  • constructor의 인자에 내부에서 사용할 Service를 전달한다.

  • 컨트롤러 클래스의 메소드에 HTTP 메소드에 해당하는 데코레이터를 붙이고, 해당 요청과 맞물릴 path도 입력!




application

@Injectable()
export class MyService {
  constructor(
    private readonly carts: Carts,
    private readonly reviews: Reviews,
    private readonly destinations: Destinations,
    private readonly questions: Questions,
    private readonly users: Users,
    private readonly orders: Orders,
    private readonly wishes: Wishes
  ) {}

  async getMyInfo(userId) {
    try {
      const user = await this.users.findUserById(userId);
      return MyInfoResponse.of(user);
    } catch (e) {
      throw new ETException(400, messages.failed.FAILTED_TO_FIND_MY_INFO);
    }
  }
  ...
  • 3 layer architecture에서 service 역할을 한다.

  • Injectable는 Nest의 Provider로 만드는 데코레이터다. 즉, Service를 Provider로 만드는 것

  • Provider의 핵심은 dependencies를 주입할 수 있다는 것!

  • constructor의 인자에 내부에서 사용할 domain을 전달한다.




domain

@Injectable()
export class Wishes {
  constructor(
    @InjectRepository(Wish)
    private readonly wishRepository: Repository<Wish>,
    @InjectRepository(Product)
    private readonly productRepository: Repository<Product>
  ) {}
  ...
  • 3 layer architecture에서 Repository 역할을 한다.

  • 마찬가지로 Injectable 데코레이터를 사용해서 Provider로 만든다.

  • constructor의 인자에 InjectRepository 데코레이터를 사용해서 내부에서 사용할 엔티티 Repository를 전달한다.

  • Repository는 엔티티에 해당하는 DB에 접근할 수 있는 객체라고 생각하면 된다.

async findMyWishesByUserId(userId: number): Promise<Wish[]> {
    return await this.wishRepository.find({
      relations: ["product", "product.images", "user"],
      where: { user_id: userId },
    });
  }
  • 사용한 Repository의 메소드는
    • find : 조건에 해당하는 모든 row를 찾는다.
    Repositoty.find({ where, relations);
    • findOne : 이론상으로는 조건에 해당하는 첫 번째 row를 찾는다. 하지만 조건 자체가 유니크해야한다. id라던가
    Repositoty.findOne(where, relations);
    Repositoty.findOne({ id });
    • insert : row를 추가한다
    Repositoty.insert(newRow);
    • delete : row를 삭제한다
    Repositoty.delete({ id });
    • update : 첫 번째 인자 조건에 맞는 row를 두 번째 row로 업데이트한다.
    Repositoty.update({ id }, newRow);
    • count : 조건에 해당하는 row의 수를 센다
    Repositoty.count({ id }, newRow);
    • query : SQL query문을 실행한다
    Repositoty.query(query);
    • putObject : S3에 key-value 구조로 저장한다
    s3Repository.putObject(key, value);
    • deleteObject :S3의 데이터를 삭제한다
    s3Repository.deleteObject(id);
    • save : 전달받은 인스턴스를 저장한다. (새로 추가되는게 아님! )
    this.productRepository.findOne(wish.productId).then((product) => {
      if (product.wishCount > 0) {
        product.wishCount++;
      }
      this.productRepository.save(product);
    });



infrastructure

  • 모든 도메인에 있는건 아니지만 S3나 GitHub/Google Oauth, bcrypt 등 외부 서비스나 DB와 직접 관련 없는 요소들이 담기는 디렉토리이다.
profile
기억보단 기록을 / TIL 전용 => https://velog.io/@jjuny546

0개의 댓글