[NestJS] 주문처리과정을 통해 알아보는 수익률 랭킹 조회 (Part 1_ 주문처리를 위한 DB 설계) #1

DatQueue·2023년 6월 28일
0
post-thumbnail

시작하기에 앞서

이번에 NestJS를 활용해 일련의 실용적인 기능 구현을 수행해보는 시간을 가졌다. 앞으로 작성하게 될 내용은 최근에 듣게 된 유데미 사이트의 특정 강의를 참고하여 진행하였다. 조금 뜬금없는 얘기지만, 해당 강의가 2020년 기준 강의인것을 뒤늦게..? 알게 되었고, 현재 nestJS 및 ORM(TypeORM) 버전의 업그레이드로 인해 강의에서 제시하는 방법과 충돌하는 경우가 빈번하였고 동시에 (이런말을 해도 될진 모르겠지만.... 저렴한 강의여서 그런지 모르겠지만 ...) 굉장히 부실한 설명 및 내용임을 의심하지 않을 수 없었다.
뭐, 덕분에 스스로 기능을 더 추가하고 잘못된 로직을 개선할 수 있었고 동시에 앞으로 진행하게 될 내용에 대해 조금은 깊게 생각해볼 수 있었다.

지금부터 소개할 내용에 대해 간단히 설명해보자면 "간단한 주문 및 결제 서비스 구현과 결제 수익에 따른 랭킹 기능 구현" 이라 할 수 있을 것 같다.

나와같은 초심자분들에게 내용을 설명하는데 있어 특정 부분(주문 결제 혹은 랭킹 구현)을 중심으로 설명한다기보단, 하나의 흐름 아래에 모든 데이터 및 로직들이 어떻게 연관성을 띄고 동작을 하는지를 서술하는 것이 유의미하다고 판단하였다. 이를 하나의 포스팅에 작성하긴 내용이 길어질 것이 분명하므로 위의 설명을 시리즈로 2~3개 분량의 포스팅에 나누어 작성해보고자 한다.


💨 이번 시리즈에서 다룰 내용

  1. 주문처리를 위한 데이터베이스 설계 - 이번 포스팅
  2. 주문 관련 로직 구현 및 결제 모듈 적용하기
  3. 레디스를 사용하여 랭킹 정보 응답하기

💨 사전 정보

이번 포스팅 및 해당 시리즈는 이전에 작성되었던 포스팅을 베이스로 진행합니다. 유저는 관리자(admin)와 판매 대리인(ambassador)가 생성된 구조로써, 판매 대리인은 상품 판매 시 관리자와 차등한 일정 수익을 얻게 되는 구조 입니다.

adminambassador에 따른 서로 다른 로그인/회원가입 (즉, 인증과 그에 따른 인가)에 대한 부분은 아래의 이전 포스팅에 설명되어있으니 필요하시면 사전 참조 바랍니다..!!!

[NestJS] Implementing Scopes for Multiple Routes ( feat. JWT Auth ) -- ✔ 링크 참조


그럼 이번 포스팅에선 제목 및 위의 목차에 따라 가장 베이스가 되고 프레임을 잡아줄 데이터베이스 설계 과정을 알아보고 NestJS에서 Typeorm을 사용하여 이를 표현해보고자 한다.


💢 데이터베이스 설계하기 (RDBMS)

> 데이터베이스 Wireframe 설계 (Feat_ FK Constraint)

주문및 결제를 수행하는데 있어서 단지 유저와 주문 정보 테이블만 있진 않을 것이다. 유저 테이블과 주문 테이블은 물론이고, 상품(products)도 존재해야 하고 주문과 상품 목록을 매칭하기 위한 테이블도 존재해야할 것이다.

그 외에도 필요한 테이블이 있을 것이고, 각 테이블마다 어떠한 컬럼을 가져야하는지와 어떻게 다른 테이블과 연관 관계를 지녀야 할지에 대한 고민도 필요할 것이다.

✔ 외래 키(Foreign-Key)는 JOIN 연산에서 꼭 사용되어야 할까?

일반적으로 JOIN 과정(연산)에서 PK(기본키)와 FK(외래키)를 통해 사용하도록 제시되어서 해당 방법이 "필수적"이라 생각할 수 있지만, 사실 그렇지 않다.
일반 컬럼으로도 충분히 JOIN을 할 수 있다.

이러한 논점에서 항상 따라다니는 것은 "데이터 무결성 원칙(Data Integrity)"이다.

데이터 무결성에 대한 설명이 이번 포스팅의 주제는 아니므로 아주 간단히 설명하자면 데이터 무결성은 데이터베이스에서 데이터의 정확성과 일관성을 보장하는 원칙을 의미한다. 그리고 이러한 데이터 무결성을 지키는 "무결성 제약 조건(Integrity Constraint)"이 존재한다. 흔히 말하는 CRUD와 같은 작업에서 얽혀있는 테이블간에 "제약"을 둠으로써 데이터의 정확성과 일관성을 보장할 수 있다.

무결성 제약조건중, 우린 "참조 무결성(referential integrity)"에서 "외래키 제약조건"이 사용됨을 알 수 있다. 외래키의 값은 NULL이거나 참조하는 테이블의 기본키(PK)와 동일해야한다는 것이 원칙이다.

자, 그럼 다시 처음으로 되돌아가서 "JOIN 연산에 있어서 외래키 제약을 가져야하는가?"에 대해 스스로 대답해보면 "권장은 하지만(어찌됐건 무결성을 지키는 것은 중요하다) 필수적인 않다."라고 할 수 있을거 같다.

이러한 "제약"을 두게 된다면 개발단계에서는 물론이고 추후 운영하는 과정에서도 테이블의 작은 변화 하나하나에 민감하게 된다. PK와 FK로 JOIN이 형성된 테이블간의 수정은 그만큼 어려워지게 된다.

즉, 이번 프로젝트에선 위에서 언급한 내용을 바탕으로 "부모-자식 테이블의 관계가 명확하거나 참조할 식별자가 기본키여야 하는 경우, 혹은 자주 변경이 일어나지 않는 스키마"에 대해선 "외래키 제약조건을 사용"하도록 하였고, 이에 반해 "참조할 식별자가 기본키일 필요가 없는 경우, 자주 변경이 일어날 수 있는 스키마"에 대해선 "외래키 제약조건을 사용하지 않도록"하였다.

그리고 이러한 JOIN 관계와 사용되는 컬럼들을 아래의 ERD 와이어프레임에 반영하였다. "Non-Idenfying relations"와 "Identifying relations"의 관점에서 볼 수 있지 않을까 싶다.


본격적으로 각 테이블 마다의 세세한 설명에 앞서 먼저 위에서 언급한 내용을 바탕으로 ERD Cloud를 통해 간단히 테이블의 구성과 테이블 마다의 연관 관계를 정의해보았다. 해당 와이어프레임을 바탕으로 진행할 것이다.


✔ Using ERDCloud (ambassador-order)

  • 노란색 열쇠: 식별자 키(PK)
  • 파란색 키: 외래 키(FK) - 외래 키 제약조건 성립
  • 분홍색 키: 외래 키 제약조건이 없는 조인 컬럼

(참고로, 아래 테이블 속성에 정의된 타입varchar()에 대한 문자열 길이 값은 무시하시면 감사하겠습니다... 현재 모든 컬럼은 디폴트인 varchar(255)로 설정되어있습니다. 이것이 핵심은 아니므로 일단 해당 값으로 작성해 두었습니다)


> User (admin && ambassador)

주문자에 해당하는 유저 테이블이다. 우리가 진행할 시리즈의 프로젝트 특성 상, 관리자는 주문 프로세스에 참여하지 않는다. ambassador라 명명된 "판매 대리인"이 판매자임과 동시에 구매를 수행할 수 있는 유저가 된다.

즉, 인증을 위한 컬럼을 포함해 해당 ambassador여부를 판별할 수 있는 컬럼이 존재해야 할 것이다.

또한, getter 생성자를 사용하여 응답시에 수익 값을 넘겨줄 수 있도록 revenue를 정의하였고 외부 엔터티에선 해당 값을 이용해 일련의 가공을 할 수 있을 것이다.

// user.entity.ts

import { Exclude } from "class-transformer";
import { Order } from "../../order/model/order.entity";
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  first_name: string

  @Column()
  last_name: string;

  @Column({ unique: true })
  email: string;

  @Exclude()
  @Column()
  password: string;

  @Exclude()
  @Column({ nullable: true })
  currentRefreshToken: string;

  @Column({ type: 'datetime', nullable: true })
  currentRefreshTokenExp: Date;
  
  // ambassador와 admin을 판별하기 위한 불리언값의 필드이다.
  @Column({ default: true })
  is_ambassador: boolean;
  
  // 외래키 제약조건없이 Order 테이블과 조인관계 형성
  @OneToMany(() => Order, order => order.user, {
    createForeignKeyConstraints: false,
  })
  orders: Order[];
  
  // 추후 주문정보에 따른 판매인 수익 계산에 사용된다.
  get revenue(): number {
    return this.orders.filter(o => o.complete).reduce((s: number, o: Order) => s + o.ambassador_revenue, 0);
  }

  get name() {
    return `${this.first_name} ${this.last_name};`
  }
}

유저 테이블은 이정도로 알아보고, 다음으로 진행하자.


> Order

다음으로 소개할 테이블은 주문 프로세스를 위한 orders 테이블이다. orders 테이블엔 어떠한 컬럼이 담겨져 있어야 할까?

국가, 주소, 우편 번호, 이메일 등등의 주문시 필요한 기본 정보에 대한 일반 컬럼부터 유저 테이블과 조인하기 위한 컬럼, 주문한 상품(order_itmes)과 조인하기 위한 컬럼, 그리고 추후 설명하겠지만 각 판매자마다의 판매 상품 및 일련 코드를 관리하기 위한 link 테이블과 조인하기 위한 컬럼이 필요할 것이다.

이를 토대로 엔터티를 작성해보자.

// order.entity.ts

import { Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { OrderItem } from "./order-item.entity";
import { Exclude, Expose } from "class-transformer";
import { Link } from "../../link/model/link.entity";
import { User } from "../../user/model/user.entity";

@Entity('orders')
export class Order {
  @PrimaryGeneratedColumn()
  id: number;
  
  // 추후 해당 필드를 통해 결제가 완료된 주문에 대한 처리를 진행할 수 있다.
  @Column({ nullable: true })
  transaction_id: string;

  @Column()
  user_id: number;

  @Column()
  code: string;
  
  // 판매 대리인 `email`
  @Column()
  ambassador_email: string;
  
  // 주문자의 `first_name`
  @Exclude()
  @Column()
  first_name: string;
  
  // 주문자의 `last_name`
  @Exclude()
  @Column()
  last_name: string;

  @Column()
  email: string;

  @Column({ nullable: true })
  address: string;

  @Column({ nullable: true })
  country: string;

  @Column({ nullable: true })
  city: string;

  @Column({ nullable: true })
  zip: string;
  
  // 결제가 최종 완료된다면 `true`로 변경되어야 할 것이다.
  @Exclude()
  @Column({ default: false })
  complete: boolean;
  
  // 외래키 제약조건을 통해 `order_items` 테이블과 조인관계를 형성한다.
  @OneToMany(() => OrderItem, orderItem => orderItem.order)
  order_items: OrderItem[];
  
  // 주문 결제를 진행하는데 있어서 `code` 값을 `links` 테이블과 공유하게끔 한다.
  // 이때 외래키 제약조건을 두지 않는다. (참조 하는 값이 기본키가 아님)
  @ManyToOne(() => Link, link => link.orders, {
    createForeignKeyConstraints: false,
  })
  @JoinColumn({
    referencedColumnName: 'code',
    name: 'code',
  })
  link: Link;
  
  // `user_id` 컬럼을 통해 user 테이블과 [N:1]관계를 맺게끔 한다.
  // 외래키 제약조건을 두지 않는다.
  @ManyToOne(() => User, user => user.orders, {
    createForeignKeyConstraints: false,
  })
  @JoinColumn({
    name: 'user_id'
  })
  user: User;

  @Expose()
  get name() {
    return `${this.first_name} ${this.last_name};`
  }

  // total admin_revenue
  @Expose()
  get total() {
    return this.order_items.reduce((s: number, i: OrderItem) => s + i.admin_revenue, 0);
  }
  
  // ambassador_revenue
  get ambassador_revenue(): number {
    return this.order_items.reduce((s: number, i: OrderItem) => s + i.ambassador_revenue, 0);
  }
}

> OrderItem

해당 테이블은 앞서 정의한 orders 테이블과 직접 연관이 있고, 외래키 제약조건을 통해 생성된 order_items 테이블이다.

이 테이블을 통해서 우린 곧 알아보게 될 products 테이블에서 정의한 상품 정보들을 요청 시 주문 (orders)과정에 받아올 수 있다.

// order-item.entity.ts

import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
import { Order } from "./order.entity";

@Entity('order_items')
export class OrderItem {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  product_title: string;

  @Column()
  price: number;

  @Column()
  quantity: number;

  @Column()
  admin_revenue: number;

  @Column()
  ambassador_revenue: number;

  @ManyToOne(() => Order, order => order.order_items, { onDelete: "CASCADE" })
  @JoinColumn({ name: 'order_id' })
  order: Order
}

> Product

상품 테이블이다. 상품 테이블은 크게 복잡한 구성은 없도록 하였지만 곧 소개할 links 테이블과 연관 관계를 가져야 한다. links 테이블에 대해 미리 잠깐 설명하자면 "판매자와 상품을 연결하기 위한 정보의 스키마"이다. 이 links 테이블을 통해 판매자는 판매할 상품을 관리할 수 있는데, 하나의 링크는 여러 상품(product)을 가질 수 있고, 동시에 하나의 상품 또한 여러 링크에 속해있을 수 있다.
조금 더 직관적으로 말하자면 관리자를 통해 받게 될 수익성 판매 상품을 여러 유저가 동시에(공유해서) 대리 판매를 할 수 있다는 것이다.

즉, 뒤에 나올 links 테이블과 해당 products 테이블은 "다대다(N:M)"관계를 가지게 된다.

일반적으로 Typeorm과 같은 orm에서 "다대다"관계를 설정하는데 있어서 "@ManyToMany" 데코레이터를 사용할 것을 제시한다.

하지만, 실무에서는 이를 권장하지 않는다. 예를 들면 자바의 Hibernate나 우리가 현재 사용하고 있는 TypeORM과 같은 orm 프레임워크는 @ManyToMany를 주입하게 될 시 자동으로 중간 테이블을 만들어준다. 굉장히 편하게 다대다 관계를 생성하게 된다고 생각할 수 있지만 이는 굉장히 허술하다(물론 현재까지는 그렇단 얘기다...)

중간 테이블을 만들고, 기본키와 외래키 쌍을 알아서 매핑해주는 것은 문제가 되지 않지만 실무 레벨에서 필요할 수 있는 추가적 필드에 대한 정의를 지원하지 않는다. 예를 들면 생성 시(create_at), 업데이트 시(updated_at)와 같은 정보를 기입할 수 없다는 것이다.

그로 인해, 수행할 과정에선 @ManyToMany를 사용하지 않고 @OneToMany@ManyToOne 사용을 통해 직접 중간 매핑 테이블을 생성하도록 하였다.


이 내용에 대해선 일전에도 다룬 적이 있었습니다. 이러한 테이블 구조와 로직에 대해 조금 더 알고 싶다면 아래의 시리즈 내용을 참조 바랍니다. ⬇⬇

TypeORM에서 ManyToMany 관계 개선하기 -링크 ✔


그럼 product 엔터티를 확인해보자.

// product.entity.ts

import { LinkProduct } from "../../link/model/link-product.entity";
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";

@Entity()
export class Product {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;

  @Column()
  description: string;

  @Column()
  image: string;

  @Column()
  price: number;
  
  // 중간 매핑 테이블인 link_products와 `OneToMany`관계로 조인한다.
  @OneToMany(() => LinkProduct, (linkProduct) => linkProduct.product)
  linkProducts: LinkProduct[];
}

links 테이블에선 어떠한 필드가 담겨야하고, 조인관계는 어떻게 정의해야할까?

먼저, 앞서 orders 테이블을 생성할 시에 언급하였고 동시에 정의하였듯이 links 테이블에서도 code가 필요하다. orderlink 테이블은 외래키 제약 조건 없이 해당 code를 참조하며 조인을 한다. 해당 코드값은 추후 따로 생성하겠지만 유니크키여야 한다.

다음으로 유저 테이블과 외래키 제약조건을 통해 조인관계를 지니도록 하고(일대다, 다대일 관계를 형성), 다음으로 소개할 linksproducts의 중간 매핑 테이블인 link_products와 외래키 제약 조건을 가지게끔 한다.

// link.entity.ts

import { Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { User } from "../../user/model/user.entity";
import { LinkProduct } from "./link-product.entity";
import { Order } from "../../order/model/order.entity";

@Entity('links')
export class Link {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ unique: true })
  code: string;

  @ManyToOne(() => User)
  @JoinColumn({ name: 'user_id' })
  user: User;

  @OneToMany(() => LinkProduct, (linkProduct) => linkProduct.link, {eager: true})
  linkProducts: LinkProduct[];

  @OneToMany(() => Order, order => order.link, {
    createForeignKeyConstraints: false,
  })
  @JoinColumn({
    referencedColumnName: 'code',
    name: 'code',
  })
  orders: Order[];
}

> LinkProduct (중간 매핑 테이블)

linksproducts 테이블의 다대다(N:M)관계를 위한 중간 매핑 테이블이다.

// link-product.entity.ts

import { BeforeInsert, Entity, ManyToOne, PrimaryColumn } from "typeorm";
import { Link } from "./link.entity";
import { Product } from "../../product/model/product.entity";

@Entity('link_products')
export class LinkProduct {
  @PrimaryColumn()
  id: string;

  @ManyToOne(() => Product, product => product.linkProducts, { onDelete: 'CASCADE' })
  product: Product;

  @ManyToOne(() => Link, link => link.linkProducts, { onDelete: 'CASCADE' })
  link: Link;

  @BeforeInsert()
  private beforeInsert() {
    const linkId = this.link.id;
    const productId = this.product.id;
    this.id = String(linkId).padStart(7, "0") + String(productId).padStart(7, "0");  
  }
}

해당 중간 매핑 테이블의 pk(기본키)는 auto-increment key, 즉 typeorm의 @PrimaryGeneratedColumn으로 선언하지 않았다. 코드에서 확인할 수 있듯이 @BeforeInsert를 사용하여 beforeInsert() 내부 실행코드를 엔티티가 db에 삽입되기 전에 실행되도록 하였다.

이렇게 auto-increment key가 아닌 String Unique Key를 사용한 이유는 다양하겠지만 (auto-increment key의 단점이 주된 이유가 될 것이다) 추후 LinkProduct 엔티티에 접근을 해야할 시에 조금 더 빠르고 간단한 코드로써의 접근을 위해서이다. linkIdproductId를 통해 생성한 String Unique Key(id)는 그 자체로써 유의미한 식별 값을 가질 수 있기 때문이다.


이에 대해서도 일전에 다룬 적이 있습니다. 아래의 링크를 참조해주세요 ⬇⬇

String Unique Key를 통한 코드 개선 - 링크 ✔



다음 포스팅 예고

이렇게 우린 수행할 프로젝트에 대한 전체 테이블 구조를 알아보았다.

해당 테이블 구조를 베이스로 하여 다음 포스팅에서 주문및 결제 수행을 진행해보도록 한다.


profile
You better cool it off before you burn it out / 티스토리(Kotlin, Android): https://nemoo-dev.tistory.com

0개의 댓글