Search

13. TypeORM을 이용한 관계

그동안 우리는 애플리케이션 내부의 인증 및 사용자 시스템을 구현해왔다.
이제는 사용자 기능에서 한 발짝 물러나, 본격적으로 보고서 기능을 살펴볼 시간이다.
이 기능은 사용자가 직접 판매한 차량에 대한 정보를 애플리케이션에 제출하면, 다른 사용자가 해당 데이터를 바탕으로 차량 견적을 조회할 수 있는 구조이다.
사용자가 보고서를 제출할 때는 다음과 같은 정보를 입력한다.
제조사
모델
연식
마일리지
차량 위치(위도, 경도)
판매 가격
이 정보들은 데이터베이스에 저장되며, 이후 유사 차량을 검색하거나 견적을 요청하는 사용자에게 유용한 기준이 된다.

보고서 엔터티 완성하기

앞서 사용자 기능을 구축하면서 컨트롤러 → 서비스 → 리포지토리 → 엔터티 구조에 대해 익숙해졌다면, 이번 보고서 기능 구현은 크게 새롭지 않을 것이다. 이미 컨트롤러, 서비스, 엔터티 파일이 존재하고, 기본적인 골격은 마련되어 있기 때문이다.
reports/reports.entity.ts 파일을 열어서 차량의 상세 정보를 저장할 수 있도록 속성을 확장해보자.
import { Column, Entity, 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; // 주행 거리 }
TypeScript
복사
이 값들은 모두 @Column() 데코레이터를 사용해 reports.entity.ts 파일 안의 Report 엔터티에 추가한다. 위도(lat)와 경도(lng) 정보를 함께 저장함으로써, 지역에 따라 달라질 수 있는 차량의 시세 예측도 가능하게 된다. 마일리지(mileage)도 주용한 요소이다. 주행 거리가 많을수록 차량의 감가가 심해지므로, 중고차 견적 계산의 핵심 기준 중 하나이다.

보고서 생성 라우트와 DTO 만들기

이전 사용자 기능에서는 컨트롤러와 서비스를 처음부터 끝까지 한 번에 구성했었지만, 이번 보고서 기능에서는 기능 하나씩 집중해서 구현하는 방식으로 진행한다.
우선 첫 번째 기능은 새로운 보고서를 생성하는 작업이다. 사용자가 차량 정보를 입력하면, 이 데이터를 저장하고 이후 다른 사용자가 조회할 수 있도록 만드는 것이다.

컨트롤러에서 POST 라우트 추가

먼저 reports.controller.ts를 열고 새로운 POST 요청을 받을 수 있도록 설정한다.
import { Body, Controller, Post } from '@nestjs/common'; @Controller('reports') export class ReportsController { @Post() createReport(@Body() body: CreateReportDto) {} }
TypeScript
복사

DTO 생성

reports/dtos 디렉토리를 만들고, 그 안에 create-report.dto.ts 파일을 생성한다.
export class CreateReportDto { make: string; model: string; year: number; mileage: number; lng: number; lat: number; price: number; }
TypeScript
복사
이제 class-validator를 활용해 CreateReportDto 클래스에 각 속성에 대한 검증 규칙을 추가한다.
import { IsLatitude, IsLongitude, IsNumber, IsString, Max, Min, } 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; }
TypeScript
복사
너무 말도 안되는 숫자 값이 들어오는 것을 방지하기 위해 @Min, @Max 데코레이터를 사용해서 범위를 제한
위도와 경도는 IsLatitudeIsLongitude 를 사용하여 검증

서비스와 의존성 주입

ReportsService는 이미 존재한다고 가정하고, 이를 ReportsController에서 의존성 주입으로 활용했다.
@Controller('reports') export class ReportsController { constructor(private readonly reportsService: ReportsService) {} ...
TypeScript
복사
서비스가 @Injectable()로 데코레이션되어 있고, reports.module.ts의 providers에도 등록되어 있어야 정상적으로 주입됩니다.

인증 가드 적용

기존에 만든 AuthGuard를 적용해, 로그인된 사용자만 보고서를 생성할 수 있도록 한다.
@Post() @UseGuards(AuthGuard) createReport(@Body() body: CreateReportDto) { return this.reportsService.create(body); }
TypeScript
복사

보고서 서비스 만들기

이제는 본격적으로 비즈니스 로직을 담당할 서비스에서 create() 메서드를 구현해보자.
먼저 reports.service.ts에서 Report 엔터티에 대한 리포지토리를 주입받는다.
import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Report } from './report.entity'; @Injectable() export class ReportsService { constructor( @InjectRepository(Report) private repo: Repository<Report>, ) {} }
TypeScript
복사
다음으로는 실제 DB에 데이터를 저장하는 create() 메서드를 구현할 차례이다.
TypeORm의 create()save() 메서드를 사용하면 매우 간단하게 데이터를 저장할 수 있다.
async create(reportDto: CreateReportDto) { const report = this.repo.create(reportDto); return this.repo.save(report); }
TypeScript
복사
create()는 엔터티 인스턴스를 생성합니다. 아직 DB에 저장되지는 않는다.
save()를 호출하면 실제 DB에 저장된다.

유효하지 않은 요청 테스트

이제 이 API가 실제로 잘 작동하는지 테스트하는 과정을 정리해보자.
우선, 유효하지 않은 빈 본문으로 요청을 보내 어떤 식으로 서버가 응답하는지 확인해보자.
POST http://localhost:3000/reports content-type: application/json {}
JSON
복사
예상한 대로 다음과 같은 400 Bad Request 응답을 받게 된다. 각 필드에 대해 class-validator가 지정한 유효성 검증 오류 메시지가 반환된다.
{ "statusCode": 400, "message": [ "make must be a string", "model must be a string", "year must not be greater than 2050", "year must not be less than 1930", "year must be a number conforming to the specified constraints", "mileage must not be greater than 1000000", "mileage must not be less than 0", "mileage must be a number conforming to the specified constraints", "lng must be a longitude string or number", "lat must be a latitude string or number", "price must not be greater than 1000000", "price must not be less than 0", "price must be a number conforming to the specified constraints" ], "error": "Bad Request" }
JSON
복사

유효한 요청 테스트

이제 유효한 데이터로 본문을 채워 보고서를 생성해보자.
POST http://localhost:3000/reports content-type: application/json { "make": "toyota", "model": "corolla", "year": 1980, "mileage": 100000, "lng": 0, "lat": 0, "price": 500000 }
JSON
복사
요청을 실행하면 201 Created 응답과 함께 생성된 리포트 객체가 반환된다. 리턴된 JSON에는 우리가 보낸 값들과 함께 자동 생성된 id 값도 포함되어 있다.

인증되지 않은 사용자 테스트

인증이 필요한 요청에 대해 AuthGuard가 잘 작동하는지 확인하기 위해 auth/signout 엔드포인트로 로그인한 뒤 다시 보고서를 생성해보면, 인증 실패(403 Forbidden)로 요청이 거부되는 것을 확인할 수 있다.
### Sign Out POST http://localhost:3000/auth/signout
JSON
복사
{ "statusCode": 403, "message": "Forbidden resource", "error": "Forbidden" }
JSON
복사

사용자와 보고서 관계 추가하기

애플리케이션에서 보고서를 만들 수 있게 되었지만, 현재는 이 보고서가 누가 만든 건지 알 수 있는 방법이 없다.
그래서 다음과 같은 문제가 생긴다.
이 보고서는 누가 만든거지?
사용자 ID가 2인 사람이 만든 보고서만 보고 싶어요!
이런 니즈를 해결하려면, 보고서를 만든 사용자와 보고서 사이의 관계를 데이터베이스 레벨에서 연결해야 한다.
관계란 간단하게 말하면 하나의 레코드(보고서)를 다른 레코드(사용자)와 연결짓는 것이다.
단순한 개념 같지만, 실제로 구현은 꽤 복잡할 수 있다. NestJS 자체도 처음엔 쉽지 않은데, 여기에 관계, 보안, REST 규칙까지 이해하려면 꽤 많은 배경지식이 필요하다.
우리의 목표는 “어떤 보고서는 ID가 1인 사용자가 만들었고, 저 보고서는 ID가 2인 사용자가 만들었다는 걸 DB에 기록하는 것”이다.
현재는 users 테이블과 reports 테이블이 각각 존재한다. 이제 reports 테이블에 user_id라는 새로운 열을 추가할 것이다. 이 열은 해당 보고서를 만든 사용자의 ID를 저장해준다. 예를 들어, ID가 1인 사용자가 보고서를 만들면 user_id는 1이 된다.

관계의 기본 개념

SQL 기반의 데이터베이스에서는 3가지 관계 유형이 존재한다.

1. 일대일(One-to-One)

한 엔터티의 인스턴스가 다른 엔터티의 인스턴스 하나와만 연결된다.
예: 국가 수도, 자동차 엔진

2. 일대일 / 다대일(One-to-Many / Many-to-One)

한 엔터티의 인스턴스가 여러 엔터티 인스턴스와 연결된다.
예: 사용자 주문, 자동차 부품

3. 다대다(Many-to-Many)

여러 인스턴스가 서로 여러 개의 인스턴스와 연결된다.
예: 학생 수업, 열차 승객
Nest와 TypeORM에서의 연관 관계 설정
1.
우리가 모델링 하려는 연관 관계의 종류를 파악한다
2.
관련된 엔터티에 적절한 데코레이터를 추가한다
3.
한 엔터티가 생성될 때 다른 엔터티와 연결시킨다
4.
공유되는 정보를 제한하기 위해 직렬화(Serializer)를 적용한다
우리 애플리케이션에서 사용자와 보고서는 어떤 관계일까?
한 명의 사용자는 여러 개의 보고서를 만들 수 있다 → 일대다 관계
하나의 보고서는 반드시 하나의 사용자에 의해 생성된다 → 다대일 관계
관점에 따라 표현이 달라지지만, 결국 중요한건 “한 사용자가 여러 보고서를 작성할 수 있다” 는 점이다. 따라서 우리는 사용자 보고서 = 일대다 관계를 구현할 것이다.

User 엔터티에 OneToMany 추가하기

먼저 user.entity.ts 파일을 열고, 상단에 다음과 같이 TypeORM에서 제공하는 데코레이터를 임포트 한다.
그다음, User 클래스 안에 reports 속성을 추가해 관계를 정의한다.
import { OneToMany } from 'typeorm'; import { Report } from '../reports/report.entity'; @OneToMany(() => Report, (report) => report.user) reports: Report[];
JavaScript
복사
여기서 사용하는 두 개의 인자는 다음과 같은 역할을 한다.
1.
() ⇒ Report: 연결 대상 엔터티 클래스를 명시한다.
2.
(report) ⇒ report.user: 반대쪽 엔터티에서 이 관계를 참조하는 필드를 명시한다.

Report 엔터티에 ManyToOne 추가하기

이제 report.entity.ts 파일을 열고 다음과 같이 수정한다.
import { ManyToOne } from 'typeorm'; import { User } from '../users/user.entity'; @ManyToOne(() => User, (user) => user.reports) user: User;
JavaScript
복사
위와 마찬가지로 두 개의 인자를 통해 관계를 정의한다.
여기서 꼭 기억할 점은 @ManyToOne() 데코레이터는 데이터베이스 스키마에 실제로 영향을 준다는 점이다.
Report 테이블에 자동으로 userId 컬럼이 생성되어, 각 보고서가 어떤 사용자에 속하는지를 기록하게 된다.
반면, @OneToMany()는 단독적으로는 데이터베이스에 직접적인 영향을 주지 않으며, 관계의 읽기 전용 매핑 역할을 한다.
자동으로 연결된 데이터가 로드될까?
NO! 기본적으로는 관계로 설정된 데이터가 자동으로 로드되지 않는다.
예를 들어 아래처럼 사용자를 불러왔다고 해서 연결된 Report 데이터가 따라오지 않는다.
const user = await userRepository.findOne({ where: { id: 1 } }); console.log(user.reports); // ❌ undefined or 빈 배열일 수 있음
JavaScript
복사
첫 번째 인자의 역할은 무엇일까?
@ManyToOne(() => User, (user) => user.reports)
JavaScript
복사
첫 번째 인자의 역할은 이 관계가 연결될 대상 엔터티를 TypeORM에게 알려주는 것이다.
그런데 왜 엔터티를 함수로 감싸야 할까?
이 함수 구조는 순환 의존성 문제를 해결하기 위한 트릭이다.
예를 들어, user.entity.tsreport.entity.ts가 서로를 import하고 있다면
// report.entity.ts console.log(User); // ❌ undefined
JavaScript
복사
한 쪽이 먼저 로드되면, 다른 쪽은 아직 정의되지 않았기 때문에 undefined가 나올 수 있다.
이렇게 함수를 사용하면 실제 코드가 모든 엔터티를 완전히 로드한 이후에 함수가 실행되기 때문에, 순환 참조로 인한 undefined 문제를 피할 수 있다.
두 번째 인자의 역할은 무엇인가?
@ManyToOne(() => User, (user) => user.reports)
JavaScript
복사
두 번째 인자의 역할은 관계가 양방향일 경우, 반대편에서 이 관계를 어떻게 연결하고 있는지 알려주는 역할을 한다.
예를 들어, 다음과 같은 관계도 가능하다.
보고서 작성자: report.creator
보고서 승인자: report.approver
@OneToMany(() => Report, (report) => report.creator) createdReports: Report[]; @OneToMany(() => Report, (report) => report.approver) approvedReports: Report[];
JavaScript
복사
이럴 때 두 번째 인자는 필수다.
우리는 라우트 핸들러에서는 다음 두 가지 정보를 받아야만 한다.
검증된 CreateReportDTO
현재 로그인한 User 엔터티 인스턴스
그러기 위해서 reports.controller.tsCurrentUser 데코레이터를 추가해서 user 엔터티를 받아온다.
import { CurrentUser } from '../users/decorators/current-user.decorator'; @Post() @UseGuards(AuthGuard) createReport(@Body() body: CreateReportDto, @CurrentUser() user: User) { return this.reportsService.create(body, user); }
TypeScript
복사
이제 받은 DTO를 기반으로 보고서 엔터티 인스턴스를 reports.service.ts에서 생성한다.
그리고 이 보고서와 사용자를 연결하려면, 보고서의 user 속성에 전체 사용자 인스턴스를 할당하면 된다.
async create(reportDto: CreateReportDto, user: User) { const report = this.repo.create(reportDto); report.user = user; return this.repo.save(report); }
TypeScript
복사
생성한 보고서를 reportRepository.save()를 통해 저장하면,
TypeORM은 user 속성에 할당된 사용자 엔터티 인스턴스를 보고,
그 안에서 ID만 추출해서 report 테이블의 userId 컬럼에 자동 저장해준다.
"사용자 엔터티 인스턴스를 보고서에 연결만 하면, 나머지는 TypeORM이 알아서 처리한다."
테스트를 해보자.
인증 API로 로그인 후, 보고서 생성 API(/reportss)에 데이터 생성 요청을 보낸다.
POST http://localhost:3000/reports content-type: application/json { "make": "toyota", "model": "corolla", "year": 1980, "mileage": 100000, "lng": 0, "lat": 0, "price": 500000 }
JSON
복사
HTTP/1.1 201 Created X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 238 ETag: W/"ee-NBWLB8/BIGFswke23Lvxw+kzscg" Date: Thu, 24 Apr 2025 10:58:31 GMT Connection: close { "price": 500000, "make": "toyota", "model": "corolla", "year": 1980, "lng": 0, "lat": 0, "mileage": 100000, "user": { "id": 1, "email": "test4@test.com", "password": "096b98e0276001a0.89436e8f13fa5482af7f28370302426c505ba480bf68c52c535a09e31e95ab58" }, "id": 2 }
JSON
복사
응답 결과에 user 속성이 포함되어 있고, 연결된 사용자의 정보가 정상적으로 표시된다.
하지만 문제가 있다. 현재 응답 결과에 비밀번호 해시값까지 포함돼 있다. 이건 보안상 바람직하지 않다.

DTO로 속성 변경하기

우리는 이 문제를 이전에 Serialize 인터셉터와 DTO를 이용해 응답을 직렬화하는 방식으로 해결했다.
이 방식을 응용해서 사용자 정보를 모두 보내지 않고, 필요한 정보만 포함시켜서 응답해보자.
src/reports/dtos/report.dto.ts 파일을 만들고, 다음과 같이 DTO 클래스를 작성한다.
import { Expose, Transform } from 'class-transformer'; 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; // userId만 포함하고 전체 User는 제외 @Transform(({ obj }) => obj.user.id) @Expose() userId: number; }
TypeScript
복사
class-transformer@Transform
@Transform은 원본 객체의 속성 값을 변환하여 DTO에 포함할 값을 정의할 수 있도록 해준다. 사용 방법은 다음과 같다.
@Transform(({ obj, key, value, type }) => ...)
TypeScript
복사
이름
설명
obj
원본 객체 (Entity) 전체
key
현재 처리 중인 키 이름
value
원본 객체에서의 해당 속성 값
type
transformation의 타입 (plainToClassclassToPlain 등)
1.
관계된 엔티티의 특정 값만 노출하고 싶을 때
@Transform(({ obj }) => obj.user.id) userId: number;
TypeScript
복사
Report 객체에는 User 엔티티 전체가 들어있지만, 응답에는 user.id만 포함하고 싶을 때
2.
날짜 포맷 변환
@Transform(({ value }) => value.toISOString()) createdAt: string;
TypeScript
복사
데이터베이스에서 Date 객체로 받은 값을 ISO 문자열로 변환
3.
복잡한 계산 또는 조건 처리
@Transform(({ obj }) => obj.price * obj.taxRate) totalPrice: number;
TypeScript
복사
복합적인 계산을 통해 새로운 속성 생성
이제 보고서를 생성해 보면, 다음과 같이 응답이 반환된다.
{ "id": 5, "price": 500000, "year": 1980, "lng": 0, "lat": 0, "make": "toyota", "model": "corolla", "mileage": 100000, "userId": 1 }
TypeScript
복사
이와 같은 구조는 실제 대규모 시스템에서도 널리 사용되는 방식이다. 응답에 모든 관계된 데이터를 포함시키는 대신, 필요한 최소한의 식별자(id)만 제공하고, 그 외 정보는 별도의 API 호출로 가져오는 구조로 사용한다.