Association

두 클래스간의 연관 관계

Report 다루기

Report는 자동차를 판 사람이 자기가 판매한 가격과 정보를 보고하는 것. 이것을 바탕으로 자신의 중고차 가치를 추정받는 시스템의 정확도를 높일 수 있다.

리포트 Entity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// reports.entity.ts
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

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

  @Column()
  price: number;

  @Column()
  make: string;

  @Column()
  model: string;

  @Column()
  year: number;

  @Column() // 판매지역 나타내는 경도
  lng: number;

  @Column() // 판매지역 나타내는 위도
  lat: number;

  @Column()
  mileage: number;
}

DTO 생성

컨트롤러를 수정하자

1
2
3
4
5
6
7
8
9
10
11
// reports.controller.ts
import { Controller, Post, Body } from '@nestjs/common';

@Controller('reports')
export class ReportsController {
    @Post()
    createReport(@Body() body: CreateReportDto) {
        
    }
}

report를 등록하되, 올바른 형식으로 왔는지 검사하기 위해 Dto를 추가하도록 한다. report 디렉토리에 dtos 폴더를 만들고 내부에 create-report.dto.ts를 만든다. 데코레이터를 추가하고 만들자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// create-report.dto.ts
import {
  IsString,
  IsNumber,
  Min,
  Max,
  IsLongitude,
  IsLatitude,
} from 'class-validator';

export class CreateReportDto {
  @IsString()
  make: string;

  @IsString()
  model: string;

  @IsNumber()
  @Min(1930)
  @Max(2050)
  year: number;

  @IsNumber()
  @Min(0)
  @Max(1000000)
  mileage: number;

  @IsLongitude()
  lng: number;

  @IsLatitude()
  lat: number;

  @IsNumber()
  @Min(0)
  @Max(1000000)
  price: number;
}

이제 컨트롤러에 import 하면 된다.

종속성 주입

아직 서비스에 관한 코드를 작성하지 않지만 미리 서비스가 작성되어있다고 가정하고 종속성 주입해놓는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// reports.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { CreateReportDto } from './dtos/create-report.dto';
import { ReportsService } from './reports.service';

@Controller('reports')
export class ReportsController {
    constructor(private reportsService: ReportsService) {}

    @Post()
    createReport(@Body() body: CreateReportDto) {}
}

service에 @injectable decorator가 되어있는지, module의 provider의 ReportsService가 설정되어 있는지 확인한다.

또한 로그인한 사람에게만 report를 작성할 수 있는 권한을 주자. 기존에 만들어뒀던 guard를 이용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Controller, Post, Body, UseGuards} from '@nestjs/common';
import { CreateReportDto } from './dtos/create-report.dto';
import { ReportsService } from './reports.service';
import { AuthGuard } from '../guards/auth.guard';

@Controller('reports')
export class ReportsController {
  constructor(private reportsService: ReportsService) {}

  @Post()
  @UseGuards(AuthGuard)
  createReport(@Body() body: CreateReportDto) {
    return this.reportsService.create(body);
  }
}

service에 메서드 추가

controller에서 create 메서드를 사용해야하니 서비스에 추가해주자. 다만 그 이전에 서비스가 repository에 접근할 수 있도록 세팅한다.

user에선

1
2
export class UsersService {`
 constructor(@InjectRepository(User) private repo: Repository<User>) {}

이렇게 작성했었다. 참고하여 여기도 작성하고 create 메서드를 만들자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// reports.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Report } from './reports.entity';
import { CreateReportDto } from './dtos/create-report.dto';

@Injectable()
export class ReportsService {
  constructor(@InjectRepository(Report) private repo: Repository<Report>) {}

  create(reportDto: CreateReportDto) {
    const report = this.repo.create(reportDto);

    return this.repo.save(report);
  }
}

API 테스트

전체 루트 디렉토리에 하나의 테스트파일을 만들어도 되지만 우린 유저는 유저 폴더에 만들었다. reports 폴더에도 requests.http를 만들고 작성해본다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// requests.http
POST http://localhost:3000/reports
content-type: application/json 

{
    "make": "toyota",
    "model" : "corolla",
    "year": 1980,
    "mileage": 10000,
    "lng": 0,
    "lat": 0,
    "price": 50000
}

요청을 보내보자. 혹시 403 Forbidden 이 발생한다면, user 디렉토리에 있는 requests.http로 가서 로그인부터 하자. 1시간 헤매서 얻은 결과다.

유저와 유저가 만든 리포트 연결하기

유저가 자신이 만든 리포트를 볼 수 있도록 유저와 유저가 만든 리포트 간에 연관(Association) 하려고 한다.

Associtation?

  • 레코드와 다른 레코드 간의 관계맺기(Relate)

  • Security, SQL, REST convention, TypeORM, Nest, Class-Transformer 를 알아야한다.
  • Record를 기록하는 Table의 user_id를 표시하는 열을 추가하

Types of Association

One-To-One Relationships

  • 일대일 대응 (나라와 수도, 자동차와 엔진, 사람과 휴대폰)

  • 규칙이기 때문에 서비스에서 사람과 휴대폰이 일대일이면 모든 사람은 1대만 가진다.

One-to-Many or Many-to-One

  • 다대일 혹은 일대다 대응
  • 주문과 고객, 자동차와 부품, 국가와 도시

Many-to-Many

  • 다대다
  • 기차와 운송객
  • 반과 학생

우리 서비스에서 유저와 리포트의 관계는 어떨까?

한 유저는 여러개의 리포트를 작성할 수 있다. 한 리포트는 한 유저에게만 속해있다. 따라서 일대다 혹은 다대일 이라고 볼 수 있다.

유저의 입장에선 일대다, 리포트의 입장에선 다대일이다. 각각에 알맞은 decorator를 사용해야 한다.

코드 작성해보기

다대일 혹은 일대다 관계인걸 알았으니 그에 알맞은 decorator를 사용하여 entity 파일에 추가하자.

유저 entity부터 작성하자.

1
2
3
4
5
6
7
8
9
// user.entity.ts
import { OneToMany } fro 'typeorm';
import { Report } from '../reports/report.entity';

@Entity()
export class User {
  @OneToMany(() => Report, (report) => report.user)
  reports: Report[];
}

이제 리포트 entity 작성

1
2
3
4
5
6
7
8
9
// report.entity.ts
import { ManyToOne } from 'typeorm';
import { User } from '../users/user.entity';

@Entity()
export class Report {
  @ManyToOne(() => User, (user) => user.reports)
  user: User;
}

그리고 기존에 존재하던 db.sqlite 파일을 삭제해주고, API Client를 통해 다시 signup 테스트를 진행하자. db에 변경사항이 있기 떄문이다.

class user

  • Users table은 변형되지 않았다.
  • user.reports코드를 통해 유저의 리포트에 접근할 수 있다.
  • 사용자를 가져올 때 마다 자동적으로 연결된 리포트의 배열을 가져오진 않는다.

class report

  • Reports table을 바꾼다.
  • report.user코드를 통해 이 리포트를 만든 유저에 접근할 수 있다.
  • 리포트에 접근해도 자동적으로 연결된 유저를 가져오진 않는다.

User entity 파일에서 report entity instance, Report entity 파일에서 user entity instance는 정의되지 않았기 떄문에 undefined이다.

circular dependency때문. 해당 entity 파일에서, 다른 instance는 코드가 실행되지 않았기 떄문이다.

Nest와 TypeORM을 이용한 association

Flow부터 살펴보자

  1. 리포트 POST 요청이 들어온다.
    • Cookie와 Body에는 year, mileage, price, make, model, lng, lat 속성이 들어있다.
  2. @CurrentUser() Decorator와 CreateReportDto 가 수신한다.
  3. handler 내에는 user entity instance와 Validated CreateReportDto 가 들어있게 된다.
  4. 이 둘은 Report Entity Instnace 형태가 된다.
    • user entity instance는 Report ENtity instance내의 user 속성에 들어가게 된다.
  5. 이후 Reports Repository Save Method에 따라 저장된다.

코드를 작성해보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// reports.controller.ts
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { CreateReportDto } from './dtos/create-report.dto';
import { ReportsService } from './reports.service';
import { AuthGuard } from '../guards/auth.guard';
import { CurrentUser } from '../users/decorators/current-user.decorator';
import { User } from '../users/user.entity';

@Controller('reports')
export class ReportsController {
  constructor(private reportsService: ReportsService) {}

  @Post()
  @UseGuards(AuthGuard)
  createReport(@Body() body: CreateReportDto, @CurrentUser() user: User) {
    return this.reportsService.create(body,user);
  }
}

이제 create 메서드가 인수를 2개 받을 수있도록 서비스 파일로 가서 수정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// reports.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Report } from './report.entity';
import { CreateReportDto } from './dtos/create-report.dto';
import { User } from '../users/user.entity';

@Injectable()
export class ReportsService {
  constructor(
    @InjectRepository(Report) private repo: Repository<Report>
    ) {}

  create(reportDto: CreateReportDto, user: User) {
    const report = this.repo.create(reportDto);
	report.user = user;
    return this.repo.save(report);
  }
}

이제 서버를 켜서 로그인한 후 무작위로 리포트를 보내보자. user 속성이 추가되었다면 성공. 하지만 password가 나오니 없애보자.

이지금처럼 id, email, password 다 나오게 하지말고 userid만 표시되면 훨씬 편할 것이다. 그 userid를 통해 유저를 찾으면 되니까!

DTO로 속성 변형하기

report 내 dtos 폴더에 새로운 파일 report.dto.ts 파일을 만들자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// report.dto.ts
import { Expose, Transform } from 'class-transformer';
import { User } from '../../users/user.entity';

export class ReportDto {
  @Expose()
  id: number;

  @Expose()
  price: number;

  @Expose()
  year: number;

  @Expose()
  lng: number;

  @Expose()
  lat: number;

  @Expose()
  make: string;

  @Expose()
  model: string;

  @Expose()
  mileage: number;
}

이제 컨트롤러에 인터셉터를 가져와서 새로운 dto를 인수로 준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// reports.controller.ts
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { CreateReportDto } from './dtos/create-report.dto';
import { ReportsService } from './reports.service';
import { AuthGuard } from '../guards/auth.guard';
import { CurrentUser } from '../users/decorators/current-user.decorator';
import { User } from '../users/user.entity';
import { ReportDto } from './dtos/report.dto';
import { Serialize } from '../interceptors/serialize.interceptor';

@Controller('reports')
export class ReportsController {
  constructor(private reportsService: ReportsService) {}

  @Post()
  @UseGuards(AuthGuard)
  @Serialize(ReportDto)
  createReport(@Body() body: CreateReportDto, @CurrentUser() user: User) {
    return this.reportsService.create(body,user);
  }
}

API Client를 테스트해보면 유저 관련 속성들이 사라졌음을 볼 수 있다.

이제 userid만 가져오도록 dto를 수정한다.

1
2
3
4
5
// report.dto.ts

  @Transform(({ obj }) => obj.user.id)
  @Expose()
  userId: number;

API Client로 테스트해보면 다시 userId 속성만 생성된 것을 볼 수 있다.